mirror of
https://github.com/BillyOutlast/drop.git
synced 2026-02-04 08:41:17 +01:00
Merge branch 'Huskydog9988-more-ui-work' into develop
This commit is contained in:
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
25
server/api/v1/object/[id]/index.head.ts
Normal file
25
server/api/v1/object/[id]/index.head.ts
Normal 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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user