Add watchStatus apis (#874)

This commit is contained in:
Zoe Roux 2025-04-07 16:08:25 +02:00 committed by GitHub
commit 572e763a61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 728 additions and 103 deletions

View File

@ -97,6 +97,6 @@ RABBITMQ_DEFAULT_PASS=aohohunuhouhuhhoahothonseuhaoensuthoaentsuhha
# v5 stuff, does absolutely nothing on master (aka: you can delete this) # v5 stuff, does absolutely nothing on master (aka: you can delete this)
EXTRA_CLAIMS='{"permissions": ["core.read"], "verified": false}' EXTRA_CLAIMS='{"permissions": ["core.read"], "verified": false}'
FIRST_USER_CLAIMS='{"permissions": ["user.read", "users.write", "users.delete", "core.read"], "verified": true}' FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "users.delete", "core.read"], "verified": true}'
GUEST_CLAIMS='{"permissions": ["core.read"]}' GUEST_CLAIMS='{"permissions": ["core.read"]}'
PROTECTED_CLAIMS="permissions,verified" PROTECTED_CLAIMS="permissions,verified"

View File

@ -52,6 +52,7 @@ jobs:
hurl --error-format long --variable host=http://localhost:4568 tests/* hurl --error-format long --variable host=http://localhost:4568 tests/*
env: env:
POSTGRES_SERVER: localhost POSTGRES_SERVER: localhost
FIRST_USER_CLAIMS: '{"permissions": ["users.read"]}'
- name: Show logs - name: Show logs
working-directory: ./auth working-directory: ./auth

View File

@ -10,7 +10,7 @@ JWT_SECRET=
# used to verify who's making the jwt # used to verify who's making the jwt
JWT_ISSUER=$PUBLIC_URL JWT_ISSUER=$PUBLIC_URL
# keibi's server to retrieve the public jwt secret # keibi's server to retrieve the public jwt secret
AUHT_SERVER=http://auth:4568 AUTH_SERVER=http://auth:4568
IMAGES_PATH=./images IMAGES_PATH=./images

View File

@ -1,6 +1,7 @@
import { TypeCompiler } from "@sinclair/typebox/compiler"; import { TypeCompiler } from "@sinclair/typebox/compiler";
import Elysia, { t } from "elysia"; import Elysia, { t } from "elysia";
import { createRemoteJWKSet, jwtVerify } from "jose"; import { createRemoteJWKSet, jwtVerify } from "jose";
import { KError } from "./models/error";
const jwtSecret = process.env.JWT_SECRET const jwtSecret = process.env.JWT_SECRET
? new TextEncoder().encode(process.env.JWT_SECRET) ? new TextEncoder().encode(process.env.JWT_SECRET)
@ -22,9 +23,12 @@ const validator = TypeCompiler.Compile(Jwt);
export const auth = new Elysia({ name: "auth" }) export const auth = new Elysia({ name: "auth" })
.guard({ .guard({
headers: t.Object({ headers: t.Object(
authorization: t.TemplateLiteral("Bearer ${string}"), {
}), authorization: t.TemplateLiteral("Bearer ${string}"),
},
{ additionalProperties: true },
),
}) })
.resolve(async ({ headers: { authorization }, error }) => { .resolve(async ({ headers: { authorization }, error }) => {
const bearer = authorization?.slice(7); const bearer = authorization?.slice(7);
@ -69,3 +73,33 @@ export const auth = new Elysia({ name: "auth" })
}, },
}) })
.as("plugin"); .as("plugin");
const User = t.Object({
id: t.String({ format: "uuid" }),
username: t.String(),
email: t.String({ format: "email" }),
createdDate: t.String({ format: "date-time" }),
lastSeen: t.String({ format: "date-time" }),
claims: t.Record(t.String(), t.Any()),
oidc: t.Record(
t.String(),
t.Object({
id: t.String({ format: "uuid" }),
username: t.String(),
profileUrl: t.Nullable(t.String({ format: "url" })),
}),
),
});
const UserC = TypeCompiler.Compile(t.Union([User, KError]));
export async function getUserInfo(
id: string,
headers: { authorization: string },
) {
const resp = await fetch(
new URL(`/auth/users/${id}`, process.env.AUTH_SERVER ?? "http://auth:4568"),
{ headers },
);
return UserC.Decode(await resp.json());
}

View File

@ -11,6 +11,7 @@ import { showsH } from "./controllers/shows/shows";
import { staffH } from "./controllers/staff"; import { staffH } from "./controllers/staff";
import { studiosH } from "./controllers/studios"; import { studiosH } from "./controllers/studios";
import { videosH } from "./controllers/videos"; import { videosH } from "./controllers/videos";
import { watchlistH } from "./controllers/watchlist";
import type { KError } from "./models/error"; import type { KError } from "./models/error";
export const base = new Elysia({ name: "base" }) export const base = new Elysia({ name: "base" })
@ -93,4 +94,5 @@ export const app = new Elysia({ prefix })
permissions: ["core.write"], permissions: ["core.write"],
}, },
(app) => app.use(videosH).use(seed), (app) => app.use(videosH).use(seed),
); )
.use(watchlistH);

View File

@ -1,5 +1,6 @@
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { auth } from "~/auth";
import { prefix } from "~/base"; import { prefix } from "~/base";
import { db } from "~/db"; import { db } from "~/db";
import { shows } from "~/db/schema"; import { shows } from "~/db/schema";
@ -32,12 +33,14 @@ export const collections = new Elysia({
collection: Collection, collection: Collection,
"collection-translation": CollectionTranslation, "collection-translation": CollectionTranslation,
}) })
.use(auth)
.get( .get(
"/:id", "/:id",
async ({ async ({
params: { id }, params: { id },
headers: { "accept-language": languages }, headers: { "accept-language": languages },
query: { preferOriginal, with: relations }, query: { preferOriginal, with: relations },
jwt: { sub },
error, error,
set, set,
}) => { }) => {
@ -52,6 +55,7 @@ export const collections = new Elysia({
fallbackLanguage: langs.includes("*"), fallbackLanguage: langs.includes("*"),
preferOriginal, preferOriginal,
relations, relations,
userId: sub,
}); });
if (!ret) { if (!ret) {
return error(404, { return error(404, {
@ -140,6 +144,7 @@ export const collections = new Elysia({
async ({ async ({
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages },
jwt: { sub },
request: { url }, request: { url },
}) => { }) => {
const langs = processLanguages(languages); const langs = processLanguages(languages);
@ -151,6 +156,7 @@ export const collections = new Elysia({
filter: and(eq(shows.kind, "collection"), filter), filter: and(eq(shows.kind, "collection"), filter),
languages: langs, languages: langs,
preferOriginal, preferOriginal,
userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit });
}, },
@ -222,6 +228,7 @@ export const collections = new Elysia({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages },
jwt: { sub },
request: { url }, request: { url },
error, error,
}) => { }) => {
@ -256,6 +263,7 @@ export const collections = new Elysia({
), ),
languages: langs, languages: langs,
preferOriginal, preferOriginal,
userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit });
}, },
@ -277,6 +285,7 @@ export const collections = new Elysia({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages },
jwt: { sub },
request: { url }, request: { url },
error, error,
}) => { }) => {
@ -311,6 +320,7 @@ export const collections = new Elysia({
), ),
languages: langs, languages: langs,
preferOriginal, preferOriginal,
userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit });
}, },
@ -332,6 +342,7 @@ export const collections = new Elysia({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages },
jwt: { sub },
request: { url }, request: { url },
error, error,
}) => { }) => {
@ -362,6 +373,7 @@ export const collections = new Elysia({
filter: and(eq(shows.collectionPk, collection.pk), filter), filter: and(eq(shows.collectionPk, collection.pk), filter),
languages: langs, languages: langs,
preferOriginal, preferOriginal,
userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit });
}, },

View File

@ -1,20 +1,9 @@
import { import { type SQL, and, eq, exists, ne, sql } from "drizzle-orm";
type SQL,
type Subquery,
and,
desc,
eq,
exists,
ne,
sql,
} from "drizzle-orm";
import type { PgSelect } from "drizzle-orm/pg-core";
import { db } from "~/db"; import { db } from "~/db";
import { import {
entries, entries,
entryTranslations, entryTranslations,
entryVideoJoin, entryVideoJoin,
history,
profiles, profiles,
showStudioJoin, showStudioJoin,
showTranslations, showTranslations,
@ -46,8 +35,19 @@ import {
sortToSql, sortToSql,
} from "~/models/utils"; } from "~/models/utils";
import type { EmbeddedVideo } from "~/models/video"; import type { EmbeddedVideo } from "~/models/video";
import { WatchlistStatus } from "~/models/watchlist";
import { entryVideosQ, getEntryProgressQ, mapProgress } from "../entries"; import { entryVideosQ, getEntryProgressQ, mapProgress } from "../entries";
export const watchStatusQ = db
.select({
...getColumns(watchlist),
percent: sql`${watchlist.seenCount}`.as("percent"),
})
.from(watchlist)
.leftJoin(profiles, eq(watchlist.profilePk, profiles.pk))
.where(eq(profiles.id, sql.placeholder("userId")))
.as("watchstatus");
export const showFilters: FilterDef = { export const showFilters: FilterDef = {
genres: { genres: {
column: shows.genres, column: shows.genres,
@ -70,6 +70,11 @@ export const showFilters: FilterDef = {
type: "string", type: "string",
isArray: true, isArray: true,
}, },
watchStatus: {
column: watchStatusQ.status,
type: "enum",
values: WatchlistStatus.enum,
},
}; };
export const showSort = Sort( export const showSort = Sort(
{ {
@ -80,6 +85,7 @@ export const showSort = Sort(
endAir: shows.endAir, endAir: shows.endAir,
createdAt: shows.createdAt, createdAt: shows.createdAt,
nextRefresh: shows.nextRefresh, nextRefresh: shows.nextRefresh,
watchStatus: watchStatusQ.status,
}, },
{ {
default: ["slug"], default: ["slug"],
@ -196,11 +202,9 @@ const showRelations = {
nextEntry: ({ nextEntry: ({
languages, languages,
userId, userId,
watchStatusQ,
}: { }: {
languages: string[]; languages: string[];
userId: string; userId: string;
watchStatusQ: Subquery;
}) => { }) => {
const transQ = db const transQ = db
.selectDistinctOn([entryTranslations.pk]) .selectDistinctOn([entryTranslations.pk])
@ -228,9 +232,7 @@ const showRelations = {
.innerJoin(transQ, eq(entries.pk, transQ.pk)) .innerJoin(transQ, eq(entries.pk, transQ.pk))
.leftJoin(progressQ, eq(entries.pk, progressQ.entryPk)) .leftJoin(progressQ, eq(entries.pk, progressQ.entryPk))
.leftJoinLateral(entryVideosQ, sql`true`) .leftJoinLateral(entryVideosQ, sql`true`)
.where( .where(eq(watchStatusQ.nextEntry, entries.pk))
eq((watchStatusQ as unknown as typeof watchlist).nextEntry, entries.pk),
)
.as("nextEntry"); .as("nextEntry");
}, },
}; };
@ -272,16 +274,6 @@ export async function getShows({
) )
.as("t"); .as("t");
const watchStatusQ = db
.select({
...getColumns(watchlist),
percent: sql`${watchlist.seenCount}`.as("percent"),
})
.from(watchlist)
.leftJoin(profiles, eq(watchlist.profilePk, profiles.pk))
.where(eq(profiles.id, userId))
.as("watchstatus");
return await db return await db
.select({ .select({
...getColumns(shows), ...getColumns(shows),
@ -302,11 +294,7 @@ export async function getShows({
watchStatus: getColumns(watchStatusQ), watchStatus: getColumns(watchStatusQ),
...buildRelations(relations, showRelations, { ...buildRelations(relations, showRelations, { languages, userId }),
languages,
userId,
watchStatusQ,
}),
}) })
.from(shows) .from(shows)
.leftJoin(watchStatusQ, eq(shows.pk, watchStatusQ.showPk)) .leftJoin(watchStatusQ, eq(shows.pk, watchStatusQ.showPk))
@ -327,5 +315,6 @@ export async function getShows({
: sortToSql(sort)), : sortToSql(sort)),
shows.pk, shows.pk,
) )
.limit(limit); .limit(limit)
.execute({ userId });
} }

View File

@ -1,5 +1,6 @@
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { auth } from "~/auth";
import { prefix } from "~/base"; import { prefix } from "~/base";
import { db } from "~/db"; import { db } from "~/db";
import { shows } from "~/db/schema"; import { shows } from "~/db/schema";
@ -22,12 +23,14 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
movie: Movie, movie: Movie,
"movie-translation": MovieTranslation, "movie-translation": MovieTranslation,
}) })
.use(auth)
.get( .get(
"/:id", "/:id",
async ({ async ({
params: { id }, params: { id },
headers: { "accept-language": languages }, headers: { "accept-language": languages },
query: { preferOriginal, with: relations }, query: { preferOriginal, with: relations },
jwt: { sub },
error, error,
set, set,
}) => { }) => {
@ -42,6 +45,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
fallbackLanguage: langs.includes("*"), fallbackLanguage: langs.includes("*"),
preferOriginal, preferOriginal,
relations, relations,
userId: sub,
}); });
if (!ret) { if (!ret) {
return error(404, { return error(404, {
@ -131,6 +135,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages },
request: { url }, request: { url },
jwt: { sub },
}) => { }) => {
const langs = processLanguages(languages); const langs = processLanguages(languages);
const items = await getShows({ const items = await getShows({
@ -141,6 +146,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
filter: and(eq(shows.kind, "movie"), filter), filter: and(eq(shows.kind, "movie"), filter),
languages: langs, languages: langs,
preferOriginal, preferOriginal,
userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit });
}, },

View File

@ -25,7 +25,7 @@ import {
sortToSql, sortToSql,
} from "~/models/utils"; } from "~/models/utils";
import { desc } from "~/models/utils/descriptions"; import { desc } from "~/models/utils/descriptions";
import type { WatchStatus } from "~/models/watchlist"; import type { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
import { showFilters, showSort } from "./shows/logic"; import { showFilters, showSort } from "./shows/logic";
const staffSort = Sort( const staffSort = Sort(
@ -219,7 +219,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
const watchStatusQ = db const watchStatusQ = db
.select({ .select({
watchStatus: jsonbBuildObject<WatchStatus>({ watchStatus: jsonbBuildObject<MovieWatchStatus & SerieWatchStatus>({
...getColumns(watchlist), ...getColumns(watchlist),
percent: watchlist.seenCount, percent: watchlist.seenCount,
}).as("watchStatus"), }).as("watchStatus"),

View File

@ -1,5 +1,6 @@
import { type SQL, and, eq, exists, sql } from "drizzle-orm"; import { type SQL, and, eq, exists, sql } from "drizzle-orm";
import Elysia, { t } from "elysia"; import Elysia, { t } from "elysia";
import { auth } from "~/auth";
import { prefix } from "~/base"; import { prefix } from "~/base";
import { db } from "~/db"; import { db } from "~/db";
import { import {
@ -127,6 +128,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
studio: Studio, studio: Studio,
"studio-translation": StudioTranslation, "studio-translation": StudioTranslation,
}) })
.use(auth)
.get( .get(
"/:id", "/:id",
async ({ async ({
@ -301,6 +303,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages },
jwt: { sub },
request: { url }, request: { url },
error, error,
}) => { }) => {
@ -339,6 +342,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
), ),
languages: langs, languages: langs,
preferOriginal, preferOriginal,
userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit });
}, },
@ -360,6 +364,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages },
jwt: { sub },
request: { url }, request: { url },
error, error,
}) => { }) => {
@ -399,6 +404,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
), ),
languages: langs, languages: langs,
preferOriginal, preferOriginal,
userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit });
}, },
@ -420,6 +426,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages },
jwt: { sub },
request: { url }, request: { url },
error, error,
}) => { }) => {
@ -459,6 +466,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
), ),
languages: langs, languages: langs,
preferOriginal, preferOriginal,
userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit });
}, },

View File

@ -0,0 +1,288 @@
import { type SQL, and, eq, isNotNull, isNull, sql } from "drizzle-orm";
import Elysia, { t } from "elysia";
import { auth, getUserInfo } from "~/auth";
import { db } from "~/db";
import { profiles, shows } from "~/db/schema";
import { watchlist } from "~/db/schema/watchlist";
import { conflictUpdateAllExcept, getColumns } from "~/db/utils";
import { KError } from "~/models/error";
import { bubble, madeInAbyss } from "~/models/examples";
import { Show } from "~/models/show";
import {
AcceptLanguage,
DbMetadata,
Filter,
Page,
createPage,
isUuid,
processLanguages,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";
import { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
import { getShows, showFilters, showSort, watchStatusQ } from "./shows/logic";
async function setWatchStatus({
show,
status,
userId,
}: {
show: { pk: number; kind: "movie" | "serie" };
status: SerieWatchStatus;
userId: string;
}) {
let [profile] = await db
.select({ pk: profiles.pk })
.from(profiles)
.where(eq(profiles.id, userId))
.limit(1);
if (!profile) {
[profile] = await db
.insert(profiles)
.values({ id: userId })
.onConflictDoUpdate({
// we can't do `onConflictDoNothing` because on race conditions
// we still want the profile to be returned.
target: [profiles.id],
set: { id: sql`excluded.id` },
})
.returning({ pk: profiles.pk });
}
const [ret] = await db
.insert(watchlist)
.values({
...status,
profilePk: profile.pk,
showPk: show.pk,
})
.onConflictDoUpdate({
target: [watchlist.profilePk, watchlist.showPk],
set: {
...conflictUpdateAllExcept(watchlist, [
"profilePk",
"showPk",
"createdAt",
"seenCount",
]),
// do not reset movie's progress during drop
...(show.kind === "movie" && status.status !== "dropped"
? { seenCount: sql`excluded.seen_count` }
: {}),
},
})
.returning({
...getColumns(watchlist),
percent: sql`${watchlist.seenCount}`.as("percent"),
});
return ret;
}
export const watchlistH = new Elysia({ tags: ["profiles"] })
.use(auth)
.guard(
{
query: t.Object({
sort: showSort,
filter: t.Optional(Filter({ def: showFilters })),
query: t.Optional(t.String({ description: desc.query })),
limit: t.Integer({
minimum: 1,
maximum: 250,
default: 50,
description: "Max page size.",
}),
after: t.Optional(t.String({ description: desc.after })),
preferOriginal: t.Optional(
t.Boolean({
description: desc.preferOriginal,
}),
),
}),
},
(app) =>
app
.get(
"/profiles/me/watchlist",
async ({
query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages },
request: { url },
jwt: { sub },
}) => {
const langs = processLanguages(languages);
const items = await getShows({
limit,
after,
query,
sort,
filter: and(
isNotNull(watchStatusQ.status),
isNull(shows.collectionPk),
filter,
),
languages: langs,
preferOriginal,
userId: sub,
});
return createPage(items, { url, sort, limit });
},
{
detail: { description: "Get all movies/series in your watchlist" },
headers: t.Object(
{
"accept-language": AcceptLanguage({ autoFallback: true }),
},
{ additionalProperties: true },
),
response: {
200: Page(Show),
422: KError,
},
},
)
.get(
"/profiles/:id/watchlist",
async ({
params: { id },
query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages, authorization },
request: { url },
error,
}) => {
const uInfo = await getUserInfo(id, { authorization });
if ("status" in uInfo) return error(uInfo.status as 404, uInfo);
const langs = processLanguages(languages);
const items = await getShows({
limit,
after,
query,
sort,
filter: and(
isNotNull(watchStatusQ.status),
isNull(shows.collectionPk),
filter,
),
languages: langs,
preferOriginal,
userId: uInfo.id,
});
return createPage(items, { url, sort, limit });
},
{
detail: {
description: "Get all movies/series in someone's watchlist",
},
params: t.Object({
id: t.String({
description:
"The id or username of the user to read the watchlist of",
example: "zoriya",
}),
}),
headers: t.Object({
authorization: t.TemplateLiteral("Bearer ${string}"),
"accept-language": AcceptLanguage({ autoFallback: true }),
}),
response: {
200: Page(Show),
403: KError,
404: {
...KError,
description: "No user found with the specified id/username.",
},
422: KError,
},
permissions: ["users.read"],
},
),
)
.post(
"/series/:id/watchstatus",
async ({ params: { id }, body, jwt: { sub }, error }) => {
const [show] = await db
.select({ pk: shows.pk })
.from(shows)
.where(
and(
eq(shows.kind, "serie"),
isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id),
),
);
if (!show) {
return error(404, {
status: 404,
message: `No serie found for the id/slug: '${id}'.`,
});
}
return await setWatchStatus({
show: { pk: show.pk, kind: "serie" },
userId: sub,
status: body,
});
},
{
detail: { description: "Set watchstatus of a series." },
params: t.Object({
id: t.String({
description: "The id or slug of the serie.",
example: madeInAbyss.slug,
}),
}),
body: SerieWatchStatus,
response: {
200: t.Union([SerieWatchStatus, DbMetadata]),
404: KError,
},
permissions: ["core.read"],
},
)
.post(
"/movies/:id/watchstatus",
async ({ params: { id }, body, jwt: { sub }, error }) => {
const [show] = await db
.select({ pk: shows.pk })
.from(shows)
.where(
and(
eq(shows.kind, "movie"),
isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id),
),
);
if (!show) {
return error(404, {
status: 404,
message: `No movie found for the id/slug: '${id}'.`,
});
}
return await setWatchStatus({
show: { pk: show.pk, kind: "movie" },
userId: sub,
status: {
...body,
startedAt: body.completedAt,
// for movies, watch-percent is stored in `seenCount`.
seenCount: body.status === "completed" ? 100 : 0,
},
});
},
{
detail: { description: "Set watchstatus of a movie." },
params: t.Object({
id: t.String({
description: "The id or slug of the movie.",
example: bubble.slug,
}),
}),
body: t.Omit(MovieWatchStatus, ["percent"]),
response: {
200: t.Union([MovieWatchStatus, DbMetadata]),
404: KError,
},
permissions: ["core.read"],
},
);

View File

@ -6,9 +6,9 @@ import { shows } from "./shows";
import { schema } from "./utils"; import { schema } from "./utils";
export const watchlistStatus = schema.enum("watchlist_status", [ export const watchlistStatus = schema.enum("watchlist_status", [
"completed",
"watching", "watching",
"rewatching", "rewatching",
"completed",
"dropped", "dropped",
"planned", "planned",
]); ]);

View File

@ -61,6 +61,10 @@ new Elysia()
name: "images", name: "images",
description: "Routes about images: posters, thumbnails...", description: "Routes about images: posters, thumbnails...",
}, },
{
name: "profiles",
description: "Routes about user profiles, watchlist & history.",
},
], ],
components: { components: {
securitySchemes: { securitySchemes: {

View File

@ -16,7 +16,7 @@ import {
} from "./utils"; } from "./utils";
import { Original } from "./utils/original"; import { Original } from "./utils/original";
import { EmbeddedVideo } from "./video"; import { EmbeddedVideo } from "./video";
import { WatchStatus } from "./watchlist"; import { MovieWatchStatus } from "./watchlist";
export const MovieStatus = t.UnionEnum(["unknown", "finished", "planned"]); export const MovieStatus = t.UnionEnum(["unknown", "finished", "planned"]);
export type MovieStatus = typeof MovieStatus.static; export type MovieStatus = typeof MovieStatus.static;
@ -56,7 +56,7 @@ export const Movie = t.Intersect([
t.Object({ t.Object({
original: Original, original: Original,
isAvailable: t.Boolean(), isAvailable: t.Boolean(),
watchStatus: t.Nullable(t.Omit(WatchStatus, ["seenCount"])), watchStatus: t.Nullable(MovieWatchStatus),
}), }),
]); ]);
export type Movie = Prettify<typeof Movie.static>; export type Movie = Prettify<typeof Movie.static>;

View File

@ -17,7 +17,7 @@ import {
TranslationRecord, TranslationRecord,
} from "./utils"; } from "./utils";
import { Original } from "./utils/original"; import { Original } from "./utils/original";
import { WatchStatus } from "./watchlist"; import { SerieWatchStatus } from "./watchlist";
export const SerieStatus = t.UnionEnum([ export const SerieStatus = t.UnionEnum([
"unknown", "unknown",
@ -71,7 +71,7 @@ export const Serie = t.Intersect([
availableCount: t.Integer({ availableCount: t.Integer({
description: "The number of episodes that can be played right away", description: "The number of episodes that can be played right away",
}), }),
watchStatus: t.Nullable(t.Omit(WatchStatus, ["percent"])), watchStatus: t.Nullable(SerieWatchStatus),
}), }),
]); ]);
export type Serie = Prettify<typeof Serie.static>; export type Serie = Prettify<typeof Serie.static>;
@ -84,7 +84,7 @@ export const FullSerie = t.Intersect([
firstEntry: t.Optional(Entry), firstEntry: t.Optional(Entry),
}), }),
]); ]);
export type FullMovie = Prettify<typeof FullSerie.static>; export type FullSerie = Prettify<typeof FullSerie.static>;
export const SeedSerie = t.Intersect([ export const SeedSerie = t.Intersect([
t.Omit(BaseSerie, ["kind", "nextRefresh"]), t.Omit(BaseSerie, ["kind", "nextRefresh"]),

View File

@ -36,20 +36,25 @@ export const WatchlistStatus = t.UnionEnum([
"planned", "planned",
]); ]);
export const WatchStatus = t.Object({ export const SerieWatchStatus = t.Object({
status: WatchlistStatus, status: WatchlistStatus,
score: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })), score: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })),
startedAt: t.Nullable(t.String({ format: "date-time" })), startedAt: t.Nullable(t.String({ format: "date-time" })),
completedAt: t.Nullable(t.String({ format: "date-time" })), completedAt: t.Nullable(t.String({ format: "date-time" })),
// only for series
seenCount: t.Integer({ seenCount: t.Integer({
description: "The number of episodes you watched in this serie.", description: "The number of episodes you watched in this serie.",
minimum: 0, minimum: 0,
}), }),
// only for movies
percent: t.Integer({
minimum: 0,
maximum: 100,
}),
}); });
export type WatchStatus = typeof WatchStatus.static; export type SerieWatchStatus = typeof SerieWatchStatus.static;
export const MovieWatchStatus = t.Intersect([
t.Omit(SerieWatchStatus, ["startedAt", "seenCount"]),
t.Object({
percent: t.Integer({
minimum: 0,
maximum: 100,
}),
}),
]);
export type MovieWatchStatus = typeof MovieWatchStatus.static;

View File

@ -1,5 +1,6 @@
export * from "./movies-helper"; export * from "./movies-helper";
export * from "./series-helper"; export * from "./series-helper";
export * from "./shows-helper";
export * from "./studio-helper"; export * from "./studio-helper";
export * from "./staff-helper"; export * from "./staff-helper";
export * from "./videos-helper"; export * from "./videos-helper";

View File

@ -5,7 +5,7 @@ export async function getJwtHeaders() {
sub: "39158be0-3f59-4c45-b00d-d25b3bc2b884", sub: "39158be0-3f59-4c45-b00d-d25b3bc2b884",
sid: "04ac7ecc-255b-481d-b0c8-537c1578e3d5", sid: "04ac7ecc-255b-481d-b0c8-537c1578e3d5",
username: "test-username", username: "test-username",
permissions: ["core.read", "core.write"], permissions: ["core.read", "core.write", "users.read"],
}) })
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
.setIssuedAt() .setIssuedAt()

View File

@ -1,6 +1,7 @@
import { buildUrl } from "tests/utils"; import { buildUrl } from "tests/utils";
import { app } from "~/base"; import { app } from "~/base";
import type { SeedMovie } from "~/models/movie"; import type { SeedMovie } from "~/models/movie";
import type { MovieWatchStatus } from "~/models/watchlist";
import { getJwtHeaders } from "./jwt"; import { getJwtHeaders } from "./jwt";
export const getMovie = async ( export const getMovie = async (
@ -66,3 +67,21 @@ export const createMovie = async (movie: SeedMovie) => {
const body = await resp.json(); const body = await resp.json();
return [resp, body] as const; return [resp, body] as const;
}; };
export const setMovieStatus = async (
id: string,
status: Omit<MovieWatchStatus, "percent">,
) => {
const resp = await app.handle(
new Request(buildUrl(`movies/${id}/watchstatus`), {
method: "POST",
body: JSON.stringify(status),
headers: {
"Content-Type": "application/json",
...(await getJwtHeaders()),
},
}),
);
const body = await resp.json();
return [resp, body] as const;
};

View File

@ -1,6 +1,7 @@
import { buildUrl } from "tests/utils"; import { buildUrl } from "tests/utils";
import { app } from "~/base"; import { app } from "~/base";
import type { SeedSerie } from "~/models/serie"; import type { SeedSerie } from "~/models/serie";
import type { SerieWatchStatus } from "~/models/watchlist";
import { getJwtHeaders } from "./jwt"; import { getJwtHeaders } from "./jwt";
export const createSerie = async (serie: SeedSerie) => { export const createSerie = async (serie: SeedSerie) => {
@ -40,6 +41,25 @@ export const getSerie = async (
return [resp, body] as const; return [resp, body] as const;
}; };
export const getSeries = async ({
langs,
...query
}: { langs?: string; preferOriginal?: boolean; with?: string[] }) => {
const resp = await app.handle(
new Request(buildUrl("series", query), {
method: "GET",
headers: langs
? {
"Accept-Language": langs,
...(await getJwtHeaders()),
}
: await getJwtHeaders(),
}),
);
const body = await resp.json();
return [resp, body] as const;
};
export const getSeasons = async ( export const getSeasons = async (
serie: string, serie: string,
{ {
@ -162,3 +182,18 @@ export const getNews = async ({
const body = await resp.json(); const body = await resp.json();
return [resp, body] as const; return [resp, body] as const;
}; };
export const setSerieStatus = async (id: string, status: SerieWatchStatus) => {
const resp = await app.handle(
new Request(buildUrl(`series/${id}/watchstatus`), {
method: "POST",
body: JSON.stringify(status),
headers: {
"Content-Type": "application/json",
...(await getJwtHeaders()),
},
}),
);
const body = await resp.json();
return [resp, body] as const;
};

View File

@ -0,0 +1,60 @@
import { buildUrl } from "tests/utils";
import { app } from "~/base";
import { getJwtHeaders } from "./jwt";
export const getShows = async ({
langs,
...query
}: {
filter?: string;
limit?: number;
after?: string;
sort?: string | string[];
query?: string;
langs?: string;
preferOriginal?: boolean;
}) => {
const resp = await app.handle(
new Request(buildUrl("shows", query), {
method: "GET",
headers: langs
? {
"Accept-Language": langs,
...(await getJwtHeaders()),
}
: await getJwtHeaders(),
}),
);
const body = await resp.json();
return [resp, body] as const;
};
export const getWatchlist = async (
id: string,
{
langs,
...query
}: {
filter?: string;
limit?: number;
after?: string;
sort?: string | string[];
query?: string;
langs?: string;
preferOriginal?: boolean;
},
) => {
const resp = await app.handle(
new Request(buildUrl(`profiles/${id}/watchlist`, query), {
method: "GET",
headers: langs
? {
"Accept-Language": langs,
...(await getJwtHeaders()),
}
: await getJwtHeaders(),
}),
);
const body = await resp.json();
return [resp, body] as const;
};

View File

@ -1,24 +1,29 @@
import { processImages } from "~/controllers/seed/images";
import { db, migrate } from "~/db"; import { db, migrate } from "~/db";
import { mqueue, shows, videos } from "~/db/schema"; import { profiles, shows } from "~/db/schema";
import { madeInAbyss, madeInAbyssVideo } from "~/models/examples"; import { madeInAbyss } from "~/models/examples";
import { createSerie, createVideo, getSerie } from "./helpers"; import { createSerie, getSerie, setSerieStatus } from "./helpers";
import { getJwtHeaders } from "./helpers/jwt";
// test file used to run manually using `bun tests/manual.ts` // test file used to run manually using `bun tests/manual.ts`
await migrate(); await migrate();
await db.delete(shows); await db.delete(shows);
await db.delete(videos); await db.delete(profiles);
await db.delete(mqueue);
const [_, vid] = await createVideo(madeInAbyssVideo); console.log(await getJwtHeaders());
console.log(vid);
const [__, ser] = await createSerie(madeInAbyss); const [_, ser] = await createSerie(madeInAbyss);
console.log(ser); console.log(ser);
const [__, ret] = await setSerieStatus(madeInAbyss.slug, {
status: "watching",
startedAt: "2024-12-21",
completedAt: "2024-12-21",
seenCount: 2,
score: 85,
});
console.log(ret);
await processImages(); const [___, got] = await getSerie(madeInAbyss.slug, {});
console.log(JSON.stringify(got, undefined, 4));
const [___, got] = await getSerie(madeInAbyss.slug, { with: ["translations"] });
console.log(got);
process.exit(0); process.exit(0);

View File

@ -1,4 +1,4 @@
import { beforeAll, describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { defaultBlurhash, processImages } from "~/controllers/seed/images"; import { defaultBlurhash, processImages } from "~/controllers/seed/images";
import { db } from "~/db"; import { db } from "~/db";
@ -6,21 +6,19 @@ import { mqueue, shows, staff, studios, videos } from "~/db/schema";
import { madeInAbyss } from "~/models/examples"; import { madeInAbyss } from "~/models/examples";
import { createSerie } from "../helpers"; import { createSerie } from "../helpers";
beforeAll(async () => {
await db.delete(shows);
await db.delete(studios);
await db.delete(staff);
await db.delete(videos);
await db.delete(mqueue);
await createSerie(madeInAbyss);
const release = await processImages();
// remove notifications to prevent other images to be downloaded (do not curl 20000 images for nothing)
release();
});
describe("images", () => { describe("images", () => {
it("Create a serie download images", async () => { it("Create a serie download images", async () => {
await db.delete(shows);
await db.delete(studios);
await db.delete(staff);
await db.delete(videos);
await db.delete(mqueue);
await createSerie(madeInAbyss);
const release = await processImages();
// remove notifications to prevent other images to be downloaded (do not curl 20000 images for nothing)
release();
const ret = await db.query.shows.findFirst({ const ret = await db.query.shows.findFirst({
where: eq(shows.slug, madeInAbyss.slug), where: eq(shows.slug, madeInAbyss.slug),
}); });

View File

@ -0,0 +1,102 @@
import { beforeAll, describe, expect, it } from "bun:test";
import {
createMovie,
getMovie,
getShows,
getWatchlist,
setMovieStatus,
} from "tests/helpers";
import { expectStatus } from "tests/utils";
import { db } from "~/db";
import { shows } from "~/db/schema";
import { bubble } from "~/models/examples";
beforeAll(async () => {
await db.delete(shows);
const [ret, body] = await createMovie(bubble);
expectStatus(ret, body).toBe(201);
});
describe("Set & get watch status", () => {
it("Creates watchlist entry", async () => {
let [resp, body] = await getWatchlist("me", {});
expectStatus(resp, body).toBe(200);
expect(body.items).toBeArrayOfSize(0);
const [r, b] = await setMovieStatus(bubble.slug, {
status: "completed",
completedAt: "2024-12-21",
score: 85,
});
expectStatus(r, b).toBe(200);
[resp, body] = await getWatchlist("me", {});
expectStatus(resp, body).toBe(200);
expect(body.items).toBeArrayOfSize(1);
expect(body.items[0].slug).toBe(bubble.slug);
expect(body.items[0].watchStatus).toMatchObject({
status: "completed",
completedAt: "2024-12-21 00:00:00+00",
score: 85,
percent: 100,
});
});
it("Edit watchlist entry", async () => {
let [resp, body] = await getWatchlist("me", {});
expectStatus(resp, body).toBe(200);
expect(body.items).toBeArrayOfSize(1);
const [r, b] = await setMovieStatus(bubble.slug, {
status: "rewatching",
// we still need to specify all values
completedAt: "2024-12-21",
score: 85,
});
expectStatus(r, b).toBe(200);
[resp, body] = await getWatchlist("me", {});
expectStatus(resp, body).toBe(200);
expect(body.items).toBeArrayOfSize(1);
expect(body.items[0].slug).toBe(bubble.slug);
expect(body.items[0].watchStatus).toMatchObject({
status: "rewatching",
completedAt: "2024-12-21 00:00:00+00",
score: 85,
percent: 0,
});
});
it("Return watchstatus in /shows", async () => {
const [resp, body] = await getShows({});
expectStatus(resp, body).toBe(200);
expect(body.items).toBeArrayOfSize(1);
expect(body.items[0].slug).toBe(bubble.slug);
expect(body.items[0].watchStatus).toMatchObject({
status: "rewatching",
completedAt: "2024-12-21 00:00:00+00",
score: 85,
percent: 0,
});
});
it("Return watchstatus in /movies/:id", async () => {
const [r, b] = await setMovieStatus(bubble.slug, {
status: "rewatching",
// we still need to specify all values
completedAt: "2024-12-21",
score: 85,
});
expectStatus(r, b).toBe(200);
const [resp, body] = await getMovie(bubble.slug, {});
expectStatus(resp, body).toBe(200);
expect(body.slug).toBe(bubble.slug);
expect(body.watchStatus).toMatchObject({
status: "rewatching",
completedAt: "2024-12-21 00:00:00+00",
score: 85,
percent: 0,
});
});
});

View File

@ -11,7 +11,7 @@ RSA_PRIVATE_KEY_PATH=""
EXTRA_CLAIMS='{}' EXTRA_CLAIMS='{}'
# json object with the claims to add to every jwt of the FIRST user (this can be used to mark the first user as admin). # json object with the claims to add to every jwt of the FIRST user (this can be used to mark the first user as admin).
# Those claims are merged with the `EXTRA_CLAIMS`. # Those claims are merged with the `EXTRA_CLAIMS`.
FIRST_USER_CLAIMS='{}' FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "users.delete"]}'
# If this is not empty, calls to `/jwt` without an `Authorization` header will still create a jwt (with `null` in `sub`) # If this is not empty, calls to `/jwt` without an `Authorization` header will still create a jwt (with `null` in `sub`)
GUEST_CLAIMS="" GUEST_CLAIMS=""
# Comma separated list of claims that users without the `user.write` permissions should NOT be able to edit # Comma separated list of claims that users without the `user.write` permissions should NOT be able to edit

View File

@ -47,14 +47,13 @@ func LoadConfiguration(db *dbc.Queries) (*Configuration, error) {
return nil, err return nil, err
} }
} }
maps.Insert(ret.FirstUserClaims, maps.All(ret.DefaultClaims))
claims = os.Getenv("FIRST_USER_CLAIMS") claims = os.Getenv("FIRST_USER_CLAIMS")
if claims != "" { if claims != "" {
err := json.Unmarshal([]byte(claims), &ret.FirstUserClaims) err := json.Unmarshal([]byte(claims), &ret.FirstUserClaims)
if err != nil { if err != nil {
return nil, err return nil, err
} }
maps.Insert(ret.FirstUserClaims, maps.All(ret.DefaultClaims))
} else { } else {
ret.FirstUserClaims = ret.DefaultClaims ret.FirstUserClaims = ret.DefaultClaims
} }

View File

@ -175,10 +175,18 @@ select
from from
users as u users as u
left join oidc_handle as h on u.pk = h.user_pk left join oidc_handle as h on u.pk = h.user_pk
where where ($1::boolean
u.id = $1 and u.id = $2)
or (not $1
and u.username = $3)
` `
type GetUserParams struct {
UseId bool `json:"useId"`
Id uuid.UUID `json:"id"`
Username string `json:"username"`
}
type GetUserRow struct { type GetUserRow struct {
User User `json:"user"` User User `json:"user"`
Provider *string `json:"provider"` Provider *string `json:"provider"`
@ -187,8 +195,8 @@ type GetUserRow struct {
ProfileUrl *string `json:"profileUrl"` ProfileUrl *string `json:"profileUrl"`
} }
func (q *Queries) GetUser(ctx context.Context, id uuid.UUID) ([]GetUserRow, error) { func (q *Queries) GetUser(ctx context.Context, arg GetUserParams) ([]GetUserRow, error) {
rows, err := q.db.Query(ctx, getUser, id) rows, err := q.db.Query(ctx, getUser, arg.UseId, arg.Id, arg.Username)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -28,8 +28,10 @@ select
from from
users as u users as u
left join oidc_handle as h on u.pk = h.user_pk left join oidc_handle as h on u.pk = h.user_pk
where where (@use_id::boolean
u.id = $1; and u.id = @id)
or (not @use_id
and u.username = @username);
-- name: GetUserByLogin :one -- name: GetUserByLogin :one
select select

View File

@ -15,6 +15,15 @@ HTTP 200
[Captures] [Captures]
jwt: jsonpath "$.token" jwt: jsonpath "$.token"
GET {{host}}/users/me
Authorization: Bearer {{jwt}}
HTTP 200
[Captures]
userId: jsonpath "$.id"
[Asserts]
# this should be defined in the `FIRST_USER_CLAIMS='{"permissions": ["users.read"]}'` env var
jsonpath "$.claims.permissions" contains "users.read"
# Duplicates usernames # Duplicates usernames
POST {{host}}/users POST {{host}}/users
@ -35,6 +44,26 @@ POST {{host}}/users
} }
HTTP 409 HTTP 409
# Cannot get non-existing user
GET {{host}}/users/dont-exist
Authorization: Bearer {{jwt}}
HTTP 404
# Can get user by id
GET {{host}}/users/{{userId}}
Authorization: Bearer {{jwt}}
HTTP 200
[Asserts]
jsonpath "$.username" == "user-1"
# Can get user by username
GET {{host}}/users/user-1
Authorization: Bearer {{jwt}}
HTTP 200
[Asserts]
jsonpath "$.id" == {{userId}}
jsonpath "$.username" == "user-1"
DELETE {{host}}/users/me DELETE {{host}}/users/me
Authorization: Bearer {{jwt}} Authorization: Bearer {{jwt}}

View File

@ -94,7 +94,7 @@ func MapOidc(oidc *dbc.GetUserRow) OidcHandle {
// @Failure 422 {object} KError "Invalid after id" // @Failure 422 {object} KError "Invalid after id"
// @Router /users [get] // @Router /users [get]
func (h *Handler) ListUsers(c echo.Context) error { func (h *Handler) ListUsers(c echo.Context) error {
err := CheckPermissions(c, []string{"user.read"}) err := CheckPermissions(c, []string{"users.read"})
if err != nil { if err != nil {
return err return err
} }
@ -139,19 +139,24 @@ func (h *Handler) ListUsers(c echo.Context) error {
// @Failure 422 {object} KError "Invalid id (not a uuid)" // @Failure 422 {object} KError "Invalid id (not a uuid)"
// @Router /users/{id} [get] // @Router /users/{id} [get]
func (h *Handler) GetUser(c echo.Context) error { func (h *Handler) GetUser(c echo.Context) error {
err := CheckPermissions(c, []string{"user.read"}) err := CheckPermissions(c, []string{"users.read"})
if err != nil { if err != nil {
return err return err
} }
id, err := uuid.Parse(c.Param("id")) id := c.Param("id")
if err != nil { uid, err := uuid.Parse(c.Param("id"))
return echo.NewHTTPError(http.StatusUnprocessableEntity, "Invalid id") dbuser, err := h.db.GetUser(context.Background(), dbc.GetUserParams{
} UseId: err == nil,
dbuser, err := h.db.GetUser(context.Background(), id) Id: uid,
Username: id,
})
if err != nil { if err != nil {
return err return err
} }
if len(dbuser) == 0 {
return echo.NewHTTPError(404, fmt.Sprintf("No user found with id or username: '%s'.", id))
}
user := MapDbUser(&dbuser[0].User) user := MapDbUser(&dbuser[0].User)
for _, oidc := range dbuser { for _, oidc := range dbuser {
@ -177,7 +182,10 @@ func (h *Handler) GetMe(c echo.Context) error {
if err != nil { if err != nil {
return err return err
} }
dbuser, err := h.db.GetUser(context.Background(), id) dbuser, err := h.db.GetUser(context.Background(), dbc.GetUserParams{
UseId: true,
Id: id,
})
if err != nil { if err != nil {
return err return err
} }
@ -246,7 +254,7 @@ func (h *Handler) Register(c echo.Context) error {
// @Failure 404 {object} KError "Invalid user id" // @Failure 404 {object} KError "Invalid user id"
// @Router /users/{id} [delete] // @Router /users/{id} [delete]
func (h *Handler) DeleteUser(c echo.Context) error { func (h *Handler) DeleteUser(c echo.Context) error {
err := CheckPermissions(c, []string{"user.delete"}) err := CheckPermissions(c, []string{"users.delete"})
if err != nil { if err != nil {
return err return err
} }
@ -348,7 +356,7 @@ func (h *Handler) EditSelf(c echo.Context) error {
// @Success 422 {object} KError "Invalid body" // @Success 422 {object} KError "Invalid body"
// @Router /users/{id} [patch] // @Router /users/{id} [patch]
func (h *Handler) EditUser(c echo.Context) error { func (h *Handler) EditUser(c echo.Context) error {
err := CheckPermissions(c, []string{"user.write"}) err := CheckPermissions(c, []string{"users.write"})
if err != nil { if err != nil {
return err return err
} }

View File

@ -68,11 +68,21 @@ func CheckPermissions(c echo.Context, perms []string) error {
if !ok { if !ok {
return echo.NewHTTPError(403, fmt.Sprintf("Missing permissions: %s.", ", ")) return echo.NewHTTPError(403, fmt.Sprintf("Missing permissions: %s.", ", "))
} }
permissions, ok := permissions_claims.([]string) fmt.Printf("%v\n", permissions_claims)
fmt.Printf("%t\n", permissions_claims)
permissions_int, ok := permissions_claims.([]any)
if !ok { if !ok {
return echo.NewHTTPError(403, "Invalid permission claim.") return echo.NewHTTPError(403, "Invalid permission claim.")
} }
permissions := make([]string, len(permissions_int))
for i, perm := range permissions_int {
permissions[i], ok = perm.(string)
if !ok {
return echo.NewHTTPError(403, "Invalid permission claim.")
}
}
missing := make([]string, 0) missing := make([]string, 0)
for _, perm := range perms { for _, perm := range perms {
if !slices.Contains(permissions, perm) { if !slices.Contains(permissions, perm) {