From 13b890062d9237b52350286eebb996492fd1b65f Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Wed, 11 Dec 2024 22:00:23 +0100 Subject: [PATCH 1/2] Rewrite configuration / environment logic using `@wuespace/envar` Fixes all kinds of glaring problems the previous configuration had, including: 1. problems related to using the same schema for env and DB config parsing, 2. bad testability due to globally static `CONFIG` object, and 3. bad documentation of configuration variables due to complex structure. With this, we can also use `@wuespace/envardoc` to document our env variables. Closes: #4 Closes: #8 --- DEPLOY.md | 1 + README.md | 4 +- compose.yml | 36 ++- deno.json | 21 +- deno.lock | 29 ++ env.md | 327 ++++++++++++++++++++ example.env | 203 ++++++------ lib/cli/bump.ts | 3 +- lib/cli/server.ts | 14 +- lib/cli/stats.ts | 14 +- lib/common/additional-managed-namespaces.ts | 4 +- lib/common/asn.ts | 17 +- lib/common/config.ts | 196 ++++++++---- lib/common/db.ts | 6 +- lib/common/namespaces.ts | 12 +- lib/common/path.ts | 12 +- lib/common/zod-helpers.ts | 18 ++ lib/http/barcode-svg.ts | 4 +- lib/http/lookup-url.ts | 10 +- lib/http/routes/lookup.ts | 6 +- lib/http/routes/ui.tsx | 4 +- lib/http/ui/index.tsx | 8 +- lib/http/ui/search.tsx | 10 +- lib/http/ui/wrapper.tsx | 4 +- main.ts | 6 +- 25 files changed, 706 insertions(+), 263 deletions(-) create mode 100644 env.md diff --git a/DEPLOY.md b/DEPLOY.md index 4e8d26d..500ab53 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,6 +1,7 @@ # Deployment [`.env` Example](example.env) · +[Configuration Parameters](env.md) · [GHCR Docker Image](https://github.com/wuespace/deno-asn-generator/pkgs/container/deno-asn-generator) · Docker Hub Docker Image (coming soon) · [JSR Package](https://jsr.io/@wuespace/asn-generator) · diff --git a/README.md b/README.md index 82b92eb..0e7ad22 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ [![Deno CI](https://github.com/wuespace/deno-asn-generator/actions/workflows/deno-ci.yml/badge.svg)](https://github.com/wuespace/deno-asn-generator/actions/workflows/deno-ci.yml) [![Docker](https://github.com/wuespace/deno-asn-generator/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/wuespace/deno-asn-generator/actions/workflows/docker-publish.yml) -[Deployment](DEPLOY.md) · [License (MIT)](./LICENSE) · +[Deployment](DEPLOY.md) · +[Configuration](env.md) · +[License (MIT)](./LICENSE) · [Contributing](./CONTRIBUTING.md) --- diff --git a/compose.yml b/compose.yml index 0a92ef4..0a821d9 100644 --- a/compose.yml +++ b/compose.yml @@ -1,16 +1,36 @@ name: deno-asn-generator services: - app: - image: asngenerator + asn-generator: + image: ghcr.io/wuespace/deno-asn-generator build: context: . dockerfile: Dockerfile ports: - - "127.0.0.1:8080:8080" - env_file: - - .env + - "127.0.0.1:8080:${PORT:-8080}" + volumes: + - asn-generator-data:/app/data environment: - ASN_NAMESPACE_RANGE: 50 - ASN_PREFIX: WBD - ASN_BARCODE_TYPE: CODE128 \ No newline at end of file + - PORT=${PORT:-8080} + - ASN_PREFIX + - ASN_NAMESPACE_RANGE + - ASN_ENABLE_NAMESPACE_EXTENSION + - ADDITIONAL_MANAGED_NAMESPACES + - ASN_LOOKUP_URL + - ASN_LOOKUP_INCLUDE_PREFIX + - ASN_BARCODE_TYPE + - DATA_DIR + - DB_FILE_NAME + - DENO_KV_ACCESS_TOKEN + - OIDC_ISSUER + - OIDC_AUTH_SECRET + - OIDC_CLIENT_ID + - OIDC_CLIENT_SECRET + - OIDC_REDIRECT_URI + - OIDC_SCOPES + - OIDC_UID_CLAIM + - OIDC_NAME_CLAIM + - OIDC_ROLES_CLAIM + +volumes: + asn-generator-data: \ No newline at end of file diff --git a/deno.json b/deno.json index 9fbcf3b..08220ea 100644 --- a/deno.json +++ b/deno.json @@ -13,7 +13,10 @@ "test": "DATA_DIR=data-test ASN_PREFIX=ASN ASN_NAMESPACE_RANGE=50 ASN_ENABLE_NAMESPACE_EXTANSION=1 deno test -A", "docs": "deno doc --reload --html main.ts", "lint": "DENO_FUTURE=1 deno lint && deno doc --lint main.ts lib/*/mod.ts", - "check": "deno check main.ts lib/**/*.ts" + "check": "deno check main.ts lib/**/*.ts", + "env:all": "deno task env:example && deno task env:docs", + "env:example": "deno run --allow-read --allow-write jsr:@wuespace/envardoc@0.4.3 example -r example.env", + "env:docs": "deno run --allow-read --allow-write jsr:@wuespace/envardoc@0.4.3 docs -o env.md example.env" }, "imports": { "$/": "./", @@ -27,23 +30,15 @@ "@std/assert": "jsr:@std/assert@^1.0.4", "@std/cli": "jsr:@std/cli@^1.0.5", "@std/datetime": "jsr:@std/datetime@^0.225.2", - "@std/dotenv": "jsr:@std/dotenv@^0.225.2" + "@std/dotenv": "jsr:@std/dotenv@^0.225.2", + "@wuespace/envar": "jsr:@wuespace/envar@^1.1.1" }, - "unstable": [ - "kv" - ], + "unstable": ["kv"], "compilerOptions": { "jsx": "precompile", "jsxImportSource": "@hono/hono/jsx" }, "lint": { - "exclude": [ - "deps/", - "dist/", - "docs/", - "node_modules/", - "vendor/", - "data/" - ] + "exclude": ["deps/", "dist/", "docs/", "node_modules/", "vendor/", "data/"] } } diff --git a/deno.lock b/deno.lock index 00d597b..d7b0cd5 100644 --- a/deno.lock +++ b/deno.lock @@ -5,7 +5,12 @@ "jsr:@std/assert@^1.0.4": "1.0.4", "jsr:@std/cli@^1.0.5": "1.0.5", "jsr:@std/dotenv@~0.225.2": "0.225.2", + "jsr:@std/fmt@^1.0.3": "1.0.3", + "jsr:@std/fs@^1.0.6": "1.0.6", "jsr:@std/internal@^1.0.3": "1.0.3", + "jsr:@std/io@0.225": "0.225.0", + "jsr:@std/log@~0.224.9": "0.224.11", + "jsr:@wuespace/envar@^1.1.1": "1.1.1", "npm:@hono/oidc-auth@^1.2.0": "1.2.0_hono@4.6.9", "npm:@types/node@*": "18.16.19", "npm:bwip-js@^4.5.1": "4.5.1", @@ -27,8 +32,31 @@ "@std/dotenv@0.225.2": { "integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23" }, + "@std/fmt@1.0.3": { + "integrity": "97765c16aa32245ff4e2204ecf7d8562496a3cb8592340a80e7e554e0bb9149f" + }, + "@std/fs@1.0.6": { + "integrity": "42b56e1e41b75583a21d5a37f6a6a27de9f510bcd36c0c85791d685ca0b85fa2" + }, "@std/internal@1.0.3": { "integrity": "208e9b94a3d5649bd880e9ca38b885ab7651ab5b5303a56ed25de4755fb7b11e" + }, + "@std/io@0.225.0": { + "integrity": "c1db7c5e5a231629b32d64b9a53139445b2ca640d828c26bf23e1c55f8c079b3" + }, + "@std/log@0.224.11": { + "integrity": "df5e5a6d6ab8bcea016a17982cd2435f65234d6618bf631925587c0b2eae2a4e", + "dependencies": [ + "jsr:@std/fmt", + "jsr:@std/fs", + "jsr:@std/io" + ] + }, + "@wuespace/envar@1.1.1": { + "integrity": "0d21905ac25f2db6f2dc6bd7e0154d5c4db90157327880eed28d007a06c69426", + "dependencies": [ + "jsr:@std/log" + ] } }, "npm": { @@ -62,6 +90,7 @@ "jsr:@std/cli@^1.0.5", "jsr:@std/datetime@~0.225.2", "jsr:@std/dotenv@~0.225.2", + "jsr:@wuespace/envar@^1.1.1", "npm:@hono/oidc-auth@^1.2.0", "npm:bwip-js@^4.5.1", "npm:zod@^3.23.8" diff --git a/env.md b/env.md new file mode 100644 index 0000000..e4706db --- /dev/null +++ b/env.md @@ -0,0 +1,327 @@ +# Configuration Environment Variables + +Note that for every parameter, you can also set "[PARAMETER]_FILE" +to a file that contains the value. This is especially useful for things +like mounted secrets in Docker Swarm or Kubernetes. + +## ASN Generation Settings + +### `ADDITIONAL_MANAGED_NAMESPACES` (optional) + +Additional namespaces managed by the system outside of the ASN_NAMESPACE_RANGE. + +Namespaces are notated as "<Namespace Label><Namespace Label>..." where: +- Namespace is the numeric ID of the namespace. +- Label is the label for the namespace. +Optionally, commas and spaces can be used to separate namespaces. + +If empty, no additional namespaces are managed and only the ASN_NAMESPACE_RANGE is used. + +#### Default Value (used by the application if not provided) + +```env +ADDITIONAL_MANAGED_NAMESPACES= +``` + +#### Other Examples + +```env +ADDITIONAL_MANAGED_NAMESPACES=<700 NDA-Covered Documents (Generic)><800 Personal Data Documents (Generic)> +``` + +### `ASN_BARCODE_TYPE` (optional) + +The type of barcode to generate for the ASN. + +#### Default Value (used by the application if not provided) + +```env +ASN_BARCODE_TYPE=CODE128 +``` + +#### Other Examples + +```env +ASN_BARCODE_TYPE=CODE39 +``` + +```env +ASN_BARCODE_TYPE=CODE93 +``` + +### `ASN_ENABLE_NAMESPACE_EXTENSION` (optional) + +Enable namespace extension. If true, the ADDITIONAL_MANAGED_NAMESPACES can have more digits than +the ASN_NAMESPACE_RANGE. +If false, the ADDITIONAL_MANAGED_NAMESPACES must have the same number of digits as the ASN_NAMESPACE_RANGE. + +This works by reserving leading `9`s for namespace extension: +For example, let's say the ASN_NAMESPACE_RANGE is 60. Therefore, without the extension, our ADDITIONAL_MANAGED_NAMESPACES +could only be 6X-9X, meaning we only have 39 available namespaces. +With the extension, in the two-digit namespace range, we actually lose 9X (leaving 6X-8X). However, leading 9s +expand the namespace by another digit. This can also be chained, giving us theoretically infinite additional namespaces: +6X-8X, 90X-98X, 990X-998X, 9990X-9998X, etc. + +Note that behind the leading 9s, the namespace must still be the same number of digits as the ASN_NAMESPACE_RANGE. + +#### Default Value (used by the application if not provided) + +```env +ASN_ENABLE_NAMESPACE_EXTENSION=false +``` + +#### Other Examples + +```env +ASN_ENABLE_NAMESPACE_EXTENSION=true +``` + +### `ASN_NAMESPACE_RANGE` + +The namespace range. The number of digits must not change after the first run. +For example, if the range is 600, auto-generated ASNs will be in the range of 100XXX to 599XXX. +600XXX to 999XXX will be reserved for manual ASNs in that case. + +#### Default Value (from the example environment file, must be provided) + +```env +ASN_NAMESPACE_RANGE=600 +``` + +### `ASN_PREFIX` + +Prefix for the ASN. Must not change after the first run. + +#### Default Value (from the example environment file, must be provided) + +```env +ASN_PREFIX=ASN +``` + +## ASN Lookup Settings + +### `ASN_LOOKUP_URL` (optional) + +URL to look up existing ASN data. "{asn}" will be replaced with the ASN. +If empty, the lookup feature will be disabled. + +#### Default Value (used by the application if not provided) + +```env +ASN_LOOKUP_URL= +``` + +#### Other Examples + +```env +ASN_LOOKUP_URL="https://dms.example.com/documents?archive_serial_number +``` + +### `ASN_LOOKUP_URL_INCLUDE_PREFIX` (optional) + +Include the ASN_PREFIX in the {asn} replacement of the lookup URL. If false, the prefix will be removed. +Default is false. + +#### Default Value (used by the application if not provided) + +```env +ASN_LOOKUP_URL_INCLUDE_PREFIX=false +``` + +## Network Settings + +### `PORT` (optional) + +The port the server will listen on. + +#### Default Value (used by the application if not provided) + +```env +PORT=8080 +``` + +#### Other Examples + +```env +PORT=80 +``` + +## OIDC (OpenID Connect) Configuration + +### `OIDC_AUTH_SECRET` + +Secret key used for signing and verifying tokens. +Must be at least 32 characters long for security purposes. + +#### Default Value (from the example environment file, must be provided) + +```env +OIDC_AUTH_SECRET=RANDOM_SECRET_WITH_MIN_32_CHARS_CHANGE_ME_IMMEDIATELY_UPON_COPYING +``` + +### `OIDC_CLIENT_ID` + +Client ID provided by your OIDC provider. +Replace "XXX" with your actual client ID. + +#### Default Value (from the example environment file, must be provided) + +```env +OIDC_CLIENT_ID="XXX" +``` + +### `OIDC_CLIENT_SECRET` + +Client Secret provided by your OIDC provider. +Replace "XXX" with your actual client secret. + +#### Default Value (from the example environment file, must be provided) + +```env +OIDC_CLIENT_SECRET="XXX" +``` + +### `OIDC_ISSUER` + +The URL of the OIDC provider's authorization server. +This is where your application will redirect users to authenticate. + +#### Default Value (from the example environment file, must be provided) + +```env +OIDC_ISSUER=https://logto.example.com/oidc # Logto +``` + +#### Other Examples + +```env +OIDC_ISSUER=https://authentik.example.com/application/o/dms/ # Authentik +``` + +```env +OIDC_ISSUER=https://authelia.example.com # Authelia +``` + +```env +OIDC_ISSUER=https://keycloak.example.com/realms/[REALM] # Keycloak +``` + +### `OIDC_NAME_CLAIM` (optional) + +The claim in the ID token that contains the user's name. + +#### Default Value (used by the application if not provided) + +```env +OIDC_NAME_CLAIM=name +``` + +#### Other Examples + +```env +OIDC_NAME_CLAIM=preferred_username +``` + +### `OIDC_REDIRECT_URI` + +The URL to which the OIDC provider will redirect users after authentication. +This should match the redirect URI registered with your OIDC provider. + +#### Default Value (from the example environment file, must be provided) + +```env +OIDC_REDIRECT_URI=http://localhost:41319/oidc/callback +``` + +### `OIDC_ROLES_CLAIM` (optional) + +The claim in the ID token that contains the user's roles. + +#### Default Value (used by the application if not provided) + +```env +OIDC_ROLES_CLAIM=roles +``` + +#### Other Examples + +```env +OIDC_ROLES_CLAIM=groups +``` + +```env +OIDC_ROLES_CLAIM=custom-roles-claim +``` + +### `OIDC_SCOPES` + +Scopes requested from the OIDC provider. +These determine the information returned in the ID token. + +#### Default Value (from the example environment file, must be provided) + +```env +OIDC_SCOPES="openid profile roles" +``` + +### `OIDC_UID_CLAIM` (optional) + +The claim in the ID token that contains the user's unique identifier. + +#### Default Value (used by the application if not provided) + +```env +OIDC_UID_CLAIM=sub +``` + +#### Other Examples + +```env +OIDC_UID_CLAIM=uid +``` + +```env +OIDC_UID_CLAIM=email +``` + +```env +OIDC_UID_CLAIM=custom-uid-claim +``` + +## Storage Settings + +### `DATA_DIR` (optional) + +Data directory. + +#### Default Value (used by the application if not provided) + +```env +DATA_DIR=data +``` + +### `DB_FILE_NAME` (optional) + +Name of the SQLite3 database file within the data directory. +The database gets created if it does not exist. +To use a distributed database, set this to a URL beginning with "http" or "https". +If it starts with "http" or "https", this uses the KV Connect Protocol: +https://github.com/denoland/denokv/blob/main/proto/kv-connect.md + +#### Default Value (used by the application if not provided) + +```env +DB_FILE_NAME=denokv.sqlite3 +``` + +### `DENO_KV_ACCESS_TOKEN` (optional) + +The access token for the KV Connect Protocol. +This is required if DB_FILE_NAME is a URL. +The token must be set in the environment variable DENO_KV_ACCESS_TOKEN as per Deno's requirements. + +#### Default Value (used by the application if not provided) + +```env +DENO_KV_ACCESS_TOKEN=XXX +``` diff --git a/example.env b/example.env index 442c03f..1c8e125 100644 --- a/example.env +++ b/example.env @@ -1,147 +1,122 @@ -# ############################################ -# #### Deno ASN Generator Configuration ###### -# ############################################ +# Note that for every parameter, you can also set "[PARAMETER]_FILE" +# to a file that contains the value. This is especially useful for things +# like mounted secrets in Docker Swarm or Kubernetes. + +# ASN Generation Settings # -# This file contains configuration options for the ASN generator. -# Copy this file to .env and modify the values as needed. +# Additional namespaces managed by the system outside of the ASN_NAMESPACE_RANGE. # -# For Docker, pass the `.env` file to the container using the `--env-file` option. -# For example: -# docker run --env-file .env -p 8080:8080 deno-asn-generator +# Namespaces are notated as "..." where: +# - Namespace is the numeric ID of the namespace. +# - Label is the label for the namespace. +# Optionally, commas and spaces can be used to separate namespaces. # -# Some settings are commented out by default. These are settings that should only be -# changed when not (!) using Docker. If you are using Docker, leave these settings -# commented out. These settings are: -# - PORT -# - DATA_DIR -# - DB_FILE_NAME +# If empty, no additional namespaces are managed and only the ASN_NAMESPACE_RANGE is used. +# ADDITIONAL_MANAGED_NAMESPACES= +# ADDITIONAL_MANAGED_NAMESPACES=<700 NDA-Covered Documents (Generic)><800 Personal Data Documents (Generic)> # -# This also allows importing the `env.example` file into Docker configuration UIs such as -# Portainer, which will ignore the comments and use the correct settings. +# The type of barcode to generate for the ASN. +# ASN_BARCODE_TYPE=CODE128 +# ASN_BARCODE_TYPE=CODE39 +# ASN_BARCODE_TYPE=CODE93 # - -# ## General Settings -# The port the server will listen on. -# FOR DOCKER, THIS DEFAULTS TO 8080 AND SHOULD NOT BE CHANGED -# PORT=8080 - -# Docker-specific setting to set the host port. -# Allows convenient configuration of the host port in Docker environments. -# Ignored by the application itself. -HOST_PORT=8080 - -# URL to look up existing ASN data. "{asn}" will be replaced with the ASN. -# If empty, the lookup feature will be disabled. -# Default is empty. -ASN_LOOKUP_URL="https://dms.example.com/documents?archive_serial_number={asn}" - -# Include the ASN_PREFIX in the {asn} replacement of the lookup URL. If false, the prefix will be removed. -# Default is false. -ASN_LOOKUP_URL_INCLUDE_PREFIX=false - -# The type of barcode to generate for the ASN. Options: -# - CODE128 -# - CODE39 -# - CODE93 -# Default is CODE128. -ASN_BARCODE_TYPE=CODE128 - -# ## ASN Generation Settings - -# Prefix for the ASN. Must not change after the first run. -ASN_PREFIX=ASN - -# The namespace range. The number of digits must not change after the first run. -# For example, if the range is 600, auto-generated ASNs will be in the range of 100XXX to 599XXX. -# 600XXX to 999XXX will be reserved for manual ASNs in that case. -ASN_NAMESPACE_RANGE=600 - # Enable namespace extension. If true, the ADDITIONAL_MANAGED_NAMESPACES can have more digits than # the ASN_NAMESPACE_RANGE. # If false, the ADDITIONAL_MANAGED_NAMESPACES must have the same number of digits as the ASN_NAMESPACE_RANGE. -# +# # This works by reserving leading `9`s for namespace extension: # For example, let's say the ASN_NAMESPACE_RANGE is 60. Therefore, without the extension, our ADDITIONAL_MANAGED_NAMESPACES # could only be 6X-9X, meaning we only have 39 available namespaces. # With the extension, in the two-digit namespace range, we actually lose 9X (leaving 6X-8X). However, leading 9s # expand the namespace by another digit. This can also be chained, giving us theoretically infinite additional namespaces: # 6X-8X, 90X-98X, 990X-998X, 9990X-9998X, etc. -# +# # Note that behind the leading 9s, the namespace must still be the same number of digits as the ASN_NAMESPACE_RANGE. -# -# Default is false. -ASN_ENABLE_NAMESPACE_EXTENSION=false - -# Additional namespaces managed by the system outside of the ASN_NAMESPACE_RANGE. +# ASN_ENABLE_NAMESPACE_EXTENSION=false +# ASN_ENABLE_NAMESPACE_EXTENSION=true # -# Namespaces are notated as "..." where: -# - Namespace is the numeric ID of the namespace. -# - Label is the label for the namespace. -# Optionally, commas and spaces can be used to separate namespaces. +# The namespace range. The number of digits must not change after the first run. +# For example, if the range is 600, auto-generated ASNs will be in the range of 100XXX to 599XXX. +# 600XXX to 999XXX will be reserved for manual ASNs in that case. +ASN_NAMESPACE_RANGE=600 # -# If empty, no additional namespaces are managed and only the ASN_NAMESPACE_RANGE is used. +# Prefix for the ASN. Must not change after the first run. +ASN_PREFIX=ASN + +# ASN Lookup Settings +# +# URL to look up existing ASN data. "{asn}" will be replaced with the ASN. +# If empty, the lookup feature will be disabled. +# ASN_LOOKUP_URL= +# ASN_LOOKUP_URL="https://dms.example.com/documents?archive_serial_number # -# For example: -# <700 NDA-Covered Documents (Generic)><800 Personal Data Documents (Generic)> -ADDITIONAL_MANAGED_NAMESPACES="<700 NDA-Covered Documents (Generic)><800 Personal Data Documents (Generic)>" +# Include the ASN_PREFIX in the {asn} replacement of the lookup URL. If false, the prefix will be removed. +# Default is false. +# ASN_LOOKUP_URL_INCLUDE_PREFIX=false +# Network Settings +# +# The port the server will listen on. +# PORT=8080 +# PORT=80 -# ## File paths +# OIDC (OpenID Connect) Configuration +# +# Secret key used for signing and verifying tokens. +# Must be at least 32 characters long for security purposes. +OIDC_AUTH_SECRET=RANDOM_SECRET_WITH_MIN_32_CHARS_CHANGE_ME_IMMEDIATELY_UPON_COPYING +# +# Client ID provided by your OIDC provider. +# Replace "XXX" with your actual client ID. +OIDC_CLIENT_ID="XXX" +# +# Client Secret provided by your OIDC provider. +# Replace "XXX" with your actual client secret. +OIDC_CLIENT_SECRET="XXX" +# +# The URL of the OIDC provider's authorization server. +# This is where your application will redirect users to authenticate. +OIDC_ISSUER=https://logto.example.com/oidc # Logto +# OIDC_ISSUER=https://authentik.example.com/application/o/dms/ # Authentik +# OIDC_ISSUER=https://authelia.example.com # Authelia +# OIDC_ISSUER=https://keycloak.example.com/realms/[REALM] # Keycloak +# +# The claim in the ID token that contains the user's name. +# OIDC_NAME_CLAIM=name +# OIDC_NAME_CLAIM=preferred_username +# +# The URL to which the OIDC provider will redirect users after authentication. +# This should match the redirect URI registered with your OIDC provider. +OIDC_REDIRECT_URI=http://localhost:41319/oidc/callback +# +# The claim in the ID token that contains the user's roles. +# OIDC_ROLES_CLAIM=roles +# OIDC_ROLES_CLAIM=groups +# OIDC_ROLES_CLAIM=custom-roles-claim +# +# Scopes requested from the OIDC provider. +# These determine the information returned in the ID token. +OIDC_SCOPES="openid profile roles" +# +# The claim in the ID token that contains the user's unique identifier. +# OIDC_UID_CLAIM=sub +# OIDC_UID_CLAIM=uid +# OIDC_UID_CLAIM=email +# OIDC_UID_CLAIM=custom-uid-claim +# Storage Settings +# # Data directory. -# FOR DOCKER, THIS DEFAULTS TO /data AND SHOULD NOT BE CHANGED # DATA_DIR=data - +# # Name of the SQLite3 database file within the data directory. # The database gets created if it does not exist. # To use a distributed database, set this to a URL beginning with "http" or "https". # If it starts with "http" or "https", this uses the KV Connect Protocol: # https://github.com/denoland/denokv/blob/main/proto/kv-connect.md # DB_FILE_NAME=denokv.sqlite3 - +# # The access token for the KV Connect Protocol. # This is required if DB_FILE_NAME is a URL. # The token must be set in the environment variable DENO_KV_ACCESS_TOKEN as per Deno's requirements. # DENO_KV_ACCESS_TOKEN=XXX - -# OIDC (OpenID Connect) Configuration -# -# The URL of the OIDC provider's authorization server. -# This is where your application will redirect users to authenticate. -# If not set, OIDC authentication will be disabled. -# OIDC_ISSUER=https://logto.example.com/oidc -# -# Secret key used for signing and verifying tokens. -# Must be at least 32 characters long for security purposes. -# OIDC_AUTH_SECRET=RANDOM_SECRET_WITH_MIN_32_CHARS_CHANGE_ME_IMMEDIATELY_UPON_COPYING -# -# Client ID provided by your OIDC provider. -# Replace "XXX" with your actual client ID. -# OIDC_CLIENT_ID="XXX" -# -# Client Secret provided by your OIDC provider. -# Replace "XXX" with your actual client secret. -# OIDC_CLIENT_SECRET="XXX" -# -# The URL to which the OIDC provider will redirect users after authentication. -# This should match the redirect URI registered with your OIDC provider. -# OIDC_REDIRECT_URI=http://localhost:41319/oidc/callback -# -# Scopes requested from the OIDC provider. -# These determine the information returned in the ID token. -# OIDC_SCOPES="openid profile roles" -# -# Optional: Uncomment and set if your application uses roles. -# The claim in the ID token that contains the user's roles. -# Defaults to "roles". -# OIDC_ROLES_CLAIM=The claim in the ID token that contains the user's roles. -# -# Optional: Uncomment and set if your application uses a custom UID claim. -# The claim in the ID token that contains the user's unique identifier. -# Defaults to "sub". -# OIDC_UID_CLAIM=The claim in the ID token that contains the user's unique identifier. -# -# Optional: Uncomment and set if your application uses a custom name claim. -# The claim in the ID token that contains the user's name. -# Defaults to "name". -# OIDC_NAME_CLAIM=The claim in the ID token that contains the user's name. diff --git a/lib/cli/bump.ts b/lib/cli/bump.ts index 22e1fd1..ca2e4d5 100644 --- a/lib/cli/bump.ts +++ b/lib/cli/bump.ts @@ -1,7 +1,7 @@ import z from "@collinhacks/zod"; import { allManagedNamespaces, - CONFIG, + getConfig, generateASN, isManagedNamespace, } from "$common/mod.ts"; @@ -17,6 +17,7 @@ const bumpArgs = z.object({ * @param args.count the number of ASNs to generate (default: 1) */ export async function runBump(args: unknown) { + const CONFIG = getConfig(); const parsedParams = bumpArgs.parse(args); if (parsedParams.namespace && !isManagedNamespace(parsedParams.namespace)) { diff --git a/lib/cli/server.ts b/lib/cli/server.ts index dfb6da2..5e76c11 100644 --- a/lib/cli/server.ts +++ b/lib/cli/server.ts @@ -1,13 +1,8 @@ import { z } from "@collinhacks/zod"; -import { CONFIG, logPaths } from "$common/mod.ts"; +import { getConfig, logPaths } from "$common/mod.ts"; import { httpApp } from "../http/mod.ts"; import metadata from "$/deno.json" with { type: "json" }; -const serverArgs = z.object({ - port: z.number().default(CONFIG.PORT), - host: z.string().default("0.0.0.0"), -}); - /** * Runs the web server. * @param args the arguments to the command @@ -15,6 +10,11 @@ const serverArgs = z.object({ * @param args.host the hostname to listen on (default: 0.0.0.0) */ export function runServer(args: unknown): Promise { + const serverArgs = z.object({ + port: z.number().default(getConfig().PORT), + host: z.string().default("0.0.0.0"), + }); + console.log(`Running ${metadata.name} v${metadata.version}`); console.log(); @@ -22,7 +22,7 @@ export function runServer(args: unknown): Promise { console.log(`Starting server on ${parsedArgs.host}:${parsedArgs.port}`); - console.log("Environment Configuration:", CONFIG); + console.log("Environment Configuration:", getConfig()); console.log("Arguments:", parsedArgs); console.log("Paths:"); logPaths(); diff --git a/lib/cli/stats.ts b/lib/cli/stats.ts index 42b07ca..288add9 100644 --- a/lib/cli/stats.ts +++ b/lib/cli/stats.ts @@ -1,5 +1,5 @@ import z from "@collinhacks/zod"; -import { allManagedNamespaces, CONFIG, TimeStats } from "$common/mod.ts"; +import { allManagedNamespaces, getConfig, TimeStats } from "$common/mod.ts"; const generateArgs = z.object({ namespace: z.number({ coerce: true }).optional(), @@ -26,7 +26,7 @@ export async function runStats(args: unknown) { ); const strings = stats.map((stats) => { - return `${CONFIG.ASN_PREFIX}${stats.namespace}XXX: ` + stats.toString(); + return `${getConfig().ASN_PREFIX}${stats.namespace}XXX: ` + stats.toString(); }); console.log(strings.join("\n")); @@ -35,7 +35,7 @@ export async function runStats(args: unknown) { "Maximum Rate of ASN registrations per hour per namespace in the above namespaces:", ); console.log( - `Filtered to only include namespaces with more than 3 ${CONFIG.ASN_PREFIX} numbers.`, + `Filtered to only include namespaces with more than 3 ${getConfig().ASN_PREFIX} numbers.`, ); function maxHourlyRate(sigma: number) { @@ -48,22 +48,22 @@ export async function runStats(args: unknown) { console.log( "1σ (68.27 %):", maxHourlyRate(1).toPrecision(5), - `registered ${CONFIG.ASN_PREFIX} numbers per namespace per hour`, + `registered ${getConfig().ASN_PREFIX} numbers per namespace per hour`, ); console.log( "2σ (95.45 %):", maxHourlyRate(2).toPrecision(5), - `registered ${CONFIG.ASN_PREFIX} numbers per namespace per hour`, + `registered ${getConfig().ASN_PREFIX} numbers per namespace per hour`, ); console.log( "3σ (99.73 %):", maxHourlyRate(3).toPrecision(5), - `registered ${CONFIG.ASN_PREFIX} numbers per namespace per hour`, + `registered ${getConfig().ASN_PREFIX} numbers per namespace per hour`, ); console.log( "6σ (99.99 %):", maxHourlyRate(6).toPrecision(5), - `registered ${CONFIG.ASN_PREFIX} numbers per namespace per hour`, + `registered ${getConfig().ASN_PREFIX} numbers per namespace per hour`, ); console.log( diff --git a/lib/common/additional-managed-namespaces.ts b/lib/common/additional-managed-namespaces.ts index 861c39f..7fbe726 100644 --- a/lib/common/additional-managed-namespaces.ts +++ b/lib/common/additional-managed-namespaces.ts @@ -1,4 +1,4 @@ -import { CONFIG, isValidNamespace } from "$common/mod.ts"; +import { getConfig, isValidNamespace } from "$common/mod.ts"; /** * An additional managed namespace outside of the default range. @@ -102,7 +102,7 @@ export function deserializeAdditionalManagedNamespaces( */ export function isValidAdditionalManagedNamespace( namespace: number, - config = CONFIG, + config = getConfig(), ): boolean { if (!isValidNamespace(namespace, config)) { return false; diff --git a/lib/common/asn.ts b/lib/common/asn.ts index 80140df..e35a36b 100644 --- a/lib/common/asn.ts +++ b/lib/common/asn.ts @@ -1,4 +1,4 @@ -import { CONFIG } from "$common/config.ts"; +import { getConfig } from "$common/config.ts"; import { addTimestampToNamespaceStats, ensureFileContent, @@ -38,7 +38,7 @@ export interface ASNData { metadata: Record; } -function getCurrentNamespace(config = CONFIG): number { +function getCurrentNamespace(config = getConfig()): number { const date = Date.now(); const range = getMinimumGenericRangeNamespace(config); return range + date % (config.ASN_NAMESPACE_RANGE - range); @@ -79,6 +79,7 @@ export async function generateASN( metadata: Record = {}, namespace?: number, deltaCounter = 1, + config = getConfig(), ): Promise { if (deltaCounter < 1) { throw new Error("Delta counter must be at least 1"); @@ -111,7 +112,7 @@ export async function generateASN( const asnData = { asn: formatASN(namespace, counter), namespace, - prefix: CONFIG.ASN_PREFIX, + prefix: config.ASN_PREFIX, counter: counter, metadata, }; @@ -147,7 +148,7 @@ export function isValidCounter(counter: number): boolean { export function formatASN( namespace: number, counter: number, - config = CONFIG, + config = getConfig(), ): string { if (!isValidNamespace(namespace, config)) { throw new Error("Invalid namespace: " + namespace); @@ -171,7 +172,7 @@ export function formatASN( * @returns a human-readable description of the ASN format that explains the prefix, the namespace, and the counter. * @remark The description is intended to be used in the console output or other monospaced text. */ -export function getFormatDescription(config = CONFIG): string { +export function getFormatDescription(config = getConfig()): string { const { ASN_PREFIX, ASN_NAMESPACE_RANGE, @@ -244,7 +245,7 @@ export function nthNinerExtensionRange( * @param config The configuration to use for validation. Defaults to the global configuration. * @returns `true` if the ASN is valid, `false` otherwise */ -export function isValidASN(asn: string, config = CONFIG): boolean { +export function isValidASN(asn: string, config = getConfig()): boolean { return new RegExp( `^(${config.ASN_PREFIX})?(\\d{${ `${config.ASN_NAMESPACE_RANGE}`.length @@ -262,7 +263,7 @@ export function isValidASN(asn: string, config = CONFIG): boolean { * @param config The configuration to use for parsing. Defaults to the global configuration. * @returns An {@link ASNData} object with the parsed ASN data */ -export function parseASN(asn: string, config = CONFIG): ASNData { +export function parseASN(asn: string, config = getConfig()): ASNData { if (!isValidASN(asn, config)) { throw new Error("Invalid ASN"); } @@ -286,7 +287,7 @@ export function parseASN(asn: string, config = CONFIG): ASNData { return { asn: formatASN(namespace, counter, config), - prefix: CONFIG.ASN_PREFIX, + prefix: config.ASN_PREFIX, namespace, counter, metadata: {}, diff --git a/lib/common/config.ts b/lib/common/config.ts index 7ce3217..cc2b566 100644 --- a/lib/common/config.ts +++ b/lib/common/config.ts @@ -1,11 +1,14 @@ -import { z } from "@collinhacks/zod"; import { type AdditionalManagedNamespace, deserializeAdditionalManagedNamespaces, getDB, isValidAdditionalManagedNamespace, + toBoolean, + toNumber, zBoolString, } from "$common/mod.ts"; +import { z, type ZodSchema } from "@collinhacks/zod"; +import { initVariable } from "@wuespace/envar"; /** * The application configuration. @@ -138,79 +141,146 @@ export interface Config { readonly DB_FILE_NAME: string; } -const configSchema = z.object({ - PORT: z.number({ coerce: true }).default(8080), +const configSchema: ZodSchema = z.object({ + PORT: z.number().positive().int(), ASN_PREFIX: z.string().min(1).max(10).regex(/^[A-Z]+$/), - ASN_NAMESPACE_RANGE: z.number({ coerce: true }), - ASN_ENABLE_NAMESPACE_EXTENSION: zBoolString().default(false), - ADDITIONAL_MANAGED_NAMESPACES: z.string().default("").transform((v) => - deserializeAdditionalManagedNamespaces(v) - ).or(z.array(z.object({ - namespace: z.number(), + ASN_NAMESPACE_RANGE: z.number().int().positive(), + ASN_ENABLE_NAMESPACE_EXTENSION: z.boolean(), + ADDITIONAL_MANAGED_NAMESPACES: z.array(z.object({ + namespace: z.number().int().positive(), label: z.string().min(1), - }))).default([]), - ASN_LOOKUP_URL: z.string().regex(/^https?\:\/\/.*\{asn\}.*$/).optional(), - ASN_LOOKUP_INCLUDE_PREFIX: zBoolString().default(false), - ASN_BARCODE_TYPE: z.preprocess( - (v) => v && String(v).toUpperCase(), - z.literal("CODE128") - .or(z.literal("CODE39")) - .or(z.literal("CODE93")) - .default("CODE128"), - ).transform((v) => v.toLowerCase()), - DATA_DIR: z.string().min(1).default("data"), - DB_FILE_NAME: z.string().min(1).default("denokv.sqlite3"), -}).superRefine((config, ctx) => { + })), + ASN_LOOKUP_URL: z.string().optional(), + ASN_LOOKUP_INCLUDE_PREFIX: z.boolean(), + ASN_BARCODE_TYPE: z.literal("CODE128") + .or(z.literal("CODE39")) + .or(z.literal("CODE93")), + DATA_DIR: z.string(), + DB_FILE_NAME: z.string(), +}); + +/** + * Initializes the environment variable based configuration, including validation and defaults. + * This function should be called at the start of the application, befor any calls to {@link getConfig}. + */ +export async function initConfig() { + await Promise.all([ + initVariable("PORT", z.number({ coerce: true }).int().positive(), "8080"), + initVariable("ASN_PREFIX", z.string().min(1).max(10).regex(/^[A-Z]+$/)), + initVariable( + "ASN_NAMESPACE_RANGE", + z.number({ coerce: true }).int().positive(), + ), + initVariable("ASN_ENABLE_NAMESPACE_EXTENSION", zBoolString(), "false"), + initVariable( + "ADDITIONAL_MANAGED_NAMESPACES", + z.string().transform((v) => deserializeAdditionalManagedNamespaces(v)), + "", + ), + initVariable( + "ASN_LOOKUP_URL", + z.string().regex(/^https?\:\/\/.*\{asn\}.*$/).optional(), + ), + initVariable("ASN_LOOKUP_INCLUDE_PREFIX", zBoolString(), "false"), + initVariable( + "ASN_BARCODE_TYPE", + z.preprocess( + (s) => s && String(s).toUpperCase(), + z.literal("CODE128") + .or(z.literal("CODE39")) + .or(z.literal("CODE93")), + ), + "CODE128", + ), + initVariable("DATA_DIR", z.string().min(1), "data"), + initVariable("DB_FILE_NAME", z.string().min(1), "denokv.sqlite3"), + initVariable("DENO_KV_ACCESS_TOKEN", z.string().optional()), + // OIDC + initVariable("OIDC_ISSUER", z.string().url().optional()), + initVariable("OIDC_AUTH_SECRET", z.string().optional()), + initVariable("OIDC_CLIENT_ID", z.string().optional()), + initVariable("OIDC_CLIENT_SECRET", z.string().optional()), + initVariable("OIDC_REDIRECT_URI", z.string().url().optional()), + initVariable("OIDC_SCOPES", z.string().optional()), + initVariable("OIDC_UID_CLAIM", z.string(), "sub"), + initVariable("OIDC_NAME_CLAIM", z.string(), "name"), + initVariable("OIDC_ROLES_CLAIM", z.string(), "roles"), + ]); + + // Additional checks + const config = getConfig(); + if ( config.ASN_ENABLE_NAMESPACE_EXTENSION && (config.ASN_NAMESPACE_RANGE - 1).toString().charAt(0) === "9" ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - params: { - ASN_NAMESPACE_RANGE: config.ASN_NAMESPACE_RANGE, - ASN_ENABLE_NAMESPACE_EXTENSION: config.ASN_ENABLE_NAMESPACE_EXTENSION, - invalidGenericNamespace: config.ASN_NAMESPACE_RANGE - 1, - }, - message: - `Semantic configuration error: ASN_NAMESPACE_RANGE includes namespaces with leading 9s.\n` + + throw new Error( + `Semantic configuration error: ASN_NAMESPACE_RANGE includes namespaces with leading 9s.\n` + `This is not allowed when ASN_ENABLE_NAMESPACE_EXTENSION is true.`, - }); + { + cause: { + ASN_NAMESPACE_RANGE: config.ASN_NAMESPACE_RANGE, + ASN_ENABLE_NAMESPACE_EXTENSION: config.ASN_ENABLE_NAMESPACE_EXTENSION, + invalidGenericNamespace: config.ASN_NAMESPACE_RANGE - 1, + }, + }, + ); } - if ( - !config.ADDITIONAL_MANAGED_NAMESPACES.every((a) => + const hasInvalidAdditionalNamespaces = !config + .ADDITIONAL_MANAGED_NAMESPACES.every((a) => isValidAdditionalManagedNamespace(a.namespace, config) - ) + ); + + if ( + hasInvalidAdditionalNamespaces ) { console.debug(config.ADDITIONAL_MANAGED_NAMESPACES); - ctx.addIssue({ - code: z.ZodIssueCode.custom, - params: { - ASN_ENABLE_NAMESPACE_EXTENSION: config.ASN_ENABLE_NAMESPACE_EXTENSION, - ASN_NAMESPACE_RANGE: config.ASN_NAMESPACE_RANGE, - invalidAdditionalManagedNamespaces: config.ADDITIONAL_MANAGED_NAMESPACES - .filter( - (a) => !isValidAdditionalManagedNamespace(a.namespace, config), - ).map((v) => `${config.ASN_PREFIX}${v.namespace}XXX - ${v.label}`), - }, - message: - `Semantic configuration error: Additional managed namespaces contain invalid namespace numbers.\n` + + throw new Error( + `Semantic configuration error: Additional managed namespaces contain invalid namespace numbers.\n` + `The namespace numbers must have the same amount of digits as ASN_NAMESPACE_RANGE.\n` + `If ASN_ENABLE_NAMESPACE_EXTENSION is true, the leading 9s are stripped from this calculation.\n` + `For example, if your ASN_NAMESPACE_RANGE has two digits, instead of only XX, you can then also have 9XX, 99XX, etc.\n` + `Note that in this case, 9X would not be valid.`, - }); + { + cause: { + ASN_ENABLE_NAMESPACE_EXTENSION: config.ASN_ENABLE_NAMESPACE_EXTENSION, + ASN_NAMESPACE_RANGE: config.ASN_NAMESPACE_RANGE, + invalidAdditionalManagedNamespaces: config + .ADDITIONAL_MANAGED_NAMESPACES + .filter( + (a) => !isValidAdditionalManagedNamespace(a.namespace, config), + ).map((v) => `${config.ASN_PREFIX}${v.namespace}XXX - ${v.label}`), + }, + }, + ); } -}); +} /** - * The current application configuration, based on environment variables, `.env` files, and defaults. - * @see {@link Config} + * Returns the current configuration. Should only be called after initializing the configuration with {@link initConfig}. + * @returns The current configuration. */ -export const CONFIG: Config = Object.freeze( - configSchema.parse(Deno.env.toObject()), -); +export function getConfig(): Config { + return configSchema.parse({ + PORT: toNumber(Deno.env.get("PORT")), + ASN_PREFIX: Deno.env.get("ASN_PREFIX"), + ASN_NAMESPACE_RANGE: toNumber(Deno.env.get("ASN_NAMESPACE_RANGE")), + ASN_ENABLE_NAMESPACE_EXTENSION: toBoolean(Deno.env.get( + "ASN_ENABLE_NAMESPACE_EXTENSION", + )), + ADDITIONAL_MANAGED_NAMESPACES: deserializeAdditionalManagedNamespaces( + z.string().parse(Deno.env.get("ADDITIONAL_MANAGED_NAMESPACES")), + ), + ASN_LOOKUP_URL: Deno.env.get("ASN_LOOKUP_URL"), + ASN_LOOKUP_INCLUDE_PREFIX: toBoolean( + Deno.env.get("ASN_LOOKUP_INCLUDE_PREFIX"), + ), + ASN_BARCODE_TYPE: Deno.env.get("ASN_BARCODE_TYPE")?.toUpperCase(), + DATA_DIR: Deno.env.get("DATA_DIR"), + DB_FILE_NAME: Deno.env.get("DB_FILE_NAME"), + }) satisfies Config; +} const DB_CONFIG_KEY = "config"; @@ -224,46 +294,46 @@ const DB_CONFIG_KEY = "config"; * @returns A promise that resolves if the database configuration is valid. * @throws {Error} If the configuration has changed in an incompatible way. */ -export async function validateDB(): Promise { +export async function validateDB(config = getConfig()): Promise { const db = await getDB(); const dbConfigRes = await db.get([DB_CONFIG_KEY]); if (!dbConfigRes.value) { - await db.set([DB_CONFIG_KEY], CONFIG); + await db.set([DB_CONFIG_KEY], config); return; } const dbConfig = configSchema.parse(dbConfigRes.value); - if (dbConfig.ASN_PREFIX !== CONFIG.ASN_PREFIX) { + if (dbConfig.ASN_PREFIX !== config.ASN_PREFIX) { throw new Error( `Database configuration mismatch: ASN_PREFIX.\n` + ` Old: ${dbConfig.ASN_PREFIX},\n` + - ` New: ${CONFIG.ASN_PREFIX}.\n` + + ` New: ${config.ASN_PREFIX}.\n` + `The prefix must be the same.`, ); } if ( dbConfig.ASN_NAMESPACE_RANGE?.toString().length !== - CONFIG.ASN_NAMESPACE_RANGE.toString().length + config.ASN_NAMESPACE_RANGE.toString().length ) { throw new Error( `Database configuration mismatch: ASN_NAMESPACE_RANGE.\n` + ` Old: ${dbConfig.ASN_NAMESPACE_RANGE},\n` + - ` New: ${CONFIG.ASN_NAMESPACE_RANGE}.\n` + + ` New: ${config.ASN_NAMESPACE_RANGE}.\n` + `The number of digits must be the same.`, ); } - if (dbConfig.ASN_BARCODE_TYPE !== CONFIG.ASN_BARCODE_TYPE) { + if (dbConfig.ASN_BARCODE_TYPE !== config.ASN_BARCODE_TYPE) { console.warn( `Warning: ASN_BARCODE_TYPE has changed. This will affect the barcode generation.\n` + ` Old: ${dbConfig.ASN_BARCODE_TYPE},\n` + - ` New: ${CONFIG.ASN_BARCODE_TYPE}.\n` + + ` New: ${config.ASN_BARCODE_TYPE}.\n` + `Any future barcodes will be generated using the new barcode type which may not be compatible with the old ones.`, ); } - await db.set([DB_CONFIG_KEY], CONFIG); + await db.set([DB_CONFIG_KEY], config); } diff --git a/lib/common/db.ts b/lib/common/db.ts index 26609ca..7941eeb 100644 --- a/lib/common/db.ts +++ b/lib/common/db.ts @@ -1,12 +1,12 @@ import { ensureParentDirExists, getDatabasePath } from "$common/path.ts"; -import { CONFIG } from "$common/config.ts"; +import { getConfig } from "$common/config.ts"; /** * Ensures that the database file exists and returns its API. * @param config The configuration object to use. Defaults to the global configuration. * @returns the key-value store for the application database. */ -export async function getDB(config = CONFIG): Promise { +export async function getDB(config = getConfig()): Promise { const databasePath = getDatabasePath(config); if (!databasePath.startsWith("http")) { await ensureParentDirExists(databasePath); @@ -38,7 +38,7 @@ export async function getDB(config = CONFIG): Promise { */ export async function performAtomicTransaction( fn: (db: Deno.Kv) => Promise, - config = CONFIG, + config = getConfig(), ) { const db = await getDB(config); let res = { ok: false }; diff --git a/lib/common/namespaces.ts b/lib/common/namespaces.ts index 4d62208..9e15f3a 100644 --- a/lib/common/namespaces.ts +++ b/lib/common/namespaces.ts @@ -1,11 +1,11 @@ -import { CONFIG } from "$common/mod.ts"; +import { getConfig } from "$common/mod.ts"; /** * Returns a list of all managed namespaces. * This includes the generic namespaces and the additional managed namespaces. * @returns all managed namespaces */ -export function allManagedNamespaces(config = CONFIG): number[] { +export function allManagedNamespaces(config = getConfig()): number[] { const minGeneric = getMinimumGenericRangeNamespace(config); const maxGeneric = getMaximumGenericRangeNamespace(config); @@ -23,7 +23,7 @@ export function allManagedNamespaces(config = CONFIG): number[] { * This is the maximum value smaller than the `ASN_NAMESPACE_RANGE` configuration parameter. * @returns the maximum namespace value for the generic range */ -export function getMaximumGenericRangeNamespace(config = CONFIG): number { +export function getMaximumGenericRangeNamespace(config = getConfig()): number { return config.ASN_NAMESPACE_RANGE - 1; } @@ -33,7 +33,7 @@ export function getMaximumGenericRangeNamespace(config = CONFIG): number { * the `ASN_NAMESPACE_RANGE` configuration parameter. * @returns the minimum namespace value for the generic range */ -export function getMinimumGenericRangeNamespace(config = CONFIG): number { +export function getMinimumGenericRangeNamespace(config = getConfig()): number { return Number.parseInt( "1" + "0".repeat(config.ASN_NAMESPACE_RANGE.toString().length - 1), ); @@ -54,7 +54,7 @@ export function getMinimumGenericRangeNamespace(config = CONFIG): number { * @param namespace the namespace to check * @returns `true` if the namespace is a valid namespace, `false` otherwise */ -export function isValidNamespace(namespace: number, config = CONFIG): boolean { +export function isValidNamespace(namespace: number, config = getConfig()): boolean { if ( !Number.isSafeInteger(namespace) || namespace < getMinimumGenericRangeNamespace(config) @@ -82,7 +82,7 @@ export function isValidNamespace(namespace: number, config = CONFIG): boolean { */ export function isManagedNamespace( namespace: number, - config = CONFIG, + config = getConfig(), ): boolean { if (namespace < getMinimumGenericRangeNamespace(config)) { return false; diff --git a/lib/common/path.ts b/lib/common/path.ts index 6077bfd..f9789ae 100644 --- a/lib/common/path.ts +++ b/lib/common/path.ts @@ -1,12 +1,12 @@ import { resolve } from "node:path"; -import { CONFIG } from "$common/mod.ts"; +import { getConfig } from "$common/mod.ts"; /** * Resolves the full path to the {@link Config.DATA_DIR}. * @param config The configuration object to use. Defaults to the global configuration. * @returns Full path to the data directory. */ -export function getDataDirectoryPath(config = CONFIG): string { +export function getDataDirectoryPath(config = getConfig()): string { return resolve(config.DATA_DIR); } @@ -17,7 +17,7 @@ export function getDataDirectoryPath(config = CONFIG): string { * @param config The configuration object to use. Defaults to the global configuration. * @returns Full path to the database file. */ -export function getDatabasePath(config = CONFIG): string { +export function getDatabasePath(config = getConfig()): string { if (config.DB_FILE_NAME.startsWith("http")) { return config.DB_FILE_NAME; } @@ -28,7 +28,7 @@ export function getDatabasePath(config = CONFIG): string { /** * Logs relevant paths to the console. */ -export function logPaths(config = CONFIG) { +export function logPaths(config = getConfig()) { console.log(`DATA_PATH: ${getDataDirectoryPath(config)}`); console.log(`DB_FILE_PATH: ${getDatabasePath(config)}`); } @@ -43,7 +43,7 @@ export function logPaths(config = CONFIG) { export function getCounterPath( namespace: number, counter: number, - config = CONFIG, + config = getConfig(), ): string { return resolve( getDataDirectoryPath(config), @@ -60,7 +60,7 @@ export function getCounterPath( */ export function getNamespaceMetadataPath( namespace: number, - config = CONFIG, + config = getConfig(), ): string { return resolve( getDataDirectoryPath(config), diff --git a/lib/common/zod-helpers.ts b/lib/common/zod-helpers.ts index c02bfaf..c189629 100644 --- a/lib/common/zod-helpers.ts +++ b/lib/common/zod-helpers.ts @@ -15,3 +15,21 @@ export function zBoolString(): z.ZodEffects { return false; }, z.boolean()); } + +export function toBoolean(value: string | undefined): boolean | undefined { + if ( + value === undefined || + value === "" + ) return undefined; + + return ["1", "true", "yes", "on", "enabled"].includes(value.toLowerCase()); +} + +export function toNumber(value: string | undefined): number | undefined { + if ( + value === undefined || + value === "" + ) return undefined; + + return Number(value); +} diff --git a/lib/http/barcode-svg.ts b/lib/http/barcode-svg.ts index d1ccb4c..a6aaf47 100644 --- a/lib/http/barcode-svg.ts +++ b/lib/http/barcode-svg.ts @@ -1,5 +1,5 @@ import * as bwip from "@metafloor/bwip-js"; -import { CONFIG } from "$common/config.ts"; +import { getConfig } from "$common/config.ts"; /** * Creates an SVG barcode for the given data and current configuration. @@ -9,7 +9,7 @@ import { CONFIG } from "$common/config.ts"; */ export function createBarcodeSVG(data: string, embedded = false): string { return bwip.toSVG({ - bcid: CONFIG.ASN_BARCODE_TYPE, // Barcode type + bcid: getConfig().ASN_BARCODE_TYPE, // Barcode type text: data, // Text to encode scale: 3, // 3x scaling factor height: 10, // Bar height, in millimeters diff --git a/lib/http/lookup-url.ts b/lib/http/lookup-url.ts index 015db2a..490c03a 100644 --- a/lib/http/lookup-url.ts +++ b/lib/http/lookup-url.ts @@ -1,4 +1,4 @@ -import { CONFIG, isValidASN } from "$common/mod.ts"; +import { getConfig, isValidASN } from "$common/mod.ts"; /** * Builds the URL to lookup the ASN based on the configuration. @@ -6,8 +6,8 @@ import { CONFIG, isValidASN } from "$common/mod.ts"; * @returns the URL to lookup the ASN if the ASN lookup is enabled * @throws {Error} when the ASN lookup is disabled or the ASN is invalid */ -export function getLookupURL(asn: string): string { - const baseUrl = CONFIG.ASN_LOOKUP_URL; +export function getLookupURL(asn: string, config = getConfig()): string { + const baseUrl = config.ASN_LOOKUP_URL; if (!baseUrl) { throw new Error("ASN Lookup is disabled"); @@ -17,8 +17,8 @@ export function getLookupURL(asn: string): string { throw new Error("Invalid ASN"); } - if (!CONFIG.ASN_LOOKUP_INCLUDE_PREFIX) { - asn = asn.slice(CONFIG.ASN_PREFIX.length); + if (!config.ASN_LOOKUP_INCLUDE_PREFIX) { + asn = asn.slice(config.ASN_PREFIX.length); } return baseUrl.replaceAll("{asn}", asn); diff --git a/lib/http/routes/lookup.ts b/lib/http/routes/lookup.ts index f07cdb2..dde3064 100644 --- a/lib/http/routes/lookup.ts +++ b/lib/http/routes/lookup.ts @@ -2,7 +2,7 @@ import { Hono } from "@hono/hono"; import { validator } from "@hono/hono/validator"; import { z } from "@collinhacks/zod"; -import { CONFIG, isValidASN } from "$common/mod.ts"; +import { getConfig, isValidASN } from "$common/mod.ts"; import { getLookupURL } from "$http/mod.ts"; @@ -22,7 +22,7 @@ lookupRoutes.post( return parsed.data; }), (c) => { - const asn = CONFIG.ASN_PREFIX + c.req.valid("form").asn; + const asn = getConfig().ASN_PREFIX + c.req.valid("form").asn; return c.redirect("/go/" + asn); }, ); @@ -38,7 +38,7 @@ lookupRoutes.get( (c) => { const asn = c.req.valid("param").asn; - if (!CONFIG.ASN_LOOKUP_URL) { + if (!getConfig().ASN_LOOKUP_URL) { return c.text("ASN Lookup is disabled", 400); } diff --git a/lib/http/routes/ui.tsx b/lib/http/routes/ui.tsx index 26565b3..0e26070 100644 --- a/lib/http/routes/ui.tsx +++ b/lib/http/routes/ui.tsx @@ -1,7 +1,7 @@ import { Hono } from "@hono/hono"; import { jsxRenderer } from "@hono/hono/jsx-renderer"; -import { CONFIG, generateASN } from "$common/mod.ts"; +import { getConfig, generateASN } from "$common/mod.ts"; import { createMetadata } from "../mod.ts"; @@ -19,7 +19,7 @@ uiRoutes.get( "/", async (c) => await c.render( - , + , ), ); diff --git a/lib/http/ui/index.tsx b/lib/http/ui/index.tsx index 0910967..19cfb7f 100644 --- a/lib/http/ui/index.tsx +++ b/lib/http/ui/index.tsx @@ -1,4 +1,4 @@ -import { CONFIG, type Config } from "$common/mod.ts"; +import type { Config } from "$common/mod.ts"; import { Search } from "$http/ui/search.tsx"; import { css, cx } from "@hono/hono/css"; import { BUTTON_STYLE } from "$http/ui/common/button-styles.ts"; @@ -44,7 +44,7 @@ export function IndexPage({ config }: { config: Config }) { accessible to all members of the organization.

- Generate generic {CONFIG.ASN_PREFIX} number + Generate generic {config.ASN_PREFIX} number
{config.ASN_PREFIX} @@ -53,7 +53,7 @@ export function IndexPage({ config }: { config: Config }) { {genericRangeEnd}XXX
- {CONFIG.ADDITIONAL_MANAGED_NAMESPACES.length + {config.ADDITIONAL_MANAGED_NAMESPACES.length ? (

Manually generate {config.ASN_PREFIX}{" "} @@ -62,7 +62,7 @@ export function IndexPage({ config }: { config: Config }) {

) : ""} - {CONFIG.ADDITIONAL_MANAGED_NAMESPACES.map(({ namespace, label }) => ( + {config.ADDITIONAL_MANAGED_NAMESPACES.map(({ namespace, label }) => ( + css` display: block; border: 1px solid var(--primary-color); padding: 0.5rem; @@ -53,9 +52,10 @@ border: 1px solid var(--primary-color); `; export function Search() { + const isLookupEnabled = Boolean(getConfig().ASN_LOOKUP_URL); return (
- {CONFIG.ASN_PREFIX} + {getConfig().ASN_PREFIX} - {CONFIG.ASN_PREFIX} Number Generator + {getConfig().ASN_PREFIX} Number Generator Date: Wed, 11 Dec 2024 22:08:35 +0100 Subject: [PATCH 2/2] Fix linter warnings --- lib/common/additional-managed-namespaces.ts | 4 ++-- lib/common/asn.ts | 11 ++++++----- lib/common/config.ts | 2 +- lib/common/db.ts | 5 +++-- lib/common/namespaces.ts | 12 ++++++------ lib/common/path.ts | 12 ++++++------ lib/common/zod-helpers.ts | 10 ++++++++++ lib/http/lookup-url.ts | 4 ++-- 8 files changed, 36 insertions(+), 24 deletions(-) diff --git a/lib/common/additional-managed-namespaces.ts b/lib/common/additional-managed-namespaces.ts index 7fbe726..faeacad 100644 --- a/lib/common/additional-managed-namespaces.ts +++ b/lib/common/additional-managed-namespaces.ts @@ -1,4 +1,4 @@ -import { getConfig, isValidNamespace } from "$common/mod.ts"; +import { type Config, getConfig, isValidNamespace } from "$common/mod.ts"; /** * An additional managed namespace outside of the default range. @@ -102,7 +102,7 @@ export function deserializeAdditionalManagedNamespaces( */ export function isValidAdditionalManagedNamespace( namespace: number, - config = getConfig(), + config: Config = getConfig(), ): boolean { if (!isValidNamespace(namespace, config)) { return false; diff --git a/lib/common/asn.ts b/lib/common/asn.ts index e35a36b..f9a6b72 100644 --- a/lib/common/asn.ts +++ b/lib/common/asn.ts @@ -1,6 +1,7 @@ import { getConfig } from "$common/config.ts"; import { addTimestampToNamespaceStats, + type Config, ensureFileContent, getCounterPath, getMaximumGenericRangeNamespace, @@ -79,7 +80,7 @@ export async function generateASN( metadata: Record = {}, namespace?: number, deltaCounter = 1, - config = getConfig(), + config: Config = getConfig(), ): Promise { if (deltaCounter < 1) { throw new Error("Delta counter must be at least 1"); @@ -148,7 +149,7 @@ export function isValidCounter(counter: number): boolean { export function formatASN( namespace: number, counter: number, - config = getConfig(), + config: Config = getConfig(), ): string { if (!isValidNamespace(namespace, config)) { throw new Error("Invalid namespace: " + namespace); @@ -172,7 +173,7 @@ export function formatASN( * @returns a human-readable description of the ASN format that explains the prefix, the namespace, and the counter. * @remark The description is intended to be used in the console output or other monospaced text. */ -export function getFormatDescription(config = getConfig()): string { +export function getFormatDescription(config: Config = getConfig()): string { const { ASN_PREFIX, ASN_NAMESPACE_RANGE, @@ -245,7 +246,7 @@ export function nthNinerExtensionRange( * @param config The configuration to use for validation. Defaults to the global configuration. * @returns `true` if the ASN is valid, `false` otherwise */ -export function isValidASN(asn: string, config = getConfig()): boolean { +export function isValidASN(asn: string, config: Config = getConfig()): boolean { return new RegExp( `^(${config.ASN_PREFIX})?(\\d{${ `${config.ASN_NAMESPACE_RANGE}`.length @@ -263,7 +264,7 @@ export function isValidASN(asn: string, config = getConfig()): boolean { * @param config The configuration to use for parsing. Defaults to the global configuration. * @returns An {@link ASNData} object with the parsed ASN data */ -export function parseASN(asn: string, config = getConfig()): ASNData { +export function parseASN(asn: string, config: Config = getConfig()): ASNData { if (!isValidASN(asn, config)) { throw new Error("Invalid ASN"); } diff --git a/lib/common/config.ts b/lib/common/config.ts index cc2b566..f044733 100644 --- a/lib/common/config.ts +++ b/lib/common/config.ts @@ -294,7 +294,7 @@ const DB_CONFIG_KEY = "config"; * @returns A promise that resolves if the database configuration is valid. * @throws {Error} If the configuration has changed in an incompatible way. */ -export async function validateDB(config = getConfig()): Promise { +export async function validateDB(config: Config = getConfig()): Promise { const db = await getDB(); const dbConfigRes = await db.get([DB_CONFIG_KEY]); diff --git a/lib/common/db.ts b/lib/common/db.ts index 7941eeb..7c3c029 100644 --- a/lib/common/db.ts +++ b/lib/common/db.ts @@ -1,12 +1,13 @@ import { ensureParentDirExists, getDatabasePath } from "$common/path.ts"; import { getConfig } from "$common/config.ts"; +import type { Config } from "$common/mod.ts"; /** * Ensures that the database file exists and returns its API. * @param config The configuration object to use. Defaults to the global configuration. * @returns the key-value store for the application database. */ -export async function getDB(config = getConfig()): Promise { +export async function getDB(config: Config = getConfig()): Promise { const databasePath = getDatabasePath(config); if (!databasePath.startsWith("http")) { await ensureParentDirExists(databasePath); @@ -38,7 +39,7 @@ export async function getDB(config = getConfig()): Promise { */ export async function performAtomicTransaction( fn: (db: Deno.Kv) => Promise, - config = getConfig(), + config: Config = getConfig(), ) { const db = await getDB(config); let res = { ok: false }; diff --git a/lib/common/namespaces.ts b/lib/common/namespaces.ts index 9e15f3a..500a0fb 100644 --- a/lib/common/namespaces.ts +++ b/lib/common/namespaces.ts @@ -1,11 +1,11 @@ -import { getConfig } from "$common/mod.ts"; +import { type Config, getConfig } from "$common/mod.ts"; /** * Returns a list of all managed namespaces. * This includes the generic namespaces and the additional managed namespaces. * @returns all managed namespaces */ -export function allManagedNamespaces(config = getConfig()): number[] { +export function allManagedNamespaces(config: Config = getConfig()): number[] { const minGeneric = getMinimumGenericRangeNamespace(config); const maxGeneric = getMaximumGenericRangeNamespace(config); @@ -23,7 +23,7 @@ export function allManagedNamespaces(config = getConfig()): number[] { * This is the maximum value smaller than the `ASN_NAMESPACE_RANGE` configuration parameter. * @returns the maximum namespace value for the generic range */ -export function getMaximumGenericRangeNamespace(config = getConfig()): number { +export function getMaximumGenericRangeNamespace(config: Config = getConfig()): number { return config.ASN_NAMESPACE_RANGE - 1; } @@ -33,7 +33,7 @@ export function getMaximumGenericRangeNamespace(config = getConfig()): number { * the `ASN_NAMESPACE_RANGE` configuration parameter. * @returns the minimum namespace value for the generic range */ -export function getMinimumGenericRangeNamespace(config = getConfig()): number { +export function getMinimumGenericRangeNamespace(config: Config = getConfig()): number { return Number.parseInt( "1" + "0".repeat(config.ASN_NAMESPACE_RANGE.toString().length - 1), ); @@ -54,7 +54,7 @@ export function getMinimumGenericRangeNamespace(config = getConfig()): number { * @param namespace the namespace to check * @returns `true` if the namespace is a valid namespace, `false` otherwise */ -export function isValidNamespace(namespace: number, config = getConfig()): boolean { +export function isValidNamespace(namespace: number, config: Config = getConfig()): boolean { if ( !Number.isSafeInteger(namespace) || namespace < getMinimumGenericRangeNamespace(config) @@ -82,7 +82,7 @@ export function isValidNamespace(namespace: number, config = getConfig()): boole */ export function isManagedNamespace( namespace: number, - config = getConfig(), + config: Config = getConfig(), ): boolean { if (namespace < getMinimumGenericRangeNamespace(config)) { return false; diff --git a/lib/common/path.ts b/lib/common/path.ts index f9789ae..35ba345 100644 --- a/lib/common/path.ts +++ b/lib/common/path.ts @@ -1,12 +1,12 @@ import { resolve } from "node:path"; -import { getConfig } from "$common/mod.ts"; +import { type Config, getConfig } from "$common/mod.ts"; /** * Resolves the full path to the {@link Config.DATA_DIR}. * @param config The configuration object to use. Defaults to the global configuration. * @returns Full path to the data directory. */ -export function getDataDirectoryPath(config = getConfig()): string { +export function getDataDirectoryPath(config: Config = getConfig()): string { return resolve(config.DATA_DIR); } @@ -17,7 +17,7 @@ export function getDataDirectoryPath(config = getConfig()): string { * @param config The configuration object to use. Defaults to the global configuration. * @returns Full path to the database file. */ -export function getDatabasePath(config = getConfig()): string { +export function getDatabasePath(config: Config = getConfig()): string { if (config.DB_FILE_NAME.startsWith("http")) { return config.DB_FILE_NAME; } @@ -28,7 +28,7 @@ export function getDatabasePath(config = getConfig()): string { /** * Logs relevant paths to the console. */ -export function logPaths(config = getConfig()) { +export function logPaths(config: Config = getConfig()) { console.log(`DATA_PATH: ${getDataDirectoryPath(config)}`); console.log(`DB_FILE_PATH: ${getDatabasePath(config)}`); } @@ -43,7 +43,7 @@ export function logPaths(config = getConfig()) { export function getCounterPath( namespace: number, counter: number, - config = getConfig(), + config: Config = getConfig(), ): string { return resolve( getDataDirectoryPath(config), @@ -60,7 +60,7 @@ export function getCounterPath( */ export function getNamespaceMetadataPath( namespace: number, - config = getConfig(), + config: Config = getConfig(), ): string { return resolve( getDataDirectoryPath(config), diff --git a/lib/common/zod-helpers.ts b/lib/common/zod-helpers.ts index c189629..85dcdd3 100644 --- a/lib/common/zod-helpers.ts +++ b/lib/common/zod-helpers.ts @@ -16,6 +16,11 @@ export function zBoolString(): z.ZodEffects { }, z.boolean()); } +/** + * Parses a boolean from an environment variable value. + * @param value the environment variable value to parse + * @returns the boolean value of the environment variable + */ export function toBoolean(value: string | undefined): boolean | undefined { if ( value === undefined || @@ -25,6 +30,11 @@ export function toBoolean(value: string | undefined): boolean | undefined { return ["1", "true", "yes", "on", "enabled"].includes(value.toLowerCase()); } +/** + * Parses a number from an environment variable value. + * @param value the environment variable value to parse + * @returns the number value of the environment variable + */ export function toNumber(value: string | undefined): number | undefined { if ( value === undefined || diff --git a/lib/http/lookup-url.ts b/lib/http/lookup-url.ts index 490c03a..40cfb88 100644 --- a/lib/http/lookup-url.ts +++ b/lib/http/lookup-url.ts @@ -1,4 +1,4 @@ -import { getConfig, isValidASN } from "$common/mod.ts"; +import { type Config, getConfig, isValidASN } from "$common/mod.ts"; /** * Builds the URL to lookup the ASN based on the configuration. @@ -6,7 +6,7 @@ import { getConfig, isValidASN } from "$common/mod.ts"; * @returns the URL to lookup the ASN if the ASN lookup is enabled * @throws {Error} when the ASN lookup is disabled or the ASN is invalid */ -export function getLookupURL(asn: string, config = getConfig()): string { +export function getLookupURL(asn: string, config: Config = getConfig()): string { const baseUrl = config.ASN_LOOKUP_URL; if (!baseUrl) {