private-wiki.pl 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. # Copyright (C) 2015 Alex-Daniel Jakimenko <alex.jakimenko@gmail.com>
  2. #
  3. # This program is free software: you can redistribute it and/or modify it under
  4. # the terms of the GNU General Public License as published by the Free Software
  5. # Foundation, either version 3 of the License, or (at your option) any later
  6. # version.
  7. #
  8. # This program is distributed in the hope that it will be useful, but WITHOUT
  9. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
  10. # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
  11. #
  12. # You should have received a copy of the GNU General Public License along with
  13. # this program. If not, see <http://www.gnu.org/licenses/>.
  14. use strict;
  15. # use warnings;
  16. use v5.10;
  17. use Crypt::Rijndael;
  18. use Crypt::Random::Seed;
  19. AddModuleDescription('private-wiki.pl', 'Private Wiki Extension');
  20. our ($q, $FS, @IndexList, %IndexHash, $IndexFile, $TempDir, $KeepDir, %LockCleaners, $ShowAll);
  21. my ($cipher, $random);
  22. my $PrivateWikiInitialized = '';
  23. sub PrivateWikiInit {
  24. return if $PrivateWikiInitialized;
  25. $PrivateWikiInitialized = 1;
  26. if (UserIsEditor()) {
  27. # keysize() is 32, but 24 and 16 are also possible, blocksize() is 16
  28. my $pass = GetParam('pwd');
  29. $cipher = Crypt::Rijndael->new(pack("H*", GetParam('pwd')), Crypt::Rijndael::MODE_CBC());
  30. # TODO print error if the password Is not in hex?
  31. # We are using /dev/urandom (or other nonblocking source) because we don't want
  32. # to make our users wait for a couple of minutes until we get our numbers...
  33. $random = Crypt::Random::Seed->new(NonBlocking => 1) // die "No random sources exist";
  34. }
  35. }
  36. sub PadTo16Bytes { # use this only on bytes (after encode_utf8)
  37. my ($data, $minLength) = @_;
  38. my $endBytes = length($data) % 16;
  39. $data .= "\0" x (16 - $endBytes) if $endBytes != 0;
  40. $data .= "\0" x ($minLength - length $data) if $minLength;
  41. return $data;
  42. }
  43. my $errorMessage = T('This error should not happen. If your password is set correctly and you are still'
  44. . ' seeing this message, then it is a bug, please report it. If you are just a stranger'
  45. . ' and trying to get unsolicited access, then keep in mind that all of the data is'
  46. . ' encrypted with AES-256 and the key is not stored on the server, good luck.');
  47. *OldPrivateWikiReadFile = \&ReadFile;
  48. *ReadFile = \&NewPrivateWikiReadFile;
  49. sub NewPrivateWikiReadFile {
  50. ReportError(T('Attempt to read encrypted data without a password.'), '403 FORBIDDEN', 0,
  51. $q->p($errorMessage)) if not UserIsEditor();
  52. PrivateWikiInit();
  53. my $file = shift;
  54. if (open(my $IN, '<', encode_utf8($file))) {
  55. local $/ = undef; # Read complete files
  56. my $data = <$IN>;
  57. close $IN;
  58. return (1, '') unless $data;
  59. $cipher->set_iv(substr $data, 0, 16);
  60. $data = $cipher->decrypt(substr $data, 16);
  61. my $copy = $data; # copying is required, see https://github.com/briandfoy/crypt-rijndael/issues/5
  62. $copy =~ s/\0+$//;
  63. return (1, decode_utf8($copy));
  64. }
  65. return (0, '');
  66. }
  67. *OldPrivateWikiWriteStringToFile = \&WriteStringToFile;
  68. *WriteStringToFile = \&NewPrivateWikiWriteStringToFile;
  69. sub NewPrivateWikiWriteStringToFile {
  70. ReportError(T('Attempt to read encrypted data without a password.'), '403 FORBIDDEN', 0,
  71. $q->p($errorMessage)) if not UserIsEditor();
  72. PrivateWikiInit();
  73. my ($file, $string) = @_;
  74. open(my $OUT, '>', encode_utf8($file))
  75. or ReportError(Ts('Cannot write %s', $file) . ": $!", '500 INTERNAL SERVER ERROR');
  76. my $iv = $random->random_bytes(16);
  77. $cipher->set_iv($iv);
  78. print $OUT $iv;
  79. print $OUT $cipher->encrypt(PadTo16Bytes(encode_utf8($string)));
  80. close($OUT);
  81. }
  82. # TODO is there any better way to append data to encrypted files?
  83. sub AppendStringToFile {
  84. my ($file, $string) = @_;
  85. WriteStringToFile($file, ReadFile($file) . $string); # This should be happening under a lock
  86. }
  87. # We do not want to store page names in plaintext, let's encrypt them!
  88. # Therefore we will rely on the pageidx file.
  89. #*OldPrivateWikiRefreshIndex = \&RefreshIndex;
  90. *RefreshIndex = \&NewPrivateWikiRefreshIndex;
  91. sub NewPrivateWikiRefreshIndex {
  92. if (not IsFile($IndexFile)) { # Index file does not exist yet, this is a new wiki
  93. my $fh;
  94. open($fh, '>', encode_utf8($IndexFile)) or die "Unable to open file $IndexFile : $!"; # 'touch' equivalent
  95. close($fh) or die "Unable to close file : $IndexFile $!";
  96. return;
  97. }
  98. return;
  99. #ReportError(T('Cannot refresh index.'), '500 Internal Server Error', 0,
  100. #$q->p('If you see this message, then there is a bug, please report it. '
  101. #. 'Normally Private Wiki Extension should prevent attempts to refresh the index, but this time something weird has happened.'));
  102. }
  103. our %PageIvs = ();
  104. #*OldPrivateWikiReadIndex = \&ReadIndex;
  105. *ReadIndex = \&NewPrivateWikiReadIndex;
  106. sub NewPrivateWikiReadIndex {
  107. my ($status, $rawIndex) = ReadFile($IndexFile); # not fatal
  108. if ($status) {
  109. my @rawPageList = split(/ /, $rawIndex);
  110. for (@rawPageList) {
  111. my ($pageName, $iv) = split /!/, $_, 2;
  112. push @IndexList, $pageName;
  113. $PageIvs{$pageName} = pack "H*", $iv; # decode hex string
  114. }
  115. %IndexHash = map {$_ => 1} @IndexList;
  116. return @IndexList;
  117. }
  118. return;
  119. }
  120. #*OldPrivateWikiWriteIndex = \&WriteIndex;
  121. *WriteIndex = \&NewPrivateWikiWriteIndex;
  122. sub NewPrivateWikiWriteIndex {
  123. WriteStringToFile($IndexFile, join(' ', map { $_ . '!' . unpack "H*", $PageIvs{$_} } @IndexList));
  124. }
  125. # pages longer than 6 blocks will result in filenames that are longer than 255 bytes
  126. our $PageNameLimit = 96;
  127. sub GetPrivatePageFile {
  128. my ($id) = @_;
  129. PrivateWikiInit();
  130. my $iv = $PageIvs{$id};
  131. if (not $iv) {
  132. # generate iv for new pages. It is okay if we are not called from SavePage, because
  133. # in that case the caller will probably check if that file exists (and it clearly does not)
  134. $iv = $random->random_bytes(16);
  135. $PageIvs{$id} = $iv;
  136. }
  137. $cipher->set_iv($iv);
  138. # We cannot use full byte range because of the filesystem limits
  139. my $returnName = unpack "H*", $iv . $cipher->encrypt(PadTo16Bytes(encode_utf8($id)), 96); # to hex string
  140. return $returnName;
  141. }
  142. *OldPrivateWikiGetPageFile = \&GetPageFile;
  143. *GetPageFile = \&NewPrivateWikiGetPageFile;
  144. sub NewPrivateWikiGetPageFile {
  145. OldPrivateWikiGetPageFile(GetPrivatePageFile @_);
  146. }
  147. *OldPrivateWikiGetKeepDir = \&GetKeepDir;
  148. *GetKeepDir = \&NewPrivateWikiGetKeepDir;
  149. sub NewPrivateWikiGetKeepDir {
  150. OldPrivateWikiGetKeepDir(GetPrivatePageFile @_);
  151. }
  152. # Now let's do some hacks!
  153. # First of all, "ban" all users so they can't see anything
  154. # (Note: they will not see anything anyway, since the pages will only
  155. # get decrypted when the user provides correct password)
  156. our $BannedCanRead = 0;
  157. sub UserIsBanned {
  158. return GetParam('action', '') ne 'password'; # login is always ok
  159. }
  160. # Oddmuse attempts to read pageidx file sometimes. If the password is not set let's just skip it
  161. *OldPrivateWikiAllPagesList = \&AllPagesList;
  162. *AllPagesList = \&NewPrivateWikiAllPagesList;
  163. our @MyInitVariables;
  164. push(@MyInitVariables, \&AllPagesList);
  165. sub NewPrivateWikiAllPagesList {
  166. return () if not UserIsEditor(); # no key - no AllPagesList
  167. OldPrivateWikiAllPagesList(@_);
  168. }
  169. # Then, let's allow DoDiff to save stuff in unencrypted form so that it can be diffed.
  170. # We will wipe the files right after the diff action.
  171. # This sub is copied from the core. Lines marked with CHANGED were changed.
  172. sub DoDiff { # Actualy call the diff program
  173. CreateDir($TempDir);
  174. my $oldName = "$TempDir/old";
  175. my $newName = "$TempDir/new";
  176. RequestLockDir('diff') or return '';
  177. $LockCleaners{'diff'} = sub { Unlink($oldName) if IsFile($oldName); Unlink($newName) if IsFile($newName); };
  178. OldPrivateWikiWriteStringToFile($oldName, $_[0]); # CHANGED Here we use the old sub!
  179. OldPrivateWikiWriteStringToFile($newName, $_[1]); # CHANGED
  180. my $diff_out = decode_utf8(`diff -- \Q$oldName\E \Q$newName\E`);
  181. $diff_out =~ s/\n\K\\ No newline.*\n//g; # Get rid of common complaint.
  182. # CHANGED We have to unlink the files because we don't want to store them in plaintext!
  183. Unlink($oldName, $newName); # CHANGED
  184. ReleaseLockDir('diff');
  185. return $diff_out;
  186. }
  187. # Same thing has to be done with MergeRevisions
  188. # This sub is copied from the core. Lines marked with CHANGED were changed.
  189. sub MergeRevisions { # merge change from file2 to file3 into file1
  190. my ($file1, $file2, $file3) = @_;
  191. my ($name1, $name2, $name3) = ("$TempDir/file1", "$TempDir/file2", "$TempDir/file3");
  192. CreateDir($TempDir);
  193. RequestLockDir('merge') or return T('Could not get a lock to merge!');
  194. $LockCleaners{'merge'} = sub { # CHANGED
  195. Unlink($name1) if IsFile($name1); Unlink($name2) if IsFile($name2); Unlink($name3) if IsFile($name3);
  196. };
  197. OldPrivateWikiWriteStringToFile($name1, $file1); # CHANGED
  198. OldPrivateWikiWriteStringToFile($name2, $file2); # CHANGED
  199. OldPrivateWikiWriteStringToFile($name3, $file3); # CHANGED
  200. my ($you, $ancestor, $other) = (T('you'), T('ancestor'), T('other'));
  201. my $output = decode_utf8(`diff3 -m -L \Q$you\E -L \Q$ancestor\E -L \Q$other\E -- \Q$name1\E \Q$name2\E \Q$name3\E`);
  202. Unlink($name1, $name2, $name3); # CHANGED unlink temp files -- we don't want to store them in plaintext!
  203. ReleaseLockDir('merge');
  204. return $output;
  205. }
  206. # Surge protection has to be unencrypted because in the context of this module
  207. # it is a tool against people who have no password set (thus we have no key
  208. # to do encryption).
  209. our ($VisitorFile, %RecentVisitors, $Now, $SurgeProtectionTime, $SurgeProtectionViews);
  210. # This sub is copied from the core. Lines marked with CHANGED were changed.
  211. sub ReadRecentVisitors {
  212. my ($status, $data) = OldPrivateWikiReadFile($VisitorFile); # CHANGED
  213. %RecentVisitors = ();
  214. return unless $status;
  215. foreach (split(/\n/, $data)) {
  216. my @entries = split /$FS/;
  217. my $name = shift(@entries);
  218. $RecentVisitors{$name} = \@entries if $name;
  219. }
  220. }
  221. # This sub is copied from the core. Lines marked with CHANGED were changed.
  222. sub WriteRecentVisitors {
  223. my $data = '';
  224. my $limit = $Now - $SurgeProtectionTime;
  225. foreach my $name (keys %RecentVisitors) {
  226. my @entries = @{$RecentVisitors{$name}};
  227. if ($entries[0] >= $limit) { # if the most recent one is too old, do not keep
  228. $data .= join($FS, $name, @entries[0 .. $SurgeProtectionViews - 1]) . "\n";
  229. }
  230. }
  231. OldPrivateWikiWriteStringToFile($VisitorFile, $data); # CHANGED
  232. }
  233. # At the same time, we don't want to store any information about the editors
  234. # because it reveals their usernames. A bit paranoidal, but why not.
  235. *OldPrivateWikiAddRecentVisitor = \&AddRecentVisitor;
  236. *AddRecentVisitor = \&NewPrivateWikiAddRecentVisitor;
  237. sub NewPrivateWikiAddRecentVisitor {
  238. return if UserIsEditor();
  239. OldPrivateWikiAddRecentVisitor(@_);
  240. }
  241. *OldPrivateWikiDelayRequired = \&DelayRequired;
  242. *DelayRequired = \&NewPrivateWikiDelayRequired;
  243. sub NewPrivateWikiDelayRequired {
  244. return '' if UserIsEditor();
  245. OldPrivateWikiDelayRequired(@_);
  246. }
  247. # PageIsUploadedFile attempts to read the file partially, which does not work that
  248. # well on encrypted data. Therefore, we disable file uploads for now.
  249. our $UploadAllowed = 0;
  250. sub PageIsUploadedFile { '' }
  251. # Finally, we have to fix RecentChanges
  252. our ($RcDefault, $RcFile, $RcOldFile, $FreeLinkPattern, $LinkPattern, $ShowEdits, $PageCluster);
  253. # This sub is copied from the core. Lines marked with CHANGED were changed.
  254. sub GetRcLines { # starttime, hash of seen pages to use as a second return value
  255. my $starttime = shift || GetParam('from', 0) ||
  256. $Now - GetParam('days', $RcDefault) * 86400; # 24*60*60
  257. my $filterOnly = GetParam('rcfilteronly', '');
  258. # these variables apply accross logfiles
  259. my %match = $filterOnly ? map { $_ => 1 } SearchTitleAndBody($filterOnly) : ();
  260. my %following = ();
  261. my @result = ();
  262. # check the first timestamp in the default file, maybe read old log file
  263. my $filelike = ReadFile($RcFile); # CHANGED
  264. open my $F, '<:encoding(UTF-8)', \$filelike or die $!; # CHANGED
  265. my $line = <$F>;
  266. my ($ts) = split(/$FS/, $line); # the first timestamp in the regular rc file
  267. if (not $ts or $ts > $starttime) { # we need to read the old rc file, too
  268. push(@result, GetRcLinesFor($RcOldFile, $starttime, \%match, \%following));
  269. }
  270. push(@result, GetRcLinesFor($RcFile, $starttime, \%match, \%following));
  271. # GetRcLinesFor is trying to save memory space, but some operations
  272. # can only happen once we have all the data.
  273. return LatestChanges(StripRollbacks(@result));
  274. }
  275. # This sub is copied from the core. Lines marked with CHANGED were changed.
  276. sub GetRcLinesFor {
  277. my $file = shift;
  278. my $starttime = shift;
  279. my %match = %{$_[0]}; # deref
  280. my %following = %{$_[1]}; # deref
  281. # parameters
  282. my $showminoredit = GetParam('showedit', $ShowEdits); # show minor edits
  283. my $all = GetParam('all', $ShowAll);
  284. my ($idOnly, $userOnly, $hostOnly, $clusterOnly, $filterOnly, $match, $lang,
  285. $followup) = map { UnquoteHtml(GetParam($_, '')); }
  286. qw(rcidonly rcuseronly rchostonly
  287. rcclusteronly rcfilteronly match lang followup);
  288. # parsing and filtering
  289. my @result = ();
  290. my $filelike = ReadFile($file); # CHANGED
  291. open my $F, '<:encoding(UTF-8)', \$filelike or return (); # CHANGED
  292. while (my $line = <$F>) {
  293. chomp($line);
  294. my ($ts, $id, $minor, $summary, $host, $username, $revision,
  295. $languages, $cluster) = split(/$FS/, $line);
  296. next if $ts < $starttime;
  297. $following{$id} = $ts if $followup and $followup eq $username;
  298. next if $followup and (not $following{$id} or $ts <= $following{$id});
  299. next if $idOnly and $idOnly ne $id;
  300. next if $filterOnly and not $match{$id};
  301. next if ($userOnly and $userOnly ne $username);
  302. next if $minor == 1 and not $showminoredit; # skip minor edits (if [[rollback]] this is bogus)
  303. next if not $minor and $showminoredit == 2; # skip major edits
  304. next if $match and $id !~ /$match/i;
  305. next if $hostOnly and $host !~ /$hostOnly/i;
  306. my @languages = split(/,/, $languages);
  307. next if $lang and @languages and not grep(/$lang/, @languages);
  308. if ($PageCluster) {
  309. ($cluster, $summary) = ($1, $2) if $summary =~ /^\[\[$FreeLinkPattern\]\] ?: *(.*)/
  310. or $summary =~ /^$LinkPattern ?: *(.*)/;
  311. next if ($clusterOnly and $clusterOnly ne $cluster);
  312. $cluster = '' if $clusterOnly; # don't show cluster if $clusterOnly eq $cluster
  313. if ($all < 2 and not $clusterOnly and $cluster) {
  314. $summary = "$id: $summary"; # print the cluster instead of the page
  315. $id = $cluster;
  316. $revision = '';
  317. }
  318. } else {
  319. $cluster = '';
  320. }
  321. $following{$id} = $ts if $followup and $followup eq $username;
  322. push(@result, [$ts, $id, $minor, $summary, $host, $username, $revision,
  323. \@languages, $cluster]);
  324. }
  325. return @result;
  326. }
  327. # We do not want to print the header to unauthorized users because it contains
  328. # the gotobar, our logo and a useless search form.
  329. *OldPrivateWikiGetHeaderDiv = \&GetHeaderDiv;
  330. *GetHeaderDiv = \&NewPrivateWikiGetHeaderDiv;
  331. sub NewPrivateWikiGetHeaderDiv {
  332. return OldPrivateWikiGetHeaderDiv(@_) if UserIsEditor();
  333. my ($id, $title, $oldId, $embed) = @_;
  334. my $result .= $q->start_div({-class=>'header'});
  335. our $Message;
  336. $result .= $q->div({-class=>'message'}, $Message) if $Message;
  337. $result .= GetHeaderTitle($id, $title, $oldId);
  338. $result .= $q->end_div();
  339. return $result;
  340. }