|
| 1 | +import type { Logger } from '@guiiai/logg' |
| 2 | +import type { MediaConfig } from '@tg-search/common' |
| 3 | +import type { MediaBinaryDescriptor, MediaBinaryLocation, MediaBinaryProvider } from '@tg-search/core' |
| 4 | + |
| 5 | +import { mkdir, readFile, writeFile } from 'node:fs/promises' |
| 6 | + |
| 7 | +import { useDataPath } from '@tg-search/common/node' |
| 8 | +import { dirname, join, posix, resolve, sep } from 'pathe' |
| 9 | + |
| 10 | +const DEFAULT_MEDIA_DIR = resolve(useDataPath(), 'media') |
| 11 | + |
| 12 | +let localMediaStorage: MediaBinaryProvider | undefined |
| 13 | + |
| 14 | +function buildRelativePath(descriptor: MediaBinaryDescriptor): string { |
| 15 | + return posix.join(descriptor.kind, descriptor.uuid) |
| 16 | +} |
| 17 | + |
| 18 | +function resolveLocationPath(baseDir: string, locationPath: string): string | null { |
| 19 | + const root = resolve(baseDir) |
| 20 | + const resolved = resolve(baseDir, locationPath) |
| 21 | + |
| 22 | + if (resolved === root || resolved.startsWith(`${root}${sep}`)) { |
| 23 | + return resolved |
| 24 | + } |
| 25 | + |
| 26 | + return null |
| 27 | +} |
| 28 | + |
| 29 | +export async function initLocalMediaStorage(logger: Logger, config?: MediaConfig): Promise<MediaBinaryProvider | undefined> { |
| 30 | + const baseDir = config?.dir ? resolve(config.dir) : DEFAULT_MEDIA_DIR |
| 31 | + |
| 32 | + try { |
| 33 | + await mkdir(baseDir, { recursive: true }) |
| 34 | + } |
| 35 | + catch (error) { |
| 36 | + logger.withError(error).warn('Failed to ensure local media directory; falling back to database storage for media') |
| 37 | + return undefined |
| 38 | + } |
| 39 | + |
| 40 | + const provider: MediaBinaryProvider = { |
| 41 | + async save(descriptor: MediaBinaryDescriptor, bytes: Uint8Array, _mimeType?: string): Promise<MediaBinaryLocation> { |
| 42 | + const relativePath = buildRelativePath(descriptor) |
| 43 | + const absolutePath = join(baseDir, relativePath) |
| 44 | + |
| 45 | + await mkdir(dirname(absolutePath), { recursive: true }) |
| 46 | + await writeFile(absolutePath, bytes) |
| 47 | + |
| 48 | + return { |
| 49 | + kind: descriptor.kind, |
| 50 | + path: relativePath, |
| 51 | + } |
| 52 | + }, |
| 53 | + |
| 54 | + async load(location: MediaBinaryLocation): Promise<Uint8Array | null> { |
| 55 | + const resolvedPath = resolveLocationPath(baseDir, location.path) |
| 56 | + if (!resolvedPath) { |
| 57 | + logger.withFields({ path: location.path }).warn('Rejected local media path outside base directory') |
| 58 | + return null |
| 59 | + } |
| 60 | + |
| 61 | + try { |
| 62 | + const buffer = await readFile(resolvedPath) |
| 63 | + return new Uint8Array(buffer) |
| 64 | + } |
| 65 | + catch (error) { |
| 66 | + if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') { |
| 67 | + return null |
| 68 | + } |
| 69 | + |
| 70 | + logger.withError(error).warn('Failed to load media from local storage; returning null') |
| 71 | + return null |
| 72 | + } |
| 73 | + }, |
| 74 | + } |
| 75 | + |
| 76 | + localMediaStorage = provider |
| 77 | + logger.withFields({ baseDir }).log('Local media storage provider registered') |
| 78 | + return provider |
| 79 | +} |
| 80 | + |
| 81 | +export function getLocalMediaStorage(): MediaBinaryProvider | undefined { |
| 82 | + return localMediaStorage |
| 83 | +} |
0 commit comments