partial: user routes

This commit is contained in:
DecDuck
2025-08-10 10:19:45 +10:00
parent 80f7757558
commit a0b4381f0b
54 changed files with 545 additions and 410 deletions

View File

@ -2,14 +2,10 @@
<div class="min-h-screen flex flex-col"> <div class="min-h-screen flex flex-col">
<div class="grow grid grid-cols-1 lg:grid-cols-2"> <div class="grow grid grid-cols-1 lg:grid-cols-2">
<div class="border-b lg:border-b-0 lg:border-r border-zinc-700"> <div class="border-b lg:border-b-0 lg:border-r border-zinc-700">
<header <header class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8">
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
>
<DropWordmark /> <DropWordmark />
</header> </header>
<main <main class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8">
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
>
<div> <div>
<h1 class="text-4xl font-display font-bold text-zinc-100"> <h1 class="text-4xl font-display font-bold text-zinc-100">
{{ $t("setup.welcome") }} {{ $t("setup.welcome") }}
@ -20,32 +16,19 @@
</p> </p>
</div> </div>
<ul role="list" class="mt-10 divide-y divide-zinc-700/5"> <ul role="list" class="mt-10 divide-y divide-zinc-700/5">
<li <li v-for="(action, actionIdx) in actions" :key="action.name" class="relative flex gap-x-6 py-6">
v-for="(action, actionIdx) in actions"
:key="action.name"
class="relative flex gap-x-6 py-6"
>
<div <div
class="flex size-10 flex-none items-center justify-center rounded-lg shadow-xs outline-1 outline-zinc-100/10" class="flex size-10 flex-none items-center justify-center rounded-lg shadow-xs outline-1 outline-zinc-100/10">
> <component :is="action.icon" v-if="!actionsComplete[actionIdx]" class="size-6 text-blue-500"
<component aria-hidden="true" />
:is="action.icon"
v-if="!actionsComplete[actionIdx]"
class="size-6 text-blue-500"
aria-hidden="true"
/>
<CheckIcon v-else class="size-6 text-blue-500" /> <CheckIcon v-else class="size-6 text-blue-500" />
</div> </div>
<div class="flex-auto"> <div class="flex-auto">
<h3 class="text-sm/6 font-semibold text-zinc-100"> <h3 class="text-sm/6 font-semibold text-zinc-100">
<button <button :class="actionsComplete[actionIdx]
:class="
actionsComplete[actionIdx]
? 'line-through text-zinc-300' ? 'line-through text-zinc-300'
: '' : ''
" " @click="() => (currentAction = actionIdx)">
@click="() => (currentAction = actionIdx)"
>
<span class="absolute inset-0" aria-hidden="true" /> <span class="absolute inset-0" aria-hidden="true" />
{{ action.name }} {{ action.name }}
</button> </button>
@ -55,18 +38,11 @@
</p> </p>
</div> </div>
<div class="flex-none self-center"> <div class="flex-none self-center">
<ChevronRightIcon <ChevronRightIcon class="size-5 text-gray-400" aria-hidden="true" />
class="size-5 text-gray-400"
aria-hidden="true"
/>
</div> </div>
</li> </li>
</ul> </ul>
<LoadingButton <LoadingButton :disabled="!finished" :loading="finishLoading" @click="() => finish()">
:disabled="!finished"
:loading="finishLoading"
@click="() => finish()"
>
<i18n-t keypath="setup.finish" tag="span" scope="global"> <i18n-t keypath="setup.finish" tag="span" scope="global">
<template #arrow> <template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span> <span aria-hidden="true">{{ $t("chars.arrow") }}</span>
@ -75,25 +51,13 @@
</LoadingButton> </LoadingButton>
</main> </main>
</div> </div>
<component <component :is="actions[currentAction].page" v-if="actions[currentAction] && !useModal"
:is="actions[currentAction].page" v-model="actionsComplete[currentAction]" :token="bearerToken" />
v-if="actions[currentAction] && !useModal" <div v-else-if="!useModal" class="bg-zinc-950/30 flex items-center justify-center">
v-model="actionsComplete[currentAction]"
:token="bearerToken"
/>
<div
v-else-if="!useModal"
class="bg-zinc-950/30 flex items-center justify-center"
>
<!-- <p class="uppercase text-sm font-display text-zinc-700 font-bold"> <!-- <p class="uppercase text-sm font-display text-zinc-700 font-bold">
{{ $t("setup.noPage") }} {{ $t("setup.noPage") }}
</p> --> </p> -->
<img <img src="/wallpapers/signin.jpg" class="inset-0 h-full w-full object-cover" alt="" preload />
src="/wallpapers/signin.jpg"
class="inset-0 h-full w-full object-cover"
alt=""
preload
/>
</div> </div>
</div> </div>
<div> <div>
@ -102,25 +66,17 @@
<div class="fixed inset-0 bg-zinc-900/75 transition-opacity" /> <div class="fixed inset-0 bg-zinc-900/75 transition-opacity" />
<div class="fixed inset-0 z-10 w-screen overflow-y-auto"> <div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div <div
class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0" class="relative transform overflow-hidden rounded-lg bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm">
>
<div
class="relative transform overflow-hidden rounded-lg bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm"
>
<div> <div>
<component <component :is="actions[currentAction].page" v-model="actionsComplete[currentAction]"
:is="actions[currentAction].page" :token="bearerToken" />
v-model="actionsComplete[currentAction]"
:token="bearerToken"
/>
</div> </div>
<div class="mt-5 sm:mt-6 p-4"> <div class="mt-5 sm:mt-6 p-4">
<button <button type="button"
type="button"
class="inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-blue-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" class="inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-blue-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="currentAction = -1" @click="currentAction = -1">
>
{{ $t("common.close") }} {{ $t("common.close") }}
</button> </button>
</div> </div>
@ -164,7 +120,7 @@ if (!token)
}); });
const bearerToken = `Bearer ${token}`; const bearerToken = `Bearer ${token}`;
const allowed = await $dropFetch("/api/v1/admin", { const allowed = await $dropFetch("/api/v1/admin/setup", {
headers: { Authorization: bearerToken }, headers: { Authorization: bearerToken },
}); });
if (!allowed) if (!allowed)

View File

@ -1,10 +1,10 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
/** /**
* Check if we are an admin/system * Check if we are a setup token
*/ */
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, []); const allowed = await aclManager.allowSystemACL(h3, ["setup"]);
if (!allowed) return false; if (!allowed) return false;
return true; return true;
}); });

View File

@ -1,5 +1,8 @@
import authManager from "~/server/internal/auth"; import authManager from "~/server/internal/auth";
/**
* Fetch public authentication provider mechanisms
*/
export default defineEventHandler(() => { export default defineEventHandler(() => {
return authManager.getEnabledAuthProviders(); return authManager.getEnabledAuthProviders();
}); });

View File

@ -15,6 +15,9 @@ const signinValidator = type({
"rememberMe?": "boolean | undefined", "rememberMe?": "boolean | undefined",
}); });
/**
* Sign in as a session using the "Simple" authentication mechanism. Not recommended for third-party applications.
*/
export default defineEventHandler<{ export default defineEventHandler<{
body: typeof signinValidator.infer; body: typeof signinValidator.infer;
}>(async (h3) => { }>(async (h3) => {

View File

@ -1,8 +1,16 @@
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
import taskHandler from "~/server/internal/tasks"; import taskHandler from "~/server/internal/tasks";
import authManager from "~/server/internal/auth"; import authManager from "~/server/internal/auth";
import { ArkErrors, type } from "arktype";
export default defineEventHandler(async (h3) => { const Query = type({
id: "string",
});
/**
* Fetch invitation details for pre-filling
*/
export default defineEventHandler<{ query: typeof Query.infer }>(async (h3) => {
const t = await useTranslation(h3); const t = await useTranslation(h3);
if (!authManager.getAuthProviders().Simple) if (!authManager.getAuthProviders().Simple)
@ -11,13 +19,13 @@ export default defineEventHandler(async (h3) => {
statusMessage: t("errors.auth.method.signinDisabled"), statusMessage: t("errors.auth.method.signinDisabled"),
}); });
const query = getQuery(h3); const query = Query(getQuery(h3));
const id = query.id?.toString(); if (query instanceof ArkErrors)
if (!id)
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
statusMessage: t("errors.auth.inviteIdRequired"), statusMessage: "Invalid query: " + query.summary,
}); });
const id = query.id;
taskHandler.runTaskGroupByName("cleanup:invitations"); taskHandler.runTaskGroupByName("cleanup:invitations");
const invitation = await prisma.invitation.findUnique({ where: { id: id } }); const invitation = await prisma.invitation.findUnique({ where: { id: id } });

View File

@ -18,6 +18,9 @@ const CreateUserValidator = SharedRegisterValidator.and({
"displayName?": "string | undefined", "displayName?": "string | undefined",
}).configure(throwingArktype); }).configure(throwingArktype);
/**
* Create user from invitation
*/
export default defineEventHandler<{ export default defineEventHandler<{
body: typeof CreateUserValidator.infer; body: typeof CreateUserValidator.infer;
}>(async (h3) => { }>(async (h3) => {

View File

@ -1,7 +1,18 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import userLibraryManager from "~/server/internal/userlibrary"; import userLibraryManager from "~/server/internal/userlibrary";
export default defineEventHandler(async (h3) => { const RemoveEntry = type({
id: "string",
}).configure(throwingArktype);
/**
* Remove entry from collection
* @param id Collection ID
*/
export default defineEventHandler<{ body: typeof RemoveEntry.infer }>(
async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["collections:remove"]); const userId = await aclManager.getUserIdACL(h3, ["collections:remove"]);
if (!userId) if (!userId)
throw createError({ throw createError({
@ -15,10 +26,8 @@ export default defineEventHandler(async (h3) => {
statusMessage: "ID required in route params", statusMessage: "ID required in route params",
}); });
const body = await readBody(h3); const body = await readDropValidatedBody(h3, RemoveEntry);
const gameId = body.id; const gameId = body.id;
if (!gameId)
throw createError({ statusCode: 400, statusMessage: "Game ID required" });
const successful = await userLibraryManager.collectionRemove( const successful = await userLibraryManager.collectionRemove(
gameId, gameId,
@ -31,4 +40,5 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Collection not found", statusMessage: "Collection not found",
}); });
return {}; return {};
}); },
);

View File

@ -1,7 +1,17 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import userLibraryManager from "~/server/internal/userlibrary"; import userLibraryManager from "~/server/internal/userlibrary";
export default defineEventHandler(async (h3) => { const AddEntry = type({
id: "string",
}).configure(throwingArktype);
/**
* Add game to collection
* @param id Collection ID
*/
export default defineEventHandler<{ body: typeof AddEntry.infer }>(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["collections:add"]); const userId = await aclManager.getUserIdACL(h3, ["collections:add"]);
if (!userId) if (!userId)
throw createError({ throw createError({
@ -15,10 +25,8 @@ export default defineEventHandler(async (h3) => {
statusMessage: "ID required in route params", statusMessage: "ID required in route params",
}); });
const body = await readBody(h3); const body = await readDropValidatedBody(h3, AddEntry);
const gameId = body.id; const gameId = body.id;
if (!gameId)
throw createError({ statusCode: 400, statusMessage: "Game ID required" });
return await userLibraryManager.collectionAdd(gameId, id, userId); return await userLibraryManager.collectionAdd(gameId, id, userId);
}); });

View File

@ -1,6 +1,10 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import userLibraryManager from "~/server/internal/userlibrary"; import userLibraryManager from "~/server/internal/userlibrary";
/**
* Delete a collection
* @param id Collection ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["collections:delete"]); const userId = await aclManager.getUserIdACL(h3, ["collections:delete"]);
if (!userId) if (!userId)

View File

@ -1,6 +1,10 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import userLibraryManager from "~/server/internal/userlibrary"; import userLibraryManager from "~/server/internal/userlibrary";
/**
* Fetch collection by ID
* @param id Collection ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["collections:read"]); const userId = await aclManager.getUserIdACL(h3, ["collections:read"]);
if (!userId) if (!userId)

View File

@ -1,7 +1,17 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import userLibraryManager from "~/server/internal/userlibrary"; import userLibraryManager from "~/server/internal/userlibrary";
export default defineEventHandler(async (h3) => { const DeleteEntry = type({
id: "string",
}).configure(throwingArktype);
/**
* Remove game from user library
*/
export default defineEventHandler<{ body: typeof DeleteEntry.infer }>(
async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["library:remove"]); const userId = await aclManager.getUserIdACL(h3, ["library:remove"]);
if (!userId) if (!userId)
throw createError({ throw createError({
@ -9,12 +19,11 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Requires authentication", statusMessage: "Requires authentication",
}); });
const body = await readBody(h3); const body = await readDropValidatedBody(h3, DeleteEntry);
const gameId = body.id; const gameId = body.id;
if (!gameId)
throw createError({ statusCode: 400, statusMessage: "Game ID required" });
await userLibraryManager.libraryRemove(gameId, userId); await userLibraryManager.libraryRemove(gameId, userId);
return {}; return {};
}); },
);

View File

@ -1,7 +1,17 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import userLibraryManager from "~/server/internal/userlibrary"; import userLibraryManager from "~/server/internal/userlibrary";
export default defineEventHandler(async (h3) => { const AddGame = type({
id: "string",
}).configure(throwingArktype);
/**
* Add game to user library
*/
export default defineEventHandler<{ body: typeof AddGame.infer }>(
async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["library:add"]); const userId = await aclManager.getUserIdACL(h3, ["library:add"]);
if (!userId) if (!userId)
throw createError({ throw createError({
@ -9,12 +19,11 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Requires authentication", statusMessage: "Requires authentication",
}); });
const body = await readBody(h3); const body = await readDropValidatedBody(h3, AddGame);
const gameId = body.id; const gameId = body.id;
if (!gameId)
throw createError({ statusCode: 400, statusMessage: "Game ID required" });
// Add the game to the default collection // Add the game to the default collection
await userLibraryManager.libraryAdd(gameId, userId); await userLibraryManager.libraryAdd(gameId, userId);
return {}; return {};
}); },
);

View File

@ -1,6 +1,9 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import userLibraryManager from "~/server/internal/userlibrary"; import userLibraryManager from "~/server/internal/userlibrary";
/**
* Fetch user library
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["collections:read"]); const userId = await aclManager.getUserIdACL(h3, ["collections:read"]);
if (!userId) if (!userId)

View File

@ -1,6 +1,9 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import userLibraryManager from "~/server/internal/userlibrary"; import userLibraryManager from "~/server/internal/userlibrary";
/**
* Fetch all collections
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["collections:read"]); const userId = await aclManager.getUserIdACL(h3, ["collections:read"]);
if (!userId) if (!userId)

View File

@ -1,20 +1,28 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import userLibraryManager from "~/server/internal/userlibrary"; import userLibraryManager from "~/server/internal/userlibrary";
export default defineEventHandler(async (h3) => { const CreateCollection = type({
name: "string",
}).configure(throwingArktype);
export default defineEventHandler<{ body: typeof CreateCollection.infer }>(
async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["collections:read"]); const userId = await aclManager.getUserIdACL(h3, ["collections:read"]);
if (!userId) if (!userId)
throw createError({ throw createError({
statusCode: 403, statusCode: 403,
}); });
const body = await readBody(h3); const body = await readDropValidatedBody(h3, CreateCollection);
const name = body.name; const name = body.name;
if (!name)
throw createError({ statusCode: 400, statusMessage: "Requires name" });
// Create the collection using the manager // Create the collection using the manager
const newCollection = await userLibraryManager.collectionCreate(name, userId); const newCollection = await userLibraryManager.collectionCreate(
name,
userId,
);
return newCollection; return newCollection;
}); },
);

View File

@ -1,16 +1,15 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
/**
* Fetch company by ID
* @param id Company ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["store:read"]); const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });
const companyId = getRouterParam(h3, "id"); const companyId = getRouterParam(h3, "id")!;
if (!companyId)
throw createError({
statusCode: 400,
statusMessage: "Missing gameId in route params (somehow...?)",
});
const company = await prisma.company.findUnique({ const company = await prisma.company.findUnique({
where: { id: companyId }, where: { id: companyId },

View File

@ -1,16 +1,15 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
/**
* Fetch game by ID
* @param id Game ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["store:read"]); const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });
const gameId = getRouterParam(h3, "id"); const gameId = getRouterParam(h3, "id")!;
if (!gameId)
throw createError({
statusCode: 400,
statusMessage: "Missing gameId in route params (somehow...?)",
});
const game = await prisma.game.findUnique({ const game = await prisma.game.findUnique({
where: { id: gameId }, where: { id: gameId },

View File

@ -1,5 +1,8 @@
import { systemConfig } from "~/server/internal/config/sys-conf"; import { systemConfig } from "~/server/internal/config/sys-conf";
/**
* Fetch instance information
*/
export default defineEventHandler(async (_h3) => { export default defineEventHandler(async (_h3) => {
return { return {
appName: "Drop", appName: "Drop",

View File

@ -2,6 +2,10 @@ import { defineEventHandler, createError } from "h3";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import newsManager from "~/server/internal/news"; import newsManager from "~/server/internal/news";
/**
* Fetch news article by ID
* @param id Article ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["news:read"]); const userId = await aclManager.getUserIdACL(h3, ["news:read"]);
if (!userId) if (!userId)
@ -10,12 +14,7 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Requires authentication", statusMessage: "Requires authentication",
}); });
const id = h3.context.params?.id; const id = getRouterParam(h3, "id")!;
if (!id)
throw createError({
statusCode: 400,
message: "Missing news ID",
});
const news = await newsManager.fetchById(id); const news = await newsManager.fetchById(id);
if (!news) if (!news)

View File

@ -1,8 +1,21 @@
import { ArkErrors, type } from "arktype";
import { defineEventHandler, getQuery } from "h3"; import { defineEventHandler, getQuery } from "h3";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import newsManager from "~/server/internal/news"; import newsManager from "~/server/internal/news";
export default defineEventHandler(async (h3) => { const NewsFetch = type({
"order?": "'asc' | 'desc'",
"tags?": "string[]",
"limit?": "string.numeric.parse",
"skip?": "string.numeric.parse",
"search?": "string",
});
/**
* Fetch instance news articles
*/
export default defineEventHandler<{ query: typeof NewsFetch.infer }>(
async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["news:read"]); const userId = await aclManager.getUserIdACL(h3, ["news:read"]);
if (!userId) if (!userId)
throw createError({ throw createError({
@ -10,28 +23,22 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Requires authentication", statusMessage: "Requires authentication",
}); });
const query = getQuery(h3); const query = NewsFetch(getQuery(h3));
if (query instanceof ArkErrors)
throw createError({ statusCode: 400, statusMessage: query.summary });
const orderBy = query.order as "asc" | "desc"; const orderBy = query.order;
if (orderBy) { const tags = query.tags;
if (typeof orderBy !== "string" || !["asc", "desc"].includes(orderBy))
throw createError({ statusCode: 400, statusMessage: "Invalid order" });
}
const tags = query.tags as string[] | undefined;
if (tags) {
if (typeof tags !== "object" || !Array.isArray(tags))
throw createError({ statusCode: 400, statusMessage: "Invalid tags" });
}
const options = { const options = {
take: parseInt(query.limit as string), take: Math.min(query.limit ?? 10, 10),
skip: parseInt(query.skip as string), skip: query.skip ?? 0,
orderBy: orderBy, orderBy: orderBy,
...(tags && { tags: tags.map((e) => e.toString()) }), ...(tags && { tags: tags.map((e) => e.toString()) }),
search: query.search as string, search: query.search,
}; };
const news = await newsManager.fetch(options); const news = await newsManager.fetch(options);
return news; return news;
}); },
);

View File

@ -1,6 +1,10 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
/**
* Delete notification.
* @param id Notification ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["notifications:delete"]); const userId = await aclManager.getUserIdACL(h3, ["notifications:delete"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });

View File

@ -1,6 +1,10 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
/**
* Fetch notification by ID
* @param id Notification ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["notifications:read"]); const userId = await aclManager.getUserIdACL(h3, ["notifications:read"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });

View File

@ -1,6 +1,10 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
/**
* Mark notification as read
* @param id Notification ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["notifications:mark"]); const userId = await aclManager.getUserIdACL(h3, ["notifications:mark"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });

View File

@ -1,6 +1,9 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
/**
* Fetch all notifications for this token
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["notifications:read"]); const userId = await aclManager.getUserIdACL(h3, ["notifications:read"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });

View File

@ -1,6 +1,9 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
/**
* Mark all notifications as read
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["notifications:mark"]); const userId = await aclManager.getUserIdACL(h3, ["notifications:mark"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });

View File

@ -6,6 +6,11 @@ import { logger } from "~/server/internal/logging";
// Peer ID to user ID // Peer ID to user ID
const socketSessions = new Map<string, string>(); const socketSessions = new Map<string, string>();
/**
* Connect to a WebSocket to listen for notification pushes.
*
* Sends "unauthenticated" if authentication fails, otherwise JSON notifications.
*/
export default defineWebSocketHandler({ export default defineWebSocketHandler({
async open(peer) { async open(peer) {
const h3 = { headers: peer.request?.headers ?? new Headers() }; const h3 = { headers: peer.request?.headers ?? new Headers() };

View File

@ -2,6 +2,10 @@ import aclManager from "~/server/internal/acls";
import objectHandler from "~/server/internal/objects"; import objectHandler from "~/server/internal/objects";
import sanitize from "sanitize-filename"; import sanitize from "sanitize-filename";
/**
* Delete object
* @param id Object ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const unsafeId = getRouterParam(h3, "id"); const unsafeId = getRouterParam(h3, "id");
if (!unsafeId) if (!unsafeId)

View File

@ -2,6 +2,10 @@ import aclManager from "~/server/internal/acls";
import objectHandler from "~/server/internal/objects"; import objectHandler from "~/server/internal/objects";
import sanitize from "sanitize-filename"; import sanitize from "sanitize-filename";
/**
* Fetch object. Sets a lot of caching headers, recommended to use them.
* @param id Object ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const unsafeId = getRouterParam(h3, "id"); const unsafeId = getRouterParam(h3, "id");
if (!unsafeId) if (!unsafeId)

View File

@ -2,7 +2,10 @@ import aclManager from "~/server/internal/acls";
import objectHandler from "~/server/internal/objects"; import objectHandler from "~/server/internal/objects";
import sanitize from "sanitize-filename"; import sanitize from "sanitize-filename";
// this request method is purely used by the browser to check if etag values are still valid /**
* Check if object has changed (etag/browser caching)
* @param id Object ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const unsafeId = getRouterParam(h3, "id"); const unsafeId = getRouterParam(h3, "id");
if (!unsafeId) if (!unsafeId)

View File

@ -2,6 +2,10 @@ import aclManager from "~/server/internal/acls";
import objectHandler from "~/server/internal/objects"; import objectHandler from "~/server/internal/objects";
import sanitize from "sanitize-filename"; import sanitize from "sanitize-filename";
/**
* Upload and overwrite object. Takes raw binary data (`application/octet-stream`)
* @param id Object ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const unsafeId = getRouterParam(h3, "id"); const unsafeId = getRouterParam(h3, "id");
if (!unsafeId) if (!unsafeId)

View File

@ -1,8 +1,11 @@
// get a specific screenshot
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import screenshotManager from "~/server/internal/screenshots"; import screenshotManager from "~/server/internal/screenshots";
import sanitize from "sanitize-filename"; import sanitize from "sanitize-filename";
/**
* Delete screenshot by ID
* @param id Screenshot ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["screenshots:delete"]); const userId = await aclManager.getUserIdACL(h3, ["screenshots:delete"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });

View File

@ -3,6 +3,10 @@ import aclManager from "~/server/internal/acls";
import screenshotManager from "~/server/internal/screenshots"; import screenshotManager from "~/server/internal/screenshots";
import sanitize from "sanitize-filename"; import sanitize from "sanitize-filename";
/**
* Fetch screenshot by ID. Use `/api/v1/object/:id` to actually fetch screenshot image data.
* @param id Screenshot ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["screenshots:read"]); const userId = await aclManager.getUserIdACL(h3, ["screenshots:read"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });

View File

@ -1,7 +1,10 @@
// get all user screenshots by game
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import screenshotManager from "~/server/internal/screenshots"; import screenshotManager from "~/server/internal/screenshots";
/**
* Fetch all screenshots for game
* @param id Game ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["screenshots:read"]); const userId = await aclManager.getUserIdACL(h3, ["screenshots:read"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });

View File

@ -3,8 +3,10 @@ import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
import screenshotManager from "~/server/internal/screenshots"; import screenshotManager from "~/server/internal/screenshots";
// TODO: make defineClientEventHandler instead? /**
// only clients will be upload screenshots yea?? * Upload screenshot by game. Subject to change, will likely become a client route. Takes raw upload (`application/octet-stream`)
* @param id Game ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["screenshots:new"]); const userId = await aclManager.getUserIdACL(h3, ["screenshots:new"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });

View File

@ -2,6 +2,9 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import screenshotManager from "~/server/internal/screenshots"; import screenshotManager from "~/server/internal/screenshots";
/**
* Fetch all screenshots
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["screenshots:read"]); const userId = await aclManager.getUserIdACL(h3, ["screenshots:read"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });

View File

@ -1,6 +1,9 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import { applicationSettings } from "~/server/internal/config/application-configuration"; import { applicationSettings } from "~/server/internal/config/application-configuration";
/**
* Fetch system settings
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const allowed = await aclManager.getUserACL(h3, ["settings:read"]); const allowed = await aclManager.getUserACL(h3, ["settings:read"]);
if (!allowed) throw createError({ statusCode: 403 }); if (!allowed) throw createError({ statusCode: 403 });

View File

@ -2,6 +2,9 @@ import { APITokenMode } from "~/prisma/client/enums";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
/**
* Complete setup, and delete setup token.
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["setup"]); const allowed = await aclManager.allowSystemACL(h3, ["setup"]);
if (!allowed) if (!allowed)

View File

@ -1,6 +1,9 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
/**
* Fetch all featured games. Used for store carousel.
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserACL(h3, ["store:read"]); const userId = await aclManager.getUserACL(h3, ["store:read"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });

View File

@ -21,7 +21,11 @@ const StoreRead = type({
sort: "'default' | 'newest' | 'recent' = 'default'", sort: "'default' | 'newest' | 'recent' = 'default'",
}); });
export default defineEventHandler(async (h3) => { /**
* Store endpoint. Filter games with pagination. Used for all "store views".
*/
export default defineEventHandler<{ query: typeof StoreRead.infer }>(
async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["store:read"]); const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });
@ -119,4 +123,5 @@ export default defineEventHandler(async (h3) => {
]); ]);
return { results, count }; return { results, count };
}); },
);

View File

@ -1,6 +1,9 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
/**
* Fetch all game tags.
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["store:read"]); const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });

View File

@ -1,16 +1,15 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
/**
* Fetch tag by ID
* @param id Tag ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["store:read"]); const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });
const tagId = getRouterParam(h3, "id"); const tagId = getRouterParam(h3, "id")!;
if (!tagId)
throw createError({
statusCode: 400,
statusMessage: "Missing gameId in route params (somehow...?)",
});
const tag = await prisma.gameTag.findUnique({ const tag = await prisma.gameTag.findUnique({
where: { id: tagId }, where: { id: tagId },

View File

@ -5,6 +5,15 @@ import type { MinimumRequestObject } from "~/server/h3";
// ID to admin // ID to admin
const socketHeaders = new Map<string, MinimumRequestObject>(); const socketHeaders = new Map<string, MinimumRequestObject>();
/**
* WebSocket to listen to task updates.
*
* Sends "unauthenticated" if authentication failed.
*
* Use `connect/:taskId` to subscribe to a task.
*
* Sends JSON tasks for all tasks subscribed.
*/
export default defineWebSocketHandler({ export default defineWebSocketHandler({
async open(peer) { async open(peer) {
const request = peer.request; const request = peer.request;

View File

@ -1,16 +1,15 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import clientHandler from "~/server/internal/clients/handler"; import clientHandler from "~/server/internal/clients/handler";
/**
* Revoke client
* @param id Client ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["clients:revoke"]); const userId = await aclManager.getUserIdACL(h3, ["clients:revoke"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });
const clientId = getRouterParam(h3, "id"); const clientId = getRouterParam(h3, "id")!;
if (!clientId)
throw createError({
statusCode: 400,
statusMessage: "Client ID missing in route params",
});
await clientHandler.removeClient(clientId); await clientHandler.removeClient(clientId);
}); });

View File

@ -1,6 +1,9 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
/**
* Fetch all clients connected to this account
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["clients:read"]); const userId = await aclManager.getUserIdACL(h3, ["clients:read"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });

View File

@ -1,5 +1,8 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
/**
* Fetch user.
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const user = await aclManager.getUserACL(h3, ["read"]); const user = await aclManager.getUserACL(h3, ["read"]);
return user ?? null; // Need to specifically return null return user ?? null; // Need to specifically return null

View File

@ -2,16 +2,15 @@ import { APITokenMode } from "~/prisma/client/enums";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
/**
* Revoke token
* @param id Token ID
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });
const id = h3.context.params?.id; const id = getRouterParam(h3, "id")!;
if (!id)
throw createError({
statusCode: 400,
statusMessage: "No id in router params",
});
const deleted = await prisma.aPIToken.delete({ const deleted = await prisma.aPIToken.delete({
where: { id: id, userId: userId, mode: APITokenMode.User }, where: { id: id, userId: userId, mode: APITokenMode.User },

View File

@ -1,6 +1,9 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import { userACLDescriptions } from "~/server/internal/acls/descriptions"; import { userACLDescriptions } from "~/server/internal/acls/descriptions";
/**
* Fetch ACL descriptions.
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });

View File

@ -2,6 +2,9 @@ import { APITokenMode } from "~/prisma/client/enums";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
/**
* Fetch all API tokens for this account.
*/
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });

View File

@ -1,29 +1,26 @@
import { type } from "arktype";
import { APITokenMode } from "~/prisma/client/enums"; import { APITokenMode } from "~/prisma/client/enums";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager, { userACLs } from "~/server/internal/acls"; import aclManager, { userACLs } from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => { const CreateToken = type({
name: "string",
acls: "string[] > 0",
}).configure(throwingArktype);
/**
*
*/
export default defineEventHandler<{ body: typeof CreateToken.infer }>(
async (h3) => {
const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });
const body = await readBody(h3); const body = await readDropValidatedBody(h3, CreateToken);
const name: string = body.name; const name: string = body.name;
const acls: string[] = body.acls; const acls: string[] = body.acls;
if (!name || typeof name !== "string")
throw createError({
statusCode: 400,
statusMessage: "Token name required",
});
if (!acls || !Array.isArray(acls))
throw createError({ statusCode: 400, statusMessage: "ACLs required" });
if (acls.length == 0)
throw createError({
statusCode: 400,
statusMessage: "Token requires more than zero ACLs",
});
const invalidACLs = acls.filter( const invalidACLs = acls.filter(
(e) => userACLs.findIndex((v) => e == v) == -1, (e) => userACLs.findIndex((v) => e == v) == -1,
); );
@ -43,4 +40,5 @@ export default defineEventHandler(async (h3) => {
}); });
return token; return token;
}); },
);

View File

@ -11,7 +11,13 @@ const GetChunk = type({
}).array(), }).array(),
}).configure(throwingArktype); }).configure(throwingArktype);
export default defineEventHandler(async (h3) => { /**
* Part of v2 download API. Intended to be client-only.
*
* Returns raw stream of all files requested, in order.
*/
export default defineEventHandler<{ body: typeof GetChunk.infer }>(
async (h3) => {
const body = await readDropValidatedBody(h3, GetChunk); const body = await readDropValidatedBody(h3, GetChunk);
const context = await contextManager.fetchContext(body.context); const context = await contextManager.fetchContext(body.context);
@ -70,4 +76,5 @@ export default defineEventHandler(async (h3) => {
await h3.node.res.end(); await h3.node.res.end();
return; return;
}); },
);

View File

@ -8,7 +8,11 @@ const CreateContext = type({
version: "string", version: "string",
}).configure(throwingArktype); }).configure(throwingArktype);
export default defineClientEventHandler(async (h3) => { /**
* Part of v2 download API. Create a download context for use with `/api/v2/client/chunk`.
*/
export default defineClientEventHandler<{ body: typeof CreateContext.infer }>(
async (h3) => {
const body = await readDropValidatedBody(h3, CreateContext); const body = await readDropValidatedBody(h3, CreateContext);
const context = await contextManager.createContext(body.game, body.version); const context = await contextManager.createContext(body.game, body.version);
@ -19,4 +23,5 @@ export default defineClientEventHandler(async (h3) => {
}); });
return { context }; return { context };
}); },
);

View File

@ -184,9 +184,6 @@ class ACLManager {
if (!token) return false; if (!token) return false;
if (token.mode != APITokenMode.System) return false; if (token.mode != APITokenMode.System) return false;
// If empty, we just want to check we are an admin *at all*, not specific ACLs
if (acls.length == 0) return true;
for (const acl of acls) { for (const acl of acls) {
const tokenACLIndex = token.acls.findIndex((e) => e == acl); const tokenACLIndex = token.acls.findIndex((e) => e == acl);
if (tokenACLIndex != -1) return true; if (tokenACLIndex != -1) return true;

View File

@ -17,7 +17,9 @@ type ClientUtils = {
const NONCE_LENIENCE = 30_000; const NONCE_LENIENCE = 30_000;
export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) { export function defineClientEventHandler<R extends EventHandlerRequest = object, K = unknown>(
handler: (h3: H3Event<R>, utils: ClientUtils) => Promise<K> | K,
) {
return defineEventHandler(async (h3) => { return defineEventHandler(async (h3) => {
const header = getHeader(h3, "Authorization"); const header = getHeader(h3, "Authorization");
if (!header) throw createError({ statusCode: 403 }); if (!header) throw createError({ statusCode: 403 });

View File

@ -46,9 +46,9 @@ class NewsManager {
options: { options: {
take?: number; take?: number;
skip?: number; skip?: number;
orderBy?: "asc" | "desc"; orderBy?: "asc" | "desc" | undefined;
tags?: string[]; tags?: string[];
search?: string; search?: string | undefined;
} = {}, } = {},
) { ) {
return await prisma.article.findMany({ return await prisma.article.findMany({