diff --git a/app/__test__/media/mime-types.spec.ts b/app/__test__/media/mime-types.spec.ts new file mode 100644 index 0000000..f00016a --- /dev/null +++ b/app/__test__/media/mime-types.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import * as large from "../../src/media/storage/mime-types"; +import * as tiny from "../../src/media/storage/mime-types-tiny"; + +describe("media/mime-types", () => { + test("tiny resolves", () => { + const tests = [[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"]]; + + for (const [ext, mime] of tests) { + expect(tiny.guess(ext)).toBe(mime); + } + }); + + test("all tiny resolves to large", () => { + for (const [ext, mime] of Object.entries(tiny.M)) { + expect(large.guessMimeType("." + ext)).toBe(mime); + } + + for (const [type, exts] of Object.entries(tiny.Q)) { + for (const ext of exts) { + const ex = `${type}/${ext}`; + try { + expect(large.guessMimeType("." + ext)).toBe(ex); + } catch (e) { + console.log(`Failed for ${ext}`, { + type, + exts, + ext, + expected: ex, + actual: large.guessMimeType("." + ext) + }); + throw new Error(`Failed for ${ext}`); + } + } + } + }); +}); diff --git a/app/src/Api.ts b/app/src/Api.ts index d6c99fe..bf31449 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -128,6 +128,14 @@ export class Api { }; } + async getVerifiedAuthState(force?: boolean): Promise { + if (force === true || !this.verified) { + await this.verifyAuth(); + } + + return this.getAuthState(); + } + async verifyAuth() { try { const res = await this.auth.me(); diff --git a/app/src/adapter/astro/astro.adapter.ts b/app/src/adapter/astro/astro.adapter.ts index f86410a..c11e95e 100644 --- a/app/src/adapter/astro/astro.adapter.ts +++ b/app/src/adapter/astro/astro.adapter.ts @@ -18,11 +18,11 @@ export function getApi(Astro: TAstro, options: Options = { mode: "static" }) { } let app: App; -export function serve(config: CreateAppConfig) { +export function serve(config: CreateAppConfig & { beforeBuild?: (app: App) => Promise }) { return async (args: TAstro) => { if (!app) { app = App.create(config); - + await config.beforeBuild?.(app); await app.build(); } return app.fetch(args.request); diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 05079e2..6ffcc6b 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -1,9 +1,10 @@ /// import path from "node:path"; -import { App, type CreateAppConfig } from "bknd"; +import { App, type CreateAppConfig, registries } from "bknd"; import type { Serve, ServeOptions } from "bun"; import { serveStatic } from "hono/bun"; +import { registerLocalMediaAdapter } from "../index"; let app: App; export type ExtendedAppCreateConfig = Partial & { @@ -18,6 +19,7 @@ export async function createApp({ buildOptions, ...config }: ExtendedAppCreateConfig) { + registerLocalMediaAdapter(); const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); if (!app) { diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index f3d1cb5..43019d7 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -1,5 +1,6 @@ import type { IncomingMessage } from "node:http"; -import type { App, CreateAppConfig } from "bknd"; +import { type App, type CreateAppConfig, registries } from "bknd"; +import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter"; export type CloudflareBkndConfig = { mode?: "warm" | "fresh" | "cache" | "durable"; @@ -47,3 +48,7 @@ export function nodeRequestToRequest(req: IncomingMessage): Request { headers }); } + +export function registerLocalMediaAdapter() { + registries.media.register("local", StorageLocalAdapter); +} diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index a888210..fe9197a 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -18,7 +18,6 @@ type GetServerSidePropsContext = { export function createApi({ req }: GetServerSidePropsContext) { const request = nodeRequestToRequest(req); - //console.log("createApi:request.headers", request.headers); return new Api({ host: new URL(request.url).origin, headers: request.headers @@ -43,10 +42,11 @@ function getCleanRequest(req: Request) { } let app: App; -export function serve(config: CreateAppConfig) { +export function serve(config: CreateAppConfig & { beforeBuild?: (app: App) => Promise }) { return async (req: Request) => { if (!app) { app = App.create(config); + await config.beforeBuild?.(app); await app.build(); } const request = getCleanRequest(req); diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts index be24456..bc5b7e5 100644 --- a/app/src/adapter/node/index.ts +++ b/app/src/adapter/node/index.ts @@ -3,3 +3,4 @@ export { StorageLocalAdapter, type LocalAdapterConfig } from "../../media/storage/adapters/StorageLocalAdapter"; +export { registerLocalMediaAdapter } from "../index"; diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index 6cd6ab1..d5fb196 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -1,7 +1,8 @@ import path from "node:path"; import { serve as honoServe } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; -import { App, type CreateAppConfig } from "bknd"; +import { App, type CreateAppConfig, registries } from "bknd"; +import { registerLocalMediaAdapter } from "../index"; export type NodeAdapterOptions = CreateAppConfig & { relativeDistPath?: string; @@ -21,6 +22,8 @@ export function serve({ buildOptions = {}, ...config }: NodeAdapterOptions = {}) { + registerLocalMediaAdapter(); + const root = path.relative( process.cwd(), path.resolve(relativeDistPath ?? "./node_modules/bknd/dist", "static") diff --git a/app/src/adapter/remix/remix.adapter.ts b/app/src/adapter/remix/remix.adapter.ts index 80d6b93..6c0c1f7 100644 --- a/app/src/adapter/remix/remix.adapter.ts +++ b/app/src/adapter/remix/remix.adapter.ts @@ -1,10 +1,11 @@ import { App, type CreateAppConfig } from "bknd"; let app: App; -export function serve(config: CreateAppConfig) { +export function serve(config: CreateAppConfig & { beforeBuild?: (app: App) => Promise }) { return async (args: { request: Request }) => { if (!app) { app = App.create(config); + await config.beforeBuild?.(app); await app.build(); } return app.fetch(args.request); diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 9a1c708..9f6d901 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,4 +1,5 @@ import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth"; +import type { PasswordStrategy } from "auth/authenticate/strategies"; import { Exception } from "core"; import { type Static, secureRandomString, transformObject } from "core/utils"; import { type Entity, EntityIndex, type EntityManager } from "data"; @@ -284,6 +285,21 @@ export class AppAuth extends Module { } catch (e) {} } + async createUser(input: { email: string; password: string }) { + const strategy = "password"; + const pw = this.authenticator.strategy(strategy) as PasswordStrategy; + const strategy_value = await pw.hash(input.password); + const mutator = this.em.mutator(this.config.entity_name as "users"); + mutator.__unstable_toggleSystemEntityCreation(false); + const { data: created } = await mutator.insertOne({ + email: input.email, + strategy, + strategy_value + }); + mutator.__unstable_toggleSystemEntityCreation(true); + return created; + } + override toJSON(secrets?: boolean): AppAuthSchema { if (!this.config.enabled) { return this.configDefault; diff --git a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts b/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts index f483eeb..b6c2650 100644 --- a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts +++ b/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts @@ -1,7 +1,7 @@ import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; import { type Static, Type, parse } from "core/utils"; import type { FileBody, FileListObject, FileMeta, StorageAdapter } from "../../Storage"; -import { guessMimeType } from "../../mime-types"; +import { guess } from "../../mime-types-tiny"; export const localAdapterConfig = Type.Object( { @@ -83,7 +83,7 @@ export class StorageLocalAdapter implements StorageAdapter { async getObject(key: string, headers: Headers): Promise { try { const content = await readFile(`${this.config.path}/${key}`); - const mimeType = guessMimeType(key); + const mimeType = guess(key); return new Response(content, { status: 200, @@ -105,7 +105,7 @@ export class StorageLocalAdapter implements StorageAdapter { async getObjectMeta(key: string): Promise { const stats = await stat(`${this.config.path}/${key}`); return { - type: guessMimeType(key) || "application/octet-stream", + type: guess(key) || "application/octet-stream", size: stats.size }; } diff --git a/app/src/media/storage/mime-types-tiny.ts b/app/src/media/storage/mime-types-tiny.ts new file mode 100644 index 0000000..a231734 --- /dev/null +++ b/app/src/media/storage/mime-types-tiny.ts @@ -0,0 +1,77 @@ +export const Q = { + video: ["mp4", "webm"], + audio: ["ogg"], + image: ["jpeg", "png", "gif", "webp", "bmp", "tiff"], + text: ["html", "css", "mdx", "yaml", "vcard", "csv", "vtt"], + application: ["zip", "xml", "toml", "json", "json5"], + font: ["woff", "woff2", "ttf", "otf"] +} as const; + +// reduced +const c = { + vnd: "vnd.openxmlformats-officedocument", + z: "application/x-7z-compressed", + t: (w = "plain") => `text/${w}`, + a: (w = "octet-stream") => `application/${w}`, + i: (w) => `image/${w}`, + v: (w) => `video/${w}` +} as const; +export const M = new Map([ + ["7z", c.z], + ["7zip", c.z], + ["ai", c.a("pdf")], + ["apk", c.a("vnd.android.package-archive")], + ["doc", c.a("msword")], + ["docx", `${c.vnd}.wordprocessingml.document`], + ["eps", c.a("postscript")], + ["epub", c.a("epub+zip")], + ["ini", c.t()], + ["jar", c.a("java-archive")], + ["jsonld", c.a("ld+json")], + ["jpg", c.i("jpeg")], + ["log", c.t()], + ["m3u", c.t()], + ["m3u8", c.a("vnd.apple.mpegurl")], + ["manifest", c.t("cache-manifest")], + ["md", c.t("markdown")], + ["mkv", c.v("x-matroska")], + ["mp3", c.a("mpeg")], + ["mobi", c.a("x-mobipocket-ebook")], + ["ppt", c.a("powerpoint")], + ["pptx", `${c.vnd}.presentationml.presentation`], + ["qt", c.v("quicktime")], + ["svg", c.i("svg+xml")], + ["tif", c.i("tiff")], + ["tsv", c.t("tab-separated-values")], + ["tgz", c.a("x-tar")], + ["txt", c.t()], + ["text", c.t()], + ["vcd", c.a("x-cdlink")], + ["vcs", c.t("x-vcalendar")], + ["wav", c.a("x-wav")], + ["webmanifest", c.a("manifest+json")], + ["xls", c.a("vnd.ms-excel")], + ["xlsx", `${c.vnd}.spreadsheetml.sheet`], + ["yml", c.t("yaml")] +]); + +export function guess(f: string): string { + try { + const e = f.split(".").pop() as string; + if (!e) { + return c.a(); + } + + // try quick first + for (const [t, _e] of Object.entries(Q)) { + // @ts-ignore + if (_e.includes(e)) { + return `${t}/${e}`; + } + } + + return M.get(e!) as string; + } catch (e) { + return c.a(); + } +} diff --git a/app/src/ui/Admin.tsx b/app/src/ui/Admin.tsx index bdfe5bc..f6af592 100644 --- a/app/src/ui/Admin.tsx +++ b/app/src/ui/Admin.tsx @@ -63,7 +63,7 @@ const Skeleton = ({ theme = "light" }: { theme?: string }) => { className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10" >
- +