App-Phoebe

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN

- 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.

Changes  view on Meta::CPAN


- 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 \

README.md  view on Meta::CPAN

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`:

README.md  view on Meta::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

README.md  view on Meta::CPAN

* [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);

t/basics.t  view on Meta::CPAN

# 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);

t/basics.t  view on Meta::CPAN

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

t/spaces.t  view on Meta::CPAN


$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);



( run in 4.336 seconds using v1.01-cache-2.11-cpan-b16cb0d3907 )