feat(storage): complete phase 3 branding foundation
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import "server-only";
|
||||
|
||||
import { getSqliteConnection } from "../db/client";
|
||||
import { BrandingService, DEFAULT_BRANDING, buildBrandingTokens, type PublicBranding } from "./service";
|
||||
|
||||
export function getBrandingService(): BrandingService {
|
||||
return new BrandingService(getSqliteConnection().db);
|
||||
}
|
||||
|
||||
export function getPublicBranding(): PublicBranding {
|
||||
try {
|
||||
return getBrandingService().getPublic();
|
||||
} catch (error) {
|
||||
if (isMissingBrandingTable(error)) {
|
||||
return {
|
||||
...DEFAULT_BRANDING,
|
||||
lightLogoUrl: null,
|
||||
darkLogoUrl: null,
|
||||
iconUrl: null,
|
||||
cssVariables: buildBrandingTokens(
|
||||
DEFAULT_BRANDING.primaryColor,
|
||||
DEFAULT_BRANDING.accentColor,
|
||||
DEFAULT_BRANDING.radiusScale,
|
||||
),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingBrandingTable(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes("no such table: instance_branding");
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { z } from "zod";
|
||||
import { requireOwnerScope, type DomainActor } from "../domain/actor";
|
||||
import type { DomainDatabase } from "../domain/database";
|
||||
import { DomainError, notFound } from "../domain/errors";
|
||||
import {
|
||||
brandingColorModes,
|
||||
brandingRadiusScales,
|
||||
type BrandingColorMode,
|
||||
type BrandingRadiusScale,
|
||||
} from "../domain/types";
|
||||
import { createBrandingRepository } from "../repositories/branding";
|
||||
import { createFileRepository } from "../repositories/files";
|
||||
|
||||
const hexColorSchema = z.string().trim().regex(/^#[0-9a-fA-F]{6}$/).transform((value) => value.toUpperCase());
|
||||
const nullableFileId = z.string().trim().min(1).max(128).nullable().optional();
|
||||
|
||||
export const brandingUpdateSchema = z.object({
|
||||
applicationName: z.string().trim().min(1).max(80).optional(),
|
||||
shortName: z.string().trim().min(1).max(24).optional(),
|
||||
primaryColor: hexColorSchema.optional(),
|
||||
accentColor: hexColorSchema.optional(),
|
||||
lightLogoFileId: nullableFileId,
|
||||
darkLogoFileId: nullableFileId,
|
||||
iconFileId: nullableFileId,
|
||||
defaultColorMode: z.enum(brandingColorModes).optional(),
|
||||
radiusScale: z.enum(brandingRadiusScales).optional(),
|
||||
organizationName: z.string().trim().max(120).nullable().optional(),
|
||||
supportEmail: z.email().nullable().optional(),
|
||||
portalWelcomeText: z.string().trim().max(2_000).nullable().optional(),
|
||||
portalFooterText: z.string().trim().max(1_000).nullable().optional(),
|
||||
});
|
||||
|
||||
export const DEFAULT_BRANDING = {
|
||||
applicationName: "Neta",
|
||||
shortName: "Neta",
|
||||
primaryColor: "#C81E1E",
|
||||
accentColor: "#E6EDF5",
|
||||
lightLogoFileId: null,
|
||||
darkLogoFileId: null,
|
||||
iconFileId: null,
|
||||
defaultColorMode: "system" as const,
|
||||
radiusScale: "default" as const,
|
||||
organizationName: null,
|
||||
supportEmail: null,
|
||||
portalWelcomeText: null,
|
||||
portalFooterText: null,
|
||||
};
|
||||
|
||||
export type BrandingSettings = {
|
||||
applicationName: string;
|
||||
shortName: string;
|
||||
primaryColor: string;
|
||||
accentColor: string;
|
||||
lightLogoFileId: string | null;
|
||||
darkLogoFileId: string | null;
|
||||
iconFileId: string | null;
|
||||
defaultColorMode: BrandingColorMode;
|
||||
radiusScale: BrandingRadiusScale;
|
||||
organizationName: string | null;
|
||||
supportEmail: string | null;
|
||||
portalWelcomeText: string | null;
|
||||
portalFooterText: string | null;
|
||||
};
|
||||
|
||||
export type PublicBranding = BrandingSettings & {
|
||||
lightLogoUrl: string | null;
|
||||
darkLogoUrl: string | null;
|
||||
iconUrl: string | null;
|
||||
cssVariables: Record<`--${string}`, string>;
|
||||
};
|
||||
|
||||
export class BrandingService {
|
||||
private readonly repository;
|
||||
private readonly files;
|
||||
|
||||
constructor(private readonly db: DomainDatabase) {
|
||||
this.repository = createBrandingRepository(db);
|
||||
this.files = createFileRepository(db);
|
||||
}
|
||||
|
||||
getPublic(): PublicBranding {
|
||||
const stored = this.repository.get();
|
||||
const settings = stored
|
||||
? {
|
||||
applicationName: stored.applicationName,
|
||||
shortName: stored.shortName,
|
||||
primaryColor: stored.primaryColor,
|
||||
accentColor: stored.accentColor,
|
||||
lightLogoFileId: stored.lightLogoFileId,
|
||||
darkLogoFileId: stored.darkLogoFileId,
|
||||
iconFileId: stored.iconFileId,
|
||||
defaultColorMode: stored.defaultColorMode,
|
||||
radiusScale: stored.radiusScale,
|
||||
organizationName: stored.organizationName,
|
||||
supportEmail: stored.supportEmail,
|
||||
portalWelcomeText: stored.portalWelcomeText,
|
||||
portalFooterText: stored.portalFooterText,
|
||||
}
|
||||
: DEFAULT_BRANDING;
|
||||
|
||||
const fallbackLogoId = settings.lightLogoFileId ?? settings.darkLogoFileId;
|
||||
const lightLogoId = settings.lightLogoFileId ?? fallbackLogoId;
|
||||
const darkLogoId = settings.darkLogoFileId ?? fallbackLogoId;
|
||||
return {
|
||||
...settings,
|
||||
lightLogoUrl: publicAssetUrl(lightLogoId),
|
||||
darkLogoUrl: publicAssetUrl(darkLogoId),
|
||||
iconUrl: publicAssetUrl(settings.iconFileId),
|
||||
cssVariables: buildBrandingTokens(settings.primaryColor, settings.accentColor, settings.radiusScale),
|
||||
};
|
||||
}
|
||||
|
||||
update(actor: DomainActor, input: unknown): PublicBranding {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const parsed = brandingUpdateSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Marka ayarları geçersiz.", {
|
||||
fields: parsed.error.flatten().fieldErrors,
|
||||
});
|
||||
}
|
||||
|
||||
this.assertBrandingFile(scope.ownerUserId, parsed.data.lightLogoFileId, "logo");
|
||||
this.assertBrandingFile(scope.ownerUserId, parsed.data.darkLogoFileId, "logo");
|
||||
this.assertBrandingFile(scope.ownerUserId, parsed.data.iconFileId, "icon");
|
||||
|
||||
const existing = this.repository.get();
|
||||
if (existing && existing.ownerUserId !== scope.ownerUserId) {
|
||||
throw new DomainError("FORBIDDEN", "Instance marka ayarları başka bir owner'a ait.");
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
this.repository.update({ ...parsed.data, updatedByUserId: scope.ownerUserId });
|
||||
} else {
|
||||
this.repository.create({
|
||||
id: "default",
|
||||
ownerUserId: scope.ownerUserId,
|
||||
updatedByUserId: scope.ownerUserId,
|
||||
...DEFAULT_BRANDING,
|
||||
...parsed.data,
|
||||
});
|
||||
}
|
||||
return this.getPublic();
|
||||
}
|
||||
|
||||
private assertBrandingFile(
|
||||
ownerUserId: string,
|
||||
fileId: string | null | undefined,
|
||||
expected: "logo" | "icon",
|
||||
): void {
|
||||
if (fileId === undefined || fileId === null) return;
|
||||
const file = this.files.get(fileId);
|
||||
if (!file || file.ownerUserId !== ownerUserId) throw notFound("Marka dosyası");
|
||||
const expectedKind = expected === "icon" ? "branding_icon" : "branding_logo";
|
||||
if (file.kind !== expectedKind || file.visibility !== "public_branding") {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "Dosya marka alanıyla uyumlu değil.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBrandingTokens(
|
||||
primary: string,
|
||||
accent: string,
|
||||
radiusScale: "compact" | "default" | "soft",
|
||||
): Record<`--${string}`, string> {
|
||||
const radius = radiusScale === "compact" ? "0.25rem" : radiusScale === "soft" ? "0.75rem" : "0.375rem";
|
||||
return {
|
||||
"--primary": primary,
|
||||
"--primary-foreground": readableForeground(primary),
|
||||
"--primary-hover": mixHex(primary, "#000000", 0.14),
|
||||
"--primary-pressed": mixHex(primary, "#000000", 0.28),
|
||||
"--accent": accent,
|
||||
"--accent-foreground": readableForeground(accent),
|
||||
"--accent-hover": mixHex(accent, readableForeground(accent), 0.1),
|
||||
"--ring": primary,
|
||||
"--radius": radius,
|
||||
"--radius-sm": radiusScale === "soft" ? "0.5rem" : "0.25rem",
|
||||
"--radius-md": radius,
|
||||
"--radius-lg": radiusScale === "compact" ? "0.375rem" : radiusScale === "soft" ? "1rem" : "0.5rem",
|
||||
"--radius-xl": radiusScale === "compact" ? "0.5rem" : radiusScale === "soft" ? "1.25rem" : "0.75rem",
|
||||
};
|
||||
}
|
||||
|
||||
export function contrastRatio(first: string, second: string): number {
|
||||
const firstLuminance = luminance(first);
|
||||
const secondLuminance = luminance(second);
|
||||
return (Math.max(firstLuminance, secondLuminance) + 0.05) / (Math.min(firstLuminance, secondLuminance) + 0.05);
|
||||
}
|
||||
|
||||
function readableForeground(background: string): "#000000" | "#FFFFFF" {
|
||||
return contrastRatio(background, "#000000") >= contrastRatio(background, "#FFFFFF")
|
||||
? "#000000"
|
||||
: "#FFFFFF";
|
||||
}
|
||||
|
||||
function luminance(color: string): number {
|
||||
const [red, green, blue] = hexChannels(color).map((channel) => {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.03928
|
||||
? normalized / 12.92
|
||||
: ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
}
|
||||
|
||||
function mixHex(base: string, overlay: string, overlayWeight: number): string {
|
||||
const baseChannels = hexChannels(base);
|
||||
const overlayChannels = hexChannels(overlay);
|
||||
const channels = baseChannels.map((channel, index) =>
|
||||
Math.round(channel * (1 - overlayWeight) + overlayChannels[index] * overlayWeight),
|
||||
);
|
||||
return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`.toUpperCase();
|
||||
}
|
||||
|
||||
function hexChannels(color: string): [number, number, number] {
|
||||
return [
|
||||
Number.parseInt(color.slice(1, 3), 16),
|
||||
Number.parseInt(color.slice(3, 5), 16),
|
||||
Number.parseInt(color.slice(5, 7), 16),
|
||||
];
|
||||
}
|
||||
|
||||
function publicAssetUrl(fileId: string | null): string | null {
|
||||
return fileId ? `/api/branding/assets/${fileId}` : null;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
CREATE TABLE `files` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`uploaded_by_user_id` text NOT NULL,
|
||||
`auth_user_id` text,
|
||||
`project_id` text,
|
||||
`kind` text NOT NULL,
|
||||
`visibility` text DEFAULT 'private' NOT NULL,
|
||||
`storage_path` text NOT NULL,
|
||||
`original_name` text NOT NULL,
|
||||
`mime_type` text NOT NULL,
|
||||
`byte_size` integer NOT NULL,
|
||||
`sha256` text NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE restrict,
|
||||
FOREIGN KEY (`uploaded_by_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE restrict,
|
||||
FOREIGN KEY (`auth_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE restrict,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE restrict,
|
||||
CONSTRAINT "files_kind_check" CHECK("files"."kind" in ('avatar', 'branding_logo', 'branding_icon', 'project_asset')),
|
||||
CONSTRAINT "files_visibility_check" CHECK("files"."visibility" in ('private', 'portal', 'public_branding')),
|
||||
CONSTRAINT "files_byte_size_check" CHECK("files"."byte_size" > 0),
|
||||
CONSTRAINT "files_sha256_check" CHECK(length("files"."sha256") = 64),
|
||||
CONSTRAINT "files_storage_path_check" CHECK("files"."storage_path" not like '/%' and instr("files"."storage_path", '..') = 0),
|
||||
CONSTRAINT "files_resource_check" CHECK((
|
||||
("files"."kind" = 'avatar' and "files"."auth_user_id" is not null and "files"."project_id" is null and "files"."visibility" = 'private')
|
||||
or ("files"."kind" in ('branding_logo', 'branding_icon') and "files"."auth_user_id" is null and "files"."project_id" is null and "files"."visibility" = 'public_branding')
|
||||
or ("files"."kind" = 'project_asset' and "files"."auth_user_id" is null and "files"."project_id" is not null and "files"."visibility" in ('private', 'portal'))
|
||||
))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `files_storage_path_unique` ON `files` (`storage_path`);--> statement-breakpoint
|
||||
CREATE INDEX `files_owner_kind_idx` ON `files` (`owner_user_id`,`kind`);--> statement-breakpoint
|
||||
CREATE INDEX `files_auth_user_id_idx` ON `files` (`auth_user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `files_project_id_idx` ON `files` (`project_id`);--> statement-breakpoint
|
||||
CREATE INDEX `files_sha256_idx` ON `files` (`sha256`);--> statement-breakpoint
|
||||
CREATE TABLE `instance_branding` (
|
||||
`id` text PRIMARY KEY DEFAULT 'default' NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`application_name` text DEFAULT 'Neta' NOT NULL,
|
||||
`short_name` text DEFAULT 'Neta' NOT NULL,
|
||||
`primary_color` text DEFAULT '#C81E1E' NOT NULL,
|
||||
`accent_color` text DEFAULT '#E6EDF5' NOT NULL,
|
||||
`light_logo_file_id` text,
|
||||
`dark_logo_file_id` text,
|
||||
`icon_file_id` text,
|
||||
`default_color_mode` text DEFAULT 'system' NOT NULL,
|
||||
`radius_scale` text DEFAULT 'default' NOT NULL,
|
||||
`organization_name` text,
|
||||
`support_email` text,
|
||||
`portal_welcome_text` text,
|
||||
`portal_footer_text` text,
|
||||
`updated_by_user_id` text NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE restrict,
|
||||
FOREIGN KEY (`light_logo_file_id`) REFERENCES `files`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`dark_logo_file_id`) REFERENCES `files`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`icon_file_id`) REFERENCES `files`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`updated_by_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE restrict,
|
||||
CONSTRAINT "instance_branding_id_check" CHECK("instance_branding"."id" = 'default'),
|
||||
CONSTRAINT "instance_branding_primary_color_check" CHECK("instance_branding"."primary_color" glob '#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]'),
|
||||
CONSTRAINT "instance_branding_accent_color_check" CHECK("instance_branding"."accent_color" glob '#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]'),
|
||||
CONSTRAINT "instance_branding_color_mode_check" CHECK("instance_branding"."default_color_mode" in ('light', 'dark', 'system')),
|
||||
CONSTRAINT "instance_branding_radius_scale_check" CHECK("instance_branding"."radius_scale" in ('compact', 'default', 'soft'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `instance_branding_owner_unique` ON `instance_branding` (`owner_user_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
||||
"when": 1784208712933,
|
||||
"tag": "0003_chief_excalibur",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1784210311370,
|
||||
"tag": "0004_fancy_baron_zemo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./auth";
|
||||
export * from "./domain";
|
||||
export * from "./runtime";
|
||||
export * from "./storage";
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { check, index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import type {
|
||||
BrandingColorMode,
|
||||
BrandingRadiusScale,
|
||||
FileKind,
|
||||
FileVisibility,
|
||||
} from "../../domain/types";
|
||||
import { user } from "./auth";
|
||||
import { projects } from "./domain";
|
||||
|
||||
const nowMs = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
|
||||
|
||||
export const files = sqliteTable(
|
||||
"files",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "restrict" }),
|
||||
uploadedByUserId: text("uploaded_by_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "restrict" }),
|
||||
authUserId: text("auth_user_id").references(() => user.id, { onDelete: "restrict" }),
|
||||
projectId: text("project_id").references(() => projects.id, { onDelete: "restrict" }),
|
||||
kind: text("kind").$type<FileKind>().notNull(),
|
||||
visibility: text("visibility").$type<FileVisibility>().default("private").notNull(),
|
||||
storagePath: text("storage_path").notNull(),
|
||||
originalName: text("original_name").notNull(),
|
||||
mimeType: text("mime_type").notNull(),
|
||||
byteSize: integer("byte_size").notNull(),
|
||||
sha256: text("sha256").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(nowMs)
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("files_storage_path_unique").on(table.storagePath),
|
||||
index("files_owner_kind_idx").on(table.ownerUserId, table.kind),
|
||||
index("files_auth_user_id_idx").on(table.authUserId),
|
||||
index("files_project_id_idx").on(table.projectId),
|
||||
index("files_sha256_idx").on(table.sha256),
|
||||
check(
|
||||
"files_kind_check",
|
||||
sql`${table.kind} in ('avatar', 'branding_logo', 'branding_icon', 'project_asset')`,
|
||||
),
|
||||
check(
|
||||
"files_visibility_check",
|
||||
sql`${table.visibility} in ('private', 'portal', 'public_branding')`,
|
||||
),
|
||||
check("files_byte_size_check", sql`${table.byteSize} > 0`),
|
||||
check("files_sha256_check", sql`length(${table.sha256}) = 64`),
|
||||
check("files_storage_path_check", sql`${table.storagePath} not like '/%' and instr(${table.storagePath}, '..') = 0`),
|
||||
check(
|
||||
"files_resource_check",
|
||||
sql`(
|
||||
(${table.kind} = 'avatar' and ${table.authUserId} is not null and ${table.projectId} is null and ${table.visibility} = 'private')
|
||||
or (${table.kind} in ('branding_logo', 'branding_icon') and ${table.authUserId} is null and ${table.projectId} is null and ${table.visibility} = 'public_branding')
|
||||
or (${table.kind} = 'project_asset' and ${table.authUserId} is null and ${table.projectId} is not null and ${table.visibility} in ('private', 'portal'))
|
||||
)`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const instanceBranding = sqliteTable(
|
||||
"instance_branding",
|
||||
{
|
||||
id: text("id").primaryKey().default("default"),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "restrict" }),
|
||||
applicationName: text("application_name").default("Neta").notNull(),
|
||||
shortName: text("short_name").default("Neta").notNull(),
|
||||
primaryColor: text("primary_color").default("#C81E1E").notNull(),
|
||||
accentColor: text("accent_color").default("#E6EDF5").notNull(),
|
||||
lightLogoFileId: text("light_logo_file_id").references(() => files.id, { onDelete: "set null" }),
|
||||
darkLogoFileId: text("dark_logo_file_id").references(() => files.id, { onDelete: "set null" }),
|
||||
iconFileId: text("icon_file_id").references(() => files.id, { onDelete: "set null" }),
|
||||
defaultColorMode: text("default_color_mode").$type<BrandingColorMode>().default("system").notNull(),
|
||||
radiusScale: text("radius_scale").$type<BrandingRadiusScale>().default("default").notNull(),
|
||||
organizationName: text("organization_name"),
|
||||
supportEmail: text("support_email"),
|
||||
portalWelcomeText: text("portal_welcome_text"),
|
||||
portalFooterText: text("portal_footer_text"),
|
||||
updatedByUserId: text("updated_by_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "restrict" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(nowMs)
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("instance_branding_owner_unique").on(table.ownerUserId),
|
||||
check("instance_branding_id_check", sql`${table.id} = 'default'`),
|
||||
check("instance_branding_primary_color_check", sql`${table.primaryColor} glob '#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]'`),
|
||||
check("instance_branding_accent_color_check", sql`${table.accentColor} glob '#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]'`),
|
||||
check("instance_branding_color_mode_check", sql`${table.defaultColorMode} in ('light', 'dark', 'system')`),
|
||||
check("instance_branding_radius_scale_check", sql`${table.radiusScale} in ('compact', 'default', 'soft')`),
|
||||
],
|
||||
);
|
||||
@@ -48,3 +48,15 @@ export type ContractStatus = (typeof contractStatuses)[number];
|
||||
export type InvoiceStatus = (typeof invoiceStatuses)[number];
|
||||
export type SubscriptionBillingCycle = (typeof subscriptionBillingCycles)[number];
|
||||
export type SubscriptionStatus = (typeof subscriptionStatuses)[number];
|
||||
|
||||
export const fileKinds = ["avatar", "branding_logo", "branding_icon", "project_asset"] as const;
|
||||
export type FileKind = (typeof fileKinds)[number];
|
||||
|
||||
export const fileVisibilities = ["private", "portal", "public_branding"] as const;
|
||||
export type FileVisibility = (typeof fileVisibilities)[number];
|
||||
|
||||
export const brandingColorModes = ["light", "dark", "system"] as const;
|
||||
export type BrandingColorMode = (typeof brandingColorModes)[number];
|
||||
|
||||
export const brandingRadiusScales = ["compact", "default", "soft"] as const;
|
||||
export type BrandingRadiusScale = (typeof brandingRadiusScales)[number];
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export function fileResponse(
|
||||
metadata: { mimeType: string; originalName: string; sha256: string },
|
||||
bytes: Uint8Array,
|
||||
cacheControl: string,
|
||||
) {
|
||||
return new Response(bytes as BodyInit, {
|
||||
headers: {
|
||||
"Cache-Control": cacheControl,
|
||||
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(metadata.originalName)}`,
|
||||
"Content-Length": String(bytes.byteLength),
|
||||
"Content-Type": metadata.mimeType,
|
||||
ETag: `"${metadata.sha256}"`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import path from "node:path";
|
||||
import { DomainError } from "../domain/errors";
|
||||
|
||||
export function resolveStoragePath(uploadsDir: string, storagePath: string): string {
|
||||
if (
|
||||
!storagePath ||
|
||||
path.isAbsolute(storagePath) ||
|
||||
storagePath.includes("\\") ||
|
||||
storagePath.includes("\0")
|
||||
) {
|
||||
throw invalidPath();
|
||||
}
|
||||
|
||||
const segments = storagePath.split("/");
|
||||
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
|
||||
throw invalidPath();
|
||||
}
|
||||
|
||||
const root = path.resolve(uploadsDir);
|
||||
const resolved = path.resolve(root, ...segments);
|
||||
if (!resolved.startsWith(`${root}${path.sep}`)) {
|
||||
throw invalidPath();
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function buildStoragePath(directory: string, id: string, extension: string): string {
|
||||
if (!/^[a-z-]+$/.test(directory) || !/^[a-zA-Z0-9-]+$/.test(id) || !/^[a-z0-9]+$/.test(extension)) {
|
||||
throw invalidPath();
|
||||
}
|
||||
return `${directory}/${id}.${extension}`;
|
||||
}
|
||||
|
||||
function invalidPath() {
|
||||
return new DomainError("VALIDATION_ERROR", "Geçersiz dosya yolu.");
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { DomainError } from "../domain/errors";
|
||||
import type { FileKind } from "../domain/types";
|
||||
|
||||
export const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
const allowedImages = {
|
||||
"image/jpeg": { extension: "jpg", matches: isJpeg },
|
||||
"image/png": { extension: "png", matches: isPng },
|
||||
"image/webp": { extension: "webp", matches: isWebp },
|
||||
"image/gif": { extension: "gif", matches: isGif },
|
||||
} as const;
|
||||
|
||||
export type AllowedMimeType = keyof typeof allowedImages;
|
||||
|
||||
export type ValidatedUpload = {
|
||||
bytes: Uint8Array;
|
||||
byteSize: number;
|
||||
mimeType: AllowedMimeType;
|
||||
extension: string;
|
||||
originalName: string;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
export function validateUpload(input: {
|
||||
kind: FileKind;
|
||||
originalName: string;
|
||||
claimedMimeType: string;
|
||||
bytes: Uint8Array;
|
||||
}): ValidatedUpload {
|
||||
const byteSize = input.bytes.byteLength;
|
||||
if (byteSize === 0) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Boş dosya yüklenemez.");
|
||||
}
|
||||
if (byteSize > MAX_UPLOAD_BYTES) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Dosya boyutu 5 MB sınırını aşıyor.", {
|
||||
maximumBytes: MAX_UPLOAD_BYTES,
|
||||
});
|
||||
}
|
||||
|
||||
const mimeType = input.claimedMimeType.toLowerCase() as AllowedMimeType;
|
||||
const policy = allowedImages[mimeType];
|
||||
if (!policy || (input.kind === "branding_icon" && mimeType !== "image/png")) {
|
||||
throw new DomainError(
|
||||
"VALIDATION_ERROR",
|
||||
"Yalnızca JPEG, PNG, WebP ve desteklenen alanlarda GIF görselleri kabul edilir; uygulama ikonu PNG olmalı ve SVG desteklenmez.",
|
||||
);
|
||||
}
|
||||
if (!policy.matches(input.bytes)) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Dosya içeriği bildirilen MIME türüyle uyuşmuyor.");
|
||||
}
|
||||
|
||||
return {
|
||||
bytes: input.bytes,
|
||||
byteSize,
|
||||
mimeType,
|
||||
extension: policy.extension,
|
||||
originalName: normalizeOriginalName(input.originalName),
|
||||
sha256: createHash("sha256").update(input.bytes).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOriginalName(value: string): string {
|
||||
const normalized = value
|
||||
.normalize("NFKC")
|
||||
.replace(/[\u0000-\u001f\u007f]/g, "")
|
||||
.replace(/[\\/]/g, "-")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
return normalized || "upload";
|
||||
}
|
||||
|
||||
function isJpeg(bytes: Uint8Array) {
|
||||
return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||
}
|
||||
|
||||
function isPng(bytes: Uint8Array) {
|
||||
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
return bytes.length >= signature.length && signature.every((value, index) => bytes[index] === value);
|
||||
}
|
||||
|
||||
function isWebp(bytes: Uint8Array) {
|
||||
return bytes.length >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP";
|
||||
}
|
||||
|
||||
function isGif(bytes: Uint8Array) {
|
||||
const header = ascii(bytes, 0, 6);
|
||||
return header === "GIF87a" || header === "GIF89a";
|
||||
}
|
||||
|
||||
function ascii(bytes: Uint8Array, start: number, end: number): string {
|
||||
return String.fromCharCode(...bytes.slice(start, end));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import "server-only";
|
||||
|
||||
import { getServerConfig } from "../config";
|
||||
import { getSqliteConnection } from "../db/client";
|
||||
import { FileService } from "./service";
|
||||
|
||||
export function getFileService(): FileService {
|
||||
const config = getServerConfig();
|
||||
return new FileService(getSqliteConnection().db, {
|
||||
uploadsDir: config.uploadsDir,
|
||||
tmpDir: config.tmpDir,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { clients, projects } from "../db/schema/domain";
|
||||
import { files } from "../db/schema/storage";
|
||||
import { user } from "../db/schema/auth";
|
||||
import { assertEnabledActor, requireClientScope, requireOwnerScope, type DomainActor } from "../domain/actor";
|
||||
import type { DomainDatabase } from "../domain/database";
|
||||
import { DomainError, notFound } from "../domain/errors";
|
||||
import { generateId, type IdGenerator } from "../domain/id";
|
||||
import type { FileKind, FileVisibility } from "../domain/types";
|
||||
import { createFileRepository } from "../repositories/files";
|
||||
import { buildStoragePath, resolveStoragePath } from "./paths";
|
||||
import { validateUpload } from "./policy";
|
||||
|
||||
export type FileStorageConfig = { uploadsDir: string; tmpDir: string };
|
||||
|
||||
export type FileUploadInput = {
|
||||
kind: FileKind;
|
||||
originalName: string;
|
||||
claimedMimeType: string;
|
||||
bytes: Uint8Array;
|
||||
projectId?: string;
|
||||
portalVisible?: boolean;
|
||||
};
|
||||
|
||||
export type StoredFile = typeof files.$inferSelect;
|
||||
|
||||
export class FileService {
|
||||
private readonly repository;
|
||||
|
||||
constructor(
|
||||
private readonly db: DomainDatabase,
|
||||
private readonly config: FileStorageConfig,
|
||||
private readonly id: IdGenerator = generateId,
|
||||
) {
|
||||
this.repository = createFileRepository(db);
|
||||
}
|
||||
|
||||
upload(actor: DomainActor, input: FileUploadInput): StoredFile {
|
||||
assertEnabledActor(actor);
|
||||
const upload = validateUpload(input);
|
||||
const fileId = this.id();
|
||||
const resource = this.resolveUploadResource(actor, input);
|
||||
const storagePath = buildStoragePath(directoryFor(input.kind), fileId, upload.extension);
|
||||
const finalPath = resolveStoragePath(this.config.uploadsDir, storagePath);
|
||||
const temporaryPath = path.join(this.config.tmpDir, `upload-${fileId}.tmp`);
|
||||
|
||||
fs.mkdirSync(path.dirname(finalPath), { recursive: true });
|
||||
fs.mkdirSync(this.config.tmpDir, { recursive: true });
|
||||
fs.writeFileSync(temporaryPath, upload.bytes, { flag: "wx", mode: 0o600 });
|
||||
let finalCreated = false;
|
||||
|
||||
try {
|
||||
fs.linkSync(temporaryPath, finalPath);
|
||||
finalCreated = true;
|
||||
fs.unlinkSync(temporaryPath);
|
||||
return this.db.transaction((tx) => {
|
||||
const stored = tx.insert(files).values({
|
||||
id: fileId,
|
||||
ownerUserId: resource.ownerUserId,
|
||||
uploadedByUserId: actor.authUserId,
|
||||
authUserId: resource.authUserId,
|
||||
projectId: resource.projectId,
|
||||
kind: input.kind,
|
||||
visibility: resource.visibility,
|
||||
storagePath,
|
||||
originalName: upload.originalName,
|
||||
mimeType: upload.mimeType,
|
||||
byteSize: upload.byteSize,
|
||||
sha256: upload.sha256,
|
||||
}).returning().get();
|
||||
|
||||
if (input.kind === "avatar") {
|
||||
tx.update(user)
|
||||
.set({ image: `/api/files/${fileId}`, updatedAt: new Date() })
|
||||
.where(eq(user.id, actor.authUserId))
|
||||
.run();
|
||||
}
|
||||
return stored;
|
||||
}, { behavior: "immediate" });
|
||||
} catch (error) {
|
||||
safeUnlink(temporaryPath);
|
||||
if (finalCreated) safeUnlink(finalPath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
read(actor: DomainActor, id: string): { metadata: StoredFile; bytes: Buffer } {
|
||||
assertEnabledActor(actor);
|
||||
const metadata = this.repository.get(id) ?? this.throwNotFound();
|
||||
this.assertCanRead(actor, metadata);
|
||||
return { metadata, bytes: this.readStoredBytes(metadata) };
|
||||
}
|
||||
|
||||
readPublicBranding(id: string): { metadata: StoredFile; bytes: Buffer } {
|
||||
const metadata = this.repository.getPublicBrandingAsset(id) ?? this.throwNotFound();
|
||||
return { metadata, bytes: this.readStoredBytes(metadata) };
|
||||
}
|
||||
|
||||
delete(actor: DomainActor, id: string): StoredFile {
|
||||
assertEnabledActor(actor);
|
||||
const metadata = this.repository.get(id) ?? this.throwNotFound();
|
||||
this.assertCanDelete(actor, metadata);
|
||||
|
||||
const finalPath = resolveStoragePath(this.config.uploadsDir, metadata.storagePath);
|
||||
const trashPath = path.join(this.config.tmpDir, `delete-${metadata.id}.tmp`);
|
||||
fs.mkdirSync(this.config.tmpDir, { recursive: true });
|
||||
const exists = fs.existsSync(finalPath);
|
||||
if (exists) fs.renameSync(finalPath, trashPath);
|
||||
|
||||
try {
|
||||
const removed = this.db.transaction((tx) => {
|
||||
if (metadata.kind === "avatar" && metadata.authUserId) {
|
||||
tx.update(user)
|
||||
.set({ image: null, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(user.id, metadata.authUserId),
|
||||
eq(user.image, `/api/files/${metadata.id}`),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
return tx.delete(files).where(eq(files.id, metadata.id)).returning().get();
|
||||
}, { behavior: "immediate" });
|
||||
if (!removed) throw notFound("Dosya");
|
||||
safeUnlink(trashPath);
|
||||
return removed;
|
||||
} catch (error) {
|
||||
if (exists && fs.existsSync(trashPath)) fs.renameSync(trashPath, finalPath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveUploadResource(actor: DomainActor, input: FileUploadInput): {
|
||||
ownerUserId: string;
|
||||
authUserId: string | null;
|
||||
projectId: string | null;
|
||||
visibility: FileVisibility;
|
||||
} {
|
||||
if (input.kind === "avatar") {
|
||||
const ownerUserId = actor.role === "freelancer"
|
||||
? requireOwnerScope(actor).ownerUserId
|
||||
: this.getClientOwner(actor);
|
||||
return { ownerUserId, authUserId: actor.authUserId, projectId: null, visibility: "private" };
|
||||
}
|
||||
|
||||
const scope = requireOwnerScope(actor);
|
||||
if (input.kind === "branding_logo" || input.kind === "branding_icon") {
|
||||
return { ownerUserId: scope.ownerUserId, authUserId: null, projectId: null, visibility: "public_branding" };
|
||||
}
|
||||
|
||||
if (!input.projectId) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Project asset için projectId zorunludur.");
|
||||
}
|
||||
const project = this.db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, input.projectId), eq(projects.ownerUserId, scope.ownerUserId)))
|
||||
.get();
|
||||
if (!project) throw notFound("Proje");
|
||||
return {
|
||||
ownerUserId: scope.ownerUserId,
|
||||
authUserId: null,
|
||||
projectId: project.id,
|
||||
visibility: input.portalVisible ? "portal" : "private",
|
||||
};
|
||||
}
|
||||
|
||||
private assertCanRead(actor: DomainActor, file: StoredFile): void {
|
||||
if (actor.role === "freelancer") {
|
||||
if (file.ownerUserId !== requireOwnerScope(actor).ownerUserId) throw notFound("Dosya");
|
||||
return;
|
||||
}
|
||||
const scope = requireClientScope(actor);
|
||||
if (file.kind === "avatar" && file.authUserId === scope.authUserId) return;
|
||||
if (file.kind === "project_asset" && file.visibility === "portal" && file.projectId) {
|
||||
const project = this.db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, file.projectId), eq(projects.clientId, scope.clientId)))
|
||||
.get();
|
||||
if (project) return;
|
||||
}
|
||||
throw notFound("Dosya");
|
||||
}
|
||||
|
||||
private assertCanDelete(actor: DomainActor, file: StoredFile): void {
|
||||
if (actor.role === "freelancer" && file.ownerUserId === actor.authUserId) return;
|
||||
if (actor.role === "client" && file.kind === "avatar" && file.authUserId === actor.authUserId) return;
|
||||
throw new DomainError("FORBIDDEN", "Bu dosyayı silme yetkiniz yok.");
|
||||
}
|
||||
|
||||
private getClientOwner(actor: DomainActor): string {
|
||||
const scope = requireClientScope(actor);
|
||||
const client = this.db
|
||||
.select({ ownerUserId: clients.ownerUserId })
|
||||
.from(clients)
|
||||
.where(and(eq(clients.id, scope.clientId), eq(clients.authUserId, scope.authUserId)))
|
||||
.get();
|
||||
if (!client) throw new DomainError("FORBIDDEN", "Geçerli müşteri bağı bulunamadı.");
|
||||
return client.ownerUserId;
|
||||
}
|
||||
|
||||
private readStoredBytes(metadata: StoredFile): Buffer {
|
||||
const absolutePath = resolveStoragePath(this.config.uploadsDir, metadata.storagePath);
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = fs.openSync(absolutePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
||||
const stat = fs.fstatSync(descriptor);
|
||||
if (!stat.isFile() || stat.size !== metadata.byteSize) {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "Dosya metadata ile uyuşmuyor.");
|
||||
}
|
||||
return fs.readFileSync(descriptor);
|
||||
} catch (error) {
|
||||
if (error instanceof DomainError) throw error;
|
||||
throw notFound("Dosya içeriği");
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
private throwNotFound(): never {
|
||||
throw notFound("Dosya");
|
||||
}
|
||||
}
|
||||
|
||||
function directoryFor(kind: FileKind): string {
|
||||
if (kind === "avatar") return "avatars";
|
||||
if (kind === "project_asset") return "project-assets";
|
||||
return "branding";
|
||||
}
|
||||
|
||||
function safeUnlink(filePath: string): void {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { instanceBranding } from "../db/schema/storage";
|
||||
import type { DomainDatabase } from "../domain/database";
|
||||
|
||||
export function createBrandingRepository(db: DomainDatabase) {
|
||||
return {
|
||||
get: () => db.select().from(instanceBranding).where(eq(instanceBranding.id, "default")).get(),
|
||||
create: (value: typeof instanceBranding.$inferInsert) =>
|
||||
db.insert(instanceBranding).values(value).returning().get(),
|
||||
update: (value: Partial<typeof instanceBranding.$inferInsert>) =>
|
||||
db.update(instanceBranding).set(value).where(eq(instanceBranding.id, "default")).returning().get(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { and, desc, eq, or } from "drizzle-orm";
|
||||
import { instanceBranding } from "../db/schema/storage";
|
||||
import { files } from "../db/schema/storage";
|
||||
import type { OwnerScope } from "../domain/actor";
|
||||
import type { DomainDatabase } from "../domain/database";
|
||||
|
||||
export function createFileRepository(db: DomainDatabase) {
|
||||
return {
|
||||
get: (id: string) => db.select().from(files).where(eq(files.id, id)).get(),
|
||||
getOwned: (scope: OwnerScope, id: string) =>
|
||||
db.select().from(files).where(and(eq(files.id, id), eq(files.ownerUserId, scope.ownerUserId))).get(),
|
||||
listOwned: (scope: OwnerScope) =>
|
||||
db.select().from(files).where(eq(files.ownerUserId, scope.ownerUserId)).orderBy(desc(files.createdAt)).all(),
|
||||
create: (value: typeof files.$inferInsert) => db.insert(files).values(value).returning().get(),
|
||||
remove: (id: string) => db.delete(files).where(eq(files.id, id)).returning().get(),
|
||||
getPublicBrandingAsset: (id: string) =>
|
||||
db
|
||||
.select({ file: files })
|
||||
.from(files)
|
||||
.innerJoin(
|
||||
instanceBranding,
|
||||
or(
|
||||
eq(instanceBranding.lightLogoFileId, files.id),
|
||||
eq(instanceBranding.darkLogoFileId, files.id),
|
||||
eq(instanceBranding.iconFileId, files.id),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(files.id, id),
|
||||
eq(files.visibility, "public_branding"),
|
||||
),
|
||||
)
|
||||
.get()?.file,
|
||||
};
|
||||
}
|
||||
|
||||
export type FileRepository = ReturnType<typeof createFileRepository>;
|
||||
Reference in New Issue
Block a user