Merge branch 'Huskydog9988-more-ui-work' into develop

This commit is contained in:
DecDuck
2025-04-14 10:54:09 +10:00
38 changed files with 1132 additions and 1827 deletions

View File

@@ -3,9 +3,7 @@ import prisma from "~/server/internal/db/database";
import objectHandler from "~/server/internal/objects";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, [
"game:image:delete",
]);
const allowed = await aclManager.allowSystemACL(h3, ["game:image:delete"]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readBody(h3);
@@ -37,8 +35,8 @@ export default defineEventHandler(async (h3) => {
throw createError({ statusCode: 400, statusMessage: "Image not found" });
game.mImageLibrary.splice(imageIndex, 1);
await objectHandler.delete(imageId);
await objectHandler.deleteAsSystem(imageId);
if (game.mBannerId === imageId) {
game.mBannerId = game.mImageLibrary[0];
}

View File

@@ -5,7 +5,6 @@ import * as jdenticon from "jdenticon";
import objectHandler from "~/server/internal/objects";
import { type } from "arktype";
import { randomUUID } from "node:crypto";
import { writeNonLiteralDefaultMessage } from "arktype/internal/parser/shift/operator/default.ts";
const userValidator = type({
username: "string >= 5",
@@ -64,7 +63,7 @@ export default defineEventHandler(async (h3) => {
profilePictureId,
async () => jdenticon.toPng(user.username, 256),
{},
[`internal:read`, `${userId}:write`]
[`internal:read`, `${userId}:read`]
);
const [linkMec] = await prisma.$transaction([
prisma.linkedAuthMec.create({

View File

@@ -11,6 +11,21 @@ export default defineEventHandler(async (h3) => {
if (!object)
throw createError({ statusCode: 404, statusMessage: "Object not found" });
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag
const etagRequestValue = h3.headers.get("If-None-Match");
const etagActualValue = await objectHandler.fetchHash(id);
if (
etagRequestValue &&
etagActualValue &&
etagActualValue === etagRequestValue
) {
// would compare if etag is valid, but objects should never change
setResponseStatus(h3, 304);
return null;
}
// TODO: fix undefined etagValue
setHeader(h3, "ETag", etagActualValue ?? "");
setHeader(h3, "Content-Type", object.mime);
setHeader(
h3,

View File

@@ -0,0 +1,25 @@
import aclManager from "~/server/internal/acls";
import objectHandler from "~/server/internal/objects";
// this request method is purely used by the browser to check if etag values are still valid
export default defineEventHandler(async (h3) => {
const id = getRouterParam(h3, "id");
if (!id) throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
const userId = await aclManager.getUserIdACL(h3, ["object:read"]);
const object = await objectHandler.fetchWithPermissions(id, userId);
if (!object)
throw createError({ statusCode: 404, statusMessage: "Object not found" });
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag
const etagRequestValue = h3.headers.get("If-None-Match");
const etagActualValue = await objectHandler.fetchHash(id);
if (etagRequestValue !== null && etagActualValue === etagRequestValue) {
// would compare if etag is valid, but objects should never change
setResponseStatus(h3, 304);
return null;
}
return null;
});

View File

@@ -129,7 +129,7 @@ class NewsManager {
where: { id },
});
if (article.image) {
return await objectHandler.delete(article.image);
return await objectHandler.deleteAsSystem(article.image);
}
return true;
}

View File

@@ -1,21 +1,23 @@
import {
Object,
ObjectBackend,
ObjectMetadata,
ObjectReference,
Source,
} from "./objectHandler";
import sanitize from "sanitize-filename";
import { LRUCache } from "lru-cache";
import fs from "fs";
import path from "path";
import { Readable, Stream } from "stream";
import { createHash } from "crypto";
import prisma from "../db/database";
export class FsObjectBackend extends ObjectBackend {
private baseObjectPath: string;
private baseMetadataPath: string;
private hashStore = new FsHashStore();
constructor() {
super();
const basePath = process.env.FS_BACKEND_PATH ?? "./.data/objects";
@@ -27,14 +29,18 @@ export class FsObjectBackend extends ObjectBackend {
}
async fetch(id: ObjectReference) {
const objectPath = path.join(this.baseObjectPath, sanitize(id));
console.log("ID: " + id);
const objectPath = path.join(this.baseObjectPath, id);
if (!fs.existsSync(objectPath)) return undefined;
return fs.createReadStream(objectPath);
}
async write(id: ObjectReference, source: Source): Promise<boolean> {
const objectPath = path.join(this.baseObjectPath, sanitize(id));
const objectPath = path.join(this.baseObjectPath, id);
if (!fs.existsSync(objectPath)) return false;
// remove item from cache
this.hashStore.delete(id);
if (source instanceof Readable) {
const outputStream = fs.createWriteStream(objectPath);
source.pipe(outputStream, { end: true });
@@ -50,9 +56,10 @@ export class FsObjectBackend extends ObjectBackend {
return false;
}
async startWriteStream(id: ObjectReference) {
const objectPath = path.join(this.baseObjectPath, sanitize(id));
const objectPath = path.join(this.baseObjectPath, id);
if (!fs.existsSync(objectPath)) return undefined;
// remove item from cache
this.hashStore.delete(id);
return fs.createWriteStream(objectPath);
}
async create(
@@ -60,11 +67,8 @@ export class FsObjectBackend extends ObjectBackend {
source: Source,
metadata: ObjectMetadata
): Promise<ObjectReference | undefined> {
const objectPath = path.join(this.baseObjectPath, sanitize(id));
const metadataPath = path.join(
this.baseMetadataPath,
`${sanitize(id)}.json`
);
const objectPath = path.join(this.baseObjectPath, id);
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
if (fs.existsSync(objectPath) || fs.existsSync(metadataPath))
return undefined;
@@ -80,11 +84,8 @@ export class FsObjectBackend extends ObjectBackend {
return id;
}
async createWithWriteStream(id: string, metadata: ObjectMetadata) {
const objectPath = path.join(this.baseObjectPath, sanitize(id));
const metadataPath = path.join(
this.baseMetadataPath,
`${sanitize(id)}.json`
);
const objectPath = path.join(this.baseObjectPath, id);
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
if (fs.existsSync(objectPath) || fs.existsSync(metadataPath))
return undefined;
@@ -94,21 +95,22 @@ export class FsObjectBackend extends ObjectBackend {
// Create file so write passes
fs.writeFileSync(objectPath, "");
return this.startWriteStream(id);
const stream = await this.startWriteStream(id);
if (!stream) throw new Error("Could not create write stream");
return stream;
}
async delete(id: ObjectReference): Promise<boolean> {
const objectPath = path.join(this.baseObjectPath, sanitize(id));
const objectPath = path.join(this.baseObjectPath, id);
if (!fs.existsSync(objectPath)) return true;
fs.rmSync(objectPath);
// remove item from cache
this.hashStore.delete(id);
return true;
}
async fetchMetadata(
id: ObjectReference
): Promise<ObjectMetadata | undefined> {
const metadataPath = path.join(
this.baseMetadataPath,
`${sanitize(id)}.json`
);
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
if (!fs.existsSync(metadataPath)) return undefined;
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
return metadata as ObjectMetadata;
@@ -117,12 +119,102 @@ export class FsObjectBackend extends ObjectBackend {
id: ObjectReference,
metadata: ObjectMetadata
): Promise<boolean> {
const metadataPath = path.join(
this.baseMetadataPath,
`${sanitize(id)}.json`
);
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
if (!fs.existsSync(metadataPath)) return false;
fs.writeFileSync(metadataPath, JSON.stringify(metadata));
return true;
}
async fetchHash(id: ObjectReference): Promise<string | undefined> {
const cacheResult = await this.hashStore.get(id);
if (cacheResult !== undefined) return cacheResult;
const obj = await this.fetch(id);
if (obj === undefined) return;
// local variable to point to object
const cache = this.hashStore;
// hash object
const hash = createHash("md5");
hash.setEncoding("hex");
// read obj into hash
obj.pipe(hash);
await new Promise<void>((r) => {
obj.on("end", function () {
hash.end();
cache.save(id, hash.read());
r();
});
});
return await this.hashStore.get(id);
}
}
class FsHashStore {
private cache = new LRUCache<string, string>({
max: 1000, // number of items
});
constructor() {}
/**
* Gets hash of object
* @param id
* @returns
*/
async get(id: ObjectReference) {
const cacheRes = this.cache.get(id);
if (cacheRes !== undefined) return cacheRes;
const objectHash = await prisma.objectHash.findUnique({
where: {
id,
},
select: {
hash: true,
},
});
if (objectHash === null) return undefined;
this.cache.set(id, objectHash.hash);
return objectHash.hash;
}
/**
* Saves hash of object
* @param id
*/
async save(id: ObjectReference, hash: string) {
await prisma.objectHash.upsert({
where: {
id,
},
create: {
id,
hash,
},
update: {
hash,
},
});
this.cache.set(id, hash);
}
/**
* Hash is no longer valid for whatever reason
* @param id
*/
async delete(id: ObjectReference) {
this.cache.delete(id);
try {
// need to catch in case the object doesn't exist
await prisma.objectHash.delete({
where: {
id,
},
});
} catch {}
}
}

View File

@@ -1,3 +1,5 @@
import { FsObjectBackend } from "./fsBackend";
export const objectHandler = new FsObjectBackend();
export default objectHandler
import { ObjectHandler } from "./objectHandler";
export const objectHandler = new ObjectHandler(new FsObjectBackend());
export default objectHandler;

View File

@@ -63,6 +63,15 @@ export abstract class ObjectBackend {
id: ObjectReference,
metadata: ObjectMetadata
): Promise<boolean>;
abstract fetchHash(id: ObjectReference): Promise<string | undefined>;
}
export class ObjectHandler {
private backend: ObjectBackend;
constructor(backend: ObjectBackend) {
this.backend = backend;
}
private async fetchMimeType(source: Source) {
if (source instanceof ReadableStream) {
@@ -92,7 +101,7 @@ export abstract class ObjectBackend {
if (!mime)
throw new Error("Unable to calculate MIME type - is the source empty?");
await this.create(id, source, {
await this.backend.create(id, source, {
permissions,
userMetadata: metadata,
mime,
@@ -104,33 +113,54 @@ export abstract class ObjectBackend {
metadata: { [key: string]: string },
permissions: Array<string>
) {
return this.createWithWriteStream(id, {
return this.backend.createWithWriteStream(id, {
permissions,
userMetadata: metadata,
mime: "application/octet-stream",
});
}
async fetchWithPermissions(id: ObjectReference, userId?: string) {
const metadata = await this.fetchMetadata(id);
if (!metadata) return;
// We only need one permission, so find instead of filter is faster
const myPermissions = metadata.permissions.find((e) => {
// We only need one permission, so find instead of filter is faster
private hasAnyPermissions(permissions: string[], userId?: string) {
return !!permissions.find((e) => {
if (userId !== undefined && e.startsWith(userId)) return true;
if (userId !== undefined && e.startsWith("internal")) return true;
if (e.startsWith("anonymous")) return true;
return false;
});
}
if (!myPermissions) {
// We do not have access to this object
return;
}
private fetchPermissions(permissions: string[], userId?: string) {
return (
permissions
.filter((e) => {
if (userId !== undefined && e.startsWith(userId)) return true;
if (userId !== undefined && e.startsWith("internal")) return true;
if (e.startsWith("anonymous")) return true;
return false;
})
// Strip IDs from permissions
.map((e) => e.split(":").at(1))
// Map to priority according to array
.map((e) => ObjectPermissionPriority.findIndex((c) => c === e))
);
}
/**
* Fetches object, but also checks if user has perms to access it
* @param id object id
* @param userId user to check, or act as anon user
* @returns
*/
async fetchWithPermissions(id: ObjectReference, userId?: string) {
const metadata = await this.backend.fetchMetadata(id);
if (!metadata) return;
if (!this.hasAnyPermissions(metadata.permissions, userId)) return;
// Because any permission can be read or up, we automatically know we can read this object
// So just straight return the object
const source = await this.fetch(id);
const source = await this.backend.fetch(id);
if (!source) return undefined;
const object: Object = {
data: source,
@@ -139,66 +169,78 @@ export abstract class ObjectBackend {
return object;
}
// If we need to fetch a remote resource, it doesn't make sense
// to immediately fetch the object, *then* check permissions.
// Instead the caller can pass a simple anonymous funciton, like
// () => $dropFetch('/my-image');
// And if we actually have permission to write, it fetches it then.
/**
* Fetch object hash. Permissions check should be done on read
* @param id object id
* @returns
*/
async fetchHash(id: ObjectReference) {
return await this.backend.fetchHash(id);
}
/**
*
* @param id object id
* @param sourceFetcher callback used to provide image
* @param userId user to check, or act as anon user
* @returns
* @description If we need to fetch a remote resource, it doesn't make sense
* to immediately fetch the object, *then* check permissions.
* Instead the caller can pass a simple anonymous funciton, like
* () => $dropFetch('/my-image');
* And if we actually have permission to write, it fetches it then.
*/
async writeWithPermissions(
id: ObjectReference,
sourceFetcher: () => Promise<Source>,
userId?: string
) {
const metadata = await this.fetchMetadata(id);
const metadata = await this.backend.fetchMetadata(id);
if (!metadata) return false;
const myPermissions = metadata.permissions
.filter((e) => {
if (userId !== undefined && e.startsWith(userId)) return true;
if (userId !== undefined && e.startsWith("internal")) return true;
if (e.startsWith("anonymous")) return true;
return false;
})
// Strip IDs from permissions
.map((e) => e.split(":").at(1))
// Map to priority according to array
.map((e) => ObjectPermissionPriority.findIndex((c) => c === e));
const permissions = this.fetchPermissions(metadata.permissions, userId);
const requiredPermissionIndex = 1;
const hasPermission =
myPermissions.find((e) => e >= requiredPermissionIndex) != undefined;
permissions.find((e) => e >= requiredPermissionIndex) != undefined;
if (!hasPermission) return false;
const source = await sourceFetcher();
const result = await this.write(id, source);
// TODO: prevent user from overwriting existing object
const result = await this.backend.write(id, source);
return result;
}
/**
*
* @param id object id
* @param userId user to check, or act as anon user
* @returns
*/
async deleteWithPermission(id: ObjectReference, userId?: string) {
const metadata = await this.fetchMetadata(id);
const metadata = await this.backend.fetchMetadata(id);
if (!metadata) return false;
const myPermissions = metadata.permissions
.filter((e) => {
if (userId !== undefined && e.startsWith(userId)) return true;
if (userId !== undefined && e.startsWith("internal")) return true;
if (e.startsWith("anonymous")) return true;
return false;
})
// Strip IDs from permissions
.map((e) => e.split(":").at(1))
// Map to priority according to array
.map((e) => ObjectPermissionPriority.findIndex((c) => c === e));
const permissions = this.fetchPermissions(metadata.permissions, userId);
const requiredPermissionIndex = 2;
const hasPermission =
myPermissions.find((e) => e >= requiredPermissionIndex) != undefined;
permissions.find((e) => e >= requiredPermissionIndex) != undefined;
if (!hasPermission) return false;
const result = await this.delete(id);
const result = await this.backend.delete(id);
return result;
}
/**
* Deletes object without checking permission
* @param id
* @returns
*/
async deleteAsSystem(id: ObjectReference) {
return await this.backend.delete(id);
}
}

View File

@@ -12,7 +12,7 @@ class SaveManager {
index: number,
objectId: string
) {
await objectHandler.delete(objectId);
await objectHandler.deleteWithPermission(objectId, userId);
}
async pushSave(
@@ -62,7 +62,7 @@ class SaveManager {
await Promise.all([hashPromise, uploadStream]);
if (!hash) {
await objectHandler.delete(newSaveObjectId);
await objectHandler.deleteAsSystem(newSaveObjectId);
throw createError({
statusCode: 500,
statusMessage: "Hash failed to generate",