diff --git a/.gitignore b/.gitignore index 2232350..fe4c90f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ packages/media/.env **/*/vite.config.ts.timestamp* .history **/*/.db/* +**/*/.configs/* **/*/*.db **/*/*.db-shm **/*/*.db-wal diff --git a/README.md b/README.md index 0a53459..b60a344 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/app/src/ui/assets/poster.png) +![bknd](docs/_assets/poster.png) bknd simplifies app development by providing fully functional backend for data management, authentication, workflows and media. Since it's lightweight and built on Web Standards, it can diff --git a/app/__test__/Module.spec.ts b/app/__test__/Module.spec.ts deleted file mode 100644 index 4089ab1..0000000 --- a/app/__test__/Module.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { type TSchema, Type, stripMark } from "../src/core/utils"; -import { Module } from "../src/modules/Module"; - -function createModule(schema: Schema) { - class TestModule extends Module { - getSchema() { - return schema; - } - toJSON() { - return this.config; - } - useForceParse() { - return true; - } - } - - return TestModule; -} - -describe("Module", async () => { - test("basic", async () => {}); - - test("listener", async () => { - let result: any; - - const module = createModule(Type.Object({ a: Type.String() })); - const m = new module({ a: "test" }); - - await m.schema().set({ a: "test2" }); - m.setListener(async (c) => { - await new Promise((r) => setTimeout(r, 10)); - result = stripMark(c); - }); - await m.schema().set({ a: "test3" }); - expect(result).toEqual({ a: "test3" }); - }); -}); diff --git a/app/__test__/auth/middleware.spec.ts b/app/__test__/auth/middleware.spec.ts new file mode 100644 index 0000000..5fef79c --- /dev/null +++ b/app/__test__/auth/middleware.spec.ts @@ -0,0 +1 @@ +import { describe, expect, it } from "bun:test"; diff --git a/app/__test__/core/crypto.spec.ts b/app/__test__/core/crypto.spec.ts index 38178af..238b9e4 100644 --- a/app/__test__/core/crypto.spec.ts +++ b/app/__test__/core/crypto.spec.ts @@ -1,14 +1,16 @@ -import { describe, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { checksum, hash } from "../../src/core/utils"; describe("crypto", async () => { test("sha256", async () => { - console.log(await hash.sha256("test")); + expect(await hash.sha256("test")).toBe( + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + ); }); test("sha1", async () => { - console.log(await hash.sha1("test")); + expect(await hash.sha1("test")).toBe("a94a8fe5ccb19ba61c4c0873d391e987982fbbd3"); }); test("checksum", async () => { - console.log(checksum("hello world")); + expect(await checksum("hello world")).toBe("2aae6c35c94fcfb415dbe95f408b9ce91ee846ed"); }); }); diff --git a/app/__test__/data/data-query-impl.spec.ts b/app/__test__/data/data-query-impl.spec.ts index d5217df..a2fcdff 100644 --- a/app/__test__/data/data-query-impl.spec.ts +++ b/app/__test__/data/data-query-impl.spec.ts @@ -1,16 +1,15 @@ import { describe, expect, test } from "bun:test"; -import type { QueryObject } from "ufo"; -import { WhereBuilder, type WhereQuery } from "../../src/data/entities/query/WhereBuilder"; +import { Value } from "../../src/core/utils"; +import { WhereBuilder, type WhereQuery, querySchema } from "../../src/data"; import { getDummyConnection } from "./helper"; -const t = "t"; describe("data-query-impl", () => { function qb() { const c = getDummyConnection(); const kysely = c.dummyConnection.kysely; - return kysely.selectFrom(t).selectAll(); + return kysely.selectFrom("t").selectAll(); } - function compile(q: QueryObject) { + function compile(q: WhereQuery) { const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile(); return { sql, parameters }; } @@ -90,3 +89,20 @@ describe("data-query-impl", () => { } }); }); + +describe("data-query-impl: Typebox", () => { + test("sort", async () => { + const decode = (input: any, expected: any) => { + const result = Value.Decode(querySchema, input); + expect(result.sort).toEqual(expected); + }; + const _dflt = { by: "id", dir: "asc" }; + + decode({ sort: "" }, _dflt); + decode({ sort: "name" }, { by: "name", dir: "asc" }); + decode({ sort: "-name" }, { by: "name", dir: "desc" }); + decode({ sort: "-posts.name" }, { by: "posts.name", dir: "desc" }); + decode({ sort: "-1name" }, _dflt); + decode({ sort: { by: "name", dir: "desc" } }, { by: "name", dir: "desc" }); + }); +}); diff --git a/app/__test__/data/data.test.ts b/app/__test__/data/data.test.ts index 5d9c32a..dc8257f 100644 --- a/app/__test__/data/data.test.ts +++ b/app/__test__/data/data.test.ts @@ -18,7 +18,7 @@ describe("some tests", async () => { const users = new Entity("users", [ new TextField("username", { required: true, default_value: "nobody" }), - new TextField("email", { max_length: 3 }) + new TextField("email", { maxLength: 3 }) ]); const posts = new Entity("posts", [ diff --git a/app/__test__/flows/workflow-basic.test.ts b/app/__test__/flows/workflow-basic.test.ts index f199762..6a0f1d6 100644 --- a/app/__test__/flows/workflow-basic.test.ts +++ b/app/__test__/flows/workflow-basic.test.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line import/no-unresolved import { describe, expect, test } from "bun:test"; import { isEqual } from "lodash-es"; -import { type Static, Type, _jsonp } from "../../src/core/utils"; +import { type Static, Type, _jsonp, withDisabledConsole } from "../../src/core/utils"; import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows"; /*beforeAll(disableConsoleLog); @@ -232,7 +232,9 @@ describe("Flow tests", async () => { ).toEqual(["second", "fourth"]); const execution = back.createExecution(); - expect(execution.start()).rejects.toThrow(); + withDisabledConsole(async () => { + expect(execution.start()).rejects.toThrow(); + }); }); test("Flow with back step: enough retries", async () => { diff --git a/app/__test__/helper.ts b/app/__test__/helper.ts index f93f4e6..e11da33 100644 --- a/app/__test__/helper.ts +++ b/app/__test__/helper.ts @@ -40,7 +40,7 @@ const _oldConsoles = { error: console.error }; -export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) { +export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) { severities.forEach((severity) => { console[severity] = () => null; }); diff --git a/app/__test__/integration/auth.integration.test.ts b/app/__test__/integration/auth.integration.test.ts new file mode 100644 index 0000000..c103848 --- /dev/null +++ b/app/__test__/integration/auth.integration.test.ts @@ -0,0 +1,213 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { App, createApp } from "../../src"; +import type { AuthResponse } from "../../src/auth"; +import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; +import { disableConsoleLog, enableConsoleLog } from "../helper"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +const roles = { + sloppy: { + guest: { + permissions: [ + "system.access.admin", + "system.schema.read", + "system.access.api", + "system.config.read", + "data.entity.read" + ], + is_default: true + }, + admin: { + is_default: true, + implicit_allow: true + } + }, + strict: { + guest: { + permissions: ["system.access.api", "system.config.read", "data.entity.read"], + is_default: true + }, + admin: { + is_default: true, + implicit_allow: true + } + } +}; +const configs = { + auth: { + enabled: true, + entity_name: "users", + jwt: { + secret: secureRandomString(20), + issuer: randomString(10) + }, + roles: roles.strict, + guard: { + enabled: true + } + }, + users: { + normal: { + email: "normal@bknd.io", + password: "12345678" + }, + admin: { + email: "admin@bknd.io", + password: "12345678", + role: "admin" + } + } +}; + +function createAuthApp() { + const app = createApp({ + initialConfig: { + auth: configs.auth + } + }); + + app.emgr.onEvent( + App.Events.AppFirstBoot, + async () => { + await app.createUser(configs.users.normal); + await app.createUser(configs.users.admin); + }, + "sync" + ); + + return app; +} + +function getCookie(r: Response, name: string) { + const cookies = r.headers.get("cookie") ?? r.headers.get("set-cookie"); + if (!cookies) return; + const cookie = cookies.split(";").find((c) => c.trim().startsWith(name)); + if (!cookie) return; + return cookie.split("=")[1]; +} + +const fns = (app: App, mode?: Mode) => { + function headers(token?: string, additional?: Record) { + if (mode === "cookie") { + return { + cookie: `auth=${token};`, + ...additional + }; + } + + return { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...additional + }; + } + function body(obj?: Record) { + if (mode === "cookie") { + const formData = new FormData(); + for (const key in obj) { + formData.append(key, obj[key]); + } + return formData; + } + + return JSON.stringify(obj); + } + + return { + login: async ( + user: any + ): Promise<{ res: Response; data: Mode extends "token" ? AuthResponse : string }> => { + const res = (await app.server.request("/api/auth/password/login", { + method: "POST", + headers: headers(), + body: body(user) + })) as Response; + + const data = mode === "cookie" ? getCookie(res, "auth") : await res.json(); + + return { res, data }; + }, + me: async (token?: string): Promise> => { + const res = (await app.server.request("/api/auth/me", { + method: "GET", + headers: headers(token) + })) as Response; + return await res.json(); + } + }; +}; + +describe("integration auth", () => { + it("should create users on boot", async () => { + const app = createAuthApp(); + await app.build(); + + const { data: users } = await app.em.repository("users").findMany(); + expect(users.length).toBe(2); + expect(users[0].email).toBe(configs.users.normal.email); + expect(users[1].email).toBe(configs.users.admin.email); + }); + + it("should log you in with API", async () => { + const app = createAuthApp(); + await app.build(); + const $fns = fns(app); + + // login api + const { data } = await $fns.login(configs.users.normal); + const me = await $fns.me(data.token); + + expect(data.user.email).toBe(me.user.email); + expect(me.user.email).toBe(configs.users.normal.email); + + // expect no user with no token + expect(await $fns.me()).toEqual({ user: null as any }); + + // expect no user with invalid token + expect(await $fns.me("invalid")).toEqual({ user: null as any }); + expect(await $fns.me()).toEqual({ user: null as any }); + }); + + it("should log you in with form and cookie", async () => { + const app = createAuthApp(); + await app.build(); + const $fns = fns(app, "cookie"); + + const { res, data: token } = await $fns.login(configs.users.normal); + expect(token).toBeDefined(); + expect(res.status).toBe(302); // because it redirects + + // test cookie jwt interchangability + { + // expect token to not work as-is for api endpoints + expect(await fns(app).me(token)).toEqual({ user: null as any }); + // hono adds an additional segment to cookies + const apified_token = token.split(".").slice(0, -1).join("."); + // now it should work + // @todo: maybe add a config to don't allow re-use? + expect((await fns(app).me(apified_token)).user.email).toBe(configs.users.normal.email); + } + + // test cookie with me endpoint + { + const me = await $fns.me(token); + expect(me.user.email).toBe(configs.users.normal.email); + + // check with invalid & empty + expect(await $fns.me("invalid")).toEqual({ user: null as any }); + expect(await $fns.me()).toEqual({ user: null as any }); + } + }); + + it("should check for permissions", async () => { + const app = createAuthApp(); + await app.build(); + + await withDisabledConsole(async () => { + const res = await app.server.request("/api/system/schema"); + expect(res.status).toBe(403); + }); + }); +}); diff --git a/app/__test__/modules/AppAuth.spec.ts b/app/__test__/modules/AppAuth.spec.ts index 14640f0..225c9d6 100644 --- a/app/__test__/modules/AppAuth.spec.ts +++ b/app/__test__/modules/AppAuth.spec.ts @@ -1,5 +1,7 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { createApp } from "../../src"; import { AuthController } from "../../src/auth/api/AuthController"; +import { em, entity, text } from "../../src/data"; import { AppAuth, type ModuleBuildContext } from "../../src/modules"; import { disableConsoleLog, enableConsoleLog } from "../helper"; import { makeCtx, moduleTestSuite } from "./module-test-suite"; @@ -76,4 +78,53 @@ describe("AppAuth", () => { expect(users[0].email).toBe("some@body.com"); } }); + + test("registers auth middleware for bknd routes only", async () => { + const app = createApp({ + initialConfig: { + auth: { + enabled: true, + jwt: { + secret: "123456" + } + } + } + }); + + await app.build(); + const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh"); + + // register custom route + app.server.get("/test", async (c) => c.text("test")); + + // call a system api and then the custom route + await app.server.request("/api/system/ping"); + await app.server.request("/test"); + + expect(spy.mock.calls.length).toBe(1); + }); + + test("should allow additional user fields", async () => { + const app = createApp({ + initialConfig: { + auth: { + entity_name: "users", + enabled: true + }, + data: em({ + users: entity("users", { + additional: text() + }) + }).toJSON() + } + }); + + await app.build(); + + const e = app.modules.em.entity("users"); + const fields = e.fields.map((f) => f.name); + expect(e.type).toBe("system"); + expect(fields).toContain("additional"); + expect(fields).toEqual(["id", "email", "strategy", "strategy_value", "role", "additional"]); + }); }); diff --git a/app/__test__/modules/AppMedia.spec.ts b/app/__test__/modules/AppMedia.spec.ts index 6f1b0f5..19fa73b 100644 --- a/app/__test__/modules/AppMedia.spec.ts +++ b/app/__test__/modules/AppMedia.spec.ts @@ -1,7 +1,55 @@ -import { describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; +import { createApp, registries } from "../../src"; +import { em, entity, text } from "../../src/data"; +import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter"; import { AppMedia } from "../../src/modules"; import { moduleTestSuite } from "./module-test-suite"; describe("AppMedia", () => { moduleTestSuite(AppMedia); + + test("should allow additional fields", async () => { + registries.media.register("local", StorageLocalAdapter); + + const app = createApp({ + initialConfig: { + media: { + entity_name: "media", + enabled: true, + adapter: { + type: "local", + config: { + path: "./" + } + } + }, + data: em({ + media: entity("media", { + additional: text() + }) + }).toJSON() + } + }); + + await app.build(); + + const e = app.modules.em.entity("media"); + const fields = e.fields.map((f) => f.name); + expect(e.type).toBe("system"); + expect(fields).toContain("additional"); + expect(fields).toEqual([ + "id", + "path", + "folder", + "mime_type", + "size", + "scope", + "etag", + "modified_at", + "reference", + "entity_id", + "metadata", + "additional" + ]); + }); }); diff --git a/app/__test__/modules/Module.spec.ts b/app/__test__/modules/Module.spec.ts new file mode 100644 index 0000000..572c5a1 --- /dev/null +++ b/app/__test__/modules/Module.spec.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from "bun:test"; +import { type TSchema, Type, stripMark } from "../../src/core/utils"; +import { EntityManager, em, entity, index, text } from "../../src/data"; +import { DummyConnection } from "../../src/data/connection/DummyConnection"; +import { Module } from "../../src/modules/Module"; + +function createModule(schema: Schema) { + class TestModule extends Module { + getSchema() { + return schema; + } + toJSON() { + return this.config; + } + useForceParse() { + return true; + } + } + + return TestModule; +} + +describe("Module", async () => { + describe("basic", () => { + test("listener", async () => { + let result: any; + + const module = createModule(Type.Object({ a: Type.String() })); + const m = new module({ a: "test" }); + + await m.schema().set({ a: "test2" }); + m.setListener(async (c) => { + await new Promise((r) => setTimeout(r, 10)); + result = stripMark(c); + }); + await m.schema().set({ a: "test3" }); + expect(result).toEqual({ a: "test3" }); + }); + }); + + describe("db schema", () => { + class M extends Module { + override getSchema() { + return Type.Object({}); + } + + prt = { + ensureEntity: this.ensureEntity.bind(this), + ensureIndex: this.ensureIndex.bind(this), + ensureSchema: this.ensureSchema.bind(this) + }; + + get em() { + return this.ctx.em; + } + } + + function make(_em: ReturnType) { + const em = new EntityManager( + Object.values(_em.entities), + new DummyConnection(), + _em.relations, + _em.indices + ); + return new M({} as any, { em, flags: Module.ctx_flags } as any); + } + function flat(_em: EntityManager) { + return { + entities: _em.entities.map((e) => ({ + name: e.name, + fields: e.fields.map((f) => f.name), + type: e.type + })), + indices: _em.indices.map((i) => ({ + name: i.name, + entity: i.entity.name, + fields: i.fields.map((f) => f.name), + unique: i.unique + })) + }; + } + + test("no change", () => { + const initial = em({}); + + const m = make(initial); + expect(m.ctx.flags.sync_required).toBe(false); + + expect(flat(make(initial).em)).toEqual({ + entities: [], + indices: [] + }); + }); + + test("init", () => { + const initial = em({ + users: entity("u", { + name: text() + }) + }); + + const m = make(initial); + expect(m.ctx.flags.sync_required).toBe(false); + + expect(flat(m.em)).toEqual({ + entities: [ + { + name: "u", + fields: ["id", "name"], + type: "regular" + } + ], + indices: [] + }); + }); + + test("ensure entity", () => { + const initial = em({ + users: entity("u", { + name: text() + }) + }); + + const m = make(initial); + expect(flat(m.em)).toEqual({ + entities: [ + { + name: "u", + fields: ["id", "name"], + type: "regular" + } + ], + indices: [] + }); + + // this should add a new entity + m.prt.ensureEntity( + entity("p", { + title: text() + }) + ); + + // this should only add the field "important" + m.prt.ensureEntity( + entity( + "u", + { + important: text() + }, + undefined, + "system" + ) + ); + + expect(m.ctx.flags.sync_required).toBe(true); + expect(flat(m.em)).toEqual({ + entities: [ + { + name: "u", + // ensured properties must come first + fields: ["id", "important", "name"], + // ensured type must be present + type: "system" + }, + { + name: "p", + fields: ["id", "title"], + type: "regular" + } + ], + indices: [] + }); + }); + + test("ensure index", () => { + const users = entity("u", { + name: text(), + title: text() + }); + const initial = em({ users }, ({ index }, { users }) => { + index(users).on(["title"]); + }); + + const m = make(initial); + m.prt.ensureIndex(index(users).on(["name"])); + + expect(m.ctx.flags.sync_required).toBe(true); + expect(flat(m.em)).toEqual({ + entities: [ + { + name: "u", + fields: ["id", "name", "title"], + type: "regular" + } + ], + indices: [ + { + name: "idx_u_title", + entity: "u", + fields: ["title"], + unique: false + }, + { + name: "idx_u_name", + entity: "u", + fields: ["name"], + unique: false + } + ] + }); + }); + }); +}); diff --git a/app/__test__/ModuleManager.spec.ts b/app/__test__/modules/ModuleManager.spec.ts similarity index 96% rename from app/__test__/ModuleManager.spec.ts rename to app/__test__/modules/ModuleManager.spec.ts index 2e928d6..e22afff 100644 --- a/app/__test__/ModuleManager.spec.ts +++ b/app/__test__/modules/ModuleManager.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { mark, stripMark } from "../src/core/utils"; -import { entity, text } from "../src/data"; -import { ModuleManager, getDefaultConfig } from "../src/modules/ModuleManager"; -import { CURRENT_VERSION, TABLE_NAME } from "../src/modules/migrations"; -import { getDummyConnection } from "./helper"; +import { stripMark } from "../../src/core/utils"; +import { entity, text } from "../../src/data"; +import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager"; +import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations"; +import { getDummyConnection } from "../helper"; describe("ModuleManager", async () => { test("s1: no config, no build", async () => { diff --git a/app/__test__/modules/module-test-suite.ts b/app/__test__/modules/module-test-suite.ts index 5c1cce7..cf4926a 100644 --- a/app/__test__/modules/module-test-suite.ts +++ b/app/__test__/modules/module-test-suite.ts @@ -5,7 +5,7 @@ import { Guard } from "../../src/auth"; import { EventManager } from "../../src/core/events"; import { Default, stripMark } from "../../src/core/utils"; import { EntityManager } from "../../src/data"; -import type { Module, ModuleBuildContext } from "../../src/modules/Module"; +import { Module, type ModuleBuildContext } from "../../src/modules/Module"; import { getDummyConnection } from "../helper"; export function makeCtx(overrides?: Partial): ModuleBuildContext { @@ -16,6 +16,7 @@ export function makeCtx(overrides?: Partial): ModuleBuildCon em: new EntityManager([], dummyConnection), emgr: new EventManager(), guard: new Guard(), + flags: Module.ctx_flags, ...overrides }; } diff --git a/app/build.ts b/app/build.ts index 6511124..db8dae3 100644 --- a/app/build.ts +++ b/app/build.ts @@ -1,8 +1,5 @@ import { $ } from "bun"; -import * as esbuild from "esbuild"; -import postcss from "esbuild-postcss"; import * as tsup from "tsup"; -import { guessMimeType } from "./src/media/storage/mime-types"; const args = process.argv.slice(2); const watch = args.includes("--watch"); @@ -12,8 +9,8 @@ const sourcemap = args.includes("--sourcemap"); const clean = args.includes("--clean"); if (clean) { - console.log("Cleaning dist"); - await $`rm -rf dist`; + console.log("Cleaning dist (w/o static)"); + await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`; } let types_running = false; @@ -22,9 +19,11 @@ function buildTypes() { types_running = true; Bun.spawn(["bun", "build:types"], { + stdout: "inherit", onExit: () => { console.log("Types built"); Bun.spawn(["bun", "tsc-alias"], { + stdout: "inherit", onExit: () => { console.log("Types aliased"); types_running = false; @@ -36,7 +35,7 @@ function buildTypes() { let watcher_timeout: any; function delayTypes() { - if (!watch) return; + if (!watch || !types) return; if (watcher_timeout) { clearTimeout(watcher_timeout); } @@ -47,67 +46,6 @@ if (types && !watch) { buildTypes(); } -/** - * Build static assets - * Using esbuild because tsup doesn't include "react" - */ -const result = await esbuild.build({ - minify, - sourcemap, - entryPoints: ["src/ui/main.tsx"], - entryNames: "[dir]/[name]-[hash]", - outdir: "dist/static", - platform: "browser", - bundle: true, - splitting: true, - metafile: true, - drop: ["console", "debugger"], - inject: ["src/ui/inject.js"], - target: "es2022", - format: "esm", - plugins: [postcss()], - loader: { - ".svg": "dataurl", - ".js": "jsx" - }, - define: { - __isDev: "0", - "process.env.NODE_ENV": '"production"' - }, - chunkNames: "chunks/[name]-[hash]", - logLevel: "error" -}); - -// Write manifest -{ - const manifest: Record = {}; - const toAsset = (output: string) => { - const name = output.split("/").pop()!; - return { - name, - path: output, - mime: guessMimeType(name) - }; - }; - - const info = Object.entries(result.metafile.outputs) - .filter(([, meta]) => { - return meta.entryPoint && meta.entryPoint === "src/ui/main.tsx"; - }) - .map(([output, meta]) => ({ output, meta })); - - for (const { output, meta } of info) { - manifest[meta.entryPoint as string] = toAsset(output); - if (meta.cssBundle) { - manifest["src/ui/main.css"] = toAsset(meta.cssBundle); - } - } - - const manifest_file = "dist/static/manifest.json"; - await Bun.write(manifest_file, JSON.stringify(manifest, null, 2)); - console.log(`Manifest written to ${manifest_file}`, manifest); -} - /** * Building backend and general API */ @@ -120,7 +58,7 @@ await tsup.build({ external: ["bun:test", "@libsql/client"], metafile: true, platform: "browser", - format: ["esm", "cjs"], + format: ["esm"], splitting: false, treeshake: true, loader: { @@ -138,12 +76,24 @@ await tsup.build({ minify, sourcemap, watch, - entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css"], + entry: [ + "src/ui/index.ts", + "src/ui/client/index.ts", + "src/ui/elements/index.ts", + "src/ui/main.css" + ], outDir: "dist/ui", - external: ["bun:test", "react", "react-dom", "use-sync-external-store"], + external: [ + "bun:test", + "react", + "react-dom", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "use-sync-external-store" + ], metafile: true, platform: "browser", - format: ["esm", "cjs"], + format: ["esm"], splitting: true, treeshake: true, loader: { @@ -166,7 +116,7 @@ function baseConfig(adapter: string): tsup.Options { minify, sourcemap, watch, - entry: [`src/adapter/${adapter}`], + entry: [`src/adapter/${adapter}/index.ts`], format: ["esm"], platform: "neutral", outDir: `dist/adapter/${adapter}`, @@ -188,37 +138,22 @@ function baseConfig(adapter: string): tsup.Options { }; } +await tsup.build(baseConfig("remix")); +await tsup.build(baseConfig("bun")); +await tsup.build(baseConfig("astro")); +await tsup.build(baseConfig("cloudflare")); + await tsup.build({ ...baseConfig("vite"), platform: "node" }); -await tsup.build({ - ...baseConfig("cloudflare") -}); - await tsup.build({ ...baseConfig("nextjs"), - format: ["esm", "cjs"], platform: "node" }); -await tsup.build({ - ...baseConfig("remix"), - format: ["esm", "cjs"] -}); - -await tsup.build({ - ...baseConfig("bun") -}); - await tsup.build({ ...baseConfig("node"), - platform: "node", - format: ["esm", "cjs"] -}); - -await tsup.build({ - ...baseConfig("astro"), - format: ["esm", "cjs"] + platform: "node" }); diff --git a/app/package.json b/app/package.json index 8efb6c9..8baeefd 100644 --- a/app/package.json +++ b/app/package.json @@ -3,22 +3,21 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.4.0", + "version": "0.5.0", "scripts": { - "build:all": "NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli", "dev": "vite", "test": "ALL_TESTS=1 bun test --bail", "build": "NODE_ENV=production bun run build.ts --minify --types", + "build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli", + "build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify", + "build:static": "vite build", "watch": "bun run build.ts --types --watch", "types": "bun tsc --noEmit", "clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo", "build:types": "tsc --emitDeclarationOnly && tsc-alias", - "build:css": "bun tailwindcss -i src/ui/main.css -o ./dist/static/styles.css", - "watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css", "updater": "bun x npm-check-updates -ui", - "build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify", "cli": "LOCAL=1 bun src/cli/index.ts", - "prepublishOnly": "bun run build:all" + "prepublishOnly": "bun run test && bun run build:all" }, "license": "FSL-1.1-MIT", "dependencies": { @@ -34,7 +33,8 @@ "liquidjs": "^10.15.0", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", - "swr": "^2.2.5" + "swr": "^2.2.5", + "json-schema-form-react": "^0.0.2" }, "devDependencies": { "@aws-sdk/client-s3": "^3.613.0", @@ -103,6 +103,11 @@ "import": "./dist/ui/index.js", "require": "./dist/ui/index.cjs" }, + "./elements": { + "types": "./dist/types/ui/elements/index.d.ts", + "import": "./dist/ui/elements/index.js", + "require": "./dist/ui/elements/index.cjs" + }, "./client": { "types": "./dist/types/ui/client/index.d.ts", "import": "./dist/ui/client/index.js", @@ -164,7 +169,7 @@ "require": "./dist/adapter/astro/index.cjs" }, "./dist/styles.css": "./dist/ui/main.css", - "./dist/manifest.json": "./dist/static/manifest.json" + "./dist/manifest.json": "./dist/static/.vite/manifest.json" }, "publishConfig": { "access": "public" diff --git a/app/src/App.ts b/app/src/App.ts index 9c856da..2df3e84 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -1,4 +1,8 @@ +import type { CreateUserPayload } from "auth/AppAuth"; +import { auth } from "auth/middlewares"; +import { config } from "core"; import { Event } from "core/events"; +import { patternMatch } from "core/utils"; import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; import { type InitialModuleConfigs, @@ -68,6 +72,12 @@ export class App { onFirstBoot: async () => { console.log("[APP] first boot"); this.trigger_first_boot = true; + }, + onServerInit: async (server) => { + server.use(async (c, next) => { + c.set("app", this); + await next(); + }); } }); this.modules.ctx().emgr.registerEvents(AppEvents); @@ -87,20 +97,20 @@ export class App { //console.log("syncing", syncResult); } + const { guard, server } = this.modules.ctx(); + // load system controller - this.modules.ctx().guard.registerPermissions(Object.values(SystemPermissions)); - this.modules.server.route("/api/system", new SystemController(this).getController()); + guard.registerPermissions(Object.values(SystemPermissions)); + server.route("/api/system", new SystemController(this).getController()); // load plugins if (this.plugins.length > 0) { await Promise.all(this.plugins.map((plugin) => plugin(this))); } - //console.log("emitting built", options); await this.emgr.emit(new AppBuiltEvent({ app: this })); - // not found on any not registered api route - this.modules.server.all("/api/*", async (c) => c.notFound()); + server.all("/api/*", async (c) => c.notFound()); if (options?.save) { await this.modules.save(); @@ -121,6 +131,10 @@ export class App { return this.modules.server; } + get em() { + return this.modules.ctx().em; + } + get fetch(): any { return this.server.fetch; } @@ -147,7 +161,7 @@ export class App { registerAdminController(config?: AdminControllerOptions) { // register admin this.adminController = new AdminController(this, config); - this.modules.server.route("/", this.adminController.getController()); + this.modules.server.route(config?.basepath ?? "/", this.adminController.getController()); return this; } @@ -158,6 +172,10 @@ export class App { static create(config: CreateAppConfig) { return createApp(config); } + + async createUser(p: CreateUserPayload) { + return this.module.auth.createUser(p); + } } export function createApp(config: CreateAppConfig = {}) { diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 7e1334c..390ac3a 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -11,13 +11,7 @@ let app: App; export type BunBkndConfig = RuntimeBkndConfig & Omit; -export async function createApp({ - distPath, - onBuilt, - buildConfig, - beforeBuild, - ...config -}: RuntimeBkndConfig = {}) { +export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) { 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 8b86f0e..19bcdef 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -1,5 +1,6 @@ import type { IncomingMessage } from "node:http"; import { App, type CreateAppConfig, registries } from "bknd"; +import { config as $config } from "core"; import type { MiddlewareHandler } from "hono"; import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter"; import type { AdminControllerOptions } from "modules/server/AdminController"; @@ -106,12 +107,10 @@ export async function createRuntimeApp( App.Events.AppBuiltEvent, async () => { if (serveStatic) { - if (Array.isArray(serveStatic)) { - const [path, handler] = serveStatic; - app.modules.server.get(path, handler); - } else { - app.modules.server.get("/*", serveStatic); - } + const [path, handler] = Array.isArray(serveStatic) + ? serveStatic + : [$config.server.assets_path + "*", serveStatic]; + app.modules.server.get(path, handler); } await config.onBuilt?.(app); diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index 835b886..4f98466 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -19,9 +19,6 @@ export function serve({ port = $config.server.default_port, hostname, listener, - onBuilt, - buildConfig = {}, - beforeBuild, ...config }: NodeBkndConfig = {}) { const root = path.relative( diff --git a/app/src/adapter/vite/dev-server-config.ts b/app/src/adapter/vite/dev-server-config.ts new file mode 100644 index 0000000..372e470 --- /dev/null +++ b/app/src/adapter/vite/dev-server-config.ts @@ -0,0 +1,14 @@ +export const devServerConfig = { + entry: "./server.ts", + exclude: [ + /.*\.tsx?($|\?)/, + /^(?!.*\/__admin).*\.(s?css|less)($|\?)/, + // exclude except /api + /^(?!.*\/api).*\.(ico|mp4|jpg|jpeg|svg|png|vtt|mp3|js)($|\?)/, + /^\/@.+$/, + /\/components.*?\.json.*/, // @todo: improve + /^\/(public|assets|static)\/.+/, + /^\/node_modules\/.*/ + ] as any, + injectClientScript: false +} as const; diff --git a/app/src/adapter/vite/vite.adapter.ts b/app/src/adapter/vite/vite.adapter.ts index e94ece6..c8cb43d 100644 --- a/app/src/adapter/vite/vite.adapter.ts +++ b/app/src/adapter/vite/vite.adapter.ts @@ -1,10 +1,13 @@ import { serveStatic } from "@hono/node-server/serve-static"; +import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server"; import { type RuntimeBkndConfig, createRuntimeApp } from "adapter"; import type { App } from "bknd"; +import { devServerConfig } from "./dev-server-config"; export type ViteBkndConfig = RuntimeBkndConfig & { + mode?: "cached" | "fresh"; setAdminHtml?: boolean; - forceDev?: boolean; + forceDev?: boolean | { mainPath: string }; html?: string; }; @@ -24,20 +27,27 @@ ${addBkndContext ? "" : ""} ); } -async function createApp(config: ViteBkndConfig, env?: any) { +async function createApp(config: ViteBkndConfig = {}, env?: any) { return await createRuntimeApp( { ...config, - adminOptions: config.setAdminHtml - ? { html: config.html, forceDev: config.forceDev } - : undefined, + registerLocalMedia: true, + adminOptions: + config.setAdminHtml === false + ? undefined + : { + html: config.html, + forceDev: config.forceDev ?? { + mainPath: "/src/main.tsx" + } + }, serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })] }, env ); } -export async function serveFresh(config: ViteBkndConfig) { +export function serveFresh(config: Omit = {}) { return { async fetch(request: Request, env: any, ctx: ExecutionContext) { const app = await createApp(config, env); @@ -47,7 +57,7 @@ export async function serveFresh(config: ViteBkndConfig) { } let app: App; -export async function serveCached(config: ViteBkndConfig) { +export function serveCached(config: Omit = {}) { return { async fetch(request: Request, env: any, ctx: ExecutionContext) { if (!app) { @@ -58,3 +68,14 @@ export async function serveCached(config: ViteBkndConfig) { } }; } + +export function serve({ mode, ...config }: ViteBkndConfig = {}) { + return mode === "fresh" ? serveFresh(config) : serveCached(config); +} + +export function devServer(options: DevServerOptions) { + return honoViteDevServer({ + ...devServerConfig, + ...options + }); +} diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 48b6ada..dfbe1f3 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,9 +1,11 @@ import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth"; import type { PasswordStrategy } from "auth/authenticate/strategies"; -import { Exception, type PrimaryFieldType } from "core"; +import { auth } from "auth/middlewares"; +import { type DB, Exception, type PrimaryFieldType } from "core"; import { type Static, secureRandomString, transformObject } from "core/utils"; import { type Entity, EntityIndex, type EntityManager } from "data"; -import { type FieldSchema, entity, enumm, make, text } from "data/prototype"; +import { type FieldSchema, em, entity, enumm, make, text } from "data/prototype"; +import type { Hono } from "hono"; import { pick } from "lodash-es"; import { Module } from "modules/Module"; import { AuthController } from "./api/AuthController"; @@ -17,6 +19,7 @@ declare module "core" { } type AuthSchema = Static; +export type CreateUserPayload = { email: string; password: string; [key: string]: any }; export class AppAuth extends Module { private _authenticator?: Authenticator; @@ -36,8 +39,12 @@ export class AppAuth extends Module { return to; } + get enabled() { + return this.config.enabled; + } + override async build() { - if (!this.config.enabled) { + if (!this.enabled) { this.setBuilt(); return; } @@ -84,14 +91,6 @@ export class AppAuth extends Module { return this._controller; } - getMiddleware() { - if (!this.config.enabled) { - return; - } - - return new AuthController(this).getMiddleware; - } - getSchema() { return authConfigSchema; } @@ -111,12 +110,12 @@ export class AppAuth extends Module { identifier: string, profile: ProfileExchange ): Promise { - console.log("***** AppAuth:resolveUser", { + /*console.log("***** AppAuth:resolveUser", { action, strategy: strategy.getName(), identifier, profile - }); + });*/ if (!this.config.allow_register && action === "register") { throw new Exception("Registration is not allowed", 403); } @@ -137,12 +136,12 @@ export class AppAuth extends Module { } private filterUserData(user: any) { - console.log( + /*console.log( "--filterUserData", user, this.config.jwt.fields, pick(user, this.config.jwt.fields) - ); + );*/ return pick(user, this.config.jwt.fields); } @@ -168,18 +167,18 @@ export class AppAuth extends Module { if (!result.data) { throw new Exception("User not found", 404); } - console.log("---login data", result.data, result); + //console.log("---login data", result.data, result); // compare strategy and identifier - console.log("strategy comparison", result.data.strategy, strategy.getName()); + //console.log("strategy comparison", result.data.strategy, strategy.getName()); if (result.data.strategy !== strategy.getName()) { - console.log("!!! User registered with different strategy"); + //console.log("!!! User registered with different strategy"); throw new Exception("User registered with different strategy"); } - console.log("identifier comparison", result.data.strategy_value, identifier); + //console.log("identifier comparison", result.data.strategy_value, identifier); if (result.data.strategy_value !== identifier) { - console.log("!!! Invalid credentials"); + //console.log("!!! Invalid credentials"); throw new Exception("Invalid credentials"); } @@ -247,51 +246,36 @@ export class AppAuth extends Module { }; registerEntities() { - const users = this.getUsersEntity(); - - if (!this.em.hasEntity(users.name)) { - this.em.addEntity(users); - } else { - // if exists, check all fields required are there - // @todo: add to context: "needs sync" flag - const _entity = this.getUsersEntity(true); - for (const field of _entity.fields) { - const _field = users.field(field.name); - if (!_field) { - users.addField(field); + const users = this.getUsersEntity(true); + this.ensureSchema( + em( + { + [users.name as "users"]: users + }, + ({ index }, { users }) => { + index(users).on(["email"], true).on(["strategy"]).on(["strategy_value"]); } - } - } - - const indices = [ - new EntityIndex(users, [users.field("email")!], true), - new EntityIndex(users, [users.field("strategy")!]), - new EntityIndex(users, [users.field("strategy_value")!]) - ]; - indices.forEach((index) => { - if (!this.em.hasIndex(index)) { - this.em.addIndex(index); - } - }); + ) + ); try { const roles = Object.keys(this.config.roles ?? {}); const field = make("role", enumm({ enum: roles })); - this.em.entity(users.name).__experimental_replaceField("role", field); + users.__replaceField("role", field); } catch (e) {} try { const strategies = Object.keys(this.config.strategies ?? {}); const field = make("strategy", enumm({ enum: strategies })); - this.em.entity(users.name).__experimental_replaceField("strategy", field); + users.__replaceField("strategy", field); } catch (e) {} } - async createUser({ - email, - password, - ...additional - }: { email: string; password: string; [key: string]: any }) { + async createUser({ email, password, ...additional }: CreateUserPayload): Promise { + if (!this.enabled) { + throw new Error("Cannot create user, auth not enabled"); + } + const strategy = "password"; const pw = this.authenticator.strategy(strategy) as PasswordStrategy; const strategy_value = await pw.hash(password); diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 9afd302..553c477 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -1,42 +1,18 @@ import type { AppAuth } from "auth"; -import { type ClassController, isDebug } from "core"; -import { Hono, type MiddlewareHandler } from "hono"; +import { Controller } from "modules/Controller"; -export class AuthController implements ClassController { - constructor(private auth: AppAuth) {} +export class AuthController extends Controller { + constructor(private auth: AppAuth) { + super(); + } get guard() { return this.auth.ctx.guard; } - getMiddleware: MiddlewareHandler = async (c, next) => { - // @todo: ONLY HOTFIX - // middlewares are added for all routes are registered. But we need to make sure that - // only HTML/JSON routes are adding a cookie to the response. Config updates might - // also use an extension "syntax", e.g. /api/system/patch/data/entities.posts - // This middleware should be extracted and added by each Controller individually, - // but it requires access to the auth secret. - // Note: This doesn't mean endpoints aren't protected, just the cookie is not set. - const url = new URL(c.req.url); - const last = url.pathname.split("/")?.pop(); - const ext = last?.includes(".") ? last.split(".")?.pop() : undefined; - if ( - !this.auth.authenticator.isJsonRequest(c) && - ["GET", "HEAD", "OPTIONS"].includes(c.req.method) && - ext && - ["js", "css", "png", "jpg", "jpeg", "svg", "ico"].includes(ext) - ) { - isDebug() && console.log("Skipping auth", { ext }, url.pathname); - } else { - const user = await this.auth.authenticator.resolveAuthFromRequest(c); - this.auth.ctx.guard.setUserContext(user); - } - - await next(); - }; - - getController(): Hono { - const hono = new Hono(); + override getController() { + const { auth } = this.middlewares; + const hono = this.create(); const strategies = this.auth.authenticator.getStrategies(); for (const [name, strategy] of Object.entries(strategies)) { @@ -44,7 +20,7 @@ export class AuthController implements ClassController { hono.route(`/${name}`, strategy.getController(this.auth.authenticator)); } - hono.get("/me", async (c) => { + hono.get("/me", auth(), async (c) => { if (this.auth.authenticator.isUserLoggedIn()) { return c.json({ user: await this.auth.authenticator.getUser() }); } @@ -52,7 +28,7 @@ export class AuthController implements ClassController { return c.json({ user: null }, 403); }); - hono.get("/logout", async (c) => { + hono.get("/logout", auth(), async (c) => { await this.auth.authenticator.logout(c); if (this.auth.authenticator.isJsonRequest(c)) { return c.json({ ok: true }); diff --git a/app/src/auth/auth-schema.ts b/app/src/auth/auth-schema.ts index 202e0b4..84882b5 100644 --- a/app/src/auth/auth-schema.ts +++ b/app/src/auth/auth-schema.ts @@ -33,6 +33,7 @@ const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => { const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject)); export type AppAuthStrategies = Static; export type AppAuthOAuthStrategy = Static; +export type AppAuthCustomOAuthStrategy = Static; const guardConfigSchema = Type.Object({ enabled: Type.Optional(Type.Boolean({ default: false })) diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 46fa586..0dc479d 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -1,19 +1,11 @@ import { Exception } from "core"; import { addFlashMessage } from "core/server/flash"; -import { - type Static, - StringEnum, - type TSchema, - Type, - parse, - randomString, - transformObject -} from "core/utils"; +import { type Static, StringEnum, Type, parse, runtimeSupports, transformObject } from "core/utils"; import type { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { sign, verify } from "hono/jwt"; import type { CookieOptions } from "hono/utils/cookie"; -import { omit } from "lodash-es"; +import type { ServerEnv } from "modules/Module"; type Input = any; // workaround export type JWTPayload = Parameters[0]; @@ -67,6 +59,9 @@ export const cookieConfig = Type.Partial( { default: {}, additionalProperties: false } ); +// @todo: maybe add a config to not allow cookie/api tokens to be used interchangably? +// see auth.integration test for further details + export const jwtConfig = Type.Object( { // @todo: autogenerate a secret if not present. But it must be persisted from AppAuth @@ -98,7 +93,13 @@ export type AuthUserResolver = ( export class Authenticator = Record> { private readonly strategies: Strategies; private readonly config: AuthConfig; - private _user: SafeUser | undefined; + private _claims: + | undefined + | (SafeUser & { + iat: number; + iss?: string; + exp?: number; + }); private readonly userResolver: AuthUserResolver; constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) { @@ -131,16 +132,18 @@ export class Authenticator = Record< } isUserLoggedIn(): boolean { - return this._user !== undefined; + return this._claims !== undefined; } - getUser() { - return this._user; + getUser(): SafeUser | undefined { + if (!this._claims) return; + + const { iat, exp, iss, ...user } = this._claims; + return user; } - // @todo: determine what to do exactly - __setUserNull() { - this._user = undefined; + resetUser() { + this._claims = undefined; } strategy< @@ -154,6 +157,7 @@ export class Authenticator = Record< } } + // @todo: add jwt tests async jwt(user: Omit): Promise { const prohibited = ["password"]; for (const prop of prohibited) { @@ -200,11 +204,11 @@ export class Authenticator = Record< } } - this._user = omit(payload, ["iat", "exp", "iss"]) as SafeUser; + this._claims = payload as any; return true; } catch (e) { - this._user = undefined; - console.error(e); + this.resetUser(); + //console.error(e); } return false; @@ -222,10 +226,8 @@ export class Authenticator = Record< private async getAuthCookie(c: Context): Promise { try { const secret = this.config.jwt.secret; - const token = await getSignedCookie(c, secret, "auth"); if (typeof token !== "string") { - await deleteCookie(c, "auth", this.cookieOptions); return undefined; } @@ -243,23 +245,27 @@ export class Authenticator = Record< if (this.config.cookie.renew) { const token = await this.getAuthCookie(c); if (token) { - console.log("renewing cookie", c.req.url); await this.setAuthCookie(c, token); } } } - private async setAuthCookie(c: Context, token: string) { + private async setAuthCookie(c: Context, token: string) { const secret = this.config.jwt.secret; await setSignedCookie(c, "auth", token, secret, this.cookieOptions); } + private async deleteAuthCookie(c: Context) { + await deleteCookie(c, "auth", this.cookieOptions); + } + async logout(c: Context) { const cookie = await this.getAuthCookie(c); if (cookie) { - await deleteCookie(c, "auth", this.cookieOptions); + await this.deleteAuthCookie(c); await addFlashMessage(c, "Signed out", "info"); } + this.resetUser(); } // @todo: move this to a server helper @@ -268,18 +274,31 @@ export class Authenticator = Record< return c.req.header("Content-Type") === "application/json"; } + private getSuccessPath(c: Context) { + const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/"); + + // nextjs doesn't support non-fq urls + // but env could be proxied (stackblitz), so we shouldn't fq every url + if (!runtimeSupports("redirects_non_fq")) { + return new URL(c.req.url).origin + p; + } + + return p; + } + async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) { if (this.isJsonRequest(c)) { return c.json(data); } - const successPath = this.config.cookie.pathSuccess ?? "/"; - const successUrl = new URL(c.req.url).origin + successPath.replace(/\/+$/, "/"); - const referer = new URL(redirect ?? c.req.header("Referer") ?? successUrl); + const successUrl = this.getSuccessPath(c); + const referer = redirect ?? c.req.header("Referer") ?? successUrl; + //console.log("auth respond", { redirect, successUrl, successPath }); if ("token" in data) { await this.setAuthCookie(c, data.token); // can't navigate to "/" – doesn't work on nextjs + //console.log("auth success, redirecting to", successUrl); return c.redirect(successUrl); } @@ -289,6 +308,7 @@ export class Authenticator = Record< } await addFlashMessage(c, message, "error"); + //console.log("auth failed, redirecting to", referer); return c.redirect(referer); } @@ -304,7 +324,7 @@ export class Authenticator = Record< if (token) { await this.verify(token); - return this._user; + return this.getUser(); } return undefined; diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 880d134..15e3af2 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -98,12 +98,16 @@ export class Guard { if (this.user && typeof this.user.role === "string") { const role = this.roles?.find((role) => role.name === this.user?.role); if (role) { - debug && console.log("guard: role found", this.user.role); + debug && console.log("guard: role found", [this.user.role]); return role; } } - debug && console.log("guard: role not found", this.user, this.user?.role); + debug && + console.log("guard: role not found", { + user: this.user, + role: this.user?.role + }); return this.getDefaultRole(); } diff --git a/app/src/auth/middlewares.ts b/app/src/auth/middlewares.ts new file mode 100644 index 0000000..50fd2d4 --- /dev/null +++ b/app/src/auth/middlewares.ts @@ -0,0 +1,105 @@ +import type { Permission } from "core"; +import { patternMatch } from "core/utils"; +import type { Context } from "hono"; +import { createMiddleware } from "hono/factory"; +import type { ServerEnv } from "modules/Module"; + +function getPath(reqOrCtx: Request | Context) { + const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw; + return new URL(req.url).pathname; +} + +export function shouldSkip(c: Context, skip?: (string | RegExp)[]) { + if (c.get("auth_skip")) return true; + + const req = c.req.raw; + if (!skip) return false; + + const path = getPath(req); + const result = skip.some((s) => patternMatch(path, s)); + + c.set("auth_skip", result); + return result; +} + +export const auth = (options?: { + skip?: (string | RegExp)[]; +}) => + createMiddleware(async (c, next) => { + // make sure to only register once + if (c.get("auth_registered")) { + throw new Error(`auth middleware already registered for ${getPath(c)}`); + } + c.set("auth_registered", true); + + const app = c.get("app"); + const skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled; + const guard = app?.modules.ctx().guard; + const authenticator = app?.module.auth.authenticator; + + if (!skipped) { + const resolved = c.get("auth_resolved"); + if (!resolved) { + if (!app.module.auth.enabled) { + guard?.setUserContext(undefined); + } else { + guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c)); + c.set("auth_resolved", true); + } + } + } + + await next(); + + if (!skipped) { + // renew cookie if applicable + authenticator?.requestCookieRefresh(c); + } + + // release + guard?.setUserContext(undefined); + authenticator?.resetUser(); + c.set("auth_resolved", false); + }); + +export const permission = ( + permission: Permission | Permission[], + options?: { + onGranted?: (c: Context) => Promise; + onDenied?: (c: Context) => Promise; + } +) => + // @ts-ignore + createMiddleware(async (c, next) => { + const app = c.get("app"); + //console.log("skip?", c.get("auth_skip")); + + // in tests, app is not defined + if (!c.get("auth_registered") || !app) { + const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`; + if (app?.module.auth.enabled) { + throw new Error(msg); + } else { + console.warn(msg); + } + } else if (!c.get("auth_skip")) { + const guard = app.modules.ctx().guard; + const permissions = Array.isArray(permission) ? permission : [permission]; + + if (options?.onGranted || options?.onDenied) { + let returned: undefined | void | Response; + if (permissions.every((p) => guard.granted(p))) { + returned = await options?.onGranted?.(c); + } else { + returned = await options?.onDenied?.(c); + } + if (returned instanceof Response) { + return returned; + } + } else { + permissions.some((p) => guard.throwUnlessGranted(p)); + } + } + + await next(); + }); diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index 309df7f..3038181 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -1,5 +1,6 @@ import path from "node:path"; import type { Config } from "@libsql/client/node"; +import { config } from "core"; import type { MiddlewareHandler } from "hono"; import open from "open"; import { fileExists, getRelativeDistPath } from "../../utils/sys"; @@ -26,7 +27,7 @@ export async function serveStatic(server: Platform): Promise } export async function attachServeStatic(app: any, platform: Platform) { - app.module.server.client.get("/*", await serveStatic(platform)); + app.module.server.client.get(config.server.assets_path + "*", await serveStatic(platform)); } export async function startServer(server: Platform, app: any, options: { port: number }) { diff --git a/app/src/cli/commands/user.ts b/app/src/cli/commands/user.ts index 0883c67..235fd4c 100644 --- a/app/src/cli/commands/user.ts +++ b/app/src/cli/commands/user.ts @@ -35,9 +35,11 @@ async function action(action: "create" | "update", options: any) { } async function create(app: App, options: any) { - const config = app.module.auth.toJSON(true); const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; - const users_entity = config.entity_name as "users"; + + if (!strategy) { + throw new Error("Password strategy not configured"); + } const email = await $text({ message: "Enter email", @@ -65,16 +67,11 @@ async function create(app: App, options: any) { } try { - const mutator = app.modules.ctx().em.mutator(users_entity); - mutator.__unstable_toggleSystemEntityCreation(false); - const res = await mutator.insertOne({ + const created = await app.createUser({ email, - strategy: "password", - strategy_value: await strategy.hash(password as string) - }); - mutator.__unstable_toggleSystemEntityCreation(true); - - console.log("Created:", res.data); + password: await strategy.hash(password as string) + }) + console.log("Created:", created); } catch (e) { console.error("Error", e); } @@ -141,4 +138,4 @@ async function update(app: App, options: any) { } catch (e) { console.error("Error", e); } -} +} \ No newline at end of file diff --git a/app/src/core/config.ts b/app/src/core/config.ts index 9a70a5c..a99d549 100644 --- a/app/src/core/config.ts +++ b/app/src/core/config.ts @@ -10,7 +10,9 @@ export interface DB {} export const config = { server: { - default_port: 1337 + default_port: 1337, + // resetted to root for now, bc bundling with vite + assets_path: "/" }, data: { default_primary_field: "id" diff --git a/app/src/core/errors.ts b/app/src/core/errors.ts index 860bd9d..d9a1cdc 100644 --- a/app/src/core/errors.ts +++ b/app/src/core/errors.ts @@ -1,6 +1,7 @@ export class Exception extends Error { code = 400; override name = "Exception"; + protected _context = undefined; constructor(message: string, code?: number) { super(message); @@ -9,11 +10,16 @@ export class Exception extends Error { } } + context(context: any) { + this._context = context; + return this; + } + toJSON() { return { error: this.message, - type: this.name - //message: this.message + type: this.name, + context: this._context }; } } diff --git a/app/src/core/server/flash.ts b/app/src/core/server/flash.ts index c64753b..aeac431 100644 --- a/app/src/core/server/flash.ts +++ b/app/src/core/server/flash.ts @@ -4,14 +4,12 @@ import { setCookie } from "hono/cookie"; const flash_key = "__bknd_flash"; export type FlashMessageType = "error" | "warning" | "success" | "info"; -export async function addFlashMessage( - c: Context, - message: string, - type: FlashMessageType = "info" -) { - setCookie(c, flash_key, JSON.stringify({ type, message }), { - path: "/" - }); +export function addFlashMessage(c: Context, message: string, type: FlashMessageType = "info") { + if (c.req.header("Accept")?.includes("text/html")) { + setCookie(c, flash_key, JSON.stringify({ type, message }), { + path: "/" + }); + } } function getCookieValue(name) { diff --git a/app/src/core/utils/index.ts b/app/src/core/utils/index.ts index dcd5aff..85809e2 100644 --- a/app/src/core/utils/index.ts +++ b/app/src/core/utils/index.ts @@ -11,3 +11,4 @@ export * from "./crypto"; export * from "./uuid"; export { FromSchema } from "./typebox/from-schema"; export * from "./test"; +export * from "./runtime"; diff --git a/app/src/core/utils/runtime.ts b/app/src/core/utils/runtime.ts new file mode 100644 index 0000000..c7007fa --- /dev/null +++ b/app/src/core/utils/runtime.ts @@ -0,0 +1,41 @@ +import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter"; + +/** + * Adds additional checks for nextjs + */ +export function getRuntimeKey(): string { + const global = globalThis as any; + + // Detect Next.js server-side runtime + if (global?.process?.env?.NEXT_RUNTIME === "nodejs") { + return "nextjs"; + } + + // Detect Next.js edge runtime + if (global?.process?.env?.NEXT_RUNTIME === "edge") { + return "nextjs-edge"; + } + + // Detect Next.js client-side runtime + // @ts-ignore + if (typeof window !== "undefined" && window.__NEXT_DATA__) { + return "nextjs-client"; + } + + return honoGetRuntimeKey(); +} + +const features = { + // supports the redirect of not full qualified addresses + // not supported in nextjs + redirects_non_fq: true +}; + +export function runtimeSupports(feature: keyof typeof features) { + const runtime = getRuntimeKey(); + if (runtime.startsWith("nextjs")) { + features.redirects_non_fq = false; + } + + return features[feature]; +} diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts index a5a7c72..c7789dd 100644 --- a/app/src/core/utils/strings.ts +++ b/app/src/core/utils/strings.ts @@ -104,3 +104,14 @@ export function replaceSimplePlaceholders(str: string, vars: Record return key in vars ? vars[key] : match; }); } + +export function patternMatch(target: string, pattern: RegExp | string): boolean { + if (pattern instanceof RegExp) { + return pattern.test(target); + } else if (typeof pattern === "string" && pattern.startsWith("/")) { + return new RegExp(pattern).test(target); + } else if (typeof pattern === "string") { + return target.startsWith(pattern); + } + return false; +} diff --git a/app/src/core/utils/test.ts b/app/src/core/utils/test.ts index b06ac55..662b33c 100644 --- a/app/src/core/utils/test.ts +++ b/app/src/core/utils/test.ts @@ -7,7 +7,7 @@ const _oldConsoles = { export async function withDisabledConsole( fn: () => Promise, - severities: ConsoleSeverity[] = ["log"] + severities: ConsoleSeverity[] = ["log", "warn", "error"] ): Promise { const _oldConsoles = { log: console.log, @@ -30,7 +30,7 @@ export async function withDisabledConsole( } } -export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) { +export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) { severities.forEach((severity) => { console[severity] = () => null; }); diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 68a5417..497ffa9 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -1,32 +1,26 @@ -import { type ClassController, isDebug, tbValidator as tb } from "core"; -import { StringEnum, Type, objectCleanEmpty, objectTransform } from "core/utils"; +import { isDebug, tbValidator as tb } from "core"; +import { StringEnum, Type } from "core/utils"; import { DataPermissions, type EntityData, type EntityManager, - FieldClassMap, type MutatorResponse, - PrimaryField, type RepoQuery, type RepositoryResponse, - TextField, querySchema } from "data"; -import { Hono } from "hono"; import type { Handler } from "hono/types"; import type { ModuleBuildContext } from "modules"; +import { Controller } from "modules/Controller"; import * as SystemPermissions from "modules/permissions"; -import { type AppDataConfig, FIELDS } from "../data-schema"; +import type { AppDataConfig } from "../data-schema"; -export class DataController implements ClassController { +export class DataController extends Controller { constructor( private readonly ctx: ModuleBuildContext, private readonly config: AppDataConfig ) { - /*console.log( - "data controller", - this.em.entities.map((e) => e.name) - );*/ + super(); } get em(): EntityManager { @@ -74,8 +68,10 @@ export class DataController implements ClassController { } } - getController(): Hono { - const hono = new Hono(); + override getController() { + const { permission, auth } = this.middlewares; + const hono = this.create().use(auth()); + const definedEntities = this.em.entities.map((e) => e.name); const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" })) .Decode(Number.parseInt) @@ -89,10 +85,7 @@ export class DataController implements ClassController { return func; } - hono.use("*", async (c, next) => { - this.ctx.guard.throwUnlessGranted(SystemPermissions.accessApi); - await next(); - }); + hono.use("*", permission(SystemPermissions.accessApi)); // info hono.get( @@ -104,9 +97,7 @@ export class DataController implements ClassController { ); // sync endpoint - hono.get("/sync", async (c) => { - this.guard.throwUnlessGranted(DataPermissions.databaseSync); - + hono.get("/sync", permission(DataPermissions.databaseSync), async (c) => { const force = c.req.query("force") === "1"; const drop = c.req.query("drop") === "1"; //console.log("force", force); @@ -126,10 +117,9 @@ export class DataController implements ClassController { // fn: count .post( "/:entity/fn/count", + permission(DataPermissions.entityRead), tb("param", Type.Object({ entity: Type.String() })), async (c) => { - this.guard.throwUnlessGranted(DataPermissions.entityRead); - const { entity } = c.req.valid("param"); if (!this.entityExists(entity)) { return c.notFound(); @@ -143,10 +133,9 @@ export class DataController implements ClassController { // fn: exists .post( "/:entity/fn/exists", + permission(DataPermissions.entityRead), tb("param", Type.Object({ entity: Type.String() })), async (c) => { - this.guard.throwUnlessGranted(DataPermissions.entityRead); - const { entity } = c.req.valid("param"); if (!this.entityExists(entity)) { return c.notFound(); @@ -163,8 +152,7 @@ export class DataController implements ClassController { */ hono // read entity schema - .get("/schema.json", async (c) => { - this.guard.throwUnlessGranted(DataPermissions.entityRead); + .get("/schema.json", permission(DataPermissions.entityRead), async (c) => { const $id = `${this.config.basepath}/schema.json`; const schemas = Object.fromEntries( this.em.entities.map((e) => [ @@ -183,6 +171,7 @@ export class DataController implements ClassController { // read schema .get( "/schemas/:entity/:context?", + permission(DataPermissions.entityRead), tb( "param", Type.Object({ @@ -191,8 +180,6 @@ export class DataController implements ClassController { }) ), async (c) => { - this.guard.throwUnlessGranted(DataPermissions.entityRead); - //console.log("request", c.req.raw); const { entity, context } = c.req.param(); if (!this.entityExists(entity)) { @@ -216,11 +203,10 @@ export class DataController implements ClassController { // read many .get( "/:entity", + permission(DataPermissions.entityRead), tb("param", Type.Object({ entity: Type.String() })), tb("query", querySchema), async (c) => { - this.guard.throwUnlessGranted(DataPermissions.entityRead); - //console.log("request", c.req.raw); const { entity } = c.req.param(); if (!this.entityExists(entity)) { @@ -238,6 +224,7 @@ export class DataController implements ClassController { // read one .get( "/:entity/:id", + permission(DataPermissions.entityRead), tb( "param", Type.Object({ @@ -246,11 +233,7 @@ export class DataController implements ClassController { }) ), tb("query", querySchema), - /*zValidator("param", z.object({ entity: z.string(), id: z.coerce.number() })), - zValidator("query", repoQuerySchema),*/ async (c) => { - this.guard.throwUnlessGranted(DataPermissions.entityRead); - const { entity, id } = c.req.param(); if (!this.entityExists(entity)) { return c.notFound(); @@ -264,6 +247,7 @@ export class DataController implements ClassController { // read many by reference .get( "/:entity/:id/:reference", + permission(DataPermissions.entityRead), tb( "param", Type.Object({ @@ -274,8 +258,6 @@ export class DataController implements ClassController { ), tb("query", querySchema), async (c) => { - this.guard.throwUnlessGranted(DataPermissions.entityRead); - const { entity, id, reference } = c.req.param(); if (!this.entityExists(entity)) { return c.notFound(); @@ -292,11 +274,10 @@ export class DataController implements ClassController { // func query .post( "/:entity/query", + permission(DataPermissions.entityRead), tb("param", Type.Object({ entity: Type.String() })), tb("json", querySchema), async (c) => { - this.guard.throwUnlessGranted(DataPermissions.entityRead); - const { entity } = c.req.param(); if (!this.entityExists(entity)) { return c.notFound(); @@ -314,25 +295,27 @@ export class DataController implements ClassController { */ // insert one hono - .post("/:entity", tb("param", Type.Object({ entity: Type.String() })), async (c) => { - this.guard.throwUnlessGranted(DataPermissions.entityCreate); + .post( + "/:entity", + permission(DataPermissions.entityCreate), + tb("param", Type.Object({ entity: Type.String() })), + async (c) => { + const { entity } = c.req.param(); + if (!this.entityExists(entity)) { + return c.notFound(); + } + const body = (await c.req.json()) as EntityData; + const result = await this.em.mutator(entity).insertOne(body); - const { entity } = c.req.param(); - if (!this.entityExists(entity)) { - return c.notFound(); + return c.json(this.mutatorResult(result), 201); } - const body = (await c.req.json()) as EntityData; - const result = await this.em.mutator(entity).insertOne(body); - - return c.json(this.mutatorResult(result), 201); - }) + ) // update one .patch( "/:entity/:id", + permission(DataPermissions.entityUpdate), tb("param", Type.Object({ entity: Type.String(), id: tbNumber })), async (c) => { - this.guard.throwUnlessGranted(DataPermissions.entityUpdate); - const { entity, id } = c.req.param(); if (!this.entityExists(entity)) { return c.notFound(); @@ -346,6 +329,8 @@ export class DataController implements ClassController { // delete one .delete( "/:entity/:id", + + permission(DataPermissions.entityDelete), tb("param", Type.Object({ entity: Type.String(), id: tbNumber })), async (c) => { this.guard.throwUnlessGranted(DataPermissions.entityDelete); @@ -363,11 +348,10 @@ export class DataController implements ClassController { // delete many .delete( "/:entity", + permission(DataPermissions.entityDelete), tb("param", Type.Object({ entity: Type.String() })), tb("json", querySchema.properties.where), async (c) => { - this.guard.throwUnlessGranted(DataPermissions.entityDelete); - //console.log("request", c.req.raw); const { entity } = c.req.param(); if (!this.entityExists(entity)) { diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index aa3d75c..a87d609 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -140,7 +140,7 @@ export class Entity< return this.fields.find((field) => field.name === name); } - __experimental_replaceField(name: string, field: Field) { + __replaceField(name: string, field: Field) { const index = this.fields.findIndex((f) => f.name === name); if (index === -1) { throw new Error(`Field "${name}" not found on entity "${this.name}"`); diff --git a/app/src/data/entities/EntityManager.ts b/app/src/data/entities/EntityManager.ts index fea93aa..f8dfd7b 100644 --- a/app/src/data/entities/EntityManager.ts +++ b/app/src/data/entities/EntityManager.ts @@ -99,14 +99,24 @@ export class EntityManager { this.entities.push(entity); } - entity(e: Entity | keyof TBD | string): Entity { - let entity: Entity | undefined; - if (typeof e === "string") { - entity = this.entities.find((entity) => entity.name === e); - } else if (e instanceof Entity) { - entity = e; + __replaceEntity(entity: Entity, name: string | undefined = entity.name) { + const entityIndex = this._entities.findIndex((e) => e.name === name); + + if (entityIndex === -1) { + throw new Error(`Entity "${name}" not found and cannot be replaced`); } + this._entities[entityIndex] = entity; + + // caused issues because this.entity() was using a reference (for when initial config was given) + } + + entity(e: Entity | keyof TBD | string): Entity { + // make sure to always retrieve by name + const entity = this.entities.find((entity) => + e instanceof Entity ? entity.name === e.name : entity.name === e + ); + if (!entity) { // @ts-ignore throw new EntityNotDefinedException(e instanceof Entity ? e.name : e); diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index 171fc3b..a6dc576 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -58,7 +58,7 @@ export class Repository 0) { throw new InvalidSearchParamsException( `Invalid select field(s): ${invalid.join(", ")}` - ); + ).context({ + entity: entity.name, + valid: validated.select + }); } validated.select = options.select; diff --git a/app/src/data/helper.ts b/app/src/data/helper.ts index 74497b0..e465247 100644 --- a/app/src/data/helper.ts +++ b/app/src/data/helper.ts @@ -1,4 +1,4 @@ -import type { EntityData, Field } from "data"; +import type { EntityData, EntityManager, Field } from "data"; import { transform } from "lodash-es"; export function getDefaultValues(fields: Field[], data: EntityData): EntityData { @@ -48,3 +48,23 @@ export function getChangeSet( {} as typeof formData ); } + +export function readableEmJson(_em: EntityManager) { + return { + entities: _em.entities.map((e) => ({ + name: e.name, + fields: e.fields.map((f) => f.name), + type: e.type + })), + indices: _em.indices.map((i) => ({ + name: i.name, + entity: i.entity.name, + fields: i.fields.map((f) => f.name), + unique: i.unique + })), + relations: _em.relations.all.map((r) => ({ + name: r.getName(), + ...r.toJSON() + })) + }; +} diff --git a/app/src/data/prototype/index.ts b/app/src/data/prototype/index.ts index e9e868f..6a05f72 100644 --- a/app/src/data/prototype/index.ts +++ b/app/src/data/prototype/index.ts @@ -272,18 +272,22 @@ class EntityManagerPrototype> extends En } } -type Chained any, Rt = ReturnType> = ( - e: E -) => { - [K in keyof Rt]: Rt[K] extends (...args: any[]) => any - ? (...args: Parameters) => Rt +type Chained any>> = { + [K in keyof R]: R[K] extends (...args: any[]) => any + ? (...args: Parameters) => Chained : never; }; +type ChainedFn< + Fn extends (...args: any[]) => Record any>, + Return extends ReturnType = ReturnType +> = (e: Entity) => { + [K in keyof Return]: (...args: Parameters) => Chained; +}; export function em>( entities: Entities, schema?: ( - fns: { relation: Chained; index: Chained }, + fns: { relation: ChainedFn; index: ChainedFn }, entities: Entities ) => void ) { diff --git a/app/src/data/server/data-query-impl.ts b/app/src/data/server/data-query-impl.ts index a85ac77..5bf18d9 100644 --- a/app/src/data/server/data-query-impl.ts +++ b/app/src/data/server/data-query-impl.ts @@ -6,7 +6,6 @@ import { Type, Value } from "core/utils"; -import type { Simplify } from "type-fest"; import { WhereBuilder } from "../entities"; const NumberOrString = (options: SchemaOptions = {}) => @@ -19,17 +18,25 @@ const limit = NumberOrString({ default: 10 }); const offset = NumberOrString({ default: 0 }); // @todo: allow "id" and "-id" +const sort_default = { by: "id", dir: "asc" }; const sort = Type.Transform( Type.Union( [Type.String(), Type.Object({ by: Type.String(), dir: StringEnum(["asc", "desc"]) })], { - default: { by: "id", dir: "asc" } + default: sort_default } ) ) .Decode((value) => { if (typeof value === "string") { - return JSON.parse(value); + if (/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(value)) { + const dir = value[0] === "-" ? "desc" : "asc"; + return { by: dir === "desc" ? value.slice(1) : value, dir }; + } else if (/^{.*}$/.test(value)) { + return JSON.parse(value); + } + + return sort_default; } return value; }) diff --git a/app/src/index.ts b/app/src/index.ts index 1e5b71d..84a5d97 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -9,6 +9,7 @@ export { type ModuleBuildContext } from "./modules/ModuleManager"; +export * as middlewares from "modules/middlewares"; export { registries } from "modules/registries"; export type * from "./adapter"; diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index 789dae9..c759479 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -1,8 +1,17 @@ import type { PrimaryFieldType } from "core"; -import { EntityIndex, type EntityManager } from "data"; +import { type Entity, EntityIndex, type EntityManager } from "data"; import { type FileUploadedEventData, Storage, type StorageAdapter } from "media"; import { Module } from "modules/Module"; -import { type FieldSchema, boolean, datetime, entity, json, number, text } from "../data/prototype"; +import { + type FieldSchema, + boolean, + datetime, + em, + entity, + json, + number, + text +} from "../data/prototype"; import { MediaController } from "./api/MediaController"; import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema"; @@ -38,18 +47,12 @@ export class AppMedia extends Module { this.setupListeners(); this.ctx.server.route(this.basepath, new MediaController(this).getController()); - // @todo: add check for media entity - const mediaEntity = this.getMediaEntity(); - if (!this.ctx.em.hasEntity(mediaEntity)) { - this.ctx.em.addEntity(mediaEntity); - } - - const pathIndex = new EntityIndex(mediaEntity, [mediaEntity.field("path")!], true); - if (!this.ctx.em.hasIndex(pathIndex)) { - this.ctx.em.addIndex(pathIndex); - } - - // @todo: check indices + const media = this.getMediaEntity(true); + this.ensureSchema( + em({ [media.name as "media"]: media }, ({ index }, { media }) => { + index(media).on(["path"], true).on(["reference"]); + }) + ); } catch (e) { console.error(e); throw new Error( @@ -94,13 +97,13 @@ export class AppMedia extends Module { metadata: json() }; - getMediaEntity() { + getMediaEntity(forceCreate?: boolean): Entity<"media", typeof AppMedia.mediaFields> { const entity_name = this.config.entity_name; - if (!this.em.hasEntity(entity_name)) { - return entity(entity_name, AppMedia.mediaFields, undefined, "system"); + if (forceCreate || !this.em.hasEntity(entity_name)) { + return entity(entity_name as "media", AppMedia.mediaFields, undefined, "system"); } - return this.em.entity(entity_name); + return this.em.entity(entity_name) as any; } get em(): EntityManager { diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 2a1a304..e469830 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -1,10 +1,9 @@ -import { type ClassController, tbValidator as tb } from "core"; +import { tbValidator as tb } from "core"; import { Type } from "core/utils"; -import { Hono } from "hono"; import { bodyLimit } from "hono/body-limit"; import type { StorageAdapter } from "media"; -import { StorageEvents } from "media"; -import { getRandomizedFilename } from "media"; +import { StorageEvents, getRandomizedFilename } from "media"; +import { Controller } from "modules/Controller"; import type { AppMedia } from "../AppMedia"; import { MediaField } from "../MediaField"; @@ -12,8 +11,10 @@ const booleanLike = Type.Transform(Type.String()) .Decode((v) => v === "1") .Encode((v) => (v ? "1" : "0")); -export class MediaController implements ClassController { - constructor(private readonly media: AppMedia) {} +export class MediaController extends Controller { + constructor(private readonly media: AppMedia) { + super(); + } private getStorageAdapter(): StorageAdapter { return this.getStorage().getAdapter(); @@ -23,11 +24,11 @@ export class MediaController implements ClassController { return this.media.storage; } - getController(): Hono { + override getController() { // @todo: multiple providers? // @todo: implement range requests - - const hono = new Hono(); + const { auth } = this.middlewares; + const hono = this.create().use(auth()); // get files list (temporary) hono.get("/files", async (c) => { @@ -107,7 +108,7 @@ export class MediaController implements ClassController { return c.json({ error: `Invalid field "${field_name}"` }, 400); } - const mediaEntity = this.media.getMediaEntity(); + const media_entity = this.media.getMediaEntity().name as "media"; const reference = `${entity_name}.${field_name}`; const mediaRef = { scope: field_name, @@ -117,11 +118,10 @@ export class MediaController implements ClassController { // check max items const max_items = field.getMaxItems(); - const ids_to_delete: number[] = []; - const id_field = mediaEntity.getPrimaryField().name; + const paths_to_delete: string[] = []; if (max_items) { const { overwrite } = c.req.valid("query"); - const { count } = await this.media.em.repository(mediaEntity).count(mediaRef); + const { count } = await this.media.em.repository(media_entity).count(mediaRef); // if there are more than or equal to max items if (count >= max_items) { @@ -140,18 +140,18 @@ export class MediaController implements ClassController { } // collect items to delete - const deleteRes = await this.media.em.repo(mediaEntity).findMany({ - select: [id_field], + const deleteRes = await this.media.em.repo(media_entity).findMany({ + select: ["path"], where: mediaRef, sort: { - by: id_field, + by: "id", dir: "asc" }, limit: count - max_items + 1 }); if (deleteRes.data && deleteRes.data.length > 0) { - deleteRes.data.map((item) => ids_to_delete.push(item[id_field])); + deleteRes.data.map((item) => paths_to_delete.push(item.path)); } } } @@ -169,7 +169,7 @@ export class MediaController implements ClassController { const file_name = getRandomizedFilename(file as File); const info = await this.getStorage().uploadFile(file, file_name, true); - const mutator = this.media.em.mutator(mediaEntity); + const mutator = this.media.em.mutator(media_entity); mutator.__unstable_toggleSystemEntityCreation(false); const result = await mutator.insertOne({ ...this.media.uploadedEventDataToMediaPayload(info), @@ -178,10 +178,11 @@ export class MediaController implements ClassController { mutator.__unstable_toggleSystemEntityCreation(true); // delete items if needed - if (ids_to_delete.length > 0) { - await this.media.em - .mutator(mediaEntity) - .deleteWhere({ [id_field]: { $in: ids_to_delete } }); + if (paths_to_delete.length > 0) { + // delete files from db & adapter + for (const path of paths_to_delete) { + await this.getStorage().deleteFile(path); + } } return c.json({ ok: true, result: result.data, ...info }); diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index 045a0ca..64a52ba 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -1,4 +1,4 @@ -import { Const, Type, objectTransform } from "core/utils"; +import { Const, type Static, Type, objectTransform } from "core/utils"; import { Adapters } from "media"; import { registries } from "modules/registries"; @@ -47,3 +47,4 @@ export function buildMediaSchema() { } export const mediaConfigSchema = buildMediaSchema(); +export type TAppMediaConfig = Static; diff --git a/app/src/modules/Controller.ts b/app/src/modules/Controller.ts new file mode 100644 index 0000000..674c2a0 --- /dev/null +++ b/app/src/modules/Controller.ts @@ -0,0 +1,19 @@ +import { Hono } from "hono"; +import type { ServerEnv } from "modules/Module"; +import * as middlewares from "modules/middlewares"; + +export class Controller { + protected middlewares = middlewares; + + protected create(): Hono { + return Controller.createServer(); + } + + static createServer(): Hono { + return new Hono(); + } + + getController(): Hono { + return this.create(); + } +} diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index 32f098c..838e964 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -1,16 +1,32 @@ +import type { App } from "App"; import type { Guard } from "auth"; import { SchemaObject } from "core"; import type { EventManager } from "core/events"; import type { Static, TSchema } from "core/utils"; -import type { Connection, EntityManager } from "data"; +import type { Connection, EntityIndex, EntityManager, em as prototypeEm } from "data"; +import { Entity } from "data"; import type { Hono } from "hono"; +export type ServerEnv = { + Variables: { + app?: App; + // to prevent resolving auth multiple times + auth_resolved?: boolean; + // to only register once + auth_registered?: boolean; + // whether or not to bypass auth + auth_skip?: boolean; + html?: string; + }; +}; + export type ModuleBuildContext = { connection: Connection; - server: Hono; + server: Hono; em: EntityManager; emgr: EventManager; guard: Guard; + flags: (typeof Module)["ctx_flags"]; }; export abstract class Module> { @@ -33,6 +49,15 @@ export abstract class Module { return to; } @@ -78,6 +103,10 @@ export abstract class Module) {} + get ctx() { if (!this._ctx) { throw new Error("Context not set"); @@ -115,4 +144,44 @@ export abstract class Module> { return this.config; } + + protected ensureEntity(entity: Entity) { + // check fields + if (!this.ctx.em.hasEntity(entity.name)) { + this.ctx.em.addEntity(entity); + this.ctx.flags.sync_required = true; + return; + } + + const instance = this.ctx.em.entity(entity.name); + + // if exists, check all fields required are there + // @todo: check if the field also equal + for (const field of instance.fields) { + const _field = entity.field(field.name); + if (!_field) { + entity.addField(field); + this.ctx.flags.sync_required = true; + } + } + + // replace entity (mainly to keep the ensured type) + this.ctx.em.__replaceEntity( + new Entity(entity.name, entity.fields, instance.config, entity.type) + ); + } + + protected ensureIndex(index: EntityIndex) { + if (!this.ctx.em.hasIndex(index)) { + this.ctx.em.addIndex(index); + this.ctx.flags.sync_required = true; + } + } + + protected ensureSchema>(schema: Schema): Schema { + Object.values(schema.entities ?? {}).forEach(this.ensureEntity.bind(this)); + schema.indices?.forEach(this.ensureIndex.bind(this)); + + return schema; + } } diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 566384c..d5840c2 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -33,7 +33,7 @@ import { AppAuth } from "../auth/AppAuth"; import { AppData } from "../data/AppData"; import { AppFlows } from "../flows/AppFlows"; import { AppMedia } from "../media/AppMedia"; -import type { Module, ModuleBuildContext } from "./Module"; +import { Module, type ModuleBuildContext, type ServerEnv } from "./Module"; export type { ModuleBuildContext }; @@ -79,6 +79,8 @@ export type ModuleManagerOptions = { onFirstBoot?: () => Promise; // base path for the hono instance basePath?: string; + // callback after server was created + onServerInit?: (server: Hono) => void; // doesn't perform validity checks for given/fetched config trustFetched?: boolean; // runs when initial config provided on a fresh database @@ -124,15 +126,12 @@ export class ModuleManager { __em!: EntityManager; // ctx for modules em!: EntityManager; - server!: Hono; + server!: Hono; emgr!: EventManager; guard!: Guard; private _version: number = 0; private _built = false; - private _fetched = false; - - // @todo: keep? not doing anything with it private readonly _booted_with?: "provided" | "partial"; private logger = new DebugLogger(false); @@ -204,19 +203,17 @@ export class ModuleManager { } private rebuildServer() { - this.server = new Hono(); + this.server = new Hono(); if (this.options?.basePath) { this.server = this.server.basePath(this.options.basePath); } + if (this.options?.onServerInit) { + this.options.onServerInit(this.server); + } - // @todo: this is a current workaround, controllers must be reworked + // optional method for each module to register global middlewares, etc. objectEach(this.modules, (module) => { - if ("getMiddleware" in module) { - const middleware = module.getMiddleware(); - if (middleware) { - this.server.use(middleware); - } - } + module.onServerInit(this.server); }); } @@ -232,7 +229,8 @@ export class ModuleManager { server: this.server, em: this.em, emgr: this.emgr, - guard: this.guard + guard: this.guard, + flags: Module.ctx_flags }; } @@ -402,8 +400,8 @@ export class ModuleManager { }); } - private async buildModules(options?: { graceful?: boolean }) { - this.logger.log("buildModules() triggered", options?.graceful, this._built); + private async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) { + this.logger.log("buildModules() triggered", options, this._built); if (options?.graceful && this._built) { this.logger.log("skipping build (graceful)"); return; @@ -417,7 +415,27 @@ export class ModuleManager { } this._built = true; - this.logger.log("modules built"); + this.logger.log("modules built", ctx.flags); + + if (options?.ignoreFlags !== true) { + if (ctx.flags.sync_required) { + ctx.flags.sync_required = false; + this.logger.log("db sync requested"); + + // sync db + await ctx.em.schema().sync({ force: true }); + await this.save(); + } + + if (ctx.flags.ctx_reload_required) { + ctx.flags.ctx_reload_required = false; + this.logger.log("ctx reload requested"); + this.ctx(true); + } + } + + // reset all falgs + ctx.flags = Module.ctx_flags; } async build() { diff --git a/app/src/modules/index.ts b/app/src/modules/index.ts index 186a689..5411636 100644 --- a/app/src/modules/index.ts +++ b/app/src/modules/index.ts @@ -11,7 +11,7 @@ export { MODULE_NAMES, type ModuleKey } from "./ModuleManager"; -export { /*Module,*/ type ModuleBuildContext } from "./Module"; +export type { ModuleBuildContext } from "./Module"; export { type PrimaryFieldType, diff --git a/app/src/modules/middlewares.ts b/app/src/modules/middlewares.ts new file mode 100644 index 0000000..be1ad59 --- /dev/null +++ b/app/src/modules/middlewares.ts @@ -0,0 +1 @@ +export { auth, permission } from "auth/middlewares"; diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index f573132..7aeb4bb 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -1,11 +1,11 @@ /** @jsxImportSource hono/jsx */ import type { App } from "App"; -import { type ClassController, isDebug } from "core"; +import { config, isDebug } from "core"; import { addFlashMessage } from "core/server/flash"; -import { Hono } from "hono"; import { html } from "hono/html"; import { Fragment } from "hono/jsx"; +import { Controller } from "modules/Controller"; import * as SystemPermissions from "modules/permissions"; const htmlBkndContextReplace = ""; @@ -13,38 +13,52 @@ const htmlBkndContextReplace = ""; // @todo: add migration to remove admin path from config export type AdminControllerOptions = { basepath?: string; + assets_path?: string; html?: string; forceDev?: boolean | { mainPath: string }; }; -export class AdminController implements ClassController { +export class AdminController extends Controller { constructor( private readonly app: App, - private options: AdminControllerOptions = {} - ) {} + private _options: AdminControllerOptions = {} + ) { + super(); + } get ctx() { return this.app.modules.ctx(); } + get options() { + return { + ...this._options, + basepath: this._options.basepath ?? "/", + assets_path: this._options.assets_path ?? config.server.assets_path + }; + } + get basepath() { return this.options.basepath ?? "/"; } private withBasePath(route: string = "") { - return (this.basepath + route).replace(/\/+$/, "/"); + return (this.basepath + route).replace(/(? { + override getController() { + const { auth: authMiddleware, permission } = this.middlewares; + const hono = this.create().use( + authMiddleware({ + //skip: [/favicon\.ico$/] + }) + ); + const auth = this.app.module.auth; const configs = this.app.modules.configs(); // if auth is not enabled, authenticator is undefined const auth_enabled = configs.auth.enabled; - const hono = new Hono<{ - Variables: { - html: string; - }; - }>().basePath(this.withBasePath()); + const authRoutes = { root: "/", success: configs.auth.cookie.pathSuccess ?? "/", @@ -66,23 +80,26 @@ export class AdminController implements ClassController { } c.set("html", html); - // refresh cookie if needed - await auth.authenticator?.requestCookieRefresh(c); await next(); }); if (auth_enabled) { - hono.get(authRoutes.login, async (c) => { - if ( - this.app.module.auth.authenticator?.isUserLoggedIn() && - this.ctx.guard.granted(SystemPermissions.accessAdmin) - ) { - return c.redirect(authRoutes.success); + hono.get( + authRoutes.login, + permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], { + // @ts-ignore + onGranted: async (c) => { + // @todo: add strict test to permissions middleware? + if (auth.authenticator.isUserLoggedIn()) { + console.log("redirecting to success"); + return c.redirect(authRoutes.success); + } + } + }), + async (c) => { + return c.html(c.get("html")!); } - - const html = c.get("html"); - return c.html(html); - }); + ); hono.get(authRoutes.logout, async (c) => { await auth.authenticator?.logout(c); @@ -90,15 +107,26 @@ export class AdminController implements ClassController { }); } - hono.get("*", async (c) => { - if (!this.ctx.guard.granted(SystemPermissions.accessAdmin)) { - await addFlashMessage(c, "You are not authorized to access the Admin UI", "error"); - return c.redirect(authRoutes.login); - } + // @todo: only load known paths + hono.get( + "/*", + permission(SystemPermissions.accessAdmin, { + onDenied: async (c) => { + addFlashMessage(c, "You are not authorized to access the Admin UI", "error"); - const html = c.get("html"); - return c.html(html); - }); + console.log("redirecting"); + return c.redirect(authRoutes.login); + } + }), + permission(SystemPermissions.schemaRead, { + onDenied: async (c) => { + addFlashMessage(c, "You not allowed to read the schema", "warning"); + } + }), + async (c) => { + return c.html(c.get("html")!); + } + ); return hono; } @@ -138,29 +166,42 @@ export class AdminController implements ClassController { const manifest = await import("bknd/dist/manifest.json", { assert: { type: "json" } }).then((m) => m.default); - assets.js = manifest["src/ui/main.tsx"].name; - assets.css = manifest["src/ui/main.css"].name; + // @todo: load all marked as entry (incl. css) + assets.js = manifest["src/ui/main.tsx"].file; + assets.css = manifest["src/ui/main.tsx"].css[0] as any; } catch (e) { console.error("Error loading manifest", e); } } + const theme = configs.server.admin.color_scheme ?? "light"; + const favicon = isProd ? this.options.assets_path + "favicon.ico" : "/favicon.ico"; + return ( {/* dnd complains otherwise */} {html``} - + + BKND {isProd ? ( -