Compare commits

...

4 Commits

34 changed files with 2492 additions and 28 deletions

View File

@ -19,6 +19,17 @@ my $build = Module::Build->new(
'Crypt::Bcrypt' => 0,
'SVG' => 0,
'JSON' => 0,
'Moo' => 0,
'Types::Standard' => 0,
'Params::ValidationCompiler' => 0,
'Crypt::URandom' => 0,
'DateTime::Format::Pg' => 0,
'Email::MIME' => 0,
'Email::Sender::Simple' => 0,
'Email::Sender::Transport::SMTP' => 0,
'List::AllUtils' => 0,
'Capture::Tiny' => 0,
},
);
$build->create_build_script;

View File

@ -9,7 +9,7 @@
<p>Para jugarlo desde Play Station necesitarás la aplicación Bedrock Together.</p>
<p>Ahora mismo estamos en <b>Beta Abierta</b>, el servidor cuando termine dicha beta costará una pequeña cantidad por persona para sufragar los gastos del servidor. (Nombre de dominio, alojamiento, desarrollo, etc.)</p>
<p>Ahora mismo estamos en <b>Beta Abierta</b>, el servidor cuando termine dicha beta costará una pequeña cantidad por persona para sufragar los gastos del servidor. (Nombre de dominio, alojamiento, desarrollo, promoción, etc.)</p>
</description>
<priority>0</priority>
<menu_text><img alt="Redland Official logo" class="index-image-menu" src="/img/redland-logo.webp"/></menu_text>

135
js/functions.js Normal file
View File

@ -0,0 +1,135 @@
"use strict";
import React, { useState, useRef } from "react";
import ReactCrop, {
centerCrop,
makeAspectCrop,
PixelCrop,
} from "react-image-crop";
import "react-image-crop/src/ReactCrop.scss";
export async function blobImgCrop(image, crop) {
const canvas = document.createElement("canvas");
canvasPreview(image, canvas, crop);
const blob = await toBlob(canvas);
if (!blob) {
console.error("Failed to create blob");
return "";
}
return blob;
}
function toBlob(canvas) {
return new Promise((resolve) => {
canvas.toBlob(resolve);
});
}
export async function canvasPreview(image, canvas, crop) {
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("No 2d context");
}
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
// devicePixelRatio slightly increases sharpness on retina devices
// at the expense of slightly slower render times and needing to
// size the image back down if you want to download/upload and be
// true to the images natural size.
const pixelRatio = window.devicePixelRatio;
// const pixelRatio = 1
canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
canvas.height = Math.floor(crop.height * scaleY * pixelRatio);
ctx.scale(pixelRatio, pixelRatio);
ctx.imageSmoothingQuality = "high";
const cropX = crop.x * scaleX;
const cropY = crop.y * scaleY;
const centerX = image.naturalWidth / 2;
const centerY = image.naturalHeight / 2;
ctx.save();
// 5) Move the crop origin to the canvas origin (0,0)
ctx.translate(-cropX, -cropY);
// 4) Move the origin to the center of the original position
ctx.translate(centerX, centerY);
// 1) Move the center of the image to the origin (0,0)
ctx.translate(-centerX, -centerY);
ctx.drawImage(
image,
0,
0,
image.naturalWidth,
image.naturalHeight,
0,
0,
image.naturalWidth,
image.naturalHeight
);
ctx.restore();
}
export function AvatarCropper(props) {
const [crop, setCrop] = React.useState({
unit: "%",
x: 10,
y: 10,
width: 80,
height: 80,
});
if (props["onStart"]) {
props["onStart"](crop);
}
return React.createElement(
ReactCrop,
{
aspect: 1,
crop: crop,
onChange: (c) => {
setCrop(c);
if (props["onChange"]) props["onChange"](c);
},
minWidth: 50,
minHeight: 50,
onComplete: (local_crop, pcrop) => {
if (props["onComplete"]) {
props["onComplete"](local_crop);
}
},
},
React.createElement("img", {
src: props.src,
onLoad: (e) => {
const { naturalWidth: width, naturalHeight: height } = e.currentTarget;
const crop_local = centerCrop(
makeAspectCrop(
{
width: 50,
height: 50,
},
1,
width,
height
),
width,
height
);
setCrop(crop_local);
console.log(props["onLoad"]);
if (props["onLoad"]) {
props["onLoad"](crop_local);
}
},
})
);
}

90
js/index.js Normal file
View File

@ -0,0 +1,90 @@
"use strict";
import React, { useState, useRef } from "react";
import * as ReactDOM from "react-dom/client";
import * as f from "/js/functions";
const avatar_cropper = document.querySelector("#avatar-cropper");
const avatar_selector = document.querySelector("#avatar-selector");
const button_select_avatar = document.querySelector("#select-avatar");
const root = ReactDOM.createRoot(avatar_cropper);
if (!window.a) {
window.a = require("/js/index");
}
export let crop;
export let img_to_crop;
let avatar_selector_on_file_lambda = () => {
if (avatar_selector.files && avatar_selector.files.length > 0) {
const reader = new FileReader();
reader.addEventListener("load", () => {
const image = new Image();
// We use the event listener load to check if it is a valid image.
// This check has to be repeated in the server of course.
image.addEventListener("load", () => {
avatar_cropper.style.display = "block";
const local_image_to_crop = document.createElement("img");
local_image_to_crop.src = reader.result?.toString();
img_to_crop = local_image_to_crop;
if (img_to_crop.width < 50 || img_to_crop.height < 50) {
alert('El avatar es demasiado pequeño, busca un imagen de al menos 50x50 pixeles.');
return true;
}
root.render(
React.createElement(f.AvatarCropper, {
src: reader.result?.toString() || "",
onStart: (c) => {
crop = c;
button_select_avatar.removeAttribute("disabled");
},
onChange: (c) => {
crop = c;
button_select_avatar.removeAttribute("disabled");
},
onLoad: (c) => {
crop = c;
button_select_avatar.removeAttribute("disabled");
},
onComplete: (c) => {
crop = c;
button_select_avatar.removeAttribute("disabled");
},
})
);
});
image.addEventListener("error", () => {
alert("No parece que esto sea una imagen.");
});
image.src = reader.result?.toString();
});
reader.readAsDataURL(avatar_selector.files[0]);
}
};
avatar_selector_on_file_lambda();
avatar_selector.addEventListener("change", avatar_selector_on_file_lambda);
button_select_avatar.addEventListener("click", () => {
if (img_to_crop.src == null || crop == null) {
console.log("Still not ready to transmit");
}
const form_data = new FormData();
f.blobImgCrop(img_to_crop, crop).then((result) => {
form_data.append('file', result);
fetch('/usuario/actualizar-avatar', {
method: 'POST',
body: form_data,
}).then((response) => {
if (response.ok) {
window.location = '/perfil';
} else {
response.json().then((object) => {
alert(object.description);
});
}
});
});
return false;
});

View File

@ -1,5 +1,8 @@
package MyRedland;
use MyRedland::Lusers;
use MyRedland::Stripe;
use MyRedland::SubscriptionOrders;
use MyRedland::Controller::Metrics;
use Mojo::Base 'Mojolicious', -signatures;
@ -21,6 +24,49 @@ sub startup ($self) {
$self->config(
hypnotoad => { proxy => 1, listen => ['http://localhost:3000'] } );
$self->secrets($self->config->{secrets});
$self->sessions->default_expiration(0);
my $luser_dao = MyRedland::Lusers->new(app => $self);
$self->helper( current_user => sub {
my $c = shift;
my $user_uuid = $c->session->{user_uuid};
if (!defined $user_uuid) {
return;
}
my $user = $luser_dao->find_by_uuid(uuid => $user_uuid);
if (!defined $user) {
delete $c->session->{user_uuid};
return;
}
return $user;
});
my $subscription_order_dao = MyRedland::SubscriptionOrders->new(app => $self);
$self->helper( current_order => sub {
my $c = shift;
my $maybe_new_order = shift;
if (defined $maybe_new_order) {
if (!$maybe_new_order->isa('MyRedland::SubscriptionOrder')) {
die "Only MyRedland::SubscriptionOrder must be passed to current_order";
}
$c->session->{subscription_order} = $maybe_new_order->uuid;
}
my $order_uuid = $c->session->{subscription_order};
if (!defined $order_uuid) {
return;
}
return $subscription_order_dao->find_by_uuid( uuid => $order_uuid );
});
my $stripe = MyRedland::Stripe->new( app => $self );
$self->helper( current_payment_intent => sub {
my $c = shift;
my $current_order = $c->current_order;
if (!defined $current_order) {
return;
}
my $payment_intent_id = $current_order->payment_intent_id;
return $stripe->retrieve_payment_intent( payment_intent_id => $payment_intent_id );
});
# Router
my $r = $self->routes;
@ -29,6 +75,24 @@ sub startup ($self) {
$r->get('/')->to('Page#index');
# $r->get('/:post')->to('Page#post');
$r->get('/perfil')->to('User#profile');
$r->get('/perfil/pago-exitoso')->to('User#payment_success_get');
$r->get('/perfil/verifica-el-correo')->to('User#mail_verify');
$r->get('/perfil/configura-tu-avatar')->to('User#setup_avatar_get');
$r->get('/perfil/opciones-de-subscripcion')->to('User#subscription_options');
$r->get('/perfil/subscribirse')->to('User#subscribe_get');
$r->get('/usuario/:username/verificacion')->to('User#user_verification');
$r->get('/usuario/avatar')->to('User#get_avatar');
$r->post('/usuario/actualizar-avatar')->to('User#setup_avatar');
$r->post('/orden-de-subscripcion/renovacion-automatica-api')->to('SubscriptionOrder#renew_auto_api');
$r->post('/orden-de-subscripcion/guardar-tarjeta-api')->to('SubscriptionOrder#save_card_api');
$r->get('/orden-de-subscripcion/estado')->to('SubscriptionOrder#get_status');
$r->get('/logout')->to('User#logout_get');
$r->post('/logout')->to('User#logout');
$r->get('/login')->to('User#login_get');
$r->post('/login')->to('User#login');
$r->get('/registrate')->to('User#sign_up_get');
$r->post('/registrate')->to('User#sign_up');
$r->get('/stats')->to('Metrics#stats');
$r->get('/<:category>.rss')->to('Page#category_rss');
$r->get('/:category')->to('Page#category');

View File

@ -0,0 +1,138 @@
package MyRedland::Controller::SubscriptionOrder;
use v5.34.1;
use strict;
use warnings;
use Mojo::Base 'Mojolicious::Controller';
use MyRedland::Stripe;
use MyRedland::SubscriptionOrders;
sub save_card_api {
my $self = shift;
my $params = $self->req->json;
my $user = $self->current_user;
my $so = $self->current_order;
my $stripe = MyRedland::Stripe->new( app => $self->app );
if ( !defined $user || !defined $so ) {
$self->render(
json => {
error =>
'No puedes hacer eso, para hacer esto debes tener un usuario loggeado y una orden de suscripción.'
},
status => 400
);
return;
}
if ( $so->user->uuid ne $user->uuid ) {
say STDERR 'Orden de subscripción no coincidente con usuario.';
$self->render(
json => { error => 'El servidor ha llegado a un estado erroneo.' },
status => 500
);
return;
}
if ( !defined $params->{new_value} ) {
$self->render(
json => { error => 'Debes pasar new_value', },
status => 400
);
return;
}
if ( !$params->{new_value} && $so->renew_auto ) {
$self->render(
json => {
error =>
'No puedes deshabilitar el guardado de tarjeta sin deshabilitar la renovación automática'
},
status => 400
);
return;
}
my $new_value = !!$params->{new_value};
my $subscription_order_dao =
MyRedland::SubscriptionOrders->new( app => $self->app );
$so->save_card( $new_value ? 1 : 0 );
$so = $subscription_order_dao->update(
subscription_order => $so,
fields => [qw/save_card/]
);
$self->render( json => { text => 'Success' } );
}
sub renew_auto_api {
my $self = shift;
my $params = $self->req->json;
my $user = $self->current_user;
my $so = $self->current_order;
my $stripe = MyRedland::Stripe->new( app => $self->app );
if ( !defined $user || !defined $so ) {
$self->render(
json => {
error =>
'No puedes hacer eso, para hacer esto debes tener un usuario loggeado y una orden de suscripción.'
},
status => 400
);
return;
}
if ( $so->user->uuid ne $user->uuid ) {
say STDERR 'Orden de subscripción no coincidente con usuario.';
$self->render(
json => { error => 'El servidor ha llegado a un estado erroneo.' },
status => 500
);
return;
}
if ( !defined $params->{new_value} ) {
$self->render(
json => { error => 'Debes pasar new_value', },
status => 400
);
return;
}
my $subscription_order_dao =
MyRedland::SubscriptionOrders->new( app => $self->app );
my $new_value = $params->{new_value};
$so->save_card(1);
$so->renew_auto( $new_value ? 1 : 0 );
$so = $subscription_order_dao->update(
subscription_order => $so,
fields => [qw/renew_auto save_card/]
);
$self->render( json => { text => 'Success' } );
}
sub get_status {
my $self = shift;
my $user = $self->current_user;
my $so = $self->current_order;
if ( !defined $user || !defined $so ) {
$self->render(
json => {
error =>
'No puedes hacer eso, para hacer esto debes tener un usuario loggeado y una orden de suscripción.'
},
status => 400
);
return;
}
if ( $so->user->uuid ne $user->uuid ) {
say STDERR 'Orden de subscripción no coincidente con usuario.';
$self->render(
json => { error => 'El servidor ha llegado a un estado erroneo.' },
status => 500
);
return;
}
$self->render(
json => {
save_card => $so->save_card,
renew_auto => $so->renew_auto
}
);
}
1;

View File

@ -0,0 +1,608 @@
package MyRedland::Controller::User;
use v5.34.1;
use strict;
use warnings;
use Mojo::Base 'Mojolicious::Controller';
use Digest::SHA qw/sha512_hex/;
use Data::Dumper;
use DateTime;
use Crypt::URandom qw/urandom/;
use Crypt::Bcrypt qw/bcrypt/;
use Capture::Tiny qw/capture/;
use MyRedland::Categories;
use MyRedland::Lusers;
use Mojo::Template;
use Mojo::URL;
use Mojo::Util qw/url_escape/;
use MyRedland::Mail;
use MyRedland::Products;
use MyRedland::Stripe;
use Path::Tiny;
my $PROJECT_ROOT = path(__FILE__)->parent->parent->parent->parent;
my $UPLOADS = $PROJECT_ROOT->child('uploads');
$UPLOADS->mkpath;
my $mt = Mojo::Template->new( auto_escape => 1 );
sub login_get {
my $self = shift;
if ( my $user = $self->current_user ) {
$self->_already_logged_in;
return;
}
my $categories = MyRedland::Categories->new->Retrieve;
$self->stash( categories => $categories );
$self->stash( current_slug => $categories->{index}{slug} );
$self->render;
}
sub setup_avatar {
my $self = shift;
my $upload = $self->req->upload('file');
my $user = $self->current_user;
if ( !defined $user ) {
$self->render(
status => 401,
json => {
status => 401,
code => 'NOTLOGGED',
description => 'No estás loggeado.',
}
);
return;
}
if ( !$user->verified ) {
$self->render(
status => 401,
json => {
status => 401,
code => 'NOTVALIDYET',
description =>
'Tu usuario no está validado, comprueba tu correo electrónico.',
}
);
return;
}
if ( !defined $upload ) {
$self->render(
status => 400,
json => {
status => 400,
code => 'NOFILE',
description =>
'No se recibió ningún fichero. (¿Usaste un multipart/form-data?)'
}
);
return;
}
my $tempdir = Path::Tiny->tempdir;
my $file = $tempdir->child('tempfile');
$upload->move_to($file);
system 'file', $file;
my ( $stdout, $stderr, $error ) = capture {
system qw/identify -format "%wx%h"/, $file;
};
if ( $error != 0 ) {
$self->render(
status => 400,
json => {
status => 400,
code => 'INVFILE',
description =>
'Archivo inválido. (El comando identify no lo reconoció)'
}
);
return;
}
my ( $width, $height ) = $stdout =~ /^"(\d+)x(\d+)"$/;
if ( $width < 50 ) {
$self->render(
status => 400,
json => {
status => 400,
code => 'SMALLFILE',
description =>
'El archivo debería tener 50x50 píxeles como mínimo.'
}
);
return;
}
if ( $width != $height ) {
$self->render(
status => 400,
json => {
status => 400,
code => 'NOTASQUARE',
description =>
'La imagen debe ser cuadrada, debe tener el mismo numero de pixeles de ancho que de alto.'
}
);
return;
}
my $converted_file = $tempdir->child('converted.png');
( $stdout, $stderr, $error ) = capture {
system 'convert', $file, $converted_file;
};
if ( $error != 0 ) {
say STDERR $stdout;
say STDERR $stderr;
$self->render(
status => 500,
json => {
status => 500,
code => 'SERVERCONVERSIONFAILED',
description =>
'El servidor no fué capaz de convertir el fichero a png, prueba a enviar otro formato.',
}
);
return;
}
my $sha512 = sha512_hex( $converted_file->slurp );
$user->avatar($sha512);
my $users_dao = MyRedland::Lusers->new( app => $self->app );
$user = $users_dao->update( user => $user, fields => [qw/avatar/] );
system 'cp', $converted_file, $UPLOADS->child($sha512);
$self->render(
status => 200,
json => {
status => 200,
code => 'SUCESS',
description => 'Your avatar was correctly setup.',
}
);
}
sub get_avatar {
my $self = shift;
my $user = $self->current_user;
if ( !defined $user ) {
$self->render( status => 401, text => 'Still not logged in.' );
return;
}
if ( !$user->avatar ) {
$self->render( status => 400, text => 'Avatar still not setup.' );
return;
}
$self->render(
format => 'png',
data => $UPLOADS->child( $user->avatar )->slurp
);
}
sub setup_avatar_get {
my $self = shift;
my $user = $self->current_user;
if ( !defined $user ) {
$self->_must_be_logged;
return;
}
if ( !$user->verified ) {
$self->_must_be_verified;
return;
}
$self->render;
return;
}
sub _must_be_verified {
my $self = shift;
$self->render(
status => 400,
text => 'Debes verificar tu correo antes de hacer eso'
);
}
sub _already_logged_in {
my $self = shift;
$self->render( text => 'Ya estás loggeado.', status => 403 );
}
sub login {
my $self = shift;
if ( my $user = $self->current_user ) {
$self->_already_logged_in;
return;
}
my $username = $self->param('username');
my $password = $self->param('password');
my $users_dao = MyRedland::Lusers->new( app => $self->app );
my $user = $users_dao->find_by_username( username => $username );
my $ip = $self->tx->remote_address;
if ( !$user ) {
say "No such user for login $username from $ip.";
$self->_invalid_login;
return;
}
if ( !$user->check_password($password) ) {
say "Invalid login attempt for @{[$user->username]} from $ip.";
$self->_invalid_login;
return;
}
$self->_create_session_for_user($user);
$self->res->headers->location('/perfil');
$self->render(
status => 302,
text => 'Exito logueandose, redirigiendo...'
);
}
sub _invalid_login {
my $self = shift;
$self->render( text => 'Login invalido.', status => 401 );
}
sub sign_up_get {
my $self = shift;
if ( my $user = $self->current_user ) {
$self->_already_logged_in;
return;
}
my $categories = MyRedland::Categories->new->Retrieve;
$self->stash( categories => $categories );
$self->stash( current_slug => $categories->{index}{slug} );
$self->render;
}
sub sign_up {
my $self = shift;
if ( my $user = $self->current_user ) {
$self->_already_logged_in;
return;
}
my $username = $self->param('username');
my $password = $self->param('password');
my $repeat_password = $self->param('repeat_password');
my $email = $self->param('email');
if ( !defined $username || length($username) < 5 ) {
$self->render(
text => 'El nombre de usuario debe ser mayor a 4 caracteres.',
status => 400
);
return;
}
if ( $username =~ /\// ) {
$self->render(
text => '"/" es un carácter prohibido en el nombre de usuario',
status => 400,
);
return;
}
if ( !defined $password
|| !defined $repeat_password
|| length $password < 10 )
{
$self->render(
text => 'La contraseña debe contener al menos 10 caracteres.',
status => 400
);
return;
}
if ( $password =~ /^\d*$/ ) {
$self->render(
text =>
'La contraseña no puede estar compuesta solo por números debido a que es extremadamente inseguro.',
status => 400
);
return;
}
if ( !defined $email || $email !~ /@.*\.\w+$/ ) {
$self->render(
text =>
'El email que has enviado no parece valido, contacta con contact@owlcode.tech desde dicho email si estás seguro que lo es.',
status => 400
);
return;
}
if ( $password ne $repeat_password ) {
$self->render(
text => 'La contraseña no coincide con la repetición.',
status => 400
);
return;
}
my $password_hash = bcrypt( $password, '2b', 12, urandom(16) );
my $users_dao = MyRedland::Lusers->new( app => $self->app );
my $user;
eval {
$user = $users_dao->create(
username => $username,
password => $password_hash,
email => $email
);
};
if ($@) {
say STDERR $@;
if ( $@ =~ /duplicate.*username/ ) {
$self->render(
text =>
'El nombre de usuario ya existe, prueba a recuperar contraseña.',
status => 400
);
return;
}
if ( $@ =~ /duplicate.*email/ ) {
$self->render(
text => 'El email ya existe, prueba a recuperar contraseña.',
status => 400
);
return;
}
$self->render(
text =>
'Error desconocido de base de datos contacta con contact@owlcode.tech.',
status => 500
);
return;
}
$self->_create_session_for_user($user);
say "Created user @{[$user->username]} with email @{[$user->email]}.";
if ( $user->verified ) {
$self->res->headers->location('/perfil/configura-tu-avatar');
}
else {
my $mailer = MyRedland::Mail->new( app => $self->app );
my $current_url = $self->req->url->to_abs;
$current_url->path('');
$current_url->query('');
my $url = Mojo::URL->new(
"${current_url}usuario/@{[url_escape($user->username)]}/verificacion"
);
$url->query( a => $user->mail_verification_payload );
my $html_email = $mt->render( <<'EOF', $url );
% my ($url) = @_;
<html>
<body>
<p>Hemos recibido una solicitud de registro en Redland Official con tu dirección de correo, si no has sido por favor ignora este email.</p>
<p>El enlace de verificación es <a href="<%= $url %>"><%= $url %></a>.</p>
</body>
</html>
EOF
my $text_email = <<"EOF";
Hemos recibido una solicitud de registro en Redland Official con tu dirección de correo, si no has sido por favor ignora este email.
El enlace de verificación es $url.
EOF
$mailer->sendmail(
to => $user->email,
subject => 'Verifica tu email.',
html => $html_email,
text => $text_email,
);
$self->res->headers->location('/perfil/verifica-el-correo');
}
$self->render( text => 'Login success.', status => 302 );
}
sub user_verification {
my $self = shift;
my $username_to_verify = $self->param('username');
my $verification_payload = $self->param('a');
my $user = $self->current_user;
if ( !defined $user ) {
my $url = Mojo::URL->new('/login');
my $current_url = $self->req->url;
$url->query( redirect => $current_url );
$self->res->headers->location($url);
$self->render( text => 'Debes loggearte antes.', status => 302 );
return;
}
if ( $user->username ne $username_to_verify ) {
$self->render(
text =>
'Has tratado de verificar una cuenta estando loggeado con otra, este incidente será reportado para futuras acciones de moderación.',
status => 403
);
say STDERR
"@{[$user->username]} attempted to verify the account $username_to_verify, possible hacking or multiaccount.";
return;
}
if ( $verification_payload ne $user->mail_verification_payload ) {
$self->render(
text => 'La url de verificación no es correcta, prueba de nuevo.',
status => 400
);
return;
}
my $current_date = DateTime->now;
if ( $current_date > $user->mail_verification_expiration ) {
$self->render(
text => 'Este enlace ya expiró, genera otro desde tu perfil',
status => 400
);
return;
}
$user->verified(1);
my $users_dao = MyRedland::Lusers->new( app => $self->app );
$user = $users_dao->update( user => $user, fields => [qw/verified/] );
say "@{[$user->username]} was verified.";
$self->res->headers->location('/perfil/configura-tu-avatar');
$self->render( text => 'Usuario verificado con exito.', status => 302 );
}
sub _create_session_for_user {
my $self = shift;
my $user = shift;
my $session = $self->session;
if ( !defined $user ) {
die "User is not defined.";
}
$session->{user_uuid} = $user->uuid;
}
sub _must_be_logged {
my $self = shift;
$self->render(
text => 'Debes estar loggeado para acceder esta parte.',
status => 401
);
}
sub logout_get {
my $self = shift;
my $user = $self->current_user;
if ( !defined $user ) {
$self->_must_be_logged;
return;
}
my $categories = MyRedland::Categories->new->Retrieve;
$self->stash( categories => $categories );
$self->stash( current_slug => $categories->{index}{slug} );
$self->render;
}
sub profile {
my $self = shift;
my $user = $self->current_user;
if ( !defined $user ) {
$self->_must_be_logged;
return;
}
my $categories = MyRedland::Categories->new->Retrieve;
$self->stash( categories => $categories );
$self->stash( current_slug => $categories->{index}{slug} );
$self->render;
}
sub logout {
my $self = shift;
my $user = $self->current_user;
if ( !defined $user ) {
$self->_must_be_logged;
return;
}
if ( defined $self->param('yes') ) {
for my $key ( keys %{ $self->session } ) {
# We do not want to store any session data after logout because of the security implications.
# We use session root keys for things such as storing the current SubscriptionOrder.
delete $self->session->{$key};
}
}
$self->res->headers->location('/');
$self->render( text => 'Action succeded', status => 302 );
}
sub subscription_options {
my $self = shift;
my $user = $self->current_user;
if ( !defined $user ) {
$self->_must_be_logged;
return;
}
if ( !$user->verified ) {
$self->_must_be_verified;
return;
}
$self->render;
}
sub subscribe_get {
my $self = shift;
my $user = $self->current_user;
if ( !defined $user ) {
$self->_must_be_logged;
return;
}
if ( !$user->verified ) {
$self->_must_be_verified;
return;
}
my $product = $self->param('product');
if ( !defined $product ) {
$self->render( text => 'Debes indicar un producto.', status => 400 );
return;
}
my $products_dao = MyRedland::Products->new;
$product = $products_dao->find_by_id( id => $product );
if ( !defined $product ) {
$self->render( text => 'No se encontró ese producto.', status => 404 );
return;
}
my $stripe = MyRedland::Stripe->new( app => $self->app );
if ( !defined $user->stripe_customer_id ) {
$user = $stripe->create_customer_for_user($user);
}
my $payment_intent = $self->current_payment_intent;
if ( $self->_check_new_so_conditions_subscription_get($product) ) {
say STDERR "Creating new SubscriptionOrder for @{[$user->username]}.";
my $subscription_orders_dao =
MyRedland::SubscriptionOrders->new( app => $self->app );
my $pi = $stripe->create_payment_intent(
user => $user,
price => $product->price,
off_session => 0
);
my $payment_intent_id = $pi->{id};
my $client_secret = $pi->{client_secret};
my $subscription_order = $subscription_orders_dao->create(
product => $product,
user => $user,
payment_intent_id => $payment_intent_id,
client_secret => $client_secret,
renew_auto => 1,
save_card => 1,
paid => 0,
to_pay => $product->price
);
$self->current_order($subscription_order);
}
my $nonce = unpack 'H*', urandom(15);
$self->res->headers->content_security_policy(
"default-src 'self' 'nonce-$nonce'; connect-src 'self' https://api.stripe.com https://maps.googleapis.com; frame-src https://js.stripe.com https://hooks.stripe.com; script-src 'self' 'nonce-$nonce' https://js.stripe.com https://maps.googleapis.com"
);
$self->render(
product => $product,
subscription_order => $self->current_order,
nonce => $nonce,
config => $self->app->config
);
}
sub _check_new_so_conditions_subscription_get {
my $self = shift;
my $product = shift;
my $payment_intent = $self->current_payment_intent;
my $user = $self->current_user;
return
!defined $self->current_order
|| $product->id ne $self->current_order->product->id
|| $user->uuid ne $self->current_order->user->uuid
|| $payment_intent->{status} eq 'succeeded';
}
sub payment_success_get {
my $self = shift;
my $current_order = $self->current_order;
if ( !defined $current_order ) {
$self->render(
text =>
'No hay ninguna orden guardada en tu navegador, esto no quiere decir que tu orden no exista, contacta con contact@owlcode.tech en caso de duda.',
status => 400
);
return;
}
my $payment_intent = $self->current_payment_intent;
if (!defined $payment_intent) {
$self->render(
text => 'No hay orden de pago, esto es un error del servidor.',
status => 500,
);
return;
}
$self->render( so => $current_order, pi => $payment_intent );
}
1;

View File

@ -25,6 +25,46 @@ sub MIGRATIONS {
path TEXT,
FOREIGN KEY (path) REFERENCES paths(path)
)',
'ALTER TABLE requests ADD PRIMARY KEY (uuid)',
'CREATE TABLE lusers (
uuid UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
verified BOOLEAN DEFAULT false,
password TEXT NOT NULL,
mail_verification_payload TEXT NOT NULL
)',
'ALTER TABLE lusers ADD COLUMN avatar TEXT DEFAULT \'\'',
'ALTER TABLE lusers ADD COLUMN mail_verification_expiration TIMESTAMP DEFAULT NOW() + interval \'1 day\'',
'ALTER TABLE lusers ADD COLUMN creation_date TIMESTAMP DEFAULT NOW()',
'ALTER TABLE lusers ADD COLUMN last_access TIMESTAMP DEFAULT NOW()',
'ALTER TABLE lusers ADD COLUMN stripe_customer_id TEXT DEFAULT NULL',
'CREATE TABLE subscription_orders (
uuid UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
product_id TEXT NOT NULL,
user_uuid UUID NOT NULL,
payment_intent_id TEXT NOT NULL,
client_secret TEXT NOT NULL,
renew_auto BOOLEAN NOT NULL,
paid BOOLEAN DEFAULT false,
create_date timestamp DEFAULT NOW(),
to_pay INTEGER NOT NULL,
FOREIGN KEY (user_uuid) REFERENCES lusers(uuid)
)',
'CREATE TABLE public_servers (
uuid UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
identifier TEXT NOT NULL UNIQUE,
name TEXT NOT NULL
)',
'INSERT INTO public_servers (name, identifier) VALUES (\'Principal Server\', \'server1\')',
'CREATE TABLE subscriptions (
public_server_uuid UUID NOT NULL,
user_uuid UUID NOT NULL,
valid_until timestamp NOT NULL,
renew_auto BOOLEAN NOT NULL,
PRIMARY KEY (public_server_uuid, user_uuid)
)',
'ALTER TABLE subscription_orders ADD COLUMN save_card BOOLEAN DEFAULT true',
);
}
1;

88
lib/MyRedland/Luser.pm Normal file
View File

@ -0,0 +1,88 @@
package MyRedland::Luser;
use v5.34.1;
use strict;
use warnings;
use Moo;
use Types::Standard qw/Maybe Str Bool InstanceOf/;
use Crypt::Bcrypt qw/bcrypt bcrypt_check/;
has uuid => (
is => 'ro',
isa => Str,
required => 0,
);
has username => (
is => 'ro',
isa => Str,
required => 1,
);
has email => (
is => 'rw',
isa => Str,
required => 1,
);
has verified => (
is => 'rw',
isa => Bool,
required => 1,
);
has password => (
is => 'rw',
isa => Str,
required => 1,
);
has mail_verification_payload => (
is => 'rw',
isa => Str,
required => 0,
);
has mail_verification_expiration => (
is => 'rw',
isa => InstanceOf['DateTime'],
required => 0,
);
has creation_date => (
is => 'ro',
isa => InstanceOf['DateTime']
);
has last_access => (
is => 'rw',
isa => InstanceOf['DateTime'],
);
has avatar => (
is => 'rw',
isa => Str,
);
has stripe_customer_id => (
is => 'rw',
isa => Maybe[Str],
);
sub check_password {
my $self = shift;
my $password = shift;
if (!defined $password) {
warn "Password not defined";
return;
}
if (!length $password) {
# Not accepting empty passwords.
return;
}
return bcrypt_check($password, $self->password);
}
1;

181
lib/MyRedland/Lusers.pm Normal file
View File

@ -0,0 +1,181 @@
package MyRedland::Lusers;
use v5.34.1;
use strict;
use warnings;
use DateTime;
use DateTime::Format::Pg;
use Moo;
use Crypt::URandom;
use Types::Standard qw/InstanceOf Str ArrayRef/;
use Params::ValidationCompiler qw/validation_for/;
use MyRedland::DB;
use MyRedland::Luser;
use List::AllUtils qw/any/;
my $fpg = DateTime::Format::Pg->new;
my @FIELDS = qw/uuid username email verified
password mail_verification_payload avatar
mail_verification_expiration creation_date
last_access stripe_customer_id/;
has app => (
is => 'rw',
isa => InstanceOf ['Mojolicious'],
required => 1,
);
has dbh => ( is => 'lazy', );
sub _build_dbh {
my $self = shift;
return MyRedland::DB->connect( $self->app );
}
{
my $validator = validation_for(
params => {
username => { type => Str },
email => { type => Str },
password => { type => Str },
}
);
sub create {
my $self = shift;
my %params = $validator->(@_);
my $dbh = $self->dbh;
my $username = $params{username};
my $email = $params{email};
my $password = $params{password};
my $test_mode = $self->app->config->{test_mode};
my $verified = 0;
if ($test_mode) {
$verified = 1;
}
my $mail_verification_payload = unpack 'H*',
Crypt::URandom::urandom(30);
my $avatar = '';
my $mail_verification_expiration =
$fpg->format_datetime( DateTime->now->add( days => 1 ) );
my $user_hash = $dbh->selectrow_hashref(
<<"EOF", undef, $username, $email, $verified, $password, $mail_verification_payload, $avatar, $mail_verification_expiration );
INSERT INTO lusers
(username, email, verified, password,
mail_verification_payload, avatar,
mail_verification_expiration)
VALUES
(?, ?, ?, ?, ?, ?, ?)
RETURNING @{[join ', ', @FIELDS]};
EOF
$self->_convert_user_hash_dates($user_hash);
my $user = MyRedland::Luser->new(%$user_hash);
return $user;
}
}
{
my $validator = validation_for(
params => {
user => { type => InstanceOf ['MyRedland::Luser'] },
fields => { type => ArrayRef [Str] },
}
);
sub update {
my $self = shift;
my %params = $validator->(@_);
my $user = $params{user};
my $fields = $params{fields};
my %updates;
for my $field (@$fields) {
if ( any { $field eq $_ } qw/verified email password mail_verification_expiration mail_verification_payload last_access avatar stripe_customer_id/) {
$updates{$field} = $user->$field;
next;
}
die "No such field $field.";
}
my $query = <<"EOF";
UPDATE lusers
SET @{[
join ', ', map { "$_ = ?" } @$fields
]} WHERE uuid = ?
RETURNING @{[join ', ', @FIELDS]};
EOF
my $dbh = $self->dbh;
my $user_hash = $dbh->selectrow_hashref($query, undef, @updates{@$fields}, $user->uuid);
$self->_convert_user_hash_dates($user_hash);
$user = MyRedland::Luser->new(%$user_hash);
return $user;
}
}
sub _convert_user_hash_dates {
my $self = shift;
my $user_hash = shift;
$user_hash->{mail_verification_expiration} =
$fpg->parse_datetime( $user_hash->{mail_verification_expiration} );
$user_hash->{creation_date} =
$fpg->parse_datetime( $user_hash->{creation_date} );
$user_hash->{last_access} =
$fpg->parse_datetime( $user_hash->{last_access} );
}
{
my $validator = validation_for(
params => {
uuid => { type => Str },
}
);
sub find_by_uuid {
my $self = shift;
my %params = $validator->(@_);
my $uuid = $params{uuid};
my $dbh = $self->dbh;
my $user_hash = $dbh->selectrow_hashref( <<"EOF", undef, $uuid );
SELECT @{[join ', ', @FIELDS]} FROM lusers where uuid = ?;
EOF
if ( !defined $user_hash ) {
return;
}
$self->_convert_user_hash_dates($user_hash);
my $user = MyRedland::Luser->new(%$user_hash);
return $user;
}
}
{
my $validator = validation_for(
params => {
username => { type => Str },
}
);
sub find_by_username {
my $self = shift;
my %params = $validator->(@_);
my $username = $params{username};
my $dbh = $self->dbh;
my $user_hash = $dbh->selectrow_hashref( <<"EOF", undef, $username );
SELECT @{[join ', ', @FIELDS]} FROM lusers where username = ?;
EOF
if ( !defined $user_hash ) {
return;
}
$self->_convert_user_hash_dates($user_hash);
my $user = MyRedland::Luser->new(%$user_hash);
return $user;
}
}
1;

117
lib/MyRedland/Mail.pm Normal file
View File

@ -0,0 +1,117 @@
package MyRedland::Mail;
use v5.34.1;
use strict;
use warnings;
use Mojolicious;
use Encode qw/decode/;
use Moo;
use Types::Standard qw/InstanceOf Str/;
use Params::ValidationCompiler qw/validation_for/;
use Email::MIME;
use Email::Sender::Simple;
use Email::Sender::Transport::SMTP;
has app => (
is => 'ro',
isa => InstanceOf ['Mojolicious'],
);
has _config => ( is => 'lazy', );
has _transport => ( is => 'lazy', );
sub _build__config {
my $self = shift;
my $app = $self->app;
if ( defined $app->config->{smtp} ) {
return $app->config->{smtp};
}
return;
}
sub _build__transport {
my $self = shift;
my $smtp_config = $self->_config;
if ( !$smtp_config ) {
die "Unable to build transport due the lack of related config.";
}
my %options = %$smtp_config;
delete $options{From};
my $tranport = Email::Sender::Transport::SMTP->new(%$smtp_config);
return $tranport;
}
{
my $validator = validation_for(
params => {
text => { type => Str },
html => { type => Str },
to => { type => Str },
subject => { type => Str },
}
);
sub sendmail {
my $self = shift;
my %params = $validator->(@_);
my $transport = $self->_transport;
my $from = $self->_config->{From};
my ( $text, $html, $to, $subject ) = @params{qw/text html to subject/};
my @parts = (
Email::MIME->create(
attributes => {
charset => 'UTF-8',
content_type => 'text/plain',
encoding => 'base64',
disposition => 'inline',
},
body_str => $text,
),
Email::MIME->create(
attributes => {
charset => 'UTF-8',
content_type => 'text/html',
encoding => 'base64',
disposition => 'inline',
},
body_str => $html,
),
);
my $email = Email::MIME->create(
header_str => [
From => $from,
to => $to,
subject => $subject,
],
attributes => {
encoding => 'base64',
content_type => 'multipart/mixed',
},
parts => [
Email::MIME->create(
attributes => {
content_type => 'multipart/alternative',
encoding => 'base64',
},
parts => [ @parts, ],
)
]
);
eval {
Email::Sender::Simple->send( $email, { transport => $self->_transport });
};
if ($@) {
warn $@;
return 0;
}
return 1;
}
}
1;

View File

@ -20,6 +20,7 @@ const my $CURRENT_FILE => __FILE__;
const my $ROOT_PROJECT => path($CURRENT_FILE)->parent->parent->parent;
const my $PUBLIC_DIR => $ROOT_PROJECT->child('public');
const my $POSTS_DIR => $ROOT_PROJECT->child('content/posts');
$POSTS_DIR->mkpath;
const my $BURGUILLOS_LOGO => $PUBLIC_DIR->child('img/burguillos.png');
const my $SVG_WIDTH => 1200;
const my $SVG_HEIGHT => 627;

40
lib/MyRedland/Product.pm Normal file
View File

@ -0,0 +1,40 @@
package MyRedland::Product;
use v5.34.1;
use strict;
use warnings;
use Moo;
use Types::Standard qw/Str Int/;
has name => (
is => 'ro',
isa => Str,
required => 1,
);
has id => (
is => 'ro',
isa => Str,
required => 1,
);
has description => (
is => 'ro',
isa => Str,
required => 1,
);
has price => (
is => 'ro',
isa => Int,
required => 1,
);
has period => (
is => 'ro',
isa => Str,
required => 1,
);
1;

72
lib/MyRedland/Products.pm Normal file
View File

@ -0,0 +1,72 @@
package MyRedland::Products;
use v5.34.1;
use strict;
use warnings;
use utf8;
use MyRedland::Product;
use Moo;
use Types::Standard qw/Str Int/;
use Params::ValidationCompiler qw/validation_for/;
has all => (
is => 'lazy'
);
has _all_by_id => (
is => 'lazy'
);
sub _build_all {
my $self = shift;
return [
MyRedland::Product->new(
id => 'MONTH',
name => 'Pago mensual',
description => 'Paga mes a mes tu subscripción al servidor principal.',
price => 300,
period => 'mes',
),
MyRedland::Product->new(
id => 'PAIRMONTHLY',
name => 'Pago mensual en pareja',
description => 'Paga mes a mes tu suscripción y la de otra persona, os ahorráis 1€/mes entre los dos.',
price => 500,
period => 'mes',
),
MyRedland::Product->new(
id => 'YEAR',
name => 'Pago anual',
description => 'Paga anualmente tu subscripción al servidor principal, pagas 1.92€/m, 1.08€/m menos, te ahorras 13€/año.',
price => 2300,
period => 'año',
),
];
}
sub _build__all_by_id {
my $self = shift;
my $all = $self->all;
return {
(map { $_->id => $_ } @$all),
};
}
{
my $validator = validation_for(
params => {
id => { type => Str },
}
);
sub find_by_id {
my $self = shift;
my %params = $validator->(@_);
my $id = $params{id};
return $self->_all_by_id->{$id};
}
}
1;

182
lib/MyRedland/Stripe.pm Normal file
View File

@ -0,0 +1,182 @@
package MyRedland::Stripe;
use v5.34.1;
use strict;
use warnings;
use MyRedland::DB;
use MyRedland::Lusers;
use Mojo::UserAgent;
use Moo;
use Params::ValidationCompiler qw/validation_for/;
use Types::Standard qw/Str InstanceOf HashRef Int Bool/;
use MIME::Base64;
use Data::Dumper;
use Mojo::Util qw/url_escape/;
my $ua = Mojo::UserAgent->new;
has app => (
is => 'ro',
isa => InstanceOf ['Mojolicious'],
);
has _dbh => ( is => 'lazy', );
has _stripe_secret => ( is => 'lazy', );
has _users_dao => ( is => 'lazy', );
sub _build__dbh {
my $self = shift;
return MyRedland::DB->new( app => $self->app );
}
sub _build__stripe_secret {
my $self = shift;
return $self->app->config->{stripe_secret};
}
sub _build__users_dao {
my $self = shift;
return MyRedland::Lusers->new( app => $self->app );
}
{
my $validator = validation_for(
params => {
method => { type => Str },
url => { type => Str },
form => { type => HashRef, optional => 1 },
headers => { type => HashRef, optional => 1 },
}
);
sub _make_request {
my $self = shift;
my %params = $validator->(@_);
my $method = $params{method};
my $url = $params{url};
my $headers = $params{headers} // {};
$headers->{Authorization} =
'Basic ' . encode_base64( $self->_stripe_secret . ':', '' );
my $form = $params{form};
my $tx = $ua->build_tx( $method, $url, $headers,
( ( defined $form ) ? ( form => $form ) : () ) );
return $ua->start($tx);
}
}
sub create_customer_for_user {
my $self = shift;
my $user = shift;
if ( defined $user->stripe_customer_id ) {
return $user;
}
my $response = $self->_make_request(
method => 'POST',
url => 'https://api.stripe.com/v1/customers'
)->result->json;
$user->stripe_customer_id( $response->{id} );
$user = $self->_users_dao->update(
user => $user,
fields => [qw/stripe_customer_id/]
);
return $user;
}
{
my $validator = validation_for(
params => {
user => {
type => InstanceOf ['MyRedland::Luser'],
},
price => {
type => Int,
},
off_session => {
type => Bool
},
}
);
sub create_payment_intent {
my $self = shift;
my %params = $validator->(@_);
my ( $user, $price, $off_session ) =
@params{qw/user price off_session/};
my $response = $self->_make_request(
method => 'POST',
url => 'https://api.stripe.com/v1/payment_intents',
form => {
currency => 'eur',
amount => $price,
'payment_method_types[]' => 'card',
customer => $user->stripe_customer_id,
( ($off_session) ? ( off_session => $off_session ) : () ),
}
)->result->json;
if ( defined $response->{error} ) {
die Data::Dumper::Dumper $response->{error};
}
return $response;
}
}
{
my $validator = validation_for(
params => {
payment_intent_id => {
type => Str
}
}
);
sub retrieve_payment_intent {
my $self = shift;
my %params = $validator->(@_);
my ($payment_intent_id) = $params{'payment_intent_id'};
my $response = $self->_make_request(
method => 'GET',
url => 'https://api.stripe.com/v1/payment_intents/'.url_escape($payment_intent_id),
)->result->json;
if ( defined $response->{error} ) {
die Data::Dumper::Dumper $response->{error};
}
return $response;
}
}
{
my $validator = validation_for(
params => {
payment_intent_id => {
type => Str
},
updates => {
type => HashRef
}
}
);
sub update_payment_intent {
my $self = shift;
my %params = $validator->(@_);
my ($payment_intent_id, $updates) = @params{qw/payment_intent_id updates/};
my $response = $self->_make_request(
method => 'POST',
url => 'https://api.stripe.com/v1/payment_intents/'.url_escape($payment_intent_id),
form => $updates,
)->result->json;
print Data::Dumper::Dumper $response;
if ( defined $response->{error} ) {
die Data::Dumper::Dumper $response->{error};
}
return $response;
}
}
1;

View File

@ -0,0 +1,56 @@
package MyRedland::SubscriptionOrder;
use v5.34.1;
use strict;
use warnings;
use Moo;
use Types::Standard qw/Str Bool InstanceOf Int/;
has uuid => (
is => 'ro',
isa => Str,
);
has product => (
is => 'ro',
isa => InstanceOf['MyRedland::Product'],
);
has user => (
is => 'ro',
isa => InstanceOf['MyRedland::Luser'],
);
has payment_intent_id => (
is => 'ro',
isa => Str,
);
has client_secret => (
is => 'ro',
isa => Str,
);
has renew_auto => (
is => 'rw',
isa => Bool,
);
has paid => (
is => 'rw',
isa => Bool,
);
has to_pay => (
is => 'ro',
isa => Int,
);
has create_date => (
is => 'ro',
isa => InstanceOf['DateTime'],
);
has save_card => (
is => 'rw',
isa => Bool,
);
1;

View File

@ -0,0 +1,146 @@
package MyRedland::SubscriptionOrders;
use v5.34.1;
use strict;
use warnings;
use Moo;
use Types::Standard qw/Str Bool InstanceOf ArrayRef Int/;
use Params::ValidationCompiler qw/validation_for/;
use DateTime::Format::Pg;
use Data::Dumper;
use List::AllUtils qw/any/;
use MyRedland::DB;
use MyRedland::Lusers;
use MyRedland::Products;
use MyRedland::SubscriptionOrder;
has app => (
is => 'ro',
isa => InstanceOf['Mojolicious'],
);
my $fpg = DateTime::Format::Pg->new;
my @FIELDS = qw/uuid product_id user_uuid payment_intent_id client_secret renew_auto paid create_date to_pay save_card/;
has dbh => ( is => 'lazy', );
sub _build_dbh {
my $self = shift;
return MyRedland::DB->connect( $self->app );
}
{
my $validator = validation_for(
params => {
product => { type => InstanceOf['MyRedland::Product'] },
user => { type => InstanceOf['MyRedland::Luser'] },
payment_intent_id => { type => Str },
client_secret => { type => Str },
renew_auto => { type => Bool },
save_card => { type => Bool },
paid => { type => Bool },
to_pay => { type => Int },
}
);
sub create {
my $self = shift;
my %params = $validator->(@_);
my $dbh = $self->dbh;
my ($product, $user, $payment_intent_id, $client_secret, $renew_auto, $paid, $to_pay, $save_card) = @params{qw/product user payment_intent_id client_secret
renew_auto paid to_pay save_card/};
my $returning_hash = $dbh->selectrow_hashref(
<<"EOF", undef, $product->id, $user->uuid, $client_secret, $renew_auto, $paid, $to_pay, $payment_intent_id, $save_card);
INSERT INTO subscription_orders
(product_id, user_uuid, client_secret, renew_auto,
paid, to_pay, payment_intent_id, save_card)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?)
RETURNING @{[join ', ', @FIELDS]};
EOF
return $self->_convert_hash_to_object($returning_hash);
}
}
sub _convert_hash_to_object {
my $self = shift;
my $hash = shift;
my $users_dao = MyRedland::Lusers->new( app => $self->app );
my $products_dao = MyRedland::Products->new;
my $product_id = delete $hash->{product_id};
my $user_uuid = delete $hash->{user_uuid};
my $create_date = delete $hash->{create_date};
$hash->{create_date} = $fpg->parse_datetime($create_date);
if (!defined $product_id) {
# This should not happen.
die 'No product.';
}
if (!defined $user_uuid) {
# This should not happen.
die 'No user';
}
$hash->{user} = $users_dao->find_by_uuid(uuid => $user_uuid);
$hash->{product} = $products_dao->find_by_id(id => $product_id);
return MyRedland::SubscriptionOrder->new(%$hash);
}
{
my $validator = validation_for(
params => {
subscription_order => { type => InstanceOf ['MyRedland::SubscriptionOrder'] },
fields => { type => ArrayRef [Str] },
}
);
sub update {
my $self = shift;
my %params = $validator->(@_);
my $subscription_order = $params{subscription_order};
my $fields = $params{fields};
my %updates;
for my $field (@$fields) {
if ( any { $field eq $_ } qw/renew_auto paid save_card/ ) {
$updates{$field} = $subscription_order->$field;
next;
}
die "No such field $field.";
}
my $query = <<"EOF";
UPDATE subscription_orders
SET @{[
join ', ', map { "$_ = ?" } @$fields
]} WHERE uuid = ?
RETURNING @{[join ', ', @FIELDS]};
EOF
my $dbh = $self->dbh;
my $hash = $dbh->selectrow_hashref($query, undef, @updates{@$fields}, $subscription_order->uuid);
return $self->_convert_hash_to_object($hash);
}
}
{
my $validator = validation_for(
params => {
uuid => { type => Str },
}
);
sub find_by_uuid {
my $self = shift;
my %params = $validator->(@_);
my $uuid = $params{uuid};
my $dbh = $self->dbh;
my $hash = $dbh->selectrow_hashref( <<"EOF", undef, $uuid );
SELECT @{[join ', ', @FIELDS]} FROM subscription_orders where uuid = ?;
EOF
if ( !defined $hash ) {
return;
}
return $self->_convert_hash_to_object($hash);
}
}
1;

View File

@ -3,5 +3,15 @@
"bcrypt_pass_stats": ["change_for_bcrypted_password"],
"db": {
"database": "example"
},
"stripe_secret": "secret",
"stripe_public": "public",
"test_mode": 1,
"smtp": {
"ssl": 1,
"hosts": [ "example.com" ],
"sasl_username": "exampleuser@example.com",
"sasl_password": "[redacted]",
"From": "Example User <exampleuser@example.com>"
}
}

59
package.json Normal file
View File

@ -0,0 +1,59 @@
{
"name": "myredland",
"version": "0.0.1",
"description": "Frontend for MyRedland",
"private": true,
"directories": {
"lib": "js"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"repository": {
"type": "git",
"url": "git@git.owlcode.tech:sergiotarxz/MyRedland.git"
},
"author": "Sergio Iglesias",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"babel-cli": "^6.26.0",
"babel-preset-react-app": "^3.1.2",
"browserify": "^17.0.0",
"react-image-crop": "^10.0.9",
"react-scripts": "5.0.1",
"sass": "^1.56.2",
"web-vitals": "^2.1.4"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"css-loader": "^6.7.3",
"prettier": "^2.8.1",
"sass-loader": "^13.2.0",
"style-loader": "^3.3.1",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.1"
}
}

View File

@ -9,6 +9,22 @@ $color-secondary: #8eea6d;
$accent-secondary: #fde68f;
$primary-secondary: #590e11;
#payment-form {
background: $color_div;
color: $background_div;
padding: 10px;
div.submit-div {
display: flex;
width: 100%;
justify-content: center;
button {
background: $background_div;
font-size: 19px;
border: none;
border-radius: 4px;
}
}
}
html {
height: 100%;
}
@ -34,6 +50,36 @@ body {
background-size: cover;
overflow: hidden;
}
div.divider {
display: flex;
flex-direction: row;
height: 100%;
div.side-menu {
width: 30%;
background: $color_div;
a {
display: inline-flex;
min-height: 60px;
justify-content: center;
text-align: center;
vertical-align: middle;
flex-direction: column;
width: 100%;
font-size: 24px;
color: #DC143C;
border-bottom: #36454F 3px solid;
text-decoration: none;
background: $color_div;
&:hover {
color: $color_div;
background: $background_div;
}
}
}
div.main-page-contents {
width: 100%;
}
}
div.page-contents {
position: absolute;
position: fixed;
@ -69,6 +115,9 @@ body {
b,p,li {
font-size: 40px;
}
small {
font-size: 20px;
}
h3 {
font-size: 47px;
}
@ -79,6 +128,29 @@ body {
font-size: 60px;
}
div.description {
div.subscription-options {
display: flex;
flex-direction: row;
div.product {
width: 44%;
margin-right: 3%;
color: $background_div;
background: $color_div;
padding: 30px;
padding-top: 10px;
p {
font-size: 20px;
}
a.subscribe-button {
padding: 10px;
background: $background_div;
color: $color_div;
border-radius: 3px;
text-decoration: none;
}
}
}
input {
display: block;
}
@ -175,11 +247,38 @@ body {
width: 100%;
display: none;
a {
height: 100%;
vertical-align: middle;
background: $background_div;
}
}
nav {
a.common-user-button {
float: right;
display: block;
margin-top: 3px;
margin-right: 10px;
border-radius: 50%;
background: $background-page;
}
a.login-button {
padding-top: 10px;
padding-bottom: 10px;
img {
width: 30px;
}
}
a.profile-button {
height: 50px;
width: 50px;
padding-left: 0px;
padding-right: 0px;
img {
width: 50px;
height: 50px;
border-radius: 50%;
}
}
overflow: auto;
display: block;
font-size: 35px;
@ -273,7 +372,8 @@ body {
}
nav.desktop {
max-height: 60px;
display: block;
display: flex;
justify-content: space-between;
height: auto;
a {
display: table-cell;

BIN
public/img/menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

5
public/img/person.svg Normal file
View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16" class="svg octicon-person" width="16" height="16" aria-hidden="true">
<path fill-rule="evenodd" d="M10.5 5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0zm.061 3.073a4 4 0 1 0-5.123 0 6.004 6.004 0 0 0-3.431 5.142.75.75 0 0 0 1.498.07 4.5 4.5 0 0 1 8.99 0 .75.75 0 1 0 1.498-.07 6.005 6.005 0 0 0-3.432-5.142z"></path>
</svg>

After

Width:  |  Height:  |  Size: 571 B

View File

@ -1,3 +1,11 @@
% my $categories = stash 'categories';
% if (!defined $categories) {
% $categories = MyRedland::Categories->new->Retrieve;
% }
% my $current_slug = stash 'current_slug';
% if (!defined $current_slug) {
% $current_slug = $categories->{index}{slug};
% }
<!DOCTYPE html>
<html lang="es">
<head>
@ -20,20 +28,28 @@
<div class="site-wrapper">
</div>
<div class="page-contents">
<nav class="desktop"><% for my $category_key (sort {
<nav class="desktop"><div><% for my $category_key (sort {
$categories->{$a}{priority} <=> $categories->{$b}{priority}
} keys %$categories) {
my $category = $categories->{$category_key};
my $selected = defined($current_slug) && $category->{slug} eq $current_slug;
%><a class="<%=$selected && "selected" %>" href="<%= '/'.$category->{slug} %>"><%==$category->{menu_text}%></a><%
}
%></nav>
<nav class="mobile-shortcuts">
%></div><%
if ($self->current_user) {
if ($self->current_user->avatar) {
%><a class="common-user-button profile-button" href="/perfil"><img alt="Tu avatar" src="/usuario/avatar"/></a><%
} else {
%><a class="common-user-button login-button" href="/perfil"><img alt="Icono de login" src="/img/person.svg"/></a><%
}
} else {
%><a class="common-user-button login-button" href="/login"><img alt="Icono de login" src="/img/person.svg"/></a><%
}
%></nav><nav class="mobile-shortcuts">
<a class="go-to-index" href="<%='/'.$categories->{index}{slug}%>"><%== $categories->{index}{menu_text} %></a>
<div></div>
<a href="#mobile-foldable" class="menu-expand"><img class="open-menu-icon" src="/img/menu.png" alt="Expandir el menú."/></a>
</nav>
<nav class="mobile-foldable" id="mobile-foldable"><%
</nav><nav class="mobile-foldable" id="mobile-foldable"><%
my $first = 1;
for my $category_key (sort {
$categories->{$a}{priority} <=> $categories->{$b}{priority}
@ -41,9 +57,14 @@
my $category = $categories->{$category_key};
%><a href="<%= '/'.$category->{slug} %>"><%==$category->{menu_text}%></a><%
}
%></nav>
<%= content %></body>
%></nav><div class="divider">
% if (stash 'side_menu') {
<div class="side-menu">
<%= content 'side_menu' %>
</div>
% }
<div class="main-page-contents">
<%= content %>
<hr/>
<div class="footer description">
<p>©2022 Sergio Iglesias</p>
@ -53,4 +74,7 @@
</a>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,8 @@
% layout 'default';
% stash side_menu => 1;
% content_for 'side_menu' => begin
<a href="/perfil">Mi perfil</a>
<a href="/perfil/configura-tu-avatar">Avatar</a>
<a href="/perfil/opciones-de-subscripcion">Opciones de subscripción</a>
% end
<%= content %>

View File

@ -0,0 +1,15 @@
% use MyRedland::Posts;
%
% layout 'default';
% title 'Login Redland Official';
<div class="description">
<h2>Página de Login.</h2>
<form action="/login" method="post">
Nombre de usuario
<input placeholder="Nombre de usuario" type="text" name="username"/>
Contraseña
<input placeholder="Contraseña" type="password" name="password"/>
<input type="submit" value="Enviar"/>
</form>
<p><small>¿No tienes cuenta? <a href="/registrate">Registrate</a></small></p>
</div>

View File

@ -0,0 +1,10 @@
% layout 'default';
% title 'Logout - Redland Official';
<div class="description">
<h2>¿Seguro que deseas cerrar la sesión?</h2>
<form action="/logout" method="post">
<input type="submit" value="Sí, deseo cerrar la sesión." name="yes"/>
<input type="submit" value="No, sacame de aquí." name="no"/>
</form>
</div>

View File

@ -0,0 +1,11 @@
% layout 'default';
% title 'Verifica el correo Redland Official';
<div class="description">
<h2>Verifica tu correo.</h2>
<p>Te hemos enviado un correo electrónico, pulsa sobre el enlace para verificar tu correo.</p>
<p>Recuerda que tendrás que estar logueado en la página para poder confirmar tu correo, nada más terminar el registro te logueamos automáticamente, pero es posible que al cerrar el navegador tu sesión sea terminada y tengas que loguearte de nuevo.</p>
<p>También es posible que quieras confirmar tu correo desde otro dispositivo, para ello tendrás que loguearte en ese otro dispositivo en redlandofficial.com, si no no podremos verificar que realmente eres tú quien pulsa el enlace, otra opción es reenviar el enlace al dispositivo desde el que te has registrado.</p>
<p>Si necesitas ayuda contacta con <a href="mailto:contact@owlcode.tech">contact@owlcode.tech</a>.</p>
</div>

View File

@ -0,0 +1,9 @@
% title 'Pago exitoso - Redland Official';
% layout 'side_menu';
% my $so = stash 'so';
% my $pi = stash 'pi';
<div class="description">
<h2>Pago exitoso.</h2>
<p>Te has subscrito con exito en la modalidad "<%=$so->product->name%>" por la cantidad de <%=$so->to_pay/100%>€ al <%=$so->product->period%>.</p>
</div>

View File

@ -0,0 +1,10 @@
% title 'Perfil - Redland Official';
% layout 'side_menu';
<div class="description">
<h2>Tu perfil.</h2>
<p>Tu nombre de usuario es <%= $self->current_user->username %></p>
<p>El estado de tu cuenta es <%= 'no' unless $self->current_user->verified %> verificado.</p>
<p><a href="/logout">Cerrar sesión</a></p>
</div>

View File

@ -0,0 +1,13 @@
% layout 'side_menu';
% title 'Configura tu avatar Redland Official';
<div class="description">
<form action="/perfil">
<h2>Configura tu avatar.</h2>
<input type="file" id="avatar-selector" accept=".jpg, .png, .jpeg .webp">
<div style="display: none;" id="avatar-cropper"></div>
<input type="submit" id="omit-avatar-setup" value="Omitir"/>
<button disabled type="button" id="select-avatar">Seleccionar</button>
</form>
<script src="/js/npm.bundle.js"></script>
</div>

View File

@ -0,0 +1,18 @@
% use MyRedland::Posts;
%
% layout 'default';
% title 'Login Redland Official';
<div class="description">
<h2>Página de Login.</h2>
<form action="/registrate" method="post">
Nombre de usuario
<input placeholder="Nombre de usuario" type="text" name="username"/>
Email
<input placeholder="Email" type="text" name="email"/>
Contraseña
<input placeholder="Contraseña" type="password" name="password"/>
Repite la contraseña
<input placeholder="Repite la contraseña" type="password" name="repeat_password"/>
<input type="submit" value="Enviar"/>
</form>
</div>

View File

@ -0,0 +1,163 @@
% use JSON qw/encode_json/;
% title 'Suscribete - Redland Official';
% layout 'side_menu';
% my $product = stash 'product';
% my $so = stash 'subscription_order';
% my $current_payment_intent = $self->current_payment_intent;
<div class="description">
<h2>Ultimando tu subscripción.</h2>
<input type="hidden" id="publishable-secret" value="<%=$config->{stripe_public}%>"/>
<input type="hidden" id="so-uuid" value="<%=$so->uuid%>"/>
<p>Estás suscribiendote en la modalidad "<%=$product->name%>"</p>
<p>Vas a pagar <%=$so->to_pay/100%>€ al <%=$product->period%>.</p>
<form id="payment-form">
<input type="checkbox" id="save-card"
<%=$so->save_card ? 'checked' : ''%> <%=$so->renew_auto ? 'disabled' : ''%>/>
<label>Guardar la tarjeta para futuros pagos.</label>
<input type="checkbox" id="renew-subscription" value="Renovar la subscripción automáticamente."
<%=$so->renew_auto ? 'checked': ''%>/>
<label>Renovar la subscripción automáticamente.</label>
<div id="card-element">
</div>
<div class="submit-div">
<button id="submit">Enviar pago</button>
</div>
<div id="error-message">
</div>
</form>
</div>
<script src="https://js.stripe.com/v3/"></script>
<script nonce="<%=$nonce%>">
const publishable_secret_element = document.querySelector('#publishable-secret');
const stripe = Stripe(publishable_secret_element.value);
const options = JSON.parse(<%== encode_json(encode_json(
{
clientSecret => $so->client_secret,
appearance => {
theme => 'night',
variables => {
colorBackground => '#ffffff',
colorText => '#30313d',
colorDanger => '#df1b41',
}
}
}
));%>);
const clientSecret = options.clientSecret;
const appearance = options.appearance;
const renew_subscription_element = document.querySelector('#renew-subscription');
const style = {
base: {
background: '#191326',
color: "#DC143C",
fontFamily: 'Arial, sans-serif',
fontSmoothing: "antialiased",
fontSize: "25px",
"::placeholder": {
color: "#DC143C",
background: '#191326',
}
},
invalid: {
fontFamily: 'Arial, sans-serif',
color: "#fa755a",
iconColor: "#fa755a"
}
};
const save_card_element = document.querySelector('#save-card');
const form = document.querySelector('#payment-form');
async function update_checkboxes() {
const response = await fetch('/orden-de-subscripcion/estado', {
method: 'GET',
});
const text = await response.text();
const body = JSON.parse(text);
save_card_element.checked = !!body.save_card;
renew_subscription_element.checked = !!body.renew_auto;
save_card_element.disabled = !!body.renew_auto;
renew_subscription_element.disabled = false;
}
function payWithCard(stripe, card, client_secret) {
update_checkboxes().then(() => {
const data = {
payment_method: {
card: card
}
};
data.setup_future_usage = 'off_session';
stripe.confirmCardPayment(clientSecret, data)
.then(function(result) {
if (result.error) {
// Show error to your customer
showError(result.error.message);
} else {
window.location = '/perfil/pago-exitoso';
}
});
});
}
function showError(error) {
const error_msg = document.querySelector("#error-message");
error_msg.textContent = error;
setTimeout(function() {
error_msg.textContent = "";
}, 4000);
}
update_checkboxes();
save_card_element.addEventListener('change', () => {
save_card_element.disabled = true;
renew_subscription_element.disabled = true;
fetch('/orden-de-subscripcion/guardar-tarjeta-api', {
method: 'POST',
body: JSON.stringify({new_value: save_card_element.checked}),
}).then((response) => response.text()).then((text) => {
const body = JSON.parse(text);
if (body['error'] != null) {
alert(body['error']);
}
update_checkboxes();
});
});
renew_subscription_element.addEventListener('change', () => {
save_card_element.disabled = true;
renew_subscription_element.disabled = true;
fetch('/orden-de-subscripcion/renovacion-automatica-api', {
method: 'POST',
body: JSON.stringify({new_value: renew_subscription_element.checked}),
}).then((response) => response.text()).then( (text) => {
const body = JSON.parse(text);
if (body['error'] != null) {
alert(body['error']);
}
update_checkboxes();
});
});
const elements = stripe.elements({clientSecret, appearance});
// Create and mount the Payment Element
const card = elements.create('card', { style, hideIcon: true });
card.mount('#card-element');
card.on("change", function (event) {
document.querySelector("#submit").disabled = event.empty;
document.querySelector("#error-message").textContent = event.error ? event.error.message : "";
});
form.addEventListener('submit', (event) => {
event.preventDefault();
payWithCard(stripe, card, options.clientSecret);
});
</script>

View File

@ -0,0 +1,21 @@
% use MyRedland::Products;
% use Mojo::Util qw/xml_escape/;
%
% title 'Opciones de subscripción - Redland Official';
% layout 'side_menu';
% my $products_dao = MyRedland::Products->new;
% my $products = $products_dao->all;
<div class="description">
<h2>Opciones de subscripción.</h2>
<div class="subscription-options">
% for my $product (@$products) {
<div class="product">
<p class="product-price"><%=$product->price/100%>€</p>
<p><%=$product->name%></p>
<p><%=$product->description%></p>
<a class="subscribe-button" href="/perfil/subscribirse?product=<%=$product->id%>">Subscribirse.</a>
</div>
% }
</div>
</div>

19
webpack.config.js Normal file
View File

@ -0,0 +1,19 @@
const path = require('path');
const devMode = process.env.NODE_ENV !== "production";
module.exports = {
devtool: "inline-source-map",
mode: 'development',
entry: path.resolve(__dirname, 'js/index.js'),
output: {
path: path.resolve(__dirname, 'public/js'),
filename: 'npm.bundle.js'
},
module: {
rules: [{
test: /\.(sa|sc|c)ss$/i,
use: [devMode ? "style-loader" : MiniCssExtractPlugin.loader, "css-loader", "postcss-loader", "sass-loader"]
}]
}
};