diff --git a/README.md b/README.md index 0a53459..b60a344 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/app/src/ui/assets/poster.png) +![bknd](docs/_assets/poster.png) bknd simplifies app development by providing fully functional backend for data management, authentication, workflows and media. Since it's lightweight and built on Web Standards, it can diff --git a/app/build.ts b/app/build.ts index c1cd766..0f682c6 100644 --- a/app/build.ts +++ b/app/build.ts @@ -111,6 +111,9 @@ const result = await esbuild.build({ 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); + + // copy assets to static + await $`cp -r src/ui/assets/* dist/static/assets`; } /** diff --git a/app/package.json b/app/package.json index c4674bc..ab050af 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.5.0-rc6", + "version": "0.5.0-rc13", "scripts": { "build:all": "NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli", "dev": "vite", @@ -18,7 +18,7 @@ "updater": "bun x npm-check-updates -ui", "build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify", "cli": "LOCAL=1 bun src/cli/index.ts", - "prepublishOnly": "bun run build:all" + "prepublishOnly": "bun run test && bun run build:all" }, "license": "FSL-1.1-MIT", "dependencies": { diff --git a/app/src/App.ts b/app/src/App.ts index cc8af11..5382562 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -1,5 +1,8 @@ import type { CreateUserPayload } from "auth/AppAuth"; +import { auth } from "auth/middlewares"; +import { config } from "core"; import { Event } from "core/events"; +import { patternMatch } from "core/utils"; import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; import { type InitialModuleConfigs, @@ -71,6 +74,9 @@ export class App { this.trigger_first_boot = true; }, onServerInit: async (server) => { + server.get("/favicon.ico", (c) => + c.redirect(config.server.assets_path + "/favicon.ico") + ); server.use(async (c, next) => { c.set("app", this); await next(); @@ -159,7 +165,7 @@ export class App { registerAdminController(config?: AdminControllerOptions) { // register admin this.adminController = new AdminController(this, config); - this.modules.server.route("/", this.adminController.getController()); + this.modules.server.route(config?.basepath ?? "/", this.adminController.getController()); return this; } diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index e866414..dfbe1f3 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -91,10 +91,6 @@ export class AppAuth extends Module { return this._controller; } - override onServerInit(hono: Hono) { - hono.use(auth); - } - getSchema() { return authConfigSchema; } diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 0291461..553c477 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -11,6 +11,7 @@ export class AuthController extends Controller { } override getController() { + const { auth } = this.middlewares; const hono = this.create(); const strategies = this.auth.authenticator.getStrategies(); @@ -19,7 +20,7 @@ export class AuthController extends Controller { hono.route(`/${name}`, strategy.getController(this.auth.authenticator)); } - hono.get("/me", async (c) => { + hono.get("/me", auth(), async (c) => { if (this.auth.authenticator.isUserLoggedIn()) { return c.json({ user: await this.auth.authenticator.getUser() }); } @@ -27,7 +28,7 @@ export class AuthController extends Controller { return c.json({ user: null }, 403); }); - hono.get("/logout", async (c) => { + hono.get("/logout", auth(), async (c) => { await this.auth.authenticator.logout(c); if (this.auth.authenticator.isJsonRequest(c)) { return c.json({ ok: true }); diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index c86244d..770b199 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -13,7 +13,7 @@ import type { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { sign, verify } from "hono/jwt"; import type { CookieOptions } from "hono/utils/cookie"; -import { omit } from "lodash-es"; +import type { ServerEnv } from "modules/Module"; type Input = any; // workaround export type JWTPayload = Parameters[0]; @@ -101,7 +101,13 @@ export type AuthUserResolver = ( export class Authenticator = Record> { private readonly strategies: Strategies; private readonly config: AuthConfig; - private _user: SafeUser | undefined; + private _claims: + | undefined + | (SafeUser & { + iat: number; + iss?: string; + exp?: number; + }); private readonly userResolver: AuthUserResolver; constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) { @@ -134,16 +140,18 @@ export class Authenticator = Record< } isUserLoggedIn(): boolean { - return this._user !== undefined; + return this._claims !== undefined; } - getUser() { - return this._user; + getUser(): SafeUser | undefined { + if (!this._claims) return; + + const { iat, exp, iss, ...user } = this._claims; + return user; } - // @todo: determine what to do exactly resetUser() { - this._user = undefined; + this._claims = undefined; } strategy< @@ -157,6 +165,7 @@ export class Authenticator = Record< } } + // @todo: add jwt tests async jwt(user: Omit): Promise { const prohibited = ["password"]; for (const prop of prohibited) { @@ -203,7 +212,7 @@ export class Authenticator = Record< } } - this._user = omit(payload, ["iat", "exp", "iss"]) as SafeUser; + this._claims = payload as any; return true; } catch (e) { this.resetUser(); @@ -249,7 +258,7 @@ export class Authenticator = Record< } } - private async setAuthCookie(c: Context, token: string) { + private async setAuthCookie(c: Context, token: string) { const secret = this.config.jwt.secret; await setSignedCookie(c, "auth", token, secret, this.cookieOptions); } @@ -281,10 +290,12 @@ export class Authenticator = Record< const successPath = this.config.cookie.pathSuccess ?? "/"; const successUrl = new URL(c.req.url).origin + successPath.replace(/\/+$/, "/"); const referer = new URL(redirect ?? c.req.header("Referer") ?? successUrl); + console.log("auth respond", { redirect, successUrl, successPath }); if ("token" in data) { await this.setAuthCookie(c, data.token); // can't navigate to "/" – doesn't work on nextjs + console.log("auth success, redirecting to", successUrl); return c.redirect(successUrl); } @@ -294,6 +305,7 @@ export class Authenticator = Record< } await addFlashMessage(c, message, "error"); + console.log("auth failed, redirecting to", referer); return c.redirect(referer); } @@ -309,7 +321,7 @@ export class Authenticator = Record< if (token) { await this.verify(token); - return this._user; + return this.getUser(); } return undefined; diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 880d134..15e3af2 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -98,12 +98,16 @@ export class Guard { if (this.user && typeof this.user.role === "string") { const role = this.roles?.find((role) => role.name === this.user?.role); if (role) { - debug && console.log("guard: role found", this.user.role); + debug && console.log("guard: role found", [this.user.role]); return role; } } - debug && console.log("guard: role not found", this.user, this.user?.role); + debug && + console.log("guard: role not found", { + user: this.user, + role: this.user?.role + }); return this.getDefaultRole(); } diff --git a/app/src/auth/middlewares.ts b/app/src/auth/middlewares.ts index 9f1aebb..ce579aa 100644 --- a/app/src/auth/middlewares.ts +++ b/app/src/auth/middlewares.ts @@ -1,4 +1,5 @@ -import { type Permission, config } from "core"; +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"; @@ -8,51 +9,71 @@ function getPath(reqOrCtx: Request | Context) { return new URL(req.url).pathname; } -export function shouldSkipAuth(req: Request) { - const skip = getPath(req).startsWith(config.server.assets_path); - if (skip) { - //console.log("skip auth for", req.url); - } - return skip; +export function shouldSkip(c: Context, skip?: (string | RegExp)[]) { + if (c.get("auth_skip")) return true; + + const req = c.req.raw; + if (!skip) return false; + + const path = getPath(req); + const result = skip.some((s) => patternMatch(path, s)); + + c.set("auth_skip", result); + return result; } -export const auth = createMiddleware(async (c, next) => { - // make sure to only register once - if (c.get("auth_registered")) { - throw new Error(`auth middleware already registered for ${getPath(c)}`); - } - c.set("auth_registered", true); +export const auth = (options?: { + skip?: (string | RegExp)[]; +}) => + createMiddleware(async (c, next) => { + // make sure to only register once + if (c.get("auth_registered")) { + throw new Error(`auth middleware already registered for ${getPath(c)}`); + } + c.set("auth_registered", true); - const skipped = shouldSkipAuth(c.req.raw); - const app = c.get("app"); - const guard = app.modules.ctx().guard; - const authenticator = app.module.auth.authenticator; + 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)); - - // renew cookie if applicable - authenticator.requestCookieRefresh(c); + if (!skipped) { + const resolved = c.get("auth_resolved"); + if (!resolved) { + if (!app.module.auth.enabled) { + guard.setUserContext(undefined); + } else { + guard.setUserContext(await authenticator.resolveAuthFromRequest(c)); + c.set("auth_resolved", true); + } } } + + await next(); + + if (!skipped) { + // renew cookie if applicable + authenticator.requestCookieRefresh(c); + } + + // release + guard.setUserContext(undefined); + authenticator?.resetUser(); + c.set("auth_resolved", false); + }); + +export const permission = ( + permission: Permission | Permission[], + options?: { + onGranted?: (c: Context) => Promise; + onDenied?: (c: Context) => Promise; } - - await next(); - - // release - guard.setUserContext(undefined); - authenticator.resetUser(); - c.set("auth_resolved", false); -}); - -export const permission = (...permissions: Permission[]) => +) => + // @ts-ignore createMiddleware(async (c, next) => { const app = c.get("app"); + //console.log("skip?", c.get("auth_skip")); + // in tests, app is not defined if (!c.get("auth_registered")) { const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`; @@ -61,11 +82,22 @@ export const permission = (...permissions: Permission[]) => } else { console.warn(msg); } - } else if (!shouldSkipAuth(c.req.raw)) { - const p = Array.isArray(permissions) ? permissions : [permissions]; + } else if (!c.get("auth_skip")) { const guard = app.modules.ctx().guard; - for (const permission of p) { - guard.throwUnlessGranted(permission); + 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)); } } diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index 309df7f..3038181 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -1,5 +1,6 @@ import path from "node:path"; import type { Config } from "@libsql/client/node"; +import { config } from "core"; import type { MiddlewareHandler } from "hono"; import open from "open"; import { fileExists, getRelativeDistPath } from "../../utils/sys"; @@ -26,7 +27,7 @@ export async function serveStatic(server: Platform): Promise } export async function attachServeStatic(app: any, platform: Platform) { - app.module.server.client.get("/*", await serveStatic(platform)); + app.module.server.client.get(config.server.assets_path + "*", await serveStatic(platform)); } export async function startServer(server: Platform, app: any, options: { port: number }) { diff --git a/app/src/core/server/flash.ts b/app/src/core/server/flash.ts index c64753b..aeac431 100644 --- a/app/src/core/server/flash.ts +++ b/app/src/core/server/flash.ts @@ -4,14 +4,12 @@ import { setCookie } from "hono/cookie"; const flash_key = "__bknd_flash"; export type FlashMessageType = "error" | "warning" | "success" | "info"; -export async function addFlashMessage( - c: Context, - message: string, - type: FlashMessageType = "info" -) { - setCookie(c, flash_key, JSON.stringify({ type, message }), { - path: "/" - }); +export function addFlashMessage(c: Context, message: string, type: FlashMessageType = "info") { + if (c.req.header("Accept")?.includes("text/html")) { + setCookie(c, flash_key, JSON.stringify({ type, message }), { + path: "/" + }); + } } function getCookieValue(name) { diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts index a5a7c72..c7789dd 100644 --- a/app/src/core/utils/strings.ts +++ b/app/src/core/utils/strings.ts @@ -104,3 +104,14 @@ export function replaceSimplePlaceholders(str: string, vars: Record return key in vars ? vars[key] : match; }); } + +export function patternMatch(target: string, pattern: RegExp | string): boolean { + if (pattern instanceof RegExp) { + return pattern.test(target); + } else if (typeof pattern === "string" && pattern.startsWith("/")) { + return new RegExp(pattern).test(target); + } else if (typeof pattern === "string") { + return target.startsWith(pattern); + } + return false; +} diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index b94b7b0..497ffa9 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -69,8 +69,9 @@ export class DataController extends Controller { } override getController() { - const hono = this.create(); - const { permission } = this.middlewares; + const { permission, auth } = this.middlewares; + const hono = this.create().use(auth()); + const definedEntities = this.em.entities.map((e) => e.name); const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" })) .Decode(Number.parseInt) diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 8f21483..e469830 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -27,8 +27,8 @@ export class MediaController extends Controller { override getController() { // @todo: multiple providers? // @todo: implement range requests - - const hono = this.create(); + const { auth } = this.middlewares; + const hono = this.create().use(auth()); // get files list (temporary) hono.get("/files", async (c) => { diff --git a/app/src/modules/Controller.ts b/app/src/modules/Controller.ts index 99aace5..4d50a80 100644 --- a/app/src/modules/Controller.ts +++ b/app/src/modules/Controller.ts @@ -2,11 +2,13 @@ import { auth, permission } from "auth/middlewares"; import { Hono } from "hono"; import type { ServerEnv } from "modules/Module"; +const middlewares = { + auth, + permission +} as const; + export class Controller { - protected middlewares = { - auth, - permission - }; + protected middlewares = middlewares; protected create(): Hono { return Controller.createServer(); diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index fe08341..c217686 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -14,6 +14,8 @@ export type ServerEnv = { auth_resolved: boolean; // to only register once auth_registered: boolean; + // whether or not to bypass auth + auth_skip: boolean; html?: string; }; }; diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index 2bf65b2..dd918c4 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -47,7 +47,13 @@ export class AdminController extends Controller { } override getController() { - const hono = this.create().basePath(this.withBasePath()); + const { auth: authMiddleware, permission } = this.middlewares; + const hono = this.create().use( + authMiddleware({ + skip: [/favicon\.ico$/] + }) + ); + const auth = this.app.module.auth; const configs = this.app.modules.configs(); // if auth is not enabled, authenticator is undefined @@ -78,16 +84,17 @@ export class AdminController extends Controller { }); if (auth_enabled) { - hono.get(authRoutes.login, async (c) => { - if ( - this.app.module.auth.authenticator?.isUserLoggedIn() && - this.ctx.guard.granted(SystemPermissions.accessAdmin) - ) { - return c.redirect(authRoutes.success); + hono.get( + authRoutes.login, + permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], { + onGranted: async (c) => { + return c.redirect(authRoutes.success); + } + }), + async (c) => { + return c.html(c.get("html")!); } - - return c.html(c.get("html")!); - }); + ); hono.get(authRoutes.logout, async (c) => { await auth.authenticator?.logout(c); @@ -95,14 +102,25 @@ export class AdminController extends Controller { }); } - hono.get("*", async (c) => { - if (!this.ctx.guard.granted(SystemPermissions.accessAdmin)) { - await addFlashMessage(c, "You are not authorized to access the Admin UI", "error"); - return c.redirect(authRoutes.login); - } + hono.get( + "/*", + permission(SystemPermissions.accessAdmin, { + onDenied: async (c) => { + addFlashMessage(c, "You are not authorized to access the Admin UI", "error"); - return c.html(c.get("html")!); - }); + console.log("redirecting"); + return c.redirect(authRoutes.login); + } + }), + permission(SystemPermissions.schemaRead, { + onDenied: async (c) => { + addFlashMessage(c, "You not allowed to read the schema", "warning"); + } + }), + async (c) => { + return c.html(c.get("html")!); + } + ); return hono; } @@ -150,6 +168,7 @@ export class AdminController extends Controller { } const theme = configs.server.admin.color_scheme ?? "light"; + const favicon = isProd ? this.options.assets_path + "favicon.ico" : "/favicon.ico"; return ( @@ -162,6 +181,7 @@ export class AdminController extends Controller { name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> + BKND {isProd ? ( diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index e7810b3..5f980b5 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -38,8 +38,8 @@ export class SystemController extends Controller { } private registerConfigController(client: Hono): void { - const hono = this.create(); const { permission } = this.middlewares; + const hono = this.create(); hono.use(permission(SystemPermissions.configRead)); @@ -202,8 +202,8 @@ export class SystemController extends Controller { } override getController() { - const hono = this.create(); - const { permission } = this.middlewares; + const { permission, auth } = this.middlewares; + const hono = this.create().use(auth()); this.registerConfigController(hono); diff --git a/app/vite.config.ts b/app/vite.config.ts index 5580f35..530c0c1 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ __isDev: "1" }, clearScreen: false, - publicDir: "./src/admin/assets", + publicDir: "./src/ui/assets", server: { host: true, port: 28623, diff --git a/app/src/ui/assets/poster.png b/docs/_assets/poster.png similarity index 100% rename from app/src/ui/assets/poster.png rename to docs/_assets/poster.png