diff --git a/conf/00-default.yaml b/conf/00-default.yaml index 3aa3525..2207c3e 100644 --- a/conf/00-default.yaml +++ b/conf/00-default.yaml @@ -69,3 +69,6 @@ color_themes: more_contrast: light: Minimal Light dark: Minimal Dark +outbound_http: + https_only: false +services: { } diff --git a/conf/01-local-test.yaml b/conf/01-local-test.yaml index ae47d3a..35b7850 100644 --- a/conf/01-local-test.yaml +++ b/conf/01-local-test.yaml @@ -13,3 +13,28 @@ session_cookie: logging: level: debug pretty: true +services: + # + # Docs: + # Access: + openweathermap.org: + enabled: true + latitude: 45.49607 + longitude: -122.67139 + api_key: '' + + # + # Docs: + # Access: + weatherapi.com: + enabled: true + api_key: e18fb4e3257d4adaa6911347231607 + + # US only; Provides weather forecasts and alerts. + # Docs: + # Access: Free; Set a User-Agent below to identify yourself (see docs) + weather.gov: + enabled: true + latitude: 45.4961 + longitude: -122.6714 + user_agent: (local.jbrumond.me, james@jbrumond.me) diff --git a/package-lock.json b/package-lock.json index 3465a9f..5511be5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@doc-utils/color-themes": "^0.1.15", + "@doc-utils/color-themes": "^0.1.16", "@fastify/compress": "^6.4.0", "@fastify/etag": "^4.2.0", "@fastify/formbody": "^7.4.0", @@ -25,6 +25,8 @@ "yaml": "^2.3.1" }, "devDependencies": { + "@types/jsonld": "^1.5.9", + "@types/luxon": "^3.3.1", "@types/mustache": "^4.2.2", "@types/node": "^20.4.2", "json-schema": "^0.4.0", @@ -33,9 +35,9 @@ } }, "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==" + "version": "0.1.16", + "resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.16/color-themes-0.1.16.tgz", + "integrity": "sha512-6H0clUE0+mRQXbAHLinLSg8YfG3KkZ9zRhbngUtpwPbUHVt9WrnMkmT9rg2VXb+C+35oM6bRPX3QcJxmZESoLw==" }, "node_modules/@fastify/accept-negotiator": { "version": "1.1.0", @@ -171,6 +173,18 @@ "node": ">= 6" } }, + "node_modules/@types/jsonld": { + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/@types/jsonld/-/jsonld-1.5.9.tgz", + "integrity": "sha512-K76ImkErPYL2wGPZpNFSKp6wE+h/APecZLJrU7UfDaGqt/f+D9Rrg1aR7VdRrQ6k5DUNRZ2vn9yACwmpOr9QcA==", + "dev": true + }, + "node_modules/@types/luxon": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.1.tgz", + "integrity": "sha512-XOS5nBcgEeP2PpcqJHjCWhUCAzGfXIU8ILOSLpx2FhxqMW9KdxgCGXNOEKGVBfveKtIpztHzKK5vSRVLyW/NqA==", + "dev": true + }, "node_modules/@types/mustache": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.2.tgz", @@ -2347,9 +2361,9 @@ }, "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==" + "version": "0.1.16", + "resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.16/color-themes-0.1.16.tgz", + "integrity": "sha512-6H0clUE0+mRQXbAHLinLSg8YfG3KkZ9zRhbngUtpwPbUHVt9WrnMkmT9rg2VXb+C+35oM6bRPX3QcJxmZESoLw==" }, "@fastify/accept-negotiator": { "version": "1.1.0", @@ -2469,6 +2483,18 @@ "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", "optional": true }, + "@types/jsonld": { + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/@types/jsonld/-/jsonld-1.5.9.tgz", + "integrity": "sha512-K76ImkErPYL2wGPZpNFSKp6wE+h/APecZLJrU7UfDaGqt/f+D9Rrg1aR7VdRrQ6k5DUNRZ2vn9yACwmpOr9QcA==", + "dev": true + }, + "@types/luxon": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.1.tgz", + "integrity": "sha512-XOS5nBcgEeP2PpcqJHjCWhUCAzGfXIU8ILOSLpx2FhxqMW9KdxgCGXNOEKGVBfveKtIpztHzKK5vSRVLyW/NqA==", + "dev": true + }, "@types/mustache": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.2.tgz", diff --git a/package.json b/package.json index 78a7eac..8e9b736 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "author": "James Brumond ", "license": "ISC", "devDependencies": { + "@types/jsonld": "^1.5.9", + "@types/luxon": "^3.3.1", "@types/mustache": "^4.2.2", "@types/node": "^20.4.2", "json-schema": "^0.4.0", @@ -23,7 +25,7 @@ "typescript": "^5.1.3" }, "dependencies": { - "@doc-utils/color-themes": "^0.1.15", + "@doc-utils/color-themes": "^0.1.16", "@fastify/compress": "^6.4.0", "@fastify/etag": "^4.2.0", "@fastify/formbody": "^7.4.0", diff --git a/src/conf.ts b/src/conf.ts index 14a392c..ae58ba7 100644 --- a/src/conf.ts +++ b/src/conf.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'fs'; import { join as path_join } from 'path'; import { parse as parse_yaml } from 'yaml'; -import { deep_merge } from './utilities/deep-merge'; +import { deep_merge } from './utilities/deep'; import { OIDCConfig, validate_oidc_conf } from './security/openid-connect'; import { PKCECookieConfig, validate_pkce_cookie_conf } from './security/pkce-cookie'; import { SnowflakeConfig, validate_snowflake_conf } from './utilities/snowflake-uid'; @@ -12,6 +12,8 @@ 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'; +import { ServicesConfig, validate_services_conf } from './services'; +import { OutboundHttpConfig, validate_outbound_http_conf } from './http/outbound'; const conf_dir = process.env.CONF_PATH; @@ -34,6 +36,22 @@ export interface Conf { storage: StorageConfig; argon2: Argon2HashConfig; color_themes: ColorThemeConfig; + outbound_http: OutboundHttpConfig; + services: ServicesConfig; +} + +export type SecretValue = string | { from_env: string; }; + +export function resolve_secret_value(value: SecretValue) { + if (typeof value === 'string') { + return value; + } + + if (! value?.from_env) { + return null; + } + + return process.env[value.from_env]; } export async function load_conf() : Promise { @@ -100,4 +118,20 @@ export function validate_conf(conf: unknown) : asserts conf is Conf { else { throw new Error('`conf.session_cookie` is missing'); } + + if ('outbound_http' in conf) { + validate_outbound_http_conf(conf.outbound_http); + } + + else { + throw new Error('`conf.outbound_http` is missing'); + } + + if ('services' in conf) { + validate_services_conf(conf.services); + } + + else { + throw new Error('`conf.services` is missing'); + } } diff --git a/src/http-web/assets.ts b/src/http-web/assets.ts new file mode 100644 index 0000000..ab010b4 --- /dev/null +++ b/src/http-web/assets.ts @@ -0,0 +1,41 @@ + +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_asset_endpoints(http_server: FastifyInstance, conf: HttpConfig, { color_themes }: HttpWebDependencies) { + const opts: RouteShorthandOptions = { + schema: { }, + }; + + endpoint('forms.css', 'text/css; charset=utf-8'); + endpoint('popup.css', 'text/css; charset=utf-8'); + endpoint('structure.css', 'text/css; charset=utf-8'); + endpoint('typography.css', 'text/css; charset=utf-8'); + endpoint('color-theme-controls.css', 'text/css; charset=utf-8'); + endpoint('color-theme-controls.js', 'text/javascript'); + + endpoint('weather.gov/styles.css', 'text/css; charset=utf-8'); + endpoint('weatherapi.com/styles.css', 'text/css; charset=utf-8'); + + 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; + }); + + function endpoint(path: string, media_type: string) { + http_server.get('/' + path, opts, async (req: Req, res) => { + res.status(200); + res.header('content-type', media_type); + res.header('cache-control', 'public, max-age=3600'); + csp_headers(res, conf.exposed_url); + return load_template(path); + }); + } +} diff --git a/src/http-web/assets/color-themes.ts b/src/http-web/assets/color-themes.ts deleted file mode 100644 index 025930b..0000000 --- a/src/http-web/assets/color-themes.ts +++ /dev/null @@ -1,20 +0,0 @@ - -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/typography.ts b/src/http-web/assets/typography.ts deleted file mode 100644 index 79eef2c..0000000 --- a/src/http-web/assets/typography.ts +++ /dev/null @@ -1,22 +0,0 @@ - -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/authentication/login-callback.ts b/src/http-web/authentication/login-callback.ts index 42c04ea..5cca4fc 100644 --- a/src/http-web/authentication/login-callback.ts +++ b/src/http-web/authentication/login-callback.ts @@ -1,17 +1,18 @@ import * as sch from '../../utilities/json-schema'; -import { render_login_page } from './login-page'; import { send_html_error } from '../../http/send-error'; import { redirect_200_refresh } from '../../http/redirects'; +import { render_template } from '../../utilities/mustache'; import type { HttpConfig } from '../../http/server'; import type { HttpWebDependencies } from '../server'; import type { HttpURL, Locale, Timezone } from '../../utilities/types'; import type { FastifyInstance, FastifyRequest, RouteShorthandOptions } from 'fastify'; import type { UserinfoResponse } from 'openid-client'; +import type { ErrorCode, ErrorInfo } from '../../http/send-error'; export function register_login_callback_endpoint(http_server: FastifyInstance, conf: HttpConfig, deps: HttpWebDependencies) { - const { logger, pkce_cookie, oidc, storage, snowflake, session, argon2 } = deps; + const { logger, pkce_cookie, oidc, storage, snowflake, session } = deps; const opts: RouteShorthandOptions = { schema: { response: { @@ -54,13 +55,13 @@ export function register_login_callback_endpoint(http_server: FastifyInstance, c if (! pkce_code_verifier) { log.debug('no pkce code verifier provided'); - return send_html_error(res, 'oidc_no_pkce_code_verifier', render_login_page); + return send_html_error(res, 'oidc_no_pkce_code_verifier', render_login_failed_page); } const params = oidc.parse_callback_params(req.url); if (! params) { - return send_html_error(res, 'oidc_callback_params_invalid', render_login_page); + return send_html_error(res, 'oidc_callback_params_invalid', render_login_failed_page); } log.debug({ callback_params: params }, 'received callback params'); @@ -68,7 +69,7 @@ export function register_login_callback_endpoint(http_server: FastifyInstance, c const token_set = await oidc.fetch_token_set(`${conf.exposed_url}/login-callback`, params, pkce_code_verifier); if (! token_set) { - return send_html_error(res, 'oidc_token_fetch_failed', render_login_page); + return send_html_error(res, 'oidc_token_fetch_failed', render_login_failed_page); } log.debug('fetched token set; requesting user info'); @@ -76,7 +77,7 @@ export function register_login_callback_endpoint(http_server: FastifyInstance, c const user_info = await oidc.fetch_user_info(token_set); if (! user_info) { - return send_html_error(res, 'oidc_userinfo_fetch_failed', render_login_page); + return send_html_error(res, 'oidc_userinfo_fetch_failed', render_login_failed_page); } log.debug({ oidc_subject: user_info.sub }, 'fetched user info; looking up local user data'); @@ -124,3 +125,19 @@ function get_zoneinfo(user_info: UserinfoResponse) : Timezone { const from_userinfo = user_info.zoneinfo as Timezone; return from_userinfo || 'Africa/Abidjan'; } + +function render_login_failed_page(error_code?: ErrorCode, error?: ErrorInfo) { + const view = { + login_error_code: error_code, + login_error: error, + page_title: 'Login Failed', + }; + + const partial_files = { + controls: 'controls.html.mustache', + color_theme_controls: 'color-theme-controls.html.mustache', + page_content: 'login-failed.html.mustache', + }; + + return render_template('page.html.mustache', view, { }, partial_files); +} diff --git a/src/http-web/authentication/login-page.ts b/src/http-web/authentication/login-page.ts deleted file mode 100644 index e4a4eda..0000000 --- a/src/http-web/authentication/login-page.ts +++ /dev/null @@ -1,43 +0,0 @@ - -import { HttpConfig } from '../../http/server'; -import { HttpWebDependencies } from '../server'; -import { ErrorCode, ErrorInfo } from '../../http/send-error'; -import { redirect_303_see_other } from '../../http/redirects'; -import { FastifyInstance, FastifyReply, RouteShorthandOptions } from 'fastify'; -import { csp_headers } from '../../http/content-security-policy'; -import { render_template } from '../../utilities/mustache'; - -export function register_login_page_endpoint(http_server: FastifyInstance, conf: HttpConfig, { pkce_cookie, session }: HttpWebDependencies) { - const opts: RouteShorthandOptions = { - schema: { }, - }; - - http_server.get('/login', opts, async (req, res) => { - try { - await session.check_login(req); - return redirect_303_see_other(res, conf.exposed_url, 'Already logged in'); - } - - catch (error) { - return send_login_page(res); - } - }); - - function send_login_page(res: FastifyReply) { - res.status(200); - res.header('content-type', 'text/html; charset=utf-8'); - csp_headers(res, conf.exposed_url); - session.reset(res); - pkce_cookie.reset(res); - return render_login_page(); - } -} - -export function render_login_page(error_code?: ErrorCode, error?: ErrorInfo) { - const view = { - error_code, - error, - }; - - return render_template('login.html.mustache', view); -} diff --git a/src/http-web/root-page.ts b/src/http-web/root-page.ts index fd02237..a62207a 100644 --- a/src/http-web/root-page.ts +++ b/src/http-web/root-page.ts @@ -6,8 +6,9 @@ import { HttpWebDependencies } from './server'; import { csp_headers } from '../http/content-security-policy'; import { FastifyInstance, RouteShorthandOptions } from 'fastify'; import { render_template } from '../utilities/mustache'; +import type { NamedLocation } from '../storage/named-location'; -export function register_root_page_endpoint(http_server: FastifyInstance, conf: HttpConfig, { session, logger }: HttpWebDependencies) { +export function register_root_page_endpoint(http_server: FastifyInstance, conf: HttpConfig, { session, services }: HttpWebDependencies) { const opts: RouteShorthandOptions = { schema: { }, }; @@ -21,17 +22,47 @@ export function register_root_page_endpoint(http_server: FastifyInstance, conf: session.reset(res); } + const location: NamedLocation = { + name: 'Home', + latitude: 45.4961, + longitude: -122.6714, + }; + + const [ + { value: forecast }, + { value: alerts }, + { value: weather } + ] = await Promise.all([ + services.weather_gov.get_forecast_for_location(location, 'us'), + services.weather_gov.get_alerts_for_location(location), + services.weatherapi_com.get_current_for_location(location), + ]); + + const rendered_widgets = await Promise.all([ + services.weatherapi_com.render_current_weather(location, weather), + services.weather_gov.render_forecast(location, forecast, alerts), + ]); + res.status(200); + res.header('cache-control', 'private, no-cache'); res.header('content-type', 'text/html; charset=utf-8'); csp_headers(res, conf.exposed_url); - return render_root_page(req.session?.user); + return render_root_page(req.session?.user, rendered_widgets); }); } -function render_root_page(user: UserData) { +function render_root_page(user: UserData, rendered_widgets: string[]) { const view = { user, + page_title: 'Dashboard', + rendered_widgets, }; - return render_template('root.html.mustache', view); + const partial_files = { + controls: 'controls.html.mustache', + color_theme_controls: 'color-theme-controls.html.mustache', + page_content: 'root.html.mustache', + }; + + return render_template('page.html.mustache', view, { }, partial_files); } diff --git a/src/http-web/server.ts b/src/http-web/server.ts index 57f68ed..cafec99 100644 --- a/src/http-web/server.ts +++ b/src/http-web/server.ts @@ -9,14 +9,15 @@ import { BaseHttpDependencies, HttpConfig, create_http_server } from '../http/se import { SnowflakeProvider } from '../utilities/snowflake-uid'; import { register_csp_report_endpoint } from '../http/content-security-policy'; +import { register_asset_endpoints } from './assets'; import { register_root_page_endpoint } from './root-page'; -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_typography_endpoint } from './assets/typography'; +import { OutboundHttpProvider } from '../http/outbound'; +import { ServicesProvider } from '../services'; +import { RSSFeedReaderProvider } from '../utilities/rss-feeds'; export interface HttpWebDependencies extends BaseHttpDependencies { oidc: OIDCProvider; @@ -26,25 +27,24 @@ export interface HttpWebDependencies extends BaseHttpDependencies { session: SessionProvider; snowflake: SnowflakeProvider; color_themes: ColorThemeProvider; + outbound_http: OutboundHttpProvider; + rss_feed_reader: RSSFeedReaderProvider; + services: ServicesProvider; } export function create_http_web_server(conf: HttpConfig, deps: HttpWebDependencies) { return create_http_server(conf, deps, { endpoints: [ register_csp_report_endpoint, - - // Shared Assets - register_color_themes_endpoint, - register_typography_endpoint, - - // Root page - register_root_page_endpoint, + register_asset_endpoints, // Login/logout - register_login_page_endpoint, register_submit_login_endpoint, register_login_callback_endpoint, register_logout_endpoint, + + // Pages + register_root_page_endpoint, ], content_parsers: { // 'application/ld+json': json_content_parser, diff --git a/src/http/outbound.ts b/src/http/outbound.ts new file mode 100644 index 0000000..013f4a3 --- /dev/null +++ b/src/http/outbound.ts @@ -0,0 +1,200 @@ + +import pino from 'pino'; +import { URL } from 'url'; +import { request as http_request, OutgoingHttpHeaders, IncomingHttpHeaders } from 'http'; +import { request as https_request } from 'https'; +import { create_request_cache_provider } from './request-cache'; +import { HttpURL } from '../utilities/types'; +import { deep_copy } from '../utilities/deep'; + +let next_id = 1; +const timeout = 5000; + +export interface HttpResult { + url: URL; + // req: ClientRequest; + // res: IncomingMessage; + status: number; + body: string; + headers: IncomingHttpHeaders; +} + +export interface OutboundHttpConfig { + https_only: boolean; +} + +export function validate_outbound_http_conf(conf: unknown) : asserts conf is OutboundHttpConfig { + // todo: validate config +} + +export type OutboundHttpProvider = ReturnType; + +export function create_outbound_http_provider(conf: OutboundHttpConfig, logger: pino.Logger) { + const http_log = logger.child({ logger: 'outbound-http' }); + const response_cache = create_request_cache_provider({ }, http_log); + + return { + req: http_req, + get(url_str: HttpURL, headers?: OutgoingHttpHeaders, skip_cache = false) { + return http_req('GET', url_str, headers, null, skip_cache); + }, + head(url_str: HttpURL, headers?: OutgoingHttpHeaders, skip_cache = false) { + return http_req('HEAD', url_str, headers, null, skip_cache); + }, + post(url_str: HttpURL, headers?: OutgoingHttpHeaders, body?: string, skip_cache = false) { + return http_req('POST', url_str, headers, body, skip_cache); + }, + put(url_str: HttpURL, headers?: OutgoingHttpHeaders, body?: string, skip_cache = false) { + return http_req('PUT', url_str, headers, body, skip_cache); + }, + patch(url_str: HttpURL, headers?: OutgoingHttpHeaders, body?: string, skip_cache = false) { + return http_req('PATCH', url_str, headers, body, skip_cache); + }, + delete(url_str: HttpURL, headers?: OutgoingHttpHeaders, body?: string, skip_cache = false) { + return http_req('DELETE', url_str, headers, body, skip_cache); + }, + }; + + async function http_req(method: string, url_str: HttpURL, headers?: OutgoingHttpHeaders, body?: string, skip_cache = false) { + const url = new URL(url_str); + const make_request + = url.protocol === 'https:' ? https_request + : url.protocol === 'http:' ? http_request + : null; + + if (! make_request) { + throw new Error('illegal protocol'); + } + + if (url.protocol !== 'https:' && conf.https_only) { + throw new Error('non-secure http requests not allowed (by configuration)'); + } + + if (url.username || url.password) { + throw new Error('urls containing user credentials not allowed'); + } + + const req_headers = headers ? deep_copy(headers) : { }; + + const result: HttpResult = { + url, + // req: null, + // res: null, + status: null, + body: null, + headers: null, + }; + + const path = url.pathname + (url.search || ''); + const port = url.port ? parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80); + const log = http_log.child({ + req_id: next_id++, + method: method, + protocol: url.protocol, + hostname: url.hostname, + port: port, + path: path, + }); + + const cached = skip_cache + ? null + : response_cache.check_for_cached_response(method, url_str, req_headers); + + if (cached) { + const revalidation = response_cache.determine_revalidation_needed(cached); + + if (! revalidation.must_revalidate) { + log.info('serving request from cache (no revalidation required)'); + result.status = cached.status; + result.body = cached.body; + result.headers = deep_copy(cached.res_headers); + return Promise.resolve(result); + } + + log.info('cached response found, but requires revalidation'); + Object.assign(req_headers, revalidation.headers); + } + + log.info('starting outbound http request'); + + return new Promise((resolve, reject) => { + const start = Date.now(); + const req = make_request({ + method: method, + protocol: url.protocol, + hostname: url.hostname, + port: port, + path: path, + headers: req_headers + }, (res) => { + // result.res = res; + result.status = res.statusCode; + + log.info({ + status: res.statusCode, + duration: Date.now() - start, + }, `headers received`); + + switch (result.status) { + case 304: + log.info({ status: cached.status }, 're-using cached response (not modified)'); + result.status = cached.status; + result.body = cached.body; + result.headers = deep_copy(cached.res_headers); + response_cache.freshen_cache(cached, res.headers); + resolve(result); + break; + + case 500: + case 502: + case 503: + case 504: + if (cached?.cache_info?.cache_control?.stale_if_error) { + log.info({ status: cached.status }, 're-using cached response (response returned an error status, and `stale-if-error` directive was provided)'); + result.status = cached.status; + result.body = cached.body; + result.headers = deep_copy(cached.res_headers); + resolve(result); + break; + } + // nobreak + + default: { + const start = Date.now(); + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + log.info({ duration: Date.now() - start }, `response finished`); + result.body = data; + result.headers = res.headers; + response_cache.add_response_to_cache(method, url_str, req_headers, result); + resolve(result); + }); + }; + } + }); + + // result.req = req; + + req.setTimeout(timeout, () => { + log.error('request timeout'); + req.destroy(); + }); + + req.on('error', (error) => { + log.error('request error ' + error.stack); + reject(error); + }); + + if (body) { + req.write(body); + } + + req.end(); + }); + } +} diff --git a/src/http/parse-cache-headers.ts b/src/http/parse-cache-headers.ts index 9e51507..cd1bf9b 100644 --- a/src/http/parse-cache-headers.ts +++ b/src/http/parse-cache-headers.ts @@ -17,8 +17,13 @@ export interface CacheControl { no_store?: boolean; no_cache?: boolean; max_age?: number; + s_max_age?: number; must_revalidate?: boolean; proxy_revalidate?: boolean; + immutable?: boolean; + stale_while_revalidate?: boolean; + stale_if_error?: boolean; + must_understand?: boolean; } export function parse_cache_headers(headers: IncomingHttpHeaders) { @@ -62,12 +67,29 @@ export function parse_cache_headers(headers: IncomingHttpHeaders) { case 'proxy-revalidate': result.cache_control.proxy_revalidate = true; break; + case 'immutable': + result.cache_control.immutable = true; + break; + case 'stale-while-revalidate': + result.cache_control.stale_while_revalidate = true; + break; + case 'stale-if-error': + result.cache_control.stale_if_error = true; + break; + case 'must-understand': + result.cache_control.must_understand = true; + break; default: if (directive.startsWith('max-age=')) { result.cache_control.max_age = parseInt(directive.slice(8), 10); break; } + if (directive.startsWith('s-max-age=')) { + result.cache_control.s_max_age = parseInt(directive.slice(10), 10); + break; + } + // todo: log something here about unknown directive } } diff --git a/src/http/request-cache.ts b/src/http/request-cache.ts new file mode 100644 index 0000000..9ab0d82 --- /dev/null +++ b/src/http/request-cache.ts @@ -0,0 +1,228 @@ + +import { pino } from 'pino'; +import { DateTime } from 'luxon'; +import { deep_copy } from '../utilities/deep'; +import { OutgoingHttpHeaders, IncomingHttpHeaders } from 'http'; +import { parse_cache_headers, ParsedCacheHeaders } from './parse-cache-headers'; +import type { HttpURL } from '../utilities/types'; +import type { HttpResult } from './outbound'; + +export interface RequestCacheConfig { + // todo: support for configurable limits: + max_records?: number; // max number of cached responses + max_length?: number; // max content-length for one response + evict_after?: number; // period of time unused after which a response should be evicted +} + +export interface CachedResponse { + cache_key: string; + cached_time: DateTime; + is_stale: boolean; + url: HttpURL; + method: string; + req_headers: OutgoingHttpHeaders; + status: number; + body: string; + res_headers: IncomingHttpHeaders; + cache_info: ParsedCacheHeaders; + expire_timer: NodeJS.Timeout; +} + +export function validate_request_cache_conf(conf: unknown) : asserts conf is RequestCacheConfig { + // todo: validate config +} + +export type RequestCacheProvider = ReturnType; + +export function create_request_cache_provider(conf: RequestCacheConfig, logger: pino.Logger) { + type URLCache = Record; + const cached_responses: Record = Object.create(null); + + const cacheable_methods = new Set([ 'GET', 'HEAD' ]); + const cacheable_status_codes = new Set([ + 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501 + ]); + + return { + /** + * Should be called before making a new request to check if a cached response already exists + */ + check_for_cached_response(method: string, url_str: HttpURL, headers?: OutgoingHttpHeaders) : Readonly { + if (! cached_responses[url_str]) { + return; + } + + for (const [cache_key, cached] of Object.entries(cached_responses[url_str])) { + if (cache_key === header_cache_key(method, cached.cache_info.vary, headers)) { + return cached; + } + } + }, + + /** + * Should be called when a cached response is found, to determine what if any revalidation is + * required before re-using the response + */ + determine_revalidation_needed(cached: CachedResponse) { + const { cache_control, etag, date, last_modified } = cached.cache_info; + + const must_revalidate = Boolean(cache_control?.must_revalidate || cache_control?.no_cache); + const headers: OutgoingHttpHeaders = { }; + + if (etag) { + headers['if-none-match'] = etag; + } + + if (last_modified || date) { + headers['if-modified-since'] = last_modified?.toHTTP() ?? date?.toHTTP(); + } + + else { + headers['if-modified-since'] = cached.cached_time.toHTTP(); + } + + return { + must_revalidate, + headers, + }; + }, + + /** + * Should be called when receiving a non-`304 Not Modified` response from a request, either + * as a novel request, or during a revalidation request + */ + add_response_to_cache(method: string, url_str: HttpURL, headers: OutgoingHttpHeaders, result: HttpResult) { + if (! cacheable_methods.has(method) || ! cacheable_status_codes.has(result.status)) { + logger.info('method/status not cacheable'); + return; + } + + const cache_info = parse_cache_headers(result.headers); + + if (cache_info.cache_control?.no_store || cache_info.cache_control?.private) { + logger.info(cache_info, 'response contains cache-control directives that prevent caching'); + return; + } + + const cache_key = header_cache_key(method, cache_info.vary, headers); + unset_expire_timer(url_str, cache_key); + + const cached: CachedResponse = { + cache_key, + cached_time: DateTime.now(), + is_stale: false, + url: url_str, + method, + req_headers: deep_copy(headers), + status: result.status, + body: result.body, + res_headers: deep_copy(result.headers), + cache_info, + expire_timer: null, + }; + + logger.info(cache_info, 'caching response'); + store_to_cache(cached, cache_info); + }, + + /** + * Should be called when receiving a `304 Not Modified` response during a revalidation request + * to update the cache with more recent freshness info + */ + freshen_cache(cached: CachedResponse, headers: IncomingHttpHeaders) { + const cache_info = parse_cache_headers(headers); + unset_expire_timer(cached.url, cached.cache_key); + store_to_cache(cached, cache_info); + }, + }; + + function store_to_cache(cached: CachedResponse, cache_info: ParsedCacheHeaders) { + if (! cached_responses[cached.url]) { + cached_responses[cached.url] = Object.create(null); + } + + cached_responses[cached.url][cached.cache_key] = cached; + cached.is_stale = false; + + if (cache_info.cache_control?.max_age) { + let ttl_ms = cache_info.cache_control.max_age * 1000; + + if (cache_info.age) { + ttl_ms -= cache_info.age * 1000; + } + + if (ttl_ms <= 0) { + cached.is_stale = true; + return; + } + + cached.expire_timer = set_expire_timer(cached.url, cached.cache_key, ttl_ms); + } + + else if (cache_info.expires) { + const ttl_ms = cache_info.expires.toUnixInteger() - Date.now(); + + if (ttl_ms <= 0) { + cached.is_stale = true; + return; + } + + cached.expire_timer = set_expire_timer(cached.url, cached.cache_key, ttl_ms); + } + + // If no cache-control or expires header is given, default to the + // recommended 10% of time since last-modified + else if (cache_info.last_modified) { + const last_modified_ago_ms = Date.now() - cache_info.last_modified.toUnixInteger(); + + if (last_modified_ago_ms <= 0) { + cached.is_stale = true; + return; + } + + const ttl_ms = (last_modified_ago_ms / 10) | 0; + cached.expire_timer = set_expire_timer(cached.url, cached.cache_key, ttl_ms); + } + } + + function unset_expire_timer(url_str: HttpURL, cache_key: string) { + const cached = cached_responses[url_str]?.[cache_key]; + + if (cached?.expire_timer) { + clearTimeout(cached.expire_timer); + } + } + + function set_expire_timer(url_str: HttpURL, cache_key: string, ttl_ms: number) { + const timer = setTimeout(expire, ttl_ms); + timer.unref(); + return timer; + + function expire() { + if (cached_responses[url_str]?.[cache_key]) { + cached_responses[url_str][cache_key].is_stale = true; + } + }; + } +} + +function header_cache_key(method: string, vary: string[], headers: OutgoingHttpHeaders) { + const parts: string[] = [ method ]; + + if (vary?.length) { + for (const header of vary) { + if (Array.isArray(headers[header])) { + const values = (headers[header] as string[]).sort(); + for (const value of values) { + parts.push(`${header}: ${value}`); + } + } + + else if (headers[header]) { + parts.push(`${header}: ${headers[header]}`); + } + } + } + + return parts.join('\0'); +} diff --git a/src/security/openid-connect.ts b/src/security/openid-connect.ts index 5fc8c24..5f2917b 100644 --- a/src/security/openid-connect.ts +++ b/src/security/openid-connect.ts @@ -4,6 +4,7 @@ import { BaseClient, CallbackParamsType, Issuer, TokenSet, TypeOfGenericClient } import { PKCECookieConfig } from './pkce-cookie'; import { redirect_302_found } from '../http/redirects'; import pino from 'pino'; +import { SecretValue, resolve_secret_value } from '../conf'; const scopes = 'openid profile email'; @@ -11,7 +12,7 @@ export interface OIDCConfig { server_url: string; signing_algorithm: 'ES512'; client_id: string; - client_secret: string; + client_secret: SecretValue; pkce: PKCECookieConfig; } @@ -33,7 +34,7 @@ export function create_oidc_provider(conf: OIDCConfig, logger: pino.Logger) { Client = issuer.Client; client = new Client({ client_id: conf.client_id, - client_secret: conf.client_secret, + client_secret: resolve_secret_value(conf.client_secret), id_token_signed_response_alg: conf.signing_algorithm, authorization_signed_response_alg: conf.signing_algorithm, }); diff --git a/src/security/session.ts b/src/security/session.ts index a96acec..5e87532 100644 --- a/src/security/session.ts +++ b/src/security/session.ts @@ -1,12 +1,13 @@ import { pino } from 'pino'; import { rand } from '../utilities/rand'; -import { FastifyReply } from 'fastify'; -import { Req } from '../http/request'; +import { resolve_secret_value, SecretValue } from '../conf'; import { invalidate_cookie, parse_req_cookies, set_cookie } from '../http/cookies'; +import type { Req } from '../http/request'; +import type { FastifyReply } from 'fastify'; +import type { Snowflake } from '../utilities/snowflake-uid'; import type { Argon2HashProvider } from './argon-hash'; import type { SessionData, StorageProvider, UserData } from '../storage'; -import { Snowflake } from '../utilities/snowflake-uid'; export interface SessionKey { full_key: string; @@ -20,7 +21,7 @@ export interface SessionCookieConfig { name: string; secure: boolean; ttl: number; - pepper: string; + pepper: SecretValue; } export function validate_session_cookie_conf(conf: unknown) : asserts conf is SessionCookieConfig { @@ -29,6 +30,7 @@ export function validate_session_cookie_conf(conf: unknown) : asserts conf is Se export function create_session_provider(conf: SessionCookieConfig, logger: pino.Logger, argon2: Argon2HashProvider, storage: StorageProvider) { const session_logger = logger.child({ logger: 'session' }); + const pepper = resolve_secret_value(conf.pepper); const self = { async generate_key() : Promise { const bytes = await rand(48); @@ -40,7 +42,7 @@ export function create_session_provider(conf: SessionCookieConfig, logger: pino. return { prefix, raw_key, full_key }; }, hash_key(key: SessionKey) : Promise { - return argon2.hash(conf.pepper + key.raw_key); + return argon2.hash(pepper + key.raw_key); }, parse_key(full_key: string) : SessionKey { const [ prefix, raw_key, ...rest ] = full_key.split('.'); @@ -52,7 +54,7 @@ export function create_session_provider(conf: SessionCookieConfig, logger: pino. return { prefix, raw_key, full_key }; }, verify_key(key: SessionKey, key_hash: string) : Promise { - return argon2.verify(key_hash, conf.pepper + key.raw_key); + return argon2.verify(key_hash, pepper + key.raw_key); }, write_to_cookie(res: FastifyReply, key: SessionKey) { const session_expire = new Date(Date.now() + (conf.ttl * 1000)); diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..b395b7f --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,38 @@ + +import { pino } from 'pino'; +import { create_weather_gov_provider, validate_weather_gov_conf, WeatherGovConfig } from './weather.gov'; +import type { SecretValue } from '../conf'; +import type { OutboundHttpProvider } from '../http/outbound'; +import { create_weatherapi_com_provider, validate_weatherapi_com_conf, WeatherAPIComConfig } from './weatherapi.com'; + +export interface ServicesConfig { + 'weather.gov'?: WeatherGovConfig; + 'weatherapi.com'?: WeatherAPIComConfig; + 'openweathermap.org'?: { + enabled: boolean; + api_key: SecretValue; + }; +} + +export type ServicesProvider = ReturnType; + +export function validate_services_conf(conf: unknown) : asserts conf is ServicesConfig { + validate_weather_gov_conf(conf['weather.gov']); + validate_weatherapi_com_conf(conf['weatherapi.com']); +} + +export function create_services_provider(conf: ServicesConfig, logger: pino.Logger, outbound_http: OutboundHttpProvider) { + const weather_gov = create_weather_gov_provider(conf['weather.gov'], logger, outbound_http); + const weatherapi_com = create_weatherapi_com_provider(conf['weatherapi.com'], logger, outbound_http); + + const ready = Promise.all([ + weather_gov.ready, + weatherapi_com.ready, + ]); + + return { + ready, + weather_gov, + weatherapi_com, + }; +} diff --git a/src/services/weather.gov/index.ts b/src/services/weather.gov/index.ts new file mode 100644 index 0000000..e2164a3 --- /dev/null +++ b/src/services/weather.gov/index.ts @@ -0,0 +1,184 @@ + +import pino from 'pino'; +import { memo } from '../../utilities/memo'; +import { deep_freeze } from '../../utilities/deep'; +import { render_forecast } from './render-forecast'; +import type { NamedLocation } from '../../storage/named-location'; +import type { OutboundHttpProvider } from '../../http/outbound'; +import type { WeatherGovPoints, WeatherGovForecast, WeatherGovAlerts } from './interface'; +import { HttpURL } from '../../utilities/types'; + +export interface WeatherGovConfig { + enabled: boolean; + user_agent: string; +} + +export function validate_weather_gov_conf(conf: unknown) : asserts conf is WeatherGovConfig { + // todo: validate config +} + +export type WeatherGovProvider = ReturnType; + +export function create_weather_gov_provider(conf: WeatherGovConfig, logger: pino.Logger, outbound_http: OutboundHttpProvider) { + const log = logger.child({ logger: 'weather.gov' }); + + const get_points_ttl = 1000 * 60 * 60 * 24; + const get_points = memo(get_points_ttl, get_points_nocache, { }); + + const get_forecast_ttl = 1000 * 60 * 30; + const get_forecast = memo(get_forecast_ttl, get_forecast_nocache, { }); + + const get_alerts_ttl = 1000 * 30; + const get_alerts = memo(get_alerts_ttl, get_alerts_nocache, { }); + + return { + ready: Promise.resolve(), + + get enabled() { + return conf?.enabled; + }, + + get_points: if_enabled(get_points), + get_forecast: if_enabled(get_forecast), + get_alerts: if_enabled(get_alerts), + + async get_forecast_for_lat_long(latitude: number, longitude: number, units: 'us' | 'si') { + const { value: points } = await get_points(latitude, longitude); + return get_forecast(points.gridId, points.gridX, points.gridY, units, false); + }, + + async get_hourly_forecast_for_lat_long(latitude: number, longitude: number, units: 'us' | 'si') { + const { value: points } = await get_points(latitude, longitude); + return get_forecast(points.gridId, points.gridX, points.gridY, units, true); + }, + + async get_forecast_for_location(location: NamedLocation, units: 'us' | 'si') { + const { value: points } = await get_points(location.latitude, location.longitude); + return get_forecast(points.gridId, points.gridX, points.gridY, units, false); + }, + + async get_hourly_forecast_for_location(location: NamedLocation, units: 'us' | 'si') { + const { value: points } = await get_points(location.latitude, location.longitude); + return get_forecast(points.gridId, points.gridX, points.gridY, units, true); + }, + + async get_alerts_for_location(location: NamedLocation) { + return get_alerts(location.latitude, location.longitude); + }, + + render_forecast, + }; + + function if_enabled(func: T) : T { + if (conf?.enabled) { + return func; + } + + return (() => { + throw new Error('weather.gov service not enabled'); + }) as T; + } + + async function get_points_nocache(latitude: number, longitude: number) : Promise> { + log.info({ latitude, longitude }, 'fetching points data'); + + let { status, headers, body } = await outbound_http.get(`https://api.weather.gov/points/${latitude},${longitude}`, { + 'accept': 'application/ld+json', + 'user-agent': conf.user_agent, + }); + + // Follow a single redirect if given + if (status === 301) { + ({ status, headers, body } = await outbound_http.get(headers.location as HttpURL, { + 'accept': 'application/ld+json', + 'user-agent': conf.user_agent, + })); + } + + if (status === 200) { + const data = JSON.parse(body); + return cacheable(get_points_ttl, data as WeatherGovPoints); + } + + const data = headers['content-type'].startsWith('application/problem+json') + ? JSON.parse(body) + : { status }; + + log.error(data, 'failed to fetch points data'); + throw new Error('failed to fetch points data'); + } + + async function get_forecast_nocache(gridId: string, gridX: number, gridY: number, units: 'us' | 'si', hourly = false) : Promise> { + log.info({ gridId, gridX, gridY }, 'fetching forecast info'); + + const url = `https://api.weather.gov/gridpoints/${gridId}/${gridX},${gridY}/forecast${hourly ? '/hourly' : ''}?units=${units}`; + let { status, headers, body } = await outbound_http.get(url as HttpURL, { + 'accept': 'application/ld+json', + 'user-agent': conf.user_agent, + }); + + // Follow a single redirect if given + if (status === 301) { + ({ status, headers, body } = await outbound_http.get(headers.location as HttpURL, { + 'accept': 'application/ld+json', + 'user-agent': conf.user_agent, + })); + } + + if (status === 200) { + const data = JSON.parse(body); + return cacheable(get_forecast_ttl, data as WeatherGovForecast); + } + + const data = headers['content-type'].startsWith('application/problem+json') + ? JSON.parse(body) + : { status }; + + log.error(data, 'failed to fetch forecast data'); + throw new Error('failed to fetch forecast data'); + } + + async function get_alerts_nocache(latitude: number, longitude: number) : Promise> { + log.info({ latitude, longitude }, 'fetching alerts info'); + + let { status, headers, body } = await outbound_http.get(`https://api.weather.gov/alerts?active=1&point=${latitude},${longitude}`, { + 'accept': 'application/ld+json', + 'user-agent': conf.user_agent, + }); + + // Follow a single redirect if given + if (status === 301) { + ({ status, headers, body } = await outbound_http.get(headers.location as HttpURL, { + 'accept': 'application/ld+json', + 'user-agent': conf.user_agent, + })); + } + + if (status === 200) { + const data = JSON.parse(body); + return cacheable(get_alerts_ttl, data as WeatherGovAlerts); + } + + const data = headers['content-type'].startsWith('application/problem+json') + ? JSON.parse(body) + : { status }; + + log.error(data, 'failed to fetch alerts data'); + throw new Error('failed to fetch alerts data'); + } +} + +function cacheable(ttl: number, value: T) : Cachable { + const now = new Date(); + return deep_freeze({ + fetched: now, + expires: new Date(now.getTime() + ttl), + value, + }); +} + +interface Cachable { + readonly fetched: Date; + readonly expires: Date; + readonly value: Readonly; +} diff --git a/src/services/weather.gov/interface.ts b/src/services/weather.gov/interface.ts new file mode 100644 index 0000000..724d222 --- /dev/null +++ b/src/services/weather.gov/interface.ts @@ -0,0 +1,103 @@ + +import { ContextDefinition } from 'jsonld'; +import { HttpURL, ISOTimestamp, Timezone, URN } from '../../utilities/types'; + +export interface WeatherGovMeasure { + unitCode: Unit; + value: number; +} + +export interface WeatherGovPoints { + '@context': ContextDefinition; + geometry: string; + cwa: string; + forecastOffice: HttpURL; + gridId: string; + gridX: number; + gridY: number; + forecast: HttpURL; + forecastHourly: HttpURL; + forecastGridData: HttpURL; + forecastStations: HttpURL; + relativeLocation: { + city: string; + state: string; + geometry: string; + distance: WeatherGovMeasure<'wmoUnit:m'>; + bearing: WeatherGovMeasure<'wmoUnit:degree_(angle)'>; + }; + forecastZone: HttpURL; + county: HttpURL; + fireWeatherZone: HttpURL; + timeZone: Timezone; + radarStation: string; +} + +export interface WeatherGovForecast { + '@context': ContextDefinition; + geometry: string; + updated: ISOTimestamp; + units: 'us' | 'si'; + forecastGenerator: 'BaselineForecastGenerator' | 'HourlyForecastGenerator'; + generatedAt: ISOTimestamp; + updateTime: ISOTimestamp; + validTimes: string; + elevation: WeatherGovMeasure<'wmoUnit:m'>; + periods: WeatherGovForecastPeriod[]; +} + +export interface WeatherGovForecastPeriod { + number: number; + name: string; + startTime: ISOTimestamp; + endTime: ISOTimestamp; + isDaytime: boolean; + temperature: number; + temperatureUnit: 'F' | 'C'; + temperatureTrend: unknown; // todo: what is this? + probabilityOfPrecipitation: WeatherGovMeasure<'wmoUnit:percent'>; + dewPoint: WeatherGovMeasure<'wmoUnit:degC'>; + relativeHumidity: WeatherGovMeasure<'wmoUnit:percent'>; + windSpeed: `${number} mph` | `${number} km/h`; + windDirection: 'N' | 'NNW' | 'NW' | 'WNW' | 'W' | 'NNE' | 'NE' | 'ENE' | 'E' + | 'S' | 'SSW' | 'SW' | 'WSW' | 'W' | 'SSE' | 'SE' | 'ESE' | 'E'; + icon: HttpURL; + shortForecast: string; + detailedForecast: string; +} + +export interface WeatherGovAlerts { + '@context': ContextDefinition; + '@graph': { + '@id': HttpURL; + '@type': 'wx:Alert', + id: URN<`oid:${string}`>; + areaDesc: string; + geometry: string; + geocode: { + SAME: `${number}`[]; + UGC: string[]; + }; + affectedZones: HttpURL[]; + references: unknown[]; // todo: what is this? + sent: ISOTimestamp; + effective: ISOTimestamp; + onset: ISOTimestamp; + expires: ISOTimestamp; + ends: ISOTimestamp; + status: 'Actual' | 'Exercise' | 'System' | 'Test' | 'Draft'; + messageType: 'Alert' | 'Update' | 'Cancel'; + category: string; + severity: 'Extreme' | 'Severe' | 'Moderate' | 'Minor' | 'Unknown'; + certainty: 'Observed' | 'Likely' | 'Possible' | 'Unlikely' | 'Unknown'; + urgency: 'Immediate' | 'Expected' | 'Future' | 'Past' | 'Unknown'; + event: string; + sender: string; + senderName: string; + headline: string; + description: string; + instruction: string; + response: string; + parameters: Record; + }[]; +} diff --git a/src/services/weather.gov/render-forecast.ts b/src/services/weather.gov/render-forecast.ts new file mode 100644 index 0000000..d2a3095 --- /dev/null +++ b/src/services/weather.gov/render-forecast.ts @@ -0,0 +1,44 @@ + +import { icons } from '../../utilities/icons'; +import { render_template } from '../../utilities/mustache'; +import type { NamedLocation } from '../../storage/named-location'; +import type { WeatherGovAlerts, WeatherGovForecast, WeatherGovForecastPeriod } from './interface'; + +export function render_forecast(location: NamedLocation, forecast: WeatherGovForecast, alerts: WeatherGovAlerts) { + let forecast_today: WeatherGovForecastPeriod[]; + let forecast_future: WeatherGovForecastPeriod[]; + const forecast_days: WeatherGovForecastPeriod[] = [ ]; + const forecast_nights: WeatherGovForecastPeriod[] = [ ]; + + if (forecast.periods[0].isDaytime) { + forecast_today = forecast.periods.slice(0, 2); + forecast_future = forecast.periods.slice(2); + } + + else { + forecast_today = forecast.periods.slice(0, 1); + forecast_future = forecast.periods.slice(1); + } + + for (const period of forecast_future) { + if (period.isDaytime) { + forecast_days.push(period); + } + + else { + forecast_nights.push(period); + } + } + + const view = { + location, + forecast_today, + forecast_future, + forecast_days, + forecast_nights, + icons, + alerts: alerts['@graph'], + }; + + return render_template('weather.gov/forecast.html.mustache', view); +} diff --git a/src/services/weatherapi.com/index.ts b/src/services/weatherapi.com/index.ts new file mode 100644 index 0000000..2f42a11 --- /dev/null +++ b/src/services/weatherapi.com/index.ts @@ -0,0 +1,96 @@ + +import pino from 'pino'; +import { memo } from '../../utilities/memo'; +import { deep_freeze } from '../../utilities/deep'; +// import { render_forecast } from './render-forecast'; +import { render_current_weather } from './render-current-weather'; +import { SecretValue, resolve_secret_value } from '../../conf'; +import type { OutboundHttpProvider } from '../../http/outbound'; +import type { WeatherAPIComCurrentWeather, WeatherAPIComLocation } from './interface'; +import type { NamedLocation } from '../../storage/named-location'; + +export interface WeatherAPIComConfig { + enabled: boolean; + api_key: SecretValue; +} + +export function validate_weatherapi_com_conf(conf: unknown) : asserts conf is WeatherAPIComConfig { + // todo: validate config +} + +export type WeatherAPIComProvider = ReturnType; + +export function create_weatherapi_com_provider(conf: WeatherAPIComConfig, logger: pino.Logger, outbound_http: OutboundHttpProvider) { + const log = logger.child({ logger: 'weatherapi.com' }); + const api_key = resolve_secret_value(conf.api_key); + + const get_current_ttl = 1000; + // const get_current_ttl = 1000 * 60 * 3; + const get_current = memo(get_current_ttl, get_current_nocache, { }); + + // const get_forecast_ttl = 1000 * 60 * 30; + // const get_forecast = memo(get_forecast_ttl, get_forecast_nocache, { }); + + // const get_alerts_ttl = 1000 * 30; + // const get_alerts = memo(get_alerts_ttl, get_alerts_nocache, { }); + + return { + ready: Promise.resolve(), + + get enabled() { + return conf?.enabled; + }, + + get_current: if_enabled(get_current), + get_current_for_location(location: NamedLocation) { + return get_current(`${location.latitude},${location.longitude}`); + }, + + render_current_weather, + }; + + function if_enabled(func: T) : T { + if (conf?.enabled) { + return func; + } + + return (() => { + throw new Error('weatherapi.com service not enabled'); + }) as T; + } + + async function get_current_nocache(query: string) : Promise> { + log.info({ query }, 'fetching current weather'); + + let { status, headers, body } = await outbound_http.get(`https://api.weatherapi.com/v1/current.json?key=${api_key}&q=${query}`, { + 'accept': 'application/json', + }); + + if (status === 200) { + const data = JSON.parse(body); + return cacheable(get_current_ttl, data as WeatherAPIComCurrentWeather); + } + + const data = headers['content-type'].startsWith('application/json') + ? JSON.parse(body) + : { status }; + + log.error(data, 'failed to fetch current weather'); + throw new Error('failed to fetch current weather'); + } +} + +function cacheable(ttl: number, value: T) : Cachable { + const now = new Date(); + return deep_freeze({ + fetched: now, + expires: new Date(now.getTime() + ttl), + value, + }); +} + +interface Cachable { + readonly fetched: Date; + readonly expires: Date; + readonly value: Readonly; +} diff --git a/src/services/weatherapi.com/interface.ts b/src/services/weatherapi.com/interface.ts new file mode 100644 index 0000000..c03778c --- /dev/null +++ b/src/services/weatherapi.com/interface.ts @@ -0,0 +1,108 @@ + +import { HttpURLSchemeless, ISODate, LocalDateTime, Timezone } from '../../utilities/types'; + +export interface WeatherAPIComLocation { + name: string; + region: string; + country: string + lat: number; + lon: number; + tz_id: Timezone; + localtime_epoch: number; + localtime: LocalDateTime; +} + +export interface WeatherAPIComCurrentWeather { + location: WeatherAPIComLocation; + current: WeatherAPIComWeather & { + last_updated_epoch: number; + last_updated: LocalDateTime; + }; +} + +export interface WeatherAPIComForecast extends WeatherAPIComCurrentWeather { + forecast: { + forecastday: { + date: ISODate; + date_epoch: number; + day: { + maxtemp_c: number; + maxtemp_f: number; + mintemp_c: number; + mintemp_f: number; + avgtemp_c: number; + avgtemp_f: number; + maxwind_mph: number; + maxwind_kph: number; + totalprecip_mm: number; + totalprecip_in: number; + totalsnow_cm: number; + avgvis_km: number; + avgvis_miles: number; + avghumidity: number; + daily_will_it_rain: number; + daily_chance_of_rain: number; + daily_will_it_snow: number; + daily_chance_of_snow: number; + condition: WeatherAPIComCondition; + uv: number; + }; + astro: { + sunrise: string; + sunset: string; + moonrise: string; + moonset: string; + moon_phase: string; + moon_illumination: `${bigint}`; + is_moon_up: 0 | 1; + is_sun_up: 0 | 1; + }; + hour: WeatherAPIComHourForecast[]; + }[]; + }; +} + +export interface WeatherAPIComCondition { + text: string; + icon: HttpURLSchemeless; + code: number; +} + +export interface WeatherAPIComWeather { + temp_c: number; + temp_f: number; + is_day: number; + condition: WeatherAPIComCondition; + wind_mph: number; + wind_kph: number; + wind_degree: number; + wind_dir: string; + pressure_mb: number; + pressure_in: number; + precip_mm: number; + precip_in: number; + humidity: number; + cloud: number; + feelslike_c: number; + feelslike_f: number; + vis_km: number; + vis_miles: number; + gust_mph: number; + gust_kph: number; + uv: number; +} + +export interface WeatherAPIComHourForecast extends WeatherAPIComWeather { + time_epoch: number; + time: LocalDateTime; + windchill_c: number; + windchill_f: number; + heatindex_c: number; + heatindex_f: number; + dewpoint_c: number; + dewpoint_f: number; + will_it_rain: number; + chance_of_rain: number; + will_it_snow: number; + chance_of_snow: number; +} diff --git a/src/services/weatherapi.com/render-current-weather.ts b/src/services/weatherapi.com/render-current-weather.ts new file mode 100644 index 0000000..a94c354 --- /dev/null +++ b/src/services/weatherapi.com/render-current-weather.ts @@ -0,0 +1,15 @@ + +import { icons } from '../../utilities/icons'; +import { render_template } from '../../utilities/mustache'; +import type { NamedLocation } from '../../storage/named-location'; +import type { WeatherAPIComCurrentWeather } from './interface'; + +export function render_current_weather(location: NamedLocation, weather: WeatherAPIComCurrentWeather) { + const view = { + location, + weather, + icons, + }; + + return render_template('weatherapi.com/current.html.mustache', view); +} diff --git a/src/start.ts b/src/start.ts index 71cdb66..abc7b44 100644 --- a/src/start.ts +++ b/src/start.ts @@ -12,6 +12,9 @@ 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'; +import { create_outbound_http_provider } from './http/outbound'; +import { create_services_provider } from './services'; +import { create_rss_feed_reader_provider } from './utilities/rss-feeds'; main(); @@ -39,11 +42,15 @@ async function main() { 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); + const outbound_http = create_outbound_http_provider(conf.outbound_http, logger); + const rss_feed_reader = create_rss_feed_reader_provider(logger, outbound_http); + const services = create_services_provider(conf.services, logger, outbound_http); // Wait for any async init steps await oidc.ready; await storage.ready; await color_themes.ready; + await services.ready; // Perform any cleanup steps before starting up await storage.cleanup_old_sessions(); @@ -57,6 +64,9 @@ async function main() { argon2, session, color_themes, + outbound_http, + rss_feed_reader, + services, }; // Create the main web server diff --git a/src/storage/named-location.ts b/src/storage/named-location.ts new file mode 100644 index 0000000..5e92fd2 --- /dev/null +++ b/src/storage/named-location.ts @@ -0,0 +1,6 @@ + +export interface NamedLocation { + name: string; + latitude: number; + longitude: number; +} diff --git a/src/utilities/color-themes.ts b/src/utilities/color-themes.ts index b177b83..561c1b0 100644 --- a/src/utilities/color-themes.ts +++ b/src/utilities/color-themes.ts @@ -23,6 +23,9 @@ export function create_color_theme_provider(conf: ColorThemeConfig) { get ready() { return ready; }, + get themes() { + return structuredClone(conf); + }, get css() { return css; }, diff --git a/src/utilities/deep-merge.ts b/src/utilities/deep-merge.ts deleted file mode 100644 index 2a4cb96..0000000 --- a/src/utilities/deep-merge.ts +++ /dev/null @@ -1,13 +0,0 @@ - -export function deep_merge(host: T, donor: T) : T { - for (const [key, value] of Object.entries(donor)) { - if (value != null && host[key] != null && typeof value === 'object' && typeof host[key] === 'object') { - deep_merge(host[key], value); - continue; - } - - host[key] = value; - } - - return host; -} diff --git a/src/utilities/deep.ts b/src/utilities/deep.ts new file mode 100644 index 0000000..300b394 --- /dev/null +++ b/src/utilities/deep.ts @@ -0,0 +1,41 @@ + +export function deep_merge(host: T, donor: T) : T { + for (const [key, value] of Object.entries(donor)) { + if (value != null && host[key] != null && typeof value === 'object' && typeof host[key] === 'object') { + deep_merge(host[key], value); + continue; + } + + host[key] = value; + } + + return host; +} + +export function deep_freeze(obj: T) : Readonly { + for (const value of Object.values(obj)) { + if (value == null) { + continue; + } + + else if (typeof value === 'object' || typeof value === 'function') { + deep_freeze(value); + } + } + + return Object.freeze(obj); +} + +export function deep_copy(obj: T) : T { + return structuredClone(obj); + + // try { + // return structuredClone(obj); + // } + + // catch (error) { + // if (error instanceof ReferenceError) { + // return JSON.parse(JSON.stringify(obj)); + // } + // } +} diff --git a/src/utilities/icons.ts b/src/utilities/icons.ts new file mode 100644 index 0000000..fd2a244 --- /dev/null +++ b/src/utilities/icons.ts @@ -0,0 +1,22 @@ + +export const icons: Record = Object.create(null); + +const whitespace = /[\s\t\n]+/g; +const feather_icons: Record = require('../../vendor/feather-icons/icons.json'); + +for (const [name, contents] of Object.entries(feather_icons)) { + icons[name] = ` + + `.replace(whitespace, ' ').trim(); +} + +Object.freeze(icons); diff --git a/src/utilities/memo.ts b/src/utilities/memo.ts index 20d6a47..e1fdda9 100644 --- a/src/utilities/memo.ts +++ b/src/utilities/memo.ts @@ -51,7 +51,8 @@ export function memo(ttl: number, func: T, opts: MemoParams = } function set_expire(key: string, args: Params, stored: number) { - setTimeout(opts.validator ? revalidate(key, args, stored) : invalidate(key), ttl); + const timer = setTimeout(opts.validator ? revalidate(key, args, stored) : invalidate(key), ttl); + timer.unref(); } function revalidate(key: string, args: Params, stored: number) { diff --git a/src/utilities/rss-feeds.ts b/src/utilities/rss-feeds.ts new file mode 100644 index 0000000..e888c4a --- /dev/null +++ b/src/utilities/rss-feeds.ts @@ -0,0 +1,50 @@ + +import pino from 'pino'; +import type { HttpURL } from './types'; +import type { OutboundHttpProvider } from '../http/outbound'; +import { XMLParser, XMLValidator, X2jOptions } from 'fast-xml-parser'; + +export type RSSFeedReaderProvider = ReturnType; + +export function create_rss_feed_reader_provider(logger: pino.Logger, outbound_http: OutboundHttpProvider) { + const log = logger.child({ logger: 'rss-feed-reader' }); + + return { + async read_feed(feed_url: HttpURL, skip_cache = false) { + const req_headers = { + accept: 'application/rss+xml; q=1.0, text/xml; q=0.9', + }; + + const { status, headers, body } = await outbound_http.get(feed_url, req_headers, skip_cache); + + if (status === 200) { + const content_type = headers['content-type'] || ''; + + if (content_type.startsWith('text/xml') || content_type.startsWith('application/rss+xml')) { + const opts: Partial = { }; + const result = XMLValidator.validate(body, opts); + + if (typeof result === 'object' && result.err) { + log.error(result.err, 'RSS feed XML validation error'); + throw_error('received invalid XML response from server'); + } + + const xmlParser = new XMLParser(opts); + return xmlParser.parse(body); + } + + else { + throw_error('received invalid content type from server'); + } + } + + else { + throw_error('received a non-200 response code'); + } + }, + }; + + function throw_error(message: string) : never { + throw new Error(`failed to fetch RSS feed content; ${message}`); + } +} diff --git a/src/utilities/types.ts b/src/utilities/types.ts index d7797ef..9cfdcae 100644 --- a/src/utilities/types.ts +++ b/src/utilities/types.ts @@ -17,8 +17,16 @@ export type ISOZoneOffset = 'Z' | `${'+' | '-' | ''}${number}:${number}`; export type ISOTimestamp = `${ISODate}T${ISOTime}${ISOZoneOffset}`; +export type TimeShort = `${number}:${number}`; + +export type LocalDateTime = `${ISODate} ${TimeShort}`; + export type Timezone = `${string}/${string}`; export type Locale = `${string}-${string}`; export type HttpURL = `http${'s' | ''}://${string}`; + +export type HttpURLSchemeless = `//${string}`; + +export type URN = `urn:${T}`; diff --git a/templates/color-theme-controls.css b/templates/color-theme-controls.css new file mode 100644 index 0000000..dd1d8c5 --- /dev/null +++ b/templates/color-theme-controls.css @@ -0,0 +1,60 @@ + +* { + box-sizing: border-box; +} + +fieldset.radio { + width: 30rem; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + column-gap: 2%; + border-color: var(--theme-border-input); +} + +fieldset.radio.theme { + margin-block: 1rem 2rem; +} + +@media screen and (max-width: 500px) { + fieldset.radio { + width: calc(100vw - 5rem); + } +} + +label { + width: 32%; + height: 2rem; + display: block; + position: relative; +} + +input { + appearance: none; + display: block; + background: transparent; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +input:not(:checked) { + cursor: pointer; + background: var(--theme-bg-button-primary); +} + +input:checked { + background: var(--theme-bg-button-primary-hover); +} + +span { + z-index: 2; + position: relative; + pointer-events: none; + text-align: center; + display: block; + height: 100%; + line-height: 2rem; +} diff --git a/templates/color-theme-controls.html.mustache b/templates/color-theme-controls.html.mustache new file mode 100644 index 0000000..835f460 --- /dev/null +++ b/templates/color-theme-controls.html.mustache @@ -0,0 +1,7 @@ +Colors and Contrast + \ No newline at end of file diff --git a/templates/color-theme-controls.js b/templates/color-theme-controls.js new file mode 100644 index 0000000..38d88c4 --- /dev/null +++ b/templates/color-theme-controls.js @@ -0,0 +1,130 @@ +(() => { + + const color_theme = 'color_theme'; + const color_contrast = 'color_contrast'; + const color_theme_attr = 'data-color-theme'; + const color_contrast_attr = 'data-color-contrast'; + + const override_theme = localStorage.getItem(color_theme); + const override_contrast = localStorage.getItem(color_contrast); + + if (override_theme) { + document.body.setAttribute(color_theme_attr, override_theme); + } + + if (override_contrast) { + document.body.setAttribute(color_contrast_attr, override_contrast); + } + + const template = ` + + + + `; + + customElements.define('color-theme-and-contrast-controls', + class ColorThemeToggleButton extends HTMLElement { + #popup = null; + #theme_inputs = null; + #contrast_inputs = null; + + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.innerHTML = template; + this.#popup = this.shadowRoot.querySelector('.popup'); + this.#theme_inputs = this.#popup.querySelectorAll('.theme input'); + this.#contrast_inputs = this.#popup.querySelectorAll('.contrast input'); + } + + connectedCallback() { + this.#popup.addEventListener('change', this.#onChange); + } + + disconnectedCallback() { + this.#popup.removeEventListener('change', this.#onChange); + } + + #onChange = () => { + const theme = this.#theme_input_value; + const contrast = this.#contrast_input_value; + + if (theme === 'auto') { + localStorage.removeItem(color_theme); + document.body.removeAttribute(color_theme_attr); + } + + else { + localStorage.setItem(color_theme, theme); + document.body.setAttribute(color_theme_attr, theme); + } + + if (contrast === 'default') { + localStorage.removeItem(color_contrast); + document.body.removeAttribute(color_contrast_attr); + } + + else { + localStorage.setItem(color_contrast, contrast); + document.body.setAttribute(color_contrast_attr, theme); + } + }; + + get #theme_input_value() { + return radio_value(this.#theme_inputs, 'auto'); + } + + get #contrast_input_value() { + return radio_value(this.#contrast_inputs, 'auto'); + } + } + ); + + function checked_if(condition) { + return condition ? 'checked' : ''; + } + + function radio_value(inputs, default_value) { + inputs = [ ...inputs ]; + + for (const input of inputs) { + if (input.checked) { + return input.value; + } + } + + return default_value; + } + +})(); \ No newline at end of file diff --git a/templates/controls.html.mustache b/templates/controls.html.mustache new file mode 100644 index 0000000..c638edb --- /dev/null +++ b/templates/controls.html.mustache @@ -0,0 +1,22 @@ + \ No newline at end of file diff --git a/templates/forms.css b/templates/forms.css new file mode 100644 index 0000000..9bac5d5 --- /dev/null +++ b/templates/forms.css @@ -0,0 +1,143 @@ + +fieldset { + border-color: var(--theme-border-input); +} + +button, input:is([type='button'], [type='submit'], [type='reset']) { + appearance: none; + cursor: pointer; + border: 0; + padding: 0.5rem 1rem; +} + + + + + + + + + +/* +label { + margin-block-start: 1rem; +} + +label:not(:first-of-type) { + margin-block-start: 2rem; +} + +label.radio { + display: flex; + margin-block: 1rem; + justify-content: flex-start; +} + +input, +textarea { + display: block; + margin-block-start: 0.35rem; + margin-block-end: 1rem; + border: 0.125rem var(--theme-border-input) solid; + border-radius: 0.25rem; + background-color: var(--theme-bg-input); + padding: 0.5rem; + width: 20rem; +} + +input[disabled], +textarea[disabled] { + opacity: 0.4; + cursor: default; + pointer-events: none; +} + +input[type='text']:invalid, +input[type='password']:invalid, +textarea:invalid { + border-color: var(--theme-border-input-invalid); +} + +input[type='text'][readonly] { + color: var(--theme-text-light); + border-color: var(--theme-line); +} + +input[type='checkbox'] { + width: 1rem; + height: 1rem; + padding: 0; +} + +input[type='checkbox'] + label { + margin-inline-start: 0.5rem; + margin-inline-end: 2rem; +} + +input[type='radio'] { + flex: 0 0 2rem; + margin: 0.2rem; +} + +textarea { + line-height: 1.7rem; + font-family: var(--font-monospace); +} + +::-webkit-input-placeholder { + color: var(--theme-text-light); +} + +button { + padding: 0.5rem 1.5rem; + border-radius: 0.5rem; + font-size: 1rem; + margin-block-start: 1rem; + margin-inline-end: 1rem; + cursor: pointer; + user-select: none; + appearance: none; + color: var(--theme-text-button-primary); + background: var(--theme-bg-button-primary); + text-decoration: none; + font-size: 1rem; + font-family: var(--font-body); + font-weight: 700; + line-height: 2rem; + display: flex; + align-items: center; +} + +button[disabled] { + opacity: 0.4; + cursor: default; + pointer-events: none; +} + +select { + display: block; + margin-block-start: 0.35rem; + margin-block-end: 1rem; + border: 0.125rem var(--theme-border-input) solid; + border-radius: 0.25rem; + background-color: var(--theme-bg-input); + padding: 0.5rem; + width: 20rem; + cursor: pointer; +} + +select[disabled] { + opacity: 0.4; + cursor: default; + pointer-events: none; +} + +select option { + background-color: var(--theme-bg-input); + padding: 0.5rem; +} + +:is(table, .table) :is(input, select) { + margin-block: 0; +} + */ \ No newline at end of file diff --git a/templates/local-clock/local-clock.html.mustache b/templates/local-clock/local-clock.html.mustache new file mode 100644 index 0000000..3f06c00 --- /dev/null +++ b/templates/local-clock/local-clock.html.mustache @@ -0,0 +1,3 @@ +
+ {{! }} +
\ No newline at end of file diff --git a/templates/login-failed.html.mustache b/templates/login-failed.html.mustache new file mode 100644 index 0000000..7b6df77 --- /dev/null +++ b/templates/login-failed.html.mustache @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/templates/login.html.mustache b/templates/login.html.mustache deleted file mode 100644 index c9a3214..0000000 --- a/templates/login.html.mustache +++ /dev/null @@ -1,21 +0,0 @@ - - - - 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/page.html.mustache b/templates/page.html.mustache new file mode 100644 index 0000000..154ac21 --- /dev/null +++ b/templates/page.html.mustache @@ -0,0 +1,16 @@ + + + + {{ page_title }} + + + + + + + + + {{> controls }} + {{> page_content }} + + \ No newline at end of file diff --git a/templates/popup.css b/templates/popup.css new file mode 100644 index 0000000..b4539e1 --- /dev/null +++ b/templates/popup.css @@ -0,0 +1,37 @@ + +.popup { + z-index: 1; + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + user-select: none; + pointer-events: none; + display: flex; + opacity: 0; + transition: opacity 0.125s linear; + background: var(--theme-bg-popup-mask); +} + +.popup:target { + opacity: 1; + user-select: unset; + pointer-events: unset; +} + +.popup .popup-content { + margin: auto; + position: relative; + background: var(--theme-bg-main); + border: 0.125rem solid var(--theme-border-input); + border-radius: 1rem; + padding: 2rem; +} + +.popup .popup-close { + position: absolute; + top: 1rem; + right: 2rem; + font-size: 0.8rem; +} diff --git a/templates/root.html.mustache b/templates/root.html.mustache index 1087a59..6f63bac 100644 --- a/templates/root.html.mustache +++ b/templates/root.html.mustache @@ -1,22 +1,9 @@ - - - - Dashboard - - - - +

Dashboard

+
- {{# user }} -

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

-
- -
- {{/ user }} - - {{^ user }} - Login Page - {{/ user }} - - \ No newline at end of file +
+ {{# rendered_widgets }} + {{{ . }}} + {{/ rendered_widgets }} +
\ No newline at end of file diff --git a/templates/structure.css b/templates/structure.css new file mode 100644 index 0000000..346da39 --- /dev/null +++ b/templates/structure.css @@ -0,0 +1,64 @@ + +* { + box-sizing: border-box; +} + +html, body { + background: var(--theme-bg-main); +} + + + +/* ===== Primary Controls ===== */ + +aside.controls { + position: absolute; + top: 1rem; + right: 1rem; + text-align: right; +} + +aside.controls :is(a, p, button) { + display: inline; + font-size: 0.8rem; + margin-block: 0.5rem; +} + +aside.controls form { + display: contents; +} + +aside.controls button { + margin: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--theme-text-link); + text-decoration: underline; +} + +aside.controls button:hover { + background: transparent; +} + + + +/* ===== Error Box ===== */ + +aside.error { + margin-block: 4rem; + margin-inline: 1rem; + padding: 1rem; + border: 0.1rem solid var(--theme-border-error-box); + background: var(--theme-bg-error-box); +} + +aside.error h2 { + font-size: 1.2rem; + margin-block-start: 0; +} + +aside.error p { + margin-block: 0; + color: var(--theme-text-error-box); +} diff --git a/templates/themes.css.mustache b/templates/themes.css.mustache index 0ebf253..2ffd56d 100644 --- a/templates/themes.css.mustache +++ b/templates/themes.css.mustache @@ -3,76 +3,60 @@ color-scheme: light dark; } - - -{{! ===== Default Themes ===== }} - body { {{> default_light }} } -body[data-color-scheme='dark'] { +body[data-color-theme='dark'] { {{> default_dark }} } +{{# less_contrast }} +body[data-color-contrast='less'] { + {{> less_contrast_light }} +} + +body[data-color-theme='dark'][data-color-contrast='less'] { + {{> less_contrast_dark }} +} +{{/ less_contrast }} + +{{# more_contrast }} +body[data-color-contrast='more'] { + {{> more_contrast_light }} +} + +body[data-color-theme='dark'][data-color-contrast='more'] { + {{> more_contrast_dark }} +} +{{/ more_contrast }} + @media (prefers-color-scheme: dark) { body { {{> default_dark }} } - body[data-color-scheme='light'] { + body[data-color-theme='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 }} + body[data-color-contrast='less'] { {{> less_contrast_dark }} } - @media (prefers-color-scheme: dark) { - body { - {{> less_contrast_dark }} - } - - body[data-color-scheme='light'] { - {{> less_contrast_light }} - } + body[data-color-theme='light'][data-color-contrast='less'] { + {{> less_contrast_light }} } + {{/ less_contrast }} + + {{# more_contrast }} + body[data-color-contrast='more'] { + {{> more_contrast_dark }} + } + + body[data-color-theme='light'][data-color-contrast='more'] { + {{> more_contrast_light }} + } + {{/ more_contrast }} } -{{/ less_contrast }} diff --git a/templates/typography.css b/templates/typography.css index 726330d..f6769f6 100644 --- a/templates/typography.css +++ b/templates/typography.css @@ -1,17 +1,36 @@ :root { - --font-heading: 'Open Sans', sans-serif; - --font-body: 'Open Sans', sans-serif; + --font-heading: Verdana, sans-serif; + --font-body: Verdana, sans-serif; --font-monospace: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + + font-size: 16px; font-family: var(--font-body); } +@media screen and (max-width: 800px) { + :root { + font-size: 14px; + } +} + +@media screen and (min-width: 2000px) { + :root { + font-size: 18px; + } +} + + + +/* ===== Font Families ===== */ + h1, h2, h3, h4, h5, h6, th, dt { font-family: var(--font-heading); } -p, td, dd, figcaption, li, blockquote { +p, td, dd, figcaption, li, blockquote, +input, textarea, select, option, optgroup, legend, fieldset, label, button { font-family: var(--font-body); } @@ -24,3 +43,110 @@ b, i, u, q, strong, em, mark, cite { font-family: inherit; } + + + +/* ===== Font Sizes ===== */ + +h1 { + font-size: 2.5rem; +} + +h2 { + font-size: 1.8rem; +} + +h3 { + font-size: 1.5rem; +} + +h4 { + font-size: 1.2rem; +} + +h5 { + font-size: 1rem; +} + +h6 { + font-size: 0.8rem; +} + +th, td, dt, dd, +p, figcaption, li, blockquote, +pre, +input, textarea, select, option, optgroup, 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, textarea, select, option, optgroup, 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; +} + diff --git a/templates/weather.gov/forecast.html.mustache b/templates/weather.gov/forecast.html.mustache new file mode 100644 index 0000000..7327249 --- /dev/null +++ b/templates/weather.gov/forecast.html.mustache @@ -0,0 +1,78 @@ +
+ + +
+
+ {{# forecast_today }} +
+

{{ name }}

+

{{ shortForecast }}

+

+ {{{ icons.thermometer }}} + {{ temperature }}{{ temperatureUnit }} +

+

+ {{{ icons.wind }}} + {{ windSpeed }} +

+
+ {{/ forecast_today }} +
+ + + + {{# forecast_days }} + + {{/ forecast_days }} + + + {{# forecast_nights }} + + {{/ forecast_nights }} + +
+ {{# . }} +

{{ name }}

+

{{ shortForecast }}

+

+ {{{ icons.thermometer }}} + {{ temperature }}{{ temperatureUnit }} +

+

+ {{{ icons.wind }}} + {{ windSpeed }} +

+ {{/ . }} +
+ {{# . }} +

{{ name }}

+

{{ shortForecast }}

+

+ {{{ icons.thermometer }}} + {{ temperature }}{{ temperatureUnit }} +

+

+ {{{ icons.wind }}} + {{ windSpeed }} +

+ {{/ . }} +
+
+ + {{# alerts.length }} +
    + {{# alerts }} +
  • +

    {{ event }}

    +

    {{ headline }}

    +

    {{ description }}

    +

    {{ instruction }}

    +
  • + {{/ alerts }} +
+ {{/ alerts.length }} + +

+ Powered by weather.gov +

+
\ No newline at end of file diff --git a/templates/weather.gov/hourly-forecast.html.mustache b/templates/weather.gov/hourly-forecast.html.mustache new file mode 100644 index 0000000..e69de29 diff --git a/templates/weather.gov/styles.css b/templates/weather.gov/styles.css new file mode 100644 index 0000000..0240dd2 --- /dev/null +++ b/templates/weather.gov/styles.css @@ -0,0 +1,83 @@ + +[data-widget='weather-gov-forecast'] { + padding: 1rem; + max-width: 70rem; +} + +[data-widget='weather-gov-forecast'] .alerts { + margin-block: 1rem; + padding-inline: 1rem; + border: 0.1rem solid var(--theme-border-error-box); + background: var(--theme-bg-error-box); + list-style: none; +} + +[data-widget='weather-gov-forecast'] .alerts li { + margin-block: 1rem; +} + +[data-widget='weather-gov-forecast'] .alerts p { + color: var(--theme-text-error-box); +} + +[data-widget='weather-gov-forecast'] .alerts p.headline { + font-weight: 700; +} + +[data-widget='weather-gov-forecast'] .flex-row { + display: flex; + flex-direction: row; + flex-wrap: nowrap; +} + +[data-widget='weather-gov-forecast'] .today { + display: inline-flex; + flex-direction: column; + row-gap: 2rem; + padding: 1rem; + margin-block-end: 1rem; + background: var(--theme-bg-light); +} + +[data-widget='weather-gov-forecast'] svg.icon { + width: 1rem; + height: 1rem; + flex: 0 0 1rem; +} + +[data-widget='weather-gov-forecast'] h2 { + font-size: 1.2rem; + margin-block: 0; +} + +[data-widget='weather-gov-forecast'] .today p { + font-size: 1rem; +} + +[data-widget='weather-gov-forecast'] .today svg.icon { + width: 1.2rem; + height: 1.2rem; +} + +[data-widget='weather-gov-forecast'] h3 { + font-size: 0.8rem; + margin-block-end: 0; +} + +[data-widget='weather-gov-forecast'] p { + font-size: 0.8rem; + margin-block: 0.25rem; +} + +[data-widget='weather-gov-forecast'] p:is(.wind, .temp) { + display: flex; + column-gap: 0.5rem; +} + +[data-widget='weather-gov-forecast'] table { + margin-block-end: 1rem; +} + +[data-widget='weather-gov-forecast'] td { + padding-inline: 1rem; +} diff --git a/templates/weatherapi.com/current.html.mustache b/templates/weatherapi.com/current.html.mustache new file mode 100644 index 0000000..90a233b --- /dev/null +++ b/templates/weatherapi.com/current.html.mustache @@ -0,0 +1,21 @@ +
+ + +

+ {{ weather.current.condition.text }} +

+ +

+ {{{ icons.thermometer }}} + {{ weather.current.temp_f }}F / {{ weather.current.temp_c }}C +

+ +

+ {{{ icons.wind }}} + {{ weather.current.wind_mph }} mph / {{ weather.current.wind_kph }} km/h / {{ weather.current.wind_dir }} +

+ +

+ Powered by WeatherAPI.com +

+
\ No newline at end of file diff --git a/templates/weatherapi.com/styles.css b/templates/weatherapi.com/styles.css new file mode 100644 index 0000000..b85e308 --- /dev/null +++ b/templates/weatherapi.com/styles.css @@ -0,0 +1,32 @@ + +[data-widget='weatherapi-com-current'] { + width: 20rem; + padding: 1rem; +} + +[data-widget='weatherapi-com-current'] svg.icon { + width: 1.2rem; + height: 1.2rem; + flex: 0 0 1.2rem; +} + +[data-widget='weatherapi-com-current'] h2 { + font-size: 1.2rem; + margin-block: 0; +} + +[data-widget='weatherapi-com-current'] p:is(.condition, .wind, .temp) { + display: flex; + column-gap: 0.5rem; + font-size: 1rem; + margin-block: 0.25rem; +} + +[data-widget='weatherapi-com-current'] p.condition { + margin-block-end: 1rem; +} + +[data-widget='weatherapi-com-current'] p.powered-by { + font-size: 0.8rem; + margin-block-start: 1rem; +} diff --git a/vendor/feather-icons/icons.json b/vendor/feather-icons/icons.json new file mode 100644 index 0000000..ba2001c --- /dev/null +++ b/vendor/feather-icons/icons.json @@ -0,0 +1,289 @@ +{ + "activity": "", + "airplay": "", + "alert-circle": "", + "alert-octagon": "", + "alert-triangle": "", + "align-center": "", + "align-justify": "", + "align-left": "", + "align-right": "", + "anchor": "", + "aperture": "", + "archive": "", + "arrow-down-circle": "", + "arrow-down-left": "", + "arrow-down-right": "", + "arrow-down": "", + "arrow-left-circle": "", + "arrow-left": "", + "arrow-right-circle": "", + "arrow-right": "", + "arrow-up-circle": "", + "arrow-up-left": "", + "arrow-up-right": "", + "arrow-up": "", + "at-sign": "", + "award": "", + "bar-chart-2": "", + "bar-chart": "", + "battery-charging": "", + "battery": "", + "bell-off": "", + "bell": "", + "bluetooth": "", + "bold": "", + "book-open": "", + "book": "", + "bookmark": "", + "box": "", + "briefcase": "", + "calendar": "", + "camera-off": "", + "camera": "", + "cast": "", + "check-circle": "", + "check-square": "", + "check": "", + "chevron-down": "", + "chevron-left": "", + "chevron-right": "", + "chevron-up": "", + "chevrons-down": "", + "chevrons-left": "", + "chevrons-right": "", + "chevrons-up": "", + "chrome": "", + "circle": "", + "clipboard": "", + "clock": "", + "cloud-drizzle": "", + "cloud-lightning": "", + "cloud-off": "", + "cloud-rain": "", + "cloud-snow": "", + "cloud": "", + "code": "", + "codepen": "", + "codesandbox": "", + "coffee": "", + "columns": "", + "command": "", + "compass": "", + "copy": "", + "corner-down-left": "", + "corner-down-right": "", + "corner-left-down": "", + "corner-left-up": "", + "corner-right-down": "", + "corner-right-up": "", + "corner-up-left": "", + "corner-up-right": "", + "cpu": "", + "credit-card": "", + "crop": "", + "crosshair": "", + "database": "", + "delete": "", + "disc": "", + "divide-circle": "", + "divide-square": "", + "divide": "", + "dollar-sign": "", + "download-cloud": "", + "download": "", + "dribbble": "", + "droplet": "", + "edit-2": "", + "edit-3": "", + "edit": "", + "external-link": "", + "eye-off": "", + "eye": "", + "facebook": "", + "fast-forward": "", + "feather": "", + "figma": "", + "file-minus": "", + "file-plus": "", + "file-text": "", + "file": "", + "film": "", + "filter": "", + "flag": "", + "folder-minus": "", + "folder-plus": "", + "folder": "", + "framer": "", + "frown": "", + "gift": "", + "git-branch": "", + "git-commit": "", + "git-merge": "", + "git-pull-request": "", + "github": "", + "gitlab": "", + "globe": "", + "grid": "", + "hard-drive": "", + "hash": "", + "headphones": "", + "heart": "", + "help-circle": "", + "hexagon": "", + "home": "", + "image": "", + "inbox": "", + "info": "", + "instagram": "", + "italic": "", + "key": "", + "layers": "", + "layout": "", + "life-buoy": "", + "link-2": "", + "link": "", + "linkedin": "", + "list": "", + "loader": "", + "lock": "", + "log-in": "", + "log-out": "", + "mail": "", + "map-pin": "", + "map": "", + "maximize-2": "", + "maximize": "", + "meh": "", + "menu": "", + "message-circle": "", + "message-square": "", + "mic-off": "", + "mic": "", + "minimize-2": "", + "minimize": "", + "minus-circle": "", + "minus-square": "", + "minus": "", + "monitor": "", + "moon": "", + "more-horizontal": "", + "more-vertical": "", + "mouse-pointer": "", + "move": "", + "music": "", + "navigation-2": "", + "navigation": "", + "octagon": "", + "package": "", + "paperclip": "", + "pause-circle": "", + "pause": "", + "pen-tool": "", + "percent": "", + "phone-call": "", + "phone-forwarded": "", + "phone-incoming": "", + "phone-missed": "", + "phone-off": "", + "phone-outgoing": "", + "phone": "", + "pie-chart": "", + "play-circle": "", + "play": "", + "plus-circle": "", + "plus-square": "", + "plus": "", + "pocket": "", + "power": "", + "printer": "", + "radio": "", + "refresh-ccw": "", + "refresh-cw": "", + "repeat": "", + "rewind": "", + "rotate-ccw": "", + "rotate-cw": "", + "rss": "", + "save": "", + "scissors": "", + "search": "", + "send": "", + "server": "", + "settings": "", + "share-2": "", + "share": "", + "shield-off": "", + "shield": "", + "shopping-bag": "", + "shopping-cart": "", + "shuffle": "", + "sidebar": "", + "skip-back": "", + "skip-forward": "", + "slack": "", + "slash": "", + "sliders": "", + "smartphone": "", + "smile": "", + "speaker": "", + "square": "", + "star": "", + "stop-circle": "", + "sun": "", + "sunrise": "", + "sunset": "", + "table": "", + "tablet": "", + "tag": "", + "target": "", + "terminal": "", + "thermometer": "", + "thumbs-down": "", + "thumbs-up": "", + "toggle-left": "", + "toggle-right": "", + "tool": "", + "trash-2": "", + "trash": "", + "trello": "", + "trending-down": "", + "trending-up": "", + "triangle": "", + "truck": "", + "tv": "", + "twitch": "", + "twitter": "", + "type": "", + "umbrella": "", + "underline": "", + "unlock": "", + "upload-cloud": "", + "upload": "", + "user-check": "", + "user-minus": "", + "user-plus": "", + "user-x": "", + "user": "", + "users": "", + "video-off": "", + "video": "", + "voicemail": "", + "volume-1": "", + "volume-2": "", + "volume-x": "", + "volume": "", + "watch": "", + "wifi-off": "", + "wifi": "", + "wind": "", + "x-circle": "", + "x-octagon": "", + "x-square": "", + "x": "", + "youtube": "", + "zap-off": "", + "zap": "", + "zoom-in": "", + "zoom-out": "" +} \ No newline at end of file diff --git a/vendor/feather-icons/license b/vendor/feather-icons/license new file mode 100644 index 0000000..4bb4ff7 --- /dev/null +++ b/vendor/feather-icons/license @@ -0,0 +1,24 @@ +https://github.com/feathericons/feather/blob/master/LICENSE +--- + +The MIT License (MIT) + +Copyright (c) 2013-2017 Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/vendor/feather-icons/readme.md b/vendor/feather-icons/readme.md new file mode 100644 index 0000000..065d721 --- /dev/null +++ b/vendor/feather-icons/readme.md @@ -0,0 +1,6 @@ + + + +`icons.json` here is sourced from `dist/icons.json` from the bundle, version 4.29.0. + +This is intentionally not installed from `npm install feather-icons` because that package includes all of `core-js` as a dependency (which this project gets zero benefit from and is very large, impacting container image size).