commit ab6a947493e0a58871c1d200a6dc675fdda6c89b Author: jpmvaz Date: Sun Sep 13 20:12:28 2026 +0100 v_1.5_patch_0.2 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..402591d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +node_modules +npm-debug.log +.env +.git +.gitignore +Dockerfile +docker-compose.yml +.dockerignore + +# Never bake runtime data into the image +data/*.db +data/*.db-* +uploads/* +brand/* +!**/.gitkeep + +# Editor / OS cruft +.DS_Store +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ec93adb --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# ---- Server ---- +PORT=3000 +SESSION_SECRET=please-change-this-to-a-long-random-string +COOKIE_SECURE=false # set true when served over HTTPS + +# ---- Storage paths (optional overrides) ---- +# DB_PATH=./data/datahub.db +# UPLOAD_DIR=./uploads +# BRAND_DIR=./brand + +# ---- First administrator ---- +# Nothing to set here. On first boot, open the site in a browser and the +# setup wizard will ask you to create the administrator account. + +# ---- SMTP (email). If omitted, mails are logged to the audit outbox instead ---- +# SMTP_HOST=smtp.yourprovider.com +# SMTP_PORT=587 +# SMTP_SECURE=false +# SMTP_USER=apikey-or-username +# SMTP_PASS=secret +# MAIL_FROM=ISDSS diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3ec06c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.env +data/*.db +data/*.db-* +uploads/* +brand/* +!**/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..55da5e4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,59 @@ +# syntax=docker/dockerfile:1 + +######################## Stage 1: build native deps ######################## +# better-sqlite3 is a native module. Building it here (with toolchain present) +# guarantees a correct binary for the target architecture, then we copy the +# finished node_modules into a slim runtime image. +FROM node:22-slim AS deps + +# Build tools needed to compile better-sqlite3 if a prebuilt binary isn't used. +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY package.json package-lock.json ./ +# Install only production dependencies, reproducibly. +RUN npm ci --omit=dev + +######################## Stage 2: runtime ######################## +FROM node:22-slim AS runtime + +ENV NODE_ENV=production \ + PORT=3000 \ + DB_PATH=/app/data/datahub.db \ + UPLOAD_DIR=/app/uploads \ + BRAND_DIR=/app/brand \ + AVATAR_DIR=/app/avatars + +# Tini gives us proper signal handling / zombie reaping for the Node process. +RUN apt-get update \ + && apt-get install -y --no-install-recommends tini \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Bring in the already-built dependencies. +COPY --from=deps /app/node_modules ./node_modules + +# Application source. +COPY . . + +# Persistent data lives in these directories (mounted as volumes at runtime). +# Create them and hand ownership to the unprivileged "node" user that ships +# with the official image. +RUN mkdir -p /app/data /app/uploads /app/brand /app/avatars \ + && chmod +x /app/docker-entrypoint.sh \ + && chown -R node:node /app + +USER node + +EXPOSE 3000 +VOLUME ["/app/data", "/app/uploads", "/app/brand", "/app/avatars"] + +# Basic liveness check against the public login page. +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "require('http').get('http://127.0.0.1:'+(process.env.PORT||3000)+'/login.html',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))" + +ENTRYPOINT ["/usr/bin/tini", "--", "/app/docker-entrypoint.sh"] +CMD ["node", "server.js"] diff --git a/OLD_VERSIONS/v1.0/martinhal-datahub.zip b/OLD_VERSIONS/v1.0/martinhal-datahub.zip new file mode 100644 index 0000000..b2cff28 Binary files /dev/null and b/OLD_VERSIONS/v1.0/martinhal-datahub.zip differ diff --git a/OLD_VERSIONS/v1.1/martinhal-datahub.zip b/OLD_VERSIONS/v1.1/martinhal-datahub.zip new file mode 100644 index 0000000..6bcea2c Binary files /dev/null and b/OLD_VERSIONS/v1.1/martinhal-datahub.zip differ diff --git a/OLD_VERSIONS/v1.2/martinhal-datahub.zip b/OLD_VERSIONS/v1.2/martinhal-datahub.zip new file mode 100644 index 0000000..43ced28 Binary files /dev/null and b/OLD_VERSIONS/v1.2/martinhal-datahub.zip differ diff --git a/OLD_VERSIONS/v1.3 Patch 0.1/martinhal-isdss-v1.3-patch0.1.zip b/OLD_VERSIONS/v1.3 Patch 0.1/martinhal-isdss-v1.3-patch0.1.zip new file mode 100644 index 0000000..7567b73 Binary files /dev/null and b/OLD_VERSIONS/v1.3 Patch 0.1/martinhal-isdss-v1.3-patch0.1.zip differ diff --git a/OLD_VERSIONS/v1.3 Patch 0.2/martinhal-isdss-v1.3-patch0.2.zip b/OLD_VERSIONS/v1.3 Patch 0.2/martinhal-isdss-v1.3-patch0.2.zip new file mode 100644 index 0000000..03ee395 Binary files /dev/null and b/OLD_VERSIONS/v1.3 Patch 0.2/martinhal-isdss-v1.3-patch0.2.zip differ diff --git a/OLD_VERSIONS/v1.3 Patch 0.3/martinhal-isdss-v1.3-patch0.3.zip b/OLD_VERSIONS/v1.3 Patch 0.3/martinhal-isdss-v1.3-patch0.3.zip new file mode 100644 index 0000000..620b804 Binary files /dev/null and b/OLD_VERSIONS/v1.3 Patch 0.3/martinhal-isdss-v1.3-patch0.3.zip differ diff --git a/OLD_VERSIONS/v1.3 Patch 0.4/martinhal-isdss-v1.3-patch0.4.zip b/OLD_VERSIONS/v1.3 Patch 0.4/martinhal-isdss-v1.3-patch0.4.zip new file mode 100644 index 0000000..e586dcf Binary files /dev/null and b/OLD_VERSIONS/v1.3 Patch 0.4/martinhal-isdss-v1.3-patch0.4.zip differ diff --git a/OLD_VERSIONS/v1.3/martinhal-isdss-v1.3.zip b/OLD_VERSIONS/v1.3/martinhal-isdss-v1.3.zip new file mode 100644 index 0000000..5fdf476 Binary files /dev/null and b/OLD_VERSIONS/v1.3/martinhal-isdss-v1.3.zip differ diff --git a/OLD_VERSIONS/v1.4 Patch 0.1/martinhal-isdss-v1.4-patch0.1.zip b/OLD_VERSIONS/v1.4 Patch 0.1/martinhal-isdss-v1.4-patch0.1.zip new file mode 100644 index 0000000..4fc0c3a Binary files /dev/null and b/OLD_VERSIONS/v1.4 Patch 0.1/martinhal-isdss-v1.4-patch0.1.zip differ diff --git a/OLD_VERSIONS/v1.4 Patch 0.2/martinhal-isdss-v1.4-patch0.2.zip b/OLD_VERSIONS/v1.4 Patch 0.2/martinhal-isdss-v1.4-patch0.2.zip new file mode 100644 index 0000000..a9a9b3d Binary files /dev/null and b/OLD_VERSIONS/v1.4 Patch 0.2/martinhal-isdss-v1.4-patch0.2.zip differ diff --git a/OLD_VERSIONS/v1.4 Patch 0.3/martinhal-isdss-v1.4-patch0.3.zip b/OLD_VERSIONS/v1.4 Patch 0.3/martinhal-isdss-v1.4-patch0.3.zip new file mode 100644 index 0000000..3b37b3f Binary files /dev/null and b/OLD_VERSIONS/v1.4 Patch 0.3/martinhal-isdss-v1.4-patch0.3.zip differ diff --git a/OLD_VERSIONS/v1.5 Patch 0.1/martinhal-isdss-v1.5-patch0.1.zip b/OLD_VERSIONS/v1.5 Patch 0.1/martinhal-isdss-v1.5-patch0.1.zip new file mode 100644 index 0000000..7819eba Binary files /dev/null and b/OLD_VERSIONS/v1.5 Patch 0.1/martinhal-isdss-v1.5-patch0.1.zip differ diff --git a/OLD_VERSIONS/v1.5 Patch 0.2/martinhal-isdss-v1.5-patch0.2.zip b/OLD_VERSIONS/v1.5 Patch 0.2/martinhal-isdss-v1.5-patch0.2.zip new file mode 100644 index 0000000..b250070 Binary files /dev/null and b/OLD_VERSIONS/v1.5 Patch 0.2/martinhal-isdss-v1.5-patch0.2.zip differ diff --git a/OLD_VERSIONS/v1.5/ISDSS Logo.png b/OLD_VERSIONS/v1.5/ISDSS Logo.png new file mode 100644 index 0000000..0e1f31b Binary files /dev/null and b/OLD_VERSIONS/v1.5/ISDSS Logo.png differ diff --git a/OLD_VERSIONS/v1.5/martinhal-isdss-v1.5.zip b/OLD_VERSIONS/v1.5/martinhal-isdss-v1.5.zip new file mode 100644 index 0000000..dc664af Binary files /dev/null and b/OLD_VERSIONS/v1.5/martinhal-isdss-v1.5.zip differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3da08f0 --- /dev/null +++ b/README.md @@ -0,0 +1,581 @@ +# Martinhal ISDSS — v1.5 + +A self-hosted, secure data-access portal with an admin approval workflow. +Built with Node.js + Express + SQLite. No external database or cloud service required. + +> © 2026 Martinhal IT - Joao Vaz - Version 1.5 Patch 0.2 + +--- + +## What it does + +| # | Requirement | Where it lives | +|---|-------------|----------------| +| 1 | Login page for users | `public/login.html` + `routes/auth.js` (first boot: `public/setup.html` + `routes/setup.js`) | +| 2 | Users can add MFA to their account | **My Account** page → `routes/auth.js` (TOTP via authenticator app) | +| 3 | "View Data" page listing downloadable data | `public/view-data.html` (folder contents hidden until access is granted) | +| 4 | Admin-only "Data Management" page: create/rename/delete/move folders & files, upload | `public/data-management.html` + `routes/manage.js` | +| + | Admin user management (add the people who request data) | Data Management → **Users** tab → `routes/users.js` | +| 5 | Items created in Data Management appear on View Data | Same folder/file tables power both pages | +| 6 | Every access request emails **all administrators** that an approval is pending; approval/denial emails the requester | `routes/data.js` (`/request`, `/requests/:id/approve`) | +| 7 | All email text is configurable, including uploading & using images | Data Management → **Email Templates** & **Brand Images** tabs → `routes/settings.js` | +| + | Mail server configuration in the interface | Data Management → **Email Server** tab → `routes/mail.js` | +| + | Log of every message sent/received, exportable & emailable | Data Management → **Email Log** tab → `routes/mail.js` | +| 8 | "Logs" page recording every action on the other pages | `public/logs.html` + `lib/audit.js` | +| + | Admin "Storage" page: server disk plus per-folder usage | `public/storage.html` + `routes/storage.js` | +| 9 | Logs can be exported (CSV) and/or emailed | Logs page → `routes/logs.js` | +| 10 | Footer on every page after login: `© 2026 Martinhal IT - Joao Vaz - Version 1.5 Patch 0.2` | Rendered by `public/js/app.js` | +| + | ISDSS logo in the sidebar of signed-in pages | `public/assets/isdss-logo.png` | +| + | Per-user profile picture, shown by the username | `routes/auth.js` avatar endpoints | +| + | Copy of every sent email placed in the mail server's Sent folder | `lib/mailer.js` (IMAP) | +| + | "Version Control" page for all users, managed by admins | `public/version-control.html` + `routes/versions.js` | +| + | "Legislation" page for all users, managed by admins | `public/legislation.html` + `routes/legislation.js` | +| + | Folder dates: Recorded Date + derived Legal Validity | `lib/dates.js`, shown on View Data | + +--- + +> **Already running an earlier version?** See **[UPGRADE.md](UPGRADE.md)** — +> upgrading in place keeps all users, folders, files, approvals, and logs. + +## Run with Docker (recommended) + +Everything runs in a single container. You only need Docker (and, for the +one-command path, the Compose plugin). + +### Option A — Docker Compose + +```bash +# 1. (Optional but recommended) set a session secret +cat > .env <<'ENV' +SESSION_SECRET=replace-with-a-long-random-string +# COOKIE_SECURE=true # when served over HTTPS +# SMTP_HOST=smtp.yourprovider.com +# SMTP_PORT=587 +# SMTP_USER=... +# SMTP_PASS=... +# MAIL_FROM=ISDSS +ENV + +# 2. Build and start +docker compose up -d --build +``` + +Now open **http://localhost:3000**. On this first visit the site shows a +**setup wizard** asking you to create the administrator account — no +credentials in environment files. Fill it in and you are signed in straight +away. + +The database, uploaded files, and brand images persist in named volumes +(`datahub-data`, `datahub-uploads`, `datahub-brand`), so they survive restarts +and rebuilds. + +```bash +docker compose logs -f # follow logs +docker compose down # stop (keeps volumes/data) +docker compose down -v # stop and DELETE all data (setup runs again) +``` + +### Option B — plain Docker + +```bash +docker build -t martinhal-datahub:1.3 . + +docker run -d --name datahub -p 3000:3000 \ + -e SESSION_SECRET="replace-with-a-long-random-string" \ + -v datahub-data:/app/data \ + -v datahub-uploads:/app/uploads \ + -v datahub-brand:/app/brand \ + martinhal-datahub:1.3 +``` + +Notes: +- The administrator account is created through the browser on first boot, so + no password ever needs to live in an env file, a shell history, or your + compose file. Until it is created, every page redirects to the wizard and the + APIs are locked; once created, the wizard can never be opened again. +- Set `COOKIE_SECURE=true` when you put the container behind an HTTPS reverse + proxy (nginx/Caddy/Traefik). +- The image runs as a non-root user and includes a container `HEALTHCHECK`. + +--- + +## Run without Docker + +## Requirements + +- **Node.js 18 or newer** (tested on Node 22) +- npm + +## Setup + +```bash +# 1. Install dependencies +npm install + +# 2. Configure environment +cp .env.example .env +# → edit .env: set a long random SESSION_SECRET, and (optionally) your SMTP settings. + +# 3. Prepare the database (creates tables + default email templates) +npm run init + +# 4. Start the server +npm start +``` + +Then open **http://localhost:3000** and the **setup wizard** will ask you to +create the administrator account. + +> `npm run init` no longer asks for credentials — it only prepares the +> database, and it is safe to re-run at any time. + +--- + +## First boot + +The very first time the site is opened it presents a one-step setup wizard: + +1. Choose an **administrator username**, **email**, and **password** + (minimum 10 characters, with at least one letter and one number). +2. The account is created and you are signed in immediately. +3. The wizard closes permanently — from then on `/setup.html` just redirects + to the login page, and a second attempt to call the setup API is rejected. + +While no administrator exists, every page redirects to the wizard and the rest +of the API returns `503 Setup required`, so the instance cannot be used in a +half-configured state. Creating the account is recorded in the audit log as +`SETUP_COMPLETED`. + +Right after setup, it's worth visiting **My Account** to switch on two-factor +authentication for the administrator. + +--- + +## How access works + +Access is granted **per folder**, and it cascades: + +- A regular user browsing **View Data** sees only the **names of top-level + folders**. The contents — sub-folders and files — stay hidden until access is + granted, so folder names are the only thing disclosed up front. +- Requesting access to a folder notifies the administrators. Once approved, the + user can open the folder and everything inside it: sub-folders, and all files, + at any depth. **No second request is ever needed for the contents.** +- Loose files sitting at the root (not inside any folder) are still requested + individually, since they have no folder to inherit from. +- Administrators see and download everything without requesting. + +The server enforces this independently of the interface: the tree endpoint +filters out anything the user may not see, and the download route re-checks +folder access on every request. + +--- + +## Version Control page + +Every signed-in user gets a **Version Control** entry in the left menu showing +the release history. The content is entirely admin-managed from +**Data Management → Version Control**, where entries can be added, edited, and +deleted. Each entry has a version, an optional title, release date +(`YYYY-MM-DD`), free-text notes, and a display-order number (higher appears +first). All three actions are audited (`VERSION_ENTRY_CREATED`, +`VERSION_ENTRY_UPDATED`, `VERSION_ENTRY_DELETED`). + +--- + +## Profile pictures + +Each user can set a profile picture from **My Account → Profile picture** +(PNG, JPEG, WebP or GIF, up to 4 MB). It appears next to their name at the +bottom-left of the sidebar on every signed-in page, and updates there +immediately without a reload. Users with no picture show their initials on a +disc instead. + +Pictures are stored under the `avatars/` directory (a persistent Docker volume, +`AVATAR_DIR`), served only to signed-in users, and one picture per user: uploading +a replacement deletes the previous file, and removing it deletes the file too. +Uploads and removals are audited (`AVATAR_UPDATED`, `AVATAR_REMOVED`). + +The `avatar` / `avatar_mime` columns are added to existing databases +automatically on the first start after upgrading. + +--- + +Uploading files into a folder shows a **progress bar** while the transfer runs, +and the dialog stays open until it completes. + +--- + +## Copying sent mail to the Sent folder + +When the platform sends an email it can also drop a copy into the mail server's +**Sent** folder over IMAP, so messages the system sends appear alongside those +sent by a person. Configure it under **Data Management → Email server** in the +IMAP section: host, port, security, username, password, and optionally an exact +Sent-folder name (left blank, the server's own Sent mailbox is detected, falling +back to common names like `Sent`, `Sent Items`, `INBOX.Sent`, +`[Gmail]/Sent Mail`). The IMAP username and password default to the SMTP ones, +which is the usual case, and **Test IMAP** confirms the connection and which +folder will be used. + +This is entirely optional: leave the IMAP host blank and nothing changes. +Crucially, filing the copy is best-effort — if IMAP is unreachable or misconfigured +the email is still sent normally, and only a `MAIL_SENT_COPY_FAILED` note is +written to the log. A failed copy never turns a delivered message into an error. + +--- + +## Branding + +Signed-in pages show the ISDSS logo at the top of the sidebar, from +`public/assets/isdss-logo.png`. Because the artwork is dark-on-white and the +sidebar is dark, it sits on a light rounded panel so it stays legible. The logo +already contains the ISDSS wordmark, so it replaces the previous badge and text +rather than sitting beside a duplicate label. + +The **login and first-run setup screens are deliberately left as they were**, +with the original monogram. + +To change the logo, replace `public/assets/isdss-logo.png` (a wide landscape +image around 3:1 works best; it is displayed about 184px wide, so roughly 520px +wide keeps it crisp on high-resolution screens). If the file is ever missing the +sidebar falls back to the original wordmark rather than showing a broken image. + +--- + +## Date format + +Dates are **shown and entered as DD-MM-YYYY** throughout: folder dates, the +Version Control and Legislation pages, the access-request table, the audit and +email logs, and the CSV exports. Internally they are stored in ISO form +(`YYYY-MM-DD`) so they sort and compare correctly, and converted for display. +Date fields accept DD-MM-YYYY and validate it as a real calendar date. + +--- + +## Folder dates + +Every folder carries a **Recorded Date**, chosen when the folder is created. +From it a second date is derived: + +- **Recorded Date** — the date the material was recorded (`YYYY-MM-DD`). +- **Legal Validity** — always the recorded date **plus 30 days**. + +Both are shown under each folder on **View Data**, for locked folders as well as +open ones. Legal Validity is never stored: it is calculated from the recorded +date on every request, so the two can never drift apart. A validity date in the +past is marked *expired*. + +The date is required when creating a folder, is validated as a real calendar +date, and can be corrected later with the calendar button on a folder in Data +Management. It cannot be cleared once set. + +Folders created before this release have no date. They keep working and are +listed with a *no recorded date* marker plus a banner in Data Management, so an +administrator can fill them in; until then their Legal Validity shows as +*not set*. + +--- + +## Browsing folders in Data Management + +The folder tree collapses. Each folder has an arrow to show or hide what is +inside it, plus a count of its sub-folders and files and its two dates at a +glance. **Expand all** and **Collapse all** act on the whole tree. The open/closed +state is kept as you work, so uploading or renaming does not reset your place. + +--- + +## Legislation page + +Every signed-in user gets a **Legislation** entry in the left menu listing the +laws, regulations, and internal rules governing the data. The content is +admin-managed from **Data Management → Legislation**, where entries can be +added, edited, and deleted. Each entry has an optional reference (e.g. +`GDPR Art. 6`), a required title, an in-force date, a summary, an optional link +to the full text, and a display-order number. All three actions are audited. + +--- + +## Access validity and the countdown + +When an administrator approves a request they choose how long the access lasts: + +| Option | Effect | +|--------|--------| +| Valid for 24 hours | expires 24 hours after approval | +| Valid for 15 days | expires 15 days after approval | +| Valid for 30 days | expires 30 days after approval | +| Valid forever | never expires | + +Every window is measured from **the moment the approval is confirmed**, not from +the request date. + +On View Data each folder shows an **Access** field: + +- a live countdown (`14d 03h 21m 07s`) for timed access, turning amber in the + final hour; +- *Access granted — no expiry* for permanent access; +- *Full access (administrator)* for administrators; +- **Access Denied** when the user has no access, or once the window has lapsed. + +The countdown runs against the server's clock (the server sends its time with +the folder list), so a wrong clock on someone's machine cannot make access look +longer than it is. When a window lapses in front of the user the page refreshes +itself, the folder's contents disappear again, and the user can submit a fresh +request. + +Expiry is enforced on the server, not just in the interface: an expired approval +stops the folder listing *and* blocks the download route. Access granted on a +folder still cascades to everything inside it for as long as it is valid. + +--- + +## Access request notifications + +The moment a user requests access, **every administrator** is emailed that a +request is waiting for approval. The message states who asked and for what, the +request reference, how many requests are currently pending, and carries a +**Review pending requests** button linking straight to Data Management. + +Set the portal address under **Data Management → Email Server → Portal address** +so that button points at your real hostname. Without it the link falls back to +the address the request came in on. + +A few deliberate behaviours: + +- If no mail server is configured the notification is recorded as **queued** in + the Email Log, and Data Management shows a warning banner saying + administrators are not receiving these emails. +- If delivery fails the failure is recorded in the Email Log and the audit log, + but **the access request itself is still created** — mail problems never lose + a request. +- The wording lives in the `request_to_admin` template and is fully editable. + Upgrades never overwrite a template you have customised; use **Restore + default** in the template editor if you want to adopt the version shipped + with a new release. + +--- + +## Storage page (administrators) + +**Storage** in the Administration section of the menu shows where space is going. + +**Server disk** — total, used, and free space on the filesystem holding the +uploads directory, with a bar that turns amber past 75% and red past 90%, and a +warning when the disk is nearly full. Free space is reported the way `df` does +(excluding blocks reserved for root), so the numbers match what you see on the +host. + +**Used by ISDSS** — how much of that space this application accounts for, split +into uploaded files, brand images, and the database (including its write-ahead +log), each with the path it lives at. + +**Space used per folder** — every folder with two figures: **total** (the folder +and everything nested inside it) and **own** (only the files sitting directly in +it), plus a file count and a relative bar. Sort by size or by name. Files stored +outside any folder are listed separately. + +**Consistency** — cross-checks the database against the disk and reports: + +- files recorded in the database but missing from disk (they appear in listings + but cannot be downloaded); +- files on disk that no record points at, with a **Reclaim space** button to + delete them. That action is audited as `STORAGE_CLEANUP`. + +In Docker the figures describe the filesystem backing the mounted volume, which +is what determines whether uploads will succeed. + +--- + +## Mail server & email log + +### Configuring the mail server + +**Data Management → Email Server** configures SMTP from inside the application — +host, port, username, password, from-address, implicit TLS, and certificate +verification. Two buttons help you check it before relying on it: + +- **Test connection** opens a connection and authenticates without saving. +- **Send test message** sends a real message using the saved settings. + +Settings saved here take precedence over the `SMTP_*` environment variables. If +you have an existing `.env`-based setup, it keeps working untouched — the page +simply shows the values as coming from the environment until you override them. + +The stored password is never sent back to the browser; the field shows a mask, +and saving the form with the mask left alone keeps the existing password. Note +that the password is stored in the database in plain text, so treat +`data/datahub.db` as a secret and keep file permissions tight. + +### Email log + +**Data Management → Email Log** records every message the system sends: access +requests, approvals and denials, log exports, and test messages. Each entry +records time, direction, status (`delivered`, `queued`, or `failed`), sender, +recipient, subject, a body preview, attachments, the triggering context, and +the failure reason when delivery fails. Messages produced while no mail server +is configured are recorded as **queued** rather than lost. + +The log can be filtered by text, direction, and status, then **exported as CSV** +or **emailed** (the filtered set is attached as a CSV) to a specific address or +to all administrators. + +**On received mail:** ISDSS sends mail but has no mailbox of its own, so there +is nothing to poll for incoming messages. The log stores direction as a first +class field and accepts inbound entries via `POST /api/mail/log/inbound`, so a +forwarder or mail-hook can record replies alongside outgoing mail. Automatic +collection would need IMAP credentials and a polling job, which is not part of +this release. + +--- + +## Managing users + +The wizard creates only the first administrator. Everyone else is added from +**Data Management → Users**: + +- **New user** — username, **email (required)**, role, and an initial password + (minimum 10 characters with a letter and a number). +- **Edit** — change email or role, or set a new password. +- **Reset MFA** — turns two-factor authentication off for someone who has lost + their authenticator app, so they can set it up again from *My Account*. +- **Delete** — removes the account. Data they published and their entries in + the audit log are kept; only their pending access requests are removed. + +Two safeguards are enforced by the server: you cannot delete the account you +are signed in with, and the last remaining administrator can be neither deleted +nor demoted. + +**Email is mandatory on every account.** It is how approval notifications +reach people, so an account without one cannot do its job. The rule is applied +in three places: the browser form, the API (creating *or* updating an account), +and — for databases created by this release onwards — a database constraint. +An existing account can have its address changed but never cleared. + +If a database created by an earlier release contains an account with no +address, the upgrade does **not** lock that user out. Instead the account is +listed with a *missing — required* badge, a banner appears above the user list, +and a warning is written to the server log at startup, so an administrator can +add the address. + +Every one of these actions is written to the audit log (`USER_CREATED`, +`USER_UPDATED`, `USER_DELETED`, `USER_MFA_RESET`). + +--- + +## Email (SMTP) + +Email is optional for evaluation. If you don't configure SMTP, every outgoing +message is written to the **Logs** as a `MAIL_OUTBOX` entry so nothing is lost +and you can see exactly what *would* have been sent. + +To send real email, set these in `.env`: + +``` +SMTP_HOST=smtp.yourprovider.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=your-username +SMTP_PASS=your-password +MAIL_FROM=ISDSS +``` + +Admin notification recipients are simply **every user whose role is `admin`**. + +--- + +## How the approval flow works + +1. A user browses **View Data** and clicks **Request access** on a file or folder. +2. An email (using the configurable *"Request received"* template) goes to all admins. +3. An admin opens **Data Management → Access Requests** and clicks **Approve** or **Deny**. +4. The requester receives an email (the *"approved"* or *"declined"* template). +5. Approved users can now **Download** the item from View Data. Admins can always download. + +--- + +## Configurable emails & images + +In **Data Management**: + +- **Email Templates** — edit the subject and HTML body of each notification. + Insert placeholders like `{{username}}`, `{{target_name}}`, `{{email}}`, + `{{target_type}}`, `{{created_at}}` (click a chip to insert). Use **Preview** + to see the rendered result with sample data. The footer is added automatically. +- **Brand Images** — upload logos/images. Each uploaded image shows up as an + insertable chip in the template editor, dropping a ready-to-use `` tag + into the body. + +--- + +## Logs + +Every meaningful action is recorded: logins, MFA changes, folder/file +create·rename·move·delete, uploads, access requests, approvals/denials, +downloads, template edits, image uploads, and email dispatch. + +From the **Logs** page you can filter by text/action/page, **Export CSV**, or +**Email logs** (sends the filtered set as a CSV attachment to an address or to +all admins). + +--- + +## Project layout + +``` +datahub/ +├── UPGRADE.md # in-place upgrade guide for existing installs +├── Dockerfile # container image (multi-stage, non-root, healthcheck) +├── docker-compose.yml # one-command run with persistent volumes +├── docker-entrypoint.sh # ensures dirs, runs DB init, starts the server +├── .dockerignore +├── server.js # app entry, session + route wiring, page gating +├── db.js # SQLite schema & connection +├── init-db.js # create first admin / seed demo users +├── lib/ +│ ├── audit.js # log() helper +│ ├── mailer.js # nodemailer transport, mail logging, no-SMTP fallback +│ ├── settings.js # key/value settings helper +│ ├── dates.js # recorded date + derived legal validity (+30 days) +│ ├── validate.js # shared account field rules (email is mandatory) +│ └── templates.js # email template store, render, defaults +├── middleware/auth.js # requireAuth / requireAdmin +├── routes/ +│ ├── setup.js # first-boot wizard API (creates the first admin) +│ ├── users.js # admin user management (create/edit/reset/delete) +│ ├── versions.js # Version Control entries (read: all, write: admin) +│ ├── legislation.js # Legislation entries (read: all, write: admin) +│ ├── mail.js # SMTP configuration + mail log (export / email) +│ ├── storage.js # disk usage + per-folder space (admin) +│ ├── auth.js # login, logout, session, MFA +│ ├── manage.js # admin folder/file CRUD + upload (Data Management) +│ ├── data.js # View Data tree, requests, approvals, downloads +│ ├── settings.js # email templates + brand images +│ └── logs.js # list, CSV export, email export +├── public/ # setup, login, view-data, version-control, legislation, +│ # data-management, storage, account, logs + css/js +│ └── assets/ # isdss-logo.png (sidebar logo on signed-in pages) +├── avatars/ # per-user profile pictures (Docker volume) +├── data/ # SQLite database (created at runtime) +├── uploads/ # stored files (created at runtime) +└── brand/ # uploaded brand images (created at runtime) +``` + +--- + +## Security notes for production + +- Serve behind HTTPS and set `COOKIE_SECURE=true` in `.env`. +- Set a strong, unique `SESSION_SECRET`. +- Passwords are hashed with bcrypt; MFA uses TOTP (RFC 6238). +- Consider putting the app behind a reverse proxy (nginx/Caddy) and adding + rate-limiting and backups of the `data/`, `uploads/`, and `brand/` folders. +- Uploaded files are served only through the authenticated, approval-checked + download route — never from a public static path. + +--- + +## License + +MIT. diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..47d1194 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,193 @@ +# Upgrading an existing installation to v1.5 Patch 0.2 + +**Short answer: yes.** This release changes only application code. Your +database, uploaded files, and brand images are never touched by the upgrade, +and the new database table is created automatically on the first start. + +**If you are already on v1.3, v1.4 or v1.5 (any patch)**, the archive folder is named +`martinhal-isdss`, the same as your install, so extracting on top of it works +directly. + +**If you are coming from v1.1/v1.2**, the folder inside the archive was renamed +from `martinhal-datahub` to `martinhal-isdss`, so a plain "unzip on top" would +create a *second* directory beside your install instead of updating it. Use the +commands below either way — they extract the contents *into* your existing +folder and are safe in both cases. + +--- + +## What is preserved + +| Item | Where it lives | Preserved? | +|------|----------------|-----------| +| User accounts & passwords | `data/datahub.db` | ✅ untouched | +| MFA enrolments | `data/datahub.db` | ✅ untouched | +| Folders & files (metadata) | `data/datahub.db` | ✅ untouched | +| Uploaded file contents | `uploads/` | ✅ untouched | +| Brand images | `brand/` | ✅ untouched | +| Access requests & approvals | `data/datahub.db` | ✅ untouched | +| Customised email templates | `data/datahub.db` | ✅ untouched | +| Audit logs | `data/datahub.db` | ✅ untouched | +| Version Control entries | `data/datahub.db` | ✅ untouched | +| Email log history | `data/datahub.db` | ✅ untouched | +| Saved mail server settings | `data/datahub.db` | ✅ untouched | +| Customised email templates | `data/datahub.db` | ✅ never overwritten | +| `SMTP_*` settings in `.env` | `.env` | ✅ still used automatically | +| Your `.env` file | `.env` | ✅ not in the archive | + +The archive ships `data/`, `uploads/`, and `brand/` containing only an empty +`.gitkeep` placeholder — no database and no files — so extracting over your +install cannot overwrite your data. + +**Two files WILL be replaced:** `docker-compose.yml` and `.env.example`. If you +edited your compose file (ports, extra volumes, a reverse proxy), back it up +first and re-apply your changes afterwards. Your `.env` is safe. + +--- + +## Always: back up first + +```bash +# Docker (named volumes) +docker compose stop +docker run --rm -v datahub-data:/d -v "$PWD:/backup" alpine \ + tar czf /backup/isdss-backup-$(date +%F).tar.gz -C /d . + +# Plain install +tar czf isdss-backup-$(date +%F).tar.gz data uploads brand .env +``` + +--- + +## Docker upgrade + +```bash +cd /path/to/your/existing/install + +cp docker-compose.yml docker-compose.yml.bak # keep your customisations +docker compose down # keeps volumes (never use -v) + +# extract the archive's CONTENTS into this directory +unzip -o ../martinhal-isdss-v1.5-patch0.2.zip -d /tmp/isdss-new +cp -a /tmp/isdss-new/martinhal-isdss/. . + +# re-apply anything you had customised in compose, then rebuild +docker compose up -d --build +docker compose logs -f +``` + +Named volumes (`datahub-data`, `datahub-uploads`, `datahub-brand`) are not +recreated by a rebuild, so all data carries over. Do **not** run +`docker compose down -v` — that deletes the volumes. + +## Plain (non-Docker) upgrade + +```bash +cd /path/to/your/existing/install +# stop the running server first (Ctrl-C, or systemctl stop ) + +unzip -o ../martinhal-isdss-v1.5-patch0.2.zip -d /tmp/isdss-new +cp -a /tmp/isdss-new/martinhal-isdss/. . + +npm install # optional: no new dependencies in 1.3, but harmless +npm start # the schema updates itself on boot +``` + +--- + +> **This release changes no database structure.** It adds a page that reads +> what is already there, so the upgrade is purely a code swap. + +## What happens automatically on first start + +- New tables (`version_entries`, `mail_log`, `legislation_entries`) are created + via `CREATE TABLE IF NOT EXISTS`. Existing tables are left exactly as they are. +- Columns are added with `ALTER TABLE ADD COLUMN`, which is non-destructive: + `folders.recorded_date`, and `access_requests.access_duration` / + `access_expires_at`. Existing rows keep all their data. The server prints a + line for each on the first start after an upgrade. +- **Existing approvals are preserved as permanent.** Anyone who already had + access keeps it, with no expiry, so nobody is locked out by the new validity + windows. Only approvals granted from now on carry a time limit. +- Existing `SMTP_*` / `MAIL_FROM` environment variables keep working unchanged. + The new **Email Server** page shows them as coming from the environment; you + only need to touch it if you want to manage the settings in the interface + instead. +- The first-boot setup wizard does **not** reappear, because your database + already contains users. You sign in with your existing credentials. +- Default email templates are only inserted when missing, so your customised + subjects and bodies are kept. + +--- + +## Behaviour changes to expect (no data loss, but visible) + +1. **Folder contents are now hidden until access is granted.** Regular users + see only top-level folder names; sub-folders and files appear once a folder + request is approved. Your existing folders and files are all still there — + administrators see everything as before. +2. **Folder approval now cascades.** A user approved for a folder can open + everything inside it without further requests. +3. **Existing file-level approvals still work.** Anyone who was previously + approved for a specific file keeps that access; nothing needs re-approving. +4. Two new admin tabs appear in Data Management: **Email Server** (SMTP + configuration) and **Email Log** (every message sent, exportable/emailable). +5. New access requests email every administrator with a pending-approval + notice. If you had customised the `request_to_admin` template, **your + wording is kept** — the improved default (with the review link and pending + count) is available via **Restore default** in the template editor. +6. **Email is now mandatory on user accounts.** Existing accounts are not + touched and nobody is locked out. If any account has no address it is + flagged in Data Management → Users (and in the server log at startup) so you + can add one. New accounts cannot be created without an address, and an + existing address can be changed but not cleared. +7. **Folders now require a Recorded Date**, and View Data shows it alongside a + derived **Legal Validity** date (recorded + 30 days). Folders created before + the upgrade have no date: they keep working, are flagged in Data Management, + and you can set a date on each with the calendar button. +8. The folder tree in Data Management is now collapsible, and a new + **Legislation** page appears in the left menu for everyone. +9. **Dates are now shown as DD-MM-YYYY** everywhere, including CSV exports. + Stored values are unchanged; only the presentation differs. +10. **Approving a request now requires choosing a validity period** (24 hours, + 15 days, 30 days, or forever), counted from the moment of approval. View + Data shows a live countdown per folder, or *Access Denied* where there is + no access. +11. A new **Storage** page appears in the Administration menu for + administrators, showing server disk space and per-folder usage. It only + reads the existing data, so there is nothing to configure. +12. Signed-in pages now show the **ISDSS logo** in the sidebar instead of the + monogram. The login and setup screens are unchanged. The image ships in + `public/assets/`, so it arrives with the upgrade automatically. +13. Users can set a **profile picture** (My Account), shown by their name in the + sidebar. Pictures live in a new `avatars/` Docker volume, added + automatically. +14. The platform can copy each sent email into the mail server's **Sent folder** + over IMAP, configured under Email server. It is optional and best-effort: if + IMAP is not set up, sending is unaffected. +15. **Uploading files into a folder is fixed.** A dialog bug closed the upload + window before the transfer finished; files now upload reliably, with a + progress bar showing how far along the transfer is. +16. The interface is rebranded to **ISDSS** and the footer now reads + `© 2026 Martinhal IT - Joao Vaz - Version 1.5 Patch 0.2`. + +--- + +## Verifying the upgrade + +After starting, confirm: + +```bash +# should report your existing accounts, not "no users yet" +docker compose logs | grep "user account" +``` + +Then sign in and check: your folders appear under Data Management, a file +downloads correctly, and **Logs** still shows your historical entries. + +## Rolling back + +Stop the service, restore the backup archive over `data/`, `uploads/`, and +`brand/`, put the previous code back, and start again. Because the upgrade only +*adds* a table, an older build will still run against the upgraded database — +it simply ignores `version_entries`. diff --git a/avatars/.gitkeep b/avatars/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/brand/.gitkeep b/brand/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/db.js b/db.js new file mode 100644 index 0000000..123f926 --- /dev/null +++ b/db.js @@ -0,0 +1,194 @@ +'use strict'; +const path = require('path'); +const Database = require('better-sqlite3'); + +const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'datahub.db'); +const db = new Database(DB_PATH); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +function init() { + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL CHECK (length(trim(email)) > 0), + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', -- 'admin' | 'user' + mfa_enabled INTEGER NOT NULL DEFAULT 0, + mfa_secret TEXT, + avatar TEXT, -- stored filename of the user's picture + avatar_mime TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, + -- Date the material in this folder was recorded (YYYY-MM-DD). + -- "Legal Validity" is derived from it: recorded date + 30 days. + recorded_date TEXT, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, -- display name + stored_name TEXT NOT NULL, -- name on disk + size INTEGER NOT NULL DEFAULT 0, + mime TEXT, + folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, + uploaded_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS access_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + target_type TEXT NOT NULL, -- 'file' | 'folder' + target_id INTEGER NOT NULL, + target_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | denied + decided_by INTEGER REFERENCES users(id), + -- How long the approval lasts ('24h' | '15d' | '30d' | 'forever') + -- and when it runs out. NULL expiry means it never expires. + access_duration TEXT, + access_expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + decided_at TEXT + ); + + CREATE TABLE IF NOT EXISTS email_templates ( + key TEXT PRIMARY KEY, -- 'request_to_admin' | 'approval_to_user' | 'denial_to_user' + subject TEXT NOT NULL, + body_html TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS brand_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + stored_name TEXT NOT NULL, + mime TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL DEFAULT (datetime('now')), + actor TEXT, -- username or 'system' + actor_id INTEGER, + action TEXT NOT NULL, -- e.g. FILE_UPLOAD + page TEXT, -- 'Data Management' | 'View Data' | ... + detail TEXT, + ip TEXT + ); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + + -- Entries shown on the "Version Control" page (managed from Data Management) + CREATE TABLE IF NOT EXISTS version_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version TEXT NOT NULL, + title TEXT, + released_on TEXT, + notes TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT + ); + -- Entries shown on the "Legislation" page (managed from Data Management) + CREATE TABLE IF NOT EXISTS legislation_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reference TEXT, + title TEXT NOT NULL, + effective_date TEXT, + summary TEXT, + link_url TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT + ); + + -- Every message the system sends (and any inbound message that is + -- recorded), independent of the general audit log. + CREATE TABLE IF NOT EXISTS mail_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL DEFAULT (datetime('now')), + direction TEXT NOT NULL DEFAULT 'sent', -- 'sent' | 'received' + status TEXT NOT NULL, -- 'delivered' | 'queued' | 'failed' + from_addr TEXT, + to_addr TEXT, + subject TEXT, + body_preview TEXT, + attachments TEXT, + error TEXT, + context TEXT, + actor TEXT + ); + `); +} + +/** + * Add columns introduced after a database was first created. + * SQLite's ALTER TABLE ADD COLUMN is safe and non-destructive: existing rows + * simply get NULL for the new column. + */ +function migrate() { + const folderCols = db.prepare('PRAGMA table_info(folders)').all().map((c) => c.name); + if (!folderCols.includes('recorded_date')) { + db.exec('ALTER TABLE folders ADD COLUMN recorded_date TEXT'); + console.log('[ISDSS] Added folders.recorded_date (existing folders have no date yet).'); + } + + const userCols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name); + if (!userCols.includes('avatar')) { + db.exec('ALTER TABLE users ADD COLUMN avatar TEXT'); + db.exec('ALTER TABLE users ADD COLUMN avatar_mime TEXT'); + console.log('[ISDSS] Added users.avatar (existing users have no picture yet).'); + } + + const reqCols = db.prepare('PRAGMA table_info(access_requests)').all().map((c) => c.name); + if (!reqCols.includes('access_duration')) { + db.exec("ALTER TABLE access_requests ADD COLUMN access_duration TEXT"); + // Approvals granted before this release had no expiry, so they are + // permanent. Marking them explicitly keeps existing access working. + db.exec("UPDATE access_requests SET access_duration = 'forever' WHERE status = 'approved'"); + console.log('[ISDSS] Added access_requests.access_duration (existing approvals kept as permanent).'); + } + if (!reqCols.includes('access_expires_at')) { + db.exec('ALTER TABLE access_requests ADD COLUMN access_expires_at TEXT'); + console.log('[ISDSS] Added access_requests.access_expires_at.'); + } +} + +/** + * Accounts with no email address cannot receive approval notifications. + * New databases block this outright (CHECK constraint above); databases created + * by earlier releases keep their original schema, so report any offenders at + * startup instead of failing silently. + */ +function reportMissingEmails() { + try { + const rows = db.prepare( + "SELECT username FROM users WHERE email IS NULL OR length(trim(email)) = 0" + ).all(); + if (rows.length) { + console.warn( + `[ISDSS] ${rows.length} account(s) have no email address: ` + + `${rows.map((r) => r.username).join(', ')}. ` + + 'Email is now mandatory — set one in Data Management → Users.' + ); + } + return rows.length; + } catch (_) { return 0; } +} + +module.exports = { db, init, migrate, DB_PATH, reportMissingEmails }; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..05c1a7e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +services: + datahub: + build: . + image: martinhal-datahub:1.5 + container_name: martinhal-datahub + restart: unless-stopped + ports: + # host:container — change the left number to expose a different host port + - "3000:3000" + environment: + # ---- Server ---- + PORT: "3000" + # IMPORTANT: set a long random value (e.g. `openssl rand -hex 32`) + SESSION_SECRET: "${SESSION_SECRET:-please-change-this-to-a-long-random-string}" + # set "true" only when serving over HTTPS (e.g. behind a TLS reverse proxy) + COOKIE_SECURE: "${COOKIE_SECURE:-false}" + + # ---- First administrator ---- + # Nothing to configure: on first boot the site shows a setup wizard in + # the browser that asks you to create the administrator account. + + # ---- SMTP (optional). If unset, emails are logged to the audit outbox ---- + SMTP_HOST: "${SMTP_HOST:-}" + SMTP_PORT: "${SMTP_PORT:-587}" + SMTP_SECURE: "${SMTP_SECURE:-false}" + SMTP_USER: "${SMTP_USER:-}" + SMTP_PASS: "${SMTP_PASS:-}" + MAIL_FROM: "${MAIL_FROM:-ISDSS }" + volumes: + # Persist the database, uploaded files, and brand images across restarts. + - datahub-data:/app/data + - datahub-uploads:/app/uploads + - datahub-brand:/app/brand + - datahub-avatars:/app/avatars + +volumes: + datahub-data: + datahub-uploads: + datahub-brand: + datahub-avatars: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..23b8588 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Container entrypoint for Martinhal DataHub. +# 1) make sure the persistent directories exist, +# 2) run the idempotent database initializer (creates schema + first admin), +# 3) hand off to the main process (node server.js). +set -e + +# Resolve storage locations (fall back to the image defaults). +DB_FILE="${DB_PATH:-/app/data/datahub.db}" +DB_DIR="$(dirname "$DB_FILE")" + +mkdir -p "$DB_DIR" "${UPLOAD_DIR:-/app/uploads}" "${BRAND_DIR:-/app/brand}" "${AVATAR_DIR:-/app/avatars}" + +# init-db.js is safe to run on every start: it creates any missing tables and +# seeds the default email templates. It does NOT create the administrator — +# on first boot the site shows a setup wizard in the browser for that. +echo "[entrypoint] preparing database ..." +node init-db.js + +echo "[entrypoint] starting: $*" +exec "$@" diff --git a/init-db.js b/init-db.js new file mode 100644 index 0000000..90dd977 --- /dev/null +++ b/init-db.js @@ -0,0 +1,27 @@ +'use strict'; +/** + * Prepares the database: creates the schema and seeds the default email + * templates. It is safe to run repeatedly (and the Docker entrypoint runs it + * on every start). + * + * The first administrator is NOT created here — on first boot the site itself + * shows a setup wizard in the browser that asks for the admin account. + */ +require('dotenv').config(); +const { db, init, migrate, DB_PATH, reportMissingEmails } = require('./db'); +const { seedDefaults } = require('./lib/templates'); + +init(); +migrate(); +seedDefaults(); + +const users = db.prepare('SELECT COUNT(*) c FROM users').get().c; +reportMissingEmails(); + +console.log(`Database ready at ${DB_PATH}`); +if (users === 0) { + console.log('No users yet — open the site in a browser to create the administrator account.'); +} else { + console.log(`${users} user account(s) already present.`); +} +process.exit(0); diff --git a/lib/audit.js b/lib/audit.js new file mode 100644 index 0000000..4d01511 --- /dev/null +++ b/lib/audit.js @@ -0,0 +1,28 @@ +'use strict'; +const { db } = require('../db'); + +const insert = db.prepare( + `INSERT INTO logs (actor, actor_id, action, page, detail, ip) + VALUES (@actor, @actor_id, @action, @page, @detail, @ip)` +); + +/** + * Record an auditable action. + * @param {object} req Express request (for user + ip). Can be null for system events. + * @param {string} action Short machine code, e.g. FILE_UPLOAD + * @param {string} page Human page name + * @param {string} detail Free-text description + */ +function log(req, action, page, detail) { + const user = req && req.session && req.session.user; + insert.run({ + actor: user ? user.username : 'system', + actor_id: user ? user.id : null, + action, + page: page || null, + detail: detail || null, + ip: req ? (req.headers['x-forwarded-for'] || req.socket.remoteAddress || null) : null, + }); +} + +module.exports = { log }; diff --git a/lib/dates.js b/lib/dates.js new file mode 100644 index 0000000..6c5ef5f --- /dev/null +++ b/lib/dates.js @@ -0,0 +1,115 @@ +'use strict'; + +/** + * Dates used by folders. + * + * "Recorded Date" is chosen by the administrator when the folder is created. + * "Legal Validity" is always derived from it: recorded date + 30 days. It is + * never stored, so it can never drift out of step with the recorded date. + */ + +const VALIDITY_DAYS = 30; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** True when the value is a real calendar date in YYYY-MM-DD form. */ +function isValidDate(value) { + if (!DATE_RE.test(String(value || ''))) return false; + const [y, m, d] = String(value).split('-').map(Number); + const dt = new Date(Date.UTC(y, m - 1, d)); + return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d; +} + +/** Recorded date + 30 days, as YYYY-MM-DD. Returns null for a missing date. */ +function legalValidity(recordedDate) { + if (!isValidDate(recordedDate)) return null; + const [y, m, d] = String(recordedDate).split('-').map(Number); + const dt = new Date(Date.UTC(y, m - 1, d)); + dt.setUTCDate(dt.getUTCDate() + VALIDITY_DAYS); + return dt.toISOString().slice(0, 10); +} + +/** Attach the derived validity date to a folder row. */ +function withValidity(folder) { + return { ...folder, legal_validity: legalValidity(folder.recorded_date) }; +} + +// --------------------------------------------------------------------------- +// Display formatting. +// Dates are STORED as ISO (YYYY-MM-DD / YYYY-MM-DD HH:MM:SS) so they sort and +// compare correctly, and PRESENTED as DD-MM-YYYY. +// --------------------------------------------------------------------------- + +/** ISO date (or datetime) -> "DD-MM-YYYY". Returns '' for anything unusable. */ +function toDisplayDate(value) { + if (!value) return ''; + const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/); + if (!m) return String(value); + return `${m[3]}-${m[2]}-${m[1]}`; +} + +/** ISO datetime -> "DD-MM-YYYY HH:MM:SS" (or DD-MM-YYYY when no time part). */ +function toDisplayDateTime(value) { + if (!value) return ''; + const str = String(value).replace('T', ' ').replace('Z', ''); + const m = str.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ ](\d{2}:\d{2})(:\d{2})?)?/); + if (!m) return str; + const date = `${m[3]}-${m[2]}-${m[1]}`; + return m[4] ? `${date} ${m[4]}${m[5] || ''}` : date; +} + +/** + * Accept a date typed as DD-MM-YYYY (what users see) or YYYY-MM-DD (what the + * browser's native date input sends) and return ISO, or '' when unusable. + */ +function parseInputDate(value) { + const v = String(value || '').trim(); + if (!v) return ''; + let iso = ''; + const dmy = v.match(/^(\d{2})-(\d{2})-(\d{4})$/); + if (dmy) iso = `${dmy[3]}-${dmy[2]}-${dmy[1]}`; + else if (DATE_RE.test(v)) iso = v; + else return ''; + return isValidDate(iso) ? iso : ''; +} + +// --------------------------------------------------------------------------- +// Access validity windows, chosen by an administrator when approving. +// --------------------------------------------------------------------------- + +const DURATIONS = { + '24h': { label: 'Valid for 24 hours', ms: 24 * 60 * 60 * 1000 }, + '15d': { label: 'Valid for 15 days', ms: 15 * 24 * 60 * 60 * 1000 }, + '30d': { label: 'Valid for 30 days', ms: 30 * 24 * 60 * 60 * 1000 }, + forever: { label: 'Valid forever', ms: null }, +}; + +const DURATION_KEYS = Object.keys(DURATIONS); + +/** Current UTC time as "YYYY-MM-DD HH:MM:SS". */ +function nowIso() { + return new Date().toISOString().replace('T', ' ').slice(0, 19); +} + +/** + * Expiry timestamp for a duration, measured from the moment of approval. + * Returns null for "forever" (no expiry). + */ +function expiryFor(durationKey, fromDate) { + const d = DURATIONS[durationKey]; + if (!d) return undefined; // caller should treat as invalid + if (d.ms === null) return null; // forever + const base = fromDate ? new Date(fromDate) : new Date(); + return new Date(base.getTime() + d.ms).toISOString().replace('T', ' ').slice(0, 19); +} + +/** True when an expiry timestamp is in the past. */ +function isExpired(expiresAt) { + if (!expiresAt) return false; // null = forever + return new Date(String(expiresAt).replace(' ', 'T') + 'Z').getTime() <= Date.now(); +} + +module.exports = { + VALIDITY_DAYS, DATE_RE, isValidDate, legalValidity, withValidity, + toDisplayDate, toDisplayDateTime, parseInputDate, + DURATIONS, DURATION_KEYS, nowIso, expiryFor, isExpired, +}; diff --git a/lib/mailer.js b/lib/mailer.js new file mode 100644 index 0000000..c2de330 --- /dev/null +++ b/lib/mailer.js @@ -0,0 +1,224 @@ +'use strict'; +const nodemailer = require('nodemailer'); +const { ImapFlow } = require('imapflow'); +const { db } = require('../db'); +const { log } = require('./audit'); +const { getJSON, setJSON } = require('./settings'); + +const SETTINGS_KEY = 'mail_config'; + +/** + * Effective mail configuration. + * Settings saved in Data Management win; anything not set there falls back to + * the environment, so an existing .env-based install keeps working untouched. + */ +function getMailConfig() { + const s = getJSON(SETTINGS_KEY, {}); + const pick = (a, b) => (a === undefined || a === null || a === '' ? b : a); + return { + host: pick(s.host, process.env.SMTP_HOST || ''), + port: Number(pick(s.port, process.env.SMTP_PORT || 587)), + secure: String(pick(s.secure, process.env.SMTP_SECURE || 'false')) === 'true', + user: pick(s.user, process.env.SMTP_USER || ''), + pass: pick(s.pass, process.env.SMTP_PASS || ''), + from: pick(s.from, process.env.MAIL_FROM || 'ISDSS '), + reject_unauthorized: s.reject_unauthorized === undefined ? true : !!s.reject_unauthorized, + // Used to build clickable links inside notification emails. + base_url: String(pick(s.base_url, process.env.BASE_URL || '')).replace(/\/+$/, ''), + // IMAP, used only to place a copy of each sent message in the Sent folder. + // When the host is blank this feature is simply off. + imap_host: pick(s.imap_host, process.env.IMAP_HOST || ''), + imap_port: Number(pick(s.imap_port, process.env.IMAP_PORT || 993)), + imap_secure: s.imap_secure === undefined + ? String(process.env.IMAP_SECURE || 'true') !== 'false' + : !!s.imap_secure, + // Falls back to the SMTP credentials, which is the common case. + imap_user: pick(s.imap_user, pick(s.user, process.env.IMAP_USER || process.env.SMTP_USER || '')), + imap_pass: pick(s.imap_pass, pick(s.pass, process.env.IMAP_PASS || process.env.SMTP_PASS || '')), + imap_sent_folder: pick(s.imap_sent_folder, process.env.IMAP_SENT_FOLDER || ''), + source: s.host ? 'settings' : (process.env.SMTP_HOST ? 'environment' : 'none'), + }; +} + +function saveMailConfig(cfg) { + setJSON(SETTINGS_KEY, cfg); +} + +/** Build a transport from the given configuration (null when unconfigured). */ +function buildTransport(cfg) { + const c = cfg || getMailConfig(); + if (!c.host) return null; + return nodemailer.createTransport({ + host: c.host, + port: c.port, + secure: c.secure, + auth: c.user ? { user: c.user, pass: c.pass } : undefined, + tls: { rejectUnauthorized: c.reject_unauthorized !== false }, + }); +} + +// ---- Mail log --------------------------------------------------------- +const insertMail = db.prepare( + `INSERT INTO mail_log (direction, status, from_addr, to_addr, subject, body_preview, attachments, error, context, actor) + VALUES (@direction, @status, @from_addr, @to_addr, @subject, @body_preview, @attachments, @error, @context, @actor)` +); + +function stripHtml(html) { + return String(html || '') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 400); +} + +function recordMail(entry) { + insertMail.run({ + direction: entry.direction || 'sent', + status: entry.status, + from_addr: entry.from_addr || null, + to_addr: entry.to_addr || null, + subject: entry.subject || null, + body_preview: entry.body_preview || null, + attachments: entry.attachments || null, + error: entry.error || null, + context: entry.context || null, + actor: entry.actor || null, + }); +} + +/** + * Send an email and record it in the mail log. + * When SMTP is not configured the message is queued (status 'queued') rather + * than lost, so the system stays usable without a mail server. + */ +async function sendMail({ to, subject, html, attachments, context, actor }) { + const cfg = getMailConfig(); + const transporter = buildTransport(cfg); + const base = { + direction: 'sent', + from_addr: cfg.from, + to_addr: to, + subject, + body_preview: stripHtml(html), + attachments: (attachments || []).map((a) => a.filename).join(', ') || null, + context: context || null, + actor: actor || null, + }; + + if (!transporter) { + recordMail({ ...base, status: 'queued', error: 'No mail server configured' }); + log(null, 'MAIL_OUTBOX', 'System', `SMTP not configured. Queued mail -> ${to} | ${subject}`); + return { delivered: false }; + } + + try { + // Build the message once so the exact bytes we send can also be filed in + // the Sent folder. + const info = await transporter.sendMail({ from: cfg.from, to, subject, html, attachments }); + recordMail({ ...base, status: 'delivered' }); + + // Place a copy in the mail server's Sent folder, best-effort: a failure + // here must never turn a successfully-sent message into an error. + let sentCopy = null; + if (cfg.imap_host && info && info.message) { + try { + const folder = await appendToSent(cfg, info.message); + sentCopy = { ok: true, folder }; + } catch (e) { + sentCopy = { ok: false, error: e.message }; + log(null, 'MAIL_SENT_COPY_FAILED', 'System', + `Could not copy message to the Sent folder: ${e.message}`); + } + } + return { delivered: true, sentCopy }; + } catch (e) { + recordMail({ ...base, status: 'failed', error: e.message }); + throw e; + } +} + +/** + * Append a raw RFC822 message to the mail server's Sent folder over IMAP. + * Tries the configured folder name, then the server's special-use \\Sent + * mailbox, then common names. Marks the copy as \\Seen. + */ +async function appendToSent(cfg, raw) { + const client = new ImapFlow({ + host: cfg.imap_host, + port: cfg.imap_port, + secure: cfg.imap_secure, + auth: { user: cfg.imap_user, pass: cfg.imap_pass }, + tls: { rejectUnauthorized: cfg.reject_unauthorized !== false }, + logger: false, + }); + await client.connect(); + try { + const candidates = []; + if (cfg.imap_sent_folder) candidates.push(cfg.imap_sent_folder); + // Prefer whatever the server marks as its Sent mailbox. + try { + for await (const box of client.list()) { + const flags = box.flags || new Set(); + if ((box.specialUse === '\\Sent') || flags.has('\\Sent')) candidates.push(box.path); + } + } catch (_) { /* listing not supported; fall through to common names */ } + candidates.push('Sent', 'Sent Items', 'INBOX.Sent', '[Gmail]/Sent Mail'); + + let lastErr = null; + for (const path of candidates) { + if (!path) continue; + try { + await client.append(path, raw, ['\\Seen']); + return path; + } catch (e) { lastErr = e; } + } + throw lastErr || new Error('No Sent folder could be found on the mail server.'); + } finally { + try { await client.logout(); } catch (_) { /* ignore */ } + } +} + +/** Verify IMAP credentials and confirm a Sent folder can be found. */ +async function verifyImap(cfg) { + if (!cfg.imap_host) throw new Error('No IMAP host configured.'); + const client = new ImapFlow({ + host: cfg.imap_host, + port: cfg.imap_port, + secure: cfg.imap_secure, + auth: { user: cfg.imap_user, pass: cfg.imap_pass }, + tls: { rejectUnauthorized: cfg.reject_unauthorized !== false }, + logger: false, + }); + await client.connect(); + try { + let sent = cfg.imap_sent_folder || null; + if (!sent) { + for await (const box of client.list()) { + const flags = box.flags || new Set(); + if (box.specialUse === '\\Sent' || flags.has('\\Sent')) { sent = box.path; break; } + } + } + return { ok: true, sent_folder: sent || '(will try common names)' }; + } finally { + try { await client.logout(); } catch (_) { /* ignore */ } + } +} + +/** Verify a configuration by opening a connection (used by "Test connection"). */ +async function verifyConfig(cfg) { + const transporter = buildTransport(cfg); + if (!transporter) throw new Error('No mail server host configured.'); + await transporter.verify(); + return true; +} + +function adminEmails() { + const rows = db.prepare(`SELECT email FROM users WHERE role = 'admin'`).all(); + return rows.map((r) => r.email); +} + +module.exports = { + sendMail, adminEmails, getMailConfig, saveMailConfig, verifyConfig, verifyImap, recordMail, stripHtml, +}; diff --git a/lib/settings.js b/lib/settings.js new file mode 100644 index 0000000..5599ad2 --- /dev/null +++ b/lib/settings.js @@ -0,0 +1,26 @@ +'use strict'; +const { db } = require('../db'); + +function getSetting(key, fallback = null) { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); + return row ? row.value : fallback; +} + +function setSetting(key, value) { + db.prepare( + `INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(key, value == null ? null : String(value)); +} + +function getJSON(key, fallback = {}) { + const raw = getSetting(key); + if (!raw) return fallback; + try { return JSON.parse(raw); } catch { return fallback; } +} + +function setJSON(key, obj) { + setSetting(key, JSON.stringify(obj)); +} + +module.exports = { getSetting, setSetting, getJSON, setJSON }; diff --git a/lib/templates.js b/lib/templates.js new file mode 100644 index 0000000..3f5be89 --- /dev/null +++ b/lib/templates.js @@ -0,0 +1,85 @@ +'use strict'; +const { db } = require('../db'); + +const DEFAULTS = { + request_to_admin: { + subject: 'Action needed: access request from {{username}} awaiting approval', + body_html: + '

Access request pending approval

' + + '

{{username}} ({{email}}) has requested access to ' + + 'the {{target_type}} {{target_name}}.

' + + '

Requested at {{created_at}}. Request reference #{{request_id}}.

' + + '

{{pending_count}} request(s) are currently waiting for a decision.

' + + '

' + + 'Review pending requests

' + + '

If the button does not work, open: {{approvals_url}}

', + }, + approval_to_user: { + subject: 'Your access request was approved', + body_html: + '

Request approved

' + + '

Hello {{username}},

' + + '

Your request to access the {{target_type}} {{target_name}} ' + + 'has been approved. You may now open it from the View Data page.

' + + '

{{validity}} — access expires on {{expires_at}}.

' + + '

A live countdown is shown next to the folder on the View Data page.

', + }, + denial_to_user: { + subject: 'Your access request was declined', + body_html: + '

Request declined

' + + '

Hello {{username}},

' + + '

Unfortunately your request to access the {{target_type}} ' + + '{{target_name}} was not approved at this time.

', + }, +}; + +function seedDefaults() { + const exists = db.prepare('SELECT key FROM email_templates WHERE key = ?'); + const insert = db.prepare( + 'INSERT INTO email_templates (key, subject, body_html) VALUES (?, ?, ?)' + ); + for (const [key, t] of Object.entries(DEFAULTS)) { + if (!exists.get(key)) insert.run(key, t.subject, t.body_html); + } +} + +function getTemplate(key) { + return ( + db.prepare('SELECT * FROM email_templates WHERE key = ?').get(key) || + DEFAULTS[key] + ); +} + +function render(str, vars) { + return String(str).replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, k) => + vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : '' + ); +} + +/** Returns { subject, html } with placeholders filled and footer appended. */ +function buildEmail(key, vars) { + const t = getTemplate(key); + const footer = + '
' + + '

' + + '© 2026 Martinhal IT - Joao Vaz - Version 1.5 Patch 0.2

'; + return { + subject: render(t.subject, vars), + html: render(t.body_html, vars) + footer, + }; +} + +const PLACEHOLDERS = { + request_to_admin: ['username', 'email', 'target_type', 'target_name', 'created_at', + 'request_id', 'pending_count', 'approvals_url', 'portal_url'], + approval_to_user: ['username', 'email', 'target_type', 'target_name', 'portal_url', + 'validity', 'expires_at'], + denial_to_user: ['username', 'email', 'target_type', 'target_name', 'portal_url'], +}; + +module.exports = { + seedDefaults, getTemplate, render, buildEmail, + DEFAULTS, PLACEHOLDERS, KEYS: Object.keys(DEFAULTS), +}; diff --git a/lib/validate.js b/lib/validate.js new file mode 100644 index 0000000..aed9695 --- /dev/null +++ b/lib/validate.js @@ -0,0 +1,54 @@ +'use strict'; + +/** + * Shared validation for user account fields. + * Email is MANDATORY on every account: it is how approval notifications and + * system messages reach people, so an account without one cannot function. + */ + +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; +const USERNAME_RE = /^[A-Za-z0-9._-]{3,32}$/; + +/** + * Normalise an email for storage: trim surrounding whitespace. + * Returns '' for null/undefined so callers can treat "missing" and "blank" + * the same way. + */ +function normaliseEmail(value) { + return value === undefined || value === null ? '' : String(value).trim(); +} + +/** + * Validate a mandatory email address. + * @returns {string|null} an error message, or null when valid. + */ +function validateEmail(value) { + const email = normaliseEmail(value); + if (!email) return 'An email address is required.'; + if (email.length > 254) return 'That email address is too long.'; + if (!EMAIL_RE.test(email)) return 'Please enter a valid email address.'; + return null; +} + +function validateUsername(value) { + const username = value === undefined || value === null ? '' : String(value).trim(); + if (!username) return 'A username is required.'; + if (!USERNAME_RE.test(username)) { + return 'Username must be 3-32 characters: letters, numbers, dot, underscore or hyphen.'; + } + return null; +} + +function validatePassword(value) { + const password = value === undefined || value === null ? '' : String(value); + if (!password) return 'A password is required.'; + if (password.length < 10) return 'Password must be at least 10 characters long.'; + if (!/[A-Za-z]/.test(password) || !/[0-9]/.test(password)) { + return 'Password must contain at least one letter and one number.'; + } + return null; +} + +module.exports = { + EMAIL_RE, USERNAME_RE, normaliseEmail, validateEmail, validateUsername, validatePassword, +}; diff --git a/middleware/auth.js b/middleware/auth.js new file mode 100644 index 0000000..371cb57 --- /dev/null +++ b/middleware/auth.js @@ -0,0 +1,25 @@ +'use strict'; + +function isApi(req) { + return (req.originalUrl || req.url).startsWith('/api/'); +} + +function requireAuth(req, res, next) { + if (req.session && req.session.user) return next(); + if (isApi(req)) { + return res.status(401).json({ error: 'Not authenticated' }); + } + return res.redirect('/login.html'); +} + +function requireAdmin(req, res, next) { + if (req.session && req.session.user && req.session.user.role === 'admin') { + return next(); + } + if (isApi(req)) { + return res.status(403).json({ error: 'Admin access required' }); + } + return res.status(403).send('Forbidden: admin access required.'); +} + +module.exports = { requireAuth, requireAdmin }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7876c85 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2036 @@ +{ + "name": "martinhal-datahub", + "version": "1.5.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "martinhal-datahub", + "version": "1.5.0", + "license": "MIT", + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.3.0", + "better-sqlite3-session-store": "^0.1.0", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-session": "^1.18.0", + "imapflow": "^1.5.0", + "multer": "^2.2.0", + "nodemailer": "^6.9.14", + "qrcode": "^1.5.4", + "speakeasy": "^2.0.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.14", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.14.tgz", + "integrity": "sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.4.1", + "libqp": "2.1.1" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/base32.js": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", + "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/better-sqlite3-session-store": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/better-sqlite3-session-store/-/better-sqlite3-session-store-0.1.0.tgz", + "integrity": "sha512-O4EO5jOGTEa/c1DbZpP3C7VTDLSWe5lrOu1S/j86ipdGZxrSb8bSUVuRgWCgl/SCgEGmyeEqvlMY9HtyOSMOWA==", + "license": "GPL-3.0-only", + "dependencies": { + "date-fns": "2.16.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.16.1.tgz", + "integrity": "sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ==", + "license": "MIT", + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/imapflow": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.5.0.tgz", + "integrity": "sha512-ayj2xIpRpXT9nXlAQQDhfm694faQxEmfAzRYL881q3YGRR5ofyIWsG3l3Lf7oSThxgLwZ/EQ5kh/nLnKBud3LQ==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.14", + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.3", + "libbase64": "1.3.0", + "libmime": "5.4.1", + "libqp": "2.1.1", + "nodemailer": "9.0.3", + "pino": "10.3.1", + "socks": "2.8.9" + } + }, + "node_modules/imapflow/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/imapflow/node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.1.tgz", + "integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libmime/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/speakeasy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz", + "integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==", + "license": "MIT", + "dependencies": { + "base32.js": "0.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..21daab5 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "martinhal-datahub", + "version": "1.5.0", + "description": "Martinhal IT - Self-hosted secure data access & approval portal", + "author": "Joao Vaz", + "license": "MIT", + "type": "commonjs", + "main": "server.js", + "scripts": { + "start": "node server.js", + "init": "node init-db.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.3.0", + "better-sqlite3-session-store": "^0.1.0", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-session": "^1.18.0", + "imapflow": "^1.5.0", + "multer": "^2.2.0", + "nodemailer": "^6.9.14", + "qrcode": "^1.5.4", + "speakeasy": "^2.0.0" + } +} diff --git a/public/account.html b/public/account.html new file mode 100644 index 0000000..0dbebbe --- /dev/null +++ b/public/account.html @@ -0,0 +1,166 @@ + + + + + +My Account · Martinhal ISDSS + + + + + + + diff --git a/public/assets/isdss-logo.png b/public/assets/isdss-logo.png new file mode 100644 index 0000000..3461eb3 Binary files /dev/null and b/public/assets/isdss-logo.png differ diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..8a66637 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,484 @@ +:root { + --ink: #131820; + --ink-2: #1c2530; + --canvas: #eef1f5; + --surface: #ffffff; + --line: #dde3ea; + --text: #1b2330; + --muted: #67707e; + --accent: #1f7a70; /* deep teal - "vault" */ + --accent-ink: #0f463f; + --accent-soft: #e5f2f0; + --clay: #b5642a; + --danger: #c23b46; + --danger-soft: #fbe9ea; + --warn: #b8860b; + --ok: #2e7d52; + --radius: 10px; + --shadow: 0 1px 2px rgba(16,24,40,.06), 0 8px 24px rgba(16,24,40,.06); + --mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + font-family: var(--sans); + color: var(--text); + background: var(--canvas); + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ---------- App shell ---------- */ +.app { display: grid; grid-template-columns: 248px 1fr; min-height: 100vh; } +.sidebar { + background: var(--ink); + color: #cdd6e2; + display: flex; flex-direction: column; + position: sticky; top: 0; height: 100vh; +} +.brand { + padding: 22px 20px 18px; + border-bottom: 1px solid rgba(255,255,255,.08); +} +.brand .mark { + display: flex; align-items: center; gap: 10px; + font-weight: 700; letter-spacing: -.01em; color: #fff; font-size: 17px; +} +.brand .logo { + width: 30px; height: 30px; border-radius: 8px; + background: linear-gradient(135deg, var(--accent), #2aa596); + display: grid; place-items: center; color: #fff; font-weight: 800; +} +.brand .tag { color: #8592a4; font-size: 12px; margin-top: 4px; letter-spacing: .02em; } + +.nav { padding: 12px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; } +.nav a { + display: flex; align-items: center; gap: 11px; + padding: 10px 12px; border-radius: 8px; color: #b7c1d0; + font-weight: 500; font-size: 14.5px; +} +.nav a:hover { background: rgba(255,255,255,.06); color: #fff; text-decoration: none; } +.nav a.active { background: var(--accent); color: #fff; } +.nav a svg { width: 18px; height: 18px; flex: none; opacity: .95; } +.nav .section-label { + font-size: 11px; text-transform: uppercase; letter-spacing: .09em; + color: #6b7788; padding: 14px 12px 6px; +} + +.side-user { + border-top: 1px solid rgba(255,255,255,.08); + padding: 14px 16px; font-size: 13px; +} +.side-user .u-name { color: #fff; font-weight: 600; } +.side-user .u-role { + display: inline-block; margin-top: 3px; font-size: 11px; letter-spacing: .04em; + text-transform: uppercase; color: var(--accent); font-weight: 700; +} +.side-user button { + margin-top: 10px; width: 100%; background: rgba(255,255,255,.08); + color: #d7deea; border: none; padding: 8px; border-radius: 7px; cursor: pointer; + font-size: 13px; +} +.side-user button:hover { background: rgba(255,255,255,.16); } + +/* ---------- Main ---------- */ +.main { display: flex; flex-direction: column; min-width: 0; } +.content { flex: 1; padding: 30px 34px; max-width: 1180px; width: 100%; } +.page-head { margin-bottom: 22px; } +.page-head h1 { font-size: 24px; margin: 0 0 4px; letter-spacing: -.02em; } +.page-head p { margin: 0; color: var(--muted); } + +.footer { + border-top: 1px solid var(--line); + padding: 14px 34px; color: var(--muted); font-size: 12.5px; + font-family: var(--mono); background: var(--surface); +} + +/* ---------- Cards / panels ---------- */ +.card { + background: var(--surface); border: 1px solid var(--line); + border-radius: var(--radius); box-shadow: var(--shadow); +} +.card + .card { margin-top: 20px; } +.card-head { + padding: 16px 20px; border-bottom: 1px solid var(--line); + display: flex; align-items: center; justify-content: space-between; gap: 12px; +} +.card-head h2 { font-size: 16px; margin: 0; letter-spacing: -.01em; } +.card-body { padding: 18px 20px; } +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } + +/* ---------- Buttons ---------- */ +.btn { + display: inline-flex; align-items: center; gap: 7px; justify-content: center; + border: 1px solid var(--line); background: var(--surface); color: var(--text); + padding: 8px 14px; border-radius: 8px; font-size: 14px; font-weight: 600; + cursor: pointer; transition: .12s; font-family: inherit; +} +.btn:hover { border-color: #c2cad4; background: #fbfcfd; } +.btn svg { width: 16px; height: 16px; } +.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn-primary:hover { background: var(--accent-ink); border-color: var(--accent-ink); } +.btn-danger { background: #fff; border-color: #e6b9bd; color: var(--danger); } +.btn-danger:hover { background: var(--danger-soft); } +.btn-ghost { border-color: transparent; background: transparent; } +.btn-sm { padding: 5px 10px; font-size: 13px; } +.btn:disabled { opacity: .55; cursor: not-allowed; } + +/* ---------- Forms ---------- */ +label { display: block; font-size: 13px; font-weight: 600; color: #384250; margin-bottom: 6px; } +input[type=text], input[type=email], input[type=password], input[type=search], +input[type=datetime-local], textarea, select { + width: 100%; padding: 9px 11px; border: 1px solid var(--line); + border-radius: 8px; font-size: 14px; font-family: inherit; background: #fff; color: var(--text); +} +input:focus, textarea:focus, select:focus { + outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); +} +textarea { resize: vertical; min-height: 120px; font-family: var(--mono); font-size: 13px; } +.field { margin-bottom: 16px; } +.hint { font-size: 12.5px; color: var(--muted); margin-top: 6px; } + +/* ---------- Tables ---------- */ +table { width: 100%; border-collapse: collapse; font-size: 14px; } +th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); } +th { font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 700; } +tbody tr:hover { background: #f8fafb; } +.mono { font-family: var(--mono); font-size: 12.5px; } + +/* ---------- Badges ---------- */ +.badge { + display: inline-block; padding: 2px 9px; border-radius: 999px; + font-size: 12px; font-weight: 700; letter-spacing: .02em; +} +.badge-pending { background: #fdf3e0; color: var(--warn); } +.badge-approved { background: #e6f4ec; color: var(--ok); } +.badge-denied { background: var(--danger-soft); color: var(--danger); } +.badge-admin { background: var(--accent-soft); color: var(--accent-ink); } + +/* ---------- File tree ---------- */ +.tree { list-style: none; margin: 0; padding: 0; } +.tree .row { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; border-radius: 8px; border: 1px solid transparent; +} +.tree .row:hover { background: #f6f8fa; border-color: var(--line); } +.tree .row .ic { width: 18px; height: 18px; flex: none; color: var(--muted); } +.tree .row .ic.folder { color: var(--clay); } +.tree .name { font-weight: 500; } +.tree .meta { color: var(--muted); font-size: 12.5px; margin-left: 6px; } +.tree .spacer { flex: 1; } +.tree .actions { display: flex; gap: 4px; opacity: 0; transition: .12s; } +.tree .row:hover .actions { opacity: 1; } +.tree ul { list-style: none; margin: 2px 0 2px 22px; padding-left: 12px; border-left: 1px dashed var(--line); } + +/* ---------- Toast ---------- */ +.toasts { position: fixed; right: 18px; bottom: 18px; display: flex; flex-direction: column; gap: 10px; z-index: 9999; } +.toast { + background: var(--ink); color: #fff; padding: 12px 16px; border-radius: 9px; + box-shadow: var(--shadow); font-size: 14px; max-width: 360px; animation: pop .18s ease; +} +.toast.ok { background: #14532d; } +.toast.err { background: #7f1d24; } +@keyframes pop { from { transform: translateY(8px); opacity: 0; } } + +/* ---------- Modal ---------- */ +.modal-back { + position: fixed; inset: 0; background: rgba(15,20,28,.5); + display: grid; place-items: center; z-index: 1000; padding: 20px; +} +.modal { + background: #fff; border-radius: 14px; width: 100%; max-width: 560px; + box-shadow: 0 20px 60px rgba(0,0,0,.3); overflow: hidden; +} +.modal.lg { max-width: 820px; } +.modal-head { padding: 18px 22px; border-bottom: 1px solid var(--line); font-weight: 700; font-size: 17px; } +.modal-body { padding: 22px; max-height: 70vh; overflow: auto; } +.modal-foot { padding: 16px 22px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 10px; } + +/* ---------- Login ---------- */ +.auth-wrap { min-height: 100vh; display: grid; grid-template-columns: 1.1fr 1fr; } +.auth-hero { + background: radial-gradient(120% 120% at 0% 0%, #1c2a2e 0%, var(--ink) 55%); + color: #fff; padding: 56px; display: flex; flex-direction: column; justify-content: center; +} +.auth-hero .logo-lg { + width: 52px; height: 52px; border-radius: 13px; + background: linear-gradient(135deg, var(--accent), #2aa596); + display: grid; place-items: center; font-weight: 800; font-size: 24px; color:#fff; +} +.auth-hero h1 { font-size: 34px; letter-spacing: -.03em; margin: 26px 0 0; line-height: 1.1; } +.auth-hero p { color: #9fb0b3; max-width: 400px; font-size: 15px; } +.auth-hero .feat { margin-top: 30px; display: flex; flex-direction: column; gap: 12px; } +.auth-hero .feat div { display: flex; gap: 11px; align-items: center; color: #cdd8d9; font-size: 14px; } +.auth-hero .feat svg { width: 18px; height: 18px; color: var(--accent); flex: none; } +.auth-hero .foot { color: #6f7f82; font-size: 12px; font-family: var(--mono); } + +.auth-panel { display: grid; place-items: center; padding: 40px; background: var(--surface); } +.auth-form { width: 100%; max-width: 360px; } +.auth-form h2 { font-size: 22px; margin: 0 0 4px; letter-spacing: -.02em; } +.auth-form .sub { color: var(--muted); margin: 0 0 26px; } +.auth-form .btn-primary { width: 100%; padding: 11px; font-size: 15px; } +.err-line { color: var(--danger); font-size: 13.5px; margin-top: 12px; min-height: 18px; } +.otp-input { letter-spacing: .5em; text-align: center; font-size: 20px; font-family: var(--mono); } + +.empty { text-align: center; padding: 44px 20px; color: var(--muted); } +.empty svg { width: 40px; height: 40px; opacity: .4; margin-bottom: 10px; } + +.qr-box { text-align: center; } +.qr-box img { width: 200px; height: 200px; border: 1px solid var(--line); border-radius: 12px; padding: 8px; background:#fff; } +.secret { + font-family: var(--mono); background: var(--canvas); padding: 8px 12px; border-radius: 8px; + display: inline-block; margin-top: 10px; font-size: 13px; word-break: break-all; +} +.placeholders { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.placeholders code { + font-family: var(--mono); font-size: 12px; background: var(--accent-soft); + color: var(--accent-ink); padding: 2px 8px; border-radius: 6px; cursor: pointer; +} +.img-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px,1fr)); gap: 12px; } +.img-card { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; background:#fff; } +.img-card img { width: 100%; height: 92px; object-fit: contain; background: #f4f6f8; } +.img-card .cap { padding: 8px; font-size: 12px; display: flex; justify-content: space-between; align-items: center; gap: 6px; } +.img-card .cap .lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; } +.toolbar .spacer { flex: 1; } + +@media (max-width: 860px) { + .app { grid-template-columns: 1fr; } + .sidebar { position: static; height: auto; flex-direction: column; } + .nav { flex-direction: row; flex-wrap: wrap; } + .grid-2 { grid-template-columns: 1fr; } + .auth-wrap { grid-template-columns: 1fr; } + .auth-hero { display: none; } +} + +/* ---- First-boot setup wizard ---- */ +.setup-badge{ + display:inline-block; + font-size:12px; + font-weight:600; + letter-spacing:.04em; + text-transform:uppercase; + color:var(--accent); + background:rgba(31,122,112,.10); + border:1px solid rgba(31,122,112,.28); + padding:5px 10px; + border-radius:999px; + margin-bottom:14px; +} +.pw-meter{ + height:5px; + border-radius:999px; + background:#e6eaef; + margin:9px 0 7px; + overflow:hidden; +} +.pw-meter span{ + display:block; + height:100%; + width:0; + border-radius:999px; + transition:width .18s ease, background .18s ease; +} +.pw-meter span.lvl-1{ background:#d9534f; } +.pw-meter span.lvl-2{ background:#e0a534; } +.pw-meter span.lvl-3{ background:#3f9d5a; } +.pw-meter span.lvl-4{ background:var(--accent); } + +/* ---- Users tab helpers ---- */ +.row-actions{ white-space:nowrap; display:flex; gap:6px; justify-content:flex-end; } +td.muted{ color:var(--muted); text-align:center; padding:18px 0; } + +/* ---- Locked (not yet approved) folders on View Data ---- */ +.tree .row.locked .name { color: var(--muted); } +.tree .row.locked .ic svg { color: var(--muted); } +.locked-note { + font-size: 12px; color: var(--muted); + border: 1px dashed var(--line); border-radius: 999px; padding: 2px 9px; margin-left: 4px; +} + +/* ---- Version Control ---- */ +.ver-list { display: flex; flex-direction: column; gap: 14px; } +.ver-item { border: 1px solid var(--line); border-radius: 12px; padding: 16px 18px; background: var(--surface); } +.ver-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; } +.ver-tag { + font-family: var(--mono); font-size: 13px; font-weight: 700; color: #fff; + background: var(--accent); border-radius: 7px; padding: 3px 9px; +} +.ver-title { font-weight: 650; font-size: 15px; } +.ver-date { color: var(--muted); font-size: 13px; margin-left: auto; font-family: var(--mono); } +.ver-notes { margin-top: 10px; color: var(--ink); font-size: 14px; white-space: pre-wrap; line-height: 1.55; } +.notes-cell { color: var(--muted); font-size: 13px; max-width: 260px; } + +/* Long page headings (e.g. the full ISDSS name) should wrap cleanly */ +.page-head h1 { max-width: 900px; line-height: 1.2; text-wrap: balance; } + +/* ---- Mail configuration + mail log ---- */ +.cfg-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; } +.cfg-grid .field.wide { grid-column: 1 / -1; } +.cfg-status { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-bottom: 14px; } +.dot { width: 9px; height: 9px; border-radius: 50%; flex: none; } +.dot.on { background: #3f9d5a; } +.dot.off { background: #c9ced6; } +.badge-delivered { background: rgba(63,157,90,.12); color: #2f7d46; border-color: rgba(63,157,90,.3); } +.badge-queued { background: rgba(224,165,52,.14); color: #8a6413; border-color: rgba(224,165,52,.35); } +.badge-failed { background: rgba(217,83,79,.12); color: #a8322e; border-color: rgba(217,83,79,.3); } +.mail-preview { color: var(--muted); font-size: 12.5px; max-width: 340px; } +@media (max-width: 860px) { .cfg-grid { grid-template-columns: 1fr; } } + +/* ---- Warning banner (e.g. notifications cannot be delivered) ---- */ +.warn-banner { + border: 1px solid rgba(224,165,52,.45); + background: rgba(224,165,52,.10); + color: #6d5210; + border-radius: 11px; + padding: 13px 16px; + font-size: 13.5px; + line-height: 1.55; + margin-bottom: 16px; +} +.warn-banner a { color: var(--accent); font-weight: 600; margin-left: 6px; } +.req-star { color: #c0392b; font-weight: 700; margin-left: 2px; } + +/* ---- Folder dates on View Data ---- */ +.folder-dates { + display: flex; flex-wrap: wrap; gap: 10px 22px; + margin: 2px 0 8px 34px; padding: 7px 12px; + border-left: 2px solid var(--line); + background: rgba(19,24,32,.02); + border-radius: 0 8px 8px 0; +} +.date-item { display: flex; align-items: baseline; gap: 7px; } +.date-label { + font-size: 10.5px; font-weight: 700; letter-spacing: .06em; + text-transform: uppercase; color: var(--muted); +} +.date-value { font-family: var(--mono); font-size: 12.5px; color: var(--ink); } +.date-value.muted-value { color: var(--muted); font-style: italic; } +.date-value.date-expired { color: #a8322e; font-weight: 600; } + +/* ---- Collapsible tree in Data Management ---- */ +.tw { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; } +.tw-toolbar { + display: flex; gap: 8px; align-items: center; + padding: 8px 12px; border-bottom: 1px solid var(--line); background: rgba(19,24,32,.02); +} +.tw-toolbar .hint { margin-left: auto; } +.caret { + width: 22px; height: 22px; flex: none; display: inline-flex; + align-items: center; justify-content: center; cursor: pointer; + border-radius: 6px; color: var(--muted); background: transparent; border: none; + transition: transform .15s ease, background .15s ease; +} +.caret:hover { background: rgba(19,24,32,.06); color: var(--ink); } +.caret svg { width: 13px; height: 13px; } +.caret.open { transform: rotate(90deg); } +.caret.leaf { visibility: hidden; cursor: default; } +.child-count { + font-size: 11.5px; color: var(--muted); background: rgba(19,24,32,.05); + border-radius: 999px; padding: 1px 8px; margin-left: 6px; +} +.fdate { font-family: var(--mono); font-size: 11.5px; color: var(--muted); margin-left: 8px; } +.fdate.missing { color: #b3541e; font-style: italic; } + +/* ---- Access countdown on View Data ---- */ +.access-timer { + font-family: var(--mono); font-size: 12.5px; + padding: 1px 9px; border-radius: 999px; border: 1px solid transparent; + display: inline-block; +} +.access-perm { color: #2f7d46; background: rgba(63,157,90,.10); border-color: rgba(63,157,90,.28); } +.access-live { color: #1f5f8b; background: rgba(31,95,139,.10); border-color: rgba(31,95,139,.26); } +.access-soon { color: #8a6413; background: rgba(224,165,52,.14); border-color: rgba(224,165,52,.38); } +.access-denied { color: #a8322e; background: rgba(217,83,79,.10); border-color: rgba(217,83,79,.28); font-weight: 600; } + +/* ---- Radio list (validity choices) ---- */ +.radio-group { display: flex; flex-direction: column; gap: 2px; } +.radio-row { + display: flex; align-items: center; gap: 10px; + padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; + cursor: pointer; font-size: 14px; transition: background .12s ease, border-color .12s ease; +} +.radio-row:hover { background: rgba(19,24,32,.03); } +.radio-row input { width: auto; margin: 0; accent-color: var(--accent); } +.radio-row:has(input:checked) { border-color: var(--accent); background: rgba(31,122,112,.07); } + +/* ---- Storage page ---- */ +.usage-bar { + height: 22px; border-radius: 999px; background: #e9edf2; + overflow: hidden; border: 1px solid var(--line); +} +.usage-bar.sm { height: 8px; } +.usage-bar span { display: block; height: 100%; transition: width .3s ease; } +.bar-ok { background: linear-gradient(90deg, var(--accent), #2f9a8d); } +.bar-warn { background: linear-gradient(90deg, #d9a13a, #e0b45b); } +.bar-crit { background: linear-gradient(90deg, #c0392b, #d9534f); } +.usage-legend { + display: flex; flex-wrap: wrap; gap: 8px 22px; margin-top: 12px; + font-size: 13.5px; color: var(--muted); align-items: center; +} +.usage-legend strong { color: var(--ink); font-family: var(--mono); } +.usage-pct { margin-left: auto; font-weight: 600; color: var(--accent); } +.usage-pct.warn { color: #8a6413; } +.usage-pct.crit { color: #a8322e; } + +/* ---- Sidebar logo ---- + The artwork is dark-on-white, so it sits on a light panel to stay legible + against the dark sidebar. */ +.brand-logo { + display: block; + background: #fff; + border-radius: 10px; + padding: 10px 12px; + line-height: 0; + box-shadow: 0 1px 0 rgba(255,255,255,.06), 0 2px 10px rgba(0,0,0,.18); + transition: box-shadow .15s ease, transform .15s ease; +} +.brand-logo:hover { box-shadow: 0 1px 0 rgba(255,255,255,.10), 0 4px 16px rgba(0,0,0,.26); } +.brand-logo img { width: 100%; height: auto; display: block; } +.brand .tag { margin-top: 10px; } +@media (max-width: 860px) { + .brand-logo { max-width: 260px; } +} + +/* ---- User avatar (sidebar) ---- */ +.side-user .u-identity { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.u-avatar { + width: 38px; height: 38px; border-radius: 50%; flex: none; overflow: hidden; + display: grid; place-items: center; background: rgba(255,255,255,.10); + border: 1px solid rgba(255,255,255,.14); +} +.u-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; } +.u-identity .u-text { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; } +.u-identity .u-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.avatar-initials { font-weight: 700; font-size: 13px; color: #cdd6e2; letter-spacing: .02em; } + +/* ---- Avatar on the account page ---- */ +.profile-row { display: flex; align-items: center; gap: 18px; } +.avatar-lg { + width: 84px; height: 84px; border-radius: 50%; flex: none; overflow: hidden; + display: grid; place-items: center; background: #eef1f5; border: 1px solid var(--line); +} +.avatar-lg img { width: 100%; height: 100%; object-fit: cover; display: block; } +.avatar-lg .avatar-initials { font-size: 28px; color: var(--muted); } +.divider { height: 1px; background: var(--line); margin: 18px 0; } + +/* ---- Upload progress ---- */ +.upload-progress { margin: 6px 0 4px; } +.upload-bar { + height: 12px; border-radius: 999px; background: #e9edf2; + overflow: hidden; border: 1px solid var(--line); +} +.upload-bar span { + display: block; height: 100%; width: 0; + background: linear-gradient(90deg, var(--accent), #2f9a8d); + transition: width .2s ease; +} +.upload-progress .hint { margin-top: 6px; } diff --git a/public/data-management.html b/public/data-management.html new file mode 100644 index 0000000..fc32f41 --- /dev/null +++ b/public/data-management.html @@ -0,0 +1,1334 @@ + + + + + +Data Management · Martinhal ISDSS + + + + + + + + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..3088519 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,234 @@ +'use strict'; + +const FOOTER_TEXT = '© 2026 Martinhal IT - Joao Vaz - Version 1.5 Patch 0.2'; + +// Dates are stored as ISO (YYYY-MM-DD) so they sort correctly, but are always +// shown to people as DD-MM-YYYY. +function fmtDate(value) { + if (!value) return ''; + const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/); + return m ? `${m[3]}-${m[2]}-${m[1]}` : String(value); +} + +function fmtDateTime(value) { + if (!value) return ''; + const str = String(value).replace('T', ' ').replace('Z', ''); + const m = str.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ ](\d{2}:\d{2})(:\d{2})?)?/); + if (!m) return str; + const date = `${m[3]}-${m[2]}-${m[1]}`; + return m[4] ? `${date} ${m[4]}${m[5] || ''}` : date; +} + +// Accepts DD-MM-YYYY (what people type) or YYYY-MM-DD (native date inputs) +// and returns ISO for the API, or '' when the value is not a real date. +function toIsoDate(value) { + const v = String(value || '').trim(); + if (!v) return ''; + let iso = ''; + const dmy = v.match(/^(\d{2})-(\d{2})-(\d{4})$/); + if (dmy) iso = `${dmy[3]}-${dmy[2]}-${dmy[1]}`; + else if (/^\d{4}-\d{2}-\d{2}$/.test(v)) iso = v; + else return ''; + const [y, mo, da] = iso.split('-').map(Number); + const dt = new Date(Date.UTC(y, mo - 1, da)); + return (dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === da) ? iso : ''; +} + +const ACCESS_DURATIONS = [ + { key: '24h', label: 'Valid for 24 hours' }, + { key: '15d', label: 'Valid for 15 days' }, + { key: '30d', label: 'Valid for 30 days' }, + { key: 'forever', label: 'Valid forever' }, +]; + +const ICON = { + storage: '', + caret: '', + calendar: '', + legislation: '', + version: '', + lock: '', + view: '', + manage: '', + logs: '', + account: '', + folder: '', + file: '', + shield: '', + check: '', + mail: '', + download: '', + plus: '', + trash: '', + edit: '', + move: '', + upload: '', +}; + +async function api(url, opts = {}) { + const o = Object.assign({ headers: {} }, opts); + if (o.body && !(o.body instanceof FormData)) { + o.headers['Content-Type'] = 'application/json'; + o.body = JSON.stringify(o.body); + } + const res = await fetch(url, o); + if (res.status === 401) { location.href = '/login.html'; throw new Error('Not authenticated'); } + const ct = res.headers.get('content-type') || ''; + const data = ct.includes('application/json') ? await res.json() : await res.text(); + if (!res.ok) throw new Error((data && data.error) || 'Request failed'); + return data; +} + +// Upload with progress. fetch() cannot report upload progress, so this uses +// XMLHttpRequest and calls onProgress(percentOrNull) as bytes go out. +function apiUpload(url, formData, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', url); + xhr.upload.addEventListener('progress', (e) => { + if (onProgress) onProgress(e.lengthComputable ? Math.round((e.loaded / e.total) * 100) : null); + }); + xhr.addEventListener('load', () => { + if (xhr.status === 401) { location.href = '/login.html'; return reject(new Error('Not authenticated')); } + let data = null; + try { data = JSON.parse(xhr.responseText); } catch (_) { /* non-JSON */ } + if (xhr.status >= 200 && xhr.status < 300) return resolve(data || {}); + reject(new Error((data && data.error) || `Upload failed (${xhr.status})`)); + }); + xhr.addEventListener('error', () => reject(new Error('Network error during upload.'))); + xhr.addEventListener('abort', () => reject(new Error('Upload cancelled.'))); + xhr.send(formData); + }); +} + +function toast(msg, kind = '') { + let box = document.querySelector('.toasts'); + if (!box) { box = document.createElement('div'); box.className = 'toasts'; document.body.appendChild(box); } + const t = document.createElement('div'); + t.className = 'toast ' + kind; + t.textContent = msg; + box.appendChild(t); + setTimeout(() => t.remove(), 3600); +} + +function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} +function fmtBytes(n) { + if (!n) return '0 B'; + const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; n = Number(n); + while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return `${n.toFixed(i ? 1 : 0)} ${u[i]}`; +} + +function modal(title, bodyHtml, opts = {}) { + const back = document.createElement('div'); + back.className = 'modal-back'; + back.innerHTML = ``; + const foot = back.querySelector('.modal-foot'); + (opts.buttons || [{ label: 'Close' }]).forEach((b) => { + const btn = document.createElement('button'); + btn.className = 'btn ' + (b.className || ''); + btn.textContent = b.label; + btn.onclick = () => { if (!b.onClick || b.onClick(back) !== false) close(); }; + foot.appendChild(btn); + }); + function close() { back.remove(); } + back.addEventListener('mousedown', (e) => { if (e.target === back && !opts.sticky) close(); }); + document.body.appendChild(back); + return { el: back, close }; +} + +function prompt2(title, fields, onSubmit, submitLabel = 'Save') { + const body = fields.map((f) => ` +
+ + ${f.type === 'select' + ? `` + : ``} +
`).join(''); + const m = modal(title, body, { + buttons: [ + { label: 'Cancel' }, + { label: submitLabel, className: 'btn-primary', onClick: (back) => { + const vals = {}; + back.querySelectorAll('[data-k]').forEach((el) => vals[el.dataset.k] = el.value.trim()); + Promise.resolve(onSubmit(vals)).then((r) => { if (r === false) {} }); + return false; // keep open; caller closes + } }, + ], + }); + return m; +} + +async function buildShell(active) { + let me; + try { me = (await api('/api/auth/me')).user; } + catch { location.href = '/login.html'; return null; } + + const isAdmin = me.role === 'admin'; + const link = (href, icon, label, key) => + `${ICON[icon]}${label}`; + + const shell = document.createElement('div'); + shell.className = 'app'; + shell.innerHTML = ` + +
+
+ +
`; + document.body.appendChild(shell); + + // If the logo file is ever missing, fall back to the original wordmark + // rather than showing a broken image. + const logoImg = shell.querySelector('.brand-logo img'); + if (logoImg) { + logoImg.addEventListener('error', () => { + const link = shell.querySelector('.brand-logo'); + const fallback = shell.querySelector('.fallback-mark'); + if (link) link.hidden = true; + if (fallback) fallback.hidden = false; + }); + } + + shell.querySelector('#logoutBtn').onclick = async () => { + await api('/api/auth/logout', { method: 'POST' }); + location.href = '/login.html'; + }; + return { me, isAdmin, content: shell.querySelector('#content') }; +} diff --git a/public/legislation.html b/public/legislation.html new file mode 100644 index 0000000..6f6ee38 --- /dev/null +++ b/public/legislation.html @@ -0,0 +1,55 @@ + + + + + +Legislation · Martinhal ISDSS + + + + + + + diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..27ef670 --- /dev/null +++ b/public/login.html @@ -0,0 +1,85 @@ + + + + + +Sign in · Martinhal ISDSS + + + +
+
+
+
M
+

Martinhal ISDSS

+
+
+ +
+
+ +
+

Sign in

+

Use your ISDSS account to continue.

+
+ + +
+
+ + +
+ +
+
+ + + +
+
+
+ + + + + diff --git a/public/logs.html b/public/logs.html new file mode 100644 index 0000000..ce1c989 --- /dev/null +++ b/public/logs.html @@ -0,0 +1,109 @@ + + + + + +Logs · Martinhal ISDSS + + + + + + + diff --git a/public/setup.html b/public/setup.html new file mode 100644 index 0000000..d80504e --- /dev/null +++ b/public/setup.html @@ -0,0 +1,132 @@ + + + + + +First-time setup · Martinhal ISDSS + + + +
+
+
+
M
+

Welcome to ISDSS

+

Let's create the administrator account for this installation. This only happens once.

+
+
%SHIELD% This account has full administrative rights
+
%CHECK% You can add two-factor authentication right after
+
%LOGS% Everything from here on is recorded in the audit log
+
+
+
© 2026 Martinhal IT - Joao Vaz - Version 1.5 Patch 0.2
+
+ +
+
+
+
Step 1 of 1 · First-time setup
+

Create administrator

+

These are the credentials you will use to sign in from now on.

+ +
+ + + 3–32 characters. Letters, numbers, dot, underscore or hyphen. +
+
+ + + Required. Approval requests and system notifications are sent here. +
+
+ + +
+ At least 10 characters, including a letter and a number. +
+
+ + +
+ + +
+
+ + +
+
+
+ + + + + diff --git a/public/storage.html b/public/storage.html new file mode 100644 index 0000000..d49bc5f --- /dev/null +++ b/public/storage.html @@ -0,0 +1,196 @@ + + + + + +Storage · Martinhal ISDSS + + + + + + + diff --git a/public/version-control.html b/public/version-control.html new file mode 100644 index 0000000..8240614 --- /dev/null +++ b/public/version-control.html @@ -0,0 +1,51 @@ + + + + + +Version Control for ISDSS · Martinhal ISDSS + + + + + + + diff --git a/public/view-data.html b/public/view-data.html new file mode 100644 index 0000000..c0a7bfc --- /dev/null +++ b/public/view-data.html @@ -0,0 +1,213 @@ + + + + + +View Data · Martinhal ISDSS + + + + + + + diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..602d792 --- /dev/null +++ b/routes/auth.js @@ -0,0 +1,178 @@ +'use strict'; +const express = require('express'); +const bcrypt = require('bcryptjs'); +const speakeasy = require('speakeasy'); +const qrcode = require('qrcode'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const { db } = require('../db'); +const { log } = require('../lib/audit'); +const { requireAuth } = require('../middleware/auth'); + +const router = express.Router(); + +const AVATAR_DIR = process.env.AVATAR_DIR || path.join(__dirname, '..', 'avatars'); +fs.mkdirSync(AVATAR_DIR, { recursive: true }); + +const ALLOWED_AVATAR = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' }; +const avatarUpload = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => cb(null, AVATAR_DIR), + filename: (req, file, cb) => + cb(null, `u${req.session.user.id}_${crypto.randomBytes(6).toString('hex')}${ALLOWED_AVATAR[file.mimetype] || ''}`), + }), + fileFilter: (req, file, cb) => cb(null, !!ALLOWED_AVATAR[file.mimetype]), + limits: { fileSize: 4 * 1024 * 1024 }, // 4 MB +}); + +function publicUser(u) { + return { + id: u.id, username: u.username, email: u.email, role: u.role, + mfa_enabled: !!u.mfa_enabled, + avatar: u.avatar ? `/api/auth/avatar/${u.id}?v=${encodeURIComponent(u.avatar)}` : null, + }; +} + +// Step 1: username + password. If MFA on, respond with mfa_required. +router.post('/login', (req, res) => { + const { username, password, token } = req.body || {}; + const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username); + if (!user || !bcrypt.compareSync(password || '', user.password_hash)) { + log(req, 'LOGIN_FAILED', 'Login', `Failed login for "${username}"`); + return res.status(401).json({ error: 'Invalid username or password.' }); + } + + if (user.mfa_enabled) { + if (!token) { + return res.json({ mfa_required: true }); + } + const ok = speakeasy.totp.verify({ + secret: user.mfa_secret, + encoding: 'base32', + token: String(token).replace(/\s/g, ''), + window: 1, + }); + if (!ok) { + log(req, 'MFA_FAILED', 'Login', `Bad MFA code for "${user.username}"`); + return res.status(401).json({ error: 'Invalid authentication code.' }); + } + } + + req.session.user = publicUser(user); + log(req, 'LOGIN', 'Login', `${user.username} signed in`); + res.json({ ok: true, user: req.session.user }); +}); + +router.post('/logout', requireAuth, (req, res) => { + log(req, 'LOGOUT', 'Login', `${req.session.user.username} signed out`); + req.session.destroy(() => res.json({ ok: true })); +}); + +router.get('/me', requireAuth, (req, res) => { + const fresh = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.user.id); + req.session.user = publicUser(fresh); + res.json({ user: req.session.user }); +}); + +// --- MFA --------------------------------------------------------------- +// Generate a secret + QR to scan. Secret is held on session until verified. +router.post('/mfa/setup', requireAuth, async (req, res) => { + const secret = speakeasy.generateSecret({ + name: `Martinhal ISDSS (${req.session.user.username})`, + }); + req.session.pending_mfa = secret.base32; + const qr = await qrcode.toDataURL(secret.otpauth_url); + res.json({ otpauth_url: secret.otpauth_url, qr, base32: secret.base32 }); +}); + +// Verify the first code and turn MFA on. +router.post('/mfa/enable', requireAuth, (req, res) => { + const { token } = req.body || {}; + const pending = req.session.pending_mfa; + if (!pending) return res.status(400).json({ error: 'Start MFA setup first.' }); + const ok = speakeasy.totp.verify({ + secret: pending, encoding: 'base32', + token: String(token || '').replace(/\s/g, ''), window: 1, + }); + if (!ok) return res.status(400).json({ error: 'Code did not match. Try again.' }); + + db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?') + .run(pending, req.session.user.id); + delete req.session.pending_mfa; + req.session.user.mfa_enabled = true; + log(req, 'MFA_ENABLED', 'Account', `${req.session.user.username} enabled MFA`); + res.json({ ok: true }); +}); + +router.post('/mfa/disable', requireAuth, (req, res) => { + const { password } = req.body || {}; + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.user.id); + if (!bcrypt.compareSync(password || '', user.password_hash)) { + return res.status(401).json({ error: 'Password incorrect.' }); + } + db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL WHERE id = ?') + .run(user.id); + req.session.user.mfa_enabled = false; + log(req, 'MFA_DISABLED', 'Account', `${user.username} disabled MFA`); + res.json({ ok: true }); +}); + +// ---- Profile picture --------------------------------------------------- +// Upload (or replace) the signed-in user's picture. +function avatarUploadSafe(req, res, next) { + avatarUpload.single('avatar')(req, res, (err) => { + if (err) { + const msg = err.code === 'LIMIT_FILE_SIZE' + ? 'That image is larger than the 4 MB limit.' + : 'Please choose a PNG, JPEG, WebP or GIF image (max 4 MB).'; + return res.status(400).json({ error: msg }); + } + next(); + }); +} + +router.post('/avatar', requireAuth, avatarUploadSafe, (req, res) => { + if (!req.file) { + return res.status(400).json({ error: 'Please choose a PNG, JPEG, WebP or GIF image (max 4 MB).' }); + } + const id = req.session.user.id; + const prev = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id); + db.prepare('UPDATE users SET avatar = ?, avatar_mime = ? WHERE id = ?') + .run(req.file.filename, req.file.mimetype, id); + // Remove the old file so avatars do not accumulate on disk. + if (prev && prev.avatar && prev.avatar !== req.file.filename) { + try { fs.unlinkSync(path.join(AVATAR_DIR, prev.avatar)); } catch (_) { /* already gone */ } + } + req.session.user = publicUser(db.prepare('SELECT * FROM users WHERE id = ?').get(id)); + log(req, 'AVATAR_UPDATED', 'My Account', `${req.session.user.username} updated their profile picture`); + res.json({ ok: true, avatar: req.session.user.avatar }); +}); + +// Remove the signed-in user's picture. +router.delete('/avatar', requireAuth, (req, res) => { + const id = req.session.user.id; + const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id); + if (row && row.avatar) { + try { fs.unlinkSync(path.join(AVATAR_DIR, row.avatar)); } catch (_) { /* already gone */ } + } + db.prepare('UPDATE users SET avatar = NULL, avatar_mime = NULL WHERE id = ?').run(id); + req.session.user = publicUser(db.prepare('SELECT * FROM users WHERE id = ?').get(id)); + log(req, 'AVATAR_REMOVED', 'My Account', `${req.session.user.username} removed their profile picture`); + res.json({ ok: true }); +}); + +// Serve a user's picture. Any signed-in user may view another's (e.g. admins +// looking at the user list), but never without a session. +router.get('/avatar/:id', requireAuth, (req, res) => { + const row = db.prepare('SELECT avatar, avatar_mime FROM users WHERE id = ?').get(Number(req.params.id)); + if (!row || !row.avatar) return res.status(404).send('No picture.'); + const file = path.join(AVATAR_DIR, row.avatar); + if (!fs.existsSync(file)) return res.status(404).send('No picture.'); + if (row.avatar_mime) res.type(row.avatar_mime); + res.setHeader('Cache-Control', 'private, max-age=300'); + res.sendFile(file); +}); + +module.exports = router; diff --git a/routes/data.js b/routes/data.js new file mode 100644 index 0000000..61fd9bb --- /dev/null +++ b/routes/data.js @@ -0,0 +1,335 @@ +'use strict'; +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const archiver = null; // folder downloads are zipped on the fly if available (optional) +const { db } = require('../db'); +const { log } = require('../lib/audit'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); +const { sendMail, adminEmails, getMailConfig } = require('../lib/mailer'); +const { buildEmail } = require('../lib/templates'); +const { withValidity, DURATIONS, expiryFor, nowIso, toDisplayDate } = require('../lib/dates'); + +const router = express.Router(); +const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads'); + +// ---- Access helpers --------------------------------------------------- +// Access is granted per FOLDER. Approval on a folder implies access to +// everything inside it (sub-folders and files), so users never have to make a +// second request for the contents of a folder they can already open. +const LIVE_APPROVAL = `status = 'approved' + AND (access_expires_at IS NULL OR access_expires_at > datetime('now'))`; + +function approvedFolderIds(userId) { + return db.prepare( + `SELECT DISTINCT target_id FROM access_requests + WHERE user_id = ? AND target_type = 'folder' AND ${LIVE_APPROVAL}` + ).all(userId).map((r) => r.target_id); +} + +/** + * The live approval covering a folder, if any: either on the folder itself or + * on an ancestor (access to a folder cascades to everything inside it). + * Used to show the countdown against each folder. + */ +function approvalForFolder(userId, folderId) { + const chain = []; + let cur = folderId; + const guard = new Set(); + while (cur && !guard.has(cur)) { + guard.add(cur); + chain.push(cur); + const row = db.prepare('SELECT parent_id FROM folders WHERE id = ?').get(cur); + cur = row ? row.parent_id : null; + } + if (!chain.length) return null; + const placeholders = chain.map(() => '?').join(','); + return db.prepare( + `SELECT target_id, access_duration, access_expires_at, decided_at + FROM access_requests + WHERE user_id = ? AND target_type = 'folder' AND target_id IN (${placeholders}) + AND ${LIVE_APPROVAL} + ORDER BY (access_expires_at IS NULL) DESC, access_expires_at DESC + LIMIT 1` + ).get(userId, ...chain) || null; +} + +/** Expand a set of folder ids to include every descendant folder. */ +function withDescendants(ids) { + const rows = db.prepare('SELECT id, parent_id FROM folders').all(); + const children = new Map(); + for (const f of rows) { + const k = f.parent_id || 0; + if (!children.has(k)) children.set(k, []); + children.get(k).push(f.id); + } + const out = new Set(); + const stack = [...ids]; + while (stack.length) { + const id = stack.pop(); + if (out.has(id)) continue; + out.add(id); + for (const c of children.get(id) || []) stack.push(c); + } + return out; +} + +/** Folder ids this user may open (empty for none). Admins may open all. */ +function accessibleFolderIds(user) { + if (user.role === 'admin') { + return new Set(db.prepare('SELECT id FROM folders').all().map((f) => f.id)); + } + return withDescendants(approvedFolderIds(user.id)); +} + +function requestStateFor(userId) { + const reqs = db.prepare( + `SELECT target_type, target_id, status FROM access_requests + WHERE user_id = ? ORDER BY created_at DESC` + ).all(userId); + const state = {}; + for (const r of reqs) { + const k = `${r.target_type}:${r.target_id}`; + if (!state[k]) state[k] = r.status; // most recent wins + } + return state; +} + +// ---- Browse (any signed-in user) ------------------------------------- +router.get('/tree', requireAuth, (req, res) => { + const user = req.session.user; + const isAdmin = user.role === 'admin'; + const accessible = accessibleFolderIds(user); + const state = requestStateFor(user.id); + + const allFolders = db.prepare( + 'SELECT id, name, parent_id, recorded_date FROM folders ORDER BY name' + ).all().map(withValidity); + const allFiles = db.prepare( + 'SELECT id, name, size, mime, folder_id, created_at FROM files ORDER BY name' + ).all(); + + // A folder is listed when it sits at the root (so it can be requested) or + // when its parent is already accessible. Its CONTENTS are only listed when + // the folder itself is accessible. + const folders = allFolders + .filter((f) => isAdmin || !f.parent_id || accessible.has(f.parent_id)) + .map((f) => { + const canOpen = isAdmin || accessible.has(f.id); + // Countdown information for the folder. Administrators always have + // access and are never on a timer. + let access = { state: 'denied', expires_at: null, duration: null }; + if (isAdmin) { + access = { state: 'admin', expires_at: null, duration: 'forever' }; + } else if (canOpen) { + const appr = approvalForFolder(user.id, f.id); + access = appr + ? { + state: appr.access_expires_at ? 'timed' : 'permanent', + expires_at: appr.access_expires_at, + duration: appr.access_duration, + granted_on: appr.target_id === f.id ? null : appr.target_id, + } + : { state: 'permanent', expires_at: null, duration: 'forever' }; + } + return { ...f, accessible: canOpen, access }; + }); + + const files = allFiles + .filter((f) => { + if (isAdmin) return true; + if (!f.folder_id) return true; // loose files at the root + return accessible.has(f.folder_id); // inside an approved folder + }) + .map((f) => ({ + ...f, + // Files inside an accessible folder are downloadable outright; a loose + // root-level file still needs its own approval. + accessible: isAdmin + || (f.folder_id ? accessible.has(f.folder_id) : state[`file:${f.id}`] === 'approved'), + })); + + // The browser counts down against server time, so a wrong clock on the + // user's machine cannot make access look longer or shorter than it is. + res.json({ folders, files, requestState: state, server_time: nowIso() }); +}); + +// ---- Request access --------------------------------------------------- +router.post('/request', requireAuth, async (req, res) => { + const { target_type, target_id } = req.body || {}; + if (!['file', 'folder'].includes(target_type)) { + return res.status(400).json({ error: 'Invalid target type.' }); + } + const row = target_type === 'file' + ? db.prepare('SELECT id, name, folder_id FROM files WHERE id = ?').get(target_id) + : db.prepare('SELECT id, name, parent_id FROM folders WHERE id = ?').get(target_id); + if (!row) return res.status(404).json({ error: 'Item not found.' }); + + const user = req.session.user; + + // If the item is already covered by an approved folder, there is nothing to + // request — access to a folder implies access to everything inside it. + const accessible = accessibleFolderIds(user); + const covered = target_type === 'folder' + ? accessible.has(row.id) + : (row.folder_id ? accessible.has(row.folder_id) : false); + if (covered) { + return res.status(400).json({ error: 'You already have access to this item.' }); + } + + // Don't stack duplicate pending requests for the same target. + const pending = db.prepare( + `SELECT 1 FROM access_requests + WHERE user_id = ? AND target_type = ? AND target_id = ? AND status = 'pending' LIMIT 1` + ).get(user.id, target_type, target_id); + if (pending) { + return res.status(400).json({ error: 'You already have a pending request for this item.' }); + } + + const info = db.prepare( + `INSERT INTO access_requests (user_id, target_type, target_id, target_name) + VALUES (?, ?, ?, ?)` + ).run(user.id, target_type, target_id, row.name); + + log(req, 'ACCESS_REQUEST', 'View Data', + `${user.username} requested ${target_type} "${row.name}" (request ${info.lastInsertRowid})`); + + // ---- Notify EVERY administrator that a request is awaiting approval ---- + const requestId = info.lastInsertRowid; + const pendingCount = db.prepare("SELECT COUNT(*) c FROM access_requests WHERE status = 'pending'").get().c; + // Build a clickable link when a base URL is configured; fall back to the + // request's own origin so the link still works in a typical deployment. + const cfg = getMailConfig(); + const origin = cfg.base_url || `${req.protocol}://${req.get('host') || 'localhost'}`; + const vars = { + username: user.username, email: user.email, + target_type, target_name: row.name, + created_at: new Date().toISOString().replace('T', ' ').slice(0, 19), + request_id: requestId, + pending_count: pendingCount, + portal_url: origin, + approvals_url: `${origin}/data-management.html`, + }; + const mail = buildEmail('request_to_admin', vars); + const recipients = adminEmails(); + + if (!recipients.length) { + // Nobody to tell — record it rather than failing silently. + log(req, 'MAIL_ERROR', 'View Data', + `No administrator email addresses available to notify about request ${requestId}`); + } else { + try { + const r = await sendMail({ + to: recipients.join(','), subject: mail.subject, html: mail.html, + context: `Access request for ${target_type} "${row.name}"`, actor: user.username, + }); + log(req, 'MAIL_SENT', 'View Data', + `Pending-approval notice sent to ${recipients.length} administrator(s) for request ${requestId} ` + + `${r.delivered ? '(delivered)' : '(queued — no mail server configured)'}`); + } catch (e) { + // A mail failure must never lose the request itself. + log(req, 'MAIL_ERROR', 'View Data', `Admin notify failed for request ${requestId}: ${e.message}`); + } + } + res.json({ ok: true, request_id: requestId }); +}); + +// ---- Admin: list + decide -------------------------------------------- +router.get('/requests', requireAuth, requireAdmin, (req, res) => { + const rows = db.prepare( + `SELECT ar.*, u.username, u.email + FROM access_requests ar JOIN users u ON u.id = ar.user_id + ORDER BY ar.created_at DESC` + ).all(); + res.json({ requests: rows }); +}); + +async function decide(req, res, status) { + const id = Number(req.params.id); + const ar = db.prepare( + `SELECT ar.*, u.username, u.email FROM access_requests ar + JOIN users u ON u.id = ar.user_id WHERE ar.id = ?` + ).get(id); + if (!ar) return res.status(404).json({ error: 'Request not found.' }); + if (ar.status !== 'pending') return res.status(400).json({ error: 'Request already decided.' }); + + // How long the access lasts, measured from this moment. + let duration = null; + let expiresAt = null; + if (status === 'approved') { + duration = String((req.body && req.body.duration) || '').trim(); + if (!DURATIONS[duration]) { + return res.status(400).json({ + error: 'Choose how long the access is valid: 24h, 15d, 30d or forever.', + }); + } + expiresAt = expiryFor(duration); // null for "forever" + } + + db.prepare( + `UPDATE access_requests + SET status = ?, decided_by = ?, decided_at = CURRENT_TIMESTAMP, + access_duration = ?, access_expires_at = ? + WHERE id = ?` + ).run(status, req.session.user.id, duration, expiresAt, id); + + const validity = status === 'approved' + ? (expiresAt ? `${DURATIONS[duration].label} (until ${expiresAt} UTC)` : 'Valid forever') + : ''; + log(req, status === 'approved' ? 'REQUEST_APPROVED' : 'REQUEST_DENIED', 'Data Management', + `${req.session.user.username} ${status} request ${id} for ${ar.username} ` + + `(${ar.target_type} "${ar.target_name}")${validity ? ' — ' + validity : ''}`); + + const key = status === 'approved' ? 'approval_to_user' : 'denial_to_user'; + const mail = buildEmail(key, { + username: ar.username, email: ar.email, + target_type: ar.target_type, target_name: ar.target_name, + validity: status === 'approved' ? (DURATIONS[duration] || {}).label || '' : '', + expires_at: expiresAt ? toDisplayDate(expiresAt) : 'no expiry', + }); + try { + const r = await sendMail({ + to: ar.email, subject: mail.subject, html: mail.html, + context: `Request ${status}: ${ar.target_type} "${ar.target_name}"`, + actor: req.session.user.username, + }); + log(req, 'MAIL_SENT', 'Data Management', + `${status} notice to ${ar.email} ${r.delivered ? 'delivered' : 'queued (no SMTP)'}`); + } catch (e) { + log(req, 'MAIL_ERROR', 'Data Management', `Decision mail failed: ${e.message}`); + } + res.json({ ok: true }); +} + +router.post('/requests/:id/approve', requireAuth, requireAdmin, (req, res) => decide(req, res, 'approved')); +router.post('/requests/:id/deny', requireAuth, requireAdmin, (req, res) => decide(req, res, 'denied')); + +// ---- Download (must be approved, or admin) --------------------------- +function hasApproval(userId, type, id) { + const row = db.prepare( + `SELECT 1 FROM access_requests + WHERE user_id = ? AND target_type = ? AND target_id = ? AND ${LIVE_APPROVAL} + LIMIT 1` + ).get(userId, type, id); + return !!row; +} + +router.get('/download/file/:id', requireAuth, (req, res) => { + const id = Number(req.params.id); + const file = db.prepare('SELECT * FROM files WHERE id = ?').get(id); + if (!file) return res.status(404).send('File not found.'); + + const user = req.session.user; + const isAdmin = user.role === 'admin'; + // Access comes either from approval on the containing folder (which cascades + // to everything inside it) or, for a loose root-level file, on the file. + const viaFolder = file.folder_id ? accessibleFolderIds(user).has(file.folder_id) : false; + const viaFile = hasApproval(user.id, 'file', id); + if (!isAdmin && !viaFolder && !viaFile) { + return res.status(403).send('You do not have approved access to this file.'); + } + log(req, 'FILE_DOWNLOAD', 'View Data', `${user.username} downloaded "${file.name}"`); + res.download(path.join(UPLOAD_DIR, file.stored_name), file.name); +}); + +module.exports = router; diff --git a/routes/legislation.js b/routes/legislation.js new file mode 100644 index 0000000..cfaeb2c --- /dev/null +++ b/routes/legislation.js @@ -0,0 +1,81 @@ +'use strict'; +const express = require('express'); +const { db } = require('../db'); +const { log } = require('../lib/audit'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); +const { parseInputDate } = require('../lib/dates'); + +const router = express.Router(); +const PAGE = 'Legislation'; + +// ---- Read: available to every signed-in user -------------------------- +router.get('/', requireAuth, (req, res) => { + const entries = db.prepare( + `SELECT id, reference, title, effective_date, summary, link_url, sort_order, created_at, updated_at + FROM legislation_entries + ORDER BY sort_order DESC, COALESCE(effective_date, '') DESC, id DESC` + ).all(); + res.json({ entries }); +}); + +// ---- Write: administrators only --------------------------------------- +function clean(body) { + return { + reference: String((body && body.reference) || '').trim(), + title: String((body && body.title) || '').trim(), + effective_date: parseInputDate((body && body.effective_date) || ''), + effective_date_raw: String((body && body.effective_date) || '').trim(), + summary: String((body && body.summary) || '').trim(), + link_url: String((body && body.link_url) || '').trim(), + sort_order: Number.isFinite(Number(body && body.sort_order)) ? Number(body.sort_order) : 0, + }; +} + +router.post('/', requireAuth, requireAdmin, (req, res) => { + const v = clean(req.body); + if (!v.title) return res.status(400).json({ error: 'A title is required.' }); + if (v.effective_date_raw && !v.effective_date) { + return res.status(400).json({ error: 'Effective date must be a valid date in DD-MM-YYYY format.' }); + } + const info = db.prepare( + `INSERT INTO legislation_entries (reference, title, effective_date, summary, link_url, sort_order, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(v.reference || null, v.title, v.effective_date || null, v.summary || null, + v.link_url || null, v.sort_order, req.session.user.id); + + log(req, 'LEGISLATION_ENTRY_CREATED', 'Data Management', `Added legislation entry "${v.title}"`); + res.json({ ok: true, entry: db.prepare('SELECT * FROM legislation_entries WHERE id = ?').get(info.lastInsertRowid) }); +}); + +router.patch('/:id', requireAuth, requireAdmin, (req, res) => { + const id = Number(req.params.id); + const existing = db.prepare('SELECT * FROM legislation_entries WHERE id = ?').get(id); + if (!existing) return res.status(404).json({ error: 'Entry not found.' }); + + const v = clean(req.body); + if (!v.title) return res.status(400).json({ error: 'A title is required.' }); + if (v.effective_date_raw && !v.effective_date) { + return res.status(400).json({ error: 'Effective date must be a valid date in DD-MM-YYYY format.' }); + } + db.prepare( + `UPDATE legislation_entries + SET reference = ?, title = ?, effective_date = ?, summary = ?, link_url = ?, + sort_order = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?` + ).run(v.reference || null, v.title, v.effective_date || null, v.summary || null, + v.link_url || null, v.sort_order, id); + + log(req, 'LEGISLATION_ENTRY_UPDATED', 'Data Management', `Updated legislation entry "${v.title}"`); + res.json({ ok: true, entry: db.prepare('SELECT * FROM legislation_entries WHERE id = ?').get(id) }); +}); + +router.delete('/:id', requireAuth, requireAdmin, (req, res) => { + const id = Number(req.params.id); + const existing = db.prepare('SELECT * FROM legislation_entries WHERE id = ?').get(id); + if (!existing) return res.status(404).json({ error: 'Entry not found.' }); + db.prepare('DELETE FROM legislation_entries WHERE id = ?').run(id); + log(req, 'LEGISLATION_ENTRY_DELETED', 'Data Management', `Deleted legislation entry "${existing.title}"`); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/routes/logs.js b/routes/logs.js new file mode 100644 index 0000000..fb16f02 --- /dev/null +++ b/routes/logs.js @@ -0,0 +1,77 @@ +'use strict'; +const express = require('express'); +const { db } = require('../db'); +const { log } = require('../lib/audit'); +const { toDisplayDateTime } = require('../lib/dates'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); +const { sendMail, adminEmails } = require('../lib/mailer'); + +const router = express.Router(); +router.use(requireAuth, requireAdmin); + +function query({ q, action, page, from, to }) { + const where = []; + const params = []; + if (q) { where.push('(actor LIKE ? OR detail LIKE ?)'); params.push(`%${q}%`, `%${q}%`); } + if (action) { where.push('action = ?'); params.push(action); } + if (page) { where.push('page = ?'); params.push(page); } + if (from) { where.push('ts >= ?'); params.push(from); } + if (to) { where.push('ts <= ?'); params.push(to); } + const sql = + 'SELECT id, ts, actor, action, page, detail, ip FROM logs' + + (where.length ? ' WHERE ' + where.join(' AND ') : '') + + ' ORDER BY ts DESC, id DESC'; + return db.prepare(sql).all(...params); +} + +router.get('/', (req, res) => { + const rows = query(req.query); + const actions = db.prepare('SELECT DISTINCT action FROM logs ORDER BY action').all().map((r) => r.action); + const pages = db.prepare('SELECT DISTINCT page FROM logs WHERE page IS NOT NULL ORDER BY page').all().map((r) => r.page); + res.json({ logs: rows.slice(0, 1000), total: rows.length, actions, pages }); +}); + +function toCSV(rows) { + const head = ['id', 'timestamp', 'actor', 'action', 'page', 'detail', 'ip']; + const esc = (v) => `"${String(v == null ? '' : v).replace(/"/g, '""')}"`; + const lines = [head.join(',')]; + for (const r of rows) { + lines.push([r.id, toDisplayDateTime(r.ts), r.actor, r.action, r.page, r.detail, r.ip].map(esc).join(',')); + } + return lines.join('\r\n'); +} + +router.get('/export', (req, res) => { + const rows = query(req.query); + const csv = toCSV(rows); + log(req, 'LOGS_EXPORT', 'Logs', `Exported ${rows.length} log rows as CSV`); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="datahub-logs-${Date.now()}.csv"`); + res.send(csv); +}); + +router.post('/email', async (req, res) => { + const rows = query(req.body || {}); + const csv = toCSV(rows); + const to = (req.body && req.body.to) || adminEmails().join(','); + if (!to) return res.status(400).json({ error: 'No recipient. Add an admin email or provide "to".' }); + const html = + `

Martinhal ISDSS — audit log export

` + + `

${rows.length} log entries are attached as CSV.

` + + `
` + + `

© 2026 Martinhal IT - Joao Vaz - Version 1.5 Patch 0.2

`; + try { + const r = await sendMail({ + to, subject: `ISDSS audit logs (${rows.length} entries)`, html, + context: 'Audit log export', actor: req.session.user.username, + attachments: [{ filename: `datahub-logs-${Date.now()}.csv`, content: csv }], + }); + log(req, 'LOGS_EMAIL', 'Logs', `Emailed ${rows.length} log rows to ${to} ${r.delivered ? '(delivered)' : '(queued, no SMTP)'}`); + res.json({ ok: true, delivered: r.delivered, count: rows.length }); + } catch (e) { + log(req, 'MAIL_ERROR', 'Logs', `Log email failed: ${e.message}`); + res.status(500).json({ error: e.message }); + } +}); + +module.exports = router; diff --git a/routes/mail.js b/routes/mail.js new file mode 100644 index 0000000..52089d1 --- /dev/null +++ b/routes/mail.js @@ -0,0 +1,212 @@ +'use strict'; +const express = require('express'); +const { db } = require('../db'); +const { log } = require('../lib/audit'); +const { toDisplayDateTime } = require('../lib/dates'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); +const { + getMailConfig, saveMailConfig, verifyConfig, verifyImap, sendMail, adminEmails, recordMail, +} = require('../lib/mailer'); + +const router = express.Router(); +router.use(requireAuth, requireAdmin); + +const PAGE = 'Data Management'; +const MASK = '********'; + +/** Never send the stored password back to the browser. */ +function maskConfig(cfg) { + return { + ...cfg, + pass: cfg.pass ? MASK : '', + imap_pass: cfg.imap_pass ? MASK : '', + }; +} + +// ---- Configuration ---------------------------------------------------- +router.get('/config', (req, res) => { + res.json({ config: maskConfig(getMailConfig()) }); +}); + +function readConfig(body, current) { + return { + host: String((body && body.host) || '').trim(), + port: Number(body && body.port) || 587, + secure: !!(body && body.secure), + user: String((body && body.user) || '').trim(), + // An unchanged password field comes back masked — keep what we already have. + pass: (body && body.pass) === MASK ? current.pass : String((body && body.pass) || ''), + from: String((body && body.from) || '').trim(), + base_url: String((body && body.base_url) || '').trim().replace(/\/+$/, ''), + reject_unauthorized: body && body.reject_unauthorized !== undefined + ? !!body.reject_unauthorized : true, + // IMAP — used to file a copy of each sent message in the Sent folder. + imap_host: String((body && body.imap_host) || '').trim(), + imap_port: Number(body && body.imap_port) || 993, + imap_secure: body && body.imap_secure !== undefined ? !!body.imap_secure : true, + imap_user: String((body && body.imap_user) || '').trim(), + imap_pass: (body && body.imap_pass) === MASK ? current.imap_pass : String((body && body.imap_pass) || ''), + imap_sent_folder: String((body && body.imap_sent_folder) || '').trim(), + }; +} + +router.put('/config', (req, res) => { + const current = getMailConfig(); + const cfg = readConfig(req.body, current); + if (cfg.host && !cfg.from) { + return res.status(400).json({ error: 'A "from" address is required when a mail server is set.' }); + } + if (cfg.port < 1 || cfg.port > 65535) { + return res.status(400).json({ error: 'Port must be between 1 and 65535.' }); + } + saveMailConfig(cfg); + log(req, 'MAIL_CONFIG_UPDATED', PAGE, + cfg.host ? `Mail server set to ${cfg.host}:${cfg.port}` : 'Mail server configuration cleared'); + res.json({ ok: true, config: maskConfig(getMailConfig()) }); +}); + +// Verify the connection without saving. +router.post('/test', async (req, res) => { + const cfg = readConfig(req.body, getMailConfig()); + try { + await verifyConfig(cfg); + log(req, 'MAIL_CONFIG_TESTED', PAGE, `Connection to ${cfg.host}:${cfg.port} succeeded`); + res.json({ ok: true, message: 'Connection successful.' }); + } catch (e) { + log(req, 'MAIL_CONFIG_TESTED', PAGE, `Connection to ${cfg.host}:${cfg.port} failed: ${e.message}`); + res.status(400).json({ error: e.message }); + } +}); + +// Verify the IMAP connection used for saving to the Sent folder. +router.post('/test-imap', async (req, res) => { + const cfg = readConfig(req.body, getMailConfig()); + try { + const r = await verifyImap(cfg); + log(req, 'MAIL_IMAP_TESTED', PAGE, `IMAP connection to ${cfg.imap_host}:${cfg.imap_port} succeeded`); + res.json({ ok: true, message: `Connected. Sent folder: ${r.sent_folder}` }); + } catch (e) { + log(req, 'MAIL_IMAP_TESTED', PAGE, `IMAP connection to ${cfg.imap_host}:${cfg.imap_port} failed: ${e.message}`); + res.status(400).json({ error: e.message }); + } +}); + +// Send a real test message using the SAVED configuration. +router.post('/test-send', async (req, res) => { + const to = String((req.body && req.body.to) || '').trim() || req.session.user.email; + try { + const r = await sendMail({ + to, + subject: 'ISDSS test message', + html: '

This is a test message from Martinhal ISDSS. If you received it, your mail server settings are working.

', + context: 'Test message', + actor: req.session.user.username, + }); + log(req, 'MAIL_TEST_SENT', PAGE, `Test message to ${to} ${r.delivered ? 'delivered' : 'queued (no mail server)'}`); + res.json({ ok: true, delivered: r.delivered }); + } catch (e) { + res.status(400).json({ error: e.message }); + } +}); + +// ---- Mail log --------------------------------------------------------- +function queryLog(q) { + const where = []; + const args = []; + if (q.q) { + where.push('(to_addr LIKE ? OR from_addr LIKE ? OR subject LIKE ? OR body_preview LIKE ?)'); + const like = `%${q.q}%`; + args.push(like, like, like, like); + } + if (q.direction) { where.push('direction = ?'); args.push(q.direction); } + if (q.status) { where.push('status = ?'); args.push(q.status); } + if (q.from) { where.push('ts >= ?'); args.push(q.from); } + if (q.to) { where.push('ts <= ?'); args.push(q.to + ' 23:59:59'); } + const sql = where.length ? ' WHERE ' + where.join(' AND ') : ''; + const rows = db.prepare(`SELECT * FROM mail_log${sql} ORDER BY ts DESC, id DESC LIMIT 1000`).all(...args); + const total = db.prepare(`SELECT COUNT(*) c FROM mail_log${sql}`).get(...args).c; + return { rows, total }; +} + +router.get('/log', (req, res) => { + const { rows, total } = queryLog(req.query); + res.json({ + entries: rows, + total, + statuses: db.prepare('SELECT DISTINCT status FROM mail_log ORDER BY status').all().map((r) => r.status), + directions: db.prepare('SELECT DISTINCT direction FROM mail_log ORDER BY direction').all().map((r) => r.direction), + }); +}); + +function toCsv(rows) { + const head = ['Time', 'Direction', 'Status', 'From', 'To', 'Subject', 'Attachments', 'Context', 'Actor', 'Error']; + const esc = (v) => { + const s = v === null || v === undefined ? '' : String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + const lines = [head.join(',')]; + for (const r of rows) { + lines.push([toDisplayDateTime(r.ts), r.direction, r.status, r.from_addr, r.to_addr, r.subject, + r.attachments, r.context, r.actor, r.error].map(esc).join(',')); + } + return lines.join('\n'); +} + +router.get('/log/export', (req, res) => { + const { rows } = queryLog(req.query); + const csv = toCsv(rows); + log(req, 'MAIL_LOG_EXPORT', PAGE, `Exported ${rows.length} mail log entries as CSV`); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="isdss-mail-log-${new Date().toISOString().slice(0, 10)}.csv"`); + res.send(csv); +}); + +router.post('/log/email', async (req, res) => { + const { rows } = queryLog(req.body || {}); + const to = String((req.body && req.body.to) || '').trim() || adminEmails().join(','); + if (!to) return res.status(400).json({ error: 'No recipient available.' }); + + const csv = toCsv(rows); + const html = + `

Attached is the Martinhal ISDSS mail log (${rows.length} entries).

` + + `

© 2026 Martinhal IT - Joao Vaz - Version 1.5 Patch 0.2

`; + try { + const r = await sendMail({ + to, + subject: `ISDSS mail log (${rows.length} entries)`, + html, + attachments: [{ filename: 'isdss-mail-log.csv', content: csv }], + context: 'Mail log export', + actor: req.session.user.username, + }); + log(req, 'MAIL_LOG_EMAILED', PAGE, + `Mail log (${rows.length} entries) to ${to} ${r.delivered ? 'delivered' : 'queued (no mail server)'}`); + res.json({ ok: true, delivered: r.delivered, count: rows.length }); + } catch (e) { + res.status(400).json({ error: e.message }); + } +}); + +// Record an inbound message. The system has no mailbox of its own, so this +// exists for forwarding/integration: anything posted here appears in the log +// alongside outgoing mail. +router.post('/log/inbound', (req, res) => { + const b = req.body || {}; + if (!b.from_addr && !b.subject) { + return res.status(400).json({ error: 'At least a sender or a subject is required.' }); + } + recordMail({ + direction: 'received', + status: 'delivered', + from_addr: b.from_addr || null, + to_addr: b.to_addr || null, + subject: b.subject || null, + body_preview: String(b.body || '').slice(0, 400) || null, + context: b.context || 'Inbound message', + actor: req.session.user.username, + }); + log(req, 'MAIL_INBOUND_RECORDED', PAGE, `Recorded inbound message from ${b.from_addr || 'unknown'}`); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/routes/manage.js b/routes/manage.js new file mode 100644 index 0000000..b4a27b1 --- /dev/null +++ b/routes/manage.js @@ -0,0 +1,170 @@ +'use strict'; +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const multer = require('multer'); +const { db } = require('../db'); +const { log } = require('../lib/audit'); +const { isValidDate, legalValidity, withValidity, parseInputDate } = require('../lib/dates'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); + +const router = express.Router(); +const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads'); +fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, UPLOAD_DIR), + filename: (req, file, cb) => + cb(null, `${Date.now()}-${crypto.randomBytes(6).toString('hex')}${path.extname(file.originalname)}`), +}); +const upload = multer({ storage, limits: { fileSize: 1024 * 1024 * 200 } }); // 200MB + +router.use(requireAuth, requireAdmin); + +// ---- Tree / listing --------------------------------------------------- +router.get('/tree', (req, res) => { + const folders = db.prepare( + 'SELECT id, name, parent_id, recorded_date FROM folders ORDER BY name' + ).all().map(withValidity); + const files = db.prepare( + 'SELECT id, name, size, mime, folder_id, created_at FROM files ORDER BY name' + ).all(); + res.json({ folders, files }); +}); + +// ---- Folders ---------------------------------------------------------- +router.post('/folders', (req, res) => { + const { name, parent_id, recorded_date } = req.body || {}; + if (!name || !name.trim()) return res.status(400).json({ error: 'Folder name required.' }); + + // Every folder must carry the date its material was recorded; the Legal + // Validity date shown to users is derived from it. + const typed = String(recorded_date || '').trim(); + if (!typed) return res.status(400).json({ error: 'A recorded date is required for the folder.' }); + const recorded = parseInputDate(typed); // accepts DD-MM-YYYY or YYYY-MM-DD + if (!recorded) { + return res.status(400).json({ error: 'Recorded date must be a valid date in DD-MM-YYYY format.' }); + } + + const info = db.prepare( + 'INSERT INTO folders (name, parent_id, recorded_date, created_by) VALUES (?, ?, ?, ?)' + ).run(name.trim(), parent_id || null, recorded, req.session.user.id); + log(req, 'FOLDER_CREATE', 'Data Management', + `Created folder "${name}" (id ${info.lastInsertRowid}) recorded ${recorded}, valid until ${legalValidity(recorded)}`); + res.json({ ok: true, id: info.lastInsertRowid, recorded_date: recorded, legal_validity: legalValidity(recorded) }); +}); + +router.patch('/folders/:id', (req, res) => { + const id = Number(req.params.id); + const folder = db.prepare('SELECT * FROM folders WHERE id = ?').get(id); + if (!folder) return res.status(404).json({ error: 'Folder not found.' }); + const { name, parent_id, recorded_date } = req.body || {}; + + if (recorded_date !== undefined) { + const typed = String(recorded_date || '').trim(); + if (!typed) return res.status(400).json({ error: 'A recorded date is required for the folder.' }); + const recorded = parseInputDate(typed); + if (!recorded) { + return res.status(400).json({ error: 'Recorded date must be a valid date in DD-MM-YYYY format.' }); + } + db.prepare('UPDATE folders SET recorded_date = ? WHERE id = ?').run(recorded, id); + log(req, 'FOLDER_DATE_SET', 'Data Management', + `Folder ${id} "${folder.name}" recorded date set to ${recorded} (valid until ${legalValidity(recorded)})`); + } + + if (name !== undefined) { + db.prepare('UPDATE folders SET name = ? WHERE id = ?').run(name.trim(), id); + log(req, 'FOLDER_RENAME', 'Data Management', `Renamed folder ${id} "${folder.name}" -> "${name}"`); + } + if (parent_id !== undefined) { + if (Number(parent_id) === id) return res.status(400).json({ error: 'A folder cannot contain itself.' }); + // prevent moving into own descendant + if (isDescendant(id, Number(parent_id))) { + return res.status(400).json({ error: 'Cannot move a folder into one of its own sub-folders.' }); + } + db.prepare('UPDATE folders SET parent_id = ? WHERE id = ?').run(parent_id || null, id); + log(req, 'FOLDER_MOVE', 'Data Management', `Moved folder ${id} "${folder.name}" to parent ${parent_id || 'root'}`); + } + res.json({ ok: true }); +}); + +router.delete('/folders/:id', (req, res) => { + const id = Number(req.params.id); + const folder = db.prepare('SELECT * FROM folders WHERE id = ?').get(id); + if (!folder) return res.status(404).json({ error: 'Folder not found.' }); + // gather files under this folder subtree to remove from disk + const ids = collectSubtree(id); + const files = db.prepare( + `SELECT stored_name FROM files WHERE folder_id IN (${ids.map(() => '?').join(',')})` + ).all(...ids); + db.prepare('DELETE FROM folders WHERE id = ?').run(id); // cascade removes files rows + for (const f of files) safeUnlink(f.stored_name); + log(req, 'FOLDER_DELETE', 'Data Management', `Deleted folder ${id} "${folder.name}" and ${files.length} file(s)`); + res.json({ ok: true }); +}); + +// ---- Files ------------------------------------------------------------ +router.post('/files', upload.array('files'), (req, res) => { + const folderId = req.body.folder_id ? Number(req.body.folder_id) : null; + const inserted = []; + const stmt = db.prepare( + `INSERT INTO files (name, stored_name, size, mime, folder_id, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?)` + ); + for (const f of req.files || []) { + const info = stmt.run(f.originalname, f.filename, f.size, f.mimetype, folderId, req.session.user.id); + inserted.push({ id: info.lastInsertRowid, name: f.originalname }); + log(req, 'FILE_UPLOAD', 'Data Management', `Uploaded "${f.originalname}" (${f.size} bytes) to folder ${folderId || 'root'}`); + } + res.json({ ok: true, files: inserted }); +}); + +router.patch('/files/:id', (req, res) => { + const id = Number(req.params.id); + const file = db.prepare('SELECT * FROM files WHERE id = ?').get(id); + if (!file) return res.status(404).json({ error: 'File not found.' }); + const { name, folder_id } = req.body || {}; + if (name !== undefined) { + db.prepare('UPDATE files SET name = ? WHERE id = ?').run(name.trim(), id); + log(req, 'FILE_RENAME', 'Data Management', `Renamed file ${id} "${file.name}" -> "${name}"`); + } + if (folder_id !== undefined) { + db.prepare('UPDATE files SET folder_id = ? WHERE id = ?').run(folder_id || null, id); + log(req, 'FILE_MOVE', 'Data Management', `Moved file ${id} "${file.name}" to folder ${folder_id || 'root'}`); + } + res.json({ ok: true }); +}); + +router.delete('/files/:id', (req, res) => { + const id = Number(req.params.id); + const file = db.prepare('SELECT * FROM files WHERE id = ?').get(id); + if (!file) return res.status(404).json({ error: 'File not found.' }); + db.prepare('DELETE FROM files WHERE id = ?').run(id); + safeUnlink(file.stored_name); + log(req, 'FILE_DELETE', 'Data Management', `Deleted file ${id} "${file.name}"`); + res.json({ ok: true }); +}); + +// ---- helpers ---------------------------------------------------------- +function childFolderIds(parentId) { + return db.prepare('SELECT id FROM folders WHERE parent_id IS ?').all(parentId).map((r) => r.id); +} +function collectSubtree(rootId) { + const out = [rootId]; + const queue = [rootId]; + while (queue.length) { + const cur = queue.shift(); + for (const cid of childFolderIds(cur)) { out.push(cid); queue.push(cid); } + } + return out; +} +function isDescendant(folderId, candidateParentId) { + if (!candidateParentId) return false; + return collectSubtree(folderId).includes(candidateParentId); +} +function safeUnlink(storedName) { + try { fs.unlinkSync(path.join(UPLOAD_DIR, storedName)); } catch (_) { /* ignore */ } +} + +module.exports = router; diff --git a/routes/settings.js b/routes/settings.js new file mode 100644 index 0000000..facb9cf --- /dev/null +++ b/routes/settings.js @@ -0,0 +1,125 @@ +'use strict'; +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const multer = require('multer'); +const { db } = require('../db'); +const { log } = require('../lib/audit'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); +const { getTemplate, buildEmail, KEYS, DEFAULTS, PLACEHOLDERS } = require('../lib/templates'); + +const router = express.Router(); +const BRAND_DIR = process.env.BRAND_DIR || path.join(__dirname, '..', 'brand'); +fs.mkdirSync(BRAND_DIR, { recursive: true }); + +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, BRAND_DIR), + filename: (req, file, cb) => + cb(null, `${Date.now()}-${crypto.randomBytes(5).toString('hex')}${path.extname(file.originalname)}`), +}); +const okImage = (req, file, cb) => + cb(null, /^image\//.test(file.mimetype)); +const upload = multer({ storage, fileFilter: okImage, limits: { fileSize: 1024 * 1024 * 10 } }); + +// ---- Email templates -------------------------------------------------- +router.get('/templates', requireAuth, requireAdmin, (req, res) => { + const list = KEYS.map((key) => { + const t = getTemplate(key); + return { + key, + ...t, + // So the UI can offer "Restore default" when a template has drifted. + is_default: t.subject === DEFAULTS[key].subject && t.body_html === DEFAULTS[key].body_html, + placeholders: PLACEHOLDERS[key] || [], + }; + }); + res.json({ templates: list, keys: KEYS }); +}); + +// Put a template back to the version that ships with this release. Useful +// after an upgrade, since existing customised templates are never overwritten. +router.post('/templates/:key/restore', requireAuth, requireAdmin, (req, res) => { + const key = req.params.key; + if (!KEYS.includes(key)) return res.status(400).json({ error: 'Unknown template.' }); + const d = DEFAULTS[key]; + const exists = db.prepare('SELECT key FROM email_templates WHERE key = ?').get(key); + if (exists) { + db.prepare('UPDATE email_templates SET subject = ?, body_html = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?') + .run(d.subject, d.body_html, key); + } else { + db.prepare('INSERT INTO email_templates (key, subject, body_html) VALUES (?, ?, ?)') + .run(key, d.subject, d.body_html); + } + log(req, 'TEMPLATE_RESTORED', 'Data Management', `Restored default email template "${key}"`); + res.json({ ok: true, template: { key, ...d } }); +}); + +router.put('/templates/:key', requireAuth, requireAdmin, (req, res) => { + const key = req.params.key; + if (!KEYS.includes(key)) return res.status(400).json({ error: 'Unknown template.' }); + const { subject, body_html } = req.body || {}; + if (!subject || !body_html) return res.status(400).json({ error: 'Subject and body are required.' }); + const exists = db.prepare('SELECT key FROM email_templates WHERE key = ?').get(key); + if (exists) { + db.prepare('UPDATE email_templates SET subject = ?, body_html = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?') + .run(subject, body_html, key); + } else { + db.prepare('INSERT INTO email_templates (key, subject, body_html) VALUES (?, ?, ?)') + .run(key, subject, body_html); + } + log(req, 'TEMPLATE_UPDATE', 'Data Management', `Updated email template "${key}"`); + res.json({ ok: true }); +}); + +// Live preview with sample data +router.post('/templates/:key/preview', requireAuth, requireAdmin, (req, res) => { + const { subject, body_html } = req.body || {}; + // temporarily render provided draft with sample vars, without saving + const vars = { + username: 'jsmith', email: 'jsmith@example.com', + target_type: 'folder', target_name: 'Q4 Financials', + created_at: '2026-07-22 10:30:00', + }; + const render = require('../lib/templates').render; + const footer = + '
' + + '

© 2026 Martinhal IT - Joao Vaz - Version 1.5 Patch 0.2

'; + res.json({ + subject: render(subject || '', vars), + html: render(body_html || '', vars) + footer, + }); +}); + +// ---- Brand images ----------------------------------------------------- +router.get('/images', requireAuth, requireAdmin, (req, res) => { + const images = db.prepare('SELECT id, label, stored_name, mime, created_at FROM brand_images ORDER BY created_at DESC').all(); + res.json({ images: images.map((i) => ({ ...i, url: `/settings/images/${i.stored_name}` })) }); +}); + +router.post('/images', requireAuth, requireAdmin, upload.single('image'), (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Please choose an image file.' }); + const label = (req.body.label || req.file.originalname).trim(); + const info = db.prepare('INSERT INTO brand_images (label, stored_name, mime) VALUES (?, ?, ?)') + .run(label, req.file.filename, req.file.mimetype); + log(req, 'IMAGE_UPLOAD', 'Data Management', `Uploaded brand image "${label}"`); + res.json({ ok: true, id: info.lastInsertRowid, url: `/settings/images/${req.file.filename}` }); +}); + +router.delete('/images/:id', requireAuth, requireAdmin, (req, res) => { + const img = db.prepare('SELECT * FROM brand_images WHERE id = ?').get(Number(req.params.id)); + if (!img) return res.status(404).json({ error: 'Image not found.' }); + db.prepare('DELETE FROM brand_images WHERE id = ?').run(img.id); + try { fs.unlinkSync(path.join(BRAND_DIR, img.stored_name)); } catch (_) {} + log(req, 'IMAGE_DELETE', 'Data Management', `Deleted brand image "${img.label}"`); + res.json({ ok: true }); +}); + +// Public-ish serve so email clients / preview can render them (auth still required to browse app) +router.get('/images/:stored', (req, res) => { + const img = db.prepare('SELECT * FROM brand_images WHERE stored_name = ?').get(req.params.stored); + if (!img) return res.status(404).end(); + res.sendFile(path.join(BRAND_DIR, img.stored_name)); +}); + +module.exports = router; diff --git a/routes/setup.js b/routes/setup.js new file mode 100644 index 0000000..7733149 --- /dev/null +++ b/routes/setup.js @@ -0,0 +1,78 @@ +'use strict'; +const express = require('express'); +const bcrypt = require('bcryptjs'); +const { db } = require('../db'); +const { log } = require('../lib/audit'); +const { normaliseEmail, validateEmail, validateUsername, validatePassword } = require('../lib/validate'); + +const router = express.Router(); + +/** True while the instance has no users at all (i.e. needs first-boot setup). */ +function needsSetup() { + return db.prepare('SELECT COUNT(*) c FROM users').get().c === 0; +} + +// Lets the front-end know whether the wizard should be shown. +router.get('/status', (req, res) => { + res.json({ needs_setup: needsSetup() }); +}); + +// Create the very first administrator. Only ever available while no users exist. +router.post('/', (req, res) => { + if (!needsSetup()) { + return res.status(409).json({ error: 'Setup has already been completed.' }); + } + + const username = String((req.body && req.body.username) || '').trim(); + const email = normaliseEmail(req.body && req.body.email); // mandatory + const password = String((req.body && req.body.password) || ''); + const confirm = String((req.body && req.body.confirm) || ''); + + // --- validation (same rules as the Users tab) ------------------------- + const err = validateUsername(username) || validateEmail(email) || validatePassword(password); + if (err) return res.status(400).json({ error: err }); + if (password !== confirm) { + return res.status(400).json({ error: 'The two passwords do not match.' }); + } + + // --- create ----------------------------------------------------------- + // Guard against two browsers racing through the wizard simultaneously: + // the transaction re-checks the user count before inserting. + let created; + try { + created = db.transaction(() => { + if (db.prepare('SELECT COUNT(*) c FROM users').get().c !== 0) { + const e = new Error('Setup has already been completed.'); + e.status = 409; + throw e; + } + const hash = bcrypt.hashSync(password, 12); + const info = db + .prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)') + .run(username, email, hash, 'admin'); + return db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid); + })(); + } catch (err) { + if (err && err.status === 409) return res.status(409).json({ error: err.message }); + if (String(err.message || '').includes('UNIQUE')) { + return res.status(409).json({ error: 'That username or email is already in use.' }); + } + throw err; + } + + log(req, 'SETUP_COMPLETED', 'Setup', `First administrator "${created.username}" created`); + + // Sign the new administrator straight in — no need to re-type credentials. + req.session.user = { + id: created.id, + username: created.username, + email: created.email, + role: created.role, + mfa_enabled: false, + }; + log(req, 'LOGIN', 'Login', `${created.username} signed in`); + + res.json({ ok: true, user: req.session.user }); +}); + +module.exports = { router, needsSetup }; diff --git a/routes/storage.js b/routes/storage.js new file mode 100644 index 0000000..d0a22bf --- /dev/null +++ b/routes/storage.js @@ -0,0 +1,229 @@ +'use strict'; +const express = require('express'); +const fs = require('fs'); +const fsp = require('fs/promises'); +const path = require('path'); +const { db, DB_PATH } = require('../db'); +const { log } = require('../lib/audit'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); + +const router = express.Router(); +router.use(requireAuth, requireAdmin); + +const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads'); +const BRAND_DIR = process.env.BRAND_DIR || path.join(__dirname, '..', 'brand'); + +/** Total bytes of every file directly inside a directory (non-recursive dirs handled too). */ +async function dirSize(dir) { + let bytes = 0; + let count = 0; + let entries; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch (_) { + return { bytes: 0, count: 0, missing: true }; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + const sub = await dirSize(full); + bytes += sub.bytes; + count += sub.count; + } else if (e.isFile()) { + try { + const st = await fsp.stat(full); + bytes += st.size; + count += 1; + } catch (_) { /* file vanished between listing and stat */ } + } + } + return { bytes, count, missing: false }; +} + +/** Size of the SQLite database, including its write-ahead log if present. */ +async function databaseSize() { + let bytes = 0; + for (const suffix of ['', '-wal', '-shm']) { + try { + const st = await fsp.stat(DB_PATH + suffix); + bytes += st.size; + } catch (_) { /* not present */ } + } + return bytes; +} + +/** Free/used figures for the filesystem holding a given path. */ +async function diskUsage(forPath) { + const s = await fsp.statfs(forPath); + const blockSize = s.bsize; + const total = s.blocks * blockSize; + // bavail is what a normal user may actually use; bfree includes blocks + // reserved for root, so using bavail avoids overstating free space. + const available = s.bavail * blockSize; + const used = (s.blocks - s.bfree) * blockSize; + const usableTotal = used + available; + return { + total, + used, + free: available, + // Percentage is taken against what is actually usable, which is how `df` + // reports it, so the number matches what an administrator sees on the host. + percent_used: usableTotal > 0 ? Math.round((used / usableTotal) * 1000) / 10 : 0, + }; +} + +/** + * Per-folder usage, built from the recorded file sizes. + * own_bytes = files sitting directly in the folder + * total_bytes = the folder and everything beneath it + */ +function folderUsage() { + const folders = db.prepare('SELECT id, name, parent_id FROM folders ORDER BY name').all(); + const files = db.prepare('SELECT id, name, size, folder_id, stored_name FROM files').all(); + + const own = new Map(); + const ownCount = new Map(); + let rootBytes = 0; + let rootCount = 0; + for (const f of files) { + const key = f.folder_id || null; + const size = Number(f.size) || 0; + if (key === null) { rootBytes += size; rootCount += 1; continue; } + own.set(key, (own.get(key) || 0) + size); + ownCount.set(key, (ownCount.get(key) || 0) + 1); + } + + const children = new Map(); + for (const f of folders) { + const k = f.parent_id || null; + if (!children.has(k)) children.set(k, []); + children.get(k).push(f); + } + + const totals = new Map(); + const totalCounts = new Map(); + function walk(folder) { + let bytes = own.get(folder.id) || 0; + let count = ownCount.get(folder.id) || 0; + for (const child of children.get(folder.id) || []) { + const sub = walk(child); + bytes += sub.bytes; + count += sub.count; + } + totals.set(folder.id, bytes); + totalCounts.set(folder.id, count); + return { bytes, count }; + } + for (const root of children.get(null) || []) walk(root); + + // Path label so deeply nested folders are identifiable in a flat table. + const byId = new Map(folders.map((f) => [f.id, f])); + function pathOf(f) { + const parts = []; + let cur = f; + const guard = new Set(); + while (cur && !guard.has(cur.id)) { + guard.add(cur.id); + parts.unshift(cur.name); + cur = cur.parent_id ? byId.get(cur.parent_id) : null; + } + return parts.join(' / '); + } + + const rows = folders.map((f) => ({ + id: f.id, + name: f.name, + parent_id: f.parent_id, + path: pathOf(f), + depth: pathOf(f).split(' / ').length - 1, + own_bytes: own.get(f.id) || 0, + own_files: ownCount.get(f.id) || 0, + total_bytes: totals.get(f.id) || 0, + total_files: totalCounts.get(f.id) || 0, + })); + + return { rows, rootBytes, rootCount, recordedTotal: files.reduce((a, f) => a + (Number(f.size) || 0), 0) }; +} + +/** + * Cross-check the database against the uploads directory: files recorded but + * missing from disk, and files on disk no longer referenced by any record. + */ +async function integrity() { + const rows = db.prepare('SELECT id, name, stored_name, size FROM files').all(); + const known = new Set(rows.map((r) => r.stored_name)); + const missing = []; + for (const r of rows) { + try { + await fsp.stat(path.join(UPLOAD_DIR, r.stored_name)); + } catch (_) { + missing.push({ id: r.id, name: r.name, stored_name: r.stored_name }); + } + } + let orphans = []; + let orphanBytes = 0; + try { + const entries = await fsp.readdir(UPLOAD_DIR, { withFileTypes: true }); + for (const e of entries) { + if (!e.isFile() || e.name === '.gitkeep') continue; + if (known.has(e.name)) continue; + try { + const st = await fsp.stat(path.join(UPLOAD_DIR, e.name)); + orphans.push({ stored_name: e.name, size: st.size }); + orphanBytes += st.size; + } catch (_) { /* ignore */ } + } + } catch (_) { /* uploads dir unreadable */ } + return { missing, orphans, orphan_bytes: orphanBytes }; +} + +router.get('/', async (req, res) => { + try { + const [disk, uploads, brand, dbBytes, checks] = await Promise.all([ + diskUsage(UPLOAD_DIR), + dirSize(UPLOAD_DIR), + dirSize(BRAND_DIR), + databaseSize(), + integrity(), + ]); + const usage = folderUsage(); + + res.json({ + disk, + app: { + uploads_bytes: uploads.bytes, + uploads_files: uploads.count, + brand_bytes: brand.bytes, + brand_files: brand.count, + database_bytes: dbBytes, + total_bytes: uploads.bytes + brand.bytes + dbBytes, + }, + folders: usage.rows, + root_files: { bytes: usage.rootBytes, count: usage.rootCount }, + recorded_total: usage.recordedTotal, + integrity: checks, + paths: { uploads: UPLOAD_DIR, brand: BRAND_DIR, database: DB_PATH }, + generated_at: new Date().toISOString().replace('T', ' ').slice(0, 19), + }); + } catch (e) { + res.status(500).json({ error: `Could not read storage information: ${e.message}` }); + } +}); + +// Remove files left on disk that no record points at (safe to reclaim). +router.post('/cleanup-orphans', async (req, res) => { + const checks = await integrity(); + let removed = 0; + let bytes = 0; + for (const o of checks.orphans) { + try { + await fsp.unlink(path.join(UPLOAD_DIR, o.stored_name)); + removed += 1; + bytes += o.size; + } catch (_) { /* leave it alone if it cannot be removed */ } + } + log(req, 'STORAGE_CLEANUP', 'Storage', `Removed ${removed} orphaned file(s), reclaiming ${bytes} bytes`); + res.json({ ok: true, removed, bytes }); +}); + +module.exports = router; diff --git a/routes/users.js b/routes/users.js new file mode 100644 index 0000000..68a050d --- /dev/null +++ b/routes/users.js @@ -0,0 +1,157 @@ +'use strict'; +const express = require('express'); +const bcrypt = require('bcryptjs'); +const { db } = require('../db'); +const { log } = require('../lib/audit'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); +const { normaliseEmail, validateEmail, validateUsername, validatePassword } = require('../lib/validate'); + +const router = express.Router(); +router.use(requireAuth, requireAdmin); + +const PAGE = 'Data Management'; + +function publicUser(u) { + const email = (u.email || '').trim(); + return { + id: u.id, + username: u.username, + email, + role: u.role, + mfa_enabled: !!u.mfa_enabled, + created_at: u.created_at, + // Databases created before this release could hold accounts with no + // address. Flag them so an administrator can put one in. + email_missing: !email, + }; +} + +/** Number of administrators, used to stop the last admin being removed. */ +function adminCount() { + return db.prepare("SELECT COUNT(*) c FROM users WHERE role = 'admin'").get().c; +} + +// ---- list ------------------------------------------------------------- +router.get('/', (req, res) => { + const users = db.prepare('SELECT * FROM users ORDER BY role DESC, username').all().map(publicUser); + res.json({ users, missing_email: users.filter((u) => u.email_missing).length }); +}); + +// ---- create ----------------------------------------------------------- +router.post('/', (req, res) => { + const body = req.body || {}; + const username = body.username === undefined ? '' : String(body.username).trim(); + const email = normaliseEmail(body.email); // mandatory + const password = body.password === undefined ? '' : String(body.password); + const role = body.role; + + const err = validateUsername(username) || validateEmail(email) || validatePassword(password); + if (err) return res.status(400).json({ error: err }); + if (role !== 'admin' && role !== 'user') return res.status(400).json({ error: 'Role must be "admin" or "user".' }); + + try { + const hash = bcrypt.hashSync(password, 12); + const info = db + .prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)') + .run(username, email, hash, role); + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid); + log(req, 'USER_CREATED', PAGE, `Created ${role} "${user.username}" (${user.email})`); + res.json({ ok: true, user: publicUser(user) }); + } catch (e) { + if (String(e.message || '').includes('UNIQUE')) { + return res.status(409).json({ error: 'That username or email is already in use.' }); + } + throw e; + } +}); + +// ---- update (email, role, password reset) ----------------------------- +router.patch('/:id', (req, res) => { + const id = Number(req.params.id); + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id); + if (!user) return res.status(404).json({ error: 'User not found.' }); + + const { email, role, password } = req.body || {}; + + // Email is mandatory: when the field is supplied it must be a valid address, + // so an existing account can never be left without one. + if (email !== undefined) { + const emailErr = validateEmail(email); + if (emailErr) return res.status(400).json({ error: emailErr }); + } + if (password !== undefined && password !== '') { + const pwErr = validatePassword(password); + if (pwErr) return res.status(400).json({ error: pwErr }); + } + + // Never let the last administrator demote themselves out of existence. + if (role && role !== user.role && user.role === 'admin' && adminCount() <= 1) { + return res.status(400).json({ error: 'This is the only administrator — promote someone else first.' }); + } + if (role && role !== 'admin' && role !== 'user') { + return res.status(400).json({ error: 'Role must be "admin" or "user".' }); + } + + const changes = []; + try { + const newEmail = normaliseEmail(email); + if (email !== undefined && newEmail !== user.email) { + db.prepare('UPDATE users SET email = ? WHERE id = ?').run(newEmail, id); + changes.push(`email → ${newEmail}`); + } + if (role && role !== user.role) { + db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id); + changes.push(`role → ${role}`); + } + if (password) { + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(bcrypt.hashSync(password, 12), id); + changes.push('password reset'); + } + } catch (e) { + if (String(e.message || '').includes('UNIQUE')) { + return res.status(409).json({ error: 'That email is already in use.' }); + } + throw e; + } + + if (changes.length) log(req, 'USER_UPDATED', PAGE, `Updated "${user.username}": ${changes.join(', ')}`); + res.json({ ok: true, user: publicUser(db.prepare('SELECT * FROM users WHERE id = ?').get(id)) }); +}); + +// ---- turn off MFA (recovery: user lost their authenticator) ----------- +router.post('/:id/mfa/reset', (req, res) => { + const id = Number(req.params.id); + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id); + if (!user) return res.status(404).json({ error: 'User not found.' }); + db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL WHERE id = ?').run(id); + log(req, 'USER_MFA_RESET', PAGE, `Two-factor authentication reset for "${user.username}"`); + res.json({ ok: true }); +}); + +// ---- delete ----------------------------------------------------------- +router.delete('/:id', (req, res) => { + const id = Number(req.params.id); + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id); + if (!user) return res.status(404).json({ error: 'User not found.' }); + if (id === req.session.user.id) { + return res.status(400).json({ error: 'You cannot delete the account you are signed in with.' }); + } + if (user.role === 'admin' && adminCount() <= 1) { + return res.status(400).json({ error: 'This is the only administrator and cannot be deleted.' }); + } + // Detach the user from everything that references them, in one transaction. + // Content they published is KEPT (ownership is simply cleared) and the audit + // log is untouched — log entries store the username as text, so history + // survives the account being removed. + db.transaction(() => { + db.prepare('DELETE FROM access_requests WHERE user_id = ?').run(id); + db.prepare('UPDATE access_requests SET decided_by = NULL WHERE decided_by = ?').run(id); + db.prepare('UPDATE folders SET created_by = NULL WHERE created_by = ?').run(id); + db.prepare('UPDATE files SET uploaded_by = NULL WHERE uploaded_by = ?').run(id); + db.prepare('DELETE FROM users WHERE id = ?').run(id); + })(); + log(req, 'USER_DELETED', PAGE, `Deleted user "${user.username}" (content and audit history retained)`); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/routes/versions.js b/routes/versions.js new file mode 100644 index 0000000..e1bf139 --- /dev/null +++ b/routes/versions.js @@ -0,0 +1,77 @@ +'use strict'; +const express = require('express'); +const { db } = require('../db'); +const { log } = require('../lib/audit'); +const { parseInputDate } = require('../lib/dates'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); + +const router = express.Router(); +const PAGE = 'Version Control'; + +// ---- Read: available to every signed-in user -------------------------- +router.get('/', requireAuth, (req, res) => { + const entries = db.prepare( + `SELECT id, version, title, released_on, notes, sort_order, created_at, updated_at + FROM version_entries + ORDER BY sort_order DESC, COALESCE(released_on, '') DESC, id DESC` + ).all(); + res.json({ entries }); +}); + +// ---- Write: administrators only --------------------------------------- +function clean(body) { + return { + version: String((body && body.version) || '').trim(), + title: String((body && body.title) || '').trim(), + released_on: parseInputDate((body && body.released_on) || ''), + released_on_raw: String((body && body.released_on) || '').trim(), + notes: String((body && body.notes) || '').trim(), + sort_order: Number.isFinite(Number(body && body.sort_order)) ? Number(body.sort_order) : 0, + }; +} + +router.post('/', requireAuth, requireAdmin, (req, res) => { + const v = clean(req.body); + if (!v.version) return res.status(400).json({ error: 'A version is required.' }); + if (v.released_on_raw && !v.released_on) { + return res.status(400).json({ error: 'Release date must be a valid date in DD-MM-YYYY format.' }); + } + const info = db.prepare( + `INSERT INTO version_entries (version, title, released_on, notes, sort_order, created_by) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(v.version, v.title || null, v.released_on || null, v.notes || null, v.sort_order, req.session.user.id); + + log(req, 'VERSION_ENTRY_CREATED', 'Data Management', `Added version entry "${v.version}"`); + res.json({ ok: true, entry: db.prepare('SELECT * FROM version_entries WHERE id = ?').get(info.lastInsertRowid) }); +}); + +router.patch('/:id', requireAuth, requireAdmin, (req, res) => { + const id = Number(req.params.id); + const existing = db.prepare('SELECT * FROM version_entries WHERE id = ?').get(id); + if (!existing) return res.status(404).json({ error: 'Entry not found.' }); + + const v = clean(req.body); + if (!v.version) return res.status(400).json({ error: 'A version is required.' }); + if (v.released_on_raw && !v.released_on) { + return res.status(400).json({ error: 'Release date must be a valid date in DD-MM-YYYY format.' }); + } + db.prepare( + `UPDATE version_entries + SET version = ?, title = ?, released_on = ?, notes = ?, sort_order = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?` + ).run(v.version, v.title || null, v.released_on || null, v.notes || null, v.sort_order, id); + + log(req, 'VERSION_ENTRY_UPDATED', 'Data Management', `Updated version entry "${v.version}"`); + res.json({ ok: true, entry: db.prepare('SELECT * FROM version_entries WHERE id = ?').get(id) }); +}); + +router.delete('/:id', requireAuth, requireAdmin, (req, res) => { + const id = Number(req.params.id); + const existing = db.prepare('SELECT * FROM version_entries WHERE id = ?').get(id); + if (!existing) return res.status(404).json({ error: 'Entry not found.' }); + db.prepare('DELETE FROM version_entries WHERE id = ?').run(id); + log(req, 'VERSION_ENTRY_DELETED', 'Data Management', `Deleted version entry "${existing.version}"`); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/server.js b/server.js new file mode 100644 index 0000000..539e23d --- /dev/null +++ b/server.js @@ -0,0 +1,115 @@ +'use strict'; +require('dotenv').config(); +const path = require('path'); +const express = require('express'); +const session = require('express-session'); +const SqliteStore = require('better-sqlite3-session-store')(session); + +const { db, init, migrate } = require('./db'); +const { seedDefaults } = require('./lib/templates'); +const { requireAuth, requireAdmin } = require('./middleware/auth'); + +init(); +migrate(); +seedDefaults(); + +const app = express(); +app.disable('x-powered-by'); +app.set('trust proxy', 1); + +app.use(express.json({ limit: '2mb' })); +app.use(express.urlencoded({ extended: true })); + +app.use(session({ + store: new SqliteStore({ + client: db, + expired: { clear: true, intervalMs: 15 * 60 * 1000 }, + }), + secret: process.env.SESSION_SECRET || 'change-me-in-production', + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + sameSite: 'lax', + secure: String(process.env.COOKIE_SECURE || 'false') === 'true', + maxAge: 1000 * 60 * 60 * 8, // 8h + }, +})); + +// ---- First-boot setup gate ------------------------------------------- +// While the instance has no users, every page redirects to the setup wizard +// so the first administrator can be created through the browser. +const setup = require('./routes/setup'); +app.use('/api/setup', setup.router); + +app.use((req, res, next) => { + const url = req.originalUrl || req.url; + // Always allow the setup API, the wizard page itself, and static assets + // (css/js) — otherwise the wizard could not render. + if (url.startsWith('/api/setup') || url.startsWith('/setup.html') || + url.startsWith('/css/') || url.startsWith('/js/') || url.startsWith('/assets/')) return next(); + + if (setup.needsSetup()) { + if (url.startsWith('/api/')) { + return res.status(503).json({ error: 'Setup required. Open the site in a browser to create the administrator account.' }); + } + return res.redirect('/setup.html'); + } + // Setup is done — the wizard must never be reachable again. + next(); +}); + +// ---- API routes ------------------------------------------------------- +app.use('/api/auth', require('./routes/auth')); +app.use('/api/manage', require('./routes/manage')); // admin only (enforced inside) +app.use('/api/users', require('./routes/users')); // admin only (enforced inside) +app.use('/api/versions', require('./routes/versions')); // read: all users, write: admin +app.use('/api/mail', require('./routes/mail')); // admin only (enforced inside) +app.use('/api/legislation', require('./routes/legislation')); // read: all users, write: admin +app.use('/api/storage', require('./routes/storage')); // admin only (enforced inside) +app.use('/api/data', require('./routes/data')); +app.use('/api/logs', require('./routes/logs')); // admin only (enforced inside) +app.use('/settings', require('./routes/settings')); // brand images served here + +// ---- Page gating ------------------------------------------------------ +// Serve the login page and static assets openly; gate the app pages. +const PUB = path.join(__dirname, 'public'); +app.get('/', (req, res) => res.redirect(req.session.user ? '/view-data.html' : '/login.html')); + +// The setup wizard is only reachable while no users exist. +app.get('/setup.html', (req, res) => { + if (!setup.needsSetup()) return res.redirect('/login.html'); + res.sendFile(path.join(PUB, 'setup.html')); +}); + +// Protect the admin-only HTML pages at the route level too (defence in depth) +app.get('/data-management.html', requireAuth, requireAdmin, (req, res) => + res.sendFile(path.join(PUB, 'data-management.html'))); +app.get('/storage.html', requireAuth, requireAdmin, (req, res) => + res.sendFile(path.join(PUB, 'storage.html'))); +app.get('/logs.html', requireAuth, requireAdmin, (req, res) => + res.sendFile(path.join(PUB, 'logs.html'))); +// Signed-in pages +app.get('/view-data.html', requireAuth, (req, res) => + res.sendFile(path.join(PUB, 'view-data.html'))); +app.get('/account.html', requireAuth, (req, res) => + res.sendFile(path.join(PUB, 'account.html'))); +app.get('/version-control.html', requireAuth, (req, res) => + res.sendFile(path.join(PUB, 'version-control.html'))); +app.get('/legislation.html', requireAuth, (req, res) => + res.sendFile(path.join(PUB, 'legislation.html'))); + +app.use(express.static(PUB)); // login.html, css, js, assets + +// ---- 404 / errors ----------------------------------------------------- +app.use((req, res) => res.status(404).send('Not found.')); +app.use((err, req, res, next) => { + console.error(err); + if (res.headersSent) return next(err); + res.status(500).json({ error: err.message || 'Server error' }); +}); + +const PORT = Number(process.env.PORT || 3000); +app.listen(PORT, () => { + console.log(`Martinhal ISDSS v1.5 Patch 0.2 running on http://localhost:${PORT}`); +}); diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..e69de29