fix: recover empty database migrations and persist Docker icons
This commit is contained in:
1 parent
2db0c75498
commit
35bd2e1828
20 files changed
+577
-62
No files matched your search
@@ -49,6 +49,7 @@ import {
|
||||
import { createHash } from 'node:crypto';
|
||||
import { toBusinessDate } from './validation';
|
||||
import { iconName, validateStoredIcon } from './icons';
|
||||
import { persistIconFile } from './icon-files';
|
||||
import { day, businessTime } from './calculation';
|
||||
const timestamp = z.iso
|
||||
.datetime()
|
||||
@@ -656,7 +657,11 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
||||
.parse(raw),
|
||||
b = validateBackup(backup);
|
||||
const iconData = new Map<string, Buffer>();
|
||||
for (const i of b.icons) iconData.set(i.id, await validateStoredIcon(i.image, i.hash));
|
||||
for (const i of b.icons) {
|
||||
const data = await validateStoredIcon(i.image, i.hash);
|
||||
iconData.set(i.id, data);
|
||||
await persistIconFile(i.hash, data);
|
||||
}
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const ps = await tx.position.findMany({
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mkdir, readFile, writeFile, rename, unlink } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
const digest = (data: Buffer) => createHash('sha256').update(data).digest('hex');
|
||||
function target(hash: string) {
|
||||
if (!/^[a-f0-9]{64}$/.test(hash)) throw Error('Invalid icon hash');
|
||||
const directory = process.env.ICON_STORAGE_DIR?.trim();
|
||||
return directory ? join(resolve(directory), hash + '.png') : undefined;
|
||||
}
|
||||
|
||||
export async function initializeIconDirectory() {
|
||||
const configured = process.env.ICON_STORAGE_DIR?.trim();
|
||||
if (!configured) return;
|
||||
const directory = resolve(configured);
|
||||
await mkdir(directory, { recursive: true, mode: 0o750 });
|
||||
const probe = join(directory, '.write-check-' + randomBytes(16).toString('hex'));
|
||||
await writeFile(probe, '', { flag: 'wx', mode: 0o600 });
|
||||
await unlink(probe);
|
||||
}
|
||||
|
||||
// Immutable content-addressed files can be shared by multiple icon records.
|
||||
// Keep the database copy for existing installations and self-contained ZIP backups.
|
||||
export async function persistIconFile(hash: string, data: Buffer) {
|
||||
const file = target(hash);
|
||||
if (!file) return;
|
||||
if (digest(data) !== hash) throw Error('Invalid icon contents');
|
||||
try {
|
||||
if (digest(await readFile(file)) === hash) return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
await mkdir(resolve(process.env.ICON_STORAGE_DIR!.trim()), { recursive: true, mode: 0o750 });
|
||||
const temporary = file + '.' + randomBytes(16).toString('hex') + '.tmp';
|
||||
try {
|
||||
await writeFile(temporary, data, { flag: 'wx', mode: 0o600 });
|
||||
try {
|
||||
await rename(temporary, file);
|
||||
} catch (error) {
|
||||
// Windows may reject replacing a file another upload has just published.
|
||||
// Accept only an already complete file with exactly the expected content.
|
||||
const existing = await readFile(file).catch(() => undefined);
|
||||
if (!existing || digest(existing) !== hash) throw error;
|
||||
}
|
||||
} finally {
|
||||
await unlink(temporary).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function readIconFile(hash: string, databaseData: Buffer) {
|
||||
const file = target(hash);
|
||||
if (!file) return databaseData;
|
||||
try {
|
||||
const data = await readFile(file);
|
||||
if (digest(data) === hash) return data;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
await persistIconFile(hash, databaseData);
|
||||
return databaseData;
|
||||
}
|
||||
+21
-2
@@ -12,6 +12,7 @@ import {
|
||||
UseInterceptors,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
@@ -21,6 +22,7 @@ import { createHash } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { initializeIconDirectory, persistIconFile, readIconFile } from './icon-files';
|
||||
|
||||
export const iconName = z.string().trim().min(1).max(100);
|
||||
export const iconHash = (data: Buffer) => createHash('sha256').update(data).digest('hex');
|
||||
@@ -78,8 +80,24 @@ export class IconsService {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IconsBusinessService {
|
||||
export class IconsBusinessService implements OnModuleInit {
|
||||
constructor(private db: Database) {}
|
||||
async onModuleInit() {
|
||||
if (!process.env.ICON_STORAGE_DIR?.trim()) return;
|
||||
await initializeIconDirectory();
|
||||
let cursor: string | undefined;
|
||||
for (;;) {
|
||||
const rows = await this.db.icon.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
take: 100,
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
select: { id: true, hash: true, data: true },
|
||||
});
|
||||
for (const row of rows) await persistIconFile(row.hash, Buffer.from(row.data));
|
||||
if (rows.length < 100) break;
|
||||
cursor = rows[rows.length - 1].id;
|
||||
}
|
||||
}
|
||||
async list(r: UserRequest, q = '', page = '1') {
|
||||
const query = z.string().trim().max(100).parse(q);
|
||||
const index = z.coerce.number().int().min(1).max(100000).parse(page);
|
||||
@@ -105,7 +123,7 @@ export class IconsBusinessService {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
// Uploads, built-in seeding and imports already validate stored PNG data.
|
||||
// Preserve essential white artwork rather than applying the cutout twice.
|
||||
res.send(Buffer.from(icon.data));
|
||||
res.send(await readIconFile(icon.hash, Buffer.from(icon.data)));
|
||||
}
|
||||
|
||||
async upload(r: UserRequest, raw: unknown, file?: Express.Multer.File) {
|
||||
@@ -123,6 +141,7 @@ export class IconsBusinessService {
|
||||
if (!file) throw new BadRequestException('请选择图标文件');
|
||||
const data = await normalizeIcon(file.buffer),
|
||||
hash = iconHash(data);
|
||||
await persistIconFile(hash, data);
|
||||
const icon = await this.db.icon.upsert({
|
||||
where: { ownerId_hash_shared: { ownerId: r.userId, hash, shared } },
|
||||
create: { name: v.name, ownerId: r.userId, shared, hash, data },
|
||||
|
||||
@@ -139,6 +139,8 @@ async function bootstrap() {
|
||||
console.log('WorthPath API ready');
|
||||
}
|
||||
void bootstrap().catch(() => {
|
||||
console.error('API startup failed. Check local configuration and database availability.');
|
||||
console.error(
|
||||
'API startup failed. Check configuration, icon directory permissions and database availability.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in new issue
Block a user