From 83f2837ce6a45b927fae2f1ee75d5cb9c31ba08b Mon Sep 17 00:00:00 2001 From: ImBenji Date: Mon, 27 Apr 2026 22:26:40 +0100 Subject: [PATCH] initialize project with basic structure and dependencies --- .env.example | 4 + app/pages/new.vue | 157 ++++++++++++++++++++++++++++++- package.json | 4 +- scripts/gen-key.mjs | 40 ++++++++ scripts/gen-keypair.mjs | 13 +++ server/api/courses/index.post.ts | 7 +- server/utils/license.ts | 31 ++++++ 7 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 scripts/gen-key.mjs create mode 100644 scripts/gen-keypair.mjs create mode 100644 server/utils/license.ts diff --git a/.env.example b/.env.example index 023b668..3686187 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,7 @@ NUXT_ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM NUXT_FISH_AUDIO_API_KEY=your_fish_audio_api_key_here NUXT_FISH_AUDIO_VOICE_ID=your_fish_audio_reference_id_here + +# License key system (run npm run gen:keypair to generate) +# LICENSE_PRIVATE_KEY stays on your local machine only, never on the server +LICENSE_PUBLIC_KEY=your_public_key_here diff --git a/app/pages/new.vue b/app/pages/new.vue index 5b622e6..6348b1c 100644 --- a/app/pages/new.vue +++ b/app/pages/new.vue @@ -20,6 +20,10 @@ const submitting = ref(false); const submitted = ref(false); const formError = ref(null); +const showLicenseModal = ref(false); +const licenseKey = ref(""); +const licenseError = ref(null); + const typeLabels: Record = { slides: "Lecture Slides", past_paper: "Past Paper", @@ -93,16 +97,28 @@ async function applyOverride(entry: UploadedFile, newType: UploadType) { } } -async function submit() { +function requestSubmit() { if (uploadedFiles.value.length === 0) { formError.value = "Add at least one PDF to continue."; return; } formError.value = null; + licenseError.value = null; + licenseKey.value = ""; + showLicenseModal.value = true; +} + +async function submit() { + if (!licenseKey.value.trim()) { + licenseError.value = "Enter a license key."; + return; + } + showLicenseModal.value = false; submitting.value = true; try { const { courseId } = await $fetch<{ courseId: string }>("/api/courses", { method: "POST", + headers: { "x-license-key": licenseKey.value.trim() }, }); for (const entry of uploadedFiles.value) { await uploadFile(entry, courseId); @@ -115,8 +131,15 @@ async function submit() { submitted.value = true; setTimeout(() => router.push("/"), 3000); } catch (err: any) { - formError.value = err?.data?.message ?? "Something went wrong. Please try again."; - submitting.value = false; + const msg = err?.data?.message ?? "Something went wrong. Please try again."; + if (err?.status === 401) { + licenseError.value = msg; + showLicenseModal.value = true; + submitting.value = false; + } else { + formError.value = msg; + submitting.value = false; + } } } @@ -227,7 +250,7 @@ async function submit() { class="submit-btn" :class="{ 'submit-btn--loading': submitting }" :disabled="submitting || uploadedFiles.length === 0" - @click="submit" + @click="requestSubmit" > Generate my course → @@ -238,6 +261,35 @@ async function submit() { + + + + + + + diff --git a/package.json b/package.json index fab094b..f4bdfa2 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "preview": "nuxt preview", "postinstall": "nuxt prepare", "db:migrate": "npx tsx server/db/migrate.ts", - "db:clear": "rm -f revisione.db revisione.db-shm revisione.db-wal && npm run db:migrate" + "db:clear": "rm -f revisione.db revisione.db-shm revisione.db-wal && npm run db:migrate", + "gen:keypair": "node scripts/gen-keypair.mjs", + "gen:key": "node scripts/gen-key.mjs" }, "dependencies": { "better-sqlite3": "^12.9.0", diff --git a/scripts/gen-key.mjs b/scripts/gen-key.mjs new file mode 100644 index 0000000..f26d453 --- /dev/null +++ b/scripts/gen-key.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { createPrivateKey, sign } from "crypto"; +import { readFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const envPath = resolve(dirname(fileURLToPath(import.meta.url)), "../.env"); + +let privateKeyB64 = process.env.LICENSE_PRIVATE_KEY; + +if (!privateKeyB64) { + try { + const envContents = readFileSync(envPath, "utf8"); + const match = envContents.match(/^LICENSE_PRIVATE_KEY=(.+)$/m); + if (match) privateKeyB64 = match[1].trim(); + } catch {} +} + +if (!privateKeyB64) { + console.error("LICENSE_PRIVATE_KEY not found in env or .env file"); + process.exit(1); +} + +const window = Math.floor(Date.now() / 1000 / 300); +const msg = Buffer.from(String(window)); + +const privateKey = createPrivateKey({ + key: Buffer.from(privateKeyB64, "base64"), + format: "der", + type: "pkcs8", +}); + +const sig = sign(null, msg, privateKey); +const key = sig.toString("base64url"); + +const minutesLeft = 5 - (Math.floor(Date.now() / 1000) % 300) / 60; + +console.log(`\nLicense key (valid for ~${minutesLeft.toFixed(1)} more minutes):\n`); +console.log(key); +console.log(); diff --git a/scripts/gen-keypair.mjs b/scripts/gen-keypair.mjs new file mode 100644 index 0000000..4dd70b0 --- /dev/null +++ b/scripts/gen-keypair.mjs @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import { generateKeyPairSync } from "crypto"; + +const { privateKey, publicKey } = generateKeyPairSync("ed25519", { + privateKeyEncoding: { type: "pkcs8", format: "der" }, + publicKeyEncoding: { type: "spki", format: "der" }, +}); + +console.log("Add these to your .env files:\n"); +console.log(`LICENSE_PRIVATE_KEY=${privateKey.toString("base64")}`); +console.log(`LICENSE_PUBLIC_KEY=${publicKey.toString("base64")}`); +console.log("\nLICENSE_PRIVATE_KEY goes on your LOCAL machine only."); +console.log("LICENSE_PUBLIC_KEY goes on the SERVER .env only."); diff --git a/server/api/courses/index.post.ts b/server/api/courses/index.post.ts index 4403835..978b40c 100644 --- a/server/api/courses/index.post.ts +++ b/server/api/courses/index.post.ts @@ -1,8 +1,13 @@ import { db } from "../../db/index"; import { courses } from "../../db/schema"; import { randomUUID } from "crypto"; +import { verifyLicenseKey } from "../../utils/license"; -export default defineEventHandler(async () => { +export default defineEventHandler(async (event) => { + const key = getHeader(event, "x-license-key") ?? ""; + if (!verifyLicenseKey(key)) { + throw createError({ statusCode: 401, message: "Invalid or expired license key" }); + } const id = randomUUID(); await db.insert(courses).values({ diff --git a/server/utils/license.ts b/server/utils/license.ts new file mode 100644 index 0000000..51f783a --- /dev/null +++ b/server/utils/license.ts @@ -0,0 +1,31 @@ +import { createPublicKey, verify } from "crypto"; + +function getPublicKey() { + const b64 = process.env.LICENSE_PUBLIC_KEY; + if (!b64) throw createError({ statusCode: 500, message: "License system not configured" }); + + return createPublicKey({ + key: Buffer.from(b64, "base64"), + format: "der", + type: "spki", + }); +} + +export function verifyLicenseKey(key: string): boolean { + try { + const pubKey = getPublicKey(); + const now = Math.floor(Date.now() / 1000 / 300); + + const sig = Buffer.from(key, "base64url"); + + // accept current window and the one before (edge case tolerance) + for (const window of [now, now - 1]) { + const msg = Buffer.from(String(window)); + if (verify(null, msg, pubKey, sig)) return true; + } + + return false; + } catch { + return false; + } +}