Flatten note tags and store processed media as ordinary resource links
wiki CI and deployment / verify-and-deploy (push) Successful in 13m27s

This commit is contained in:
2026-10-26 00:23:02 +08:00
parent 742b58013e
commit 9986846c36
31 changed files with 255 additions and 209 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ export default async function handler(req:NextApiRequest,res:NextApiResponse){
try{
const variant=String(req.query.variant||'');
if(variant&&!['poster','silent_video','audio_track'].includes(variant))return res.status(404).end();
const result=variant?await db().query('SELECT storage_key,mime_type FROM asset_variants WHERE asset_id=$1 AND role=$2',[req.query.id,variant]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[req.query.id]);
const result=variant?await db().query('SELECT storage_key,mime_type FROM assets WHERE source_asset_id=$1 AND derivation=$2',[req.query.id,variant]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[req.query.id]);
if(!result.rows[0]?.storage_key)return res.status(404).end();
const object=await getObject(result.rows[0].storage_key,req.headers.range);
res.status(object.ContentRange?206:200);
@@ -1,8 +0,0 @@
import type {NextApiRequest,NextApiResponse} from "next";
import {snapshotState,queueSnapshots} from "@/lib/snapshots";
import {sameOrigin,apiError} from "@/lib/api";
export default async function handler(req:NextApiRequest,res:NextApiResponse){
res.setHeader('Cache-Control','no-store');
if(!['GET','POST'].includes(req.method||''))return res.status(405).end();
try{const state=await snapshotState(String(req.query.slug));if(!state)return res.status(404).end();if(req.method==='POST'){sameOrigin(req);const page=req.body?.page;if(page!==undefined&&(!Number.isInteger(page)||page<1||page>8))return res.status(400).json({error:'页码应为 1—8'});await queueSnapshots(state.postId,page);return res.status(202).json({queued:true});}return res.json(state);}catch(e){return apiError(res,e);}
}
+1 -1
View File
@@ -2,5 +2,5 @@ import type {NextApiRequest,NextApiResponse} from 'next';
import {db} from '@/lib/db';
import {getObject} from '@/lib/storage';
import {previewWidth,cachedPreview,loadStaticPreview} from '@/lib/preview';
export default async function handler(req:NextApiRequest,res:NextApiResponse){if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();const width=previewWidth(req.query.width);if(!width||typeof req.query.source!=='string')return res.status(400).end();try{const source=req.query.source;let input:{identity:string;load:()=>Promise<Buffer>};if(source.startsWith('/api/media/')){const match=source.match(/^\/api\/media\/([0-9a-f-]{36})(?:\?variant=(poster))?$/);if(!match)return res.status(400).end();const result=match[2]?await db().query('SELECT storage_key,mime_type FROM asset_variants WHERE asset_id=$1 AND role=$2',[match[1],match[2]]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[match[1]]);const row=result.rows[0];if(!row?.storage_key||!row.mime_type?.startsWith('image/'))return res.status(404).end();input={identity:row.storage_key,load:async()=>{const object=await getObject(row.storage_key);if((object.ContentLength||0)>64*1024*1024)throw Error('Image too large');const chunks:Buffer[]=[];let length=0;for await(const chunk of object.Body as AsyncIterable<Uint8Array>){length+=chunk.length;if(length>64*1024*1024)throw Error('Image too large');chunks.push(Buffer.from(chunk));}return Buffer.concat(chunks);}};}else input=await loadStaticPreview(source);const {bytes,etag}=await cachedPreview(input.identity,width,input.load);res.setHeader('ETag',etag);res.setHeader('Cache-Control','public, max-age=3600');res.setHeader('Content-Type','image/webp');res.setHeader('X-Content-Type-Options','nosniff');if(req.headers['if-none-match']===etag)return res.status(304).end();res.setHeader('Content-Length',bytes.length);return req.method==='HEAD'?res.end():res.send(bytes);}catch(e){const message=(e as Error).message;if(message.includes('Invalid source'))return res.status(400).end();if((e as NodeJS.ErrnoException).code==='ENOENT')return res.status(404).end();res.setHeader('Retry-After','5');return res.status(503).end();}}
export default async function handler(req:NextApiRequest,res:NextApiResponse){if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();const width=previewWidth(req.query.width);if(!width||typeof req.query.source!=='string')return res.status(400).end();try{const source=req.query.source;let input:{identity:string;load:()=>Promise<Buffer>};if(source.startsWith('/api/media/')){const match=source.match(/^\/api\/media\/([0-9a-f-]{36})(?:\?variant=(poster))?$/);if(!match)return res.status(400).end();const result=match[2]?await db().query('SELECT storage_key,mime_type FROM assets WHERE source_asset_id=$1 AND derivation=$2',[match[1],match[2]]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[match[1]]);const row=result.rows[0];if(!row?.storage_key||!row.mime_type?.startsWith('image/'))return res.status(404).end();input={identity:row.storage_key,load:async()=>{const object=await getObject(row.storage_key);if((object.ContentLength||0)>64*1024*1024)throw Error('Image too large');const chunks:Buffer[]=[];let length=0;for await(const chunk of object.Body as AsyncIterable<Uint8Array>){length+=chunk.length;if(length>64*1024*1024)throw Error('Image too large');chunks.push(Buffer.from(chunk));}return Buffer.concat(chunks);}};}else input=await loadStaticPreview(source);const {bytes,etag}=await cachedPreview(input.identity,width,input.load);res.setHeader('ETag',etag);res.setHeader('Cache-Control','public, max-age=3600');res.setHeader('Content-Type','image/webp');res.setHeader('X-Content-Type-Options','nosniff');if(req.headers['if-none-match']===etag)return res.status(304).end();res.setHeader('Content-Length',bytes.length);return req.method==='HEAD'?res.end():res.send(bytes);}catch(e){const message=(e as Error).message;if(message.includes('Invalid source'))return res.status(400).end();if((e as NodeJS.ErrnoException).code==='ENOENT')return res.status(404).end();res.setHeader('Retry-After','5');return res.status(503).end();}}
export const config={api:{responseLimit:false}};
+11 -8
View File
@@ -1,15 +1,18 @@
import type { NextApiRequest,NextApiResponse } from "next";
import { z } from "zod";
import { randomUUID } from "node:crypto";
import { db } from "@/lib/db";
import { tags } from "@/lib/library";
import { sameOrigin,apiError } from "@/lib/api";
import type {NextApiRequest,NextApiResponse} from 'next';
import {randomUUID} from 'node:crypto';
import {z} from 'zod';
import {db} from '@/lib/db';
import {tags} from '@/lib/library';
import {sameOrigin,apiError} from '@/lib/api';
export default async function handler(req:NextApiRequest,res:NextApiResponse){
try{
if(req.method==='GET')return res.json(await tags());
if(req.method!=='POST')return res.status(405).end();
sameOrigin(req);
const data=z.object({name:z.string().trim().min(1).max(60),slug:z.string().regex(/^[a-z0-9][a-z0-9-]{0,59}$/),parentId:z.uuid().nullable().default(null)}).parse(req.body);
const id=randomUUID(); await db().query('INSERT INTO tags(id,name,slug,parent_id) VALUES($1,$2,$3,$4)',[id,data.name,data.slug,data.parentId]);return res.status(201).json({id});
const data=z.object({name:z.string().trim().transform(v=>v.replace(/^#+/,'').trim()).pipe(z.string().min(1).max(60).regex(/^[^#\s,,]+$/)),slug:z.string().regex(/^[a-z0-9][a-z0-9-]{0,59}$/).optional()}).parse(req.body);
const id=randomUUID();
const result=await db().query(`INSERT INTO tags(id,name,slug) VALUES($1,$2,$3)
ON CONFLICT(lower(btrim(name))) DO UPDATE SET name=tags.name RETURNING *,slug AS path`,[id,data.name,data.slug||'tag-'+id]);
return res.status(result.rows[0].id===id?201:200).json(result.rows[0]);
}catch(e){return apiError(res,e);}
}