initialize project with basic structure and dependencies
This commit is contained in:
parent
a3b3bd6b04
commit
83f2837ce6
7 changed files with 250 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
40
scripts/gen-key.mjs
Normal 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
13
scripts/gen-keypair.mjs
Normal 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.");
|
||||
|
|
@ -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
31
server/utils/license.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue