diff --git a/.github/README.md b/.github/README.md index af71b80..5cd40ac 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. -[![Build Checks](https://github.com/EmberlyOSS/Emberly/actions/workflows/build.yml/badge.svg)](https://github.com/EmberlyOSS/Emberly/actions/workflows/build.yml) [![CodeQL Advanced](https://github.com/EmberlyOSS/Emberly/actions/workflows/codeql.yml/badge.svg)](https://github.com/EmberlyOSS/Emberly/actions/workflows/codeql.yml) ![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/EmberlyOSS/Emberly?utm_source=oss&utm_medium=github&utm_campaign=EmberlyOSS%2FEmberly&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews) +[![Build Checks](https://github.com/EmberlyOSS/Emberly/actions/workflows/build.yml/badge.svg)](https://github.com/EmberlyOSS/Emberly/actions/workflows/build.yml) [![CodeQL Advanced](https://github.com/EmberlyOSS/Emberly/actions/workflows/codeql.yml/badge.svg)](https://github.com/EmberlyOSS/Emberly/actions/workflows/codeql.yml) ![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/EmberlyOSS/Emberly?utm_source=oss&utm_medium=github&utm_campaign=EmberlyOSS%2FEmberly&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FEmberlyOSS%2FEmberly.svg?type=shield&issueType=security)](https://app.fossa.com/projects/git%2Bgithub.com%2FEmberlyOSS%2FEmberly?ref=badge_shield&issueType=security) ## Features @@ -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,15 +175,13 @@ 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. ## 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. Contributors diff --git a/CHANGELOG.md b/CHANGELOG.md index ebff84e..6d9e23f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,69 @@ 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 (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. + +### 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. +- **TypeScript — Logger argument types in `sync-buckets.ts`** — `BucketSyncStats` was passed directly as a `logger.info` context argument (expects `Record`); fixed by spreading with `{ ...stats }`. Two `logger.warn` calls passed raw `Error` objects where a context object is expected; fixed by passing `{ error: String(err) }`. +- **TypeScript — `PaginationData.pages` property in `user-list.tsx`** — Three references used `.pages` on the `PaginationData` type returned by `useUserManagement`, which defines the property as `pageCount`. Corrected to `.pageCount`. +- **TypeScript — `emailVerified` type mismatch in `app/api/files/route.ts`** — Prisma returns `emailVerified: Date | null` but `AuthenticatedUser` expects `boolean`. The squad-owner user object is now spread with `emailVerified: ownerUser.emailVerified !== null` before assignment. +- **ESLint — `require()` imports in migration scripts** — `scripts/migrate-config.js` and `scripts/hash-file-passwords.js` used CommonJS `require()`, which is forbidden by the project's ESLint config. Both converted to ESM (`import`) and renamed to `.mjs`. +- **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. +- **`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 ### Added diff --git a/app/(main)/[userUrlId]/[filename]/page.tsx b/app/(main)/[userUrlId]/[filename]/page.tsx index 9d0cd4e..0d2dae3 100644 --- a/app/(main)/[userUrlId]/[filename]/page.tsx +++ b/app/(main)/[userUrlId]/[filename]/page.tsx @@ -9,7 +9,6 @@ import { getServerSession } from 'next-auth' import { ProtectedFile } from '@/packages/components/file/protected-file' import { DynamicBackground } from '@/packages/components/layout/dynamic-background' -import { Footer } from '@/packages/components/layout/footer' import { Icons } from '@/packages/components/shared/icons' import { Avatar, @@ -21,7 +20,6 @@ import { Card } from '@/packages/components/ui/card' import { Input } from '@/packages/components/ui/input' import { authOptions } from '@/packages/lib/auth' -import { getConfig } from '@/packages/lib/config' import { prisma } from '@/packages/lib/database/prisma' import { buildDirectMediaMetadata, @@ -103,7 +101,6 @@ export async function generateMetadata({ const urlPath = `/${userUrlId}/${filename}` const headersList = await headers() const session = await getServerSession(authOptions) - const providedPassword = (await searchParams).password as string | undefined // Find the file const file = await findFileByUrlPath(userUrlId, filename, { @@ -167,7 +164,6 @@ export default async function FilePage({ searchParams, }: FilePageProps) { const session = await getServerSession(authOptions) - const config = await getConfig() const { userUrlId, filename } = await params const urlPath = `/${userUrlId}/${filename}` const providedPassword = (await searchParams).password as string | undefined diff --git a/app/(main)/auth/alpha-migration/page.tsx b/app/(main)/auth/alpha-migration/page.tsx index c0ed072..3b7bd48 100644 --- a/app/(main)/auth/alpha-migration/page.tsx +++ b/app/(main)/auth/alpha-migration/page.tsx @@ -3,10 +3,25 @@ import { useEffect, useState } from 'react' import { useSession } from 'next-auth/react' import { useRouter } from 'next/navigation' -import { CheckCircle, Gift, Globe, Loader2, Mail, Send, Sparkles, Shield } from 'lucide-react' +import { + CheckCircle, + Gift, + Globe, + Loader2, + Mail, + Send, + Sparkles, + Shield, +} from 'lucide-react' import { Button } from '@/packages/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/packages/components/ui/card' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/packages/components/ui/card' import { Input } from '@/packages/components/ui/input' import { Label } from '@/packages/components/ui/label' import { Alert, AlertDescription } from '@/packages/components/ui/alert' @@ -14,342 +29,356 @@ import { Badge } from '@/packages/components/ui/badge' import { DynamicBackground } from '@/packages/components/layout/dynamic-background' export default function AlphaMigrationPage() { - const { data: session, status, update } = useSession() - const router = useRouter() - - const [step, setStep] = useState<'confirm' | 'verify' | 'complete'>('confirm') - const [email, setEmail] = useState('') - const [verificationCode, setVerificationCode] = useState('') - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState('') - const [success, setSuccess] = useState('') - const [codeSent, setCodeSent] = useState(false) - - useEffect(() => { - if (status === 'loading') return - - // Not logged in - redirect to login - if (!session?.user) { - router.push('/auth/login') - return - } - - // Set current email - setEmail(session.user.email || '') - }, [session, status, router]) - - const handleSendVerification = async () => { - setIsLoading(true) - setError('') - setSuccess('') - - try { - const response = await fetch('/api/auth/alpha-migration', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, action: 'send-verification' }), - }) - - const data = await response.json() - - if (!response.ok) { - setError(data.error || 'Failed to send verification email') - return - } - - setSuccess('Verification code sent! Check your email.') - setCodeSent(true) - setStep('verify') - } catch { - setError('An error occurred. Please try again.') - } finally { - setIsLoading(false) - } + const { data: session, status, update } = useSession() + const router = useRouter() + + const [step, setStep] = useState<'confirm' | 'verify' | 'complete'>('confirm') + const [email, setEmail] = useState('') + const [verificationCode, setVerificationCode] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + useEffect(() => { + if (status === 'loading') return + + // Not logged in - redirect to login + if (!session?.user) { + router.push('/auth/login') + return } - const handleVerifyCode = async () => { - setIsLoading(true) - setError('') - setSuccess('') - - try { - const response = await fetch('/api/auth/alpha-migration', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, code: verificationCode, action: 'verify' }), - }) - - const data = await response.json() - - if (!response.ok) { - setError(data.error || 'Verification failed') - return - } - - setSuccess('Email verified successfully!') - setStep('complete') - - // Refresh the session to update needsAlphaMigration flag - await update() - - // Redirect to dashboard after a short delay - setTimeout(() => { - router.push('/dashboard') - }, 2000) - } catch { - setError('An error occurred. Please try again.') - } finally { - setIsLoading(false) - } + // Set current email + setEmail(session.user.email || '') + }, [session, status, router]) + + const handleSendVerification = async () => { + setIsLoading(true) + setError('') + setSuccess('') + + try { + const response = await fetch('/api/auth/alpha-migration', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, action: 'send-verification' }), + }) + + const data = await response.json() + + if (!response.ok) { + setError(data.error || 'Failed to send verification email') + return + } + + setSuccess('Verification code sent! Check your email.') + setStep('verify') + } catch { + setError('An error occurred. Please try again.') + } finally { + setIsLoading(false) } - - const handleResendCode = async () => { - setIsLoading(true) - setError('') - - try { - const response = await fetch('/api/auth/alpha-migration', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, action: 'send-verification' }), - }) - - const data = await response.json() - - if (!response.ok) { - setError(data.error || 'Failed to resend code') - return - } - - setSuccess('New verification code sent!') - } catch { - setError('An error occurred. Please try again.') - } finally { - setIsLoading(false) - } + } + + const handleVerifyCode = async () => { + setIsLoading(true) + setError('') + setSuccess('') + + try { + const response = await fetch('/api/auth/alpha-migration', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + code: verificationCode, + action: 'verify', + }), + }) + + const data = await response.json() + + if (!response.ok) { + setError(data.error || 'Verification failed') + return + } + + setSuccess('Email verified successfully!') + setStep('complete') + + // Refresh the session to update needsAlphaMigration flag + await update() + + // Redirect to dashboard after a short delay + setTimeout(() => { + router.push('/dashboard') + }, 2000) + } catch { + setError('An error occurred. Please try again.') + } finally { + setIsLoading(false) } - - if (status === 'loading') { - return ( -
- -
- ) + } + + const handleResendCode = async () => { + setIsLoading(true) + setError('') + + try { + const response = await fetch('/api/auth/alpha-migration', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, action: 'send-verification' }), + }) + + const data = await response.json() + + if (!response.ok) { + setError(data.error || 'Failed to resend code') + return + } + + setSuccess('New verification code sent!') + } catch { + setError('An error occurred. Please try again.') + } finally { + setIsLoading(false) } + } + if (status === 'loading') { return ( -
- -
- {/* Hero Header */} -
-
- - Alpha Supporter +
+ +
+ ) + } + + return ( +
+ +
+ {/* Hero Header */} +
+
+ + Alpha Supporter +
+

+ {step === 'complete' ? 'Welcome Back!' : 'One Quick Step'} +

+

+ {step === 'complete' + ? 'Your account has been upgraded with your alpha supporter benefits.' + : 'Thank you for being an early supporter of Emberly. We just need to verify your email to unlock your account.'} +

+
+ + {/* Alpha Reward Card */} + {step !== 'complete' && ( + + +
+
+ +
+
+
+

Alpha Supporter Reward

+ + Free + +
+

+ As a thank you for being an early adopter, you'll + receive{' '} + + +1 bonus custom domain slot + {' '} + completely free when you verify your email. +

+
+
+ + Custom domain
-

- {step === 'complete' ? 'Welcome Back!' : 'One Quick Step'} -

-

- {step === 'complete' - ? 'Your account has been upgraded with your alpha supporter benefits.' - : 'Thank you for being an early supporter of Emberly. We just need to verify your email to unlock your account.'} -

+
+ + Secured account +
+
- - {/* Alpha Reward Card */} - {step !== 'complete' && ( - - -
-
- -
-
-
-

Alpha Supporter Reward

- Free -
-

- As a thank you for being an early adopter, you'll receive +1 bonus custom domain slot completely free when you verify your email. -

-
-
- - Custom domain -
-
- - Secured account -
-
-
-
-
-
- )} - - {/* Main Card */} - - -
- {step === 'complete' ? ( - - ) : ( - - )} -
- - {step === 'complete' ? 'Migration Complete!' : 'Verify Your Email'} - - - {step === 'complete' - ? 'Your bonus domain slot has been added to your account.' - : 'Verify your email to secure your account and claim your reward.'} - -
- - - {error && ( - - {error} - - )} - - {success && step !== 'complete' && ( - - - {success} - - )} - - {step === 'confirm' && ( -
-
- - setEmail(e.target.value)} - placeholder="your@email.com" - className="h-11" - /> -

- We'll send a 6 digit verification code to this address. -

-
- - -
- )} - - {step === 'verify' && ( -
-
- - setVerificationCode(e.target.value.replace(/\D/g, ''))} - placeholder="000000" - maxLength={6} - className="h-11 text-center text-2xl tracking-[0.5em] font-mono" - /> -

- Enter the code sent to {email} -

-
- - - -
- - -
-
- )} - - {step === 'complete' && ( -
-
-
- - +1 Custom Domain Slot Added -
-
-
- - Redirecting to dashboard... -
-
- )} -
-
- - {/* Footer Note */} - {step !== 'complete' && ( -

- During our alpha stages, we didn't have email verification. This one time step ensures - your account is secure and enables password recovery. -

- )} +
+
+
+ )} + + {/* Main Card */} + + +
+ {step === 'complete' ? ( + + ) : ( + + )}
-
- ) + + {step === 'complete' + ? 'Migration Complete!' + : 'Verify Your Email'} + + + {step === 'complete' + ? 'Your bonus domain slot has been added to your account.' + : 'Verify your email to secure your account and claim your reward.'} + + + + + {error && ( + + {error} + + )} + + {success && step !== 'complete' && ( + + + + {success} + + + )} + + {step === 'confirm' && ( +
+
+ + setEmail(e.target.value)} + placeholder="your@email.com" + className="h-11" + /> +

+ We'll send a 6 digit verification code to this address. +

+
+ + +
+ )} + + {step === 'verify' && ( +
+
+ + + setVerificationCode(e.target.value.replace(/\D/g, '')) + } + placeholder="000000" + maxLength={6} + className="h-11 text-center text-2xl tracking-[0.5em] font-mono" + /> +

+ Enter the code sent to {email} +

+
+ + + +
+ + +
+
+ )} + + {step === 'complete' && ( +
+
+
+ + +1 Custom Domain Slot Added +
+
+
+ + Redirecting to dashboard... +
+
+ )} +
+ + + {/* Footer Note */} + {step !== 'complete' && ( +

+ During our alpha stages, we didn't have email verification. + This one time step ensures your account is secure and enables + password recovery. +

+ )} +
+
+ ) } diff --git a/app/(main)/blog/[slug]/page.tsx b/app/(main)/blog/[slug]/page.tsx index 881f371..ab95c52 100644 --- a/app/(main)/blog/[slug]/page.tsx +++ b/app/(main)/blog/[slug]/page.tsx @@ -4,7 +4,7 @@ import type { Metadata } from 'next' import { format, formatDistanceToNow } from 'date-fns' import GithubSlugger from 'github-slugger' -import { ArrowLeft, Calendar, User, Clock, Share2 } from 'lucide-react' +import { ArrowLeft, Calendar, Clock } from 'lucide-react' import BlogToc, { BlogHeading } from '@/packages/components/shared/BlogToc' import MarkdownRenderer from '@/packages/components/shared/MarkdownRenderer' @@ -14,7 +14,11 @@ import { getPostBySlug } from '@/packages/lib/blog' type ParamsPromise = Promise<{ slug: string }> -export async function generateMetadata({ params }: { params: ParamsPromise }): Promise { +export async function generateMetadata({ + params, +}: { + params: ParamsPromise +}): Promise { const resolved = await params const post = await getPostBySlug(resolved.slug, true) @@ -73,12 +77,20 @@ export default async function PostPage({ params }: { params: ParamsPromise }) { const readTime = estimateReadTime(post.content || '') return ( - +
{/* Back button */}
- @@ -115,7 +127,9 @@ export default async function PostPage({ params }: { params: ParamsPromise }) {
{post.author?.name ?? 'Unknown author'}
-
Author
+
+ Author +
@@ -127,9 +141,15 @@ export default async function PostPage({ params }: { params: ParamsPromise }) { {post.publishedAt && (
- {format(new Date(post.publishedAt), 'MMMM d, yyyy')} + + {format(new Date(post.publishedAt), 'MMMM d, yyyy')} + - ({formatDistanceToNow(new Date(post.publishedAt), { addSuffix: true })}) + ( + {formatDistanceToNow(new Date(post.publishedAt), { + addSuffix: true, + })} + )
)} @@ -157,13 +177,13 @@ export default async function PostPage({ params }: { params: ParamsPromise }) {

Enjoyed this article?

-

Share it with others or check out more posts.

+

+ Share it with others or check out more posts. +

- +
diff --git a/app/(main)/blog/page.tsx b/app/(main)/blog/page.tsx index 12780ab..ddfad54 100644 --- a/app/(main)/blog/page.tsx +++ b/app/(main)/blog/page.tsx @@ -1,7 +1,14 @@ import Link from 'next/link' import { format, formatDistanceToNow } from 'date-fns' -import { Calendar, User, ArrowRight, BookOpen, MessageCircle, ExternalLink, FileText } from 'lucide-react' +import { + Calendar, + ArrowRight, + BookOpen, + MessageCircle, + ExternalLink, + FileText, +} from 'lucide-react' import { listPosts } from '@/packages/lib/blog' import PageShell from '@/packages/components/layout/PageShell' @@ -10,14 +17,19 @@ import { buildPageMetadata } from '@/packages/lib/embeds/metadata' export const metadata = buildPageMetadata({ title: 'Blog', - description: 'News, tips and updates about Emberly and file sharing best practices.', + description: + 'News, tips and updates about Emberly and file sharing best practices.', }) export default async function BlogListPage() { const posts = await listPosts({ publishedOnly: true, limit: 20, offset: 0 }) return ( - +
{/* Main content - Blog posts */} @@ -30,15 +42,20 @@ export default async function BlogListPage() {

No posts yet

-

Check back soon for updates!

+

+ Check back soon for updates! +

) : ( posts.map((p, index) => ( - +
- {/* Featured badge for first post */} {index === 0 && (
@@ -88,9 +105,15 @@ export default async function BlogListPage() { {p.publishedAt && (
- {format(new Date(p.publishedAt), 'MMM d, yyyy')} + + {format(new Date(p.publishedAt), 'MMM d, yyyy')} + - ({formatDistanceToNow(new Date(p.publishedAt), { addSuffix: true })}) + ( + {formatDistanceToNow(new Date(p.publishedAt), { + addSuffix: true, + })} + )
)} @@ -122,7 +145,9 @@ export default async function BlogListPage() {

About this blog

- Announcements, how-to guides, and updates from the Emberly team. Stay up to date with the latest features and best practices. + Announcements, how-to guides, and updates from the Emberly + team. Stay up to date with the latest features and best + practices.

@@ -130,7 +155,9 @@ export default async function BlogListPage() { {/* Quick Links card */}
-

Quick Links

+

+ Quick Links +

  • -
    {posts.length}
    -
    Published articles
    +
    + {posts.length} +
    +
    + Published articles +
diff --git a/app/(main)/dashboard/bucket/page.tsx b/app/(main)/dashboard/bucket/page.tsx index b8cbc81..2bb6b20 100644 --- a/app/(main)/dashboard/bucket/page.tsx +++ b/app/(main)/dashboard/bucket/page.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation' import { getServerSession } from 'next-auth' -import { AlertTriangle, Copy, Key, Server } from 'lucide-react' +import { AlertTriangle, Key, Server } from 'lucide-react' import { authOptions } from '@/packages/lib/auth' import { prisma } from '@/packages/lib/database/prisma' @@ -13,12 +13,26 @@ export const metadata = buildPageMetadata({ description: 'View your dedicated S3 storage bucket credentials and status.', }) -function CredentialRow({ label, value, mono = true }: { label: string; value: string; mono?: boolean }) { +function CredentialRow({ + label, + value, + mono = true, +}: { + label: string + value: string + mono?: boolean +}) { return (
- {label} + + {label} +
- {value} + + {value} +
) @@ -76,10 +90,16 @@ export default async function BucketPage() { let autoProvisionFailed = false if (!bucket && user && activeStorageSub?.stripeSubscriptionId) { - const metadata = (activeStorageSub.metadata || {}) as Record - const region = typeof metadata.location === 'string' ? metadata.location : null - const tierFromMetadata = typeof metadata.tier === 'string' ? metadata.tier : null - const tierFromSlug = activeStorageSub.product.slug?.replace('storage-bucket-', '') || null + const metadata = (activeStorageSub.metadata || {}) as Record< + string, + unknown + > + const region = + typeof metadata.location === 'string' ? metadata.location : null + const tierFromMetadata = + typeof metadata.tier === 'string' ? metadata.tier : null + const tierFromSlug = + activeStorageSub.product.slug?.replace('storage-bucket-', '') || null try { await provisionBucketForUserSubscription({ @@ -117,21 +137,25 @@ export default async function BucketPage() { if (!bucket) { return ( - -
-
-
- + +
+
+
+ +
+

+ Storage Bucket +

-

Storage Bucket

+

+ Dedicated S3-compatible storage with unlimited capacity. +

-

- Dedicated S3-compatible storage with unlimited capacity. -

-
- }> + } + > {/* No bucket assigned */}
@@ -142,16 +166,20 @@ export default async function BucketPage() {

No bucket assigned yet

{hasStorageSubscription ? (

- Your storage subscription is active, but your bucket credentials are not visible yet. + Your storage subscription is active, but your bucket + credentials are not visible yet. {autoProvisionFailed ? ' Automatic provisioning is still pending. Please refresh in a minute, and contact support if it remains unavailable.' : ' We attempted automatic provisioning for this page load. Please refresh in a few seconds.'}

) : (

- You don't have a dedicated storage bucket assigned to your account. Purchase the - Storage Bucket add-on on the{' '} - + You don't have a dedicated storage bucket assigned to + your account. Purchase the Storage Bucket add-on on the{' '} + pricing page {' '} to instantly provision your bucket. @@ -171,30 +199,39 @@ export default async function BucketPage() { : `${bucket.s3AccessKeyId.slice(0, 4)}••••` return ( - -

-
-
- -
-
-

Storage Bucket

-

{bucket.name}

+ +
+
+
+ +
+
+

+ Storage Bucket +

+

{bucket.name}

+
+

+ Your dedicated S3-compatible bucket. All storage quotas and upload + size limits are removed while your subscription is active. +

-

- Your dedicated S3-compatible bucket. All storage quotas and upload size limits are removed while your subscription is active. -

-
- }> + } + > {/* Security notice */}
- Keep your credentials safe. Never expose your Secret Key publicly. If you believe your credentials have been compromised, contact{' '} - + Keep your credentials safe. Never expose your Secret Key publicly. If + you believe your credentials have been compromised, contact{' '} + support@embrly.ca {' '} immediately. @@ -211,11 +248,23 @@ export default async function BucketPage() { - + {bucket.s3ForcePathStyle !== undefined && ( - + )} - +
@@ -224,8 +273,12 @@ export default async function BucketPage() {

Need help?

- Check your email for the full credentials sent when your bucket was assigned. For troubleshooting or to rotate your access key, contact{' '} - + Check your email for the full credentials sent when your bucket was + assigned. For troubleshooting or to rotate your access key, contact{' '} + support@embrly.ca . diff --git a/app/(main)/dashboard/client.tsx b/app/(main)/dashboard/client.tsx index 5381bef..6ed4701 100644 --- a/app/(main)/dashboard/client.tsx +++ b/app/(main)/dashboard/client.tsx @@ -75,7 +75,7 @@ const quickActions = [ ] export function DashboardIndex({ - userName, + userName: _userName, fileCount, urlCount, storageUsed, diff --git a/app/(main)/dashboard/squads/client.tsx b/app/(main)/dashboard/squads/client.tsx index 25d8f3f..c77ae11 100644 --- a/app/(main)/dashboard/squads/client.tsx +++ b/app/(main)/dashboard/squads/client.tsx @@ -37,7 +37,12 @@ type SquadIncomingInvite = { _count: { members: number } maxSize: number } - invitedBy: { id: string; name: string | null; image: string | null; urlId: string } + invitedBy: { + id: string + name: string | null + image: string | null + urlId: string + } } type Squad = { @@ -63,26 +68,63 @@ const STATUS_COLORS: Record = { // -- Glass card wrappers ----------------------------------------------------- -function GlassCard({ children, className = '' }: { children: React.ReactNode; className?: string }) { - return

{children}
+function GlassCard({ + children, + className = '', +}: { + children: React.ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) } -function GlassCardHeader({ children, className = '' }: { children: React.ReactNode; className?: string }) { - return
{children}
+function GlassCardHeader({ + children, + className = '', +}: { + children: React.ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) } -function GlassCardTitle({ children, className = '' }: { children: React.ReactNode; className?: string }) { - return

{children}

+function GlassCardTitle({ + children, + className = '', +}: { + children: React.ReactNode + className?: string +}) { + return ( +

+ {children} +

+ ) } -function GlassCardContent({ children, className = '' }: { children: React.ReactNode; className?: string }) { +function GlassCardContent({ + children, + className = '', +}: { + children: React.ReactNode + className?: string +}) { return
{children}
} // -- Incoming invites section ----------------------------------------------- function IncomingInvites() { - const { toast } = useToast() const [invites, setInvites] = useState([]) const [loaded, setLoaded] = useState(false) @@ -114,7 +156,9 @@ function IncomingInvites() { {invites.length}
-

You've been invited to join these squads

+

+ You've been invited to join these squads +

{invites.map((inv) => ( @@ -174,7 +218,11 @@ function SquadsList() { const data = await res.json() setSquads(data.data?.squads ?? []) } catch { - toast({ title: 'Error', description: 'Failed to load squads', variant: 'destructive' }) + toast({ + title: 'Error', + description: 'Failed to load squads', + variant: 'destructive', + }) } finally { setLoading(false) } @@ -195,7 +243,9 @@ function SquadsList() { }) if (!res.ok) { const err = await res.json().catch(() => ({})) - throw new Error((err as { error?: string }).error || 'Failed to create squad') + throw new Error( + (err as { error?: string }).error || 'Failed to create squad' + ) } toast({ title: 'Squad created' }) setNewName('') @@ -204,7 +254,8 @@ function SquadsList() { } catch (err) { toast({ title: 'Error', - description: err instanceof Error ? err.message : 'Failed to create squad', + description: + err instanceof Error ? err.message : 'Failed to create squad', variant: 'destructive', }) } finally { @@ -226,7 +277,8 @@ function SquadsList() {
@@ -238,13 +290,17 @@ function SquadsList() {

Early Access

- + Nexium

- Organized hubs for your team's output — share uploads, domains, and resources with your squad. - Available now for all users with public profiles. + Organized hubs for your team's output — share uploads, + domains, and resources with your squad. Available now for all + users with public profiles.

@@ -256,7 +312,11 @@ function SquadsList() {

{squads.length} squad{squads.length !== 1 ? 's' : ''}

-
@@ -272,10 +332,18 @@ function SquadsList() { onKeyDown={(e) => e.key === 'Enter' && handleCreate()} className="h-8 text-sm" /> - -
@@ -284,7 +352,9 @@ function SquadsList() { {/* Squad list */} {loading ? ( -
Loading squads...
+
+ Loading squads... +
) : squads.length === 0 ? (
@@ -292,9 +362,14 @@ function SquadsList() {

No squads yet

- Create a squad to share uploads, domains, and resources with your team. + Create a squad to share uploads, domains, and resources with your + team.

-
@@ -312,19 +387,25 @@ function SquadsList() { {squad.name}

{squad.description && ( -

{squad.description}

+

+ {squad.description} +

)}
- + {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/app/api/admin/integrations/test/route.ts b/app/api/admin/integrations/test/route.ts index e45a560..4f21383 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 @@ -73,12 +60,23 @@ 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 { 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), + } } } @@ -87,48 +85,111 @@ 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' } - 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', + }, + signal: AbortSignal.timeout(8000), }) 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}` }, + signal: AbortSignal.timeout(8000), + } + ) 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 +204,47 @@ 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})` } + 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) + 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 +255,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' } @@ -192,35 +277,28 @@ async function testGitHub(pat: string, org?: string): Promise { Accept: 'application/vnd.github+json', '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 +309,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 +349,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 +375,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 +412,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..652872e 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,11 +64,12 @@ export async function POST(req: Request) { role: true, randomizeFileUrls: true, preferredUploadDomain: true, + emailVerified: true, }, }) if (!ownerUser) return apiError('Squad owner not found', HTTP_STATUS.UNAUTHORIZED) - user = ownerUser + user = { ...ownerUser, emailVerified: ownerUser.emailVerified !== null } squadContext = squad } else { const auth = await requireAuth(req) @@ -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,37 @@ 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, + }) + 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, + }) + }) + if (expirationDate) { try { await scheduleFileExpiration( @@ -300,12 +324,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 +410,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 +516,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 +613,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..4c01b05 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -5,7 +5,7 @@ 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 { requireAuth, requireSuperAdmin } from '@/packages/lib/auth/api-auth' import { EmberlyConfig, getConfig, @@ -31,12 +31,25 @@ 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 = '' + } + // Remove any stale legacy integration keys that may still live in DB config + delete i.kener + delete i.uptimeKuma return c as EmberlyConfig } @@ -182,4 +195,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/package.json b/package.json index 00a6a6f..6393f3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@emberly/website", - "version": "2.4.4", + "version": "2.4.6", "license": "AGPL-3.0-only", "author": "CodeMeAPixel", "homepage": "https://github.com/EmberlyOSS/Emberly", 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/dashboard/user-list.tsx b/packages/components/dashboard/user-list.tsx index e39f250..6fa5446 100644 --- a/packages/components/dashboard/user-list.tsx +++ b/packages/components/dashboard/user-list.tsx @@ -2412,7 +2412,7 @@ export function UserList() { - {pagination && pagination.pages > 1 && ( + {pagination && pagination.pageCount > 1 && (
@@ -2429,19 +2429,21 @@ export function UserList() { - {getPaginationRange(currentPage, pagination.pages).map((p) => ( - - ) => { - e.preventDefault() - fetchUsers(p) - }} - isActive={p === currentPage} - > - {p} - - - ))} + {getPaginationRange(currentPage, pagination.pageCount).map( + (p) => ( + + ) => { + e.preventDefault() + fetchUsers(p) + }} + isActive={p === currentPage} + > + {p} + + + ) + )} 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/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/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..37c7d5b 100644 --- a/packages/lib/cache/session-cache.ts +++ b/packages/lib/cache/session-cache.ts @@ -11,181 +11,210 @@ 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 + 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, + }) + 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..a7d28ef 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 (Object(val) === val && typeof val === 'object') { + 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> { + if (fileIds.length === 0) return new Map() + + const idSet = new Set(fileIds) + const scheduledEvents = await events.getEvents({ + type: 'file.schedule-expiration', + status: EventStatus.SCHEDULED, + }) + + const result = new Map() + for (const event of scheduledEvents) { + const fileId = (event.payload as Record)?.fileId as + | string + | undefined + if (fileId && idSet.has(fileId) && event.scheduledAt) { + result.set(fileId, event.scheduledAt) + } + } + return result +} diff --git a/packages/lib/files/filename.ts b/packages/lib/files/filename.ts index d573a15..1eedbee 100644 --- a/packages/lib/files/filename.ts +++ b/packages/lib/files/filename.ts @@ -28,9 +28,6 @@ function validateAndNormalizePath(basePath: string, filename: string): string { } const fullPath = join(cleanBase, cleanFilename) - // On Windows `path.join` returns backslashes while `cleanBase` uses - // forward slashes. Normalize both sides to forward-slash form before - // comparing to avoid false positives for path traversal detection. const fullPathNormalized = fullPath.replace(/\\/g, '/').replace(/\/+/g, '/') const cleanBaseNormalized = cleanBase.replace(/\\/g, '/').replace(/\/+/g, '/') @@ -98,10 +95,13 @@ export async function getUniqueFilename( ? originalName.slice(0, originalName.lastIndexOf('.')) : originalName - let urlSafeName = baseNameWithoutExt - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') + let urlSafeName = baseNameWithoutExt.toLowerCase().replace(/[^a-z0-9]+/g, '-') + let s = 0, + e = urlSafeName.length + 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/files/security-validation.ts b/packages/lib/files/security-validation.ts index 5236861..d36236a 100644 --- a/packages/lib/files/security-validation.ts +++ b/packages/lib/files/security-validation.ts @@ -9,21 +9,84 @@ import { extname } from 'path' import { createHash } from 'crypto' const DANGEROUS_EXTENSIONS = new Set([ - '.exe', '.bat', '.cmd', '.com', '.scr', '.vbs', '.js', '.jse', '.vbe', - '.ps1', '.ps2', '.psc1', '.psc2', '.msh', '.msh1', '.msh2', '.mshxml', - '.msh1xml', '.msh2xml', '.elf', '.bin', '.sh', '.bash', '.csh', '.ksh', - '.zsh', '.pl', '.py', '.rb', '.php', '.asp', '.aspx', '.jsp', '.jspx', - '.cfm', '.cfc', '.docm', '.xlsm', '.pptm', '.potm', '.ppam', '.ppsm', - '.sldm', '.dll', '.so', '.dylib', '.o', '.a', '.lib', '.msi', '.msp', - '.cab', '.jar', '.class', '.lnk', '.url', '.scf', '.inf', '.reg', '.hta', '.chm', + '.exe', + '.bat', + '.cmd', + '.com', + '.scr', + '.vbs', + '.js', + '.jse', + '.vbe', + '.ps1', + '.ps2', + '.psc1', + '.psc2', + '.msh', + '.msh1', + '.msh2', + '.mshxml', + '.msh1xml', + '.msh2xml', + '.elf', + '.bin', + '.sh', + '.bash', + '.csh', + '.ksh', + '.zsh', + '.pl', + '.py', + '.rb', + '.php', + '.asp', + '.aspx', + '.jsp', + '.jspx', + '.cfm', + '.cfc', + '.docm', + '.xlsm', + '.pptm', + '.potm', + '.ppam', + '.ppsm', + '.sldm', + '.dll', + '.so', + '.dylib', + '.o', + '.a', + '.lib', + '.msi', + '.msp', + '.cab', + '.jar', + '.class', + '.lnk', + '.url', + '.scf', + '.inf', + '.reg', + '.hta', + '.chm', ]) const DANGEROUS_MIME_TYPES = new Set([ - 'application/x-msdownload', 'application/x-msdos-program', 'application/x-executable', - 'application/x-elf-executable', 'application/x-sharedlib', 'application/x-object', - 'application/x-shellscript', 'application/x-bash', 'application/x-perl', - 'application/x-python', 'application/x-ruby', 'application/x-php', - 'application/x-asp', 'application/x-jsp', + 'application/x-msdownload', + 'application/x-msdos-program', + 'application/x-executable', + 'application/x-elf-executable', + 'application/x-sharedlib', + 'application/x-object', + 'application/x-shellscript', + 'application/x-bash', + 'application/x-perl', + 'application/x-python', + 'application/x-ruby', + 'application/x-php', + 'application/x-asp', + 'application/x-jsp', 'application/vnd.ms-excel.addin.macroEnabled.12', 'application/vnd.ms-word.document.macroEnabled.12', 'application/vnd.ms-powerpoint.presentation.macroEnabled.12', @@ -49,7 +112,10 @@ export interface VirusTotalScanResult { /** * Check for dangerous extensions and MIME types */ -function checkBasicSecurity(filename: string, mimeType: string): FileSecurityCheckResult { +function checkBasicSecurity( + filename: string, + mimeType: string +): FileSecurityCheckResult { const ext = extname(filename).toLowerCase() if (DANGEROUS_EXTENSIONS.has(ext)) { return { @@ -71,7 +137,10 @@ function checkBasicSecurity(filename: string, mimeType: string): FileSecurityChe /** * Detect zip bombs by analyzing compression ratio */ -function checkZipBomb(buffer: Buffer, filename: string): FileSecurityCheckResult { +function checkZipBomb( + buffer: Buffer, + filename: string +): FileSecurityCheckResult { if (!filename.toLowerCase().endsWith('.zip') || buffer.length < 4) { return { valid: true } } @@ -96,10 +165,17 @@ function checkZipBomb(buffer: Buffer, filename: string): FileSecurityCheckResult uncompressedTotal += uncompressedSize if (fileCount > 10000) { - return { valid: false, error: 'Archive contains too many files (>10k). Potential zip bomb.' } + return { + valid: false, + error: + 'Archive contains too many files (>10k). Potential zip bomb.', + } } if (uncompressedTotal > 50 * 1024 * 1024 * 1024) { - return { valid: false, error: 'Archive expands to >50GB. Potential zip bomb detected.' } + return { + valid: false, + error: 'Archive expands to >50GB. Potential zip bomb detected.', + } } const filenameLen = buffer.readUInt16LE(cdPos + 26) @@ -118,7 +194,10 @@ function checkZipBomb(buffer: Buffer, filename: string): FileSecurityCheckResult if (buffer.length > 0 && uncompressedTotal > 0) { const ratio = uncompressedTotal / buffer.length if (ratio > 100) { - return { valid: false, error: `Suspicious compression ratio (${ratio.toFixed(1)}:1). Potential zip bomb.` } + return { + valid: false, + error: `Suspicious compression ratio (${ratio.toFixed(1)}:1). Potential zip bomb.`, + } } } @@ -131,32 +210,51 @@ function checkZipBomb(buffer: Buffer, filename: string): FileSecurityCheckResult /** * Check VirusTotal for known malware (hash-based, no upload) */ -async function checkVirusTotal(buffer: Buffer, mimeType: string): Promise { +async function checkVirusTotal( + buffer: Buffer, + mimeType: string +): Promise { const apiKey = process.env.VIRUSTOTAL_API_KEY if (!apiKey) { return { scanPerformed: false, detected: false } } // Skip scanning images, videos, audio - if (mimeType.startsWith('image/') || mimeType.startsWith('video/') || mimeType.startsWith('audio/')) { + if ( + mimeType.startsWith('image/') || + mimeType.startsWith('video/') || + mimeType.startsWith('audio/') + ) { return { scanPerformed: false, detected: false } } try { const hash = createHash('sha256').update(buffer).digest('hex') - const response = await fetch(`https://www.virustotal.com/api/v3/files/${hash}`, { - method: 'GET', - headers: { 'x-apikey': apiKey }, - }) + const response = await fetch( + `https://www.virustotal.com/api/v3/files/${hash}`, + { + method: 'GET', + headers: { 'x-apikey': apiKey }, + } + ) if (response.status === 404) { return { scanPerformed: true, detected: false } } if (response.status === 429) { - return { scanPerformed: false, detected: false, rateLimited: true, error: 'VirusTotal rate limit reached.' } + return { + scanPerformed: false, + detected: false, + rateLimited: true, + error: 'VirusTotal rate limit reached.', + } } if (!response.ok) { - return { scanPerformed: false, detected: false, error: `VirusTotal API error: ${response.statusText}` } + return { + scanPerformed: false, + detected: false, + error: `VirusTotal API error: ${response.statusText}`, + } } const data = (await response.json()) as any @@ -166,7 +264,10 @@ async function checkVirusTotal(buffer: Buffer, mimeType: string): Promise a + (typeof b === 'number' ? b : 0), 0) + const totalCount = Object.values(stats).reduce( + (a: number, b: any) => a + (typeof b === 'number' ? b : 0), + 0 + ) return { scanPerformed: true, @@ -177,32 +278,58 @@ async function checkVirusTotal(buffer: Buffer, mimeType: string): Promise { - // Local checks first +): FileSecurityCheckResult { const basicCheck = checkBasicSecurity(filename, mimeType) - if (!basicCheck.valid) { - return basicCheck - } - + if (!basicCheck.valid) return basicCheck const zipCheck = checkZipBomb(buffer, filename) - if (!zipCheck.valid) { - return zipCheck - } + if (!zipCheck.valid) return zipCheck + return { valid: true } +} - // VirusTotal check +/** + * VirusTotal hash lookup — network call, runs in the background after upload. + * On detection, calls onDetected(result) so the caller can quarantine the file. + */ +export async function scanWithVirusTotal( + buffer: Buffer, + mimeType: string, + onDetected: (result: VirusTotalScanResult) => Promise +): Promise { const vtResult = await checkVirusTotal(buffer, mimeType) + if (vtResult.detected) { + await onDetected(vtResult) + } +} +/** + * @deprecated Use validateFileSecurityChecks + scanWithVirusTotal instead. + * Kept for compatibility with chunked upload routes. + */ +export async function validateFileSecurityChecksWithVT( + buffer: Buffer, + filename: string, + mimeType: string +): Promise { + const localResult = validateFileSecurityChecks(buffer, filename, mimeType) + if (!localResult.valid) return localResult + + const vtResult = await checkVirusTotal(buffer, mimeType) if (vtResult.detected) { return { valid: false, @@ -210,7 +337,6 @@ export async function validateFileSecurityChecksWithVT( virusTotal: vtResult, } } - return { valid: true, virusTotal: vtResult.scanPerformed ? vtResult : undefined, diff --git a/packages/lib/files/upload-validation.ts b/packages/lib/files/upload-validation.ts index f7c397b..772e7dc 100644 --- a/packages/lib/files/upload-validation.ts +++ b/packages/lib/files/upload-validation.ts @@ -11,9 +11,9 @@ import { prisma } from '@/packages/lib/database/prisma' import { hasPermission, Permission } from '@/packages/lib/permissions' export interface UploadValidationResult { - valid: boolean - error?: string - errorCode?: 'EMAIL_NOT_VERIFIED' | 'DOMAIN_NOT_VERIFIED' | 'DOMAIN_NOT_FOUND' + valid: boolean + error?: string + errorCode?: 'EMAIL_NOT_VERIFIED' | 'DOMAIN_NOT_VERIFIED' | 'DOMAIN_NOT_FOUND' } /** @@ -21,30 +21,43 @@ export interface UploadValidationResult { * Email verification is ALWAYS required before uploading. * Returns validation result with appropriate error if not verified. */ -export async function validateEmailVerified(userId: string): Promise { - const user = await prisma.user.findUnique({ +export async function validateEmailVerified( + userId: string, + preloaded?: { emailVerified: boolean; role: string } +): Promise { + const userData = + preloaded ?? + (await (async () => { + const user = await prisma.user.findUnique({ where: { id: userId }, select: { emailVerified: true, role: true }, - }) - - if (!user) { - return { valid: false, error: 'User not found', errorCode: 'EMAIL_NOT_VERIFIED' } + }) + if (!user) return null + return { emailVerified: !!user.emailVerified, role: user.role } + })()) + + if (!userData) { + return { + valid: false, + error: 'User not found', + errorCode: 'EMAIL_NOT_VERIFIED', } + } - // Admins and higher roles bypass email verification requirement - if (hasPermission(user.role as any, Permission.MODERATE_CONTENT)) { - return { valid: true } - } + // Admins and higher roles bypass email verification requirement + if (hasPermission(userData.role as any, Permission.MODERATE_CONTENT)) { + return { valid: true } + } - if (!user.emailVerified) { - return { - valid: false, - error: 'Please verify your email address before uploading files', - errorCode: 'EMAIL_NOT_VERIFIED', - } + if (!userData.emailVerified) { + return { + valid: false, + error: 'Please verify your email address before uploading files', + errorCode: 'EMAIL_NOT_VERIFIED', } + } - return { valid: true } + return { valid: true } } /** @@ -53,66 +66,77 @@ export async function validateEmailVerified(userId: string): Promise { - // No custom domain specified, no validation needed - if (!requestedDomain) { - return { valid: true } - } - - // Clean domain (remove protocol/trailing slash) - const cleanDomain = requestedDomain - .replace(/^https?:\/\//, '') - .replace(/\/+$/, '') - .toLowerCase() - - // Check if domain is the main app domain - no validation needed - const appDomain = (process.env.APP_BASE_URL || process.env.NEXTAUTH_URL || '') - .replace(/^https?:\/\//, '') - .replace(/\/+$/, '') - .toLowerCase() - - if (cleanDomain === appDomain || cleanDomain === 'localhost' || cleanDomain.startsWith('localhost:')) { - return { valid: true } - } - - // Check if this is a verified custom domain owned by the user OR one of their squads - const domainRecord = await prisma.customDomain.findFirst({ - where: { - domain: cleanDomain, - OR: [ - { userId }, - { - squad: { - members: { some: { userId } }, - }, - }, - ], - }, - select: { - verified: true, - domain: true, + // No custom domain specified, no validation needed + if (!requestedDomain) { + return { valid: true } + } + + const trimTrailingSlashes = (s: string) => { + let end = s.length + while (end > 0 && s[end - 1] === '/') end-- + return end === s.length ? s : s.slice(0, end) + } + + // Clean domain (remove protocol/trailing slash) + const cleanDomain = trimTrailingSlashes( + requestedDomain.replace(/^https?:\/\//, '') + ).toLowerCase() + + // Check if domain is the main app domain - no validation needed + const appDomain = trimTrailingSlashes( + (process.env.APP_BASE_URL || process.env.NEXTAUTH_URL || '').replace( + /^https?:\/\//, + '' + ) + ).toLowerCase() + + if ( + cleanDomain === appDomain || + cleanDomain === 'localhost' || + cleanDomain.startsWith('localhost:') + ) { + return { valid: true } + } + + // Check if this is a verified custom domain owned by the user OR one of their squads + const domainRecord = await prisma.customDomain.findFirst({ + where: { + domain: cleanDomain, + OR: [ + { userId }, + { + squad: { + members: { some: { userId } }, + }, }, - }) - - if (!domainRecord) { - return { - valid: false, - error: `Domain "${cleanDomain}" is not registered to your account`, - errorCode: 'DOMAIN_NOT_FOUND', - } + ], + }, + select: { + verified: true, + domain: true, + }, + }) + + if (!domainRecord) { + return { + valid: false, + error: `Domain "${cleanDomain}" is not registered to your account`, + errorCode: 'DOMAIN_NOT_FOUND', } + } - if (!domainRecord.verified) { - return { - valid: false, - error: `Domain "${cleanDomain}" has not been verified yet. Please complete domain verification first.`, - errorCode: 'DOMAIN_NOT_VERIFIED', - } + if (!domainRecord.verified) { + return { + valid: false, + error: `Domain "${cleanDomain}" has not been verified yet. Please complete domain verification first.`, + errorCode: 'DOMAIN_NOT_VERIFIED', } + } - return { valid: true } + return { valid: true } } /** @@ -120,47 +144,58 @@ export async function validateCustomDomain( * The domain must belong to the squad itself. */ export async function validateSquadCustomDomain( - squadId: string, - requestedDomain: string | null + squadId: string, + requestedDomain: string | null ): Promise { - if (!requestedDomain) return { valid: true } - - const cleanDomain = requestedDomain - .replace(/^https?:\/\//, '') - .replace(/\/+$/, '') - .toLowerCase() - - const appDomain = (process.env.APP_BASE_URL || process.env.NEXTAUTH_URL || '') - .replace(/^https?:\/\//, '') - .replace(/\/+$/, '') - .toLowerCase() - - if (cleanDomain === appDomain || cleanDomain === 'localhost' || cleanDomain.startsWith('localhost:')) { - return { valid: true } - } - - const domainRecord = await prisma.customDomain.findFirst({ - where: { domain: cleanDomain, squadId }, - select: { verified: true, domain: true }, - }) - - if (!domainRecord) { - return { - valid: false, - error: `Domain "${cleanDomain}" is not registered to this squad`, - errorCode: 'DOMAIN_NOT_FOUND', - } + if (!requestedDomain) return { valid: true } + + 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 cleanDomain = trimTrailingSlashes( + requestedDomain.replace(/^https?:\/\//, '') + ).toLowerCase() + + const appDomain = trimTrailingSlashes( + (process.env.APP_BASE_URL || process.env.NEXTAUTH_URL || '').replace( + /^https?:\/\//, + '' + ) + ).toLowerCase() + + if ( + cleanDomain === appDomain || + cleanDomain === 'localhost' || + cleanDomain.startsWith('localhost:') + ) { + return { valid: true } + } + + const domainRecord = await prisma.customDomain.findFirst({ + where: { domain: cleanDomain, squadId }, + select: { verified: true, domain: true }, + }) + + if (!domainRecord) { + return { + valid: false, + error: `Domain "${cleanDomain}" is not registered to this squad`, + errorCode: 'DOMAIN_NOT_FOUND', } + } - if (!domainRecord.verified) { - return { - valid: false, - error: `Domain "${cleanDomain}" has not been verified yet`, - errorCode: 'DOMAIN_NOT_VERIFIED', - } + if (!domainRecord.verified) { + return { + valid: false, + error: `Domain "${cleanDomain}" has not been verified yet`, + errorCode: 'DOMAIN_NOT_VERIFIED', } + } - return { valid: true } + return { valid: true } } /** @@ -168,20 +203,21 @@ export async function validateSquadCustomDomain( * Returns the first validation error encountered, or valid: true if all pass. */ export async function validateUploadRequest( - userId: string, - requestedDomain: string | null + userId: string, + requestedDomain: string | null, + preloadedUser?: { emailVerified: boolean; role: string } ): Promise { - // Check email verification - const emailResult = await validateEmailVerified(userId) - if (!emailResult.valid) { - return emailResult - } - - // Check custom domain verification - const domainResult = await validateCustomDomain(userId, requestedDomain) - if (!domainResult.valid) { - return domainResult - } - - return { valid: true } + // Check email verification (skip DB query if caller already has user data) + const emailResult = await validateEmailVerified(userId, preloadedUser) + if (!emailResult.valid) { + return emailResult + } + + // Check custom domain verification + const domainResult = await validateCustomDomain(userId, requestedDomain) + if (!domainResult.valid) { + return domainResult + } + + return { valid: true } } diff --git a/packages/lib/kener/index.ts b/packages/lib/kener/index.ts index 1a01b23..5c4db60 100644 --- a/packages/lib/kener/index.ts +++ b/packages/lib/kener/index.ts @@ -1,178 +1 @@ -/** - * Kener API Client - * Fetches status data from a self-hosted Kener instance (https://kener.ing) - * - * API docs: https://kener.ing/docs/spec/v4 - * Configured via admin settings → integrations → kener (apiKey + baseUrl) - */ - -import { cache } from 'react' - -import { logger } from '@/packages/lib/logger' -import { getIntegrations } from '@/packages/lib/config' - -// ────────────────────────────────────────────── -// Kener API types (v4) -// ────────────────────────────────────────────── - -export type KenerMonitorStatus = 'UP' | 'DOWN' | 'DEGRADED' | 'UNKNOWN' - -export interface KenerMonitor { - tag: string - name: string - description?: string - status: KenerMonitorStatus - category_name?: string - monitor_type?: string - is_hidden?: string | boolean - [key: string]: unknown -} - -export interface KenerPageResponse { - page: { - page_path: string - page_title?: string - monitors: KenerMonitor[] - [key: string]: unknown - } -} - -export interface KenerStatusSummary { - page: { - name: string - url: string - /** Aggregated overall status */ - status: KenerMonitorStatus - } - activeIncidents: KenerIncident[] - activeMaintenances: KenerIncident[] -} - -export interface KenerIncident { - id: string - name: string - status?: string - started?: string - url?: string -} - -// ────────────────────────────────────────────── -// Config helper -// ────────────────────────────────────────────── - -async function getKenerConfig() { - const integrations = await getIntegrations() - const kener = integrations.kener as { apiKey?: string; baseUrl?: string } | undefined - return { - baseUrl: (kener?.baseUrl ?? process.env.KENER_BASE_URL ?? 'https://emberlystat.us').replace(/\/$/, ''), - apiKey: kener?.apiKey ?? process.env.KENER_API_KEY ?? '', - } -} - -// Cache TTL: 60 s -const CACHE_TTL = 60 - -// ────────────────────────────────────────────── -// Fetch helpers -// ────────────────────────────────────────────── - -async function fetchKener( - path: string, - config: Awaited>, -): Promise { - const url = `${config.baseUrl}${path}` - try { - const res = await fetch(url, { - headers: config.apiKey - ? { Authorization: `Bearer ${config.apiKey}` } - : {}, - next: { revalidate: CACHE_TTL, tags: ['status'] }, - }) - if (!res.ok) { - logger.error(`Kener API error: ${res.status} ${res.statusText}`, { url }) - return null - } - return await res.json() - } catch (err) { - logger.error('Failed to fetch from Kener', { - url, - error: err instanceof Error ? err.message : 'Unknown error', - }) - return null - } -} - -// ────────────────────────────────────────────── -// Aggregate status from monitor list -// ────────────────────────────────────────────── - -function aggregateStatus(monitors: KenerMonitor[]): KenerMonitorStatus { - if (monitors.length === 0) return 'UNKNOWN' - const visible = monitors.filter( - (m) => { - const isHidden = String(m.is_hidden).toLowerCase() - return m.is_hidden !== true && isHidden !== 'true' && isHidden !== 'yes' - }, - ) - // Kener v4 returns "ACTIVE"/"INACTIVE" as workflow state, not health status - // Map to health status: ACTIVE means UP, and we'll assume INACTIVE means DOWN - const statuses = visible.map((m) => { - const rawStatus = String(m.status ?? '').toUpperCase() as string - // Map Kener workflow states to health status - if (rawStatus === 'ACTIVE') return 'UP' as KenerMonitorStatus - if (rawStatus === 'INACTIVE') return 'DOWN' as KenerMonitorStatus - // Pass through actual health statuses if present - if (['UP', 'DOWN', 'DEGRADED', 'UNKNOWN'].includes(rawStatus)) return rawStatus as KenerMonitorStatus - return 'UNKNOWN' as KenerMonitorStatus - }) - if (statuses.includes('DOWN')) return 'DOWN' - if (statuses.includes('DEGRADED')) return 'DEGRADED' - if (statuses.length > 0 && statuses.every((s) => s === 'UP')) return 'UP' - return 'UNKNOWN' -} - -// ────────────────────────────────────────────── -// Public API -// ────────────────────────────────────────────── - -/** - * Returns an aggregated status summary from Kener monitor list. - * Shape is compatible with what StatusIndicator expects. - */ -export const getKenerStatus = cache(async (): Promise => { - const config = await getKenerConfig() - // /api/v4/monitors returns live per-monitor status; /api/v4/pages/ only returns config (no status field) - const data = await fetchKener<{ monitors: KenerMonitor[] }>('/api/v4/monitors', config) - if (!data?.monitors) return null - - const status = aggregateStatus(data.monitors) - - return { - page: { - name: 'Emberly Status', - url: config.baseUrl, - status, - }, - activeIncidents: [], - activeMaintenances: [], - } -}) - -/** - * Test Kener connectivity (used by admin integrations test endpoint). - */ -export async function testKenerConnection(baseUrl: string, apiKey?: string): Promise<{ ok: boolean; message: string }> { - const url = `${baseUrl.replace(/\/$/, '')}/api/v4/monitors` - try { - const res = await fetch(url, { - headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, - signal: AbortSignal.timeout(8000), - }) - if (!res.ok) return { ok: false, message: `Kener API responded with ${res.status}` } - const data = await res.json() - const count = (data?.monitors as unknown[])?.length ?? 0 - return { ok: true, message: `Connected — ${count} monitor${count !== 1 ? 's' : ''} found` } - } catch (err) { - return { ok: false, message: err instanceof Error ? err.message : 'Connection failed' } - } -} +// Status page integration removed — link users directly to https://emberlystat.us diff --git a/packages/lib/storage/index.ts b/packages/lib/storage/index.ts index 2096d7a..4f02fbd 100644 --- a/packages/lib/storage/index.ts +++ b/packages/lib/storage/index.ts @@ -11,7 +11,11 @@ const logger = loggers.storage export type { StorageProvider, RangeOptions } from './types' export { LocalStorageProvider, S3StorageProvider } -let storageProvider: StorageProvider | null = null +// Stored on globalThis so the singleton survives Next.js hot-reloads in dev mode +const _g = globalThis as typeof globalThis & { + __storageProvider?: StorageProvider +} +let storageProvider: StorageProvider | null = _g.__storageProvider ?? null /** Build a provider from a StorageBucket DB record (no caching — used for per-user routing). */ function providerFromBucket(bucket: { @@ -70,6 +74,7 @@ export async function getStorageProvider(): Promise { storageProvider = new LocalStorageProvider() } + _g.__storageProvider = storageProvider return storageProvider } @@ -78,7 +83,9 @@ export async function getStorageProvider(): Promise { * If the user has an assigned `storageBucketId`, returns a provider for that bucket. * Otherwise falls back to the global default provider. */ -export async function getStorageProviderForUser(userId: string): Promise { +export async function getStorageProviderForUser( + userId: string +): Promise { const user = await prisma.user.findUnique({ where: { id: userId }, select: { storageBucketId: true }, @@ -89,7 +96,11 @@ export async function getStorageProviderForUser(userId: string): Promise { +export async function getStorageProviderForSquad( + squadId: string +): Promise { const squad = await prisma.nexiumSquad.findUnique({ where: { id: squadId }, select: { storageBucketId: true }, @@ -113,7 +126,11 @@ export async function getStorageProviderForSquad(squadId: string): Promise() +// Stored on globalThis so it survives Next.js hot-reloads in dev mode +const _g = globalThis as typeof globalThis & { + __stripeSyncCache?: Map +} +if (!_g.__stripeSyncCache) _g.__stripeSyncCache = new Map() +const stripeSyncCache = _g.__stripeSyncCache const STRIPE_SYNC_TTL_MS = 5 * 60 * 1000 export interface QuotaInfo { - quotaMB: number - usedMB: number - remainingMB: number - purchasedMB: number - baseQuotaMB: number - percentageUsed: number + quotaMB: number + usedMB: number + remainingMB: number + purchasedMB: number + baseQuotaMB: number + percentageUsed: number } export interface PlanLimits { - /** null = unlimited (Ember / Enterprise) */ - storageQuotaGB: number | null - /** null = unlimited (Ember / Enterprise) */ - uploadSizeCapMB: number | null - /** null = unlimited (Ember / Enterprise) */ - customDomainsLimit: number | null - planName: string + /** null = unlimited (Ember / Enterprise) */ + storageQuotaGB: number | null + /** null = unlimited (Ember / Enterprise) */ + uploadSizeCapMB: number | null + /** null = unlimited (Ember / Enterprise) */ + customDomainsLimit: number | null + planName: string } /** * Get the user's current plan limits. * Returns plan limits or defaults if no active subscription. - * + * * Note: an active `storage-bucket` subscription removes ALL limits. * A `past_due` bucket subscription reinstates normal plan quotas. */ export async function getPlanLimits(userId: string): Promise { - // Check for an active storage-bucket-* subscription first — it removes all limits - const bucketSub = await prisma.subscription.findFirst({ - where: { - userId, - status: 'active', - product: { slug: { startsWith: 'storage-bucket' } }, - }, - select: { id: true }, - }) + // Check for an active storage-bucket-* subscription first — it removes all limits + const bucketSub = await prisma.subscription.findFirst({ + where: { + userId, + status: 'active', + product: { slug: { startsWith: 'storage-bucket' } }, + }, + select: { id: true }, + }) - if (bucketSub) { - return { - storageQuotaGB: null, // unlimited - uploadSizeCapMB: null, // unlimited - customDomainsLimit: null, - planName: 'Storage Bucket (Unlimited)', - } + if (bucketSub) { + return { + storageQuotaGB: null, // unlimited + uploadSizeCapMB: null, // unlimited + customDomainsLimit: null, + planName: 'Storage Bucket (Unlimited)', + } + } + + // Get user's active subscription with product details + const subscription = await prisma.subscription.findFirst({ + where: { + userId, + status: 'active', + }, + include: { + product: true, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + if (subscription && subscription.product) { + return { + // null means unlimited — preserved as-is for Ember/Enterprise plans + storageQuotaGB: subscription.product.storageQuotaGB ?? null, + uploadSizeCapMB: subscription.product.uploadSizeCapMB ?? null, + customDomainsLimit: subscription.product.customDomainsLimit ?? null, + planName: subscription.product.name, } + } - // Get user's active subscription with product details - const subscription = await prisma.subscription.findFirst({ - where: { + // No active subscription in DB — attempt a one-time Stripe sync to self-heal + // (covers cases where the webhook was missed or not yet configured) + const now = Date.now() + const lastSync = stripeSyncCache.get(userId) ?? 0 + if (now - lastSync > STRIPE_SYNC_TTL_MS) { + stripeSyncCache.set(userId, now) + try { + const userRecord = await prisma.user.findUnique({ + where: { id: userId }, + select: { stripeCustomerId: true }, + }) + if (userRecord?.stripeCustomerId) { + await syncUserSubscriptionsFromStripe( + userId, + userRecord.stripeCustomerId + ) + // Re-check after sync — storage-bucket subs take precedence (unlimited) + const syncedBucketSub = await prisma.subscription.findFirst({ + where: { userId, status: 'active', - }, - include: { - product: true, - }, - orderBy: { - createdAt: 'desc', - }, - }) - - if (subscription && subscription.product) { - return { - // null means unlimited — preserved as-is for Ember/Enterprise plans - storageQuotaGB: subscription.product.storageQuotaGB ?? null, - uploadSizeCapMB: subscription.product.uploadSizeCapMB ?? null, - customDomainsLimit: subscription.product.customDomainsLimit ?? null, - planName: subscription.product.name, + product: { slug: { startsWith: 'storage-bucket' } }, + }, + select: { id: true }, + }) + if (syncedBucketSub) { + return { + storageQuotaGB: null, + uploadSizeCapMB: null, + customDomainsLimit: null, + planName: 'Storage Bucket (Unlimited)', + } } - } - - // No active subscription in DB — attempt a one-time Stripe sync to self-heal - // (covers cases where the webhook was missed or not yet configured) - const now = Date.now() - const lastSync = stripeSyncCache.get(userId) ?? 0 - if (now - lastSync > STRIPE_SYNC_TTL_MS) { - stripeSyncCache.set(userId, now) - try { - const userRecord = await prisma.user.findUnique({ - where: { id: userId }, - select: { stripeCustomerId: true }, - }) - if (userRecord?.stripeCustomerId) { - await syncUserSubscriptionsFromStripe(userId, userRecord.stripeCustomerId) - // Re-check after sync - const syncedSub = await prisma.subscription.findFirst({ - where: { userId, status: 'active' }, - include: { product: true }, - orderBy: { createdAt: 'desc' }, - }) - if (syncedSub?.product) { - return { - storageQuotaGB: syncedSub.product.storageQuotaGB ?? null, - uploadSizeCapMB: syncedSub.product.uploadSizeCapMB ?? null, - customDomainsLimit: syncedSub.product.customDomainsLimit ?? null, - planName: syncedSub.product.name, - } - } - } - } catch (err) { - console.warn('[getPlanLimits] Stripe subscription sync failed:', err) + const syncedSub = await prisma.subscription.findFirst({ + where: { userId, status: 'active' }, + include: { product: true }, + orderBy: { createdAt: 'desc' }, + }) + if (syncedSub?.product) { + return { + storageQuotaGB: syncedSub.product.storageQuotaGB ?? null, + uploadSizeCapMB: syncedSub.product.uploadSizeCapMB ?? null, + customDomainsLimit: syncedSub.product.customDomainsLimit ?? null, + planName: syncedSub.product.name, + } } + } + } catch (err) { + console.warn('[getPlanLimits] Stripe subscription sync failed:', err) } + } - // Default free plan (Spark) - return { - storageQuotaGB: 10, - uploadSizeCapMB: 500, - customDomainsLimit: 3, - planName: 'Spark (Free)', - } + // Default free plan (Spark) + return { + storageQuotaGB: 10, + uploadSizeCapMB: 500, + customDomainsLimit: 3, + planName: 'Spark (Free)', + } } /** * Get the user's custom domain count. */ export async function getUserDomainCount(userId: string): Promise { - const result = await prisma.customDomain.count({ - where: { userId }, - }) - return result + const result = await prisma.customDomain.count({ + where: { userId }, + }) + return result } /** @@ -145,27 +172,27 @@ export async function getUserDomainCount(userId: string): Promise { * Counts legacy one-off purchases as well as active yearly subscriptions. */ export async function getPurchasedDomainSlots(userId: string): Promise { - const [oneOffResult, subscriptionCount] = await Promise.all([ - prisma.oneOffPurchase.aggregate({ - where: { - userId, - type: 'custom_domain', - }, - _sum: { - quantity: true, - }, - }), - prisma.subscription.count({ - where: { - userId, - status: { in: ['active', 'trialing'] }, - product: { - slug: { in: ['extra-domain-slot', 'extra-domain-slot-squad'] }, - }, - }, - }), - ]) - return (oneOffResult._sum?.quantity || 0) + subscriptionCount + const [oneOffResult, subscriptionCount] = await Promise.all([ + prisma.oneOffPurchase.aggregate({ + where: { + userId, + type: 'custom_domain', + }, + _sum: { + quantity: true, + }, + }), + prisma.subscription.count({ + where: { + userId, + status: { in: ['active', 'trialing'] }, + product: { + slug: { in: ['extra-domain-slot', 'extra-domain-slot-squad'] }, + }, + }, + }), + ]) + return (oneOffResult._sum?.quantity || 0) + subscriptionCount } /** @@ -173,20 +200,20 @@ export async function getPurchasedDomainSlots(userId: string): Promise { * Takes perk bonuses and purchased slots into account. */ export async function canAddCustomDomain(userId: string): Promise { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { perkRoles: true }, - }) - - const limits = await getPlanLimits(userId) - // null = unlimited plan - if (limits.customDomainsLimit === null) return true - const currentCount = await getUserDomainCount(userId) - const domainBonus = calculateDomainSlotBonus(user?.perkRoles || []) - const purchasedSlots = await getPurchasedDomainSlots(userId) - const totalLimit = limits.customDomainsLimit + domainBonus + purchasedSlots - - return currentCount < totalLimit + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { perkRoles: true }, + }) + + const limits = await getPlanLimits(userId) + // null = unlimited plan + if (limits.customDomainsLimit === null) return true + const currentCount = await getUserDomainCount(userId) + const domainBonus = calculateDomainSlotBonus(user?.perkRoles || []) + const purchasedSlots = await getPurchasedDomainSlots(userId) + const totalLimit = limits.customDomainsLimit + domainBonus + purchasedSlots + + return currentCount < totalLimit } /** @@ -194,19 +221,19 @@ export async function canAddCustomDomain(userId: string): Promise { * Sums up all extra_storage one-off purchases. */ export async function getPurchasedStorageMB(userId: string): Promise { - const result = await prisma.oneOffPurchase.aggregate({ - where: { - userId, - type: 'extra_storage', - }, - _sum: { - quantity: true, - }, - }) + const result = await prisma.oneOffPurchase.aggregate({ + where: { + userId, + type: 'extra_storage', + }, + _sum: { + quantity: true, + }, + }) - // quantity is in GB; convert to MB - const quantityGB = result._sum?.quantity || 0 - return quantityGB * 1024 + // quantity is in GB; convert to MB + const quantityGB = result._sum?.quantity || 0 + return quantityGB * 1024 } /** @@ -216,54 +243,56 @@ export async function getPurchasedStorageMB(userId: string): Promise { * - Plan-based storage quota * - Purchased additional storage */ -export async function getEffectiveQuotaMB(userId: string, defaultQuotaMB?: number): Promise { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { - storageUsed: true, - storageQuotaMB: true, - perkRoles: true, - }, - }) +export async function getEffectiveQuotaMB( + userId: string, + defaultQuotaMB?: number +): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + storageUsed: true, + storageQuotaMB: true, + perkRoles: true, + }, + }) - if (!user) { - throw new Error(`User ${userId} not found`) - } + if (!user) { + throw new Error(`User ${userId} not found`) + } - const planLimits = await getPlanLimits(userId) - const purchasedMB = await getPurchasedStorageMB(userId) - - // Calculate perk bonuses - const perkStorageBonusGB = calculateStorageBonusGB(user.perkRoles || []) - const perkStorageBonusMB = perkStorageBonusGB * 1024 - - // Priority: admin override > plan quota + perks > default quota - let baseQuotaMB = user.storageQuotaMB - if (!baseQuotaMB) { - if (planLimits.storageQuotaGB === null) { - // Unlimited plan — use a 100 TB sentinel so arithmetic still works - baseQuotaMB = 100 * 1024 * 1024 - } else { - baseQuotaMB = (planLimits.storageQuotaGB + perkStorageBonusGB) * 1024 - } - } - if (!baseQuotaMB && defaultQuotaMB) { - baseQuotaMB = defaultQuotaMB - } - - const quotaMB = baseQuotaMB + purchasedMB - const usedMB = user.storageUsed - const remainingMB = Math.max(0, quotaMB - usedMB) - const percentageUsed = quotaMB > 0 ? (usedMB / quotaMB) * 100 : 0 + const planLimits = await getPlanLimits(userId) + const purchasedMB = await getPurchasedStorageMB(userId) - return { - quotaMB, - usedMB, - remainingMB, - purchasedMB, - baseQuotaMB, - percentageUsed, + // Calculate perk bonuses + const perkStorageBonusGB = calculateStorageBonusGB(user.perkRoles || []) + + // Priority: admin override > plan quota + perks > default quota + let baseQuotaMB = user.storageQuotaMB + if (baseQuotaMB == null) { + if (planLimits.storageQuotaGB === null) { + // Unlimited plan — use a 100 TB sentinel so arithmetic still works + baseQuotaMB = 100 * 1024 * 1024 + } else { + baseQuotaMB = (planLimits.storageQuotaGB + perkStorageBonusGB) * 1024 } + } + if (defaultQuotaMB != null) { + baseQuotaMB = defaultQuotaMB + } + + const quotaMB = baseQuotaMB + purchasedMB + const usedMB = user.storageUsed + const remainingMB = Math.max(0, quotaMB - usedMB) + const percentageUsed = quotaMB > 0 ? (usedMB / quotaMB) * 100 : 0 + + return { + quotaMB, + usedMB, + remainingMB, + purchasedMB, + baseQuotaMB, + percentageUsed, + } } /** @@ -271,60 +300,63 @@ export async function getEffectiveQuotaMB(userId: string, defaultQuotaMB?: numbe * Validates against both storage quota AND upload size cap. */ export async function canUploadSize( - userId: string, - fileSizeMB: number, - defaultQuotaMB?: number + userId: string, + fileSizeMB: number, + defaultQuotaMB?: number ): Promise<{ allowed: boolean; reason?: string }> { - const planLimits = await getPlanLimits(userId) - - // Check upload size cap (null = unlimited) - if (planLimits.uploadSizeCapMB !== null && fileSizeMB > planLimits.uploadSizeCapMB) { - return { - allowed: false, - reason: `File exceeds ${planLimits.planName} plan limit of ${planLimits.uploadSizeCapMB}MB. Upgrade your plan or purchase larger file size add-on.`, - } + const planLimits = await getPlanLimits(userId) + + // Check upload size cap (null = unlimited) + if ( + planLimits.uploadSizeCapMB !== null && + fileSizeMB > planLimits.uploadSizeCapMB + ) { + return { + allowed: false, + reason: `File exceeds ${planLimits.planName} plan limit of ${planLimits.uploadSizeCapMB}MB. Upgrade your plan or purchase larger file size add-on.`, } - - // Check storage quota - const quota = await getEffectiveQuotaMB(userId, defaultQuotaMB) - const canFit = quota.usedMB + fileSizeMB <= quota.quotaMB - - if (!canFit) { - return { - allowed: false, - reason: `Uploading this file would exceed your storage quota. You have ${quota.remainingMB.toFixed(0)}MB remaining.`, - } + } + + // Check storage quota + const quota = await getEffectiveQuotaMB(userId, defaultQuotaMB) + const canFit = quota.usedMB + fileSizeMB <= quota.quotaMB + + if (!canFit) { + return { + allowed: false, + reason: `Uploading this file would exceed your storage quota. You have ${quota.remainingMB.toFixed(0)}MB remaining.`, } - - return { allowed: true } + } + + return { allowed: true } } /** * Get a user-friendly quota message explaining current usage. */ export function formatQuotaMessage(quota: QuotaInfo): string { - const formatMB = (mb: number): string => { - if (mb >= 1024) { - return `${(mb / 1024).toFixed(2)} GB` - } - return `${mb.toFixed(0)} MB` + const formatMB = (mb: number): string => { + if (mb >= 1024) { + return `${(mb / 1024).toFixed(2)} GB` } + return `${mb.toFixed(0)} MB` + } - const usedStr = formatMB(quota.usedMB) - const totalStr = formatMB(quota.quotaMB) - const remainingStr = formatMB(quota.remainingMB) + const usedStr = formatMB(quota.usedMB) + const totalStr = formatMB(quota.quotaMB) + const remainingStr = formatMB(quota.remainingMB) - let message = `You are using ${usedStr} of ${totalStr} (${quota.percentageUsed.toFixed(1)}%)` + let message = `You are using ${usedStr} of ${totalStr} (${quota.percentageUsed.toFixed(1)}%, ${remainingStr} remaining)` - if (quota.percentageUsed > 90) { - message += '. ⚠️ Storage is critically low!' - } else if (quota.percentageUsed > 75) { - message += '. Storage is getting low.' - } + if (quota.percentageUsed > 90) { + message += '. ⚠️ Storage is critically low!' + } else if (quota.percentageUsed > 75) { + message += '. Storage is getting low.' + } - if (quota.purchasedMB > 0) { - message += ` (${formatMB(quota.purchasedMB)} purchased)` - } + if (quota.purchasedMB > 0) { + message += ` (${formatMB(quota.purchasedMB)} purchased)` + } - return message + return message } diff --git a/packages/lib/storage/sync-buckets.ts b/packages/lib/storage/sync-buckets.ts index fa84864..ea940ef 100644 --- a/packages/lib/storage/sync-buckets.ts +++ b/packages/lib/storage/sync-buckets.ts @@ -160,7 +160,7 @@ export async function syncStorageBucketSubscriptions(): Promise stats.duration = Date.now() - startTime - logger.info('[Sync] Bucket synchronization completed', stats) + logger.info('[Sync] Bucket synchronization completed', { ...stats }) return stats } catch (err) { logger.error('[Sync] Bucket synchronization failed', err as Error) @@ -226,7 +226,7 @@ async function reconcileDeprovisionedBuckets(): Promise { } catch (err) { logger.warn( `[Sync] Failed to delete Vultr bucket ${bucket.vultrBucketName}`, - err as Error + { error: String(err) } ) } } @@ -266,7 +266,7 @@ async function reconcileDeprovisionedBuckets(): Promise { } catch (deleteErr) { logger.warn( `[Sync] Failed to delete Vultr bucket ${bucket.vultrBucketName}`, - deleteErr as Error + { error: String(deleteErr) } ) } } diff --git a/packages/lib/utils/index.ts b/packages/lib/utils/index.ts index c61f5b1..1ec3006 100644 --- a/packages/lib/utils/index.ts +++ b/packages/lib/utils/index.ts @@ -27,7 +27,9 @@ export function getRelativeTime(date: Date): string { export function urlForHost(host: string): string { if (!host) return '' - const cleaned = host.replace(/\/+$/, '') + let end = host.length + while (end > 0 && host[end - 1] === '/') end-- + const cleaned = end === host.length ? host : host.slice(0, end) if (cleaned.startsWith('http://') || cleaned.startsWith('https://')) return cleaned 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/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)) } } 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 = {