feat(storage): complete phase 3 branding foundation

This commit is contained in:
poyrazavsever
2026-07-16 17:05:59 +03:00
parent b155132acf
commit 5d863280bf
31 changed files with 5302 additions and 57 deletions
+15
View File
@@ -0,0 +1,15 @@
import { apiError } from "@/server/api/responses";
import { getFileService } from "@/server/files/runtime";
import { fileResponse } from "@/server/files/http";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const file = getFileService().readPublicBranding((await params).id);
return fileResponse(file.metadata, file.bytes, "public, max-age=3600, stale-while-revalidate=86400");
} catch (error) {
return apiError(error);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { apiError, apiSuccess } from "@/server/api/responses";
import { domainActorFromSession } from "@/server/auth/domain-actor";
import { getSessionContextFromHeaders } from "@/server/auth/session";
import { getBrandingService, getPublicBranding } from "@/server/branding/runtime";
import { DomainError } from "@/server/domain/errors";
export function GET() {
return apiSuccess(getPublicBranding());
}
export async function PATCH(request: Request) {
const context = await getSessionContextFromHeaders(new Headers(request.headers));
if (!context) return apiError(new DomainError("UNAUTHENTICATED", "Oturum gerekli."));
try {
const branding = getBrandingService().update(
domainActorFromSession(context),
await request.json(),
);
return apiSuccess(branding);
} catch (error) {
return apiError(error);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { apiError } from "@/server/api/responses";
import { domainActorFromSession } from "@/server/auth/domain-actor";
import { getSessionContextFromHeaders } from "@/server/auth/session";
import { DomainError } from "@/server/domain/errors";
import { getFileService } from "@/server/files/runtime";
import { fileResponse } from "@/server/files/http";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const context = await getSessionContextFromHeaders(new Headers(request.headers));
if (!context) return apiError(new DomainError("UNAUTHENTICATED", "Oturum gerekli."));
try {
const file = getFileService().read(domainActorFromSession(context), (await params).id);
return fileResponse(file.metadata, file.bytes, "private, no-store");
} catch (error) {
return apiError(error);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const context = await getSessionContextFromHeaders(new Headers(request.headers));
if (!context) return apiError(new DomainError("UNAUTHENTICATED", "Oturum gerekli."));
try {
getFileService().delete(domainActorFromSession(context), (await params).id);
return new Response(null, { status: 204 });
} catch (error) {
return apiError(error);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { apiError, apiSuccess } from "@/server/api/responses";
import { domainActorFromSession } from "@/server/auth/domain-actor";
import { getSessionContextFromHeaders } from "@/server/auth/session";
import { DomainError } from "@/server/domain/errors";
import { fileKinds, type FileKind } from "@/server/domain/types";
import { getFileService } from "@/server/files/runtime";
import { MAX_UPLOAD_BYTES } from "@/server/files/policy";
export async function POST(request: Request) {
const context = await getSessionContextFromHeaders(new Headers(request.headers));
if (!context) return apiError(new DomainError("UNAUTHENTICATED", "Oturum gerekli."));
try {
const formData = await request.formData();
const upload = formData.get("file");
const rawKind = formData.get("kind");
if (!(upload instanceof File) || typeof rawKind !== "string" || !isFileKind(rawKind)) {
throw new DomainError("VALIDATION_ERROR", "file ve geçerli kind alanları zorunludur.");
}
if (upload.size > MAX_UPLOAD_BYTES) {
throw new DomainError("VALIDATION_ERROR", "Dosya boyutu 5 MB sınırını aşıyor.");
}
const stored = getFileService().upload(domainActorFromSession(context), {
kind: rawKind,
originalName: upload.name,
claimedMimeType: upload.type,
bytes: new Uint8Array(await upload.arrayBuffer()),
projectId: stringValue(formData.get("projectId")),
portalVisible: formData.get("portalVisible") === "true",
});
return apiSuccess(toFileResponse(stored), { status: 201 });
} catch (error) {
return apiError(error);
}
}
function isFileKind(value: string): value is FileKind {
return fileKinds.includes(value as FileKind);
}
function stringValue(value: FormDataEntryValue | null): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function toFileResponse(file: ReturnType<ReturnType<typeof getFileService>["upload"]>) {
return {
id: file.id,
kind: file.kind,
visibility: file.visibility,
originalName: file.originalName,
mimeType: file.mimeType,
byteSize: file.byteSize,
sha256: file.sha256,
projectId: file.projectId,
url: `/api/files/${file.id}`,
createdAt: file.createdAt,
};
}
+44
View File
@@ -90,6 +90,50 @@
color-scheme: light; color-scheme: light;
} }
:root[data-color-mode="dark"] {
color-scheme: dark;
--background: #0b1018;
--background-dark: #0b1018;
--foreground: #f2f4f7;
--surface: #121926;
--surface-raised: #182230;
--card: #121926;
--card-foreground: #f2f4f7;
--popover: #182230;
--popover-foreground: #f2f4f7;
--secondary: #253044;
--secondary-foreground: #f2f4f7;
--muted: #253044;
--muted-foreground: #98a2b3;
--border: #344054;
--border-strong: #475467;
--input: #475467;
--input-bg: #121926;
}
@media (prefers-color-scheme: dark) {
:root[data-color-mode="system"] {
color-scheme: dark;
--background: #0b1018;
--background-dark: #0b1018;
--foreground: #f2f4f7;
--surface: #121926;
--surface-raised: #182230;
--card: #121926;
--card-foreground: #f2f4f7;
--popover: #182230;
--popover-foreground: #f2f4f7;
--secondary: #253044;
--secondary-foreground: #f2f4f7;
--muted: #253044;
--muted-foreground: #98a2b3;
--border: #344054;
--border-strong: #475467;
--input: #475467;
--input-bg: #121926;
}
}
body { body {
@apply min-h-screen bg-background text-foreground antialiased; @apply min-h-screen bg-background text-foreground antialiased;
font-size: 14px; font-size: 14px;
+17 -8
View File
@@ -1,36 +1,45 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import type { CSSProperties } from "react";
import "./globals.css"; import "./globals.css";
import { Geist } from "next/font/google"; import { Geist } from "next/font/google";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { OfflineIndicator } from "@/components/ui/offline-indicator"; import { OfflineIndicator } from "@/components/ui/offline-indicator";
import { Toaster } from "@/components/ui/toast"; import { Toaster } from "@/components/ui/toast";
import { getPublicBranding } from "@/server/branding/runtime";
const geist = Geist({ subsets: ["latin"], variable: "--font-geist-sans" }); const geist = Geist({ subsets: ["latin"], variable: "--font-geist-sans" });
export const metadata: Metadata = { export function generateMetadata(): Metadata {
title: "Neta", const branding = getPublicBranding();
return {
title: { default: branding.applicationName, template: `%s · ${branding.applicationName}` },
description: "Self-hosted freelancer operating dashboard", description: "Self-hosted freelancer operating dashboard",
manifest: "/manifest.json", manifest: "/manifest.webmanifest",
icons: branding.iconUrl ? { icon: branding.iconUrl, apple: branding.iconUrl } : undefined,
appleWebApp: { appleWebApp: {
capable: true, capable: true,
statusBarStyle: "default", statusBarStyle: "default",
title: "Neta", title: branding.shortName,
}, },
}; };
}
export const viewport: Viewport = { export function generateViewport(): Viewport {
themeColor: "#ffffff", return { themeColor: getPublicBranding().primaryColor };
}; }
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const branding = getPublicBranding();
return ( return (
<html <html
lang="tr" lang="tr"
className={cn("font-sans", geist.variable)} className={cn("font-sans", geist.variable, branding.defaultColorMode === "dark" && "dark")}
data-color-mode={branding.defaultColorMode}
style={branding.cssVariables as CSSProperties}
suppressHydrationWarning suppressHydrationWarning
> >
<body> <body>
+23
View File
@@ -0,0 +1,23 @@
import type { MetadataRoute } from "next";
import { getPublicBranding } from "@/server/branding/runtime";
export const dynamic = "force-dynamic";
export default function manifest(): MetadataRoute.Manifest {
const branding = getPublicBranding();
return {
name: branding.applicationName,
short_name: branding.shortName,
description: "Self-hosted freelancer operating dashboard",
start_url: "/",
display: "standalone",
background_color: "#FFFFFF",
theme_color: branding.primaryColor,
icons: branding.iconUrl
? [{ src: branding.iconUrl, sizes: "any", type: "image/png" }]
: [
{ src: "/icons/icon-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512x512.png", sizes: "512x512", type: "image/png" },
],
};
}
@@ -289,19 +289,19 @@ Hedef klasör yapısı:
Dosya checklist'i: Dosya checklist'i:
- [ ] Dosya metadata tablosu oluşturuldu. - [x] Dosya metadata tablosu oluşturuldu.
- [ ] Relative path dışında mutlak kullanıcı girdisi kullanılmıyor. - [x] Relative path dışında mutlak kullanıcı girdisi kullanılmıyor.
- [ ] Path traversal koruması var. - [x] Path traversal koruması var.
- [ ] Dosya boyutu limiti var. - [x] Dosya boyutu limiti var.
- [ ] MIME allowlist var. - [x] MIME allowlist var.
- [ ] Gerekli türlerde magic-byte doğrulaması var. - [x] Gerekli türlerde magic-byte doğrulaması var.
- [ ] SVG kabul ediliyorsa sanitizasyon kararı uygulandı. - [x] SVG kabul ediliyorsa sanitizasyon kararı uygulandı (ilk sürümde SVG reddediliyor).
- [ ] Yetkili upload Route Handler yazıldı. - [x] Yetkili upload Route Handler yazıldı.
- [ ] Yetkili download Route Handler yazıldı. - [x] Yetkili download Route Handler yazıldı.
- [ ] Public branding asset'leri ayrı ve kontrollü sunuluyor. - [x] Public branding asset'leri ayrı ve kontrollü sunuluyor.
- [ ] Project asset erişimi owner/project/client ilişkisiyle doğrulanıyor. - [x] Project asset erişimi owner/project/client ilişkisiyle doğrulanıyor.
- [ ] Dosya silme ve DB metadata işlemi tutarlı. - [x] Dosya silme ve DB metadata işlemi tutarlı.
- [ ] Backup içine upload klasörü dahil. - [x] Backup içine upload klasörü dahil.
## 11. Instance özelleştirme ve tema ## 11. Instance özelleştirme ve tema
@@ -351,15 +351,15 @@ type BrandingSettings = {
Tema checklist'i: Tema checklist'i:
- [ ] Poyraz UI semantic tokenları kullanılıyor. - [ ] Poyraz UI semantic tokenları kullanılıyor.
- [ ] Marka ayarları root layout'ta server-side okunuyor. - [x] Marka ayarları root layout'ta server-side okunuyor.
- [ ] İlk render sırasında tema/renk parlaması yok. - [x] İlk render sırasında tema/renk parlaması yok.
- [ ] Primary ve accent renk girdileri doğrulanıyor. - [x] Primary ve accent renk girdileri doğrulanıyor.
- [ ] Metin/zemin kontrastı kontrol ediliyor. - [x] Metin/zemin kontrastı kontrol ediliyor.
- [ ] Hard-coded brand renkleri feature sayfalarına dağılmıyor. - [ ] Hard-coded brand renkleri feature sayfalarına dağılmıyor.
- [ ] Açık ve koyu modda logo fallback'i var. - [x] Açık ve koyu modda logo fallback'i var.
- [ ] Logo kaldırma ve varsayılana dönme desteği var. - [x] Logo kaldırma ve varsayılana dönme desteği var.
- [ ] Branding ayarlarına yalnızca freelancer/admin yazabiliyor. - [x] Branding ayarlarına yalnızca freelancer/admin yazabiliyor.
- [ ] Client portal aynı instance markasını güvenli biçimde kullanıyor. - [x] Client portal aynı instance markasını güvenli biçimde kullanıyor.
## 12. Poyraz UI v3 stratejisi ## 12. Poyraz UI v3 stratejisi
@@ -717,13 +717,13 @@ Mümkün olduğunda küçük ve doğrudan test araçları tercih edilir; test al
- [x] Client private task erişim negatif testi - [x] Client private task erişim negatif testi
- [x] Revision project-client eşleşme negatif testi - [x] Revision project-client eşleşme negatif testi
- [x] Revision quota testi - [x] Revision quota testi
- [ ] File upload MIME/size testi - [x] File upload MIME/size testi
- [ ] Path traversal negatif testi - [x] Path traversal negatif testi
- [x] Backup oluşturma testi - [x] Backup oluşturma testi
- [ ] Restore ve checksum testi - [x] Restore ve checksum testi
- [ ] Supabase import fixture testi - [ ] Supabase import fixture testi
- [ ] API response/error contract testi - [x] API response/error contract testi
- [ ] Kritik sayfalar için SSR smoke testi - [x] Kritik sayfalar için SSR smoke testi
### 19.2. Her faz sonunda çalıştırılacak kalite kapıları ### 19.2. Her faz sonunda çalıştırılacak kalite kapıları
@@ -843,13 +843,23 @@ Faz 2 tamamlanma notu (2026-07-16):
Amaç: Supabase Storage yerine güvenli local filesystem ve instance özelleştirmesi sağlamak. Amaç: Supabase Storage yerine güvenli local filesystem ve instance özelleştirmesi sağlamak.
- [ ] File metadata schema tamamlandı. - [x] File metadata schema tamamlandı.
- [ ] Upload/download servisleri tamamlandı. - [x] Upload/download servisleri tamamlandı.
- [ ] Avatar desteği tamamlandı. - [x] Avatar desteği tamamlandı.
- [ ] Branding asset desteği tamamlandı. - [x] Branding asset desteği tamamlandı.
- [ ] Project asset desteği tamamlandı. - [x] Project asset desteği tamamlandı.
- [ ] Instance branding schema ve service tamamlandı. - [x] Instance branding schema ve service tamamlandı.
- [ ] Server-rendered token uygulaması tamamlandı. - [x] Server-rendered token uygulaması tamamlandı.
Faz 3 tamamlanma notu (2026-07-16):
- `files` ve `instance_branding` tabloları; owner/resource/visibility constraint'leri ve SHA-256 metadata ile eklendi.
- Local file servisi 5 MiB limit, MIME allowlist, magic-byte kontrolü, SVG reddi, root-bound relative path, symlink koruması ve geri alınabilir upload/delete sırası uygular.
- Authenticated upload/download/delete, kontrollü public branding asset ve owner-only branding Route Handler'ları standart API envelope ile eklendi.
- Avatar subject, private/portal project asset ve referenced-only public branding authorization kuralları gerçek SQLite/filesystem ve Next.js HTTP smoke testleriyle doğrulandı.
- Instance adı, logo/icon, primary/accent, color mode ve radius; root layout metadata/CSS tokenları ile dinamik web manifest'e server-side uygulanıyor.
- Backup uploads ağacını kapsıyor; restore artık path, symlink, byte size, manifest completeness ve SHA-256 checksum doğrulaması yapıyor.
- Tasarım, güvenlik ve test ayrıntıları `phase-3-storage-branding.md` belgesinde kaydedildi.
Çıkış kriteri: Logo, avatar ve project asset için Supabase Storage gerekmiyor. Çıkış kriteri: Logo, avatar ve project asset için Supabase Storage gerekmiyor.
@@ -971,22 +981,22 @@ Amaç: React Native geliştirmesine başlamadan önce instance keşif ve stabil
### Özelleştirme ### Özelleştirme
- [ ] Instance adı değiştirilebiliyor. - [x] Instance adı değiştirilebiliyor.
- [ ] Logo yüklenebiliyor. - [x] Logo yüklenebiliyor.
- [ ] Favicon/ikon yüklenebiliyor. - [x] Favicon/ikon yüklenebiliyor.
- [ ] Primary renk değiştirilebiliyor. - [x] Primary renk değiştirilebiliyor.
- [ ] Accent renk değiştirilebiliyor. - [x] Accent renk değiştirilebiliyor.
- [ ] Varsayılan tema değiştirilebiliyor. - [x] Varsayılan tema değiştirilebiliyor.
- [ ] Radius yoğunluğu değiştirilebiliyor. - [x] Radius yoğunluğu değiştirilebiliyor.
- [ ] Portal markası uygulanıyor. - [x] Portal markası uygulanıyor.
### Operasyon ### Operasyon
- [ ] Docker kurulumu çalışıyor. - [ ] Docker kurulumu çalışıyor.
- [ ] Persistent volume doğrulandı. - [ ] Persistent volume doğrulandı.
- [ ] Migration güvenli. - [ ] Migration güvenli.
- [ ] Backup çalışıyor. - [x] Backup çalışıyor.
- [ ] Restore ve checksum doğrulaması çalışıyor. - [x] Restore ve checksum doğrulaması çalışıyor.
- [ ] Health endpoint'leri çalışıyor. - [ ] Health endpoint'leri çalışıyor.
- [ ] Upgrade dokümantasyonu hazır. - [ ] Upgrade dokümantasyonu hazır.
- [ ] Rollback planı hazır. - [ ] Rollback planı hazır.
@@ -0,0 +1,118 @@
---
title: Faz 3 Yerel Storage ve Instance Branding
description: Güvenli local filesystem, dosya metadata, authorized file route'ları, backup checksum ve server-rendered branding sözleşmesi.
status: complete
last_updated: 2026-07-16
---
# Faz 3 Yerel Storage ve Instance Branding
Faz 3, avatar, branding ve project asset içeriklerinin Supabase Storage yerine instance'ın persistent data volume'ünde saklanabileceği güvenli backend temelini kurar. Mevcut feature sayfalarının bu servislere taşınması ilgili dikey sayfa fazlarında yapılacaktır; file ve branding çekirdeğinin kendisi Supabase, browser veya UI bağımlılığı taşımaz.
## Veri modeli
`0004_fancy_baron_zemo.sql` iki tablo ekler:
- `files`: owner/uploader, avatar subject veya project ilişkisi, tür, visibility, relative storage path, MIME, byte size ve SHA-256 metadata'sı;
- `instance_branding`: tek instance kaydı, uygulama isimleri, logo/icon file referansları, primary/accent renkleri, color mode, radius ve portal metinleri.
`files` tablosu tür-kaynak-visibility kombinasyonlarını SQLite CHECK constraint'iyle sınırlar. Mutlak path, `..`, sıfır/negatif byte size ve geçersiz checksum metadata seviyesinde de reddedilir. File foreign key'leri `restrict` kullanır; bir user veya project silinmesi physical dosyayı atlayarak orphan üretemez. Branding file referansları dosya silindiğinde `set null` olur.
## Filesystem sözleşmesi
Dosyalar yalnızca aşağıdaki servis tarafından üretilen relative path'lerde tutulur:
```text
uploads/
avatars/<generated-id>.<detected-extension>
branding/<generated-id>.<detected-extension>
project-assets/<generated-id>.<detected-extension>
```
Actor, project ID veya original filename path üretiminde kullanılmaz. `resolveStoragePath` absolute path, backslash, boş segment, `.` ve `..` segmentlerini reddeder; çözülmüş path'in uploads root altında kaldığını ikinci kez doğrular.
Upload sırası:
1. Role/resource authorization ve policy doğrulanır.
2. İçerik instance `tmp/` alanına `wx` ve `0600` ile yazılır.
3. Hedef path'e overwrite etmeyen hard-link ile atomik publish edilir.
4. File metadata ve avatar `user.image` değişikliği `BEGIN IMMEDIATE` transaction'da yazılır.
5. Transaction başarısızsa yalnızca o işlemde oluşturulan physical dosya kaldırılır.
Delete işleminde dosya önce aynı data volume içindeki trash path'e taşınır, metadata transaction'ı tamamlanır, sonra trash kaldırılır. DB işlemi başarısızsa dosya eski yerine alınır. Avatar silinirse halen ilgili dosyayı gösteren `user.image` alanı da aynı transaction'da temizlenir.
Download sırasında metadata authorization yeniden uygulanır. File descriptor `O_NOFOLLOW` ile açılır; symbolic link izlenmez ve physical size metadata ile eşleşmelidir. Response `nosniff`, doğru Content-Type, byte length, ETag ve kontrollü cache header'ları taşır.
## Dosya politikası
- Maksimum dosya boyutu: 5 MiB.
- Allowlist: JPEG, PNG, WebP ve avatar/project/logo için GIF.
- Branding icon/favicon için yalnızca PNG kabul edilir; manifest MIME sözleşmesi sabit ve güvenli kalır.
- MIME yalnızca browser beyanından alınmaz; JPEG/PNG/WebP/GIF magic byte imzası içerikten doğrulanır.
- SVG ilk sürümde kabul edilmez. Böylece SVG script/external reference sanitizasyon bağımlılığı eklenmez.
- Original filename yalnızca download adı olarak normalize edilir; storage path'e girmez.
## Authorization matrisi
| Kaynak | Upload | Authenticated read | Public read | Delete |
| --- | --- | --- | --- | --- |
| Avatar | Aktif owner veya client, yalnızca kendisi | Owner veya avatar subject | Yok | Owner veya avatar subject |
| Branding logo/icon | Yalnızca owner | Owner | Yalnızca aktif branding kaydınca referanslanan dosya | Owner |
| Private project asset | Yalnızca project owner | Owner | Yok | Owner |
| Portal project asset | Yalnızca project owner | Owner veya projeye bağlı client | Yok | Owner |
Unauthorized resource varlığı sızdırmamak için cross-owner/cross-client read çoğunlukla `NOT_FOUND` döner. Branding public route'u `public_branding` visibility tek başına yeterli saymaz; dosyanın aktif `instance_branding` kaydındaki light logo, dark logo veya icon alanlarından birinde referanslanması gerekir.
Route sınırları:
- `POST /api/files`: authenticated multipart upload;
- `GET /api/files/:id`: authenticated authorized download;
- `DELETE /api/files/:id`: authorized delete;
- `GET /api/branding/assets/:id`: kontrollü public branding asset;
- `GET /api/branding`: public, secret içermeyen instance markası;
- `PATCH /api/branding`: owner-only branding mutation.
## Branding ve ilk render
Primary/accent değerleri yalnızca altı haneli hex olarak kabul edilir ve normalize edilir. Her renk için siyah/beyaz foreground arasından WCAG contrast oranı yüksek olan seçilir; smoke test en az 4.5:1 oranını doğrular. Hover/pressed, ring ve radius tokenları server-side üretilir.
Root layout her request'te branding'i SQLite'tan okur ve semantic CSS custom property'lerini doğrudan `<html style>` üzerinde üretir. `data-color-mode` ve dark class ilk HTML'de bulunur; system dark tercihi CSS media query ile uygulanır. Bu nedenle token veya color mode için hydration sonrası browser düzeltmesi ve ilk render parlaması gerekmez.
Metadata title, Apple web app adı, theme color ve icon da branding'den üretilir. `manifest.webmanifest` dinamik olarak application name, short name, primary color ve icon referansını kullanır. Light/dark logo alanlarından biri boşsa diğeri fallback olur; file silmek foreign key `set null` ile varsayılan asset durumuna döner. Dashboard ve client portal aynı root layout tokenlarını kullanır.
## Backup ve restore
Backup mevcut davranışını koruyarak SQLite dosyasını ve `uploads/` ağacını aynı backup klasörüne kopyalar; manifest her dosya için size ve SHA-256 içerir. Restore artık kopyalamadan önce:
- manifest formatını ve her path'in backup root içinde kalmasını;
- symlink bulunmadığını;
- size ve SHA-256 eşleşmesini;
- manifest dışında doğrulanmamış ek dosya bulunmadığını
kontrol eder. Bozuk veya sonradan değiştirilmiş bir upload içeren backup reddedilir.
## Doğrulama kapsamı
`npm run phase3:storage-smoke`, migration uygulanmış gerçek SQLite ve gerçek geçici filesystem üzerinde şunları doğrular:
- avatar, logo/icon ve private/portal project asset upload/read/delete;
- client avatar subject, project-client visibility ve cross-owner negatifleri;
- MIME allowlist, magic byte, 5 MiB limit ve SVG reddi;
- absolute/traversal/backslash path reddi ve symlink takip etmeme;
- DB CHECK constraint'leri ve physical/metadata delete tutarlılığı;
- owner-only branding mutation, file-kind eşleşmesi, logo fallback ve file silme;
- primary/accent normalization ve foreground contrast.
Uçtan uca auth smoke gerçek Next.js Route Handler'ları üzerinden anonymous error envelope, authenticated multipart upload, public referenced logo, client portal/private ayrımı, client avatar upload/delete, server-rendered token/metadata ve dinamik manifest'i test eder. Runtime backup smoke upload restore'unu ve bozuk checksum reddini doğrular.
| Kontrol | Sonuç |
| --- | --- |
| `npm run typecheck` | Başarılı |
| Değişen Faz 3 TypeScript dosyalarında targeted ESLint | 0 error, 0 warning |
| `npm run phase3:storage-smoke` | Başarılı |
| `node scripts/phase1-auth-smoke.mjs` | Başarılı; file/branding HTTP ve SSR dahil |
| `node scripts/phase1-smoke.mjs` | Başarılı; uploads backup/restore ve bozuk checksum reddi dahil |
| `pnpm db:generate` | Schema drift yok |
| `npm run build` | Başarılı; file/branding route'ları ve dinamik manifest üretildi |
Repo geneli lint, önceki fazlardan kayıtlı UI/AI baseline dosyalarında 31 error ve 18 warning ile açık kalır. Faz 3 dosyalarının targeted lint kontrolü temizdir.
+1
View File
@@ -15,6 +15,7 @@
"phase1:smoke": "node scripts/phase1-smoke.mjs", "phase1:smoke": "node scripts/phase1-smoke.mjs",
"phase1:auth-smoke": "node scripts/phase1-auth-smoke.mjs", "phase1:auth-smoke": "node scripts/phase1-auth-smoke.mjs",
"phase2:domain-smoke": "node scripts/phase2-domain-smoke.mjs", "phase2:domain-smoke": "node scripts/phase2-domain-smoke.mjs",
"phase3:storage-smoke": "node scripts/phase3-storage-smoke.mjs",
"phase2:smoke": "node scripts/phase2-auth-smoke.mjs", "phase2:smoke": "node scripts/phase2-auth-smoke.mjs",
"phase3:ui-boundary": "node scripts/phase3-ui-boundary.mjs" "phase3:ui-boundary": "node scripts/phase3-ui-boundary.mjs"
}, },
+105
View File
@@ -6,6 +6,8 @@ import net from "node:net";
import path from "node:path"; import path from "node:path";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
const PNG_BYTES = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
const dataDir = path.join(process.cwd(), ".data", `phase1-auth-smoke-${Date.now()}`); const dataDir = path.join(process.cwd(), ".data", `phase1-auth-smoke-${Date.now()}`);
const databasePath = path.join(dataDir, "neta.db"); const databasePath = path.join(dataDir, "neta.db");
const port = await getAvailablePort(); const port = await getAvailablePort();
@@ -91,6 +93,9 @@ try {
]) { ]) {
insertClient.run(clientId, ownerUserId, name); insertClient.run(clientId, ownerUserId, name);
} }
db.prepare(
"insert into projects (id, owner_user_id, client_id, name, status) values (?, ?, ?, ?, ?)",
).run("project-alpha", ownerUserId, "client-alpha", "Alpha Project", "active");
const rejectedRegistration = await authPost("/api/auth/sign-up/email", { const rejectedRegistration = await authPost("/api/auth/sign-up/email", {
name: "Public Attacker", name: "Public Attacker",
@@ -111,6 +116,60 @@ try {
} }
assert.ok(ownerCookie, "Owner session cookie must be issued"); assert.ok(ownerCookie, "Owner session cookie must be issued");
const anonymousUpload = await uploadFile("avatar", { fileName: "anonymous.png" });
assert.equal(anonymousUpload.response.status, 401, "Anonymous file upload must fail");
assert.deepEqual(
{ ok: anonymousUpload.payload.ok, code: anonymousUpload.payload.error.code },
{ ok: false, code: "UNAUTHENTICATED" },
"File API errors must use the standard envelope",
);
const logoUpload = await uploadFile("branding_logo", {
cookie: ownerCookie,
fileName: "logo.png",
});
assert.equal(logoUpload.response.status, 201, JSON.stringify(logoUpload.payload));
assert.equal(logoUpload.payload.ok, true, "File API success must use the standard envelope");
const logoFileId = logoUpload.payload.data.id;
const brandingUpdate = await jsonRequest("/api/branding", {
method: "PATCH",
cookie: ownerCookie,
body: {
applicationName: "Neta Smoke Studio",
primaryColor: "#336699",
accentColor: "#F0CC22",
lightLogoFileId: logoFileId,
},
});
assert.equal(brandingUpdate.response.ok, true, JSON.stringify(brandingUpdate.payload));
assert.equal(brandingUpdate.payload.data.applicationName, "Neta Smoke Studio");
const brandedLoginHtml = await (await fetch(`${baseUrl}/login`)).text();
assert.match(brandedLoginHtml, /Neta Smoke Studio/, "Branding metadata must be server-rendered");
assert.match(brandedLoginHtml, /--primary:#336699/, "Brand tokens must be present in first HTML response");
const dynamicManifest = await (await fetch(`${baseUrl}/manifest.webmanifest`)).json();
assert.equal(dynamicManifest.name, "Neta Smoke Studio", "Manifest must use instance branding");
const publicLogo = await fetch(`${baseUrl}/api/branding/assets/${logoFileId}`);
assert.equal(publicLogo.status, 200, "Referenced branding asset must be publicly readable");
assert.equal(publicLogo.headers.get("x-content-type-options"), "nosniff");
assert.deepEqual(new Uint8Array(await publicLogo.arrayBuffer()), PNG_BYTES);
const portalAssetUpload = await uploadFile("project_asset", {
cookie: ownerCookie,
fileName: "portal.png",
projectId: "project-alpha",
portalVisible: true,
});
assert.equal(portalAssetUpload.response.status, 201, JSON.stringify(portalAssetUpload.payload));
const portalAssetFileId = portalAssetUpload.payload.data.id;
const privateAssetUpload = await uploadFile("project_asset", {
cookie: ownerCookie,
fileName: "private.png",
projectId: "project-alpha",
portalVisible: false,
});
assert.equal(privateAssetUpload.response.status, 201, JSON.stringify(privateAssetUpload.payload));
const privateAssetFileId = privateAssetUpload.payload.data.id;
const anonymousInvite = await jsonRequest("/api/portal-invitations", { const anonymousInvite = await jsonRequest("/api/portal-invitations", {
method: "POST", method: "POST",
body: { clientId: "anonymous-client", email: "anonymous@example.com" }, body: { clientId: "anonymous-client", email: "anonymous@example.com" },
@@ -193,6 +252,39 @@ try {
assert.equal(clientSignIn.response.ok, true, JSON.stringify(clientSignIn.payload)); assert.equal(clientSignIn.response.ok, true, JSON.stringify(clientSignIn.payload));
const clientCookie = cookieHeader(clientSignIn.response); const clientCookie = cookieHeader(clientSignIn.response);
const clientPortalAsset = await fetch(`${baseUrl}/api/files/${portalAssetFileId}`, {
headers: { cookie: clientCookie },
});
assert.equal(clientPortalAsset.status, 200, "Client must read portal-visible project asset");
const clientPrivateAsset = await fetch(`${baseUrl}/api/files/${privateAssetFileId}`, {
headers: { cookie: clientCookie },
});
assert.equal(clientPrivateAsset.status, 404, "Client must not read private project asset");
const forbiddenProjectUpload = await uploadFile("project_asset", {
cookie: clientCookie,
fileName: "forbidden.png",
projectId: "project-alpha",
portalVisible: true,
});
assert.equal(forbiddenProjectUpload.response.status, 403, "Client must not upload project assets");
const clientAvatarUpload = await uploadFile("avatar", {
cookie: clientCookie,
fileName: "client-avatar.png",
});
assert.equal(clientAvatarUpload.response.status, 201, JSON.stringify(clientAvatarUpload.payload));
const clientAvatarFileId = clientAvatarUpload.payload.data.id;
const clientAvatar = await fetch(`${baseUrl}/api/files/${clientAvatarFileId}`, {
headers: { cookie: clientCookie },
});
assert.equal(clientAvatar.status, 200, "Client must read own avatar");
const deletedAvatar = await fetch(`${baseUrl}/api/files/${clientAvatarFileId}`, {
method: "DELETE",
headers: { cookie: clientCookie, origin: baseUrl },
});
assert.equal(deletedAvatar.status, 204, "Client must delete own avatar");
const roleViolation = await jsonRequest("/api/portal-invitations", { const roleViolation = await jsonRequest("/api/portal-invitations", {
method: "POST", method: "POST",
cookie: clientCookie, cookie: clientCookie,
@@ -318,6 +410,19 @@ async function authPost(pathname, body, cookie) {
return jsonRequest(pathname, { method: "POST", body, cookie }); return jsonRequest(pathname, { method: "POST", body, cookie });
} }
async function uploadFile(kind, { cookie, fileName, projectId, portalVisible } = {}) {
const formData = new FormData();
formData.set("kind", kind);
formData.set("file", new Blob([PNG_BYTES], { type: "image/png" }), fileName ?? "upload.png");
if (projectId) formData.set("projectId", projectId);
if (portalVisible !== undefined) formData.set("portalVisible", String(portalVisible));
const headers = { origin: baseUrl };
if (cookie) headers.cookie = cookie;
const response = await fetch(`${baseUrl}/api/files`, { method: "POST", headers, body: formData });
const text = await response.text();
return { response, payload: text ? JSON.parse(text) : null };
}
async function jsonRequest(pathname, { method, body, cookie } = {}) { async function jsonRequest(pathname, { method, body, cookie } = {}) {
const headers = { origin: baseUrl }; const headers = { origin: baseUrl };
if (body !== undefined) headers["content-type"] = "application/json"; if (body !== undefined) headers["content-type"] = "application/json";
+29
View File
@@ -37,6 +37,10 @@ try {
sqlite.close(); sqlite.close();
} }
const uploadFixturePath = path.join(smokeRoot, "uploads", "project-assets", "backup-fixture.txt");
fs.mkdirSync(path.dirname(uploadFixturePath), { recursive: true });
fs.writeFileSync(uploadFixturePath, "neta-upload-backup-fixture");
const reopened = new Database(dbPath, { readonly: true }); const reopened = new Database(dbPath, { readonly: true });
try { try {
@@ -83,4 +87,29 @@ try {
restored.close(); restored.close();
} }
const restoredUploadFixture = path.join(
restoreRoot,
"uploads",
"project-assets",
"backup-fixture.txt",
);
if (fs.readFileSync(restoredUploadFixture, "utf8") !== "neta-upload-backup-fixture") {
throw new Error("Restore smoke check failed: upload fixture missing or corrupted.");
}
fs.appendFileSync(path.join(backupDir, "uploads", "project-assets", "backup-fixture.txt"), "-tampered");
let corruptedBackupRejected = false;
try {
execFileSync(
process.execPath,
["scripts/restore.mjs", "--from", backupDir, "--target", `${restoreRoot}-corrupt`, "--force"],
{ cwd: process.cwd(), env: process.env, stdio: "pipe" },
);
} catch {
corruptedBackupRejected = true;
}
if (!corruptedBackupRejected) {
throw new Error("Restore smoke check failed: corrupted upload checksum was accepted.");
}
console.log("Phase 1 smoke checks passed."); console.log("Phase 1 smoke checks passed.");
+19
View File
@@ -0,0 +1,19 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const dataDir = path.join(process.cwd(), ".data", `phase3-storage-smoke-${Date.now()}`);
const databasePath = path.join(dataDir, "neta.db");
const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath };
fs.mkdirSync(dataDir, { recursive: true });
execFileSync(process.execPath, ["scripts/migrate.mjs"], { cwd: process.cwd(), env, stdio: "inherit" });
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.phase3-smoke.json"], {
cwd: process.cwd(),
stdio: "inherit",
});
execFileSync(
process.execPath,
[path.join(".next", "phase3-storage-smoke-dist", "scripts", "phase3-storage-smoke.js"), dataDir],
{ cwd: process.cwd(), stdio: "inherit" },
);
+173
View File
@@ -0,0 +1,173 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "../server/db/schema";
import { BrandingService, contrastRatio } from "../server/branding/service";
import type { DomainActor } from "../server/domain/actor";
import { DomainError } from "../server/domain/errors";
import { resolveStoragePath } from "../server/files/paths";
import { MAX_UPLOAD_BYTES } from "../server/files/policy";
import { FileService } from "../server/files/service";
import { DomainService } from "../server/services/domain";
const dataDir = process.argv[2];
assert.ok(dataDir, "Data directory is required");
const databasePath = path.join(dataDir, "neta.db");
const uploadsDir = path.join(dataDir, "uploads");
const tmpDir = path.join(dataDir, "tmp");
const sqlite = new Database(databasePath);
sqlite.pragma("foreign_keys = ON");
const db = drizzle({ client: sqlite, schema });
let generatedId = 0;
const fileService = new FileService(db, { uploadsDir, tmpDir }, () => `file-${++generatedId}`);
const brandingService = new BrandingService(db);
const domainService = new DomainService(db, () => `domain-${++generatedId}`);
const ownerOne: DomainActor = { authUserId: "owner-1", role: "freelancer", clientId: null, disabled: false };
const ownerTwo: DomainActor = { authUserId: "owner-2", role: "freelancer", clientId: null, disabled: false };
const clientOne: DomainActor = { authUserId: "client-user-1", role: "client", clientId: "client-1", disabled: false };
const clientTwo: DomainActor = { authUserId: "client-user-2", role: "client", clientId: "client-2", disabled: false };
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
try {
for (const actor of [ownerOne, ownerTwo, clientOne, clientTwo]) {
db.insert(schema.user).values({
id: actor.authUserId,
name: actor.authUserId,
email: `${actor.authUserId}@example.com`,
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
}).run();
}
domainService.createClient(ownerOne, { id: "client-1", name: "Client One" });
domainService.createClient(ownerOne, { id: "client-2", name: "Client Two" });
domainService.createClient(ownerTwo, { id: "client-other", name: "Other Client" });
db.update(schema.clients).set({ authUserId: clientOne.authUserId }).where(eq(schema.clients.id, "client-1")).run();
db.update(schema.clients).set({ authUserId: clientTwo.authUserId }).where(eq(schema.clients.id, "client-2")).run();
domainService.createProject(ownerOne, { id: "project-1", name: "Portal Project", clientId: "client-1", status: "active" });
domainService.createProject(ownerOne, { id: "project-2", name: "Private Project", clientId: "client-2", status: "active" });
domainService.createProject(ownerTwo, { id: "project-other", name: "Other Project", clientId: "client-other", status: "active" });
const ownerAvatar = fileService.upload(ownerOne, imageInput("avatar", "../owner avatar.png"));
const clientAvatar = fileService.upload(clientOne, imageInput("avatar", "client.png"));
assert.equal(ownerAvatar.storagePath, `avatars/${ownerAvatar.id}.png`);
assert.equal(ownerAvatar.originalName, "..-owner avatar.png");
assert.equal(db.select({ image: schema.user.image }).from(schema.user).where(eq(schema.user.id, ownerOne.authUserId)).get()?.image, `/api/files/${ownerAvatar.id}`);
assert.deepEqual(fileService.read(clientOne, clientAvatar.id).bytes, Buffer.from(png));
const collisionService = new FileService(db, { uploadsDir, tmpDir }, () => ownerAvatar.id);
assert.throws(
() => collisionService.upload(ownerOne, imageInput("avatar", "collision.png")),
/EEXIST/,
"A generated path collision must never overwrite the existing file",
);
assert.deepEqual(fileService.read(ownerOne, ownerAvatar.id).bytes, Buffer.from(png));
assertDomainError(() => fileService.read(clientOne, ownerAvatar.id), "NOT_FOUND");
assertDomainError(() => fileService.read(ownerTwo, ownerAvatar.id), "NOT_FOUND");
const logo = fileService.upload(ownerOne, imageInput("branding_logo", "logo.png"));
const icon = fileService.upload(ownerOne, imageInput("branding_icon", "icon.png"));
assertDomainError(() => fileService.readPublicBranding(logo.id), "NOT_FOUND");
const branding = brandingService.update(ownerOne, {
applicationName: "Studio Portal",
shortName: "Studio",
primaryColor: "#336699",
accentColor: "#f0cc22",
lightLogoFileId: logo.id,
iconFileId: icon.id,
defaultColorMode: "dark",
radiusScale: "soft",
});
assert.equal(branding.applicationName, "Studio Portal");
assert.equal(branding.primaryColor, "#336699");
assert.equal(branding.darkLogoUrl, branding.lightLogoUrl, "Missing dark logo must fall back to light logo");
assert.equal(fileService.readPublicBranding(logo.id).metadata.id, logo.id);
assert.ok(contrastRatio(branding.primaryColor, branding.cssVariables["--primary-foreground"]) >= 4.5);
assert.ok(contrastRatio(branding.accentColor, branding.cssVariables["--accent-foreground"]) >= 4.5);
assertDomainError(() => brandingService.update(clientOne, { applicationName: "Attack" }), "FORBIDDEN");
assertDomainError(() => brandingService.update(ownerTwo, { applicationName: "Attack" }), "FORBIDDEN");
assertDomainError(() => brandingService.update(ownerOne, { primaryColor: "red" }), "VALIDATION_ERROR");
const portalAsset = fileService.upload(ownerOne, {
...imageInput("project_asset", "cover.png"),
projectId: "project-1",
portalVisible: true,
});
const privateAsset = fileService.upload(ownerOne, {
...imageInput("project_asset", "private.png"),
projectId: "project-1",
portalVisible: false,
});
assert.equal(fileService.read(clientOne, portalAsset.id).metadata.id, portalAsset.id);
assertDomainError(() => fileService.read(clientOne, privateAsset.id), "NOT_FOUND");
assertDomainError(() => fileService.read(clientTwo, portalAsset.id), "NOT_FOUND");
assertDomainError(
() => fileService.upload(clientOne, { ...imageInput("project_asset", "attack.png"), projectId: "project-1" }),
"FORBIDDEN",
);
assertDomainError(
() => fileService.upload(ownerOne, { ...imageInput("project_asset", "foreign.png"), projectId: "project-other" }),
"NOT_FOUND",
);
assertDomainError(() => fileService.upload(ownerOne, { ...imageInput("avatar", "fake.png"), claimedMimeType: "image/jpeg" }), "VALIDATION_ERROR");
assertDomainError(() => fileService.upload(ownerOne, { ...imageInput("avatar", "fake.svg"), claimedMimeType: "image/svg+xml" }), "VALIDATION_ERROR");
assertDomainError(() => fileService.upload(ownerOne, { ...imageInput("avatar", "large.png"), bytes: new Uint8Array(MAX_UPLOAD_BYTES + 1) }), "VALIDATION_ERROR");
assertDomainError(() => fileService.upload(ownerOne, { ...imageInput("avatar", "bad.png"), bytes: Uint8Array.from([1, 2, 3]) }), "VALIDATION_ERROR");
for (const candidate of ["../secret", "/etc/passwd", "project-assets/../../secret", "project-assets\\secret"] ) {
assertDomainError(() => resolveStoragePath(uploadsDir, candidate), "VALIDATION_ERROR");
}
const outsidePath = path.join(dataDir, "outside.png");
fs.writeFileSync(outsidePath, png);
const symlinkPath = path.join(uploadsDir, "project-assets", "symlink.png");
fs.symlinkSync(outsidePath, symlinkPath);
db.insert(schema.files).values({
id: "symlink-file",
ownerUserId: ownerOne.authUserId,
uploadedByUserId: ownerOne.authUserId,
projectId: "project-1",
kind: "project_asset",
visibility: "private",
storagePath: "project-assets/symlink.png",
originalName: "symlink.png",
mimeType: "image/png",
byteSize: png.byteLength,
sha256: "0".repeat(64),
}).run();
assertDomainError(() => fileService.read(ownerOne, "symlink-file"), "NOT_FOUND");
fileService.delete(ownerOne, "symlink-file");
assert.equal(fs.readFileSync(outsidePath).byteLength, png.byteLength, "Deleting symlink metadata must not delete target");
assert.throws(
() => sqlite.prepare("insert into files (id, owner_user_id, uploaded_by_user_id, project_id, kind, visibility, storage_path, original_name, mime_type, byte_size, sha256) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run("invalid-path", ownerOne.authUserId, ownerOne.authUserId, "project-1", "project_asset", "private", "../escape.png", "escape.png", "image/png", 1, "0".repeat(64)),
/CHECK constraint failed/,
);
const avatarPath = resolveStoragePath(uploadsDir, ownerAvatar.storagePath);
assert.ok(fs.existsSync(avatarPath));
fileService.delete(ownerOne, ownerAvatar.id);
assert.equal(fs.existsSync(avatarPath), false);
assert.equal(db.select({ image: schema.user.image }).from(schema.user).where(eq(schema.user.id, ownerOne.authUserId)).get()?.image, null);
const logoPath = resolveStoragePath(uploadsDir, logo.storagePath);
fileService.delete(ownerOne, logo.id);
assert.equal(fs.existsSync(logoPath), false);
assert.equal(brandingService.getPublic().lightLogoFileId, null, "Deleting a logo must clear branding reference");
console.log("Phase 3 storage smoke passed: uploads, authorization, path safety, branding and deletion verified.");
} finally {
sqlite.close();
}
function imageInput(kind: "avatar" | "branding_logo" | "branding_icon" | "project_asset", originalName: string) {
return { kind, originalName, claimedMimeType: "image/png", bytes: png } as const;
}
function assertDomainError(run: () => unknown, code: DomainError["code"]) {
assert.throws(run, (error) => error instanceof DomainError && error.code === code);
}
+65
View File
@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { ensureDataLayout, getDataConfig } from "./lib/data-dir.mjs"; import { ensureDataLayout, getDataConfig } from "./lib/data-dir.mjs";
@@ -18,11 +19,14 @@ const config = ensureDataLayout(getDataConfig(targetEnv));
const backupDir = path.resolve(args.from); const backupDir = path.resolve(args.from);
const backupDbPath = path.join(backupDir, "neta.db"); const backupDbPath = path.join(backupDir, "neta.db");
const backupUploadsDir = path.join(backupDir, "uploads"); const backupUploadsDir = path.join(backupDir, "uploads");
const manifestPath = path.join(backupDir, "manifest.json");
if (!fs.existsSync(backupDbPath)) { if (!fs.existsSync(backupDbPath)) {
throw new Error(`Backup database not found: ${backupDbPath}`); throw new Error(`Backup database not found: ${backupDbPath}`);
} }
verifyManifest(backupDir, manifestPath);
if (fs.existsSync(config.databasePath) && !args.force) { if (fs.existsSync(config.databasePath) && !args.force) {
throw new Error(`Target database exists: ${config.databasePath}. Pass --force to overwrite.`); throw new Error(`Target database exists: ${config.databasePath}. Pass --force to overwrite.`);
} }
@@ -70,3 +74,64 @@ function copyDirectory(sourceDir, targetDir) {
} }
} }
} }
function verifyManifest(rootDir, manifestFile) {
if (!fs.existsSync(manifestFile)) {
throw new Error(`Backup manifest not found: ${manifestFile}`);
}
const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
if (!Array.isArray(manifest.files) || manifest.files.length === 0) {
throw new Error("Backup manifest has no file entries.");
}
const normalizedRoot = path.resolve(rootDir);
const verifiedPaths = new Set();
for (const entry of manifest.files) {
if (
!entry ||
typeof entry.path !== "string" ||
typeof entry.bytes !== "number" ||
typeof entry.sha256 !== "string" ||
!/^[0-9a-f]{64}$/i.test(entry.sha256)
) {
throw new Error("Backup manifest contains an invalid file entry.");
}
const filePath = path.resolve(normalizedRoot, entry.path);
if (!filePath.startsWith(`${normalizedRoot}${path.sep}`)) {
throw new Error(`Backup manifest path escapes backup root: ${entry.path}`);
}
const stat = fs.lstatSync(filePath);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== entry.bytes) {
throw new Error(`Backup file metadata mismatch: ${entry.path}`);
}
const actualHash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(actualHash, "hex"), Buffer.from(entry.sha256, "hex"))) {
throw new Error(`Backup checksum mismatch: ${entry.path}`);
}
verifiedPaths.add(entry.path.replace(/\\/g, "/"));
}
const actualPaths = collectBackupFiles(normalizedRoot, normalizedRoot);
if (
actualPaths.length !== verifiedPaths.size ||
actualPaths.some((filePath) => !verifiedPaths.has(filePath))
) {
throw new Error("Backup contains files that are missing from the checksum manifest.");
}
}
function collectBackupFiles(rootDir, currentDir) {
const files = [];
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
const entryPath = path.join(currentDir, entry.name);
if (entryPath === path.join(rootDir, "manifest.json")) continue;
if (entry.isSymbolicLink()) throw new Error(`Backup contains a symbolic link: ${entry.name}`);
if (entry.isDirectory()) {
files.push(...collectBackupFiles(rootDir, entryPath));
} else if (entry.isFile()) {
files.push(path.relative(rootDir, entryPath).replace(/\\/g, "/"));
}
}
return files;
}
+33
View File
@@ -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");
}
+224
View File
@@ -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
+7
View File
@@ -29,6 +29,13 @@
"when": 1784208712933, "when": 1784208712933,
"tag": "0003_chief_excalibur", "tag": "0003_chief_excalibur",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1784210311370,
"tag": "0004_fancy_baron_zemo",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -1,3 +1,4 @@
export * from "./auth"; export * from "./auth";
export * from "./domain"; export * from "./domain";
export * from "./runtime"; export * from "./runtime";
export * from "./storage";
+104
View File
@@ -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')`),
],
);
+12
View File
@@ -48,3 +48,15 @@ export type ContractStatus = (typeof contractStatuses)[number];
export type InvoiceStatus = (typeof invoiceStatuses)[number]; export type InvoiceStatus = (typeof invoiceStatuses)[number];
export type SubscriptionBillingCycle = (typeof subscriptionBillingCycles)[number]; export type SubscriptionBillingCycle = (typeof subscriptionBillingCycles)[number];
export type SubscriptionStatus = (typeof subscriptionStatuses)[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];
+16
View File
@@ -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",
},
});
}
+36
View File
@@ -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.");
}
+93
View File
@@ -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));
}
+13
View File
@@ -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,
});
}
+241
View File
@@ -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;
}
}
+13
View File
@@ -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(),
};
}
+38
View File
@@ -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>;
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.phase2-smoke.json",
"compilerOptions": {
"outDir": ".next/phase3-storage-smoke-dist"
},
"include": [
"scripts/phase3-storage-smoke.ts",
"server/auth/types.ts",
"server/branding/service.ts",
"server/db/schema/**/*.ts",
"server/domain/**/*.ts",
"server/files/paths.ts",
"server/files/policy.ts",
"server/files/service.ts",
"server/repositories/**/*.ts",
"server/services/domain.ts"
]
}