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

View File

@ -1,10 +1,10 @@
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) => {
const allowed = await aclManager.allowSystemACL(h3, []);
const allowed = await aclManager.allowSystemACL(h3, ["setup"]);
if (!allowed) return false;
return true;
});

View File

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

View File

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

View File

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

View File

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

View File

@ -1,34 +1,44 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import userLibraryManager from "~/server/internal/userlibrary";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["collections:remove"]);
if (!userId)
throw createError({
statusCode: 403,
});
const RemoveEntry = type({
id: "string",
}).configure(throwingArktype);
const id = getRouterParam(h3, "id");
if (!id)
throw createError({
statusCode: 400,
statusMessage: "ID required in route params",
});
/**
* 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"]);
if (!userId)
throw createError({
statusCode: 403,
});
const body = await readBody(h3);
const gameId = body.id;
if (!gameId)
throw createError({ statusCode: 400, statusMessage: "Game ID required" });
const id = getRouterParam(h3, "id");
if (!id)
throw createError({
statusCode: 400,
statusMessage: "ID required in route params",
});
const successful = await userLibraryManager.collectionRemove(
gameId,
id,
userId,
);
if (!successful)
throw createError({
statusCode: 404,
statusMessage: "Collection not found",
});
return {};
});
const body = await readDropValidatedBody(h3, RemoveEntry);
const gameId = body.id;
const successful = await userLibraryManager.collectionRemove(
gameId,
id,
userId,
);
if (!successful)
throw createError({
statusCode: 404,
statusMessage: "Collection not found",
});
return {};
},
);

View File

@ -1,7 +1,17 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
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"]);
if (!userId)
throw createError({
@ -15,10 +25,8 @@ export default defineEventHandler(async (h3) => {
statusMessage: "ID required in route params",
});
const body = await readBody(h3);
const body = await readDropValidatedBody(h3, AddEntry);
const gameId = body.id;
if (!gameId)
throw createError({ statusCode: 400, statusMessage: "Game ID required" });
return await userLibraryManager.collectionAdd(gameId, id, userId);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,37 +1,44 @@
import { ArkErrors, type } from "arktype";
import { defineEventHandler, getQuery } from "h3";
import aclManager from "~/server/internal/acls";
import newsManager from "~/server/internal/news";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["news:read"]);
if (!userId)
throw createError({
statusCode: 403,
statusMessage: "Requires authentication",
});
const query = getQuery(h3);
const orderBy = query.order as "asc" | "desc";
if (orderBy) {
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 = {
take: parseInt(query.limit as string),
skip: parseInt(query.skip as string),
orderBy: orderBy,
...(tags && { tags: tags.map((e) => e.toString()) }),
search: query.search as string,
};
const news = await newsManager.fetch(options);
return news;
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"]);
if (!userId)
throw createError({
statusCode: 403,
statusMessage: "Requires authentication",
});
const query = NewsFetch(getQuery(h3));
if (query instanceof ArkErrors)
throw createError({ statusCode: 400, statusMessage: query.summary });
const orderBy = query.order;
const tags = query.tags;
const options = {
take: Math.min(query.limit ?? 10, 10),
skip: query.skip ?? 0,
orderBy: orderBy,
...(tags && { tags: tags.map((e) => e.toString()) }),
search: query.search,
};
const news = await newsManager.fetch(options);
return news;
},
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,11 @@ import { logger } from "~/server/internal/logging";
// Peer ID to user ID
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({
async open(peer) {
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 sanitize from "sanitize-filename";
/**
* Delete object
* @param id Object ID
*/
export default defineEventHandler(async (h3) => {
const unsafeId = getRouterParam(h3, "id");
if (!unsafeId)

View File

@ -2,6 +2,10 @@ import aclManager from "~/server/internal/acls";
import objectHandler from "~/server/internal/objects";
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) => {
const unsafeId = getRouterParam(h3, "id");
if (!unsafeId)

View File

@ -2,7 +2,10 @@ import aclManager from "~/server/internal/acls";
import objectHandler from "~/server/internal/objects";
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) => {
const unsafeId = getRouterParam(h3, "id");
if (!unsafeId)

View File

@ -2,6 +2,10 @@ import aclManager from "~/server/internal/acls";
import objectHandler from "~/server/internal/objects";
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) => {
const unsafeId = getRouterParam(h3, "id");
if (!unsafeId)

View File

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

View File

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

View File

@ -1,6 +1,9 @@
import aclManager from "~/server/internal/acls";
import { applicationSettings } from "~/server/internal/config/application-configuration";
/**
* Fetch system settings
*/
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.getUserACL(h3, ["settings:read"]);
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 prisma from "~/server/internal/db/database";
/**
* Complete setup, and delete setup token.
*/
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["setup"]);
if (!allowed)

View File

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

View File

@ -21,102 +21,107 @@ const StoreRead = type({
sort: "'default' | 'newest' | 'recent' = 'default'",
});
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
if (!userId) throw createError({ statusCode: 403 });
/**
* 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"]);
if (!userId) throw createError({ statusCode: 403 });
const query = getQuery(h3);
const options = StoreRead(query);
if (options instanceof ArkErrors)
throw createError({ statusCode: 400, statusMessage: options.summary });
const query = getQuery(h3);
const options = StoreRead(query);
if (options instanceof ArkErrors)
throw createError({ statusCode: 400, statusMessage: options.summary });
/**
* Generic filters
*/
const tagFilter = options.tags
? {
tags: {
some: {
id: {
in: options.tags.split(","),
/**
* Generic filters
*/
const tagFilter = options.tags
? {
tags: {
some: {
id: {
in: options.tags.split(","),
},
},
},
},
}
: undefined;
const platformFilter = options.platform
? {
versions: {
some: {
platform: {
in: options.platform
.split(",")
.map(parsePlatform)
.filter((e) => e !== undefined),
}
: undefined;
const platformFilter = options.platform
? {
versions: {
some: {
platform: {
in: options.platform
.split(",")
.map(parsePlatform)
.filter((e) => e !== undefined),
},
},
},
},
}
: undefined;
}
: undefined;
/**
* Company filtering
*/
const companyActions = options.companyActions.split(",");
const developedFilter = companyActions.includes("developed")
? {
developers: {
some: {
id: options.company!,
/**
* Company filtering
*/
const companyActions = options.companyActions.split(",");
const developedFilter = companyActions.includes("developed")
? {
developers: {
some: {
id: options.company!,
},
},
},
}
: undefined;
const publishedFilter = companyActions.includes("published")
? {
publishers: {
some: {
id: options.company!,
}
: undefined;
const publishedFilter = companyActions.includes("published")
? {
publishers: {
some: {
id: options.company!,
},
},
},
}
: undefined;
const companyFilter = options.company
? ({
OR: [developedFilter, publishedFilter].filter((e) => e !== undefined),
} satisfies Prisma.GameWhereInput)
: undefined;
}
: undefined;
const companyFilter = options.company
? ({
OR: [developedFilter, publishedFilter].filter((e) => e !== undefined),
} satisfies Prisma.GameWhereInput)
: undefined;
/**
* Query
*/
/**
* Query
*/
const finalFilter: Prisma.GameWhereInput = {
...tagFilter,
...platformFilter,
...companyFilter,
};
const finalFilter: Prisma.GameWhereInput = {
...tagFilter,
...platformFilter,
...companyFilter,
};
const sort: Prisma.GameOrderByWithRelationInput = {};
switch (options.sort) {
case "default":
case "newest":
sort.mReleased = "desc";
break;
case "recent":
sort.created = "desc";
break;
}
const sort: Prisma.GameOrderByWithRelationInput = {};
switch (options.sort) {
case "default":
case "newest":
sort.mReleased = "desc";
break;
case "recent":
sort.created = "desc";
break;
}
const [results, count] = await prisma.$transaction([
prisma.game.findMany({
skip: options.skip,
take: Math.min(options.take, 50),
where: finalFilter,
orderBy: sort,
}),
prisma.game.count({ where: finalFilter }),
]);
const [results, count] = await prisma.$transaction([
prisma.game.findMany({
skip: options.skip,
take: Math.min(options.take, 50),
where: finalFilter,
orderBy: sort,
}),
prisma.game.count({ where: finalFilter }),
]);
return { results, count };
});
return { results, count };
},
);

View File

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

View File

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

View File

@ -5,6 +5,15 @@ import type { MinimumRequestObject } from "~/server/h3";
// ID to admin
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({
async open(peer) {
const request = peer.request;

View File

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

View File

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

View File

@ -1,5 +1,8 @@
import aclManager from "~/server/internal/acls";
/**
* Fetch user.
*/
export default defineEventHandler(async (h3) => {
const user = await aclManager.getUserACL(h3, ["read"]);
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 prisma from "~/server/internal/db/database";
/**
* Revoke token
* @param id Token ID
*/
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication
if (!userId) throw createError({ statusCode: 403 });
const id = h3.context.params?.id;
if (!id)
throw createError({
statusCode: 400,
statusMessage: "No id in router params",
});
const id = getRouterParam(h3, "id")!;
const deleted = await prisma.aPIToken.delete({
where: { id: id, userId: userId, mode: APITokenMode.User },

View File

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

View File

@ -1,46 +1,44 @@
import { type } from "arktype";
import { APITokenMode } from "~/prisma/client/enums";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager, { userACLs } from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication
if (!userId) throw createError({ statusCode: 403 });
const CreateToken = type({
name: "string",
acls: "string[] > 0",
}).configure(throwingArktype);
const body = await readBody(h3);
const name: string = body.name;
const acls: string[] = body.acls;
/**
*
*/
export default defineEventHandler<{ body: typeof CreateToken.infer }>(
async (h3) => {
const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication
if (!userId) throw createError({ statusCode: 403 });
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" });
const body = await readDropValidatedBody(h3, CreateToken);
const name: string = body.name;
const acls: string[] = body.acls;
if (acls.length == 0)
throw createError({
statusCode: 400,
statusMessage: "Token requires more than zero ACLs",
const invalidACLs = acls.filter(
(e) => userACLs.findIndex((v) => e == v) == -1,
);
if (invalidACLs.length > 0)
throw createError({
statusCode: 400,
statusMessage: `Invalid ACLs: ${invalidACLs.join(", ")}`,
});
const token = await prisma.aPIToken.create({
data: {
mode: APITokenMode.User,
name: name,
userId: userId,
acls: acls,
},
});
const invalidACLs = acls.filter(
(e) => userACLs.findIndex((v) => e == v) == -1,
);
if (invalidACLs.length > 0)
throw createError({
statusCode: 400,
statusMessage: `Invalid ACLs: ${invalidACLs.join(", ")}`,
});
const token = await prisma.aPIToken.create({
data: {
mode: APITokenMode.User,
name: name,
userId: userId,
acls: acls,
},
});
return token;
});
return token;
},
);

View File

@ -11,63 +11,70 @@ const GetChunk = type({
}).array(),
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const body = await readDropValidatedBody(h3, GetChunk);
/**
* 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 context = await contextManager.fetchContext(body.context);
if (!context)
throw createError({
statusCode: 400,
statusMessage: "Invalid download context.",
});
const streamFiles = [];
for (const file of body.files) {
const manifestFile = context.manifest[file.filename];
if (!manifestFile)
const context = await contextManager.fetchContext(body.context);
if (!context)
throw createError({
statusCode: 400,
statusMessage: `Unknown file: ${file.filename}`,
statusMessage: "Invalid download context.",
});
const start = manifestFile.lengths
.slice(0, file.chunkIndex)
.reduce((a, b) => a + b, 0);
const end = start + manifestFile.lengths[file.chunkIndex];
const streamFiles = [];
streamFiles.push({ filename: file.filename, start, end });
}
for (const file of body.files) {
const manifestFile = context.manifest[file.filename];
if (!manifestFile)
throw createError({
statusCode: 400,
statusMessage: `Unknown file: ${file.filename}`,
});
setHeader(
h3,
"Content-Lengths",
streamFiles.map((e) => e.end - e.start).join(","),
); // Non-standard header, but we're cool like that 😎
const start = manifestFile.lengths
.slice(0, file.chunkIndex)
.reduce((a, b) => a + b, 0);
const end = start + manifestFile.lengths[file.chunkIndex];
for (const file of streamFiles) {
const gameReadStream = await libraryManager.readFile(
context.libraryId,
context.libraryPath,
context.versionName,
file.filename,
{ start: file.start, end: file.end },
);
if (!gameReadStream)
throw createError({
statusCode: 500,
statusMessage: "Failed to create read stream",
});
await gameReadStream.pipeTo(
new WritableStream({
write(chunk) {
h3.node.res.write(chunk);
},
}),
);
}
streamFiles.push({ filename: file.filename, start, end });
}
await h3.node.res.end();
setHeader(
h3,
"Content-Lengths",
streamFiles.map((e) => e.end - e.start).join(","),
); // Non-standard header, but we're cool like that 😎
return;
});
for (const file of streamFiles) {
const gameReadStream = await libraryManager.readFile(
context.libraryId,
context.libraryPath,
context.versionName,
file.filename,
{ start: file.start, end: file.end },
);
if (!gameReadStream)
throw createError({
statusCode: 500,
statusMessage: "Failed to create read stream",
});
await gameReadStream.pipeTo(
new WritableStream({
write(chunk) {
h3.node.res.write(chunk);
},
}),
);
}
await h3.node.res.end();
return;
},
);

View File

@ -8,15 +8,20 @@ const CreateContext = type({
version: "string",
}).configure(throwingArktype);
export default defineClientEventHandler(async (h3) => {
const body = await readDropValidatedBody(h3, CreateContext);
/**
* 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 context = await contextManager.createContext(body.game, body.version);
if (!context)
throw createError({
statusCode: 400,
statusMessage: "Invalid game or version",
});
const context = await contextManager.createContext(body.game, body.version);
if (!context)
throw createError({
statusCode: 400,
statusMessage: "Invalid game or version",
});
return { context };
});
return { context };
},
);

View File

@ -184,9 +184,6 @@ class ACLManager {
if (!token) 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) {
const tokenACLIndex = token.acls.findIndex((e) => e == acl);
if (tokenACLIndex != -1) return true;

View File

@ -17,7 +17,9 @@ type ClientUtils = {
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) => {
const header = getHeader(h3, "Authorization");
if (!header) throw createError({ statusCode: 403 });

View File

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