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)
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"]}'
PROTECTED_CLAIMS="permissions,verified"

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import { TypeCompiler } from "@sinclair/typebox/compiler";
import Elysia, { t } from "elysia";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { KError } from "./models/error";
const jwtSecret = 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" })
.guard({
headers: t.Object({
authorization: t.TemplateLiteral("Bearer ${string}"),
}),
headers: t.Object(
{
authorization: t.TemplateLiteral("Bearer ${string}"),
},
{ additionalProperties: true },
),
})
.resolve(async ({ headers: { authorization }, error }) => {
const bearer = authorization?.slice(7);
@ -69,3 +73,33 @@ export const auth = new Elysia({ name: "auth" })
},
})
.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 { studiosH } from "./controllers/studios";
import { videosH } from "./controllers/videos";
import { watchlistH } from "./controllers/watchlist";
import type { KError } from "./models/error";
export const base = new Elysia({ name: "base" })
@ -93,4 +94,5 @@ export const app = new Elysia({ prefix })
permissions: ["core.write"],
},
(app) => app.use(videosH).use(seed),
);
)
.use(watchlistH);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ import {
TranslationRecord,
} from "./utils";
import { Original } from "./utils/original";
import { WatchStatus } from "./watchlist";
import { SerieWatchStatus } from "./watchlist";
export const SerieStatus = t.UnionEnum([
"unknown",
@ -71,7 +71,7 @@ export const Serie = t.Intersect([
availableCount: t.Integer({
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>;
@ -84,7 +84,7 @@ export const FullSerie = t.Intersect([
firstEntry: t.Optional(Entry),
}),
]);
export type FullMovie = Prettify<typeof FullSerie.static>;
export type FullSerie = Prettify<typeof FullSerie.static>;
export const SeedSerie = t.Intersect([
t.Omit(BaseSerie, ["kind", "nextRefresh"]),

View File

@ -36,20 +36,25 @@ export const WatchlistStatus = t.UnionEnum([
"planned",
]);
export const WatchStatus = t.Object({
export const SerieWatchStatus = t.Object({
status: WatchlistStatus,
score: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })),
startedAt: t.Nullable(t.String({ format: "date-time" })),
completedAt: t.Nullable(t.String({ format: "date-time" })),
// only for series
seenCount: t.Integer({
description: "The number of episodes you watched in this serie.",
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 "./series-helper";
export * from "./shows-helper";
export * from "./studio-helper";
export * from "./staff-helper";
export * from "./videos-helper";

View File

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

View File

@ -1,6 +1,7 @@
import { buildUrl } from "tests/utils";
import { app } from "~/base";
import type { SeedMovie } from "~/models/movie";
import type { MovieWatchStatus } from "~/models/watchlist";
import { getJwtHeaders } from "./jwt";
export const getMovie = async (
@ -66,3 +67,21 @@ export const createMovie = async (movie: SeedMovie) => {
const body = await resp.json();
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 { app } from "~/base";
import type { SeedSerie } from "~/models/serie";
import type { SerieWatchStatus } from "~/models/watchlist";
import { getJwtHeaders } from "./jwt";
export const createSerie = async (serie: SeedSerie) => {
@ -40,6 +41,25 @@ export const getSerie = async (
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 (
serie: string,
{
@ -162,3 +182,18 @@ export const getNews = async ({
const body = await resp.json();
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 { mqueue, shows, videos } from "~/db/schema";
import { madeInAbyss, madeInAbyssVideo } from "~/models/examples";
import { createSerie, createVideo, getSerie } from "./helpers";
import { profiles, shows } from "~/db/schema";
import { madeInAbyss } from "~/models/examples";
import { createSerie, getSerie, setSerieStatus } from "./helpers";
import { getJwtHeaders } from "./helpers/jwt";
// test file used to run manually using `bun tests/manual.ts`
await migrate();
await db.delete(shows);
await db.delete(videos);
await db.delete(mqueue);
await db.delete(profiles);
const [_, vid] = await createVideo(madeInAbyssVideo);
console.log(vid);
const [__, ser] = await createSerie(madeInAbyss);
console.log(await getJwtHeaders());
const [_, ser] = await createSerie(madeInAbyss);
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, { with: ["translations"] });
console.log(got);
const [___, got] = await getSerie(madeInAbyss.slug, {});
console.log(JSON.stringify(got, undefined, 4));
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 { defaultBlurhash, processImages } from "~/controllers/seed/images";
import { db } from "~/db";
@ -6,21 +6,19 @@ import { mqueue, shows, staff, studios, videos } from "~/db/schema";
import { madeInAbyss } from "~/models/examples";
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", () => {
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({
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='{}'
# 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`.
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`)
GUEST_CLAIMS=""
# 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
}
}
maps.Insert(ret.FirstUserClaims, maps.All(ret.DefaultClaims))
claims = os.Getenv("FIRST_USER_CLAIMS")
if claims != "" {
err := json.Unmarshal([]byte(claims), &ret.FirstUserClaims)
if err != nil {
return nil, err
}
maps.Insert(ret.FirstUserClaims, maps.All(ret.DefaultClaims))
} else {
ret.FirstUserClaims = ret.DefaultClaims
}

View File

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

View File

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

View File

@ -15,6 +15,15 @@ HTTP 200
[Captures]
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
POST {{host}}/users
@ -35,6 +44,26 @@ POST {{host}}/users
}
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
Authorization: Bearer {{jwt}}

View File

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

View File

@ -68,11 +68,21 @@ func CheckPermissions(c echo.Context, perms []string) error {
if !ok {
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 {
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)
for _, perm := range perms {
if !slices.Contains(permissions, perm) {