Files
oddmuse/modules/private-wiki.pl
2016-06-22 15:37:04 +02:00

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;
}