diff --git a/config_example.json b/config_example.json index 846f740..445093f 100644 --- a/config_example.json +++ b/config_example.json @@ -72,6 +72,14 @@ "enable_cleanup": false, "enable_data_export": true, "enable_user_api_keys": true, + "enable_user_invites": true, + "user_invite_slots": 2, + "invite_criteria": { + "uploads": 10, + "age_days": 10, + "comments": 10, + "tags": 10 + }, "cleanup_timeframe_days": 30, "web_url_upload": true, "enable_youtube_upload": true, diff --git a/migrations/f0ckm_schema.sql b/migrations/f0ckm_schema.sql index 84cffab..2680d04 100644 --- a/migrations/f0ckm_schema.sql +++ b/migrations/f0ckm_schema.sql @@ -825,12 +825,25 @@ CREATE TABLE public.invite_tokens ( used_by integer, is_used boolean DEFAULT false, created_by_discord character varying(255) DEFAULT NULL::character varying, - created_by_matrix character varying(255) DEFAULT NULL::character varying + created_by_matrix character varying(255) DEFAULT NULL::character varying, + used_at timestamp with time zone DEFAULT NULL ); ALTER TABLE public.invite_tokens OWNER TO f0ckm; +-- +-- Name: idx_invite_tokens_created_by; Type: INDEX; Schema: public; Owner: f0ckm +-- + +CREATE INDEX IF NOT EXISTS idx_invite_tokens_created_by ON public.invite_tokens (created_by); + +-- +-- Name: idx_invite_tokens_used_at; Type: INDEX; Schema: public; Owner: f0ckm +-- + +CREATE INDEX IF NOT EXISTS idx_invite_tokens_used_at ON public.invite_tokens (used_at) WHERE is_used = true; + -- -- Name: invite_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm -- diff --git a/src/inc/locales/de.json b/src/inc/locales/de.json index f311407..e934339 100644 --- a/src/inc/locales/de.json +++ b/src/inc/locales/de.json @@ -724,5 +724,29 @@ "left_hand_desc": "Du weißt bescheid.", "replying_to": "Antwort an {user}", "reply": "Antworten" + }, + "invites": { + "section_title": "Einladungen", + "section_desc": "Lade neue Nutzer ein. Du musst alle unten stehenden Kriterien erfüllen, um Einladungstokens zu generieren.", + "eligible": "✓ Du bist berechtigt, Einladungen zu generieren.", + "not_eligible": "✗ Du erfüllst noch nicht alle Kriterien.", + "slots_used": "{used} / {total} Einladungsslots genutzt", + "criteria_uploads": "Uploads", + "criteria_age": "Kontoalter", + "criteria_comments": "Kommentare", + "criteria_tags": "Vergebene Tags", + "criteria_days": " Tage", + "generate_btn": "Einladung generieren", + "generating": "Wird generiert…", + "loading": "Wird geladen…", + "no_invites": "Noch keine Einladungstokens generiert.", + "status_unused": "Ungenutzt", + "status_used_by": "Genutzt von {user}", + "copy_btn": "Kopieren", + "copied": "Kopiert!", + "delete_btn": "Löschen", + "delete_confirm": "Diesen Einladungstoken löschen?", + "slot_refreshes_on": "Slot erneuert sich am {date}", + "slot_refreshed": "Slot erneuert" } } \ No newline at end of file diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json index 5bbe14f..cbc2571 100644 --- a/src/inc/locales/en.json +++ b/src/inc/locales/en.json @@ -726,5 +726,29 @@ "left_hand_desc": "You know why.", "replying_to": "Replying to {user}", "reply": "Reply" + }, + "invites": { + "section_title": "Invites", + "section_desc": "Invite new users to join. You must meet all criteria below to generate invite tokens.", + "eligible": "✓ You are eligible to generate invites.", + "not_eligible": "✗ You do not yet meet all criteria.", + "slots_used": "{used} / {total} invite slots used", + "criteria_uploads": "Uploads", + "criteria_age": "Account age", + "criteria_comments": "Comments", + "criteria_tags": "Tags assigned", + "criteria_days": " days", + "generate_btn": "Generate invite", + "generating": "Generating…", + "loading": "Loading…", + "no_invites": "No invite tokens generated yet.", + "status_unused": "Unused", + "status_used_by": "Used by {user}", + "copy_btn": "Copy", + "copied": "Copied!", + "delete_btn": "Delete", + "delete_confirm": "Delete this invite token?", + "slot_refreshes_on": "slot refreshes on {date}", + "slot_refreshed": "slot refreshed" } } \ No newline at end of file diff --git a/src/inc/locales/nl.json b/src/inc/locales/nl.json index f7db62e..9acc3fe 100644 --- a/src/inc/locales/nl.json +++ b/src/inc/locales/nl.json @@ -722,5 +722,29 @@ "left_hand_desc": "Je weet wel waarom.", "replying_to": "Antwoord aan {user}", "reply": "Antwoorden" + }, + "invites": { + "section_title": "Uitnodigingen", + "section_desc": "Nodig nieuwe gebruikers uit. Je moet aan alle onderstaande criteria voldoen om uitnodigingstokens te genereren.", + "eligible": "✓ Je bent bevoegd om uitnodigingen te genereren.", + "not_eligible": "✗ Je voldoet nog niet aan alle criteria.", + "slots_used": "{used} / {total} uitnodigingsslots gebruikt", + "criteria_uploads": "Uploads", + "criteria_age": "Accountleeftijd", + "criteria_comments": "Opmerkingen", + "criteria_tags": "Toegewezen tags", + "criteria_days": " dagen", + "generate_btn": "Uitnodiging genereren", + "generating": "Genereren…", + "loading": "Laden…", + "no_invites": "Nog geen uitnodigingstokens gegenereerd.", + "status_unused": "Ongebruikt", + "status_used_by": "Gebruikt door {user}", + "copy_btn": "Kopiëren", + "copied": "Gekopieërd!", + "delete_btn": "Verwijderen", + "delete_confirm": "Dit uitnodigingstoken verwijderen?", + "slot_refreshes_on": "slot vernieuwd op {date}", + "slot_refreshed": "slot vernieuwd" } } \ No newline at end of file diff --git a/src/inc/locales/zange.json b/src/inc/locales/zange.json index c84b14d..e21c5e4 100644 --- a/src/inc/locales/zange.json +++ b/src/inc/locales/zange.json @@ -89,7 +89,7 @@ "password_min_hint": "Muss mindestens 20 Zeichen lang sein.", "confirm_password": "Kennwort bestätigen", "email_placeholder": "E-Post", - "invite_token": "Einladungskennzeichen", + "invite_token": "Einladungskots", "tos_private": "Ich bin mindestens 18 Jahre alt und stimme der Befolgung des Regelwerks zu", "tos_public": "Ich habe die Nutzungsbedingungen gelesen und akzeptiere diese", "tos_terms": "Nutzungsbedingungen", @@ -727,5 +727,29 @@ "left_hand_desc": "Sie wissen schon wieso.", "replying_to": "Antwort an {user}", "reply": "Antworten" + }, + "invites": { + "section_title": "Einladungswesen", + "section_desc": "Laden Sie neue Nutzer ein, beizutreten. Sie müssen alle nachstehenden Kriterien erfüllen, um Einladungskots zu erzeugen.", + "eligible": "✓ Sie sind berechtigt, Einladungen zu erzeugen.", + "not_eligible": "✗ Sie erfüllen noch nicht alle Kriterien.", + "slots_used": "{used} / {total} Einladungsplätze in Benutzung", + "criteria_uploads": "Aufladierungen", + "criteria_age": "Kontoalter", + "criteria_comments": "Kommentare", + "criteria_tags": "Vergebene Etiketten", + "criteria_days": " Tage", + "generate_btn": "Einladung erzeugen", + "generating": "Erzeugung wird durchgeführt…", + "loading": "Ladung wird aufbereitet…", + "no_invites": "Noch keine Einladungskots erzeugt.", + "status_unused": "Ungebraucht", + "status_used_by": "Gebraucht von {user}", + "copy_btn": "Kopieren", + "copied": "Kopiert!", + "delete_btn": "Löschen", + "delete_confirm": "Diesen Einladungskot löschen?", + "slot_refreshes_on": "Platz erneuert sich am {date}", + "slot_refreshed": "Platz erneuert" } } \ No newline at end of file diff --git a/src/inc/routes/apiv2/settings.mjs b/src/inc/routes/apiv2/settings.mjs index f671709..00bec46 100644 --- a/src/inc/routes/apiv2/settings.mjs +++ b/src/inc/routes/apiv2/settings.mjs @@ -811,6 +811,186 @@ export default router => { } }); + + // --- User Invite System --- + // Eligibility: ≥150 uploads, ≥30 days old, ≥66 comments, ≥200 tags + // Slots: configurable (default 2), refresh 30 days after a token is used. + + const getInviteCriteria = () => { + const ic = cfg.websrv.invite_criteria || {}; + return { + uploads: Number.isFinite(+ic.uploads) ? +ic.uploads : 150, + age_days: Number.isFinite(+ic.age_days) ? +ic.age_days : 30, + comments: Number.isFinite(+ic.comments) ? +ic.comments : 66, + tags: Number.isFinite(+ic.tags) ? +ic.tags : 200, + }; + }; + + const getInviteSlots = () => { + const n = parseInt(cfg.websrv.user_invite_slots); + return Number.isFinite(n) && n > 0 ? n : 2; + }; + + // GET /api/v2/settings/invites + // Returns eligibility, criteria breakdown, tokens created by this user, and slot usage. + group.get(/\/invites$/, lib.loggedin, async (req, res) => { + if (cfg.websrv.enable_user_invites === false) { + return res.json({ success: false, msg: 'Invite system is disabled' }, 403); + } + try { + const userId = +req.session.id; + const username = req.session.user; + const totalSlots = getInviteSlots(); + const refreshDays = 30; + + // Gather eligibility stats in one query + const [stats] = await db` + SELECT + (SELECT COUNT(*) FROM items WHERE username = ${username} AND active = true AND is_deleted = false)::int AS upload_count, + EXTRACT(EPOCH FROM (NOW() - u.created_at)) / 86400 AS age_days, + (SELECT COUNT(*) FROM comments WHERE user_id = ${userId} AND is_deleted = false)::int AS comment_count, + (SELECT COUNT(*) FROM tags_assign WHERE user_id = ${userId})::int AS tag_count + FROM "user" u + WHERE u.id = ${userId} + `; + + const INVITE_CRITERIA = getInviteCriteria(); + const criteria = { + uploads: { current: stats.upload_count, required: INVITE_CRITERIA.uploads, met: stats.upload_count >= INVITE_CRITERIA.uploads }, + age_days: { current: Math.floor(stats.age_days), required: INVITE_CRITERIA.age_days, met: stats.age_days >= INVITE_CRITERIA.age_days }, + comments: { current: stats.comment_count, required: INVITE_CRITERIA.comments, met: stats.comment_count >= INVITE_CRITERIA.comments }, + tags: { current: stats.tag_count, required: INVITE_CRITERIA.tags, met: stats.tag_count >= INVITE_CRITERIA.tags }, + }; + + const eligible = Object.values(criteria).every(c => c.met); + + // Fetch all tokens this user created, join used_by name + const tokens = await db` + SELECT + it.id, + it.token, + it.is_used, + it.used_at, + it.created_at, + u.user AS used_by_name + FROM invite_tokens it + LEFT JOIN "user" u ON u.id = it.used_by + WHERE it.created_by = ${userId} + ORDER BY it.created_at DESC + `; + + // Slots consumed = tokens used within the last 30 days + const cutoff = new Date(Date.now() - refreshDays * 24 * 60 * 60 * 1000); + const slotsConsumed = tokens.filter(t => t.is_used && t.used_at && new Date(t.used_at) > cutoff).length; + const slotsAvailable = Math.max(0, totalSlots - slotsConsumed); + + return res.json({ + success: true, + eligible, + criteria, + tokens, + slots_total: totalSlots, + slots_consumed: slotsConsumed, + slots_available: slotsAvailable, + refresh_days: refreshDays, + }, 200); + } catch (e) { + console.error('[INVITES] GET error:', e); + return res.json({ success: false, msg: 'Error fetching invite data' }, 500); + } + }); + + // POST /api/v2/settings/invites/create + // Generates a new invite token if eligible and slots remain. + group.post(/\/invites\/create$/, lib.loggedin, async (req, res) => { + if (cfg.websrv.enable_user_invites === false) { + return res.json({ success: false, msg: 'Invite system is disabled' }, 403); + } + try { + const userId = +req.session.id; + const username = req.session.user; + const totalSlots = getInviteSlots(); + const refreshDays = 30; + + // Eligibility check + const [stats] = await db` + SELECT + (SELECT COUNT(*) FROM items WHERE username = ${username} AND active = true AND is_deleted = false)::int AS upload_count, + EXTRACT(EPOCH FROM (NOW() - u.created_at)) / 86400 AS age_days, + (SELECT COUNT(*) FROM comments WHERE user_id = ${userId} AND is_deleted = false)::int AS comment_count, + (SELECT COUNT(*) FROM tags_assign WHERE user_id = ${userId})::int AS tag_count + FROM "user" u WHERE u.id = ${userId} + `; + + const INVITE_CRITERIA = getInviteCriteria(); + const eligible = + stats.upload_count >= INVITE_CRITERIA.uploads && + stats.age_days >= INVITE_CRITERIA.age_days && + stats.comment_count >= INVITE_CRITERIA.comments && + stats.tag_count >= INVITE_CRITERIA.tags; + + if (!eligible) { + return res.json({ success: false, msg: 'You do not meet the eligibility criteria' }, 403); + } + + // Check available slots (used within last 30 days) + const cutoff = new Date(Date.now() - refreshDays * 24 * 60 * 60 * 1000); + const [{ slots_consumed }] = await db` + SELECT COUNT(*)::int AS slots_consumed + FROM invite_tokens + WHERE created_by = ${userId} + AND is_used = true + AND used_at > ${cutoff} + `; + + if (slots_consumed >= totalSlots) { + return res.json({ success: false, msg: 'No invite slots available. Slots refresh 30 days after use.' }, 403); + } + + // Generate token + const token = crypto.randomBytes(16).toString('hex').toUpperCase(); + await db` + INSERT INTO invite_tokens (token, created_at, created_by) + VALUES (${token}, ${~~(Date.now() / 1e3)}, ${userId}) + `; + + console.log(`[INVITES] User ${username} (${userId}) generated invite token ${token}`); + return res.json({ success: true, token }, 200); + } catch (e) { + console.error('[INVITES] Create error:', e); + return res.json({ success: false, msg: 'Error creating invite token' }, 500); + } + }); + + // POST /api/v2/settings/invites/delete + // Deletes an unused invite token owned by the calling user. + group.post(/\/invites\/delete$/, lib.loggedin, async (req, res) => { + if (cfg.websrv.enable_user_invites === false) { + return res.json({ success: false, msg: 'Invite system is disabled' }, 403); + } + try { + const { id } = req.post; + if (!id) return res.json({ success: false, msg: 'Missing token ID' }, 400); + + const result = await db` + DELETE FROM invite_tokens + WHERE id = ${+id} + AND created_by = ${+req.session.id} + AND is_used = false + RETURNING id + `; + + if (result.length === 0) { + return res.json({ success: false, msg: 'Token not found or already used' }, 404); + } + + return res.json({ success: true }, 200); + } catch (e) { + console.error('[INVITES] Delete error:', e); + return res.json({ success: false, msg: 'Error deleting invite token' }, 500); + } + }); + return group; }); diff --git a/src/inc/routes/register.mjs b/src/inc/routes/register.mjs index ba72240..cf1c8f8 100644 --- a/src/inc/routes/register.mjs +++ b/src/inc/routes/register.mjs @@ -186,7 +186,7 @@ export default (router, tpl) => { if (tokenRow.length > 0) { await db` update invite_tokens - set is_used = true, used_by = ${userId} + set is_used = true, used_by = ${userId}, used_at = now() where id = ${tokenRow[0].id} `; } diff --git a/src/inc/routes/settings.mjs b/src/inc/routes/settings.mjs index 63b928c..8224af7 100644 --- a/src/inc/routes/settings.mjs +++ b/src/inc/routes/settings.mjs @@ -51,6 +51,7 @@ export default (router, tpl) => { enable_swf: cfg.enable_swf, enable_data_export: cfg.websrv.enable_data_export, enable_user_api_keys: cfg.websrv.enable_user_api_keys !== false, + enable_user_invites: cfg.websrv.enable_user_invites !== false, site_domain: cfg.main.url.domain, session: (req.session && req.session.user) ? { ...req.session } : false, page_meta: { diff --git a/views/admin/tokens.html b/views/admin/tokens.html index 38be40a..aa30453 100644 --- a/views/admin/tokens.html +++ b/views/admin/tokens.html @@ -15,6 +15,7 @@ Source Used By Created + Used At Actions @@ -43,11 +44,12 @@ '' + '' + (t.created_by_matrix ? '[Matrix] ' + t.created_by_matrix + '' : - (t.created_by_discord ? ' ' + t.created_by_discord + '' : - (t.created_by_name ? 'Web/Admin (' + t.created_by_name + ')' : 'Web/Admin'))) + + (t.created_by_discord ? ' ' + t.created_by_discord + '' : + (t.created_by_name ? '👤 User: ' + t.created_by_name + '' : 'Admin'))) + '' + - '' + (t.used_by_name || '-') + '' + - '' + (t.created_at ? new Date(parseInt(t.created_at) * 1000).toLocaleString() : '-') + '' + + '' + (t.used_by_name || '—') + '' + + '' + (t.created_at ? new Date(parseInt(t.created_at) * 1000).toLocaleString() : '—') + '' + + '' + (t.used_at ? new Date(t.used_at).toLocaleString() : '—') + '' + '' + (!t.is_used ? '' : '') + '' + diff --git a/views/settings.html b/views/settings.html index f0391ff..c19b0ff 100644 --- a/views/settings.html +++ b/views/settings.html @@ -474,6 +474,268 @@ @endif + @if(enable_user_invites) +

{{ t('invites.section_title') }}

+
+ +

{{ t('invites.section_desc') }}

+ + +
+
+ + {{ t('invites.criteria_uploads') }} + +
+
+ + {{ t('invites.criteria_age') }} + +
+
+ + {{ t('invites.criteria_comments') }} + +
+
+ + {{ t('invites.criteria_tags') }} + +
+
+ + +
+ + +
+ + +
+ {{ t('invites.loading') }} +
+ + +
+ +
+
+
+ + + + + + + + @endif +