diff --git a/migrations/047-inactive-warning.sql b/migrations/047-inactive-warning.sql new file mode 100644 index 00000000..a638a46d --- /dev/null +++ b/migrations/047-inactive-warning.sql @@ -0,0 +1,6 @@ +-- Up + +ALTER TABLE users ADD COLUMN inactiveWarning INTEGER NULL; + +-- Down + diff --git a/migrations/048-ban-history.sql b/migrations/048-ban-history.sql new file mode 100644 index 00000000..696e2021 --- /dev/null +++ b/migrations/048-ban-history.sql @@ -0,0 +1,12 @@ +-- Up + +CREATE TABLE bans ( + type TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (type, value) +); +INSERT INTO bans VALUES ('email', 'myriamnicoleacostarodriguez@gmail.com'); +INSERT INTO bans VALUES ('email', 'beherit1015@gmail.com'); + +-- Down + diff --git a/server/ban.js b/server/ban.js new file mode 100644 index 00000000..8e8c7e82 --- /dev/null +++ b/server/ban.js @@ -0,0 +1,55 @@ +const SQL = require('sql-template-strings'); +const socialLoginConfig = require('./social').config; + +const upsertBanArchive = async (db, type, value) => { + await db.get(SQL`INSERT INTO bans (type, value) VALUES (${type}, ${value}) ON CONFLICT DO NOTHING`); +} + +const removeBanArchive = async (db, type, value) => { + await db.get(SQL`DELETE FROM bans WHERE type = ${type} AND value = ${value});`); +} + +const normaliseEmail = (email) => { + let [username, domain] = email.split('@'); + username = username.replace(/\.+/g, ''); + username = username.replace(/\+.*/, ''); + return `${username}@${domain}`.toLowerCase(); +}; + +module.exports.archiveBan = async (db, user) => { + for (let auth of await db.all(SQL`SELECT * FROM authenticators WHERE userId = ${user.id}`)) { + const p = JSON.parse(auth.payload); + if (auth.type === 'email') { + await upsertBanArchive(db, 'email', normaliseEmail(p.email)); + } else if (socialLoginConfig[auth.type] !== undefined) { + await upsertBanArchive(db, auth.type, p.id); + if (p.email) { + await upsertBanArchive(db, 'email', normaliseEmail(p.email)); + } + } + } +} + +module.exports.liftBan = async (db, user) => { + for (let auth of await db.all(SQL`SELECT * FROM authenticators WHERE userId = ${user.id}`)) { + const p = JSON.parse(auth.payload); + if (auth.type === 'email') { + await removeBanArchive(db, 'email', normaliseEmail(p.email)); + } else if (socialLoginConfig[auth.type] !== undefined) { + await removeBanArchive(db, auth.type, p.id); + if (p.email) { + await removeBanArchive(db, 'email', normaliseEmail(p.email)); + } + } + } +} + +module.exports.lookupBanArchive = async (db, type, value) => { + if (type === 'email') { + value = normaliseEmail(value.email); + } else { + value = value.id; + } + + return (await db.all(SQL`SELECT * FROM bans WHERE type=${type} and value = ${value}`)).length > 0; +} diff --git a/server/cleanupAccounts.js b/server/cleanupAccounts.js new file mode 100644 index 00000000..6b3bf298 --- /dev/null +++ b/server/cleanupAccounts.js @@ -0,0 +1,105 @@ +require('../src/dotenv')(); + +const dbConnection = require('./db'); +const mailer = require('../src/mailer'); +const {archiveBan} = require("./ban"); + +const execute = process.env.EXECUTE === '1'; +console.log(execute ? 'WILL EXECUTE!' : 'Dry run'); + +const now = +new Date(); +const month = 30*24*60*60*1000; +const week = 7*24*60*60*1000; + +const sleep = ms => new Promise(res => setTimeout(res, ms)); + +async function warnInactive(db) { + console.log('--- Fetching ids to warn ---'); + + const users = (await db.all(` + SELECT u.id, u.username, u.email, u.bannedReason + FROM users u + WHERE + inactiveWarning IS NULL + AND ( + ( + u.id NOT IN (SELECT DISTINCT p.userId FROM profiles p) + AND (lastActive IS NULL OR lastActive < ${now - month}) + ) + OR bannedReason IS NOT NULL + ) + `)); + + console.log(users.length); + + for (let user of users) { + console.log('warn', user); + if (!execute) { continue; } + if (user.email.endsWith('.oauth')) { + await db.get(`UPDATE users SET inactiveWarning = ${now - week - 1000} WHERE id = '${user.id}'`); + continue; + } + await db.get(`UPDATE users SET inactiveWarning = ${now} WHERE id = '${user.id}'`); + if (user.bannedReason !== null) { + continue; + } + mailer(user.email, 'inactivityWarning') + await sleep(3000); + } +} + +async function removeWarned(db) { + console.log('--- Fetching ids to remove ---'); + + const users = (await db.all(` + SELECT u.id, u.username, u.email + FROM users u + WHERE ( + u.id NOT IN ( + SELECT DISTINCT p.userId + FROM profiles p + ) + OR bannedReason IS NOT NULL + ) + AND inactiveWarning IS NOT NULL + AND inactiveWarning < ${now - week} + `)); + + console.log(users.length); + + for (let user of users) { + console.log('remove', user); + if (!execute) { continue; } + await db.get(`DELETE FROM users WHERE id = '${user.id}'`); + } +} + +async function archiveBans(db) { + console.log('--- Archiving banned accounts ---'); + + const users = (await db.all(` + SELECT u.id, u.username, u.email + FROM users u + WHERE u.bannedReason IS NOT NULL + `)); + + console.log(users.length); + + for (let user of users) { + console.log('archiveBan', user); + if (!execute) { continue; } + await archiveBan(db, user); + } +} + +async function cleanup() { + const db = await dbConnection(); + + await db.get('PRAGMA foreign_keys = ON') + + await archiveBans(db); // TODO one-time only + await warnInactive(db); + await removeWarned(db); +} + +cleanup(); diff --git a/server/routes/admin.js b/server/routes/admin.js index 08ea023c..4570bf01 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -9,6 +9,7 @@ import { caches } from "../../src/cache"; import mailer from "../../src/mailer"; import {profilesSnapshot} from "./profile"; import buildLocaleList from "../../src/buildLocaleList"; +import {archiveBan, liftBan} from "../ban"; const router = Router(); @@ -161,6 +162,7 @@ router.post('/admin/ban/:username', handleErrorAsync(async (req, res) => { banSnapshot = ${await profilesSnapshot(req.db, normalise(req.params.username))} WHERE id = ${user.id} `); + await archiveBan(req.db, user); mailer(user.email, 'ban', {reason: req.body.reason}); } else { await req.db.get(SQL` @@ -168,6 +170,7 @@ router.post('/admin/ban/:username', handleErrorAsync(async (req, res) => { SET bannedReason = null WHERE id = ${user.id} `); + await liftBan(req.db, user); } await req.db.get(SQL` diff --git a/server/routes/profile.js b/server/routes/profile.js index ec8aad53..594cc921 100644 --- a/server/routes/profile.js +++ b/server/routes/profile.js @@ -273,6 +273,8 @@ router.post('/profile/save', handleErrorAsync(async (req, res) => { await caches.adminsFooter.invalidate(); } + await req.db.get(SQL`UPDATE users SET inactiveWarning = null WHERE id = ${req.user.id}`); + return res.json(await fetchProfiles(req.db, req.user.username, true)); })); diff --git a/server/routes/user.js b/server/routes/user.js index a6a70ca0..cb8560e8 100644 --- a/server/routes/user.js +++ b/server/routes/user.js @@ -12,6 +12,7 @@ import {validateCaptcha} from "../captcha"; import assert from "assert"; import {addMfaInfo} from './mfa'; import buildLocaleList from "../../src/buildLocaleList"; +import {lookupBanArchive} from '../ban'; const config = loadSuml('config'); const translations = loadSuml('translations'); @@ -35,6 +36,9 @@ const replaceExtension = username => username ; export const saveAuthenticator = async (db, type, user, payload, validForMinutes = null) => { + if (await lookupBanArchive(db, type, payload)) { + throw 'banned'; + } const id = ulid(); await db.get(SQL`INSERT INTO authenticators (id, userId, type, payload, validUntil) VALUES ( ${id}, diff --git a/server/social.js b/server/social.js index 9e46b479..4d0a8963 100644 --- a/server/social.js +++ b/server/social.js @@ -1,4 +1,4 @@ -export const config = { +module.exports.config = { defaults: { origin: process.env.BASE_URL, transport: 'session', @@ -34,7 +34,7 @@ export const config = { mastodon: {}, } -export const handlers = { +module.exports.handlers = { twitter(r) { return { id: r.profile.id_str, diff --git a/src/mailer.js b/src/mailer.js index 830eec0e..c96deb4f 100644 --- a/src/mailer.js +++ b/src/mailer.js @@ -74,6 +74,18 @@ const templates = {

[[quotation.start]]${terms}[[quotation.end]]

`, }, + inactivityWarning: { + subject: '[[user.removeInactive.email.subject]]', + text: '[[user.removeInactive.email.content]]', + html: ` +

[[user.removeInactive.email.content]]

+

+ + [[user.removeInactive.email.cta]] + +

+ `, + }, } const applyTemplate = (template, context, params) => {