v_1.5_patch_0.2

This commit is contained in:
jpmvaz
2026-09-13 20:12:28 +01:00
commit ab6a947493
62 changed files with 8674 additions and 0 deletions
+19
View File
@@ -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
+21
View File
@@ -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 <no-reply@yourdomain.com>
+7
View File
@@ -0,0 +1,7 @@
node_modules/
.env
data/*.db
data/*.db-*
uploads/*
brand/*
!**/.gitkeep
+59
View File
@@ -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"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.
+581
View File
@@ -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 <no-reply@yourdomain.com>
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 <no-reply@yourdomain.com>
```
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 `<img>` 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.
+193
View File
@@ -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 <your-unit>)
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`.
View File
View File
View File
+194
View File
@@ -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 };
+40
View File
@@ -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 <no-reply@example.com>}"
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:
+21
View File
@@ -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 "$@"
+27
View File
@@ -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);
+28
View File
@@ -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 };
+115
View File
@@ -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,
};
+224
View File
@@ -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 <no-reply@martinhal.local>'),
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(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/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,
};
+26
View File
@@ -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 };
+85
View File
@@ -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:
'<h2>Access request pending approval</h2>' +
'<p><strong>{{username}}</strong> ({{email}}) has requested access to ' +
'the {{target_type}} <strong>{{target_name}}</strong>.</p>' +
'<p>Requested at {{created_at}}. Request reference #{{request_id}}.</p>' +
'<p><strong>{{pending_count}}</strong> request(s) are currently waiting for a decision.</p>' +
'<p><a href="{{approvals_url}}" style="display:inline-block;background:#1f7a70;color:#fff;' +
'padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:600">' +
'Review pending requests</a></p>' +
'<p style="font-size:12px;color:#6b7280">If the button does not work, open: {{approvals_url}}</p>',
},
approval_to_user: {
subject: 'Your access request was approved',
body_html:
'<h2>Request approved</h2>' +
'<p>Hello {{username}},</p>' +
'<p>Your request to access the {{target_type}} <strong>{{target_name}}</strong> ' +
'has been <strong>approved</strong>. You may now open it from the View Data page.</p>' +
'<p><strong>{{validity}}</strong> — access expires on {{expires_at}}.</p>' +
'<p>A live countdown is shown next to the folder on the View Data page.</p>',
},
denial_to_user: {
subject: 'Your access request was declined',
body_html:
'<h2>Request declined</h2>' +
'<p>Hello {{username}},</p>' +
'<p>Unfortunately your request to access the {{target_type}} ' +
'<strong>{{target_name}}</strong> was not approved at this time.</p>',
},
};
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 =
'<hr style="margin-top:32px;border:none;border-top:1px solid #d8d8d8">' +
'<p style="color:#8a8a8a;font-size:12px;margin-top:12px">' +
'© 2026 Martinhal IT - Joao Vaz - Version 1.5 Patch 0.2</p>';
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),
};
+54
View File
@@ -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,
};
+25
View File
@@ -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 };
+2036
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+166
View File
@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Account · Martinhal ISDSS</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<script src="/js/app.js"></script>
<script>
(async () => {
const shell = await buildShell('account'); if (!shell) return;
const { me, content } = shell;
function render() {
content.innerHTML = `
<div class="page-head">
<h1>My Account</h1>
<p>Manage your sign-in security.</p>
</div>
<div class="card" style="max-width:640px">
<div class="card-head"><h2>Profile</h2></div>
<div class="card-body">
<div class="profile-row">
<div class="avatar-lg" id="avatarPreview">${avatarMarkup(me)}</div>
<div style="flex:1">
<p><strong>Username:</strong> ${esc(me.username)}</p>
<p><strong>Email:</strong> ${esc(me.email)}</p>
<p><strong>Role:</strong> <span class="badge badge-admin">${esc(me.role)}</span></p>
</div>
</div>
<div class="divider"></div>
<h3 style="margin:0 0 6px">Profile picture</h3>
<p class="hint" style="margin:0 0 12px">
Shown next to your name in the sidebar. PNG, JPEG, WebP or GIF, up to 4 MB.
</p>
<div class="row-actions" style="justify-content:flex-start">
<label class="btn btn-primary">
${me.avatar ? 'Change picture' : 'Upload picture'}
<input type="file" id="avatarFile" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
</label>
<button class="btn btn-danger" id="avatarRemove" ${me.avatar ? '' : 'disabled'}>Remove</button>
</div>
<div class="err-line" id="avatarErr"></div>
</div>
</div>
<div class="card" style="max-width:640px">
<div class="card-head">
<h2>Multi-factor authentication</h2>
<span class="badge ${me.mfa_enabled ? 'badge-approved' : 'badge-pending'}">${me.mfa_enabled ? 'Enabled' : 'Disabled'}</span>
</div>
<div class="card-body" id="mfaBody"></div>
</div>`;
renderMfa();
}
function renderMfa() {
const body = document.getElementById('mfaBody');
if (me.mfa_enabled) {
body.innerHTML = `
<p>Your account is protected with an authenticator app. You'll be asked for a code each time you sign in.</p>
<div class="field" style="max-width:320px">
<label>Confirm password to turn off MFA</label>
<input type="password" id="offpass">
</div>
<button class="btn btn-danger" id="disableBtn">Turn off MFA</button>`;
document.getElementById('disableBtn').onclick = async () => {
try {
await api('/api/auth/mfa/disable', { method: 'POST', body: { password: document.getElementById('offpass').value } });
me.mfa_enabled = false; toast('MFA disabled', 'ok'); render();
} catch (e) { toast(e.message, 'err'); }
};
} else {
body.innerHTML = `
<p>Add a second layer of security. You'll scan a QR code with an authenticator app (Google Authenticator, Authy, 1Password…) and enter a code to confirm.</p>
<button class="btn btn-primary" id="startBtn">${ICON.shield} Set up MFA</button>`;
document.getElementById('startBtn').onclick = startSetup;
}
}
async function startSetup() {
const r = await api('/api/auth/mfa/setup', { method: 'POST' });
modal('Set up multi-factor authentication', `
<div class="qr-box">
<img src="${r.qr}" alt="QR code">
<p class="hint">Scan with your authenticator app, or enter this key manually:</p>
<div class="secret">${esc(r.base32)}</div>
</div>
<div class="field" style="margin-top:20px">
<label>Enter the 6-digit code to confirm</label>
<input type="text" id="confirmCode" class="otp-input" inputmode="numeric" maxlength="6" placeholder="••••••">
</div>
<div class="err-line" id="mfaErr"></div>`, {
sticky: true,
buttons: [
{ label: 'Cancel' },
{ label: 'Enable MFA', className: 'btn-primary', onClick: async (back) => {
try {
await api('/api/auth/mfa/enable', { method: 'POST', body: { token: back.querySelector('#confirmCode').value.trim() } });
me.mfa_enabled = true; toast('MFA enabled', 'ok'); back.remove(); render();
} catch (e) { back.querySelector('#mfaErr').textContent = e.message; }
return false;
} },
],
});
}
// Picture, or the user's initials on a coloured disc as a fallback.
function avatarMarkup(u) {
if (u.avatar) return `<img src="${esc(u.avatar)}" alt="Your profile picture">`;
const initials = (u.username || '?').slice(0, 2).toUpperCase();
return `<span class="avatar-initials">${esc(initials)}</span>`;
}
function bindAvatar() {
const fileInput = document.getElementById('avatarFile');
const removeBtn = document.getElementById('avatarRemove');
const err = document.getElementById('avatarErr');
if (fileInput) fileInput.onchange = async () => {
err.textContent = '';
const file = fileInput.files && fileInput.files[0];
if (!file) return;
if (file.size > 4 * 1024 * 1024) { err.textContent = 'That image is larger than 4 MB.'; return; }
const fd = new FormData();
fd.append('avatar', file);
try {
const r = await api('/api/auth/avatar', { method: 'POST', body: fd });
me.avatar = r.avatar;
toast('Profile picture updated', 'ok');
render(); bindAvatar();
refreshSidebarAvatar();
} catch (e) { err.textContent = e.message; }
};
if (removeBtn) removeBtn.onclick = async () => {
err.textContent = '';
try {
await api('/api/auth/avatar', { method: 'DELETE' });
me.avatar = null;
toast('Profile picture removed', 'ok');
render(); bindAvatar();
refreshSidebarAvatar();
} catch (e) { err.textContent = e.message; }
};
}
// Update the sidebar avatar live, without a page reload.
function refreshSidebarAvatar() {
const holder = document.querySelector('.side-user .u-avatar');
if (!holder) return;
holder.innerHTML = me.avatar
? `<img src="${esc(me.avatar)}?t=${Date.now()}" alt="">`
: `<span class="avatar-initials">${esc((me.username || '?').slice(0, 2).toUpperCase())}</span>`;
}
const _origRender = render;
render = function () { _origRender(); bindAvatar(); };
render();
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

+484
View File
@@ -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; }
File diff suppressed because it is too large Load Diff
+234
View File
@@ -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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/></svg>',
caret: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>',
calendar: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></svg>',
legislation: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 4v16M5 8h14M7 8l-3 6a3 3 0 0 0 6 0L7 8zM17 8l-3 6a3 3 0 0 0 6 0l-3-6zM9 20h6"/></svg>',
version: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7M8.5 6H13a2.5 2.5 0 0 1 2.5 2.5v1M8.5 18H13a2.5 2.5 0 0 0 2.5-2.5v-1"/></svg>',
lock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>',
view: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>',
manage: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h5l2 2h11v9a2 2 0 0 1-2 2H3Z"/><path d="M3 7V5a2 2 0 0 1 2-2h4l2 2"/></svg>',
logs: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4Z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg>',
account: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>',
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h6l2 2h10v9a2 2 0 0 1-2 2H3Z"/></svg>',
file: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h8l4 4v16H6Z"/><path d="M14 2v4h4"/></svg>',
shield: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l7 3v6c0 5-3.5 8-7 9-3.5-1-7-4-7-9V6Z"/></svg>',
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6 9 17l-5-5"/></svg>',
mail: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>',
download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0 4-4m-4 4-4-4"/><path d="M4 21h16"/></svg>',
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>',
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>',
edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20h4L18 10l-4-4L4 16Z"/><path d="m14 6 4 4"/></svg>',
move: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 9 2 12l3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20"/></svg>',
upload: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 15V3m0 0 4 4m-4-4L8 7"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>',
};
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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = `<div class="modal ${opts.lg ? 'lg' : ''}">
<div class="modal-head">${esc(title)}</div>
<div class="modal-body">${bodyHtml}</div>
<div class="modal-foot"></div></div>`;
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) => `
<div class="field">
<label>${esc(f.label)}</label>
${f.type === 'select'
? `<select data-k="${f.key}">${f.options.map((o) => `<option value="${esc(o.value)}" ${o.value == f.value ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select>`
: `<input type="text" data-k="${f.key}" value="${esc(f.value || '')}" placeholder="${esc(f.placeholder || '')}">`}
</div>`).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) =>
`<a href="${href}" class="${active === key ? 'active' : ''}">${ICON[icon]}<span>${label}</span></a>`;
const shell = document.createElement('div');
shell.className = 'app';
shell.innerHTML = `
<aside class="sidebar">
<div class="brand">
<a class="brand-logo" href="/view-data.html" aria-label="ISDSS home">
<img src="/assets/isdss-logo.png" alt="ISDSS — Internal Surveillance Data Storage System">
</a>
<div class="mark fallback-mark" hidden><span class="logo">M</span> ISDSS</div>
<div class="tag">Martinhal IT · secure portal</div>
</div>
<nav class="nav">
${link('/view-data.html', 'view', 'View Data', 'view')}
${link('/version-control.html', 'version', 'Version Control', 'version')}
${link('/legislation.html', 'legislation', 'Legislation', 'legislation')}
${link('/account.html', 'account', 'My Account', 'account')}
${isAdmin ? `<div class="section-label">Administration</div>
${link('/data-management.html', 'manage', 'Data Management', 'manage')}
${link('/storage.html', 'storage', 'Storage', 'storage')}
${link('/logs.html', 'logs', 'Logs', 'logs')}` : ''}
</nav>
<div class="side-user">
<div class="u-identity">
<span class="u-avatar">${me.avatar
? `<img src="${esc(me.avatar)}" alt="">`
: `<span class="avatar-initials">${esc((me.username || '?').slice(0, 2).toUpperCase())}</span>`}</span>
<span class="u-text">
<span class="u-name">${esc(me.username)}</span>
<span class="u-role">${esc(me.role)}${me.mfa_enabled ? ' · MFA on' : ''}</span>
</span>
</div>
<button id="logoutBtn">Sign out</button>
</div>
</aside>
<div class="main">
<div class="content" id="content"></div>
<div class="footer">${FOOTER_TEXT}</div>
</div>`;
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') };
}
+55
View File
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Legislation · Martinhal ISDSS</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<script src="/js/app.js"></script>
<script>
(async () => {
const shell = await buildShell('legislation'); if (!shell) return;
const { isAdmin, content } = shell;
function entryHtml(e) {
return `
<div class="ver-item">
<div class="ver-head">
${e.reference ? `<span class="ver-tag">${esc(e.reference)}</span>` : ''}
<span class="ver-title">${esc(e.title)}</span>
${e.effective_date ? `<span class="ver-date">In force: ${esc(fmtDate(e.effective_date))}</span>` : ''}
</div>
${e.summary ? `<div class="ver-notes">${esc(e.summary)}</div>` : ''}
${e.link_url ? `<div style="margin-top:10px">
<a class="btn btn-sm" href="${esc(e.link_url)}" target="_blank" rel="noopener noreferrer">
Read the full text</a></div>` : ''}
</div>`;
}
async function load() {
let entries = [];
try { entries = (await api('/api/legislation')).entries; }
catch (e) { toast(e.message, 'err'); }
content.innerHTML = `
<div class="page-head">
<h1>Legislation</h1>
<p>Laws, regulations and internal rules governing the handling of this data.${
isAdmin ? ' Entries are managed in Data Management → Legislation.' : ''}</p>
</div>
<div class="card">
<div class="card-body">
${entries.length
? `<div class="ver-list">${entries.map(entryHtml).join('')}</div>`
: `<div class="empty">${ICON.legislation}<div>No legislation has been published yet.</div></div>`}
</div>
</div>`;
}
load();
})();
</script>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · Martinhal ISDSS</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="auth-wrap">
<div class="auth-hero">
<div>
<div class="logo-lg">M</div>
<h1>Martinhal ISDSS</h1>
</div>
</div>
<div class="auth-panel">
<div class="auth-form">
<!-- Step 1: credentials -->
<div id="step-cred">
<h2>Sign in</h2>
<p class="sub">Use your ISDSS account to continue.</p>
<div class="field">
<label>Username or email</label>
<input type="text" id="username" autocomplete="username" autofocus>
</div>
<div class="field">
<label>Password</label>
<input type="password" id="password" autocomplete="current-password">
</div>
<button class="btn btn-primary" id="loginBtn">Sign in</button>
<div class="err-line" id="err"></div>
</div>
<!-- Step 2: MFA -->
<div id="step-mfa" style="display:none">
<h2>Two-factor code</h2>
<p class="sub">Enter the 6-digit code from your authenticator app.</p>
<div class="field">
<input type="text" id="otp" class="otp-input" inputmode="numeric" maxlength="6" placeholder="••••••">
</div>
<button class="btn btn-primary" id="verifyBtn">Verify &amp; sign in</button>
<div class="err-line" id="err2"></div>
<p class="hint" style="margin-top:14px"><a href="#" id="backLink">← Use a different account</a></p>
</div>
</div>
</div>
</div>
<script src="/js/app.js"></script>
<script>
const $ = (id) => document.getElementById(id);
let creds = {};
async function doLogin(token) {
$('err').textContent = ''; $('err2').textContent = '';
try {
const body = { username: creds.username, password: creds.password };
if (token) body.token = token;
const r = await api('/api/auth/login', { method: 'POST', body });
if (r.mfa_required) {
$('step-cred').style.display = 'none';
$('step-mfa').style.display = 'block';
$('otp').focus();
return;
}
location.href = '/view-data.html';
} catch (e) {
(token ? $('err2') : $('err')).textContent = e.message;
}
}
$('loginBtn').onclick = () => {
creds = { username: $('username').value.trim(), password: $('password').value };
if (!creds.username || !creds.password) { $('err').textContent = 'Enter your username and password.'; return; }
doLogin();
};
$('password').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('loginBtn').click(); });
$('verifyBtn').onclick = () => doLogin($('otp').value.trim());
$('otp').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('verifyBtn').click(); });
$('backLink').onclick = (e) => { e.preventDefault(); location.reload(); };
</script>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Logs · Martinhal ISDSS</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<script src="/js/app.js"></script>
<script>
(async () => {
const shell = await buildShell('logs'); if (!shell) return;
const { content } = shell;
let meta = { actions: [], pages: [] };
content.innerHTML = `
<div class="page-head">
<h1>Logs</h1>
<p>Every action on the View Data and Data Management pages is recorded here.</p>
</div>
<div class="card">
<div class="card-head">
<h2>Audit trail</h2>
<div class="toolbar" style="margin:0">
<button class="btn" id="exportBtn">${ICON.download} Export CSV</button>
<button class="btn btn-primary" id="emailBtn">${ICON.mail} Email logs</button>
</div>
</div>
<div class="card-body">
<div class="toolbar">
<input type="search" id="q" placeholder="Search actor or detail…" style="max-width:240px">
<select id="action" style="max-width:200px"><option value="">All actions</option></select>
<select id="page" style="max-width:200px"><option value="">All pages</option></select>
<button class="btn" id="applyBtn">Filter</button>
<span class="spacer"></span>
<span class="hint" id="count"></span>
</div>
<div id="table"></div>
</div>
</div>`;
const $ = (id) => document.getElementById(id);
function params() {
const p = new URLSearchParams();
if ($('q').value.trim()) p.set('q', $('q').value.trim());
if ($('action').value) p.set('action', $('action').value);
if ($('page').value) p.set('page', $('page').value);
return p;
}
async function load() {
const data = await api('/api/logs?' + params().toString());
meta = data;
// populate selects once
if ($('action').options.length <= 1) {
$('action').innerHTML = '<option value="">All actions</option>' +
data.actions.map((a) => `<option value="${esc(a)}">${esc(a)}</option>`).join('');
$('page').innerHTML = '<option value="">All pages</option>' +
data.pages.map((p) => `<option value="${esc(p)}">${esc(p)}</option>`).join('');
}
$('count').textContent = `${data.logs.length} of ${data.total} entries`;
$('table').innerHTML = data.logs.length ? `
<table><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Page</th><th>Detail</th><th>IP</th></tr></thead>
<tbody>${data.logs.map((l) => `<tr>
<td class="mono">${esc(fmtDateTime(l.ts))}</td>
<td>${esc(l.actor)}</td>
<td><span class="badge badge-admin">${esc(l.action)}</span></td>
<td>${esc(l.page || '—')}</td>
<td>${esc(l.detail || '')}</td>
<td class="mono">${esc(l.ip || '')}</td>
</tr>`).join('')}</tbody></table>` :
`<div class="empty">${ICON.logs}<div>No log entries match your filter.</div></div>`;
}
$('applyBtn').onclick = load;
$('q').addEventListener('keydown', (e) => { if (e.key === 'Enter') load(); });
$('action').onchange = load;
$('page').onchange = load;
$('exportBtn').onclick = () => { location.href = '/api/logs/export?' + params().toString(); };
$('emailBtn').onclick = () => {
modal('Email audit logs', `
<p>Send the currently filtered logs as a CSV attachment.</p>
<div class="field">
<label>Recipient (leave blank to send to all admins)</label>
<input type="email" id="to" placeholder="security@yourdomain.com">
</div>`, {
buttons: [{ label: 'Cancel' }, { label: 'Send', className: 'btn-primary', onClick: async (back) => {
const body = Object.fromEntries(params());
const to = back.querySelector('#to').value.trim();
if (to) body.to = to;
try {
const r = await api('/api/logs/email', { method: 'POST', body });
back.remove();
toast(r.delivered ? `Sent ${r.count} entries` : `Queued ${r.count} entries (SMTP not configured)`, 'ok');
} catch (e) { toast(e.message, 'err'); }
return false;
} }],
});
};
load();
})();
</script>
</body>
</html>
+132
View File
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>First-time setup · Martinhal ISDSS</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="auth-wrap">
<div class="auth-hero">
<div>
<div class="logo-lg">M</div>
<h1>Welcome to ISDSS</h1>
<p>Let's create the administrator account for this installation. This only happens once.</p>
<div class="feat">
<div>%SHIELD% This account has full administrative rights</div>
<div>%CHECK% You can add two-factor authentication right after</div>
<div>%LOGS% Everything from here on is recorded in the audit log</div>
</div>
</div>
<div class="foot">© 2026 Martinhal IT - Joao Vaz - Version 1.5 Patch 0.2</div>
</div>
<div class="auth-panel">
<div class="auth-form">
<div id="step-form">
<div class="setup-badge">Step 1 of 1 · First-time setup</div>
<h2>Create administrator</h2>
<p class="sub">These are the credentials you will use to sign in from now on.</p>
<div class="field">
<label>Username</label>
<input type="text" id="username" autocomplete="username" autofocus placeholder="admin">
<span class="hint">332 characters. Letters, numbers, dot, underscore or hyphen.</span>
</div>
<div class="field">
<label>Email address <span class="req-star">*</span></label>
<input type="email" id="email" required autocomplete="email" placeholder="admin@yourdomain.com">
<span class="hint">Required. Approval requests and system notifications are sent here.</span>
</div>
<div class="field">
<label>Password</label>
<input type="password" id="password" autocomplete="new-password">
<div class="pw-meter"><span id="pwBar"></span></div>
<span class="hint" id="pwHint">At least 10 characters, including a letter and a number.</span>
</div>
<div class="field">
<label>Confirm password</label>
<input type="password" id="confirm" autocomplete="new-password">
</div>
<button class="btn btn-primary" id="createBtn">Create account &amp; continue</button>
<div class="err-line" id="err"></div>
</div>
<div id="step-done" style="display:none">
<h2>All set</h2>
<p class="sub">Your administrator account has been created and you are now signed in.</p>
<p class="hint">Taking you to ISDSS…</p>
</div>
</div>
</div>
</div>
<script src="/js/app.js"></script>
<script>
document.querySelector('.auth-hero').innerHTML =
document.querySelector('.auth-hero').innerHTML
.replace('%SHIELD%', ICON.shield).replace('%CHECK%', ICON.check).replace('%LOGS%', ICON.logs);
const $ = (id) => document.getElementById(id);
// If setup was already completed (e.g. someone bookmarked this page), leave.
(async () => {
try {
const s = await api('/api/setup/status');
if (!s.needs_setup) location.href = '/login.html';
} catch (_) { /* ignore — the server will reject the POST anyway */ }
})();
// Lightweight strength indicator (guidance only; the server enforces the rules).
function strength(p) {
let s = 0;
if (p.length >= 10) s++;
if (p.length >= 14) s++;
if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++;
if (/[0-9]/.test(p)) s++;
if (/[^A-Za-z0-9]/.test(p)) s++;
return Math.min(s, 4);
}
$('password').addEventListener('input', () => {
const s = strength($('password').value);
const bar = $('pwBar');
bar.className = 'lvl-' + s;
bar.style.width = (s * 25) + '%';
});
async function create() {
$('err').textContent = '';
const payload = {
username: $('username').value.trim(),
email: $('email').value.trim(),
password: $('password').value,
confirm: $('confirm').value,
};
if (!payload.email) { $('err').textContent = 'An email address is required.'; return; }
if (!payload.username || !payload.password) {
$('err').textContent = 'Please fill in every field.'; return;
}
if (payload.password !== payload.confirm) {
$('err').textContent = 'The two passwords do not match.'; return;
}
$('createBtn').disabled = true;
$('createBtn').textContent = 'Creating…';
try {
await api('/api/setup', { method: 'POST', body: payload });
$('step-form').style.display = 'none';
$('step-done').style.display = 'block';
setTimeout(() => { location.href = '/view-data.html'; }, 1200);
} catch (e) {
$('err').textContent = e.message;
$('createBtn').disabled = false;
$('createBtn').textContent = 'Create account & continue';
}
}
$('createBtn').onclick = create;
$('confirm').addEventListener('keydown', (e) => { if (e.key === 'Enter') create(); });
</script>
</body>
</html>
+196
View File
@@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Storage · Martinhal ISDSS</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<script src="/js/app.js"></script>
<script>
(async () => {
const shell = await buildShell('storage'); if (!shell) return;
const { content } = shell;
let data = null;
let sortBy = 'total'; // 'total' | 'name'
function pct(part, whole) {
if (!whole) return 0;
return Math.round((part / whole) * 1000) / 10;
}
function barClass(p) {
if (p >= 90) return 'bar-crit';
if (p >= 75) return 'bar-warn';
return 'bar-ok';
}
function diskCard() {
const d = data.disk;
const p = d.percent_used;
return `
<div class="card">
<div class="card-head"><h3>Server disk</h3>
<span class="hint">Filesystem holding the uploads directory</span></div>
<div class="card-body">
<div class="usage-bar"><span class="${barClass(p)}" style="width:${Math.min(p, 100)}%"></span></div>
<div class="usage-legend">
<span><strong>${fmtBytes(d.used)}</strong> used</span>
<span><strong>${fmtBytes(d.free)}</strong> free</span>
<span><strong>${fmtBytes(d.total)}</strong> total</span>
<span class="usage-pct ${p >= 90 ? 'crit' : (p >= 75 ? 'warn' : '')}">${p}% used</span>
</div>
${p >= 90 ? `<div class="warn-banner" style="margin-top:14px">
<strong>The disk is nearly full.</strong> Uploads will start failing when it runs out.
</div>` : ''}
</div>
</div>`;
}
function appCard() {
const a = data.app;
const rows = [
['Uploaded files', a.uploads_bytes, `${a.uploads_files} file${a.uploads_files === 1 ? '' : 's'}`, data.paths.uploads],
['Brand images', a.brand_bytes, `${a.brand_files} file${a.brand_files === 1 ? '' : 's'}`, data.paths.brand],
['Database', a.database_bytes, 'accounts, folders, logs', data.paths.database],
].map(([label, bytes, note, where]) => `
<tr>
<td><strong>${esc(label)}</strong><div class="hint mono">${esc(where)}</div></td>
<td class="mono">${fmtBytes(bytes)}</td>
<td class="hint">${esc(note)}</td>
<td style="width:34%">
<div class="usage-bar sm"><span class="bar-ok" style="width:${pct(bytes, a.total_bytes)}%"></span></div>
</td>
</tr>`).join('');
return `
<div class="card">
<div class="card-head"><h3>Used by ISDSS</h3>
<span class="hint">${fmtBytes(data.app.total_bytes)} in total</span></div>
<div class="card-body">
<table><tbody>${rows}</tbody></table>
</div>
</div>`;
}
function foldersCard() {
const folders = [...data.folders];
if (sortBy === 'total') folders.sort((a, b) => b.total_bytes - a.total_bytes || a.path.localeCompare(b.path));
else folders.sort((a, b) => a.path.localeCompare(b.path));
const biggest = folders.reduce((m, f) => Math.max(m, f.total_bytes), 0);
const rows = folders.map((f) => `
<tr>
<td><strong>${esc(f.name)}</strong>${f.depth
? `<div class="hint">${esc(f.path)}</div>` : ''}</td>
<td class="mono">${fmtBytes(f.total_bytes)}</td>
<td class="mono hint">${fmtBytes(f.own_bytes)}</td>
<td class="hint">${f.total_files} file${f.total_files === 1 ? '' : 's'}</td>
<td style="width:30%">
<div class="usage-bar sm"><span class="bar-ok" style="width:${pct(f.total_bytes, biggest)}%"></span></div>
</td>
</tr>`).join('');
const rootRow = data.root_files.count ? `
<tr>
<td><strong>(files outside any folder)</strong></td>
<td class="mono">${fmtBytes(data.root_files.bytes)}</td>
<td class="mono hint">${fmtBytes(data.root_files.bytes)}</td>
<td class="hint">${data.root_files.count} file${data.root_files.count === 1 ? '' : 's'}</td>
<td><div class="usage-bar sm"><span class="bar-ok" style="width:${pct(data.root_files.bytes, biggest)}%"></span></div></td>
</tr>` : '';
return `
<div class="card">
<div class="card-head"><h3>Space used per folder</h3>
<div class="row-actions" style="justify-content:flex-end">
<button class="btn btn-sm ${sortBy === 'total' ? 'btn-primary' : ''}" id="sortSize">Largest first</button>
<button class="btn btn-sm ${sortBy === 'name' ? 'btn-primary' : ''}" id="sortName">By name</button>
</div>
</div>
<div class="card-body">
<p class="hint" style="margin:0 0 12px">
<strong>Total</strong> includes everything nested inside the folder;
<strong>own</strong> counts only files sitting directly in it.
</p>
<table>
<thead><tr><th>Folder</th><th>Total</th><th>Own</th><th>Files</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="5" class="muted">No folders yet.</td></tr>'}${rootRow}</tbody>
</table>
</div>
</div>`;
}
function integrityCard() {
const i = data.integrity;
if (!i.missing.length && !i.orphans.length) {
return `<div class="card"><div class="card-head"><h3>Consistency</h3></div>
<div class="card-body"><p class="hint" style="margin:0">
Every recorded file is present on disk, and nothing on disk is unaccounted for.
</p></div></div>`;
}
return `
<div class="card">
<div class="card-head"><h3>Consistency</h3></div>
<div class="card-body">
${i.missing.length ? `
<div class="warn-banner">
<strong>${i.missing.length} recorded file(s) are missing from disk.</strong>
They will appear in listings but cannot be downloaded:
${esc(i.missing.slice(0, 5).map((m) => m.name).join(', '))}${i.missing.length > 5 ? '…' : ''}
</div>` : ''}
${i.orphans.length ? `
<div class="warn-banner">
<strong>${i.orphans.length} file(s) on disk are not referenced by any record</strong>
(${fmtBytes(i.orphan_bytes)}). These are safe to remove.
<button class="btn btn-sm" id="cleanOrphans" style="margin-left:8px">Reclaim space</button>
</div>` : ''}
</div>
</div>`;
}
function render() {
content.innerHTML = `
<div class="page-head">
<h1>Storage</h1>
<p>Disk space on this server and how much of it each folder is using.</p>
</div>
${diskCard()}
${appCard()}
${foldersCard()}
${integrityCard()}
<p class="hint" style="margin-top:14px">
Measured ${esc(fmtDateTime(data.generated_at))} ·
<a href="#" id="refresh">Refresh</a>
</p>`;
document.getElementById('sortSize').onclick = () => { sortBy = 'total'; render(); };
document.getElementById('sortName').onclick = () => { sortBy = 'name'; render(); };
document.getElementById('refresh').onclick = (e) => { e.preventDefault(); load(); };
const clean = document.getElementById('cleanOrphans');
if (clean) clean.onclick = async () => {
if (!confirm('Permanently delete files on disk that no record points at?')) return;
try {
const r = await api('/api/storage/cleanup-orphans', { method: 'POST' });
toast(`Reclaimed ${fmtBytes(r.bytes)} from ${r.removed} file(s)`, 'ok');
load();
} catch (err) { toast(err.message, 'err'); }
};
}
async function load() {
try {
data = await api('/api/storage');
render();
} catch (e) {
content.innerHTML = `<div class="page-head"><h1>Storage</h1></div>
<div class="card"><div class="card-body"><p>${esc(e.message)}</p></div></div>`;
}
}
load();
})();
</script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Version Control for ISDSS · Martinhal ISDSS</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<script src="/js/app.js"></script>
<script>
(async () => {
const shell = await buildShell('version'); if (!shell) return;
const { isAdmin, content } = shell;
function entryHtml(e) {
return `
<div class="ver-item">
<div class="ver-head">
<span class="ver-tag">${esc(e.version)}</span>
${e.title ? `<span class="ver-title">${esc(e.title)}</span>` : ''}
${e.released_on ? `<span class="ver-date">${esc(fmtDate(e.released_on))}</span>` : ''}
</div>
${e.notes ? `<div class="ver-notes">${esc(e.notes)}</div>` : ''}
</div>`;
}
async function load() {
let entries = [];
try { entries = (await api('/api/versions')).entries; }
catch (e) { toast(e.message, 'err'); }
content.innerHTML = `
<div class="page-head">
<h1>Version Control for ISDSS - Internal Surveillance Data Storage System</h1>
<p>Release history for this system.${isAdmin ? ' Entries are managed in Data Management → Version Control.' : ''}</p>
</div>
<div class="card">
<div class="card-body">
${entries.length
? `<div class="ver-list">${entries.map(entryHtml).join('')}</div>`
: `<div class="empty">${ICON.version}<div>No version entries have been published yet.</div></div>`}
</div>
</div>`;
}
load();
})();
</script>
</body>
</html>
+213
View File
@@ -0,0 +1,213 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>View Data · Martinhal ISDSS</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<script src="/js/app.js"></script>
<script>
(async () => {
const shell = await buildShell('view'); if (!shell) return;
const { isAdmin, content } = shell;
let state = { folders: [], files: [], requestState: {} };
async function load() {
state = await api('/api/data/tree');
setClockOffset(state.server_time);
render();
startTimers();
}
function children(parentId) {
return {
folders: state.folders.filter((f) => (f.parent_id || null) === parentId),
files: state.files.filter((f) => (f.folder_id || null) === parentId),
};
}
function reqBadge(type, id) {
const s = state.requestState[`${type}:${id}`];
if (s === 'approved') return `<span class="badge badge-approved">Approved</span>`;
if (s === 'pending') return `<span class="badge badge-pending">Pending</span>`;
if (s === 'denied') return `<span class="badge badge-denied">Declined</span>`;
return '';
}
// Files inside a folder the user can open need no request of their own —
// access to the folder implies access to its contents.
function fileActions(f) {
if (f.accessible) {
return `<a class="btn btn-sm btn-primary" href="/api/data/download/file/${f.id}">${ICON.download} Download</a>`;
}
const s = state.requestState[`file:${f.id}`];
if (s === 'pending') return `<button class="btn btn-sm" disabled>Requested</button>`;
return `<button class="btn btn-sm" data-req="file" data-id="${f.id}">${ICON.mail} Request access</button>`;
}
function folderActions(f) {
if (f.accessible) return isAdmin ? '' : `<span class="badge badge-approved">Access granted</span>`;
const s = state.requestState[`folder:${f.id}`];
if (s === 'pending') return `<button class="btn btn-sm" disabled>Requested</button>`;
return `<button class="btn btn-sm" data-req="folder" data-id="${f.id}">${ICON.mail} Request access</button>`;
}
// Both dates are shown for every folder, whether or not access is granted.
// "Legal Validity" is the recorded date plus 30 days, calculated server-side.
function folderDates(fo) {
const rec = fo.recorded_date
? `<span class="date-value">${esc(fmtDate(fo.recorded_date))}</span>`
: '<span class="date-value muted-value">not set</span>';
let val;
if (!fo.legal_validity) {
val = '<span class="date-value muted-value">not set</span>';
} else {
const expired = fo.legal_validity < today();
val = `<span class="date-value${expired ? ' date-expired' : ''}">${esc(fmtDate(fo.legal_validity))}${
expired ? ' (expired)' : ''}</span>`;
}
return `<div class="folder-dates">
<span class="date-item"><span class="date-label">Recorded Date</span>${rec}</span>
<span class="date-item"><span class="date-label">Legal Validity</span>${val}</span>
<span class="date-item"><span class="date-label">Access</span>${accessCell(fo)}</span>
</div>`;
}
function today() { return new Date().toISOString().slice(0, 10); }
// ---- Access countdown -------------------------------------------------
// The span carries the expiry as a data attribute; a single ticking timer
// updates every one of them once a second.
function accessCell(fo) {
const a = fo.access || { state: 'denied' };
if (a.state === 'admin') {
return '<span class="access-timer access-perm">Full access (administrator)</span>';
}
if (a.state === 'permanent') {
return '<span class="access-timer access-perm">Access granted — no expiry</span>';
}
if (a.state === 'timed') {
return `<span class="access-timer access-live" data-expires="${esc(a.expires_at)}">…</span>`;
}
return '<span class="access-timer access-denied">Access Denied</span>';
}
// Difference between server time and this browser's clock, so the countdown
// stays honest even if the local clock is wrong.
let clockOffset = 0;
function setClockOffset(serverTime) {
if (!serverTime) return;
const server = Date.parse(String(serverTime).replace(' ', 'T') + 'Z');
if (!Number.isNaN(server)) clockOffset = server - Date.now();
}
function remainingText(expiresAt) {
const end = Date.parse(String(expiresAt).replace(' ', 'T') + 'Z');
if (Number.isNaN(end)) return '—';
let ms = end - (Date.now() + clockOffset);
if (ms <= 0) return 'expired';
const s = Math.floor(ms / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n) => String(n).padStart(2, '0');
if (d > 0) return `${d}d ${pad(h)}h ${pad(m)}m ${pad(sec)}s`;
return `${pad(h)}h ${pad(m)}m ${pad(sec)}s`;
}
let tickHandle = null;
function tickTimers() {
const nodes = document.querySelectorAll('.access-live[data-expires]');
let anyExpired = false;
nodes.forEach((el) => {
const txt = remainingText(el.dataset.expires);
el.textContent = txt === 'expired' ? 'Access Denied' : txt;
if (txt === 'expired') {
el.classList.remove('access-live');
el.classList.add('access-denied');
anyExpired = true;
} else {
// warn when under an hour remains
el.classList.toggle('access-soon', (Date.parse(String(el.dataset.expires).replace(' ', 'T') + 'Z')
- (Date.now() + clockOffset)) < 3600000);
}
});
// Once something lapses, refresh so the folder's contents disappear too.
if (anyExpired) load();
}
function startTimers() {
if (tickHandle) clearInterval(tickHandle);
tickTimers();
tickHandle = setInterval(tickTimers, 1000);
}
function renderNode(parentId) {
const { folders, files } = children(parentId);
if (!folders.length && !files.length) return '';
let html = '<ul class="tree">';
for (const fo of folders) {
// A locked folder shows its name only: no contents, no counts.
const locked = !fo.accessible;
html += `<li>
<div class="row${locked ? ' locked' : ''}">
<span class="ic folder">${locked ? ICON.lock : ICON.folder}</span>
<span class="name">${esc(fo.name)}</span> ${reqBadge('folder', fo.id)}
${locked ? '<span class="locked-note">Access required</span>' : ''}
<span class="spacer"></span>
<span class="actions" style="opacity:1">${folderActions(fo)}</span>
</div>
${folderDates(fo)}
${locked ? '' : renderNode(fo.id)}
</li>`;
}
for (const fi of files) {
html += `<li>
<div class="row">
<span class="ic">${ICON.file}</span>
<span class="name">${esc(fi.name)}</span>
<span class="meta">${fmtBytes(fi.size)}</span> ${fi.accessible ? '' : reqBadge('file', fi.id)}
<span class="spacer"></span>
<span class="actions" style="opacity:1">${fileActions(fi)}</span>
</div>
</li>`;
}
return html + '</ul>';
}
function render() {
const tree = renderNode(null);
content.innerHTML = `
<div class="page-head">
<h1>View Data</h1>
<p>${isAdmin
? 'Everything published in Data Management, as your users will see it.'
: 'Request access to a folder to see what it contains. Once approved, everything inside is available to you.'}</p>
</div>
<div class="card">
<div class="card-head"><h2>Available data</h2></div>
<div class="card-body">
${tree || `<div class="empty">${ICON.folder}<div>No data has been published yet.</div></div>`}
</div>
</div>`;
content.querySelectorAll('[data-req]').forEach((btn) => {
btn.onclick = async () => {
btn.disabled = true;
try {
await api('/api/data/request', { method: 'POST', body: { target_type: btn.dataset.req, target_id: Number(btn.dataset.id) } });
toast('Request sent to administrators', 'ok');
await load();
} catch (e) { toast(e.message, 'err'); btn.disabled = false; }
};
});
}
load();
})();
</script>
</body>
</html>
+178
View File
@@ -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;
+335
View File
@@ -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;
+81
View File
@@ -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;
+77
View File
@@ -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 =
`<h2>Martinhal ISDSS — audit log export</h2>` +
`<p>${rows.length} log entries are attached as CSV.</p>` +
`<hr style="border:none;border-top:1px solid #d8d8d8">` +
`<p style="color:#8a8a8a;font-size:12px">© 2026 Martinhal IT - Joao Vaz - Version 1.5 Patch 0.2</p>`;
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;
+212
View File
@@ -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: '<p>This is a test message from Martinhal ISDSS. If you received it, your mail server settings are working.</p>',
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 =
`<p>Attached is the Martinhal ISDSS mail log (${rows.length} entries).</p>` +
`<p style="color:#8a8a8a;font-size:12px">© 2026 Martinhal IT - Joao Vaz - Version 1.5 Patch 0.2</p>`;
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;
+170
View File
@@ -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;
+125
View File
@@ -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 =
'<hr style="margin-top:32px;border:none;border-top:1px solid #d8d8d8">' +
'<p style="color:#8a8a8a;font-size:12px;margin-top:12px">© 2026 Martinhal IT - Joao Vaz - Version 1.5 Patch 0.2</p>';
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;
+78
View File
@@ -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 };
+229
View File
@@ -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;
+157
View File
@@ -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;
+77
View File
@@ -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;
+115
View File
@@ -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}`);
});
View File