import { readFile } from 'fs/promises' import path from 'path' import { prisma } from './prisma' 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 { 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 { 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( pathSegments: string[], userId: string | null | undefined, ): Promise { 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}` 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 }) } async function noteContainsImage(opts: { imagePath: string filename: string userId?: string isPublic?: boolean }): Promise { // Match full URL only (not bare filename) to avoid accidental cross-access const where: Record = { 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 !!row }