feat: complete admin panel and backend admin api migration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user