feat: complete admin panel and backend admin api migration

This commit is contained in:
s.esmaeili
2026-09-13 21:02:25 +03:30
parent 03119cd655
commit 267fc7b7d1
239 changed files with 25431 additions and 935 deletions
@@ -0,0 +1,64 @@
import {
Controller,
Get,
HttpCode,
Param,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { AdminUploadsService } from './admin-uploads.service';
import { CurrentAdmin } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
import { Body } from '@nestjs/common';
/**
* The subset of multer's file object this controller uses. Declared locally
* rather than pulling in @types/multer for one interface — multer itself ships
* with @nestjs/platform-express, but its types do not.
*/
interface UploadedMulterFile {
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
}
/**
* Legacy endpoints:
* POST file/create -> storeWithoutData
* PUT file/update/{id} -> updateBody (metadata only; not ported — nothing
* in henza_admin calls it)
*
* The multipart field name `file` matches what every legacy uploader posts
* (MyUpload.vue, SingleUploadInput.vue, UploadInput.vue, PodcastUploadInput.vue).
*
* PERMISSIONS. Legacy gated this on nothing at all: `file/create` sat outside
* any middleware, and henza_server's `auth:api` group is itself commented out
* (routes/admin.php:12), so the endpoint accepted anonymous uploads. That is a
* security bug, not a feature, so it is NOT reproduced — these routes require a
* signed-in admin. No specific rid is demanded, since legacy's permission
* vocabulary has no media/file entry to port and inventing one would let it
* drift from the 144 real rids.
*/
@Controller('admin/files')
export class AdminUploadsController {
constructor(private readonly uploads: AdminUploadsService) {}
@Post('create')
@HttpCode(200)
@UseInterceptors(FileInterceptor('file'))
create(
@UploadedFile() file: UploadedMulterFile | undefined,
@Body('purpose') purpose: string | undefined,
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.uploads.store(file, purpose, admin.id ?? null);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.uploads.findOne(id);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminUploadsController } from './admin-uploads.controller';
import { AdminUploadsService } from './admin-uploads.service';
@Module({
imports: [PrismaModule],
controllers: [AdminUploadsController],
providers: [AdminUploadsService],
exports: [AdminUploadsService],
})
export class AdminUploadsModule {}
@@ -0,0 +1,80 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { PrismaService } from '../../prisma/prisma.service';
/**
* File uploads — ports legacy `POST file/create`
* (henza_server Adm/FileController::storeWithoutData).
*
* Legacy behaviour, reproduced here:
* - the multipart field is named `file`
* - the stored name is md5(time + original name) + the original extension
* - files land in one flat public directory (legacy: public/storage)
* - the JSON response carries an `id`, which the ant-design Upload wrappers
* (SingleUploadInput.vue:87, MyUpload.vue) require before emitting `ok`
*
* Deliberately NOT ported: legacy's commented-out webp re-encoding, watermarking
* and thumbnailing. That code is dead in legacy too — every branch of it is
* inside a `/* ... *​/` block, leaving only the plain `$file->move(...)` live.
*/
@Injectable()
export class AdminUploadsService {
/** Matches legacy's flat public/storage directory. */
private readonly dir = process.env.UPLOAD_DIR ?? join(process.cwd(), 'storage', 'uploads');
/** Mirrors legacy's `type` field: 1 = image, 2 = any other document. */
private static readonly IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/;
private static readonly ALLOWED = /^(image\/(jpeg|png|gif|webp|svg\+xml)|application\/pdf)$/;
/** 8 MB. Legacy relied on PHP's upload_max_filesize; this makes it explicit. */
private static readonly MAX_BYTES = 8 * 1024 * 1024;
constructor(private readonly prisma: PrismaService) {}
async store(
file: { originalname: string; mimetype: string; size: number; buffer: Buffer } | undefined,
purpose: string | undefined,
actorAdminUserId: string | null,
) {
if (!file) throw new BadRequestException('فایلی ارسال نشده است');
if (file.size > AdminUploadsService.MAX_BYTES) {
throw new BadRequestException('حجم فایل بیش از حد مجاز است (۸ مگابایت)');
}
if (!AdminUploadsService.ALLOWED.test(file.mimetype)) {
throw new BadRequestException('نوع فایل مجاز نیست');
}
// legacy: md5(time() . original_name) . '.' . ext
const ext = extname(file.originalname).toLowerCase();
const stored =
createHash('md5').update(`${Date.now()}${file.originalname}${randomUUID()}`).digest('hex') +
ext;
await mkdir(this.dir, { recursive: true });
await writeFile(join(this.dir, stored), file.buffer);
return this.prisma.uploadedFile.create({
data: {
url: `/uploads/${stored}`,
originalName: file.originalname,
mimeType: file.mimetype,
sizeBytes: file.size,
purpose: purpose ?? null,
uploadedByAdminUserId: actorAdminUserId,
},
});
}
async findOne(id: string) {
const f = await this.prisma.uploadedFile.findUnique({ where: { id } });
if (!f) throw new NotFoundException('فایل یافت نشد');
return f;
}
/** True when the stored file is an image, for callers that care. */
static isImage(mime: string) {
return AdminUploadsService.IMAGE_MIME.test(mime);
}
}