Merge pull request #40 from bknd-io/release/0.5

Release 0.5
This commit is contained in:
dswbx
2025-01-14 12:29:32 +01:00
committed by GitHub
92 changed files with 2587 additions and 1021 deletions

1
.gitignore vendored
View File

@@ -18,6 +18,7 @@ packages/media/.env
**/*/vite.config.ts.timestamp* **/*/vite.config.ts.timestamp*
.history .history
**/*/.db/* **/*/.db/*
**/*/.configs/*
**/*/*.db **/*/*.db
**/*/*.db-shm **/*/*.db-shm
**/*/*.db-wal **/*/*.db-wal

View File

@@ -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, 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 authentication, workflows and media. Since it's lightweight and built on Web Standards, it can

View File

@@ -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 extends TSchema>(schema: Schema) {
class TestModule extends Module<typeof schema> {
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" });
});
});

View File

@@ -0,0 +1 @@
import { describe, expect, it } from "bun:test";

View File

@@ -1,14 +1,16 @@
import { describe, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { checksum, hash } from "../../src/core/utils"; import { checksum, hash } from "../../src/core/utils";
describe("crypto", async () => { describe("crypto", async () => {
test("sha256", async () => { test("sha256", async () => {
console.log(await hash.sha256("test")); expect(await hash.sha256("test")).toBe(
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
);
}); });
test("sha1", async () => { test("sha1", async () => {
console.log(await hash.sha1("test")); expect(await hash.sha1("test")).toBe("a94a8fe5ccb19ba61c4c0873d391e987982fbbd3");
}); });
test("checksum", async () => { test("checksum", async () => {
console.log(checksum("hello world")); expect(await checksum("hello world")).toBe("2aae6c35c94fcfb415dbe95f408b9ce91ee846ed");
}); });
}); });

View File

@@ -1,16 +1,15 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import type { QueryObject } from "ufo"; import { Value } from "../../src/core/utils";
import { WhereBuilder, type WhereQuery } from "../../src/data/entities/query/WhereBuilder"; import { WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
import { getDummyConnection } from "./helper"; import { getDummyConnection } from "./helper";
const t = "t";
describe("data-query-impl", () => { describe("data-query-impl", () => {
function qb() { function qb() {
const c = getDummyConnection(); const c = getDummyConnection();
const kysely = c.dummyConnection.kysely; 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(); const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
return { sql, parameters }; 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" });
});
});

View File

@@ -18,7 +18,7 @@ describe("some tests", async () => {
const users = new Entity("users", [ const users = new Entity("users", [
new TextField("username", { required: true, default_value: "nobody" }), new TextField("username", { required: true, default_value: "nobody" }),
new TextField("email", { max_length: 3 }) new TextField("email", { maxLength: 3 })
]); ]);
const posts = new Entity("posts", [ const posts = new Entity("posts", [

View File

@@ -1,7 +1,7 @@
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { isEqual } from "lodash-es"; 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"; import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
/*beforeAll(disableConsoleLog); /*beforeAll(disableConsoleLog);
@@ -232,8 +232,10 @@ describe("Flow tests", async () => {
).toEqual(["second", "fourth"]); ).toEqual(["second", "fourth"]);
const execution = back.createExecution(); const execution = back.createExecution();
withDisabledConsole(async () => {
expect(execution.start()).rejects.toThrow(); expect(execution.start()).rejects.toThrow();
}); });
});
test("Flow with back step: enough retries", async () => { test("Flow with back step: enough retries", async () => {
const first = getNamedTask("first"); const first = getNamedTask("first");

View File

@@ -40,7 +40,7 @@ const _oldConsoles = {
error: console.error error: console.error
}; };
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) { export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) {
severities.forEach((severity) => { severities.forEach((severity) => {
console[severity] = () => null; console[severity] = () => null;
}); });

View File

@@ -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 = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) => {
function headers(token?: string, additional?: Record<string, string>) {
if (mode === "cookie") {
return {
cookie: `auth=${token};`,
...additional
};
}
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...additional
};
}
function body(obj?: Record<string, any>) {
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<Pick<AuthResponse, "user">> => {
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);
});
});
});

View File

@@ -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 { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, text } from "../../src/data";
import { AppAuth, type ModuleBuildContext } from "../../src/modules"; import { AppAuth, type ModuleBuildContext } from "../../src/modules";
import { disableConsoleLog, enableConsoleLog } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "../helper";
import { makeCtx, moduleTestSuite } from "./module-test-suite"; import { makeCtx, moduleTestSuite } from "./module-test-suite";
@@ -76,4 +78,53 @@ describe("AppAuth", () => {
expect(users[0].email).toBe("some@body.com"); 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"]);
});
}); });

View File

@@ -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 { AppMedia } from "../../src/modules";
import { moduleTestSuite } from "./module-test-suite"; import { moduleTestSuite } from "./module-test-suite";
describe("AppMedia", () => { describe("AppMedia", () => {
moduleTestSuite(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"
]);
});
}); });

View File

@@ -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 extends TSchema>(schema: Schema) {
class TestModule extends Module<typeof schema> {
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<typeof em>) {
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
}
]
});
});
});
});

View File

@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { mark, stripMark } from "../src/core/utils"; import { stripMark } from "../../src/core/utils";
import { entity, text } from "../src/data"; import { entity, text } from "../../src/data";
import { ModuleManager, getDefaultConfig } from "../src/modules/ModuleManager"; import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "../src/modules/migrations"; import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
import { getDummyConnection } from "./helper"; import { getDummyConnection } from "../helper";
describe("ModuleManager", async () => { describe("ModuleManager", async () => {
test("s1: no config, no build", async () => { test("s1: no config, no build", async () => {

View File

@@ -5,7 +5,7 @@ import { Guard } from "../../src/auth";
import { EventManager } from "../../src/core/events"; import { EventManager } from "../../src/core/events";
import { Default, stripMark } from "../../src/core/utils"; import { Default, stripMark } from "../../src/core/utils";
import { EntityManager } from "../../src/data"; 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"; import { getDummyConnection } from "../helper";
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext { export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
@@ -16,6 +16,7 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
em: new EntityManager([], dummyConnection), em: new EntityManager([], dummyConnection),
emgr: new EventManager(), emgr: new EventManager(),
guard: new Guard(), guard: new Guard(),
flags: Module.ctx_flags,
...overrides ...overrides
}; };
} }

View File

@@ -1,8 +1,5 @@
import { $ } from "bun"; import { $ } from "bun";
import * as esbuild from "esbuild";
import postcss from "esbuild-postcss";
import * as tsup from "tsup"; import * as tsup from "tsup";
import { guessMimeType } from "./src/media/storage/mime-types";
const args = process.argv.slice(2); const args = process.argv.slice(2);
const watch = args.includes("--watch"); const watch = args.includes("--watch");
@@ -12,8 +9,8 @@ const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean"); const clean = args.includes("--clean");
if (clean) { if (clean) {
console.log("Cleaning dist"); console.log("Cleaning dist (w/o static)");
await $`rm -rf dist`; await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
} }
let types_running = false; let types_running = false;
@@ -22,9 +19,11 @@ function buildTypes() {
types_running = true; types_running = true;
Bun.spawn(["bun", "build:types"], { Bun.spawn(["bun", "build:types"], {
stdout: "inherit",
onExit: () => { onExit: () => {
console.log("Types built"); console.log("Types built");
Bun.spawn(["bun", "tsc-alias"], { Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit",
onExit: () => { onExit: () => {
console.log("Types aliased"); console.log("Types aliased");
types_running = false; types_running = false;
@@ -36,7 +35,7 @@ function buildTypes() {
let watcher_timeout: any; let watcher_timeout: any;
function delayTypes() { function delayTypes() {
if (!watch) return; if (!watch || !types) return;
if (watcher_timeout) { if (watcher_timeout) {
clearTimeout(watcher_timeout); clearTimeout(watcher_timeout);
} }
@@ -47,67 +46,6 @@ if (types && !watch) {
buildTypes(); 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<string, object> = {};
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 * Building backend and general API
*/ */
@@ -120,7 +58,7 @@ await tsup.build({
external: ["bun:test", "@libsql/client"], external: ["bun:test", "@libsql/client"],
metafile: true, metafile: true,
platform: "browser", platform: "browser",
format: ["esm", "cjs"], format: ["esm"],
splitting: false, splitting: false,
treeshake: true, treeshake: true,
loader: { loader: {
@@ -138,12 +76,24 @@ await tsup.build({
minify, minify,
sourcemap, sourcemap,
watch, 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", 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, metafile: true,
platform: "browser", platform: "browser",
format: ["esm", "cjs"], format: ["esm"],
splitting: true, splitting: true,
treeshake: true, treeshake: true,
loader: { loader: {
@@ -166,7 +116,7 @@ function baseConfig(adapter: string): tsup.Options {
minify, minify,
sourcemap, sourcemap,
watch, watch,
entry: [`src/adapter/${adapter}`], entry: [`src/adapter/${adapter}/index.ts`],
format: ["esm"], format: ["esm"],
platform: "neutral", platform: "neutral",
outDir: `dist/adapter/${adapter}`, 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({ await tsup.build({
...baseConfig("vite"), ...baseConfig("vite"),
platform: "node" platform: "node"
}); });
await tsup.build({
...baseConfig("cloudflare")
});
await tsup.build({ await tsup.build({
...baseConfig("nextjs"), ...baseConfig("nextjs"),
format: ["esm", "cjs"],
platform: "node" platform: "node"
}); });
await tsup.build({
...baseConfig("remix"),
format: ["esm", "cjs"]
});
await tsup.build({
...baseConfig("bun")
});
await tsup.build({ await tsup.build({
...baseConfig("node"), ...baseConfig("node"),
platform: "node", platform: "node"
format: ["esm", "cjs"]
});
await tsup.build({
...baseConfig("astro"),
format: ["esm", "cjs"]
}); });

View File

@@ -3,22 +3,21 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.4.0", "version": "0.5.0",
"scripts": { "scripts": {
"build:all": "NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"dev": "vite", "dev": "vite",
"test": "ALL_TESTS=1 bun test --bail", "test": "ALL_TESTS=1 bun test --bail",
"build": "NODE_ENV=production bun run build.ts --minify --types", "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", "watch": "bun run build.ts --types --watch",
"types": "bun tsc --noEmit", "types": "bun tsc --noEmit",
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo", "clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
"build:types": "tsc --emitDeclarationOnly && tsc-alias", "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", "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", "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", "license": "FSL-1.1-MIT",
"dependencies": { "dependencies": {
@@ -34,7 +33,8 @@
"liquidjs": "^10.15.0", "liquidjs": "^10.15.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"swr": "^2.2.5" "swr": "^2.2.5",
"json-schema-form-react": "^0.0.2"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.613.0", "@aws-sdk/client-s3": "^3.613.0",
@@ -103,6 +103,11 @@
"import": "./dist/ui/index.js", "import": "./dist/ui/index.js",
"require": "./dist/ui/index.cjs" "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": { "./client": {
"types": "./dist/types/ui/client/index.d.ts", "types": "./dist/types/ui/client/index.d.ts",
"import": "./dist/ui/client/index.js", "import": "./dist/ui/client/index.js",
@@ -164,7 +169,7 @@
"require": "./dist/adapter/astro/index.cjs" "require": "./dist/adapter/astro/index.cjs"
}, },
"./dist/styles.css": "./dist/ui/main.css", "./dist/styles.css": "./dist/ui/main.css",
"./dist/manifest.json": "./dist/static/manifest.json" "./dist/manifest.json": "./dist/static/.vite/manifest.json"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"

View File

@@ -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 { Event } from "core/events";
import { patternMatch } from "core/utils";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import { import {
type InitialModuleConfigs, type InitialModuleConfigs,
@@ -68,6 +72,12 @@ export class App {
onFirstBoot: async () => { onFirstBoot: async () => {
console.log("[APP] first boot"); console.log("[APP] first boot");
this.trigger_first_boot = true; 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); this.modules.ctx().emgr.registerEvents(AppEvents);
@@ -87,20 +97,20 @@ export class App {
//console.log("syncing", syncResult); //console.log("syncing", syncResult);
} }
const { guard, server } = this.modules.ctx();
// load system controller // load system controller
this.modules.ctx().guard.registerPermissions(Object.values(SystemPermissions)); guard.registerPermissions(Object.values(SystemPermissions));
this.modules.server.route("/api/system", new SystemController(this).getController()); server.route("/api/system", new SystemController(this).getController());
// load plugins // load plugins
if (this.plugins.length > 0) { if (this.plugins.length > 0) {
await Promise.all(this.plugins.map((plugin) => plugin(this))); await Promise.all(this.plugins.map((plugin) => plugin(this)));
} }
//console.log("emitting built", options);
await this.emgr.emit(new AppBuiltEvent({ app: this })); await this.emgr.emit(new AppBuiltEvent({ app: this }));
// not found on any not registered api route server.all("/api/*", async (c) => c.notFound());
this.modules.server.all("/api/*", async (c) => c.notFound());
if (options?.save) { if (options?.save) {
await this.modules.save(); await this.modules.save();
@@ -121,6 +131,10 @@ export class App {
return this.modules.server; return this.modules.server;
} }
get em() {
return this.modules.ctx().em;
}
get fetch(): any { get fetch(): any {
return this.server.fetch; return this.server.fetch;
} }
@@ -147,7 +161,7 @@ export class App {
registerAdminController(config?: AdminControllerOptions) { registerAdminController(config?: AdminControllerOptions) {
// register admin // register admin
this.adminController = new AdminController(this, config); this.adminController = new AdminController(this, config);
this.modules.server.route("/", this.adminController.getController()); this.modules.server.route(config?.basepath ?? "/", this.adminController.getController());
return this; return this;
} }
@@ -158,6 +172,10 @@ export class App {
static create(config: CreateAppConfig) { static create(config: CreateAppConfig) {
return createApp(config); return createApp(config);
} }
async createUser(p: CreateUserPayload) {
return this.module.auth.createUser(p);
}
} }
export function createApp(config: CreateAppConfig = {}) { export function createApp(config: CreateAppConfig = {}) {

View File

@@ -11,13 +11,7 @@ let app: App;
export type BunBkndConfig = RuntimeBkndConfig & Omit<ServeOptions, "fetch">; export type BunBkndConfig = RuntimeBkndConfig & Omit<ServeOptions, "fetch">;
export async function createApp({ export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) {
distPath,
onBuilt,
buildConfig,
beforeBuild,
...config
}: RuntimeBkndConfig = {}) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
if (!app) { if (!app) {

View File

@@ -1,5 +1,6 @@
import type { IncomingMessage } from "node:http"; import type { IncomingMessage } from "node:http";
import { App, type CreateAppConfig, registries } from "bknd"; import { App, type CreateAppConfig, registries } from "bknd";
import { config as $config } from "core";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter"; import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter";
import type { AdminControllerOptions } from "modules/server/AdminController"; import type { AdminControllerOptions } from "modules/server/AdminController";
@@ -106,12 +107,10 @@ export async function createRuntimeApp<Env = any>(
App.Events.AppBuiltEvent, App.Events.AppBuiltEvent,
async () => { async () => {
if (serveStatic) { if (serveStatic) {
if (Array.isArray(serveStatic)) { const [path, handler] = Array.isArray(serveStatic)
const [path, handler] = serveStatic; ? serveStatic
: [$config.server.assets_path + "*", serveStatic];
app.modules.server.get(path, handler); app.modules.server.get(path, handler);
} else {
app.modules.server.get("/*", serveStatic);
}
} }
await config.onBuilt?.(app); await config.onBuilt?.(app);

View File

@@ -19,9 +19,6 @@ export function serve({
port = $config.server.default_port, port = $config.server.default_port,
hostname, hostname,
listener, listener,
onBuilt,
buildConfig = {},
beforeBuild,
...config ...config
}: NodeBkndConfig = {}) { }: NodeBkndConfig = {}) {
const root = path.relative( const root = path.relative(

View File

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

View File

@@ -1,10 +1,13 @@
import { serveStatic } from "@hono/node-server/serve-static"; 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 RuntimeBkndConfig, createRuntimeApp } from "adapter";
import type { App } from "bknd"; import type { App } from "bknd";
import { devServerConfig } from "./dev-server-config";
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & { export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
mode?: "cached" | "fresh";
setAdminHtml?: boolean; setAdminHtml?: boolean;
forceDev?: boolean; forceDev?: boolean | { mainPath: string };
html?: string; html?: string;
}; };
@@ -24,20 +27,27 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
); );
} }
async function createApp(config: ViteBkndConfig, env?: any) { async function createApp(config: ViteBkndConfig = {}, env?: any) {
return await createRuntimeApp( return await createRuntimeApp(
{ {
...config, ...config,
adminOptions: config.setAdminHtml registerLocalMedia: true,
? { html: config.html, forceDev: config.forceDev } adminOptions:
: undefined, config.setAdminHtml === false
? undefined
: {
html: config.html,
forceDev: config.forceDev ?? {
mainPath: "/src/main.tsx"
}
},
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })] serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })]
}, },
env env
); );
} }
export async function serveFresh(config: ViteBkndConfig) { export function serveFresh(config: Omit<ViteBkndConfig, "mode"> = {}) {
return { return {
async fetch(request: Request, env: any, ctx: ExecutionContext) { async fetch(request: Request, env: any, ctx: ExecutionContext) {
const app = await createApp(config, env); const app = await createApp(config, env);
@@ -47,7 +57,7 @@ export async function serveFresh(config: ViteBkndConfig) {
} }
let app: App; let app: App;
export async function serveCached(config: ViteBkndConfig) { export function serveCached(config: Omit<ViteBkndConfig, "mode"> = {}) {
return { return {
async fetch(request: Request, env: any, ctx: ExecutionContext) { async fetch(request: Request, env: any, ctx: ExecutionContext) {
if (!app) { 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
});
}

View File

@@ -1,9 +1,11 @@
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth"; import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
import type { PasswordStrategy } from "auth/authenticate/strategies"; 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 Static, secureRandomString, transformObject } from "core/utils";
import { type Entity, EntityIndex, type EntityManager } from "data"; 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 { pick } from "lodash-es";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { AuthController } from "./api/AuthController"; import { AuthController } from "./api/AuthController";
@@ -17,6 +19,7 @@ declare module "core" {
} }
type AuthSchema = Static<typeof authConfigSchema>; type AuthSchema = Static<typeof authConfigSchema>;
export type CreateUserPayload = { email: string; password: string; [key: string]: any };
export class AppAuth extends Module<typeof authConfigSchema> { export class AppAuth extends Module<typeof authConfigSchema> {
private _authenticator?: Authenticator; private _authenticator?: Authenticator;
@@ -36,8 +39,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return to; return to;
} }
get enabled() {
return this.config.enabled;
}
override async build() { override async build() {
if (!this.config.enabled) { if (!this.enabled) {
this.setBuilt(); this.setBuilt();
return; return;
} }
@@ -84,14 +91,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return this._controller; return this._controller;
} }
getMiddleware() {
if (!this.config.enabled) {
return;
}
return new AuthController(this).getMiddleware;
}
getSchema() { getSchema() {
return authConfigSchema; return authConfigSchema;
} }
@@ -111,12 +110,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
identifier: string, identifier: string,
profile: ProfileExchange profile: ProfileExchange
): Promise<any> { ): Promise<any> {
console.log("***** AppAuth:resolveUser", { /*console.log("***** AppAuth:resolveUser", {
action, action,
strategy: strategy.getName(), strategy: strategy.getName(),
identifier, identifier,
profile profile
}); });*/
if (!this.config.allow_register && action === "register") { if (!this.config.allow_register && action === "register") {
throw new Exception("Registration is not allowed", 403); throw new Exception("Registration is not allowed", 403);
} }
@@ -137,12 +136,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
} }
private filterUserData(user: any) { private filterUserData(user: any) {
console.log( /*console.log(
"--filterUserData", "--filterUserData",
user, user,
this.config.jwt.fields, this.config.jwt.fields,
pick(user, this.config.jwt.fields) pick(user, this.config.jwt.fields)
); );*/
return pick(user, this.config.jwt.fields); return pick(user, this.config.jwt.fields);
} }
@@ -168,18 +167,18 @@ export class AppAuth extends Module<typeof authConfigSchema> {
if (!result.data) { if (!result.data) {
throw new Exception("User not found", 404); 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 // 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()) { 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"); 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) { if (result.data.strategy_value !== identifier) {
console.log("!!! Invalid credentials"); //console.log("!!! Invalid credentials");
throw new Exception("Invalid credentials"); throw new Exception("Invalid credentials");
} }
@@ -247,51 +246,36 @@ export class AppAuth extends Module<typeof authConfigSchema> {
}; };
registerEntities() { registerEntities() {
const users = this.getUsersEntity(); const users = this.getUsersEntity(true);
this.ensureSchema(
if (!this.em.hasEntity(users.name)) { em(
this.em.addEntity(users); {
} else { [users.name as "users"]: users
// if exists, check all fields required are there },
// @todo: add to context: "needs sync" flag ({ index }, { users }) => {
const _entity = this.getUsersEntity(true); index(users).on(["email"], true).on(["strategy"]).on(["strategy_value"]);
for (const field of _entity.fields) {
const _field = users.field(field.name);
if (!_field) {
users.addField(field);
} }
} )
} );
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 { try {
const roles = Object.keys(this.config.roles ?? {}); const roles = Object.keys(this.config.roles ?? {});
const field = make("role", enumm({ enum: roles })); const field = make("role", enumm({ enum: roles }));
this.em.entity(users.name).__experimental_replaceField("role", field); users.__replaceField("role", field);
} catch (e) {} } catch (e) {}
try { try {
const strategies = Object.keys(this.config.strategies ?? {}); const strategies = Object.keys(this.config.strategies ?? {});
const field = make("strategy", enumm({ enum: strategies })); const field = make("strategy", enumm({ enum: strategies }));
this.em.entity(users.name).__experimental_replaceField("strategy", field); users.__replaceField("strategy", field);
} catch (e) {} } catch (e) {}
} }
async createUser({ async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> {
email, if (!this.enabled) {
password, throw new Error("Cannot create user, auth not enabled");
...additional }
}: { email: string; password: string; [key: string]: any }) {
const strategy = "password"; const strategy = "password";
const pw = this.authenticator.strategy(strategy) as PasswordStrategy; const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
const strategy_value = await pw.hash(password); const strategy_value = await pw.hash(password);

View File

@@ -1,42 +1,18 @@
import type { AppAuth } from "auth"; import type { AppAuth } from "auth";
import { type ClassController, isDebug } from "core"; import { Controller } from "modules/Controller";
import { Hono, type MiddlewareHandler } from "hono";
export class AuthController implements ClassController { export class AuthController extends Controller {
constructor(private auth: AppAuth) {} constructor(private auth: AppAuth) {
super();
}
get guard() { get guard() {
return this.auth.ctx.guard; return this.auth.ctx.guard;
} }
getMiddleware: MiddlewareHandler = async (c, next) => { override getController() {
// @todo: ONLY HOTFIX const { auth } = this.middlewares;
// middlewares are added for all routes are registered. But we need to make sure that const hono = this.create();
// 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<any> {
const hono = new Hono();
const strategies = this.auth.authenticator.getStrategies(); const strategies = this.auth.authenticator.getStrategies();
for (const [name, strategy] of Object.entries(strategies)) { 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.route(`/${name}`, strategy.getController(this.auth.authenticator));
} }
hono.get("/me", async (c) => { hono.get("/me", auth(), async (c) => {
if (this.auth.authenticator.isUserLoggedIn()) { if (this.auth.authenticator.isUserLoggedIn()) {
return c.json({ user: await this.auth.authenticator.getUser() }); return c.json({ user: await this.auth.authenticator.getUser() });
} }
@@ -52,7 +28,7 @@ export class AuthController implements ClassController {
return c.json({ user: null }, 403); return c.json({ user: null }, 403);
}); });
hono.get("/logout", async (c) => { hono.get("/logout", auth(), async (c) => {
await this.auth.authenticator.logout(c); await this.auth.authenticator.logout(c);
if (this.auth.authenticator.isJsonRequest(c)) { if (this.auth.authenticator.isJsonRequest(c)) {
return c.json({ ok: true }); return c.json({ ok: true });

View File

@@ -33,6 +33,7 @@ const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject)); const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
export type AppAuthStrategies = Static<typeof strategiesSchema>; export type AppAuthStrategies = Static<typeof strategiesSchema>;
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>; export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
export type AppAuthCustomOAuthStrategy = Static<typeof STRATEGIES.custom_oauth.schema>;
const guardConfigSchema = Type.Object({ const guardConfigSchema = Type.Object({
enabled: Type.Optional(Type.Boolean({ default: false })) enabled: Type.Optional(Type.Boolean({ default: false }))

View File

@@ -1,19 +1,11 @@
import { Exception } from "core"; import { Exception } from "core";
import { addFlashMessage } from "core/server/flash"; import { addFlashMessage } from "core/server/flash";
import { import { type Static, StringEnum, Type, parse, runtimeSupports, transformObject } from "core/utils";
type Static,
StringEnum,
type TSchema,
Type,
parse,
randomString,
transformObject
} from "core/utils";
import type { Context, Hono } from "hono"; import type { Context, Hono } from "hono";
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt"; import { sign, verify } from "hono/jwt";
import type { CookieOptions } from "hono/utils/cookie"; import type { CookieOptions } from "hono/utils/cookie";
import { omit } from "lodash-es"; import type { ServerEnv } from "modules/Module";
type Input = any; // workaround type Input = any; // workaround
export type JWTPayload = Parameters<typeof sign>[0]; export type JWTPayload = Parameters<typeof sign>[0];
@@ -67,6 +59,9 @@ export const cookieConfig = Type.Partial(
{ default: {}, additionalProperties: false } { 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( export const jwtConfig = Type.Object(
{ {
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth // @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
@@ -98,7 +93,13 @@ export type AuthUserResolver = (
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> { export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
private readonly strategies: Strategies; private readonly strategies: Strategies;
private readonly config: AuthConfig; private readonly config: AuthConfig;
private _user: SafeUser | undefined; private _claims:
| undefined
| (SafeUser & {
iat: number;
iss?: string;
exp?: number;
});
private readonly userResolver: AuthUserResolver; private readonly userResolver: AuthUserResolver;
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) { constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
@@ -131,16 +132,18 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
isUserLoggedIn(): boolean { isUserLoggedIn(): boolean {
return this._user !== undefined; return this._claims !== undefined;
} }
getUser() { getUser(): SafeUser | undefined {
return this._user; if (!this._claims) return;
const { iat, exp, iss, ...user } = this._claims;
return user;
} }
// @todo: determine what to do exactly resetUser() {
__setUserNull() { this._claims = undefined;
this._user = undefined;
} }
strategy< strategy<
@@ -154,6 +157,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
} }
// @todo: add jwt tests
async jwt(user: Omit<User, "password">): Promise<string> { async jwt(user: Omit<User, "password">): Promise<string> {
const prohibited = ["password"]; const prohibited = ["password"];
for (const prop of prohibited) { for (const prop of prohibited) {
@@ -200,11 +204,11 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
} }
this._user = omit(payload, ["iat", "exp", "iss"]) as SafeUser; this._claims = payload as any;
return true; return true;
} catch (e) { } catch (e) {
this._user = undefined; this.resetUser();
console.error(e); //console.error(e);
} }
return false; return false;
@@ -222,10 +226,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
private async getAuthCookie(c: Context): Promise<string | undefined> { private async getAuthCookie(c: Context): Promise<string | undefined> {
try { try {
const secret = this.config.jwt.secret; const secret = this.config.jwt.secret;
const token = await getSignedCookie(c, secret, "auth"); const token = await getSignedCookie(c, secret, "auth");
if (typeof token !== "string") { if (typeof token !== "string") {
await deleteCookie(c, "auth", this.cookieOptions);
return undefined; return undefined;
} }
@@ -243,23 +245,27 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
if (this.config.cookie.renew) { if (this.config.cookie.renew) {
const token = await this.getAuthCookie(c); const token = await this.getAuthCookie(c);
if (token) { if (token) {
console.log("renewing cookie", c.req.url);
await this.setAuthCookie(c, token); await this.setAuthCookie(c, token);
} }
} }
} }
private async setAuthCookie(c: Context, token: string) { private async setAuthCookie(c: Context<ServerEnv>, token: string) {
const secret = this.config.jwt.secret; const secret = this.config.jwt.secret;
await setSignedCookie(c, "auth", token, secret, this.cookieOptions); await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
} }
private async deleteAuthCookie(c: Context) {
await deleteCookie(c, "auth", this.cookieOptions);
}
async logout(c: Context) { async logout(c: Context) {
const cookie = await this.getAuthCookie(c); const cookie = await this.getAuthCookie(c);
if (cookie) { if (cookie) {
await deleteCookie(c, "auth", this.cookieOptions); await this.deleteAuthCookie(c);
await addFlashMessage(c, "Signed out", "info"); await addFlashMessage(c, "Signed out", "info");
} }
this.resetUser();
} }
// @todo: move this to a server helper // @todo: move this to a server helper
@@ -268,18 +274,31 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return c.req.header("Content-Type") === "application/json"; 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) { async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
if (this.isJsonRequest(c)) { if (this.isJsonRequest(c)) {
return c.json(data); return c.json(data);
} }
const successPath = this.config.cookie.pathSuccess ?? "/"; const successUrl = this.getSuccessPath(c);
const successUrl = new URL(c.req.url).origin + successPath.replace(/\/+$/, "/"); const referer = redirect ?? c.req.header("Referer") ?? successUrl;
const referer = new URL(redirect ?? c.req.header("Referer") ?? successUrl); //console.log("auth respond", { redirect, successUrl, successPath });
if ("token" in data) { if ("token" in data) {
await this.setAuthCookie(c, data.token); await this.setAuthCookie(c, data.token);
// can't navigate to "/" doesn't work on nextjs // can't navigate to "/" doesn't work on nextjs
//console.log("auth success, redirecting to", successUrl);
return c.redirect(successUrl); return c.redirect(successUrl);
} }
@@ -289,6 +308,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
await addFlashMessage(c, message, "error"); await addFlashMessage(c, message, "error");
//console.log("auth failed, redirecting to", referer);
return c.redirect(referer); return c.redirect(referer);
} }
@@ -304,7 +324,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
if (token) { if (token) {
await this.verify(token); await this.verify(token);
return this._user; return this.getUser();
} }
return undefined; return undefined;

View File

@@ -98,12 +98,16 @@ export class Guard {
if (this.user && typeof this.user.role === "string") { if (this.user && typeof this.user.role === "string") {
const role = this.roles?.find((role) => role.name === this.user?.role); const role = this.roles?.find((role) => role.name === this.user?.role);
if (role) { if (role) {
debug && console.log("guard: role found", this.user.role); debug && console.log("guard: role found", [this.user.role]);
return 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(); return this.getDefaultRole();
} }

105
app/src/auth/middlewares.ts Normal file
View File

@@ -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<ServerEnv>, 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<ServerEnv>(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<ServerEnv>) => Promise<Response | void | undefined>;
onDenied?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
}
) =>
// @ts-ignore
createMiddleware<ServerEnv>(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();
});

View File

@@ -1,5 +1,6 @@
import path from "node:path"; import path from "node:path";
import type { Config } from "@libsql/client/node"; import type { Config } from "@libsql/client/node";
import { config } from "core";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import open from "open"; import open from "open";
import { fileExists, getRelativeDistPath } from "../../utils/sys"; import { fileExists, getRelativeDistPath } from "../../utils/sys";
@@ -26,7 +27,7 @@ export async function serveStatic(server: Platform): Promise<MiddlewareHandler>
} }
export async function attachServeStatic(app: any, platform: Platform) { 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 }) { export async function startServer(server: Platform, app: any, options: { port: number }) {

View File

@@ -35,9 +35,11 @@ async function action(action: "create" | "update", options: any) {
} }
async function create(app: App, 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 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({ const email = await $text({
message: "Enter email", message: "Enter email",
@@ -65,16 +67,11 @@ async function create(app: App, options: any) {
} }
try { try {
const mutator = app.modules.ctx().em.mutator(users_entity); const created = await app.createUser({
mutator.__unstable_toggleSystemEntityCreation(false);
const res = await mutator.insertOne({
email, email,
strategy: "password", password: await strategy.hash(password as string)
strategy_value: await strategy.hash(password as string) })
}); console.log("Created:", created);
mutator.__unstable_toggleSystemEntityCreation(true);
console.log("Created:", res.data);
} catch (e) { } catch (e) {
console.error("Error", e); console.error("Error", e);
} }

View File

@@ -10,7 +10,9 @@ export interface DB {}
export const config = { export const config = {
server: { server: {
default_port: 1337 default_port: 1337,
// resetted to root for now, bc bundling with vite
assets_path: "/"
}, },
data: { data: {
default_primary_field: "id" default_primary_field: "id"

View File

@@ -1,6 +1,7 @@
export class Exception extends Error { export class Exception extends Error {
code = 400; code = 400;
override name = "Exception"; override name = "Exception";
protected _context = undefined;
constructor(message: string, code?: number) { constructor(message: string, code?: number) {
super(message); super(message);
@@ -9,11 +10,16 @@ export class Exception extends Error {
} }
} }
context(context: any) {
this._context = context;
return this;
}
toJSON() { toJSON() {
return { return {
error: this.message, error: this.message,
type: this.name type: this.name,
//message: this.message context: this._context
}; };
} }
} }

View File

@@ -4,15 +4,13 @@ import { setCookie } from "hono/cookie";
const flash_key = "__bknd_flash"; const flash_key = "__bknd_flash";
export type FlashMessageType = "error" | "warning" | "success" | "info"; export type FlashMessageType = "error" | "warning" | "success" | "info";
export async function addFlashMessage( export function addFlashMessage(c: Context, message: string, type: FlashMessageType = "info") {
c: Context, if (c.req.header("Accept")?.includes("text/html")) {
message: string,
type: FlashMessageType = "info"
) {
setCookie(c, flash_key, JSON.stringify({ type, message }), { setCookie(c, flash_key, JSON.stringify({ type, message }), {
path: "/" path: "/"
}); });
} }
}
function getCookieValue(name) { function getCookieValue(name) {
const cookies = document.cookie.split("; "); const cookies = document.cookie.split("; ");

View File

@@ -11,3 +11,4 @@ export * from "./crypto";
export * from "./uuid"; export * from "./uuid";
export { FromSchema } from "./typebox/from-schema"; export { FromSchema } from "./typebox/from-schema";
export * from "./test"; export * from "./test";
export * from "./runtime";

View File

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

View File

@@ -104,3 +104,14 @@ export function replaceSimplePlaceholders(str: string, vars: Record<string, any>
return key in vars ? vars[key] : match; 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;
}

View File

@@ -7,7 +7,7 @@ const _oldConsoles = {
export async function withDisabledConsole<R>( export async function withDisabledConsole<R>(
fn: () => Promise<R>, fn: () => Promise<R>,
severities: ConsoleSeverity[] = ["log"] severities: ConsoleSeverity[] = ["log", "warn", "error"]
): Promise<R> { ): Promise<R> {
const _oldConsoles = { const _oldConsoles = {
log: console.log, log: console.log,
@@ -30,7 +30,7 @@ export async function withDisabledConsole<R>(
} }
} }
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) { export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) {
severities.forEach((severity) => { severities.forEach((severity) => {
console[severity] = () => null; console[severity] = () => null;
}); });

View File

@@ -1,32 +1,26 @@
import { type ClassController, isDebug, tbValidator as tb } from "core"; import { isDebug, tbValidator as tb } from "core";
import { StringEnum, Type, objectCleanEmpty, objectTransform } from "core/utils"; import { StringEnum, Type } from "core/utils";
import { import {
DataPermissions, DataPermissions,
type EntityData, type EntityData,
type EntityManager, type EntityManager,
FieldClassMap,
type MutatorResponse, type MutatorResponse,
PrimaryField,
type RepoQuery, type RepoQuery,
type RepositoryResponse, type RepositoryResponse,
TextField,
querySchema querySchema
} from "data"; } from "data";
import { Hono } from "hono";
import type { Handler } from "hono/types"; import type { Handler } from "hono/types";
import type { ModuleBuildContext } from "modules"; import type { ModuleBuildContext } from "modules";
import { Controller } from "modules/Controller";
import * as SystemPermissions from "modules/permissions"; 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( constructor(
private readonly ctx: ModuleBuildContext, private readonly ctx: ModuleBuildContext,
private readonly config: AppDataConfig private readonly config: AppDataConfig
) { ) {
/*console.log( super();
"data controller",
this.em.entities.map((e) => e.name)
);*/
} }
get em(): EntityManager<any> { get em(): EntityManager<any> {
@@ -74,8 +68,10 @@ export class DataController implements ClassController {
} }
} }
getController(): Hono<any> { override getController() {
const hono = new Hono(); const { permission, auth } = this.middlewares;
const hono = this.create().use(auth());
const definedEntities = this.em.entities.map((e) => e.name); const definedEntities = this.em.entities.map((e) => e.name);
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" })) const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
.Decode(Number.parseInt) .Decode(Number.parseInt)
@@ -89,10 +85,7 @@ export class DataController implements ClassController {
return func; return func;
} }
hono.use("*", async (c, next) => { hono.use("*", permission(SystemPermissions.accessApi));
this.ctx.guard.throwUnlessGranted(SystemPermissions.accessApi);
await next();
});
// info // info
hono.get( hono.get(
@@ -104,9 +97,7 @@ export class DataController implements ClassController {
); );
// sync endpoint // sync endpoint
hono.get("/sync", async (c) => { hono.get("/sync", permission(DataPermissions.databaseSync), async (c) => {
this.guard.throwUnlessGranted(DataPermissions.databaseSync);
const force = c.req.query("force") === "1"; const force = c.req.query("force") === "1";
const drop = c.req.query("drop") === "1"; const drop = c.req.query("drop") === "1";
//console.log("force", force); //console.log("force", force);
@@ -126,10 +117,9 @@ export class DataController implements ClassController {
// fn: count // fn: count
.post( .post(
"/:entity/fn/count", "/:entity/fn/count",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })), tb("param", Type.Object({ entity: Type.String() })),
async (c) => { async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const { entity } = c.req.valid("param"); const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return c.notFound(); return c.notFound();
@@ -143,10 +133,9 @@ export class DataController implements ClassController {
// fn: exists // fn: exists
.post( .post(
"/:entity/fn/exists", "/:entity/fn/exists",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })), tb("param", Type.Object({ entity: Type.String() })),
async (c) => { async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const { entity } = c.req.valid("param"); const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return c.notFound(); return c.notFound();
@@ -163,8 +152,7 @@ export class DataController implements ClassController {
*/ */
hono hono
// read entity schema // read entity schema
.get("/schema.json", async (c) => { .get("/schema.json", permission(DataPermissions.entityRead), async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const $id = `${this.config.basepath}/schema.json`; const $id = `${this.config.basepath}/schema.json`;
const schemas = Object.fromEntries( const schemas = Object.fromEntries(
this.em.entities.map((e) => [ this.em.entities.map((e) => [
@@ -183,6 +171,7 @@ export class DataController implements ClassController {
// read schema // read schema
.get( .get(
"/schemas/:entity/:context?", "/schemas/:entity/:context?",
permission(DataPermissions.entityRead),
tb( tb(
"param", "param",
Type.Object({ Type.Object({
@@ -191,8 +180,6 @@ export class DataController implements ClassController {
}) })
), ),
async (c) => { async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
//console.log("request", c.req.raw); //console.log("request", c.req.raw);
const { entity, context } = c.req.param(); const { entity, context } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
@@ -216,11 +203,10 @@ export class DataController implements ClassController {
// read many // read many
.get( .get(
"/:entity", "/:entity",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })), tb("param", Type.Object({ entity: Type.String() })),
tb("query", querySchema), tb("query", querySchema),
async (c) => { async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
//console.log("request", c.req.raw); //console.log("request", c.req.raw);
const { entity } = c.req.param(); const { entity } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
@@ -238,6 +224,7 @@ export class DataController implements ClassController {
// read one // read one
.get( .get(
"/:entity/:id", "/:entity/:id",
permission(DataPermissions.entityRead),
tb( tb(
"param", "param",
Type.Object({ Type.Object({
@@ -246,11 +233,7 @@ export class DataController implements ClassController {
}) })
), ),
tb("query", querySchema), tb("query", querySchema),
/*zValidator("param", z.object({ entity: z.string(), id: z.coerce.number() })),
zValidator("query", repoQuerySchema),*/
async (c) => { async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const { entity, id } = c.req.param(); const { entity, id } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return c.notFound(); return c.notFound();
@@ -264,6 +247,7 @@ export class DataController implements ClassController {
// read many by reference // read many by reference
.get( .get(
"/:entity/:id/:reference", "/:entity/:id/:reference",
permission(DataPermissions.entityRead),
tb( tb(
"param", "param",
Type.Object({ Type.Object({
@@ -274,8 +258,6 @@ export class DataController implements ClassController {
), ),
tb("query", querySchema), tb("query", querySchema),
async (c) => { async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const { entity, id, reference } = c.req.param(); const { entity, id, reference } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return c.notFound(); return c.notFound();
@@ -292,11 +274,10 @@ export class DataController implements ClassController {
// func query // func query
.post( .post(
"/:entity/query", "/:entity/query",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })), tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema), tb("json", querySchema),
async (c) => { async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const { entity } = c.req.param(); const { entity } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return c.notFound(); return c.notFound();
@@ -314,9 +295,11 @@ export class DataController implements ClassController {
*/ */
// insert one // insert one
hono hono
.post("/:entity", tb("param", Type.Object({ entity: Type.String() })), async (c) => { .post(
this.guard.throwUnlessGranted(DataPermissions.entityCreate); "/:entity",
permission(DataPermissions.entityCreate),
tb("param", Type.Object({ entity: Type.String() })),
async (c) => {
const { entity } = c.req.param(); const { entity } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return c.notFound(); return c.notFound();
@@ -325,14 +308,14 @@ export class DataController implements ClassController {
const result = await this.em.mutator(entity).insertOne(body); const result = await this.em.mutator(entity).insertOne(body);
return c.json(this.mutatorResult(result), 201); return c.json(this.mutatorResult(result), 201);
}) }
)
// update one // update one
.patch( .patch(
"/:entity/:id", "/:entity/:id",
permission(DataPermissions.entityUpdate),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })), tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => { async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityUpdate);
const { entity, id } = c.req.param(); const { entity, id } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return c.notFound(); return c.notFound();
@@ -346,6 +329,8 @@ export class DataController implements ClassController {
// delete one // delete one
.delete( .delete(
"/:entity/:id", "/:entity/:id",
permission(DataPermissions.entityDelete),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })), tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => { async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityDelete); this.guard.throwUnlessGranted(DataPermissions.entityDelete);
@@ -363,11 +348,10 @@ export class DataController implements ClassController {
// delete many // delete many
.delete( .delete(
"/:entity", "/:entity",
permission(DataPermissions.entityDelete),
tb("param", Type.Object({ entity: Type.String() })), tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema.properties.where), tb("json", querySchema.properties.where),
async (c) => { async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
//console.log("request", c.req.raw); //console.log("request", c.req.raw);
const { entity } = c.req.param(); const { entity } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {

View File

@@ -140,7 +140,7 @@ export class Entity<
return this.fields.find((field) => field.name === name); 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); const index = this.fields.findIndex((f) => f.name === name);
if (index === -1) { if (index === -1) {
throw new Error(`Field "${name}" not found on entity "${this.name}"`); throw new Error(`Field "${name}" not found on entity "${this.name}"`);

View File

@@ -99,14 +99,24 @@ export class EntityManager<TBD extends object = DefaultDB> {
this.entities.push(entity); this.entities.push(entity);
} }
entity(e: Entity | keyof TBD | string): Entity { __replaceEntity(entity: Entity, name: string | undefined = entity.name) {
let entity: Entity | undefined; const entityIndex = this._entities.findIndex((e) => e.name === name);
if (typeof e === "string") {
entity = this.entities.find((entity) => entity.name === e); if (entityIndex === -1) {
} else if (e instanceof Entity) { throw new Error(`Entity "${name}" not found and cannot be replaced`);
entity = e;
} }
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) { if (!entity) {
// @ts-ignore // @ts-ignore
throw new EntityNotDefinedException(e instanceof Entity ? e.name : e); throw new EntityNotDefinedException(e instanceof Entity ? e.name : e);

View File

@@ -58,7 +58,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
} }
private cloneFor(entity: Entity) { private cloneFor(entity: Entity) {
return new Repository(this.em, entity, this.emgr); return new Repository(this.em, this.em.entity(entity), this.emgr);
} }
private get conn() { private get conn() {
@@ -94,7 +94,10 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
if (invalid.length > 0) { if (invalid.length > 0) {
throw new InvalidSearchParamsException( throw new InvalidSearchParamsException(
`Invalid select field(s): ${invalid.join(", ")}` `Invalid select field(s): ${invalid.join(", ")}`
); ).context({
entity: entity.name,
valid: validated.select
});
} }
validated.select = options.select; validated.select = options.select;

View File

@@ -1,4 +1,4 @@
import type { EntityData, Field } from "data"; import type { EntityData, EntityManager, Field } from "data";
import { transform } from "lodash-es"; import { transform } from "lodash-es";
export function getDefaultValues(fields: Field[], data: EntityData): EntityData { export function getDefaultValues(fields: Field[], data: EntityData): EntityData {
@@ -48,3 +48,23 @@ export function getChangeSet(
{} as typeof formData {} 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()
}))
};
}

View File

@@ -272,18 +272,22 @@ class EntityManagerPrototype<Entities extends Record<string, Entity>> extends En
} }
} }
type Chained<Fn extends (...args: any[]) => any, Rt = ReturnType<Fn>> = <E extends Entity>( type Chained<R extends Record<string, (...args: any[]) => any>> = {
e: E [K in keyof R]: R[K] extends (...args: any[]) => any
) => { ? (...args: Parameters<R[K]>) => Chained<R>
[K in keyof Rt]: Rt[K] extends (...args: any[]) => any
? (...args: Parameters<Rt[K]>) => Rt
: never; : never;
}; };
type ChainedFn<
Fn extends (...args: any[]) => Record<string, (...args: any[]) => any>,
Return extends ReturnType<Fn> = ReturnType<Fn>
> = (e: Entity) => {
[K in keyof Return]: (...args: Parameters<Return[K]>) => Chained<Return>;
};
export function em<Entities extends Record<string, Entity>>( export function em<Entities extends Record<string, Entity>>(
entities: Entities, entities: Entities,
schema?: ( schema?: (
fns: { relation: Chained<typeof relation>; index: Chained<typeof index> }, fns: { relation: ChainedFn<typeof relation>; index: ChainedFn<typeof index> },
entities: Entities entities: Entities
) => void ) => void
) { ) {

View File

@@ -6,7 +6,6 @@ import {
Type, Type,
Value Value
} from "core/utils"; } from "core/utils";
import type { Simplify } from "type-fest";
import { WhereBuilder } from "../entities"; import { WhereBuilder } from "../entities";
const NumberOrString = (options: SchemaOptions = {}) => const NumberOrString = (options: SchemaOptions = {}) =>
@@ -19,18 +18,26 @@ const limit = NumberOrString({ default: 10 });
const offset = NumberOrString({ default: 0 }); const offset = NumberOrString({ default: 0 });
// @todo: allow "id" and "-id" // @todo: allow "id" and "-id"
const sort_default = { by: "id", dir: "asc" };
const sort = Type.Transform( const sort = Type.Transform(
Type.Union( Type.Union(
[Type.String(), Type.Object({ by: Type.String(), dir: StringEnum(["asc", "desc"]) })], [Type.String(), Type.Object({ by: Type.String(), dir: StringEnum(["asc", "desc"]) })],
{ {
default: { by: "id", dir: "asc" } default: sort_default
} }
) )
) )
.Decode((value) => { .Decode((value) => {
if (typeof value === "string") { if (typeof value === "string") {
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 JSON.parse(value);
} }
return sort_default;
}
return value; return value;
}) })
.Encode(JSON.stringify); .Encode(JSON.stringify);

View File

@@ -9,6 +9,7 @@ export {
type ModuleBuildContext type ModuleBuildContext
} from "./modules/ModuleManager"; } from "./modules/ModuleManager";
export * as middlewares from "modules/middlewares";
export { registries } from "modules/registries"; export { registries } from "modules/registries";
export type * from "./adapter"; export type * from "./adapter";

View File

@@ -1,8 +1,17 @@
import type { PrimaryFieldType } from "core"; 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 { type FileUploadedEventData, Storage, type StorageAdapter } from "media";
import { Module } from "modules/Module"; 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 { MediaController } from "./api/MediaController";
import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema"; import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema";
@@ -38,18 +47,12 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
this.setupListeners(); this.setupListeners();
this.ctx.server.route(this.basepath, new MediaController(this).getController()); this.ctx.server.route(this.basepath, new MediaController(this).getController());
// @todo: add check for media entity const media = this.getMediaEntity(true);
const mediaEntity = this.getMediaEntity(); this.ensureSchema(
if (!this.ctx.em.hasEntity(mediaEntity)) { em({ [media.name as "media"]: media }, ({ index }, { media }) => {
this.ctx.em.addEntity(mediaEntity); index(media).on(["path"], true).on(["reference"]);
} })
);
const pathIndex = new EntityIndex(mediaEntity, [mediaEntity.field("path")!], true);
if (!this.ctx.em.hasIndex(pathIndex)) {
this.ctx.em.addIndex(pathIndex);
}
// @todo: check indices
} catch (e) { } catch (e) {
console.error(e); console.error(e);
throw new Error( throw new Error(
@@ -94,13 +97,13 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
metadata: json() metadata: json()
}; };
getMediaEntity() { getMediaEntity(forceCreate?: boolean): Entity<"media", typeof AppMedia.mediaFields> {
const entity_name = this.config.entity_name; const entity_name = this.config.entity_name;
if (!this.em.hasEntity(entity_name)) { if (forceCreate || !this.em.hasEntity(entity_name)) {
return entity(entity_name, AppMedia.mediaFields, undefined, "system"); 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 { get em(): EntityManager {

View File

@@ -1,10 +1,9 @@
import { type ClassController, tbValidator as tb } from "core"; import { tbValidator as tb } from "core";
import { Type } from "core/utils"; import { Type } from "core/utils";
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit"; import { bodyLimit } from "hono/body-limit";
import type { StorageAdapter } from "media"; import type { StorageAdapter } from "media";
import { StorageEvents } from "media"; import { StorageEvents, getRandomizedFilename } from "media";
import { getRandomizedFilename } from "media"; import { Controller } from "modules/Controller";
import type { AppMedia } from "../AppMedia"; import type { AppMedia } from "../AppMedia";
import { MediaField } from "../MediaField"; import { MediaField } from "../MediaField";
@@ -12,8 +11,10 @@ const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1") .Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0")); .Encode((v) => (v ? "1" : "0"));
export class MediaController implements ClassController { export class MediaController extends Controller {
constructor(private readonly media: AppMedia) {} constructor(private readonly media: AppMedia) {
super();
}
private getStorageAdapter(): StorageAdapter { private getStorageAdapter(): StorageAdapter {
return this.getStorage().getAdapter(); return this.getStorage().getAdapter();
@@ -23,11 +24,11 @@ export class MediaController implements ClassController {
return this.media.storage; return this.media.storage;
} }
getController(): Hono<any> { override getController() {
// @todo: multiple providers? // @todo: multiple providers?
// @todo: implement range requests // @todo: implement range requests
const { auth } = this.middlewares;
const hono = new Hono(); const hono = this.create().use(auth());
// get files list (temporary) // get files list (temporary)
hono.get("/files", async (c) => { hono.get("/files", async (c) => {
@@ -107,7 +108,7 @@ export class MediaController implements ClassController {
return c.json({ error: `Invalid field "${field_name}"` }, 400); 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 reference = `${entity_name}.${field_name}`;
const mediaRef = { const mediaRef = {
scope: field_name, scope: field_name,
@@ -117,11 +118,10 @@ export class MediaController implements ClassController {
// check max items // check max items
const max_items = field.getMaxItems(); const max_items = field.getMaxItems();
const ids_to_delete: number[] = []; const paths_to_delete: string[] = [];
const id_field = mediaEntity.getPrimaryField().name;
if (max_items) { if (max_items) {
const { overwrite } = c.req.valid("query"); 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 there are more than or equal to max items
if (count >= max_items) { if (count >= max_items) {
@@ -140,18 +140,18 @@ export class MediaController implements ClassController {
} }
// collect items to delete // collect items to delete
const deleteRes = await this.media.em.repo(mediaEntity).findMany({ const deleteRes = await this.media.em.repo(media_entity).findMany({
select: [id_field], select: ["path"],
where: mediaRef, where: mediaRef,
sort: { sort: {
by: id_field, by: "id",
dir: "asc" dir: "asc"
}, },
limit: count - max_items + 1 limit: count - max_items + 1
}); });
if (deleteRes.data && deleteRes.data.length > 0) { 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 file_name = getRandomizedFilename(file as File);
const info = await this.getStorage().uploadFile(file, file_name, true); 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); mutator.__unstable_toggleSystemEntityCreation(false);
const result = await mutator.insertOne({ const result = await mutator.insertOne({
...this.media.uploadedEventDataToMediaPayload(info), ...this.media.uploadedEventDataToMediaPayload(info),
@@ -178,10 +178,11 @@ export class MediaController implements ClassController {
mutator.__unstable_toggleSystemEntityCreation(true); mutator.__unstable_toggleSystemEntityCreation(true);
// delete items if needed // delete items if needed
if (ids_to_delete.length > 0) { if (paths_to_delete.length > 0) {
await this.media.em // delete files from db & adapter
.mutator(mediaEntity) for (const path of paths_to_delete) {
.deleteWhere({ [id_field]: { $in: ids_to_delete } }); await this.getStorage().deleteFile(path);
}
} }
return c.json({ ok: true, result: result.data, ...info }); return c.json({ ok: true, result: result.data, ...info });

View File

@@ -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 { Adapters } from "media";
import { registries } from "modules/registries"; import { registries } from "modules/registries";
@@ -47,3 +47,4 @@ export function buildMediaSchema() {
} }
export const mediaConfigSchema = buildMediaSchema(); export const mediaConfigSchema = buildMediaSchema();
export type TAppMediaConfig = Static<typeof mediaConfigSchema>;

View File

@@ -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<ServerEnv> {
return Controller.createServer();
}
static createServer(): Hono<ServerEnv> {
return new Hono<ServerEnv>();
}
getController(): Hono<ServerEnv> {
return this.create();
}
}

View File

@@ -1,16 +1,32 @@
import type { App } from "App";
import type { Guard } from "auth"; import type { Guard } from "auth";
import { SchemaObject } from "core"; import { SchemaObject } from "core";
import type { EventManager } from "core/events"; import type { EventManager } from "core/events";
import type { Static, TSchema } from "core/utils"; 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"; 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 = { export type ModuleBuildContext = {
connection: Connection; connection: Connection;
server: Hono<any>; server: Hono<ServerEnv>;
em: EntityManager; em: EntityManager;
emgr: EventManager<any>; emgr: EventManager<any>;
guard: Guard; guard: Guard;
flags: (typeof Module)["ctx_flags"];
}; };
export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = Static<Schema>> { export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = Static<Schema>> {
@@ -33,6 +49,15 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
}); });
} }
static ctx_flags = {
sync_required: false,
ctx_reload_required: false
} as {
// signal that a sync is required at the end of build
sync_required: boolean;
ctx_reload_required: boolean;
};
onBeforeUpdate(from: ConfigSchema, to: ConfigSchema): ConfigSchema | Promise<ConfigSchema> { onBeforeUpdate(from: ConfigSchema, to: ConfigSchema): ConfigSchema | Promise<ConfigSchema> {
return to; return to;
} }
@@ -78,6 +103,10 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
return this._schema; return this._schema;
} }
// action performed when server has been initialized
// can be used to assign global middlewares
onServerInit(hono: Hono<ServerEnv>) {}
get ctx() { get ctx() {
if (!this._ctx) { if (!this._ctx) {
throw new Error("Context not set"); throw new Error("Context not set");
@@ -115,4 +144,44 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
toJSON(secrets?: boolean): Static<ReturnType<(typeof this)["getSchema"]>> { toJSON(secrets?: boolean): Static<ReturnType<(typeof this)["getSchema"]>> {
return this.config; 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 extends ReturnType<typeof prototypeEm>>(schema: Schema): Schema {
Object.values(schema.entities ?? {}).forEach(this.ensureEntity.bind(this));
schema.indices?.forEach(this.ensureIndex.bind(this));
return schema;
}
} }

View File

@@ -33,7 +33,7 @@ import { AppAuth } from "../auth/AppAuth";
import { AppData } from "../data/AppData"; import { AppData } from "../data/AppData";
import { AppFlows } from "../flows/AppFlows"; import { AppFlows } from "../flows/AppFlows";
import { AppMedia } from "../media/AppMedia"; import { AppMedia } from "../media/AppMedia";
import type { Module, ModuleBuildContext } from "./Module"; import { Module, type ModuleBuildContext, type ServerEnv } from "./Module";
export type { ModuleBuildContext }; export type { ModuleBuildContext };
@@ -79,6 +79,8 @@ export type ModuleManagerOptions = {
onFirstBoot?: () => Promise<void>; onFirstBoot?: () => Promise<void>;
// base path for the hono instance // base path for the hono instance
basePath?: string; basePath?: string;
// callback after server was created
onServerInit?: (server: Hono<ServerEnv>) => void;
// doesn't perform validity checks for given/fetched config // doesn't perform validity checks for given/fetched config
trustFetched?: boolean; trustFetched?: boolean;
// runs when initial config provided on a fresh database // runs when initial config provided on a fresh database
@@ -124,15 +126,12 @@ export class ModuleManager {
__em!: EntityManager<T_INTERNAL_EM>; __em!: EntityManager<T_INTERNAL_EM>;
// ctx for modules // ctx for modules
em!: EntityManager; em!: EntityManager;
server!: Hono; server!: Hono<ServerEnv>;
emgr!: EventManager; emgr!: EventManager;
guard!: Guard; guard!: Guard;
private _version: number = 0; private _version: number = 0;
private _built = false; private _built = false;
private _fetched = false;
// @todo: keep? not doing anything with it
private readonly _booted_with?: "provided" | "partial"; private readonly _booted_with?: "provided" | "partial";
private logger = new DebugLogger(false); private logger = new DebugLogger(false);
@@ -204,19 +203,17 @@ export class ModuleManager {
} }
private rebuildServer() { private rebuildServer() {
this.server = new Hono(); this.server = new Hono<ServerEnv>();
if (this.options?.basePath) { if (this.options?.basePath) {
this.server = this.server.basePath(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) => { objectEach(this.modules, (module) => {
if ("getMiddleware" in module) { module.onServerInit(this.server);
const middleware = module.getMiddleware();
if (middleware) {
this.server.use(middleware);
}
}
}); });
} }
@@ -232,7 +229,8 @@ export class ModuleManager {
server: this.server, server: this.server,
em: this.em, em: this.em,
emgr: this.emgr, 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 }) { private async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) {
this.logger.log("buildModules() triggered", options?.graceful, this._built); this.logger.log("buildModules() triggered", options, this._built);
if (options?.graceful && this._built) { if (options?.graceful && this._built) {
this.logger.log("skipping build (graceful)"); this.logger.log("skipping build (graceful)");
return; return;
@@ -417,7 +415,27 @@ export class ModuleManager {
} }
this._built = true; 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() { async build() {

View File

@@ -11,7 +11,7 @@ export {
MODULE_NAMES, MODULE_NAMES,
type ModuleKey type ModuleKey
} from "./ModuleManager"; } from "./ModuleManager";
export { /*Module,*/ type ModuleBuildContext } from "./Module"; export type { ModuleBuildContext } from "./Module";
export { export {
type PrimaryFieldType, type PrimaryFieldType,

View File

@@ -0,0 +1 @@
export { auth, permission } from "auth/middlewares";

View File

@@ -1,11 +1,11 @@
/** @jsxImportSource hono/jsx */ /** @jsxImportSource hono/jsx */
import type { App } from "App"; import type { App } from "App";
import { type ClassController, isDebug } from "core"; import { config, isDebug } from "core";
import { addFlashMessage } from "core/server/flash"; import { addFlashMessage } from "core/server/flash";
import { Hono } from "hono";
import { html } from "hono/html"; import { html } from "hono/html";
import { Fragment } from "hono/jsx"; import { Fragment } from "hono/jsx";
import { Controller } from "modules/Controller";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->"; const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
@@ -13,38 +13,52 @@ const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
// @todo: add migration to remove admin path from config // @todo: add migration to remove admin path from config
export type AdminControllerOptions = { export type AdminControllerOptions = {
basepath?: string; basepath?: string;
assets_path?: string;
html?: string; html?: string;
forceDev?: boolean | { mainPath: string }; forceDev?: boolean | { mainPath: string };
}; };
export class AdminController implements ClassController { export class AdminController extends Controller {
constructor( constructor(
private readonly app: App, private readonly app: App,
private options: AdminControllerOptions = {} private _options: AdminControllerOptions = {}
) {} ) {
super();
}
get ctx() { get ctx() {
return this.app.modules.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() { get basepath() {
return this.options.basepath ?? "/"; return this.options.basepath ?? "/";
} }
private withBasePath(route: string = "") { private withBasePath(route: string = "") {
return (this.basepath + route).replace(/\/+$/, "/"); return (this.basepath + route).replace(/(?<!:)\/+/g, "/");
} }
getController(): Hono<any> { override getController() {
const { auth: authMiddleware, permission } = this.middlewares;
const hono = this.create().use(
authMiddleware({
//skip: [/favicon\.ico$/]
})
);
const auth = this.app.module.auth; const auth = this.app.module.auth;
const configs = this.app.modules.configs(); const configs = this.app.modules.configs();
// if auth is not enabled, authenticator is undefined // if auth is not enabled, authenticator is undefined
const auth_enabled = configs.auth.enabled; const auth_enabled = configs.auth.enabled;
const hono = new Hono<{
Variables: {
html: string;
};
}>().basePath(this.withBasePath());
const authRoutes = { const authRoutes = {
root: "/", root: "/",
success: configs.auth.cookie.pathSuccess ?? "/", success: configs.auth.cookie.pathSuccess ?? "/",
@@ -66,23 +80,26 @@ export class AdminController implements ClassController {
} }
c.set("html", html); c.set("html", html);
// refresh cookie if needed
await auth.authenticator?.requestCookieRefresh(c);
await next(); await next();
}); });
if (auth_enabled) { if (auth_enabled) {
hono.get(authRoutes.login, async (c) => { hono.get(
if ( authRoutes.login,
this.app.module.auth.authenticator?.isUserLoggedIn() && permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
this.ctx.guard.granted(SystemPermissions.accessAdmin) // @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); return c.redirect(authRoutes.success);
} }
}
const html = c.get("html"); }),
return c.html(html); async (c) => {
}); return c.html(c.get("html")!);
}
);
hono.get(authRoutes.logout, async (c) => { hono.get(authRoutes.logout, async (c) => {
await auth.authenticator?.logout(c); await auth.authenticator?.logout(c);
@@ -90,15 +107,26 @@ export class AdminController implements ClassController {
}); });
} }
hono.get("*", async (c) => { // @todo: only load known paths
if (!this.ctx.guard.granted(SystemPermissions.accessAdmin)) { hono.get(
await addFlashMessage(c, "You are not authorized to access the Admin UI", "error"); "/*",
permission(SystemPermissions.accessAdmin, {
onDenied: async (c) => {
addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
console.log("redirecting");
return c.redirect(authRoutes.login); return c.redirect(authRoutes.login);
} }
}),
const html = c.get("html"); permission(SystemPermissions.schemaRead, {
return c.html(html); onDenied: async (c) => {
}); addFlashMessage(c, "You not allowed to read the schema", "warning");
}
}),
async (c) => {
return c.html(c.get("html")!);
}
);
return hono; return hono;
} }
@@ -138,29 +166,42 @@ export class AdminController implements ClassController {
const manifest = await import("bknd/dist/manifest.json", { const manifest = await import("bknd/dist/manifest.json", {
assert: { type: "json" } assert: { type: "json" }
}).then((m) => m.default); }).then((m) => m.default);
assets.js = manifest["src/ui/main.tsx"].name; // @todo: load all marked as entry (incl. css)
assets.css = manifest["src/ui/main.css"].name; assets.js = manifest["src/ui/main.tsx"].file;
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
} catch (e) { } catch (e) {
console.error("Error loading manifest", 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 ( return (
<Fragment> <Fragment>
{/* dnd complains otherwise */} {/* dnd complains otherwise */}
{html`<!DOCTYPE html>`} {html`<!DOCTYPE html>`}
<html lang="en" class={configs.server.admin.color_scheme ?? "light"}> <html lang="en" class={theme}>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1" content="width=device-width, initial-scale=1, maximum-scale=1"
/> />
<link rel="icon" href={favicon} type="image/x-icon" />
<title>BKND</title> <title>BKND</title>
{isProd ? ( {isProd ? (
<Fragment> <Fragment>
<script type="module" CrossOrigin src={"/" + assets?.js} /> <script
<link rel="stylesheet" crossOrigin href={"/" + assets?.css} /> type="module"
CrossOrigin
src={this.options.assets_path + assets?.js}
/>
<link
rel="stylesheet"
crossOrigin
href={this.options.assets_path + assets?.css}
/>
</Fragment> </Fragment>
) : ( ) : (
<Fragment> <Fragment>
@@ -177,10 +218,16 @@ export class AdminController implements ClassController {
<script type="module" src={"/@vite/client"} /> <script type="module" src={"/@vite/client"} />
</Fragment> </Fragment>
)} )}
<style dangerouslySetInnerHTML={{ __html: "body { margin: 0; padding: 0; }" }} />
</head> </head>
<body> <body>
<div id="root" /> <div id="root">
<div id="app" /> <div id="loading" style={style(theme)}>
<span style={{ opacity: 0.3, fontSize: 14, fontFamily: "monospace" }}>
Initializing...
</span>
</div>
</div>
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: bknd_context __html: bknd_context
@@ -193,3 +240,32 @@ export class AdminController implements ClassController {
); );
} }
} }
const style = (theme: "light" | "dark" = "light") => {
const base = {
margin: 0,
padding: 0,
height: "100vh",
width: "100vw",
display: "flex",
justifyContent: "center",
alignItems: "center",
"-webkit-font-smoothing": "antialiased",
"-moz-osx-font-smoothing": "grayscale"
};
const styles = {
light: {
color: "rgb(9,9,11)",
backgroundColor: "rgb(250,250,250)"
},
dark: {
color: "rgb(250,250,250)",
backgroundColor: "rgb(30,31,34)"
}
};
return {
...base,
...styles[theme]
};
};

View File

@@ -1,10 +1,12 @@
/// <reference types="@cloudflare/workers-types" /> /// <reference types="@cloudflare/workers-types" />
import type { App } from "App"; import type { App } from "App";
import type { ClassController } from "core";
import { tbValidator as tb } from "core"; import { tbValidator as tb } from "core";
import { StringEnum, Type, TypeInvalidError } from "core/utils"; import { StringEnum, Type, TypeInvalidError } from "core/utils";
import { type Context, Hono } from "hono"; import { getRuntimeKey } from "core/utils";
import type { Context, Hono } from "hono";
import { Controller } from "modules/Controller";
import { import {
MODULE_NAMES, MODULE_NAMES,
type ModuleConfigs, type ModuleConfigs,
@@ -27,21 +29,20 @@ export type ConfigUpdateResponse<Key extends ModuleKey = ModuleKey> =
| ConfigUpdate<Key> | ConfigUpdate<Key>
| { success: false; type: "type-invalid" | "error" | "unknown"; error?: any; errors?: any }; | { success: false; type: "type-invalid" | "error" | "unknown"; error?: any; errors?: any };
export class SystemController implements ClassController { export class SystemController extends Controller {
constructor(private readonly app: App) {} constructor(private readonly app: App) {
super();
}
get ctx() { get ctx() {
return this.app.modules.ctx(); return this.app.modules.ctx();
} }
private registerConfigController(client: Hono<any>): void { private registerConfigController(client: Hono<any>): void {
const hono = new Hono(); const { permission } = this.middlewares;
const hono = this.create();
/*hono.use("*", async (c, next) => { hono.use(permission(SystemPermissions.configRead));
//this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead);
console.log("perm?", this.ctx.guard.hasPermission(SystemPermissions.configRead));
return next();
});*/
hono.get( hono.get(
"/:module?", "/:module?",
@@ -57,7 +58,6 @@ export class SystemController implements ClassController {
const { secrets } = c.req.valid("query"); const { secrets } = c.req.valid("query");
const { module } = c.req.valid("param"); const { module } = c.req.valid("param");
this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead);
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets); secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets);
const config = this.app.toJSON(secrets); const config = this.app.toJSON(secrets);
@@ -96,6 +96,7 @@ export class SystemController implements ClassController {
hono.post( hono.post(
"/set/:module", "/set/:module",
permission(SystemPermissions.configWrite),
tb( tb(
"query", "query",
Type.Object({ Type.Object({
@@ -107,8 +108,6 @@ export class SystemController implements ClassController {
const { force } = c.req.valid("query"); const { force } = c.req.valid("query");
const value = await c.req.json(); const value = await c.req.json();
this.ctx.guard.throwUnlessGranted(SystemPermissions.configWrite);
return await handleConfigUpdateResponse(c, async () => { return await handleConfigUpdateResponse(c, async () => {
// you must explicitly set force to override existing values // you must explicitly set force to override existing values
// because omitted values gets removed // because omitted values gets removed
@@ -131,14 +130,12 @@ export class SystemController implements ClassController {
} }
); );
hono.post("/add/:module/:path", async (c) => { hono.post("/add/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
// @todo: require auth (admin) // @todo: require auth (admin)
const module = c.req.param("module") as any; const module = c.req.param("module") as any;
const value = await c.req.json(); const value = await c.req.json();
const path = c.req.param("path") as string; const path = c.req.param("path") as string;
this.ctx.guard.throwUnlessGranted(SystemPermissions.configWrite);
const moduleConfig = this.app.mutateConfig(module); const moduleConfig = this.app.mutateConfig(module);
if (moduleConfig.has(path)) { if (moduleConfig.has(path)) {
return c.json({ success: false, path, error: "Path already exists" }, { status: 400 }); return c.json({ success: false, path, error: "Path already exists" }, { status: 400 });
@@ -155,14 +152,12 @@ export class SystemController implements ClassController {
}); });
}); });
hono.patch("/patch/:module/:path", async (c) => { hono.patch("/patch/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
// @todo: require auth (admin) // @todo: require auth (admin)
const module = c.req.param("module") as any; const module = c.req.param("module") as any;
const value = await c.req.json(); const value = await c.req.json();
const path = c.req.param("path"); const path = c.req.param("path");
this.ctx.guard.throwUnlessGranted(SystemPermissions.configWrite);
return await handleConfigUpdateResponse(c, async () => { return await handleConfigUpdateResponse(c, async () => {
await this.app.mutateConfig(module).patch(path, value); await this.app.mutateConfig(module).patch(path, value);
return { return {
@@ -173,14 +168,12 @@ export class SystemController implements ClassController {
}); });
}); });
hono.put("/overwrite/:module/:path", async (c) => { hono.put("/overwrite/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
// @todo: require auth (admin) // @todo: require auth (admin)
const module = c.req.param("module") as any; const module = c.req.param("module") as any;
const value = await c.req.json(); const value = await c.req.json();
const path = c.req.param("path"); const path = c.req.param("path");
this.ctx.guard.throwUnlessGranted(SystemPermissions.configWrite);
return await handleConfigUpdateResponse(c, async () => { return await handleConfigUpdateResponse(c, async () => {
await this.app.mutateConfig(module).overwrite(path, value); await this.app.mutateConfig(module).overwrite(path, value);
return { return {
@@ -191,13 +184,11 @@ export class SystemController implements ClassController {
}); });
}); });
hono.delete("/remove/:module/:path", async (c) => { hono.delete("/remove/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
// @todo: require auth (admin) // @todo: require auth (admin)
const module = c.req.param("module") as any; const module = c.req.param("module") as any;
const path = c.req.param("path")!; const path = c.req.param("path")!;
this.ctx.guard.throwUnlessGranted(SystemPermissions.configWrite);
return await handleConfigUpdateResponse(c, async () => { return await handleConfigUpdateResponse(c, async () => {
await this.app.mutateConfig(module).remove(path); await this.app.mutateConfig(module).remove(path);
return { return {
@@ -211,13 +202,15 @@ export class SystemController implements ClassController {
client.route("/config", hono); client.route("/config", hono);
} }
getController(): Hono { override getController() {
const hono = new Hono(); const { permission, auth } = this.middlewares;
const hono = this.create().use(auth());
this.registerConfigController(hono); this.registerConfigController(hono);
hono.get( hono.get(
"/schema/:module?", "/schema/:module?",
permission(SystemPermissions.schemaRead),
tb( tb(
"query", "query",
Type.Object({ Type.Object({
@@ -228,7 +221,7 @@ export class SystemController implements ClassController {
async (c) => { async (c) => {
const module = c.req.param("module") as ModuleKey | undefined; const module = c.req.param("module") as ModuleKey | undefined;
const { config, secrets } = c.req.valid("query"); const { config, secrets } = c.req.valid("query");
this.ctx.guard.throwUnlessGranted(SystemPermissions.schemaRead);
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead); config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead);
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets); secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets);
@@ -300,8 +293,8 @@ export class SystemController implements ClassController {
return c.json({ return c.json({
version: this.app.version(), version: this.app.version(),
test: 2, test: 2,
// @ts-ignore app: c.get("app")?.version(),
app: !!c.var.app runtime: getRuntimeKey()
}); });
}); });

View File

@@ -54,16 +54,19 @@ function AdminInternal() {
); );
} }
const Skeleton = ({ theme = "light" }: { theme?: string }) => { const Skeleton = ({ theme }: { theme?: string }) => {
const actualTheme =
(theme ?? document.querySelector("html")?.classList.contains("light")) ? "light" : "dark";
return ( return (
<div id="bknd-admin" className={(theme ?? "light") + " antialiased"}> <div id="bknd-admin" className={actualTheme + " antialiased"}>
<AppShell.Root> <AppShell.Root>
<header <header
data-shell="header" data-shell="header"
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10" className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
> >
<div className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none"> <div className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none">
<Logo theme={theme} /> <Logo theme={actualTheme} />
</div> </div>
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center"> <nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
{[...new Array(5)].map((item, key) => ( {[...new Array(5)].map((item, key) => (
@@ -84,7 +87,7 @@ const Skeleton = ({ theme = "light" }: { theme?: string }) => {
</header> </header>
<AppShell.Content> <AppShell.Content>
<div className="flex flex-col w-full h-full justify-center items-center"> <div className="flex flex-col w-full h-full justify-center items-center">
<span className="font-mono opacity-30">Loading</span> {/*<span className="font-mono opacity-30">Loading</span>*/}
</div> </div>
</AppShell.Content> </AppShell.Content>
</AppShell.Root> </AppShell.Root>

View File

@@ -143,6 +143,8 @@ export const useEntityQuery = <
return { return {
...swr, ...swr,
...mapped, ...mapped,
mutate: mutateAll,
mutateRaw: swr.mutate,
api, api,
key key
}; };

View File

@@ -125,12 +125,18 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
</thead> </thead>
) : null} ) : null}
<tbody> <tbody>
{!data || data.length === 0 ? ( {!data || !Array.isArray(data) || data.length === 0 ? (
<tr> <tr>
<td colSpan={select.length + (checkable ? 1 : 0)}> <td colSpan={select.length + (checkable ? 1 : 0)}>
<div className="flex flex-col gap-2 p-8 justify-center items-center border-t border-muted"> <div className="flex flex-col gap-2 p-8 justify-center items-center border-t border-muted">
<i className="opacity-50"> <i className="opacity-50">
{Array.isArray(data) ? "No data to show" : "Loading..."} {Array.isArray(data) ? (
"No data to show"
) : !data ? (
"Loading..."
) : (
<pre>{JSON.stringify(data, null, 2)}</pre>
)}
</i> </i>
</div> </div>
</td> </td>

View File

@@ -45,8 +45,9 @@ const useLocationFromRouter = (router) => {
export function Link({ export function Link({
className, className,
native, native,
onClick,
...props ...props
}: { className?: string; native?: boolean } & LinkProps) { }: { className?: string; native?: boolean; transition?: boolean } & LinkProps) {
const router = useRouter(); const router = useRouter();
const [path, navigate] = useLocationFromRouter(router); const [path, navigate] = useLocationFromRouter(router);
@@ -69,17 +70,28 @@ export function Link({
const absPath = absolutePath(path, router.base).replace("//", "/"); const absPath = absolutePath(path, router.base).replace("//", "/");
const active = const active =
href.replace(router.base, "").length <= 1 ? href === absPath : isActive(absPath, href); href.replace(router.base, "").length <= 1 ? href === absPath : isActive(absPath, href);
const a = useRoute(_href);
/*if (active) {
console.log("link", { a, path, absPath, href, to, active, router });
}*/
if (native) { if (native) {
return <a className={`${active ? "active " : ""}${className}`} {...props} />; return <a className={`${active ? "active " : ""}${className}`} {...props} />;
} }
const wouterOnClick = (e: any) => {
// prepared for view transition
/*if (props.transition !== false) {
e.preventDefault();
onClick?.(e);
document.startViewTransition(() => {
navigate(props.href ?? props.to, props);
});
}*/
};
return ( return (
<WouterLink
// @ts-expect-error className is not typed on WouterLink // @ts-expect-error className is not typed on WouterLink
<WouterLink className={`${active ? "active " : ""}${className}`} {...props} /> className={`${active ? "active " : ""}${className}`}
{...props}
onClick={wouterOnClick}
/>
); );
} }

View File

@@ -0,0 +1,2 @@
export { Auth } from "ui/modules/auth/index";
export * from "./media";

View File

@@ -0,0 +1,15 @@
import { PreviewWrapperMemoized } from "ui/modules/media/components/dropzone/Dropzone";
import { DropzoneContainer } from "ui/modules/media/components/dropzone/DropzoneContainer";
export const Media = {
Dropzone: DropzoneContainer,
Preview: PreviewWrapperMemoized
};
export type {
PreviewComponentProps,
FileState,
DropzoneProps,
DropzoneRenderProps
} from "ui/modules/media/components/dropzone/Dropzone";
export type { DropzoneContainerProps } from "ui/modules/media/components/dropzone/DropzoneContainer";

View File

@@ -144,7 +144,7 @@ export function Header({ hasSidebar = true }) {
} }
function UserMenu() { function UserMenu() {
const { adminOverride } = useBknd(); const { adminOverride, config } = useBknd();
const auth = useAuth(); const auth = useAuth();
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const { logout_route } = useBkndWindowContext(); const { logout_route } = useBkndWindowContext();
@@ -163,10 +163,16 @@ function UserMenu() {
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings } { label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings }
]; ];
if (config.auth.enabled) {
if (!auth.user) { if (!auth.user) {
items.push({ label: "Login", onClick: handleLogin, icon: IconUser }); items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
} else { } else {
items.push({ label: `Logout ${auth.user.email}`, onClick: handleLogout, icon: IconKeyOff }); items.push({
label: `Logout ${auth.user.email}`,
onClick: handleLogout,
icon: IconKeyOff
});
}
} }
if (!adminOverride) { if (!adminOverride) {

View File

@@ -1,9 +1,6 @@
import type { PrimaryFieldType } from "core"; import type { PrimaryFieldType } from "core";
import { encodeSearch } from "core/utils"; import { encodeSearch } from "core/utils";
import { atom, useSetAtom } from "jotai";
import { useEffect, useState } from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { useBaseUrl } from "../client";
import { useBknd } from "../client/BkndProvider"; import { useBknd } from "../client/BkndProvider";
export const routes = { export const routes = {
@@ -64,9 +61,26 @@ export function useNavigate() {
( (
url: string, url: string,
options?: options?:
| { query?: object; absolute?: boolean; replace?: boolean; state?: any } | {
query?: object;
absolute?: boolean;
replace?: boolean;
state?: any;
transition?: boolean;
}
| { reload: true } | { reload: true }
) => { ) => {
const wrap = (fn: () => void) => {
fn();
// prepared for view transition
/*if (options && "transition" in options && options.transition === false) {
fn();
} else {
document.startViewTransition(fn);
}*/
};
wrap(() => {
if (options && "reload" in options) { if (options && "reload" in options) {
window.location.href = url; window.location.href = url;
return; return;
@@ -77,6 +91,7 @@ export function useNavigate() {
replace: options?.replace, replace: options?.replace,
state: options?.state state: options?.state
}); });
});
}, },
location location
] as const; ] as const;

View File

@@ -1,13 +1,14 @@
@import "./components/form/json-schema/styles.css"; @import "./components/form/json-schema/styles.css";
@import '@xyflow/react/dist/style.css'; @import "@xyflow/react/dist/style.css";
@import "@mantine/core/styles.css"; @import "@mantine/core/styles.css";
@import '@mantine/notifications/styles.css'; @import "@mantine/notifications/styles.css";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
html.fixed, html.fixed body { html.fixed,
html.fixed body {
top: 0; top: 0;
left: 0; left: 0;
height: 100%; height: 100%;
@@ -18,20 +19,14 @@ html.fixed, html.fixed body {
touch-action: none; touch-action: none;
} }
#bknd-admin, .bknd-admin { #bknd-admin,
.bknd-admin {
--color-primary: 9 9 11; /* zinc-950 */ --color-primary: 9 9 11; /* zinc-950 */
--color-background: 250 250 250; /* zinc-50 */ --color-background: 250 250 250; /* zinc-50 */
--color-muted: 228 228 231; /* ? */ --color-muted: 228 228 231; /* ? */
--color-darkest: 0 0 0; /* black */ --color-darkest: 0 0 0; /* black */
--color-lightest: 255 255 255; /* white */ --color-lightest: 255 255 255; /* white */
&.dark {
--color-primary: 250 250 250; /* zinc-50 */
--color-background: 9 9 11; /* zinc-950 */
--color-muted: 39 39 42; /* zinc-800 */
--color-darkest: 255 255 255; /* white */
--color-lightest: 0 0 0; /* black */
}
&.dark { &.dark {
--color-primary: 250 250 250; /* zinc-50 */ --color-primary: 250 250 250; /* zinc-50 */
--color-background: 30 31 34; --color-background: 30 31 34;
@@ -52,7 +47,8 @@ html.fixed, html.fixed body {
} }
} }
html, body { html,
body {
font-size: 14px; font-size: 14px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
@@ -111,7 +107,8 @@ body,
} }
} }
@layer utilities {} @layer utilities {
}
/* Hide scrollbar for Chrome, Safari and Opera */ /* Hide scrollbar for Chrome, Safari and Opera */
.app-scrollbar::-webkit-scrollbar { .app-scrollbar::-webkit-scrollbar {
@@ -153,11 +150,15 @@ input[type="date"]::-webkit-calendar-picker-indicator {
} }
} }
input[readonly]::placeholder, input[disabled]::placeholder { input[readonly]::placeholder,
input[disabled]::placeholder {
opacity: 0.1; opacity: 0.1;
} }
.react-flow__pane, .react-flow__renderer, .react-flow__node, .react-flow__edge { .react-flow__pane,
.react-flow__renderer,
.react-flow__node,
.react-flow__edge {
cursor: inherit !important; cursor: inherit !important;
.drag-handle { .drag-handle {
cursor: grab; cursor: grab;
@@ -168,7 +169,6 @@ input[readonly]::placeholder, input[disabled]::placeholder {
stroke-width: 2; stroke-width: 2;
} }
.mantine-TextInput-wrapper input { .mantine-TextInput-wrapper input {
font-family: inherit; font-family: inherit;
line-height: 1; line-height: 1;
@@ -191,7 +191,8 @@ input[readonly]::placeholder, input[disabled]::placeholder {
flex: 1 1 0; flex: 1 1 0;
} }
#bknd-admin, .bknd-admin { #bknd-admin,
.bknd-admin {
/* Chrome, Edge, and Safari */ /* Chrome, Edge, and Safari */
& *::-webkit-scrollbar { & *::-webkit-scrollbar {
@apply w-1; @apply w-1;

View File

@@ -1,23 +1,13 @@
import * as React from "react"; import * as React from "react";
import * as ReactDOM from "react-dom/client"; import * as ReactDOM from "react-dom/client";
import Admin from "./Admin";
import "./main.css"; import "./main.css";
import Admin from "./Admin"; ReactDOM.createRoot(document.getElementById("root")!).render(
function ClientApp() {
return <Admin withProvider />;
}
// Render the app
const rootElement = document.getElementById("app")!;
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode> <React.StrictMode>
<ClientApp /> <Admin withProvider />
</React.StrictMode> </React.StrictMode>
); );
}
// REGISTER ERROR OVERLAY // REGISTER ERROR OVERLAY
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {

View File

@@ -0,0 +1,128 @@
import type { ValueError } from "@sinclair/typebox/value";
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
import { type TSchema, Type, Value } from "core/utils";
import { Form, type Validator } from "json-schema-form-react";
import { transform } from "lodash-es";
import type { ComponentPropsWithoutRef } from "react";
import { twMerge } from "tailwind-merge";
import { Button } from "ui/components/buttons/Button";
import { Group, Input, Label } from "ui/components/form/Formy";
import { SocialLink } from "ui/modules/auth/SocialLink";
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
className?: string;
formData?: any;
action: "login" | "register";
method?: "POST" | "GET";
auth?: Partial<Pick<AppAuthSchema, "basepath" | "strategies">>;
buttonLabel?: string;
};
class TypeboxValidator implements Validator<ValueError> {
async validate(schema: TSchema, data: any) {
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
}
}
const validator = new TypeboxValidator();
const schema = Type.Object({
email: Type.String({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
}),
password: Type.String({
minLength: 8 // @todo: this should be configurable
})
});
export function AuthForm({
formData,
className,
method = "POST",
action,
auth,
buttonLabel = action === "login" ? "Sign in" : "Sign up",
...props
}: LoginFormProps) {
const basepath = auth?.basepath ?? "/api/auth";
const password = {
action: `${basepath}/password/${action}`,
strategy: auth?.strategies?.password ?? ({ type: "password" } as const)
};
const oauth = transform(
auth?.strategies ?? {},
(result, value, key) => {
if (value.type !== "password") {
result[key] = value.config;
}
},
{}
) as Record<string, AppAuthOAuthStrategy>;
const has_oauth = Object.keys(oauth).length > 0;
return (
<div className="flex flex-col gap-4 w-full">
{has_oauth && (
<>
<div>
{Object.entries(oauth)?.map(([name, oauth], key) => (
<SocialLink
provider={name}
method={method}
basepath={basepath}
key={key}
action={action}
/>
))}
</div>
<Or />
</>
)}
<Form
method={method}
action={password.action}
{...props}
schema={schema}
validator={validator}
validationMode="change"
className={twMerge("flex flex-col gap-3 w-full", className)}
>
{({ errors, submitting }) => (
<>
<Group>
<Label htmlFor="email">Email address</Label>
<Input type="email" name="email" />
</Group>
<Group>
<Label htmlFor="password">Password</Label>
<Input type="password" name="password" />
</Group>
<Button
type="submit"
variant="primary"
size="large"
className="w-full mt-2 justify-center"
disabled={errors.length > 0 || submitting}
>
{buttonLabel}
</Button>
</>
)}
</Form>
</div>
);
}
const Or = () => (
<div className="w-full flex flex-row items-center">
<div className="relative flex grow">
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
</div>
<div className="mx-5">or</div>
<div className="relative flex grow">
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
</div>
</div>
);

View File

@@ -0,0 +1,41 @@
import type { ReactNode } from "react";
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
import { Logo } from "ui/components/display/Logo";
import { Link } from "ui/components/wouter/Link";
import { AuthForm } from "ui/modules/auth/AuthForm";
export type AuthScreenProps = {
method?: "POST" | "GET";
action?: "login" | "register";
logo?: ReactNode;
intro?: ReactNode;
};
export function AuthScreen({ method = "POST", action = "login", logo, intro }: AuthScreenProps) {
const { strategies, basepath, loading } = useAuthStrategies();
return (
<div className="flex flex-1 flex-col select-none h-dvh w-dvw justify-center items-center bknd-admin">
{!loading && (
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
{typeof logo !== "undefined" ? (
logo
) : (
<Link href={"/"} className="link">
<Logo scale={0.25} />
</Link>
)}
{typeof intro !== "undefined" ? (
intro
) : (
<div className="flex flex-col items-center">
<h1 className="text-xl font-bold">Sign in to your admin panel</h1>
<p className="text-primary/50">Enter your credentials below to get access.</p>
</div>
)}
<AuthForm auth={{ basepath, strategies }} method={method} action={action} />
</div>
)}
</div>
);
}

View File

@@ -1,55 +0,0 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Type } from "core/utils";
import type { ComponentPropsWithoutRef } from "react";
import { useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy";
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit"> & {
className?: string;
formData?: any;
};
const schema = Type.Object({
email: Type.String({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
}),
password: Type.String({
minLength: 8 // @todo: this should be configurable
})
});
export function LoginForm({ formData, className, method = "POST", ...props }: LoginFormProps) {
const {
register,
formState: { isValid, errors }
} = useForm({
mode: "onChange",
defaultValues: formData,
resolver: typeboxResolver(schema)
});
return (
<form {...props} method={method} className={twMerge("flex flex-col gap-3 w-full", className)}>
<Formy.Group>
<Formy.Label htmlFor="email">Email address</Formy.Label>
<Formy.Input type="email" {...register("email")} />
</Formy.Group>
<Formy.Group>
<Formy.Label htmlFor="password">Password</Formy.Label>
<Formy.Input type="password" {...register("password")} />
</Formy.Group>
<Button
type="submit"
variant="primary"
size="large"
className="w-full mt-2 justify-center"
disabled={!isValid}
>
Sign in
</Button>
</form>
);
}

View File

@@ -0,0 +1,33 @@
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import type { ReactNode } from "react";
import { Button } from "ui/components/buttons/Button";
import type { IconType } from "ui/components/buttons/IconButton";
export type SocialLinkProps = {
label?: string;
provider: string;
icon?: IconType;
action: "login" | "register";
method?: "GET" | "POST";
basepath?: string;
children?: ReactNode;
};
export function SocialLink({
label,
provider,
icon,
action,
method = "POST",
basepath = "/api/auth",
children
}: SocialLinkProps) {
return (
<form method={method} action={[basepath, name, action].join("/")} className="w-full">
<Button type="submit" size="large" variant="outline" className="justify-center w-full">
Continue with {label ?? ucFirstAllSnakeToPascalWithSpaces(provider)}
</Button>
{children}
</form>
);
}

View File

@@ -0,0 +1,9 @@
import { AuthForm } from "ui/modules/auth/AuthForm";
import { AuthScreen } from "ui/modules/auth/AuthScreen";
import { SocialLink } from "ui/modules/auth/SocialLink";
export const Auth = {
Screen: AuthScreen,
Form: AuthForm,
SocialLink: SocialLink
};

View File

@@ -10,13 +10,11 @@ import {
} from "data"; } from "data";
import { MediaField } from "media/MediaField"; import { MediaField } from "media/MediaField";
import { type ComponentProps, Suspense } from "react"; import { type ComponentProps, Suspense } from "react";
import { useApi, useBaseUrl, useInvalidate } from "ui/client";
import { JsonEditor } from "ui/components/code/JsonEditor"; import { JsonEditor } from "ui/components/code/JsonEditor";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { FieldLabel } from "ui/components/form/Formy"; import { FieldLabel } from "ui/components/form/Formy";
import { Media } from "ui/elements";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
import { Dropzone, type FileState } from "../../media/components/dropzone/Dropzone";
import { mediaItemsToFileStates } from "../../media/helper";
import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField"; import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField";
import { EntityRelationalFormField } from "./fields/EntityRelationalFormField"; import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
@@ -215,9 +213,6 @@ function EntityMediaFormField({
}) { }) {
if (!entityId) return; if (!entityId) return;
const api = useApi();
const baseUrl = useBaseUrl();
const invalidate = useInvalidate();
const value = formApi.useStore((state) => { const value = formApi.useStore((state) => {
const val = state.values[field.name]; const val = state.values[field.name];
if (!val || typeof val === "undefined") return []; if (!val || typeof val === "undefined") return [];
@@ -225,37 +220,20 @@ function EntityMediaFormField({
return [val]; return [val];
}); });
const initialItems: FileState[] = const key = JSON.stringify([entity, entityId, field.name, value.length]);
value.length === 0
? []
: mediaItemsToFileStates(value, {
baseUrl: api.baseUrl,
overrides: { state: "uploaded" }
});
const getUploadInfo = useEvent(() => {
return {
url: api.media.getEntityUploadUrl(entity.name, entityId, field.name),
headers: api.media.getUploadHeaders(),
method: "POST"
};
});
const handleDelete = useEvent(async (file: FileState) => {
invalidate((api) => api.data.readOne(entity.name, entityId));
return api.media.deleteFile(file.path);
});
return ( return (
<Formy.Group> <Formy.Group>
<FieldLabel field={field} /> <FieldLabel field={field} />
<Dropzone <Media.Dropzone
key={`${entity.name}-${entityId}-${field.name}-${value.length === 0 ? "initial" : "loaded"}`} key={key}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
initialItems={initialItems}
maxItems={field.getMaxItems()} maxItems={field.getMaxItems()}
autoUpload initialItems={value} /* @todo: test if better be omitted, so it fetches */
entity={{
name: entity.name,
id: entityId,
field: field.name
}}
/> />
</Formy.Group> </Formy.Group>
); );

View File

@@ -1,5 +1,6 @@
import { import {
type ComponentPropsWithRef, type ComponentPropsWithRef,
type ComponentPropsWithoutRef,
type RefObject, type RefObject,
memo, memo,
useEffect, useEffect,
@@ -28,10 +29,11 @@ export type DropzoneRenderProps = {
state: { state: {
files: FileState[]; files: FileState[];
isOver: boolean; isOver: boolean;
isOverAccepted: boolean;
showPlaceholder: boolean; showPlaceholder: boolean;
}; };
actions: { actions: {
uploadFileProgress: (file: FileState) => Promise<void>; uploadFile: (file: FileState) => Promise<void>;
deleteFile: (file: FileState) => Promise<void>; deleteFile: (file: FileState) => Promise<void>;
openFileInput: () => void; openFileInput: () => void;
}; };
@@ -43,11 +45,16 @@ export type DropzoneProps = {
handleDelete: (file: FileState) => Promise<boolean>; handleDelete: (file: FileState) => Promise<boolean>;
initialItems?: FileState[]; initialItems?: FileState[];
maxItems?: number; maxItems?: number;
overwrite?: boolean;
autoUpload?: boolean; autoUpload?: boolean;
onRejected?: (files: FileWithPath[]) => void;
onDeleted?: (file: FileState) => void;
onUploaded?: (file: FileState) => void;
placeholder?: { placeholder?: {
show?: boolean; show?: boolean;
text?: string; text?: string;
}; };
children?: (props: DropzoneRenderProps) => JSX.Element;
}; };
export function Dropzone({ export function Dropzone({
@@ -55,23 +62,65 @@ export function Dropzone({
handleDelete, handleDelete,
initialItems = [], initialItems = [],
maxItems, maxItems,
overwrite,
autoUpload, autoUpload,
placeholder placeholder,
onRejected,
onDeleted,
onUploaded,
children
}: DropzoneProps) { }: DropzoneProps) {
const [files, setFiles] = useState<FileState[]>(initialItems); const [files, setFiles] = useState<FileState[]>(initialItems);
const [uploading, setUploading] = useState<boolean>(false); const [uploading, setUploading] = useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [isOverAccepted, setIsOverAccepted] = useState(false);
function isMaxReached(added: number): boolean {
if (!maxItems) {
console.log("maxItems is undefined, never reached");
return false;
}
const current = files.length;
const remaining = maxItems - current;
console.log("isMaxReached", { added, current, remaining, maxItems, overwrite });
// if overwrite is set, but added is bigger than max items
if (overwrite) {
console.log("added > maxItems, stop?", added > maxItems);
return added > maxItems;
}
console.log("remaining > added, stop?", remaining > added);
// or remaining doesn't suffice, stop
return added > remaining;
}
const { isOver, handleFileInputChange, ref } = useDropzone({ const { isOver, handleFileInputChange, ref } = useDropzone({
onDropped: (newFiles: FileWithPath[]) => { onDropped: (newFiles: FileWithPath[]) => {
if (maxItems && files.length + newFiles.length > maxItems) { let to_drop = 0;
alert("Max items reached"); const added = newFiles.length;
if (maxItems) {
if (isMaxReached(added)) {
if (onRejected) {
onRejected(newFiles);
} else {
console.warn("maxItems reached");
}
return; return;
} }
console.log("files", newFiles); to_drop = added;
}
console.log("files", newFiles, { to_drop });
setFiles((prev) => { setFiles((prev) => {
const currentPaths = prev.map((f) => f.path); // drop amount calculated
const _prev = prev.slice(to_drop);
// prep new files
const currentPaths = _prev.map((f) => f.path);
const filteredFiles: FileState[] = newFiles const filteredFiles: FileState[] = newFiles
.filter((f) => f.path && !currentPaths.includes(f.path)) .filter((f) => f.path && !currentPaths.includes(f.path))
.map((f) => ({ .map((f) => ({
@@ -84,7 +133,7 @@ export function Dropzone({
progress: 0 progress: 0
})); }));
return [...prev, ...filteredFiles]; return [..._prev, ...filteredFiles];
}); });
if (autoUpload) { if (autoUpload) {
@@ -92,17 +141,12 @@ export function Dropzone({
} }
}, },
onOver: (items) => { onOver: (items) => {
if (maxItems && files.length + items.length >= maxItems) { const max_reached = isMaxReached(items.length);
// indicate that the drop is not allowed setIsOverAccepted(!max_reached);
return; },
onLeave: () => {
setIsOverAccepted(false);
} }
}
/*onOver: (items) =>
console.log(
"onOver",
items,
items.map((i) => [i.kind, i.type].join(":"))
)*/
}); });
useEffect(() => { useEffect(() => {
@@ -180,7 +224,14 @@ export function Dropzone({
formData.append("file", file.body); formData.append("file", file.body);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open(method, url, true); const urlWithParams = new URL(url);
if (overwrite) {
urlWithParams.searchParams.append("overwrite", "1");
}
console.log("url", urlWithParams.toString());
//return;
xhr.open(method, urlWithParams.toString(), true);
if (headers) { if (headers) {
headers.forEach((value, key) => { headers.forEach((value, key) => {
@@ -207,6 +258,8 @@ export function Dropzone({
if (xhr.status === 200) { if (xhr.status === 200) {
//setFileState(file.path, "uploaded", 1); //setFileState(file.path, "uploaded", 1);
console.log("Upload complete"); console.log("Upload complete");
onUploaded?.(file);
try { try {
const response = JSON.parse(xhr.responseText); const response = JSON.parse(xhr.responseText);
@@ -252,6 +305,7 @@ export function Dropzone({
setFileState(file.path, "deleting"); setFileState(file.path, "deleting");
await handleDelete(file); await handleDelete(file);
removeFileFromState(file.path); removeFileFromState(file.path);
onDeleted?.(file);
} }
break; break;
} }
@@ -262,54 +316,61 @@ export function Dropzone({
placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems) placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)
); );
const Component = DropzoneInner; const renderProps: DropzoneRenderProps = {
wrapperRef: ref,
return ( inputProps: {
<Component
wrapperRef={ref}
inputProps={{
ref: inputRef, ref: inputRef,
type: "file", type: "file",
multiple: !maxItems || maxItems > 1, multiple: !maxItems || maxItems > 1,
onChange: handleFileInputChange onChange: handleFileInputChange
}} },
state={{ files, isOver, showPlaceholder }} state: {
actions={{ uploadFileProgress, deleteFile, openFileInput }} files,
dropzoneProps={{ maxItems, placeholder, autoUpload }} isOver,
/> isOverAccepted,
); showPlaceholder
},
actions: {
uploadFile: uploadFileProgress,
deleteFile,
openFileInput
},
dropzoneProps: {
maxItems,
placeholder,
autoUpload
}
};
return children ? children(renderProps) : <DropzoneInner {...renderProps} />;
} }
const DropzoneInner = ({ const DropzoneInner = ({
wrapperRef, wrapperRef,
inputProps, inputProps,
state: { files, isOver, showPlaceholder }, state: { files, isOver, isOverAccepted, showPlaceholder },
actions: { uploadFileProgress, deleteFile, openFileInput }, actions: { uploadFile, deleteFile, openFileInput },
dropzoneProps: { placeholder } dropzoneProps: { placeholder }
}: DropzoneRenderProps) => { }: DropzoneRenderProps) => {
return ( return (
<div <div
ref={wrapperRef} ref={wrapperRef}
/*data-drag-over={"1"}*/ className={twMerge(
data-drag-over={isOver ? "1" : undefined} "dropzone w-full h-full align-start flex flex-col select-none",
className="dropzone data-[drag-over]:bg-green-200/10 w-full h-full align-start flex flex-col select-none" isOver && isOverAccepted && "bg-green-200/10",
isOver && !isOverAccepted && "bg-red-200/40 cursor-not-allowed"
)}
> >
<div className="hidden"> <div className="hidden">
<input <input {...inputProps} />
{...inputProps}
/*ref={inputRef}
type="file"
multiple={!maxItems || maxItems > 1}
onChange={handleFileInputChange}*/
/>
</div> </div>
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<div className="flex flex-row flex-wrap gap-2 md:gap-3"> <div className="flex flex-row flex-wrap gap-2 md:gap-3">
{files.map((file, i) => ( {files.map((file) => (
<Preview <Preview
key={file.path} key={file.path}
file={file} file={file}
handleUpload={uploadFileProgress} handleUpload={uploadFile}
handleDelete={deleteFile} handleDelete={deleteFile}
/> />
))} ))}
@@ -333,18 +394,29 @@ const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
); );
}; };
const Wrapper = ({ file }: { file: FileState }) => { export type PreviewComponentProps = {
file: FileState;
fallback?: (props: { file: FileState }) => JSX.Element;
className?: string;
onClick?: () => void;
onTouchStart?: () => void;
};
const Wrapper = ({ file, fallback, ...props }: PreviewComponentProps) => {
if (file.type.startsWith("image/")) { if (file.type.startsWith("image/")) {
return <ImagePreview file={file} />; return <ImagePreview {...props} file={file} />;
} }
if (file.type.startsWith("video/")) { if (file.type.startsWith("video/")) {
return <VideoPreview file={file} />; return <VideoPreview {...props} file={file} />;
} }
return <FallbackPreview file={file} />; return fallback ? fallback({ file }) : null;
}; };
const WrapperMemoized = memo(Wrapper, (prev, next) => prev.file.path === next.file.path); export const PreviewWrapperMemoized = memo(
Wrapper,
(prev, next) => prev.file.path === next.file.path
);
type PreviewProps = { type PreviewProps = {
file: FileState; file: FileState;
@@ -370,7 +442,6 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
file.state === "deleting" && "opacity-70" file.state === "deleting" && "opacity-70"
)} )}
> >
{/*{file.state}*/}
<div className="absolute top-2 right-2"> <div className="absolute top-2 right-2">
<Dropdown items={dropdownItems} position="bottom-end"> <Dropdown items={dropdownItems} position="bottom-end">
<IconButton Icon={TbDots} /> <IconButton Icon={TbDots} />
@@ -385,7 +456,11 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
</div> </div>
)} )}
<div className="flex bg-primary/5 aspect-[1/0.8] overflow-hidden items-center justify-center"> <div className="flex bg-primary/5 aspect-[1/0.8] overflow-hidden items-center justify-center">
<WrapperMemoized file={file} /> <PreviewWrapperMemoized
file={file}
fallback={FallbackPreview}
className="max-w-full max-h-full"
/>
</div> </div>
<div className="flex flex-col px-1.5 py-1"> <div className="flex flex-col px-1.5 py-1">
<p className="truncate">{file.name}</p> <p className="truncate">{file.name}</p>
@@ -398,14 +473,20 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
); );
}; };
const ImagePreview = ({ file }: { file: FileState }) => { const ImagePreview = ({
file,
...props
}: { file: FileState } & ComponentPropsWithoutRef<"img">) => {
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body); const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
return <img className="max-w-full max-h-full" src={objectUrl} />; return <img {...props} src={objectUrl} />;
}; };
const VideoPreview = ({ file }: { file: FileState }) => { const VideoPreview = ({
file,
...props
}: { file: FileState } & ComponentPropsWithoutRef<"video">) => {
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body); const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
return <video src={objectUrl} />; return <video {...props} src={objectUrl} />;
}; };
const FallbackPreview = ({ file }: { file: FileState }) => { const FallbackPreview = ({ file }: { file: FileState }) => {

View File

@@ -0,0 +1,98 @@
import type { RepoQuery } from "data";
import type { MediaFieldSchema } from "media/AppMedia";
import type { TAppMediaConfig } from "media/media-schema";
import { useId } from "react";
import { useApi, useBaseUrl, useEntityQuery, useInvalidate } from "ui/client";
import { useEvent } from "ui/hooks/use-event";
import {
Dropzone,
type DropzoneProps,
type DropzoneRenderProps,
type FileState
} from "ui/modules/media/components/dropzone/Dropzone";
import { mediaItemsToFileStates } from "ui/modules/media/helper";
export type DropzoneContainerProps = {
children?: (props: DropzoneRenderProps) => JSX.Element;
initialItems?: MediaFieldSchema[];
entity?: {
name: string;
id: number;
field: string;
};
query?: Partial<RepoQuery>;
} & Partial<Pick<TAppMediaConfig, "basepath" | "entity_name" | "storage">> &
Partial<DropzoneProps>;
export function DropzoneContainer({
initialItems,
basepath = "/api/media",
storage = {},
entity_name = "media",
entity,
query,
...props
}: DropzoneContainerProps) {
const id = useId();
const baseUrl = useBaseUrl();
const api = useApi();
const invalidate = useInvalidate();
const limit = query?.limit ? query?.limit : props.maxItems ? props.maxItems : 50;
const $q = useEntityQuery(
entity_name as "media",
undefined,
{
...query,
limit,
where: entity
? {
reference: `${entity.name}.${entity.field}`,
entity_id: entity.id,
...query?.where
}
: query?.where
},
{ enabled: !initialItems }
);
const getUploadInfo = useEvent((file) => {
const url = entity
? api.media.getEntityUploadUrl(entity.name, entity.id, entity.field)
: api.media.getFileUploadUrl(file);
return {
url,
headers: api.media.getUploadHeaders(),
method: "POST"
};
});
const refresh = useEvent(async () => {
if (entity) {
invalidate((api) => api.data.readOne(entity.name, entity.id));
}
await $q.mutate();
});
const handleDelete = useEvent(async (file: FileState) => {
return api.media.deleteFile(file.path);
});
const actualItems = initialItems ?? (($q.data || []) as MediaFieldSchema[]);
const _initialItems = mediaItemsToFileStates(actualItems, { baseUrl });
const key = id + JSON.stringify(_initialItems);
return (
<Dropzone
key={id + key}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
onUploaded={refresh}
onDeleted={refresh}
autoUpload
initialItems={_initialItems}
{...props}
/>
);
}

View File

@@ -4,15 +4,16 @@ import { type FileWithPath, fromEvent } from "./file-selector";
type DropzoneProps = { type DropzoneProps = {
onDropped: (files: FileWithPath[]) => void; onDropped: (files: FileWithPath[]) => void;
onOver?: (items: DataTransferItem[]) => void; onOver?: (items: DataTransferItem[]) => void;
onLeave?: () => void;
}; };
const events = { const events = {
enter: ["dragenter", "dragover", "dragstart"], enter: ["dragenter", "dragover", "dragstart"],
leave: ["dragleave", "drop"], leave: ["dragleave", "drop"]
}; };
const allEvents = [...events.enter, ...events.leave]; const allEvents = [...events.enter, ...events.leave];
export function useDropzone({ onDropped, onOver }: DropzoneProps) { export function useDropzone({ onDropped, onOver, onLeave }: DropzoneProps) {
const [isOver, setIsOver] = useState(false); const [isOver, setIsOver] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const onOverCalled = useRef(false); const onOverCalled = useRef(false);
@@ -31,8 +32,10 @@ export function useDropzone({ onDropped, onOver }: DropzoneProps) {
} }
setIsOver(_isOver); setIsOver(_isOver);
if (_isOver === false && onOverCalled.current) { if (_isOver === false && onOverCalled.current) {
onOverCalled.current = false; onOverCalled.current = false;
onLeave?.();
} }
}, []); }, []);
@@ -42,7 +45,7 @@ export function useDropzone({ onDropped, onOver }: DropzoneProps) {
onDropped?.(files as any); onDropped?.(files as any);
onOverCalled.current = false; onOverCalled.current = false;
}, },
[onDropped], [onDropped]
); );
const handleFileInputChange = useCallback( const handleFileInputChange = useCallback(
@@ -50,7 +53,7 @@ export function useDropzone({ onDropped, onOver }: DropzoneProps) {
const files = await fromEvent(e); const files = await fromEvent(e);
onDropped?.(files as any); onDropped?.(files as any);
}, },
[onDropped], [onDropped]
); );
useEffect(() => { useEffect(() => {

View File

@@ -12,7 +12,9 @@ export function AuthIndex() {
config: { roles, strategies, entity_name, enabled } config: { roles, strategies, entity_name, enabled }
} = useBkndAuth(); } = useBkndAuth();
const users_entity = entity_name; const users_entity = entity_name;
const $q = useApiQuery((api) => api.data.count(users_entity)); const $q = useApiQuery((api) => api.data.count(users_entity), {
enabled
});
const usersTotal = $q.data?.count ?? 0; const usersTotal = $q.data?.count ?? 0;
const rolesTotal = Object.keys(roles ?? {}).length ?? 0; const rolesTotal = Object.keys(roles ?? {}).length ?? 0;
const strategiesTotal = Object.keys(strategies ?? {}).length ?? 0; const strategiesTotal = Object.keys(strategies ?? {}).length ?? 0;

View File

@@ -1,81 +1,7 @@
import type { AppAuthOAuthStrategy } from "auth/auth-schema";
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import { transform } from "lodash-es";
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
import { Button } from "ui/components/buttons/Button";
import { Logo } from "ui/components/display/Logo";
import { Link } from "ui/components/wouter/Link";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { LoginForm } from "ui/modules/auth/LoginForm"; import { AuthScreen } from "ui/modules/auth/AuthScreen";
import * as AppShell from "../../layouts/AppShell/AppShell";
export function AuthLogin() { export function AuthLogin() {
useBrowserTitle(["Login"]); useBrowserTitle(["Login"]);
const { strategies, basepath, loading } = useAuthStrategies(); return <AuthScreen action="login" />;
const oauth = transform(
strategies ?? {},
(result, value, key) => {
if (value.type !== "password") {
result[key] = value.config;
}
},
{}
) as Record<string, AppAuthOAuthStrategy>;
//console.log("oauth", oauth, strategies);
return (
<AppShell.Root>
<AppShell.Content center>
{!loading && (
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
<Link href={"/"} className="link">
<Logo scale={0.25} />
</Link>
<div className="flex flex-col items-center">
<h1 className="text-xl font-bold">Sign in to your admin panel</h1>
<p className="text-primary/50">Enter your credentials below to get access.</p>
</div>
<div className="flex flex-col gap-4 w-full">
{Object.keys(oauth).length > 0 && (
<>
{Object.entries(oauth)?.map(([name, oauth], key) => (
<form
method="POST"
action={`${basepath}/${name}/login`}
key={key}
className="w-full"
>
<Button
key={key}
type="submit"
size="large"
variant="outline"
className="justify-center w-full"
>
Continue with {ucFirstAllSnakeToPascalWithSpaces(oauth.name)}
</Button>
</form>
))}
<div className="w-full flex flex-row items-center">
<div className="relative flex grow">
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
</div>
<div className="mx-5">or</div>
<div className="relative flex grow">
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
</div>
</div>
</>
)}
<LoginForm action="/api/auth/password/login" />
{/*<a href="/auth/logout">Logout</a>*/}
</div>
</div>
)}
</AppShell.Content>
</AppShell.Root>
);
} }

View File

@@ -1,16 +1,12 @@
import { IconPhoto } from "@tabler/icons-react"; import { IconPhoto } from "@tabler/icons-react";
import type { MediaFieldSchema } from "modules";
import { TbSettings } from "react-icons/tb"; import { TbSettings } from "react-icons/tb";
import { useApi, useBaseUrl, useEntityQuery } from "ui/client";
import { useBknd } from "ui/client/BkndProvider"; import { useBknd } from "ui/client/BkndProvider";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty"; import { Empty } from "ui/components/display/Empty";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
import { Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { useEvent } from "ui/hooks/use-event";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { Dropzone, type FileState } from "ui/modules/media/components/dropzone/Dropzone";
import { mediaItemsToFileStates } from "ui/modules/media/helper";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
export function MediaRoot({ children }) { export function MediaRoot({ children }) {
@@ -63,35 +59,11 @@ export function MediaRoot({ children }) {
// @todo: add infinite load // @todo: add infinite load
export function MediaEmpty() { export function MediaEmpty() {
useBrowserTitle(["Media"]); useBrowserTitle(["Media"]);
const baseUrl = useBaseUrl();
const api = useApi();
const $q = useEntityQuery("media", undefined, { limit: 50 });
const getUploadInfo = useEvent((file) => {
return {
url: api.media.getFileUploadUrl(file),
headers: api.media.getUploadHeaders(),
method: "POST"
};
});
const handleDelete = useEvent(async (file: FileState) => {
return api.media.deleteFile(file.path);
});
const media = ($q.data || []) as MediaFieldSchema[];
const initialItems = mediaItemsToFileStates(media, { baseUrl });
return ( return (
<AppShell.Scrollable> <AppShell.Scrollable>
<div className="flex flex-1 p-3"> <div className="flex flex-1 p-3">
<Dropzone <Media.Dropzone />
key={$q.isLoading ? "loaded" : "initial"}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
autoUpload
initialItems={initialItems}
/>
</div> </div>
</AppShell.Scrollable> </AppShell.Scrollable>
); );

View File

@@ -1,4 +1,5 @@
import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test"; import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test";
import JsonSchemaFormReactTest from "ui/routes/test/tests/json-schema-form-react-test";
import SwaggerTest from "ui/routes/test/tests/swagger-test"; import SwaggerTest from "ui/routes/test/tests/swagger-test";
import SWRAndAPI from "ui/routes/test/tests/swr-and-api"; import SWRAndAPI from "ui/routes/test/tests/swr-and-api";
import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api"; import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api";
@@ -11,6 +12,7 @@ import FlowFormTest from "../../routes/test/tests/flow-form-test";
import ModalTest from "../../routes/test/tests/modal-test"; import ModalTest from "../../routes/test/tests/modal-test";
import QueryJsonFormTest from "../../routes/test/tests/query-jsonform"; import QueryJsonFormTest from "../../routes/test/tests/query-jsonform";
import DropdownTest from "./tests/dropdown-test"; import DropdownTest from "./tests/dropdown-test";
import DropzoneElementTest from "./tests/dropzone-element-test";
import EntityFieldsForm from "./tests/entity-fields-form"; import EntityFieldsForm from "./tests/entity-fields-form";
import FlowsTest from "./tests/flows-test"; import FlowsTest from "./tests/flows-test";
import JsonFormTest from "./tests/jsonform-test"; import JsonFormTest from "./tests/jsonform-test";
@@ -41,7 +43,9 @@ const tests = {
AppShellAccordionsTest, AppShellAccordionsTest,
SwaggerTest, SwaggerTest,
SWRAndAPI, SWRAndAPI,
SwrAndDataApi SwrAndDataApi,
DropzoneElementTest,
JsonSchemaFormReactTest
} as const; } as const;
export default function TestRoutes() { export default function TestRoutes() {

View File

@@ -0,0 +1,78 @@
import { type DropzoneRenderProps, Media } from "ui/elements";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
export default function DropzoneElementTest() {
return (
<Scrollable>
<div className="flex flex-col w-full h-full p-4 gap-4">
<div>
<b>Dropzone User Avatar 1 (fully customized)</b>
<Media.Dropzone
entity={{ name: "users", id: 1, field: "avatar" }}
maxItems={1}
overwrite
>
{(props) => <CustomUserAvatarDropzone {...props} />}
</Media.Dropzone>
</div>
<div>
<b>Dropzone User Avatar 1 (overwrite)</b>
<Media.Dropzone
entity={{ name: "users", id: 1, field: "avatar" }}
maxItems={1}
overwrite
/>
</div>
<div>
<b>Dropzone User Avatar 1</b>
<Media.Dropzone entity={{ name: "users", id: 1, field: "avatar" }} maxItems={1} />
</div>
<div>
<b>Dropzone Container blank w/ query</b>
<Media.Dropzone query={{ limit: 2 }} />
</div>
<div>
<b>Dropzone Container blank</b>
<Media.Dropzone />
</div>
<div>
<b>Dropzone Post 12</b>
<Media.Dropzone entity={{ name: "posts", id: 12, field: "images" }} />
</div>
</div>
</Scrollable>
);
}
function CustomUserAvatarDropzone({
wrapperRef,
inputProps,
state: { files, isOver, isOverAccepted, showPlaceholder },
actions: { openFileInput }
}: DropzoneRenderProps) {
const file = files[0];
return (
<div
ref={wrapperRef}
className="size-32 rounded-full border border-gray-200 flex justify-center items-center leading-none overflow-hidden"
>
<div className="hidden">
<input {...inputProps} />
</div>
{showPlaceholder && <>{isOver && isOverAccepted ? "let it drop" : "drop here"}</>}
{file && (
<Media.Preview
file={file}
className="object-cover w-full h-full"
onClick={openFileInput}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { Form, type Validator } from "json-schema-form-react";
import { useState } from "react";
import { type TSchema, Type } from "@sinclair/typebox";
import { Value, type ValueError } from "@sinclair/typebox/value";
class TypeboxValidator implements Validator<ValueError> {
async validate(schema: TSchema, data: any) {
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
}
}
const validator = new TypeboxValidator();
const schema = Type.Object({
name: Type.String(),
age: Type.Optional(Type.Number())
});
export default function JsonSchemaFormReactTest() {
const [data, setData] = useState(null);
return (
<>
<Form
schema={schema}
onChange={setData}
onSubmit={setData}
validator={validator}
validationMode="change"
>
{({ errors, dirty, reset }) => (
<>
<div>
<b>
Form {dirty ? "*" : ""} (valid: {errors.length === 0 ? "valid" : "invalid"})
</b>
</div>
<div>
<input type="text" name="name" />
<input type="number" name="age" />
</div>
<div>
<button type="submit">submit</button>
<button type="button" onClick={reset}>
reset
</button>
</div>
</>
)}
</Form>
<pre>{JSON.stringify(data, null, 2)}</pre>
</>
);
}

View File

@@ -1,20 +1,16 @@
import devServer from "@hono/vite-dev-server"; import devServer from "@hono/vite-dev-server";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig, loadEnv } from "vite"; import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths"; import tsconfigPaths from "vite-tsconfig-paths";
import { devServerConfig } from "./src/adapter/vite/dev-server-config";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(async () => { export default defineConfig({
/**
* DEVELOPMENT MODE
*/
if (process.env.NODE_ENV === "development") {
return {
define: { define: {
__isDev: "1" __isDev: "1"
}, },
clearScreen: false, clearScreen: false,
publicDir: "./src/admin/assets", publicDir: "./src/ui/assets",
server: { server: {
host: true, host: true,
port: 28623, port: 28623,
@@ -26,23 +22,15 @@ export default defineConfig(async () => {
react(), react(),
tsconfigPaths(), tsconfigPaths(),
devServer({ devServer({
entry: "./vite.dev.ts", ...devServerConfig,
exclude: [ entry: "./vite.dev.ts"
// We need to override this option since the default setting doesn't fit
/.*\.tsx?($|\?)/,
/^(?!.*\/__admin).*\.(s?css|less)($|\?)/,
/^(?!.*\/api).*\.(svg|png)($|\?)/, // exclude except /api
/^\/@.+$/,
/^\/favicon\.ico$/,
/^\/(public|assets|static)\/.+/,
/^\/node_modules\/.*/
],
//injectClientScript: true
injectClientScript: false // This option is buggy, disable it and inject the code manually
}) })
] ],
}; build: {
manifest: true,
outDir: "./dist/static",
rollupOptions: {
input: "./src/ui/main.tsx"
}
} }
throw new Error("Don't use vite for building in production");
}); });

View File

@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises";
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { createClient } from "@libsql/client/node"; import { createClient } from "@libsql/client/node";
import { App, registries } from "./src"; import { App, registries } from "./src";
@@ -6,7 +7,15 @@ import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAd
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
const credentials = { const run_example: string | boolean = false;
//run_example = "ex-admin-rich";
const credentials = run_example
? {
url: `file:.configs/${run_example}.db`
//url: ":memory:"
}
: {
url: import.meta.env.VITE_DB_URL!, url: import.meta.env.VITE_DB_URL!,
authToken: import.meta.env.VITE_DB_TOKEN! authToken: import.meta.env.VITE_DB_TOKEN!
}; };
@@ -16,10 +25,20 @@ if (!credentials.url) {
const connection = new LibsqlConnection(createClient(credentials)); const connection = new LibsqlConnection(createClient(credentials));
let initialConfig: any = undefined;
if (run_example) {
const { version, ...config } = JSON.parse(
await readFile(`.configs/${run_example}.json`, "utf-8")
);
initialConfig = config;
}
let app: App;
const recreate = true;
export default { export default {
async fetch(request: Request) { async fetch(request: Request) {
const app = App.create({ connection }); if (!app || recreate) {
app = App.create({ connection, initialConfig });
app.emgr.onEvent( app.emgr.onEvent(
App.Events.AppBuiltEvent, App.Events.AppBuiltEvent,
async () => { async () => {
@@ -29,6 +48,7 @@ export default {
"sync" "sync"
); );
await app.build(); await app.build();
}
return app.fetch(request); return app.fetch(request);
} }

BIN
bun.lockb

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 163 KiB

116
docs/integration/vite.mdx Normal file
View File

@@ -0,0 +1,116 @@
---
title: 'Vite'
description: 'Run bknd inside Vite'
---
import InstallBknd from '/snippets/install-bknd.mdx';
Vite is a powerful toolkit to accelerate your local development.
## Installation
Create a new vite project by following the [official guide](https://vite.dev/guide/#scaffolding-your-first-vite-project)
and then install bknd as a dependency:
<InstallBknd />
Additionally, install required dependencies:
```bash
npm install @hono/vite-dev-server
```
## Serve the API
To serve the **bknd** API, you first have to create a local server file for you vite environment.
Create a `server.ts` file:
```ts
import { serve } from "bknd/adapter/vite";
// the configuration given is optional
export default serve({
mode: "cached", // that's the default
connection: {
type: "libsql",
config: {
url: ":memory:"
}
}
})
```
For more information about the connection object, refer to the [Setup](/setup/introduction) guide.
You can also run your vite server in `mode: "fresh"`, this will re-create the app on every fetch.
This is only useful for when working on the `bknd` repository directly.
Next, adjust your `vite.config.ts` to look like the following:
```ts
import { devServer } from "bknd/adapter/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
tsconfigPaths(),
devServer({
// point to your previously created server file
entry: "./server.ts"
})
]
});
```
Now you can start your application using `npm run dev`. Now opening http://localhost:5174/
looks like an empty project. That's because we only registered the API, head over to
http://localhost:5174/api/system/config to see **bknd** respond.
## Serve the Admin UI
After adding the API, you can easily add the Admin UI by simply returning it in your `App.tsx`.
Replace all of its content with the following:
```tsx
import { Admin } from "bknd/ui";
import "bknd/dist/styles.css";
export default function App() {
return <Admin withProvider />
}
```
Now http://localhost:5174/ should give you the Admin UI.
## Customizations
This is just the bare minimum and may not always fulfill your requirements. There are a few
options you can make use of to adjust it according to your setup.
### Use custom HTML to serve the Admin UI
There might be cases you want to be sure to be in control over the HTML that is being used.
`bknd` generates it automatically, but you use your own one as follows:
```ts server.ts
import { serve, addViteScript } from "bknd/adapter/vite";
import { readFile } from "node:fs/promises"
let html = await readFile("./index.html", "utf-8");
// add vite scripts
html = addViteScript(html);
// then add it as an option
export default serve({ html })
```
The vite scripts has to be added manually currently, as adding them automatically with
`@hono/vite-dev-server` is buggy. This may change in the future.
### Use a custom entry point
By default, the entry point `/src/main.tsx` is used and should fit most cases. If that's not you,
you can supply a different one like so:
```ts server.ts
import { serve } from "bknd/adapter/vite";
// the configuration given is optional
export default serve({
forceDev: {
mainPath: "/src/special.tsx"
}
});
```

View File

@@ -82,6 +82,15 @@ in the future, so stay tuned!
</div>} </div>}
href="/integration/node" href="/integration/node"
/> />
<Card
title="Vite"
icon={<div className="text-primary-light">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/><path fill="currentColor" d="m8.525 4.63l-5.132-.915a1.17 1.17 0 0 0-1.164.468a1.16 1.16 0 0 0-.07 1.28l8.901 15.58a1.182 1.182 0 0 0 2.057-.008l8.729-15.578c.49-.875-.262-1.917-1.242-1.739l-4.574.813l-.206.754l4.906-.871a.474.474 0 0 1 .498.697L12.5 20.689a.47.47 0 0 1-.5.234a.47.47 0 0 1-.326-.231L2.772 5.112a.474.474 0 0 1 .496-.7l5.133.916l.074.013z"/><path fill="currentColor" d="m15.097 5.26l.162-.593l-.6.107zm-5.88-.506l.513.09l-.542.427z"/><path fill="currentColor" d="m15.549 2.367l-6.1 1.26a.22.22 0 0 0-.126.077a.25.25 0 0 0-.055.142l-.375 6.685a.24.24 0 0 0 .079.194a.21.21 0 0 0 .195.05l1.698-.414c.16-.038.302.11.27.278l-.505 2.606c-.034.176.122.326.285.274l1.049-.336c.162-.052.319.098.284.274l-.801 4.093c-.05.257.272.396.407.177l.09-.147l4.97-10.464c.084-.175-.06-.375-.242-.338l-1.748.356c-.165.034-.304-.128-.258-.297l1.14-4.173c.047-.17-.093-.331-.257-.297"/>
</svg>
</div>}
href="/integration/vite"
/>
<Card <Card
title="Docker" title="Docker"
icon={<div className="text-primary-light"> icon={<div className="text-primary-light">

View File

@@ -89,6 +89,7 @@
"integration/astro", "integration/astro",
"integration/node", "integration/node",
"integration/deno", "integration/deno",
"integration/vite",
"integration/docker" "integration/docker"
] ]
}, },

View File

@@ -4,6 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"deploy": "wrangler deploy", "deploy": "wrangler deploy",
"db": "turso dev --db-file test.db",
"dev": "wrangler dev", "dev": "wrangler dev",
"start": "wrangler dev", "start": "wrangler dev",
"test": "vitest", "test": "vitest",

View File

@@ -1,4 +1,7 @@
import { App } from "bknd";
import { serve } from "bknd/adapter/nextjs"; import { serve } from "bknd/adapter/nextjs";
import { boolean, em, entity, text } from "bknd/data";
import { secureRandomString } from "bknd/utils";
export const config = { export const config = {
runtime: "edge", runtime: "edge",
@@ -9,11 +12,60 @@ export const config = {
unstable_allowDynamic: ["**/*.js"] unstable_allowDynamic: ["**/*.js"]
}; };
// the em() function makes it easy to create an initial schema
const schema = em({
todos: entity("todos", {
title: text(),
done: boolean()
})
});
// register your schema to get automatic type completion
type Database = (typeof schema)["DB"];
declare module "bknd/core" {
interface DB extends Database {}
}
export default serve({ export default serve({
// we can use any libsql config, and if omitted, uses in-memory
connection: { connection: {
type: "libsql", type: "libsql",
config: { config: {
url: "http://localhost:8080" url: "http://localhost:8080"
} }
},
// an initial config is only applied if the database is empty
initialConfig: {
data: schema.toJSON(),
// we're enabling auth ...
auth: {
enabled: true,
jwt: {
secret: secureRandomString(64)
}
}
},
options: {
// the seed option is only executed if the database was empty
seed: async (ctx) => {
await ctx.em.mutator("todos").insertMany([
{ title: "Learn bknd", done: true },
{ title: "Build something cool", done: false }
]);
}
},
// here we can hook into the app lifecycle events ...
beforeBuild: async (app) => {
app.emgr.onEvent(
App.Events.AppFirstBoot,
async () => {
// ... to create an initial user
await app.module.auth.createUser({
email: "ds@bknd.io",
password: "12345678"
});
},
"sync"
);
} }
}); });

View File

@@ -7,7 +7,7 @@ export const meta: MetaFunction = () => {
export const loader = async (args: LoaderFunctionArgs) => { export const loader = async (args: LoaderFunctionArgs) => {
const api = args.context.api; const api = args.context.api;
const user = (await api.getVerifiedAuthState()).user; const user = (await api.getVerifiedAuthState(true)).user;
const { data } = await api.data.readMany("todos"); const { data } = await api.data.readMany("todos");
return { data, user }; return { data, user };
}; };

125
tmp/lazy_codemirror.patch Normal file
View File

@@ -0,0 +1,125 @@
Subject: [PATCH] lazy codemirror
---
Index: app/src/ui/components/code/LiquidJsEditor.tsx
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/app/src/ui/components/code/LiquidJsEditor.tsx b/app/src/ui/components/code/LiquidJsEditor.tsx
--- a/app/src/ui/components/code/LiquidJsEditor.tsx (revision b1a32f370565aded3a34b79ffd254c3c45d1085c)
+++ b/app/src/ui/components/code/LiquidJsEditor.tsx (date 1736687726081)
@@ -1,7 +1,7 @@
-import { liquid } from "@codemirror/lang-liquid";
-import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { Suspense, lazy } from "react";
import { twMerge } from "tailwind-merge";
+
+import type { CodeEditorProps } from "./CodeEditor";
const CodeEditor = lazy(() => import("./CodeEditor"));
const filters = [
@@ -106,7 +106,7 @@
{ label: "when" }
];
-export function LiquidJsEditor({ editable, ...props }: ReactCodeMirrorProps) {
+export function LiquidJsEditor({ editable, ...props }: CodeEditorProps) {
return (
<Suspense fallback={null}>
<CodeEditor
@@ -115,7 +115,9 @@
!editable && "opacity-70"
)}
editable={editable}
- extensions={[liquid({ filters, tags })]}
+ _extensions={{
+ liquid: { filters, tags }
+ }}
{...props}
/>
</Suspense>
Index: app/src/ui/components/code/CodeEditor.tsx
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/app/src/ui/components/code/CodeEditor.tsx b/app/src/ui/components/code/CodeEditor.tsx
--- a/app/src/ui/components/code/CodeEditor.tsx (revision b1a32f370565aded3a34b79ffd254c3c45d1085c)
+++ b/app/src/ui/components/code/CodeEditor.tsx (date 1736687634668)
@@ -1,8 +1,22 @@
import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
-
import { useBknd } from "ui/client/bknd";
-export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) {
+import { json } from "@codemirror/lang-json";
+import { type LiquidCompletionConfig, liquid } from "@codemirror/lang-liquid";
+
+export type CodeEditorProps = ReactCodeMirrorProps & {
+ _extensions?: Partial<{
+ json: boolean;
+ liquid: LiquidCompletionConfig;
+ }>;
+};
+
+export default function CodeEditor({
+ editable,
+ basicSetup,
+ _extensions = {},
+ ...props
+}: CodeEditorProps) {
const b = useBknd();
const theme = b.app.getAdminConfig().color_scheme;
const _basicSetup: Partial<ReactCodeMirrorProps["basicSetup"]> = !editable
@@ -13,11 +27,21 @@
}
: basicSetup;
+ const extensions = Object.entries(_extensions ?? {}).map(([ext, config]: any) => {
+ switch (ext) {
+ case "json":
+ return json();
+ case "liquid":
+ return liquid(config);
+ }
+ });
+
return (
<CodeMirror
theme={theme === "dark" ? "dark" : "light"}
editable={editable}
basicSetup={_basicSetup}
+ extensions={extensions}
{...props}
/>
);
Index: app/src/ui/components/code/JsonEditor.tsx
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/app/src/ui/components/code/JsonEditor.tsx b/app/src/ui/components/code/JsonEditor.tsx
--- a/app/src/ui/components/code/JsonEditor.tsx (revision b1a32f370565aded3a34b79ffd254c3c45d1085c)
+++ b/app/src/ui/components/code/JsonEditor.tsx (date 1736687681965)
@@ -1,10 +1,9 @@
-import { json } from "@codemirror/lang-json";
-import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { Suspense, lazy } from "react";
import { twMerge } from "tailwind-merge";
+import type { CodeEditorProps } from "./CodeEditor";
const CodeEditor = lazy(() => import("./CodeEditor"));
-export function JsonEditor({ editable, className, ...props }: ReactCodeMirrorProps) {
+export function JsonEditor({ editable, className, ...props }: CodeEditorProps) {
return (
<Suspense fallback={null}>
<CodeEditor
@@ -14,7 +13,7 @@
className
)}
editable={editable}
- extensions={[json()]}
+ _extensions={{ json: true }}
{...props}
/>
</Suspense>