Merge branch 'develop'

This commit is contained in:
Alice Gaudon 2021-01-25 17:29:26 +01:00
commit 0a11792557
11 changed files with 102 additions and 44 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "swaf", "name": "swaf",
"version": "0.23.2", "version": "0.23.3",
"description": "Structure Web Application Framework.", "description": "Structure Web Application Framework.",
"repository": "https://eternae.ink/arisu/swaf", "repository": "https://eternae.ink/arisu/swaf",
"author": "Alice Gaudon <alice@gaudon.pro>", "author": "Alice Gaudon <alice@gaudon.pro>",
@ -17,8 +17,8 @@
"clean": "(test ! -d dist || rm -r dist)", "clean": "(test ! -d dist || rm -r dist)",
"prepareSources": "cp package.json src/", "prepareSources": "cp package.json src/",
"compile": "yarn clean && tsc", "compile": "yarn clean && tsc",
"dev": "yarn prepareSources && concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon\" \"maildev\"",
"build": "yarn prepareSources && yarn compile && cp -r yarn.lock README.md config/ views/ dist/ && mkdir dist/types && cp src/types/* dist/types/", "build": "yarn prepareSources && yarn compile && cp -r yarn.lock README.md config/ views/ dist/ && mkdir dist/types && cp src/types/* dist/types/",
"dev": "yarn prepareSources && concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon\" \"maildev\"",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx", "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"release": "yarn build && yarn lint && yarn test && cd dist && yarn publish" "release": "yarn build && yarn lint && yarn test && cd dist && yarn publish"
}, },

View File

@ -11,6 +11,7 @@ import Application from "../Application";
import NunjucksComponent from "../components/NunjucksComponent"; import NunjucksComponent from "../components/NunjucksComponent";
import AuthMethod from "./AuthMethod"; import AuthMethod from "./AuthMethod";
import {Session, SessionData} from "express-session"; import {Session, SessionData} from "express-session";
import UserNameComponent from "./models/UserNameComponent";
export default class AuthGuard { export default class AuthGuard {
private readonly authMethods: AuthMethod<AuthProof<User>>[]; private readonly authMethods: AuthMethod<AuthProof<User>>[];
@ -140,7 +141,9 @@ export default class AuthGuard {
if (!user.isApproved()) { if (!user.isApproved()) {
await new Mail(this.app.as(NunjucksComponent).getEnvironment(), PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, { await new Mail(this.app.as(NunjucksComponent).getEnvironment(), PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, {
username: (await user.mainEmail.get())?.getOrFail('email'), username: user.asOptional(UserNameComponent)?.name ||
(await user.mainEmail.get())?.getOrFail('email') ||
'Could not find an identifier',
link: config.get<string>('public_url') + Controller.route('accounts-approval'), link: config.get<string>('public_url') + Controller.route('accounts-approval'),
}).send(config.get<string>('app.contact_email')); }).send(config.get<string>('app.contact_email'));
} }

View File

@ -4,6 +4,7 @@ export default class AddUsedToMagicLinksMigration extends Migration {
public async install(): Promise<void> { public async install(): Promise<void> {
await this.query(`ALTER TABLE magic_links await this.query(`ALTER TABLE magic_links
ADD COLUMN used BOOLEAN NOT NULL`); ADD COLUMN used BOOLEAN NOT NULL`);
await this.query(`DELETE FROM magic_links`);
} }
public async rollback(): Promise<void> { public async rollback(): Promise<void> {

View File

@ -49,6 +49,7 @@ export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
public async interruptAuth(req: Request, res: Response): Promise<boolean> { public async interruptAuth(req: Request, res: Response): Promise<boolean> {
const pendingLink = await MagicLink.select() const pendingLink = await MagicLink.select()
.where('session_id', req.getSession().id) .where('session_id', req.getSession().id)
.where('used', 0)
.first(); .first();
if (pendingLink) { if (pendingLink) {

View File

@ -4,13 +4,24 @@ import User from "../models/User";
import UserNameComponent from "../models/UserNameComponent"; import UserNameComponent from "../models/UserNameComponent";
import MagicLink from "../models/MagicLink"; import MagicLink from "../models/MagicLink";
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent"; import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent";
import {nanoid} from "nanoid";
export default class AddNameToUsersMigration extends Migration { export default class AddNameToUsersMigration extends Migration {
public async install(): Promise<void> { public async install(): Promise<void> {
await this.query(`ALTER TABLE users await this.query(`ALTER TABLE users
ADD COLUMN name VARCHAR(64) UNIQUE NOT NULL`); ADD COLUMN name VARCHAR(64) NOT NULL`);
await this.query(`ALTER TABLE magic_links await this.query(`ALTER TABLE magic_links
ADD COLUMN username VARCHAR(64) DEFAULT NULL`); ADD COLUMN username VARCHAR(64) DEFAULT NULL`);
// Give every user a random name
const users = await User.select().get(this.getCurrentConnection());
await Promise.all(users.map(user => {
user.name = nanoid();
return user.save(this.getCurrentConnection());
}));
await this.query(`ALTER TABLE users
ADD CONSTRAINT UNIQUE (name)`);
} }
public async rollback(): Promise<void> { public async rollback(): Promise<void> {

View File

@ -23,12 +23,21 @@ export default class FormHelperComponent extends ApplicationComponent {
return _previousFormData; return _previousFormData;
}; };
let _formPrefix: string | null;
res.locals.getFormPrefix = () => {
return _formPrefix;
};
res.locals.setFormPrefix = (formPrefix: string) => {
_formPrefix = formPrefix;
return '';
};
next(); next();
}); });
router.use((req, res, next) => { router.use((req, res, next) => {
if (['GET', 'POST'].find(m => m === req.method)) { if (['GET', 'POST'].find(m => m === req.method)) {
if (typeof req.body === 'object') { if (typeof req.body === 'object' && Object.keys(req.body).length > 0) {
req.flash('previousFormData', req.body); req.flash('previousFormData', req.body);
} }
} }

View File

@ -10,6 +10,7 @@ import Application from "../Application";
import RedisComponent from "./RedisComponent"; import RedisComponent from "./RedisComponent";
import WebSocketListener from "../WebSocketListener"; import WebSocketListener from "../WebSocketListener";
import NunjucksComponent from "./NunjucksComponent"; import NunjucksComponent from "./NunjucksComponent";
import {Session} from "express-session";
export default class WebSocketServerComponent extends ApplicationComponent { export default class WebSocketServerComponent extends ApplicationComponent {
private wss?: WebSocket.Server; private wss?: WebSocket.Server;
@ -65,7 +66,7 @@ export default class WebSocketServerComponent extends ApplicationComponent {
session.id = sid; session.id = sid;
store.createSession(<Request>request, session); store.createSession(<Request>request, session);
listener.handle(socket, request, (<Request>request).session).catch(err => { listener.handle(socket, request, session as Session).catch(err => {
logger.error(err, 'Error in websocket listener.'); logger.error(err, 'Error in websocket listener.');
}); });
}); });

View File

@ -206,9 +206,7 @@ export default class MysqlConnectionManager {
]); ]);
}); });
} }
}
for (const migration of this.migrations) {
migration.registerModels?.(); migration.registerModels?.();
} }
} }

View File

@ -110,7 +110,7 @@ export default class Mail {
this.data.app = config.get('app'); this.data.app = config.get('app');
// Log // Log
logger.debug('Send mail', this.options); logger.debug('Send mail', this.options, this.data);
// Render email // Render email
this.options.html = Mail.parse(this.environment, 'mails/' + this.template.template + '.mjml.njk', this.options.html = Mail.parse(this.environment, 'mails/' + this.template.template + '.mjml.njk',

View File

@ -16,8 +16,9 @@
{% endif %} {% endif %}
<section class="panel"> <section class="panel">
<h2>Log in</h2> <h2><i data-feather="log-in"></i> Log in</h2>
{{ setFormPrefix('login-') }}
<form action="{{ route('login') + queryStr }}" method="POST" id="login-form"> <form action="{{ route('login') + queryStr }}" method="POST" id="login-form">
{{ macros.field(_locals, 'text', 'identifier', query.identifier or '', 'Your email address or username', null, 'required') }} {{ macros.field(_locals, 'text', 'identifier', query.identifier or '', 'Your email address or username', null, 'required') }}
@ -32,8 +33,9 @@
</section> </section>
<section class="panel"> <section class="panel">
<h2>Register with email</h2> <h2><i data-feather="user-plus"></i> Register</h2>
{{ setFormPrefix('register-') }}
<form action="{{ route('register') + queryStr }}" method="POST" id="register-form"> <form action="{{ route('register') + queryStr }}" method="POST" id="register-form">
<input type="hidden" name="auth_method" value="magic_link"> <input type="hidden" name="auth_method" value="magic_link">
{{ macros.csrf(getCsrfToken) }} {{ macros.csrf(getCsrfToken) }}
@ -42,38 +44,69 @@
{{ macros.field(_locals, 'text', 'name', null, 'Choose your username', 'This cannot be changed later.', 'pattern="[0-9a-z_-]+" required') }} {{ macros.field(_locals, 'text', 'name', null, 'Choose your username', 'This cannot be changed later.', 'pattern="[0-9a-z_-]+" required') }}
{% endif %} {% endif %}
{{ macros.field(_locals, 'email', 'identifier', null, 'Your email address', null, 'required') }} <div id="register-magic_link_method_fields">
{{ macros.field(_locals, 'email', 'identifier', null, 'Your email address', null, 'required') }}
<a href="javascript: void(0);">Use password instead</a>
</div>
<div id="register-password_method_fields" class="hidden">
{{ macros.field(_locals, 'password', 'password', null, 'Choose a password', null, 'required disabled') }}
{{ macros.field(_locals, 'password', 'password_confirmation', null, 'Confirm your password', null, 'required disabled') }}
<a href="javascript: void(0);">Use email address instead</a>
</div>
{{ macros.field(_locals, 'checkbox', 'terms', null, 'I accept the <a href="/terms-of-services" target="_blank">Terms Of Services</a>.' | safe, null, 'required') }} {{ macros.field(_locals, 'checkbox', 'terms', null, 'I accept the <a href="/terms-of-services" target="_blank">Terms Of Services</a>.' | safe, null, 'required') }}
<button type="submit" class="primary">Register</button> <button type="submit" class="primary">Register</button>
</form> </form>
</section> </section>
{% if register_with_password %}
<section class="panel">
<h2>Register with password</h2>
<form action="{{ route('register') + queryStr }}" method="POST" id="register-form">
<input type="hidden" name="auth_method" value="password">
{{ macros.csrf(getCsrfToken) }}
<section class="sub-panel">
<h3>Username</h3>
{{ macros.field(_locals, 'text', 'identifier', null, 'Choose your username', 'This cannot be changed later.', 'pattern="[0-9a-z_-]+" required') }}
</section>
<section class="sub-panel">
<h3>Password</h3>
{{ macros.field(_locals, 'password', 'password', null, 'Choose a password', null, 'required') }}
{{ macros.field(_locals, 'password', 'password_confirmation', null, 'Confirm your password', null, 'required') }}
</section>
{{ macros.field(_locals, 'checkbox', 'terms', null, 'I accept the <a href="/terms-of-services" target="_blank">Terms Of Services</a>.' | safe, null, 'required') }}
<button type="submit" class="primary">Register</button>
</form>
</section>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
// Register form dynamics
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('register-form');
const authMethodField = form.querySelector('input[name=auth_method]');
const usernameField = form.querySelector('input[name=name]');
const magicLinkFields = document.getElementById('register-magic_link_method_fields');
const passwordFields = document.getElementById('register-password_method_fields');
let switchToPassword;
magicLinkFields.querySelector('a').addEventListener('click', switchToPassword = () => {
authMethodField.value = 'password';
usernameField.name = 'identifier';
magicLinkFields.classList.add('hidden');
magicLinkFields.querySelectorAll('input').forEach(el => {
el.disabled = true;
});
passwordFields.classList.remove('hidden');
passwordFields.querySelectorAll('input').forEach(el => {
el.disabled = false;
});
});
passwordFields.querySelector('a').addEventListener('click', () => {
authMethodField.value = 'magic_link';
usernameField.name = 'name';
magicLinkFields.classList.remove('hidden');
magicLinkFields.querySelectorAll('input').forEach(el => {
el.disabled = false;
});
passwordFields.classList.add('hidden');
passwordFields.querySelectorAll('input').forEach(el => {
el.disabled = true;
});
});
if (`{{ _locals.previousFormData()['auth_method'] | default('') }}` === 'password') {
switchToPassword();
}
});
</script>
{% endblock %}

View File

@ -37,6 +37,7 @@
{% set validation = validation[name] if validation[name] or null %} {% set validation = validation[name] if validation[name] or null %}
{% set previousFormData = _locals.previousFormData() %} {% set previousFormData = _locals.previousFormData() %}
{% set value = previousFormData[name] or value or validation.value or '' %} {% set value = previousFormData[name] or value or validation.value or '' %}
{% set prefix = _locals.getFormPrefix() | default('no-') %}
{% if type == 'hidden' %} {% if type == 'hidden' %}
{% if validation %} {% if validation %}
@ -63,16 +64,16 @@
{% else %} {% else %}
{% set v = (value % 60) if f == 's' else (((value - value % 60) / 60 % 60) if f == 'm' else ((value - value % 3600) / 3600 if f == 'h')) %} {% set v = (value % 60) if f == 's' else (((value - value % 60) / 60 % 60) if f == 'm' else ((value - value % 3600) / 3600 if f == 'h')) %}
{% endif %} {% endif %}
<input type="number" name="{{ name }}[{{ f }}]" id="field-{{ name }}-{{ f }}" <input type="number" name="{{ name }}[{{ f }}]" id="field-{{ prefix }}{{ name }}-{{ f }}"
value="{{ v }}" value="{{ v }}"
min="0" {{ 'max=60' if (f == 's' or f == 'm') }} min="0" {{ 'max=60' if (f == 's' or f == 'm') }}
{{ validation_attributes }}> {{ validation_attributes }}>
<label for="field-{{ name }}-{{ f }}">{{ f }}</label> <label for="field-{{ prefix }}{{ name }}-{{ f }}">{{ f }}</label>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% elseif type == 'select' %} {% elseif type == 'select' %}
<select name="{{ name }}" id="field-{{ name }}" {{ validation_attributes|safe }}> <select name="{{ name }}" id="field-{{ prefix }}{{ name }}" {{ validation_attributes|safe }}>
{% for option in extraData %} {% for option in extraData %}
<option value="{% if option.display === undefined or option.value !== undefined %}{{ option.value | default(option) }}{% endif %}" <option value="{% if option.display === undefined or option.value !== undefined %}{{ option.value | default(option) }}{% endif %}"
{{ 'selected' if value == (option.value | default(option)) }}>{{ option.display | default(option) }}</option> {{ 'selected' if value == (option.value | default(option)) }}>{{ option.display | default(option) }}</option>
@ -80,16 +81,16 @@
</select> </select>
<i data-feather="chevron-down"></i> <i data-feather="chevron-down"></i>
{% elseif type == 'textarea' %} {% elseif type == 'textarea' %}
<textarea name="{{ name }}" id="field-{{ name }}" <textarea name="{{ name }}" id="field-{{ prefix }}{{ name }}"
{{ validation_attributes|safe }} value="{{ value }}">{{ value }}</textarea> {{ validation_attributes|safe }} value="{{ value }}">{{ value }}</textarea>
{% else %} {% else %}
<input type="{{ type }}" name="{{ name }}" id="field-{{ name }}" <input type="{{ type }}" name="{{ name }}" id="field-{{ prefix }}{{ name }}"
{% if type != 'checkbox' %} value="{{ value }}" {% endif %} {% if type != 'checkbox' %} value="{{ value }}" {% endif %}
{{ 'checked' if (type == 'checkbox' and value == 'on') }} {{ 'checked' if (type == 'checkbox' and value == 'on') }}
{{ validation_attributes|safe }}> {{ validation_attributes|safe }}>
{% endif %} {% endif %}
<label for="field-{{ name }}{{ '-' + extraData[0] if type == 'duration' }}">{{ placeholder }}</label> <label for="field-{{ prefix }}{{ name }}{{ '-' + extraData[0] if type == 'duration' }}">{{ placeholder }}</label>
</div> </div>
{{ fieldError(_locals, name) }} {{ fieldError(_locals, name) }}