Initial import

This commit is contained in:
Lucas 2023-02-18 09:49:05 +00:00
commit 1ff831f80d
19 changed files with 956 additions and 0 deletions

0
.dancer Normal file
View File

15
bin/app.psgi Executable file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env perl
use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/../lib";
use PoorBooru;
use PoorBooru::API::V0;
use Plack::Builder;
builder {
mount "/" => PoorBooru->to_app;
mount "/api/v0" => PoorBooru::API::V0->to_app;
};

62
bin/create-dbix-class-schemas Executable file
View File

@ -0,0 +1,62 @@
#!/bin/sh
err()
{
printf "%s: %s\n" "${0##*/}" "$*" >&2
exit 1
}
warn()
{
printf "%s: %s\n" "${0##*/}" "$*" >&2
}
check_required_programs()
{
_rc=0
for _prog; do
if ! command -v "$_prog" >/dev/null; then
_rc=1
warn "$_prog: not found"
fi
done
return $_rc;
}
set -eu
check_required_programs sqlite3 dbicdump || exit 1
sqlite3 "db/PoorBooru.db" <<'_SQL'
PRAGMA foreig_keys = ON;
CREATE TABLE IF NOT EXISTS media(
media_id INTEGER PRIMARY KEY,
content BLOB (10485760) NOT NULL,
filename TEXT (255) NOT NULL,
content_type TEXT (255)
);
CREATE TABLE IF NOT EXISTS tags(
tag_id INTEGER PRIMARY KEY,
name TEXT (255) NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS media_tags(
media_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
FOREIGN KEY (media_id) REFERENCES media (media_id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags (tag_id) ON DELETE CASCADE,
PRIMARY KEY(media_id, tag_id)
);
CREATE VIEW IF NOT EXISTS tags_count_view AS SELECT
tags.tag_id AS tag_id,
tags.name AS name,
COUNT(media_tags.media_id) AS count
FROM tags
INNER JOIN media_tags USING (tag_id)
GROUP BY tag_id;
_SQL
dbicdump -o dump_directory=./lib PoorBooru::Schema dbi:SQLite:db/PoorBooru.db

68
config.yml Normal file
View File

@ -0,0 +1,68 @@
# This is the main configuration file of your Dancer2 app
# env-related settings should go to environments/$env.yml
# all the settings in this file will be loaded at Dancer's startup.
# === Basic configuration ===
# Your application's name
appname: "PoorBooru"
# The default layout to use for your application (located in
# views/layouts/main.tt)
layout: "main"
# when the charset is set to UTF-8 Dancer2 will handle for you
# all the magic of encoding and decoding. You should not care
# about unicode within your app when this setting is set (recommended).
charset: "UTF-8"
# === Engines ===
#
# NOTE: All the engine configurations need to be under a single "engines:"
# key. If you uncomment engine configurations below, make sure to delete
# all "engines:" lines except the first. Otherwise, only the last
# "engines:" block will take effect.
# template engine
# simple: default and very basic template engine
# template_toolkit: TT
template: "tiny"
# template: "template_toolkit"
# engines:
# template:
# template_toolkit:
# # Note: start_tag and end_tag are regexes
# start_tag: '<%'
# end_tag: '%>'
# session engine
#
# Simple: in-memory session store - Dancer2::Session::Simple
# YAML: session stored in YAML files - Dancer2::Session::YAML
#
# Check out metacpan for other session storage options:
# https://metacpan.org/search?q=Dancer2%3A%3ASession&search_type=modules
#
# Default value for 'cookie_name' is 'dancer.session'. If you run multiple
# Dancer apps on the same host then you will need to make sure 'cookie_name'
# is different for each app.
#
#engines:
# session:
# Simple:
# cookie_name: testapp.session
#
#engines:
# session:
# YAML:
# cookie_name: eshop.session
# is_secure: 1
# is_http_only: 1
plugins:
DBIC:
default:
dsn: "dbi:SQLite:dbname=db/PoorBooru.db"
schema_class: PoorBooru::Schema

34
cpanfile Normal file
View File

@ -0,0 +1,34 @@
requires "Dancer2" => "0.400000";
recommends "YAML" => "0";
recommends "URL::Encode::XS" => "0";
recommends "CGI::Deurl::XS" => "0";
recommends "CBOR::XS" => "0";
recommends "YAML::XS" => "0";
recommends "Class::XSAccessor" => "0";
recommends "Crypt::URandom" => "0";
recommends "HTTP::XSCookies" => "0";
recommends "HTTP::XSHeaders" => "0";
recommends "Math::Random::ISAAC::XS" => "0";
recommends "MooX::TypeTiny" => "0";
recommends "Type::Tiny::XS" => "0";
feature 'accelerate', 'Accelerate Dancer2 app performance with XS modules' => sub {
requires "URL::Encode::XS" => "0";
requires "CGI::Deurl::XS" => "0";
requires "YAML::XS" => "0";
requires "Class::XSAccessor" => "0";
requires "Cpanel::JSON::XS" => "0";
requires "Crypt::URandom" => "0";
requires "HTTP::XSCookies" => "0";
requires "HTTP::XSHeaders" => "0";
requires "Math::Random::ISAAC::XS" => "0";
requires "MooX::TypeTiny" => "0";
requires "Type::Tiny::XS" => "0";
};
on "test" => sub {
requires "Test::More" => "0";
requires "HTTP::Request::Common" => "0";
};

View File

@ -0,0 +1,22 @@
# configuration file for development environment
# the logger engine to use
# console: log messages to STDOUT (your console where you started the
# application server)
# file: log message to a file in log/
logger: "console"
# the log level for this environment
# core is the lowest, it shows Dancer2's core log messages as well as yours
# (debug, info, warning and error)
log: "core"
# should Dancer2 show a stacktrace when an 5xx error is caught?
# if set to yes, public/500.html will be ignored and either
# views/500.tt, 'error_template' template, or a default error template will be used.
show_errors: 1
# print the banner
startup_info: 1
poorbooru_api: "http://localhost:8080/api/v0"

View File

@ -0,0 +1,13 @@
# configuration file for production environment
# only log warning and error messsages
log: "warning"
# log message to a file in logs/
logger: "file"
# hide errors
show_errors: 0
# disable server tokens in production environments
no_server_tokens: 1

37
lib/PoorBooru.pm Normal file
View File

@ -0,0 +1,37 @@
package PoorBooru;
use Dancer2;
use HTTP::Tiny;
our $VERSION = v0.1;
my $POORBOORU_API = setting("poorbooru_api");
hook before_template_render => sub {
my $tokens = shift;
$tokens->{uris}->{root} = uri_for("/");
$tokens->{uris}->{login} = uri_for("/login");
$tokens->{uris}->{logout} = uri_for("/logout");
$tokens->{uris}->{random} = uri_for("/random");
$tokens->{uris}->{tags} = uri_for("/tags");
};
get "/" => sub {
template "index" => {
"title" => "main",
};
};
get "/tags" => sub {
};
get "/tag/:tag_id" => sub {
};
get "/random" => sub {
};
get "/image/:image_id" => sub {
};
true;

113
lib/PoorBooru/API/V0.pm Normal file
View File

@ -0,0 +1,113 @@
package PoorBooru::API::V0;
use Dancer2;
use Dancer2::Plugin::DBIC;
our $VERSION = v0;
set serializer => "JSON";
set database => "db/booru.db";
my $DEFAULT_CONTENT_TYPE = "application/json";
my @ROUTES = (
{ path => "/meta", verb => "GET" },
{ path => "/tags", verb => "GET" },
{ path => "/tags/new", verb => "POST" },
{ path => "/tag/:tag_id_or_name", verb => "GET" },
{ path => "/random", verb => "GET" },
{ path => "/media/:media_id", verb => "GET" },
{ path => "/download/:media_id", verb => "GET" },
);
get "/meta" => sub {
return \@ROUTES;
};
get "/tags" => sub {
my @tags = schema("default")->resultset("TagsCountView")->all;
return [
map +( {
id => $_->tag_id,
name => $_->name,
count => $_->count,
} ), @tags,
];
};
post "/tags/new" => sub {
my $tag;
eval {
$tag = schema("default")->resultset("Tag")
->create({ name => body_parameters->get("name") });
} or send_error("Tag exists", 409);
return {
id => $tag->tag_id,
name => $tag->name,
};
};
get "/tag/:tag_id_or_name" => sub {
my ($tag, $tag_rset, @media);
my $tag_id_or_name = route_parameters->get("tag_id_or_name");
$tag_rset = schema("default")->resultset("Tag");
$tag = $tag_rset->single({ tag_id => $tag_id_or_name }) //
$tag_rset->single({ name => $tag_id_or_name });
send_error("Tag not found", 404) if !defined($tag);
@media = map { $_->media_id } schema("default")->resultset("MediaTag")
->search({ tag_id => $tag->tag_id })->all;
return {
id => $tag->tag_id,
name => $tag->name,
media => [
map +( {
id => $_,
uri => uri_for("/media/$_"),
donwload_uri => uri_for("/download/$_"),
} ), @media,
],
};
};
get "/random" => sub {
my $media = schema("default")->resultset("Media")
->search({}, { order_by => \"random()", limit => 1 })->single;
send_error("Media not found", 404) if !defined($media);
forward "/media/" . $media->media_id;
};
get "/media/:media_id" => sub {
my $media = schema("default")->resultset("Media")
->single({ media_id => route_parameters->get("media_id") });
send_error("Media not found", 404) if !defined($media);
return {
id => $media->media_id,
filename => $media->filename,
size => length($media->content),
download_uri => uri_for("/download/" . $media->media_id),
};
};
get "/download/:media_id" => sub {
my $media = schema("default")->resultset("Media")
->single({ media_id => route_parameters->get("media_id") });
send_error("Media not found", 404) if !defined($media);
send_file(
\$media->content,
content_type => $media->content_type // $DEFAULT_CONTENT_TYPE,
filename => $media->filename,
);
};
true;

20
lib/PoorBooru/Schema.pm Normal file
View File

@ -0,0 +1,20 @@
use utf8;
package PoorBooru::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.07049 @ 2023-02-18 09:09:31
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:0PabNwBpp04P3y4a8O5MtA
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View File

@ -0,0 +1,108 @@
use utf8;
package PoorBooru::Schema::Result::Media;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
PoorBooru::Schema::Result::Media
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 TABLE: C<media>
=cut
__PACKAGE__->table("media");
=head1 ACCESSORS
=head2 media_id
data_type: 'integer'
is_auto_increment: 1
is_nullable: 0
=head2 content
data_type: 'blob'
is_nullable: 0
size: 10485760
=head2 filename
data_type: 'text'
is_nullable: 0
size: 255
=head2 content_type
data_type: 'text'
is_nullable: 1
size: 255
=cut
__PACKAGE__->add_columns(
"media_id",
{ data_type => "integer", is_auto_increment => 1, is_nullable => 0 },
"content",
{ data_type => "blob", is_nullable => 0, size => 10485760 },
"filename",
{ data_type => "text", is_nullable => 0, size => 255 },
"content_type",
{ data_type => "text", is_nullable => 1, size => 255 },
);
=head1 PRIMARY KEY
=over 4
=item * L</media_id>
=back
=cut
__PACKAGE__->set_primary_key("media_id");
=head1 RELATIONS
=head2 media_tags
Type: has_many
Related object: L<PoorBooru::Schema::Result::MediaTag>
=cut
__PACKAGE__->has_many(
"media_tags",
"PoorBooru::Schema::Result::MediaTag",
{ "foreign.media_id" => "self.media_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.07049 @ 2023-02-18 09:09:31
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:X0A6CSw6yWOYvxhMNRoS0g
# 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 PoorBooru::Schema::Result::MediaTag;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
PoorBooru::Schema::Result::MediaTag
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 TABLE: C<media_tags>
=cut
__PACKAGE__->table("media_tags");
=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<PoorBooru::Schema::Result::Media>
=cut
__PACKAGE__->belongs_to(
"media",
"PoorBooru::Schema::Result::Media",
{ media_id => "media_id" },
{ is_deferrable => 0, on_delete => "CASCADE", on_update => "NO ACTION" },
);
=head2 tag
Type: belongs_to
Related object: L<PoorBooru::Schema::Result::Tag>
=cut
__PACKAGE__->belongs_to(
"tag",
"PoorBooru::Schema::Result::Tag",
{ tag_id => "tag_id" },
{ is_deferrable => 0, on_delete => "CASCADE", on_update => "NO ACTION" },
);
# Created by DBIx::Class::Schema::Loader v0.07049 @ 2023-02-18 09:09:31
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:CaXiI8+eaCoDbot368Hjyw
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View File

@ -0,0 +1,106 @@
use utf8;
package PoorBooru::Schema::Result::Tag;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
PoorBooru::Schema::Result::Tag
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 TABLE: C<tags>
=cut
__PACKAGE__->table("tags");
=head1 ACCESSORS
=head2 tag_id
data_type: 'integer'
is_auto_increment: 1
is_nullable: 0
=head2 name
data_type: 'text'
is_nullable: 0
size: 255
=cut
__PACKAGE__->add_columns(
"tag_id",
{ data_type => "integer", is_auto_increment => 1, is_nullable => 0 },
"name",
{ data_type => "text", is_nullable => 0, size => 255 },
);
=head1 PRIMARY KEY
=over 4
=item * L</tag_id>
=back
=cut
__PACKAGE__->set_primary_key("tag_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 media_tags
Type: has_many
Related object: L<PoorBooru::Schema::Result::MediaTag>
=cut
__PACKAGE__->has_many(
"media_tags",
"PoorBooru::Schema::Result::MediaTag",
{ "foreign.tag_id" => "self.tag_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.07049 @ 2023-02-18 09:09:31
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:tlbIFVg6S6LAWWiR0ioHFw
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View File

@ -0,0 +1,60 @@
use utf8;
package PoorBooru::Schema::Result::TagsCountView;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
PoorBooru::Schema::Result::TagsCountView
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
__PACKAGE__->table_class("DBIx::Class::ResultSource::View");
=head1 TABLE: C<tags_count_view>
=cut
__PACKAGE__->table("tags_count_view");
=head1 ACCESSORS
=head2 tag_id
data_type: 'integer'
is_nullable: 1
=head2 name
data_type: 'text'
is_nullable: 1
size: 255
=head2 count
data_type: (empty string)
is_nullable: 1
=cut
__PACKAGE__->add_columns(
"tag_id",
{ data_type => "integer", is_nullable => 1 },
"name",
{ data_type => "text", is_nullable => 1, size => 255 },
"count",
{ data_type => "", is_nullable => 1 },
);
# Created by DBIx::Class::Schema::Loader v0.07049 @ 2023-02-18 09:09:31
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:9NUzqweyXVu/G84fxb3eew
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

150
public/css/style.css Normal file
View File

@ -0,0 +1,150 @@
/*
* PoorBooru - Poorman's booru
*
* Written in 2023 by Lucas
*
* To the extent possible under law, the author(s) have dedicated all
* copyright and related and neighboring rights to this software to the
* public domain worldwide. This software is distributed without any
* warranty.
*
* You should have received a copy of the CC0 Public Domain Dedication
* along with this software. If not, see
* <http://creativecommons.org/publicdomain/zero/1.0/>.
*/
/*
* Some parts copied from https://piccalil.li/blog/a-modern-css-reset/
*/
:root {
--bg-color: #0c0700;
--fg-color: #fff8f0;
--accent-color-0: #ec57bc;
--accent-color-1: #f7be00;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body,
h1,
p {
margin: 0;
}
body {
margin: 0 auto;
min-height: 100vh;
background-color: var(--bg-color);
color: var(--fg-color);
font-family: monospace;
line-height: 1.5;
}
h1,
p {
margin-bottom: 1.5rem;
}
footer > p {
margin-bottom: 0;
}
h1 {
text-align: center;
font-size: 4rem;
margin-bottom: 1rem;
}
a {
color: var(--accent-color-0);
}
a:link,
a:visited {
color: var(--accent-color-0);
}
a:hover,
a:active {
color: var(--accent-color-1);
}
nav {
font-size: 1.25rem;
line-height: 2.4;
}
.viewport {
margin: 0 auto;
max-width: 60rem;
}
.border-bottom {
border-bottom: 1px solid;
}
.border-top {
border-top: 1px solid;
}
.border-accent {
border-color: var(--accent-color-0);
}
.text-center {
text-align: center;
}
.bg-color {
background-color: var(--bg-color);
}
.fg-color {
color: var(--fg-color);
}
.fg-accent {
color: var(--accent-color-0);
}
.nav-link {
font-weight: bold;
text-decoration: none;
}
.nav-link:link,
.nav-link:visited {
color: var(--accent-color-0);
}
.nav-link:hover,
.nav-link:active {
color: var(--accent-color-1);
}
.nav-link-gap {
gap: 0 1rem;
}
.flex-c-horizontal {
display: flex;
}
.flex-c-vertical {
display: flex;
flex-direction: column;
}
.flex-c-center {
display: flex;
justify-content: center;
align-items: center;
}
.flex-i-fullsize {
flex: auto;
}

5
t/001_base.t Normal file
View File

@ -0,0 +1,5 @@
use strict;
use warnings;
use Test::More tests => 1;
use_ok 'PoorBooru';

16
t/002_index_route.t Normal file
View File

@ -0,0 +1,16 @@
use strict;
use warnings;
use PoorBooru;
use Test::More tests => 2;
use Plack::Test;
use HTTP::Request::Common;
use Ref::Util qw<is_coderef>;
my $app = PoorBooru->to_app;
ok( is_coderef($app), 'Got app' );
my $test = Plack::Test->create($app);
my $res = $test->request( GET '/' );
ok( $res->is_success, '[GET /] successful' );

1
views/index.tt Normal file
View File

@ -0,0 +1 @@
<h1>No content</h1>

27
views/layouts/main.tt Normal file
View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="[% settings.charset %]">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title>[% settings.appname %] - [% title %]</title>
<link rel="stylesheet" href="[% request.uri_base %]/css/style.css">
</head>
<body class="bg-default fg-default flex-c-vertical">
<header class="border-bottom border-accent">
<nav class="viewport flex-c-horizontal nav-link-gap">
<a class="nav-link" href="[% uris.root %]">[% settings.appname %]</a>
<a class="nav-link" href="[% uris.random %]">random</a>
<a class="nav-link" href="[% uris.tags %]">tags</a>
<span class="flex-i-fullsize"><!-- spacer --></span>
<a class="nav-link" href="[% uris.login %]">login</a>
</nav>
</header>
<!--<main class="viewport flex-i-fullsize flex-c-vertical flex-c-center">-->
<main class="viewport flex-i-fullsize">
[% content -%]
</main>
<footer class="text-center">
<p class="viewport">Powered by <a href="https://www.openbsd.org">OpenBSD</a> and <a href="https://perldancer.org/">Dancer2</a></p>
</footer>
</body>
</html>