forked from github/kensanata.oddmuse
403 lines
15 KiB
Perl
403 lines
15 KiB
Perl
# 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;
|
|
}
|