feat: remove app name and logo customization feature

Users can no longer customize the app name or logo. The branding API
endpoints, permission, frontend UI, env vars (APP_NAME, MAX_LOGO_SIZE_KB),
and all related tests are removed. Includes a migration to clean up
branding data from existing databases.
This commit is contained in:
SnapOtter
2026-05-07 19:41:30 +08:00
parent 25a1dd7aba
commit 16af9c573a
29 changed files with 52 additions and 977 deletions
+14
View File
@@ -0,0 +1,14 @@
-- Remove branding:manage from built-in admin role permissions
UPDATE `roles`
SET `permissions` = REPLACE(`permissions`, '"branding:manage",', ''),
`updated_at` = unixepoch()
WHERE `id` = 'builtin-admin';
--> statement-breakpoint
-- Remove branding:manage from any custom roles that have it
UPDATE `roles`
SET `permissions` = REPLACE(REPLACE(`permissions`, ',"branding:manage"', ''), '"branding:manage",', ''),
`updated_at` = unixepoch()
WHERE `id` != 'builtin-admin' AND `permissions` LIKE '%branding:manage%';
--> statement-breakpoint
-- Clean up branding-related settings
DELETE FROM `settings` WHERE `key` IN ('customLogo', 'appName');
+7
View File
@@ -71,6 +71,13 @@
"when": 1776855591000,
"tag": "0009_analytics_consent",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1778300000000,
"tag": "0010_remove_branding",
"breakpoints": true
}
]
}
-4
View File
@@ -21,7 +21,6 @@ import { analyticsRoutes } from "./routes/analytics.js";
import { apiKeyRoutes } from "./routes/api-keys.js";
import { auditLogRoutes } from "./routes/audit-log.js";
import { registerBatchRoutes } from "./routes/batch.js";
import { brandingRoutes } from "./routes/branding.js";
import { docsRoutes } from "./routes/docs.js";
import { registerFeatureRoutes } from "./routes/features.js";
import { fileRoutes } from "./routes/files.js";
@@ -180,9 +179,6 @@ await analyticsRoutes(app);
// Feature management routes (AI feature bundle install/uninstall)
await registerFeatureRoutes(app);
// Branding routes (logo upload/serve/delete)
await brandingRoutes(app);
// Teams routes
await teamsRoutes(app);
-2
View File
@@ -26,7 +26,6 @@ const envSchema = z.object({
WORKSPACE_PATH: z.string().default("./tmp/workspace"),
DEFAULT_THEME: z.enum(["light", "dark", "system"]).default("light"),
DEFAULT_LOCALE: z.string().default("en"),
APP_NAME: z.string().default("snapotter"),
CORS_ORIGIN: z.string().default(""),
MAX_USERS: z.coerce.number().default(0),
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
@@ -35,7 +34,6 @@ const envSchema = z.object({
MAX_PIPELINE_STEPS: z.coerce.number().default(0),
MAX_CANVAS_PIXELS: z.coerce.number().default(0),
MAX_SVG_SIZE_MB: z.coerce.number().default(0),
MAX_LOGO_SIZE_KB: z.coerce.number().default(500),
MAX_SPLIT_GRID: z.coerce.number().default(100),
MAX_PDF_PAGES: z.coerce.number().default(0),
SESSION_DURATION_HOURS: z.coerce.number().default(168),
+1 -93
View File
@@ -39,8 +39,6 @@ tags:
description: System-wide configuration (admin only for writes).
- name: Teams
description: Organize users into teams.
- name: Branding
description: Custom logo management.
- name: Roles
description: Custom role management with fine-grained permissions.
- name: Audit
@@ -5764,96 +5762,6 @@ paths:
schema:
$ref: "#/components/schemas/Error"
# ─── Branding ─────────────────────────────────────────────────────────────
/api/v1/settings/logo:
get:
tags: [Branding]
summary: Get custom logo
security: []
responses:
"200":
description: Logo image
content:
image/*:
schema:
type: string
format: binary
"404":
description: No custom logo set
post:
tags: [Branding]
summary: Upload custom logo (admin)
security:
- bearerAuth: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [logo]
properties:
logo:
type: string
format: binary
description: Logo image file
responses:
"200":
description: Logo uploaded
"400":
description: Invalid input
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: Authentication required
content:
application/json:
schema:
$ref: "#/components/schemas/UnauthorizedError"
"403":
description: Admin access required
content:
application/json:
schema:
$ref: "#/components/schemas/ForbiddenError"
delete:
tags: [Branding]
summary: Remove custom logo
description: Requires branding:manage permission.
security:
- bearerAuth: []
responses:
"200":
description: Logo removed
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
"400":
description: Invalid input
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: Authentication required
content:
application/json:
schema:
$ref: "#/components/schemas/UnauthorizedError"
"403":
description: Admin access required
content:
application/json:
schema:
$ref: "#/components/schemas/ForbiddenError"
# ─── System (additional) ──────────────────────────────────────────────────
/api/v1/config/auth:
@@ -6417,7 +6325,7 @@ paths:
Permission strings. Valid values: tools:use, files:own, files:all,
apikeys:own, apikeys:all, pipelines:own, pipelines:all,
settings:read, settings:write, users:manage, teams:manage,
branding:manage, features:manage, system:health, audit:read
features:manage, system:health, audit:read
responses:
"201":
description: Role created
-1
View File
@@ -17,7 +17,6 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"settings:write",
"users:manage",
"teams:manage",
"branding:manage",
"features:manage",
"system:health",
"audit:read",
-1
View File
@@ -763,7 +763,6 @@ const PUBLIC_PATHS = [
"/api/auth/",
"/api/v1/download/",
"/api/v1/jobs/",
"/api/v1/settings/logo",
"/api/docs",
"/api/v1/openapi.yaml",
];
-106
View File
@@ -1,106 +0,0 @@
/**
* Branding routes custom logo upload, serving, and deletion.
*
* POST /api/v1/settings/logo Upload logo (admin only)
* GET /api/v1/settings/logo Serve custom logo as PNG (public)
* DELETE /api/v1/settings/logo Remove custom logo (admin only)
*/
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { ensureSharpCompat } from "../lib/heic-converter.js";
import { requirePermission } from "../permissions.js";
const BRANDING_DIR = join(env.FILES_STORAGE_PATH, "branding");
const LOGO_PATH = join(BRANDING_DIR, "logo.png");
const maxLogoSize = env.MAX_LOGO_SIZE_KB * 1024;
function upsertSetting(key: string, value: string): void {
const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
if (existing) {
db.update(schema.settings)
.set({ value, updatedAt: new Date() })
.where(eq(schema.settings.key, key))
.run();
} else {
db.insert(schema.settings).values({ key, value }).run();
}
}
export async function brandingRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/settings/logo — Upload logo (admin only)
app.post("/api/v1/settings/logo", async (request: FastifyRequest, reply: FastifyReply) => {
const admin = requirePermission("branding:manage")(request, reply);
if (!admin) return;
const file = await request.file();
if (!file) {
return reply.status(400).send({ error: "No file uploaded", code: "VALIDATION_ERROR" });
}
// Validate mimetype
if (!file.mimetype.startsWith("image/")) {
return reply.status(400).send({ error: "File must be an image", code: "VALIDATION_ERROR" });
}
// Read file buffer
const buffer = await file.toBuffer();
// Validate size
if (buffer.length > maxLogoSize) {
return reply.status(400).send({
error: `Logo must be ${env.MAX_LOGO_SIZE_KB}KB or smaller`,
code: "VALIDATION_ERROR",
});
}
// Decode HEIC/HEIF if needed, then convert to PNG, resize to max 128x128
const compatBuffer = await ensureSharpCompat(buffer);
const pngBuffer = await sharp(compatBuffer)
.resize(128, 128, { fit: "inside", withoutEnlargement: true })
.png()
.toBuffer();
// Ensure branding directory exists
mkdirSync(BRANDING_DIR, { recursive: true });
// Write file
writeFileSync(LOGO_PATH, pngBuffer);
// Upsert setting
upsertSetting("customLogo", "true");
return reply.send({ ok: true });
});
// GET /api/v1/settings/logo — Serve logo (public, no auth required)
app.get("/api/v1/settings/logo", async (_request: FastifyRequest, reply: FastifyReply) => {
if (!existsSync(LOGO_PATH)) {
return reply.status(404).send({ error: "No custom logo set", code: "NOT_FOUND" });
}
const logoBuffer = readFileSync(LOGO_PATH);
return reply.type("image/png").send(logoBuffer);
});
// DELETE /api/v1/settings/logo — Remove logo (admin only)
app.delete("/api/v1/settings/logo", async (request: FastifyRequest, reply: FastifyReply) => {
const admin = requirePermission("branding:manage")(request, reply);
if (!admin) return;
if (existsSync(LOGO_PATH)) {
unlinkSync(LOGO_PATH);
}
upsertSetting("customLogo", "false");
return reply.send({ ok: true });
});
app.log.info("Branding routes registered");
}
-1
View File
@@ -19,7 +19,6 @@ const ALL_PERMISSIONS: Permission[] = [
"settings:write",
"users:manage",
"teams:manage",
"branding:manage",
"features:manage",
"system:health",
"audit:read",
+2 -10
View File
@@ -312,14 +312,6 @@ To auto-save a tool result to the library, include `fileId` in the settings payl
| `PUT` | `/api/v1/teams/:id` | Admin (`teams:manage`) | Rename team |
| `DELETE` | `/api/v1/teams/:id` | Admin (`teams:manage`) | Delete team (cannot delete default team or teams with members) |
## Branding
| Method | Path | Access | Description |
|--------|------|--------|-------------|
| `POST` | `/api/v1/settings/logo` | Admin | Upload custom logo (max 500 KB, converted to 128×128 PNG) |
| `GET` | `/api/v1/settings/logo` | Public | Serve current logo |
| `DELETE` | `/api/v1/settings/logo` | Admin | Remove custom logo |
## Settings
Runtime key-value configuration (read by any authenticated user, write by admin only).
@@ -330,7 +322,7 @@ Runtime key-value configuration (read by any authenticated user, write by admin
| `PUT` | `/api/v1/settings` | Bulk update settings (JSON body with key-value pairs) |
| `GET` | `/api/v1/settings/:key` | Get a specific setting by key |
Known keys: `disabledTools` (JSON array of tool IDs), `enableExperimentalTools` (bool string), `loginAttemptLimit` (number), `customLogo` (managed via branding endpoint).
Known keys: `disabledTools` (JSON array of tool IDs), `enableExperimentalTools` (bool string), `loginAttemptLimit` (number).
## Roles
@@ -343,7 +335,7 @@ Custom role management with granular permissions.
| `PUT` | `/api/v1/roles/:id` | Admin (`users:manage`) | Update a custom role (cannot modify built-in roles) |
| `DELETE` | `/api/v1/roles/:id` | Admin (`users:manage`) | Delete a custom role (cannot delete built-in roles; affected users revert to `user` role) |
Available permissions: `tools:use`, `files:own`, `files:all`, `apikeys:own`, `apikeys:all`, `pipelines:own`, `pipelines:all`, `settings:read`, `settings:write`, `users:manage`, `teams:manage`, `branding:manage`, `features:manage`, `system:health`, `audit:read`.
Available permissions: `tools:use`, `files:own`, `files:all`, `apikeys:own`, `apikeys:all`, `pipelines:own`, `pipelines:all`, `settings:read`, `settings:write`, `users:manage`, `teams:manage`, `features:manage`, `system:health`, `audit:read`.
## Audit Log
-2
View File
@@ -47,7 +47,6 @@ All configuration is done through environment variables. Every variable has a se
| `MAX_PIPELINE_STEPS` | `0` (no limit) | Maximum number of steps in a pipeline. Set to 0 for no limit. |
| `MAX_CANVAS_PIXELS` | `0` (no limit) | Maximum canvas size in pixels for output images. Set to 0 for no limit. |
| `MAX_SVG_SIZE_MB` | `0` (unlimited) | Maximum SVG file size in megabytes. Set to 0 for unlimited. |
| `MAX_LOGO_SIZE_KB` | `500` | Maximum custom branding logo size in kilobytes. |
| `MAX_SPLIT_GRID` | `100` | Maximum grid dimension for the image split tool. |
| `MAX_PDF_PAGES` | `0` (unlimited) | Maximum number of PDF pages for PDF-to-image conversion. Set to 0 for unlimited. |
@@ -62,7 +61,6 @@ All configuration is done through environment variables. Every variable has a se
| Variable | Default | Description |
|---|---|---|
| `APP_NAME` | `SnapOtter` | Display name shown in the UI. |
| `DEFAULT_THEME` | `light` | Default theme for new sessions. `light` or `dark`. |
| `DEFAULT_LOCALE` | `en` | Default interface language. |
+13 -38
View File
@@ -1,8 +1,7 @@
import { FolderOpen, LayoutGrid, Menu, Settings as SettingsIcon, Workflow, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { useMobile } from "@/hooks/use-mobile";
import { apiGet } from "@/lib/api";
import { cn } from "@/lib/utils";
import { useConnectionStore } from "@/stores/connection-store";
import { Dropzone } from "../common/dropzone";
@@ -25,16 +24,9 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
const [helpOpen, setHelpOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const isMobile = useMobile();
const [customLogo, setCustomLogo] = useState(false);
const connectionStatus = useConnectionStore((s) => s.status);
const bannerVisible = connectionStatus !== "connected";
useEffect(() => {
apiGet<{ settings: Record<string, string> }>("/v1/settings")
.then((data) => setCustomLogo(data.settings.customLogo === "true"))
.catch(() => {});
}, []);
return (
<div
className={cn(
@@ -47,7 +39,6 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
<Sidebar
onSettingsClick={() => setSettingsOpen(true)}
onHelpClick={() => setHelpOpen(true)}
customLogo={customLogo}
/>
)}
@@ -61,20 +52,12 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
/>
<div className="fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border shadow-xl animate-in slide-in-from-left">
<div className="flex items-center justify-between p-3 border-b border-border">
{customLogo ? (
<img
src="/api/v1/settings/logo"
className="h-6 w-6 rounded object-contain"
alt="Logo"
/>
) : (
<div className="flex items-center gap-2">
<OtterLogo className="h-5 w-5 text-primary" />
<span className="text-sm font-bold text-foreground">
<span className="text-primary">SnapOtter</span>
</span>
</div>
)}
<div className="flex items-center gap-2">
<OtterLogo className="h-5 w-5 text-primary" />
<span className="text-sm font-bold text-foreground">
<span className="text-primary">SnapOtter</span>
</span>
</div>
<button
type="button"
onClick={() => setMobileSidebarOpen(false)}
@@ -114,20 +97,12 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
>
<Menu className="h-5 w-5" />
</button>
{customLogo ? (
<img
src="/api/v1/settings/logo"
className="h-6 w-6 rounded object-contain"
alt="Logo"
/>
) : (
<div className="flex items-center gap-2">
<OtterLogo className="h-5 w-5 text-primary" />
<span className="text-sm font-bold text-foreground">
<span className="text-primary">SnapOtter</span>
</span>
</div>
)}
<div className="flex items-center gap-2">
<OtterLogo className="h-5 w-5 text-primary" />
<span className="text-sm font-bold text-foreground">
<span className="text-primary">SnapOtter</span>
</span>
</div>
</div>
)}
+1 -8
View File
@@ -29,8 +29,6 @@ interface SidebarProps {
onNavClick?: () => void;
/** When true, renders in expanded mode (for mobile overlay). */
expanded?: boolean;
/** Whether a custom logo is set. */
customLogo?: boolean;
}
export function Sidebar({
@@ -38,7 +36,6 @@ export function Sidebar({
onHelpClick,
onNavClick,
expanded = false,
customLogo = false,
}: SidebarProps) {
const location = useLocation();
@@ -103,11 +100,7 @@ export function Sidebar({
return (
<aside className="flex flex-col items-center w-16 bg-sidebar border-r border-border py-3 gap-1 shrink-0">
<div className="mb-2 flex items-center justify-center">
{customLogo ? (
<img src="/api/v1/settings/logo" className="h-8 w-8 rounded object-contain" alt="Logo" />
) : (
<OtterLogo className="h-7 w-7 text-primary" />
)}
<OtterLogo className="h-7 w-7 text-primary" />
</div>
<div className="border-t border-border w-10 mb-2" />
<div className="flex flex-col gap-1 flex-1">
@@ -28,7 +28,7 @@ import {
} from "lucide-react";
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api";
import { apiDelete, apiGet, apiPost, apiPut, clearToken } from "@/lib/api";
import { cn, copyToClipboard } from "@/lib/utils";
import { useAnalyticsStore } from "@/stores/analytics-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -349,7 +349,6 @@ function SystemSection() {
.catch(() => {
// Fallback defaults if endpoint not ready
setSettings({
appName: "SnapOtter",
fileUploadLimitMb: "100",
defaultTheme: "system",
defaultLocale: "en",
@@ -381,37 +380,6 @@ function SystemSection() {
}
}, [settings]);
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
try {
const res = await fetch("/api/v1/settings/logo", {
method: "POST",
headers: formatHeaders(),
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => null);
setSaveMsg(body?.error || "Failed to upload logo.");
return;
}
setSettings((prev) => ({ ...prev, customLogo: "true" }));
} catch {
setSaveMsg("Failed to upload logo.");
}
};
const handleLogoDelete = async () => {
try {
await apiDelete("/v1/settings/logo");
setSettings((prev) => ({ ...prev, customLogo: "false" }));
} catch {
setSaveMsg("Failed to delete logo.");
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@@ -427,52 +395,6 @@ function SystemSection() {
<p className="text-sm text-muted-foreground mt-1">Server-side configuration and limits.</p>
</div>
<SettingRow label="App Name" description="Display name for the application">
<input
type="text"
value={settings.appName || ""}
onChange={(e) => updateSetting("appName", e.target.value)}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-48"
/>
</SettingRow>
<SettingRow
label="Custom Logo"
description="Upload a custom logo for the sidebar. PNG, SVG, or JPEG. Max 500KB."
>
<div className="flex items-center gap-3">
{settings.customLogo === "true" && (
<img
src="/api/v1/settings/logo"
className="w-10 h-10 rounded object-contain"
alt="Logo"
/>
)}
<label
htmlFor="system-logo-upload"
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm cursor-pointer hover:bg-muted transition-colors"
>
Upload
<input
id="system-logo-upload"
type="file"
accept="image/png,image/jpeg,image/svg+xml"
className="hidden"
onChange={handleLogoUpload}
/>
</label>
{settings.customLogo === "true" && (
<button
type="button"
onClick={handleLogoDelete}
className="text-sm text-destructive hover:underline"
>
Remove
</button>
)}
</div>
</SettingRow>
<SettingRow label="File Upload Limit (MB)" description="Maximum file size per upload">
<input
type="number"
@@ -1756,7 +1678,6 @@ const PERMISSION_GROUPS = [
{ label: "Settings", permissions: ["settings:read", "settings:write"] },
{ label: "Users", permissions: ["users:manage"] },
{ label: "Teams", permissions: ["teams:manage"] },
{ label: "Branding", permissions: ["branding:manage"] },
{
label: "System",
permissions: ["features:manage", "system:health", "audit:read"],