Initial import

This commit is contained in:
Lucas Gabriel Vuotto 2025-04-26 09:41:50 +00:00
commit dd492db92e
37 changed files with 1953 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
db/
s/

12
bin/mk-schema.pl Normal file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env perl
use v5.40;
use DBIx::Class::Schema::Loader qw(make_schema_at);
my $dsn = (require "./pooru-api-v0.conf")->{dsn};
my %opts = (
dump_directory => "lib",
exclude => "dbix_migration",
);
make_schema_at("Pooru::Schema", \%opts, [$dsn]);

12
bin/run.pl Normal file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env perl
use Mojolicious::Lite;
app->secrets(["unused"]);
app->static->paths(["s"]);
app->static->prefix("/s");
plugin Mount => { "/api/v0" => "script/pooru-api-v0" };
plugin Mount => { "/" => "script/pooru-site" };
app->start;

9
cpanfile Normal file
View file

@ -0,0 +1,9 @@
# Reflects OpenBSD's versions at time of development.
requires "perl", "5.40.1";
requires "Mojolicious", "9.37";
requires "DBIx::Class", "0.082";
requires "DBIx::Class::Schema::Loader", "0.070";
requires "DBIx::Migration", "0.07"; # Quite an old one.
requires "DBD::SQLite", "1.76";

80
lib/Pooru/API/V0.pm Normal file
View file

@ -0,0 +1,80 @@
package Pooru::API::V0;
use Mojo::Base "Mojolicious", -signatures;
use DBD::SQLite::Constants ":dbd_sqlite_string_mode";
use DBIx::Migration;
use Pooru::Schema;
sub _search_opts ()
{
return {
Media => {
order_by => {-desc => "id"},
rows => 12,
},
TagCountView => {
order_by => [
{-desc => [qw(count kind_id)]},
{-asc => "name"},
],
rows => 1000,
},
TaggedMediaView_media => {
order_by => {-desc => "media_id"},
rows => 12,
},
TaggedMediaView_tags => {
order_by => [
{-desc => "tag_kind_id"},
{-asc => "tag_name"},
],
rows => 1000,
},
};
}
sub startup ($self)
{
$self->moniker("pooru-api-v0");
my $config = $self->plugin("Config");
$self->secrets($config->{secrets});
$self->helper(schema => sub {
state $schema = Pooru::Schema->connect(
$config->{dsn},
undef,
undef,
{
sqlite_string_mode =>
DBD_SQLITE_STRING_MODE_UNICODE_STRICT,
on_connect_call => "use_foreign_keys",
}
);
});
$self->helper(pager => sub ($, $rs) {
return map +($_ => ($rs->pager->$_ and int($rs->pager->$_))),
qw(first_page previous_page current_page next_page
last_page);
});
DBIx::Migration->new({
dsn => $config->{dsn},
dir => $self->app->home->child("migrations")->to_string,
})->migrate;
my $r = $self->routes;
$r->get("/meta")->to("meta#index")->name("meta");
$r->get("/media")->to("media#list")->name("list_media");
$r->get("/media/<media_id:num>")->to("media#show")->name("show_media");
$r->get("/tag")->to("tags#show")->name("show_tag");
$r->get("/tags")->to("tags#list")->name("list_tags");
$r->get("/random/media")->to("random#media")->name("random_media");
$r->get("/random/tag")->to("random#tag")->name("random_tag");
}
1;

View file

@ -0,0 +1,120 @@
package Pooru::API::V0::Controller::Media;
use Mojo::Base "Mojolicious::Controller", -signatures;
my %search_opts = Pooru::API::V0::_search_opts->%*;
sub _list_no_tags ($self, $page)
{
my $paged_media = $self->schema->resultset("Media")
->search(undef, $search_opts{Media})
->page($page);
my @media = map +{
id => $_->id,
storage_id => $_->storage_id,
filename => $_->filename,
content_type => $_->content_type,
upload_datetime => $_->upload_datetime,
}, $paged_media->all;
return $self->render(json => {
media => [@media],
pager => {$self->pager($paged_media)},
});
}
# Executes
#
# SELECT * FROM tagged_media_view
# WHERE (tag_display = t_1 OR ... OR tag_display = t_N)
# GROUP BY media_id HAVING COUNT(media_id) = N
sub _list_with_tags ($self, $page, @tags)
{
my $attrs = {
$search_opts{TaggedMediaView_media}->%*,
# Use "0 + ?" as otherwise a bare "?" interprets the value as
# TEXT, breaking the functionality.
having => \["COUNT(media_id) = 0 + ?", scalar @tags],
group_by => "media_id",
};
my $paged_media = $self->schema->resultset("TaggedMediaView")
->search({tag_display => [@tags]}, $attrs)->page($page);
my @media = map +{
id => $_->media_id,
storage_id => $_->media_storage_id,
filename => $_->media_filename,
content_type => $_->media_content_type,
upload_datetime => $_->media_upload_datetime,
}, $paged_media->all;
return $self->render(json => {
media => [@media],
pager => {$self->pager($paged_media)},
});
}
sub list ($self)
{
my $v = $self->validation;
my $page = $v->optional("page")->num(1, undef)->param // 1;
return $self->render(
json => {error => "Invalid page number."},
status => 400,
) if $v->has_error;
my @tags = split(" ", $v->optional("tags")->param // "");
return $self->render(
json => {error => "Invalid tags."},
status => 400,
) if $v->has_error;
return @tags == 0 ?
$self->_list_no_tags($page) :
$self->_list_with_tags($page, @tags);
}
sub show ($self)
{
my $v = $self->validation;
my $page = $v->optional("page")->num(1, undef)->param // 1;
return $self->render(
json => {error => "Invalid page number."},
status => 400,
) if $v->has_error;
my $media_id = $self->stash("media_id");
my $media = $self->schema->resultset("Media")
->single({id => $media_id});
return $self->render(
json => {error => "Media not found"},
status => 404,
) if !defined($media);
my $paged_tags = $self->schema->resultset("TaggedMediaView")
->search({media_id => $media_id},
$search_opts{TaggedMediaView_tags})->page($page);
my @tags = map +{
id => $_->tag_id,
name => $_->tag_name,
kind_id => $_->tag_kind_id,
display => $_->tag_display,
count => $_->tag_count,
}, $paged_tags->all;
return $self->render(json => {
id => $media->id,
storage_id => $media->storage_id,
filename => $media->filename,
content_type => $media->content_type,
upload_datetime => $media->upload_datetime,
tags => [@tags],
pager => {$self->pager($paged_tags)},
});
}
1;

View file

@ -0,0 +1,22 @@
package Pooru::API::V0::Controller::Meta;
use Mojo::Base "Mojolicious::Controller", -signatures;
sub index ($self)
{
return $self->render(json => {
routes => [
{ path => "/meta", verb => "GET" },
{ path => "/media", verb => "GET" },
{ path => "/media/<media_id:num>", verb => "GET" },
{ path => "/tag", verb => "GET" },
{ path => "/tags", verb => "GET" },
{ path => "/random/media", verb => "GET" },
{ path => "/random/tag", verb => "GET" },
],
});
}
1;

View file

@ -0,0 +1,35 @@
package Pooru::API::V0::Controller::Random;
use Mojo::Base "Mojolicious::Controller", -signatures;
sub _random_entry ($self, $rs)
{
return $self->schema->resultset($rs)
->search(undef, {order_by => 'random()', rows => 1})->single;
}
sub media ($self)
{
my $media = $self->_random_entry("Media");
return $self->render(
json => {error => "Media not found."},
status => 404,
) if !defined($media);
return $self->redirect_to("show_media", media_id => $media->id);
}
sub tag ($self)
{
my $tag = $self->_random_entry("Tag");
return $self->render(
json => {error => "Tag not found."},
status => 404,
) if !defined($tag);
return $self->redirect_to(
$self->url_for("show_tag")->query(id => $tag->id));
}
1;

View file

@ -0,0 +1,69 @@
package Pooru::API::V0::Controller::Tags;
use Mojo::Base "Mojolicious::Controller", -signatures;
my %search_opts = Pooru::API::V0::_search_opts->%*;
sub list ($self)
{
my $v = $self->validation;
my $page = $v->optional("page")->num(1, undef)->param // 1;
return $self->render(
json => {error => "Invalid page number."},
status => 400,
) if $v->has_error;
my $paged_tags = $self->schema->resultset("TagCountView")
->search(undef, $search_opts{TagCountView})->page($page);
my @tags = map +{
id => $_->id,
name => $_->name,
kind_id => $_->kind_id,
display => $_->display,
count => $_->count,
}, $paged_tags->all;
return $self->render(json => {
tags => [@tags],
pager => {$self->pager($paged_tags)},
});
}
sub show ($self)
{
my $v = $self->validation;
my $tag_id_or_name = $v->optional("id")->num(1, undef)->param //
$v->optional("display")->param //
$v->required("name")->param;
return $self->render(
json => {error => "Invalid tag ID or name."},
status => 400,
) if $v->has_error;
my %search = ($v->topic => $tag_id_or_name);
my $kind_id = $v->optional("kind_id")->param;
return $self->render(
json => {error => "Invalid kind ID."},
status => 400,
) if $v->has_error;
$search{kind_id} = $kind_id if defined($kind_id);
my @tags = map +{
id => $_->id,
name => $_->name,
kind_id => $_->kind_id,
display => $_->display,
count => $_->count,
}, $self->schema->resultset("TagCountView")->search(\%search)->all;
return $self->render(
json => {error => "Tag not found."},
status => 404,
) if @tags == 0;
return $self->render(json => {tags => [@tags]});
}
1;

20
lib/Pooru/Schema.pm Normal file
View file

@ -0,0 +1,20 @@
use utf8;
package Pooru::Schema;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
use strict;
use warnings;
use base 'DBIx::Class::Schema';
__PACKAGE__->load_namespaces;
# Created by DBIx::Class::Schema::Loader v0.07052 @ 2025-04-26 09:36:47
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:CKLwXldwvhv5+lWXivRpCA
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -0,0 +1,96 @@
use utf8;
package Pooru::Schema::Result::Kind;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
Pooru::Schema::Result::Kind
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 TABLE: C<kind>
=cut
__PACKAGE__->table("kind");
=head1 ACCESSORS
=head2 id
data_type: 'text'
is_nullable: 0
size: 32
=head2 name
data_type: 'text'
is_nullable: 0
size: 256
=cut
__PACKAGE__->add_columns(
"id",
{ data_type => "text", is_nullable => 0, size => 32 },
"name",
{ data_type => "text", is_nullable => 0, size => 256 },
);
=head1 PRIMARY KEY
=over 4
=item * L</id>
=back
=cut
__PACKAGE__->set_primary_key("id");
=head1 UNIQUE CONSTRAINTS
=head2 C<name_unique>
=over 4
=item * L</name>
=back
=cut
__PACKAGE__->add_unique_constraint("name_unique", ["name"]);
=head1 RELATIONS
=head2 tags
Type: has_many
Related object: L<Pooru::Schema::Result::Tag>
=cut
__PACKAGE__->has_many(
"tags",
"Pooru::Schema::Result::Tag",
{ "foreign.kind_id" => "self.id" },
{ cascade_copy => 0, cascade_delete => 0 },
);
# Created by DBIx::Class::Schema::Loader v0.07052 @ 2025-04-26 09:36:47
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ePfTpTOWY1H3Bky/bjA3iA
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -0,0 +1,136 @@
use utf8;
package Pooru::Schema::Result::Media;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
Pooru::Schema::Result::Media
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 TABLE: C<media>
=cut
__PACKAGE__->table("media");
=head1 ACCESSORS
=head2 id
data_type: 'integer'
is_auto_increment: 1
is_nullable: 0
=head2 storage_id
data_type: 'text'
is_nullable: 0
size: 64
=head2 filename
data_type: 'text'
is_nullable: 0
size: 256
=head2 content_type
data_type: 'text'
is_nullable: 1
size: 256
=head2 upload_datetime
data_type: 'text'
default_value: current_timestamp
is_nullable: 1
size: 32
=cut
__PACKAGE__->add_columns(
"id",
{ data_type => "integer", is_auto_increment => 1, is_nullable => 0 },
"storage_id",
{ data_type => "text", is_nullable => 0, size => 64 },
"filename",
{ data_type => "text", is_nullable => 0, size => 256 },
"content_type",
{ data_type => "text", is_nullable => 1, size => 256 },
"upload_datetime",
{
data_type => "text",
default_value => \"current_timestamp",
is_nullable => 1,
size => 32,
},
);
=head1 PRIMARY KEY
=over 4
=item * L</id>
=back
=cut
__PACKAGE__->set_primary_key("id");
=head1 UNIQUE CONSTRAINTS
=head2 C<storage_id_unique>
=over 4
=item * L</storage_id>
=back
=cut
__PACKAGE__->add_unique_constraint("storage_id_unique", ["storage_id"]);
=head1 RELATIONS
=head2 media_tags
Type: has_many
Related object: L<Pooru::Schema::Result::MediaTag>
=cut
__PACKAGE__->has_many(
"media_tags",
"Pooru::Schema::Result::MediaTag",
{ "foreign.media_id" => "self.id" },
{ cascade_copy => 0, cascade_delete => 0 },
);
=head2 tags
Type: many_to_many
Composing rels: L</media_tags> -> tag
=cut
__PACKAGE__->many_to_many("tags", "media_tags", "tag");
# Created by DBIx::Class::Schema::Loader v0.07052 @ 2025-04-26 09:36:47
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ODG4pqImG0JNc1YpUIVNFA
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -0,0 +1,99 @@
use utf8;
package Pooru::Schema::Result::MediaTag;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
Pooru::Schema::Result::MediaTag
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 TABLE: C<media_tag>
=cut
__PACKAGE__->table("media_tag");
=head1 ACCESSORS
=head2 media_id
data_type: 'integer'
is_foreign_key: 1
is_nullable: 0
=head2 tag_id
data_type: 'integer'
is_foreign_key: 1
is_nullable: 0
=cut
__PACKAGE__->add_columns(
"media_id",
{ data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
"tag_id",
{ data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
);
=head1 PRIMARY KEY
=over 4
=item * L</media_id>
=item * L</tag_id>
=back
=cut
__PACKAGE__->set_primary_key("media_id", "tag_id");
=head1 RELATIONS
=head2 media
Type: belongs_to
Related object: L<Pooru::Schema::Result::Media>
=cut
__PACKAGE__->belongs_to(
"media",
"Pooru::Schema::Result::Media",
{ id => "media_id" },
{ is_deferrable => 0, on_delete => "CASCADE", on_update => "NO ACTION" },
);
=head2 tag
Type: belongs_to
Related object: L<Pooru::Schema::Result::Tag>
=cut
__PACKAGE__->belongs_to(
"tag",
"Pooru::Schema::Result::Tag",
{ id => "tag_id" },
{ is_deferrable => 0, on_delete => "CASCADE", on_update => "NO ACTION" },
);
# Created by DBIx::Class::Schema::Loader v0.07052 @ 2025-04-26 09:36:47
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:vtbD/Q3h2FEwALyG3IAaEg
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -0,0 +1,157 @@
use utf8;
package Pooru::Schema::Result::Tag;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
Pooru::Schema::Result::Tag
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 TABLE: C<tag>
=cut
__PACKAGE__->table("tag");
=head1 ACCESSORS
=head2 id
data_type: 'integer'
is_auto_increment: 1
is_nullable: 0
=head2 name
data_type: 'text'
is_nullable: 0
size: 256
=head2 kind_id
data_type: 'text'
is_foreign_key: 1
is_nullable: 1
size: 32
=head2 display
data_type: 'text '
is_nullable: 1
size: 289
=cut
__PACKAGE__->add_columns(
"id",
{ data_type => "integer", is_auto_increment => 1, is_nullable => 0 },
"name",
{ data_type => "text", is_nullable => 0, size => 256 },
"kind_id",
{ data_type => "text", is_foreign_key => 1, is_nullable => 1, size => 32 },
"display",
{ data_type => "text ", is_nullable => 1, size => 289 },
);
=head1 PRIMARY KEY
=over 4
=item * L</id>
=back
=cut
__PACKAGE__->set_primary_key("id");
=head1 UNIQUE CONSTRAINTS
=head2 C<display_unique>
=over 4
=item * L</display>
=back
=cut
__PACKAGE__->add_unique_constraint("display_unique", ["display"]);
=head2 C<name_kind_id_unique>
=over 4
=item * L</name>
=item * L</kind_id>
=back
=cut
__PACKAGE__->add_unique_constraint("name_kind_id_unique", ["name", "kind_id"]);
=head1 RELATIONS
=head2 kind
Type: belongs_to
Related object: L<Pooru::Schema::Result::Kind>
=cut
__PACKAGE__->belongs_to(
"kind",
"Pooru::Schema::Result::Kind",
{ id => "kind_id" },
{
is_deferrable => 0,
join_type => "LEFT",
on_delete => "NO ACTION",
on_update => "CASCADE",
},
);
=head2 media_tags
Type: has_many
Related object: L<Pooru::Schema::Result::MediaTag>
=cut
__PACKAGE__->has_many(
"media_tags",
"Pooru::Schema::Result::MediaTag",
{ "foreign.tag_id" => "self.id" },
{ cascade_copy => 0, cascade_delete => 0 },
);
=head2 medias
Type: many_to_many
Composing rels: L</media_tags> -> media
=cut
__PACKAGE__->many_to_many("medias", "media_tags", "media");
# Created by DBIx::Class::Schema::Loader v0.07052 @ 2025-04-26 09:36:47
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:XopZiRzgoNJuweuyFj9elA
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -0,0 +1,76 @@
use utf8;
package Pooru::Schema::Result::TagCountView;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
Pooru::Schema::Result::TagCountView
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
__PACKAGE__->table_class("DBIx::Class::ResultSource::View");
=head1 TABLE: C<tag_count_view>
=cut
__PACKAGE__->table("tag_count_view");
=head1 ACCESSORS
=head2 id
data_type: 'integer'
is_nullable: 1
=head2 name
data_type: 'text'
is_nullable: 1
size: 256
=head2 kind_id
data_type: 'text'
is_nullable: 1
size: 32
=head2 display
data_type: 'text'
is_nullable: 1
size: 289
=head2 count
data_type: (empty string)
is_nullable: 1
=cut
__PACKAGE__->add_columns(
"id",
{ data_type => "integer", is_nullable => 1 },
"name",
{ data_type => "text", is_nullable => 1, size => 256 },
"kind_id",
{ data_type => "text", is_nullable => 1, size => 32 },
"display",
{ data_type => "text", is_nullable => 1, size => 289 },
"count",
{ data_type => "", is_nullable => 1 },
);
# Created by DBIx::Class::Schema::Loader v0.07052 @ 2025-04-26 09:36:47
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Z8o5//l7dE0oSJLzDguHFw
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -0,0 +1,115 @@
use utf8;
package Pooru::Schema::Result::TaggedMediaView;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
Pooru::Schema::Result::TaggedMediaView
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
__PACKAGE__->table_class("DBIx::Class::ResultSource::View");
=head1 TABLE: C<tagged_media_view>
=cut
__PACKAGE__->table("tagged_media_view");
=head1 ACCESSORS
=head2 media_id
data_type: 'integer'
is_nullable: 1
=head2 media_storage_id
data_type: 'text'
is_nullable: 1
size: 64
=head2 media_filename
data_type: 'text'
is_nullable: 1
size: 256
=head2 media_content_type
data_type: 'text'
is_nullable: 1
size: 256
=head2 media_upload_datetime
data_type: 'text'
is_nullable: 1
size: 32
=head2 tag_id
data_type: 'integer'
is_nullable: 1
=head2 tag_name
data_type: 'text'
is_nullable: 1
size: 256
=head2 tag_kind_id
data_type: 'text'
is_nullable: 1
size: 32
=head2 tag_display
data_type: 'text'
is_nullable: 1
size: 289
=head2 tag_count
data_type: (empty string)
is_nullable: 1
=cut
__PACKAGE__->add_columns(
"media_id",
{ data_type => "integer", is_nullable => 1 },
"media_storage_id",
{ data_type => "text", is_nullable => 1, size => 64 },
"media_filename",
{ data_type => "text", is_nullable => 1, size => 256 },
"media_content_type",
{ data_type => "text", is_nullable => 1, size => 256 },
"media_upload_datetime",
{ data_type => "text", is_nullable => 1, size => 32 },
"tag_id",
{ data_type => "integer", is_nullable => 1 },
"tag_name",
{ data_type => "text", is_nullable => 1, size => 256 },
"tag_kind_id",
{ data_type => "text", is_nullable => 1, size => 32 },
"tag_display",
{ data_type => "text", is_nullable => 1, size => 289 },
"tag_count",
{ data_type => "", is_nullable => 1 },
);
# Created by DBIx::Class::Schema::Loader v0.07052 @ 2025-04-26 09:36:47
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:yihpYS6ccFg3BurhyxXqIw
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

90
lib/Pooru/Site.pm Normal file
View file

@ -0,0 +1,90 @@
package Pooru::Site;
use Mojo::Base "Mojolicious", -signatures;
use Pooru::Storage::Static;
sub extended_pager ($self, $pager)
{
my ($start, $end);
if ($pager->{current_page} < $pager->{first_page} + 2) {
$start = $pager->{first_page};
$end = $start + 4;
} elsif ($pager->{current_page} > $pager->{last_page} - 2) {
$end = $pager->{last_page};
$start = $end - 4;
} else {
$start = $pager->{current_page} - 2;
$end = $pager->{current_page} + 2;
}
return grep {$_ >= $pager->{first_page} && $_ <= $pager->{last_page}}
($start .. $end);
}
sub link_for_tag ($self, $tag)
{
my $url = $self->url_for("list_media")
->query(tags => $tag->{display});
defined($tag->{kind_id}) and
my %class = (class => "tag-kind-$tag->{kind_id}");
return $self->link_to($tag->{display} => $url => %class);
}
sub tags_by_kind ($self, $tags)
{
my @kinds = qw(Author Character Release Tags);
my %kind_id = (
Author => "a",
Character => "c",
Release => "r",
Tags => "",
);
my @tags_by_kind;
for my $kind (@kinds) {
my @ts = grep {($_->{kind_id} // "") eq $kind_id{$kind}}
$tags->@*;
push @tags_by_kind, {kind => $kind, tags => [@ts]};
}
return @tags_by_kind;
}
sub startup ($self)
{
$self->moniker("pooru-site");
my $config = $self->plugin("Config");
$self->secrets($config->{secrets});
$self->helper(storage => sub {
state $storage = Pooru::Storage::Static->
new($config->{store}, "/s/");
});
$self->helper(api_v0_url => sub {
return Mojo::URL->new($self->config("pooru_api")->{v0});
});
$self->helper(extended_pager => \&extended_pager);
$self->helper(link_for_tag => \&link_for_tag);
$self->helper(tags_by_kind => \&tags_by_kind);
$self->ua
->connect_timeout(5)
->inactivity_timeout(5)
->max_redirects(1);
my $r = $self->routes;
$r->get("/")->to("media#list")->name("list_media");
$r->get("/media/<media_id:num>")->to("media#show")->name("show_media");
$r->get("/tags")->to("tags#list")->name("list_tags");
$r->get("/random/media")->to("random#media")->name("random_media");
$r->get("/random/tag")->to("random#tag")->name("random_tag");
}
1;

View file

@ -0,0 +1,90 @@
package Pooru::Site::Controller::Media;
use Mojo::Base "Mojolicious::Controller", -signatures;
sub list ($self)
{
my $v = $self->validation;
my $page = $v->optional("page")->num(1, undef)->param // 1;
return $self->render(
status => 400
) if $v->has_error;
my $tags = $v->optional("tags")->param;
return $self->render(
status => 400
) if $v->has_error;
$self->title($tags) if defined($tags) && $tags ne "";
$self->render_later;
my $endpoint = $self->api_v0_url->path("media")
->query(page => $page, tags => $tags);
$self->ua->get($endpoint, sub ($ua, $tx) {
return $self->render(
text => "Backend error.",
status => 500,
) if $tx->error;
my $json = $tx->res->json;
my @media = map +{
show_media_url => $self->url_for("show_media",
media_id => $_->{id}),
media_src => $self->url_for_file(
$self->storage->get($_->{id})),
}, $json->{media}->@*;
$self->stash(pager => $json->{pager});
return $self->render(
template => "gallery",
media => [@media],
);
});
}
sub show ($self)
{
my $v = $self->validation;
my $page = $v->optional("page")->num(1, undef)->param // 1;
return $self->render(
status => 400
) if $v->has_error;
my $media_id = $self->stash("media_id");
$self->render_later;
my $endpoint = $self->api_v0_url->path("media/$media_id")
->query(page => $page);
$self->ua->get($endpoint, sub ($ua, $tx) {
return $self->render(
text => "Backend error.",
status => 500,
) if $tx->error;
my $json = $tx->res->json;
my @tags = map +{
name => $_->{name},
kind_id => $_->{kind_id},
display => $_->{display},
count => $_->{count},
}, $json->{tags}->@*;
$self->stash(pager => $json->{pager});
return $self->render(
template => "media",
media => {
id => $json->{id},
filename => $json->{filename},
content_type => $json->{content_type},
upload_datetime => $json->{upload_datetime},
src => $self->url_for_file(
$self->storage->get($json->{id})),
},
tags => [@tags],
);
});
}
1;

View file

@ -0,0 +1,38 @@
package Pooru::Site::Controller::Random;
use Mojo::Base "Mojolicious::Controller", -signatures;
sub media ($self)
{
$self->render_later;
my $endpoint = $self->api_v0_url->path("random/media");
$self->ua->get($endpoint, sub ($ua, $tx) {
return $self->render(
text => "Backend error.",
status => 500,
) if $tx->error;
return $self->redirect_to("show_media",
media_id => $tx->res->json->{id});
});
}
sub tag ($self)
{
$self->render_later;
my $endpoint = $self->api_v0_url->path("random/tag");
$self->ua->get($endpoint, sub ($ua, $tx) {
return $self->render(
text => "Backend error.",
status => 500,
) if $tx->error;
my @tags = $tx->res->json->{tags}->@*;
my $url = $self->url_for("list_media")
->query(tags => $tags[int(rand(@tags))]->{display});
return $self->redirect_to($url);
});
}
1;

View file

@ -0,0 +1,38 @@
package Pooru::Site::Controller::Tags;
use Mojo::Base "Mojolicious::Controller", -signatures;
sub list ($self)
{
my $v = $self->validation;
my $page = $v->optional("page")->num(1, undef)->param // 1;
return $self->render(
status => 400
) if $v->has_error;
$self->render_later;
my $endpoint = $self->api_v0_url->path("tags")->query(page => $page);
$self->ua->get($endpoint, sub ($ua, $tx) {
return $self->render(
text => "Backend error.",
status => 500,
) if $tx->error;
my $json = $tx->res->json;
my @tags = map +{
name => $_->{name},
kind_id => $_->{kind_id},
display => $_->{display},
count => $_->{count},
}, $json->{tags}->@*;
$self->stash(pager => $json->{pager});
return $self->render(
template => "tags",
tags => [@tags],
);
});
}
1;

17
lib/Pooru/Storage.pm Normal file
View file

@ -0,0 +1,17 @@
package Pooru::Storage;
use v5.40;
use Carp;
sub new ($class) {
return bless {}, $class;
}
sub get ($self, $id)
{
croak "not implemented";
}
sub put ($self, $name)
{
croak "not implemented";
}

View file

@ -0,0 +1,40 @@
package Pooru::Storage::Static;
use v5.40;
use parent "Pooru::Storage";
use Storable qw(lock_store lock_retrieve);
sub new ($class, $file, $prefix = "")
{
my $self = $class->SUPER::new;
$self->{_file} = $file;
$self->{_prefix} = $prefix;
if (-f $file) {
$self->{_store} = lock_retrieve($file);
} else {
$self->{_store} = {
_last_id => 0,
_entries => {},
};
}
return $self;
}
sub get ($self, $id)
{
my $entry = $self->{_store}->{_entries}->{$id};
return (defined($entry) and "$self->{_prefix}$entry->{name}");
}
sub put ($self, $name)
{
my $id = ++$self->{_store}->{_last_id};
$self->{_store}->{_entries}->{$id} = {
name => $name,
};
return (lock_store($self->{_store}, $self->{_file}) and $id);
}

View file

@ -0,0 +1,3 @@
DELETE FROM kind WHERE id = 'r';
DELETE FROM kind WHERE id = 'c';
DELETE FROM kind WHERE id = 'a';

3
migrations/kind_2_up.sql Normal file
View file

@ -0,0 +1,3 @@
INSERT INTO kind(id, name) VALUES ('a', 'author');
INSERT INTO kind(id, name) VALUES ('c', 'character');
INSERT INTO kind(id, name) VALUES ('r', 'release');

View file

@ -0,0 +1,7 @@
DROP VIEW IF EXISTS tagged_media_view;
DROP VIEW IF EXISTS tag_count_view;
DROP TABLE IF EXISTS media_tag;
DROP INDEX IF EXISTS tag_display_idx;
DROP TABLE IF EXISTS tag;
DROP TABLE IF EXISTS kind;
DROP TABLE IF EXISTS media;

57
migrations/pooru_1_up.sql Normal file
View file

@ -0,0 +1,57 @@
PRAGMA journal_mode = WAL;
CREATE TABLE media(
id INTEGER PRIMARY KEY,
storage_id TEXT (64) NOT NULL UNIQUE,
filename TEXT (256) NOT NULL,
content_type TEXT (256),
upload_datetime TEXT (32) DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE kind(
id TEXT (32) PRIMARY KEY,
name TEXT (256) NOT NULL UNIQUE
) WITHOUT ROWID;
CREATE TABLE tag(
id INTEGER PRIMARY KEY,
name TEXT (256) NOT NULL,
kind_id TEXT (32) REFERENCES kind(id) ON UPDATE CASCADE,
display TEXT (289) AS (concat_ws(':', kind_id, name)) STORED,
UNIQUE (name, kind_id)
);
CREATE UNIQUE INDEX tag_display_idx ON tag(display);
CREATE TABLE media_tag(
media_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE,
PRIMARY KEY (media_id, tag_id)
);
CREATE VIEW tag_count_view AS SELECT
tag.id AS id,
tag.name AS name,
tag.kind_id AS kind_id,
tag.display AS display,
COUNT(media_tag.tag_id) AS count
FROM tag
INNER JOIN media_tag ON media_tag.tag_id = tag.id
LEFT JOIN kind ON kind.id = tag.kind_id
GROUP BY tag.id;
CREATE VIEW tagged_media_view AS SELECT
media.id AS media_id,
media.storage_id AS media_storage_id,
media.filename AS media_filename,
media.content_type AS media_content_type,
media.upload_datetime AS media_upload_datetime,
tag_count_view.id AS tag_id,
tag_count_view.name AS tag_name,
tag_count_view.kind_id AS tag_kind_id,
tag_count_view.display AS tag_display,
tag_count_view.count AS tag_count
FROM media_tag
INNER JOIN media ON media.id = media_id
INNER JOIN tag_count_view ON tag_count_view.id = tag_id;

8
pooru-api-v0.conf Normal file
View file

@ -0,0 +1,8 @@
{
dsn => "dbi:SQLite:db/pooru.db",
secrets => [
# Generate with "openssl rand -hex 32".
pack("H*", "ce360aef6dbbcc5cf5e7d78eb830fe21d00281ef43f8434c9281b823423a2ac8"),
],
}

12
pooru-site.conf Normal file
View file

@ -0,0 +1,12 @@
{
pooru_api => {
v0 => "http://127.0.0.1:3000/api/v0/",
},
store => "db/pooru.storable",
secrets => [
# Generate with "openssl rand -hex 32".
pack("H*", "a4ccdf6539b6308b9d911bc9d7ab0a209cfc2c192f83606198f1ab0f41d0cdef"),
],
}

343
public/css/style.css Normal file
View file

@ -0,0 +1,343 @@
/*
* Copyright (c) 2025 Lucas Gabriel Vuotto <lucas@lgv5.net>
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
:root {
--dark-theme-bg: black;
--dark-theme-text: white;
--dark-theme-accent: royalblue;
--dark-theme-accent-hover: orange;
--dark-theme-tag-kind-a: crimson;
--dark-theme-tag-kind-c: seagreen;
--dark-theme-tag-kind-r: mediumorchid;
--light-theme-bg: white;
--light-theme-text: black;
--light-theme-accent: royalblue;
--light-theme-accent-hover: orange;
--light-theme-tag-kind-a: crimson;
--light-theme-tag-kind-c: seagreen;
--light-theme-tag-kind-r: mediumorchid;
--bg: var(--light-theme-bg);
--text: var(--light-theme-text);
--accent: var(--light-theme-accent);
--accent-hover: var(--light-theme-accent-hover);
--tag-kind-a: var(--light-theme-tag-kind-a);
--tag-kind-c: var(--light-theme-tag-kind-c);
--tag-kind-r: var(--light-theme-tag-kind-r);
--gap: 1.5rem;
--viewport-width: 60rem;
--border-thin: 0.0625rem solid var(--accent);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
dl,
h1,
h2,
h3,
ol,
p,
ul {
margin: 0;
}
body {
margin: 0 auto;
min-height: 100vh;
background-color: var(--bg);
color: var(--text);
font-family: monospace;
font-size: 1rem;
line-height: 1.5;
}
dl,
h1,
h2,
h3,
ol,
p,
ul {
margin-bottom: var(--gap);
}
h1 {
text-align: center;
font-size: 3rem;
line-height: 2;
}
h2 {
font-size: 2.5rem;
line-height: 1.2;
}
h3 {
font-size: 2rem;
line-height: 1.5;
}
dd {
margin-left: var(--gap);
}
dt {
font-weight: bold;
}
ol,
ul {
padding-left: calc(var(--gap) * 2);
}
a,
a:link,
a:visited {
color: var(--accent);
}
a:hover,
a:focus,
a:active {
color: var(--accent-hover);
}
img {
max-width: 100%;
height: auto;
}
body > header {
border-bottom: var(--border-thin);
font-size: 1.25rem;
line-height: 2.4;
}
body > header > nav {
gap: 0 var(--gap);
/* Make the navbar horizontally scrollable. Used for mobile. */
white-space: nowrap;
overflow-x: auto;
}
body > header > nav > a {
font-weight: bold;
text-decoration: none;
}
body > footer {
border-top: var(--border-thin);
}
body > footer > p {
/*
* Together they add for a single --gap, which is also the vertical rhythm
* unit.
*/
padding-top: calc(var(--gap) / 2);
margin-bottom: calc(var(--gap) / 2);
}
main {
border-top: var(--border-thin);
border-bottom: var(--border-thin);
margin-top: 0.1875rem;
margin-bottom: 0.1875rem;
padding-top: var(--gap);
padding-bottom: var(--gap);
}
.text-center {
text-align: center;
}
.layout-viewport {
margin-left: auto;
margin-right: auto;
width: var(--viewport-width);
}
.layout-flex-row {
display: flex;
flex-direction: row;
}
.layout-flex-column {
display: flex;
flex-direction: column;
}
.layout-flex-item-fullsize {
flex: auto;
}
.pager > a,
.pager > span {
display: inline-block;
padding-left: 0.5rem;
padding-right: 0.5rem;
border: var(--border-thin);
}
.pager > a {
text-decoration: none;
}
.pager > a:hover,
.pager > a:focus {
color: var(--bg);
background-color: var(--accent);
}
.pager > a:active {
color: var(--bg);
background-color: var(--accent-hover);
border-color: var(--accent-hover);
}
.pager > span {
border-color: var(--text);
}
.gallery {
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 1rem;
margin-bottom: var(--gap);
}
.gallery > a {
flex: 0 1 14rem;
padding: 0.1875rem;
line-height: 0;
}
.gallery > a:link,
.gallery > a:visited {
border: var(--border-thin);
}
.gallery > a:hover,
.gallery > a:focus,
.gallery > a:active {
border-color: var(--accent-hover);
}
.media {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
align-items: flex-start;
gap: var(--gap);
}
.media-metadata {
flex: 1;
}
.media-item {
flex: 3;
line-height: 0;
}
a.tag-kind-a:link,
a.tag-kind-a:visited {
color: var(--tag-kind-a);
}
a.tag-kind-c:link,
a.tag-kind-c:visited {
color: var(--tag-kind-c);
}
a.tag-kind-r:link,
a.tag-kind-r:visited {
color: var(--tag-kind-r);
}
a.tag-kind-a:hover,
a.tag-kind-a:focus,
a.tag-kind-a:active,
a.tag-kind-c:hover,
a.tag-kind-c:focus,
a.tag-kind-c:active,
a.tag-kind-r:hover,
a.tag-kind-r:focus,
a.tag-kind-r:active {
color: var(--accent-hover);
}
@media screen and (max-width: 60rem) {
:root {
--viewport-width: 100%;
}
body > header > nav,
body > footer,
main {
padding-left: var(--gap);
padding-right: var(--gap);
}
.media { flex-direction: column-reverse; }
.media-metadata { flex: auto; }
.media-item { flex: auto; }
}
@media screen and (min-width: 60rem) {
:root {
--viewport-width: 60rem;
}
body > header > nav,
body > footer,
main {
padding-left: 0;
padding-right: 0;
}
.media { flex-direction: row; }
.media-metadata { flex: 1; }
.media-item { flex: 3; }
}
@media screen and (min-width: 90rem) {
:root {
--viewport-width: 90rem;
}
}
@media (prefers-color-scheme: dark) {
:root {
--bg: var(--dark-theme-bg);
--text: var(--dark-theme-text);
--accent: var(--dark-theme-accent);
--accent-hover: var(--dark-theme-accent-hover);
--tag-kind-author: var(--dark-theme-tag-kind-author);
--tag-kind-character: var(--dark-theme-tag-kind-character);
--tag-kind-release: var(--dark-theme-tag-kind-release);
}
}

11
script/pooru-api-v0 Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env perl
use strict;
use warnings;
use Mojo::File qw(curfile);
use lib curfile->dirname->sibling("lib")->to_string;
use Mojolicious::Commands;
# Start command line interface for application
Mojolicious::Commands->start_app("Pooru::API::V0");

11
script/pooru-site Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env perl
use strict;
use warnings;
use Mojo::File qw(curfile);
use lib curfile->dirname->sibling("lib")->to_string;
use Mojolicious::Commands;
# Start command line interface for application
Mojolicious::Commands->start_app("Pooru::Site");

9
t/basic.t Normal file
View file

@ -0,0 +1,9 @@
use Mojo::Base -strict;
use Test::More;
use Test::Mojo;
my $t = Test::Mojo->new('Pooru::API::V0');
$t->get_ok('/')->status_is(200)->content_like(qr/Mojolicious/i);
done_testing();

24
templates/_pager.html.ep Normal file
View file

@ -0,0 +1,24 @@
% if (my $pager = stash("pager")) {
% my @pages = extended_pager($pager);
<footer>
<nav class="text-center pager">
<%= link_to "<<" => url_with->query({page => $pager->{first_page}})
if $pager->{first_page} < $pages[0] %>
<%= link_to "<" => url_with->query({page => $pager->{previous_page}})
if defined($pager->{previous_page}) &&
$pager->{previous_page} > 3 %>
% for my $page (@pages) {
% if ($page == $pager->{current_page}) {
<span><%= $pager->{current_page} %></span>
% } else {
<%= link_to $page => url_with->query({page => $page}) %>
% }
% }
<%= link_to ">" => url_with->query({page => $pager->{next_page}})
if defined($pager->{next_page}) &&
$pager->{next_page} < $pager->{last_page} - 2 %>
<%= link_to ">>" => url_with->query({page => $pager->{last_page}})
if $pager->{last_page} > $pages[-1] %>
</nav>
</footer>
% }

13
templates/gallery.html.ep Normal file
View file

@ -0,0 +1,13 @@
% layout "main";
<%= tag("h1", title) if title %>
<div class="layout-flex-row gallery">
% for my $m ($media->@*) {
<%= link_to $m->{show_media_url} => begin %>
<%= image $m->{media_src} %>
<%= end %>
% }
</div>
%= include "_pager";

View file

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
<title><%= join(" - ", "Pooru", title() // ()) %></title>
<%= stylesheet "/css/style.css" %>
</head>
<body class="layout-flex-column">
<header>
<nav class="layout-viewport layout-flex-row">
<%= link_to "Pooru~" => "/" %>
<%= link_to "tags" => url_for("list_tags") %>
<%= link_to "random tag" => url_for("random_tag") %>
<%= link_to "random media" => url_for("random_media") %>
<span class="layout-flex-item-fullsize"><!-- spacer --></span>
<%= link_to "login" => "/login" %>
</nav>
</header>
<main class="layout-flex-item-fullsize layout-flex-column">
<div class="layout-viewport layout-flex-item-fullsize layout-flex-column">
<%= content %>
</div>
</main>
<footer>
<p class="layout-viewport text-center">
Powered by <a href="//www.openbsd.org/">OpenBSD</a> /
<a href="//www.mojolicious.org/">Mojolicious</a> /
<a href="//www.haproxy.org/">HAProxy</a>.
</p>
</footer>
</body>
</html>

37
templates/media.html.ep Normal file
View file

@ -0,0 +1,37 @@
% layout "main";
<div class="layout-flex-box media">
<div class="media-metadata">
% if ($tags->@* == 0) {
<p>Non tags yet. &gt;_&lt;</p>
% } else {
<dl>
% for my $entry (tags_by_kind $tags) {
% next if $entry->{tags}->@* == 0;
<dt><%= $entry->{kind} %></dt>
% for my $tag ($entry->{tags}->@*) {
<dd><%= link_for_tag $tag %> <%= $tag->{count} %></dd>
% }
% }
</dl>
% }
<dl>
<dt>ID</dt>
<dd><%= $media->{id} %></dd>
<dt>Original filename</dt>
<dd><%= $media->{filename} %></dd>
<dt>Content-type</dt>
<dd><%= $media->{content_type} %></dd>
<dt>Upload date</dt>
<dd><%= $media->{upload_datetime} %></dd>
</dl>
</div>
<%= link_to $media->{src} =>
class => "layout-flex-item-fullsize media-item" =>
begin %>
<%= image $media->{src} %>
<% end %>
</div>

9
templates/tags.html.ep Normal file
View file

@ -0,0 +1,9 @@
% layout "main";
<p class="text-center">
% for my $tag ($tags->@*) {
<%= link_for_tag $tag %>(<%= $tag->{count} %>)
% }
</p>
%= include "_pager";