From 1271fd5b6f6d05dea7e809a87abd6817827b2602 Mon Sep 17 00:00:00 2001
From: Pixelated
Date: Fri, 12 Jun 2026 13:58:48 -0600
Subject: [PATCH 01/13] Update README.md
---
.github/README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/README.md b/.github/README.md
index af71b80..361b567 100644
--- a/.github/README.md
+++ b/.github/README.md
@@ -2,7 +2,7 @@
Emberly is an open source platform for modern file storage, sharing, and identity verification. Build your digital presence with powerful tools for teams and individuals.
-[](https://github.com/EmberlyOSS/Emberly/actions/workflows/build.yml) [](https://github.com/EmberlyOSS/Emberly/actions/workflows/codeql.yml) 
+[](https://github.com/EmberlyOSS/Emberly/actions/workflows/build.yml) [](https://github.com/EmberlyOSS/Emberly/actions/workflows/codeql.yml)  [](https://app.fossa.com/projects/git%2Bgithub.com%2FEmberlyOSS%2FEmberly?ref=badge_shield&issueType=security)
## Features
From 4382880b9b079d7279efb2c551f194fc858d43f1 Mon Sep 17 00:00:00 2001
From: Pixelated
Date: Fri, 12 Jun 2026 16:24:33 -0600
Subject: [PATCH 02/13] Update README.md
---
.github/README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/README.md b/.github/README.md
index 361b567..1bb39a1 100644
--- a/.github/README.md
+++ b/.github/README.md
@@ -183,7 +183,7 @@ This project adheres to the Contributor Covenant Code of Conduct. By participati
## Acknowledgments
-Thank you to all [contributors](https://github.com/EmberlyOSS/Emberly/graphs/contributors) who have helped make Emberly possible. We also appreciate the open source projects and communities that make this platform possible.
+Thank you to all [contributors](https://github.com/EmberlyOSS/Emberly/graphs/contributors) who have helped make Emberly possible. We also appreciate the [open source projects and communities](https://github.com/EmberlyOSS/Emberly/network/dependencies) that make Emberly possible.
From d03fe46145837b568d0dff602229d174a62b92f1 Mon Sep 17 00:00:00 2001
From: TheRealToxicDev
Date: Sat, 13 Jun 2026 09:31:48 -0600
Subject: [PATCH 03/13] fix(stuff): remove status check and more
---
CHANGELOG.md | 43 +
app/api/admin/integrations/test/route.ts | 274 +-
app/api/files/route.ts | 201 +-
app/api/settings/route.ts | 29 +-
app/api/status/route.ts | 29 +-
.../admin/settings/settings-manager.tsx | 5210 +++++++++--------
.../components/layout/StatusIndicator.tsx | 84 +-
packages/components/layout/footer.tsx | 3 -
packages/lib/auth/api-auth.ts | 30 +-
packages/lib/cache/session-cache.ts | 366 +-
packages/lib/config/index.ts | 392 +-
packages/lib/events/handlers/file-expiry.ts | 23 +
packages/lib/files/filename.ts | 13 +-
packages/lib/files/security-validation.ts | 206 +-
packages/lib/files/upload-validation.ts | 286 +-
packages/lib/kener/index.ts | 179 +-
packages/lib/storage/index.ts | 30 +-
packages/lib/storage/quota.ts | 475 +-
packages/lib/utils/index.ts | 4 +-
19 files changed, 4362 insertions(+), 3515 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ebff84e..65c11d4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,49 @@ All notable changes to this project will be documented in this file.
The format is based on "Keep a Changelog" and follows [Semantic Versioning](https://semver.org/).
+## [2.4.6] - 2026-06-13
+
+### Security
+
+- **ReDoS — Polynomial Regular Expression Hardening**
+ - Replaced all `/\/+$/` trailing-slash regex patterns applied to user-controlled values with linear while-loop equivalents, eliminating O(n²) backtracking risk (CodeQL `js/polynomial-redos`).
+ - Affected files: `packages/lib/utils/index.ts` (`urlForHost`), `packages/lib/files/upload-validation.ts` (domain cleaning), `packages/lib/files/filename.ts` (slug trimming), `app/api/files/route.ts` (URL construction).
+ - Replaced `/^-+|-+$/g` alternation pattern in filename slug generation with pointer-based leading/trailing trim.
+- **SSRF — Integration Test Endpoint Input Validation**
+ - Discord server ID (`serverId`) now validated against snowflake format (`/^\d{17,20}$/`) before being interpolated into the Discord API URL (CodeQL `js/request-forgery`).
+ - Cloudflare account ID (`accountId`) now validated against 32-character lowercase hex format before URL construction.
+ - Both integrations return a clear validation error message rather than making an outbound request with unsanitised input.
+- **Miscellaneous CodeQL Findings Resolved**
+ - Incomplete URL substring sanitisation (alerts 6, 7) — hardened URL host checks.
+ - Shell command built from environment values (alert 16) — environment input sanitised before shell interpolation.
+ - Use of externally-controlled format string (alert 15) — format string construction tightened.
+ - Additional SSRF alerts (10–13, 17–19) addressed across various API routes.
+
+### Changed
+
+- **Status Page Integration Removed**
+ - Removed the Kener / Uptime Kuma dynamic status integration entirely. The polling logic, `/api/status` route, admin settings panel, and integration test handler have all been removed.
+ - `StatusIndicator` in the site footer is now a lightweight static link to [emberlystat.us](https://emberlystat.us) — no external API calls, no runtime failures, no "Status unknown" states.
+ - `KENER_API_KEY`, `KENER_BASE_URL`, `UPTIME_KUMA_BASE_URL`, and `UPTIME_KUMA_SLUG` environment variables are no longer used and can be removed.
+
+### Added
+
+- **CodeQL Workflow** — Automated static analysis via GitHub Actions (`/.github/workflows/codeql.yml`) now runs on push and pull request for continuous security scanning.
+- **SECURITY.md** — Added security policy documenting responsible disclosure process and supported versions.
+- **License Scan** — Added license scan report and status badge to repository.
+
+### Performance
+
+- **VirusTotal scan moved off the critical path** — VT hash lookups previously blocked the upload response for 5-10s on non-media files. The scan now runs in the background after the file is stored and the response is returned. Files detected as malicious are automatically quarantined (removed from storage, marked private) and logged.
+- **Stripe subscription sync debounce survives hot-reloads** — The per-user 5-minute Stripe sync cache (`stripeSyncCache`) was stored as a module-level variable, causing it to reset on every Next.js hot-reload in development and trigger a live Stripe API call on every upload. Moved to `globalThis` so the TTL is respected across reloads.
+- **S3 provider singleton persisted across hot-reloads** — Storage provider was re-initialized on every request in development for the same reason. Also moved to `globalThis`, eliminating redundant initialization logs and the associated config DB read per request.
+- **File buffer, storage provider, and filename generation parallelized** — `arrayBuffer()`, `getStorageProvider()`, and `getUniqueFilename()` now run concurrently with `Promise.all` instead of sequentially, removing 1-2 unnecessary round-trips from the critical path.
+- **`bcrypt.hash` moved outside the DB transaction** — Password hashing was running inside `prisma.$transaction`, blocking the database connection during a CPU-intensive operation. The hash is now computed before the transaction opens.
+
+### Fixed
+
+- **Sitemap** — Marked sitemap route as dynamic to prevent build-time errors when database is unavailable during static export.
+
## [2.4.5] - 2026-06-02
### Added
diff --git a/app/api/admin/integrations/test/route.ts b/app/api/admin/integrations/test/route.ts
index e45a560..ff3a6fe 100644
--- a/app/api/admin/integrations/test/route.ts
+++ b/app/api/admin/integrations/test/route.ts
@@ -5,7 +5,14 @@ import nodemailer from 'nodemailer'
const logger = loggers.config
-type IntegrationKey = 'stripe' | 'resend' | 'cloudflare' | 'discord' | 'github' | 'kener' | 'smtp' | 'vultr'
+type IntegrationKey =
+ | 'stripe'
+ | 'resend'
+ | 'cloudflare'
+ | 'discord'
+ | 'github'
+ | 'smtp'
+ | 'vultr'
interface TestIntegrationBody {
integration: IntegrationKey
@@ -42,26 +49,6 @@ function isPrivateOrLocalHost(hostname: string): boolean {
return false
}
-function getSafeKenerOrigin(baseUrl?: string): string | null {
- const candidate = (baseUrl && baseUrl.trim()) || 'https://emberlystat.us'
-
- let parsed: URL
- try {
- parsed = new URL(candidate)
- } catch {
- return null
- }
-
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
- if (parsed.username || parsed.password) return null
- if (isPrivateOrLocalHost(parsed.hostname)) return null
-
- const allowedOrigins = new Set(['https://emberlystat.us'])
- if (!allowedOrigins.has(parsed.origin)) return null
-
- return parsed.origin
-}
-
interface TestResult {
ok: boolean
message: string
@@ -74,11 +61,21 @@ async function testStripe(secretKey: string): Promise {
const res = await fetch('https://api.stripe.com/v1/customers?limit=1', {
headers: { Authorization: `Bearer ${secretKey}` },
})
- if (res.status === 401) return { ok: false, message: 'Invalid secret key', detail: 'Authentication failed' }
- if (!res.ok) return { ok: false, message: `Stripe API error (${res.status})` }
+ if (res.status === 401)
+ return {
+ ok: false,
+ message: 'Invalid secret key',
+ detail: 'Authentication failed',
+ }
+ if (!res.ok)
+ return { ok: false, message: `Stripe API error (${res.status})` }
return { ok: true, message: 'Connected to Stripe successfully' }
} catch (err) {
- return { ok: false, message: 'Failed to reach Stripe API', detail: String(err) }
+ return {
+ ok: false,
+ message: 'Failed to reach Stripe API',
+ detail: String(err),
+ }
}
}
@@ -88,47 +85,107 @@ async function testResend(apiKey: string): Promise {
const res = await fetch('https://api.resend.com/domains', {
headers: { Authorization: `Bearer ${apiKey}` },
})
- if (res.status === 401 || res.status === 403) return { ok: false, message: 'Invalid API key' }
- if (!res.ok) return { ok: false, message: `Resend API error (${res.status})` }
+ if (res.status === 401 || res.status === 403)
+ return { ok: false, message: 'Invalid API key' }
+ if (!res.ok)
+ return { ok: false, message: `Resend API error (${res.status})` }
return { ok: true, message: 'Connected to Resend successfully' }
} catch (err) {
- return { ok: false, message: 'Failed to reach Resend API', detail: String(err) }
+ return {
+ ok: false,
+ message: 'Failed to reach Resend API',
+ detail: String(err),
+ }
}
}
-async function testCloudflare(apiToken: string, accountId?: string): Promise {
+function sanitizeCloudflareAccountId(id?: string): string | null {
+ if (!id) return null
+ const trimmed = id.trim()
+ // Cloudflare account IDs are 32-char lowercase hex strings
+ if (!/^[0-9a-f]{32}$/.test(trimmed)) return null
+ return trimmed
+}
+
+async function testCloudflare(
+ apiToken: string,
+ accountId?: string
+): Promise {
if (!apiToken) return { ok: false, message: 'API token is not configured' }
+ const safeAccountId = sanitizeCloudflareAccountId(accountId)
+ if (accountId && !safeAccountId) {
+ return { ok: false, message: 'Invalid Cloudflare account ID format' }
+ }
try {
- const url = accountId
- ? `https://api.cloudflare.com/client/v4/accounts/${accountId}`
+ const url = safeAccountId
+ ? `https://api.cloudflare.com/client/v4/accounts/${safeAccountId}`
: 'https://api.cloudflare.com/client/v4/user'
const res = await fetch(url, {
- headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
+ headers: {
+ Authorization: `Bearer ${apiToken}`,
+ 'Content-Type': 'application/json',
+ },
})
const json = await res.json().catch(() => null)
if (!res.ok || json?.success === false) {
- return { ok: false, message: 'Invalid Cloudflare API token', detail: json?.errors?.[0]?.message }
+ return {
+ ok: false,
+ message: 'Invalid Cloudflare API token',
+ detail: json?.errors?.[0]?.message,
+ }
}
return { ok: true, message: 'Connected to Cloudflare successfully' }
} catch (err) {
- return { ok: false, message: 'Failed to reach Cloudflare API', detail: String(err) }
+ return {
+ ok: false,
+ message: 'Failed to reach Cloudflare API',
+ detail: String(err),
+ }
}
}
-async function testDiscord(webhookUrl: string, botToken?: string, serverId?: string): Promise {
+function sanitizeDiscordServerId(id?: string): string | null {
+ if (!id) return null
+ const trimmed = id.trim()
+ // Discord snowflakes are 17–20 digit integers
+ if (!/^\d{17,20}$/.test(trimmed)) return null
+ return trimmed
+}
+
+async function testDiscord(
+ webhookUrl: string,
+ botToken?: string,
+ serverId?: string
+): Promise {
// Try bot token first (more informative)
- if (botToken && serverId) {
+ const safeServerId = sanitizeDiscordServerId(serverId)
+ if (botToken && serverId && !safeServerId) {
+ return { ok: false, message: 'Invalid Discord server ID format' }
+ }
+ if (botToken && safeServerId) {
try {
- const res = await fetch(`https://discord.com/api/v10/guilds/${serverId}`, {
- headers: { Authorization: `Bot ${botToken}` },
- })
+ const res = await fetch(
+ `https://discord.com/api/v10/guilds/${safeServerId}`,
+ {
+ headers: { Authorization: `Bot ${botToken}` },
+ }
+ )
if (res.status === 401) return { ok: false, message: 'Invalid bot token' }
- if (res.status === 404) return { ok: false, message: 'Server not found or bot not in server' }
- if (!res.ok) return { ok: false, message: `Discord API error (${res.status})` }
+ if (res.status === 404)
+ return { ok: false, message: 'Server not found or bot not in server' }
+ if (!res.ok)
+ return { ok: false, message: `Discord API error (${res.status})` }
const json = await res.json().catch(() => null)
- return { ok: true, message: `Connected to Discord — server: ${json?.name ?? serverId}` }
+ return {
+ ok: true,
+ message: `Connected to Discord — server: ${json?.name ?? safeServerId}`,
+ }
} catch (err) {
- return { ok: false, message: 'Failed to reach Discord API', detail: String(err) }
+ return {
+ ok: false,
+ message: 'Failed to reach Discord API',
+ detail: String(err),
+ }
}
}
@@ -143,25 +200,44 @@ async function testDiscord(webhookUrl: string, botToken?: string, serverId?: str
}
const hostname = parsed.hostname.toLowerCase()
- const isDiscordHost = hostname === 'discord.com' || hostname === 'discordapp.com'
- if (parsed.protocol !== 'https:' || !isDiscordHost || isPrivateOrLocalHost(hostname)) {
- return { ok: false, message: 'Webhook URL must be a valid Discord HTTPS URL' }
+ const isDiscordHost =
+ hostname === 'discord.com' || hostname === 'discordapp.com'
+ if (
+ parsed.protocol !== 'https:' ||
+ !isDiscordHost ||
+ isPrivateOrLocalHost(hostname)
+ ) {
+ return {
+ ok: false,
+ message: 'Webhook URL must be a valid Discord HTTPS URL',
+ }
}
- const match = parsed.pathname.match(/^\/api\/webhooks\/(\d+)\/([A-Za-z0-9._-]+)$/)
+ const match = parsed.pathname.match(
+ /^\/api\/webhooks\/(\d+)\/([A-Za-z0-9._-]+)$/
+ )
if (!match) {
- return { ok: false, message: 'Webhook URL must match Discord webhook format' }
+ return {
+ ok: false,
+ message: 'Webhook URL must match Discord webhook format',
+ }
}
const [, webhookId, webhookToken] = match
const safeWebhookUrl = `https://discord.com/api/webhooks/${encodeURIComponent(webhookId)}/${encodeURIComponent(webhookToken)}`
const res = await fetch(safeWebhookUrl, { method: 'GET' })
- if (res.status === 401) return { ok: false, message: 'Invalid webhook URL' }
- if (!res.ok) return { ok: false, message: `Discord webhook error (${res.status})` }
+ if (res.status === 401)
+ return { ok: false, message: 'Invalid webhook URL' }
+ if (!res.ok)
+ return { ok: false, message: `Discord webhook error (${res.status})` }
return { ok: true, message: 'Discord webhook is valid' }
} catch (err) {
- return { ok: false, message: 'Failed to reach Discord webhook', detail: String(err) }
+ return {
+ ok: false,
+ message: 'Failed to reach Discord webhook',
+ detail: String(err),
+ }
}
}
@@ -172,12 +248,14 @@ function sanitizeGitHubOrg(org?: string): string | null {
if (!org) return null
const trimmed = org.trim()
// GitHub org/user name rules: alphanumeric or single hyphens, no leading/trailing hyphen, max 39 chars.
- if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(trimmed)) return null
+ if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(trimmed))
+ return null
return trimmed
}
async function testGitHub(pat: string, org?: string): Promise {
- if (!pat) return { ok: false, message: 'Personal access token is not configured' }
+ if (!pat)
+ return { ok: false, message: 'Personal access token is not configured' }
const safeOrg = sanitizeGitHubOrg(org)
if (org && !safeOrg) {
return { ok: false, message: 'Invalid GitHub organization name format' }
@@ -193,34 +271,26 @@ async function testGitHub(pat: string, org?: string): Promise {
'X-GitHub-Api-Version': '2022-11-28',
},
})
- if (res.status === 401) return { ok: false, message: 'Invalid personal access token' }
- if (res.status === 404) return { ok: false, message: `Organization "${safeOrg ?? org}" not found` }
- if (!res.ok) return { ok: false, message: `GitHub API error (${res.status})` }
- const json = await res.json().catch(() => null)
- return { ok: true, message: `Connected to GitHub${safeOrg ? ` — org: ${json?.name ?? safeOrg}` : ` — user: ${json?.login}`}` }
- } catch (err) {
- return { ok: false, message: 'Failed to reach GitHub API', detail: String(err) }
- }
-}
-
-async function testKener(apiKey: string, baseUrl?: string): Promise {
- const origin = getSafeKenerOrigin(baseUrl)
- if (!origin) {
- return { ok: false, message: 'Invalid Kener base URL' }
- }
-
- try {
- const res = await fetch(`${origin}/api/v4/monitors`, {
- headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
- signal: AbortSignal.timeout(8000),
- })
- if (res.status === 401 || res.status === 403) return { ok: false, message: 'Invalid API key' }
- if (!res.ok) return { ok: false, message: `Kener API error (${res.status})` }
+ if (res.status === 401)
+ return { ok: false, message: 'Invalid personal access token' }
+ if (res.status === 404)
+ return {
+ ok: false,
+ message: `Organization "${safeOrg ?? org}" not found`,
+ }
+ if (!res.ok)
+ return { ok: false, message: `GitHub API error (${res.status})` }
const json = await res.json().catch(() => null)
- const count = (json?.monitors as unknown[])?.length ?? 0
- return { ok: true, message: `Connected to Kener — ${count} monitor${count !== 1 ? 's' : ''} found` }
+ return {
+ ok: true,
+ message: `Connected to GitHub${safeOrg ? ` — org: ${json?.name ?? safeOrg}` : ` — user: ${json?.login}`}`,
+ }
} catch (err) {
- return { ok: false, message: 'Failed to reach Kener instance', detail: String(err) }
+ return {
+ ok: false,
+ message: 'Failed to reach GitHub API',
+ detail: String(err),
+ }
}
}
@@ -231,17 +301,32 @@ async function testVultr(apiKey: string): Promise {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(8000),
})
- if (res.status === 401 || res.status === 403) return { ok: false, message: 'Invalid API key' }
- if (!res.ok) return { ok: false, message: `Vultr API error (${res.status})` }
+ if (res.status === 401 || res.status === 403)
+ return { ok: false, message: 'Invalid API key' }
+ if (!res.ok)
+ return { ok: false, message: `Vultr API error (${res.status})` }
const json = await res.json().catch(() => null)
const email = json?.account?.email
- return { ok: true, message: `Connected to Vultr${email ? ` — ${email}` : ''}` }
+ return {
+ ok: true,
+ message: `Connected to Vultr${email ? ` — ${email}` : ''}`,
+ }
} catch (err) {
- return { ok: false, message: 'Failed to reach Vultr API', detail: String(err) }
+ return {
+ ok: false,
+ message: 'Failed to reach Vultr API',
+ detail: String(err),
+ }
}
}
-async function testSmtp(host: string, port: number, secure: boolean, user: string, password: string): Promise {
+async function testSmtp(
+ host: string,
+ port: number,
+ secure: boolean,
+ user: string,
+ password: string
+): Promise {
if (!host) return { ok: false, message: 'SMTP host is not configured' }
try {
const transport = nodemailer.createTransport({
@@ -256,7 +341,11 @@ async function testSmtp(host: string, port: number, secure: boolean, user: strin
transport.close()
return { ok: true, message: `Connected to SMTP server at ${host}:${port}` }
} catch (err) {
- return { ok: false, message: 'Failed to connect to SMTP server', detail: String(err) }
+ return {
+ ok: false,
+ message: 'Failed to connect to SMTP server',
+ detail: String(err),
+ }
}
}
@@ -278,24 +367,28 @@ export async function POST(req: Request) {
result = await testResend(credentials.apiKey)
break
case 'cloudflare':
- result = await testCloudflare(credentials.apiToken, credentials.accountId)
+ result = await testCloudflare(
+ credentials.apiToken,
+ credentials.accountId
+ )
break
case 'discord':
- result = await testDiscord(credentials.webhookUrl, credentials.botToken, credentials.serverId)
+ result = await testDiscord(
+ credentials.webhookUrl,
+ credentials.botToken,
+ credentials.serverId
+ )
break
case 'github':
result = await testGitHub(credentials.pat, credentials.org)
break
- case 'kener':
- result = await testKener(credentials.apiKey, credentials.baseUrl)
- break
case 'smtp':
result = await testSmtp(
credentials.host,
Number(credentials.port) || 587,
credentials.secure === 'true',
credentials.user,
- credentials.password,
+ credentials.password
)
break
case 'vultr':
@@ -311,4 +404,3 @@ export async function POST(req: Request) {
return apiError('Internal server error')
}
}
-
diff --git a/app/api/files/route.ts b/app/api/files/route.ts
index 0119945..7db08f8 100644
--- a/app/api/files/route.ts
+++ b/app/api/files/route.ts
@@ -20,12 +20,15 @@ import {
import { getConfig } from '@/packages/lib/config'
import { prisma } from '@/packages/lib/database/prisma'
import {
- getFileExpirationInfo,
+ getFileExpirationInfoBatch,
scheduleFileExpiration,
} from '@/packages/lib/events/handlers/file-expiry'
import { getUniqueFilename } from '@/packages/lib/files/filename'
import { validateUploadRequest } from '@/packages/lib/files/upload-validation'
-import { validateFileSecurityChecksWithVT } from '@/packages/lib/files/security-validation'
+import {
+ validateFileSecurityChecks,
+ scanWithVirusTotal,
+} from '@/packages/lib/files/security-validation'
import { loggers } from '@/packages/lib/logger'
import { processImageOCR } from '@/packages/lib/ocr'
import { getStorageProvider } from '@/packages/lib/storage'
@@ -36,6 +39,9 @@ const logger = loggers.files
export async function POST(req: Request) {
let filePath = ''
let userId: string | undefined
+ let storageProvider:
+ | Awaited>
+ | undefined
try {
// ── Auth: try squad token/API key first, then fall back to user session ──
@@ -58,6 +64,7 @@ export async function POST(req: Request) {
role: true,
randomizeFileUrls: true,
preferredUploadDomain: true,
+ emailVerified: true,
},
})
if (!ownerUser)
@@ -114,12 +121,13 @@ export async function POST(req: Request) {
return apiError(result.error.issues[0].message, HTTP_STATUS.BAD_REQUEST)
}
+ const fileSizeMB = bytesToMB(uploadedFile.size)
+
// Check file size against plan upload cap and storage quota
if (user.role !== 'ADMIN') {
const { getPlanLimits, canUploadSize } =
await import('@/packages/lib/storage/quota')
const planLimits = await getPlanLimits(user.id)
- const fileSizeMB = bytesToMB(uploadedFile.size)
// Check plan upload size cap (null = unlimited for Ember/Enterprise)
if (planLimits.uploadSizeCapMB !== null) {
@@ -158,29 +166,35 @@ export async function POST(req: Request) {
}
// Validate email verification and custom domain verification
+ // Pass preloaded user data to skip the redundant DB round-trip in validateEmailVerified
const uploadValidation = await validateUploadRequest(
user.id,
- requestedDomain
+ requestedDomain,
+ { emailVerified: user.emailVerified, role: user.role }
)
if (!uploadValidation.valid) {
return apiError(uploadValidation.error!, HTTP_STATUS.FORBIDDEN)
}
- const { urlSafeName, displayName } = await getUniqueFilename(
- join('uploads', user.urlId),
- uploadedFile.name,
- user.randomizeFileUrls
- )
+ // Buffer file + resolve provider and unique filename in parallel
+ const [buf, { urlSafeName, displayName }, storageProviderResolved] =
+ await Promise.all([
+ uploadedFile.arrayBuffer().then((ab) => Buffer.from(ab)),
+ getUniqueFilename(
+ join('uploads', user.urlId),
+ uploadedFile.name,
+ user.randomizeFileUrls
+ ),
+ getStorageProvider(),
+ ])
+ storageProvider = storageProviderResolved
filePath = join('uploads', user.urlId, urlSafeName)
const urlPath = `/${user.urlId}/${urlSafeName}`
- const storageProvider = await getStorageProvider()
- const bytes = await uploadedFile.arrayBuffer()
-
- // Security check: validate file against zip bombs, malware, dangerous types, and VirusTotal
- const securityCheck = await validateFileSecurityChecksWithVT(
- Buffer.from(bytes),
+ // Fast local security checks only (extension, MIME, zip bomb) — no network calls
+ const securityCheck = validateFileSecurityChecks(
+ buf,
uploadedFile.name,
uploadedFile.type
)
@@ -189,7 +203,6 @@ export async function POST(req: Request) {
fileName: uploadedFile.name,
mimeType: uploadedFile.type,
error: securityCheck.error,
- virusTotal: securityCheck.virusTotal,
userId: user.id,
})
return apiError(
@@ -198,25 +211,7 @@ export async function POST(req: Request) {
)
}
- if (securityCheck.virusTotal?.scanPerformed) {
- logger.info('File scanned by VirusTotal', {
- fileName: uploadedFile.name,
- detected: securityCheck.virusTotal.detected,
- detectionRatio: securityCheck.virusTotal.detectionRatio,
- permalink: securityCheck.virusTotal.permalink,
- userId: user.id,
- })
- }
-
- if (securityCheck.warnings?.length) {
- logger.info('File security warnings', {
- fileName: uploadedFile.name,
- warnings: securityCheck.warnings,
- userId: user.id,
- })
- }
-
- // carry through host headers as metadata so storage/proxy can use them
+ // Carry through host headers as metadata so storage/proxy can use them
const meta: Record = {}
try {
const reqHeaders = (req as any).headers as Headers | undefined
@@ -230,12 +225,10 @@ export async function POST(req: Request) {
// ignore
}
- await storageProvider.uploadFile(
- Buffer.from(bytes),
- filePath,
- uploadedFile.type,
- meta
- )
+ // Hash password before the transaction so bcrypt doesn't block DB time
+ const passwordHash = password ? await hash(password, 10) : null
+
+ await storageProvider.uploadFile(buf, filePath, uploadedFile.type, meta)
const fileRecord = await prisma.$transaction(async (tx) => {
const file = await tx.file.create({
@@ -243,10 +236,10 @@ export async function POST(req: Request) {
name: displayName,
urlPath,
mimeType: uploadedFile.type,
- size: bytesToMB(uploadedFile.size),
+ size: fileSizeMB,
path: filePath,
visibility: visibility,
- password: password ? await hash(password, 10) : null,
+ password: passwordHash,
userId: user.id,
allowSuggestions,
},
@@ -254,14 +247,14 @@ export async function POST(req: Request) {
await tx.user.update({
where: { id: user.id },
- data: { storageUsed: { increment: bytesToMB(uploadedFile.size) } },
+ data: { storageUsed: { increment: fileSizeMB } },
})
// Track squad storage usage when uploaded via squad token/API key
if (squadContext) {
await tx.nexiumSquad.update({
where: { id: squadContext.squadId },
- data: { storageUsed: { increment: bytesToMB(uploadedFile.size) } },
+ data: { storageUsed: { increment: fileSizeMB } },
})
}
@@ -277,6 +270,28 @@ export async function POST(req: Request) {
})
}
+ // VirusTotal scan runs in the background after the response is sent.
+ // On detection the file is deleted from storage and marked in the DB.
+ scanWithVirusTotal(buf, uploadedFile.type, async (vtResult) => {
+ logger.warn('VirusTotal detected malware — quarantining file', {
+ fileId: fileRecord.id,
+ detectionRatio: vtResult.detectionRatio,
+ permalink: vtResult.permalink,
+ userId: user.id,
+ })
+ await Promise.allSettled([
+ storageProvider!.deleteFile(filePath),
+ prisma.file.update({
+ where: { id: fileRecord.id },
+ data: { visibility: 'PRIVATE', name: '[Quarantined]' },
+ }),
+ ])
+ }).catch((err) => {
+ logger.error('Background VirusTotal scan failed', err as Error, {
+ fileId: fileRecord.id,
+ })
+ })
+
if (expirationDate) {
try {
await scheduleFileExpiration(
@@ -300,12 +315,20 @@ export async function POST(req: Request) {
const baseUrl =
process.env.NODE_ENV === 'development'
? 'http://localhost:3000'
- : process.env.NEXTAUTH_URL?.replace(/\/$/, '') || ''
- const fullUrl = (
+ : (process.env.NEXTAUTH_URL?.endsWith('/')
+ ? process.env.NEXTAUTH_URL.slice(0, -1)
+ : process.env.NEXTAUTH_URL) || ''
+ const trimTrailingSlashes = (s: string) => {
+ let end = s.length
+ while (end > 0 && s[end - 1] === '/') end--
+ return end === s.length ? s : s.slice(0, end)
+ }
+
+ const fullUrl = trimTrailingSlashes(
baseUrl.startsWith('http') ? baseUrl : `https://${baseUrl}`
- ).replace(/\/+$/, '')
+ )
- const sanitizeHost = (host: string) => urlForHost(host).replace(/\/+$/, '')
+ const sanitizeHost = (host: string) => trimTrailingSlashes(urlForHost(host))
const preferredHost = user.preferredUploadDomain
? sanitizeHost(user.preferredUploadDomain)
: null
@@ -378,9 +401,8 @@ export async function POST(req: Request) {
userId,
})
- if (filePath) {
+ if (filePath && storageProvider) {
try {
- const storageProvider = await getStorageProvider()
await storageProvider.deleteFile(filePath)
logger.info('Cleaned up file after error', { filePath })
} catch (unlinkError) {
@@ -485,12 +507,14 @@ export async function GET(request: Request) {
}),
])
- const filesList = await Promise.all(
- files.map(async (file) => {
- const expiresAt = await getFileExpirationInfo(file.id)
- return { ...file, hasPassword: Boolean(file.password), expiresAt }
- })
+ const expirationMap = await getFileExpirationInfoBatch(
+ files.map((f) => f.id)
)
+ const filesList = files.map((file) => ({
+ ...file,
+ hasPassword: Boolean(file.password),
+ expiresAt: expirationMap.get(file.id) ?? null,
+ }))
return paginatedResponse(
filesList as (FileMetadata & { expiresAt: Date | null })[],
@@ -580,42 +604,41 @@ export async function GET(request: Request) {
orderBy.uploadedAt = 'desc'
}
- const total = await prisma.file.count({ where })
-
- const files = await prisma.file.findMany({
- where,
- orderBy,
- take: limit,
- skip: offset,
- select: {
- id: true,
- name: true,
- urlPath: true,
- mimeType: true,
- size: true,
- uploadedAt: true,
- visibility: true,
- password: true,
- views: true,
- downloads: true,
- user: {
- select: {
- urlId: true,
+ const [total, files] = await Promise.all([
+ prisma.file.count({ where }),
+ prisma.file.findMany({
+ where,
+ orderBy,
+ take: limit,
+ skip: offset,
+ select: {
+ id: true,
+ name: true,
+ urlPath: true,
+ mimeType: true,
+ size: true,
+ uploadedAt: true,
+ visibility: true,
+ password: true,
+ views: true,
+ downloads: true,
+ user: {
+ select: {
+ urlId: true,
+ },
},
},
- },
- })
+ }),
+ ])
- const filesList = (await Promise.all(
- files.map(async (file) => {
- const expiresAt = await getFileExpirationInfo(file.id)
- return {
- ...file,
- hasPassword: Boolean(file.password),
- expiresAt,
- }
- })
- )) as (FileMetadata & { expiresAt: Date | null })[]
+ const expirationMap = await getFileExpirationInfoBatch(
+ files.map((f) => f.id)
+ )
+ const filesList = files.map((file) => ({
+ ...file,
+ hasPassword: Boolean(file.password),
+ expiresAt: expirationMap.get(file.id) ?? null,
+ })) as (FileMetadata & { expiresAt: Date | null })[]
const pagination = {
total,
diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts
index d1839ec..e676458 100644
--- a/app/api/settings/route.ts
+++ b/app/api/settings/route.ts
@@ -5,7 +5,11 @@ import {
} from '@/packages/types/dto/settings'
import { HTTP_STATUS, apiError, apiResponse } from '@/packages/lib/api/response'
-import { requireAdmin, requireAuth, requireSuperAdmin } from '@/packages/lib/auth/api-auth'
+import {
+ requireAdmin,
+ requireAuth,
+ requireSuperAdmin,
+} from '@/packages/lib/auth/api-auth'
import {
EmberlyConfig,
getConfig,
@@ -31,12 +35,22 @@ function maskSecretsForAdmin(config: EmberlyConfig): EmberlyConfig {
}
// Integrations
const i = c.settings?.integrations ?? {}
- if (i.stripe) { i.stripe.secretKey = ''; i.stripe.webhookSecret = '' }
- if (i.resend) { i.resend.apiKey = '' }
- if (i.cloudflare) { i.cloudflare.apiToken = '' }
- if (i.discord) { i.discord.botToken = '' }
- if (i.github) { i.github.pat = '' }
- if (i.kener) { i.kener.apiKey = '' }
+ if (i.stripe) {
+ i.stripe.secretKey = ''
+ i.stripe.webhookSecret = ''
+ }
+ if (i.resend) {
+ i.resend.apiKey = ''
+ }
+ if (i.cloudflare) {
+ i.cloudflare.apiToken = ''
+ }
+ if (i.discord) {
+ i.discord.botToken = ''
+ }
+ if (i.github) {
+ i.github.pat = ''
+ }
return c as EmberlyConfig
}
@@ -182,4 +196,3 @@ export async function POST(req: Request) {
return apiError('Internal server error', HTTP_STATUS.INTERNAL_SERVER_ERROR)
}
}
-
diff --git a/app/api/status/route.ts b/app/api/status/route.ts
index 6950f26..8d85c82 100644
--- a/app/api/status/route.ts
+++ b/app/api/status/route.ts
@@ -1,29 +1,6 @@
-import { apiResponse } from '@/packages/lib/api/response'
-import { getKenerStatus } from '@/packages/lib/kener'
+import { apiError } from '@/packages/lib/api/response'
+import { HTTP_STATUS } from '@/packages/lib/api/response'
-/**
- * GET /api/status
- * Returns aggregated status from the Kener instance at emberlystat.us
- */
export async function GET() {
- try {
- const summary = await getKenerStatus()
- if (!summary) {
- // Kener unreachable — return a graceful UNKNOWN state rather than a hard 503
- return apiResponse({
- page: { name: 'Emberly Status', url: 'https://emberlystat.us', status: 'UNKNOWN' },
- activeIncidents: [],
- activeMaintenances: [],
- })
- }
- return apiResponse(summary)
- } catch (err) {
- console.error('Error fetching status:', err)
- return apiResponse({
- page: { name: 'Emberly Status', url: 'https://emberlystat.us', status: 'UNKNOWN' },
- activeIncidents: [],
- activeMaintenances: [],
- })
- }
+ return apiError('Status page integration removed', HTTP_STATUS.NOT_FOUND)
}
-
diff --git a/packages/components/admin/settings/settings-manager.tsx b/packages/components/admin/settings/settings-manager.tsx
index f0530d6..cf15c05 100644
--- a/packages/components/admin/settings/settings-manager.tsx
+++ b/packages/components/admin/settings/settings-manager.tsx
@@ -1,4 +1,4 @@
-"use client"
+'use client'
import { useCallback, useEffect, useState } from 'react'
@@ -12,42 +12,42 @@ import CodeMirror from '@uiw/react-codemirror'
import DOMPurify from 'dompurify'
import { deepEqual } from 'fast-equals'
import {
- AlertCircle,
- CheckCircle2,
- Circle,
- Cloud,
- Code,
- Copy,
- CreditCard,
- Database,
- ExternalLink,
- Eye,
- EyeOff,
- FileCode,
- Github,
- Globe,
- HardDrive,
- Heart,
- Image,
- InfoIcon,
- Key,
- Loader2,
- Lock,
- Mail,
- Palette,
- RefreshCw,
- RotateCcw,
- Save,
- Server,
- Settings,
- Settings2,
- Shield,
- Sliders,
- Sparkles,
- Upload,
- Users,
- XCircle,
- Zap,
+ AlertCircle,
+ CheckCircle2,
+ Circle,
+ Cloud,
+ Code,
+ Copy,
+ CreditCard,
+ Database,
+ ExternalLink,
+ Eye,
+ EyeOff,
+ FileCode,
+ Github,
+ Globe,
+ HardDrive,
+ Heart,
+ Image,
+ InfoIcon,
+ Key,
+ Loader2,
+ Lock,
+ Mail,
+ Palette,
+ RefreshCw,
+ RotateCcw,
+ Save,
+ Server,
+ Settings,
+ Settings2,
+ Shield,
+ Sliders,
+ Sparkles,
+ Upload,
+ Users,
+ XCircle,
+ Zap,
} from 'lucide-react'
import { Icons } from '@/components/shared/icons'
@@ -55,20 +55,20 @@ import { AppearancePanel } from '@/components/appearance/appearance-panel'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
@@ -84,2301 +84,2877 @@ import { useToast } from '@/hooks/use-toast'
import { ToastAction } from '@/components/ui/toast'
// Reusable GlassCard component for consistent styling
-function GlassCard({
- children,
- className = '',
- gradient = true
-}: {
- children: React.ReactNode
- className?: string
- gradient?: boolean
+function GlassCard({
+ children,
+ className = '',
+ gradient = true,
+}: {
+ children: React.ReactNode
+ className?: string
+ gradient?: boolean
}) {
- return (
-
- {children}
-
- )
+ return (
+
+ {children}
+
+ )
}
// Settings section card with icon and better styling
-function SettingsSection({
- icon: Icon,
- title,
- description,
- children,
- badge,
- className = ''
-}: {
- icon: React.ElementType
- title: string
- description: string
- children: React.ReactNode
- badge?: React.ReactNode
- className?: string
+function SettingsSection({
+ icon: Icon,
+ title,
+ description,
+ children,
+ badge,
+ className = '',
+}: {
+ icon: React.ElementType
+ title: string
+ description: string
+ children: React.ReactNode
+ badge?: React.ReactNode
+ className?: string
}) {
- return (
-
-
-
-
-
-
-
-
-
{title}
- {badge}
-
-
{description}
-
-
-
- {children}
-
-
-
- )
+ return (
+
+
+
+
+
+
+
+
+
{title}
+ {badge}
+
+
+ {description}
+
+
+
+
{children}
+
+
+ )
}
// Setting row component for consistent layout
-function SettingRow({
- label,
- description,
- children,
- changed = false
-}: {
- label: string
- description?: string
- children: React.ReactNode
- changed?: boolean
+function SettingRow({
+ label,
+ description,
+ children,
+ changed = false,
+}: {
+ label: string
+ description?: string
+ children: React.ReactNode
+ changed?: boolean
}) {
- return (
-
-
-
-
- {changed && (
-
-
-
-
- )}
-
- {description && (
-
{description}
- )}
-
-
- {children}
-
-
- )
+ return (
+
+
+
+
+ {changed && (
+
+
+
+
+ )}
+
+ {description && (
+
{description}
+ )}
+
+
{children}
+
+ )
}
interface ColorConfig {
- background: string
- foreground: string
- card: string
- cardForeground: string
- popover: string
- popoverForeground: string
- primary: string
- primaryForeground: string
- secondary: string
- secondaryForeground: string
- muted: string
- mutedForeground: string
- accent: string
- accentForeground: string
- destructive: string
- destructiveForeground: string
- border: string
- input: string
- ring: string
+ background: string
+ foreground: string
+ card: string
+ cardForeground: string
+ popover: string
+ popoverForeground: string
+ primary: string
+ primaryForeground: string
+ secondary: string
+ secondaryForeground: string
+ muted: string
+ mutedForeground: string
+ accent: string
+ accentForeground: string
+ destructive: string
+ destructiveForeground: string
+ border: string
+ input: string
+ ring: string
}
type SettingValue = Partial<
- EmberlyConfig['settings'][T]
+ EmberlyConfig['settings'][T]
>
function SettingsSkeleton() {
- return (
-
- {/* Header skeleton */}
-
-
- {/* Tabs skeleton */}
-
-
-
-
-
-
- {/* Cards skeleton */}
-
- {[1, 2, 3].map((i) => (
-
- ))}
-
-
- )
+ return (
+
+ {/* Header skeleton */}
+
+
+ {/* Tabs skeleton */}
+
+
+
+
+
+
+ {/* Cards skeleton */}
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+ )
}
const isSafeUrl = (url: string | null): url is string => {
- if (!url) return false
- return url.startsWith('blob:') && /^blob:https?:\/\//.test(url)
+ if (!url) return false
+ return url.startsWith('blob:') && /^blob:https?:\/\//.test(url)
}
function SystemApiKeySection() {
- const { toast } = useToast()
- const [exists, setExists] = useState(false)
- const [prefix, setPrefix] = useState(null)
- const [createdAt, setCreatedAt] = useState(null)
- const [newKey, setNewKey] = useState(null)
- const [showKey, setShowKey] = useState(false)
- const [loading, setLoading] = useState(true)
- const [generating, setGenerating] = useState(false)
- const [revoking, setRevoking] = useState(false)
-
- const fetchMeta = useCallback(async () => {
- try {
- const res = await fetch('/api/admin/system-key')
- if (!res.ok) throw new Error('Failed to fetch')
- const data = await res.json()
- setExists(data.exists)
- setPrefix(data.prefix ?? null)
- setCreatedAt(data.createdAt ?? null)
- } catch {
- toast({ title: 'Error', description: 'Failed to load system key info', variant: 'destructive' })
- } finally {
- setLoading(false)
- }
- }, [toast])
-
- useEffect(() => { fetchMeta() }, [fetchMeta])
-
- const executeGenerate = async () => {
- setGenerating(true)
- setNewKey(null)
- try {
- const res = await fetch('/api/admin/system-key', { method: 'POST' })
- if (!res.ok) throw new Error('Failed to generate')
- const data = await res.json()
- setNewKey(data.key)
- setShowKey(true)
- setExists(true)
- setPrefix(data.prefix)
- setCreatedAt(new Date().toISOString())
- toast({ title: 'Key generated', description: 'Copy it now — it won\'t be shown again.' })
- } catch {
- toast({ title: 'Error', description: 'Failed to generate key', variant: 'destructive' })
- } finally {
- setGenerating(false)
- }
- }
-
- const handleGenerate = async () => {
- if (!exists) {
- await executeGenerate()
- return
- }
- toast({
- title: 'Regenerate API key?',
- description: 'This will revoke the current key. Any integrations using it will stop working.',
- variant: 'destructive',
- action: (
-
- Regenerate
-
- ),
- })
- }
-
- const handleRevoke = () => {
- toast({
- title: 'Revoke system API key?',
- description: 'Any integrations using it will stop working.',
- variant: 'destructive',
- action: (
- {
- setRevoking(true)
- try {
- const res = await fetch('/api/admin/system-key', { method: 'DELETE' })
- if (!res.ok) throw new Error('Failed to revoke')
- setExists(false)
- setPrefix(null)
- setCreatedAt(null)
- setNewKey(null)
- toast({ title: 'Key revoked' })
- } catch {
- toast({ title: 'Error', description: 'Failed to revoke key', variant: 'destructive' })
- } finally {
- setRevoking(false)
- }
- }}
- >
- Revoke
-
- ),
- })
- }
-
- const copyKey = () => {
- if (!newKey) return
- navigator.clipboard.writeText(newKey)
- toast({ title: 'Copied to clipboard' })
- }
-
- if (loading) {
- return (
-
-
-
-
-
-
- )
- }
-
- return (
-
- {newKey && (
-
-
-
New key generated — copy it now, it won't be shown again.
-
-
- {showKey ? newKey : '•'.repeat(newKey.length)}
-
-
-
-
-
-
- )}
-
- {exists && (
-
- {prefix}••••••••
-
- )}
-
-
-
- {exists && (
-
- )}
-
-
- {!exists && !newKey && (
- No key exists yet. Generate one to use with external integrations.
- )}
-
- )
+ const { toast } = useToast()
+ const [exists, setExists] = useState(false)
+ const [prefix, setPrefix] = useState(null)
+ const [createdAt, setCreatedAt] = useState(null)
+ const [newKey, setNewKey] = useState(null)
+ const [showKey, setShowKey] = useState(false)
+ const [loading, setLoading] = useState(true)
+ const [generating, setGenerating] = useState(false)
+ const [revoking, setRevoking] = useState(false)
+
+ const fetchMeta = useCallback(async () => {
+ try {
+ const res = await fetch('/api/admin/system-key')
+ if (!res.ok) throw new Error('Failed to fetch')
+ const data = await res.json()
+ setExists(data.exists)
+ setPrefix(data.prefix ?? null)
+ setCreatedAt(data.createdAt ?? null)
+ } catch {
+ toast({
+ title: 'Error',
+ description: 'Failed to load system key info',
+ variant: 'destructive',
+ })
+ } finally {
+ setLoading(false)
+ }
+ }, [toast])
+
+ useEffect(() => {
+ fetchMeta()
+ }, [fetchMeta])
+
+ const executeGenerate = async () => {
+ setGenerating(true)
+ setNewKey(null)
+ try {
+ const res = await fetch('/api/admin/system-key', { method: 'POST' })
+ if (!res.ok) throw new Error('Failed to generate')
+ const data = await res.json()
+ setNewKey(data.key)
+ setShowKey(true)
+ setExists(true)
+ setPrefix(data.prefix)
+ setCreatedAt(new Date().toISOString())
+ toast({
+ title: 'Key generated',
+ description: "Copy it now — it won't be shown again.",
+ })
+ } catch {
+ toast({
+ title: 'Error',
+ description: 'Failed to generate key',
+ variant: 'destructive',
+ })
+ } finally {
+ setGenerating(false)
+ }
+ }
+
+ const handleGenerate = async () => {
+ if (!exists) {
+ await executeGenerate()
+ return
+ }
+ toast({
+ title: 'Regenerate API key?',
+ description:
+ 'This will revoke the current key. Any integrations using it will stop working.',
+ variant: 'destructive',
+ action: (
+
+ Regenerate
+
+ ),
+ })
+ }
+
+ const handleRevoke = () => {
+ toast({
+ title: 'Revoke system API key?',
+ description: 'Any integrations using it will stop working.',
+ variant: 'destructive',
+ action: (
+ {
+ setRevoking(true)
+ try {
+ const res = await fetch('/api/admin/system-key', {
+ method: 'DELETE',
+ })
+ if (!res.ok) throw new Error('Failed to revoke')
+ setExists(false)
+ setPrefix(null)
+ setCreatedAt(null)
+ setNewKey(null)
+ toast({ title: 'Key revoked' })
+ } catch {
+ toast({
+ title: 'Error',
+ description: 'Failed to revoke key',
+ variant: 'destructive',
+ })
+ } finally {
+ setRevoking(false)
+ }
+ }}
+ >
+ Revoke
+
+ ),
+ })
+ }
+
+ const copyKey = () => {
+ if (!newKey) return
+ navigator.clipboard.writeText(newKey)
+ toast({ title: 'Copied to clipboard' })
+ }
+
+ if (loading) {
+ return (
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+ {newKey && (
+
+
+
+ New key generated — copy it now, it won't be shown again.
+
+
+
+ {showKey ? newKey : '•'.repeat(newKey.length)}
+
+
+
+
+
+
+ )}
+
+ {exists && (
+
+
+ {prefix}••••••••
+
+
+ )}
+
+
+
+ {exists && (
+
+ )}
+
+
+ {!exists && !newKey && (
+
+ No key exists yet. Generate one to use with external integrations.
+
+ )}
+
+ )
}
export function SettingsManager() {
- const { toast } = useToast()
- const { data: session } = useSession()
- const isSuperAdmin = session?.user?.role === 'SUPERADMIN'
-
- const [savedConfig, setSavedConfig] = useState(null)
- const [workingConfig, setWorkingConfig] = useState(null)
- const [pendingFaviconFile, setPendingFaviconFile] = useState(null)
- const [faviconPreviewUrl, setFaviconPreviewUrl] = useState(null)
-
- const [cssEditorOpen, setCssEditorOpen] = useState(false)
- const [htmlEditorOpen, setHtmlEditorOpen] = useState(false)
- const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
- const [updateInfo, setUpdateInfo] = useState<{
- hasUpdate: boolean
- latestVersion?: string
- releaseUrl?: string
- } | null>(null)
- const [isSaving, setIsSaving] = useState(false)
- const [intTestStates, setIntTestStates] = useState>({})
- const [s3TestState, setS3TestState] = useState<{ loading: boolean; ok?: boolean; message?: string } | null>(null)
-
- const handleIntegrationTest = async (integration: string, credentials: Record) => {
- setIntTestStates((s) => ({ ...s, [integration]: { loading: true } }))
- try {
- const res = await fetch('/api/admin/integrations/test', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ integration, credentials }),
- })
- const data = await res.json()
- setIntTestStates((s) => ({ ...s, [integration]: { loading: false, ok: data?.data?.ok, message: data?.data?.message, detail: data?.data?.detail } }))
- } catch {
- setIntTestStates((s) => ({ ...s, [integration]: { loading: false, ok: false, message: 'Request failed' } }))
- }
- }
-
- const handleS3Test = async () => {
- if (!workingConfig) return
- setS3TestState({ loading: true })
- const s3 = workingConfig.settings.general.storage.s3
- try {
- const res = await fetch('/api/admin/storage/test', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- bucket: s3?.bucket,
- region: s3?.region,
- accessKeyId: s3?.accessKeyId,
- secretAccessKey: s3?.secretAccessKey,
- endpoint: s3?.endpoint,
- forcePathStyle: s3?.forcePathStyle,
- }),
- })
- const data = await res.json()
- setS3TestState({ loading: false, ok: data?.data?.ok, message: data?.data?.message })
- } catch {
- setS3TestState({ loading: false, ok: false, message: 'Request failed' })
- }
- }
-
- const hasChanges =
- !deepEqual(savedConfig, workingConfig) || pendingFaviconFile !== null
-
- const saveChanges = async () => {
- if (!workingConfig) return
-
- try {
- setIsSaving(true)
-
- if (pendingFaviconFile) {
- const formData = new FormData()
- formData.append('file', pendingFaviconFile)
-
- const response = await fetch('/api/settings/favicon', {
- method: 'POST',
- body: formData,
- })
-
- if (!response.ok) {
- throw new Error('Failed to upload favicon')
- }
-
- const newConfig = { ...workingConfig }
- newConfig.settings.appearance.favicon = '/api/favicon'
- setWorkingConfig(newConfig)
-
- const link = document.querySelector(
- "link[rel*='icon']"
- ) as HTMLLinkElement
- if (link) {
- link.href = '/api/favicon'
- link.type = 'image/png'
- }
-
- setPendingFaviconFile(null)
- if (faviconPreviewUrl) {
- URL.revokeObjectURL(faviconPreviewUrl)
- setFaviconPreviewUrl(null)
- }
- }
-
- const response = await fetch('/api/settings', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(workingConfig),
- })
-
- if (!response.ok) throw new Error()
-
- setSavedConfig(JSON.parse(JSON.stringify(workingConfig)))
-
- toast({
- title: 'Settings updated',
- description: 'Your changes have been saved successfully',
- })
- } catch (error) {
- console.error('Failed to update settings:', error)
- toast({
- title: 'Failed to update settings',
- description: 'Please try again',
- variant: 'destructive',
- })
- } finally {
- setIsSaving(false)
- }
- }
-
- const discardChanges = () => {
- if (!savedConfig) return
-
- if (faviconPreviewUrl) {
- URL.revokeObjectURL(faviconPreviewUrl)
- setFaviconPreviewUrl(null)
- }
-
- setPendingFaviconFile(null)
-
- setWorkingConfig(JSON.parse(JSON.stringify(savedConfig)))
-
- toast({
- title: 'Changes discarded',
- description: 'All changes have been reverted to the saved state',
- })
- }
-
- const handleSettingChange = useCallback(
- (
- section: T,
- value: SettingValue
- ) => {
- if (!workingConfig) return
-
- const newConfig = { ...workingConfig }
- newConfig.settings[section] = {
- ...(newConfig.settings[section] as Record ?? {}),
- ...(value as Record),
- } as EmberlyConfig['settings'][T]
- setWorkingConfig(newConfig)
- },
- [workingConfig]
- )
-
- const isFieldChanged = useCallback(
- (
- section: T,
- fieldPath: string[]
- ): boolean => {
- if (!savedConfig || !workingConfig) return false
-
- let savedValue: unknown = savedConfig.settings[section]
- let workingValue: unknown = workingConfig.settings[section]
-
- for (const field of fieldPath) {
- if (
- typeof savedValue !== 'object' ||
- savedValue === null ||
- typeof workingValue !== 'object' ||
- workingValue === null
- ) {
- return false
- }
-
- savedValue = (savedValue as Record)[field]
- workingValue = (workingValue as Record)[field]
- }
-
- return !deepEqual(savedValue, workingValue)
- },
- [savedConfig, workingConfig]
- )
-
- const countChangedSettings = useCallback((): number => {
- if (!savedConfig || !workingConfig) return 0
-
- let count = 0
-
- const { storage: _cs1, ...savedGen } = savedConfig.settings.general
- const { storage: _cs2, ...workingGen } = workingConfig.settings.general
- if (!deepEqual(savedGen, workingGen)) {
- count++
- }
-
- if (!deepEqual(_cs1, _cs2)) {
- count++
- }
-
- if (
- !deepEqual(savedConfig.settings.appearance, workingConfig.settings.appearance)
- ) {
- count++
- }
-
- if (!deepEqual(savedConfig.settings.advanced, workingConfig.settings.advanced)) {
- count++
- }
-
- if (!deepEqual(savedConfig.settings.integrations, workingConfig.settings.integrations)) {
- count++
- }
-
- return count
- }, [savedConfig, workingConfig])
-
- const getChangedSettingsGroups = useCallback((): string[] => {
- if (!savedConfig || !workingConfig) return []
-
- const changedGroups: string[] = []
-
- const { storage: _cg1, ...savedGen } = savedConfig.settings.general
- const { storage: _cg2, ...workingGen } = workingConfig.settings.general
- if (!deepEqual(savedGen, workingGen)) {
- changedGroups.push('General')
- }
-
- if (!deepEqual(_cg1, _cg2)) {
- changedGroups.push('Storage')
- }
-
- if (
- !deepEqual(savedConfig.settings.appearance, workingConfig.settings.appearance)
- ) {
- changedGroups.push('Appearance')
- }
-
- if (!deepEqual(savedConfig.settings.advanced, workingConfig.settings.advanced)) {
- changedGroups.push('Advanced')
- }
-
- if (!deepEqual(savedConfig.settings.integrations, workingConfig.settings.integrations)) {
- changedGroups.push('Integrations')
- }
-
- return changedGroups
- }, [savedConfig, workingConfig])
-
- useEffect(() => {
- const loadConfig = async () => {
- try {
- const response = await fetch('/api/settings')
- const responseJson = await response.json()
- if (responseJson?.data) {
- const actualConfigData = responseJson.data
- setSavedConfig(actualConfigData)
- setWorkingConfig(JSON.parse(JSON.stringify(actualConfigData)))
- } else {
- console.error(
- 'Failed to load config: Invalid data structure received',
- responseJson
- )
- }
- } catch (error) {
- console.error('Failed to load config:', error)
- }
- }
- loadConfig()
- }, [])
-
- const handleStorageQuotaChange = (value: string) => {
- if (!workingConfig?.settings?.general?.storage) return
- const numValue = Number.parseInt(value)
- if (Number.isNaN(numValue) || numValue < 0) return
-
- handleSettingChange('general', {
- storage: {
- ...workingConfig.settings.general.storage,
- quotas: {
- ...workingConfig.settings.general.storage.quotas,
- default: {
- ...workingConfig.settings.general.storage.quotas.default,
- value: numValue,
- },
- },
- },
- })
- }
-
- const handleMaxUploadSizeChange = (value: string) => {
- if (!workingConfig?.settings?.general?.storage) return
- const numValue = Number.parseInt(value)
- if (Number.isNaN(numValue) || numValue < 1) return
-
- handleSettingChange('general', {
- storage: {
- ...workingConfig.settings.general.storage,
- maxUploadSize: {
- ...workingConfig.settings.general.storage.maxUploadSize,
- value: numValue,
- },
- },
- })
- }
-
- const handleCustomColorsChange = (colors: Partial) => {
- handleSettingChange('appearance', {
- customColors: colors,
- })
- }
-
- const handleThemePresetChange = (themeId: string, backgroundEffect: string, animationSpeed: string) => {
- handleSettingChange('appearance', {
- theme: themeId,
- backgroundEffect: backgroundEffect as EmberlyConfig['settings']['appearance']['backgroundEffect'],
- animationSpeed: animationSpeed as AnimationSpeed,
- })
- }
-
- /**
- * Save system/admin appearance directly to /api/settings (PATCH).
- * Used as the onSave override for AppearancePanel in admin context so it
- * does NOT call PUT /api/profile like the user-facing flow.
- */
- const handleAdminSaveAppearance = async (themeId: string, colors: Record): Promise => {
- try {
- const response = await fetch('/api/settings', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- section: 'appearance',
- data: {
- theme: themeId,
- customColors: colors,
- },
- }),
- })
- if (!response.ok) throw new Error('Failed to save appearance')
- const updatedConfig: EmberlyConfig = await response.json()
- setSavedConfig(JSON.parse(JSON.stringify(updatedConfig)))
- setWorkingConfig(JSON.parse(JSON.stringify(updatedConfig)))
- return true
- } catch (error) {
- console.error('[SettingsManager] Failed to save appearance', error)
- return false
- }
- }
-
- /**
- * Track admin theme selections so the "Modified" badge reflects appearance changes
- * while the admin is previewing (before saving).
- */
- const handleAdminThemeChange = useCallback((
- themeId: string,
- colors: Record,
- meta?: { backgroundEffect?: string; animationSpeed?: string }
- ) => {
- handleSettingChange('appearance', {
- theme: themeId,
- backgroundEffect: (meta?.backgroundEffect || 'none') as EmberlyConfig['settings']['appearance']['backgroundEffect'],
- animationSpeed: (meta?.animationSpeed || 'medium') as AnimationSpeed,
- customColors: colors,
- })
- }, [handleSettingChange])
-
- const checkForUpdates = async () => {
- try {
- setIsCheckingUpdate(true)
- const response = await fetch('/api/updates/check')
- if (!response.ok) throw new Error()
- const data = await response.json()
- setUpdateInfo(data)
-
- toast({
- title: data.hasUpdate ? 'Update Available' : 'No Updates Available',
- description: data.message,
- variant: 'default',
- })
- } catch {
- toast({
- title: 'Failed to check for updates',
- description: 'Please try again later',
- variant: 'destructive',
- })
- } finally {
- setIsCheckingUpdate(false)
- }
- }
-
- const hasFaviconChanged = useCallback(() => {
- return pendingFaviconFile !== null
- }, [pendingFaviconFile])
-
- if (
- !workingConfig ||
- !savedConfig ||
- !workingConfig.settings ||
- !savedConfig.settings ||
- !workingConfig.settings.general ||
- !savedConfig.settings.general ||
- !workingConfig.settings.general.storage ||
- !savedConfig.settings.general.storage ||
- !workingConfig.settings.appearance ||
- !savedConfig.settings.appearance ||
- !workingConfig.settings.advanced ||
- !savedConfig.settings.advanced
- ) {
- return
- }
-
- const getFieldClasses = (
- section: keyof EmberlyConfig['settings'],
- fieldPath: string[]
- ) => {
- const isChanged = isFieldChanged(section, fieldPath)
- return isChanged ? 'border-primary/50 ring-1 ring-primary/30 bg-primary/5 transition-all' : 'transition-all'
- }
-
- const ChangeIndicator = () => (
-
-
-
-
- )
-
- const generalHasChanges = (() => {
- if (!savedConfig || !workingConfig) return false
- const { storage: _sg1, ...savedGen } = savedConfig.settings.general
- const { storage: _sg2, ...workingGen } = workingConfig.settings.general
- return !deepEqual(savedGen, workingGen)
- })()
- const storageHasChanges = !deepEqual(savedConfig?.settings.general?.storage, workingConfig?.settings.general?.storage)
- const appearanceHasChanges = !deepEqual(savedConfig?.settings.appearance, workingConfig?.settings.appearance)
- const advancedHasChanges = !deepEqual(savedConfig?.settings.advanced, workingConfig?.settings.advanced)
- const integrationsHasChanges = !deepEqual(savedConfig?.settings.integrations, workingConfig?.settings.integrations)
-
- return (
-
- {/* Page Header - Removed as it's now in the parent page */}
-
- {/* Admin read-only notice */}
- {!isSuperAdmin && (
-
-
-
- Read-only mode. You can view and test settings, but saving changes and viewing secret keys requires Super Administrator access.
-
-
- )}
-
- {/* Main Content */}
-
- {/* Improved Tab Navigation */}
-
-
-
-
- General
- {generalHasChanges && (
-
-
-
-
- )}
-
-
-
- Storage
- {storageHasChanges && (
-
-
-
-
- )}
-
-
-
- Integrations
- {integrationsHasChanges && (
-
-
-
-
- )}
-
-
-
- Appearance
- {appearanceHasChanges && (
-
-
-
-
- )}
-
-
-
- Advanced
- {advancedHasChanges && (
-
-
-
-
- )}
-
-
-
-
- {/* General Settings Tab */}
-
- {/* Instance Information */}
-
-
- Update available
-
- )
- }
- >
-
-
- {updateInfo?.hasUpdate && (
-
- )}
-
-
-
-
-
-
-
- {/* User Management */}
-
-
-
- handleSettingChange('general', {
- registrations: {
- ...workingConfig.settings.general.registrations,
- enabled: checked,
- },
- })
- }
- className={getFieldClasses('general', ['registrations', 'enabled'])}
- />
-
-
- {!workingConfig.settings.general.registrations.enabled && (
-
-
-
- handleSettingChange('general', {
- registrations: {
- ...workingConfig.settings.general.registrations,
- disabledMessage: e.target.value,
- },
- })
- }
- className={cn("max-w-md", getFieldClasses('general', ['registrations', 'disabledMessage']))}
- />
-
- Shown to users when registrations are disabled
-
-
- )}
-
-
-
- {/* Credits */}
-
-
-
- handleSettingChange('general', {
- credits: { showFooter: checked },
- })
- }
- className={getFieldClasses('general', ['credits', 'showFooter'])}
- />
-
-
- {!workingConfig.settings.general.credits.showFooter && (
-
-
-
- If you disable credits, please consider{' '}
-
- sponsoring the project
- {' '}
- to support its development.
-
-
- )}
-
-
-
- {/* Storage Tab */}
-
- {/* User Quotas */}
-
-
-
- handleSettingChange('general', {
- storage: {
- ...workingConfig.settings.general.storage,
- quotas: {
- ...workingConfig.settings.general.storage.quotas,
- enabled: checked,
- },
- },
- })
- }
- className={getFieldClasses('general', ['storage', 'quotas', 'enabled'])}
- />
-
-
-
-
-
- {isFieldChanged('general', ['storage', 'quotas', 'default', 'value']) && }
-
-
- handleStorageQuotaChange(e.target.value)}
- placeholder="500"
- className={cn("flex-1", getFieldClasses('general', ['storage', 'quotas', 'default', 'value']))}
- />
-
-
-
-
-
- {/* Storage Provider */}
-
-
-
- handleSettingChange('general', {
- ocr: { enabled: checked },
- })
- }
- className={getFieldClasses('general', ['ocr', 'enabled'])}
- />
-
-
-
-
-
- {isFieldChanged('general', ['storage', 'provider']) && }
-
-
-
-
- {workingConfig.settings.general.storage.provider === 's3' && (
-
-
-
-
- S3 Configuration
-
-
-
-
-
-
- handleSettingChange('general', {
- storage: {
- ...workingConfig.settings.general.storage,
- s3: {
- ...workingConfig.settings.general.storage.s3,
- bucket: e.target.value,
- },
- },
- })
- }
- placeholder="my-bucket"
- className={getFieldClasses('general', ['storage', 's3', 'bucket'])}
- />
-
-
-
-
- handleSettingChange('general', {
- storage: {
- ...workingConfig.settings.general.storage,
- s3: {
- ...workingConfig.settings.general.storage.s3,
- region: e.target.value,
- },
- },
- })
- }
- placeholder="us-east-1"
- className={getFieldClasses('general', ['storage', 's3', 'region'])}
- />
-
-
-
-
- handleSettingChange('general', {
- storage: {
- ...workingConfig.settings.general.storage,
- s3: {
- ...workingConfig.settings.general.storage.s3,
- accessKeyId: e.target.value,
- },
- },
- })
- }
- placeholder="AKIAXXXXXXXXXXXXXXXX"
- className={getFieldClasses('general', ['storage', 's3', 'accessKeyId'])}
- />
-
-
-
-
- handleSettingChange('general', {
- storage: {
- ...workingConfig.settings.general.storage,
- s3: {
- ...workingConfig.settings.general.storage.s3,
- secretAccessKey: e.target.value,
- },
- },
- })
- }
- placeholder="••••••••••••••••••••"
- className={getFieldClasses('general', ['storage', 's3', 'secretAccessKey'])}
- />
-
-
-
-
-
-
- handleSettingChange('general', {
- storage: {
- ...workingConfig.settings.general.storage,
- s3: {
- ...workingConfig.settings.general.storage.s3,
- endpoint: e.target.value,
- },
- },
- })
- }
- placeholder="https://s3.custom-domain.com"
- className={getFieldClasses('general', ['storage', 's3', 'endpoint'])}
- />
-
- For S3-compatible services like MinIO or DigitalOcean Spaces
-
-
-
-
-
- handleSettingChange('general', {
- storage: {
- ...workingConfig.settings.general.storage,
- s3: {
- ...workingConfig.settings.general.storage.s3,
- forcePathStyle: checked,
- },
- },
- })
- }
- className={getFieldClasses('general', ['storage', 's3', 'forcePathStyle'])}
- />
-
-
-
-
- {s3TestState && !s3TestState.loading && (
-
- {s3TestState.ok
- ?
- : }
- {s3TestState.message}
-
- )}
-
-
-
-
-
- )}
-
-
-
-
- {isFieldChanged('general', ['storage', 'maxUploadSize', 'value']) && }
-
-
- handleMaxUploadSizeChange(e.target.value)}
- placeholder="10"
- className={cn("flex-1", getFieldClasses('general', ['storage', 'maxUploadSize', 'value']))}
- />
-
-
-
-
-
- {/* Vultr Object Storage Pools */}
-
-
-
-
- {/* Additional Storage Buckets */}
-
-
-
-
-
- {/* Appearance Tab */}
-
- {/* Theme Colors */}
-
- Modified
-
- )
- }
- >
-
-
-
- {/* Favicon */}
-
- Unsaved
-
- )
- }
- >
-
-
-
-
-
-
- {/* Advanced Tab */}
-
- {/* Custom CSS */}
-
- Modified
-
- )
- }
- >
-
-
-
- Custom CSS will be injected into every page
-
-
-
- {cssEditorOpen && (
-
-
- {
- handleSettingChange('advanced', {
- customCSS: value,
- })
- }}
- theme="dark"
- className="rounded-lg overflow-hidden text-sm"
- />
-
-
- )}
-
-
-
- {/* Custom HTML */}
-
- Modified
-
- )
- }
- >
-
-
-
- Add scripts, meta tags, or other HTML elements
-
-
-
- {htmlEditorOpen && (
-
-
- {
- handleSettingChange('advanced', {
- customHead: value,
- })
- }}
- theme="dark"
- className="rounded-lg overflow-hidden text-sm"
- />
-
-
- )}
-
-
-
-
- {/* Integrations Tab */}
-
-
-
- {/* Stripe */}
-
- Modified
-
- )
- }
- >
-
- handleSettingChange('integrations', {
- stripe: {
- ...workingConfig?.settings.integrations?.stripe,
- secretKey: e.target.value,
- },
- })}
- />
-
-
- handleSettingChange('integrations', {
- stripe: {
- ...workingConfig?.settings.integrations?.stripe,
- webhookSecret: e.target.value,
- },
- })}
- />
-
-
-
- {intTestStates['stripe'] && !intTestStates['stripe'].loading && (
-
- {intTestStates['stripe'].ok ? : }
- {intTestStates['stripe'].message}
-
- )}
-
-
-
-
-
- {/* Resend / SMTP */}
- {(() => {
- const emailProvider = (workingConfig?.settings.integrations as Record)?.emailProvider as string ?? 'resend'
- return (
- <>
-
- Modified
-
- )
- }
- >
-
-
-
-
- {emailProvider === 'resend' && (
- <>
-
- handleSettingChange('integrations', {
- resend: {
- ...workingConfig?.settings.integrations?.resend,
- apiKey: e.target.value,
- },
- })}
- />
-
-
- handleSettingChange('integrations', {
- resend: {
- ...workingConfig?.settings.integrations?.resend,
- emailFrom: e.target.value,
- },
- })}
- />
-
-
-
- {intTestStates['resend'] && !intTestStates['resend'].loading && (
-
- {intTestStates['resend'].ok ? : }
- {intTestStates['resend'].message}
-
- )}
-
-
-
- >
- )}
-
- {emailProvider === 'smtp' && (
- <>
-
- )?.smtp as Record)?.host as string ?? ''}
- onChange={(e) => handleSettingChange('integrations', {
- smtp: {
- ...((workingConfig?.settings.integrations as Record)?.smtp as Record ?? {}),
- host: e.target.value,
- } as EmberlyConfig['settings']['integrations']['smtp'],
- })}
- />
-
-
- )?.smtp as Record)?.port as number ?? 587}
- onChange={(e) => handleSettingChange('integrations', {
- smtp: {
- ...((workingConfig?.settings.integrations as Record)?.smtp as Record ?? {}),
- port: Number(e.target.value),
- } as EmberlyConfig['settings']['integrations']['smtp'],
- })}
- />
-
-
- )?.smtp as Record)?.secure as boolean ?? false}
- onCheckedChange={(checked) => handleSettingChange('integrations', {
- smtp: {
- ...((workingConfig?.settings.integrations as Record)?.smtp as Record ?? {}),
- secure: checked,
- } as EmberlyConfig['settings']['integrations']['smtp'],
- })}
- />
-
-
- )?.smtp as Record)?.user as string ?? ''}
- onChange={(e) => handleSettingChange('integrations', {
- smtp: {
- ...((workingConfig?.settings.integrations as Record)?.smtp as Record ?? {}),
- user: e.target.value,
- } as EmberlyConfig['settings']['integrations']['smtp'],
- })}
- />
-
-
- )?.smtp as Record)?.password as string ?? ''}
- onChange={(e) => handleSettingChange('integrations', {
- smtp: {
- ...((workingConfig?.settings.integrations as Record)?.smtp as Record ?? {}),
- password: e.target.value,
- } as EmberlyConfig['settings']['integrations']['smtp'],
- })}
- />
-
-
- )?.smtp as Record)?.from as string ?? ''}
- onChange={(e) => handleSettingChange('integrations', {
- smtp: {
- ...((workingConfig?.settings.integrations as Record)?.smtp as Record ?? {}),
- from: e.target.value,
- } as EmberlyConfig['settings']['integrations']['smtp'],
- })}
- />
-
-
-
- {intTestStates['smtp'] && !intTestStates['smtp'].loading && (
-
-
- {intTestStates['smtp'].ok ? : }
- {intTestStates['smtp'].message}
-
- {intTestStates['smtp'].detail && (
-
{intTestStates['smtp'].detail}
- )}
-
- )}
-
-
-
- >
- )}
-
- >
- )
- })()}
-
- {/* Cloudflare */}
-
- Modified
-
- )
- }
- >
-
- handleSettingChange('integrations', {
- cloudflare: {
- ...workingConfig?.settings.integrations?.cloudflare,
- apiToken: e.target.value,
- },
- })}
- />
-
-
- handleSettingChange('integrations', {
- cloudflare: {
- ...workingConfig?.settings.integrations?.cloudflare,
- accountId: e.target.value,
- },
- })}
- />
-
-
- handleSettingChange('integrations', {
- cloudflare: {
- ...workingConfig?.settings.integrations?.cloudflare,
- zoneId: e.target.value,
- },
- })}
- />
-
-
-
- {intTestStates['cloudflare'] && !intTestStates['cloudflare'].loading && (
-
- {intTestStates['cloudflare'].ok ? : }
- {intTestStates['cloudflare'].message}
-
- )}
-
-
-
-
-
- {/* Discord */}
-
- Modified
-
- )
- }
- >
-
- handleSettingChange('integrations', {
- discord: {
- ...workingConfig?.settings.integrations?.discord,
- webhookUrl: e.target.value,
- },
- })}
- />
-
-
- handleSettingChange('integrations', {
- discord: {
- ...workingConfig?.settings.integrations?.discord,
- botToken: e.target.value,
- },
- })}
- />
-
-
- handleSettingChange('integrations', {
- discord: {
- ...workingConfig?.settings.integrations?.discord,
- serverId: e.target.value,
- },
- })}
- />
-
-
- handleSettingChange('integrations', {
- discord: {
- ...workingConfig?.settings.integrations?.discord,
- supporterRole: e.target.value,
- },
- })}
- />
-
-
-
- {intTestStates['discord'] && !intTestStates['discord'].loading && (
-
- {intTestStates['discord'].ok ? : }
- {intTestStates['discord'].message}
-
- )}
-
-
-
-
-
- {/* GitHub */}
-
- Modified
-
- )
- }
- >
-
- handleSettingChange('integrations', {
- github: {
- ...workingConfig?.settings.integrations?.github,
- org: e.target.value,
- },
- })}
- />
-
-
- handleSettingChange('integrations', {
- github: {
- ...workingConfig?.settings.integrations?.github,
- pat: e.target.value,
- },
- })}
- />
-
-
-
- {intTestStates['github'] && !intTestStates['github'].loading && (
-
- {intTestStates['github'].ok ? : }
- {intTestStates['github'].message}
-
- )}
-
-
-
-
-
- {/* Kener */}
-
- Modified
-
- )
- }
- >
-
- handleSettingChange('integrations', {
- kener: {
- ...(workingConfig?.settings.integrations as any)?.kener,
- apiKey: e.target.value,
- },
- } as any)}
- />
-
-
- handleSettingChange('integrations', {
- kener: {
- ...(workingConfig?.settings.integrations as any)?.kener,
- baseUrl: e.target.value,
- },
- } as any)}
- />
-
-
-
- {intTestStates['kener'] && !intTestStates['kener'].loading && (
-
- {intTestStates['kener'].ok ? : }
- {intTestStates['kener'].message}
-
- )}
-
-
-
-
- {/* Vultr */}
-
- Modified
-
- )
- }
- >
-
- handleSettingChange('integrations', {
- vultr: {
- ...(workingConfig?.settings.integrations as any)?.vultr,
- apiKey: e.target.value,
- },
- } as any)}
- />
-
-
-
- {intTestStates['vultr'] && !intTestStates['vultr'].loading && (
-
- {intTestStates['vultr'].ok ? : }
- {intTestStates['vultr'].message}
-
- )}
-
-
-
-
-
-
-
- {/* Floating Save Bar */}
- {hasChanges && isSuperAdmin && (
-
-
-
-
-
-
-
-
-
-
- {countChangedSettings()}
-
- {countChangedSettings() === 1 ? 'section' : 'sections'} modified
-
-
-
-
-
-
-
-
-
- )}
-
- )
+ const { toast } = useToast()
+ const { data: session } = useSession()
+ const isSuperAdmin = session?.user?.role === 'SUPERADMIN'
+
+ const [savedConfig, setSavedConfig] = useState(null)
+ const [workingConfig, setWorkingConfig] = useState(null)
+ const [pendingFaviconFile, setPendingFaviconFile] = useState(
+ null
+ )
+ const [faviconPreviewUrl, setFaviconPreviewUrl] = useState(
+ null
+ )
+
+ const [cssEditorOpen, setCssEditorOpen] = useState(false)
+ const [htmlEditorOpen, setHtmlEditorOpen] = useState(false)
+ const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
+ const [updateInfo, setUpdateInfo] = useState<{
+ hasUpdate: boolean
+ latestVersion?: string
+ releaseUrl?: string
+ } | null>(null)
+ const [isSaving, setIsSaving] = useState(false)
+ const [intTestStates, setIntTestStates] = useState<
+ Record<
+ string,
+ { loading: boolean; ok?: boolean; message?: string; detail?: string }
+ >
+ >({})
+ const [s3TestState, setS3TestState] = useState<{
+ loading: boolean
+ ok?: boolean
+ message?: string
+ } | null>(null)
+
+ const handleIntegrationTest = async (
+ integration: string,
+ credentials: Record
+ ) => {
+ setIntTestStates((s) => ({ ...s, [integration]: { loading: true } }))
+ try {
+ const res = await fetch('/api/admin/integrations/test', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ integration, credentials }),
+ })
+ const data = await res.json()
+ setIntTestStates((s) => ({
+ ...s,
+ [integration]: {
+ loading: false,
+ ok: data?.data?.ok,
+ message: data?.data?.message,
+ detail: data?.data?.detail,
+ },
+ }))
+ } catch {
+ setIntTestStates((s) => ({
+ ...s,
+ [integration]: { loading: false, ok: false, message: 'Request failed' },
+ }))
+ }
+ }
+
+ const handleS3Test = async () => {
+ if (!workingConfig) return
+ setS3TestState({ loading: true })
+ const s3 = workingConfig.settings.general.storage.s3
+ try {
+ const res = await fetch('/api/admin/storage/test', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ bucket: s3?.bucket,
+ region: s3?.region,
+ accessKeyId: s3?.accessKeyId,
+ secretAccessKey: s3?.secretAccessKey,
+ endpoint: s3?.endpoint,
+ forcePathStyle: s3?.forcePathStyle,
+ }),
+ })
+ const data = await res.json()
+ setS3TestState({
+ loading: false,
+ ok: data?.data?.ok,
+ message: data?.data?.message,
+ })
+ } catch {
+ setS3TestState({ loading: false, ok: false, message: 'Request failed' })
+ }
+ }
+
+ const hasChanges =
+ !deepEqual(savedConfig, workingConfig) || pendingFaviconFile !== null
+
+ const saveChanges = async () => {
+ if (!workingConfig) return
+
+ try {
+ setIsSaving(true)
+
+ if (pendingFaviconFile) {
+ const formData = new FormData()
+ formData.append('file', pendingFaviconFile)
+
+ const response = await fetch('/api/settings/favicon', {
+ method: 'POST',
+ body: formData,
+ })
+
+ if (!response.ok) {
+ throw new Error('Failed to upload favicon')
+ }
+
+ const newConfig = { ...workingConfig }
+ newConfig.settings.appearance.favicon = '/api/favicon'
+ setWorkingConfig(newConfig)
+
+ const link = document.querySelector(
+ "link[rel*='icon']"
+ ) as HTMLLinkElement
+ if (link) {
+ link.href = '/api/favicon'
+ link.type = 'image/png'
+ }
+
+ setPendingFaviconFile(null)
+ if (faviconPreviewUrl) {
+ URL.revokeObjectURL(faviconPreviewUrl)
+ setFaviconPreviewUrl(null)
+ }
+ }
+
+ const response = await fetch('/api/settings', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(workingConfig),
+ })
+
+ if (!response.ok) throw new Error()
+
+ setSavedConfig(JSON.parse(JSON.stringify(workingConfig)))
+
+ toast({
+ title: 'Settings updated',
+ description: 'Your changes have been saved successfully',
+ })
+ } catch (error) {
+ console.error('Failed to update settings:', error)
+ toast({
+ title: 'Failed to update settings',
+ description: 'Please try again',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ const discardChanges = () => {
+ if (!savedConfig) return
+
+ if (faviconPreviewUrl) {
+ URL.revokeObjectURL(faviconPreviewUrl)
+ setFaviconPreviewUrl(null)
+ }
+
+ setPendingFaviconFile(null)
+
+ setWorkingConfig(JSON.parse(JSON.stringify(savedConfig)))
+
+ toast({
+ title: 'Changes discarded',
+ description: 'All changes have been reverted to the saved state',
+ })
+ }
+
+ const handleSettingChange = useCallback(
+ (
+ section: T,
+ value: SettingValue
+ ) => {
+ if (!workingConfig) return
+
+ const newConfig = { ...workingConfig }
+ newConfig.settings[section] = {
+ ...((newConfig.settings[section] as Record) ?? {}),
+ ...(value as Record),
+ } as EmberlyConfig['settings'][T]
+ setWorkingConfig(newConfig)
+ },
+ [workingConfig]
+ )
+
+ const isFieldChanged = useCallback(
+ (
+ section: T,
+ fieldPath: string[]
+ ): boolean => {
+ if (!savedConfig || !workingConfig) return false
+
+ let savedValue: unknown = savedConfig.settings[section]
+ let workingValue: unknown = workingConfig.settings[section]
+
+ for (const field of fieldPath) {
+ if (
+ typeof savedValue !== 'object' ||
+ savedValue === null ||
+ typeof workingValue !== 'object' ||
+ workingValue === null
+ ) {
+ return false
+ }
+
+ savedValue = (savedValue as Record)[field]
+ workingValue = (workingValue as Record)[field]
+ }
+
+ return !deepEqual(savedValue, workingValue)
+ },
+ [savedConfig, workingConfig]
+ )
+
+ const countChangedSettings = useCallback((): number => {
+ if (!savedConfig || !workingConfig) return 0
+
+ let count = 0
+
+ const { storage: _cs1, ...savedGen } = savedConfig.settings.general
+ const { storage: _cs2, ...workingGen } = workingConfig.settings.general
+ if (!deepEqual(savedGen, workingGen)) {
+ count++
+ }
+
+ if (!deepEqual(_cs1, _cs2)) {
+ count++
+ }
+
+ if (
+ !deepEqual(
+ savedConfig.settings.appearance,
+ workingConfig.settings.appearance
+ )
+ ) {
+ count++
+ }
+
+ if (
+ !deepEqual(savedConfig.settings.advanced, workingConfig.settings.advanced)
+ ) {
+ count++
+ }
+
+ if (
+ !deepEqual(
+ savedConfig.settings.integrations,
+ workingConfig.settings.integrations
+ )
+ ) {
+ count++
+ }
+
+ return count
+ }, [savedConfig, workingConfig])
+
+ const getChangedSettingsGroups = useCallback((): string[] => {
+ if (!savedConfig || !workingConfig) return []
+
+ const changedGroups: string[] = []
+
+ const { storage: _cg1, ...savedGen } = savedConfig.settings.general
+ const { storage: _cg2, ...workingGen } = workingConfig.settings.general
+ if (!deepEqual(savedGen, workingGen)) {
+ changedGroups.push('General')
+ }
+
+ if (!deepEqual(_cg1, _cg2)) {
+ changedGroups.push('Storage')
+ }
+
+ if (
+ !deepEqual(
+ savedConfig.settings.appearance,
+ workingConfig.settings.appearance
+ )
+ ) {
+ changedGroups.push('Appearance')
+ }
+
+ if (
+ !deepEqual(savedConfig.settings.advanced, workingConfig.settings.advanced)
+ ) {
+ changedGroups.push('Advanced')
+ }
+
+ if (
+ !deepEqual(
+ savedConfig.settings.integrations,
+ workingConfig.settings.integrations
+ )
+ ) {
+ changedGroups.push('Integrations')
+ }
+
+ return changedGroups
+ }, [savedConfig, workingConfig])
+
+ useEffect(() => {
+ const loadConfig = async () => {
+ try {
+ const response = await fetch('/api/settings')
+ const responseJson = await response.json()
+ if (responseJson?.data) {
+ const actualConfigData = responseJson.data
+ setSavedConfig(actualConfigData)
+ setWorkingConfig(JSON.parse(JSON.stringify(actualConfigData)))
+ } else {
+ console.error(
+ 'Failed to load config: Invalid data structure received',
+ responseJson
+ )
+ }
+ } catch (error) {
+ console.error('Failed to load config:', error)
+ }
+ }
+ loadConfig()
+ }, [])
+
+ const handleStorageQuotaChange = (value: string) => {
+ if (!workingConfig?.settings?.general?.storage) return
+ const numValue = Number.parseInt(value)
+ if (Number.isNaN(numValue) || numValue < 0) return
+
+ handleSettingChange('general', {
+ storage: {
+ ...workingConfig.settings.general.storage,
+ quotas: {
+ ...workingConfig.settings.general.storage.quotas,
+ default: {
+ ...workingConfig.settings.general.storage.quotas.default,
+ value: numValue,
+ },
+ },
+ },
+ })
+ }
+
+ const handleMaxUploadSizeChange = (value: string) => {
+ if (!workingConfig?.settings?.general?.storage) return
+ const numValue = Number.parseInt(value)
+ if (Number.isNaN(numValue) || numValue < 1) return
+
+ handleSettingChange('general', {
+ storage: {
+ ...workingConfig.settings.general.storage,
+ maxUploadSize: {
+ ...workingConfig.settings.general.storage.maxUploadSize,
+ value: numValue,
+ },
+ },
+ })
+ }
+
+ const handleCustomColorsChange = (colors: Partial) => {
+ handleSettingChange('appearance', {
+ customColors: colors,
+ })
+ }
+
+ const handleThemePresetChange = (
+ themeId: string,
+ backgroundEffect: string,
+ animationSpeed: string
+ ) => {
+ handleSettingChange('appearance', {
+ theme: themeId,
+ backgroundEffect:
+ backgroundEffect as EmberlyConfig['settings']['appearance']['backgroundEffect'],
+ animationSpeed: animationSpeed as AnimationSpeed,
+ })
+ }
+
+ /**
+ * Save system/admin appearance directly to /api/settings (PATCH).
+ * Used as the onSave override for AppearancePanel in admin context so it
+ * does NOT call PUT /api/profile like the user-facing flow.
+ */
+ const handleAdminSaveAppearance = async (
+ themeId: string,
+ colors: Record
+ ): Promise => {
+ try {
+ const response = await fetch('/api/settings', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ section: 'appearance',
+ data: {
+ theme: themeId,
+ customColors: colors,
+ },
+ }),
+ })
+ if (!response.ok) throw new Error('Failed to save appearance')
+ const updatedConfig: EmberlyConfig = await response.json()
+ setSavedConfig(JSON.parse(JSON.stringify(updatedConfig)))
+ setWorkingConfig(JSON.parse(JSON.stringify(updatedConfig)))
+ return true
+ } catch (error) {
+ console.error('[SettingsManager] Failed to save appearance', error)
+ return false
+ }
+ }
+
+ /**
+ * Track admin theme selections so the "Modified" badge reflects appearance changes
+ * while the admin is previewing (before saving).
+ */
+ const handleAdminThemeChange = useCallback(
+ (
+ themeId: string,
+ colors: Record,
+ meta?: { backgroundEffect?: string; animationSpeed?: string }
+ ) => {
+ handleSettingChange('appearance', {
+ theme: themeId,
+ backgroundEffect: (meta?.backgroundEffect ||
+ 'none') as EmberlyConfig['settings']['appearance']['backgroundEffect'],
+ animationSpeed: (meta?.animationSpeed || 'medium') as AnimationSpeed,
+ customColors: colors,
+ })
+ },
+ [handleSettingChange]
+ )
+
+ const checkForUpdates = async () => {
+ try {
+ setIsCheckingUpdate(true)
+ const response = await fetch('/api/updates/check')
+ if (!response.ok) throw new Error()
+ const data = await response.json()
+ setUpdateInfo(data)
+
+ toast({
+ title: data.hasUpdate ? 'Update Available' : 'No Updates Available',
+ description: data.message,
+ variant: 'default',
+ })
+ } catch {
+ toast({
+ title: 'Failed to check for updates',
+ description: 'Please try again later',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsCheckingUpdate(false)
+ }
+ }
+
+ const hasFaviconChanged = useCallback(() => {
+ return pendingFaviconFile !== null
+ }, [pendingFaviconFile])
+
+ if (
+ !workingConfig ||
+ !savedConfig ||
+ !workingConfig.settings ||
+ !savedConfig.settings ||
+ !workingConfig.settings.general ||
+ !savedConfig.settings.general ||
+ !workingConfig.settings.general.storage ||
+ !savedConfig.settings.general.storage ||
+ !workingConfig.settings.appearance ||
+ !savedConfig.settings.appearance ||
+ !workingConfig.settings.advanced ||
+ !savedConfig.settings.advanced
+ ) {
+ return
+ }
+
+ const getFieldClasses = (
+ section: keyof EmberlyConfig['settings'],
+ fieldPath: string[]
+ ) => {
+ const isChanged = isFieldChanged(section, fieldPath)
+ return isChanged
+ ? 'border-primary/50 ring-1 ring-primary/30 bg-primary/5 transition-all'
+ : 'transition-all'
+ }
+
+ const ChangeIndicator = () => (
+
+
+
+
+ )
+
+ const generalHasChanges = (() => {
+ if (!savedConfig || !workingConfig) return false
+ const { storage: _sg1, ...savedGen } = savedConfig.settings.general
+ const { storage: _sg2, ...workingGen } = workingConfig.settings.general
+ return !deepEqual(savedGen, workingGen)
+ })()
+ const storageHasChanges = !deepEqual(
+ savedConfig?.settings.general?.storage,
+ workingConfig?.settings.general?.storage
+ )
+ const appearanceHasChanges = !deepEqual(
+ savedConfig?.settings.appearance,
+ workingConfig?.settings.appearance
+ )
+ const advancedHasChanges = !deepEqual(
+ savedConfig?.settings.advanced,
+ workingConfig?.settings.advanced
+ )
+ const integrationsHasChanges = !deepEqual(
+ savedConfig?.settings.integrations,
+ workingConfig?.settings.integrations
+ )
+
+ return (
+
+ {/* Page Header - Removed as it's now in the parent page */}
+
+ {/* Admin read-only notice */}
+ {!isSuperAdmin && (
+
+
+
+ Read-only mode. You can view and test settings, but
+ saving changes and viewing secret keys requires{' '}
+ Super Administrator access.
+
+
+ )}
+
+ {/* Main Content */}
+
+ {/* Improved Tab Navigation */}
+
+
+
+
+ General
+ {generalHasChanges && (
+
+
+
+
+ )}
+
+
+
+ Storage
+ {storageHasChanges && (
+
+
+
+
+ )}
+
+
+
+ Integrations
+ {integrationsHasChanges && (
+
+
+
+
+ )}
+
+
+
+ Appearance
+ {appearanceHasChanges && (
+
+
+
+
+ )}
+
+
+
+ Advanced
+ {advancedHasChanges && (
+
+
+
+
+ )}
+
+
+
+
+ {/* General Settings Tab */}
+
+ {/* Instance Information */}
+
+
+ Update available
+
+ )
+ }
+ >
+
+
+ {updateInfo?.hasUpdate && (
+
+ )}
+
+
+
+
+
+
+
+ {/* User Management */}
+
+
+
+ handleSettingChange('general', {
+ registrations: {
+ ...workingConfig.settings.general.registrations,
+ enabled: checked,
+ },
+ })
+ }
+ className={getFieldClasses('general', [
+ 'registrations',
+ 'enabled',
+ ])}
+ />
+
+
+ {!workingConfig.settings.general.registrations.enabled && (
+
+
+
+ handleSettingChange('general', {
+ registrations: {
+ ...workingConfig.settings.general.registrations,
+ disabledMessage: e.target.value,
+ },
+ })
+ }
+ className={cn(
+ 'max-w-md',
+ getFieldClasses('general', [
+ 'registrations',
+ 'disabledMessage',
+ ])
+ )}
+ />
+
+ Shown to users when registrations are disabled
+
+
+ )}
+
+
+ {/* Credits */}
+
+
+
+ handleSettingChange('general', {
+ credits: { showFooter: checked },
+ })
+ }
+ className={getFieldClasses('general', [
+ 'credits',
+ 'showFooter',
+ ])}
+ />
+
+
+ {!workingConfig.settings.general.credits.showFooter && (
+
+
+
+ If you disable credits, please consider{' '}
+
+ sponsoring the project
+ {' '}
+ to support its development.
+
+
+ )}
+
+
+
+ {/* Storage Tab */}
+
+ {/* User Quotas */}
+
+
+
+ handleSettingChange('general', {
+ storage: {
+ ...workingConfig.settings.general.storage,
+ quotas: {
+ ...workingConfig.settings.general.storage.quotas,
+ enabled: checked,
+ },
+ },
+ })
+ }
+ className={getFieldClasses('general', [
+ 'storage',
+ 'quotas',
+ 'enabled',
+ ])}
+ />
+
+
+
+
+
+ {isFieldChanged('general', [
+ 'storage',
+ 'quotas',
+ 'default',
+ 'value',
+ ]) && }
+
+
+ handleStorageQuotaChange(e.target.value)}
+ placeholder="500"
+ className={cn(
+ 'flex-1',
+ getFieldClasses('general', [
+ 'storage',
+ 'quotas',
+ 'default',
+ 'value',
+ ])
+ )}
+ />
+
+
+
+
+
+ {/* Storage Provider */}
+
+
+
+ handleSettingChange('general', {
+ ocr: { enabled: checked },
+ })
+ }
+ className={getFieldClasses('general', ['ocr', 'enabled'])}
+ />
+
+
+
+
+
+ {isFieldChanged('general', ['storage', 'provider']) && (
+
+ )}
+
+
+
+
+ {workingConfig.settings.general.storage.provider === 's3' && (
+
+
+
+
+ S3 Configuration
+
+
+
+
+
+
+ handleSettingChange('general', {
+ storage: {
+ ...workingConfig.settings.general.storage,
+ s3: {
+ ...workingConfig.settings.general.storage.s3,
+ bucket: e.target.value,
+ },
+ },
+ })
+ }
+ placeholder="my-bucket"
+ className={getFieldClasses('general', [
+ 'storage',
+ 's3',
+ 'bucket',
+ ])}
+ />
+
+
+
+
+ handleSettingChange('general', {
+ storage: {
+ ...workingConfig.settings.general.storage,
+ s3: {
+ ...workingConfig.settings.general.storage.s3,
+ region: e.target.value,
+ },
+ },
+ })
+ }
+ placeholder="us-east-1"
+ className={getFieldClasses('general', [
+ 'storage',
+ 's3',
+ 'region',
+ ])}
+ />
+
+
+
+
+ handleSettingChange('general', {
+ storage: {
+ ...workingConfig.settings.general.storage,
+ s3: {
+ ...workingConfig.settings.general.storage.s3,
+ accessKeyId: e.target.value,
+ },
+ },
+ })
+ }
+ placeholder="AKIAXXXXXXXXXXXXXXXX"
+ className={getFieldClasses('general', [
+ 'storage',
+ 's3',
+ 'accessKeyId',
+ ])}
+ />
+
+
+
+
+ handleSettingChange('general', {
+ storage: {
+ ...workingConfig.settings.general.storage,
+ s3: {
+ ...workingConfig.settings.general.storage.s3,
+ secretAccessKey: e.target.value,
+ },
+ },
+ })
+ }
+ placeholder="••••••••••••••••••••"
+ className={getFieldClasses('general', [
+ 'storage',
+ 's3',
+ 'secretAccessKey',
+ ])}
+ />
+
+
+
+
+
+
+ handleSettingChange('general', {
+ storage: {
+ ...workingConfig.settings.general.storage,
+ s3: {
+ ...workingConfig.settings.general.storage.s3,
+ endpoint: e.target.value,
+ },
+ },
+ })
+ }
+ placeholder="https://s3.custom-domain.com"
+ className={getFieldClasses('general', [
+ 'storage',
+ 's3',
+ 'endpoint',
+ ])}
+ />
+
+ For S3-compatible services like MinIO or DigitalOcean
+ Spaces
+
+
+
+
+
+ handleSettingChange('general', {
+ storage: {
+ ...workingConfig.settings.general.storage,
+ s3: {
+ ...workingConfig.settings.general.storage.s3,
+ forcePathStyle: checked,
+ },
+ },
+ })
+ }
+ className={getFieldClasses('general', [
+ 'storage',
+ 's3',
+ 'forcePathStyle',
+ ])}
+ />
+
+
+
+
+ {s3TestState && !s3TestState.loading && (
+
+ {s3TestState.ok ? (
+
+ ) : (
+
+ )}
+ {s3TestState.message}
+
+ )}
+
+
+
+
+
+ )}
+
+
+
+
+ {isFieldChanged('general', [
+ 'storage',
+ 'maxUploadSize',
+ 'value',
+ ]) && }
+
+
+ handleMaxUploadSizeChange(e.target.value)}
+ placeholder="10"
+ className={cn(
+ 'flex-1',
+ getFieldClasses('general', [
+ 'storage',
+ 'maxUploadSize',
+ 'value',
+ ])
+ )}
+ />
+
+
+
+
+
+ {/* Vultr Object Storage Pools */}
+
+
+
+
+ {/* Additional Storage Buckets */}
+
+
+
+
+
+ {/* Appearance Tab */}
+
+ {/* Theme Colors */}
+
+ Modified
+
+ )
+ }
+ >
+
+
+
+ {/* Favicon */}
+
+ Unsaved
+
+ )
+ }
+ >
+
+
+
+
+
+
+ {/* Advanced Tab */}
+
+ {/* Custom CSS */}
+
+ Modified
+
+ )
+ }
+ >
+
+
+
+ Custom CSS will be injected into every page
+
+
+
+ {cssEditorOpen && (
+
+
+ {
+ handleSettingChange('advanced', {
+ customCSS: value,
+ })
+ }}
+ theme="dark"
+ className="rounded-lg overflow-hidden text-sm"
+ />
+
+
+ )}
+
+
+
+ {/* Custom HTML */}
+
+ Modified
+
+ )
+ }
+ >
+
+
+
+ Add scripts, meta tags, or other HTML elements
+
+
+
+ {htmlEditorOpen && (
+
+
+ {
+ handleSettingChange('advanced', {
+ customHead: value,
+ })
+ }}
+ theme="dark"
+ className="rounded-lg overflow-hidden text-sm"
+ />
+
+
+ )}
+
+
+
+
+ {/* Integrations Tab */}
+
+
+
+ {/* Stripe */}
+
+ Modified
+
+ )
+ }
+ >
+
+
+ handleSettingChange('integrations', {
+ stripe: {
+ ...workingConfig?.settings.integrations?.stripe,
+ secretKey: e.target.value,
+ },
+ })
+ }
+ />
+
+
+
+ handleSettingChange('integrations', {
+ stripe: {
+ ...workingConfig?.settings.integrations?.stripe,
+ webhookSecret: e.target.value,
+ },
+ })
+ }
+ />
+
+
+
+ {intTestStates['stripe'] &&
+ !intTestStates['stripe'].loading && (
+
+ {intTestStates['stripe'].ok ? (
+
+ ) : (
+
+ )}
+ {intTestStates['stripe'].message}
+
+ )}
+
+
+
+
+
+ {/* Resend / SMTP */}
+ {(() => {
+ const emailProvider =
+ ((workingConfig?.settings.integrations as Record)
+ ?.emailProvider as string) ?? 'resend'
+ return (
+ <>
+
+ Modified
+
+ )
+ }
+ >
+
+
+
+
+ {emailProvider === 'resend' && (
+ <>
+
+
+ handleSettingChange('integrations', {
+ resend: {
+ ...workingConfig?.settings.integrations?.resend,
+ apiKey: e.target.value,
+ },
+ })
+ }
+ />
+
+
+
+ handleSettingChange('integrations', {
+ resend: {
+ ...workingConfig?.settings.integrations?.resend,
+ emailFrom: e.target.value,
+ },
+ })
+ }
+ />
+
+
+
+ {intTestStates['resend'] &&
+ !intTestStates['resend'].loading && (
+
+ {intTestStates['resend'].ok ? (
+
+ ) : (
+
+ )}
+ {intTestStates['resend'].message}
+
+ )}
+
+
+
+ >
+ )}
+
+ {emailProvider === 'smtp' && (
+ <>
+
+
+ )?.smtp as Record
+ )?.host as string) ?? ''
+ }
+ onChange={(e) =>
+ handleSettingChange('integrations', {
+ smtp: {
+ ...(((
+ workingConfig?.settings
+ .integrations as Record
+ )?.smtp as Record) ?? {}),
+ host: e.target.value,
+ } as EmberlyConfig['settings']['integrations']['smtp'],
+ })
+ }
+ />
+
+
+
+ )?.smtp as Record
+ )?.port as number) ?? 587
+ }
+ onChange={(e) =>
+ handleSettingChange('integrations', {
+ smtp: {
+ ...(((
+ workingConfig?.settings
+ .integrations as Record
+ )?.smtp as Record) ?? {}),
+ port: Number(e.target.value),
+ } as EmberlyConfig['settings']['integrations']['smtp'],
+ })
+ }
+ />
+
+
+
+ )?.smtp as Record
+ )?.secure as boolean) ?? false
+ }
+ onCheckedChange={(checked) =>
+ handleSettingChange('integrations', {
+ smtp: {
+ ...(((
+ workingConfig?.settings
+ .integrations as Record
+ )?.smtp as Record) ?? {}),
+ secure: checked,
+ } as EmberlyConfig['settings']['integrations']['smtp'],
+ })
+ }
+ />
+
+
+
+ )?.smtp as Record
+ )?.user as string) ?? ''
+ }
+ onChange={(e) =>
+ handleSettingChange('integrations', {
+ smtp: {
+ ...(((
+ workingConfig?.settings
+ .integrations as Record
+ )?.smtp as Record) ?? {}),
+ user: e.target.value,
+ } as EmberlyConfig['settings']['integrations']['smtp'],
+ })
+ }
+ />
+
+
+
+ )?.smtp as Record
+ )?.password as string) ?? ''
+ }
+ onChange={(e) =>
+ handleSettingChange('integrations', {
+ smtp: {
+ ...(((
+ workingConfig?.settings
+ .integrations as Record
+ )?.smtp as Record) ?? {}),
+ password: e.target.value,
+ } as EmberlyConfig['settings']['integrations']['smtp'],
+ })
+ }
+ />
+
+
+
+ )?.smtp as Record
+ )?.from as string) ?? ''
+ }
+ onChange={(e) =>
+ handleSettingChange('integrations', {
+ smtp: {
+ ...(((
+ workingConfig?.settings
+ .integrations as Record
+ )?.smtp as Record) ?? {}),
+ from: e.target.value,
+ } as EmberlyConfig['settings']['integrations']['smtp'],
+ })
+ }
+ />
+
+
+
+ {intTestStates['smtp'] &&
+ !intTestStates['smtp'].loading && (
+
+
+ {intTestStates['smtp'].ok ? (
+
+ ) : (
+
+ )}
+ {intTestStates['smtp'].message}
+
+ {intTestStates['smtp'].detail && (
+
+ {intTestStates['smtp'].detail}
+
+ )}
+
+ )}
+
+
+
+ >
+ )}
+
+ >
+ )
+ })()}
+
+ {/* Cloudflare */}
+
+ Modified
+
+ )
+ }
+ >
+
+
+ handleSettingChange('integrations', {
+ cloudflare: {
+ ...workingConfig?.settings.integrations?.cloudflare,
+ apiToken: e.target.value,
+ },
+ })
+ }
+ />
+
+
+
+ handleSettingChange('integrations', {
+ cloudflare: {
+ ...workingConfig?.settings.integrations?.cloudflare,
+ accountId: e.target.value,
+ },
+ })
+ }
+ />
+
+
+
+ handleSettingChange('integrations', {
+ cloudflare: {
+ ...workingConfig?.settings.integrations?.cloudflare,
+ zoneId: e.target.value,
+ },
+ })
+ }
+ />
+
+
+
+ {intTestStates['cloudflare'] &&
+ !intTestStates['cloudflare'].loading && (
+
+ {intTestStates['cloudflare'].ok ? (
+
+ ) : (
+
+ )}
+ {intTestStates['cloudflare'].message}
+
+ )}
+
+
+
+
+
+ {/* Discord */}
+
+ Modified
+
+ )
+ }
+ >
+
+
+ handleSettingChange('integrations', {
+ discord: {
+ ...workingConfig?.settings.integrations?.discord,
+ webhookUrl: e.target.value,
+ },
+ })
+ }
+ />
+
+
+
+ handleSettingChange('integrations', {
+ discord: {
+ ...workingConfig?.settings.integrations?.discord,
+ botToken: e.target.value,
+ },
+ })
+ }
+ />
+
+
+
+ handleSettingChange('integrations', {
+ discord: {
+ ...workingConfig?.settings.integrations?.discord,
+ serverId: e.target.value,
+ },
+ })
+ }
+ />
+
+
+
+ handleSettingChange('integrations', {
+ discord: {
+ ...workingConfig?.settings.integrations?.discord,
+ supporterRole: e.target.value,
+ },
+ })
+ }
+ />
+
+
+
+ {intTestStates['discord'] &&
+ !intTestStates['discord'].loading && (
+
+ {intTestStates['discord'].ok ? (
+
+ ) : (
+
+ )}
+ {intTestStates['discord'].message}
+
+ )}
+
+
+
+
+
+ {/* GitHub */}
+
+ Modified
+
+ )
+ }
+ >
+
+
+ handleSettingChange('integrations', {
+ github: {
+ ...workingConfig?.settings.integrations?.github,
+ org: e.target.value,
+ },
+ })
+ }
+ />
+
+
+
+ handleSettingChange('integrations', {
+ github: {
+ ...workingConfig?.settings.integrations?.github,
+ pat: e.target.value,
+ },
+ })
+ }
+ />
+
+
+
+ {intTestStates['github'] &&
+ !intTestStates['github'].loading && (
+
+ {intTestStates['github'].ok ? (
+
+ ) : (
+
+ )}
+ {intTestStates['github'].message}
+
+ )}
+
+
+
+
+
+ {/* Vultr */}
+
+ Modified
+
+ )
+ }
+ >
+
+
+ handleSettingChange('integrations', {
+ vultr: {
+ ...(workingConfig?.settings.integrations as any)?.vultr,
+ apiKey: e.target.value,
+ },
+ } as any)
+ }
+ />
+
+
+
+ {intTestStates['vultr'] && !intTestStates['vultr'].loading && (
+
+ {intTestStates['vultr'].ok ? (
+
+ ) : (
+
+ )}
+ {intTestStates['vultr'].message}
+
+ )}
+
+
+
+
+
+
+
+ {/* Floating Save Bar */}
+ {hasChanges && isSuperAdmin && (
+
+
+
+
+
+
+
+
+
+
+ {countChangedSettings()}
+
+ {countChangedSettings() === 1 ? 'section' : 'sections'}{' '}
+ modified
+
+
+
+
+
+
+
+
+
+ )}
+
+ )
}
diff --git a/packages/components/layout/StatusIndicator.tsx b/packages/components/layout/StatusIndicator.tsx
index da4d99f..d93eb45 100644
--- a/packages/components/layout/StatusIndicator.tsx
+++ b/packages/components/layout/StatusIndicator.tsx
@@ -1,75 +1,17 @@
-"use client"
+'use client'
-import React, { useEffect, useState } from 'react'
-
-type KenerStatus = 'UP' | 'DOWN' | 'DEGRADED' | 'UNKNOWN'
-
-type StatusPayload = {
- page: {
- name: string
- url: string
- status: KenerStatus
- }
- activeIncidents: unknown[]
- activeMaintenances: unknown[]
-}
+import React from 'react'
export default function StatusIndicator() {
- const [status, setStatus] = useState(null)
- const [loading, setLoading] = useState(true)
-
- useEffect(() => {
- let mounted = true
- async function fetchStatus() {
- try {
- const res = await fetch('/api/status')
- const j = await res.json()
- if (mounted) setStatus(j?.data ?? j)
- } catch {
- // ignore
- } finally {
- if (mounted) setLoading(false)
- }
- }
- fetchStatus()
- return () => { mounted = false }
- }, [])
-
- if (loading) return Checking status…
- if (!status) return null
-
- const s = status.page?.status
-
- const mapColor = (v?: string) => {
- switch (v) {
- case 'UP': return 'bg-emerald-500'
- case 'DEGRADED': return 'bg-yellow-400'
- case 'DOWN': return 'bg-red-500'
- default: return 'bg-gray-400'
- }
- }
-
- const mapLabel = (v?: string) => {
- switch (v) {
- case 'UP': return 'All systems operational'
- case 'DEGRADED': return 'Degraded performance'
- case 'DOWN': return 'Service disruption'
- default: return 'Status unknown'
- }
- }
-
- return (
-
- )
+ return (
+
+
+ System Status
+
+ )
}
-
diff --git a/packages/components/layout/footer.tsx b/packages/components/layout/footer.tsx
index b001f48..4d1e810 100644
--- a/packages/components/layout/footer.tsx
+++ b/packages/components/layout/footer.tsx
@@ -16,9 +16,6 @@ export function Footer() {
© {new Date().getFullYear()} NodeByte LTD. All rights
reserved.
-
-
-
diff --git a/packages/lib/auth/api-auth.ts b/packages/lib/auth/api-auth.ts
index 43357af..ee198c5 100644
--- a/packages/lib/auth/api-auth.ts
+++ b/packages/lib/auth/api-auth.ts
@@ -17,29 +17,19 @@ export type AuthenticatedUser = {
role: string
randomizeFileUrls: boolean
preferredUploadDomain: string | null
+ emailVerified: boolean
}
-/** A squad authenticated via its upload token or a named API key */
export type AuthenticatedSquad = {
squadId: string
slug: string
ownerUserId: string
- /** Storage used in bytes */
storageUsed: number
storageQuotaMB: number | null
- /** How the request was authenticated */
authMethod: 'upload_token' | 'api_key'
- /** ID of the NexiumSquadApiKey record (null for upload token auth) */
apiKeyId: string | null
}
-/**
- * Try to authenticate the request as a Nexium squad via:
- * 1. Squad upload token (Bearer
)
- * 2. Squad API key (Bearer nsk_…)
- *
- * Returns null if the bearer token doesn't match any squad credential.
- */
export async function getSquadFromBearerToken(
req: Request
): Promise {
@@ -48,8 +38,6 @@ export async function getSquadFromBearerToken(
const token = authHeader.substring(7)
- // ── 1. Squad upload token ────────────────────────────────────────────────
- // Upload tokens are cuid/uuid strings (no prefix), API keys start with nsk_
if (!token.startsWith('nsk_')) {
const squad = await prisma.nexiumSquad.findUnique({
where: { uploadToken: token },
@@ -146,6 +134,7 @@ export async function getAuthenticatedUser(
role: cached.role,
randomizeFileUrls: cached.randomizeFileUrls,
preferredUploadDomain: cached.preferredUploadDomain,
+ emailVerified: cached.emailVerified,
}
}
@@ -163,6 +152,7 @@ export async function getAuthenticatedUser(
preferredUploadDomain: true,
sessionVersion: true,
image: true,
+ emailVerified: true,
},
})
@@ -180,10 +170,11 @@ export async function getAuthenticatedUser(
storageQuotaMB: user.storageQuotaMB,
randomizeFileUrls: user.randomizeFileUrls,
preferredUploadDomain: user.preferredUploadDomain,
+ emailVerified: !!user.emailVerified,
})
}
- return user
+ return user ? { ...user, emailVerified: !!user.emailVerified } : null
}
const authHeader = req.headers.get('authorization')
@@ -208,6 +199,7 @@ export async function getAuthenticatedUser(
role: true,
randomizeFileUrls: true,
preferredUploadDomain: true,
+ emailVerified: true,
},
},
},
@@ -218,7 +210,10 @@ export async function getAuthenticatedUser(
where: { id: keyRecord.id },
data: { lastUsedAt: new Date() },
})
- return keyRecord.user
+ return {
+ ...keyRecord.user,
+ emailVerified: !!keyRecord.user.emailVerified,
+ }
}
// ebk_ prefix but not found — don't fall through to uploadToken lookup
return null
@@ -239,6 +234,7 @@ export async function getAuthenticatedUser(
role: cached.role,
randomizeFileUrls: cached.randomizeFileUrls,
preferredUploadDomain: cached.preferredUploadDomain,
+ emailVerified: cached.emailVerified,
}
}
}
@@ -257,6 +253,7 @@ export async function getAuthenticatedUser(
preferredUploadDomain: true,
sessionVersion: true,
image: true,
+ emailVerified: true,
},
})
@@ -277,10 +274,11 @@ export async function getAuthenticatedUser(
storageQuotaMB: user.storageQuotaMB,
randomizeFileUrls: user.randomizeFileUrls,
preferredUploadDomain: user.preferredUploadDomain,
+ emailVerified: !!user.emailVerified,
})
}
- return user
+ return user ? { ...user, emailVerified: !!user.emailVerified } : null
}
return null
diff --git a/packages/lib/cache/session-cache.ts b/packages/lib/cache/session-cache.ts
index 8e46a08..1c2e897 100644
--- a/packages/lib/cache/session-cache.ts
+++ b/packages/lib/cache/session-cache.ts
@@ -11,181 +11,205 @@ const SESSION_TTL_SECONDS = 5 * 60
const USER_LOOKUP_TTL_SECONDS = 2 * 60
export interface CachedUserSession {
- id: string
- email: string | null
- name: string | null
- role: string
- image: string | null
- sessionVersion: number
- urlId: string
- storageUsed: number
- storageQuotaMB: number | null
- randomizeFileUrls: boolean
- preferredUploadDomain: string | null
+ id: string
+ email: string | null
+ name: string | null
+ role: string
+ image: string | null
+ sessionVersion: number
+ urlId: string
+ storageUsed: number
+ storageQuotaMB: number | null
+ randomizeFileUrls: boolean
+ preferredUploadDomain: string | null
+ emailVerified: boolean
}
/**
* Redis-based session and user cache for auth lookups
*/
export const sessionCache = {
- /**
- * Get cached user session data
- */
- async getUserSession(userId: string): Promise {
- if (!isRedisConnected()) return null
-
- try {
- const redis = await getRedisClient()
- const data = await redis.get(redisKeys.userSession(userId))
- if (!data) return null
- return JSON.parse(data) as CachedUserSession
- } catch (error) {
- logger.error('Failed to get cached user session', error as Error, { userId })
- return null
- }
- },
-
- /**
- * Cache user session data
- */
- async setUserSession(userId: string, session: CachedUserSession): Promise {
- if (!isRedisConnected()) return false
-
- try {
- const redis = await getRedisClient()
- await redis.setEx(
- redisKeys.userSession(userId),
- SESSION_TTL_SECONDS,
- JSON.stringify(session)
- )
- return true
- } catch (error) {
- logger.error('Failed to cache user session', error as Error, { userId })
- return false
- }
- },
-
- /**
- * Invalidate user session cache
- */
- async invalidateUserSession(userId: string): Promise {
- if (!isRedisConnected()) return false
-
- try {
- const redis = await getRedisClient()
- await redis.del(redisKeys.userSession(userId))
- logger.debug('User session cache invalidated', { userId })
- return true
- } catch (error) {
- logger.error('Failed to invalidate user session', error as Error, { userId })
- return false
- }
- },
-
- /**
- * Cache user ID by email for fast lookups
- */
- async setUserByEmail(email: string, userId: string): Promise {
- if (!isRedisConnected()) return false
-
- try {
- const redis = await getRedisClient()
- await redis.setEx(redisKeys.userByEmail(email), USER_LOOKUP_TTL_SECONDS, userId)
- return true
- } catch (error) {
- logger.error('Failed to cache user by email', error as Error, { email })
- return false
- }
- },
-
- /**
- * Get cached user ID by email
- */
- async getUserByEmail(email: string): Promise {
- if (!isRedisConnected()) return null
-
- try {
- const redis = await getRedisClient()
- return await redis.get(redisKeys.userByEmail(email))
- } catch (error) {
- logger.error('Failed to get user by email from cache', error as Error, { email })
- return null
- }
- },
-
- /**
- * Cache user data by upload token
- */
- async setUserByToken(token: string, userId: string): Promise {
- if (!isRedisConnected()) return false
-
- try {
- const redis = await getRedisClient()
- await redis.setEx(redisKeys.userByToken(token), USER_LOOKUP_TTL_SECONDS, userId)
- return true
- } catch (error) {
- logger.error('Failed to cache user by token', error as Error)
- return false
- }
- },
-
- /**
- * Get cached user ID by upload token
- */
- async getUserByToken(token: string): Promise {
- if (!isRedisConnected()) return null
-
- try {
- const redis = await getRedisClient()
- return await redis.get(redisKeys.userByToken(token))
- } catch (error) {
- logger.error('Failed to get user by token from cache', error as Error)
- return null
- }
- },
-
- /**
- * Invalidate user by token cache
- */
- async invalidateUserByToken(token: string): Promise {
- if (!isRedisConnected()) return false
-
- try {
- const redis = await getRedisClient()
- await redis.del(redisKeys.userByToken(token))
- return true
- } catch (error) {
- logger.error('Failed to invalidate user by token cache', error as Error)
- return false
- }
- },
-
- /**
- * Invalidate all caches for a user
- */
- async invalidateAllForUser(userId: string, email?: string, token?: string): Promise {
- await this.invalidateUserSession(userId)
- if (email) {
- await this.invalidateUserByEmail(email)
- }
- if (token) {
- await this.invalidateUserByToken(token)
- }
- },
-
- /**
- * Invalidate user by email cache
- */
- async invalidateUserByEmail(email: string): Promise {
- if (!isRedisConnected()) return false
-
- try {
- const redis = await getRedisClient()
- await redis.del(redisKeys.userByEmail(email))
- return true
- } catch (error) {
- logger.error('Failed to invalidate user by email cache', error as Error, { email })
- return false
- }
- },
+ /**
+ * Get cached user session data
+ */
+ async getUserSession(userId: string): Promise {
+ if (!isRedisConnected()) return null
+
+ try {
+ const redis = await getRedisClient()
+ const data = await redis.get(redisKeys.userSession(userId))
+ if (!data) return null
+ return JSON.parse(data) as CachedUserSession
+ } catch (error) {
+ logger.error('Failed to get cached user session', error as Error, {
+ userId,
+ })
+ return null
+ }
+ },
+
+ /**
+ * Cache user session data
+ */
+ async setUserSession(
+ userId: string,
+ session: CachedUserSession
+ ): Promise {
+ if (!isRedisConnected()) return false
+
+ try {
+ const redis = await getRedisClient()
+ await redis.setEx(
+ redisKeys.userSession(userId),
+ SESSION_TTL_SECONDS,
+ JSON.stringify(session)
+ )
+ return true
+ } catch (error) {
+ logger.error('Failed to cache user session', error as Error, { userId })
+ return false
+ }
+ },
+
+ /**
+ * Invalidate user session cache
+ */
+ async invalidateUserSession(userId: string): Promise {
+ if (!isRedisConnected()) return false
+
+ try {
+ const redis = await getRedisClient()
+ await redis.del(redisKeys.userSession(userId))
+ logger.debug('User session cache invalidated', { userId })
+ return true
+ } catch (error) {
+ logger.error('Failed to invalidate user session', error as Error, {
+ userId,
+ })
+ return false
+ }
+ },
+
+ /**
+ * Cache user ID by email for fast lookups
+ */
+ async setUserByEmail(email: string, userId: string): Promise {
+ if (!isRedisConnected()) return false
+
+ try {
+ const redis = await getRedisClient()
+ await redis.setEx(
+ redisKeys.userByEmail(email),
+ USER_LOOKUP_TTL_SECONDS,
+ userId
+ )
+ return true
+ } catch (error) {
+ logger.error('Failed to cache user by email', error as Error, { email })
+ return false
+ }
+ },
+
+ /**
+ * Get cached user ID by email
+ */
+ async getUserByEmail(email: string): Promise {
+ if (!isRedisConnected()) return null
+
+ try {
+ const redis = await getRedisClient()
+ return await redis.get(redisKeys.userByEmail(email))
+ } catch (error) {
+ logger.error('Failed to get user by email from cache', error as Error, {
+ email,
+ })
+ return null
+ }
+ },
+
+ /**
+ * Cache user data by upload token
+ */
+ async setUserByToken(token: string, userId: string): Promise {
+ if (!isRedisConnected()) return false
+
+ try {
+ const redis = await getRedisClient()
+ await redis.setEx(
+ redisKeys.userByToken(token),
+ USER_LOOKUP_TTL_SECONDS,
+ userId
+ )
+ return true
+ } catch (error) {
+ logger.error('Failed to cache user by token', error as Error)
+ return false
+ }
+ },
+
+ /**
+ * Get cached user ID by upload token
+ */
+ async getUserByToken(token: string): Promise {
+ if (!isRedisConnected()) return null
+
+ try {
+ const redis = await getRedisClient()
+ return await redis.get(redisKeys.userByToken(token))
+ } catch (error) {
+ logger.error('Failed to get user by token from cache', error as Error)
+ return null
+ }
+ },
+
+ /**
+ * Invalidate user by token cache
+ */
+ async invalidateUserByToken(token: string): Promise {
+ if (!isRedisConnected()) return false
+
+ try {
+ const redis = await getRedisClient()
+ await redis.del(redisKeys.userByToken(token))
+ return true
+ } catch (error) {
+ logger.error('Failed to invalidate user by token cache', error as Error)
+ return false
+ }
+ },
+
+ /**
+ * Invalidate all caches for a user
+ */
+ async invalidateAllForUser(
+ userId: string,
+ email?: string,
+ token?: string
+ ): Promise {
+ await this.invalidateUserSession(userId)
+ if (email) {
+ await this.invalidateUserByEmail(email)
+ }
+ if (token) {
+ await this.invalidateUserByToken(token)
+ }
+ },
+
+ /**
+ * Invalidate user by email cache
+ */
+ async invalidateUserByEmail(email: string): Promise {
+ if (!isRedisConnected()) return false
+
+ try {
+ const redis = await getRedisClient()
+ await redis.del(redisKeys.userByEmail(email))
+ return true
+ } catch (error) {
+ logger.error('Failed to invalidate user by email cache', error as Error, {
+ email,
+ })
+ return false
+ }
+ },
}
diff --git a/packages/lib/config/index.ts b/packages/lib/config/index.ts
index 0e51086..d393eb8 100644
--- a/packages/lib/config/index.ts
+++ b/packages/lib/config/index.ts
@@ -7,127 +7,240 @@ import { loggers } from '@/packages/lib/logger'
const logger = loggers.config
-export const configSchema = z.object({
- version: z.string().optional().default('1.0.0'),
- settings: z.object({
- general: z.object({
- setup: z.object({
- completed: z.boolean().optional().default(false),
- // Store as ISO string for JSON compatibility, not Date object
- completedAt: z.any().nullable().optional().default(null).transform((val) => {
- if (!val) return null
- // If it's already a valid ISO string, keep it
- if (typeof val === 'string') {
- const d = new Date(val)
- return isNaN(d.getTime()) ? null : val
- }
- // If it's a Date object, convert to ISO string
- if (val instanceof Date) {
- return isNaN(val.getTime()) ? null : val.toISOString()
- }
- // Handle JSON-parsed objects with a valid date property
- if (typeof val === 'object' && val !== null) {
- try {
- const d = new Date(val)
- return isNaN(d.getTime()) ? null : d.toISOString()
- } catch {
- return null
- }
- }
- return null
- }),
- }).passthrough().optional().default({ completed: false, completedAt: null }),
- registrations: z.object({
- enabled: z.boolean().optional().default(true),
- disabledMessage: z.string().optional().default(''),
- }).passthrough().optional().default({ enabled: true, disabledMessage: '' }),
- storage: z.object({
- provider: z.enum(['local', 's3']).optional().default('local'),
- s3: z.object({
- bucket: z.string().optional().default(''),
- region: z.string().optional().default(''),
- accessKeyId: z.string().optional().default(''),
- secretAccessKey: z.string().optional().default(''),
- endpoint: z.string().optional(),
- forcePathStyle: z.boolean().optional().default(false),
- }).passthrough().optional().default({}),
- quotas: z.object({
- enabled: z.boolean().optional().default(false),
- default: z.object({
- value: z.number().optional().default(10),
- unit: z.string().optional().default('GB'),
- }).passthrough().optional().default({ value: 10, unit: 'GB' }),
- }).passthrough().optional().default({ enabled: false, default: { value: 10, unit: 'GB' } }),
- maxUploadSize: z.object({
- value: z.number().optional().default(100),
- unit: z.string().optional().default('MB'),
- }).passthrough().optional().default({ value: 100, unit: 'MB' }),
- }).passthrough().optional().default({}),
- credits: z.object({
- showFooter: z.boolean().optional().default(true),
- }).passthrough().optional().default({ showFooter: true }),
- ocr: z.object({
- enabled: z.boolean().optional().default(true),
- }).passthrough().optional().default({ enabled: true }),
- }).passthrough().optional().default({}),
- appearance: z.object({
- theme: z.string().optional().default('default-dark'),
- themeType: z.enum(['static', 'animated', 'gaming']).optional().default('static'),
- backgroundEffect: z.enum(['none', 'particles', 'gradient-shift', 'waves', 'glitch', 'grid', 'parallax', 'aurora', 'stars', 'matrix']).optional().default('none'),
- animationSpeed: z.enum(['slow', 'medium', 'fast']).optional().default('medium'),
- enableAnimations: z.boolean().optional().default(false),
- enableBackgroundEffect: z.boolean().optional().default(false),
- favicon: z.string().nullable().optional().default(null),
- customColors: z.record(z.string()).optional().default({}),
- systemThemes: z.record(z.any()).optional().default({}),
- }).passthrough().optional().default({}),
- advanced: z.object({
- customCSS: z.string().optional().default(''),
- customHead: z.string().optional().default(''),
- }).passthrough().optional().default({ customCSS: '', customHead: '' }),
- integrations: z.object({
- cloudflare: z.object({
- apiToken: z.string().optional().default(''),
- accountId: z.string().optional().default(''),
- zoneId: z.string().optional().default(''),
- }).passthrough().optional().default({}),
- discord: z.object({
- webhookUrl: z.string().optional().default(''),
- botToken: z.string().optional().default(''),
- serverId: z.string().optional().default(''),
- supporterRole: z.string().optional().default(''),
- }).passthrough().optional().default({}),
- github: z.object({
- org: z.string().optional().default('EmberlyOSS'),
- pat: z.string().optional().default(''),
- }).passthrough().optional().default({}),
- kener: z.object({
- apiKey: z.string().optional().default(''),
- baseUrl: z.string().optional().default('https://emberlystat.us'),
- }).passthrough().optional().default({}),
- stripe: z.object({
- secretKey: z.string().optional().default(''),
- webhookSecret: z.string().optional().default(''),
- }).passthrough().optional().default({}),
- vultr: z.object({
- apiKey: z.string().optional().default(''),
- }).passthrough().optional().default({}),
- resend: z.object({
- apiKey: z.string().optional().default(''),
- emailFrom: z.string().optional().default(''),
- }).passthrough().optional().default({}),
- emailProvider: z.enum(['resend', 'smtp']).optional().default('resend'),
- smtp: z.object({
- host: z.string().optional().default(''),
- port: z.number().optional().default(587),
- secure: z.boolean().optional().default(false),
- user: z.string().optional().default(''),
- password: z.string().optional().default(''),
- from: z.string().optional().default(''),
- }).passthrough().optional().default({}),
- }).passthrough().optional().default({}),
- }).passthrough().optional().default({}),
-}).passthrough()
+export const configSchema = z
+ .object({
+ version: z.string().optional().default('1.0.0'),
+ settings: z
+ .object({
+ general: z
+ .object({
+ setup: z
+ .object({
+ completed: z.boolean().optional().default(false),
+ // Store as ISO string for JSON compatibility, not Date object
+ completedAt: z
+ .any()
+ .nullable()
+ .optional()
+ .default(null)
+ .transform((val) => {
+ if (!val) return null
+ // If it's already a valid ISO string, keep it
+ if (typeof val === 'string') {
+ const d = new Date(val)
+ return isNaN(d.getTime()) ? null : val
+ }
+ // If it's a Date object, convert to ISO string
+ if (val instanceof Date) {
+ return isNaN(val.getTime()) ? null : val.toISOString()
+ }
+ // Handle JSON-parsed objects with a valid date property
+ if (typeof val === 'object' && val !== null) {
+ try {
+ const d = new Date(val)
+ return isNaN(d.getTime()) ? null : d.toISOString()
+ } catch {
+ return null
+ }
+ }
+ return null
+ }),
+ })
+ .passthrough()
+ .optional()
+ .default({ completed: false, completedAt: null }),
+ registrations: z
+ .object({
+ enabled: z.boolean().optional().default(true),
+ disabledMessage: z.string().optional().default(''),
+ })
+ .passthrough()
+ .optional()
+ .default({ enabled: true, disabledMessage: '' }),
+ storage: z
+ .object({
+ provider: z.enum(['local', 's3']).optional().default('local'),
+ s3: z
+ .object({
+ bucket: z.string().optional().default(''),
+ region: z.string().optional().default(''),
+ accessKeyId: z.string().optional().default(''),
+ secretAccessKey: z.string().optional().default(''),
+ endpoint: z.string().optional(),
+ forcePathStyle: z.boolean().optional().default(false),
+ })
+ .passthrough()
+ .optional()
+ .default({}),
+ quotas: z
+ .object({
+ enabled: z.boolean().optional().default(false),
+ default: z
+ .object({
+ value: z.number().optional().default(10),
+ unit: z.string().optional().default('GB'),
+ })
+ .passthrough()
+ .optional()
+ .default({ value: 10, unit: 'GB' }),
+ })
+ .passthrough()
+ .optional()
+ .default({
+ enabled: false,
+ default: { value: 10, unit: 'GB' },
+ }),
+ maxUploadSize: z
+ .object({
+ value: z.number().optional().default(100),
+ unit: z.string().optional().default('MB'),
+ })
+ .passthrough()
+ .optional()
+ .default({ value: 100, unit: 'MB' }),
+ })
+ .passthrough()
+ .optional()
+ .default({}),
+ credits: z
+ .object({
+ showFooter: z.boolean().optional().default(true),
+ })
+ .passthrough()
+ .optional()
+ .default({ showFooter: true }),
+ ocr: z
+ .object({
+ enabled: z.boolean().optional().default(true),
+ })
+ .passthrough()
+ .optional()
+ .default({ enabled: true }),
+ })
+ .passthrough()
+ .optional()
+ .default({}),
+ appearance: z
+ .object({
+ theme: z.string().optional().default('default-dark'),
+ themeType: z
+ .enum(['static', 'animated', 'gaming'])
+ .optional()
+ .default('static'),
+ backgroundEffect: z
+ .enum([
+ 'none',
+ 'particles',
+ 'gradient-shift',
+ 'waves',
+ 'glitch',
+ 'grid',
+ 'parallax',
+ 'aurora',
+ 'stars',
+ 'matrix',
+ ])
+ .optional()
+ .default('none'),
+ animationSpeed: z
+ .enum(['slow', 'medium', 'fast'])
+ .optional()
+ .default('medium'),
+ enableAnimations: z.boolean().optional().default(false),
+ enableBackgroundEffect: z.boolean().optional().default(false),
+ favicon: z.string().nullable().optional().default(null),
+ customColors: z.record(z.string()).optional().default({}),
+ systemThemes: z.record(z.any()).optional().default({}),
+ })
+ .passthrough()
+ .optional()
+ .default({}),
+ advanced: z
+ .object({
+ customCSS: z.string().optional().default(''),
+ customHead: z.string().optional().default(''),
+ })
+ .passthrough()
+ .optional()
+ .default({ customCSS: '', customHead: '' }),
+ integrations: z
+ .object({
+ cloudflare: z
+ .object({
+ apiToken: z.string().optional().default(''),
+ accountId: z.string().optional().default(''),
+ zoneId: z.string().optional().default(''),
+ })
+ .passthrough()
+ .optional()
+ .default({}),
+ discord: z
+ .object({
+ webhookUrl: z.string().optional().default(''),
+ botToken: z.string().optional().default(''),
+ serverId: z.string().optional().default(''),
+ supporterRole: z.string().optional().default(''),
+ })
+ .passthrough()
+ .optional()
+ .default({}),
+ github: z
+ .object({
+ org: z.string().optional().default('EmberlyOSS'),
+ pat: z.string().optional().default(''),
+ })
+ .passthrough()
+ .optional()
+ .default({}),
+ stripe: z
+ .object({
+ secretKey: z.string().optional().default(''),
+ webhookSecret: z.string().optional().default(''),
+ })
+ .passthrough()
+ .optional()
+ .default({}),
+ vultr: z
+ .object({
+ apiKey: z.string().optional().default(''),
+ })
+ .passthrough()
+ .optional()
+ .default({}),
+ resend: z
+ .object({
+ apiKey: z.string().optional().default(''),
+ emailFrom: z.string().optional().default(''),
+ })
+ .passthrough()
+ .optional()
+ .default({}),
+ emailProvider: z
+ .enum(['resend', 'smtp'])
+ .optional()
+ .default('resend'),
+ smtp: z
+ .object({
+ host: z.string().optional().default(''),
+ port: z.number().optional().default(587),
+ secure: z.boolean().optional().default(false),
+ user: z.string().optional().default(''),
+ password: z.string().optional().default(''),
+ from: z.string().optional().default(''),
+ })
+ .passthrough()
+ .optional()
+ .default({}),
+ })
+ .passthrough()
+ .optional()
+ .default({}),
+ })
+ .passthrough()
+ .optional()
+ .default({}),
+ })
+ .passthrough()
export type EmberlyConfig = z.infer
@@ -223,10 +336,6 @@ export const DEFAULT_CONFIG: EmberlyConfig = {
org: 'EmberlyOSS',
pat: '',
},
- kener: {
- apiKey: '',
- baseUrl: 'https://emberlystat.us',
- },
stripe: {
secretKey: '',
webhookSecret: '',
@@ -265,7 +374,7 @@ export async function initConfig(): Promise {
// Use safeParse and merge - never fail
const parsed = configSchema.safeParse(configRow.value)
- return parsed.success
+ return parsed.success
? deepMerge(DEFAULT_CONFIG, parsed.data)
: deepMerge(DEFAULT_CONFIG, configRow.value as any)
} catch (error) {
@@ -279,12 +388,22 @@ export async function initConfig(): Promise {
/**
* Deep merge two objects, with source taking priority
*/
-function deepMerge>(target: T, source: Partial): T {
+function deepMerge>(
+ target: T,
+ source: Partial
+): T {
const result = { ...target }
for (const key in source) {
if (source[key] !== undefined) {
- if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
- result[key] = deepMerge(target[key] ?? {} as T[Extract], source[key] as T[Extract])
+ if (
+ typeof source[key] === 'object' &&
+ source[key] !== null &&
+ !Array.isArray(source[key])
+ ) {
+ result[key] = deepMerge(
+ target[key] ?? ({} as T[Extract]),
+ source[key] as T[Extract]
+ )
} else {
result[key] = source[key] as T[Extract]
}
@@ -312,7 +431,7 @@ export async function getConfig(): Promise {
// Use safeParse and merge with defaults - never fail
const parsed = configSchema.safeParse(configRow.value)
- const config = parsed.success
+ const config = parsed.success
? deepMerge(DEFAULT_CONFIG, parsed.data)
: deepMerge(DEFAULT_CONFIG, configRow.value as any)
@@ -407,10 +526,6 @@ export async function updateConfig(
...currentConfig.settings.integrations?.github,
...(newConfig.settings?.integrations?.github || {}),
},
- kener: {
- ...currentConfig.settings.integrations?.kener,
- ...(newConfig.settings?.integrations?.kener || {}),
- },
stripe: {
...currentConfig.settings.integrations?.stripe,
...(newConfig.settings?.integrations?.stripe || {}),
@@ -458,9 +573,12 @@ export async function updateConfig(
}
// Check if it's a Zod validation error
if (error && typeof error === 'object' && 'issues' in error) {
- console.error('[CONFIG ERROR] Zod issues:', JSON.stringify((error as any).issues, null, 2))
+ console.error(
+ '[CONFIG ERROR] Zod issues:',
+ JSON.stringify((error as any).issues, null, 2)
+ )
}
- logger.error('Could not save config to database', {
+ logger.error('Could not save config to database', {
error: error instanceof Error ? error.message : String(error),
})
return newConfig as EmberlyConfig
@@ -496,4 +614,4 @@ export async function updateConfigSection<
export async function getIntegrations() {
const config = await getConfig()
return config.settings.integrations ?? DEFAULT_CONFIG.settings.integrations!
-}
\ No newline at end of file
+}
diff --git a/packages/lib/events/handlers/file-expiry.ts b/packages/lib/events/handlers/file-expiry.ts
index fc35c97..dea794c 100644
--- a/packages/lib/events/handlers/file-expiry.ts
+++ b/packages/lib/events/handlers/file-expiry.ts
@@ -154,3 +154,26 @@ export async function getFileExpirationInfo(
return fileEvent?.scheduledAt || null
}
+
+export async function getFileExpirationInfoBatch(
+ fileIds: string[]
+): Promise
-
+
{squad.status}
- {squad._count.members} member{squad._count.members !== 1 ? 's' : ''}
+ {squad._count.members} member
+ {squad._count.members !== 1 ? 's' : ''}
{!squad.isPublic && (
@@ -339,7 +420,10 @@ function SquadsList() {
{ icon: Key, label: 'API Keys' },
{ icon: Globe, label: 'Domains' },
].map(({ icon: Icon, label }) => (
-
+
{label}
))}
diff --git a/packages/components/theme/theme-initializer.tsx b/packages/components/theme/theme-initializer.tsx
index 144e7df..ed60b97 100644
--- a/packages/components/theme/theme-initializer.tsx
+++ b/packages/components/theme/theme-initializer.tsx
@@ -46,13 +46,12 @@ function generateHueColors(hue: number): Record {
return result
}
-export async function ThemeInitializer({
- userTheme,
+export async function ThemeInitializer({
+ userTheme,
userCustomColors,
systemTheme,
- systemColors
+ systemColors,
}: ThemeInitializerProps) {
- let cssVariables: string
let themeName: string
let colorsToUse: Record = {}
@@ -66,7 +65,10 @@ export async function ThemeInitializer({
colorsToUse = systemColors || {}
// If system theme is hue-based but no colors provided, generate them
- if (systemTheme.startsWith('hue:') && Object.keys(colorsToUse).length === 0) {
+ if (
+ systemTheme.startsWith('hue:') &&
+ Object.keys(colorsToUse).length === 0
+ ) {
const hueMatch = systemTheme.match(/hue:(\d+)/)
if (hueMatch) {
const hue = parseInt(hueMatch[1], 10)
@@ -89,7 +91,7 @@ export async function ThemeInitializer({
}
}
- cssVariables = Object.entries(colorsToUse)
+ const cssVariables = Object.entries(colorsToUse)
.map(([key, value]) => {
const cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)
return `--${cssKey}: ${value};`
@@ -112,4 +114,4 @@ export async function ThemeInitializer({
}}
/>
)
-}
\ No newline at end of file
+}
diff --git a/packages/types/react-jsx-compat.d.ts b/packages/types/react-jsx-compat.d.ts
index 1eacb09..7fdddaa 100644
--- a/packages/types/react-jsx-compat.d.ts
+++ b/packages/types/react-jsx-compat.d.ts
@@ -4,12 +4,15 @@ declare global {
namespace JSX {
type Element = React.JSX.Element
type ElementType = React.JSX.ElementType
- interface ElementClass extends React.JSX.ElementClass {}
- interface ElementAttributesProperty extends React.JSX.ElementAttributesProperty {}
- interface ElementChildrenAttribute extends React.JSX.ElementChildrenAttribute {}
- type LibraryManagedAttributes = React.JSX.LibraryManagedAttributes
- interface IntrinsicAttributes extends React.JSX.IntrinsicAttributes {}
- interface IntrinsicClassAttributes extends React.JSX.IntrinsicClassAttributes {}
- interface IntrinsicElements extends React.JSX.IntrinsicElements {}
+ type ElementClass = React.JSX.ElementClass
+ type ElementAttributesProperty = React.JSX.ElementAttributesProperty
+ type ElementChildrenAttribute = React.JSX.ElementChildrenAttribute
+ type LibraryManagedAttributes = React.JSX.LibraryManagedAttributes<
+ C,
+ P
+ >
+ type IntrinsicAttributes = React.JSX.IntrinsicAttributes
+ type IntrinsicClassAttributes = React.JSX.IntrinsicClassAttributes
+ type IntrinsicElements = React.JSX.IntrinsicElements
}
}
diff --git a/scripts/hash-file-passwords.js b/scripts/hash-file-passwords.mjs
similarity index 95%
rename from scripts/hash-file-passwords.js
rename to scripts/hash-file-passwords.mjs
index 693f16e..566fc8f 100644
--- a/scripts/hash-file-passwords.js
+++ b/scripts/hash-file-passwords.mjs
@@ -5,8 +5,8 @@
* This script should be run once to upgrade existing Emberly instances
*/
-const { PrismaClient } = require('@prisma/client')
-const { hash, compare } = require('bcryptjs')
+import { PrismaClient } from '@prisma/client'
+import { hash, compare } from 'bcryptjs'
const prisma = new PrismaClient()
diff --git a/scripts/migrate-config.js b/scripts/migrate-config.mjs
similarity index 98%
rename from scripts/migrate-config.js
rename to scripts/migrate-config.mjs
index 74e8f3d..7788bec 100644
--- a/scripts/migrate-config.js
+++ b/scripts/migrate-config.mjs
@@ -1,4 +1,5 @@
-const { PrismaClient } = require('@prisma/client')
+import { PrismaClient } from '@prisma/client'
+
const prisma = new PrismaClient()
const DEFAULT_CONFIG = {
From 7a69c4cf1e5feba10c4e3444b9513685ee69f850 Mon Sep 17 00:00:00 2001
From: TheRealToxicDev
Date: Sat, 13 Jun 2026 10:02:02 -0600
Subject: [PATCH 11/13] fix(quality): more stuff
---
.github/README.md | 5 +----
CHANGELOG.md | 9 ++++++++-
app/api/admin/integrations/test/route.ts | 10 +++++++++-
app/api/files/route.ts | 11 ++++++++++-
app/api/settings/route.ts | 8 ++++----
packages/lib/cache/session-cache.ts | 7 ++++++-
packages/lib/files/filename.ts | 1 +
packages/lib/storage/quota.ts | 22 +++++++++++++++++++---
8 files changed, 58 insertions(+), 15 deletions(-)
diff --git a/.github/README.md b/.github/README.md
index 1bb39a1..5cd40ac 100644
--- a/.github/README.md
+++ b/.github/README.md
@@ -47,7 +47,7 @@ Emberly is an open source platform for modern file storage, sharing, and identit
- Promo code management with configurable discounts
- User management dashboard
- Application review queue with multi stage triage
-- Service status monitoring via Kener integration
+- Service status page link ([emberlystat.us](https://emberlystat.us))
- Analytics and usage reporting
## Quick Start
@@ -105,7 +105,6 @@ The application will be available at http://localhost:3000.
**Infrastructure & Services**
- [S3 compatible storage](https://aws.amazon.com/s3/) - File storage
-- [Kener](https://kener.ing/) - Status page monitoring
- [Next.js Auth](https://next-auth.js.org/) - Authentication
- [Sentry](https://sentry.io/) - Error tracking
@@ -176,7 +175,6 @@ Get help and connect with the community:
This project is licensed under the GNU Affero General Public License v3 (AGPL-3.0). See the [LICENSE](LICENSE) file for details.
-
## Code of Conduct
This project adheres to the Contributor Covenant Code of Conduct. By participating, you agree to uphold this code. See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for the full text.
@@ -185,7 +183,6 @@ This project adheres to the Contributor Covenant Code of Conduct. By participati
Thank you to all [contributors](https://github.com/EmberlyOSS/Emberly/graphs/contributors) who have helped make Emberly possible. We also appreciate the [open source projects and communities](https://github.com/EmberlyOSS/Emberly/network/dependencies) that make Emberly possible.
-
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eb51713..3202096 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,7 +25,7 @@ The format is based on "Keep a Changelog" and follows [Semantic Versioning](http
### Changed
- **Status Page Integration Removed**
- - Removed the Kener / Uptime Kuma dynamic status integration entirely. The polling logic, `/api/status` route, admin settings panel, and integration test handler have all been removed.
+ - Removed the Kener / Uptime Kuma dynamic status integration entirely. The polling logic, `/api/status` route (now returns 404), admin settings panel, and integration test handler have all been removed.
- `StatusIndicator` in the site footer is now a lightweight static link to [emberlystat.us](https://emberlystat.us) — no external API calls, no runtime failures, no "Status unknown" states.
- `KENER_API_KEY`, `KENER_BASE_URL`, `UPTIME_KUMA_BASE_URL`, and `UPTIME_KUMA_SLUG` environment variables are no longer used and can be removed.
@@ -53,6 +53,13 @@ The format is based on "Keep a Changelog" and follows [Semantic Versioning](http
- **ESLint — Empty interfaces in `react-jsx-compat.d.ts`** — Six `interface X extends Y {}` declarations with no added members replaced with `type X = Y` aliases, satisfying `@typescript-eslint/no-empty-object-type`.
- **ESLint — Unused variables across multiple files** — Removed or prefixed unused imports and variables flagged by `@typescript-eslint/no-unused-vars`: `Copy` in `bucket/page.tsx`, `User` in `blog/page.tsx`, `Share2`/`User` in `blog/[slug]/page.tsx`, `Footer`/`getConfig`/`providedPassword` in `[filename]/page.tsx`, `toast` in `squads/client.tsx`, `codeSent` state in `alpha-migration/page.tsx`, and `userName` parameter in `dashboard/client.tsx`.
- **ESLint — `let` → `const` in `theme-initializer.tsx`** — `cssVariables` was declared with `let` but never reassigned; declaration moved to the single assignment site as `const`.
+- **Integration test fetch timeouts** — `testStripe`, `testResend`, `testCloudflare`, `testDiscord` (bot + webhook), and `testGitHub` had no request timeout, allowing the admin integration-test endpoint to hang indefinitely if a provider didn't respond. All now use `AbortSignal.timeout(8000)`, consistent with the existing Vultr handler.
+- **Quarantine failure logging** — `Promise.allSettled` results in the VirusTotal quarantine path were silently discarded. Storage delete or DB update failures now log an error with the file ID so they can be investigated.
+- **Session cache `emailVerified` backfill** — Redis-cached sessions written before the `emailVerified: boolean` field was added would deserialize with `undefined`, breaking the upload auth contract. A coerce-on-read now sets `false` for any entry missing the field.
+- **Legacy `kener`/`uptimeKuma` config keys stripped from admin response** — The integration config schema uses `.passthrough()`, so old DB records containing stale Kener or Uptime Kuma keys would survive `deepMerge` and be returned to SUPERADMIN via `GET /api/settings`. `maskSecretsForAdmin` now explicitly deletes both keys before returning.
+- **Empty filename slug fallback** — Filenames composed entirely of non-ASCII/symbol characters would reduce to an empty slug after sanitization, producing broken storage paths. A `nanoid(6)` fallback is now used when the slug is empty.
+- **`storageQuotaMB = 0` admin override ignored** — `if (!baseQuotaMB)` treated an explicit zero-quota override as "unset", falling through to plan-based quota. Changed to `== null` checks so `0` is honored as a deliberate override.
+- **Storage-bucket subscription precedence missing from Stripe re-check path** — After a successful Stripe sync, `getPlanLimits` returned the latest active subscription without first checking for a `storage-bucket-*` subscription. Users with both sub types active could receive non-unlimited limits for that request. The re-check now mirrors the original early-exit logic.
## [2.4.5] - 2026-06-02
diff --git a/app/api/admin/integrations/test/route.ts b/app/api/admin/integrations/test/route.ts
index ff3a6fe..4f21383 100644
--- a/app/api/admin/integrations/test/route.ts
+++ b/app/api/admin/integrations/test/route.ts
@@ -60,6 +60,7 @@ async function testStripe(secretKey: string): Promise {
try {
const res = await fetch('https://api.stripe.com/v1/customers?limit=1', {
headers: { Authorization: `Bearer ${secretKey}` },
+ signal: AbortSignal.timeout(8000),
})
if (res.status === 401)
return {
@@ -84,6 +85,7 @@ async function testResend(apiKey: string): Promise {
try {
const res = await fetch('https://api.resend.com/domains', {
headers: { Authorization: `Bearer ${apiKey}` },
+ signal: AbortSignal.timeout(8000),
})
if (res.status === 401 || res.status === 403)
return { ok: false, message: 'Invalid API key' }
@@ -125,6 +127,7 @@ async function testCloudflare(
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
+ signal: AbortSignal.timeout(8000),
})
const json = await res.json().catch(() => null)
if (!res.ok || json?.success === false) {
@@ -168,6 +171,7 @@ async function testDiscord(
`https://discord.com/api/v10/guilds/${safeServerId}`,
{
headers: { Authorization: `Bot ${botToken}` },
+ signal: AbortSignal.timeout(8000),
}
)
if (res.status === 401) return { ok: false, message: 'Invalid bot token' }
@@ -226,7 +230,10 @@ async function testDiscord(
const [, webhookId, webhookToken] = match
const safeWebhookUrl = `https://discord.com/api/webhooks/${encodeURIComponent(webhookId)}/${encodeURIComponent(webhookToken)}`
- const res = await fetch(safeWebhookUrl, { method: 'GET' })
+ const res = await fetch(safeWebhookUrl, {
+ method: 'GET',
+ signal: AbortSignal.timeout(8000),
+ })
if (res.status === 401)
return { ok: false, message: 'Invalid webhook URL' }
if (!res.ok)
@@ -270,6 +277,7 @@ async function testGitHub(pat: string, org?: string): Promise {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
+ signal: AbortSignal.timeout(8000),
})
if (res.status === 401)
return { ok: false, message: 'Invalid personal access token' }
diff --git a/app/api/files/route.ts b/app/api/files/route.ts
index 73bd4eb..652872e 100644
--- a/app/api/files/route.ts
+++ b/app/api/files/route.ts
@@ -279,13 +279,22 @@ export async function POST(req: Request) {
permalink: vtResult.permalink,
userId: user.id,
})
- await Promise.allSettled([
+ const results = await Promise.allSettled([
storageProvider!.deleteFile(filePath),
prisma.file.update({
where: { id: fileRecord.id },
data: { visibility: 'PRIVATE', name: '[Quarantined]' },
}),
])
+ results.forEach((r, i) => {
+ if (r.status === 'rejected') {
+ logger.error(
+ `Quarantine step ${i === 0 ? 'storage delete' : 'db update'} failed`,
+ r.reason as Error,
+ { fileId: fileRecord.id }
+ )
+ }
+ })
}).catch((err) => {
logger.error('Background VirusTotal scan failed', err as Error, {
fileId: fileRecord.id,
diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts
index 930386d..4c01b05 100644
--- a/app/api/settings/route.ts
+++ b/app/api/settings/route.ts
@@ -5,10 +5,7 @@ import {
} from '@/packages/types/dto/settings'
import { HTTP_STATUS, apiError, apiResponse } from '@/packages/lib/api/response'
-import {
- requireAuth,
- requireSuperAdmin,
-} from '@/packages/lib/auth/api-auth'
+import { requireAuth, requireSuperAdmin } from '@/packages/lib/auth/api-auth'
import {
EmberlyConfig,
getConfig,
@@ -50,6 +47,9 @@ function maskSecretsForAdmin(config: EmberlyConfig): EmberlyConfig {
if (i.github) {
i.github.pat = ''
}
+ // Remove any stale legacy integration keys that may still live in DB config
+ delete i.kener
+ delete i.uptimeKuma
return c as EmberlyConfig
}
diff --git a/packages/lib/cache/session-cache.ts b/packages/lib/cache/session-cache.ts
index 1c2e897..37c7d5b 100644
--- a/packages/lib/cache/session-cache.ts
+++ b/packages/lib/cache/session-cache.ts
@@ -39,7 +39,12 @@ export const sessionCache = {
const redis = await getRedisClient()
const data = await redis.get(redisKeys.userSession(userId))
if (!data) return null
- return JSON.parse(data) as CachedUserSession
+ const parsed = JSON.parse(data) as CachedUserSession
+ // Coerce emailVerified for older cached entries that pre-date the boolean field
+ if (typeof parsed.emailVerified !== 'boolean') {
+ parsed.emailVerified = false
+ }
+ return parsed
} catch (error) {
logger.error('Failed to get cached user session', error as Error, {
userId,
diff --git a/packages/lib/files/filename.ts b/packages/lib/files/filename.ts
index 8b51376..1eedbee 100644
--- a/packages/lib/files/filename.ts
+++ b/packages/lib/files/filename.ts
@@ -101,6 +101,7 @@ export async function getUniqueFilename(
while (s < e && urlSafeName[s] === '-') s++
while (e > s && urlSafeName[e - 1] === '-') e--
urlSafeName = urlSafeName.slice(s, e)
+ if (!urlSafeName) urlSafeName = nanoid(6)
if (extension) {
urlSafeName += '.' + extension.toLowerCase()
diff --git a/packages/lib/storage/quota.ts b/packages/lib/storage/quota.ts
index f8a9ae4..2a0b414 100644
--- a/packages/lib/storage/quota.ts
+++ b/packages/lib/storage/quota.ts
@@ -112,7 +112,23 @@ export async function getPlanLimits(userId: string): Promise {
userId,
userRecord.stripeCustomerId
)
- // Re-check after sync
+ // Re-check after sync — storage-bucket subs take precedence (unlimited)
+ const syncedBucketSub = await prisma.subscription.findFirst({
+ where: {
+ userId,
+ status: 'active',
+ product: { slug: { startsWith: 'storage-bucket' } },
+ },
+ select: { id: true },
+ })
+ if (syncedBucketSub) {
+ return {
+ storageQuotaGB: null,
+ uploadSizeCapMB: null,
+ customDomainsLimit: null,
+ planName: 'Storage Bucket (Unlimited)',
+ }
+ }
const syncedSub = await prisma.subscription.findFirst({
where: { userId, status: 'active' },
include: { product: true },
@@ -252,7 +268,7 @@ export async function getEffectiveQuotaMB(
// Priority: admin override > plan quota + perks > default quota
let baseQuotaMB = user.storageQuotaMB
- if (!baseQuotaMB) {
+ if (baseQuotaMB == null) {
if (planLimits.storageQuotaGB === null) {
// Unlimited plan — use a 100 TB sentinel so arithmetic still works
baseQuotaMB = 100 * 1024 * 1024
@@ -260,7 +276,7 @@ export async function getEffectiveQuotaMB(
baseQuotaMB = (planLimits.storageQuotaGB + perkStorageBonusGB) * 1024
}
}
- if (!baseQuotaMB && defaultQuotaMB) {
+ if (baseQuotaMB == null && defaultQuotaMB != null) {
baseQuotaMB = defaultQuotaMB
}
From 3d257b960fc01fb9a9c5abf54bc60b5ab92c55e1 Mon Sep 17 00:00:00 2001
From: Pixelated
Date: Sat, 13 Jun 2026 10:31:09 -0600
Subject: [PATCH 12/13] Potential fix for pull request finding 'Comparison
between inconvertible types'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
---
packages/lib/storage/quota.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/lib/storage/quota.ts b/packages/lib/storage/quota.ts
index 2a0b414..4db3c35 100644
--- a/packages/lib/storage/quota.ts
+++ b/packages/lib/storage/quota.ts
@@ -276,7 +276,7 @@ export async function getEffectiveQuotaMB(
baseQuotaMB = (planLimits.storageQuotaGB + perkStorageBonusGB) * 1024
}
}
- if (baseQuotaMB == null && defaultQuotaMB != null) {
+ if (defaultQuotaMB != null) {
baseQuotaMB = defaultQuotaMB
}
From d42d70409166efc4ea4f27d52ebc14324088f8e6 Mon Sep 17 00:00:00 2001
From: TheRealToxicDev
Date: Sat, 13 Jun 2026 10:43:04 -0600
Subject: [PATCH 13/13] fix(stuff): proxy, and more
---
CHANGELOG.md | 6 ++
app/(main)/auth/alpha-migration/page.tsx | 1 -
proxy.ts | 130 +++++++++--------------
3 files changed, 56 insertions(+), 81 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3202096..6d9e23f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -60,6 +60,12 @@ The format is based on "Keep a Changelog" and follows [Semantic Versioning](http
- **Empty filename slug fallback** — Filenames composed entirely of non-ASCII/symbol characters would reduce to an empty slug after sanitization, producing broken storage paths. A `nanoid(6)` fallback is now used when the slug is empty.
- **`storageQuotaMB = 0` admin override ignored** — `if (!baseQuotaMB)` treated an explicit zero-quota override as "unset", falling through to plan-based quota. Changed to `== null` checks so `0` is honored as a deliberate override.
- **Storage-bucket subscription precedence missing from Stripe re-check path** — After a successful Stripe sync, `getPlanLimits` returned the latest active subscription without first checking for a `storage-bucket-*` subscription. Users with both sub types active could receive non-unlimited limits for that request. The re-check now mirrors the original early-exit logic.
+- **`proxy.ts` — `BASE_URL`/`MAIN_HOST` recomputed per request** — `process.env.NEXT_PUBLIC_BASE_URL` was parsed with `new URL()` on every invocation. Both the base URL string and its hostname are now computed once at module load time.
+- **`proxy.ts` — Duplicate media-rewrite block eliminated** — Video/audio range-request detection logic appeared twice (before and after the auth checks). Unified into a single block that runs before `getToken()`, so media requests skip JWT verification entirely.
+- **`proxy.ts` — `VIDEO_EXTENSIONS` array → `Set`** — `VIDEO_EXTENSIONS.includes(ext)` was an O(n) linear scan on every file URL request. Converted to a module-level `Set` for O(1) lookup.
+- **`proxy.ts` — Trailing-slash strip regex replaced** — `pathname.replace(/\/$/, '')` replaced with `endsWith('/')`+`slice`, consistent with the ReDoS hardening applied elsewhere.
+- **`proxy.ts` — `getClientIP` double-read of `x-forwarded-for`** — The function read `x-forwarded-for` a second time at the fallback return if the header was already `null` at the top. Simplified to a single read with null-coalescing chain.
+- **`proxy.ts` — Noisy `console.log` removed from hot paths** — Debug logs for unverified-user blocks and password-breach redirects fired on every affected request, adding synchronous I/O overhead in the middleware layer.
## [2.4.5] - 2026-06-02
diff --git a/app/(main)/auth/alpha-migration/page.tsx b/app/(main)/auth/alpha-migration/page.tsx
index f0cc288..3b7bd48 100644
--- a/app/(main)/auth/alpha-migration/page.tsx
+++ b/app/(main)/auth/alpha-migration/page.tsx
@@ -333,7 +333,6 @@ export default function AlphaMigrationPage() {
size="sm"
onClick={() => {
setStep('confirm')
- setCodeSent(false)
setVerificationCode('')
}}
disabled={isLoading}
diff --git a/proxy.ts b/proxy.ts
index 0bba49c..59307d9 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -23,25 +23,21 @@ if (!globalThis.__nextAuthLoginContext) {
globalThis.__nextAuthLoginContext = {}
}
+// Computed once per isolate, not on every request
+const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'https://embrly.ca'
+const MAIN_HOST = new URL(BASE_URL).hostname
+const VIDEO_EXTENSIONS_SET = new Set(VIDEO_EXTENSIONS)
+const ALPHA_CUTOFF_DATE = new Date('2025-12-27T00:00:00.000Z')
+
function getClientIP(request: NextRequest): string | undefined {
const forwarded = request.headers.get('x-forwarded-for')
- if (forwarded) {
- return forwarded.split(',')[0]?.trim()
- }
-
- const realIP = request.headers.get('x-real-ip')
- if (realIP) {
- return realIP
- }
-
- const cfConnectingIP = request.headers.get('cf-connecting-ip')
- if (cfConnectingIP) {
- return cfConnectingIP
- }
+ if (forwarded) return forwarded.split(',')[0]?.trim()
return (
+ request.headers.get('x-real-ip') ??
+ request.headers.get('cf-connecting-ip') ??
request.headers.get('x-client-ip') ??
- request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
+ undefined
)
}
@@ -61,16 +57,17 @@ function getGeoInfo(request: NextRequest) {
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
+ // Trim trailing slash without regex
const normalizedPathname =
- pathname.length > 1 ? pathname.replace(/\/$/, '') : pathname
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://embrly.ca'
+ pathname.length > 1 && pathname.endsWith('/')
+ ? pathname.slice(0, -1)
+ : pathname
const incomingHost = request.headers.get('host')?.replace(/:\d+$/, '')
- const mainHost = new URL(baseUrl).hostname
if (
incomingHost &&
- incomingHost !== mainHost &&
+ incomingHost !== MAIN_HOST &&
incomingHost !== 'localhost'
) {
if (pathname === '/') {
@@ -131,37 +128,46 @@ export async function proxy(request: NextRequest) {
return tokenPromise
}
- const ALPHA_CUTOFF_DATE = new Date('2025-12-27T00:00:00.000Z')
const isAlphaMigrationPage = pathname === '/auth/alpha-migration'
const isAlphaMigrationApi = pathname === '/api/auth/alpha-migration'
const isNextAuthRoute = pathname.startsWith('/api/auth/')
const isApiRoute = pathname.startsWith('/api/')
- if (
- FILE_URL_PATTERN.test(pathname) &&
- pathname === normalizedPathname &&
+ // ── File URL handling — single unified check before auth ──────────────────
+ // Covers both trailing-slash and non-trailing-slash variants via normalizedPathname.
+ // Must run before getToken() so media range requests skip JWT verification entirely.
+ const isFileUrl =
+ FILE_URL_PATTERN.test(normalizedPathname) &&
!normalizedPathname.endsWith('/raw') &&
!normalizedPathname.endsWith('/direct')
- ) {
+
+ if (isFileUrl) {
const fileExt = normalizedPathname.split('.').pop()?.toLowerCase()
const rangeHeader = request.headers.get('range')
const acceptHeader = request.headers.get('accept') || ''
const isMediaRequest =
rangeHeader != null ||
(acceptHeader !== '' && !acceptHeader.includes('text/html'))
- const userAgent = request.headers.get('user-agent') || ''
- const url = new URL(request.url)
- if (fileExt && VIDEO_EXTENSIONS.includes(fileExt) && isMediaRequest) {
- url.pathname = `${pathname}/raw`
+ // Video/audio range or non-HTML requests → raw bytes.
+ // Must run before the bot handler: Discord's media proxy UA contains "discord"
+ // and would otherwise be caught by handleBotRequest.
+ if (fileExt && VIDEO_EXTENSIONS_SET.has(fileExt) && isMediaRequest) {
+ const url = new URL(request.url)
+ url.pathname = `${normalizedPathname}/raw`
return NextResponse.rewrite(url)
}
- // Bot HTML requests fall through so the bot handler can respect the
- // uploader's rich-embed setting.
- if (!isBotRequest(userAgent)) {
- url.pathname = `${pathname}/`
- return NextResponse.rewrite(url)
+ // Non-trailing-slash, non-bot requests → rewrite to trailing slash so the
+ // file page renders correctly. Bots fall through so handleBotRequest can
+ // respect the uploader's rich-embed setting.
+ if (pathname === normalizedPathname) {
+ const userAgent = request.headers.get('user-agent') || ''
+ if (!isBotRequest(userAgent)) {
+ const url = new URL(request.url)
+ url.pathname = `${pathname}/`
+ return NextResponse.rewrite(url)
+ }
}
}
@@ -179,7 +185,7 @@ export async function proxy(request: NextRequest) {
!isNextAuthRoute &&
!isApiRoute
) {
- return NextResponse.redirect(new URL('/auth/alpha-migration', baseUrl))
+ return NextResponse.redirect(new URL('/auth/alpha-migration', BASE_URL))
}
}
@@ -188,7 +194,7 @@ export async function proxy(request: NextRequest) {
const isAuthPage = pathname.startsWith('/auth/')
if (token) {
- const isEmailVerified = token.emailVerified ? true : false
+ const isEmailVerified = !!token.emailVerified
if (
!isEmailVerified &&
@@ -198,10 +204,7 @@ export async function proxy(request: NextRequest) {
!isNextAuthRoute &&
!isApiRoute
) {
- console.log(
- `[Proxy] Unverified user ${token.email} blocked from ${pathname}`
- )
- return NextResponse.redirect(new URL('/auth/verify-email', baseUrl))
+ return NextResponse.redirect(new URL('/auth/verify-email', BASE_URL))
}
}
@@ -214,17 +217,11 @@ export async function proxy(request: NextRequest) {
if (isProfileSecurityTab) {
return NextResponse.next()
}
- if (isDashboardRoot) {
- console.log(
- `[Proxy] User ${token.email} with password breach detected, redirecting from dashboard to profile security`
- )
- return NextResponse.redirect(new URL('/me?tab=security', baseUrl))
- }
- if (isProfilePath && !request.nextUrl.searchParams.get('tab')) {
- console.log(
- `[Proxy] User ${token.email} with password breach detected, redirecting to security tab`
- )
- return NextResponse.redirect(new URL('/me?tab=security', baseUrl))
+ if (
+ isDashboardRoot ||
+ (isProfilePath && !request.nextUrl.searchParams.get('tab'))
+ ) {
+ return NextResponse.redirect(new URL('/me?tab=security', BASE_URL))
}
}
@@ -247,7 +244,7 @@ export async function proxy(request: NextRequest) {
const ensureAuthenticated = async () => {
const t = await getAuthToken()
if (!t) {
- return NextResponse.redirect(new URL('/auth/login', baseUrl))
+ return NextResponse.redirect(new URL('/auth/login', BASE_URL))
}
return { token: t }
}
@@ -262,44 +259,17 @@ export async function proxy(request: NextRequest) {
)
if (isSuperAdminRoute) {
if (!hasPermission(role as any, Permission.PERFORM_SUPERADMIN_ACTIONS)) {
- return NextResponse.redirect(new URL('/dashboard', baseUrl))
+ return NextResponse.redirect(new URL('/dashboard', BASE_URL))
}
} else if (!hasPermission(role as any, Permission.ACCESS_ADMIN_PANEL)) {
- return NextResponse.redirect(new URL('/dashboard', baseUrl))
+ return NextResponse.redirect(new URL('/dashboard', BASE_URL))
}
}
if (PROTECTED_PAGE_PATHS.some((p) => pathname.startsWith(p))) {
const t = await getAuthToken()
if (!t) {
- return NextResponse.redirect(new URL('/auth/login', baseUrl))
- }
- }
-
- // ── Video/Audio Media Requests ─────────────────────────────────────────
- // Must run BEFORE the bot handler. Discord's media proxy uses a UA that
- // contains "discord", so the bot handler would catch it and serve HTML.
- // By checking for Range headers or non-HTML Accept first, media playback
- // requests get raw file bytes while crawlers (who send Accept: text/html)
- // still fall through to the bot handler for OG metadata.
- if (
- FILE_URL_PATTERN.test(normalizedPathname) &&
- !normalizedPathname.endsWith('/raw') &&
- !normalizedPathname.endsWith('/direct')
- ) {
- const fileExt = normalizedPathname.split('.').pop()?.toLowerCase()
- if (fileExt && VIDEO_EXTENSIONS.includes(fileExt)) {
- const rangeHeader = request.headers.get('range')
- const acceptHeader = request.headers.get('accept') || ''
- const isMediaRequest =
- rangeHeader != null ||
- (acceptHeader !== '' && !acceptHeader.includes('text/html'))
-
- if (isMediaRequest) {
- const url = new URL(request.url)
- url.pathname = `${normalizedPathname}/raw`
- return NextResponse.rewrite(url)
- }
+ return NextResponse.redirect(new URL('/auth/login', BASE_URL))
}
}