143 lines
4.5 KiB
TypeScript
143 lines
4.5 KiB
TypeScript
import fs from 'fs'
|
|
import path from 'path'
|
|
import crypto from 'crypto'
|
|
|
|
const APP_PACKAGE_ROOT = path.resolve(__dirname, '..', '..', '..')
|
|
const APP_SOURCE_ROOT = path.join(APP_PACKAGE_ROOT, 'src')
|
|
const APP_DIST_ROOT = path.join(APP_PACKAGE_ROOT, 'dist')
|
|
const DEFAULT_STORAGE_ROOT = path.join(APP_PACKAGE_ROOT, 'storage')
|
|
const LEGACY_STORAGE_ROOTS = [
|
|
path.join(APP_SOURCE_ROOT, 'lib', 'storage'),
|
|
]
|
|
|
|
export type StorageVisibility = 'public' | 'private'
|
|
|
|
export function getStorageRoot(): string {
|
|
return process.env.FILE_STORAGE_ROOT
|
|
? path.resolve(process.env.FILE_STORAGE_ROOT)
|
|
: DEFAULT_STORAGE_ROOT
|
|
}
|
|
|
|
export function getPublicStorageRoot(): string {
|
|
return path.join(getStorageRoot(), 'public')
|
|
}
|
|
|
|
export function getPrivateStorageRoot(): string {
|
|
return path.join(getStorageRoot(), 'private')
|
|
}
|
|
|
|
export function getLegacyStorageRoots(): string[] {
|
|
return LEGACY_STORAGE_ROOTS
|
|
}
|
|
|
|
function isWithinPath(targetPath: string, parentPath: string): boolean {
|
|
const relative = path.relative(parentPath, targetPath)
|
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
}
|
|
|
|
export function assertStorageConfiguration(): string {
|
|
const storageRoot = getStorageRoot()
|
|
|
|
if (process.env.NODE_ENV === 'production' && !process.env.FILE_STORAGE_ROOT) {
|
|
throw new Error('FILE_STORAGE_ROOT must be set in production so uploads are stored on the mounted volume.')
|
|
}
|
|
|
|
if (process.env.NODE_ENV === 'production') {
|
|
const forbiddenRoots = [APP_PACKAGE_ROOT, APP_SOURCE_ROOT, APP_DIST_ROOT]
|
|
const invalidRoot = forbiddenRoots.find((root) => isWithinPath(storageRoot, root))
|
|
if (invalidRoot) {
|
|
throw new Error(
|
|
`FILE_STORAGE_ROOT must point outside the API app tree in production. Received ${storageRoot}, which is inside ${invalidRoot}.`
|
|
)
|
|
}
|
|
}
|
|
|
|
return storageRoot
|
|
}
|
|
|
|
function ensureStorageRoot(visibility: StorageVisibility): string {
|
|
assertStorageConfiguration()
|
|
const root = visibility === 'public' ? getPublicStorageRoot() : getPrivateStorageRoot()
|
|
fs.mkdirSync(root, { recursive: true })
|
|
return root
|
|
}
|
|
|
|
function inferVisibility(folder: string): StorageVisibility {
|
|
const normalized = folder.replace(/\\/g, '/')
|
|
if (/\/customers\//.test(`/${normalized}/`)) return 'private'
|
|
if (/\/licenses?\//.test(`/${normalized}/`)) return 'private'
|
|
if (/\/contracts?\//.test(`/${normalized}/`)) return 'private'
|
|
if (/\/documents?\//.test(`/${normalized}/`)) return 'private'
|
|
if (/\/inspections?\//.test(`/${normalized}/`)) return 'private'
|
|
if (/\/internal\//.test(`/${normalized}/`)) return 'private'
|
|
return 'public'
|
|
}
|
|
|
|
function getApiBase(): string {
|
|
return (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '')
|
|
}
|
|
|
|
function getDashboardBase(): string {
|
|
return (
|
|
process.env.DASHBOARD_URL ??
|
|
process.env.NEXT_PUBLIC_DASHBOARD_URL ??
|
|
'http://localhost:3000/dashboard'
|
|
).replace(/\/$/, '')
|
|
}
|
|
|
|
function getStorageRelativePath(imageUrl: string): string | null {
|
|
const marker = '/storage/'
|
|
const idx = imageUrl.indexOf(marker)
|
|
if (idx === -1) return null
|
|
return imageUrl.slice(idx + marker.length)
|
|
}
|
|
|
|
export function getProtectedCustomerLicenseImageUrl(customerId: string): string {
|
|
return `${getDashboardBase()}/api/v1/customers/${customerId}/license-image`
|
|
}
|
|
|
|
export async function uploadImage(
|
|
buffer: Buffer,
|
|
folder: string,
|
|
publicId?: string,
|
|
visibility: StorageVisibility = inferVisibility(folder),
|
|
): Promise<string> {
|
|
const storageRoot = ensureStorageRoot(visibility)
|
|
const folderPath = path.join(storageRoot, folder)
|
|
if (!isWithinPath(folderPath, storageRoot)) {
|
|
throw new Error('Upload path escapes storage root')
|
|
}
|
|
fs.mkdirSync(folderPath, { recursive: true })
|
|
|
|
const filename = publicId
|
|
? `${publicId}.jpg`
|
|
: `${crypto.randomBytes(16).toString('hex')}.jpg`
|
|
|
|
fs.writeFileSync(path.join(folderPath, filename), buffer)
|
|
|
|
return `${getApiBase()}/storage/${folder}/${filename}`
|
|
}
|
|
|
|
export function resolveStoredFilePath(imageUrl: string): string | null {
|
|
const relative = getStorageRelativePath(imageUrl)
|
|
if (!relative) return null
|
|
|
|
const roots = [getPublicStorageRoot(), getPrivateStorageRoot(), ...getLegacyStorageRoots(), assertStorageConfiguration()]
|
|
for (const root of roots) {
|
|
const filePath = path.join(root, relative)
|
|
if (!isWithinPath(filePath, root)) continue
|
|
if (fs.existsSync(filePath)) {
|
|
return filePath
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export async function deleteImage(imageUrl: string): Promise<void> {
|
|
const filePath = resolveStoredFilePath(imageUrl)
|
|
if (filePath && fs.existsSync(filePath)) {
|
|
fs.unlinkSync(filePath)
|
|
}
|
|
}
|