#87 move backend to express

This commit is contained in:
Avris 2020-10-31 21:33:59 +01:00
parent b5521eab47
commit c8abcaf9f7
25 changed files with 557 additions and 615 deletions

View File

@ -36,11 +36,11 @@
}
},
methods: {
async removeProfile() {
async removeProfile(locale) {
await this.$confirm(this.$t('profile.deleteConfirm'), 'danger');
this.deleting = true;
const response = await this.$axios.$post(`/profile/delete/${this.config.locale}`, {}, { headers: this.$auth() });
const response = await this.$axios.$post(`/profile/delete/${locale}`, {}, { headers: this.$auth() });
this.deleting = false;
this.$emit('update', response);
},

View File

@ -86,6 +86,7 @@ export default {
},
env: {
BASE_URL: process.env.BASE_URL,
TITLE: title,
PUBLIC_KEY: fs.readFileSync(__dirname + '/keys/public.pem').toString(),
LOCALE: config.locale,
FLAGS: buildDict(function *() {
@ -102,15 +103,7 @@ export default {
}
}),
},
serverMiddleware: {
'/': bodyParser.json(),
'/banner': '~/server/banner.js',
'/api/nouns': '~/server/nouns.js',
'/api/user': '~/server/user.js',
'/api/profile': '~/server/profile.js',
'/api/admin': '~/server/admin.js',
'/api/sources': '~/server/sources.js',
},
serverMiddleware: ['~/server/index.js'],
axios: {
baseURL: process.env.BASE_URL + '/api',
},

View File

@ -17,6 +17,7 @@
"canvas": "^2.6.1",
"cookie-universal-nuxt": "^2.1.4",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"js-base64": "^3.5.2",
"js-md5": "^0.7.3",
"jsonwebtoken": "^8.5.1",

View File

@ -61,7 +61,7 @@
head() {
return head({
title: `${this.$t('template.intro')}: ${this.$t('template.any.short')}`,
banner: `banner/${this.$t('template.any.short')}.png`,
banner: `api/banner/${this.$t('template.any.short')}.png`,
});
},
methods: {

View File

@ -7,7 +7,7 @@
</h2>
<div>
<nuxt-link v-if="$user() && $user().username === username" :to="`/${config.profile.editorRoute}`"
class="btn btn-outline-primary btn-sm"
class="btn btn-outline-primary btn-sm mb-2"
>
<Icon v="edit"/>
<T>profile.edit</T>
@ -158,7 +158,7 @@
head() {
return head({
title: `@${this.username}`,
banner: `banner/@${this.username}.png`,
banner: `api/banner/@${this.username}.png`,
});
},
}

View File

@ -140,7 +140,7 @@
head() {
return this.selectedTemplate ? head({
title: `${this.$t('template.intro')}: ${this.selectedTemplate.name(this.glue)}`,
banner: `banner${this.$route.path.replace(/\/$/, '')}.png`,
banner: `api/banner${this.$route.path.replace(/\/$/, '')}.png`,
}) : {};
},
methods: {

View File

@ -1,41 +0,0 @@
import {renderJson} from "../src/helpers";
const dbConnection = require('./db');
const SQL = require('sql-template-strings');
import authenticate from './authenticate';
export default async function (req, res, next) {
const db = await dbConnection();
const user = authenticate(req);
if (!user || !user.authenticated || user.roles !== 'admin') {
return renderJson(res, {error: 'unauthorised'}, 401);
}
if (req.method === 'GET' && req.url === '/users') {
const users = await db.all(SQL`
SELECT u.id, u.username, u.email, u.roles, p.locale
FROM users u
LEFT JOIN profiles p ON p.userId = u.id
ORDER BY u.id DESC
`);
const groupedUsers = {};
for (let user of users) {
if (groupedUsers[user.id] === undefined) {
groupedUsers[user.id] = {
...user,
locale: undefined,
profiles: user.locale ? [user.locale] : [],
}
} else {
groupedUsers[user.id].profiles.push(user.locale);
}
}
return renderJson(res, groupedUsers);
}
return renderJson(res, { error: 'notfound' }, 404);
}

30
server/index.js Normal file
View File

@ -0,0 +1,30 @@
import express from 'express';
import authenticate from '../src/authenticate';
import dbConnection from './db';
const app = express()
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(async function (req, res, next) {
req.rawUser = authenticate(req);
req.user = req.rawUser && req.rawUser.authenticated ? req.rawUser : null;
req.admin = req.user && req.user.roles === 'admin';
req.db = await dbConnection();
next();
})
app.use(require('./routes/banner').default);
app.use(require('./routes/user').default);
app.use(require('./routes/profile').default);
app.use(require('./routes/admin').default);
app.use(require('./routes/nouns').default);
app.use(require('./routes/sources').default);
export default {
path: '/api',
handler: app,
}

View File

@ -1,6 +1,6 @@
const dbConnection = require('./db');
require('dotenv').config({ path:__dirname + '/../.env' });
const mailer = require('./mailer');
const mailer = require('../src/mailer');
async function notify() {
const db = await dbConnection();

View File

@ -1,111 +0,0 @@
import {renderJson} from "../src/helpers";
const dbConnection = require('./db');
const SQL = require('sql-template-strings');
import { ulid } from 'ulid'
import authenticate from './authenticate';
const getId = url => url.match(/\/([^/]+)$/)[1];
const approve = async (db, id) => {
const {base_id} = await db.get(SQL`SELECT base_id FROM nouns WHERE id=${id}`);
if (base_id) {
await db.get(SQL`
DELETE FROM nouns
WHERE id = ${base_id}
`);
}
await db.get(SQL`
UPDATE nouns
SET approved = 1, base_id = NULL
WHERE id = ${id}
`);
}
const hide = async (db, id) => {
await db.get(SQL`
UPDATE nouns
SET approved = 0
WHERE id = ${id}
`);
}
const remove = async (db, id) => {
await db.get(SQL`
DELETE FROM nouns
WHERE id = ${id}
`);
}
const trollWords = [
'cipeusz',
'feminazi',
'bruksela',
'zboczeń',
];
const isTroll = (body) => {
const jsonBody = JSON.stringify(body);
for (let trollWord of trollWords) {
if (jsonBody.indexOf(trollWord) > -1) {
return true;
}
}
return false;
}
export default async function (req, res, next) {
const db = await dbConnection();
const user = authenticate(req);
const isAdmin = user && user.authenticated && user.roles === 'admin';
if (req.method === 'GET' && req.url.startsWith('/all/')) {
const locale = req.url.substring(5);
return renderJson(res, await db.all(SQL`
SELECT * FROM nouns
WHERE locale = ${locale}
AND approved >= ${isAdmin ? 0 : 1}
ORDER BY approved, masc
`));
}
if (req.method === 'POST' && req.url.startsWith('/submit/')) {
const locale = req.url.substring(8);
if (isAdmin || !isTroll(req.body)) {
const id = ulid()
await db.get(SQL`
INSERT INTO nouns (id, masc, fem, neutr, mascPl, femPl, neutrPl, approved, base_id, locale)
VALUES (
${id},
${req.body.masc.join('|')}, ${req.body.fem.join('|')}, ${req.body.neutr.join('|')},
${req.body.mascPl.join('|')}, ${req.body.femPl.join('|')}, ${req.body.neutrPl.join('|')},
0, ${req.body.base}, ${locale}
)
`);
if (isAdmin) {
await approve(db, id);
}
}
return renderJson(res, 'ok');
}
if (req.method === 'POST' && req.url.startsWith('/approve/') && isAdmin) {
await approve(db, getId(req.url));
return renderJson(res, 'ok');
}
if (req.method === 'POST' && req.url.startsWith('/hide/') && isAdmin) {
await hide(db, getId(req.url));
return renderJson(res, 'ok');
}
if (req.method === 'POST' && req.url.startsWith('/remove/') && isAdmin) {
await remove(db, getId(req.url));
return renderJson(res, 'ok');
}
return renderJson(res, {error: 'Not found'}, 404);
}

View File

@ -1,99 +0,0 @@
const dbConnection = require('./db');
const SQL = require('sql-template-strings');
import {buildDict, renderJson} from "../src/helpers";
import { ulid } from 'ulid'
import authenticate from './authenticate';
import md5 from 'js-md5';
const calcAge = birthday => {
if (!birthday) {
return null;
}
const now = new Date();
const birth = new Date(
parseInt(birthday.substring(0, 4)),
parseInt(birthday.substring(5, 7)) - 1,
parseInt(birthday.substring(8, 10))
);
const diff = now.getTime() - birth.getTime();
return parseInt(Math.floor(diff / 1000 / 60 / 60 / 24 / 365.25));
}
const buildProfile = profile => {
return {
id: profile.id,
userId: profile.userId,
username: profile.username,
emailHash: md5(profile.email),
names: JSON.parse(profile.names),
pronouns: JSON.parse(profile.pronouns),
description: profile.description,
age: calcAge(profile.birthday),
links: JSON.parse(profile.links),
flags: JSON.parse(profile.flags),
words: JSON.parse(profile.words),
};
};
const fetchProfiles = async (db, res, username, self) => {
const profiles = await db.all(SQL`
SELECT profiles.*, users.username, users.email FROM profiles LEFT JOIN users on users.id == profiles.userId
WHERE users.username = ${username}
AND profiles.active = 1
ORDER BY profiles.locale
`);
return renderJson(res, buildDict(function* () {
for (let profile of profiles) {
yield [
profile.locale,
{
...buildProfile(profile),
birthday: self ? profile.birthday : undefined,
}
];
}
}));
}
export default async function (req, res, next) {
const db = await dbConnection();
const user = authenticate(req);
if (req.method === 'GET' && req.url.startsWith('/get/')) {
const username = req.url.substring(5);
return await fetchProfiles(db, res, username, user && user.authenticated && user.username === username)
}
if (!user || !user.authenticated) {
return renderJson(res, {error: 'unauthorised'}, 401);
}
if (req.method === 'POST' && req.url.startsWith('/save/')) {
const locale = req.url.substring(6);
const userId = (await db.get(SQL`SELECT id FROM users WHERE username = ${user.username}`)).id;
await db.get(SQL`DELETE FROM profiles WHERE userId = ${userId} AND locale = ${locale}`);
await db.get(SQL`INSERT INTO profiles (id, userId, locale, names, pronouns, description, birthday, links, flags, words, active)
VALUES (${ulid()}, ${userId}, ${locale}, ${JSON.stringify(req.body.names)}, ${JSON.stringify(req.body.pronouns)},
${req.body.description}, ${req.body.birthday || null}, ${JSON.stringify(req.body.links)}, ${JSON.stringify(req.body.flags)},
${JSON.stringify(req.body.words)}, 1
)`);
return fetchProfiles(db, res, user.username, true);
}
if (req.method === 'POST' && req.url.startsWith('/delete/')) {
const locale = req.url.substring(8);
const userId = (await db.get(SQL`SELECT id FROM users WHERE username = ${user.username}`)).id;
await db.get(SQL`DELETE FROM profiles WHERE userId = ${userId} AND locale = ${locale}`);
return fetchProfiles(db, res, user.username, true);
}
return renderJson(res, { error: 'notfound' }, 404);
}

34
server/routes/admin.js Normal file
View File

@ -0,0 +1,34 @@
import { Router } from 'express';
import SQL from 'sql-template-strings';
const router = Router();
router.get('/admin/users', async (req, res) => {
if (!req.admin) {
return res.status(401).json({error: 'Unauthorised'});
}
const users = await req.db.all(SQL`
SELECT u.id, u.username, u.email, u.roles, p.locale
FROM users u
LEFT JOIN profiles p ON p.userId = u.id
ORDER BY u.id DESC
`);
const groupedUsers = {};
for (let user of users) {
if (groupedUsers[user.id] === undefined) {
groupedUsers[user.id] = {
...user,
locale: undefined,
profiles: user.locale ? [user.locale] : [],
}
} else {
groupedUsers[user.id].profiles.push(user.locale);
}
}
return res.json(groupedUsers);
});
export default router;

View File

@ -1,11 +1,10 @@
import { buildTemplate, parseTemplates } from "../src/buildTemplate";
import { createCanvas, registerFont, loadImage } from 'canvas';
import { loadTsv } from './tsv';
import translations from '../server/translations';
import {gravatar, renderImage, renderText} from "../src/helpers";
const dbConnection = require('./db');
const SQL = require('sql-template-strings');
import { Router } from 'express';
import SQL from 'sql-template-strings';
import {createCanvas, loadImage, registerFont} from "canvas";
import translations from "../translations";
import {gravatar} from "../../src/helpers";
import {buildTemplate, parseTemplates} from "../../src/buildTemplate";
import {loadTsv} from "../../src/tsv";
const drawCircle = (context, image, x, y, size) => {
context.save();
@ -23,14 +22,9 @@ const drawCircle = (context, image, x, y, size) => {
context.restore();
}
const router = Router();
export default async function (req, res, next) {
if (req.url.substr(req.url.length - 4) !== '.png') {
return renderText(res, 'Not found', 404);
}
const templateName = decodeURIComponent(req.url.substr(1, req.url.length - 5));
router.get('/banner/:templateName.png', async (req, res) => {
const width = 1200
const height = 600
const mime = 'image/png';
@ -55,12 +49,11 @@ export default async function (req, res, next) {
context.fillText(translations.title, width / leftRatio + imageSize / 1.5, height / 2 + 48);
}
if (templateName.startsWith('@')) {
const db = await dbConnection();
const user = await db.get(SQL`SELECT username, email FROM users WHERE username=${templateName.substring(1)}`);
if (req.params.templateName.startsWith('@')) {
const user = await req.db.get(SQL`SELECT username, email FROM users WHERE username=${req.params.templateName.substring(1)}`);
if (!user) {
await fallback();
return renderImage(res, canvas, mime);
return res.set('content-type', mime).send(canvas.toBuffer(mime));
}
const avatar = await loadImage(gravatar(user, imageSize));
@ -78,28 +71,30 @@ export default async function (req, res, next) {
context.drawImage(logo, width / leftRatio + imageSize, height / 2 + logoSize - 4, logoSize, logoSize / 1.25)
context.fillText(translations.title, width / leftRatio + imageSize + 36, height / 2 + 48);
return renderImage(res, canvas, mime);
return res.set('content-type', mime).send(canvas.toBuffer(mime));
}
const template = buildTemplate(
parseTemplates(loadTsv(__dirname + '/../data/templates/templates.tsv')),
templateName,
parseTemplates(loadTsv(__dirname + '/../../data/templates/templates.tsv')),
req.params.templateName,
);
const logo = await loadImage('node_modules/@fortawesome/fontawesome-pro/svgs/light/tags.svg');
if (!template && templateName !== 'dowolne') {
if (!template && req.params.templateName !== 'dowolne') { // TODO
await fallback();
return renderImage(res, canvas, mime);
return res.set('content-type', mime).send(canvas.toBuffer(mime));
}
context.drawImage(logo, width / leftRatio - imageSize / 2, height / 2 - imageSize / 1.25 / 2, imageSize, imageSize / 1.25)
context.font = 'regular 48pt Quicksand'
context.fillText(translations.template.intro + ':', width / leftRatio + imageSize / 1.5, height / 2 - 36)
const templateNameOptions = templateName === 'dowolne' ? ['dowolne'] : template.nameOptions();
const templateNameOptions = req.params.templateName === 'dowolne' ? ['dowolne'] : template.nameOptions();
context.font = `bold ${templateNameOptions.length <= 2 ? '70' : '36'}pt Quicksand`
context.fillText(templateNameOptions.join('\n'), width / leftRatio + imageSize / 1.5, height / 2 + (templateNameOptions.length <= 2 ? 72 : 24))
return renderImage(res, canvas, mime);
}
return res.set('content-type', mime).send(canvas.toBuffer(mime));
});
export default router;

95
server/routes/nouns.js Normal file
View File

@ -0,0 +1,95 @@
import { Router } from 'express';
import SQL from 'sql-template-strings';
import {ulid} from "ulid";
const isTroll = (body) => {
return ['cipeusz', 'feminazi', 'bruksela', 'zboczeń'].some(t => body.indexOf(t) > -1);
}
const approve = async (db, id) => {
const { base_id } = await db.get(SQL`SELECT base_id FROM nouns WHERE id=${id}`);
if (base_id) {
await db.get(SQL`
DELETE FROM nouns
WHERE id = ${base_id}
`);
}
await db.get(SQL`
UPDATE nouns
SET approved = 1, base_id = NULL
WHERE id = ${id}
`);
}
const router = Router();
router.get('/nouns/all/:locale', async (req, res) => {
return res.json(await req.db.all(SQL`
SELECT * FROM nouns
WHERE locale = ${req.params.locale}
AND approved >= ${req.admin ? 0 : 1}
ORDER BY approved, masc
`));
});
router.post('/nouns/submit/:locale', async (req, res) => {
if (!(req.user && $req.user.admin) && isTroll(JSON.stringify(body))) {
return res.json('ok');
}
const id = ulid();
await req.db.get(SQL`
INSERT INTO nouns (id, masc, fem, neutr, mascPl, femPl, neutrPl, approved, base_id, locale)
VALUES (
${id},
${req.body.masc.join('|')}, ${req.body.fem.join('|')}, ${req.body.neutr.join('|')},
${req.body.mascPl.join('|')}, ${req.body.femPl.join('|')}, ${req.body.neutrPl.join('|')},
0, ${req.body.base}, ${locale}
)
`);
if (req.admin) {
await approve(req.db, id);
}
return res.json('ok');
});
router.post('/nouns/hide/:id', async (req, res) => {
if (!req.admin) {
res.status(401).json({error: 'Unauthorised'});
}
await req.db.get(SQL`
UPDATE nouns
SET approved = 0
WHERE id = ${req.params.id}
`);
return res.json('ok');
});
router.post('/nouns/approve/:id', async (req, res) => {
if (!req.admin) {
res.status(401).json({error: 'Unauthorised'});
}
await approve(req.db, req.params.id);
return res.json('ok');
});
router.post('/nouns/remove/:id', async (req, res) => {
if (!req.admin) {
res.status(401).json({error: 'Unauthorised'});
}
await req.db.get(SQL`
DELETE FROM nouns
WHERE id = ${req.params.id}
`);
return res.json('ok');
});
export default router;

92
server/routes/profile.js Normal file
View File

@ -0,0 +1,92 @@
import { Router } from 'express';
import SQL from 'sql-template-strings';
import md5 from "js-md5";
import {buildDict} from "../../src/helpers";
import {ulid} from "ulid";
const calcAge = birthday => {
if (!birthday) {
return null;
}
const now = new Date();
const birth = new Date(
parseInt(birthday.substring(0, 4)),
parseInt(birthday.substring(5, 7)) - 1,
parseInt(birthday.substring(8, 10))
);
const diff = now.getTime() - birth.getTime();
return parseInt(Math.floor(diff / 1000 / 60 / 60 / 24 / 365.25));
}
const buildProfile = profile => {
return {
id: profile.id,
userId: profile.userId,
username: profile.username,
emailHash: md5(profile.email),
names: JSON.parse(profile.names),
pronouns: JSON.parse(profile.pronouns),
description: profile.description,
age: calcAge(profile.birthday),
links: JSON.parse(profile.links),
flags: JSON.parse(profile.flags),
words: JSON.parse(profile.words),
};
};
const fetchProfiles = async (db, username, self) => {
const profiles = await db.all(SQL`
SELECT profiles.*, users.username, users.email FROM profiles LEFT JOIN users on users.id == profiles.userId
WHERE users.username = ${username}
AND profiles.active = 1
ORDER BY profiles.locale
`);
return buildDict(function* () {
for (let profile of profiles) {
yield [
profile.locale,
{
...buildProfile(profile),
birthday: self ? profile.birthday : undefined,
}
];
}
});
};
const router = Router();
router.get('/profile/get/:username', async (req, res) => {
return res.json(await fetchProfiles(req.db, req.params.username, req.user && req.user.username === req.params.username))
});
router.post('/profile/save/:locale', async (req, res) => {
if (!req.user) {
return res.status(401).json({error: 'Unauthorised'});
}
const userId = (await req.db.get(SQL`SELECT id FROM users WHERE username = ${req.user.username}`)).id;
await req.db.get(SQL`DELETE FROM profiles WHERE userId = ${userId} AND locale = ${req.params.locale}`);
await req.db.get(SQL`INSERT INTO profiles (id, userId, locale, names, pronouns, description, birthday, links, flags, words, active)
VALUES (${ulid()}, ${userId}, ${req.params.locale}, ${JSON.stringify(req.body.names)}, ${JSON.stringify(req.body.pronouns)},
${req.body.description}, ${req.body.birthday || null}, ${JSON.stringify(req.body.links)}, ${JSON.stringify(req.body.flags)},
${JSON.stringify(req.body.words)}, 1
)`);
return res.json(await fetchProfiles(req.db, req.user.username, true));
});
router.post('/profile/delete/:locale', async (req, res) => {
const userId = (await req.db.get(SQL`SELECT id FROM users WHERE username = ${req.user.username}`)).id;
await req.db.get(SQL`DELETE FROM profiles WHERE userId = ${userId} AND locale = ${req.params.locale}`);
return res.json(await fetchProfiles(req.db, req.user.username, true));
});
export default router;

31
server/routes/sources.js Normal file
View File

@ -0,0 +1,31 @@
import { Router } from 'express';
import mailer from "../../src/mailer";
const buildEmail = (data, user) => {
const human = [
`<li><strong>user:</strong> ${user ? user.username : ''}</li>`,
`<li><strong>templates:</strong> ${data.templates}</li>`,
];
const tsv = ['???'];
for (let field of ['type','author','title','extra','year','fragments','comment','link']) {
human.push(`<li><strong>${field}:</strong> ${field === 'fragments' ? `<pre>${data[field]}</pre>`: data[field]}</li>`);
tsv.push(field === 'fragments' ? (data[field].join('@').replace(/\n/g, '|')) : data[field]);
}
return `<ul>${human.join('')}</ul><pre>${tsv.join('\t')}</pre>`;
}
const router = Router();
router.post('/sources/submit/:locale', async (req, res) => {
const emailBody = buildEmail(req.body, req.user);
for (let admin of process.env.MAILER_ADMINS.split(',')) {
mailer(admin, `[Pronouns][${req.params.locale}] Source submission`, undefined, emailBody);
}
return res.json({ result: 'ok' });
});
export default router;

241
server/routes/user.js Normal file
View File

@ -0,0 +1,241 @@
import { Router } from 'express';
import SQL from 'sql-template-strings';
import {ulid} from "ulid";
import {makeId} from "../../src/helpers";
import translations from "../translations";
import jwt from "../../src/jwt";
import mailer from "../../src/mailer";
const now = Math.floor(Date.now() / 1000);
const USERNAME_CHARS = 'A-Za-zĄĆĘŁŃÓŚŻŹąćęłńóśżź0-9._-';
const normalise = s => s.trim().toLowerCase();
const saveAuthenticator = async (db, type, user, payload, validForMinutes = null) => {
const id = ulid();
await db.get(SQL`INSERT INTO authenticators (id, userId, type, payload, validUntil) VALUES (
${id},
${user ? user.id : null},
${type},
${JSON.stringify(payload)},
${validForMinutes ? (now + validForMinutes * 60) : null}
)`);
return id;
}
const findAuthenticator = async (db, id, type) => {
const authenticator = await db.get(SQL`SELECT * FROM authenticators
WHERE id = ${id}
AND type = ${type}
AND (validUntil IS NULL OR validUntil > ${now})
`);
if (authenticator) {
authenticator.payload = JSON.parse(authenticator.payload);
}
return authenticator
}
const invalidateAuthenticator = async (db, id) => {
await db.get(SQL`UPDATE authenticators
SET validUntil = ${now}
WHERE id = ${id}
`);
}
const defaultUsername = async (db, email) => {
const base = email.substring(0, email.indexOf('@'))
.padEnd(4, '0')
.substring(0, 12)
.replace(new RegExp(`[^${USERNAME_CHARS}]`, 'g'), '_');
let c = 0;
while (true) {
let proposal = base + (c || '');
let dbUser = await db.get(SQL`SELECT id FROM users WHERE lower(trim(username)) = ${normalise(proposal)}`);
if (!dbUser) {
return proposal;
}
c++;
}
}
const issueAuthentication = async (db, user) => {
let dbUser = await db.get(SQL`SELECT * FROM users WHERE email = ${normalise(user.email)}`);
if (!dbUser) {
dbUser = {
id: ulid(),
username: await defaultUsername(db, user.email),
email: normalise(user.email),
roles: 'user',
avatarSource: null,
}
await db.get(SQL`INSERT INTO users(id, username, email, roles, avatarSource)
VALUES (${dbUser.id}, ${dbUser.username}, ${dbUser.email}, ${dbUser.roles}, ${dbUser.avatarSource})`)
}
return {
token: jwt.sign({
...dbUser,
authenticated: true,
}),
};
}
const validateEmail = (email) => {
const re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(String(email).toLowerCase());
}
const router = Router();
router.post('/user/init', async (req, res) => {
let user = undefined;
let usernameOrEmail = req.body.usernameOrEmail;
const isEmail = usernameOrEmail.indexOf('@') > -1;
let isTest = false;
if (process.env.NODE_ENV === 'development' && usernameOrEmail.endsWith('+')) {
isTest = true;
usernameOrEmail = usernameOrEmail.substring(0, usernameOrEmail.length - 1);
}
if (isEmail) {
user = await req.db.get(SQL`SELECT * FROM users WHERE email = ${normalise(usernameOrEmail)}`);
} else {
user = await req.db.get(SQL`SELECT * FROM users WHERE lower(trim(username)) = ${normalise(usernameOrEmail)}`);
}
if (!user && !isEmail) {
return res.json({error: 'user.login.userNotFound'})
}
const payload = {
username: isEmail ? (user ? user.username : null) : usernameOrEmail,
email: isEmail ? normalise(usernameOrEmail) : user.email,
code: isTest ? '999999' : makeId(6, '0123456789'),
}
const codeKey = await saveAuthenticator(req.db, 'email', user, payload, 15);
if (!isTest) {
mailer(
payload.email,
`[${translations.title}] ${translations.user.login.email.subject.replace('%code%', payload.code)}`,
translations.user.login.email.content.replace('%code%', payload.code),
)
}
return res.json({
token: jwt.sign({...payload, code: null, codeKey}, '15m'),
});
});
router.post('/user/validate', async (req, res) => {
if (!req.rawUser || !req.rawUser.codeKey) {
return res.json({error: 'user.tokenExpired'});
}
const authenticator = await findAuthenticator(req.db, req.rawUser.codeKey, 'email');
if (!authenticator) {
return res.json({error: 'user.tokenExpired'});
}
if (authenticator.payload.code !== normalise(req.body.code)) {
return res.json({error: 'user.code.invalid'});
}
await invalidateAuthenticator(req.db, authenticator);
return res.json(await issueAuthentication(req.db, req.rawUser));
});
router.post('/user/change-username', async (req, res) => {
if (!req.user) {
return res.status(401).json({error: 'Unauthorised'});
}
if (req.body.username.length < 4 || req.body.username.length > 16 || !req.body.username.match(new RegExp(`^[${USERNAME_CHARS}]+$`))) {
return { error: 'user.account.changeUsername.invalid' }
}
const dbUser = await req.db.get(SQL`SELECT * FROM users WHERE lower(trim(username)) = ${normalise(req.body.username)}`);
if (dbUser) {
return res.json({ error: 'user.account.changeUsername.taken' })
}
await req.db.get(SQL`UPDATE users SET username = ${req.body.username} WHERE email = ${normalise(req.user.email)}`);
return res.json(await issueAuthentication(req.db, req.user));
});
router.post('/user/change-email', async (req, res) => {
if (!req.user) {
return res.status(401).json({error: 'Unauthorised'});
}
if (!validateEmail(req.user.email)) {
return res.json({ error: 'user.account.changeEmail.invalid' })
}
const dbUser = await req.db.get(SQL`SELECT * FROM users WHERE lower(trim(email)) = ${normalise(req.body.email)}`);
if (dbUser) {
return res.json({ error: 'user.account.changeEmail.taken' })
}
if (!req.body.authId) {
const payload = {
from: req.user.email,
to: normalise(req.body.email),
code: makeId(6, '0123456789'),
};
const authId = await saveAuthenticator(req.db, 'changeEmail', req.user, payload, 15);
mailer(
payload.to,
`[${translations.title}] ${translations.user.login.email.subject.replace('%code%', payload.code)}`,
translations.user.login.email.content.replace('%code%', payload.code),
)
return res.json({ authId });
}
const authenticator = await findAuthenticator(req.db, req.body.authId, 'changeEmail');
if (!authenticator) {
return res.json({error: 'user.tokenExpired'});
}
if (authenticator.payload.code !== normalise(req.body.code)) {
return res.json({error: 'user.code.invalid'});
}
await invalidateAuthenticator(req.db, authenticator);
await req.db.get(SQL`UPDATE users SET email = ${authenticator.payload.to} WHERE email = ${normalise(req.user.email)}`);
req.user.email = authenticator.payload.to;
return res.json(await issueAuthentication(req.db, req.user));
});
router.post('/user/delete', async (req, res) => {
if (!req.user) {
return res.status(401).json({error: 'Unauthorised'});
}
const userId = (await req.db.get(SQL`SELECT id FROM users WHERE username = ${req.user.username}`)).id;
if (!userId) {
return res.json(false);
}
await req.db.get(SQL`DELETE FROM profiles WHERE userId = ${userId}`)
await req.db.get(SQL`DELETE FROM authenticators WHERE userId = ${userId}`)
await req.db.get(SQL`DELETE FROM users WHERE id = ${userId}`)
return res.json(true);
});
export default router;

View File

@ -1,38 +0,0 @@
const dbConnection = require('./db');
import {renderJson} from "../src/helpers";
import authenticate from './authenticate';
const mailer = require('./mailer');
const buildEmail = (data, user) => {
const human = [
`<li><strong>user:</strong> ${user ? user.username : ''}</li>`,
`<li><strong>templates:</strong> ${data.templates}</li>`,
];
const tsv = ['???'];
for (let field of ['type','author','title','extra','year','fragments','comment','link']) {
human.push(`<li><strong>${field}:</strong> ${data[field]}</li>`);
tsv.push(data[field]);
}
return `<ul>${human.join('')}</ul><pre>${tsv.join('\t')}</pre>`;
}
export default async function (req, res, next) {
const db = await dbConnection();
const user = authenticate(req);
if (req.method === 'POST' && req.url.startsWith('/submit/')) {
const locale = req.url.substring(8);
const emailBody = buildEmail(req.body, user);
for (let admin of process.env.MAILER_ADMINS.split(',')) {
mailer(admin, `[Pronouns][${locale}] Source submission`, undefined, emailBody);
}
return renderJson(res, { result: 'ok' });
}
return renderJson(res, { error: 'notfound' }, 404);
}

View File

@ -1,250 +0,0 @@
import jwt from './jwt';
import {makeId, renderJson} from '../src/helpers';
const dbConnection = require('./db');
const SQL = require('sql-template-strings');
import { ulid } from 'ulid';
import translations from "./translations";
const mailer = require('./mailer');
import authenticate from './authenticate';
const now = Math.floor(Date.now() / 1000);
const USERNAME_CHARS = 'A-Za-zĄĆĘŁŃÓŚŻŹąćęłńóśżź0-9._-';
const normalise = s => s.trim().toLowerCase();
const saveAuthenticator = async (db, type, user, payload, validForMinutes = null) => {
const id = ulid();
await db.get(SQL`INSERT INTO authenticators (id, userId, type, payload, validUntil) VALUES (
${id},
${user ? user.id : null},
${type},
${JSON.stringify(payload)},
${validForMinutes ? (now + validForMinutes * 60) : null}
)`);
return id;
}
const findAuthenticator = async (db, id, type) => {
const authenticator = await db.get(SQL`SELECT * FROM authenticators
WHERE id = ${id}
AND type = ${type}
AND (validUntil IS NULL OR validUntil > ${now})
`);
if (authenticator) {
authenticator.payload = JSON.parse(authenticator.payload);
}
return authenticator
}
const invalidateAuthenticator = async (db, id) => {
await db.get(SQL`UPDATE authenticators
SET validUntil = ${now}
WHERE id = ${id}
`);
}
const init = async (db, usernameOrEmail) => {
let user = undefined;
const isEmail = usernameOrEmail.indexOf('@') > -1;
let isTest = false;
if (process.env.NODE_ENV === 'development' && usernameOrEmail.endsWith('+')) {
isTest = true;
usernameOrEmail = usernameOrEmail.substring(0, usernameOrEmail.length - 1);
}
if (isEmail) {
user = await db.get(SQL`SELECT * FROM users WHERE email = ${normalise(usernameOrEmail)}`);
} else {
user = await db.get(SQL`SELECT * FROM users WHERE lower(trim(username)) = ${normalise(usernameOrEmail)}`);
}
if (!user && !isEmail) {
return {error: 'user.login.userNotFound'}
}
const payload = {
username: isEmail ? (user ? user.username : null) : usernameOrEmail,
email: isEmail ? normalise(usernameOrEmail) : user.email,
code: isTest ? '999999' : makeId(6, '0123456789'),
}
const codeKey = await saveAuthenticator(db, 'email', user, payload, 15);
if (!isTest) {
mailer(
payload.email,
`[${translations.title}] ${translations.user.login.email.subject.replace('%code%', payload.code)}`,
translations.user.login.email.content.replace('%code%', payload.code),
)
}
return {
token: jwt.sign({...payload, code: null, codeKey}, '15m'),
};
}
const validate = async (db, user, code) => {
if (!user || !user.codeKey) {
return {error: 'user.tokenExpired'};
}
const authenticator = await findAuthenticator(db, user.codeKey, 'email');
if (!authenticator) {
return {error: 'user.tokenExpired'};
}
if (authenticator.payload.code !== normalise(code)) {
return {error: 'user.code.invalid'};
}
await invalidateAuthenticator(db, authenticator);
return await issueAuthentication(db, user);
}
const defaultUsername = async (db, email) => {
const base = email.substring(0, email.indexOf('@'))
.padEnd(4, '0')
.substring(0, 12)
.replace(new RegExp(`[^${USERNAME_CHARS}]`, 'g'), '_');
let c = 0;
while (true) {
let proposal = base + (c || '');
let dbUser = await db.get(SQL`SELECT id FROM users WHERE lower(trim(username)) = ${normalise(proposal)}`);
if (!dbUser) {
return proposal;
}
c++;
}
}
const issueAuthentication = async (db, user) => {
let dbUser = await db.get(SQL`SELECT * FROM users WHERE email = ${normalise(user.email)}`);
if (!dbUser) {
dbUser = {
id: ulid(),
username: await defaultUsername(db, user.email),
email: normalise(user.email),
roles: 'user',
avatarSource: null,
}
await db.get(SQL`INSERT INTO users(id, username, email, roles, avatarSource)
VALUES (${dbUser.id}, ${dbUser.username}, ${dbUser.email}, ${dbUser.roles}, ${dbUser.avatarSource})`)
}
return {
token: jwt.sign({
...dbUser,
authenticated: true,
}),
};
}
const changeUsername = async (db, user, username) => {
if (username.length < 4 || username.length > 16 || !username.match(new RegExp(`^[${USERNAME_CHARS}]+$`))) {
return { error: 'user.account.changeUsername.invalid' }
}
const dbUser = await db.get(SQL`SELECT * FROM users WHERE lower(trim(username)) = ${normalise(username)}`);
if (dbUser) {
return { error: 'user.account.changeUsername.taken' }
}
await db.get(SQL`UPDATE users SET username = ${username} WHERE email = ${normalise(user.email)}`);
return await issueAuthentication(db, user);
}
const validateEmail = (email) => {
const re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(String(email).toLowerCase());
}
const changeEmail = async (db, user, email, authId, code) => {
if (!validateEmail(email)) {
return { error: 'user.account.changeEmail.invalid' }
}
const dbUser = await db.get(SQL`SELECT * FROM users WHERE lower(trim(email)) = ${normalise(email)}`);
if (dbUser) {
return { error: 'user.account.changeEmail.taken' }
}
if (!authId) {
const payload = {
from: user.email,
to: normalise(email),
code: makeId(6, '0123456789'),
};
const authId = await saveAuthenticator(db, 'changeEmail', user, payload, 15);
mailer(
payload.to,
`[${translations.title}] ${translations.user.login.email.subject.replace('%code%', payload.code)}`,
translations.user.login.email.content.replace('%code%', payload.code),
)
return { authId };
}
const authenticator = await findAuthenticator(db, authId, 'changeEmail');
if (!authenticator) {
return {error: 'user.tokenExpired'};
}
if (authenticator.payload.code !== normalise(code)) {
return {error: 'user.code.invalid'};
}
await invalidateAuthenticator(db, authenticator);
await db.get(SQL`UPDATE users SET email = ${authenticator.payload.to} WHERE email = ${normalise(user.email)}`);
user.email = authenticator.payload.to;
return await issueAuthentication(db, user);
}
const removeAccount = async (db, user) => {
const userId = (await db.get(SQL`SELECT id FROM users WHERE username = ${user.username}`)).id;
if (!userId) {
return false;
}
await db.get(SQL`DELETE FROM profiles WHERE userId = ${userId}`)
await db.get(SQL`DELETE FROM authenticators WHERE userId = ${userId}`)
await db.get(SQL`DELETE FROM users WHERE id = ${userId}`)
return true;
}
export default async function (req, res, next) {
const db = await dbConnection();
const user = authenticate(req);
if (req.method === 'POST' && req.url === '/init' && req.body.usernameOrEmail) {
return renderJson(res, await init(db, req.body.usernameOrEmail));
}
if (req.method === 'POST' && req.url === '/validate' && req.body.code) {
return renderJson(res, await validate(db, user, req.body.code));
}
if (req.method === 'POST' && req.url === '/change-username' && user && user.authenticated && req.body.username) {
return renderJson(res, await changeUsername(db, user, req.body.username));
}
if (req.method === 'POST' && req.url === '/change-email' && user && user.authenticated && req.body.email) {
return renderJson(res, await changeEmail(db, user, req.body.email, req.body.authId, req.body.code));
}
if (req.method === 'POST' && req.url === '/delete' && user && user.authenticated) {
return renderJson(res, await removeAccount(db, user));
}
return renderJson(res, {error: 'Not found'}, 404);
}

View File

@ -21,7 +21,7 @@ export const head = ({title, description, banner}) => {
const meta = { meta: [] };
if (title) {
title += ' • Zaimki.pl';
title += ' • ' + process.env.TITLE;
meta.title = title;
meta.meta.push({ hid: 'og:title', property: 'og:title', content: title });
meta.meta.push({ hid: 'twitter:title', property: 'twitter:title', content: title });
@ -76,37 +76,6 @@ export const makeId = (length, characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi
return result;
}
export const parseQuery = (queryString) => {
const query = {};
const pairs = (queryString[0] === '?' ? queryString.substr(1) : queryString).split('&');
for (let i = 0; i < pairs.length; i++) {
let pair = pairs[i].split('=');
query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || '');
}
return query;
}
export const renderText = (res, content, status = 200) => {
res.statusCode = status;
res.setHeader('content-type', 'application/json');
res.write(JSON.stringify(content));
res.end();
}
export const renderJson = (res, content, status = 200) => {
res.statusCode = status;
res.setHeader('content-type', 'application/json');
res.write(JSON.stringify(content));
res.end();
}
export const renderImage = (res, canvas, mime, status = 200) => {
res.statusCode = status;
res.setHeader('content-type', mime);
res.write(canvas.toBuffer(mime));
res.end();
}
export const gravatar = (user, size = 128) => {
const fallback = `https://avi.avris.it/${size}/${Base64.encode(user.username).replace(/\+/g, '-').replace(/\//g, '_')}.png`;

View File

@ -3740,7 +3740,7 @@ expand-brackets@^2.1.4:
snapdragon "^0.8.1"
to-regex "^3.0.1"
express@^4.16.3:
express@^4.16.3, express@^4.17.1:
version "4.17.1"
resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==