mirror of
https://github.com/mollersuite/monofile.git
synced 2024-11-25 23:16:27 -08:00
files API
This commit is contained in:
parent
3e834bfda2
commit
e01788de4f
59
src/server/lib/apply.ts
Normal file
59
src/server/lib/apply.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import type Files from "./files.js"
|
||||||
|
import type { FilePointer } from "./files.js"
|
||||||
|
import * as Accounts from "./accounts.js"
|
||||||
|
import { FileSchemas } from "./schemas/index.js"
|
||||||
|
|
||||||
|
export type Update = Pick<FilePointer, "visibility" | "filename" | "tag">
|
||||||
|
& {
|
||||||
|
owner: string | null,
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTagMask(tags: string[], mask: Record<string, boolean>) {
|
||||||
|
return Object.entries(Object.assign(
|
||||||
|
Object.fromEntries(tags.map(e => [e, true])),
|
||||||
|
mask
|
||||||
|
)).filter(e => e[1]).map(e => e[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
export const operations : Exclude<({
|
||||||
|
[K in keyof Update]: [K,
|
||||||
|
((files: Files, passed: Update[K], id: string, file: FilePointer) => void)
|
||||||
|
| true
|
||||||
|
]
|
||||||
|
})[keyof Update], undefined>[] = [
|
||||||
|
["filename", true],
|
||||||
|
["visibility", true],
|
||||||
|
["tag", true],
|
||||||
|
["owner", (files: Files, owner: string|null, id: string, file: FilePointer) => {
|
||||||
|
files.chown(id, owner || undefined, true)
|
||||||
|
return
|
||||||
|
}],
|
||||||
|
["id", (files: Files, newId: string, oldId: string, file: FilePointer) => {
|
||||||
|
files.mv(oldId, newId, true)
|
||||||
|
return
|
||||||
|
}]
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function apply(
|
||||||
|
files: Files,
|
||||||
|
uploadId: string,
|
||||||
|
source: Partial<Update>,
|
||||||
|
noWrite: boolean = false
|
||||||
|
) {
|
||||||
|
let file = files.db.data[uploadId]
|
||||||
|
let issues = operations.map(([k, v]) => {
|
||||||
|
if (source[k] === undefined) return
|
||||||
|
if (v == true)
|
||||||
|
//@ts-ignore SHUTUPSHUTUPSHUTUP
|
||||||
|
file[k] = source[k]
|
||||||
|
else
|
||||||
|
//@ts-ignore oh my god you shut up too
|
||||||
|
v(files, source[k], uploadId, file)
|
||||||
|
}).filter(e => Boolean(e))
|
||||||
|
|
||||||
|
if (!noWrite) {
|
||||||
|
Accounts.Db.save()
|
||||||
|
files.db.save()
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import { Client as API, convertSnowflakeToDate } from "./DiscordAPI/index.js"
|
||||||
import type { APIAttachment } from "discord-api-types/v10"
|
import type { APIAttachment } from "discord-api-types/v10"
|
||||||
import config, { Configuration } from "./config.js"
|
import config, { Configuration } from "./config.js"
|
||||||
import "dotenv/config"
|
import "dotenv/config"
|
||||||
|
import apply from "./apply.js"
|
||||||
|
|
||||||
import * as Accounts from "./accounts.js"
|
import * as Accounts from "./accounts.js"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
@ -695,6 +696,11 @@ export default class Files {
|
||||||
|
|
||||||
async mv(uploadId: string, newId: string, noWrite: boolean = false) {
|
async mv(uploadId: string, newId: string, noWrite: boolean = false) {
|
||||||
let target = this.db.data[uploadId]
|
let target = this.db.data[uploadId]
|
||||||
|
|
||||||
|
if (newId in this.db.data) {
|
||||||
|
throw new Error("overwriting another file with this move")
|
||||||
|
}
|
||||||
|
|
||||||
if (target.owner) {
|
if (target.owner) {
|
||||||
let owner = Accounts.getFromId(target.owner)
|
let owner = Accounts.getFromId(target.owner)
|
||||||
if (owner) {
|
if (owner) {
|
||||||
|
@ -710,4 +716,6 @@ export default class Files {
|
||||||
if (!noWrite)
|
if (!noWrite)
|
||||||
return this.db.save()
|
return this.db.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apply = apply.bind(null, this)
|
||||||
}
|
}
|
|
@ -184,6 +184,13 @@ export function scheme(scheme: z.ZodTypeAny, transformer: (ctx: Context) => Prom
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// this is bad but idgaf
|
||||||
|
export function runtimeEvaluatedScheme(sch: (c: Context) => z.ZodTypeAny, transformer?: Parameters<typeof scheme>[1]): RequestHandler {
|
||||||
|
return async function(ctx, next) {
|
||||||
|
return scheme(sch(ctx),transformer)(ctx, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Not really middleware but a utility
|
// Not really middleware but a utility
|
||||||
|
|
||||||
export const login = async (ctx: Context, account: Accounts.AccountResolvable) => {
|
export const login = async (ctx: Context, account: Accounts.AccountResolvable) => {
|
||||||
|
@ -212,7 +219,7 @@ export const verifyPoi = (user: string, poi?: string, wantsMfaPoi: boolean = fal
|
||||||
export const mirror = (apiRoot: Hono, ctx: Context, url: string, init: Partial<RequestInit>) => apiRoot.fetch(
|
export const mirror = (apiRoot: Hono, ctx: Context, url: string, init: Partial<RequestInit>) => apiRoot.fetch(
|
||||||
new Request(
|
new Request(
|
||||||
(new URL(url, ctx.req.raw.url)).href,
|
(new URL(url, ctx.req.raw.url)).href,
|
||||||
Object.assign(ctx.req.raw,init)
|
init.body ? {...ctx.req.raw, headers: ctx.req.raw.headers, ...init} : Object.assign(ctx.req.raw, init)
|
||||||
),
|
),
|
||||||
ctx.env
|
ctx.env
|
||||||
)
|
)
|
|
@ -6,14 +6,14 @@ export const FileId = z.string()
|
||||||
.max(config.maxUploadIdLength,"file ID too long")
|
.max(config.maxUploadIdLength,"file ID too long")
|
||||||
.min(1, "you... *need* a file ID")
|
.min(1, "you... *need* a file ID")
|
||||||
export const FileVisibility = z.enum(["public", "anonymous", "private"])
|
export const FileVisibility = z.enum(["public", "anonymous", "private"])
|
||||||
export const FileTag = z.string().toLowerCase().max(30, "tag length too long")
|
export const FileTag = z.string().toLowerCase().regex(/^[a-z\-]+$/, "invalid characters").max(30, "tag length too long")
|
||||||
export const FilePointer = z.object({
|
export const FilePointer = z.object({
|
||||||
filename: z.string().max(256, "filename too long"),
|
filename: z.string().max(512, "filename too long"),
|
||||||
mime: z.string().max(256, "mimetype too long"),
|
mime: z.string().max(256, "mimetype too long"),
|
||||||
messageids: z.array(z.string()),
|
messageids: z.array(z.string()),
|
||||||
owner: z.optional(z.string()),
|
owner: z.optional(z.string()),
|
||||||
sizeInBytes: z.optional(z.number()),
|
sizeInBytes: z.optional(z.number()),
|
||||||
tag: z.optional(FileTag),
|
tag: z.optional(FileTag.array().max(5)),
|
||||||
visibility: z.optional(FileVisibility).default("public"),
|
visibility: z.optional(FileVisibility).default("public"),
|
||||||
chunkSize: z.optional(z.number()),
|
chunkSize: z.optional(z.number()),
|
||||||
lastModified: z.optional(z.number()),
|
lastModified: z.optional(z.number()),
|
||||||
|
|
|
@ -4,7 +4,7 @@ import * as auth from "../../../../lib/auth.js"
|
||||||
import RangeParser, { type Range } from "range-parser"
|
import RangeParser, { type Range } from "range-parser"
|
||||||
import ServeError from "../../../../lib/errors.js"
|
import ServeError from "../../../../lib/errors.js"
|
||||||
import Files, { WebError } from "../../../../lib/files.js"
|
import Files, { WebError } from "../../../../lib/files.js"
|
||||||
import { getAccount, requiresAccount, requiresScopes, scheme } from "../../../../lib/middleware.js"
|
import { getAccount, requiresAccount, requiresScopes, runtimeEvaluatedScheme, scheme } from "../../../../lib/middleware.js"
|
||||||
import {Readable} from "node:stream"
|
import {Readable} from "node:stream"
|
||||||
import type {ReadableStream as StreamWebReadable} from "node:stream/web"
|
import type {ReadableStream as StreamWebReadable} from "node:stream/web"
|
||||||
import formidable from "formidable"
|
import formidable from "formidable"
|
||||||
|
@ -14,6 +14,8 @@ import { type StatusCode } from "hono/utils/http-status"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { FileSchemas } from "../../../../lib/schemas/index.js"
|
import { FileSchemas } from "../../../../lib/schemas/index.js"
|
||||||
import config from "../../../../lib/config.js"
|
import config from "../../../../lib/config.js"
|
||||||
|
import { BulkFileUpdate, BulkUnprivilegedFileUpdate } from "./schemes.js"
|
||||||
|
import { applyTagMask } from "../../../../lib/apply.js"
|
||||||
|
|
||||||
const router = new Hono<{
|
const router = new Hono<{
|
||||||
Variables: {
|
Variables: {
|
||||||
|
@ -158,5 +160,81 @@ export default function(files: Files) {
|
||||||
})}
|
})}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// THIS IS SHIT!!!
|
||||||
|
router.patch("/", requiresAccount, runtimeEvaluatedScheme(
|
||||||
|
(c) => c.get("account").admin ? BulkFileUpdate : BulkUnprivilegedFileUpdate
|
||||||
|
), (ctx) => {
|
||||||
|
let actor = ctx.get("account")
|
||||||
|
let update = ctx.get("parsedScheme") as z.infer<typeof BulkFileUpdate>
|
||||||
|
let to = Array.from(new Set(update.to).values())
|
||||||
|
let todo = update.do
|
||||||
|
|
||||||
|
for (let k of to) {
|
||||||
|
if (!(k in files.db.data))
|
||||||
|
return ServeError(ctx, 404, `file ${k} doesn't exist`)
|
||||||
|
if (!actor.admin && files.db.data[k].owner != actor.id)
|
||||||
|
return ServeError(ctx, 403, `you don't own file ${k}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let applied: Record<string, string[]> = {}
|
||||||
|
|
||||||
|
if (typeof todo !== "string" && "tag" in todo)
|
||||||
|
for (let e of to) {
|
||||||
|
applied[e] = applyTagMask(
|
||||||
|
files.db.data[e].tag || [],
|
||||||
|
todo.tag as Exclude<typeof todo.tag, undefined>
|
||||||
|
)
|
||||||
|
if (applied[e].length > 5)
|
||||||
|
return ServeError(ctx, 400, `too many tags for file ID ${e}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
to.forEach(
|
||||||
|
todo == "delete"
|
||||||
|
? e => files.unlink(e, true)
|
||||||
|
: e => files.apply(e, {
|
||||||
|
...todo,
|
||||||
|
...("tag" in todo ? {
|
||||||
|
tag: applied[e]
|
||||||
|
} : {})
|
||||||
|
} as Omit<typeof todo, "tag"> & { tag: string[] }, true)
|
||||||
|
)
|
||||||
|
|
||||||
|
files.db.save()
|
||||||
|
Accounts.Db.save()
|
||||||
|
|
||||||
|
return ctx.text("ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get("/", requiresAccount,
|
||||||
|
/*scheme(
|
||||||
|
z.object({
|
||||||
|
page: z.string().refine(e => !Number.isNaN(parseInt(e,10))),
|
||||||
|
amount: z.string().refine(e => !Number.isNaN(parseInt(e,10))),
|
||||||
|
changedOn: z.string().refine(e => !Number.isNaN(parseInt(e,10)))
|
||||||
|
}).partial(),
|
||||||
|
c=>c.req.query()
|
||||||
|
),*/ (ctx,next) => {
|
||||||
|
let queryStr = ctx.req.query()
|
||||||
|
let accId = queryStr.account
|
||||||
|
let actor = ctx.get("account")
|
||||||
|
|
||||||
|
let target = accId
|
||||||
|
? (
|
||||||
|
accId == "me"
|
||||||
|
? actor
|
||||||
|
: Accounts.resolve(accId)
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!actor.admin && target != actor)
|
||||||
|
return ServeError(ctx, 403, "can't control other users")
|
||||||
|
let d = Object.entries(files.db.data)
|
||||||
|
.map(([id, file]) => ({...file, messageids: undefined, id}))
|
||||||
|
.filter(e => (!target || e.owner == target.id))
|
||||||
|
|
||||||
|
return ctx.json(d)
|
||||||
|
})
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import * as auth from "../../../../lib/auth.js"
|
||||||
import RangeParser, { type Range } from "range-parser"
|
import RangeParser, { type Range } from "range-parser"
|
||||||
import ServeError from "../../../../lib/errors.js"
|
import ServeError from "../../../../lib/errors.js"
|
||||||
import Files, { WebError } from "../../../../lib/files.js"
|
import Files, { WebError } from "../../../../lib/files.js"
|
||||||
import { getAccount, requiresScopes } from "../../../../lib/middleware.js"
|
import { getAccount, mirror, requiresScopes } from "../../../../lib/middleware.js"
|
||||||
import {Readable} from "node:stream"
|
import {Readable} from "node:stream"
|
||||||
import type {ReadableStream as StreamWebReadable} from "node:stream/web"
|
import type {ReadableStream as StreamWebReadable} from "node:stream/web"
|
||||||
import formidable from "formidable"
|
import formidable from "formidable"
|
||||||
|
@ -17,7 +17,7 @@ const router = new Hono<{
|
||||||
},
|
},
|
||||||
Bindings: HttpBindings
|
Bindings: HttpBindings
|
||||||
}>()
|
}>()
|
||||||
router.all("*", getAccount)
|
router.use(getAccount)
|
||||||
|
|
||||||
export default function(files: Files, apiRoot: Hono) {
|
export default function(files: Files, apiRoot: Hono) {
|
||||||
|
|
||||||
|
@ -134,5 +134,25 @@ export default function(files: Files, apiRoot: Hono) {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.delete("/:id", async (ctx) =>
|
||||||
|
mirror(apiRoot, ctx, "/api/v1/file", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
do: "delete",
|
||||||
|
to: [ctx.req.param("id")]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
router.patch("/:id", async (ctx) =>
|
||||||
|
mirror(apiRoot, ctx, "/api/v1/file", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
do: await ctx.req.json(),
|
||||||
|
to: [ctx.req.param("id")]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
29
src/server/routes/api/v1/file/schemes.ts
Normal file
29
src/server/routes/api/v1/file/schemes.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { FileSchemas } from "../../../../lib/schemas/index.js";
|
||||||
|
|
||||||
|
export const FilePatch = FileSchemas.FilePointer
|
||||||
|
.pick({ filename: true, visibility: true })
|
||||||
|
.extend({
|
||||||
|
id: z.string(),
|
||||||
|
owner: z.string().nullable(),
|
||||||
|
tag: z.record(FileSchemas.FileTag, z.boolean())
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
|
||||||
|
export const FileUpdate = z.union([
|
||||||
|
z.literal("delete"),
|
||||||
|
FilePatch
|
||||||
|
])
|
||||||
|
export const UnprivilegedFileUpdate = z.union([
|
||||||
|
z.literal("delete"),
|
||||||
|
FilePatch.omit({ id: true, owner: true })
|
||||||
|
])
|
||||||
|
|
||||||
|
export const BulkFileUpdate = z.object({
|
||||||
|
do: FileUpdate,
|
||||||
|
to: FileSchemas.FileId.array()
|
||||||
|
})
|
||||||
|
export const BulkUnprivilegedFileUpdate = z.object({
|
||||||
|
do: UnprivilegedFileUpdate,
|
||||||
|
to: FileSchemas.FileId.array()
|
||||||
|
})
|
Loading…
Reference in a new issue