initialize project with basic structure and dependencies

This commit is contained in:
ImBenji 2026-04-27 22:26:40 +01:00
parent a3b3bd6b04
commit 83f2837ce6
7 changed files with 250 additions and 6 deletions

View file

@ -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

View file

@ -20,6 +20,10 @@ const submitting = ref(false);
const submitted = ref(false);
const formError = ref<string | null>(null);
const showLicenseModal = ref(false);
const licenseKey = ref("");
const licenseError = ref<string | null>(null);
const typeLabels: Record<UploadType, string> = {
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.";
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;
}
}
}
</script>
@ -227,7 +250,7 @@ async function submit() {
class="submit-btn"
:class="{ 'submit-btn--loading': submitting }"
:disabled="submitting || uploadedFiles.length === 0"
@click="submit"
@click="requestSubmit"
>
<span v-if="!submitting">Generate my course </span>
<span v-else class="btn-loading">
@ -238,6 +261,35 @@ async function submit() {
</div>
</div>
<!-- license key modal -->
<Teleport to="body">
<Transition name="modal">
<div v-if="showLicenseModal" class="modal-backdrop" @click.self="showLicenseModal = false">
<div class="modal-box">
<p class="modal-title">License key required</p>
<p class="modal-sub">Run <code>npm run gen:key</code> locally to get a key.</p>
<input
v-model="licenseKey"
class="modal-input"
type="text"
placeholder="paste key here…"
autofocus
@keydown.enter="submit"
@keydown.esc="showLicenseModal = false"
/>
<p v-if="licenseError" class="modal-error">{{ licenseError }}</p>
<div class="modal-actions">
<button class="modal-cancel" @click="showLicenseModal = false">Cancel</button>
<button class="modal-confirm" @click="submit">Continue </button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
@ -531,4 +583,101 @@ async function submit() {
.file-list-leave-active { transition: all 0.2s ease; }
.file-list-enter-from { opacity: 0; transform: translateY(-6px); }
.file-list-leave-to { opacity: 0; transform: translateX(10px); }
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-box {
background: var(--bg);
border: 1px solid var(--border-2);
border-radius: var(--r-surface);
padding: 28px 28px 24px;
width: 100%;
max-width: 420px;
}
.modal-title {
font-size: 16px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.modal-sub {
font-size: 13px;
color: var(--text-3);
margin-bottom: 18px;
}
.modal-sub code {
font-family: monospace;
background: var(--surface);
padding: 1px 5px;
border-radius: 4px;
font-size: 12px;
color: var(--accent);
}
.modal-input {
width: 100%;
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: var(--r-sm);
color: var(--text);
font-size: 13px;
font-family: monospace;
padding: 10px 12px;
outline: none;
box-sizing: border-box;
transition: border-color 0.15s;
}
.modal-input:focus { border-color: var(--accent); }
.modal-error {
font-size: 12px;
color: oklch(42% 0.12 15);
margin-top: 8px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.modal-cancel {
font-size: 14px;
color: var(--text-3);
background: none;
border: 1px solid var(--border-2);
border-radius: var(--r-btn);
padding: 8px 16px;
cursor: pointer;
transition: color 0.15s;
}
.modal-cancel:hover { color: var(--text); }
.modal-confirm {
font-size: 14px;
font-weight: 500;
background: var(--accent);
color: white;
border: none;
border-radius: var(--r-btn);
padding: 8px 20px;
cursor: pointer;
transition: opacity 0.15s;
}
.modal-confirm:hover { opacity: 0.88; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.18s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>

View file

@ -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",

40
scripts/gen-key.mjs Normal file
View file

@ -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();

13
scripts/gen-keypair.mjs Normal file
View file

@ -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.");

View file

@ -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({

31
server/utils/license.ts Normal file
View file

@ -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;
}
}