files API

This commit is contained in:
May 2024-06-18 11:10:41 -07:00
parent 3e834bfda2
commit e01788de4f
7 changed files with 208 additions and 7 deletions

59
src/server/lib/apply.ts Normal file
View 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()
}
}

View file

@ -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)
} }

View file

@ -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
) )

View file

@ -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()),

View file

@ -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
} }

View file

@ -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
} }

View 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()
})