From 130cbcc952a9b2fb24a388dede8f2518dfa60562 Mon Sep 17 00:00:00 2001 From: Pixelated Date: Sat, 27 Jun 2026 16:23:12 -0600 Subject: [PATCH 1/3] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1655c54..e422fa3 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,7 @@ "eslint-config-next": "^15.4.4", "husky": "^8.0.3", "lint-staged": "^15.5.2", - "postcss": "^8.5.6", + "postcss": "^8.5.10", "postcss-load-config": "^6.0.1", "prettier": "^3.8.1", "tailwindcss": "^3.4.17", From a11f0355a7d9640201d1452bd5f5f198b4b2163c Mon Sep 17 00:00:00 2001 From: Pixelated Date: Sat, 27 Jun 2026 16:26:37 -0600 Subject: [PATCH 2/3] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e422fa3..21a8c69 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "@types/nodemailer": "^8.0.1", "@types/react": "^19.2.13", "@types/react-dom": "^18.3.7", - "@types/uuid": "^10.0.0", + "@types/uuid": "^11.1.1", "babel-plugin-react-compiler": "^1.0.0", "commitlint": "^21.0.1", "eslint": "^9.37.0", From 475dd9625e0b6a92c9f55a36e4feea1e934f6f58 Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Sun, 26 Jul 2026 16:30:04 -0600 Subject: [PATCH 3/3] fix(api): updated some inconsistencies --- CHANGELOG.md | 12 + app/api/analytics/top-users/route.ts | 265 +++++++++++------- app/api/files/[id]/thumbnail/route.ts | 2 +- .../files/chunks/[uploadId]/complete/route.ts | 1 + app/api/files/route.ts | 1 + package.json | 2 +- packages/lib/auth/api-auth.ts | 82 ++++-- packages/types/dto/file.ts | 3 +- 8 files changed, 241 insertions(+), 127 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 999bdbc..775ec31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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.5.1] - 2026-07-26 + +### Fixed + +- **System/admin API keys rejected on protected routes** — `requireAdmin()`, `requireSuperAdmin()`, `requireRole()`, and `requirePermission()` (`packages/lib/auth/api-auth.ts`) take an optional `req` parameter used to check the `esk_…` system key via the Bearer token, but the overwhelming majority of call sites (54 for `requireAdmin()` alone, all 8 for `requireSuperAdmin()`) never passed it — so the system-key check silently never ran on those routes and a valid system key got "Unauthorized". Fixed by falling back to Next.js's request-scoped `headers()` when `req` is omitted, so the check now runs regardless of call site. +- **Admin API keys had no effect on admin-gated routes** — the same four helpers only ever checked the browser session or the system key, never a personal `ebk_…` API key belonging to an actual admin/superadmin user. Added a `resolveActingUser()` layer that checks session → personal API key/upload token → system key, in that order, so an admin's own key now works on admin routes, not just the system key. +- **SUPERADMIN excluded from two admin routes** — `app/api/analytics/top-users/route.ts` and `app/api/files/[id]/thumbnail/route.ts` both gated access with a strict `role === 'ADMIN'` check, so a SUPERADMIN got 403/404 where an ADMIN would succeed. Both now accept either role. + +### Added + +- `FileUploadResponse` (`packages/types/dto/file.ts`) now includes the file's database `id`, returned from both `POST /api/files` and the chunked-upload completion endpoint. Previously an API client had no way to reference a file it had just uploaded for a later update/delete call. + ## [2.5.0] - 2026-06-25 ### Added diff --git a/app/api/analytics/top-users/route.ts b/app/api/analytics/top-users/route.ts index 1009367..810abd7 100644 --- a/app/api/analytics/top-users/route.ts +++ b/app/api/analytics/top-users/route.ts @@ -3,113 +3,188 @@ import { requireAuth } from '@/packages/lib/auth/api-auth' import { prisma } from '@/packages/lib/database/prisma' export async function GET(req: Request) { - try { - const { user, response } = await requireAuth(req) + try { + const { user, response } = await requireAuth(req) if (response) return response - // ensure we know the user's role - const isAdmin = user.role === 'ADMIN' - if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + // ensure we know the user's role + const isAdmin = user.role === 'ADMIN' || user.role === 'SUPERADMIN' + if (!isAdmin) + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - // aggregate downloads per user from files (include all users so scoring is accurate) - const downloads = await prisma.file.groupBy({ by: ['userId'], _sum: { downloads: true } }) - const clicks = await prisma.shortenedUrl.groupBy({ by: ['userId'], _sum: { clicks: true } }) - const fileCounts = await prisma.file.groupBy({ by: ['userId'], _count: { _all: true } }) + // aggregate downloads per user from files (include all users so scoring is accurate) + const downloads = await prisma.file.groupBy({ + by: ['userId'], + _sum: { downloads: true }, + }) + const clicks = await prisma.shortenedUrl.groupBy({ + by: ['userId'], + _sum: { clicks: true }, + }) + const fileCounts = await prisma.file.groupBy({ + by: ['userId'], + _count: { _all: true }, + }) - // merge by userId - const map = new Map() - downloads.forEach((d) => map.set(d.userId, { downloads: d._sum.downloads ?? 0, clicks: 0, filesCount: 0 })) - clicks.forEach((c) => { - const cur = map.get(c.userId) || { downloads: 0, clicks: 0, filesCount: 0 } - cur.clicks = c._sum.clicks ?? 0 - map.set(c.userId, cur) - }) - fileCounts.forEach((f) => { - const cur = map.get(f.userId) || { downloads: 0, clicks: 0, filesCount: 0 } - cur.filesCount = f._count._all ?? 0 - map.set(f.userId, cur) - }) + // merge by userId + const map = new Map< + string, + { downloads: number; clicks: number; filesCount: number } + >() + downloads.forEach((d) => + map.set(d.userId, { + downloads: d._sum.downloads ?? 0, + clicks: 0, + filesCount: 0, + }) + ) + clicks.forEach((c) => { + const cur = map.get(c.userId) || { + downloads: 0, + clicks: 0, + filesCount: 0, + } + cur.clicks = c._sum.clicks ?? 0 + map.set(c.userId, cur) + }) + fileCounts.forEach((f) => { + const cur = map.get(f.userId) || { + downloads: 0, + clicks: 0, + filesCount: 0, + } + cur.filesCount = f._count._all ?? 0 + map.set(f.userId, cur) + }) - // compute primary score (popularity) and a composite score that slightly rewards file count - const FILE_COUNT_WEIGHT = 0.2 - const items = Array.from(map.entries()).map(([userId, v]) => { - const downloads = Number(v.downloads || 0) - const clicks = Number(v.clicks || 0) - const filesCount = Number(v.filesCount || 0) - const primaryScore = downloads + clicks - const compositeScore = primaryScore + filesCount * FILE_COUNT_WEIGHT - return { userId, downloads, clicks, filesCount, primaryScore, compositeScore } - }) - // sort by composite score descending (break ties by primaryScore, then filesCount) - items.sort((a, b) => { - if (b.compositeScore !== a.compositeScore) return b.compositeScore - a.compositeScore - if (b.primaryScore !== a.primaryScore) return b.primaryScore - a.primaryScore - return b.filesCount - a.filesCount - }) - const top = items.slice(0, 10) + // compute primary score (popularity) and a composite score that slightly rewards file count + const FILE_COUNT_WEIGHT = 0.2 + const items = Array.from(map.entries()).map(([userId, v]) => { + const downloads = Number(v.downloads || 0) + const clicks = Number(v.clicks || 0) + const filesCount = Number(v.filesCount || 0) + const primaryScore = downloads + clicks + const compositeScore = primaryScore + filesCount * FILE_COUNT_WEIGHT + return { + userId, + downloads, + clicks, + filesCount, + primaryScore, + compositeScore, + } + }) + // sort by composite score descending (break ties by primaryScore, then filesCount) + items.sort((a, b) => { + if (b.compositeScore !== a.compositeScore) + return b.compositeScore - a.compositeScore + if (b.primaryScore !== a.primaryScore) + return b.primaryScore - a.primaryScore + return b.filesCount - a.filesCount + }) + const top = items.slice(0, 10) - const userIds = top.map((t) => t.userId) - const users = await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true, image: true, email: true } }) - const usersById = new Map(users.map((u) => [u.id, u])) + const userIds = top.map((t) => t.userId) + const users = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, name: true, image: true, email: true }, + }) + const usersById = new Map(users.map((u) => [u.id, u])) - // prepare enriched items with avgPerFile and include both primary and composite scores - const enriched = top.map((t) => ({ - userId: t.userId, - downloads: t.downloads, - clicks: t.clicks, - filesCount: t.filesCount || 0, - primaryScore: t.primaryScore, - compositeScore: t.compositeScore, - avgPerFile: t.filesCount ? (t.primaryScore / t.filesCount) : t.primaryScore, - })) + // prepare enriched items with avgPerFile and include both primary and composite scores + const enriched = top.map((t) => ({ + userId: t.userId, + downloads: t.downloads, + clicks: t.clicks, + filesCount: t.filesCount || 0, + primaryScore: t.primaryScore, + compositeScore: t.compositeScore, + avgPerFile: t.filesCount ? t.primaryScore / t.filesCount : t.primaryScore, + })) - // prepare totals and me info for everyone (admins also get these) - const totalUsers = items.length + // prepare totals and me info for everyone (admins also get these) + const totalUsers = items.length - const currentUserId: string | undefined = user.id + const currentUserId: string | undefined = user.id - const meEntry = items.find((it) => it.userId === currentUserId) || { userId: currentUserId || 'unknown', downloads: 0, clicks: 0, filesCount: 0, primaryScore: 0, compositeScore: 0 } - // rank based on compositeScore (the ranking users see) - const rank = items.filter((it) => it.compositeScore > meEntry.compositeScore).length + 1 - const me = { - userId: meEntry.userId, - downloads: meEntry.downloads || 0, - clicks: meEntry.clicks || 0, - filesCount: meEntry.filesCount || 0, - primaryScore: meEntry.primaryScore || 0, - compositeScore: meEntry.compositeScore || 0, - avgPerFile: meEntry.filesCount ? (meEntry.primaryScore / meEntry.filesCount) : meEntry.primaryScore, - } - - // build anonymized distribution (deciles) for privacy - const bucketsCount = 10 - const groupSize = Math.max(1, Math.ceil(items.length / bucketsCount)) - const buckets: Array<{ label: string; count: number; avgScore: number }> = [] - for (let i = 0; i < bucketsCount; i++) { - const start = i * groupSize - const end = Math.min(start + groupSize, items.length) - const slice = items.slice(start, end) - const count = slice.length - const avgScore = count > 0 ? Math.round(slice.reduce((s, x) => s + x.compositeScore, 0) / count) : 0 - const label = `${i * 10 + 1}-${Math.min((i + 1) * 10, 100)}%` - buckets.push({ label, count, avgScore }) - } + const meEntry = items.find((it) => it.userId === currentUserId) || { + userId: currentUserId || 'unknown', + downloads: 0, + clicks: 0, + filesCount: 0, + primaryScore: 0, + compositeScore: 0, + } + // rank based on compositeScore (the ranking users see) + const rank = + items.filter((it) => it.compositeScore > meEntry.compositeScore).length + + 1 + const me = { + userId: meEntry.userId, + downloads: meEntry.downloads || 0, + clicks: meEntry.clicks || 0, + filesCount: meEntry.filesCount || 0, + primaryScore: meEntry.primaryScore || 0, + compositeScore: meEntry.compositeScore || 0, + avgPerFile: meEntry.filesCount + ? meEntry.primaryScore / meEntry.filesCount + : meEntry.primaryScore, + } - let userBucketIndex = -1 - const myIndex = items.findIndex((it) => it.userId === currentUserId) - if (myIndex >= 0) userBucketIndex = Math.floor(myIndex / groupSize) + // build anonymized distribution (deciles) for privacy + const bucketsCount = 10 + const groupSize = Math.max(1, Math.ceil(items.length / bucketsCount)) + const buckets: Array<{ label: string; count: number; avgScore: number }> = + [] + for (let i = 0; i < bucketsCount; i++) { + const start = i * groupSize + const end = Math.min(start + groupSize, items.length) + const slice = items.slice(start, end) + const count = slice.length + const avgScore = + count > 0 + ? Math.round(slice.reduce((s, x) => s + x.compositeScore, 0) / count) + : 0 + const label = `${i * 10 + 1}-${Math.min((i + 1) * 10, 100)}%` + buckets.push({ label, count, avgScore }) + } - // if the requester is admin, return the full top users list (with avgPerFile) plus metadata - if (isAdmin) { - const result = enriched.map((t) => ({ user: usersById.get(t.userId) || { id: t.userId }, downloads: t.downloads, clicks: t.clicks, filesCount: t.filesCount, primaryScore: t.primaryScore, compositeScore: t.compositeScore, avgPerFile: t.avgPerFile })) - return NextResponse.json({ topUsers: result, me, rank, totalUsers, distribution: { buckets, userBucketIndex } }) - } + let userBucketIndex = -1 + const myIndex = items.findIndex((it) => it.userId === currentUserId) + if (myIndex >= 0) userBucketIndex = Math.floor(myIndex / groupSize) - // For non-admins, return the requesting user's stats and the anonymized distribution - return NextResponse.json({ me, rank, totalUsers, distribution: { buckets, userBucketIndex } }) - } catch (err) { - console.error('analytics/top-users error', err) - return NextResponse.json({ error: 'Failed to fetch top users' }, { status: 500 }) + // if the requester is admin, return the full top users list (with avgPerFile) plus metadata + if (isAdmin) { + const result = enriched.map((t) => ({ + user: usersById.get(t.userId) || { id: t.userId }, + downloads: t.downloads, + clicks: t.clicks, + filesCount: t.filesCount, + primaryScore: t.primaryScore, + compositeScore: t.compositeScore, + avgPerFile: t.avgPerFile, + })) + return NextResponse.json({ + topUsers: result, + me, + rank, + totalUsers, + distribution: { buckets, userBucketIndex }, + }) } -} + // For non-admins, return the requesting user's stats and the anonymized distribution + return NextResponse.json({ + me, + rank, + totalUsers, + distribution: { buckets, userBucketIndex }, + }) + } catch (err) { + console.error('analytics/top-users error', err) + return NextResponse.json( + { error: 'Failed to fetch top users' }, + { status: 500 } + ) + } +} diff --git a/app/api/files/[id]/thumbnail/route.ts b/app/api/files/[id]/thumbnail/route.ts index c3b23e5..060b946 100644 --- a/app/api/files/[id]/thumbnail/route.ts +++ b/app/api/files/[id]/thumbnail/route.ts @@ -55,7 +55,7 @@ export async function GET( // Check access permissions (similar to main file endpoint) const isOwner = user?.id === file.userId - const isAdmin = user?.role === 'ADMIN' + const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPERADMIN' const isPrivate = file.visibility === 'PRIVATE' && !isOwner && !isAdmin if (isPrivate) { diff --git a/app/api/files/chunks/[uploadId]/complete/route.ts b/app/api/files/chunks/[uploadId]/complete/route.ts index d572b4b..329eefa 100644 --- a/app/api/files/chunks/[uploadId]/complete/route.ts +++ b/app/api/files/chunks/[uploadId]/complete/route.ts @@ -335,6 +335,7 @@ export async function POST( } const responseData: FileUploadResponse = { + id: fileRecord.id, url: `${finalFullUrl}${metadata.urlPath}/`, name: metadata.filename, size: metadata.totalSize, diff --git a/app/api/files/route.ts b/app/api/files/route.ts index 05155c1..94d2dd6 100644 --- a/app/api/files/route.ts +++ b/app/api/files/route.ts @@ -415,6 +415,7 @@ export async function POST(req: Request) { } const responseData: FileUploadResponse = { + id: fileRecord.id, url: `${finalFullUrl}${urlPath}/`, name: displayName, size: uploadedFile.size, diff --git a/package.json b/package.json index 1655c54..db1d45b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@emberly/website", - "version": "2.5.0", + "version": "2.5.1", "license": "AGPL-3.0-only", "author": "CodeMeAPixel", "homepage": "https://github.com/EmberlyOSS/Emberly", diff --git a/packages/lib/auth/api-auth.ts b/packages/lib/auth/api-auth.ts index ee198c5..830865e 100644 --- a/packages/lib/auth/api-auth.ts +++ b/packages/lib/auth/api-auth.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import { headers } from 'next/headers' import { authOptions } from '@/packages/lib/auth' import { sessionCache } from '@/packages/lib/cache/session-cache' @@ -117,7 +118,7 @@ export async function requireSquadAuth(req: Request) { } export async function getAuthenticatedUser( - req: Request + req?: Request ): Promise { const session = await getServerSession(authOptions) if (session?.user) { @@ -177,7 +178,7 @@ export async function getAuthenticatedUser( return user ? { ...user, emailVerified: !!user.emailVerified } : null } - const authHeader = req.headers.get('authorization') + const authHeader = await resolveAuthHeader(req) if (authHeader?.startsWith('Bearer ')) { const token = authHeader.substring(7) @@ -284,7 +285,7 @@ export async function getAuthenticatedUser( return null } -export async function requireAuth(req: Request) { +export async function requireAuth(req?: Request) { const user = await getAuthenticatedUser(req) if (!user) { return { @@ -307,22 +308,34 @@ const SYSTEM_KEY_USER = { * Returns a synthetic SUPERADMIN user object on success, null otherwise. */ async function getSystemKeyUser(req?: Request) { - if (!req) return null const valid = await isSystemKeyAuth(req) return valid ? SYSTEM_KEY_USER : null } -export async function requireAdmin(req?: Request) { +/** + * Resolve the "acting user" for a permission check across all three + * supported auth methods: browser session, personal/squad Bearer token + * (upload token or `ebk_…` API key), or the `esk_…` system key. The system + * key always satisfies any permission check (it's a SUPERADMIN identity); + * the other two are checked by the caller against the actual permission. + */ +async function resolveActingUser(req?: Request) { const session = await getServerSession(authOptions) + if (session?.user) return session.user - if ( - session?.user && - hasPermission(session.user.role as any, Permission.ACCESS_ADMIN_PANEL) - ) { - return { user: session.user, response: null } + const apiUser = await getAuthenticatedUser(req) + if (apiUser) return apiUser + + return null +} + +export async function requireAdmin(req?: Request) { + const user = await resolveActingUser(req) + if (user && hasPermission(user.role as any, Permission.ACCESS_ADMIN_PANEL)) { + return { user, response: null } } - // Fall back to system API key + // Fall back to system API key (always satisfies any permission) const systemUser = await getSystemKeyUser(req) if (systemUser) return { user: systemUser, response: null } @@ -333,16 +346,12 @@ export async function requireAdmin(req?: Request) { } export async function requireSuperAdmin(req?: Request) { - const session = await getServerSession(authOptions) - + const user = await resolveActingUser(req) if ( - session?.user && - hasPermission( - session.user.role as any, - Permission.PERFORM_SUPERADMIN_ACTIONS - ) + user && + hasPermission(user.role as any, Permission.PERFORM_SUPERADMIN_ACTIONS) ) { - return { user: session.user, response: null } + return { user, response: null } } // Fall back to system API key @@ -367,14 +376,14 @@ export async function requireRole( minRole: 'USER' | 'ADMIN' | 'SUPERADMIN', req?: Request ) { - const session = await getServerSession(authOptions) + const user = await resolveActingUser(req) - if (session?.user) { - const userRole = session.user.role as 'USER' | 'ADMIN' | 'SUPERADMIN' + if (user) { + const userRole = user.role as 'USER' | 'ADMIN' | 'SUPERADMIN' const roleHierarchy = { USER: 0, ADMIN: 10, SUPERADMIN: 100 } if (roleHierarchy[userRole] >= roleHierarchy[minRole]) { - return { user: session.user, response: null } + return { user, response: null } } return { @@ -402,10 +411,10 @@ export async function requireRole( * if (response) return response */ export async function requirePermission(permission: Permission, req?: Request) { - const session = await getServerSession(authOptions) + const user = await resolveActingUser(req) - if (session?.user && hasPermission(session.user.role as any, permission)) { - return { user: session.user, response: null } + if (user && hasPermission(user.role as any, permission)) { + return { user, response: null } } // System API key has all permissions @@ -605,13 +614,28 @@ export async function requireSquadPermission( // ── System API key authentication ────────────────────────────────────────── +/** + * Resolve the Authorization header either from an explicitly passed `Request` + * (e.g. in tests, or handlers with a non-standard signature) or, when omitted, + * from Next.js's request-scoped `headers()` — which works in any Route + * Handler regardless of whether the caller threaded `req` through. Most + * `requireAdmin()`/`requireSuperAdmin()` call sites in this codebase call + * these with no arguments, so this fallback is what makes system API keys + * actually work on those routes. + */ +async function resolveAuthHeader(req?: Request): Promise { + if (req) return req.headers.get('authorization') + const headerList = await headers() + return headerList.get('authorization') +} + /** * Verify a system API key from a Bearer token (esk_…). * The key hash is stored in the Config table under key 'system_api_key'. * Returns true if the token matches, false otherwise. */ -export async function isSystemKeyAuth(req: Request): Promise { - const authHeader = req.headers.get('authorization') +export async function isSystemKeyAuth(req?: Request): Promise { + const authHeader = await resolveAuthHeader(req) if (!authHeader?.startsWith('Bearer esk_')) return false const token = authHeader.substring(7) @@ -629,7 +653,7 @@ export async function isSystemKeyAuth(req: Request): Promise { /** * Require a valid system API key. Returns a 401 response if invalid. */ -export async function requireSystemKey(req: Request) { +export async function requireSystemKey(req?: Request) { const valid = await isSystemKeyAuth(req) if (!valid) { return { diff --git a/packages/types/dto/file.ts b/packages/types/dto/file.ts index 5e47d27..6b81413 100644 --- a/packages/types/dto/file.ts +++ b/packages/types/dto/file.ts @@ -15,7 +15,7 @@ export type FileUploadRequest = z.infer const FileLikeSchema = (() => { const globalFile = typeof globalThis !== 'undefined' && - typeof (globalThis as { File?: typeof File }).File !== 'undefined' + typeof (globalThis as { File?: typeof File }).File !== 'undefined' ? (globalThis as { File: typeof File }).File : null @@ -55,6 +55,7 @@ export interface FileMetadata { } export interface FileUploadResponse { + id: string url: string name: string size: number