Générateur de portfolios PDF Figures Libres (app Nuxt autonome)
App Nuxt 3 + Nuxt UI qui lit les projets du CMS Grav et compose des portfolios A3 paysage exportés en PDF (gabarit book_v3) : composeur WYSIWYG, templates cover/toc/projet/grille/page libre, pipeline Playwright + Ghostscript, gel du contenu à la génération, partage public opt-in. Dépôt autonome : Dockerfile (base Playwright + Ghostscript + ffmpeg + rsync) inclus. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
11
server/api/auth/login.post.ts
Normal file
11
server/api/auth/login.post.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { setSessionCookie } from '../../lib/session'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { password } = await readBody<{ password?: string }>(event)
|
||||
const config = useRuntimeConfig(event)
|
||||
if (!password || password !== config.portfolioPassword) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Mot de passe incorrect' })
|
||||
}
|
||||
setSessionCookie(event)
|
||||
return { authenticated: true }
|
||||
})
|
||||
6
server/api/auth/logout.post.ts
Normal file
6
server/api/auth/logout.post.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clearSessionCookie } from '../../lib/session'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
clearSessionCookie(event)
|
||||
return { authenticated: false }
|
||||
})
|
||||
5
server/api/auth/me.get.ts
Normal file
5
server/api/auth/me.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { isAuthenticated } from '../../lib/session'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
return { authenticated: isAuthenticated(event) }
|
||||
})
|
||||
22
server/api/exports/[file].get.ts
Normal file
22
server/api/exports/[file].get.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { join, normalize } from 'node:path'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const file = decodeURIComponent(getRouterParam(event, 'file') ?? '')
|
||||
const exportsDir = join(useRuntimeConfig(event).dataDir, 'exports')
|
||||
const abs = normalize(join(exportsDir, file))
|
||||
if (!abs.startsWith(normalize(exportsDir) + '/') || !abs.endsWith('.pdf')) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Fichier invalide' })
|
||||
}
|
||||
let s
|
||||
try {
|
||||
s = await stat(abs)
|
||||
} catch {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Export introuvable' })
|
||||
}
|
||||
setHeader(event, 'Content-Type', 'application/pdf')
|
||||
setHeader(event, 'Content-Length', s.size)
|
||||
setHeader(event, 'Content-Disposition', `attachment; filename="${file.replace(/"/g, '')}"`)
|
||||
return sendStream(event, createReadStream(abs))
|
||||
})
|
||||
28
server/api/media/[...path].get.ts
Normal file
28
server/api/media/[...path].get.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Sert les médias du contenu Grav (et des snapshots figés).
|
||||
// - `?w=480` : image redimensionnée (voir server/lib/media.ts).
|
||||
// - `?poster=1` : première frame d'une vidéo.
|
||||
// - `?frozen=<portfolioId>` : résout d'abord dans le snapshot figé (§7.3).
|
||||
import { join } from 'node:path'
|
||||
import { useContentSource } from '../../lib/content-source'
|
||||
import { serveMedia, splitMediaPath } from '../../lib/media'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { projectId, filename } = splitMediaPath(getRouterParam(event, 'path') ?? '')
|
||||
|
||||
const query = getQuery(event)
|
||||
const width = Number(query.w) || 0
|
||||
const wantPoster = Boolean(query.poster)
|
||||
const frozenId = typeof query.frozen === 'string' ? query.frozen : ''
|
||||
|
||||
const config = useRuntimeConfig(event)
|
||||
const source = useContentSource()
|
||||
|
||||
// Racines de résolution : snapshot figé d'abord, contenu live ensuite
|
||||
const roots: string[] = []
|
||||
if (frozenId && /^[\w-]+$/.test(frozenId)) {
|
||||
roots.push(join(config.dataDir, 'frozen', frozenId))
|
||||
}
|
||||
roots.push(source.rootDir)
|
||||
|
||||
return serveMedia(event, { roots, projectId, filename, width, wantPoster, dataDir: config.dataDir })
|
||||
})
|
||||
7
server/api/portfolios/[id]/duplicate.post.ts
Normal file
7
server/api/portfolios/[id]/duplicate.post.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { duplicatePortfolio } from '../../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const copy = await duplicatePortfolio(getRouterParam(event, 'id')!)
|
||||
if (!copy) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
return copy
|
||||
})
|
||||
23
server/api/portfolios/[id]/generate.post.ts
Normal file
23
server/api/portfolios/[id]/generate.post.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getPortfolio } from '../../../lib/portfolio-store'
|
||||
import { generatePdf } from '../../../lib/pdf-pipeline'
|
||||
|
||||
// Génération séquentielle : une seule instance Chromium à la fois.
|
||||
let queue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const portfolio = await getPortfolio(getRouterParam(event, 'id')!)
|
||||
if (!portfolio) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
|
||||
const { fileName } = await readBody<{ fileName?: string }>(event).catch(() => ({ fileName: undefined }))
|
||||
|
||||
const run = queue.then(() => generatePdf(portfolio, fileName))
|
||||
queue = run.catch(() => {})
|
||||
try {
|
||||
return await run
|
||||
} catch (err) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Échec de la génération : ${err instanceof Error ? err.message : String(err)}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
6
server/api/portfolios/[id]/index.delete.ts
Normal file
6
server/api/portfolios/[id]/index.delete.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { deletePortfolio } from '../../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await deletePortfolio(getRouterParam(event, 'id')!)
|
||||
return { ok: true }
|
||||
})
|
||||
7
server/api/portfolios/[id]/index.get.ts
Normal file
7
server/api/portfolios/[id]/index.get.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getPortfolio } from '../../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const portfolio = await getPortfolio(getRouterParam(event, 'id')!)
|
||||
if (!portfolio) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
return portfolio
|
||||
})
|
||||
21
server/api/portfolios/[id]/index.put.ts
Normal file
21
server/api/portfolios/[id]/index.put.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Portfolio } from '~~/types'
|
||||
import { getPortfolio, savePortfolio } from '../../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')!
|
||||
const existing = await getPortfolio(id)
|
||||
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
|
||||
const body = await readBody<Portfolio>(event)
|
||||
if (!body || !Array.isArray(body.pages) || typeof body.name !== 'string') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Portfolio invalide' })
|
||||
}
|
||||
// L'id et les dates de création restent ceux du serveur.
|
||||
const portfolio: Portfolio = {
|
||||
...existing,
|
||||
...body,
|
||||
id,
|
||||
createdAt: existing.createdAt,
|
||||
}
|
||||
return await savePortfolio(portfolio)
|
||||
})
|
||||
30
server/api/portfolios/[id]/share.post.ts
Normal file
30
server/api/portfolios/[id]/share.post.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Active / coupe le lien public d'un portfolio (bouton « Partager un lien public »).
|
||||
// - body { enabled: false } → coupe le partage (publicShare = false), sans
|
||||
// toucher au snapshot figé (réutilisé par le PDF).
|
||||
// - sinon → (re)fige le contenu courant et active le partage. Re-cliquer
|
||||
// « republie » : l'instantané reflète l'état courant du portfolio.
|
||||
import { getPortfolio, savePortfolio } from '../../../lib/portfolio-store'
|
||||
import { freezeContent, sanitizeFileName } from '../../../lib/pdf-pipeline'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id') ?? ''
|
||||
const portfolio = await getPortfolio(id)
|
||||
if (!portfolio) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
}
|
||||
|
||||
const body = await readBody<{ enabled?: boolean }>(event).catch(() => ({}))
|
||||
|
||||
if (body?.enabled === false) {
|
||||
portfolio.publicShare = false
|
||||
// Renvoie le portfolio complet pour resynchroniser le client (évite qu'une
|
||||
// sauvegarde auto ultérieure ne réécrase publicShare/frozenContent).
|
||||
return await savePortfolio(portfolio)
|
||||
}
|
||||
|
||||
// (Re)geler le contenu courant puis activer le partage
|
||||
const fileName = portfolio.frozenContent?.fileName || sanitizeFileName(portfolio.name)
|
||||
portfolio.frozenContent = await freezeContent(portfolio, fileName)
|
||||
portfolio.publicShare = true
|
||||
return await savePortfolio(portfolio)
|
||||
})
|
||||
16
server/api/portfolios/[id]/unfreeze.post.ts
Normal file
16
server/api/portfolios/[id]/unfreeze.post.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// « Réactualiser depuis Grav » (spec §7.3) : supprime le snapshot figé ;
|
||||
// la prochaine génération repartira du contenu live.
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { getPortfolio, savePortfolio } from '../../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')!
|
||||
const portfolio = await getPortfolio(id)
|
||||
if (!portfolio) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
|
||||
portfolio.frozenContent = undefined
|
||||
await rm(join(useRuntimeConfig().dataDir, 'frozen', portfolio.id), { recursive: true, force: true })
|
||||
await savePortfolio(portfolio)
|
||||
return { ok: true }
|
||||
})
|
||||
5
server/api/portfolios/index.get.ts
Normal file
5
server/api/portfolios/index.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { listPortfolios } from '../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
return { portfolios: await listPortfolios() }
|
||||
})
|
||||
6
server/api/portfolios/index.post.ts
Normal file
6
server/api/portfolios/index.post.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createPortfolio } from '../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { name } = await readBody<{ name?: string }>(event)
|
||||
return await createPortfolio(name?.trim() ?? '')
|
||||
})
|
||||
10
server/api/projects/[...id].get.ts
Normal file
10
server/api/projects/[...id].get.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useContentSource } from '../../lib/content-source'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = decodeURIComponent(getRouterParam(event, 'id') ?? '')
|
||||
const project = await useContentSource().getProject(id)
|
||||
if (!project) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Projet introuvable' })
|
||||
}
|
||||
return project
|
||||
})
|
||||
7
server/api/projects/index.get.ts
Normal file
7
server/api/projects/index.get.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useContentSource } from '../../lib/content-source'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const source = useContentSource()
|
||||
const projects = await source.listProjects()
|
||||
return { projects }
|
||||
})
|
||||
19
server/api/public/[id].get.ts
Normal file
19
server/api/public/[id].get.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Données de la vue publique : le portfolio partagé + ses projets figés.
|
||||
// Public (hors mot de passe), mais uniquement si publicShare est actif et
|
||||
// qu'un instantané figé existe. On ne sert jamais le contenu live.
|
||||
import { getPortfolio } from '../../lib/portfolio-store'
|
||||
import type { Portfolio, Project } from '~~/types'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<{ portfolio: Portfolio; projects: Record<string, Project> }> => {
|
||||
const id = getRouterParam(event, 'id') ?? ''
|
||||
if (!/^[\w-]+$/.test(id)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Identifiant invalide' })
|
||||
}
|
||||
|
||||
const portfolio = await getPortfolio(id)
|
||||
if (!portfolio?.publicShare || !portfolio.frozenContent) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
}
|
||||
|
||||
return { portfolio, projects: portfolio.frozenContent.projects }
|
||||
})
|
||||
28
server/api/public/[id]/media/[...path].get.ts
Normal file
28
server/api/public/[id]/media/[...path].get.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Médias de la vue publique : sert exclusivement le snapshot figé du portfolio
|
||||
// <id>, et seulement s'il est partagé publiquement (publicShare). Aucun repli
|
||||
// sur le contenu live — la surface publique reste limitée à l'instantané.
|
||||
import { join } from 'node:path'
|
||||
import { getPortfolio } from '../../../../lib/portfolio-store'
|
||||
import { serveMedia, splitMediaPath } from '../../../../lib/media'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id') ?? ''
|
||||
if (!/^[\w-]+$/.test(id)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Identifiant invalide' })
|
||||
}
|
||||
|
||||
const portfolio = await getPortfolio(id)
|
||||
if (!portfolio?.publicShare) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
}
|
||||
|
||||
const { projectId, filename } = splitMediaPath(getRouterParam(event, 'path') ?? '')
|
||||
const query = getQuery(event)
|
||||
const width = Number(query.w) || 0
|
||||
const wantPoster = Boolean(query.poster)
|
||||
|
||||
const config = useRuntimeConfig(event)
|
||||
const roots = [join(config.dataDir, 'frozen', id)]
|
||||
|
||||
return serveMedia(event, { roots, projectId, filename, width, wantPoster, dataDir: config.dataDir })
|
||||
})
|
||||
7
server/api/sync.post.ts
Normal file
7
server/api/sync.post.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Bouton « Rafraîchir » (spec §6) : re-synchronise le contenu.
|
||||
// En DEV : simple relecture du FS. En PROD (phase 3) : rsync/SSH.
|
||||
import { useContentSource } from '../lib/content-source'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
return await useContentSource().refresh()
|
||||
})
|
||||
140
server/lib/content-source.ts
Normal file
140
server/lib/content-source.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// Abstraction ContentSource (spec §5) : DEV = FS local (grav-reference monté RO),
|
||||
// PROD = cache local alimenté par rsync/SSH. Le reste de l'app ne voit que cette interface.
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join, normalize } from 'node:path'
|
||||
import type { Project } from '~~/types'
|
||||
import { readAllProjects } from './grav-reader'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export interface SyncResult {
|
||||
ok: boolean
|
||||
summary: string
|
||||
syncedAt: string
|
||||
}
|
||||
|
||||
export interface ContentSource {
|
||||
/** Racine FS des projets (catégories/<slug>/…) */
|
||||
readonly rootDir: string
|
||||
listProjects(): Promise<Project[]>
|
||||
getProject(id: string): Promise<Project | null>
|
||||
/** Chemin absolu d'un média, borné à rootDir (anti path-traversal). */
|
||||
resolveMediaPath(projectId: string, filename: string): string | null
|
||||
/** Re-synchronise le contenu (no-op en DEV). */
|
||||
refresh(): Promise<SyncResult>
|
||||
/** Invalide le cache mémoire. */
|
||||
invalidate(): void
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 30_000
|
||||
|
||||
abstract class FsContentSource implements ContentSource {
|
||||
private cache: { at: number; projects: Project[] } | null = null
|
||||
|
||||
constructor(public readonly rootDir: string) {}
|
||||
|
||||
async listProjects(): Promise<Project[]> {
|
||||
if (this.cache && Date.now() - this.cache.at < CACHE_TTL_MS) {
|
||||
return this.cache.projects
|
||||
}
|
||||
const projects = await readAllProjects(this.rootDir)
|
||||
this.cache = { at: Date.now(), projects }
|
||||
return projects
|
||||
}
|
||||
|
||||
async getProject(id: string): Promise<Project | null> {
|
||||
const projects = await this.listProjects()
|
||||
return projects.find(p => p.id === id) ?? null
|
||||
}
|
||||
|
||||
resolveMediaPath(projectId: string, filename: string): string | null {
|
||||
const abs = normalize(join(this.rootDir, projectId, filename))
|
||||
if (!abs.startsWith(normalize(this.rootDir) + '/')) return null
|
||||
return abs
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cache = null
|
||||
}
|
||||
|
||||
abstract refresh(): Promise<SyncResult>
|
||||
}
|
||||
|
||||
/** Mode DEV : lecture directe du Grav local monté en lecture seule. */
|
||||
export class DevContentSource extends FsContentSource {
|
||||
async refresh(): Promise<SyncResult> {
|
||||
this.invalidate()
|
||||
const projects = await this.listProjects()
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Mode dev : contenu local relu (${projects.length} projets).`,
|
||||
syncedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode PROD : cache local `data/content-cache/` alimenté par rsync/SSH
|
||||
* depuis la prod (spec §6). Lecture seule côté prod, incrémental.
|
||||
*/
|
||||
export class ProdContentSource extends FsContentSource {
|
||||
constructor(
|
||||
rootDir: string,
|
||||
private readonly remote: string, // ex. user@host:/chemin/user/pages/02.projets/
|
||||
private readonly sshKey?: string,
|
||||
) {
|
||||
super(rootDir)
|
||||
}
|
||||
|
||||
async refresh(): Promise<SyncResult> {
|
||||
if (!this.remote) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: 'NUXT_SYNC_REMOTE non configuré : impossible de synchroniser.',
|
||||
syncedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
await mkdir(this.rootDir, { recursive: true })
|
||||
const ssh = ['ssh', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'BatchMode=yes']
|
||||
if (this.sshKey) ssh.push('-i', this.sshKey)
|
||||
|
||||
const started = Date.now()
|
||||
try {
|
||||
const { stdout } = await execFileAsync('rsync', [
|
||||
'-az',
|
||||
'--delete',
|
||||
'--itemize-changes',
|
||||
'-e', ssh.join(' '),
|
||||
this.remote.endsWith('/') ? this.remote : `${this.remote}/`,
|
||||
`${this.rootDir}/`,
|
||||
], { timeout: 15 * 60_000 })
|
||||
this.invalidate()
|
||||
const changed = stdout.split('\n').filter(l => /^[<>ch.]\S+\s/.test(l)).length
|
||||
const projects = await this.listProjects()
|
||||
return {
|
||||
ok: true,
|
||||
summary: `${changed} fichier(s) mis à jour en ${Math.round((Date.now() - started) / 1000)} s — ${projects.length} projets.`,
|
||||
syncedAt: new Date().toISOString(),
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: `Échec rsync : ${err instanceof Error ? err.message : String(err)}`,
|
||||
syncedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let source: ContentSource | null = null
|
||||
|
||||
export function useContentSource(): ContentSource {
|
||||
if (source) return source
|
||||
const config = useRuntimeConfig()
|
||||
source = config.contentSource === 'prod'
|
||||
? new ProdContentSource(join(config.dataDir, 'content-cache'), config.syncRemote, config.syncSshKey || undefined)
|
||||
: new DevContentSource(config.contentDir)
|
||||
return source
|
||||
}
|
||||
198
server/lib/grav-reader.ts
Normal file
198
server/lib/grav-reader.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// Parseur du contenu Grav (lecture seule) — spec §7.1
|
||||
// Lit user/pages/02.projets/<categorie>/<projet>/reader.md + médias.
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { marked } from 'marked'
|
||||
import sharp from 'sharp'
|
||||
import type { MediaAsset, MediaVariant, Project } from '~~/types'
|
||||
|
||||
const IMAGE_EXT = /\.(png|jpe?g|webp|gif)$/i
|
||||
const VIDEO_EXT = /\.(mp4|webm)$/i
|
||||
const VARIANT_RE = /^(.*)@(\d+)x(\.[^.]+)$/i
|
||||
|
||||
/** "01.culturelle" -> "culturelle" */
|
||||
function stripOrderPrefix(dirname: string): string {
|
||||
return dirname.replace(/^\d+\./, '')
|
||||
}
|
||||
|
||||
/** Date Grav "09-12-2024 22:19" (DD-MM-YYYY HH:mm) -> ISO */
|
||||
function parseGravDate(raw: unknown): string | undefined {
|
||||
if (!raw) return undefined
|
||||
const s = String(raw).trim()
|
||||
const m = s.match(/^(\d{2})-(\d{2})-(\d{4})(?:\s+(\d{2}):(\d{2}))?$/)
|
||||
if (m) {
|
||||
const [, dd, mm, yyyy, hh = '00', min = '00'] = m
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}:00`
|
||||
}
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? undefined : d.toISOString()
|
||||
}
|
||||
|
||||
function toArray(v: unknown): string[] {
|
||||
if (Array.isArray(v)) return v.map(String).filter(Boolean)
|
||||
if (typeof v === 'string' && v.trim()) return [v.trim()]
|
||||
return []
|
||||
}
|
||||
|
||||
/** Détecte l'URL externe du projet (souvent en fin de corps). */
|
||||
function detectExternalUrl(body: string): string | undefined {
|
||||
const matches = body.match(/https?:\/\/[^\s)>\]"']+/g)
|
||||
if (!matches?.length) return undefined
|
||||
return matches[matches.length - 1].replace(/[.,;]$/, '')
|
||||
}
|
||||
|
||||
interface ParsedMarkdown {
|
||||
frontmatter: Record<string, unknown>
|
||||
body: string
|
||||
}
|
||||
|
||||
function splitFrontmatter(raw: string): ParsedMarkdown {
|
||||
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
|
||||
if (!m) return { frontmatter: {}, body: raw }
|
||||
let frontmatter: Record<string, unknown> = {}
|
||||
try {
|
||||
frontmatter = (parseYaml(m[1]) as Record<string, unknown>) ?? {}
|
||||
} catch {
|
||||
frontmatter = {}
|
||||
}
|
||||
return { frontmatter, body: m[2].trim() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Regroupe les fichiers d'un dossier projet en MediaAsset :
|
||||
* base.png + base@2x.png/@3x/@4x -> un asset avec variantes,
|
||||
* ordonnés selon media_order puis alphabétique pour le reste.
|
||||
*/
|
||||
function collectMedia(files: string[], mediaOrder: string[]): MediaAsset[] {
|
||||
const assets = new Map<string, MediaAsset>()
|
||||
|
||||
// Le nom canonique est celui sans suffixe @Nx (c'est lui que référencent
|
||||
// media_order et aura.image). Certains projets n'ont qu'un @1x sans base.
|
||||
for (const f of files) {
|
||||
if (!IMAGE_EXT.test(f) && !VIDEO_EXT.test(f)) continue
|
||||
const m = f.match(VARIANT_RE)
|
||||
const canonical = m ? m[1] + m[3] : f
|
||||
const label = (m ? `${m[2]}x` : '1x') as MediaVariant['label']
|
||||
|
||||
let asset = assets.get(canonical)
|
||||
if (!asset) {
|
||||
asset = {
|
||||
filename: canonical,
|
||||
type: VIDEO_EXT.test(canonical) ? 'video' : 'image',
|
||||
variants: [],
|
||||
}
|
||||
assets.set(canonical, asset)
|
||||
}
|
||||
const existing = asset.variants.find(v => v.label === label)
|
||||
if (!existing) {
|
||||
asset.variants.push({ label, path: f })
|
||||
} else if (!m) {
|
||||
existing.path = f // le fichier de base l'emporte sur un doublon @1x
|
||||
}
|
||||
}
|
||||
for (const a of assets.values()) {
|
||||
a.variants.sort((x, y) => parseInt(x.label) - parseInt(y.label))
|
||||
}
|
||||
|
||||
// Tri : media_order d'abord, puis le reste en alphabétique
|
||||
const orderIndex = new Map(mediaOrder.map((name, i) => [name, i]))
|
||||
return [...assets.values()].sort((a, b) => {
|
||||
const ia = orderIndex.has(a.filename) ? orderIndex.get(a.filename)! : Infinity
|
||||
const ib = orderIndex.has(b.filename) ? orderIndex.get(b.filename)! : Infinity
|
||||
if (ia !== ib) return ia - ib
|
||||
return a.filename.localeCompare(b.filename)
|
||||
})
|
||||
}
|
||||
|
||||
async function readProject(
|
||||
contentDir: string,
|
||||
categoryDir: string,
|
||||
projectDir: string,
|
||||
): Promise<Project | null> {
|
||||
const dir = join(contentDir, categoryDir, projectDir)
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(join(dir, 'reader.md'), 'utf8')
|
||||
} catch {
|
||||
return null // pas un dossier projet (pas de reader.md)
|
||||
}
|
||||
|
||||
const { frontmatter, body } = splitFrontmatter(raw)
|
||||
const files = (await readdir(dir)).filter(f => f !== 'reader.md')
|
||||
|
||||
const taxonomy = (frontmatter.taxonomy ?? {}) as Record<string, unknown>
|
||||
const mediaOrder = String(frontmatter.media_order ?? '')
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const media = collectMedia(files, mediaOrder)
|
||||
// Dimensions des images (en-tête seulement, rapide) : le ratio est le même
|
||||
// pour toutes les variantes @Nx, la 1re suffit.
|
||||
for (const asset of media) {
|
||||
if (asset.type !== 'image') continue
|
||||
try {
|
||||
const meta = await sharp(join(dir, asset.variants[0].path)).metadata()
|
||||
asset.width = meta.width
|
||||
asset.height = meta.height
|
||||
} catch {
|
||||
// dimensions indisponibles : le choix de grille retombera sur « mixte »
|
||||
}
|
||||
}
|
||||
// Image de couverture = celle de la grille du CMS : la PREMIÈRE image de
|
||||
// media_order (cf. projet_card.html.twig : p.media.images|first), pas
|
||||
// aura.image qui sert aux partages sociaux.
|
||||
const coverImage = media.find(m => m.type === 'image')?.filename
|
||||
|
||||
return {
|
||||
id: `${categoryDir}/${projectDir}`,
|
||||
category: stripOrderPrefix(categoryDir),
|
||||
title: String(frontmatter.title ?? stripOrderPrefix(projectDir)),
|
||||
bodyMarkdown: body,
|
||||
bodyHtml: await marked.parse(body),
|
||||
coverImage,
|
||||
media,
|
||||
categories: toArray(taxonomy.category),
|
||||
tags: toArray(taxonomy.tag),
|
||||
date: parseGravDate(frontmatter.date),
|
||||
externalUrl: detectExternalUrl(body),
|
||||
published: frontmatter.published !== false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Lit tous les projets du dossier 02.projets (3 catégories). */
|
||||
export async function readAllProjects(contentDir: string): Promise<Project[]> {
|
||||
const projects: Project[] = []
|
||||
let categoryDirs: string[] = []
|
||||
try {
|
||||
categoryDirs = (await readdir(contentDir, { withFileTypes: true }))
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
.sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
for (const categoryDir of categoryDirs) {
|
||||
const entries = await readdir(join(contentDir, categoryDir), { withFileTypes: true })
|
||||
for (const entry of entries.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const project = await readProject(contentDir, categoryDir, entry.name)
|
||||
if (project) projects.push(project)
|
||||
}
|
||||
}
|
||||
|
||||
// Tri global : plus récent d'abord (comme les listings Grav)
|
||||
projects.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''))
|
||||
return projects
|
||||
}
|
||||
|
||||
/** mtime le plus récent des reader.md — sert à invalider le cache mémoire. */
|
||||
export async function contentFingerprint(contentDir: string): Promise<string> {
|
||||
try {
|
||||
const s = await stat(contentDir)
|
||||
return String(s.mtimeMs)
|
||||
} catch {
|
||||
return '0'
|
||||
}
|
||||
}
|
||||
186
server/lib/media.ts
Normal file
186
server/lib/media.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
// Service de fichiers médias, partagé par la route authentifiée
|
||||
// (/api/media/…) et la route publique (/api/public/<id>/media/…).
|
||||
// - `width` > 0 : image redimensionnée via sharp (cache disque). La source est
|
||||
// choisie parmi les variantes @1x…@4x : la plus petite qui couvre la largeur
|
||||
// demandée (spec §10.1 : on descend, on n'agrandit pas).
|
||||
// - `wantPoster` : première frame d'une vidéo (ffmpeg), servie comme image.
|
||||
// - `roots` : racines de résolution testées dans l'ordre (snapshot figé d'abord,
|
||||
// contenu live ensuite ; la route publique ne passe que le snapshot figé).
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { extname, join, normalize } from 'node:path'
|
||||
import type { H3Event } from 'h3'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
}
|
||||
|
||||
// Cache mémoire des largeurs d'images (clé : chemin + mtime)
|
||||
const widthCache = new Map<string, number>()
|
||||
|
||||
async function imageWidth(abs: string, mtimeMs: number): Promise<number> {
|
||||
const key = `${abs}:${mtimeMs}`
|
||||
const cached = widthCache.get(key)
|
||||
if (cached) return cached
|
||||
const meta = await sharp(abs).metadata()
|
||||
const w = meta.width ?? 0
|
||||
widthCache.set(key, w)
|
||||
return w
|
||||
}
|
||||
|
||||
interface FoundFile {
|
||||
abs: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
async function findExisting(paths: string[]): Promise<FoundFile[]> {
|
||||
const found: FoundFile[] = []
|
||||
for (const abs of paths) {
|
||||
try {
|
||||
const s = await stat(abs)
|
||||
found.push({ abs, size: s.size, mtimeMs: s.mtimeMs })
|
||||
} catch {
|
||||
// absent
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** Découpe le chemin d'URL `projectId/.../filename` en (projectId, filename). */
|
||||
export function splitMediaPath(rawPath: string): { projectId: string; filename: string } {
|
||||
const segments = rawPath.split('/').map(decodeURIComponent).filter(Boolean)
|
||||
if (segments.length < 2 || segments.some(s => s === '..')) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Chemin invalide' })
|
||||
}
|
||||
return {
|
||||
filename: segments[segments.length - 1],
|
||||
projectId: segments.slice(0, -1).join('/'),
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServeMediaOptions {
|
||||
roots: string[] // racines de résolution, dans l'ordre de priorité
|
||||
projectId: string
|
||||
filename: string
|
||||
width: number
|
||||
wantPoster: boolean
|
||||
dataDir: string
|
||||
}
|
||||
|
||||
/** Résout, redimensionne/extrait puis renvoie un média. Logique commune. */
|
||||
export async function serveMedia(event: H3Event, opts: ServeMediaOptions) {
|
||||
const { roots, projectId, filename, width, wantPoster, dataDir } = opts
|
||||
|
||||
// Le nom canonique peut ne pas exister sur disque : variantes @Nx en repli
|
||||
const ext = extname(filename).toLowerCase()
|
||||
const variantNames = [
|
||||
filename,
|
||||
...['1', '2', '3', '4', '5', '6'].map(n => `${filename.slice(0, -ext.length)}@${n}x${ext}`),
|
||||
]
|
||||
|
||||
let candidates: FoundFile[] = []
|
||||
for (const root of roots) {
|
||||
const paths = variantNames.map((name) => {
|
||||
const abs = normalize(join(root, projectId, name))
|
||||
if (!abs.startsWith(normalize(root) + '/')) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Chemin invalide' })
|
||||
}
|
||||
return abs
|
||||
})
|
||||
candidates = await findExisting(paths)
|
||||
if (candidates.length) break
|
||||
}
|
||||
if (!candidates.length) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Média introuvable' })
|
||||
}
|
||||
|
||||
const primary = candidates[0]
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=3600')
|
||||
|
||||
// ---- Poster de vidéo ----
|
||||
if (wantPoster && MIME[ext]?.startsWith('video/')) {
|
||||
const cacheDir = join(dataDir, 'cache', 'posters')
|
||||
const key = createHash('sha1').update(`${primary.abs}:${primary.mtimeMs}`).digest('hex')
|
||||
const posterPath = join(cacheDir, `${key}.jpg`)
|
||||
|
||||
try {
|
||||
await stat(posterPath)
|
||||
} catch {
|
||||
await mkdir(cacheDir, { recursive: true })
|
||||
try {
|
||||
await execFileAsync('ffmpeg', [
|
||||
'-y', '-ss', '1', '-i', primary.abs, '-frames:v', '1', '-q:v', '3', posterPath,
|
||||
])
|
||||
} catch {
|
||||
// vidéos plus courtes qu'une seconde : première frame
|
||||
await execFileAsync('ffmpeg', ['-y', '-i', primary.abs, '-frames:v', '1', '-q:v', '3', posterPath])
|
||||
}
|
||||
}
|
||||
|
||||
setHeader(event, 'Content-Type', 'image/jpeg')
|
||||
if (width > 0) {
|
||||
return await sharp(posterPath)
|
||||
.resize({ width: Math.min(width, 3200), withoutEnlargement: true })
|
||||
.jpeg({ quality: 82 })
|
||||
.toBuffer()
|
||||
}
|
||||
return sendStream(event, createReadStream(posterPath))
|
||||
}
|
||||
|
||||
// ---- Image redimensionnée ----
|
||||
if (width > 0 && MIME[ext]?.startsWith('image/') && ext !== '.gif') {
|
||||
const target = Math.min(width, 3200)
|
||||
|
||||
// Choix de la source : la plus petite variante couvrant la cible
|
||||
let best = candidates[0]
|
||||
let bestWidth = await imageWidth(best.abs, best.mtimeMs)
|
||||
for (const c of candidates.slice(1)) {
|
||||
const w = await imageWidth(c.abs, c.mtimeMs)
|
||||
const covers = w >= target
|
||||
const bestCovers = bestWidth >= target
|
||||
if ((covers && (!bestCovers || w < bestWidth)) || (!covers && !bestCovers && w > bestWidth)) {
|
||||
best = c
|
||||
bestWidth = w
|
||||
}
|
||||
}
|
||||
|
||||
const cacheDir = join(dataDir, 'cache', 'thumbs')
|
||||
const key = createHash('sha1').update(`${best.abs}:${target}:${best.mtimeMs}`).digest('hex')
|
||||
const cachePath = join(cacheDir, `${key}.webp`)
|
||||
|
||||
try {
|
||||
const cached = await readFile(cachePath)
|
||||
setHeader(event, 'Content-Type', 'image/webp')
|
||||
return cached
|
||||
} catch {
|
||||
// pas en cache : on génère
|
||||
}
|
||||
|
||||
await mkdir(cacheDir, { recursive: true })
|
||||
const resized = await sharp(best.abs)
|
||||
.resize({ width: target, withoutEnlargement: true })
|
||||
.webp({ quality: 80 })
|
||||
.toBuffer()
|
||||
await writeFile(cachePath, resized)
|
||||
setHeader(event, 'Content-Type', 'image/webp')
|
||||
return resized
|
||||
}
|
||||
|
||||
// ---- Fichier original ----
|
||||
setHeader(event, 'Content-Type', MIME[ext] ?? 'application/octet-stream')
|
||||
setHeader(event, 'Content-Length', primary.size)
|
||||
return sendStream(event, createReadStream(primary.abs))
|
||||
}
|
||||
198
server/lib/pdf-pipeline.ts
Normal file
198
server/lib/pdf-pipeline.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// Pipeline de génération PDF (spec §10) :
|
||||
// 1. gel du contenu (§7.3) : textes + médias utilisés copiés dans data/frozen/<id>/
|
||||
// 2. rendu de la route /print/<id> par Playwright/Chromium (mêmes composants Vue)
|
||||
// 3. page.pdf() au format 16:9 (960×540 pt ≡ 1280×720 px)
|
||||
// 4. post-traitement Ghostscript : recompression + métadonnées
|
||||
import { copyFile, mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { chromium } from 'playwright'
|
||||
import type { FrozenSnapshot, Portfolio, Project } from '~~/types'
|
||||
import { useContentSource } from './content-source'
|
||||
import { savePortfolio } from './portfolio-store'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export function sanitizeFileName(name: string): string {
|
||||
const base = name
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.toLowerCase()
|
||||
return (base || 'portfolio') + '.pdf'
|
||||
}
|
||||
|
||||
/** Fige le contenu utilisé : projets référencés + copie des fichiers médias. */
|
||||
export async function freezeContent(portfolio: Portfolio, fileName: string): Promise<FrozenSnapshot> {
|
||||
const config = useRuntimeConfig()
|
||||
const source = useContentSource()
|
||||
const frozenDir = join(config.dataDir, 'frozen', portfolio.id)
|
||||
|
||||
// Re-création à neuf du snapshot
|
||||
await rm(frozenDir, { recursive: true, force: true })
|
||||
await mkdir(frozenDir, { recursive: true })
|
||||
|
||||
const projectIds = new Set<string>()
|
||||
for (const page of portfolio.pages) {
|
||||
if (page.kind === 'project' || page.kind === 'project-grid') projectIds.add(page.projectId)
|
||||
if (page.kind === 'free') {
|
||||
for (const ref of page.media ?? []) projectIds.add(ref.slice(0, ref.lastIndexOf('/')))
|
||||
}
|
||||
}
|
||||
if (portfolio.cover.backgroundImage) {
|
||||
const ref = portfolio.cover.backgroundImage
|
||||
projectIds.add(ref.slice(0, ref.lastIndexOf('/')))
|
||||
}
|
||||
|
||||
const projects: Record<string, Project> = {}
|
||||
for (const id of projectIds) {
|
||||
const project = await source.getProject(id)
|
||||
if (!project) continue
|
||||
projects[id] = project
|
||||
// Copie de toutes les variantes des médias du projet utilisés
|
||||
for (const asset of project.media) {
|
||||
for (const variant of asset.variants) {
|
||||
const abs = source.resolveMediaPath(id, variant.path)
|
||||
if (!abs) continue
|
||||
const dest = join(frozenDir, id, variant.path)
|
||||
await mkdir(dirname(dest), { recursive: true })
|
||||
try {
|
||||
await copyFile(abs, dest)
|
||||
} catch {
|
||||
// média manquant : le rendu retombera sur le contenu live
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { generatedAt: new Date().toISOString(), fileName, projects }
|
||||
}
|
||||
|
||||
export interface GenerateResult {
|
||||
fileName: string
|
||||
downloadUrl: string
|
||||
sizeBytes: number
|
||||
pageCount: number
|
||||
}
|
||||
|
||||
export async function generatePdf(portfolio: Portfolio, rawFileName?: string): Promise<GenerateResult> {
|
||||
const config = useRuntimeConfig()
|
||||
const fileName = sanitizeFileName(rawFileName || portfolio.frozenContent?.fileName?.replace(/\.pdf$/, '') || portfolio.name)
|
||||
|
||||
// Gel du contenu : réutilisé tel quel s'il existe (régénération à l'identique),
|
||||
// sinon créé depuis le contenu live. « Réactualiser depuis Grav » le supprime.
|
||||
if (!portfolio.frozenContent) {
|
||||
portfolio.frozenContent = await freezeContent(portfolio, fileName)
|
||||
} else {
|
||||
portfolio.frozenContent.fileName = fileName
|
||||
}
|
||||
await savePortfolio(portfolio)
|
||||
|
||||
const exportsDir = join(config.dataDir, 'exports')
|
||||
const workDir = join(config.dataDir, 'cache', 'pdf-work')
|
||||
await mkdir(exportsDir, { recursive: true })
|
||||
await mkdir(workDir, { recursive: true })
|
||||
const rawPdf = join(workDir, `${portfolio.id}-raw.pdf`)
|
||||
const finalPdf = join(workDir, `${portfolio.id}-final.pdf`)
|
||||
|
||||
// ---- Rendu Chromium ----
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
const page = await browser.newPage({
|
||||
viewportSize: { width: 1588, height: 1123 }, // A3 paysage en px CSS
|
||||
// Le jeton doit accompagner chaque sous-requête (médias, API), pas
|
||||
// seulement la navigation initiale.
|
||||
extraHTTPHeaders: { 'x-internal-token': config.internalToken },
|
||||
})
|
||||
const url = `http://localhost:3000/print/${portfolio.id}?internal-token=${encodeURIComponent(config.internalToken)}`
|
||||
const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 120_000 })
|
||||
if (!response?.ok()) {
|
||||
throw new Error(`Rendu print en échec (HTTP ${response?.status()})`)
|
||||
}
|
||||
// Attendre fonts + images (les médias pleine résolution peuvent être longs)
|
||||
await page.evaluate(async () => {
|
||||
// Chargement forcé de TOUTES les faces déclarées (fonts.ready ne couvre
|
||||
// que celles déjà en cours de chargement)
|
||||
await Promise.all(Array.from((document as any).fonts).map((f: any) => f.load().catch(() => {})))
|
||||
await (document as any).fonts.ready
|
||||
const images = Array.from(document.querySelectorAll('img'))
|
||||
await Promise.all(images.map(img => img.complete
|
||||
? Promise.resolve()
|
||||
: new Promise((resolve) => { img.onload = img.onerror = resolve })))
|
||||
})
|
||||
await page.emulateMedia({ media: 'print' })
|
||||
// Bug Chromium : le texte peint pendant la période « invisible » de
|
||||
// font-display: block reste absent du PDF même une fois les fontes
|
||||
// chargées. Invalider tous les styles + reflow force un repaint complet
|
||||
// avec les glyphes réels.
|
||||
await page.evaluate(async () => {
|
||||
for (const sheet of document.styleSheets) sheet.disabled = true
|
||||
void document.body.offsetHeight
|
||||
for (const sheet of document.styleSheets) sheet.disabled = false
|
||||
void document.body.offsetHeight
|
||||
await (document as any).fonts.ready
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
})
|
||||
await page.pdf({
|
||||
path: rawPdf,
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
timeout: 120_000,
|
||||
})
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
// ---- Post-traitement Ghostscript : recompression + métadonnées (§10.4) ----
|
||||
const pdfmarks = join(workDir, `${portfolio.id}-marks.ps`)
|
||||
const psEscape = (s: string) => s.replace(/[\\()]/g, c => `\\${c}`)
|
||||
await writeFile(
|
||||
pdfmarks,
|
||||
`[ /Title (${psEscape(portfolio.cover.title || portfolio.name)})\n`
|
||||
+ ` /Author (Figures Libres)\n`
|
||||
+ ` /Creator (Générateur de portfolios Figures Libres)\n`
|
||||
+ ` /DOCINFO pdfmark\n`,
|
||||
'latin1',
|
||||
)
|
||||
await execFileAsync('gs', [
|
||||
'-dBATCH', '-dNOPAUSE', '-dQUIET', '-dSAFER',
|
||||
'-sDEVICE=pdfwrite',
|
||||
'-dCompatibilityLevel=1.6',
|
||||
'-dAutoRotatePages=/None',
|
||||
'-sColorConversionStrategy=RGB',
|
||||
'-dDownsampleColorImages=true',
|
||||
'-dColorImageDownsampleType=/Bicubic',
|
||||
'-dColorImageResolution=200',
|
||||
'-dDownsampleGrayImages=true',
|
||||
'-dGrayImageResolution=200',
|
||||
'-dMonoImageResolution=300',
|
||||
`-sOutputFile=${finalPdf}`,
|
||||
rawPdf,
|
||||
pdfmarks,
|
||||
])
|
||||
|
||||
const exportPath = join(exportsDir, fileName)
|
||||
await rename(finalPdf, exportPath)
|
||||
await rm(rawPdf, { force: true })
|
||||
await rm(pdfmarks, { force: true })
|
||||
|
||||
const { size } = await import('node:fs/promises').then(fs => fs.stat(exportPath))
|
||||
|
||||
// On aligne updatedAt sur lastExport.at (touch=false) : le PDF est ainsi
|
||||
// marqué « à jour » tant que le portfolio n'est pas réédité (toute sauvegarde
|
||||
// ultérieure retouche updatedAt et le rend périmé).
|
||||
const exportedAt = new Date().toISOString()
|
||||
portfolio.lastExport = { fileName, at: exportedAt }
|
||||
portfolio.updatedAt = exportedAt
|
||||
await savePortfolio(portfolio, false)
|
||||
|
||||
return {
|
||||
fileName,
|
||||
downloadUrl: `/api/exports/${encodeURIComponent(fileName)}`,
|
||||
sizeBytes: size,
|
||||
pageCount: portfolio.pages.length,
|
||||
}
|
||||
}
|
||||
104
server/lib/portfolio-store.ts
Normal file
104
server/lib/portfolio-store.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Bibliothèque de portfolios : un fichier JSON par portfolio (spec §13).
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { Portfolio, PortfolioSummary } from '~~/types'
|
||||
|
||||
function portfoliosDir(): string {
|
||||
return join(useRuntimeConfig().dataDir, 'portfolios')
|
||||
}
|
||||
|
||||
function pathFor(id: string): string {
|
||||
if (!/^[\w-]+$/.test(id)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Identifiant invalide' })
|
||||
}
|
||||
return join(portfoliosDir(), `${id}.json`)
|
||||
}
|
||||
|
||||
export async function listPortfolios(): Promise<PortfolioSummary[]> {
|
||||
let files: string[] = []
|
||||
try {
|
||||
files = (await readdir(portfoliosDir())).filter(f => f.endsWith('.json'))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const summaries: PortfolioSummary[] = []
|
||||
for (const f of files) {
|
||||
try {
|
||||
const p = JSON.parse(await readFile(join(portfoliosDir(), f), 'utf8')) as Portfolio
|
||||
summaries.push({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
pageCount: p.pages.length,
|
||||
updatedAt: p.updatedAt,
|
||||
createdAt: p.createdAt,
|
||||
coverTitle: p.cover?.title ?? p.name,
|
||||
hasPdf: Boolean(p.lastExport),
|
||||
// « À jour » : la génération aligne lastExport.at sur updatedAt ; toute
|
||||
// édition ultérieure retouche updatedAt (> lastExport.at) → périmé.
|
||||
pdfCurrent: Boolean(p.lastExport && p.updatedAt <= p.lastExport.at),
|
||||
exportFileName: p.lastExport?.fileName,
|
||||
publicShare: Boolean(p.publicShare),
|
||||
})
|
||||
} catch {
|
||||
// fichier corrompu : ignoré de la liste
|
||||
}
|
||||
}
|
||||
summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
return summaries
|
||||
}
|
||||
|
||||
export async function getPortfolio(id: string): Promise<Portfolio | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(pathFor(id), 'utf8')) as Portfolio
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePortfolio(portfolio: Portfolio, touch = true): Promise<Portfolio> {
|
||||
// touch=false : conserve updatedAt tel quel (utilisé après génération pour
|
||||
// aligner updatedAt sur lastExport.at et marquer le PDF « à jour »).
|
||||
if (touch) portfolio.updatedAt = new Date().toISOString()
|
||||
await mkdir(portfoliosDir(), { recursive: true })
|
||||
await writeFile(pathFor(portfolio.id), JSON.stringify(portfolio, null, 2))
|
||||
return portfolio
|
||||
}
|
||||
|
||||
export async function createPortfolio(name: string): Promise<Portfolio> {
|
||||
const now = new Date().toISOString()
|
||||
const portfolio: Portfolio = {
|
||||
id: nanoid(10),
|
||||
name: name || 'Portfolio sans titre',
|
||||
cover: {
|
||||
title: name || 'Portfolio',
|
||||
subtitle: '',
|
||||
recipient: '',
|
||||
background: { mode: 'white' },
|
||||
},
|
||||
pages: [{ kind: 'cover', id: nanoid(8), background: { mode: 'white' } }],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
return await savePortfolio(portfolio)
|
||||
}
|
||||
|
||||
export async function duplicatePortfolio(id: string): Promise<Portfolio | null> {
|
||||
const source = await getPortfolio(id)
|
||||
if (!source) return null
|
||||
const now = new Date().toISOString()
|
||||
const copy: Portfolio = {
|
||||
...source,
|
||||
id: nanoid(10),
|
||||
name: `${source.name} (copie)`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
frozenContent: undefined,
|
||||
lastExport: undefined,
|
||||
}
|
||||
return await savePortfolio(copy)
|
||||
}
|
||||
|
||||
export async function deletePortfolio(id: string): Promise<void> {
|
||||
await rm(pathFor(id), { force: true })
|
||||
}
|
||||
48
server/lib/session.ts
Normal file
48
server/lib/session.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Session « mot de passe partagé » (spec §12) : cookie HMAC signé, sans stockage serveur.
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
const COOKIE_NAME = 'fl_session'
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 jours
|
||||
|
||||
function sign(secret: string, payload: string): string {
|
||||
return createHmac('sha256', secret).update(payload).digest('base64url')
|
||||
}
|
||||
|
||||
export function createSessionToken(secret: string): string {
|
||||
const exp = String(Date.now() + SESSION_TTL_MS)
|
||||
return `${exp}.${sign(secret, exp)}`
|
||||
}
|
||||
|
||||
export function isValidSessionToken(secret: string, token: string | undefined): boolean {
|
||||
if (!token) return false
|
||||
const [exp, sig] = token.split('.')
|
||||
if (!exp || !sig) return false
|
||||
if (Number(exp) < Date.now()) return false
|
||||
const expected = sign(secret, exp)
|
||||
const a = Buffer.from(sig)
|
||||
const b = Buffer.from(expected)
|
||||
return a.length === b.length && timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
export function setSessionCookie(event: H3Event): void {
|
||||
const { sessionSecret } = useRuntimeConfig(event)
|
||||
setCookie(event, COOKIE_NAME, createSessionToken(sessionSecret), {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: SESSION_TTL_MS / 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function clearSessionCookie(event: H3Event): void {
|
||||
deleteCookie(event, COOKIE_NAME, { path: '/' })
|
||||
}
|
||||
|
||||
export function isAuthenticated(event: H3Event): boolean {
|
||||
const config = useRuntimeConfig(event)
|
||||
// Jeton interne : utilisé par le rendu print (Playwright) en phase 2.
|
||||
const headerToken = getHeader(event, 'x-internal-token') ?? getQuery(event)['internal-token']
|
||||
if (headerToken && headerToken === config.internalToken) return true
|
||||
return isValidSessionToken(config.sessionSecret, getCookie(event, COOKIE_NAME))
|
||||
}
|
||||
16
server/middleware/01.auth.ts
Normal file
16
server/middleware/01.auth.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Protège toute l'API (sauf login/me) derrière la session mot de passe partagé.
|
||||
import { isAuthenticated } from '../lib/session'
|
||||
|
||||
const PUBLIC_API = new Set(['/api/auth/login', '/api/auth/me'])
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const path = event.path.split('?')[0]
|
||||
if (!path.startsWith('/api/')) return
|
||||
if (PUBLIC_API.has(path)) return
|
||||
// Vue publique : /api/public/* est ouvert (chaque endpoint vérifie lui-même
|
||||
// que le portfolio ciblé a publicShare actif).
|
||||
if (path.startsWith('/api/public/')) return
|
||||
if (!isAuthenticated(event)) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Authentification requise' })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user