initial commit
This commit is contained in:
20
.env.example
Normal file
20
.env.example
Normal file
@@ -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
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
webui/node_modules/
|
||||||
|
traefik/certs/
|
||||||
|
*.log
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
166
README.md
Normal file
166
README.md
Normal file
@@ -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\<shareName>` are separate shares pointing to `/data/shares/<shareName>`.
|
||||||
|
- **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/<username>`
|
||||||
|
- `/data/shares/<shareName>`
|
||||||
|
- `/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 = <owner+rw+ro users>`
|
||||||
|
- `write list = <owner+rw users>`
|
||||||
|
- `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/<shareName>`.
|
||||||
|
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-<timestamp>.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`
|
||||||
17
backupd/Dockerfile
Normal file
17
backupd/Dockerfile
Normal file
@@ -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"]
|
||||||
53
backupd/backup.sh
Normal file
53
backupd/backup.sh
Normal file
@@ -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" <<EOF
|
||||||
|
{
|
||||||
|
"timestamp": "${timestamp}",
|
||||||
|
"data_tar": "data.tar.zst",
|
||||||
|
"database": "app.db",
|
||||||
|
"sizes": {
|
||||||
|
"data_tar": ${data_size},
|
||||||
|
"database": ${db_size}
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"data_tar": "${data_sha}",
|
||||||
|
"database": "${db_sha}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cd "${workdir}"
|
||||||
|
xorriso -as mkisofs -o "${workdir}/backup-${timestamp}.iso" "backup-${timestamp}"
|
||||||
|
|
||||||
|
if [ "${compression}" = "gzip" ]; then
|
||||||
|
gzip -9 "${workdir}/backup-${timestamp}.iso"
|
||||||
|
archive="${workdir}/backup-${timestamp}.iso.gz"
|
||||||
|
else
|
||||||
|
zstd -19 "${workdir}/backup-${timestamp}.iso"
|
||||||
|
archive="${workdir}/backup-${timestamp}.iso.zst"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${remote_path}"
|
||||||
|
mv "${archive}" "${remote_path}/"
|
||||||
|
|
||||||
|
rm -rf "${workdir}"
|
||||||
10
backupd/entrypoint.sh
Normal file
10
backupd/entrypoint.sh
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CRON_EXPR="${BACKUP_CRON:-0 2 * * *}"
|
||||||
|
|
||||||
|
cat > /etc/backup-cron <<EOF
|
||||||
|
${CRON_EXPR} /usr/local/bin/backup.sh
|
||||||
|
EOF
|
||||||
|
|
||||||
|
exec /usr/local/bin/supercronic /etc/backup-cron
|
||||||
96
docker-compose.yml
Normal file
96
docker-compose.yml
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.1
|
||||||
|
container_name: aad-files-traefik
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- --providers.docker=true
|
||||||
|
- --providers.docker.exposedbydefault=false
|
||||||
|
- --providers.file.filename=/etc/traefik/dynamic.yml
|
||||||
|
- --entrypoints.web.address=:80
|
||||||
|
- --entrypoints.websecure.address=:443
|
||||||
|
- --api.dashboard=true
|
||||||
|
ports:
|
||||||
|
- 80:80
|
||||||
|
- 443:443
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||||
|
- ./traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||||
|
- ./traefik/certs:/certs:ro
|
||||||
|
environment:
|
||||||
|
- TRAEFIK_HOSTNAME=${TRAEFIK_HOSTNAME}
|
||||||
|
|
||||||
|
webui:
|
||||||
|
build:
|
||||||
|
context: ./webui
|
||||||
|
container_name: aad-files-webui
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- ENTRA_TENANT_ID=${ENTRA_TENANT_ID}
|
||||||
|
- ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID}
|
||||||
|
- ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET}
|
||||||
|
- ENTRA_REDIRECT_URI=${ENTRA_REDIRECT_URI}
|
||||||
|
- ALLOWED_UPN_SUFFIX=${ALLOWED_UPN_SUFFIX}
|
||||||
|
- WEBUI_SESSION_SECRET=${WEBUI_SESSION_SECRET}
|
||||||
|
- DOMAIN_REALM=${DOMAIN_REALM}
|
||||||
|
- SAMBA_GENERATED_PATH=/samba-generated/shares.generated.conf
|
||||||
|
- DATA_ROOT=/data
|
||||||
|
- FILESVC_UID=10050
|
||||||
|
- FILESVC_GID=10050
|
||||||
|
volumes:
|
||||||
|
- /srv/files/data:/data
|
||||||
|
- samba-generated:/samba-generated
|
||||||
|
- webui-db:/var/lib/webui
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.webui.rule=Host(`${TRAEFIK_HOSTNAME}`)
|
||||||
|
- traefik.http.routers.webui.entrypoints=websecure
|
||||||
|
- traefik.http.routers.webui.tls=true
|
||||||
|
- traefik.http.services.webui.loadbalancer.server.port=3000
|
||||||
|
depends_on:
|
||||||
|
- samba
|
||||||
|
|
||||||
|
samba:
|
||||||
|
build:
|
||||||
|
context: ./samba
|
||||||
|
container_name: aad-files-samba
|
||||||
|
restart: unless-stopped
|
||||||
|
hostname: ${SAMBA_NETBIOS_NAME}
|
||||||
|
environment:
|
||||||
|
- DOMAIN_REALM=${DOMAIN_REALM}
|
||||||
|
- DOMAIN_WORKGROUP=${DOMAIN_WORKGROUP}
|
||||||
|
- DOMAIN_JOIN_USER=${DOMAIN_JOIN_USER}
|
||||||
|
- DOMAIN_JOIN_PASSWORD=${DOMAIN_JOIN_PASSWORD}
|
||||||
|
- SAMBA_NETBIOS_NAME=${SAMBA_NETBIOS_NAME}
|
||||||
|
- FILESVC_UID=10050
|
||||||
|
- FILESVC_GID=10050
|
||||||
|
ports:
|
||||||
|
- 445:445
|
||||||
|
volumes:
|
||||||
|
- /srv/files/data:/data
|
||||||
|
- samba-state:/var/lib/samba
|
||||||
|
- samba-cache:/var/cache/samba
|
||||||
|
- samba-generated:/samba-generated
|
||||||
|
|
||||||
|
backupd:
|
||||||
|
build:
|
||||||
|
context: ./backupd
|
||||||
|
container_name: aad-files-backupd
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- BACKUP_CRON=${BACKUP_CRON}
|
||||||
|
- BACKUP_REMOTE_PATH=${BACKUP_REMOTE_PATH}
|
||||||
|
- BACKUP_COMPRESSION=${BACKUP_COMPRESSION}
|
||||||
|
- SQLITE_DB_PATH=/var/lib/webui/app.db
|
||||||
|
volumes:
|
||||||
|
- /srv/files/data:/data:ro
|
||||||
|
- /srv/files/remote-backup:/remote
|
||||||
|
- webui-db:/var/lib/webui:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
samba-generated:
|
||||||
|
samba-state:
|
||||||
|
samba-cache:
|
||||||
|
webui-db:
|
||||||
19
samba/Dockerfile
Normal file
19
samba/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
samba winbind krb5-user smbclient inotify-tools gettext-base \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY smb.conf.template /etc/samba/smb.conf.template
|
||||||
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
COPY samba-private-mkdir.sh /usr/local/bin/samba-private-mkdir
|
||||||
|
COPY watch-reload.sh /usr/local/bin/watch-reload
|
||||||
|
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh \
|
||||||
|
/usr/local/bin/samba-private-mkdir \
|
||||||
|
/usr/local/bin/watch-reload
|
||||||
|
|
||||||
|
EXPOSE 445
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
57
samba/entrypoint.sh
Normal file
57
samba/entrypoint.sh
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
FILESVC_UID="${FILESVC_UID:-10050}"
|
||||||
|
FILESVC_GID="${FILESVC_GID:-10050}"
|
||||||
|
|
||||||
|
if ! getent group filesvc >/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 <<EOF
|
||||||
|
[libdefaults]
|
||||||
|
default_realm = ${DOMAIN_REALM}
|
||||||
|
dns_lookup_realm = true
|
||||||
|
dns_lookup_kdc = true
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f /etc/samba/smb.conf.template ]; then
|
||||||
|
envsubst < /etc/samba/smb.conf.template > /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}
|
||||||
24
samba/samba-private-mkdir.sh
Normal file
24
samba/samba-private-mkdir.sh
Normal file
@@ -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
|
||||||
47
samba/smb.conf.template
Normal file
47
samba/smb.conf.template
Normal file
@@ -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
|
||||||
9
samba/watch-reload.sh
Normal file
9
samba/watch-reload.sh
Normal file
@@ -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
|
||||||
15
scripts/generate-self-signed.sh
Normal file
15
scripts/generate-self-signed.sh
Normal file
@@ -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}"
|
||||||
4
traefik/dynamic.yml
Normal file
4
traefik/dynamic.yml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
tls:
|
||||||
|
certificates:
|
||||||
|
- certFile: /certs/traefik.crt
|
||||||
|
keyFile: /certs/traefik.key
|
||||||
17
traefik/traefik.yml
Normal file
17
traefik/traefik.yml
Normal file
@@ -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"
|
||||||
22
webui/Dockerfile
Normal file
22
webui/Dockerfile
Normal file
@@ -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"]
|
||||||
31
webui/migrations/001_init.sql
Normal file
31
webui/migrations/001_init.sql
Normal file
@@ -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)
|
||||||
|
);
|
||||||
15
webui/migrations/002_logs.sql
Normal file
15
webui/migrations/002_logs.sql
Normal file
@@ -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);
|
||||||
18
webui/package.json
Normal file
18
webui/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
270
webui/public/styles.css
Normal file
270
webui/public/styles.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
webui/src/auth.js
Normal file
111
webui/src/auth.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
42
webui/src/db.js
Normal file
42
webui/src/db.js
Normal file
@@ -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 };
|
||||||
481
webui/src/index.js
Normal file
481
webui/src/index.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
286
webui/src/shares.js
Normal file
286
webui/src/shares.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
88
webui/views/admin.ejs
Normal file
88
webui/views/admin.ejs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<%- include('partials/header', { title: 'Admin', user }) %>
|
||||||
|
<section class="panel">
|
||||||
|
<h1>Access logs</h1>
|
||||||
|
<p class="muted">Every authenticated request and share operation is recorded.</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time (UTC)</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Method</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Share</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% logs.forEach((log) => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= log.occurred_at %></td>
|
||||||
|
<td><%= log.user_upn %></td>
|
||||||
|
<td><%= log.action %></td>
|
||||||
|
<td><%= log.method %></td>
|
||||||
|
<td><%= log.path %></td>
|
||||||
|
<td><%= log.status_code || '' %></td>
|
||||||
|
<td><%= log.share_id || '' %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Daily activity</h2>
|
||||||
|
<canvas id="dailyChart" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Actions breakdown</h2>
|
||||||
|
<canvas id="actionChart" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const dailyLabels = <%- JSON.stringify(daily.map(item => item.day)) %>;
|
||||||
|
const dailyCounts = <%- JSON.stringify(daily.map(item => item.count)) %>;
|
||||||
|
const actionLabels = <%- JSON.stringify(actions.map(item => item.action)) %>;
|
||||||
|
const actionCounts = <%- JSON.stringify(actions.map(item => item.count)) %>;
|
||||||
|
|
||||||
|
new Chart(document.getElementById('dailyChart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: dailyLabels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Requests',
|
||||||
|
data: dailyCounts,
|
||||||
|
borderColor: '#d26b2f',
|
||||||
|
backgroundColor: 'rgba(210, 107, 47, 0.2)',
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: false } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById('actionChart'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: actionLabels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Count',
|
||||||
|
data: actionCounts,
|
||||||
|
backgroundColor: '#b75522'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: false } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<%- include('partials/footer') %>
|
||||||
47
webui/views/index.ejs
Normal file
47
webui/views/index.ejs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<%- include('partials/header', { title: 'Shares', user }) %>
|
||||||
|
<section class="panel">
|
||||||
|
<h1>Create share</h1>
|
||||||
|
<% if (error) { %>
|
||||||
|
<div class="alert"><%= error %></div>
|
||||||
|
<% } %>
|
||||||
|
<form action="/shares" method="post" class="form-row">
|
||||||
|
<input name="name" placeholder="share-name" required />
|
||||||
|
<button class="primary" type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
<div class="hint">Share names are 1-31 chars and must avoid reserved names like private or IPC$.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>My shares</h2>
|
||||||
|
<% if (!myShares.length) { %>
|
||||||
|
<p class="muted">You do not own any shares yet.</p>
|
||||||
|
<% } %>
|
||||||
|
<ul class="list">
|
||||||
|
<% myShares.forEach((share) => { %>
|
||||||
|
<li>
|
||||||
|
<a href="/shares/<%= share.id %>"><%= share.name %></a>
|
||||||
|
<span class="badge">Owner</span>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Shares you can access</h2>
|
||||||
|
<% if (!shares.length) { %>
|
||||||
|
<p class="muted">No shares are available to you yet.</p>
|
||||||
|
<% } %>
|
||||||
|
<ul class="list">
|
||||||
|
<% shares.forEach((share) => { %>
|
||||||
|
<li>
|
||||||
|
<a href="/shares/<%= share.id %>"><%= share.name %></a>
|
||||||
|
<% if (share.owner_upn === user.upn) { %>
|
||||||
|
<span class="badge">Owner</span>
|
||||||
|
<% } %>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<%- include('partials/footer') %>
|
||||||
7
webui/views/login.ejs
Normal file
7
webui/views/login.ejs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<%- include('partials/header', { title: 'Sign in', user: null }) %>
|
||||||
|
<section class="panel">
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<p>Use Entra ID to access your file shares.</p>
|
||||||
|
<a class="primary" href="/auth/login">Continue with Entra ID</a>
|
||||||
|
</section>
|
||||||
|
<%- include('partials/footer') %>
|
||||||
3
webui/views/partials/footer.ejs
Normal file
3
webui/views/partials/footer.ejs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
webui/views/partials/header.ejs
Normal file
29
webui/views/partials/header.ejs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title><%= title %></title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-mark">AAF</span>
|
||||||
|
<div>
|
||||||
|
<div class="brand-title">AAD File Shares</div>
|
||||||
|
<div class="brand-subtitle">AD-authenticated SMB shares</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (user) { %>
|
||||||
|
<div class="user">
|
||||||
|
<div class="user-name"><%= user.name %></div>
|
||||||
|
<div class="user-upn"><%= user.upn %></div>
|
||||||
|
<a class="secondary" href="/admin">Admin</a>
|
||||||
|
<form action="/auth/logout" method="post">
|
||||||
|
<button class="secondary" type="submit">Sign out</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</header>
|
||||||
|
<main class="main">
|
||||||
117
webui/views/share.ejs
Normal file
117
webui/views/share.ejs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<%- include('partials/header', { title: 'Share detail', user }) %>
|
||||||
|
<% const isOwner = share.owner_upn === user.upn; %>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h1><%= share.name %></h1>
|
||||||
|
<div class="muted">Owner: <%= share.owner_upn %></div>
|
||||||
|
<div class="muted">State: <%= share.state %></div>
|
||||||
|
</div>
|
||||||
|
<% if (isOwner) { %>
|
||||||
|
<form action="/shares/<%= share.id %>/delete" method="post">
|
||||||
|
<button class="danger" type="submit">Disable share</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Members</h2>
|
||||||
|
<% if (error) { %>
|
||||||
|
<div class="alert"><%= error %></div>
|
||||||
|
<% } %>
|
||||||
|
<% if (isOwner) { %>
|
||||||
|
<form action="/shares/<%= share.id %>/members" method="post" class="form-grid">
|
||||||
|
<input name="principal" placeholder="user@domain" required />
|
||||||
|
<input type="hidden" name="principalType" value="user" />
|
||||||
|
<select name="role" required>
|
||||||
|
<option value="rw">RW</option>
|
||||||
|
<option value="ro">RO</option>
|
||||||
|
<option value="owner">Owner</option>
|
||||||
|
</select>
|
||||||
|
<button class="primary" type="submit">Add/Update</button>
|
||||||
|
</form>
|
||||||
|
<form action="/shares/<%= share.id %>/members" method="post" class="form-grid">
|
||||||
|
<input name="principal" placeholder="group-name" required />
|
||||||
|
<input type="hidden" name="principalType" value="group" />
|
||||||
|
<select name="role" required>
|
||||||
|
<option value="rw">RW</option>
|
||||||
|
<option value="ro">RO</option>
|
||||||
|
<option value="owner">Owner</option>
|
||||||
|
</select>
|
||||||
|
<button class="secondary" type="submit">Add Group</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<ul class="list">
|
||||||
|
<% members.forEach((member) => { %>
|
||||||
|
<li>
|
||||||
|
<div>
|
||||||
|
<div class="member"><%= member.upn || member.name %></div>
|
||||||
|
<div class="muted"><%= member.type %></div>
|
||||||
|
</div>
|
||||||
|
<div class="member-actions">
|
||||||
|
<span class="badge"><%= member.role.toUpperCase() %></span>
|
||||||
|
<% if (isOwner) { %>
|
||||||
|
<form action="/shares/<%= share.id %>/members" method="post">
|
||||||
|
<input type="hidden" name="principal" value="<%= member.upn || member.name %>" />
|
||||||
|
<input type="hidden" name="principalType" value="<%= member.type %>" />
|
||||||
|
<input type="hidden" name="action" value="remove" />
|
||||||
|
<button class="secondary" type="submit">Remove</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Local groups</h2>
|
||||||
|
<% if (isOwner) { %>
|
||||||
|
<form action="/groups" method="post" class="form-row">
|
||||||
|
<input name="name" placeholder="group-name" required />
|
||||||
|
<input type="hidden" name="shareId" value="<%= share.id %>" />
|
||||||
|
<button class="primary" type="submit">Create group</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<div class="hint">Groups are stored in SQLite and can be assigned roles per share.</div>
|
||||||
|
<div class="group-grid">
|
||||||
|
<% if (!groups.length) { %>
|
||||||
|
<div class="muted">No local groups yet.</div>
|
||||||
|
<% } %>
|
||||||
|
<% groups.forEach((group) => { %>
|
||||||
|
<div class="group-card">
|
||||||
|
<div class="group-head">
|
||||||
|
<div>
|
||||||
|
<div class="group-title"><%= group.name %></div>
|
||||||
|
<div class="muted"><%= group.member_count %> members</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (isOwner) { %>
|
||||||
|
<form action="/groups/<%= group.id %>/members" method="post" class="form-row">
|
||||||
|
<input name="member" placeholder="user@domain" required />
|
||||||
|
<input type="hidden" name="shareId" value="<%= share.id %>" />
|
||||||
|
<button class="secondary" type="submit">Add member</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<ul class="list compact">
|
||||||
|
<% group.members.forEach((member) => { %>
|
||||||
|
<li>
|
||||||
|
<span><%= member.user_upn %></span>
|
||||||
|
<% if (isOwner) { %>
|
||||||
|
<form action="/groups/<%= group.id %>/members" method="post">
|
||||||
|
<input type="hidden" name="member" value="<%= member.user_upn %>" />
|
||||||
|
<input type="hidden" name="action" value="remove" />
|
||||||
|
<input type="hidden" name="shareId" value="<%= share.id %>" />
|
||||||
|
<button class="secondary" type="submit">Remove</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<%- include('partials/footer') %>
|
||||||
Reference in New Issue
Block a user