view release on metacpan or search on metacpan
- No user facing changes, just small bugfixes.
4.06
- No user facing changes: The client certificate using extensions are
no longer tested by default.
4.05
- Uploading of text/gemini is now treated like text/plain, resulting
in a page (instead of an attempted file upload).
- Uploading "to the /files folder" is now allowed.
- Document how to build and run a docker image.
- MIME type is optional.
4.04
- App::Phoebe::Oracle is new.
- App::Phoebe::StaticFiles is new; use it to serve static files from
directories on your server
- App::Phoebe::Ijirait is new; a MUSH, i.e. a shared world for all
participants to build and interact in; script/ijirait is also new, a
custom CLI client for the game
- App::Phoebe::Spartan is new; an alternative to Gemini and Titan
since it's very simply, suggests gemtext for text formatting, allows
uploads, but doesn't do TLS
- App::Phoebe::Iapetus is new; an alternative to the Titan protocol
for uploading
- App::Phoebe::SpeedBump loads the block list on startup; as it saves
every half an hour, that means you no longer have to worry too much
about losing information on the blocked IP numbers and networks
- App::Gopher is new and improved; it no longer just prints the Gemini
text but does line wrapping and all that
3.00
Makefile.PL view on Meta::CPAN
# that it drops priviledges to the current user as soon as it started.
web:
./t/cert.pl
sudo ./script/phoebe \
--cert_file=./t/cert.pem \
--key_file=./t/key.pem \
--user=$(shell id -un) --group=$(shell id -gn) \
--port=443 --port=1965
# Start the wiki on port 2020, with debug log level, two example
# spaces, allowing the upload of JPEG files, and use 'morbo' (which is
# part of the Mojolicious Perl package) to watch for changes of the
# code: when it detects a change, the server is restarted
# automatically.
dev:
morbo --watch ./script/phoebe \
--watch ./wiki/config -- \
./script/phoebe \
--host localhost --host 127.0.0.1 --port=2020 \
--wiki_space=127.0.0.1/alex --wiki_space=localhost/berta \
--log_level=debug --wiki_main_page=Welcome \
A line starting with ">", followed by a space and some text is a quote.
The monologue at the end is fantastic, with the city lights and the rain.
> I have seen things you people would not believe.
## Editing the wiki
How do you edit a Phoebe wiki? You need to use a Titan-enabled client.
[Titan](https://transjovian.org:1965/titan) is a companion protocol to Gemini:
it allows clients to upload files to Gemini sites, if servers allow this. On
Phoebe, you can edit "raw" pages. That is, at the bottom of a page you'll see a
link to the "raw" page. If you follow it, you'll see the page content as plain
text. You can submit a changed version of this text to the same URL using Titan.
Known clients:
This repository comes with a Perl script called
[titan](https://metacpan.org/pod/titan) to upload files.
[Gemini Write](https://alexschroeder.ch/cgit/gemini-write/) is an extension for
the Emacs Gopher and Gemini client [Elpher](https://thelambdalab.xyz/elpher/).
[Gemini & Titan for Bash](https://alexschroeder.ch/cgit/gemini-titan/about/) are
two shell functions that allow you to download and upload files.
[Lagrange](https://gmi.skyjake.fi/lagrange/) is a GUI client that is Titan
enabled.
See [App::Phoebe::WebEdit](https://metacpan.org/pod/App%3A%3APhoebe%3A%3AWebEdit) to
enable editing via the web.
## Installation
Using `cpan`:
* [gemini](https://metacpan.org/pod/gemini) - a command line client for the Gemini protocol
* [gemini-chat](https://metacpan.org/pod/gemini-chat) - a command line client for the Gemini protocol to send lines you type
* [ijirait](https://metacpan.org/pod/ijirait) - a command line client for the Gemini protocol to play the Ijirait MUSH
* [phoebe-ctl](https://metacpan.org/pod/phoebe-ctl) - admin control for a Phoebe wiki
* [spartan](https://metacpan.org/pod/spartan) - a command line client for the Spartan protocol
* [titan](https://metacpan.org/pod/titan) - a command line client to upload texts and files using the Titan protocol
* [App::Phoebe](https://metacpan.org/pod/App%3A%3APhoebe) - a Gemini-based wiki
* [App::Phoebe::BlockFediverse](https://metacpan.org/pod/App%3A%3APhoebe%3A%3ABlockFediverse) - block Fediverse instances from Phoebe wiki
* [App::Phoebe::Capsules](https://metacpan.org/pod/App%3A%3APhoebe%3A%3ACapsules) - provide every visitor with a writeable capsule
* [App::Phoebe::Chat](https://metacpan.org/pod/App%3A%3APhoebe%3A%3AChat) - add a Gemini-based chat room for every Phoebe wiki space
* [App::Phoebe::Comments](https://metacpan.org/pod/App%3A%3APhoebe%3A%3AComments) - add comment pages to Phoebe wiki
* [App::Phoebe::DebugIpNumbers](https://metacpan.org/pod/App%3A%3APhoebe%3A%3ADebugIpNumbers) - log visitor IP numbers for Phoebe
* [App::Phoebe::Favicon](https://metacpan.org/pod/App%3A%3APhoebe%3A%3AFavicon) - serve a favicon via the web for Phoebe
* [App::Phoebe::Galleries](https://metacpan.org/pod/App%3A%3APhoebe%3A%3AGalleries) - serving sitelen mute image galleries via Gemini
* [App::Phoebe::Gopher](https://metacpan.org/pod/App%3A%3APhoebe%3A%3AGopher) - serving a Phoebe wiki via the Gopher protocol
* [App::Phoebe::HeapDump](https://metacpan.org/pod/App%3A%3APhoebe%3A%3AHeapDump) - debugging Phoebe memory leaks
* [App::Phoebe::Iapetus](https://metacpan.org/pod/App%3A%3APhoebe%3A%3AIapetus) - uploads using the Iapetus protocol
* [App::Phoebe::Ijirait](https://metacpan.org/pod/App%3A%3APhoebe%3A%3AIjirait) - a Gemini-based MUSH running on Phoebe
* [App::Phoebe::MokuPona](https://metacpan.org/pod/App%3A%3APhoebe%3A%3AMokuPona) - serve files from moku pona
* [App::Phoebe::Oddmuse](https://metacpan.org/pod/App%3A%3APhoebe%3A%3AOddmuse) - act as a Gemini proxy for an Oddmuse wiki
* [App::Phoebe::Oracle](https://metacpan.org/pod/App%3A%3APhoebe%3A%3AOracle) - an anonymous question asking game
* [App::Phoebe::PageHeadings](https://metacpan.org/pod/App%3A%3APhoebe%3A%3APageHeadings) - use headings instead of file names
lib/App/Phoebe.pm view on Meta::CPAN
my $code = shift;
my $meta = shift;
my $data = shift||"";
$stream->write("$code $meta\r\n$data");
}
sub handle_titan {
my $stream = shift;
my $data = shift;
# extra processing of the request if we didn't do that, yet
$data->{upload} ||= is_upload($stream, $data->{request}) or return;
my $size = $data->{upload}->{params}->{size};
my $actual = length($data->{buffer});
if ($actual == $size) {
$log->debug("Handle Titan request");
process_titan($stream, $data->{request}, $data->{upload}, $data->{buffer}, $size);
# do not close in case we're waiting for the lock
return;
} elsif ($actual > $size) {
$log->debug("Received more than the promised $size bytes");
result($stream, "59", "Received more than the promised $size bytes");
$stream->close_gracefully();
return;
}
$log->debug("Waiting for " . ($size - $actual) . " more bytes");
}
sub process_titan {
my ($stream, $request, $upload, $buffer, $size) = @_;
eval {
local $SIG{'ALRM'} = sub { $log->error("Timeout processing upload $request") };
alarm(10); # timeout
if (run_extensions($stream, $request, $upload, $buffer, $size)) {
# config file goes first
} else {
save_page($stream, $upload->{host}, $upload->{space}, $upload->{id},
$upload->{params}->{mime}, $buffer, $size);
}
alarm(0);
};
# save page might still be waiting for the lock so we must not close the
# stream: save_page will close the stream
return unless $@;
$log->error("Error: $@");
$stream->close_gracefully();
}
lib/App/Phoebe.pm view on Meta::CPAN
}
} elsif (@{$server->{wiki_space}}) {
$spaces{"$scheme://$host:$port/"} = "Main space";
for (sort @{$server->{wiki_space}}) {
$spaces{"$scheme://$host:$port/$_/"} = $_;
}
}
return \%spaces;
}
sub is_upload {
my $stream = shift;
my $request = shift;
$log->info("Looking at $request");
my $hosts = host_regex();
my $spaces_regex = space_regex();
my $port = port($stream);
if ($request =~ m!^titan://($hosts)(?::$port)?!) {
my $host = $1;
my($scheme, $authority, $path, $query, $fragment) =
$request =~ m|(?:([^:/?#]+):)?(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?|;
lib/App/Phoebe.pm view on Meta::CPAN
my $space = shift;
my $id = shift;
my $params = shift;
my $type = $params->{mime} || "text/plain";
my ($main_type) = split(/\//, $type, 1);
my @types = @{$server->{wiki_mime_type}};
# the wiki always allows text/plain or text/gemini
if ($type eq "text/plain" or $type eq "text/gemini") {
return 1;
} elsif (not @types) {
$log->debug("This wiki does not allow file uploads");
result($stream, "59", "This wiki does not allow file uploads");
$stream->close_gracefully();
return;
} elsif (not grep(/^$type$/, @types) and not grep(/^$main_type$/, @types)) {
$log->debug("This wiki does not allow $type");
result($stream, "59", "This wiki does not allow $type, only @types");
$stream->close_gracefully();
return;
}
return 1;
}
lib/App/Phoebe/Capsules.pm view on Meta::CPAN
use App::Phoebe::Capsules;
Every client certificate gets assigned a capsule name.
You can provide a link with some documentation, if you want:
our $capsule_help = '//transjovian.org/phoebe/page/Capsules';
=head1 NO MIME TYPES
When uploading to a capsule, the MIME type is ignored. Instead, it is determined
from the filename extension.
=head1 TROUBLESHOOTING
ð¥ In the wiki directory, you can have a file called F<fingerprint_equivalents>.
Its main use is to allow people to add more fingerprints for their site, such as
from other devices or friends. The file format is line oriented, each line
containing two fingerprints, C<FROM> and C<TO>.
ð¥ The capsule name I<login> is reserved.
ð¥ The file names I<archive>, I<backup>, and I<upload> are reserved.
=head1 NO WIKI, ONLY CAPSULES
Here's how to disable all wiki functions of Phoebe and just use capsules. The
C<nothing_else> function comes right after C<capsules> as an extension and
always returns 1, so Phoebe considers this request handled. Therefore, the
regular request handlers won't get used. Make sure that any extensions you do
want to have are prepended to C<@extensions> after setting it (using
C<unshift>).
lib/App/Phoebe/Capsules.pm view on Meta::CPAN
my $bytes = read_binary("$dir/fingerprint_equivalents");
%capsule_equivalent = split(' ', $bytes);
} } );
sub capsules {
my $stream = shift;
my $url = shift;
my $hosts = capsule_regex();
my $port = port($stream);
my ($host, $capsule, $id, $token);
if ($url =~ m!^gemini://($hosts)(?::$port)?/$capsule_space/([^/]+)/upload$!) {
return result($stream, "10", "Filename");
} elsif (($host, $capsule, $id) = $url =~ m!^gemini://($hosts)(?::$port)?/$capsule_space/([^/]+)/upload\?([^/]+)$!) {
$capsule = decode_utf8(uri_unescape($capsule));
return result($stream, "30", "gemini://$host:$port/$capsule_space/$capsule/$id");
} elsif (($host) = $url =~ m!^gemini://($hosts)(?::$port)?/$capsule_space/login$!) {
return serve_capsule_login($stream, $host);
} elsif (($host, $capsule) = $url =~ m!^gemini://($hosts)(?::$port)?/$capsule_space/([^/]+)/archive$!) {
return serve_capsule_archive($stream, $host, decode_utf8(uri_unescape($capsule)));
} elsif (($host, $capsule, $id) = $url =~ m!^gemini://($hosts)(?::$port)?/$capsule_space/([^/]+)/backup(?:/([^/]+))?$!) {
return serve_capsule_backup($stream, $host, map { decode_utf8(uri_unescape($_)) } $capsule, $id||"");
} elsif (($host, $capsule, $id) = $url =~ m!^gemini://($hosts)(?::$port)?/$capsule_space/([^/]+)/delete(?:/([^/]+))?$!) {
return serve_capsule_delete($stream, $host, map { decode_utf8(uri_unescape($_)) } $capsule, $id||"");
lib/App/Phoebe/Capsules.pm view on Meta::CPAN
sub serve_capsule_page {
my ($stream, $host, $capsule, $id) = @_;
my $dir = capsule_dir($host, $capsule);
my $file = $dir . "/" . encode_utf8($id);
if (-f $file) {
$log->info("Serving $file");
# this works for text files, too!
success($stream, mime_type($id));
$stream->write(read_binary($file));
} else {
$log->info("Serving invitation to upload $file");
success($stream);
$stream->write("This file does not exist. Upload it using Titan!\n");
$stream->write("=> gemini://transjovian.org/titan What is Titan?\n");
}
return 1;
}
sub serve_capsule_menu {
my ($stream, $host, $capsule) = @_;
my $name = capsule_name($stream);
my $dir = capsule_dir($host, $capsule);
my @files;
@files = read_dir($dir) if -d $dir;
my $has_backup = first { $_ eq "backup" } @files;
@files = grep { $_ ne "backup" } @files if $has_backup;
success($stream);
$log->info("Serving $capsule");
$stream->write("# " . ucfirst($capsule) . "\n");
if ($name) {
if ($name eq $capsule) {
print_link($stream, $host, $capsule_space, "Specify file for upload", "$capsule/upload");
print_link($stream, $host, $capsule_space, "Delete file", "$capsule/delete") if @files;
print_link($stream, $host, $capsule_space, "Share access with other people or other devices", "$capsule/share");
print_link($stream, $host, $capsule_space, "Access backup", "$capsule/backup") if $has_backup;
print_link($stream, $host, $capsule_space, "Download archive", "$capsule/archive") if @files;
} elsif (@capsule_tokens) {
print_link($stream, $host, $capsule_space, "Access this capsule", "$capsule/access");
}
}
if (@files) {
$stream->write("Files:\n");
lib/App/Phoebe/Capsules.pm view on Meta::CPAN
}
sub capsule_token_cleanup {
# only keep tokens created in the last 10 minutes
my $ts = time - 600;
@capsule_tokens = grep { $_->[0] > $ts } @capsule_tokens;
}
unshift(@request_handlers, '^titan://(' . capsule_regex() . ')(?::\d+)?/' . $capsule_space . '/' => \&handle_titan);
# We need our own Titan handler because we want a different copy of is_upload;
# and once we're here we can run our extension directly.
sub handle_titan {
my $stream = shift;
my $data = shift;
# extra processing of the request if we didn't do that, yet
$data->{upload} ||= is_upload($stream, $data->{request}) or return;
my $size = $data->{upload}->{params}->{size};
my $actual = length($data->{buffer});
if ($actual == $size) {
save_file($stream, $data->{request}, $data->{upload}, $data->{buffer}, $size);
$stream->close_gracefully();
return;
} elsif ($actual > $size) {
$log->debug("Received more than the promised $size bytes");
result($stream, "59", "Received more than the promised $size bytes");
$stream->close_gracefully();
return;
}
$log->debug("Waiting for " . ($size - $actual) . " more bytes");
}
# We need our own is_upload because the regular expression is different.
sub is_upload {
my $stream = shift;
my $request = shift;
$log->info("Looking at capsule $request");
my $hosts = capsule_regex();
my $port = port($stream);
if ($request =~ m!^titan://($hosts)(?::$port)?/$capsule_space/([^/?#;]+)/([^/?#;]+);([^?#]+)$!) {
my $host = $1;
my ($capsule, $id, %params) = map {decode_utf8(uri_unescape($_))} $2, $3, split(/[;=&]/, $4);
if (valid_params($stream, $host, $capsule_space, $id, \%params)) {
return {
host => $host,
space => $capsule_space,
capsule => $capsule,
id => $id,
params => \%params,
}
}
# valid_params printed a response and closed the stream
return;
}
$log->debug("Capsule upload with malformed titan URL");
if ($request =~ m!^titan://($hosts)(?::$port)?/$capsule_space/([^/?#;]+)/([^/?#;]+);([^?#]*)[?#]!) {
result($stream, "59", "The titan URL must not have a query or a fragment at the end");
} elsif ($request =~ m!^titan://($hosts)(?::$port)?/$capsule_space/([^/?#;]+)/([^/?#;]+)/!) {
result($stream, "59", "These capsules do not allow uploads for subdirectories");
} elsif ($request =~ m!^titan://($hosts)(?::$port)?/$capsule_space/([^/?#;]+)/([^/?#;]+)$!) {
result($stream, "59", "The titan URL is missing the parameters after a semikolon $1 $2 $3 $4");
} elsif ($request =~ m!^titan://($hosts)(?::$port)?/$capsule_space/([^/?#;]+)/?(;.*)$!) {
result($stream, "59", "The titan URL is missing the file name");
} elsif ($request =~ m!^titan://($hosts)(?::$port)?/$capsule_space/?$!) {
result($stream, "59", "The titan URL is missing the capsule name and the file name");
} else {
result($stream, "59", "The titan URL is malformed");
}
$stream->close_gracefully();
lib/App/Phoebe/Capsules.pm view on Meta::CPAN
my $params = shift;
return unless valid_id($stream, $host, $space, $id, $params);
# return unless valid_token($stream, $host, $space, $id, $params);
$params->{mime} = mime_type($id);
return unless valid_mime_type($stream, $host, $space, $id, $params);
return unless valid_size($stream, $host, $space, $id, $params);
return 1;
}
sub save_file {
my ($stream, $url, $upload, $buffer, $size) = @_;
my $name = capsule_name($stream);
my $capsule = $upload->{capsule} || "";
if (not $name) {
$log->debug("Missing certificate for capsule upload");
return result($stream, "60", "Uploading files requires a client certificate");
} elsif ($name ne $capsule) {
$log->debug("Wrong certificate for capsule upload: $name vs $capsule");
return result($stream, "61", "This is not your space: your certificate authorizes you for $name");
}
return result($stream, "50", "Titan upload failed")
unless defined $buffer and defined $size and $upload->{id}
and $upload->{space} and $upload->{space} eq "capsule";
my $host = $upload->{host};
my $dir = capsule_dir($host, $capsule);
my $id = $upload->{id};
my $file = $dir . "/" . encode_utf8($id);
if ($size == 0) {
return result($stream, "51", "This capsule does not exist") unless -d $dir;
return result($stream, "51", "This file does not exist") unless -f $file;
return result($stream, "40", "Cannot delete this file") unless unlink $file;
$log->info("Deleted $file");
} else {
mkdir($dir) unless -d $dir;
backup($dir, $id);
write_binary($file, $buffer);
lib/App/Phoebe/Iapetus.pm view on Meta::CPAN
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <https://www.gnu.org/licenses/>.
=encoding utf8
=head1 NAME
App::Phoebe::Iapetus - uploads using the Iapetus protocol
=head1 DESCRIPTION
This allows known editors to upload files and pages using the Iapetus protocol.
See L<Iapetus documentation|https://codeberg.org/oppenlab/iapetus>.
In order to be a known editor, you need to set C<@known_fingerprints> in your
F<config> file. Hereâs an example:
package App::Phoebe;
our @known_fingerprints;
@known_fingerprints = qw(
sha256$fce75346ccbcf0da647e887271c3d3666ef8c7b181f2a3b22e976ddc8fa38401
sha256$54c0b95dd56aebac1432a3665107d3aec0d4e28fef905020ed6762db49e84ee1);
lib/App/Phoebe/Iapetus.pm view on Meta::CPAN
F<client-cert.pem>:
openssl x509 -in client-cert.pem -noout -sha256 -fingerprint \
| sed -e 's/://g' -e 's/SHA256 Fingerprint=/sha256$/' \
| tr [:upper:] [:lower:]
This should give you the fingerprint in the correct format to add to the list
above.
Make sure your main menu has a link to the login page. The login page allows
people to pick the right certificate without interrupting their uploads.
=> /login Login
=cut
package App::Phoebe::Iapetus;
use App::Phoebe qw($server $log @request_handlers @extensions host_regex space_regex space port result
valid_id valid_mime_type valid_size @known_fingerprints process_titan);
use Modern::Perl;
use File::MimeInfo qw(globs);
use Encode qw(decode_utf8);
use URI::Escape;
push(@{$server->{wiki_mime_type}},'text/gemini');
unshift(@request_handlers, '^iapetus://' => \&handle_iapetus);
sub handle_iapetus {
my $stream = shift;
my $data = shift;
# extra processing of the request if we didn't do that, yet
return setup_iapetus($stream, $data) unless $data->{upload};
my $size = $data->{upload}->{params}->{size};
my $actual = length($data->{buffer});
if ($actual == $size) {
$log->debug("Handle Iapetus request as Titan request");
process_titan($stream, $data->{request}, $data->{upload}, $data->{buffer}, $size);
# do not close in case we're waiting for the lock
return;
} elsif ($actual > $size) {
$log->debug("Received more than the promised $size bytes");
result($stream, "59", "Received more than the promised $size bytes");
$stream->close_gracefully();
return;
}
$log->debug("Waiting for " . ($size - $actual) . " more bytes");
}
lib/App/Phoebe/Iapetus.pm view on Meta::CPAN
my($scheme, $authority, $path, $query, $fragment, $size) =
$request =~ m|(?:([^:/?#]+):)?(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(\S*))?\s+(\d+)|;
if ($path =~ m!^(?:/($spaces_regex))?(?:/raw)?/([^/;=&]+)!) {
my ($space, $id) = ($1, $2);
return unless valid_id($stream, $host, $space, $id);
my $type = globs($id) || mime_type($id);
my $params = { size => $size, mime => $type };
return unless valid_mime_type($stream, $host, $space, $id, $params);
return unless valid_size($stream, $host, $space, $id, $params);
return unless valid_client_cert($stream, $host, $space, $id, $params);
$data->{upload} = {
host => $host,
space => space($stream, $host, $space),
id => decode_utf8(uri_unescape($id)),
params => $params,
};
result($stream, "10", "Continue"); # weird!
return 1;
} else {
$log->debug("The path $path is malformed");
result($stream, "59", "The path $path is malformed");
script/phoebe view on Meta::CPAN
gemini gemini://localhost:1965/page/Welcome
You should get back a page that starts as follows:
20 text/gemini; charset=UTF-8
Welcome to the wiki!
Please be kind.
Yay! ðð ðð
If you have a bunch of Gemtext files in a directory, you can upload them all in
one go:
titan --url=titan://localhost/ --token=hello *.gmi
=head1 CERTIFICATES
If you want to generate your own certificates, here's how you would generate a
certificate for two domains (you can add as many as you need), and a common name
of "Phoebe" (use whatever you want).
openssl req -new -x509 -newkey ec \
-pkeyopt ec_paramgen_curve:prime256v1 \
-subj "/CN=Phoebe" \
-addext "subjectAltName=DNS:localhost,DNS:phoebe.local" \
-days 1825 -nodes -out cert.pem -keyout key.pem
=head1 IMAGE UPLOADS
OK, how do image uploads work? First, we need to specify which MIME types Phoebe
accepts. The files are going to be served back with that MIME type, so even if
somebody uploads an executable and claim it's an image, other people's clients
will treat it as an image instead of executing it (one hopes!) â so let's start
with a list of common MIME types.
=over
=item * C<image/jpeg> is for photos (usually with the C<jpg> extension)
=item * C<image/png> is for graphics (usually with the C<png> extension)
=item * C<audio/mpeg> is for sound (usually with the C<mp3> extension)
script/phoebe view on Meta::CPAN
Let's continue using the setup we used for the L</QUICKSTART> section. Restart
the server and allow photos:
phoebe --wiki_mime_type=image/jpeg
Upload the image using the C<titan> script:
titan --url=titan://localhost:1965/jupiter.jpg \
--token=hello Pictures/Planets/Juno.jpg
You should get back a redirect to the uploaded image:
30 gemini://localhost:1965/file/jupiter.jpg
How did the C<titan> script know the MIME-type to use for the upload? If you
don't specify a MIME-type using C<--mime>, the C<file> utility is called to
guess the MIME type of the file.
Test it:
file --mime-type --brief Pictures/Planets/Juno.jpg
The result is the MIME-type we enabled for our wiki:
image/jpeg
Here's what happens when you're trying to upload an unsupported MIME-type:
titan --url=titan://localhost:1965/earth.png \
--token=hello Pictures/Planets/Earth.png
What you get back explains the problem:
59 This wiki does not allow image/png
In order to allow such graphics as well, you need to restart Phoebe:
phoebe --wiki_mime_type=image/jpeg --wiki_mime_type=image/png
Except that in my case, the image is too big:
59 This wiki does not allow more than 100000 bytes per page
I could scale it down before I upload the image, using C<convert> (which is part
of ImageMagick):
convert -scale 20% Pictures/Planets/Earth.png earth-small.png
Try again:
titan --url=titan://localhost:1965/earth.png \
--token=hello earth-small.png
Alternatively, you can increase the size limit using the
C<--wiki_page_size_limit> option, but you need to restart Phoebe:
phoebe --wiki_page_size_limit=10000000 \
--wiki_mime_type=image/jpeg --wiki_mime_type=image/png
Now you can upload about 10MBâ¦
=head1 USING SYSTEMD
Systemd is going to handle daemonisation for us. There's more documentation
available online.
L<https://www.freedesktop.org/software/systemd/man/systemd.service.html>.
Basically, this is the template for our service, assuming that you created a
separate user for Phoebe:
script/phoebe view on Meta::CPAN
(without the C<.gmi> extension) per line, with lines separated from each other
by a single C<\n>
F<keep> is the directory with all the old revisions of pages in it â if you've
only made one change, then it won't exist; if you don't care about the older
revisions, you can delete them; assuming you have a page called C<Welcome> and
edit it once, you have the current revision as F<page/Welcome.gmi>, and the old
revision in F<keep/Welcome/1.gmi> (the page name turns into a subdirectory and
each revision gets an apropriate number)
F<file> is the directory with all the uploaded files in it â if you haven't
uploaded any files, then it won't exist; you must explicitly allow MIME types
for upload using the C<--wiki_mime_type> option (see I<Options> below)
F<meta> is the directory with all the meta data for uploaded files in it â there
should be a file here for every file in the F<file> directory; if you create new
files in the F<file> directory, you should create a matching file here; if you
have a file F<file/alex.jpg> you want to create a file F<meta/alex.jpg>
containing the line C<content-type: image/jpeg>
F<changes.log> is a file listing all the pages made to the wiki; if you make
changes to the files in the F<page> or F<file> directory, they aren't going to
be listed in this file and thus people will be confused by the changes you made
â your call (but in all fairness, if you're collaborating with others you
probably shouldn't do this); the format is one change per line, with lines
script/phoebe view on Meta::CPAN
and give different users different passwords, if you want
=item * C<--wiki_page> is an extra page to show in the main menu; you can use
this option multiple times; this is ideal for general items like I<About>
or I<Contact>
=item * C<--wiki_main_page> is the page containing your header for the main
page; that's were you would put your ASCII art header, your welcome
message, and so on, see L</Main Page and Title> below
=item * C<--wiki_mime_type> is a MIME type to allow for uploads; text/plain is
always allowed and doesn't need to be listed; you can also just list the
type without a subtype, eg. C<image> will allow all sorts of images (make
sure random people can't use your server to exchange images â set a
password using C<--wiki_token>)
=item * C<--wiki_page_size_limit> is the number of bytes to allow for uploads,
both for pages and for files; the default is 10000 (10kB)
=item * C<--host> is the hostname to serve; the default is C<localhost> â you
probably want to pick the name of your machine, if it is reachable from
the Internet; if you use it multiple times, each host gets its own wiki
space (see C<--wiki_space> below)
=item * C<--port> is the port to use; the default is 1965; if you use it
multiple times on the same host, they all share the same wiki space
script/phoebe view on Meta::CPAN
private key is what you get from Let's Encrypt (and if you do, restart Phoebe),
and the regular certificate and key file is what you generated yourself for
Gemini. Just make sure you replace those before they expire!
phoebe --host transjovian.org \
--port 1965 --cert_file cert.pem --key_file key.pem \
--port 443 --cert_file web_cert.pem --key_file web_key.pem
=head2 UPLOADS
If you allow uploads of binary files, these are stored separately from the
regular pages; the wiki doesn't keep old revisions of files around. If somebody
overwrites a file, the old revision is gone.
You definitely don't want random people uploading all sorts of images, videos
and binaries to your server. Make sure you set up those L<tokens|/Security>
using C<--wiki_token>!
=head1 NOTES
=head2 Security
The server uses "access tokens" to check whether people are allowed to edit
files. You could also call them "passwords", if you want. They aren't associated
with a username. You set them using the C<--wiki_token> option. By default, the
script/titan view on Meta::CPAN
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <https://www.gnu.org/licenses/>.
=encoding utf8
=head1 NAME
titan - a command line client to upload texts and files using the Titan protocol
=head1 SYNOPSIS
B<titan> [B<--help>] [B<--token=>I<TOKEN>] [B<--mime=>I<MIMETYPE>]
[B<--cert_file=>I<FILE> B<--key_file=>I<FILE>] I<URL> [I<FILES> ...]
=head1 DESCRIPTION
This is a script to upload content to a Titan-enabled site like Phoebe.
B<URL> specifies the Titan URL to use; this should be really similar to the
Gemini URL you used to read the page.
B<--token=TOKEN> specifies the token to use; this is optional but spammers and
vandals basically ensured that any site out on the Internet needs some sort of
protection; how to get a token depends on the site you're editing.
B<--mime=MIMETYPE> specifies the MIME type to send to the server. If you don't
specify a MIME type, the C<file> utility is used to determine the MIME type of
the file you're uploading.
B<FILES...> are the files to upload, if any; this is optional: you can also use
a pipe, or type a few words by hand (terminating it with a Ctrl-D, the end of
transmission byte).
Note that if you specify multiple files, the URL must end in a slash and all the
filenames are used as page names. So, uploading F<Alex.gmi> and F<Berta.gmi> to
C<titan://localhost/> will create C<gemini://localhost/Alex> and
C<gemini://localhost/Berta>.
The following two options control the use of client certificates:
B<--cert_file=FILE> specifies an optional client certificate to use; if you
don't specify one, the default is to try to use F<client-cert.pem> in the
current directory.
B<--key_file=FILE> specifies an optional client certificate key to use; if you
t/Capsules.t view on Meta::CPAN
my $titan = "titan://$host:$port/capsule";
my $page = query_gemini("$base/capsule");
like($page, qr/^# Capsules/m, "Capsules");
my ($name) = $page =~ qr/^=> \S+ (\S+)/m;
ok($name, "Link to capsule");
$page = query_gemini("$base/capsule/$name");
like($page, qr/# $name/mi, "Title");
like($page, qr/^=> $base\/capsule\/$name\/upload/m, "Upload link");
$page = query_gemini("$base/capsule/$name/upload");
like($page, qr/^10 /, "Filename");
$page = query_gemini("$base/capsule/$name/upload?haiku.gmi");
like($page, qr/^30 $base\/capsule\/$name\/haiku\.gmi/, "Redirect");
$page = query_gemini("$base/capsule/$name/haiku.gmi");
like($page, qr/This file does not exist. Upload it using Titan!/, "Invitation");
# no client cert
$page = query_gemini("$titan/$name/haiku.gmi;size=71;mime=text/plain", $haiku, 0);
like($page, qr/^60 Uploading files requires a client certificate/, "Client certificate required");
$page = query_gemini("$titan/xxx/haiku.gmi;size=71;mime=text/plain", $haiku);
t/Capsules.t view on Meta::CPAN
$page = query_gemini("$base/capsule/$name/access?$pwd", undef, 2); # a different certificate
like($page, qr/^30 $base\/capsule\/$name/m, "Redirect to the same capsule");
ok(-f "$dir/fingerprint_equivalents", "Fingerprint equivalents saved");
like(read_text("$dir/fingerprint_equivalents"), qr/^sha256\S+ sha256\S+$/, "Fingerprint equivalents");
# testing the fingerprint equivalency
$page = query_gemini("$base/capsule/$name", undef, 2);
like($page, qr/# $name/mi, "Title");
like($page, qr/^=> $base\/capsule\/$name\/upload/m, "Equivalent upload link");
# test backup
ok(! -d "$dir/capsule/$name/backup", "No backup dir has been created");
ok(! -f "$dir/capsule/$name/backup/haiku.gmi", "No backup has been made");
my $ts = time - 1000;
is(utime($ts, $ts, "$dir/capsule/$name/haiku.gmi"), 1, "File backdated");
$haiku = <<"EOT";
Nervous late at night
Typing furiously, in vain
t/Capsules.t view on Meta::CPAN
my $tar = read_binary("$dir/capsule/$name/backup/data.tar.gz");
ok($tar eq $page, "tar bytes are correct");
open(my $fh, "tar --list --gzip --file $dir/capsule/$name/backup/data.tar.gz |");
my @files = <$fh>;
close($fh);
ok((first { "$name/haiku.gmi\n" } @files), "Found haiku in the archive");
ok((grep !/backup/, @files), "No backups in the archive (@files)");
}
# upload to the wrong place
$page = query_gemini("$titan/$name;size=69;mime=text/plain", $haiku);
like($page, qr/^59 The titan URL is missing the file name/, "Missing file name");
# Missing extension
$page = query_gemini("$titan/$name/no-extension;size=69;mime=text/plain", $haiku);
like($page, qr/^59 This wiki does not allow application\/octet-stream/, "Missing file extension");
# delete file
t/Iapetus.t view on Meta::CPAN
# variables set by test.pl
our $host;
our $base;
our $port;
# test page
my $page = query_gemini("$base/Haiku");
like($page, qr/^51 Path not found/m, "Test page does not exist");
# upload text
sub iapetus {
my $request = shift;
my $data = shift;
my $socket = IO::Socket::SSL->new(
PeerHost => $host, PeerPort => $port,
# don't verify the server certificate
SSL_verify_mode => SSL_VERIFY_NONE,
SSL_cert_file => 't/cert.pem',
SSL_key_file => 't/key.pem', );
$socket->print($request);
# main menu
$page = query_gemini("$base/");
unlike($page, qr/^=> .*\/$/m, "No empty links in the menu");
# --wiki_page
for my $item(qw(Alex Berta Chris)) {
like($page, qr/^=> $base\/page\/$item $item/m, "main menu contains $item");
}
# upload text
my $titan = "titan://$host:$port";
my $haiku = <<EOT;
Quiet disk ratling
Keyboard clicking, then it stops.
Rain falls and I think
EOT
$page = query_gemini("$titan/raw/Haiku;size=76;mime=text/plain;token=hello", $haiku);
EOT
# plain text
$page = query_gemini("$titan/raw/Bread;size=72;token=hello", $haiku);
like($page, qr/^30 $base\/page\/Bread\r$/, "Bread haiku without MIME type");
$page = query_gemini("$base/page/Bread");
like($page, qr/^20 text\/gemini; charset=UTF-8\r\n# Bread\n$haiku/, "Bread haiku saved");
# upload image
my $data = read_binary("t/alex.jpg");
my $size = length($data);
$page = query_gemini("$titan/raw/Alex;size=$size;token=hello", $data);
like($page, qr/^59 The text is invalid UTF-8/, "Upload image without MIME type");
$page = query_gemini("$titan/raw/Alex;size=$size;mime=image/png;token=hello", $data);
like($page, qr/^59 This wiki does not allow image\/png/, "Upload image with wrong MIME type");
$page = query_gemini("$base/page/Alex");
like($page, qr/This page does not yet exist/, "Save of unsupported MIME type failed");
t/encoding.t view on Meta::CPAN
use utf8; # tests contain UTF-8 characters and it matters
my $idn = "æ±äº¬.jp";
our @hosts = ("localhost", $idn);
our $port;
our $base;
our $dir;
require './t/test.pl';
# upload text
my $titan = "titan://$hosts[0]:$port";
my $name = "æ¥æ¬èª";
my $encoded_name = uri_escape_utf8($name);
my $text = <<EOT;
Schröder answered: ï½¢éµä¾¿å±ï½£
EOT
my $encoded_text = encode_utf8($text);
my $length = length($encoded_text);
t/oddmuse-wiki.pl view on Meta::CPAN
$text =~ s/\r\n/\n/g; # DOS to Unix
$text =~ s/\n+$//g; # No trailing paragraphs
return if $text eq ''; # allow the text '0'
local $Fragment = ''; # the clean HTML fragment not yet on @Blocks
local @Blocks = (); # the list of cached HTML blocks
local @Flags = (); # a list for each block, 1 = dirty, 0 = clean
Clean(join('', map { AddHtmlEnvironment($_) } @tags));
if ($OpenPageName and $PlainTextPages{$OpenPageName}) { # there should be no $PlainTextPages{''}
Clean(CloseHtmlEnvironments() . $q->pre($text));
} elsif (my ($type) = TextIsFile($text)) { # TODO? $type defined here??
Clean(CloseHtmlEnvironments() . $q->p(T('This page contains an uploaded file:'))
. $q->p(GetDownloadLink($OpenPageName, (substr($type, 0, 6) eq 'image/'), $revision))
. (length $Page{summary} > 0 ? $q->blockquote(QuoteHtml($Page{summary})) : $q->p(T('No summary was provided for this file.'))));
} else {
my $smileyregex = join "|", keys %Smilies;
$smileyregex = qr/(?=$smileyregex)/;
local $_ = $text;
local $bol = 1;
while (1) {
# Block level elements should eat trailing empty lines to prevent empty p elements.
if ($bol and m/\G(\s*\n)+/cg) {
t/oddmuse-wiki.pl view on Meta::CPAN
sub GetPageLink { # use if you want to force a link to local pages, whether it exists or not
my ($id, $name, $class, $accesskey) = @_;
$id = FreeToNormal($id);
$name ||= $id;
$class .= ' ' if $class;
return ScriptLink(UrlEncode($id), NormalToFree($name), $class . 'local',
undef, undef, $accesskey);
}
sub GetEditLink { # shortcut
my ($id, $name, $upload, $accesskey) = @_;
$id = FreeToNormal($id);
my $action = 'action=edit;id=' . UrlEncode($id);
$action .= ';upload=1' if $upload;
return ScriptLink($action, NormalToFree($name), 'edit', undef, T('Click to edit this page'), $accesskey);
}
sub ScriptUrl {
my $action = shift;
if ($action =~ /^($UrlProtocols)\%3a/ or $action =~ /^\%2f/) { # nearlinks and other URLs
$action =~ s/%([0-9a-f][0-9a-f])/chr(hex($1))/eg; # undo urlencode
# do nothing
} else {
$action = $ScriptName . (($UsePathInfo and index($action, '=') == -1) ? '/' : '?') . $action;
t/oddmuse-wiki.pl view on Meta::CPAN
$action = "action=download;id=" . UrlEncode($id) . ";revision=$revision";
} elsif ($UsePathInfo) {
$action = "download/" . UrlEncode($id);
} else {
$action = "action=download;id=" . UrlEncode($id);
}
if ($image) {
$action = $ScriptName . (($UsePathInfo and not $revision) ? '/' : '?') . $action;
return $action if $image == 2;
my $result = $q->img({-src=>$action, -alt=>UnquoteHtml($alt), -title=>UnquoteHtml($alt),
-class=>'upload', -loading=>'lazy'});
$result = ScriptLink(UrlEncode($id), $result, 'image') unless $id eq $OpenPageName;
return $result;
} else {
return ScriptLink($action, $alt, 'upload');
}
}
sub PrintCache { # Use after OpenPage!
my @blocks = split($FS, $Page{blocks});
my @flags = split($FS, $Page{flags});
$FootnoteNumber = 0;
foreach my $block (@blocks) {
if (shift(@flags)) {
ApplyRules($block, 1, 1); # local links, anchors, current revision, no start tag
t/oddmuse-wiki.pl view on Meta::CPAN
sub UnWiki {
my $str = shift;
return $str unless $WikiLinks and $str =~ /^$LinkPattern$/;
$str =~ s/([[:lower:]])([[:upper:]])/$1 $2/g;
return $str;
}
sub DoEdit {
my ($id, $newText, $preview) = @_;
UserCanEditOrDie($id);
my $upload = GetParam('upload', undef);
if ($upload and not $UploadAllowed and not UserIsAdmin()) {
ReportError(T('Only administrators can upload files.'), '403 FORBIDDEN');
}
OpenPage($id);
my ($revisionPage, $revision) = GetTextRevision(GetParam('revision', ''), 1); # maybe revision reset!
my $oldText = $preview ? $newText : $revisionPage->{text};
my $isFile = TextIsFile($oldText);
$upload //= $isFile;
if ($upload and not $UploadAllowed and not UserIsAdmin()) {
ReportError(T('Only administrators can upload files.'), '403 FORBIDDEN');
}
if ($upload) { # shortcut lots of code
$revision = '';
$preview = 0;
} elsif ($isFile) {
$oldText = '';
}
my $header;
if ($revision and not $upload) {
$header = Ts('Editing revision %s of', $revision) . ' ' . NormalToFree($id);
} else {
$header = Ts('Editing %s', NormalToFree($id));
}
print GetHeader('', $header), $q->start_div({-class=>'content edit'});
if ($preview and not $upload) {
print $q->start_div({-class=>'preview'});
print $q->h2(T('Preview:'));
PrintWikiToHTML($oldText); # no caching, current revision, unlocked
print $q->hr(), $q->h2(T('Preview only, not yet saved')), $q->end_div();
}
if ($revision) {
print $q->strong(Ts('Editing old revision %s.', $revision) . ' '
. T('Saving this page will replace the latest revision with this text.'))
}
print GetEditForm($id, $upload, $oldText, $revision), $q->end_div();
PrintFooter($id, 'edit');
}
sub GetEditForm {
my ($page_name, $upload, $oldText, $revision) = @_;
my $html = GetFormStart(undef, undef, $upload ? 'edit upload' : 'edit text') # protected by questionasker
.$q->p(GetHiddenValue("title", $page_name),
($revision ? GetHiddenValue('revision', $revision) : ''),
GetHiddenValue('oldtime', GetParam('oldtime', $Page{ts})), # prefer parameter over actual timestamp
($upload ? GetUpload() : GetTextArea('text', $oldText)));
my $summary = UnquoteHtml(GetParam('summary', ''))
|| ($Now - $Page{ts} < ($SummaryHours * 3600) ? $Page{summary} : '');
$html .= $q->p(T('Summary:').$q->br().GetTextArea('summary', $summary, 2))
.$q->p($q->checkbox(-name=>'recent_edit', -checked=>(GetParam('recent_edit', '') eq 'on'),
-label=>T('This change is a minor edit.')));
$html .= T($EditNote) if $EditNote; # Allow translation
my $username = GetParam('username', '');
$html .= $q->p($q->label({-for=>'username'}, T('Username:')).' '
.$q->textfield(-name=>'username', -id=>'username', -default=>$username,
-override=>1, -size=>20, -maxlength=>50))
.$q->p($q->submit(-name=>'Save', -accesskey=>T('s'), -value=>T('Save')),
($upload ? '' : ' ' . $q->submit(-name=>'Preview', -accesskey=>T('p'), -value=>T('Preview'))).
' '.$q->submit(-name=>'Cancel', -value=>T('Cancel')));
if ($upload) {
$html .= $q->p(ScriptLink('action=edit;upload=0;id=' . UrlEncode($page_name), T('Replace this file with text'), 'upload'));
} elsif ($UploadAllowed or UserIsAdmin()) {
$html .= $q->p(ScriptLink('action=edit;upload=1;id=' . UrlEncode($page_name), T('Replace this text with a file'), 'upload'));
}
$html .= $q->end_form();
foreach my $sub (@MyFormChanges) {
$html = $sub->($html, 'edit', $upload);
}
return $html;
}
sub GetTextArea {
my ($name, $text, $rows) = @_;
return $q->textarea(-id=>$name, -name=>$name, -default=>$text, -rows=>$rows || 25, -columns=>78, -override=>1);
}
sub GetUpload {
return T('File to upload:') . ' ' . $q->filefield(-name=>'file', -size=>50, -maxlength=>100);
}
sub DoDownload {
my $id = shift;
OpenPage($id) if ValidIdOrDie($id);
print $q->header(-status=>'304 NOT MODIFIED') and return if FileFresh(); # FileFresh needs an OpenPage!
my ($revisionPage, $revision) = GetTextRevision(GetParam('revision', '')); # maybe revision reset!
my $text = $revisionPage->{text};
if (my ($type, $encoding) = TextIsFile($text)) {
my ($data) = $text =~ /^[^\n]*\n(.*)/s;
t/oddmuse-wiki.pl view on Meta::CPAN
return TextIsFile(substr($_, 6)); # pass "#FILE image/png\n" to the test
}
}
sub SearchTitleAndBody {
my ($regex, $func, @args) = @_;
my @found;
my $lang = GetParam('lang', '');
foreach my $id (Filtered($regex, AllPagesList())) {
my $name = NormalToFree($id);
my ($text) = PageIsUploadedFile($id); # set to mime-type if this is an uploaded file
local ($OpenPageName, %Page); # this is local!
if (not $text) { # not uploaded file, therefore allow searching of page body
OpenPage($id); # this opens a page twice if it is not uploaded, but that's ok
if ($lang) {
my @languages = split(/,/, $Page{languages});
next if (@languages and not grep(/$lang/, @languages));
}
$text = $Page{text};
}
if (SearchString($regex, $name . "\n" . $text)) { # the real search code
push(@found, $id);
$func->($id, @args) if $func;
}
t/oddmuse-wiki.pl view on Meta::CPAN
grep /./, shift =~ /\"([^\"]+)\"|(\S+)/g; # this acts as OR
$regexp =~ s/\\s/[[:space:]]/g;
return $regexp;
}
sub PrintSearchResult {
my ($name, $regex) = @_;
return PrintPage($name) if not GetParam('context', 1);
OpenPage($name); # should be open already, just making sure!
my $text = $Page{text};
my ($type) = TextIsFile($text); # MIME type if an uploaded file
my %entry;
# get the page, filter it, remove all tags
$text =~ s/$FS//g; # Remove separators (paranoia)
$text =~ s/[\s]+/ /g; # Shrink whitespace
$text =~ s/([-_=\\*\\.]){10,}/$1$1$1$1$1/g ; # e.g. shrink "----------"
$entry{title} = $name;
$entry{description} = $type || SearchHighlight(QuoteHtml(SearchExtract($text, $regex)), QuoteHtml($regex));
$entry{size} = int((length($text) / 1024) + 1) . 'K';
$entry{'last-modified'} = TimeToText($Page{ts});
$entry{username} = $Page{username};
t/oddmuse-wiki.pl view on Meta::CPAN
sub DoPost {
my $id = FreeToNormal(shift);
UserCanEditOrDie($id);
# Lock before getting old page to prevent races
RequestLockOrError(); # fatal
OpenPage($id);
my $old = $Page{text};
my $string = UnquoteHtml(GetParam('text', undef));
$string =~ s/(\r|$FS)//g;
my ($type) = TextIsFile($string); # MIME type if an uploaded file
my $filename = GetParam('file', undef);
if (($filename or $type) and not $UploadAllowed and not UserIsAdmin()) {
ReportError(T('Only administrators can upload files.'), '403 FORBIDDEN');
}
my $comment = UnquoteHtml(GetParam('aftertext', undef));
$comment =~ s/(\r|$FS)//g;
if (defined $comment and $comment eq '') {
ReleaseLock();
return ReBrowsePage($id);
}
if ($filename) { # upload file
my $file = $q->upload('file');
if (not $file and $q->cgi_error) {
ReportError(Ts('Transfer Error: %s', $q->cgi_error), '500 INTERNAL SERVER ERROR');
}
ReportError(T('Browser reports no file info.'), '500 INTERNAL SERVER ERROR') unless $q->uploadInfo($filename);
$type = $q->uploadInfo($filename)->{'Content-Type'};
ReportError(T('Browser reports no file type.'), '415 UNSUPPORTED MEDIA TYPE') unless $type;
local $/ = undef; # Read complete files
my $content = <$file>; # Apparently we cannot count on <$file> to always work within the eval!?
my $encoding = substr($content, 0, 2) eq "\x1f\x8b" ? 'gzip' : '';
eval { require MIME::Base64; $_ = MIME::Base64::encode($content) };
$string = "#FILE $type $encoding\n" . $_;
} else { # ordinary text edit
$string = AddComment($old, $comment) if defined $comment;
if ($comment and substr($string, 0, length($DeletedPage)) eq $DeletedPage) { # look ma, no regexp!
$string = substr($string, length($DeletedPage)); # undelete pages when adding a comment
t/oddmuse-wiki.pl view on Meta::CPAN
$homepage = 'http://' . $homepage if $homepage and $homepage !~ /^($UrlProtocols):/;
$author = "[$homepage $author]" if $homepage;
$string .= "\n----\n\n" if $string and $string ne "\n";
$string .= $comment . "\n\n"
. '-- ' . $author . ' ' . TimeToText($Now) . "\n\n";
}
return $string;
}
sub Save { # call within lock, with opened page
my ($id, $new, $summary, $minor, $upload) = @_;
my $user = GetParam('username', '');
my $revision = $Page{revision} + 1;
my $old = $Page{text};
my $olddiff = $Page{'diff-major'} == '1' ? $Page{'diff-minor'} : $Page{'diff-major'};
if ($revision == 1 and IsFile($IndexFile) and not Unlink($IndexFile)) { # regenerate index on next request
SetParam('msg', Ts('Cannot delete the index file %s.', $IndexFile)
. ' ' . T('Please check the directory permissions.')
. ' ' . T('Your changes were not saved.'));
return 0;
}
ReInit($id);
TouchIndexFile();
SaveKeepFile(); # deletes blocks, flags, diff-major, and diff-minor, and sets keep-ts
ExpireKeepFiles();
$Page{lastmajor} = $revision unless $minor;
$Page{lastmajorsummary} = $summary unless $minor;
@Page{qw(ts revision summary username minor text)} =
($Now, $revision, $summary, $user, $minor, $new);
if ($UseDiff and $UseCache > 1 and $revision > 1 and not $upload and not TextIsFile($old)) {
UpdateDiffs($old, $new, $olddiff); # sets diff-major and diff-minor
}
my $languages;
$languages = GetLanguages($new) unless $upload;
$Page{languages} = $languages;
SavePage();
if ($revision == 1 and $LockOnCreation{$id}) {
WriteStringToFile(GetLockedPageFile($id), 'LockOnCreation');
}
my $host = $q->remote_addr();
WriteRcLog($id, $summary, $minor, $revision, $user, $host, $languages, GetCluster($new));
AddToIndex($id) if ($revision == 1);
}
$page = query_gemini("$base/alex");
like($page, qr/^=> $base\/alex\/page\/Alex Alex/m, "main menu contains Alex");
$page = query_gemini("$base/alex/page/Alex");
like($page, qr/^This page does not yet exist/m, "Alex page is empty in the alex space");
# redirect of reserved word
$page = query_gemini("$base/alex/do");
is($page, "31 $base/alex\r\n", "Redirect reserved word");
# upload
my $titan = "titan://$host:$port";
my $haiku = <<EOT;
Outside children shout
Everybody is running
Recess is the best
EOT
$page = query_gemini("$titan/alex/raw/Haiku;size=63;mime=text/plain;token=hello", $haiku);