fix: resolve lint errors from ACL/admin implementation

- Remove unused requireOwnerOrAdmin import from videos.ts
- Remove unused requireAuth import from users.ts
- Remove unused GraphQLError import from articles.ts
- Replace URLSearchParams with SvelteURLSearchParams in admin users page
- Apply prettier formatting to all changed files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 12:35:11 +01:00
parent c1770ab9c9
commit ad7ceee5f8
18 changed files with 112 additions and 130 deletions

View File

@@ -75,14 +75,14 @@ Points + achievements system tracked in `user_points` and `user_stats` tables. L
## Environment Variables (Backend) ## Environment Variables (Backend)
| Variable | Purpose | | Variable | Purpose |
|----------|---------| | --------------------------- | ---------------------------- |
| `DATABASE_URL` | PostgreSQL connection string | | `DATABASE_URL` | PostgreSQL connection string |
| `REDIS_URL` | Redis connection string | | `REDIS_URL` | Redis connection string |
| `COOKIE_SECRET` | Session cookie signing | | `COOKIE_SECRET` | Session cookie signing |
| `CORS_ORIGIN` | Frontend origin URL | | `CORS_ORIGIN` | Frontend origin URL |
| `UPLOAD_DIR` | File storage path | | `UPLOAD_DIR` | File storage path |
| `SMTP_HOST/PORT/EMAIL_FROM` | Email (Nodemailer) | | `SMTP_HOST/PORT/EMAIL_FROM` | Email (Nodemailer) |
## Docker ## Docker

View File

@@ -1,4 +1,3 @@
import { GraphQLError } from "graphql";
import { builder } from "../builder"; import { builder } from "../builder";
import { ArticleType } from "../types/index"; import { ArticleType } from "../types/index";
import { articles, users } from "../../db/schema/index"; import { articles, users } from "../../db/schema/index";
@@ -80,10 +79,7 @@ builder.queryField("adminListArticles", (t) =>
type: [ArticleType], type: [ArticleType],
resolve: async (_root, _args, ctx) => { resolve: async (_root, _args, ctx) => {
requireRole(ctx, "admin"); requireRole(ctx, "admin");
const articleList = await ctx.db const articleList = await ctx.db.select().from(articles).orderBy(desc(articles.publish_date));
.select()
.from(articles)
.orderBy(desc(articles.publish_date));
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article))); return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
}, },
}), }),

View File

@@ -87,11 +87,7 @@ builder.mutationField("deleteComment", (t) =>
id: t.arg.int({ required: true }), id: t.arg.int({ required: true }),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
const comment = await ctx.db const comment = await ctx.db.select().from(comments).where(eq(comments.id, args.id)).limit(1);
.select()
.from(comments)
.where(eq(comments.id, args.id))
.limit(1);
if (!comment[0]) throw new GraphQLError("Comment not found"); if (!comment[0]) throw new GraphQLError("Comment not found");
requireOwnerOrAdmin(ctx, comment[0].user_id); requireOwnerOrAdmin(ctx, comment[0].user_id);
await ctx.db.delete(comments).where(eq(comments.id, args.id)); await ctx.db.delete(comments).where(eq(comments.id, args.id));

View File

@@ -3,7 +3,7 @@ import { builder } from "../builder";
import { CurrentUserType, UserType, AdminUserListType } from "../types/index"; import { CurrentUserType, UserType, AdminUserListType } from "../types/index";
import { users } from "../../db/schema/index"; import { users } from "../../db/schema/index";
import { eq, ilike, or, count, and } from "drizzle-orm"; import { eq, ilike, or, count, and } from "drizzle-orm";
import { requireAuth, requireRole } from "../../lib/acl"; import { requireRole } from "../../lib/acl";
builder.queryField("me", (t) => builder.queryField("me", (t) =>
t.field({ t.field({

View File

@@ -15,7 +15,7 @@ import {
files, files,
} from "../../db/schema/index"; } from "../../db/schema/index";
import { eq, and, lte, desc, inArray, count } from "drizzle-orm"; import { eq, and, lte, desc, inArray, count } from "drizzle-orm";
import { requireRole, requireOwnerOrAdmin } from "../../lib/acl"; import { requireRole } from "../../lib/acl";
async function enrichVideo(db: any, video: any) { async function enrichVideo(db: any, video: any) {
// Fetch models // Fetch models
@@ -433,10 +433,7 @@ builder.queryField("adminListVideos", (t) =>
type: [VideoType], type: [VideoType],
resolve: async (_root, _args, ctx) => { resolve: async (_root, _args, ctx) => {
requireRole(ctx, "admin"); requireRole(ctx, "admin");
const rows = await ctx.db const rows = await ctx.db.select().from(videos).orderBy(desc(videos.upload_date));
.select()
.from(videos)
.orderBy(desc(videos.upload_date));
return Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v))); return Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
}, },
}), }),

View File

@@ -229,13 +229,11 @@ export const VideoPlayResponseType = builder
}), }),
}); });
export const VideoLikeStatusType = builder export const VideoLikeStatusType = builder.objectRef<VideoLikeStatus>("VideoLikeStatus").implement({
.objectRef<VideoLikeStatus>("VideoLikeStatus") fields: (t) => ({
.implement({ liked: t.exposeBoolean("liked"),
fields: (t) => ({ }),
liked: t.exposeBoolean("liked"), });
}),
});
export const VideoAnalyticsType = builder.objectRef<VideoAnalytics>("VideoAnalytics").implement({ export const VideoAnalyticsType = builder.objectRef<VideoAnalytics>("VideoAnalytics").implement({
fields: (t) => ({ fields: (t) => ({

View File

@@ -26,7 +26,17 @@ function createLogger(bindings: Record<string, unknown> = {}, initialLevel: LogL
message = arg; message = arg;
} else if (arg !== null && typeof arg === "object") { } else if (arg !== null && typeof arg === "object") {
// Pino-style: log(obj, msg?) — strip internal pino keys // Pino-style: log(obj, msg?) — strip internal pino keys
const { msg: m, level: _l, time: _t, pid: _p, hostname: _h, req: _req, res: _res, reqId, ...rest } = arg as Record<string, unknown>; const {
msg: m,
level: _l,
time: _t,
pid: _p,
hostname: _h,
req: _req,
res: _res,
reqId,
...rest
} = arg as Record<string, unknown>;
message = msg || (typeof m === "string" ? m : ""); message = msg || (typeof m === "string" ? m : "");
if (reqId) meta.reqId = reqId; if (reqId) meta.reqId = reqId;
Object.assign(meta, rest); Object.assign(meta, rest);

View File

@@ -90,7 +90,10 @@
{/each} {/each}
{#if (recording.device_info?.length ?? 0) > 2} {#if (recording.device_info?.length ?? 0) > 2}
<div class="text-xs text-muted-foreground/60 px-2"> <div class="text-xs text-muted-foreground/60 px-2">
+{(recording.device_info?.length ?? 0) - 2} more device{(recording.device_info?.length ?? 0) - 2 > 1 +{(recording.device_info?.length ?? 0) - 2} more device{(recording.device_info?.length ??
0) -
2 >
1
? "s" ? "s"
: ""} : ""}
</div> </div>

View File

@@ -16,9 +16,7 @@
<div class="flex min-h-screen bg-background"> <div class="flex min-h-screen bg-background">
<!-- Sidebar --> <!-- Sidebar -->
<aside <aside class="w-56 shrink-0 border-r border-border/40 bg-card/60 backdrop-blur-sm flex flex-col">
class="w-56 shrink-0 border-r border-border/40 bg-card/60 backdrop-blur-sm flex flex-col"
>
<div class="px-4 py-5 border-b border-border/40"> <div class="px-4 py-5 border-b border-border/40">
<a href="/" class="text-xs text-muted-foreground hover:text-foreground transition-colors"> <a href="/" class="text-xs text-muted-foreground hover:text-foreground transition-colors">
← Back to site ← Back to site

View File

@@ -21,14 +21,12 @@
let tags = $state<string[]>(data.article.tags ?? []); let tags = $state<string[]>(data.article.tags ?? []);
let featured = $state(data.article.featured ?? false); let featured = $state(data.article.featured ?? false);
let publishDate = $state( let publishDate = $state(
data.article.publish_date data.article.publish_date ? new Date(data.article.publish_date).toISOString().slice(0, 16) : "",
? new Date(data.article.publish_date).toISOString().slice(0, 16)
: "",
); );
let imageId = $state<string | null>(data.article.image ?? null); let imageId = $state<string | null>(data.article.image ?? null);
let saving = $state(false); let saving = $state(false);
let preview = $derived(content ? marked.parse(content) as string : ""); let preview = $derived(content ? (marked.parse(content) as string) : "");
async function handleImageUpload(files: File[]) { async function handleImageUpload(files: File[]) {
const file = files[0]; const file = files[0];
@@ -98,10 +96,7 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Content (Markdown)</Label> <Label>Content (Markdown)</Label>
<div class="grid grid-cols-2 gap-4 min-h-96"> <div class="grid grid-cols-2 gap-4 min-h-96">
<Textarea <Textarea bind:value={content} class="h-full min-h-96 font-mono text-sm resize-none" />
bind:value={content}
class="h-full min-h-96 font-mono text-sm resize-none"
/>
<div <div
class="rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground" class="rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground"
> >
@@ -117,13 +112,13 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Cover image</Label> <Label>Cover image</Label>
{#if imageId} {#if imageId}
<img src={getAssetUrl(imageId, "thumbnail")} alt="" class="h-24 rounded object-cover mb-2" /> <img
src={getAssetUrl(imageId, "thumbnail")}
alt=""
class="h-24 rounded object-cover mb-2"
/>
{/if} {/if}
<FileDropZone <FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
accept="image/*"
maxFileSize={10 * MEGABYTE}
onUpload={handleImageUpload}
/>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">

View File

@@ -21,7 +21,7 @@
let imageId = $state<string | null>(null); let imageId = $state<string | null>(null);
let saving = $state(false); let saving = $state(false);
let preview = $derived(content ? marked.parse(content) as string : ""); let preview = $derived(content ? (marked.parse(content) as string) : "");
function generateSlug(t: string) { function generateSlug(t: string) {
return t return t
@@ -87,7 +87,9 @@
<Input <Input
id="title" id="title"
bind:value={title} bind:value={title}
oninput={() => { if (!slug) slug = generateSlug(title); }} oninput={() => {
if (!slug) slug = generateSlug(title);
}}
placeholder="Article title" placeholder="Article title"
/> />
</div> </div>
@@ -125,11 +127,7 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Cover image</Label> <Label>Cover image</Label>
<FileDropZone <FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
accept="image/*"
maxFileSize={10 * MEGABYTE}
onUpload={handleImageUpload}
/>
{#if imageId}<p class="text-xs text-green-600 mt-1">Image uploaded ✓</p>{/if} {#if imageId}<p class="text-xs text-green-600 mt-1">Image uploaded ✓</p>{/if}
</div> </div>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto, invalidateAll } from "$app/navigation"; import { goto, invalidateAll } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
import { SvelteURLSearchParams } from "svelte/reactivity";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { adminUpdateUser, adminDeleteUser } from "$lib/services"; import { adminUpdateUser, adminDeleteUser } from "$lib/services";
import { getAssetUrl } from "$lib/api"; import { getAssetUrl } from "$lib/api";
@@ -25,7 +26,7 @@
function debounceSearch(value: string) { function debounceSearch(value: string) {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
const params = new URLSearchParams(page.url.searchParams); const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value) params.set("search", value); if (value) params.set("search", value);
else params.delete("search"); else params.delete("search");
params.delete("offset"); params.delete("offset");
@@ -34,7 +35,7 @@
} }
function setRole(role: string) { function setRole(role: string) {
const params = new URLSearchParams(page.url.searchParams); const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (role) params.set("role", role); if (role) params.set("role", role);
else params.delete("role"); else params.delete("role");
params.delete("offset"); params.delete("offset");
@@ -193,7 +194,7 @@
variant="outline" variant="outline"
disabled={data.offset === 0} disabled={data.offset === 0}
onclick={() => { onclick={() => {
const params = new URLSearchParams(page.url.searchParams); const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit))); params.set("offset", String(Math.max(0, data.offset - data.limit)));
goto(`?${params.toString()}`); goto(`?${params.toString()}`);
}} }}
@@ -205,7 +206,7 @@
variant="outline" variant="outline"
disabled={data.offset + data.limit >= data.total} disabled={data.offset + data.limit >= data.total}
onclick={() => { onclick={() => {
const params = new URLSearchParams(page.url.searchParams); const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit)); params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`); goto(`?${params.toString()}`);
}} }}
@@ -230,11 +231,7 @@
</Dialog.Header> </Dialog.Header>
<Dialog.Footer> <Dialog.Footer>
<Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button> <Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button>
<Button <Button variant="destructive" disabled={deleting} onclick={handleDelete}>
variant="destructive"
disabled={deleting}
onclick={handleDelete}
>
{deleting ? "Deleting…" : "Delete"} {deleting ? "Deleting…" : "Delete"}
</Button> </Button>
</Dialog.Footer> </Dialog.Footer>

View File

@@ -87,8 +87,7 @@
> >
{/if} {/if}
{#if video.featured} {#if video.featured}
<span <span class="px-1.5 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary"
class="px-1.5 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary"
>Featured</span >Featured</span
> >
{/if} {/if}

View File

@@ -19,9 +19,7 @@
let premium = $state(data.video.premium ?? false); let premium = $state(data.video.premium ?? false);
let featured = $state(data.video.featured ?? false); let featured = $state(data.video.featured ?? false);
let uploadDate = $state( let uploadDate = $state(
data.video.upload_date data.video.upload_date ? new Date(data.video.upload_date).toISOString().slice(0, 16) : "",
? new Date(data.video.upload_date).toISOString().slice(0, 16)
: "",
); );
let imageId = $state<string | null>(data.video.image ?? null); let imageId = $state<string | null>(data.video.image ?? null);
let movieId = $state<string | null>(data.video.movie ?? null); let movieId = $state<string | null>(data.video.movie ?? null);
@@ -118,13 +116,13 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Cover image</Label> <Label>Cover image</Label>
{#if imageId} {#if imageId}
<img src={getAssetUrl(imageId, "thumbnail")} alt="" class="h-24 rounded object-cover mb-2" /> <img
src={getAssetUrl(imageId, "thumbnail")}
alt=""
class="h-24 rounded object-cover mb-2"
/>
{/if} {/if}
<FileDropZone <FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
accept="image/*"
maxFileSize={10 * MEGABYTE}
onUpload={handleImageUpload}
/>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
@@ -132,11 +130,7 @@
{#if movieId} {#if movieId}
<p class="text-xs text-muted-foreground mb-1">Current file: {movieId}</p> <p class="text-xs text-muted-foreground mb-1">Current file: {movieId}</p>
{/if} {/if}
<FileDropZone <FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
accept="video/*"
maxFileSize={2000 * MEGABYTE}
onUpload={handleVideoUpload}
/>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">

View File

@@ -110,7 +110,9 @@
<Input <Input
id="title" id="title"
bind:value={title} bind:value={title}
oninput={() => { if (!slug) slug = generateSlug(title); }} oninput={() => {
if (!slug) slug = generateSlug(title);
}}
placeholder="Video title" placeholder="Video title"
/> />
</div> </div>
@@ -122,26 +124,23 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="description">Description</Label> <Label for="description">Description</Label>
<Textarea id="description" bind:value={description} placeholder="Optional description" rows={3} /> <Textarea
id="description"
bind:value={description}
placeholder="Optional description"
rows={3}
/>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Cover image</Label> <Label>Cover image</Label>
<FileDropZone <FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
accept="image/*"
maxFileSize={10 * MEGABYTE}
onUpload={handleImageUpload}
/>
{#if imageId}<p class="text-xs text-green-600 mt-1">Image uploaded ✓</p>{/if} {#if imageId}<p class="text-xs text-green-600 mt-1">Image uploaded ✓</p>{/if}
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Video file</Label> <Label>Video file</Label>
<FileDropZone <FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
accept="video/*"
maxFileSize={2000 * MEGABYTE}
onUpload={handleVideoUpload}
/>
{#if movieId}<p class="text-xs text-green-600 mt-1">Video uploaded ✓</p>{/if} {#if movieId}<p class="text-xs text-green-600 mt-1">Video uploaded ✓</p>{/if}
</div> </div>

View File

@@ -141,37 +141,37 @@
<!-- Author Bio --> <!-- Author Bio -->
{#if data.article.author} {#if data.article.author}
<Card class="p-0 bg-gradient-to-r from-card/50 to-card"> <Card class="p-0 bg-gradient-to-r from-card/50 to-card">
<CardContent class="p-6"> <CardContent class="p-6">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<img <img
src={getAssetUrl(data.article.author.avatar, "mini")} src={getAssetUrl(data.article.author.avatar, "mini")}
alt={data.article.author.first_name} alt={data.article.author.first_name}
class="w-16 h-16 rounded-full object-cover ring-2 ring-primary/20" class="w-16 h-16 rounded-full object-cover ring-2 ring-primary/20"
/> />
<div class="flex-1"> <div class="flex-1">
<h3 class="font-semibold text-lg mb-2"> <h3 class="font-semibold text-lg mb-2">
About {data.article.author.first_name} About {data.article.author.first_name}
</h3> </h3>
{#if data.article.author.description} {#if data.article.author.description}
<p class="text-muted-foreground mb-4"> <p class="text-muted-foreground mb-4">
{data.article.author.description} {data.article.author.description}
</p> </p>
{/if} {/if}
{#if data.article.author.website} {#if data.article.author.website}
<div class="flex gap-4 text-sm"> <div class="flex gap-4 text-sm">
<a <a
href={"https://" + data.article.author.website} href={"https://" + data.article.author.website}
class="text-primary hover:underline" class="text-primary hover:underline"
> >
{data.article.author.website} {data.article.author.website}
</a> </a>
</div> </div>
{/if} {/if}
</div>
</div> </div>
</div> </CardContent>
</CardContent> </Card>
</Card>
{/if} {/if}
</article> </article>

View File

@@ -7,7 +7,9 @@ export const GET = async () => {
excludeRoutePatterns: ["^/signup/verify", "^/password/reset", "^/me", "^/play", "^/tags/.+"], excludeRoutePatterns: ["^/signup/verify", "^/password/reset", "^/me", "^/play", "^/tags/.+"],
paramValues: { paramValues: {
"/magazine/[slug]": (await getArticles(fetch)).map((a) => a.slug), "/magazine/[slug]": (await getArticles(fetch)).map((a) => a.slug),
"/models/[slug]": (await getModels(fetch)).map((a) => a.slug).filter((s): s is string => s !== null), "/models/[slug]": (await getModels(fetch))
.map((a) => a.slug)
.filter((s): s is string => s !== null),
"/videos/[slug]": (await getVideos(fetch)).map((a) => a.slug), "/videos/[slug]": (await getVideos(fetch)).map((a) => a.slug),
}, },
defaultChangefreq: "always", defaultChangefreq: "always",

View File

@@ -27,7 +27,7 @@
<Meta <Meta
title={displayName} title={displayName}
description={data.user.description || `${displayName}'s profile`} description={data.user.description || `${displayName}'s profile`}
image={data.user.avatar ? getAssetUrl(data.user.avatar, "thumbnail") ?? undefined : undefined} image={data.user.avatar ? (getAssetUrl(data.user.avatar, "thumbnail") ?? undefined) : undefined}
/> />
<div class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5"> <div class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
@@ -91,8 +91,6 @@
> >
</div> </div>
{#if data.user.description} {#if data.user.description}
<p class="text-muted-foreground mb-4"> <p class="text-muted-foreground mb-4">
{data.user.description} {data.user.description}
@@ -183,7 +181,7 @@
{$_("gamification.achievements")} ({data.gamification.achievements.length}) {$_("gamification.achievements")} ({data.gamification.achievements.length})
</h3> </h3>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3"> <div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{#each (data.gamification?.achievements ?? []) as achievement (achievement.id)} {#each data.gamification?.achievements ?? [] as achievement (achievement.id)}
<div <div
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-accent/10 border border-border/30 hover:border-primary/50 transition-colors" class="flex flex-col items-center gap-2 p-3 rounded-lg bg-accent/10 border border-border/30 hover:border-primary/50 transition-colors"
title={achievement.description} title={achievement.description}
@@ -194,7 +192,9 @@
</span> </span>
{#if achievement.date_unlocked} {#if achievement.date_unlocked}
<span class="text-xs text-muted-foreground"> <span class="text-xs text-muted-foreground">
{new Date(achievement.date_unlocked).toLocaleDateString($locale ?? undefined)} {new Date(achievement.date_unlocked).toLocaleDateString(
$locale ?? undefined,
)}
</span> </span>
{/if} {/if}
</div> </div>