fix: images, couverture, slash, agents, wizard, Stripe et admin
- Collage/accès images: ownership path userId + meta legacy, 403 non caché - Couverture note: explicite (choix/aucune), plus d’auto depuis le corps - Éditeur: suppression image TipTap, sync immédiate, toolbar réordonnée - Slash: listes par titre (plus d’index), keywords nettoyés - Agents: nextRun à la réactivation, cron sans stampede null - Wizard: titres courts + mode Expert plus robuste - Stripe checkout hosted fiable; admin métriques live; dark mode IA/settings - Indexation notes courtes + test unit aligné
This commit is contained in:
@@ -1,37 +1,118 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { prisma } from './prisma'
|
||||
|
||||
/** Whether a note image upload may be served to the current viewer. */
|
||||
const NOTES_UPLOAD_ROOT = path.join(process.cwd(), 'data', 'uploads', 'notes')
|
||||
|
||||
export type UploadMeta = {
|
||||
userId: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical URL form:
|
||||
* /uploads/notes/{userId}/{filename} ← preferred (ownership in path)
|
||||
* Legacy:
|
||||
* /uploads/notes/{filename} ← meta sidecar or note content
|
||||
*/
|
||||
|
||||
export function uploadMetaPathForRelative(relativeUnderNotes: string): string {
|
||||
// relativeUnderNotes e.g. "userId/uuid.png" or "uuid.png"
|
||||
return path.join(NOTES_UPLOAD_ROOT, `${relativeUnderNotes}.meta.json`)
|
||||
}
|
||||
|
||||
export async function writeUploadMeta(relativeUnderNotes: string, userId: string): Promise<void> {
|
||||
const { writeFile, mkdir } = await import('fs/promises')
|
||||
const full = uploadMetaPathForRelative(relativeUnderNotes)
|
||||
await mkdir(path.dirname(full), { recursive: true })
|
||||
const meta: UploadMeta = { userId, createdAt: Date.now() }
|
||||
await writeFile(full, JSON.stringify(meta), 'utf8')
|
||||
}
|
||||
|
||||
async function readUploadMeta(relativeUnderNotes: string): Promise<UploadMeta | null> {
|
||||
try {
|
||||
const raw = await readFile(uploadMetaPathForRelative(relativeUnderNotes), 'utf8')
|
||||
const parsed = JSON.parse(raw) as UploadMeta
|
||||
if (parsed?.userId && typeof parsed.userId === 'string') return parsed
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param pathSegments path after `/uploads/` e.g. `['notes', userId, 'file.png']` or `['notes', 'file.png']`
|
||||
*/
|
||||
export async function canAccessUploadedNoteImage(
|
||||
filename: string,
|
||||
pathSegments: string[],
|
||||
userId: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (pathSegments.length < 2 || pathSegments[0] !== 'notes') return false
|
||||
|
||||
// notes/{userId}/{filename}
|
||||
if (pathSegments.length >= 3) {
|
||||
const ownerId = pathSegments[1]
|
||||
const filename = pathSegments[pathSegments.length - 1]
|
||||
const relative = pathSegments.slice(1).join('/') // userId/file.png
|
||||
const imagePath = `/uploads/notes/${relative}`
|
||||
|
||||
// 1) Path ownership — definitive for new uploads
|
||||
if (userId && ownerId === userId) return true
|
||||
|
||||
// 2) Meta (redundant but safe)
|
||||
if (userId) {
|
||||
const meta = await readUploadMeta(relative)
|
||||
if (meta?.userId === userId) return true
|
||||
}
|
||||
|
||||
// 3) Public note embeds this URL
|
||||
const published = await noteContainsImage({ imagePath, filename, isPublic: true })
|
||||
if (published) return true
|
||||
|
||||
if (!userId) return false
|
||||
|
||||
// 4) Private note owned by user embeds this URL
|
||||
return noteContainsImage({ imagePath, filename, userId })
|
||||
}
|
||||
|
||||
// Legacy: notes/{filename}
|
||||
const filename = pathSegments[1]
|
||||
const imagePath = `/uploads/notes/${filename}`
|
||||
|
||||
const published = await prisma.note.findFirst({
|
||||
where: {
|
||||
isPublic: true,
|
||||
trashedAt: null,
|
||||
OR: [
|
||||
{ content: { contains: imagePath } },
|
||||
{ images: { contains: filename } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
if (userId) {
|
||||
const meta = await readUploadMeta(filename)
|
||||
if (meta?.userId === userId) return true
|
||||
}
|
||||
|
||||
const published = await noteContainsImage({ imagePath, filename, isPublic: true })
|
||||
if (published) return true
|
||||
|
||||
if (!userId) return false
|
||||
return noteContainsImage({ imagePath, filename, userId })
|
||||
}
|
||||
|
||||
const owned = await prisma.note.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
trashedAt: null,
|
||||
OR: [
|
||||
{ content: { contains: imagePath } },
|
||||
{ images: { contains: filename } },
|
||||
],
|
||||
},
|
||||
async function noteContainsImage(opts: {
|
||||
imagePath: string
|
||||
filename: string
|
||||
userId?: string
|
||||
isPublic?: boolean
|
||||
}): Promise<boolean> {
|
||||
// Match full URL only (not bare filename) to avoid accidental cross-access
|
||||
const where: Record<string, unknown> = {
|
||||
trashedAt: null,
|
||||
OR: [
|
||||
{ content: { contains: opts.imagePath } },
|
||||
{ images: { contains: opts.imagePath } },
|
||||
// images JSON sometimes stored without leading path prefix
|
||||
{ images: { contains: opts.filename } },
|
||||
],
|
||||
}
|
||||
if (opts.isPublic) where.isPublic = true
|
||||
if (opts.userId) where.userId = opts.userId
|
||||
|
||||
const row = await prisma.note.findFirst({
|
||||
where,
|
||||
select: { id: true },
|
||||
})
|
||||
return !!owned
|
||||
return !!row
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user