123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- # Copyright (C) 2015 Alex-Daniel Jakimenko <alex.jakimenko@gmail.com>
- #
- # This program is free software: you can redistribute it and/or modify it under
- # the terms of the GNU General Public License as published by the Free Software
- # Foundation, either version 3 of the License, or (at your option) any later
- # version.
- #
- # This program is distributed in the hope that it will be useful, but WITHOUT
- # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
- #
- # You should have received a copy of the GNU General Public License along with
- # this program. If not, see <http://www.gnu.org/licenses/>.
- use strict;
- # use warnings;
- use v5.10;
- use Crypt::Rijndael;
- use Crypt::Random::Seed;
- AddModuleDescription('private-wiki.pl', 'Private Wiki Extension');
- our ($q, $FS, @IndexList, %IndexHash, $IndexFile, $TempDir, $KeepDir, %LockCleaners, $ShowAll);
- my ($cipher, $random);
- my $PrivateWikiInitialized = '';
- sub PrivateWikiInit {
- return if $PrivateWikiInitialized;
- $PrivateWikiInitialized = 1;
- if (UserIsEditor()) {
- # keysize() is 32, but 24 and 16 are also possible, blocksize() is 16
- my $pass = GetParam('pwd');
- $cipher = Crypt::Rijndael->new(pack("H*", GetParam('pwd')), Crypt::Rijndael::MODE_CBC());
- # TODO print error if the password Is not in hex?
- # We are using /dev/urandom (or other nonblocking source) because we don't want
- # to make our users wait for a couple of minutes until we get our numbers...
- $random = Crypt::Random::Seed->new(NonBlocking => 1) // die "No random sources exist";
- }
- }
- sub PadTo16Bytes { # use this only on bytes (after encode_utf8)
- my ($data, $minLength) = @_;
- my $endBytes = length($data) % 16;
- $data .= "\0" x (16 - $endBytes) if $endBytes != 0;
- $data .= "\0" x ($minLength - length $data) if $minLength;
- return $data;
- }
- my $errorMessage = T('This error should not happen. If your password is set correctly and you are still'
- . ' seeing this message, then it is a bug, please report it. If you are just a stranger'
- . ' and trying to get unsolicited access, then keep in mind that all of the data is'
- . ' encrypted with AES-256 and the key is not stored on the server, good luck.');
- *OldPrivateWikiReadFile = \&ReadFile;
- *ReadFile = \&NewPrivateWikiReadFile;
- sub NewPrivateWikiReadFile {
- ReportError(T('Attempt to read encrypted data without a password.'), '403 FORBIDDEN', 0,
- $q->p($errorMessage)) if not UserIsEditor();
- PrivateWikiInit();
- my $file = shift;
- if (open(my $IN, '<', encode_utf8($file))) {
- local $/ = undef; # Read complete files
- my $data = <$IN>;
- close $IN;
- return (1, '') unless $data;
- $cipher->set_iv(substr $data, 0, 16);
- $data = $cipher->decrypt(substr $data, 16);
- my $copy = $data; # copying is required, see https://github.com/briandfoy/crypt-rijndael/issues/5
- $copy =~ s/\0+$//;
- return (1, decode_utf8($copy));
- }
- return (0, '');
- }
- *OldPrivateWikiWriteStringToFile = \&WriteStringToFile;
- *WriteStringToFile = \&NewPrivateWikiWriteStringToFile;
- sub NewPrivateWikiWriteStringToFile {
- ReportError(T('Attempt to read encrypted data without a password.'), '403 FORBIDDEN', 0,
- $q->p($errorMessage)) if not UserIsEditor();
- PrivateWikiInit();
- my ($file, $string) = @_;
- open(my $OUT, '>', encode_utf8($file))
- or ReportError(Ts('Cannot write %s', $file) . ": $!", '500 INTERNAL SERVER ERROR');
- my $iv = $random->random_bytes(16);
- $cipher->set_iv($iv);
- print $OUT $iv;
- print $OUT $cipher->encrypt(PadTo16Bytes(encode_utf8($string)));
- close($OUT);
- }
- # TODO is there any better way to append data to encrypted files?
- sub AppendStringToFile {
- my ($file, $string) = @_;
- WriteStringToFile($file, ReadFile($file) . $string); # This should be happening under a lock
- }
- # We do not want to store page names in plaintext, let's encrypt them!
- # Therefore we will rely on the pageidx file.
- #*OldPrivateWikiRefreshIndex = \&RefreshIndex;
- *RefreshIndex = \&NewPrivateWikiRefreshIndex;
- sub NewPrivateWikiRefreshIndex {
- if (not IsFile($IndexFile)) { # Index file does not exist yet, this is a new wiki
- my $fh;
- open($fh, '>', encode_utf8($IndexFile)) or die "Unable to open file $IndexFile : $!"; # 'touch' equivalent
- close($fh) or die "Unable to close file : $IndexFile $!";
- return;
- }
- return;
- #ReportError(T('Cannot refresh index.'), '500 Internal Server Error', 0,
- #$q->p('If you see this message, then there is a bug, please report it. '
- #. 'Normally Private Wiki Extension should prevent attempts to refresh the index, but this time something weird has happened.'));
- }
- our %PageIvs = ();
- #*OldPrivateWikiReadIndex = \&ReadIndex;
- *ReadIndex = \&NewPrivateWikiReadIndex;
- sub NewPrivateWikiReadIndex {
- my ($status, $rawIndex) = ReadFile($IndexFile); # not fatal
- if ($status) {
- my @rawPageList = split(/ /, $rawIndex);
- for (@rawPageList) {
- my ($pageName, $iv) = split /!/, $_, 2;
- push @IndexList, $pageName;
- $PageIvs{$pageName} = pack "H*", $iv; # decode hex string
- }
- %IndexHash = map {$_ => 1} @IndexList;
- return @IndexList;
- }
- return;
- }
- #*OldPrivateWikiWriteIndex = \&WriteIndex;
- *WriteIndex = \&NewPrivateWikiWriteIndex;
- sub NewPrivateWikiWriteIndex {
- WriteStringToFile($IndexFile, join(' ', map { $_ . '!' . unpack "H*", $PageIvs{$_} } @IndexList));
- }
- # pages longer than 6 blocks will result in filenames that are longer than 255 bytes
- our $PageNameLimit = 96;
- sub GetPrivatePageFile {
- my ($id) = @_;
- PrivateWikiInit();
- my $iv = $PageIvs{$id};
- if (not $iv) {
- # generate iv for new pages. It is okay if we are not called from SavePage, because
- # in that case the caller will probably check if that file exists (and it clearly does not)
- $iv = $random->random_bytes(16);
- $PageIvs{$id} = $iv;
- }
- $cipher->set_iv($iv);
- # We cannot use full byte range because of the filesystem limits
- my $returnName = unpack "H*", $iv . $cipher->encrypt(PadTo16Bytes(encode_utf8($id)), 96); # to hex string
- return $returnName;
- }
- *OldPrivateWikiGetPageFile = \&GetPageFile;
- *GetPageFile = \&NewPrivateWikiGetPageFile;
- sub NewPrivateWikiGetPageFile {
- OldPrivateWikiGetPageFile(GetPrivatePageFile @_);
- }
- *OldPrivateWikiGetKeepDir = \&GetKeepDir;
- *GetKeepDir = \&NewPrivateWikiGetKeepDir;
- sub NewPrivateWikiGetKeepDir {
- OldPrivateWikiGetKeepDir(GetPrivatePageFile @_);
- }
- # Now let's do some hacks!
- # First of all, "ban" all users so they can't see anything
- # (Note: they will not see anything anyway, since the pages will only
- # get decrypted when the user provides correct password)
- our $BannedCanRead = 0;
- sub UserIsBanned {
- return GetParam('action', '') ne 'password'; # login is always ok
- }
- # Oddmuse attempts to read pageidx file sometimes. If the password is not set let's just skip it
- *OldPrivateWikiAllPagesList = \&AllPagesList;
- *AllPagesList = \&NewPrivateWikiAllPagesList;
- our @MyInitVariables;
- push(@MyInitVariables, \&AllPagesList);
- sub NewPrivateWikiAllPagesList {
- return () if not UserIsEditor(); # no key - no AllPagesList
- OldPrivateWikiAllPagesList(@_);
- }
- # Then, let's allow DoDiff to save stuff in unencrypted form so that it can be diffed.
- # We will wipe the files right after the diff action.
- # This sub is copied from the core. Lines marked with CHANGED were changed.
- sub DoDiff { # Actualy call the diff program
- CreateDir($TempDir);
- my $oldName = "$TempDir/old";
- my $newName = "$TempDir/new";
- RequestLockDir('diff') or return '';
- $LockCleaners{'diff'} = sub { Unlink($oldName) if IsFile($oldName); Unlink($newName) if IsFile($newName); };
- OldPrivateWikiWriteStringToFile($oldName, $_[0]); # CHANGED Here we use the old sub!
- OldPrivateWikiWriteStringToFile($newName, $_[1]); # CHANGED
- my $diff_out = decode_utf8(`diff -- \Q$oldName\E \Q$newName\E`);
- $diff_out =~ s/\n\K\\ No newline.*\n//g; # Get rid of common complaint.
- # CHANGED We have to unlink the files because we don't want to store them in plaintext!
- Unlink($oldName, $newName); # CHANGED
- ReleaseLockDir('diff');
- return $diff_out;
- }
- # Same thing has to be done with MergeRevisions
- # This sub is copied from the core. Lines marked with CHANGED were changed.
- sub MergeRevisions { # merge change from file2 to file3 into file1
- my ($file1, $file2, $file3) = @_;
- my ($name1, $name2, $name3) = ("$TempDir/file1", "$TempDir/file2", "$TempDir/file3");
- CreateDir($TempDir);
- RequestLockDir('merge') or return T('Could not get a lock to merge!');
- $LockCleaners{'merge'} = sub { # CHANGED
- Unlink($name1) if IsFile($name1); Unlink($name2) if IsFile($name2); Unlink($name3) if IsFile($name3);
- };
- OldPrivateWikiWriteStringToFile($name1, $file1); # CHANGED
- OldPrivateWikiWriteStringToFile($name2, $file2); # CHANGED
- OldPrivateWikiWriteStringToFile($name3, $file3); # CHANGED
- my ($you, $ancestor, $other) = (T('you'), T('ancestor'), T('other'));
- 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`);
- Unlink($name1, $name2, $name3); # CHANGED unlink temp files -- we don't want to store them in plaintext!
- ReleaseLockDir('merge');
- return $output;
- }
- # Surge protection has to be unencrypted because in the context of this module
- # it is a tool against people who have no password set (thus we have no key
- # to do encryption).
- our ($VisitorFile, %RecentVisitors, $Now, $SurgeProtectionTime, $SurgeProtectionViews);
- # This sub is copied from the core. Lines marked with CHANGED were changed.
- sub ReadRecentVisitors {
- my ($status, $data) = OldPrivateWikiReadFile($VisitorFile); # CHANGED
- %RecentVisitors = ();
- return unless $status;
- foreach (split(/\n/, $data)) {
- my @entries = split /$FS/;
- my $name = shift(@entries);
- $RecentVisitors{$name} = \@entries if $name;
- }
- }
- # This sub is copied from the core. Lines marked with CHANGED were changed.
- sub WriteRecentVisitors {
- my $data = '';
- my $limit = $Now - $SurgeProtectionTime;
- foreach my $name (keys %RecentVisitors) {
- my @entries = @{$RecentVisitors{$name}};
- if ($entries[0] >= $limit) { # if the most recent one is too old, do not keep
- $data .= join($FS, $name, @entries[0 .. $SurgeProtectionViews - 1]) . "\n";
- }
- }
- OldPrivateWikiWriteStringToFile($VisitorFile, $data); # CHANGED
- }
- # At the same time, we don't want to store any information about the editors
- # because it reveals their usernames. A bit paranoidal, but why not.
- *OldPrivateWikiAddRecentVisitor = \&AddRecentVisitor;
- *AddRecentVisitor = \&NewPrivateWikiAddRecentVisitor;
- sub NewPrivateWikiAddRecentVisitor {
- return if UserIsEditor();
- OldPrivateWikiAddRecentVisitor(@_);
- }
- *OldPrivateWikiDelayRequired = \&DelayRequired;
- *DelayRequired = \&NewPrivateWikiDelayRequired;
- sub NewPrivateWikiDelayRequired {
- return '' if UserIsEditor();
- OldPrivateWikiDelayRequired(@_);
- }
- # PageIsUploadedFile attempts to read the file partially, which does not work that
- # well on encrypted data. Therefore, we disable file uploads for now.
- our $UploadAllowed = 0;
- sub PageIsUploadedFile { '' }
- # Finally, we have to fix RecentChanges
- our ($RcDefault, $RcFile, $RcOldFile, $FreeLinkPattern, $LinkPattern, $ShowEdits, $PageCluster);
- # This sub is copied from the core. Lines marked with CHANGED were changed.
- sub GetRcLines { # starttime, hash of seen pages to use as a second return value
- my $starttime = shift || GetParam('from', 0) ||
- $Now - GetParam('days', $RcDefault) * 86400; # 24*60*60
- my $filterOnly = GetParam('rcfilteronly', '');
- # these variables apply accross logfiles
- my %match = $filterOnly ? map { $_ => 1 } SearchTitleAndBody($filterOnly) : ();
- my %following = ();
- my @result = ();
- # check the first timestamp in the default file, maybe read old log file
- my $filelike = ReadFile($RcFile); # CHANGED
- open my $F, '<:encoding(UTF-8)', \$filelike or die $!; # CHANGED
- my $line = <$F>;
- my ($ts) = split(/$FS/, $line); # the first timestamp in the regular rc file
- if (not $ts or $ts > $starttime) { # we need to read the old rc file, too
- push(@result, GetRcLinesFor($RcOldFile, $starttime, \%match, \%following));
- }
- push(@result, GetRcLinesFor($RcFile, $starttime, \%match, \%following));
- # GetRcLinesFor is trying to save memory space, but some operations
- # can only happen once we have all the data.
- return LatestChanges(StripRollbacks(@result));
- }
- # This sub is copied from the core. Lines marked with CHANGED were changed.
- sub GetRcLinesFor {
- my $file = shift;
- my $starttime = shift;
- my %match = %{$_[0]}; # deref
- my %following = %{$_[1]}; # deref
- # parameters
- my $showminoredit = GetParam('showedit', $ShowEdits); # show minor edits
- my $all = GetParam('all', $ShowAll);
- my ($idOnly, $userOnly, $hostOnly, $clusterOnly, $filterOnly, $match, $lang,
- $followup) = map { UnquoteHtml(GetParam($_, '')); }
- qw(rcidonly rcuseronly rchostonly
- rcclusteronly rcfilteronly match lang followup);
- # parsing and filtering
- my @result = ();
- my $filelike = ReadFile($file); # CHANGED
- open my $F, '<:encoding(UTF-8)', \$filelike or return (); # CHANGED
- while (my $line = <$F>) {
- chomp($line);
- my ($ts, $id, $minor, $summary, $host, $username, $revision,
- $languages, $cluster) = split(/$FS/, $line);
- next if $ts < $starttime;
- $following{$id} = $ts if $followup and $followup eq $username;
- next if $followup and (not $following{$id} or $ts <= $following{$id});
- next if $idOnly and $idOnly ne $id;
- next if $filterOnly and not $match{$id};
- next if ($userOnly and $userOnly ne $username);
- next if $minor == 1 and not $showminoredit; # skip minor edits (if [[rollback]] this is bogus)
- next if not $minor and $showminoredit == 2; # skip major edits
- next if $match and $id !~ /$match/i;
- next if $hostOnly and $host !~ /$hostOnly/i;
- my @languages = split(/,/, $languages);
- next if $lang and @languages and not grep(/$lang/, @languages);
- if ($PageCluster) {
- ($cluster, $summary) = ($1, $2) if $summary =~ /^\[\[$FreeLinkPattern\]\] ?: *(.*)/
- or $summary =~ /^$LinkPattern ?: *(.*)/;
- next if ($clusterOnly and $clusterOnly ne $cluster);
- $cluster = '' if $clusterOnly; # don't show cluster if $clusterOnly eq $cluster
- if ($all < 2 and not $clusterOnly and $cluster) {
- $summary = "$id: $summary"; # print the cluster instead of the page
- $id = $cluster;
- $revision = '';
- }
- } else {
- $cluster = '';
- }
- $following{$id} = $ts if $followup and $followup eq $username;
- push(@result, [$ts, $id, $minor, $summary, $host, $username, $revision,
- \@languages, $cluster]);
- }
- return @result;
- }
- # We do not want to print the header to unauthorized users because it contains
- # the gotobar, our logo and a useless search form.
- *OldPrivateWikiGetHeaderDiv = \&GetHeaderDiv;
- *GetHeaderDiv = \&NewPrivateWikiGetHeaderDiv;
- sub NewPrivateWikiGetHeaderDiv {
- return OldPrivateWikiGetHeaderDiv(@_) if UserIsEditor();
- my ($id, $title, $oldId, $embed) = @_;
- my $result .= $q->start_div({-class=>'header'});
- our $Message;
- $result .= $q->div({-class=>'message'}, $Message) if $Message;
- $result .= GetHeaderTitle($id, $title, $oldId);
- $result .= $q->end_div();
- return $result;
- }
|