From 70fe6076a4bfb73d4f0abad7efa9e68b6b1ece14 Mon Sep 17 00:00:00 2001 From: Ludwig Lehnert Date: Tue, 3 Feb 2026 16:39:37 +0100 Subject: [PATCH] initial commit --- .env.example | 20 ++ .gitignore | 7 + README.md | 166 +++++++++++ backupd/Dockerfile | 17 ++ backupd/backup.sh | 53 ++++ backupd/entrypoint.sh | 10 + docker-compose.yml | 96 +++++++ samba/Dockerfile | 19 ++ samba/entrypoint.sh | 57 ++++ samba/samba-private-mkdir.sh | 24 ++ samba/smb.conf.template | 47 ++++ samba/watch-reload.sh | 9 + scripts/generate-self-signed.sh | 15 + traefik/dynamic.yml | 4 + traefik/traefik.yml | 17 ++ webui/Dockerfile | 22 ++ webui/migrations/001_init.sql | 31 ++ webui/migrations/002_logs.sql | 15 + webui/package.json | 18 ++ webui/public/styles.css | 270 ++++++++++++++++++ webui/src/auth.js | 111 ++++++++ webui/src/db.js | 42 +++ webui/src/index.js | 481 ++++++++++++++++++++++++++++++++ webui/src/shares.js | 286 +++++++++++++++++++ webui/views/admin.ejs | 88 ++++++ webui/views/index.ejs | 47 ++++ webui/views/login.ejs | 7 + webui/views/partials/footer.ejs | 3 + webui/views/partials/header.ejs | 29 ++ webui/views/share.ejs | 117 ++++++++ 30 files changed, 2128 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backupd/Dockerfile create mode 100644 backupd/backup.sh create mode 100644 backupd/entrypoint.sh create mode 100644 docker-compose.yml create mode 100644 samba/Dockerfile create mode 100644 samba/entrypoint.sh create mode 100644 samba/samba-private-mkdir.sh create mode 100644 samba/smb.conf.template create mode 100644 samba/watch-reload.sh create mode 100644 scripts/generate-self-signed.sh create mode 100644 traefik/dynamic.yml create mode 100644 traefik/traefik.yml create mode 100644 webui/Dockerfile create mode 100644 webui/migrations/001_init.sql create mode 100644 webui/migrations/002_logs.sql create mode 100644 webui/package.json create mode 100644 webui/public/styles.css create mode 100644 webui/src/auth.js create mode 100644 webui/src/db.js create mode 100644 webui/src/index.js create mode 100644 webui/src/shares.js create mode 100644 webui/views/admin.ejs create mode 100644 webui/views/index.ejs create mode 100644 webui/views/login.ejs create mode 100644 webui/views/partials/footer.ejs create mode 100644 webui/views/partials/header.ejs create mode 100644 webui/views/share.ejs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c316d58 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +DOMAIN_REALM=CORP.EXAMPLE.COM +DOMAIN_WORKGROUP=CORP +DOMAIN_JOIN_USER=svc_samba_join +DOMAIN_JOIN_PASSWORD=change-me +SAMBA_NETBIOS_NAME=FILESHARE01 + +ENTRA_TENANT_ID=00000000-0000-0000-0000-000000000000 +ENTRA_CLIENT_ID=00000000-0000-0000-0000-000000000000 +ENTRA_CLIENT_SECRET=change-me +ENTRA_REDIRECT_URI=https://files.example.com/auth/callback +ALLOWED_UPN_SUFFIX=@example.com + +WEBUI_SESSION_SECRET=change-me +ADMIN_UPN_BCRYPT=$2a$10$Dq4s8cP5rQx9wq2g2CSFeu3R2iw4pD4kV5g3sO7GydM83B8Yx0t3m + +TRAEFIK_HOSTNAME=files.example.com + +BACKUP_CRON=0 2 * * * +BACKUP_REMOTE_PATH=/remote +BACKUP_COMPRESSION=zstd diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4b033d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +node_modules/ +webui/node_modules/ +traefik/certs/ +*.log +*.sqlite +*.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8238d2 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# AAD Local File Share (Samba + Web UI) + +Dockerized SMB/CIFS file sharing system for a single on-prem Linux host. Authentication is via on-prem AD DS (Samba `security = ADS` with winbind). Authorization and share management live exclusively in local SQLite, driven by the web UI. + +## Architecture highlights + +- **Authentication:** Samba joins AD as a member server (`security = ADS`). SMB1 is disabled; SMB2+ only. NTLMv2 is allowed for non-domain-joined devices. +- **Authorization:** SQLite is the source of truth (no AD groups). Share membership is expanded to per-user lists in a generated Samba config file. +- **Share model:** + - `\\server\private` is a single share that maps to `/data/private/%U` with per-user content. + - `\\server\` are separate shares pointing to `/data/shares/`. +- **No per-folder ACLs:** Samba config uses `force user = filesvc` and `force group = filesvc` for shared areas. `nt acl support = no` and related toggles prevent ACL edits from clients. +- **Dynamic share reload:** Web UI regenerates `/etc/samba/shares.generated.conf` atomically and Samba reloads via `smbcontrol all reload-config` on change. +- **Backups:** Scheduled ISO backups with a tar-based payload (`data.tar.zst`) + SQLite `.backup` file + `manifest.json` containing hashes/sizes. + +## Directory layout + +Host directories: + +- `/srv/files/data` -> `/data` + - `/data/private/` + - `/data/shares/` +- `/srv/files/remote-backup` -> `/remote` (bind-mounted remote share) + +SQLite location: + +- `/var/lib/webui/app.db` (inside `webui` container, persisted via volume) + +## Quick start + +1. **Clone repo and create env file** + + ```bash + cp .env.example .env + ``` + +2. **Generate self-signed TLS certs for Traefik** + + ```bash + ./scripts/generate-self-signed.sh ./traefik/certs files.example.com + ``` + +3. **Create host directories** + + ```bash + sudo mkdir -p /srv/files/data /srv/files/remote-backup + ``` + +4. **Start the stack** + + ```bash + docker compose up -d + ``` + +5. **Open the Web UI** + + - `https://files.example.com/` + +## Samba security notes + +- **SMB1 is disabled** (`server min protocol = SMB2`). +- **NTLMv2** is allowed for non-domain-joined clients (`ntlm auth = ntlmv2-only`). + - Recommended hardening: prefer Kerberos and domain-joined devices, set `server signing = mandatory`, and enable `smb encrypt = desired` or `required` based on performance and compliance needs. +- **No ACL editing**: `nt acl support = no`, `dos filemode = no`, and `unix extensions = no` reduce client-side permission manipulation. + +## Share behavior + +- **Private share:** `\\server\private` maps to `/data/private/%U` and creates the user directory on first connect. Directory is `0700` and owned by the user (mapped via winbind). +- **Shared shares:** Each share is a distinct Samba stanza in `/etc/samba/shares.generated.conf`. Users are allowed via per-user lists from SQLite: + - `valid users = ` + - `write list = ` + - `read only = yes` (writes allowed via `write list`) + +**Important:** Shared area permissions are enforced by Samba config (lists), not filesystem ACLs. All files are owned by `filesvc` inside the Samba container due to `force user/group`. + +The `filesvc` UID/GID is `10050` by default in both `webui` and `samba` containers so ownership stays consistent on the host bind mount. + +## Web UI + +The Web UI authenticates via Entra ID OIDC (authorization code flow) and stores sessions in an HTTP-only cookie. Users are authorized if their UPN suffix matches `ALLOWED_UPN_SUFFIX`. + +### Admin page + +`/admin` shows access logs and aggregated statistics with Chart.js (daily activity and action breakdown). Access is restricted to the admin user via a bcrypt hash in `.env`. + +To generate a bcrypt hash for the admin UPN: + +```bash +node -e "const bcrypt=require('bcryptjs'); const upn=process.argv[1]; console.log(bcrypt.hashSync(upn, 10));" "admin@example.com" +``` + +Set the output in `ADMIN_UPN_BCRYPT`. + +### Pages + +- `/` list shares, create share +- `/shares/:id` manage membership +- `/admin` logs and statistics + +### APIs + +- `POST /api/shares` create share +- `GET /api/shares` list shares visible to current user +- `GET /api/shares/:id` share detail +- `POST /api/shares/:id/members` add/remove users or local groups +- `DELETE /api/shares/:id` disable share + +Local groups are managed in the UI on each share page and can be assigned roles per share. + +### Create share flow + +1. Validate share name (strict regex, length limits, reserved names). +2. Insert into SQLite with state `creating`. +3. Create `/data/shares/`. +4. `chown root:filesvc` and `chmod 2770`. +5. Regenerate `/samba-generated/shares.generated.conf` atomically. +6. Mark share `ready`. + +## Backup job + +Backups run on the `BACKUP_CRON` schedule and produce: + +- `data.tar.zst` (tar archive of `/data` with xattrs/acl metadata) +- `app.db` (SQLite `.backup`) +- `manifest.json` (timestamp, sizes, sha256) +- `backup-.iso` containing the above, then compressed to `.iso.zst` (or `.iso.gz`) + +**Note:** The ISO file itself does not preserve POSIX ACLs; the tar file inside does. + +### Restore steps + +1. Copy the compressed ISO back from `/remote`. +2. Decompress it. +3. Extract the ISO contents. +4. Decompress `data.tar.zst` and restore `/data` on the host. +5. Restore `app.db` to the web UI volume. +6. Redeploy compose. Re-join AD if the Samba secrets volume is lost. + +## Connecting clients + +### Windows + +- `\\server\private` +- `\\server\shareName` + +Use `DOMAIN\user` or `user@domain` when prompted. NTLMv2 is supported for non-domain-joined devices. + +### macOS + +- Finder -> Go -> Connect to Server +- `smb://server/private` +- `smb://server/shareName` + +### Linux + +```bash +smbclient -U user@domain //server/private +smbclient -U user@domain //server/shareName +``` + +## Files of interest + +- `docker-compose.yml` +- `samba/smb.conf.template` +- `webui/src/index.js` +- `backupd/backup.sh` diff --git a/backupd/Dockerfile b/backupd/Dockerfile new file mode 100644 index 0000000..c058409 --- /dev/null +++ b/backupd/Dockerfile @@ -0,0 +1,17 @@ +FROM debian:bookworm-slim + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + xorriso tar zstd sqlite3 ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL https://github.com/aptible/supercronic/releases/download/v0.2.29/supercronic-linux-amd64 \ + -o /usr/local/bin/supercronic \ + && chmod +x /usr/local/bin/supercronic + +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +COPY backup.sh /usr/local/bin/backup.sh + +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/backup.sh + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/backupd/backup.sh b/backupd/backup.sh new file mode 100644 index 0000000..db92c70 --- /dev/null +++ b/backupd/backup.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +timestamp=$(date -u +"%Y%m%dT%H%M%SZ") +workdir="/tmp/backup-${timestamp}" +stagedir="${workdir}/backup-${timestamp}" +data_tar="${stagedir}/data.tar.zst" +db_path="${SQLITE_DB_PATH:-/var/lib/webui/app.db}" +remote_path="${BACKUP_REMOTE_PATH:-/remote}" +compression="${BACKUP_COMPRESSION:-zstd}" + +mkdir -p "${stagedir}" + +tar --xattrs --acls --numeric-owner -cpf - /data | zstd -19 -o "${data_tar}" + +sqlite3 "${db_path}" ".backup '${stagedir}/app.db'" + +data_size=$(stat -c %s "${stagedir}/data.tar.zst") +data_sha=$(sha256sum "${stagedir}/data.tar.zst" | awk '{print $1}') +db_size=$(stat -c %s "${stagedir}/app.db") +db_sha=$(sha256sum "${stagedir}/app.db" | awk '{print $1}') + +cat > "${stagedir}/manifest.json" < /etc/backup-cron </dev/null 2>&1; then + groupadd -g "${FILESVC_GID}" filesvc +fi + +if ! id filesvc >/dev/null 2>&1; then + useradd -u "${FILESVC_UID}" -g filesvc -M -s /usr/sbin/nologin filesvc +fi + +if [ -n "${DOMAIN_REALM:-}" ]; then + cat > /etc/krb5.conf < /etc/samba/smb.conf +fi + +if ! grep -q "winbind" /etc/nsswitch.conf; then + sed -i 's/^passwd:.*/& winbind/' /etc/nsswitch.conf + sed -i 's/^group:.*/& winbind/' /etc/nsswitch.conf +fi + +mkdir -p /samba-generated +touch /samba-generated/shares.generated.conf +ln -sf /samba-generated/shares.generated.conf /etc/samba/shares.generated.conf + +if [ ! -f /var/lib/samba/private/secrets.tdb ]; then + if [ -z "${DOMAIN_JOIN_USER:-}" ] || [ -z "${DOMAIN_JOIN_PASSWORD:-}" ]; then + echo "DOMAIN_JOIN_USER and DOMAIN_JOIN_PASSWORD must be set to join the domain." >&2 + exit 1 + fi + echo "Joining AD domain ${DOMAIN_REALM}..." + net ads join -U "${DOMAIN_JOIN_USER}%${DOMAIN_JOIN_PASSWORD}" +fi + +winbindd -F & +WINBIND_PID=$! + +smbd -F & +SMBD_PID=$! + +/usr/local/bin/watch-reload & +WATCH_PID=$! + +trap 'kill ${WINBIND_PID} ${SMBD_PID} ${WATCH_PID}' TERM INT + +wait -n ${WINBIND_PID} ${SMBD_PID} ${WATCH_PID} diff --git a/samba/samba-private-mkdir.sh b/samba/samba-private-mkdir.sh new file mode 100644 index 0000000..ca25c8b --- /dev/null +++ b/samba/samba-private-mkdir.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +user="$1" +domain="${2:-}" +target="/data/private/${user}" + +if [ ! -d "${target}" ]; then + mkdir -p "${target}" + chmod 0700 "${target}" +fi + +if getent passwd "${user}" >/dev/null 2>&1; then + uid=$(getent passwd "${user}" | cut -d: -f3) + gid=$(getent passwd "${user}" | cut -d: -f4) + chown "${uid}:${gid}" "${target}" + exit 0 +fi + +if [ -n "${domain}" ] && getent passwd "${domain}\\${user}" >/dev/null 2>&1; then + uid=$(getent passwd "${domain}\\${user}" | cut -d: -f3) + gid=$(getent passwd "${domain}\\${user}" | cut -d: -f4) + chown "${uid}:${gid}" "${target}" +fi diff --git a/samba/smb.conf.template b/samba/smb.conf.template new file mode 100644 index 0000000..282373f --- /dev/null +++ b/samba/smb.conf.template @@ -0,0 +1,47 @@ +[global] + workgroup = ${DOMAIN_WORKGROUP} + realm = ${DOMAIN_REALM} + netbios name = ${SAMBA_NETBIOS_NAME} + security = ADS + kerberos method = secrets and keytab + dedicated keytab file = /etc/krb5.keytab + + server min protocol = SMB2 + server max protocol = SMB3 + ntlm auth = ntlmv2-only + server signing = mandatory + smb encrypt = desired + + winbind use default domain = no + winbind nss info = rfc2307 + winbind enum users = yes + winbind enum groups = yes + idmap config * : backend = tdb + idmap config * : range = 30000-79999 + idmap config ${DOMAIN_WORKGROUP} : backend = rid + idmap config ${DOMAIN_WORKGROUP} : range = 10000-29999 + + template shell = /bin/false + template homedir = /home/%D/%U + + map to guest = never + unix extensions = no + dos filemode = no + nt acl support = no + + log file = /var/log/samba/log.%m + max log size = 1000 + logging = file + +[private] + path = /data/private/%U + browseable = yes + read only = no + valid users = %U + create mask = 0600 + directory mask = 0700 + root preexec = /usr/local/bin/samba-private-mkdir %U %D + nt acl support = no + dos filemode = no + +include = /etc/samba/shares.generated.conf diff --git a/samba/watch-reload.sh b/samba/watch-reload.sh new file mode 100644 index 0000000..9de15a2 --- /dev/null +++ b/samba/watch-reload.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +WATCH_DIR="/etc/samba" + +while true; do + inotifywait -e close_write,move,create,delete "${WATCH_DIR}" >/dev/null 2>&1 || true + smbcontrol all reload-config || true +done diff --git a/scripts/generate-self-signed.sh b/scripts/generate-self-signed.sh new file mode 100644 index 0000000..ca93a53 --- /dev/null +++ b/scripts/generate-self-signed.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +CERT_DIR="${1:-./traefik/certs}" +HOSTNAME="${2:-localhost}" + +mkdir -p "${CERT_DIR}" + +openssl req -x509 -nodes -newkey rsa:2048 \ + -keyout "${CERT_DIR}/traefik.key" \ + -out "${CERT_DIR}/traefik.crt" \ + -days 365 \ + -subj "/CN=${HOSTNAME}" + +echo "Generated certs in ${CERT_DIR} for CN=${HOSTNAME}" diff --git a/traefik/dynamic.yml b/traefik/dynamic.yml new file mode 100644 index 0000000..eb9809a --- /dev/null +++ b/traefik/dynamic.yml @@ -0,0 +1,4 @@ +tls: + certificates: + - certFile: /certs/traefik.crt + keyFile: /certs/traefik.key diff --git a/traefik/traefik.yml b/traefik/traefik.yml new file mode 100644 index 0000000..563e6b1 --- /dev/null +++ b/traefik/traefik.yml @@ -0,0 +1,17 @@ +log: + level: INFO + +api: + dashboard: true + +providers: + docker: + exposedByDefault: false + file: + filename: /etc/traefik/dynamic.yml + +entryPoints: + web: + address: ":80" + websecure: + address: ":443" diff --git a/webui/Dockerfile b/webui/Dockerfile new file mode 100644 index 0000000..a093733 --- /dev/null +++ b/webui/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-bookworm-slim + +ENV NODE_ENV=production + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates sqlite3 \ + && rm -rf /var/lib/apt/lists/* + +COPY package.json /app/package.json + +RUN npm install --omit=dev + +COPY src /app/src +COPY views /app/views +COPY public /app/public +COPY migrations /app/migrations + +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/webui/migrations/001_init.sql b/webui/migrations/001_init.sql new file mode 100644 index 0000000..9a9a785 --- /dev/null +++ b/webui/migrations/001_init.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + owner_upn TEXT NOT NULL, + created_at TEXT NOT NULL, + state TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS principals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + name TEXT, + upn TEXT, + UNIQUE(type, name, upn) +); + +CREATE TABLE IF NOT EXISTS memberships ( + share_id INTEGER NOT NULL, + principal_id INTEGER NOT NULL, + role TEXT NOT NULL, + PRIMARY KEY (share_id, principal_id), + FOREIGN KEY (share_id) REFERENCES shares(id), + FOREIGN KEY (principal_id) REFERENCES principals(id) +); + +CREATE TABLE IF NOT EXISTS group_members ( + group_id INTEGER NOT NULL, + user_upn TEXT NOT NULL, + PRIMARY KEY (group_id, user_upn), + FOREIGN KEY (group_id) REFERENCES principals(id) +); diff --git a/webui/migrations/002_logs.sql b/webui/migrations/002_logs.sql new file mode 100644 index 0000000..877e02c --- /dev/null +++ b/webui/migrations/002_logs.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS access_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + occurred_at TEXT NOT NULL, + user_upn TEXT NOT NULL, + action TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + status_code INTEGER, + share_id INTEGER, + details TEXT +); + +CREATE INDEX IF NOT EXISTS idx_access_logs_occurred_at ON access_logs (occurred_at); +CREATE INDEX IF NOT EXISTS idx_access_logs_user ON access_logs (user_upn); +CREATE INDEX IF NOT EXISTS idx_access_logs_action ON access_logs (action); diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..a5b06c8 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,18 @@ +{ + "name": "aad-files-webui", + "version": "1.0.0", + "private": true, + "main": "src/index.js", + "scripts": { + "start": "node src/index.js" + }, + "dependencies": { + "better-sqlite3": "^11.5.0", + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", + "ejs": "^3.1.10", + "express": "^4.19.2", + "express-session": "^1.18.1", + "openid-client": "^6.3.3" + } +} diff --git a/webui/public/styles.css b/webui/public/styles.css new file mode 100644 index 0000000..63565eb --- /dev/null +++ b/webui/public/styles.css @@ -0,0 +1,270 @@ +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap'); + +:root { + color-scheme: light; + --bg: #f5f2ea; + --bg-accent: #e8e1d3; + --ink: #1e1b16; + --muted: #5b5447; + --primary: #d26b2f; + --primary-dark: #b75522; + --danger: #a11f2c; + --panel: #fff9f0; + --shadow: 0 24px 45px rgba(34, 31, 26, 0.12); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'Space Grotesk', sans-serif; + background: radial-gradient(circle at top, #ffffff 0%, var(--bg) 45%, var(--bg-accent) 100%); + color: var(--ink); +} + +.site-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px 36px; + border-bottom: 1px solid rgba(30, 27, 22, 0.1); + backdrop-filter: blur(10px); +} + +.brand { + display: flex; + align-items: center; + gap: 16px; +} + +.brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + background: var(--primary); + color: #fff; + font-weight: 600; + border-radius: 14px; + box-shadow: var(--shadow); +} + +.brand-title { + font-size: 20px; + font-weight: 600; +} + +.brand-subtitle { + color: var(--muted); + font-size: 13px; +} + +.user { + text-align: right; + display: grid; + gap: 6px; + justify-items: end; +} + +.user-name { + font-weight: 600; +} + +.user-upn { + color: var(--muted); + font-size: 12px; +} + +.main { + padding: 32px 36px 60px; + display: grid; + gap: 24px; +} + +.panel { + background: var(--panel); + padding: 24px; + border-radius: 18px; + box-shadow: var(--shadow); +} + +.panel-head { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: center; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; +} + +.form-row { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.form-grid { + display: grid; + gap: 12px; + grid-template-columns: 2fr 1fr auto; + align-items: center; +} + +input, +select { + padding: 12px 14px; + border-radius: 12px; + border: 1px solid rgba(30, 27, 22, 0.15); + background: #fff; + font-size: 14px; +} + +button, +.primary, +.secondary { + border: none; + padding: 10px 16px; + border-radius: 12px; + font-weight: 600; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.primary { + background: var(--primary); + color: #fff; +} + +.primary:hover { + background: var(--primary-dark); +} + +.secondary { + background: #efe7da; + color: var(--ink); +} + +.danger { + background: var(--danger); + color: #fff; +} + +.list { + list-style: none; + padding: 0; + margin: 16px 0 0; + display: grid; + gap: 12px; +} + +.list.compact li { + padding: 8px 10px; +} + +.list li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 14px; + border-radius: 12px; + background: #fff; + border: 1px solid rgba(30, 27, 22, 0.08); +} + +.badge { + background: #fff2db; + color: var(--primary-dark); + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; +} + +.alert { + background: #ffe3d8; + color: #8c2b0c; + padding: 12px; + border-radius: 12px; + margin: 12px 0; +} + +.hint { + color: var(--muted); + font-size: 12px; + margin-top: 10px; +} + +.muted { + color: var(--muted); +} + +.member-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.group-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; + margin-top: 16px; +} + +.group-card { + background: #fff; + border: 1px solid rgba(30, 27, 22, 0.08); + border-radius: 16px; + padding: 16px; + display: grid; + gap: 12px; +} + +.group-title { + font-weight: 600; +} + +.table-wrap { + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.table th, +.table td { + text-align: left; + padding: 10px; + border-bottom: 1px solid rgba(30, 27, 22, 0.1); +} + +.table th { + font-weight: 600; + color: var(--muted); +} + +@media (max-width: 720px) { + .site-header { + flex-direction: column; + align-items: flex-start; + gap: 16px; + } + + .form-grid { + grid-template-columns: 1fr; + } +} diff --git a/webui/src/auth.js b/webui/src/auth.js new file mode 100644 index 0000000..246df07 --- /dev/null +++ b/webui/src/auth.js @@ -0,0 +1,111 @@ +const { Issuer, generators } = require('openid-client'); + +function parseAllowedSuffixes(value) { + if (!value) return []; + return value + .split(',') + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); +} + +function isAllowedUpn(upn, allowedSuffixes) { + if (!allowedSuffixes.length) return true; + const lower = upn.toLowerCase(); + return allowedSuffixes.some((suffix) => lower.endsWith(suffix)); +} + +async function buildOidcClient() { + const tenantId = process.env.ENTRA_TENANT_ID; + const clientId = process.env.ENTRA_CLIENT_ID; + const clientSecret = process.env.ENTRA_CLIENT_SECRET; + const redirectUri = process.env.ENTRA_REDIRECT_URI; + + if (!tenantId || !clientId || !clientSecret || !redirectUri) { + throw new Error('Missing Entra ID OIDC configuration.'); + } + + const issuer = await Issuer.discover( + `https://login.microsoftonline.com/${tenantId}/v2.0/.well-known/openid-configuration` + ); + + return new issuer.Client({ + client_id: clientId, + client_secret: clientSecret, + redirect_uris: [redirectUri], + response_types: ['code'] + }); +} + +function authMiddleware() { + return function requireAuth(req, res, next) { + if (req.session && req.session.user) return next(); + return res.redirect('/login'); + }; +} + +function registerAuthRoutes(app, oidcClient) { + const allowedSuffixes = parseAllowedSuffixes(process.env.ALLOWED_UPN_SUFFIX); + + app.get('/login', (req, res) => { + res.render('login'); + }); + + app.get('/auth/login', (req, res) => { + const state = generators.state(); + const nonce = generators.nonce(); + req.session.oidcState = state; + req.session.oidcNonce = nonce; + + const authorizationUrl = oidcClient.authorizationUrl({ + scope: 'openid profile email', + state, + nonce + }); + res.redirect(authorizationUrl); + }); + + app.get('/auth/callback', async (req, res, next) => { + try { + const params = oidcClient.callbackParams(req); + const tokenSet = await oidcClient.callback( + process.env.ENTRA_REDIRECT_URI, + params, + { + state: req.session.oidcState, + nonce: req.session.oidcNonce + } + ); + + const claims = tokenSet.claims(); + const upn = claims.preferred_username || claims.upn || claims.email; + if (!upn) { + return res.status(403).send('UPN missing in token.'); + } + + if (!isAllowedUpn(upn, allowedSuffixes)) { + return res.status(403).send('User UPN suffix not allowed.'); + } + + req.session.user = { + upn: upn.toLowerCase(), + name: claims.name || upn + }; + + return res.redirect('/'); + } catch (error) { + return next(error); + } + }); + + app.post('/auth/logout', (req, res) => { + req.session.destroy(() => { + res.redirect('/login'); + }); + }); +} + +module.exports = { + buildOidcClient, + registerAuthRoutes, + authMiddleware +}; diff --git a/webui/src/db.js b/webui/src/db.js new file mode 100644 index 0000000..23032ee --- /dev/null +++ b/webui/src/db.js @@ -0,0 +1,42 @@ +const fs = require('fs'); +const path = require('path'); +const Database = require('better-sqlite3'); + +const MIGRATIONS_DIR = path.join(__dirname, '..', 'migrations'); + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function initDb(dbPath) { + ensureDir(path.dirname(dbPath)); + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + + db.prepare( + 'CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)' + ).run(); + + const applied = new Set( + db.prepare('SELECT version FROM schema_migrations').all().map((row) => row.version) + ); + + const migrationFiles = fs + .readdirSync(MIGRATIONS_DIR) + .filter((name) => name.endsWith('.sql')) + .sort(); + + for (const file of migrationFiles) { + if (applied.has(file)) continue; + const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf8'); + db.exec(sql); + db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)').run( + file, + new Date().toISOString() + ); + } + + return db; +} + +module.exports = { initDb }; diff --git a/webui/src/index.js b/webui/src/index.js new file mode 100644 index 0000000..d6e1938 --- /dev/null +++ b/webui/src/index.js @@ -0,0 +1,481 @@ +const fs = require('fs'); +const path = require('path'); +const express = require('express'); +const bcrypt = require('bcryptjs'); +const session = require('express-session'); +const cookieParser = require('cookie-parser'); + +const { initDb } = require('./db'); +const { + validateShareName, + validateGroupName, + listSharesVisible, + getShare, + createShare, + markShareState, + addMembership, + removeMembership, + userRoleForShare, + renderSharesConfig, + writeSharesConfig, + listGroups, + getGroupMembers, + createGroup, + addGroupMember, + removeGroupMember +} = require('./shares'); +const { buildOidcClient, registerAuthRoutes, authMiddleware } = require('./auth'); + +const app = express(); +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, '..', 'views')); +app.set('trust proxy', 1); + +app.use(cookieParser()); +app.use( + session({ + secret: process.env.WEBUI_SESSION_SECRET || 'dev-secret', + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + sameSite: 'lax', + secure: true + } + }) +); + +app.use(express.urlencoded({ extended: true })); +app.use(express.json()); +app.use('/public', express.static(path.join(__dirname, '..', 'public'))); + +const dbPath = process.env.SQLITE_DB_PATH || '/var/lib/webui/app.db'; +const db = initDb(dbPath); + +const sambaGeneratedPath = process.env.SAMBA_GENERATED_PATH || '/samba-generated/shares.generated.conf'; +const dataRoot = process.env.DATA_ROOT || '/data'; +const filesvcGid = Number(process.env.FILESVC_GID || 10050); + +fs.mkdirSync(path.dirname(sambaGeneratedPath), { recursive: true }); +if (!fs.existsSync(sambaGeneratedPath)) { + fs.writeFileSync(sambaGeneratedPath, '', 'utf8'); +} + +function requireOwnerOrShareRole(req, res, next) { + const shareId = Number(req.params.id); + const shareData = getShare(db, shareId); + if (!shareData) return res.status(404).send('Share not found'); + const { share } = shareData; + const userUpn = req.session.user.upn; + if (share.owner_upn === userUpn) { + req.shareData = shareData; + return next(); + } + const role = userRoleForShare(db, shareId, userUpn); + if (role) { + req.shareData = shareData; + return next(); + } + return res.status(403).send('Forbidden'); +} + +function requireOwner(req, res, next) { + const shareId = Number(req.params.id); + const shareData = getShare(db, shareId); + if (!shareData) return res.status(404).send('Share not found'); + const { share } = shareData; + const userUpn = req.session.user.upn; + if (share.owner_upn !== userUpn) { + return res.status(403).send('Only owners can modify memberships.'); + } + req.shareData = shareData; + return next(); +} + +function ensureOwnerForShareId(req, res, next) { + const shareId = Number(req.body.shareId || req.query.shareId); + if (!shareId) return res.status(400).send('Share id required.'); + const shareData = getShare(db, shareId); + if (!shareData) return res.status(404).send('Share not found'); + if (shareData.share.owner_upn !== req.session.user.upn) { + return res.status(403).send('Only owners can manage groups.'); + } + req.shareData = shareData; + return next(); +} + +function requireAdmin(req, res, next) { + const adminHash = process.env.ADMIN_UPN_BCRYPT; + if (!adminHash) return res.status(403).send('Admin access not configured.'); + const userUpn = req.session.user.upn; + const isAdmin = bcrypt.compareSync(userUpn, adminHash); + if (!isAdmin) return res.status(403).send('Admin access required.'); + return next(); +} + +function logEvent({ userUpn, action, method, path, statusCode, shareId, details }) { + try { + db.prepare( + `INSERT INTO access_logs (occurred_at, user_upn, action, method, path, status_code, share_id, details) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + new Date().toISOString(), + userUpn, + action, + method, + path, + statusCode || null, + shareId || null, + details ? JSON.stringify(details) : null + ); + } catch (err) { + console.error('Failed to log event', err.message); + } +} + +function logAccessMiddleware(req, res, next) { + if (!req.session || !req.session.user) return next(); + res.on('finish', () => { + logEvent({ + userUpn: req.session.user.upn, + action: 'access', + method: req.method, + path: req.originalUrl, + statusCode: res.statusCode + }); + }); + return next(); +} + +async function main() { + const oidcClient = await buildOidcClient(); + registerAuthRoutes(app, oidcClient); + + app.use(authMiddleware()); + app.use(logAccessMiddleware); + + const initialConfig = renderSharesConfig(db); + writeSharesConfig(sambaGeneratedPath, initialConfig); + + app.get('/', (req, res) => { + const shares = listSharesVisible(db, req.session.user.upn); + const myShares = shares.filter((share) => share.owner_upn === req.session.user.upn); + res.render('index', { user: req.session.user, shares, myShares, error: null }); + }); + + app.get('/shares/:id', requireOwnerOrShareRole, (req, res) => { + const groups = listGroups(db).map((group) => ({ + ...group, + members: getGroupMembers(db, group.id) + })); + res.render('share', { + user: req.session.user, + share: req.shareData.share, + members: req.shareData.members, + groups, + error: null + }); + }); + + app.post('/shares/:id/members', requireOwner, (req, res) => { + const shareId = Number(req.params.id); + const { principal, role, action, principalType } = req.body; + try { + if (!principal) throw new Error('Principal is required.'); + if (action === 'remove') { + removeMembership(db, shareId, principalType || 'user', principal); + logEvent({ + userUpn: req.session.user.upn, + action: 'remove_member', + method: req.method, + path: req.originalUrl, + shareId, + details: { principal, principalType: principalType || 'user' } + }); + } else { + addMembership(db, shareId, principalType || 'user', principal, role); + logEvent({ + userUpn: req.session.user.upn, + action: 'add_member', + method: req.method, + path: req.originalUrl, + shareId, + details: { principal, principalType: principalType || 'user', role } + }); + } + const contents = renderSharesConfig(db); + writeSharesConfig(sambaGeneratedPath, contents); + return res.redirect(`/shares/${shareId}`); + } catch (error) { + const shareData = getShare(db, shareId); + return res.status(400).render('share', { + user: req.session.user, + share: shareData.share, + members: shareData.members, + groups: listGroups(db).map((group) => ({ + ...group, + members: getGroupMembers(db, group.id) + })), + error: error.message + }); + } + }); + + app.post('/shares', async (req, res) => { + const { name } = req.body; + const error = validateShareName(name); + if (error) { + const shares = listSharesVisible(db, req.session.user.upn); + const myShares = shares.filter((share) => share.owner_upn === req.session.user.upn); + return res.status(400).render('index', { + user: req.session.user, + shares, + myShares, + error + }); + } + + let shareId; + try { + shareId = createShare(db, name, req.session.user.upn); + } catch (err) { + const shares = listSharesVisible(db, req.session.user.upn); + const myShares = shares.filter((share) => share.owner_upn === req.session.user.upn); + return res.status(400).render('index', { + user: req.session.user, + shares, + myShares, + error: 'Share name already exists.' + }); + } + try { + const shareDir = path.join(dataRoot, 'shares', name); + fs.mkdirSync(shareDir, { recursive: true }); + fs.chownSync(shareDir, 0, filesvcGid); + fs.chmodSync(shareDir, 0o2770); + + const contents = renderSharesConfig(db); + writeSharesConfig(sambaGeneratedPath, contents); + markShareState(db, shareId, 'ready'); + logEvent({ + userUpn: req.session.user.upn, + action: 'create_share', + method: req.method, + path: req.originalUrl, + shareId, + details: { name } + }); + } catch (err) { + markShareState(db, shareId, 'error'); + throw err; + } + + return res.redirect('/'); + }); + + app.post('/shares/:id/delete', requireOwner, (req, res) => { + const shareId = Number(req.params.id); + markShareState(db, shareId, 'deleted'); + const contents = renderSharesConfig(db); + writeSharesConfig(sambaGeneratedPath, contents); + logEvent({ + userUpn: req.session.user.upn, + action: 'delete_share', + method: req.method, + path: req.originalUrl, + shareId + }); + return res.redirect('/'); + }); + + app.get('/api/shares', (req, res) => { + res.json(listSharesVisible(db, req.session.user.upn)); + }); + + app.get('/api/shares/:id', requireOwnerOrShareRole, (req, res) => { + res.json(req.shareData); + }); + + app.post('/api/shares', (req, res) => { + const { name } = req.body; + const error = validateShareName(name); + if (error) return res.status(400).json({ error }); + let shareId; + try { + shareId = createShare(db, name, req.session.user.upn); + } catch (err) { + return res.status(400).json({ error: 'Share name already exists.' }); + } + try { + const shareDir = path.join(dataRoot, 'shares', name); + fs.mkdirSync(shareDir, { recursive: true }); + fs.chownSync(shareDir, 0, filesvcGid); + fs.chmodSync(shareDir, 0o2770); + const contents = renderSharesConfig(db); + writeSharesConfig(sambaGeneratedPath, contents); + markShareState(db, shareId, 'ready'); + logEvent({ + userUpn: req.session.user.upn, + action: 'create_share', + method: req.method, + path: req.originalUrl, + shareId, + details: { name } + }); + } catch (err) { + markShareState(db, shareId, 'error'); + return res.status(500).json({ error: err.message }); + } + return res.status(201).json({ id: shareId }); + }); + + app.post('/api/shares/:id/members', requireOwner, (req, res) => { + const shareId = Number(req.params.id); + const { principal, role, action, principalType } = req.body; + try { + if (!principal) throw new Error('Principal is required.'); + if (action === 'remove') { + removeMembership(db, shareId, principalType || 'user', principal); + logEvent({ + userUpn: req.session.user.upn, + action: 'remove_member', + method: req.method, + path: req.originalUrl, + shareId, + details: { principal, principalType: principalType || 'user' } + }); + } else { + addMembership(db, shareId, principalType || 'user', principal, role); + logEvent({ + userUpn: req.session.user.upn, + action: 'add_member', + method: req.method, + path: req.originalUrl, + shareId, + details: { principal, principalType: principalType || 'user', role } + }); + } + const contents = renderSharesConfig(db); + writeSharesConfig(sambaGeneratedPath, contents); + return res.json({ ok: true }); + } catch (error) { + return res.status(400).json({ error: error.message }); + } + }); + + app.delete('/api/shares/:id', requireOwner, (req, res) => { + const shareId = Number(req.params.id); + markShareState(db, shareId, 'deleted'); + const contents = renderSharesConfig(db); + writeSharesConfig(sambaGeneratedPath, contents); + logEvent({ + userUpn: req.session.user.upn, + action: 'delete_share', + method: req.method, + path: req.originalUrl, + shareId + }); + res.json({ ok: true }); + }); + + app.post('/groups', ensureOwnerForShareId, (req, res) => { + const { name } = req.body; + const error = validateGroupName(name); + if (error) return res.status(400).send(error); + try { + createGroup(db, name); + } catch (err) { + return res.status(400).send('Group name already exists.'); + } + logEvent({ + userUpn: req.session.user.upn, + action: 'create_group', + method: req.method, + path: req.originalUrl, + shareId: req.shareData.share.id, + details: { name } + }); + return res.redirect(`/shares/${req.shareData.share.id}`); + }); + + app.post('/groups/:id/members', ensureOwnerForShareId, (req, res) => { + const groupId = Number(req.params.id); + const { member, action } = req.body; + if (!member) return res.status(400).send('Member UPN required.'); + if (action === 'remove') { + removeGroupMember(db, groupId, member); + logEvent({ + userUpn: req.session.user.upn, + action: 'remove_group_member', + method: req.method, + path: req.originalUrl, + shareId: req.shareData.share.id, + details: { groupId, member } + }); + } else { + addGroupMember(db, groupId, member); + logEvent({ + userUpn: req.session.user.upn, + action: 'add_group_member', + method: req.method, + path: req.originalUrl, + shareId: req.shareData.share.id, + details: { groupId, member } + }); + } + return res.redirect(`/shares/${req.shareData.share.id}`); + }); + + app.get('/admin', requireAdmin, (req, res) => { + const logs = db + .prepare( + `SELECT id, occurred_at, user_upn, action, method, path, status_code, share_id, details + FROM access_logs + ORDER BY occurred_at DESC + LIMIT 200` + ) + .all(); + + const daily = db + .prepare( + `SELECT substr(occurred_at, 1, 10) AS day, COUNT(*) AS count + FROM access_logs + GROUP BY day + ORDER BY day DESC + LIMIT 14` + ) + .all() + .reverse(); + + const actions = db + .prepare( + `SELECT action, COUNT(*) AS count + FROM access_logs + GROUP BY action + ORDER BY count DESC` + ) + .all(); + + res.render('admin', { + user: req.session.user, + logs, + daily, + actions + }); + }); + + app.use((err, req, res, next) => { + console.error(err); + res.status(500).send('Unexpected error.'); + }); + + const port = process.env.PORT || 3000; + app.listen(port, () => { + console.log(`Web UI listening on ${port}`); + }); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/webui/src/shares.js b/webui/src/shares.js new file mode 100644 index 0000000..e311dfd --- /dev/null +++ b/webui/src/shares.js @@ -0,0 +1,286 @@ +const fs = require('fs'); +const path = require('path'); + +const RESERVED_SHARE_NAMES = new Set([ + 'private', + 'ipc$', + 'print$', + 'admin$', + 'netlogon', + 'sysvol' +]); + +const SHARE_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,30}$/; +const GROUP_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,40}$/; + +function normalizeUpn(upn) { + return upn.trim().toLowerCase(); +} + +function validateShareName(name) { + if (!name || typeof name !== 'string') return 'Share name is required.'; + if (!SHARE_NAME_REGEX.test(name)) { + return 'Share name must start with an alphanumeric character and be 1-31 chars using letters, numbers, dot, underscore, or dash.'; + } + if (RESERVED_SHARE_NAMES.has(name.toLowerCase())) { + return `Share name ${name} is reserved.`; + } + return null; +} + +function validateGroupName(name) { + const trimmed = name ? name.trim() : ''; + if (!trimmed || typeof name !== 'string') return 'Group name is required.'; + if (!GROUP_NAME_REGEX.test(trimmed)) { + return 'Group name must start with an alphanumeric character and be 1-41 chars using letters, numbers, dot, underscore, or dash.'; + } + return null; +} + +function normalizeGroupName(name) { + return name.trim().toLowerCase(); +} + +function ensurePrincipal(db, type, { upn, name }) { + const stmt = db.prepare( + 'SELECT id FROM principals WHERE type = ? AND COALESCE(upn, "") = COALESCE(?, "") AND COALESCE(name, "") = COALESCE(?, "")' + ); + const existing = stmt.get(type, upn || null, name || null); + if (existing) return existing.id; + const insert = db.prepare('INSERT INTO principals (type, name, upn) VALUES (?, ?, ?)'); + const info = insert.run(type, name || null, upn || null); + return info.lastInsertRowid; +} + +function listSharesVisible(db, upn) { + const normalized = normalizeUpn(upn); + const rows = db + .prepare( + `SELECT DISTINCT s.* + FROM shares s + LEFT JOIN memberships m ON s.id = m.share_id + LEFT JOIN principals p ON m.principal_id = p.id + LEFT JOIN group_members gm ON p.id = gm.group_id + WHERE s.state != 'deleted' + AND (s.owner_upn = ? + OR (p.type = 'user' AND p.upn = ?) + OR (p.type = 'group' AND gm.user_upn = ?))` + ) + .all(normalized, normalized, normalized); + return rows; +} + +function getShare(db, id) { + const share = db.prepare('SELECT * FROM shares WHERE id = ? AND state != ?').get(id, 'deleted'); + if (!share) return null; + + const members = db + .prepare( + `SELECT m.role, p.type, p.upn, p.name, p.id AS principal_id + FROM memberships m + JOIN principals p ON p.id = m.principal_id + WHERE m.share_id = ? + ORDER BY m.role, p.type, COALESCE(p.upn, p.name)` + ) + .all(id); + + return { share, members }; +} + +function createShare(db, name, ownerUpn) { + const now = new Date().toISOString(); + const info = db + .prepare('INSERT INTO shares (name, owner_upn, created_at, state) VALUES (?, ?, ?, ?)') + .run(name, normalizeUpn(ownerUpn), now, 'creating'); + return info.lastInsertRowid; +} + +function markShareState(db, id, state) { + db.prepare('UPDATE shares SET state = ? WHERE id = ?').run(state, id); +} + +function addMembership(db, shareId, principalType, principalValue, role) { + if (!role || !['owner', 'rw', 'ro'].includes(role)) { + throw new Error('Invalid role. Must be owner, rw, or ro.'); + } + + let principalId; + if (principalType === 'group') { + principalId = ensurePrincipal(db, 'group', { name: normalizeGroupName(principalValue) }); + } else { + principalId = ensurePrincipal(db, 'user', { upn: normalizeUpn(principalValue) }); + } + + db.prepare( + 'INSERT INTO memberships (share_id, principal_id, role) VALUES (?, ?, ?) ON CONFLICT(share_id, principal_id) DO UPDATE SET role = excluded.role' + ).run(shareId, principalId, role); +} + +function removeMembership(db, shareId, principalType, principalValue) { + const principal = principalType === 'group' + ? db.prepare('SELECT id FROM principals WHERE type = ? AND name = ?').get('group', normalizeGroupName(principalValue)) + : db.prepare('SELECT id FROM principals WHERE type = ? AND upn = ?').get('user', normalizeUpn(principalValue)); + if (!principal) return; + db.prepare('DELETE FROM memberships WHERE share_id = ? AND principal_id = ?').run(shareId, principal.id); +} + +function userRoleForShare(db, shareId, upn) { + const normalized = normalizeUpn(upn); + const roles = db + .prepare( + `SELECT m.role + FROM memberships m + JOIN principals p ON p.id = m.principal_id + LEFT JOIN group_members gm ON p.id = gm.group_id + WHERE m.share_id = ? + AND (p.type = 'user' AND p.upn = ? + OR (p.type = 'group' AND gm.user_upn = ?))` + ) + .all(shareId, normalized, normalized) + .map((row) => row.role); + if (roles.includes('owner')) return 'owner'; + if (roles.includes('rw')) return 'rw'; + if (roles.includes('ro')) return 'ro'; + return null; +} + +function getExpandedMembers(db, shareId, ownerUpn) { + const memberships = db + .prepare( + `SELECT m.role, p.type, p.upn, p.name, p.id AS principal_id + FROM memberships m + JOIN principals p ON p.id = m.principal_id + WHERE m.share_id = ?` + ) + .all(shareId); + + const ownerSet = new Set([normalizeUpn(ownerUpn)]); + const rwSet = new Set(); + const roSet = new Set(); + + for (const member of memberships) { + if (member.type === 'user') { + const upn = normalizeUpn(member.upn); + if (member.role === 'owner') ownerSet.add(upn); + if (member.role === 'rw') rwSet.add(upn); + if (member.role === 'ro') roSet.add(upn); + continue; + } + + if (member.type === 'group') { + const rows = db + .prepare('SELECT user_upn FROM group_members WHERE group_id = ?') + .all(member.principal_id); + for (const row of rows) { + const upn = normalizeUpn(row.user_upn); + if (member.role === 'owner') ownerSet.add(upn); + if (member.role === 'rw') rwSet.add(upn); + if (member.role === 'ro') roSet.add(upn); + } + } + } + + const validUsers = new Set([...ownerSet, ...rwSet, ...roSet]); + return { + validUsers: [...validUsers], + writeUsers: [...ownerSet, ...rwSet] + }; +} + +function renderSharesConfig(db) { + const shares = db.prepare('SELECT * FROM shares WHERE state != ?').all('deleted'); + const lines = []; + + for (const share of shares) { + if (share.state !== 'ready') continue; + const expanded = getExpandedMembers(db, share.id, share.owner_upn); + const validUsers = expanded.validUsers.join(' '); + const writeUsers = expanded.writeUsers.join(' '); + + lines.push(`[${share.name}]`); + lines.push(`path = /data/shares/${share.name}`); + lines.push('browseable = yes'); + lines.push('read only = yes'); + lines.push(`valid users = ${validUsers}`); + lines.push(`write list = ${writeUsers}`); + lines.push('force user = filesvc'); + lines.push('force group = filesvc'); + lines.push('create mask = 0660'); + lines.push('directory mask = 2770'); + lines.push('nt acl support = no'); + lines.push('dos filemode = no'); + lines.push('inherit permissions = no'); + lines.push(''); + } + + return lines.join('\n'); +} + +function listGroups(db) { + return db + .prepare( + `SELECT p.id, p.name, COUNT(gm.user_upn) AS member_count + FROM principals p + LEFT JOIN group_members gm ON gm.group_id = p.id + WHERE p.type = 'group' + GROUP BY p.id + ORDER BY p.name` + ) + .all(); +} + +function getGroupMembers(db, groupId) { + return db + .prepare('SELECT user_upn FROM group_members WHERE group_id = ? ORDER BY user_upn') + .all(groupId); +} + +function createGroup(db, name) { + const normalized = normalizeGroupName(name); + const existing = db.prepare('SELECT id FROM principals WHERE type = ? AND name = ?').get('group', normalized); + if (existing) throw new Error('Group already exists.'); + const id = ensurePrincipal(db, 'group', { name: normalized }); + return id; +} + +function addGroupMember(db, groupId, userUpn) { + db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_upn) VALUES (?, ?)').run( + groupId, + normalizeUpn(userUpn) + ); +} + +function removeGroupMember(db, groupId, userUpn) { + db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_upn = ?').run( + groupId, + normalizeUpn(userUpn) + ); +} + +function writeSharesConfig(configPath, contents) { + const dir = path.dirname(configPath); + const tmpPath = path.join(dir, `.shares.generated.${Date.now()}.tmp`); + fs.writeFileSync(tmpPath, contents, 'utf8'); + fs.renameSync(tmpPath, configPath); +} + +module.exports = { + validateShareName, + validateGroupName, + normalizeUpn, + normalizeGroupName, + listSharesVisible, + getShare, + createShare, + markShareState, + addMembership, + removeMembership, + userRoleForShare, + renderSharesConfig, + writeSharesConfig, + listGroups, + getGroupMembers, + createGroup, + addGroupMember, + removeGroupMember +}; diff --git a/webui/views/admin.ejs b/webui/views/admin.ejs new file mode 100644 index 0000000..503ae70 --- /dev/null +++ b/webui/views/admin.ejs @@ -0,0 +1,88 @@ +<%- include('partials/header', { title: 'Admin', user }) %> +
+

Access logs

+

Every authenticated request and share operation is recorded.

+
+ + + + + + + + + + + + + + <% logs.forEach((log) => { %> + + + + + + + + + + <% }) %> + +
Time (UTC)UserActionMethodPathStatusShare
<%= log.occurred_at %><%= log.user_upn %><%= log.action %><%= log.method %><%= log.path %><%= log.status_code || '' %><%= log.share_id || '' %>
+
+
+ +
+
+

Daily activity

+ +
+
+

Actions breakdown

+ +
+
+ + + +<%- include('partials/footer') %> diff --git a/webui/views/index.ejs b/webui/views/index.ejs new file mode 100644 index 0000000..638eeec --- /dev/null +++ b/webui/views/index.ejs @@ -0,0 +1,47 @@ +<%- include('partials/header', { title: 'Shares', user }) %> +
+

Create share

+ <% if (error) { %> +
<%= error %>
+ <% } %> +
+ + +
+
Share names are 1-31 chars and must avoid reserved names like private or IPC$.
+
+ +
+
+

My shares

+ <% if (!myShares.length) { %> +

You do not own any shares yet.

+ <% } %> + +
+ +
+

Shares you can access

+ <% if (!shares.length) { %> +

No shares are available to you yet.

+ <% } %> +
    + <% shares.forEach((share) => { %> +
  • + <%= share.name %> + <% if (share.owner_upn === user.upn) { %> + Owner + <% } %> +
  • + <% }) %> +
+
+
+<%- include('partials/footer') %> diff --git a/webui/views/login.ejs b/webui/views/login.ejs new file mode 100644 index 0000000..234bcbc --- /dev/null +++ b/webui/views/login.ejs @@ -0,0 +1,7 @@ +<%- include('partials/header', { title: 'Sign in', user: null }) %> +
+

Sign in

+

Use Entra ID to access your file shares.

+ Continue with Entra ID +
+<%- include('partials/footer') %> diff --git a/webui/views/partials/footer.ejs b/webui/views/partials/footer.ejs new file mode 100644 index 0000000..fd8d415 --- /dev/null +++ b/webui/views/partials/footer.ejs @@ -0,0 +1,3 @@ + + + diff --git a/webui/views/partials/header.ejs b/webui/views/partials/header.ejs new file mode 100644 index 0000000..86c3558 --- /dev/null +++ b/webui/views/partials/header.ejs @@ -0,0 +1,29 @@ + + + + + + <%= title %> + + + + +
diff --git a/webui/views/share.ejs b/webui/views/share.ejs new file mode 100644 index 0000000..57da518 --- /dev/null +++ b/webui/views/share.ejs @@ -0,0 +1,117 @@ +<%- include('partials/header', { title: 'Share detail', user }) %> +<% const isOwner = share.owner_upn === user.upn; %> +
+
+
+

<%= share.name %>

+
Owner: <%= share.owner_upn %>
+
State: <%= share.state %>
+
+ <% if (isOwner) { %> +
+ +
+ <% } %> +
+
+ +
+

Members

+ <% if (error) { %> +
<%= error %>
+ <% } %> + <% if (isOwner) { %> +
+ + + + +
+
+ + + + +
+ <% } %> + +
    + <% members.forEach((member) => { %> +
  • +
    +
    <%= member.upn || member.name %>
    +
    <%= member.type %>
    +
    +
    + <%= member.role.toUpperCase() %> + <% if (isOwner) { %> +
    + + + + +
    + <% } %> +
    +
  • + <% }) %> +
+
+ +
+

Local groups

+ <% if (isOwner) { %> +
+ + + +
+ <% } %> +
Groups are stored in SQLite and can be assigned roles per share.
+
+ <% if (!groups.length) { %> +
No local groups yet.
+ <% } %> + <% groups.forEach((group) => { %> +
+
+
+
<%= group.name %>
+
<%= group.member_count %> members
+
+
+ <% if (isOwner) { %> +
+ + + +
+ <% } %> +
    + <% group.members.forEach((member) => { %> +
  • + <%= member.user_upn %> + <% if (isOwner) { %> +
    + + + + +
    + <% } %> +
  • + <% }) %> +
+
+ <% }) %> +
+
+<%- include('partials/footer') %>