Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
The delete button was offered on every invitation regardless of state, and the query behind it deleted any row it was given. Deleting an unused code takes it off the list without taking it out of anybody's inbox: the person still holds something that looks like a valid invitation, it silently stops working, and there is no longer a record of who it went to or why. Revoke is what stops a live code — it leaves the row behind, marked. So the delete is now for spent codes only, in three places rather than one: the query carries AND used_at IS NOT NULL, the route answers 409 with the reason instead of pretending the row is missing, and the button is rendered only on a used row. A "Clear N used" control alongside, since the complaint was clutter and clearing them one at a time is not much of an answer. Same rule — nothing unused or revoked is touched — and it confirms first, because it is still a delete. The bulk route is declared before /invites/:id, or Express reads "used" as an id. Verified against the live database: deleting an unused invitation is refused and the row survives, deleting a used one works, the bulk clear removes only used ones, and the unused probe row was still there afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
159 lines
5.5 KiB
JavaScript
159 lines
5.5 KiB
JavaScript
// ============================================================
|
|
// REGISTRATION INVITES
|
|
// registration_enabled is open-or-closed. This is the middle setting: open to
|
|
// people you invited. One module owns the rules so the register route and the
|
|
// admin routes cannot disagree about what "valid" means.
|
|
//
|
|
// A code is single-use, expires, and can be revoked. It is stored hashed,
|
|
// because an invite grants account creation and a database dump should not
|
|
// hand someone a working one.
|
|
// ============================================================
|
|
|
|
var crypto = require('crypto');
|
|
|
|
// Required lazily. The code-generation and hashing helpers are pure, and
|
|
// requiring the database at module load made simply importing this file open a
|
|
// connection — which is why a test that only checked those helpers hung.
|
|
function db() {
|
|
return require('../db/database');
|
|
}
|
|
|
|
var DEFAULT_TTL_DAYS = 7;
|
|
var MAX_TTL_DAYS = 90;
|
|
|
|
// Crockford-style: no I, L, O or U, so a code read aloud or copied off a screen
|
|
// is hard to mistype and cannot spell anything unfortunate.
|
|
var ALPHABET = '23456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
|
|
function generateCode() {
|
|
var bytes = crypto.randomBytes(16);
|
|
var out = '';
|
|
for (var i = 0; i < 16; i++) {
|
|
out += ALPHABET[bytes[i] % ALPHABET.length];
|
|
if (i % 4 === 3 && i !== 15) out += '-';
|
|
}
|
|
return out; // XXXX-XXXX-XXXX-XXXX
|
|
}
|
|
|
|
function normalize(code) {
|
|
return String(code == null ? '' : code).toUpperCase().replace(/[^0-9A-Z]/g, '');
|
|
}
|
|
|
|
function hash(code) {
|
|
return crypto.createHash('sha256').update(normalize(code)).digest('hex');
|
|
}
|
|
|
|
function ttlDays(requested) {
|
|
var days = Number(requested);
|
|
if (!Number.isFinite(days) || days < 1) return DEFAULT_TTL_DAYS;
|
|
return Math.min(Math.floor(days), MAX_TTL_DAYS);
|
|
}
|
|
|
|
// Returns the code once. It is never recoverable afterwards — only its hash and
|
|
// the last four characters are kept.
|
|
async function create(adminUserId, options) {
|
|
var opts = options || {};
|
|
var code = generateCode();
|
|
var days = ttlDays(opts.days);
|
|
var note = String(opts.note || '').trim().slice(0, 200);
|
|
await db().run(
|
|
"INSERT INTO registration_invites (code_hash, code_hint, note, created_by, expires_at) " +
|
|
"VALUES ($1, $2, $3, $4, NOW() + ($5 || ' days')::interval)",
|
|
[hash(code), normalize(code).slice(-4), note, adminUserId, String(days)]
|
|
);
|
|
return { code: code, days: days, note: note };
|
|
}
|
|
|
|
async function list() {
|
|
return db().all(
|
|
"SELECT i.id, i.code_hint, i.note, i.created_at, i.expires_at, i.used_at, i.revoked_at, " +
|
|
" c.email AS created_by_email, u.email AS used_by_email, " +
|
|
" CASE WHEN i.revoked_at IS NOT NULL THEN 'revoked' " +
|
|
" WHEN i.used_at IS NOT NULL THEN 'used' " +
|
|
" WHEN i.expires_at <= NOW() THEN 'expired' " +
|
|
" ELSE 'active' END AS status " +
|
|
"FROM registration_invites i " +
|
|
"LEFT JOIN users c ON c.id = i.created_by " +
|
|
"LEFT JOIN users u ON u.id = i.used_by " +
|
|
"ORDER BY i.created_at DESC LIMIT 200", []
|
|
);
|
|
}
|
|
|
|
async function revoke(id, adminUserId) {
|
|
// Revoking an already-used code would rewrite history, so only a live one.
|
|
var result = await db().run(
|
|
'UPDATE registration_invites SET revoked_at = NOW(), revoked_by = $1 ' +
|
|
'WHERE id = $2 AND revoked_at IS NULL AND used_at IS NULL',
|
|
[adminUserId, id]
|
|
);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
/**
|
|
* Delete a spent invitation.
|
|
*
|
|
* Used codes only. A code that has not been used yet is one somebody may still
|
|
* be holding: deleting it takes it out of the list without taking it out of
|
|
* their inbox, and there is then no record of who it went to or why it stopped
|
|
* working. Revoke does that job — it leaves the row, marked. This is only for
|
|
* clearing away codes whose whole story is already told.
|
|
*
|
|
* Returns false for a code that is not spent, which the caller reports rather
|
|
* than treating as a missing row.
|
|
*/
|
|
async function remove(id) {
|
|
var result = await db().run(
|
|
'DELETE FROM registration_invites WHERE id = $1 AND used_at IS NOT NULL', [id]);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
// Every spent invitation at once, which is what "they clutter the list" asks
|
|
// for. Same rule: nothing unused is touched.
|
|
async function removeUsed() {
|
|
var result = await db().run('DELETE FROM registration_invites WHERE used_at IS NOT NULL');
|
|
return result.changes || 0;
|
|
}
|
|
|
|
/**
|
|
* Claim a code for a registration, atomically.
|
|
*
|
|
* The UPDATE carries every condition, so two registrations racing the same code
|
|
* cannot both succeed: the second matches no row. Checking first and updating
|
|
* after would leave exactly that gap.
|
|
*
|
|
* Returns the invite id, or null if the code is unusable for any reason —
|
|
* unknown, expired, revoked or already used. The caller must not say which:
|
|
* distinguishing them tells someone probing codes which guesses were closer.
|
|
*/
|
|
async function claim(code, userId) {
|
|
var normalized = normalize(code);
|
|
if (!normalized) return null;
|
|
var rows = await db().all(
|
|
'UPDATE registration_invites SET used_at = NOW(), used_by = $1 ' +
|
|
'WHERE code_hash = $2 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > NOW() ' +
|
|
'RETURNING id',
|
|
[userId, hash(normalized)]
|
|
);
|
|
return rows && rows.length ? rows[0].id : null;
|
|
}
|
|
|
|
// True when a code must be supplied to register at all.
|
|
async function inviteOnly() {
|
|
return String(await db().getSetting('registration_invite_only') || 'false') === 'true';
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_TTL_DAYS,
|
|
MAX_TTL_DAYS,
|
|
generateCode,
|
|
normalize,
|
|
hash,
|
|
ttlDays,
|
|
create,
|
|
list,
|
|
revoke,
|
|
remove,
|
|
removeUsed,
|
|
claim,
|
|
inviteOnly
|
|
};
|