From deff979215c857a465c6f4f59fd17df7ff92b5a8 Mon Sep 17 00:00:00 2001 From: Andrea Vos Date: Sat, 31 Oct 2020 21:33:59 +0100 Subject: [PATCH] #87 move backend to express --- components/ProfileOverview.vue | 4 +- nuxt.config.js | 11 +- package.json | 1 + routes/any.vue | 2 +- routes/profile.vue | 4 +- routes/template.vue | 2 +- server/admin.js | 41 ------ server/index.js | 30 ++++ server/notify.js | 2 +- server/nouns.js | 111 -------------- server/profile.js | 99 ------------- server/routes/admin.js | 34 +++++ server/{ => routes}/banner.js | 49 +++---- server/routes/nouns.js | 95 ++++++++++++ server/routes/profile.js | 92 ++++++++++++ server/routes/sources.js | 31 ++++ server/routes/user.js | 241 ++++++++++++++++++++++++++++++ server/sources.js | 38 ----- server/user.js | 250 -------------------------------- {server => src}/authenticate.js | 0 src/helpers.js | 33 +---- {server => src}/jwt.js | 0 {server => src}/mailer.js | 0 {server => src}/tsv.js | 0 yarn.lock | 2 +- 25 files changed, 557 insertions(+), 615 deletions(-) delete mode 100644 server/admin.js create mode 100644 server/index.js delete mode 100644 server/nouns.js delete mode 100644 server/profile.js create mode 100644 server/routes/admin.js rename server/{ => routes}/banner.js (69%) create mode 100644 server/routes/nouns.js create mode 100644 server/routes/profile.js create mode 100644 server/routes/sources.js create mode 100644 server/routes/user.js delete mode 100644 server/sources.js delete mode 100644 server/user.js rename {server => src}/authenticate.js (100%) rename {server => src}/jwt.js (100%) rename {server => src}/mailer.js (100%) rename {server => src}/tsv.js (100%) diff --git a/components/ProfileOverview.vue b/components/ProfileOverview.vue index 3961c7097..9b66d5a26 100644 --- a/components/ProfileOverview.vue +++ b/components/ProfileOverview.vue @@ -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); }, diff --git a/nuxt.config.js b/nuxt.config.js index a9c6ce9b7..04ce7fd79 100644 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -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', }, diff --git a/package.json b/package.json index e398ab90a..5abbbeed8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/routes/any.vue b/routes/any.vue index baa5688f3..f8950a014 100644 --- a/routes/any.vue +++ b/routes/any.vue @@ -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: { diff --git a/routes/profile.vue b/routes/profile.vue index c06ef6a58..288927f64 100644 --- a/routes/profile.vue +++ b/routes/profile.vue @@ -7,7 +7,7 @@
profile.edit @@ -158,7 +158,7 @@ head() { return head({ title: `@${this.username}`, - banner: `banner/@${this.username}.png`, + banner: `api/banner/@${this.username}.png`, }); }, } diff --git a/routes/template.vue b/routes/template.vue index 7f9b88b10..303c18827 100644 --- a/routes/template.vue +++ b/routes/template.vue @@ -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: { diff --git a/server/admin.js b/server/admin.js deleted file mode 100644 index 234cad1c4..000000000 --- a/server/admin.js +++ /dev/null @@ -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); -} diff --git a/server/index.js b/server/index.js new file mode 100644 index 000000000..bd48c6a91 --- /dev/null +++ b/server/index.js @@ -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, +} diff --git a/server/notify.js b/server/notify.js index 7bfb4afdc..5ff98fb8a 100644 --- a/server/notify.js +++ b/server/notify.js @@ -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(); diff --git a/server/nouns.js b/server/nouns.js deleted file mode 100644 index 0b7b76469..000000000 --- a/server/nouns.js +++ /dev/null @@ -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); -} diff --git a/server/profile.js b/server/profile.js deleted file mode 100644 index c0a21c750..000000000 --- a/server/profile.js +++ /dev/null @@ -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); -} diff --git a/server/routes/admin.js b/server/routes/admin.js new file mode 100644 index 000000000..ef5dbcdc3 --- /dev/null +++ b/server/routes/admin.js @@ -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; diff --git a/server/banner.js b/server/routes/banner.js similarity index 69% rename from server/banner.js rename to server/routes/banner.js index 274a53921..f4974b48e 100644 --- a/server/banner.js +++ b/server/routes/banner.js @@ -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; diff --git a/server/routes/nouns.js b/server/routes/nouns.js new file mode 100644 index 000000000..f36695ece --- /dev/null +++ b/server/routes/nouns.js @@ -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; diff --git a/server/routes/profile.js b/server/routes/profile.js new file mode 100644 index 000000000..9fef8f60b --- /dev/null +++ b/server/routes/profile.js @@ -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; diff --git a/server/routes/sources.js b/server/routes/sources.js new file mode 100644 index 000000000..0edaa27fd --- /dev/null +++ b/server/routes/sources.js @@ -0,0 +1,31 @@ +import { Router } from 'express'; +import mailer from "../../src/mailer"; + +const buildEmail = (data, user) => { + const human = [ + `
  • user: ${user ? user.username : ''}
  • `, + `
  • templates: ${data.templates}
  • `, + ]; + const tsv = ['???']; + + for (let field of ['type','author','title','extra','year','fragments','comment','link']) { + human.push(`
  • ${field}: ${field === 'fragments' ? `
    ${data[field]}
    `: data[field]}
  • `); + tsv.push(field === 'fragments' ? (data[field].join('@').replace(/\n/g, '|')) : data[field]); + } + + return `
      ${human.join('')}
    ${tsv.join('\t')}
    `; +} + +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; diff --git a/server/routes/user.js b/server/routes/user.js new file mode 100644 index 000000000..4cd3b7d9a --- /dev/null +++ b/server/routes/user.js @@ -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; diff --git a/server/sources.js b/server/sources.js deleted file mode 100644 index 09e694c05..000000000 --- a/server/sources.js +++ /dev/null @@ -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 = [ - `
  • user: ${user ? user.username : ''}
  • `, - `
  • templates: ${data.templates}
  • `, - ]; - const tsv = ['???']; - - for (let field of ['type','author','title','extra','year','fragments','comment','link']) { - human.push(`
  • ${field}: ${data[field]}
  • `); - tsv.push(data[field]); - } - - return `
      ${human.join('')}
    ${tsv.join('\t')}
    `; -} - -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); -} diff --git a/server/user.js b/server/user.js deleted file mode 100644 index aaf3c131f..000000000 --- a/server/user.js +++ /dev/null @@ -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); -} diff --git a/server/authenticate.js b/src/authenticate.js similarity index 100% rename from server/authenticate.js rename to src/authenticate.js diff --git a/src/helpers.js b/src/helpers.js index 1a70a47de..4ec07365d 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -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`; diff --git a/server/jwt.js b/src/jwt.js similarity index 100% rename from server/jwt.js rename to src/jwt.js diff --git a/server/mailer.js b/src/mailer.js similarity index 100% rename from server/mailer.js rename to src/mailer.js diff --git a/server/tsv.js b/src/tsv.js similarity index 100% rename from server/tsv.js rename to src/tsv.js diff --git a/yarn.lock b/yarn.lock index e95fc8bde..3ceb2e10b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==