mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Add watchStatus apis (#874)
This commit is contained in:
commit
572e763a61
@ -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"
|
||||
|
1
.github/workflows/auth-hurl.yml
vendored
1
.github/workflows/auth-hurl.yml
vendored
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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());
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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 });
|
||||
},
|
||||
|
@ -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 });
|
||||
}
|
||||
|
@ -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 });
|
||||
},
|
||||
|
@ -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"),
|
||||
|
@ -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 });
|
||||
},
|
||||
|
288
api/src/controllers/watchlist.ts
Normal file
288
api/src/controllers/watchlist.ts
Normal 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"],
|
||||
},
|
||||
);
|
@ -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",
|
||||
]);
|
||||
|
@ -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: {
|
||||
|
@ -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>;
|
||||
|
@ -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"]),
|
||||
|
@ -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;
|
||||
|
@ -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";
|
||||
|
@ -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()
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
||||
|
60
api/tests/helpers/shows-helper.ts
Normal file
60
api/tests/helpers/shows-helper.ts
Normal 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;
|
||||
};
|
@ -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);
|
||||
|
@ -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),
|
||||
});
|
||||
|
102
api/tests/movies/watchstatus.test.ts
Normal file
102
api/tests/movies/watchstatus.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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}}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user