Skip to content
Merged

Dev #113

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
265 changes: 170 additions & 95 deletions app/api/analytics/top-users/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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)
})
// 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 }
)
}
}
2 changes: 1 addition & 1 deletion app/api/files/[id]/thumbnail/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions app/api/files/chunks/[uploadId]/complete/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ export async function POST(
}

const responseData: FileUploadResponse = {
id: fileRecord.id,
url: `${finalFullUrl}${metadata.urlPath}/`,
name: metadata.filename,
size: metadata.totalSize,
Expand Down
1 change: 1 addition & 0 deletions app/api/files/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ export async function POST(req: Request) {
}

const responseData: FileUploadResponse = {
id: fileRecord.id,
url: `${finalFullUrl}${urlPath}/`,
name: displayName,
size: uploadedFile.size,
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -133,14 +133,14 @@
"@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",
"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",
Expand Down
Loading
Loading