diff --git a/.env.example b/.env.example index 81ec5a1..4838b84 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,6 @@ NOTIFY_SMTP_PASS= # Bootstrap invitations BOOTSTRAP_INVITE_DEADLINE=2025-09-01T00:00:00Z + +# Manual invitation generator +MANUAL_INVITE_PASSWORD=pm.local@zghl.de diff --git a/app/pages/api-docs.vue b/app/pages/api-docs.vue index 1a9f22a..4306919 100644 --- a/app/pages/api-docs.vue +++ b/app/pages/api-docs.vue @@ -95,6 +95,7 @@ const publicEndpoints = [ { method: 'POST', path: '/api/service/auth/refresh', description: 'Access-Token anhand des Refresh-Cookies erneuern.' }, { method: 'GET', path: '/api/service/invitations/{code}', description: 'Einladungscode prüfen (gültig, abgelaufen, verwendet).' }, { method: 'POST', path: '/api/service/invitations/bootstrap', description: 'Bootstrap-Einladungscode generieren (aktiv bis 01.07.2024, optionales Label).' }, + { method: 'POST', path: '/api/service/invitations/manual', description: 'Manuellen Einladungscode mit Passwortschutz erstellen (intern).' }, ] const protectedEndpoints = [ diff --git a/app/pages/invite.vue b/app/pages/invite.vue new file mode 100644 index 0000000..1c71a0e --- /dev/null +++ b/app/pages/invite.vue @@ -0,0 +1,151 @@ + + + + + OpenSquawk + Einladungscode erstellen + Passwortgeschützte Oberfläche für interne Freischaltungen. + + + + + Passwort + + + + + Label (optional) + + + + + Einladungscode generieren + + + Wird erstellt… + + + + + + + {{ errorMessage }} + + + + + + + Einladungscode + {{ result.code }} + + + + Gültig bis + {{ expiresAtDisplay }} + + + Label + {{ result.label || 'Kein Label gesetzt' }} + + + + + + + + + + + diff --git a/nuxt.config.ts b/nuxt.config.ts index 96d6d5d..57d2e26 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -20,6 +20,7 @@ export default defineNuxtConfig({ ttsModel: process.env.TTS_MODEL || 'tts-1', jwtSecret: process.env.JWT_SECRET, jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET, + manualInvitePassword: process.env.MANUAL_INVITE_PASSWORD, mongoose: { uri: process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/opensquawk', options: {}, diff --git a/server/api/service/invitations/manual.post.ts b/server/api/service/invitations/manual.post.ts new file mode 100644 index 0000000..8bc7720 --- /dev/null +++ b/server/api/service/invitations/manual.post.ts @@ -0,0 +1,63 @@ +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto' +import { createError, readBody } from 'h3' +import { useRuntimeConfig } from '#imports' +import { InvitationCode } from '../../../models/InvitationCode' + +interface ManualInviteRequestBody { + password?: string + label?: string +} + +interface ManualInviteResponse { + success: true + code: string + expiresAt: string + label: string | null +} + +function generateCode() { + return randomBytes(4).toString('hex').toUpperCase() +} + +function safeComparePassword(provided: string, expected: string) { + const key = 'opensquawk-manual-invite' + const providedDigest = createHmac('sha256', key).update(provided).digest() + const expectedDigest = createHmac('sha256', key).update(expected).digest() + return timingSafeEqual(providedDigest, expectedDigest) +} + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const expectedPassword = (config.manualInvitePassword as string | undefined)?.trim() || '' + + if (!expectedPassword) { + throw createError({ statusCode: 500, statusMessage: 'Konfiguration für manuellen Einladungscode fehlt' }) + } + + const body = await readBody(event).catch(() => ({}) as ManualInviteRequestBody) + const providedPassword = body.password?.trim() || '' + + if (!providedPassword || !safeComparePassword(providedPassword, expectedPassword)) { + throw createError({ statusCode: 401, statusMessage: 'Ungültiges Passwort' }) + } + + const now = new Date() + const code = generateCode() + const expiresAt = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 30) + const label = body.label?.trim() || undefined + + await InvitationCode.create({ + code, + createdAt: now, + expiresAt, + channel: 'manual', + label, + }) + + return { + success: true, + code, + expiresAt: expiresAt.toISOString(), + label: label ?? null, + } +}) diff --git a/server/models/InvitationCode.ts b/server/models/InvitationCode.ts index 5d15031..0d1f401 100644 --- a/server/models/InvitationCode.ts +++ b/server/models/InvitationCode.ts @@ -9,7 +9,7 @@ export interface InvitationCodeDocument extends mongoose.Document { expiresAt?: Date usedBy?: mongoose.Types.ObjectId usedAt?: Date - channel: 'user' | 'bootstrap' + channel: 'user' | 'bootstrap' | 'manual' label?: string } @@ -20,7 +20,7 @@ const invitationSchema = new mongoose.Schema({ expiresAt: { type: Date }, usedBy: { type: Schema.Types.ObjectId, ref: 'User' }, usedAt: { type: Date }, - channel: { type: String, enum: ['user', 'bootstrap'], default: 'user' }, + channel: { type: String, enum: ['user', 'bootstrap', 'manual'], default: 'user' }, label: { type: String, trim: true }, })
OpenSquawk
Passwortgeschützte Oberfläche für interne Freischaltungen.
+ {{ errorMessage }} +
Einladungscode
{{ result.code }}
Gültig bis
{{ expiresAtDisplay }}
Label
{{ result.label || 'Kein Label gesetzt' }}