Initial Commit
This commit is contained in:
commit
c9932b80a7
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "styles-rokojori",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "source/server/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node scripts/copy-pages.js && ts-node --project tsconfig.ts-node.json source/server/index.ts",
|
||||||
|
"dev": "ts-node --project tsconfig.ts-node.json source/server/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.7",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
const PAGES = path.join(ROOT, 'source', 'pages');
|
||||||
|
const DEST = path.join(ROOT, 'build', 'app');
|
||||||
|
|
||||||
|
fs.mkdirSync(DEST, { recursive: true });
|
||||||
|
|
||||||
|
for (const file of fs.readdirSync(PAGES)) {
|
||||||
|
if (file.endsWith('.html')) {
|
||||||
|
fs.copyFileSync(path.join(PAGES, file), path.join(DEST, file));
|
||||||
|
console.log(`copied ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Add fonts — Styles</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: #111; color: #eee; min-height: 100vh; }
|
||||||
|
|
||||||
|
.topbar { display: flex; align-items: center; gap: 1rem; padding: 1rem 1.5rem; border-bottom: 1px solid #1e1e1e; }
|
||||||
|
.topbar a { color: #555; text-decoration: none; font-size: 0.85rem; }
|
||||||
|
.topbar a:hover { color: #aaa; }
|
||||||
|
.topbar h1 { font-size: 1rem; font-weight: 600; color: #eee; }
|
||||||
|
|
||||||
|
.form { max-width: 520px; margin: 2rem auto; padding: 0 1.5rem; }
|
||||||
|
|
||||||
|
label { display: block; font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; margin-top: 1.25rem; }
|
||||||
|
label:first-child { margin-top: 0; }
|
||||||
|
input[type=text] { display: block; width: 100%; padding: 0.6rem 0.75rem; background: #1c1c1c; border: 1px solid #2a2a2a; border-radius: 4px; color: #eee; font-size: 0.95rem; }
|
||||||
|
input[type=text]:focus { outline: none; border-color: #555; }
|
||||||
|
|
||||||
|
.hint { font-size: 0.75rem; color: #444; margin-top: 0.3rem; }
|
||||||
|
|
||||||
|
button { margin-top: 1.75rem; width: 100%; padding: 0.65rem; background: #2563eb; border: none; border-radius: 4px; color: #fff; font-size: 0.95rem; cursor: pointer; }
|
||||||
|
button:hover:not(:disabled) { background: #1d4ed8; }
|
||||||
|
button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.status { margin-top: 1.25rem; font-size: 0.85rem; padding: 0.75rem 1rem; border-radius: 4px; display: none; }
|
||||||
|
.status.ok { background: #14532d; color: #86efac; display: block; }
|
||||||
|
.status.err { background: #450a0a; color: #fca5a5; display: block; }
|
||||||
|
|
||||||
|
.preview { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid #1e1e1e; display: none; }
|
||||||
|
.preview p.label { font-size: 0.75rem; color: #555; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 0.75rem; }
|
||||||
|
.preview-sentence { font-size: clamp(1.5rem, 4vw, 3rem); color: #eee; line-height: 1.3; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="topbar">
|
||||||
|
<a href="/">← Styles</a>
|
||||||
|
<h1>Add fonts</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form">
|
||||||
|
|
||||||
|
<label for="family">Font family name</label>
|
||||||
|
<input type="text" id="family" placeholder="e.g. Barlow" autocomplete="off">
|
||||||
|
|
||||||
|
<label for="weights">Weights (optional)</label>
|
||||||
|
<input type="text" id="weights" placeholder="e.g. 400,700,900 — leave blank for all">
|
||||||
|
<p class="hint">Comma-separated numeric weights. If blank, all available weights are downloaded.</p>
|
||||||
|
|
||||||
|
<label for="sentence">Preview sentence</label>
|
||||||
|
<input type="text" id="sentence" value="The quick brown fox jumps over the lazy dog">
|
||||||
|
|
||||||
|
<button id="btn">Download from Google Fonts</button>
|
||||||
|
|
||||||
|
<div class="status" id="status"></div>
|
||||||
|
|
||||||
|
<div class="preview" id="preview">
|
||||||
|
<p class="label">Preview</p>
|
||||||
|
<p class="preview-sentence" id="preview-text"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const btn = document.getElementById('btn');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const preview = document.getElementById('preview');
|
||||||
|
const previewText = document.getElementById('preview-text');
|
||||||
|
|
||||||
|
function showStatus(msg, ok) {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.className = 'status ' + (ok ? 'ok' : 'err');
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const family = document.getElementById('family').value.trim();
|
||||||
|
const wRaw = document.getElementById('weights').value.trim();
|
||||||
|
const sentence = document.getElementById('sentence').value || 'The quick brown fox';
|
||||||
|
|
||||||
|
if (!family) { showStatus('Enter a font family name.', false); return; }
|
||||||
|
|
||||||
|
const weights = wRaw
|
||||||
|
? wRaw.split(',').map(w => parseInt(w.trim(), 10)).filter(n => !isNaN(n))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Downloading…';
|
||||||
|
statusEl.className = 'status';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/fonts/download', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ family, weights }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showStatus(`Downloaded ${data.downloaded.length} file(s): ${data.downloaded.join(', ')}`, true);
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = `/get-font?family=${encodeURIComponent(family)}`;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
previewText.style.fontFamily = `'${family}', sans-serif`;
|
||||||
|
previewText.textContent = sentence;
|
||||||
|
preview.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
showStatus(data.error ?? 'Download failed.', false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showStatus('Network error.', false);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Download from Google Fonts';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Styles — rokojori</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: #111; color: #eee; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||||
|
|
||||||
|
.hero { text-align: center; padding: 2rem; }
|
||||||
|
.hero h1 { font-size: 5rem; font-weight: 900; letter-spacing: 0.15em; color: #fff; margin-bottom: 1.5rem; }
|
||||||
|
.hero p { color: #666; margin-bottom: 2rem; font-size: 1rem; }
|
||||||
|
.hero a.btn { display: inline-block; padding: 0.65rem 1.75rem; background: #2563eb; color: #fff; text-decoration: none; border-radius: 4px; font-size: 0.95rem; }
|
||||||
|
.hero a.btn:hover { background: #1d4ed8; }
|
||||||
|
|
||||||
|
.sections { width: 100%; max-width: 480px; padding: 2rem; }
|
||||||
|
.sections h2 { font-size: 0.8rem; letter-spacing: 0.12em; text-transform: uppercase; color: #555; margin-bottom: 1.25rem; }
|
||||||
|
.section-link { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; background: #1c1c1c; border: 1px solid #2a2a2a; border-radius: 6px; text-decoration: none; color: #eee; margin-bottom: 0.75rem; }
|
||||||
|
.section-link:hover { border-color: #444; background: #222; }
|
||||||
|
.section-link .label { font-size: 0.95rem; }
|
||||||
|
.section-link .arrow { color: #555; font-size: 1.1rem; }
|
||||||
|
|
||||||
|
#no-access { text-align: center; padding: 2rem; }
|
||||||
|
#no-access p { color: #666; margin-bottom: 1.5rem; }
|
||||||
|
#no-access a { color: #2563eb; text-decoration: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="hero" class="hero" hidden>
|
||||||
|
<h1>STYLES</h1>
|
||||||
|
<p>Font and asset hosting for rokojori projects.</p>
|
||||||
|
<a class="btn" href="https://account.rokojori.com/login?redirect=https://styles.rokojori.com">Log in</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sections" class="sections" hidden>
|
||||||
|
<h2>Sections</h2>
|
||||||
|
<a href="/list-fonts" class="section-link">
|
||||||
|
<span class="label">List fonts</span>
|
||||||
|
<span class="arrow">›</span>
|
||||||
|
</a>
|
||||||
|
<a href="/add-fonts" class="section-link">
|
||||||
|
<span class="label">Add fonts</span>
|
||||||
|
<span class="arrow">›</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="no-access" hidden>
|
||||||
|
<p>Your account does not have access to Styles.</p>
|
||||||
|
<a href="https://account.rokojori.com/profile.html">Manage account</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
fetch('/api/fonts')
|
||||||
|
.then(res => {
|
||||||
|
if (res.ok) { document.getElementById('sections').hidden = false; }
|
||||||
|
else if (res.status === 403) { document.getElementById('no-access').hidden = false; }
|
||||||
|
else { document.getElementById('hero').hidden = false; }
|
||||||
|
})
|
||||||
|
.catch(() => { document.getElementById('hero').hidden = false; });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>List fonts — Styles</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: #111; color: #eee; min-height: 100vh; }
|
||||||
|
|
||||||
|
.topbar { display: flex; align-items: center; gap: 1rem; padding: 1rem 1.5rem; border-bottom: 1px solid #1e1e1e; }
|
||||||
|
.topbar a { color: #555; text-decoration: none; font-size: 0.85rem; }
|
||||||
|
.topbar a:hover { color: #aaa; }
|
||||||
|
.topbar h1 { font-size: 1rem; font-weight: 600; color: #eee; }
|
||||||
|
|
||||||
|
.preview-bar { padding: 1rem 1.5rem; border-bottom: 1px solid #1e1e1e; display: flex; gap: 0.75rem; align-items: center; }
|
||||||
|
.preview-bar label { font-size: 0.8rem; color: #666; white-space: nowrap; }
|
||||||
|
.preview-bar input { flex: 1; background: #1c1c1c; border: 1px solid #2a2a2a; border-radius: 4px; color: #eee; padding: 0.5rem 0.75rem; font-size: 0.95rem; }
|
||||||
|
.preview-bar input:focus { outline: none; border-color: #555; }
|
||||||
|
|
||||||
|
.families { padding: 1.5rem; display: flex; flex-direction: column; gap: 2.5rem; }
|
||||||
|
.family-block .family-name { font-size: 0.75rem; letter-spacing: 0.1em; text-transform: uppercase; color: #555; margin-bottom: 1rem; }
|
||||||
|
.weight-row { margin-bottom: 0.5rem; display: flex; align-items: baseline; gap: 1rem; }
|
||||||
|
.weight-label { font-size: 0.7rem; color: #444; width: 3.5rem; flex-shrink: 0; }
|
||||||
|
.weight-sample { color: #eee; line-height: 1.2; }
|
||||||
|
.weight-sample.italic { font-style: italic; }
|
||||||
|
|
||||||
|
.empty { padding: 3rem 1.5rem; text-align: center; color: #444; font-size: 0.9rem; }
|
||||||
|
.loading { padding: 3rem 1.5rem; text-align: center; color: #444; font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="topbar">
|
||||||
|
<a href="/">← Styles</a>
|
||||||
|
<h1>List fonts</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-bar">
|
||||||
|
<label for="sentence">Preview</label>
|
||||||
|
<input type="text" id="sentence" value="The quick brown fox jumps over the lazy dog">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="content">
|
||||||
|
<p class="loading">Loading…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const sentenceInput = document.getElementById('sentence');
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
let families = [];
|
||||||
|
|
||||||
|
function renderSize(weight, sentence) {
|
||||||
|
const size = Math.max(1.2, Math.min(4, window.innerWidth / (sentence.length * 0.6 + 4)));
|
||||||
|
return `${size.toFixed(2)}rem`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const sentence = sentenceInput.value || 'The quick brown fox';
|
||||||
|
if (families.length === 0) {
|
||||||
|
content.innerHTML = '<p class="empty">No fonts downloaded yet. <a href="/add-fonts" style="color:#2563eb;text-decoration:none">Add one →</a></p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
content.className = 'families';
|
||||||
|
content.innerHTML = families.map(f => {
|
||||||
|
const rows = [
|
||||||
|
...f.weights.map(w => `
|
||||||
|
<div class="weight-row">
|
||||||
|
<span class="weight-label">${w}</span>
|
||||||
|
<span class="weight-sample" style="font-family:'${f.family}';font-weight:${w};font-size:clamp(1rem,3vw,2.5rem)">${sentence}</span>
|
||||||
|
</div>`),
|
||||||
|
...f.italics.map(w => `
|
||||||
|
<div class="weight-row">
|
||||||
|
<span class="weight-label">${w}i</span>
|
||||||
|
<span class="weight-sample italic" style="font-family:'${f.family}';font-weight:${w};font-size:clamp(1rem,3vw,2.5rem)">${sentence}</span>
|
||||||
|
</div>`),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const cssUrl = `/get-font?family=${encodeURIComponent(f.family)}`;
|
||||||
|
return `
|
||||||
|
<div class="family-block">
|
||||||
|
<link rel="stylesheet" href="${cssUrl}">
|
||||||
|
<p class="family-name">${f.family}</p>
|
||||||
|
${rows}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
sentenceInput.addEventListener('input', render);
|
||||||
|
|
||||||
|
fetch('/api/fonts')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => { families = data; render(); })
|
||||||
|
.catch(() => { content.innerHTML = '<p class="empty">Failed to load fonts.</p>'; });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import path from 'path';
|
||||||
|
import { requireAuth } from './middleware/requireAuth';
|
||||||
|
import { requireAccess, STYLES_RULES } from './middleware/requireAccess';
|
||||||
|
import { publicFontsRouter, apiFontsRouter } from './routes/fonts';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.set( 'trust proxy', 1 );
|
||||||
|
app.use( express.json() );
|
||||||
|
app.use( cookieParser() );
|
||||||
|
|
||||||
|
// Public: font CSS endpoint
|
||||||
|
app.use( publicFontsRouter );
|
||||||
|
|
||||||
|
// Public: raw font files
|
||||||
|
app.use( '/fonts', express.static( path.join( __dirname, '..', '..', 'storage', 'fonts' ) ) );
|
||||||
|
|
||||||
|
// Auth-gated: font management API
|
||||||
|
app.use( '/api/fonts', requireAuth, requireAccess( STYLES_RULES ), apiFontsRouter );
|
||||||
|
|
||||||
|
// Auth-gated: pages (served dynamically so middleware can run before sending the file)
|
||||||
|
const pagesDir = path.join( __dirname, '..', '..', 'build', 'app' );
|
||||||
|
|
||||||
|
app.get( '/list-fonts', requireAuth, requireAccess( STYLES_RULES ), ( _req, res ) =>
|
||||||
|
{
|
||||||
|
res.sendFile( path.join( pagesDir, 'list-fonts.html' ) );
|
||||||
|
} );
|
||||||
|
|
||||||
|
app.get( '/add-fonts', requireAuth, requireAccess( STYLES_RULES ), ( _req, res ) =>
|
||||||
|
{
|
||||||
|
res.sendFile( path.join( pagesDir, 'add-fonts.html' ) );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// Public: static pages (index.html, assets)
|
||||||
|
app.use( express.static( pagesDir ) );
|
||||||
|
|
||||||
|
const PORT = process.env.PORT ? Number( process.env.PORT ) : 3002;
|
||||||
|
app.listen( PORT, () => console.log( `styles running on http://localhost:${PORT}` ) );
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
export type AccessRule = { role: string; product?: string };
|
||||||
|
|
||||||
|
export const STYLES_RULES: AccessRule[] = [
|
||||||
|
{ role: 'admin' },
|
||||||
|
{ role: 'user', product: 'styles' },
|
||||||
|
{ role: 'user', product: 'premium' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function requireAccess( rules: AccessRule[] )
|
||||||
|
{
|
||||||
|
return ( req: Request, res: Response, next: NextFunction ): void =>
|
||||||
|
{
|
||||||
|
const auth = req.auth;
|
||||||
|
if ( !auth )
|
||||||
|
{
|
||||||
|
if ( req.accepts( 'html' ) )
|
||||||
|
{
|
||||||
|
res.redirect( `https://account.rokojori.com/login?redirect=${encodeURIComponent( req.originalUrl )}` );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
res.status( 401 ).json( { error: 'Not authenticated' } );
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( auth.roles.includes( 'superadmin' ) ) { next(); return; }
|
||||||
|
|
||||||
|
const allowed = rules.some( rule =>
|
||||||
|
auth.roles.includes( rule.role ) &&
|
||||||
|
( !rule.product || auth.products.includes( rule.product ) )
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( allowed ) { next(); return; }
|
||||||
|
|
||||||
|
if ( req.accepts( 'html' ) )
|
||||||
|
{
|
||||||
|
res.redirect( '/' );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
res.status( 403 ).json( { error: 'Forbidden' } );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
export interface AuthPayload
|
||||||
|
{
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
roles: string[];
|
||||||
|
products: string[];
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global
|
||||||
|
{
|
||||||
|
namespace Express
|
||||||
|
{
|
||||||
|
interface Request
|
||||||
|
{
|
||||||
|
auth?: AuthPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuth( req: Request, res: Response, next: NextFunction ): void
|
||||||
|
{
|
||||||
|
const token = req.cookies?.accessToken ?? extractBearer( req );
|
||||||
|
if ( !token )
|
||||||
|
{
|
||||||
|
res.status( 401 ).json( { error: 'Not authenticated' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
req.auth = jwt.verify( token, process.env.JWT_SECRET ?? '' ) as AuthPayload;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
res.status( 401 ).json( { error: 'Invalid token' } );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBearer( req: Request ): string | undefined
|
||||||
|
{
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
if ( auth?.startsWith( 'Bearer ' ) ) return auth.slice( 7 );
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,282 @@
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import https from 'https';
|
||||||
|
|
||||||
|
const FONTS_DIR = path.join( __dirname, '..', '..', '..', 'storage', 'fonts' );
|
||||||
|
|
||||||
|
const GOOGLE_FONTS_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||||
|
const ALL_WEIGHTS = [ 100, 200, 300, 400, 500, 600, 700, 800, 900 ];
|
||||||
|
|
||||||
|
// ─── types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface FontMeta
|
||||||
|
{
|
||||||
|
family: string;
|
||||||
|
weights: string[];
|
||||||
|
italics: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fontsDir( family: string ): string
|
||||||
|
{
|
||||||
|
return path.join( FONTS_DIR, family.toLowerCase() );
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMeta( family: string ): FontMeta | null
|
||||||
|
{
|
||||||
|
const p = path.join( fontsDir( family ), 'meta.json' );
|
||||||
|
if ( !fs.existsSync( p ) ) return null;
|
||||||
|
return JSON.parse( fs.readFileSync( p, 'utf8' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMeta( meta: FontMeta ): void
|
||||||
|
{
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join( fontsDir( meta.family ), 'meta.json' ),
|
||||||
|
JSON.stringify( meta, null, 2 )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fontFileExists( family: string, weight: string, italic: boolean ): boolean
|
||||||
|
{
|
||||||
|
const name = italic ? `${weight}-italic.woff2` : `${weight}.woff2`;
|
||||||
|
return fs.existsSync( path.join( fontsDir( family ), name ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFontFace( displayFamily: string, weight: string, italic: boolean ): string
|
||||||
|
{
|
||||||
|
const style = italic ? 'italic' : 'normal';
|
||||||
|
const fileName = italic ? `${weight}-italic.woff2` : `${weight}.woff2`;
|
||||||
|
const url = `/fonts/${displayFamily.toLowerCase()}/${fileName}`;
|
||||||
|
return [
|
||||||
|
'@font-face {',
|
||||||
|
` font-family: '${displayFamily}';`,
|
||||||
|
` font-style: ${style};`,
|
||||||
|
` font-weight: ${weight};`,
|
||||||
|
` src: url('${url}') format('woff2');`,
|
||||||
|
' font-display: swap;',
|
||||||
|
'}',
|
||||||
|
].join( '\n' );
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpsGet( url: string, headers: Record<string, string> = {} ): Promise<string>
|
||||||
|
{
|
||||||
|
return new Promise( ( resolve, reject ) =>
|
||||||
|
{
|
||||||
|
const req = https.get( url, { headers }, res =>
|
||||||
|
{
|
||||||
|
if ( res.statusCode >= 300 && res.statusCode < 400 && res.headers.location )
|
||||||
|
{
|
||||||
|
resolve( httpsGet( res.headers.location, headers ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data = '';
|
||||||
|
res.on( 'data', chunk => { data += chunk; } );
|
||||||
|
res.on( 'end', () => resolve( data ) );
|
||||||
|
} );
|
||||||
|
req.on( 'error', reject );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpsGetBuffer( url: string ): Promise<Buffer>
|
||||||
|
{
|
||||||
|
return new Promise( ( resolve, reject ) =>
|
||||||
|
{
|
||||||
|
const req = https.get( url, res =>
|
||||||
|
{
|
||||||
|
if ( res.statusCode >= 300 && res.statusCode < 400 && res.headers.location )
|
||||||
|
{
|
||||||
|
resolve( httpsGetBuffer( res.headers.location ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on( 'data', chunk => { chunks.push( chunk ); } );
|
||||||
|
res.on( 'end', () => resolve( Buffer.concat( chunks ) ) );
|
||||||
|
} );
|
||||||
|
req.on( 'error', reject );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── public router ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// GET /get-font — returns a CSS file with @font-face rules
|
||||||
|
//
|
||||||
|
// Modes (mutually exclusive):
|
||||||
|
// ?family=barlow → all files in directory
|
||||||
|
// ?family=barlow&weights=400,700 → those weights + auto-italic if exists
|
||||||
|
// ?family=barlow&variations=400,400 italic → exactly those variations
|
||||||
|
|
||||||
|
export const publicFontsRouter = Router();
|
||||||
|
|
||||||
|
publicFontsRouter.get( '/get-font', ( req: Request, res: Response ): void =>
|
||||||
|
{
|
||||||
|
const familyParam = ( req.query.family as string | undefined )?.trim();
|
||||||
|
if ( !familyParam )
|
||||||
|
{
|
||||||
|
res.status( 400 ).send( '/* family param required */' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = readMeta( familyParam );
|
||||||
|
if ( !meta )
|
||||||
|
{
|
||||||
|
res.status( 404 ).send( `/* font family '${familyParam}' not found */` );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks: string[] = [];
|
||||||
|
|
||||||
|
if ( req.query.variations )
|
||||||
|
{
|
||||||
|
const vars = ( req.query.variations as string )
|
||||||
|
.split( ',' )
|
||||||
|
.map( v => v.trim().toLowerCase() );
|
||||||
|
|
||||||
|
for ( const v of vars )
|
||||||
|
{
|
||||||
|
const italic = v.endsWith( ' italic' );
|
||||||
|
const weight = italic ? v.slice( 0, -7 ).trim() : v;
|
||||||
|
if ( fontFileExists( meta.family, weight, italic ) )
|
||||||
|
blocks.push( buildFontFace( meta.family, weight, italic ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if ( req.query.weights )
|
||||||
|
{
|
||||||
|
const weights = ( req.query.weights as string )
|
||||||
|
.split( ',' )
|
||||||
|
.map( w => w.trim().toLowerCase() );
|
||||||
|
|
||||||
|
for ( const w of weights )
|
||||||
|
{
|
||||||
|
if ( fontFileExists( meta.family, w, false ) )
|
||||||
|
blocks.push( buildFontFace( meta.family, w, false ) );
|
||||||
|
if ( fontFileExists( meta.family, w, true ) )
|
||||||
|
blocks.push( buildFontFace( meta.family, w, true ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
for ( const w of meta.weights )
|
||||||
|
blocks.push( buildFontFace( meta.family, w, false ) );
|
||||||
|
for ( const w of meta.italics )
|
||||||
|
blocks.push( buildFontFace( meta.family, w, true ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
res
|
||||||
|
.set( 'Content-Type', 'text/css; charset=utf-8' )
|
||||||
|
.set( 'Cache-Control', 'public, max-age=86400' )
|
||||||
|
.send( blocks.join( '\n\n' ) );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// ─── api router (auth-gated, mounted at /api/fonts) ──────────────────────────
|
||||||
|
|
||||||
|
export const apiFontsRouter = Router();
|
||||||
|
|
||||||
|
// GET / — list all available font families
|
||||||
|
apiFontsRouter.get( '/', ( _req: Request, res: Response ): void =>
|
||||||
|
{
|
||||||
|
if ( !fs.existsSync( FONTS_DIR ) )
|
||||||
|
{
|
||||||
|
res.json( [] );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const families = fs.readdirSync( FONTS_DIR, { withFileTypes: true } )
|
||||||
|
.filter( d => d.isDirectory() )
|
||||||
|
.map( d =>
|
||||||
|
{
|
||||||
|
const meta = readMeta( d.name );
|
||||||
|
return meta ?? { family: d.name, weights: [], italics: [] };
|
||||||
|
} );
|
||||||
|
|
||||||
|
res.json( families );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// POST /download — fetch from Google Fonts and save to storage
|
||||||
|
apiFontsRouter.post( '/download', async ( req: Request, res: Response ): Promise<void> =>
|
||||||
|
{
|
||||||
|
const { family, weights } = req.body as { family: string; weights?: number[] };
|
||||||
|
|
||||||
|
if ( !family || typeof family !== 'string' )
|
||||||
|
{
|
||||||
|
res.status( 400 ).json( { error: 'family is required' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedWeights = ( weights && weights.length > 0 ) ? weights : ALL_WEIGHTS;
|
||||||
|
|
||||||
|
const wghts = requestedWeights.map( w => `0,${w};1,${w}` ).join( ';' );
|
||||||
|
const googleUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent( family )}:ital,wght@${wghts}&display=swap`;
|
||||||
|
|
||||||
|
let css: string;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
css = await httpsGet( googleUrl, { 'User-Agent': GOOGLE_FONTS_UA } );
|
||||||
|
}
|
||||||
|
catch ( err )
|
||||||
|
{
|
||||||
|
res.status( 502 ).json( { error: 'Failed to reach Google Fonts', detail: String( err ) } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( css.trim() === '' )
|
||||||
|
{
|
||||||
|
res.status( 404 ).json( { error: `Font family '${family}' not found on Google Fonts` } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const faceRegex = /@font-face\s*\{([^}]+)\}/g;
|
||||||
|
const urlRegex = /src:\s*url\(([^)]+)\)/;
|
||||||
|
const weightRx = /font-weight:\s*(\d+)/;
|
||||||
|
const styleRx = /font-style:\s*(normal|italic)/;
|
||||||
|
|
||||||
|
const dir = fontsDir( family );
|
||||||
|
fs.mkdirSync( dir, { recursive: true } );
|
||||||
|
|
||||||
|
const meta: FontMeta = { family, weights: [], italics: [] };
|
||||||
|
const downloaded: string[] = [];
|
||||||
|
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ( ( match = faceRegex.exec( css ) ) !== null )
|
||||||
|
{
|
||||||
|
const block = match[1];
|
||||||
|
const urlMatch = urlRegex.exec( block );
|
||||||
|
const weightMatch = weightRx.exec( block );
|
||||||
|
const styleMatch = styleRx.exec( block );
|
||||||
|
|
||||||
|
if ( !urlMatch || !weightMatch || !styleMatch ) continue;
|
||||||
|
|
||||||
|
const fontUrl = urlMatch[1].trim();
|
||||||
|
const weight = weightMatch[1];
|
||||||
|
const italic = styleMatch[1] === 'italic';
|
||||||
|
const fileName = italic ? `${weight}-italic.woff2` : `${weight}.woff2`;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const buffer = await httpsGetBuffer( fontUrl );
|
||||||
|
fs.writeFileSync( path.join( dir, fileName ), buffer );
|
||||||
|
downloaded.push( fileName );
|
||||||
|
|
||||||
|
if ( italic )
|
||||||
|
{
|
||||||
|
if ( !meta.italics.includes( weight ) ) meta.italics.push( weight );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if ( !meta.weights.includes( weight ) ) meta.weights.push( weight );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// skip individual file failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.weights.sort( ( a, b ) => Number( a ) - Number( b ) );
|
||||||
|
meta.italics.sort( ( a, b ) => Number( a ) - Number( b ) );
|
||||||
|
writeMeta( meta );
|
||||||
|
|
||||||
|
res.json( { ok: true, family, downloaded } );
|
||||||
|
} );
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["source/server/**/*"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strictNullChecks": false
|
||||||
|
},
|
||||||
|
"include": ["source/server/**/*"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue