Skip to content

Commit ee32169

Browse files
committed
feat: add branding module for custom logo, favicon, and settings
Add BrandingController with upload/serve/delete endpoints for custom branding assets (logo, favicon) stored in S3/MinIO. Supports admin-only uploads with file type validation, streaming serve with cache headers, and settings upsert via Hasura for live branding updates.
1 parent 4f4deab commit ee32169

4 files changed

Lines changed: 251 additions & 0 deletions

File tree

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { Transport } from "@nestjs/microservices";
4141
import { DedicatedServersModule } from "./dedicated-servers/dedicated-servers.module";
4242
import { K8sModule } from "./k8s/k8s.module";
4343
import { FileManagerModule } from "./file-manager/file-manager.module";
44+
import { BrandingModule } from "./branding/branding.module";
4445

4546
@Module({
4647
imports: [
@@ -122,6 +123,7 @@ import { FileManagerModule } from "./file-manager/file-manager.module";
122123
DedicatedServersModule,
123124
K8sModule,
124125
FileManagerModule,
126+
BrandingModule,
125127
],
126128
providers: [loggerFactory()],
127129
controllers: [AppController, QuickConnectController],
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {
2+
Controller,
3+
Post,
4+
Get,
5+
Delete,
6+
Param,
7+
Body,
8+
UploadedFile,
9+
UseInterceptors,
10+
ParseFilePipe,
11+
MaxFileSizeValidator,
12+
FileTypeValidator,
13+
Req,
14+
Res,
15+
ForbiddenException,
16+
NotFoundException,
17+
BadRequestException,
18+
} from "@nestjs/common";
19+
import { FileInterceptor } from "@nestjs/platform-express";
20+
import { Request, Response } from "express";
21+
import { BrandingService } from "./branding.service";
22+
23+
@Controller("branding")
24+
export class BrandingController {
25+
constructor(private readonly brandingService: BrandingService) {}
26+
27+
@Post("upload")
28+
@UseInterceptors(FileInterceptor("file"))
29+
async upload(
30+
@Req() request: Request,
31+
@UploadedFile(
32+
new ParseFilePipe({
33+
validators: [
34+
new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }),
35+
new FileTypeValidator({
36+
fileType: /image\/(png|jpeg|svg\+xml|webp|x-icon)/,
37+
}),
38+
],
39+
}),
40+
)
41+
file: Express.Multer.File,
42+
@Body("type") type: string,
43+
) {
44+
this.requireAdmin(request);
45+
46+
if (type !== "logo" && type !== "favicon") {
47+
throw new BadRequestException("Type must be 'logo' or 'favicon'");
48+
}
49+
50+
const path = await this.brandingService.uploadFile(
51+
type,
52+
file.buffer,
53+
file.mimetype,
54+
);
55+
56+
return { success: true, path };
57+
}
58+
59+
@Get(":type")
60+
async serve(
61+
@Param("type") type: string,
62+
@Res() res: Response,
63+
) {
64+
if (type !== "logo" && type !== "favicon") {
65+
throw new NotFoundException();
66+
}
67+
68+
const result = await this.brandingService.getFile(type);
69+
70+
if (!result) {
71+
throw new NotFoundException("No custom branding found");
72+
}
73+
74+
res.setHeader("Content-Type", result.contentType);
75+
res.setHeader("Cache-Control", "public, max-age=3600");
76+
if (result.etag) {
77+
res.setHeader("ETag", result.etag);
78+
}
79+
80+
result.stream.pipe(res);
81+
}
82+
83+
@Delete(":type")
84+
async remove(
85+
@Param("type") type: string,
86+
@Req() request: Request,
87+
) {
88+
this.requireAdmin(request);
89+
90+
if (type !== "logo" && type !== "favicon") {
91+
throw new BadRequestException("Type must be 'logo' or 'favicon'");
92+
}
93+
94+
await this.brandingService.deleteFile(type);
95+
return { success: true };
96+
}
97+
98+
private requireAdmin(request: Request) {
99+
const user = request.user as any;
100+
if (!user) {
101+
throw new ForbiddenException("Authentication required");
102+
}
103+
if (user.role !== "administrator") {
104+
throw new ForbiddenException("Administrator access required");
105+
}
106+
}
107+
}

src/branding/branding.module.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Module } from "@nestjs/common";
2+
import { BrandingService } from "./branding.service";
3+
import { BrandingController } from "./branding.controller";
4+
import { S3Module } from "../s3/s3.module";
5+
import { HasuraModule } from "../hasura/hasura.module";
6+
import { loggerFactory } from "../utilities/LoggerFactory";
7+
8+
@Module({
9+
imports: [S3Module, HasuraModule],
10+
providers: [BrandingService, loggerFactory()],
11+
controllers: [BrandingController],
12+
})
13+
export class BrandingModule {}

src/branding/branding.service.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { Injectable, Logger } from "@nestjs/common";
2+
import { S3Service } from "../s3/s3.service";
3+
import { HasuraService } from "../hasura/hasura.service";
4+
5+
@Injectable()
6+
export class BrandingService {
7+
constructor(
8+
private readonly logger: Logger,
9+
private readonly s3: S3Service,
10+
private readonly hasura: HasuraService,
11+
) {}
12+
13+
async uploadFile(
14+
type: "logo" | "favicon",
15+
buffer: Buffer,
16+
mimetype: string,
17+
): Promise<string> {
18+
const extension = this.getExtension(mimetype);
19+
const path = `branding/${type}.${extension}`;
20+
21+
await this.s3.put(path, buffer);
22+
23+
const settingName =
24+
type === "logo" ? "public.logo_url" : "public.favicon_url";
25+
26+
await this.upsertSetting(settingName, path);
27+
28+
this.logger.log(`Uploaded branding ${type} to ${path}`);
29+
return path;
30+
}
31+
32+
async getFile(type: "logo" | "favicon") {
33+
const settingName =
34+
type === "logo" ? "public.logo_url" : "public.favicon_url";
35+
36+
const { settings_by_pk } = await this.hasura.query({
37+
settings_by_pk: {
38+
__args: { name: settingName },
39+
value: true,
40+
},
41+
});
42+
43+
if (!settings_by_pk?.value) {
44+
return null;
45+
}
46+
47+
const filePath = settings_by_pk.value;
48+
49+
const exists = await this.s3.has(filePath);
50+
if (!exists) {
51+
return null;
52+
}
53+
54+
const stream = await this.s3.get(filePath);
55+
const stat = await this.s3.stat(filePath);
56+
57+
return {
58+
stream,
59+
contentType: stat.metaData?.["content-type"] || this.guessContentType(filePath),
60+
etag: stat.etag,
61+
};
62+
}
63+
64+
async deleteFile(type: "logo" | "favicon"): Promise<boolean> {
65+
const settingName =
66+
type === "logo" ? "public.logo_url" : "public.favicon_url";
67+
68+
const { settings_by_pk } = await this.hasura.query({
69+
settings_by_pk: {
70+
__args: { name: settingName },
71+
value: true,
72+
},
73+
});
74+
75+
if (settings_by_pk?.value) {
76+
await this.s3.remove(settings_by_pk.value);
77+
}
78+
79+
await this.deleteSetting(settingName);
80+
81+
this.logger.log(`Deleted branding ${type}`);
82+
return true;
83+
}
84+
85+
private async upsertSetting(name: string, value: string) {
86+
await this.hasura.mutation({
87+
insert_settings_one: {
88+
__args: {
89+
object: { name, value },
90+
on_conflict: {
91+
constraint: "settings_pkey",
92+
update_columns: ["value"],
93+
},
94+
},
95+
__typename: true,
96+
},
97+
});
98+
}
99+
100+
private async deleteSetting(name: string) {
101+
await this.hasura.mutation({
102+
delete_settings_by_pk: {
103+
__args: { name },
104+
__typename: true,
105+
},
106+
});
107+
}
108+
109+
private getExtension(mimetype: string): string {
110+
const map: Record<string, string> = {
111+
"image/png": "png",
112+
"image/jpeg": "jpg",
113+
"image/svg+xml": "svg",
114+
"image/webp": "webp",
115+
"image/x-icon": "ico",
116+
};
117+
return map[mimetype] || "png";
118+
}
119+
120+
private guessContentType(filePath: string): string {
121+
if (filePath.endsWith(".svg")) return "image/svg+xml";
122+
if (filePath.endsWith(".png")) return "image/png";
123+
if (filePath.endsWith(".jpg") || filePath.endsWith(".jpeg"))
124+
return "image/jpeg";
125+
if (filePath.endsWith(".webp")) return "image/webp";
126+
if (filePath.endsWith(".ico")) return "image/x-icon";
127+
return "application/octet-stream";
128+
}
129+
}

0 commit comments

Comments
 (0)