v2 download API and Admin UI fixes (#177)

* fix: small ui fixes

* feat: #171

* fix: improvements to library scanning on admin UI

* feat: v2 download API

* fix: add download context cleanup

* fix: lint
This commit is contained in:
DecDuck
2025-08-09 15:45:39 +10:00
committed by GitHub
parent f6f972c2d6
commit b84d1f20b5
17 changed files with 504 additions and 44 deletions

View File

@ -0,0 +1,47 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import * as jdenticon from "jdenticon";
import { ObjectTransactionalHandler } from "~/server/internal/objects/transactional";
import prisma from "~/server/internal/db/database";
import { MetadataSource } from "~/prisma/client/enums";
const CompanyCreate = type({
name: "string",
description: "string",
website: "string",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["company:create"]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, CompanyCreate);
const obj = new ObjectTransactionalHandler();
const [register, pull, _] = obj.new({}, ["internal:read"]);
const icon = jdenticon.toPng(body.name, 512);
const logoId = register(icon);
const banner = jdenticon.toPng(body.description, 1024);
const bannerId = register(banner);
const company = await prisma.company.create({
data: {
metadataSource: MetadataSource.Manual,
metadataId: crypto.randomUUID(),
metadataOriginalQuery: "",
mName: body.name,
mShortDescription: body.description,
mDescription: "",
mLogoObjectId: logoId,
mBannerObjectId: bannerId,
mWebsite: body.website,
},
});
await pull();
return company;
});

View File

@ -5,5 +5,6 @@ export default defineEventHandler((_h3) => {
appName: "Drop",
version: systemConfig.getDropVersion(),
gitRef: `#${systemConfig.getGitRef()}`,
external: systemConfig.getExternalUrl(),
};
});

View File

@ -0,0 +1,73 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import contextManager from "~/server/internal/downloads/coordinator";
import libraryManager from "~/server/internal/library";
const GetChunk = type({
context: "string",
files: type({
filename: "string",
chunkIndex: "number",
}).array(),
}).configure(throwingArktype);
export default defineEventHandler(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)
throw createError({
statusCode: 400,
statusMessage: `Unknown file: ${file.filename}`,
});
const start = manifestFile.lengths
.slice(0, file.chunkIndex)
.reduce((a, b) => a + b, 0);
const end = start + manifestFile.lengths[file.chunkIndex];
streamFiles.push({ filename: file.filename, start, end });
}
setHeader(
h3,
"Content-Lengths",
streamFiles.map((e) => e.end - e.start).join(","),
); // Non-standard header, but we're cool like that 😎
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

@ -0,0 +1,22 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
import contextManager from "~/server/internal/downloads/coordinator";
const CreateContext = type({
game: "string",
version: "string",
}).configure(throwingArktype);
export default defineClientEventHandler(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",
});
return { context };
});