feat(acls): added backend acls
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { APITokenMode, User } from "@prisma/client";
|
||||
import { H3Context, H3Event } from "h3";
|
||||
import prisma from "../db/database";
|
||||
import sessionHandler from "../session";
|
||||
import { MinimumRequestObject } from "~/server/h3";
|
||||
|
||||
const userACLs = [
|
||||
"read",
|
||||
|
||||
"store:read",
|
||||
|
||||
"object:read",
|
||||
"object:update",
|
||||
"object:delete",
|
||||
|
||||
"notifications:read",
|
||||
"notifications:mark",
|
||||
"notifications:listen",
|
||||
"notifications:delete",
|
||||
|
||||
"collections:new",
|
||||
"collections:read",
|
||||
"collections:delete",
|
||||
"collections:add",
|
||||
"collections:remove",
|
||||
"library:add",
|
||||
"library:remove",
|
||||
] as const;
|
||||
const userACLPrefix = "user:";
|
||||
|
||||
type UserACL = Array<(typeof userACLs)[number]>;
|
||||
|
||||
const systemACLs = [
|
||||
"auth:simple:invitation:read",
|
||||
"auth:simple:invitation:new",
|
||||
"auth:simple:invitation:delete",
|
||||
|
||||
"library:read",
|
||||
"game:read",
|
||||
"game:update",
|
||||
"game:delete",
|
||||
"game:version:update",
|
||||
"game:version:delete",
|
||||
"game:image:new",
|
||||
"game:image:delete",
|
||||
|
||||
"import:version:read",
|
||||
"import:version:new",
|
||||
|
||||
"import:game:read",
|
||||
"import:game:new",
|
||||
|
||||
"user:read",
|
||||
] as const;
|
||||
const systemACLPrefix = "system:";
|
||||
|
||||
type SystemACL = Array<(typeof systemACLs)[number]>;
|
||||
|
||||
class ACLManager {
|
||||
private getAuthorizationToken(request: MinimumRequestObject) {
|
||||
const [type, token] =
|
||||
request.headers.get("Authorization")?.split(" ") ?? [];
|
||||
if (!type || !token) return undefined;
|
||||
if (type != "Bearer") return undefined;
|
||||
return token;
|
||||
}
|
||||
|
||||
async getUserIdACL(request: MinimumRequestObject | undefined, acls: UserACL) {
|
||||
if (!request)
|
||||
throw new Error("Native web requests not available - weird deployment?");
|
||||
// Sessions automatically have all ACLs
|
||||
const userId = await sessionHandler.getUserId(request);
|
||||
if (userId) return userId;
|
||||
|
||||
const authorizationToken = this.getAuthorizationToken(request);
|
||||
if (!authorizationToken) return undefined;
|
||||
const token = await prisma.aPIToken.findUnique({
|
||||
where: { token: authorizationToken },
|
||||
});
|
||||
if (!token) return undefined;
|
||||
if (token.mode != APITokenMode.User || !token.userId) return undefined; // If it's a system token
|
||||
|
||||
for (const acl of acls) {
|
||||
const tokenACLIndex = token.acls.findIndex((e) => e == acl);
|
||||
if (tokenACLIndex != -1) return token.userId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async getUserACL(request: MinimumRequestObject | undefined, acls: UserACL) {
|
||||
if (!request)
|
||||
throw new Error("Native web requests not available - weird deployment?");
|
||||
const userId = await this.getUserIdACL(request, acls);
|
||||
if (!userId) return undefined;
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (user) return user;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async allowSystemACL(
|
||||
request: MinimumRequestObject | undefined,
|
||||
acls: SystemACL
|
||||
) {
|
||||
if (!request)
|
||||
throw new Error("Native web requests not available - weird deployment?");
|
||||
const userId = await sessionHandler.getUserId(request);
|
||||
if (userId) {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) return false;
|
||||
if (user.admin) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const authorizationToken = this.getAuthorizationToken(request);
|
||||
if (!authorizationToken) return false;
|
||||
const token = await prisma.aPIToken.findUnique({
|
||||
where: { token: authorizationToken },
|
||||
});
|
||||
if (!token) return false;
|
||||
if (token.mode != APITokenMode.System) return false;
|
||||
for (const acl of acls) {
|
||||
const tokenACLIndex = token.acls.findIndex((e) => e == acl);
|
||||
if (tokenACLIndex != -1) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async hasACL(request: MinimumRequestObject | undefined, acls: string[]) {
|
||||
for (const acl of acls) {
|
||||
if (acl.startsWith(userACLPrefix)) {
|
||||
const rawACL = acl.substring(userACLPrefix.length);
|
||||
const userId = await this.getUserIdACL(request, [rawACL as any]);
|
||||
if (!userId) return false;
|
||||
}
|
||||
|
||||
if (acl.startsWith(systemACLPrefix)) {
|
||||
const rawACL = acl.substring(systemACLPrefix.length);
|
||||
const allowed = await this.allowSystemACL(request, [rawACL as any]);
|
||||
if (!allowed) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const aclManager = new ACLManager();
|
||||
export default aclManager;
|
||||
@@ -1,11 +0,0 @@
|
||||
# Library Format
|
||||
|
||||
Drop uses a filesystem-based library format, as it targets homelabs and not enterprise-grade solutions. The format works as follows:
|
||||
|
||||
## /{game name}
|
||||
|
||||
The game name is only used for initial matching, and doesn't affect actual metadata. Metadata is linked to the game's database entry, which is linked to it's filesystem name (they, however, can be completely different).
|
||||
|
||||
## /{game name}/{version name}
|
||||
|
||||
The version name can be anything. Versions have to manually imported within the web UI. There, you can change the order of the updates and mark them as deltas. Delta updates apply files over the previous versions.
|
||||
@@ -1,309 +0,0 @@
|
||||
/**
|
||||
* The Library Manager keeps track of games in Drop's library and their various states.
|
||||
* It uses path relative to the library, so it can moved without issue
|
||||
*
|
||||
* It also provides the endpoints with information about unmatched games
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import prisma from "../db/database";
|
||||
import { GameVersion, Platform } from "@prisma/client";
|
||||
import { fuzzy } from "fast-fuzzy";
|
||||
import { recursivelyReaddir } from "../utils/recursivedirs";
|
||||
import taskHandler from "../tasks";
|
||||
import { parsePlatform } from "../utils/parseplatform";
|
||||
import droplet from "@drop/droplet";
|
||||
|
||||
class AppLibraryManager {
|
||||
private basePath: string;
|
||||
|
||||
constructor() {
|
||||
this.basePath = process.env.LIBRARY ?? "./.data/library";
|
||||
fs.mkdirSync(this.basePath, { recursive: true });
|
||||
}
|
||||
|
||||
fetchLibraryPath() {
|
||||
return this.basePath;
|
||||
}
|
||||
|
||||
async fetchAllUnimportedGames() {
|
||||
const dirs = fs.readdirSync(this.basePath).filter((e) => {
|
||||
const fullDir = path.join(this.basePath, e);
|
||||
return fs.lstatSync(fullDir).isDirectory();
|
||||
});
|
||||
|
||||
const validGames = await prisma.game.findMany({
|
||||
where: {
|
||||
libraryBasePath: { in: dirs },
|
||||
},
|
||||
select: {
|
||||
libraryBasePath: true,
|
||||
},
|
||||
});
|
||||
const validGameDirs = validGames.map((e) => e.libraryBasePath);
|
||||
|
||||
const unregisteredGames = dirs.filter((e) => !validGameDirs.includes(e));
|
||||
|
||||
return unregisteredGames;
|
||||
}
|
||||
|
||||
async fetchUnimportedGameVersions(
|
||||
libraryBasePath: string,
|
||||
versions: Array<GameVersion>
|
||||
) {
|
||||
const gameDir = path.join(this.basePath, libraryBasePath);
|
||||
const versionsDirs = fs.readdirSync(gameDir);
|
||||
const importedVersionDirs = versions.map((e) => e.versionName);
|
||||
const unimportedVersions = versionsDirs.filter(
|
||||
(e) => !importedVersionDirs.includes(e)
|
||||
);
|
||||
|
||||
return unimportedVersions;
|
||||
}
|
||||
|
||||
async fetchGamesWithStatus() {
|
||||
const games = await prisma.game.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
versions: true,
|
||||
mName: true,
|
||||
mShortDescription: true,
|
||||
metadataSource: true,
|
||||
mDevelopers: true,
|
||||
mPublishers: true,
|
||||
mIconId: true,
|
||||
libraryBasePath: true,
|
||||
},
|
||||
orderBy: {
|
||||
mName: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
return await Promise.all(
|
||||
games.map(async (e) => ({
|
||||
game: e,
|
||||
status: {
|
||||
noVersions: e.versions.length == 0,
|
||||
unimportedVersions: await this.fetchUnimportedGameVersions(
|
||||
e.libraryBasePath,
|
||||
e.versions
|
||||
),
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
async fetchUnimportedVersions(gameId: string) {
|
||||
const game = await prisma.game.findUnique({
|
||||
where: { id: gameId },
|
||||
select: {
|
||||
versions: {
|
||||
select: {
|
||||
versionName: true,
|
||||
},
|
||||
},
|
||||
libraryBasePath: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!game) return undefined;
|
||||
const targetDir = path.join(this.basePath, game.libraryBasePath);
|
||||
if (!fs.existsSync(targetDir))
|
||||
throw new Error(
|
||||
"Game in database, but no physical directory? Something is very very wrong..."
|
||||
);
|
||||
const versions = fs.readdirSync(targetDir);
|
||||
const validVersions = versions.filter((versionDir) => {
|
||||
const versionPath = path.join(targetDir, versionDir);
|
||||
const stat = fs.statSync(versionPath);
|
||||
return stat.isDirectory();
|
||||
});
|
||||
const currentVersions = game.versions.map((e) => e.versionName);
|
||||
|
||||
const unimportedVersions = validVersions.filter(
|
||||
(e) => !currentVersions.includes(e)
|
||||
);
|
||||
return unimportedVersions;
|
||||
}
|
||||
|
||||
async fetchUnimportedVersionInformation(gameId: string, versionName: string) {
|
||||
const game = await prisma.game.findUnique({
|
||||
where: { id: gameId },
|
||||
select: { libraryBasePath: true, mName: true },
|
||||
});
|
||||
if (!game) return undefined;
|
||||
const targetDir = path.join(
|
||||
this.basePath,
|
||||
game.libraryBasePath,
|
||||
versionName
|
||||
);
|
||||
if (!fs.existsSync(targetDir)) return undefined;
|
||||
|
||||
const fileExts: { [key: string]: string[] } = {
|
||||
Linux: [
|
||||
// Ext for Unity games
|
||||
".x86_64",
|
||||
// Shell scripts
|
||||
".sh",
|
||||
// No extension is common for Linux binaries
|
||||
"",
|
||||
],
|
||||
Windows: [
|
||||
// Pretty much the only one
|
||||
".exe",
|
||||
],
|
||||
};
|
||||
|
||||
const options: Array<{
|
||||
filename: string;
|
||||
platform: string;
|
||||
match: number;
|
||||
}> = [];
|
||||
|
||||
const files = recursivelyReaddir(targetDir, 2);
|
||||
for (const file of files) {
|
||||
const filename = path.basename(file);
|
||||
const dotLocation = file.lastIndexOf(".");
|
||||
const ext = dotLocation == -1 ? "" : file.slice(dotLocation);
|
||||
for (const [platform, checkExts] of Object.entries(fileExts)) {
|
||||
for (const checkExt of checkExts) {
|
||||
if (checkExt != ext) continue;
|
||||
const fuzzyValue = fuzzy(filename, game.mName);
|
||||
const relative = path.relative(targetDir, file);
|
||||
options.push({
|
||||
filename: relative,
|
||||
platform: platform,
|
||||
match: fuzzyValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortedOptions = options.sort((a, b) => b.match - a.match);
|
||||
|
||||
return sortedOptions;
|
||||
}
|
||||
|
||||
// Checks are done in least to most expensive order
|
||||
async checkUnimportedGamePath(targetPath: string) {
|
||||
const targetDir = path.join(this.basePath, targetPath);
|
||||
if (!fs.existsSync(targetDir)) return false;
|
||||
|
||||
const hasGame =
|
||||
(await prisma.game.count({ where: { libraryBasePath: targetPath } })) > 0;
|
||||
if (hasGame) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async importVersion(
|
||||
gameId: string,
|
||||
versionName: string,
|
||||
metadata: {
|
||||
platform: string;
|
||||
onlySetup: boolean;
|
||||
|
||||
setup: string;
|
||||
setupArgs: string;
|
||||
launch: string;
|
||||
launchArgs: string;
|
||||
delta: boolean;
|
||||
|
||||
umuId: string;
|
||||
}
|
||||
) {
|
||||
const taskId = `import:${gameId}:${versionName}`;
|
||||
|
||||
const platform = parsePlatform(metadata.platform);
|
||||
if (!platform) return undefined;
|
||||
|
||||
const game = await prisma.game.findUnique({
|
||||
where: { id: gameId },
|
||||
select: { mName: true, libraryBasePath: true },
|
||||
});
|
||||
if (!game) return undefined;
|
||||
|
||||
const baseDir = path.join(this.basePath, game.libraryBasePath, versionName);
|
||||
if (!fs.existsSync(baseDir)) return undefined;
|
||||
|
||||
taskHandler.create({
|
||||
id: taskId,
|
||||
name: `Importing version ${versionName} for ${game.mName}`,
|
||||
requireAdmin: true,
|
||||
async run({ progress, log }) {
|
||||
// First, create the manifest via droplet.
|
||||
// This takes up 90% of our progress, so we wrap it in a *0.9
|
||||
const manifest = await new Promise<string>((resolve, reject) => {
|
||||
droplet.generateManifest(
|
||||
baseDir,
|
||||
(err, value) => {
|
||||
if (err) return reject(err);
|
||||
progress(value * 0.9);
|
||||
},
|
||||
(err, line) => {
|
||||
if (err) return reject(err);
|
||||
log(line);
|
||||
},
|
||||
(err, manifest) => {
|
||||
if (err) return reject(err);
|
||||
resolve(manifest);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
log("Created manifest successfully!");
|
||||
|
||||
const currentIndex = await prisma.gameVersion.count({
|
||||
where: { gameId: gameId },
|
||||
});
|
||||
|
||||
// Then, create the database object
|
||||
if (metadata.onlySetup) {
|
||||
await prisma.gameVersion.create({
|
||||
data: {
|
||||
gameId: gameId,
|
||||
versionName: versionName,
|
||||
dropletManifest: manifest,
|
||||
versionIndex: currentIndex,
|
||||
delta: metadata.delta,
|
||||
umuIdOverride: metadata.umuId,
|
||||
platform: platform,
|
||||
|
||||
onlySetup: true,
|
||||
setupCommand: metadata.setup,
|
||||
setupArgs: metadata.setupArgs.split(" "),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.gameVersion.create({
|
||||
data: {
|
||||
gameId: gameId,
|
||||
versionName: versionName,
|
||||
dropletManifest: manifest,
|
||||
versionIndex: currentIndex,
|
||||
delta: metadata.delta,
|
||||
umuIdOverride: metadata.umuId,
|
||||
platform: platform,
|
||||
|
||||
onlySetup: false,
|
||||
setupCommand: metadata.setup,
|
||||
setupArgs: metadata.setupArgs.split(" "),
|
||||
launchCommand: metadata.launch,
|
||||
launchArgs: metadata.launchArgs.split(" "),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
log("Successfully created version!");
|
||||
|
||||
progress(100);
|
||||
},
|
||||
});
|
||||
|
||||
return taskId;
|
||||
}
|
||||
}
|
||||
|
||||
export const appLibraryManager = new AppLibraryManager();
|
||||
export default appLibraryManager;
|
||||
@@ -230,7 +230,7 @@ class LibraryManager {
|
||||
taskHandler.create({
|
||||
id: taskId,
|
||||
name: `Importing version ${versionName} for ${game.mName}`,
|
||||
requireAdmin: true,
|
||||
acls: ["system:import:version:read"],
|
||||
async run({ progress, log }) {
|
||||
// First, create the manifest via droplet.
|
||||
// This takes up 90% of our progress, so we wrap it in a *0.9
|
||||
|
||||
@@ -76,7 +76,7 @@ export abstract class ObjectBackend {
|
||||
}
|
||||
if (source instanceof Buffer) {
|
||||
const mime =
|
||||
getMimeTypeBuffer(source)?.mime ?? "application/octet-stream";
|
||||
getMimeTypeBuffer(new Uint8Array(source).buffer)?.mime ?? "application/octet-stream";
|
||||
return { source: source, mime };
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { SessionProvider } from "./types";
|
||||
import prisma from "../db/database";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import moment from "moment";
|
||||
import { parse as parseCookies } from "cookie-es";
|
||||
import { MinimumRequestObject } from "~/server/h3";
|
||||
|
||||
/*
|
||||
This implementation may need work.
|
||||
@@ -25,8 +27,12 @@ export class SessionHandler {
|
||||
this.sessionProvider = createMemorySessionProvider();
|
||||
}
|
||||
|
||||
private getSessionToken(h3: H3Event) {
|
||||
const cookie = getCookie(h3, dropTokenCookie);
|
||||
private getSessionToken(request: MinimumRequestObject | undefined) {
|
||||
if(!request) throw new Error("Native web request not available");
|
||||
const cookieHeader = request.headers.get("Cookie");
|
||||
if (!cookieHeader) return undefined;
|
||||
const cookies = parseCookies(cookieHeader);
|
||||
const cookie = cookies[dropTokenCookie];
|
||||
return cookie;
|
||||
}
|
||||
|
||||
@@ -47,8 +53,8 @@ export class SessionHandler {
|
||||
return dropTokenCookie;
|
||||
}
|
||||
|
||||
async getSession<T extends Session>(h3: H3Event) {
|
||||
const token = this.getSessionToken(h3);
|
||||
async getSession<T extends Session>(request: MinimumRequestObject) {
|
||||
const token = this.getSessionToken(request);
|
||||
if (!token) return undefined;
|
||||
const data = await this.sessionProvider.getSession<{ [userSessionKey]: T }>(
|
||||
token
|
||||
@@ -68,14 +74,14 @@ export class SessionHandler {
|
||||
|
||||
return result;
|
||||
}
|
||||
async clearSession(h3: H3Event) {
|
||||
const token = this.getSessionToken(h3);
|
||||
async clearSession(request: MinimumRequestObject) {
|
||||
const token = this.getSessionToken(request);
|
||||
if (!token) return false;
|
||||
await this.sessionProvider.clearSession(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
async getUserId(h3: H3Event) {
|
||||
async getUserId(h3: MinimumRequestObject) {
|
||||
const token = this.getSessionToken(h3);
|
||||
if (!token) return undefined;
|
||||
|
||||
@@ -91,17 +97,6 @@ export class SessionHandler {
|
||||
return session[userIdKey];
|
||||
}
|
||||
|
||||
async getUser(obj: H3Event | string) {
|
||||
const userId =
|
||||
typeof obj === "string"
|
||||
? await this.getUserIdRaw(obj)
|
||||
: await this.getUserId(obj);
|
||||
if (!userId) return undefined;
|
||||
|
||||
const user = await prisma.user.findFirst({ where: { id: userId } });
|
||||
return user;
|
||||
}
|
||||
|
||||
async setUserId(h3: H3Event, userId: string, extend = false) {
|
||||
const token =
|
||||
this.getSessionToken(h3) ?? (await this.createSession(h3, extend));
|
||||
@@ -112,13 +107,7 @@ export class SessionHandler {
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
async getAdminUser(h3: H3Event | string) {
|
||||
const user = await this.getUser(h3);
|
||||
if (!user) return undefined;
|
||||
if (!user.admin) return undefined;
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
export default new SessionHandler();
|
||||
export const sessionHandler = new SessionHandler();
|
||||
export default sessionHandler;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import droplet from "@drop/droplet";
|
||||
import { MinimumRequestObject } from "~/server/h3";
|
||||
import aclManager from "../acls";
|
||||
|
||||
/**
|
||||
* The TaskHandler setups up two-way connections to web clients and manages the state for them
|
||||
@@ -13,7 +15,7 @@ type TaskRegistryEntry = {
|
||||
error: { title: string; description: string } | undefined;
|
||||
clients: { [key: string]: boolean };
|
||||
name: string;
|
||||
requireAdmin: boolean;
|
||||
acls: string[];
|
||||
};
|
||||
|
||||
class TaskHandler {
|
||||
@@ -84,7 +86,7 @@ class TaskHandler {
|
||||
error: undefined,
|
||||
log: [],
|
||||
clients: {},
|
||||
requireAdmin: task.requireAdmin ?? false,
|
||||
acls: task.acls,
|
||||
};
|
||||
|
||||
updateAllClients(true);
|
||||
@@ -113,7 +115,12 @@ class TaskHandler {
|
||||
});
|
||||
}
|
||||
|
||||
connect(id: string, taskId: string, peer: PeerImpl, isAdmin = false) {
|
||||
async connect(
|
||||
id: string,
|
||||
taskId: string,
|
||||
peer: PeerImpl,
|
||||
request: MinimumRequestObject
|
||||
) {
|
||||
const task = this.taskRegistry[taskId];
|
||||
if (!task) {
|
||||
peer.send(
|
||||
@@ -122,8 +129,9 @@ class TaskHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.requireAdmin && !isAdmin) {
|
||||
console.warn("user is not an admin, so cannot view this task");
|
||||
const allowed = await aclManager.hasACL(request, task.acls);
|
||||
if (!allowed) {
|
||||
console.warn("user does not have necessary ACLs");
|
||||
peer.send(
|
||||
`error/${taskId}/Unknown task/Drop couldn't find the task you're looking for.`
|
||||
);
|
||||
@@ -186,7 +194,7 @@ export interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
run: (context: TaskRunContext) => Promise<void>;
|
||||
requireAdmin?: boolean;
|
||||
acls: string[];
|
||||
}
|
||||
|
||||
export type TaskMessage = {
|
||||
|
||||
Reference in New Issue
Block a user