diff --git a/Dockerfile b/Dockerfile index 4096522b9537a3461946a5618013f67cd91cf481..d6f1084c5b065e523550c5fb338eb006d3e4f4e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,8 @@ RUN cpanm \ Mojo::Pg \ Mojo::Redis \ Mojo::JWT \ - Mojolicious::Plugin::Authentication + Mojolicious::Plugin::Authentication \ + Mojolicious::Plugin::OpenAPI ADD . /opt/PZ WORKDIR /opt/PZ diff --git a/VERSION b/VERSION index ee1372d33a29e27945406f0527f8af8e6ee119c9..3eefcb9dd5b38e2c1dc061052455dd97bcd51e6c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.2 +1.0.0 diff --git a/lib/PZ.pm b/lib/PZ.pm index 6f92d06bb3ea991cfb3b2bbf11e677a0b26cd50c..81221fbd0b33d18ecb1f5f9e8db66d751799a32f 100644 --- a/lib/PZ.pm +++ b/lib/PZ.pm @@ -20,6 +20,7 @@ sub startup { # Delka session $self->sessions->default_expiration($cfg->{session}{lifetime}); + $self->plugin('PZ::Helpers::Core'); $self->plugin('PZ::Helpers::OIDC'); my $redis = Mojo::Redis->new( 'redis://' . $cfg->{redis}{server} ); @@ -47,7 +48,6 @@ sub startup { id => $c->session->{user}{id}, }); return $user; -# return $c->session->{user}; }, validate_user => sub { my $c = shift; @@ -56,8 +56,40 @@ sub startup { }, }); + $self->plugin("OpenAPI" => { + url => $self->home->rel_file("openapi.yaml"), + schema => 'v3', + plugins => [qw(+SpecRenderer +Cors +Security)], + render_specification => 1, + render_specification_for_paths => 1, + default_response_codes => [400, 401, 403, 404, 500, 501], + + security => { + Token => sub { + my ($c, $definition, $scopes, $cb ) = @_; + + my $token = $c->req->headers->header('X-Auth-Token'); + + return $c->$cb('Authorization header not present') if ! $token; + + my $user = $c->schema->resultset('User')->find( + { token => $token } + ); + + if (! $user ) { + return $c->$cb('Invalid user'); + } + + $c->stash->{user} = $user; + return $c->$cb(); + } + } + }); + # defautni globalni promenne ve stash - $self->defaults(); + $self->defaults( + openapi_cors_allowed_origins => ['*'], + ); # vypnuti cache templatu pri vyvoji $self->renderer->cache->max_keys(0) if $cfg->{dev_mode}; @@ -70,7 +102,7 @@ sub startup { $r->get('/')->to(cb => sub { shift->render('index'); }); $r->post('/')->to('Shortcut#create'); - $r->get('/shortcuts')->to('Shortcut#list'); + $r->get('/shortcuts')->to(cb => sub { shift->render('shortcuts'); }); $r->get('/:shortcut')->to('Shortcut#redirect'); $r->get('/:shortcut/qr.png')->to('Shortcut#qr'); diff --git a/lib/PZ/Controller/Shortcut.pm b/lib/PZ/Controller/Shortcut.pm index cfdb2336b207751ce7108c01e35fd051034d1e33..7f47f9c712775820912e4faff7c2cd2e1af31764 100644 --- a/lib/PZ/Controller/Shortcut.pm +++ b/lib/PZ/Controller/Shortcut.pm @@ -4,7 +4,22 @@ use Mojo::Base 'Mojolicious::Controller', -signatures; use Data::Validate::URI qw(is_uri); use Image::PNG::QRCode 'qrpng'; -# This action will render a template +sub redirect ($c) { + my $shortcut = $c->schema->resultset('Shortcut')->search({ + shortcut => $c->stash->{shortcut}, + is_active => 1, + deleted => undef, + })->first; + + if ( ! $shortcut ) { + $c->render( status => 404, text => 'not found' ); + return; + } + + $c->res->code($shortcut->code); + $c->redirect_to($shortcut->url); +} + sub create ($c) { my $url = $c->param('url'); @@ -34,28 +49,49 @@ sub create ($c) { } my %data = ( - user_id => $c->current_user->id, deleted => undef, url => $url, ); - my $shortcut = $c->schema->resultset('Shortcut')->search(\%data)->first; + my $shortcut = $c->current_user->shortcuts(\%data)->first; - $shortcut ||= $c->schema->resultset('Shortcut')->create({ - %data, - shortcut => $c->schema->resultset('Shortcut')->generate($custom), - }); + if ( ! $shortcut ) { + $data{shortcut} = $custom || $c->schema->resultset('Shortcut')->generate(); + $shortcut = $c->current_user->add_to_shortcuts(\%data); + } + + $c->render('shortcut/created', + url => 'https://' . $c->config->{domain} . '/' . $shortcut->shortcut, + shortcut => $shortcut + ); +} - $url = 'https://' . $c->config->{domain} . '/' . $shortcut->shortcut; - $c->render('shortcut/created', url => $url, shortcut => $shortcut ); +sub qr ($c) { + my $url = 'https://' . $c->config->{domain} . '/' . $c->stash->{shortcut}; + my $png = qrpng (text => $url, level => 4); + $c->res->headers->content_type('image/png'); + $c->render( data => $png ); } -sub redirect ($c) { - my $shortcut = $c->schema->resultset('Shortcut')->search({ - shortcut => $c->stash->{shortcut}, - is_active => 1, - deleted => undef, + +sub list ($c) { + my @shortcuts = (); + + SHORTCUT: + foreach my $shortcut ( $c->stash->{user}->shortcuts({ deleted => undef }) ) { + push @shortcuts, $c->spec_filter( + { $shortcut->get_columns }, 'Shortcut' + ); + } + + $c->render(json => \@shortcuts, ); +} + +sub update ($c) { + + my $shortcut = $c->stash->{user}->shortcuts({ + id => $c->stash->{id} })->first; if ( ! $shortcut ) { @@ -63,15 +99,30 @@ sub redirect ($c) { return; } - $c->res->code($shortcut->code); - $c->redirect_to($shortcut->url); + $shortcut->update({ + code => $c->req->json->{code}, + url => $c->req->json->{url}, + }); + + $c->render(status => 204, text => '' ); } -sub qr ($c) { - my $url = 'https://' . $c->config->{domain} . '/' . $c->stash->{shortcut}; - my $png = qrpng (text => $url, level => 4); - $c->res->headers->content_type('image/png'); - $c->render( data => $png ); +sub delete ($c) { + + my $shortcut = $c->stash->{user}->shortcuts({ + id => $c->stash->{id} + })->first; + + if ( ! $shortcut ) { + $c->render( status => 404, text => 'not found' ); + return; + } + + $shortcut->update({ + deleted => \'now()' + }); + + $c->render(status => 204, text => '' ); } 1; diff --git a/lib/PZ/Helpers/Core.pm b/lib/PZ/Helpers/Core.pm new file mode 100644 index 0000000000000000000000000000000000000000..f566d9a5169d4ca09ca91cb4bdaa5205c75c1e0e --- /dev/null +++ b/lib/PZ/Helpers/Core.pm @@ -0,0 +1,86 @@ +package PZ::Helpers::Core; + +use base 'Mojolicious::Plugin'; + +use YAML; + +sub register { + my ($class, $self ) = @_; + + $self->helper(error => sub { + my $c = shift; + my $status = shift; + my $errors = []; + + $c->cirpack_ws->rollback(); + + if ( scalar @_ == 2 ) { + $errors = [{ code => shift, message => shift }]; + } + elsif ( ref $_[0] eq 'ARRAY' ) { + $errors = shift; + } + elsif ( ref $_[0] eq 'HASH' ) { + $errors = [ shift ]; + } + else { + $errors = [{ message => shift, code => undef }]; + } + + $c->stash( + status => $status, + openapi => { errors => $errors } + ); + + return undef; + }); + + $self->helper( trace => sub { + my $c = shift; + my $data = shift // ''; + + $data = Dump $data if ref $data; + $c->app->log->debug($data); + + }); + + $self->helper( spec_filter => sub { + my $c = shift; + my $data = shift; + my $class = shift; + + if (my $def =$c->openapi->spec("/components/schemas/$class")) { + my $filtered = {}; + + KEY: + foreach my $key ( keys %{ $def->{properties} } ) { + my $value = $data->{$key}; + my $nullable = 0; + + my $types = $def->{properties}{$key}{type}; + + if ( ref $types eq 'ARRAY' ) { + TYPE: + foreach my $type ( @{ $types } ) { + $nullable = 1, last if $type eq 'null'; + } + } + + if ( $key =~ /^cirpack_/ && $value eq 'XXX' ) { + $value = $nullable ? undef : ''; + } + + $filtered->{$key} = $value; + } + + $data = $filtered; + + } + + return $data; + }); + +} + +1; + diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000000000000000000000000000000000000..78115fe46d19bba512e51fbf571cf180c95534d6 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,112 @@ +openapi: 3.0.3 + +info: + title: Piratský zkracovač + description: Piratský zkracovač API + version: 1.0.0 + license: + name: Artistic License 2.0 + url: https://www.perlfoundation.org/artistic-license-20.html + contact: + name: Andrej Ramašeuski + email: andrej@x2.cz + url: https://pardubicky.pirati.cz/lide/andrej-ramaseuski/ + +servers: + - url: https://z.pirati.cz/api + description: Production server + - url: http://127.0.0.1:3000/api + description: Test server + +components: + schemas: + Shortcut: + type: object + properties: + id: + type: integer + readOnly: true + maxLength: 6 + shortcut: + type: string + description: Zkratka + url: + type: string + description: URL pro přesměrování + maxLength: 1024 + code: + type: integer + description: Kód přesměrování + enum: [301, 302] + securitySchemes: + Token: + type: apiKey + in: header + name: X-Auth-Token + +paths: + /shortcuts: + post: + tags: + - shortcuts + security: + - Token: [] + summary: "Pridat zkratku" + operationId: ShortcutCreate + x-mojo-to: shortcut#create1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Shortcut' + responses: + 201: + description: Shortcut created + get: + tags: + - shortcuts + security: + - Token: [] + summary: "Seznam zkratek" + operationId: Shortcuts + x-mojo-to: shortcut#list + responses: + 200: + description: Seznam zkratek + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Shortcut' + + /shortcuts/{id}: + put: + tags: + - shortcuts + security: + - Token: [] + summary: "Aktualizovat zkratku" + operationId: ShortcutUpdate + x-mojo-to: shortcut#update + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Shortcut' + responses: + 204: + description: Shortcut updated + delete: + tags: + - shortcuts + security: + - Token: [] + summary: "Smazat zkratku" + operationId: ShortcutDelete + x-mojo-to: shortcut#delete + responses: + 204: + description: Shortcut deleted diff --git a/public/custom.css b/public/custom.css new file mode 100644 index 0000000000000000000000000000000000000000..dd9a28ff97a066e230bbd4097dfaeedec58ad522 --- /dev/null +++ b/public/custom.css @@ -0,0 +1,8 @@ +.jsgrid-edit-row > .jsgrid-cell, .content-block table tr:nth-child(2n).jsgrid-edit-row .jsgrid-cell, .table--striped tr:nth-child(2n).jsgrid-edit-row .jsgrid-cell { + background: #92c6ab; +} + +.jsgrid-edit-row input, .jsgrid-edit-row select, .jsgrid-edit-row textarea, .jsgrid-filter-row input, .jsgrid-filter-row select, .jsgrid-filter-row textarea, .jsgrid-insert-row input, .jsgrid-insert-row select, .jsgrid-insert-row textarea { + width: 100%; + padding: 0; +} diff --git a/sql/migrations.sql b/sql/migrations.sql index 0084d0f312b68dbafd37aa79b65d7f3ffe9ff284..eda2436e0198699aba867bcad447318eea4f1bcd 100644 --- a/sql/migrations.sql +++ b/sql/migrations.sql @@ -39,3 +39,8 @@ create table "log" ( primary key("id"), foreign key("shortcut_id") references "shortcuts" ("id") on update cascade on delete restrict ); + +-- 2 up +alter table "shortcuts" alter column "code" set default 302; +alter table "shortcuts" drop constraint "shortcuts_shortcut_key"; +create index "shortcuts_shortcut_idx" on "shortcuts" ("shortcut"); diff --git a/templates/index.html.ep b/templates/index.html.ep index 7051ee0515f03ff58c2191b7be11d00e8849d7d8..e9683040777abc31a78ff2f8e0138fae1cc8b65a 100644 --- a/templates/index.html.ep +++ b/templates/index.html.ep @@ -1,5 +1,4 @@ % layout 'default'; -% title ''; <div class="content-block"> <p> @@ -17,6 +16,6 @@ Pro použiti Pirátského zkracovače musíte se přihliásit <strong><a href="< % if ( $c->is_user_authenticated ) { <div class="content-block"> -%= include 'includes/form'; +%= include 'shortcut/form'; </div> % } diff --git a/templates/layouts/default.html.ep b/templates/layouts/default.html.ep index 84cbd301a5601ae8a1c772dbe551a80e8285aaf7..ea4edf698a8996abd222ac9283eddcef26488eb4 100644 --- a/templates/layouts/default.html.ep +++ b/templates/layouts/default.html.ep @@ -13,20 +13,87 @@ <meta name="msapplication-square310x310logo" content="<%= $c->config->{styleguide} %>images/favicons/mstile-310x310.png"> <meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"/> <meta name="theme-color" content="#000000"/> - <meta property="og:url" content="https://z.pirati.cz"/> + <meta property="og:url" content="<%= $c->config->{base_url} %>"/> <meta property="og:type" content="website"/> - <meta property="og:title" content="<%= $c->config->{name} %> | <%= title %>"/> + <meta property="og:title" content="<%= $c->config->{name} %>"/> <meta property="og:image" content="https://z.pirati.cz/img/og.png"/> <meta property="og:description" content="<%= $c->config->{description} %>"/> <meta name="description" content="<%= $c->config->{description} %>"/> - <title><%= $c->config->{name} %> | <%= title %></title> + <title><%= $c->config->{name} %></title> <link rel="manifest" href="/manifest.json"/> <link rel="stylesheet" href="<%= $c->config->{styleguide} %>css/styles.css"/> - <script src="/htmx.min.js"></script> + <script src="https://cdn-unpkg.pirati.cz/jquery@3.6.0/dist/jquery.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script> + <script src="https://cdn-unpkg.pirati.cz/htmx.org@1.7.0" integrity="sha384-EzBXYPt0/T6gxNp0nuPtLkmRpmDBbjg6WmCUZRLXBBwYYmwAUxzlSGej0ARHX0Bo" crossorigin="anonymous"></script> + + <link type="text/css" rel="stylesheet" href="https://cdn-cdnjs-cloudflare.pirati.cz/ajax/libs/jsgrid/1.5.3/jsgrid.min.css" /> + <link type="text/css" rel="stylesheet" href="https://cdn-cdnjs-cloudflare.pirati.cz/ajax/libs/jsgrid/1.5.3/jsgrid-theme.min.css" /> + <script type="text/javascript" src="https://cdn-cdnjs-cloudflare.pirati.cz/ajax/libs/jsgrid/1.5.3/jsgrid.min.js"></script> + + <link rel="stylesheet" href="/custom.css"/> + </head> <body> -%= include 'styleguide/navbar'; -<div class="container container--default py-8 lg:py-24"> +<nav class="navbar navbar--simple __js-root"> + <ui-app inline-template> + <ui-navbar inline-template> + <div> + <div class="container container--wide navbar__content" :class="{'navbar__content--initialized': true}"> + <div class="navbar__brand my-4 flex items-center lg:pr-8 lg:my-0"> + <a href="#"> + <img src="<%= $c->config->{styleguide} %>/images/logo-round-white.svg" class="w-8" /> + </a> + <span class="pl-4 font-bold text-xl lg:pr-8"><%= $c->config->{name} %></span> + </div> + <div class="navbar__menutoggle my-4 flex justify-end lg:hidden"> + <a href="#" @click="show = !show" class="no-underline hover:no-underline"> + <i class="ico--menu text-3xl"></i> + </a> + </div> + <div v-if="show || isLgScreenSize" class="navbar__main navbar__section navbar__section--expandable container-padding--zero lg:container-padding--auto flex items-center"> + <div class="flex-grow"> + +% if ( $c->is_user_authenticated ) { + <ul class="navbar-menu text-white"> + <li class="navbar-menu__item"> + <a href="/" class="navbar-menu__link">Nová zkratka</a> + </li> + <li class="navbar-menu__item"> + <a href="/shortcuts" class="navbar-menu__link">Moje zkratky</a> + </li> + </ul> +% } + + </div> + <div class="flex items-center space-x-4"> +% if ( $c->is_user_authenticated ) { + <div class="flex items-center space-x-4"> + <span class="head-heavy-2xs"><%= $c->current_user->displayname %></span> + <div class="avatar avatar--2xs"> + <img src="<%= $c->config->{piratar}%><%= $c->current_user->username %>.jpg" alt="<%= $c->current_user->displayname %>" /> + </div> + <a href="/logout"><button class="text-grey-200 hover:text-white"><i class="ico--log-out"></i></button></a> + </div> +% } else { + <a href="<%= $c->oidc->authorize %>"> + <button class="btn btn--icon btn--grey-125 btn--hoveractive"> + <div class="btn__body-wrap"> + <div class="btn__body">Přihlásit se</div> + <div class="btn__icon"> + <i class="ico--pirati"></i> + </div> + </div> + </button> + </a> +% } + </div> + </div> + </div> + </div> + </ui-navbar> + </ui-app> +</nav> + +<div class="container container--default py-8"> <section> <main> <%= content %> diff --git a/templates/includes/form.html.ep b/templates/shortcut/form.html.ep similarity index 91% rename from templates/includes/form.html.ep rename to templates/shortcut/form.html.ep index cd97686ee620766cc68580829f971e912697b957..1c84a69307f7f1d957371966125a2f58adf491a1 100644 --- a/templates/includes/form.html.ep +++ b/templates/shortcut/form.html.ep @@ -1,14 +1,13 @@ <form hx-post="/" hx-target="#response" hx-params="*"> -<div class="card elevation-4 space-y-4"> - <div class="card__body"> + <div class="card elevation-4 space-y-4 mt-2"> + <div class="card__body"> <div class="grid grid-cols-12 gap-4 row-gap-6"> <div class="form-field col-span-9"> <label class="form-field__label" for="url">URL</label> <div class="form-field__wrapper form-field__wrapper--shadowed"> <input type="text" name="url" class="text-input form-field__control" value="" placeholder="https://www.pirati.cz/program/dlouhodoby/psychotropni-latky/" required="required" /> - </div> </div> @@ -16,15 +15,14 @@ <label class="form-field__label" for="shortcut">Zkratka</label> <div class="form-field__wrapper form-field__wrapper--shadowed"> <input type="text" name="shortcut" class="text-input form-field__control w-40" size="8" maxlength="8" value="" placeholder="thc" /> - <button class="btn btn--grey-125 btn--hoveractive ml-4"> <div class="btn__body">Zkrátit</div> </button> </div> </div> -</div> -</div> + </div> + </div> </div> </form> diff --git a/templates/shortcuts.html.ep b/templates/shortcuts.html.ep new file mode 100644 index 0000000000000000000000000000000000000000..a5b98c29725a242ebec13048a5ca6e562fbfa10e --- /dev/null +++ b/templates/shortcuts.html.ep @@ -0,0 +1,94 @@ +% layout 'default'; + +<h1 class="head-alt-md md:head-alt-lg max-w-5xl mb-8">Moje zkratky</h1> + +<div id="jsGrid"></div> + +<script> + +jsGrid.validators.url = { + message: 'Zadejte platný URL', + validator: function(value, item) { + let url; + + try { + url = new URL(value); + } catch (_) { + return false; + } + + return url.protocol === "http:" || url.protocol === "https:"; + } +} + +$(function() { + $("#jsGrid").jsGrid({ + width: "100%", + height: "auto", + + inserting: false, + editing: true, + sorting: true, + paging: true, + selecting: false, + autoload: true, + invalidMessage: 'Neplatná data', + deleteConfirm: 'Opravdu chcete smazat zkratku?', + + controller: { + loadData: function(filter) { + + return $.ajax({ + url: "/api/shortcuts", + dataType: "json", + headers: { 'X-Auth-Token': '<%= $c->current_user->token %>' }, + data: filter, + }); + }, + + insertItem: function(item) { + return $.ajax({ + type: "POST", + dataType: "json", + headers: { 'X-Auth-Token': '<%= $c->current_user->token %>' }, + url: "/api/shortcuts", + data: JSON.stringify(item) + }); + }, + + updateItem: function(item) { + return $.ajax({ + type: "PUT", + dataType: "json", + headers: { 'X-Auth-Token': '<%= $c->current_user->token %>' }, + url: "/api/shortcuts/" + item.id, + data: JSON.stringify(item) + }); + }, + + deleteItem: function(item) { + return $.ajax({ + type: "DELETE", + headers: { 'X-Auth-Token': '<%= $c->current_user->token %>' }, + url: "/api/shortcuts/" + item.id, + }); + }, + + }, + + fields: [ + { name: "shortcut", title: "Zkratka", type: "text", width: 50, editing: false }, + { name: "url", title: "URL", type: "text", width: 250, validate: "url" }, + { name: "code", title: "Týp přesměrování", type: "select", width: 70, items: [ + { name: "302 dočasné", code: 302 }, + { name: "301 trvalé", code: 301 }, + ], + valueField: "code", + textField: "name" + }, + { type: "control", editButton: false, width: 30 } + ] + }); +}); +</script> +