diff --git a/package-lock.json b/package-lock.json index 4969271..fec13fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@doc-utils/color-themes": "^0.1.15", "@fastify/compress": "^6.4.0", "@fastify/etag": "^4.2.0", "@fastify/formbody": "^7.4.0", @@ -17,6 +18,7 @@ "fastify": "^4.19.2", "generic-pool": "^3.9.0", "luxon": "^3.3.0", + "mustache": "^4.2.0", "openid-client": "^5.4.3", "pino": "^8.14.1", "sqlite3": "^5.1.6", @@ -29,6 +31,11 @@ "typescript": "^5.1.3" } }, + "node_modules/@doc-utils/color-themes": { + "version": "0.1.15", + "resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.15/color-themes-0.1.15.tgz", + "integrity": "sha512-P0oIlq4Z0cUOf7P4T2OUfhT/Cn/msl3oHTenVWHxKMDPlIYueR6AhFdRCHTI0A+DxyH+0EIi6CnMq3JYnILI0w==" + }, "node_modules/@fastify/accept-negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", @@ -1379,6 +1386,14 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -2324,6 +2339,11 @@ } }, "dependencies": { + "@doc-utils/color-themes": { + "version": "0.1.15", + "resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.15/color-themes-0.1.15.tgz", + "integrity": "sha512-P0oIlq4Z0cUOf7P4T2OUfhT/Cn/msl3oHTenVWHxKMDPlIYueR6AhFdRCHTI0A+DxyH+0EIi6CnMq3JYnILI0w==" + }, "@fastify/accept-negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", @@ -3394,6 +3414,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==" + }, "negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", diff --git a/package.json b/package.json index 8b19093..449ed68 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "typescript": "^5.1.3" }, "dependencies": { + "@doc-utils/color-themes": "^0.1.15", "@fastify/compress": "^6.4.0", "@fastify/etag": "^4.2.0", "@fastify/formbody": "^7.4.0", @@ -30,6 +31,7 @@ "fastify": "^4.19.2", "generic-pool": "^3.9.0", "luxon": "^3.3.0", + "mustache": "^4.2.0", "openid-client": "^5.4.3", "pino": "^8.14.1", "sqlite3": "^5.1.6", diff --git a/readme.md b/readme.md index 52347d2..175a9b9 100644 --- a/readme.md +++ b/readme.md @@ -1,21 +1,8 @@ -Template project for creating new Node.js / TypeScript services +Configurable, self-hostable web dashboard --- -## Get Started - -### Pull down the code - -```bash -git init -git pull https://gitea.jbrumond.me/templates/nodejs-typescript-service.git master -``` - -### Update configuration - -- In `package.json`, update any fields like `name`, `description`, `repository`, etc. - ## Building from source @@ -36,7 +23,7 @@ APP_PATH="./build" DATA_PATH="./data" CONF_PATH="./conf" node ./build/start.js ## Building container image ```bash -docker build . -f Dockerfile -t nodejs-template-service:latest +docker build . -f Dockerfile -t dashboard:latest ``` diff --git a/src/conf.ts b/src/conf.ts index 4daa5c8..14a392c 100644 --- a/src/conf.ts +++ b/src/conf.ts @@ -11,6 +11,7 @@ import { LoggingConfig, validate_logging_conf } from './logger'; import { Argon2HashConfig } from './security/argon-hash'; import { SessionCookieConfig, validate_session_cookie_conf } from './security/session'; import { HttpConfig } from './http/server'; +import { ColorThemeConfig } from './utilities/color-themes'; const conf_dir = process.env.CONF_PATH; @@ -32,6 +33,7 @@ export interface Conf { snowflake_uid: SnowflakeConfig; storage: StorageConfig; argon2: Argon2HashConfig; + color_themes: ColorThemeConfig; } export async function load_conf() : Promise { diff --git a/src/http-web/assets/color-themes.ts b/src/http-web/assets/color-themes.ts new file mode 100644 index 0000000..025930b --- /dev/null +++ b/src/http-web/assets/color-themes.ts @@ -0,0 +1,20 @@ + +import { Req } from '../../http/request'; +import { HttpConfig } from '../../http/server'; +import { HttpWebDependencies } from '../server'; +import { csp_headers } from '../../http/content-security-policy'; +import { FastifyInstance, RouteShorthandOptions } from 'fastify'; + +export function register_color_themes_endpoint(http_server: FastifyInstance, conf: HttpConfig, { color_themes }: HttpWebDependencies) { + const opts: RouteShorthandOptions = { + schema: { }, + }; + + http_server.get('/themes.css', opts, async (req: Req, res) => { + res.status(200); + res.header('content-type', 'text/css; charset=utf-8'); + res.header('cache-control', 'public, max-age=3600'); + csp_headers(res, conf.exposed_url); + return color_themes.css; + }); +} diff --git a/src/http-web/assets/structure.ts b/src/http-web/assets/structure.ts new file mode 100644 index 0000000..e0ca144 --- /dev/null +++ b/src/http-web/assets/structure.ts @@ -0,0 +1,22 @@ + +import { Req } from '../../http/request'; +import { HttpConfig } from '../../http/server'; +import { HttpWebDependencies } from '../server'; +import { csp_headers } from '../../http/content-security-policy'; +import { FastifyInstance, RouteShorthandOptions } from 'fastify'; +import { load_template } from '../../utilities/mustache'; + +export function register_structure_endpoint(http_server: FastifyInstance, conf: HttpConfig, { }: HttpWebDependencies) { + const opts: RouteShorthandOptions = { + schema: { }, + }; + + http_server.get('/structure.css', opts, async (req: Req, res) => { + res.status(200); + res.header('content-type', 'text/css; charset=utf-8'); + res.header('cache-control', 'no-store'); + // res.header('cache-control', 'public, max-age=3600'); + csp_headers(res, conf.exposed_url); + return load_template('structure.css'); + }); +} diff --git a/src/http-web/assets/typography.ts b/src/http-web/assets/typography.ts new file mode 100644 index 0000000..79eef2c --- /dev/null +++ b/src/http-web/assets/typography.ts @@ -0,0 +1,22 @@ + +import { Req } from '../../http/request'; +import { HttpConfig } from '../../http/server'; +import { HttpWebDependencies } from '../server'; +import { csp_headers } from '../../http/content-security-policy'; +import { FastifyInstance, RouteShorthandOptions } from 'fastify'; +import { load_template } from '../../utilities/mustache'; + +export function register_typography_endpoint(http_server: FastifyInstance, conf: HttpConfig, { }: HttpWebDependencies) { + const opts: RouteShorthandOptions = { + schema: { }, + }; + + http_server.get('/typography.css', opts, async (req: Req, res) => { + res.status(200); + res.header('content-type', 'text/css; charset=utf-8'); + res.header('cache-control', 'no-store'); + // res.header('cache-control', 'public, max-age=3600'); + csp_headers(res, conf.exposed_url); + return load_template('typography.css'); + }); +} diff --git a/src/http-web/server.ts b/src/http-web/server.ts index f526ef3..86403af 100644 --- a/src/http-web/server.ts +++ b/src/http-web/server.ts @@ -14,6 +14,10 @@ import { register_login_page_endpoint } from './authentication/login-page'; import { register_submit_login_endpoint } from './authentication/submit-login'; import { register_login_callback_endpoint } from './authentication/login-callback'; import { register_logout_endpoint } from './authentication/logout'; +import { ColorThemeProvider } from '../utilities/color-themes'; +import { register_color_themes_endpoint } from './assets/color-themes'; +import { register_structure_endpoint } from './assets/structure'; +import { register_typography_endpoint } from './assets/typography'; export interface HttpWebDependencies extends BaseHttpDependencies { oidc: OIDCProvider; @@ -22,6 +26,7 @@ export interface HttpWebDependencies extends BaseHttpDependencies { argon2: Argon2HashProvider; session: SessionProvider; snowflake: SnowflakeProvider; + color_themes: ColorThemeProvider; } export function create_http_web_server(conf: HttpConfig, deps: HttpWebDependencies) { @@ -29,6 +34,11 @@ export function create_http_web_server(conf: HttpConfig, deps: HttpWebDependenci endpoints: [ register_csp_report_endpoint, + // Shared Assets + register_color_themes_endpoint, + register_structure_endpoint, + register_typography_endpoint, + // Root page register_root_page_endpoint, diff --git a/src/start.ts b/src/start.ts index 4f06edc..f45b56c 100644 --- a/src/start.ts +++ b/src/start.ts @@ -11,6 +11,7 @@ import { create_session_provider } from './security/session'; import { create_http_metadata_server } from './http-metadata/server'; import { HttpWebDependencies, create_http_web_server } from './http-web/server'; +import { create_color_theme_provider } from './utilities/color-themes'; main(); @@ -37,6 +38,7 @@ async function main() { const snowflake = create_snowflake_provider(conf.snowflake_uid); const argon2 = create_argon_hash_provider(conf.argon2); const session = create_session_provider(conf.session_cookie, logger, argon2, storage); + const color_themes = create_color_theme_provider(conf.color_themes); // Wait for any async init steps await oidc.ready; @@ -53,6 +55,7 @@ async function main() { storage, argon2, session, + color_themes, }; // Create the main web server diff --git a/src/utilities/color-themes.ts b/src/utilities/color-themes.ts new file mode 100644 index 0000000..b177b83 --- /dev/null +++ b/src/utilities/color-themes.ts @@ -0,0 +1,65 @@ + +import { ColorTheme, load_theme } from '@doc-utils/color-themes'; +import { render_template } from './mustache'; + +export interface ColorThemeConfig { + default: LightAndDarkTheme; + less_contrast?: LightAndDarkTheme; + more_contrast?: LightAndDarkTheme; +} + +export interface LightAndDarkTheme { + light: string; + dark: string; +} + +export type ColorThemeProvider = ReturnType; + +export function create_color_theme_provider(conf: ColorThemeConfig) { + let css: string; + const ready = setup(); + + return { + get ready() { + return ready; + }, + get css() { + return css; + }, + }; + + async function setup() { + const view = { + more_contrast: conf.more_contrast != null, + less_contrast: conf.less_contrast != null, + }; + + const partials = { + default_light: render_theme_css_properties(await load(conf.default.light)), + default_dark: render_theme_css_properties(await load(conf.default.dark)), + more_contrast_light: render_theme_css_properties(await load(conf.more_contrast?.light)), + more_contrast_dark: render_theme_css_properties(await load(conf.more_contrast?.dark)), + less_contrast_light: render_theme_css_properties(await load(conf.less_contrast?.light)), + less_contrast_dark: render_theme_css_properties(await load(conf.less_contrast?.dark)), + }; + + css = await render_template('themes.css.mustache', view, partials); + } +} + +function load(theme: string) { + if (theme) { + return load_theme(theme); + } + + return null; +} + +function render_theme_css_properties(theme: ColorTheme) { + return Object + .entries(theme.colors) + .map(([ name, color ]) => { + return `--theme-${name.replace(/_/g, '-')}: ${color};`; + }) + .join('\n') + '\n'; +} diff --git a/src/utilities/mustache.ts b/src/utilities/mustache.ts new file mode 100644 index 0000000..a6a9b54 --- /dev/null +++ b/src/utilities/mustache.ts @@ -0,0 +1,32 @@ + +import { render } from 'mustache'; +import { promises as fs } from 'fs'; +import { resolve as path_resolve } from 'path'; + +const templates: Record> = Object.create(null); +const template_path = path_resolve(__dirname, '../../templates'); + +export async function render_template(file: string, view: any, partials: Record = Object.create(null), partial_files: Record = Object.create(null)) { + const template = await load_template(file); + const partial_promises: Promise[] = [ ]; + + for (const [key, file] of Object.entries(partial_files)) { + const promise = load_template(file).then((template) => { + partials[key] = template; + }); + + partial_promises.push(promise); + } + + await Promise.all(partial_promises); + return render(template, view, partials); +} + +export function load_template(file: string) { + if (! templates[file]) { + const path = path_resolve(template_path, file); + templates[file] = fs.readFile(path, 'utf8'); + } + + return templates[file]; +} diff --git a/templates/login.html.mustache b/templates/login.html.mustache new file mode 100644 index 0000000..3614e79 --- /dev/null +++ b/templates/login.html.mustache @@ -0,0 +1,22 @@ + + + + Login + + + + + +
+ +
+ + {{# error_code }} +
+

Login failed

+ Error Code: {{ error_code }}
+ Error Message: {{ error.message }} +
+ {{/ error_code }} + + \ No newline at end of file diff --git a/templates/root.html.mustache b/templates/root.html.mustache new file mode 100644 index 0000000..36e2e2a --- /dev/null +++ b/templates/root.html.mustache @@ -0,0 +1,23 @@ + + + + Dashboard + + + + + +

Dashboard

+ + {{# user }} +

Logged in as {{ user.name }} ({{ user.username }})

+
+ +
+ {{/ user }} + + {{^ user }} + Login Page + {{/ user }} + + \ No newline at end of file diff --git a/templates/structure.css b/templates/structure.css new file mode 100644 index 0000000..5e86f86 --- /dev/null +++ b/templates/structure.css @@ -0,0 +1,17 @@ + +* { + box-sizing: border-box; +} + + + + + +/* ===== Forms ===== */ + +button, input:is([type='button'], [type='submit'], [type='reset']) { + appearance: none; + cursor: pointer; + border: 0; + padding: 0.5rem 1rem; +} diff --git a/templates/themes.css.mustache b/templates/themes.css.mustache new file mode 100644 index 0000000..0ebf253 --- /dev/null +++ b/templates/themes.css.mustache @@ -0,0 +1,78 @@ + +:root { + color-scheme: light dark; +} + + + +{{! ===== Default Themes ===== }} + +body { + {{> default_light }} +} + +body[data-color-scheme='dark'] { + {{> default_dark }} +} + +@media (prefers-color-scheme: dark) { + body { + {{> default_dark }} + } + + body[data-color-scheme='light'] { + {{> default_light }} + } +} + + + +{{! ===== High Contrast Themes ===== }} + +{{# more_contrast }} +@media (prefers-contrast: more) { + body { + {{> more_contrast_light }} + } + + body[data-color-scheme='dark'] { + {{> more_contrast_dark }} + } + + @media (prefers-color-scheme: dark) { + body { + {{> more_contrast_dark }} + } + + body[data-color-scheme='light'] { + {{> more_contrast_light }} + } + } +} +{{/ more_contrast }} + + + +{{! ===== Low Contrast Themes ===== }} + +{{# less_contrast }} +@media (prefers-contrast: less) { + body { + {{> less_contrast_light }} + } + + body[data-color-scheme='dark'] { + {{> less_contrast_dark }} + } + + @media (prefers-color-scheme: dark) { + body { + {{> less_contrast_dark }} + } + + body[data-color-scheme='light'] { + {{> less_contrast_light }} + } + } +} +{{/ less_contrast }} diff --git a/templates/typography.css b/templates/typography.css new file mode 100644 index 0000000..5aa9a99 --- /dev/null +++ b/templates/typography.css @@ -0,0 +1,151 @@ + +:root { + --font-heading: Verdana, sans-serif; + --font-body: Verdana, sans-serif; + --font-monospace: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + font-family: var(--font-body); + font-size: 16px; +} + +@media screen and (max-width: 800px) { + :root { + font-size: 14px; + } +} + +@media screen and (min-width: 2000px) { + :root { + font-size: 20px; + } +} + + + +/* ===== Font Families ===== */ + +h1, h2, h3, h4, h5, h6, +th, dt { + font-family: var(--font-heading); +} + +p, td, dd, figcaption, li, blockquote, +input, legend, fieldset, label, button { + font-family: var(--font-body); +} + +pre, code, samp { + font-family: var(--font-monospace); +} + +a, span, +b, i, u, q, +strong, em, mark, cite { + font-family: inherit; +} + + + +/* ===== Font Sizes ===== */ + +h1 { + font-size: 2.5rem; +} + +h2 { + font-size: 2rem; +} + +h3 { + font-size: 1.8rem; +} + +h4 { + font-size: 1.5rem; +} + +h5 { + font-size: 1.2rem; +} + +h6 { + font-size: 1rem; +} + +th, td, dt, dd, +p, figcaption, li, blockquote, +pre, +input, legend, fieldset, label, button { + font-size: 1rem; +} + +a, span, +b, i, u, q, +strong, em, mark, cite, +code, samp { + font-size: inherit; +} + + + +/* ===== Colors ===== */ + +::selection { + color: var(--theme-text-selection); + background: var(--theme-bg-text-selection); +} + +h1, h2, h3, h4, h5, h6, +th, dt { + color: var(--theme-text-heading) +} + +p, td, dd, figcaption, li, blockquote, +input, legend, fieldset, label { + color: var(--theme-text-body) +} + +button, input:is([type='button'], [type='submit'], [type='reset']) { + color: var(--theme-text-button-primary); + background: var(--theme-bg-button-primary); +} + +:is(button, input:is([type='button'], [type='submit'], [type='reset'])):hover { + background: var(--theme-bg-button-primary-hover); +} + +:is(button, input:is([type='button'], [type='submit'], [type='reset'])).secondary { + color: var(--theme-text-button-secondary); + background: var(--theme-bg-button-secondary); +} + +:is(button, input:is([type='button'], [type='submit'], [type='reset'])).secondary:hover { + background: var(--theme-bg-button-secondary-hover); +} + +pre, code, samp { + color: var(--theme-code-normal); +} + +a { + color: var(--theme-text-link); +} + +a:active { + color: var(--theme-text-link-active); +} + +a:visited { + color: var(--theme-text-link-visited); +} + +mark { + color: var(--theme-text-highlight); + background: var(--theme-bg-text-highlight); +} + +span, +b, i, u, q, +strong, em, cite { + color: inherit; +} +