v_1.7
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
data
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
npm-debug.log
|
||||||
|
public/vendor/pdfjs
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Copy to .env and adjust. All values are optional.
|
||||||
|
|
||||||
|
# The URL audience phones will open when they scan the QR code.
|
||||||
|
# On a LAN, use this machine's IP, e.g. http://192.168.1.50:3000
|
||||||
|
# Behind a reverse proxy, use the public https URL.
|
||||||
|
PUBLIC_BASE_URL=
|
||||||
|
|
||||||
|
# Set a password to lock the host dashboard. Empty = open (trusted network only).
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# Max upload size in megabytes.
|
||||||
|
MAX_UPLOAD_MB=500
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# QR Presentation — self-contained image (Node + LibreOffice + ffmpeg)
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# LibreOffice handles Office/OpenDocument -> PDF; ffmpeg normalises video;
|
||||||
|
# fonts make converted decks render correctly.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libreoffice-impress \
|
||||||
|
libreoffice-writer \
|
||||||
|
libreoffice-calc \
|
||||||
|
ffmpeg \
|
||||||
|
curl ca-certificates \
|
||||||
|
fonts-liberation fonts-dejavu-core fonts-noto-core \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Vendor PDF.js so the viewer works even on an offline / air-gapped network.
|
||||||
|
RUN mkdir -p public/vendor/pdfjs \
|
||||||
|
&& curl -fsSL https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js -o public/vendor/pdfjs/pdf.min.js \
|
||||||
|
&& curl -fsSL https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js -o public/vendor/pdfjs/pdf.worker.min.js
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
DATA_DIR=/data \
|
||||||
|
HOME=/tmp \
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# LibreOffice needs a writable home for its profile.
|
||||||
|
RUN mkdir -p /data && chown -R node:node /data /tmp
|
||||||
|
USER node
|
||||||
|
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server/index.js"]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -0,0 +1,185 @@
|
|||||||
|
# MPS — Martinhal Presentation System
|
||||||
|
|
||||||
|
A self-hosted app that turns any presentation into a link and a QR code. Upload a
|
||||||
|
**video, PowerPoint, Word doc, Excel sheet, PDF, or image**; the app converts it to a
|
||||||
|
browser-friendly format and generates a QR code. Anyone who scans it watches along on
|
||||||
|
their own phone — **live and in sync with the presenter**, or browsing at their own pace.
|
||||||
|
|
||||||
|
Everything runs in a single Docker container. No cloud, no third-party services except
|
||||||
|
the mail account you choose to connect for email sharing.
|
||||||
|
|
||||||
|
**Version 1.3** · © 2026 Martinhal IT — Joao Vaz
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's new in 1.3
|
||||||
|
|
||||||
|
- **First-run setup.** The very first time you open the app it asks you to **create the
|
||||||
|
administrator account** — no default password to remember or change.
|
||||||
|
- **Rebranded** as MPS — Martinhal Presentation System, with the MPS logo across the app.
|
||||||
|
- **QR Code button** on every ready presentation opens the code on its own page in
|
||||||
|
**large format** — ideal for putting on a projector. It has copy-link, fullscreen and
|
||||||
|
print actions.
|
||||||
|
- **Bigger presentation cards** on the host dashboard (twice as wide, 50% taller) so the
|
||||||
|
QR preview and controls are easier to use.
|
||||||
|
|
||||||
|
### From 1.2
|
||||||
|
|
||||||
|
Login page and user accounts, admin-only user management, mail settings and activity logs,
|
||||||
|
email sharing, PurelyMail SMTP+IMAP with a copy saved to **Sent**, and First/Last slide
|
||||||
|
controls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start (Docker Compose)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Point QR codes at an address phones can actually reach (LAN IP or public URL).
|
||||||
|
export PUBLIC_BASE_URL=http://192.168.1.50:3000
|
||||||
|
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://<host>:3000`. On first launch you'll be asked to **create the admin
|
||||||
|
account**. After that, sign in, upload a file, and hit **Present**. Show the QR code
|
||||||
|
(**Share / QR**, or the **QR Code** button for a full-screen page) and the room scans to
|
||||||
|
join.
|
||||||
|
|
||||||
|
> **Unattended installs:** if you'd rather not use the setup screen, set `ADMIN_USERNAME`
|
||||||
|
> and `ADMIN_PASSWORD` before the first start and an admin will be pre-created from those
|
||||||
|
> values instead. They're ignored once an account exists.
|
||||||
|
|
||||||
|
### Plain Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t mps .
|
||||||
|
docker run -d --name mps \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e PUBLIC_BASE_URL=http://192.168.1.50:3000 \
|
||||||
|
-v qr_data:/data \
|
||||||
|
mps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Without Docker (Node 18+)
|
||||||
|
|
||||||
|
Requires `libreoffice`, `ffmpeg`, and fonts on the host.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run fetch-vendor # vendors PDF.js for offline use
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Updating (no data loss)
|
||||||
|
|
||||||
|
All state — user accounts, mail settings, logs, uploaded decks — lives under the `/data`
|
||||||
|
volume, never inside the app code. To update:
|
||||||
|
|
||||||
|
1. Unzip the new version over your existing project folder (code files are replaced;
|
||||||
|
your `data/` and the Docker `qr_data` volume are left untouched).
|
||||||
|
2. Rebuild and restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Your admin account, users, presentations, and mail configuration carry straight over, and
|
||||||
|
the first-run setup screen does **not** reappear.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Users & roles
|
||||||
|
|
||||||
|
| Role | Can do |
|
||||||
|
|---|---|
|
||||||
|
| **admin** | Everything: manage users, configure mail, view logs, see and manage **all** presentations. |
|
||||||
|
| **user** | Sign in, upload and present **their own** decks, share them (link, QR, email). |
|
||||||
|
|
||||||
|
The first admin is created during setup. Manage further accounts at **/users** (admins
|
||||||
|
only). The app prevents removing or demoting the last remaining admin, and you can't
|
||||||
|
delete your own account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sharing a presentation
|
||||||
|
|
||||||
|
Each ready presentation offers several ways to get people in:
|
||||||
|
|
||||||
|
- **QR Code** — opens a dedicated full-screen page with a large, scannable code (plus
|
||||||
|
copy-link, fullscreen, and print). Great for a projector or printed handout.
|
||||||
|
- **Share / QR** (in the presenter view) — a quick pop-over with the code and link.
|
||||||
|
- **Copy link** — the plain audience URL.
|
||||||
|
- **Email** — sends the link and QR straight to recipients (requires mail set up).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email sharing (PurelyMail)
|
||||||
|
|
||||||
|
1. Sign in as an admin → **Manage users → Mail**.
|
||||||
|
2. Tick **Enable email sending**, enter the PurelyMail **account email** and **password**.
|
||||||
|
Servers are prefilled and match PurelyMail's settings:
|
||||||
|
|
||||||
|
| | Host | Port | Security |
|
||||||
|
|---|---|---|---|
|
||||||
|
| SMTP (send) | `smtp.purelymail.com` | `465` | SSL/TLS |
|
||||||
|
| IMAP (Sent copy) | `imap.purelymail.com` | `993` | SSL/TLS |
|
||||||
|
|
||||||
|
3. Save, then **Send test** to confirm it works.
|
||||||
|
|
||||||
|
Every message is delivered over SMTP and a copy is appended to the **Sent** folder over
|
||||||
|
IMAP, so it appears in your normal mail client. The stored mail password lives only in
|
||||||
|
`/data/config.json` on your server and is never sent back to the browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Presenting
|
||||||
|
|
||||||
|
- **Slides:** `« First` · `‹ Prev` · `Next ›` · `Last »`, or the keys `←` `→` `Home` `End`.
|
||||||
|
- **Synced ↔ Free browse:** in Synced mode the audience follows you; in Free browse they
|
||||||
|
move on their own. Audience members who wander off get a one-tap "Rejoin presenter".
|
||||||
|
- **Video:** play / pause / seek are broadcast to everyone. Viewers tap once to allow
|
||||||
|
sound (browsers block autoplay audio until then).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported formats
|
||||||
|
|
||||||
|
| Input | Handled as | Tool |
|
||||||
|
|---|---|---|
|
||||||
|
| `.pdf` | paged deck | served directly |
|
||||||
|
| `.pptx .ppt .odp .pps .ppsx` | paged deck | LibreOffice → PDF |
|
||||||
|
| `.docx .doc .odt .rtf .txt .md` | paged deck | LibreOffice → PDF |
|
||||||
|
| `.xlsx .xls .ods .csv .tsv` | paged deck | LibreOffice → PDF |
|
||||||
|
| `.mp4 .webm .ogg` | synced video | served directly |
|
||||||
|
| `.mov .avi .mkv .wmv .flv .3gp .mpeg .ts` | synced video | ffmpeg → MP4 |
|
||||||
|
| `.png .jpg .jpeg .gif .webp .svg .bmp .avif` | image | served directly |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `PUBLIC_BASE_URL` | derived from request | Absolute URL encoded in QR codes / links. **Set this.** |
|
||||||
|
| `ADMIN_USERNAME` | `admin` | Only for unattended pre-seed (see Quick start). |
|
||||||
|
| `ADMIN_PASSWORD` | *(unset)* | If set on first run, pre-creates an admin instead of showing setup. |
|
||||||
|
| `ADMIN_EMAIL` | *(empty)* | Email for a pre-seeded admin. |
|
||||||
|
| `MAX_UPLOAD_MB` | `500` | Maximum upload size. |
|
||||||
|
| `PORT` | `3000` | Listen port. |
|
||||||
|
| `DATA_DIR` | `/data` (Docker) | Where all state is stored. Mount as a volume. |
|
||||||
|
| `CONVERT_TIMEOUT_MS` | `240000` | Per-file conversion timeout. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Put a reverse proxy with **HTTPS** in front for anything beyond a trusted LAN, and set
|
||||||
|
`PUBLIC_BASE_URL` to the https address.
|
||||||
|
- Passwords are stored as salted scrypt hashes. Login sessions are HttpOnly cookies.
|
||||||
|
- The container runs as a non-root user; all state stays on the mounted volume.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,206 @@
|
|||||||
|
# MPS — Martinhal Presentation System
|
||||||
|
|
||||||
|
A self-hosted app that turns any presentation into a link and a QR code. Upload a
|
||||||
|
**video, PowerPoint, Word doc, Excel sheet, PDF, or image**; the app converts it to a
|
||||||
|
browser-friendly format and generates a QR code. Anyone who scans it watches along on
|
||||||
|
their own phone — **live and in sync with the presenter**, or browsing at their own pace.
|
||||||
|
|
||||||
|
Everything runs in a single Docker container. No cloud, no third-party services except
|
||||||
|
the mail account you choose to connect for email sharing.
|
||||||
|
|
||||||
|
**Version 1.7** · © 2026 Martinhal IT — Joao Vaz
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's new in 1.7
|
||||||
|
|
||||||
|
- **Present Local Only** opens **two browser tabs**: the presenter console (to drive the
|
||||||
|
talk, exactly like **Present**) and a second, chrome-free tab showing the presentation
|
||||||
|
only — no header text, no control buttons. The second tab mirrors the console live, so
|
||||||
|
it's ideal for a projector or second screen. Both open as normal tabs (not pop-up
|
||||||
|
windows).
|
||||||
|
|
||||||
|
#### Since 1.4
|
||||||
|
|
||||||
|
- **Simpler login screen** — just the credentials, nothing else.
|
||||||
|
- **End session** button in the presenter bar (left of Exit): it **invalidates the QR
|
||||||
|
code** and disconnects everyone watching, so the link can't be reused afterwards.
|
||||||
|
- **Refresh regenerates codes** — pressing **Refresh** on the dashboard issues a fresh QR
|
||||||
|
code for every presentation, so links from a finished talk stop working.
|
||||||
|
- The presenter bar now shows a single **QR Code** button (opens the large, scannable
|
||||||
|
page); the old "Share / QR" pop-over was removed.
|
||||||
|
|
||||||
|
#### Since 1.3
|
||||||
|
|
||||||
|
- **First-run setup.** The very first time you open the app it asks you to **create the
|
||||||
|
administrator account** — no default password to remember or change.
|
||||||
|
- **Rebranded** as MPS — Martinhal Presentation System, with the MPS logo across the app.
|
||||||
|
- **QR Code button** on every ready presentation opens the code on its own page in
|
||||||
|
**large format** — ideal for putting on a projector. It has copy-link, fullscreen and
|
||||||
|
print actions.
|
||||||
|
- **Bigger presentation cards** on the host dashboard (twice as wide, 50% taller) so the
|
||||||
|
QR preview and controls are easier to use.
|
||||||
|
|
||||||
|
### From 1.2
|
||||||
|
|
||||||
|
Login page and user accounts, admin-only user management, mail settings and activity logs,
|
||||||
|
email sharing, PurelyMail SMTP+IMAP with a copy saved to **Sent**, and First/Last slide
|
||||||
|
controls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start (Docker Compose)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Point QR codes at an address phones can actually reach (LAN IP or public URL).
|
||||||
|
export PUBLIC_BASE_URL=http://192.168.1.50:3000
|
||||||
|
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://<host>:3000`. On first launch you'll be asked to **create the admin
|
||||||
|
account**. After that, sign in, upload a file, and hit **Present**. Show the QR code
|
||||||
|
(**Share / QR**, or the **QR Code** button for a full-screen page) and the room scans to
|
||||||
|
join.
|
||||||
|
|
||||||
|
> **Unattended installs:** if you'd rather not use the setup screen, set `ADMIN_USERNAME`
|
||||||
|
> and `ADMIN_PASSWORD` before the first start and an admin will be pre-created from those
|
||||||
|
> values instead. They're ignored once an account exists.
|
||||||
|
|
||||||
|
### Plain Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t mps .
|
||||||
|
docker run -d --name mps \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e PUBLIC_BASE_URL=http://192.168.1.50:3000 \
|
||||||
|
-v qr_data:/data \
|
||||||
|
mps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Without Docker (Node 18+)
|
||||||
|
|
||||||
|
Requires `libreoffice`, `ffmpeg`, and fonts on the host.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run fetch-vendor # vendors PDF.js for offline use
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Updating (no data loss)
|
||||||
|
|
||||||
|
All state — user accounts, mail settings, logs, uploaded decks — lives under the `/data`
|
||||||
|
volume, never inside the app code. To update:
|
||||||
|
|
||||||
|
1. Unzip the new version over your existing project folder (code files are replaced;
|
||||||
|
your `data/` and the Docker `qr_data` volume are left untouched).
|
||||||
|
2. Rebuild and restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Your admin account, users, presentations, and mail configuration carry straight over, and
|
||||||
|
the first-run setup screen does **not** reappear.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Users & roles
|
||||||
|
|
||||||
|
| Role | Can do |
|
||||||
|
|---|---|
|
||||||
|
| **admin** | Everything: manage users, configure mail, view logs, see and manage **all** presentations. |
|
||||||
|
| **user** | Sign in, upload and present **their own** decks, share them (link, QR, email). |
|
||||||
|
|
||||||
|
The first admin is created during setup. Manage further accounts at **/users** (admins
|
||||||
|
only). The app prevents removing or demoting the last remaining admin, and you can't
|
||||||
|
delete your own account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sharing a presentation
|
||||||
|
|
||||||
|
Each ready presentation offers several ways to get people in:
|
||||||
|
|
||||||
|
- **QR Code** — the presenter bar opens a dedicated full-screen page with a large,
|
||||||
|
scannable code (plus copy-link, fullscreen, and print). Great for a projector.
|
||||||
|
- **End session** — invalidates the QR code and disconnects viewers when you're done.
|
||||||
|
- **Copy link** — the plain audience URL.
|
||||||
|
- **Email** — sends the link and QR straight to recipients (requires mail set up).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email sharing (PurelyMail)
|
||||||
|
|
||||||
|
1. Sign in as an admin → **Manage users → Mail**.
|
||||||
|
2. Tick **Enable email sending**, enter the PurelyMail **account email** and **password**.
|
||||||
|
Servers are prefilled and match PurelyMail's settings:
|
||||||
|
|
||||||
|
| | Host | Port | Security |
|
||||||
|
|---|---|---|---|
|
||||||
|
| SMTP (send) | `smtp.purelymail.com` | `465` | SSL/TLS |
|
||||||
|
| IMAP (Sent copy) | `imap.purelymail.com` | `993` | SSL/TLS |
|
||||||
|
|
||||||
|
3. Save, then **Send test** to confirm it works.
|
||||||
|
|
||||||
|
Every message is delivered over SMTP and a copy is appended to the **Sent** folder over
|
||||||
|
IMAP, so it appears in your normal mail client. The stored mail password lives only in
|
||||||
|
`/data/config.json` on your server and is never sent back to the browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Presenting
|
||||||
|
|
||||||
|
- **Slides:** `« First` · `‹ Prev` · `Next ›` · `Last »`, or the keys `←` `→` `Home` `End`.
|
||||||
|
- **Present Local Only:** opens the presenter console plus a separate controls-free tab
|
||||||
|
that mirrors it for a projector/second screen. If the second tab doesn't open, allow
|
||||||
|
pop-ups for the site.
|
||||||
|
- **Synced ↔ Free browse:** in Synced mode the audience follows you; in Free browse they
|
||||||
|
move on their own. Audience members who wander off get a one-tap "Rejoin presenter".
|
||||||
|
- **Video:** play / pause / seek are broadcast to everyone. Viewers tap once to allow
|
||||||
|
sound (browsers block autoplay audio until then).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported formats
|
||||||
|
|
||||||
|
| Input | Handled as | Tool |
|
||||||
|
|---|---|---|
|
||||||
|
| `.pdf` | paged deck | served directly |
|
||||||
|
| `.pptx .ppt .odp .pps .ppsx` | paged deck | LibreOffice → PDF |
|
||||||
|
| `.docx .doc .odt .rtf .txt .md` | paged deck | LibreOffice → PDF |
|
||||||
|
| `.xlsx .xls .ods .csv .tsv` | paged deck | LibreOffice → PDF |
|
||||||
|
| `.mp4 .webm .ogg` | synced video | served directly |
|
||||||
|
| `.mov .avi .mkv .wmv .flv .3gp .mpeg .ts` | synced video | ffmpeg → MP4 |
|
||||||
|
| `.png .jpg .jpeg .gif .webp .svg .bmp .avif` | image | served directly |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `PUBLIC_BASE_URL` | derived from request | Absolute URL encoded in QR codes / links. **Set this.** |
|
||||||
|
| `ADMIN_USERNAME` | `admin` | Only for unattended pre-seed (see Quick start). |
|
||||||
|
| `ADMIN_PASSWORD` | *(unset)* | If set on first run, pre-creates an admin instead of showing setup. |
|
||||||
|
| `ADMIN_EMAIL` | *(empty)* | Email for a pre-seeded admin. |
|
||||||
|
| `MAX_UPLOAD_MB` | `500` | Maximum upload size. |
|
||||||
|
| `PORT` | `3000` | Listen port. |
|
||||||
|
| `DATA_DIR` | `/data` (Docker) | Where all state is stored. Mount as a volume. |
|
||||||
|
| `CONVERT_TIMEOUT_MS` | `240000` | Per-file conversion timeout. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Put a reverse proxy with **HTTPS** in front for anything beyond a trusted LAN, and set
|
||||||
|
`PUBLIC_BASE_URL` to the https address.
|
||||||
|
- Passwords are stored as salted scrypt hashes. Login sessions are HttpOnly cookies.
|
||||||
|
- The container runs as a non-root user; all state stays on the mounted volume.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
qr-presentation:
|
||||||
|
build: .
|
||||||
|
image: qr-presentation
|
||||||
|
container_name: qr-presentation
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
# Absolute URL that audience phones can reach. Strongly recommended.
|
||||||
|
# e.g. http://192.168.1.50:3000 or https://present.example.com
|
||||||
|
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}
|
||||||
|
# Leave empty for an open dashboard on a trusted LAN, or set a password.
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||||
|
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-500}
|
||||||
|
volumes:
|
||||||
|
- qr_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
qr_data:
|
||||||
Generated
+1791
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "qr-presentation",
|
||||||
|
"version": "1.7.0",
|
||||||
|
"description": "Self-hosted app to broadcast a presentation (video, PowerPoint, Word, Excel, PDF, images) to an audience who join by scanning a QR code. Includes user accounts, an admin console, and email sharing via PurelyMail.",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"main": "server/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server/index.js",
|
||||||
|
"dev": "node --watch server/index.js",
|
||||||
|
"fetch-vendor": "bash scripts/fetch-vendor.sh"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"imapflow": "^1.0.164",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
|
"nodemailer": "^6.9.14",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"socket.io": "^4.7.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0d0f22;
|
||||||
|
--bg-2: #151935;
|
||||||
|
--panel: #1b2044;
|
||||||
|
--ink: #eef0ff;
|
||||||
|
--muted: #9aa1d6;
|
||||||
|
--faint: #6a6fa0;
|
||||||
|
--line: rgba(255, 255, 255, 0.10);
|
||||||
|
--line-strong: rgba(255, 255, 255, 0.18);
|
||||||
|
--accent: #ffb020; /* "on air" signal amber — the one bold note */
|
||||||
|
--accent-ink: #2a1c00;
|
||||||
|
--indigo: #7b86ff;
|
||||||
|
--ok: #47d6a0;
|
||||||
|
--danger: #ff6b6b;
|
||||||
|
--radius: 14px;
|
||||||
|
--shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.7);
|
||||||
|
--display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--body: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body { height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--body);
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 82% -8%, rgba(123, 134, 255, 0.16), transparent 60%),
|
||||||
|
radial-gradient(900px 500px at 6% 108%, rgba(255, 176, 32, 0.10), transparent 55%),
|
||||||
|
var(--bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: inherit; }
|
||||||
|
|
||||||
|
h1, h2, h3 { font-family: var(--display); font-weight: 600; letter-spacing: -0.02em; margin: 0; }
|
||||||
|
|
||||||
|
.wrap { max-width: 1120px; margin: 0 auto; padding: 28px 22px 72px; }
|
||||||
|
|
||||||
|
/* --- top bar --- */
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 16px; padding-bottom: 26px;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; font-family: var(--display); font-weight: 600; }
|
||||||
|
.lamp {
|
||||||
|
width: 12px; height: 12px; border-radius: 50%;
|
||||||
|
background: var(--faint); box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
.lamp.on { background: var(--accent); box-shadow: 0 0 14px 2px rgba(255, 176, 32, 0.7); animation: pulse 1.8s ease-in-out infinite; }
|
||||||
|
@keyframes pulse { 50% { opacity: 0.55; } }
|
||||||
|
.brand small { color: var(--muted); font-weight: 400; letter-spacing: 0.02em; }
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--display); text-transform: uppercase; letter-spacing: 0.22em;
|
||||||
|
font-size: 11px; color: var(--accent); font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- buttons --- */
|
||||||
|
.btn {
|
||||||
|
font: inherit; font-weight: 600; cursor: pointer; border: 1px solid var(--line-strong);
|
||||||
|
background: var(--panel); color: var(--ink); padding: 10px 16px; border-radius: 10px;
|
||||||
|
display: inline-flex; align-items: center; gap: 8px; transition: transform .06s ease, background .15s ease, border-color .15s ease;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--indigo); }
|
||||||
|
.btn:active { transform: translateY(1px); }
|
||||||
|
.btn.primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
|
||||||
|
.btn.primary:hover { filter: brightness(1.05); }
|
||||||
|
.btn.ghost { background: transparent; }
|
||||||
|
.btn.danger { color: var(--danger); border-color: rgba(255, 107, 107, 0.35); background: transparent; }
|
||||||
|
.btn.small { padding: 7px 11px; font-size: 13px; }
|
||||||
|
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.btn:focus-visible, a:focus-visible, input:focus-visible { outline: 2px solid var(--indigo); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* --- cards --- */
|
||||||
|
.card {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--bg-2);
|
||||||
|
border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- upload dropzone --- */
|
||||||
|
.hero { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 26px; align-items: stretch; }
|
||||||
|
.hero h1 { font-size: clamp(30px, 5vw, 48px); line-height: 1.02; }
|
||||||
|
.hero p.lede { color: var(--muted); font-size: 17px; margin: 16px 0 0; max-width: 42ch; }
|
||||||
|
.drop {
|
||||||
|
margin-top: 24px; border: 1.5px dashed var(--line-strong); border-radius: var(--radius);
|
||||||
|
padding: 30px; text-align: center; background: rgba(255, 255, 255, 0.015);
|
||||||
|
transition: border-color .15s ease, background .15s ease;
|
||||||
|
}
|
||||||
|
.drop.drag { border-color: var(--accent); background: rgba(255, 176, 32, 0.06); }
|
||||||
|
.drop .big { font-family: var(--display); font-size: 18px; }
|
||||||
|
.drop .sub { color: var(--faint); font-size: 13px; margin-top: 6px; }
|
||||||
|
.field { margin-top: 14px; }
|
||||||
|
.field input[type="text"] {
|
||||||
|
width: 100%; font: inherit; color: var(--ink); background: var(--bg); border: 1px solid var(--line-strong);
|
||||||
|
padding: 11px 13px; border-radius: 10px;
|
||||||
|
}
|
||||||
|
.progress { height: 8px; border-radius: 99px; background: rgba(255,255,255,0.08); overflow: hidden; margin-top: 14px; display: none; }
|
||||||
|
.progress > i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .2s ease; }
|
||||||
|
|
||||||
|
/* --- session list --- */
|
||||||
|
.section-head { display: flex; align-items: baseline; justify-content: space-between; margin: 44px 0 16px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||||
|
.tile { padding: 16px; display: flex; gap: 14px; }
|
||||||
|
.tile .qr { width: 84px; height: 84px; border-radius: 10px; background: #fff; padding: 6px; flex: none; }
|
||||||
|
.tile .qr img { width: 100%; height: 100%; display: block; }
|
||||||
|
.tile .meta { min-width: 0; flex: 1; }
|
||||||
|
.tile .title { font-family: var(--display); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.tag { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; padding: 3px 8px; border-radius: 99px; border: 1px solid var(--line); color: var(--muted); text-transform: capitalize; }
|
||||||
|
.tag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); }
|
||||||
|
.tag.ready .dot { background: var(--ok); } .tag.ready { color: var(--ok); }
|
||||||
|
.tag.processing .dot { background: var(--accent); }
|
||||||
|
.tag.error .dot { background: var(--danger); } .tag.error { color: var(--danger); }
|
||||||
|
.tile .row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||||
|
.empty { color: var(--faint); border: 1px dashed var(--line); border-radius: var(--radius); padding: 34px; text-align: center; }
|
||||||
|
|
||||||
|
.note { font-size: 13px; color: var(--faint); margin-top: 10px; }
|
||||||
|
.warn { color: var(--accent); }
|
||||||
|
|
||||||
|
/* --- stage (present + view) --- */
|
||||||
|
.stage-page { display: flex; flex-direction: column; height: 100dvh; }
|
||||||
|
.stage-bar {
|
||||||
|
display: flex; align-items: center; gap: 12px; padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--line); background: rgba(9, 10, 26, 0.6); backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.stage-bar .spacer { flex: 1; }
|
||||||
|
.pill { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--muted);
|
||||||
|
border: 1px solid var(--line); border-radius: 99px; padding: 5px 11px; }
|
||||||
|
.pill b { color: var(--ink); }
|
||||||
|
.stage {
|
||||||
|
flex: 1; position: relative; display: flex; align-items: center; justify-content: center;
|
||||||
|
overflow: hidden; padding: 14px; min-height: 0;
|
||||||
|
}
|
||||||
|
.pdf-canvas, .stage video, .stage img { max-width: 100%; max-height: 100%; border-radius: 10px; box-shadow: var(--shadow); }
|
||||||
|
.stage video { background: #000; }
|
||||||
|
.stage .msg { color: var(--muted); text-align: center; max-width: 34ch; }
|
||||||
|
.stage .msg .spinner {
|
||||||
|
width: 34px; height: 34px; margin: 0 auto 16px; border-radius: 50%;
|
||||||
|
border: 3px solid var(--line-strong); border-top-color: var(--accent); animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* presenter control bar */
|
||||||
|
.controls { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--line); flex-wrap: wrap; }
|
||||||
|
.pageind { font-family: var(--display); min-width: 74px; text-align: center; }
|
||||||
|
.seg { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 10px; overflow: hidden; }
|
||||||
|
.seg button { font: inherit; font-weight: 600; background: transparent; color: var(--muted); border: 0; padding: 8px 12px; cursor: pointer; }
|
||||||
|
.seg button.active { background: var(--accent); color: var(--accent-ink); }
|
||||||
|
|
||||||
|
/* audience follow banner */
|
||||||
|
.followbar {
|
||||||
|
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
|
||||||
|
display: none; align-items: center; gap: 10px;
|
||||||
|
background: var(--panel); border: 1px solid var(--line-strong); border-radius: 99px;
|
||||||
|
padding: 8px 8px 8px 16px; box-shadow: var(--shadow); font-size: 14px;
|
||||||
|
}
|
||||||
|
.followbar.show { display: flex; }
|
||||||
|
|
||||||
|
/* tap-to-join overlay for synced video (autoplay policy) */
|
||||||
|
.tapjoin {
|
||||||
|
position: absolute; inset: 0; display: none; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 14px; background: rgba(8, 9, 22, 0.82); backdrop-filter: blur(4px); text-align: center; padding: 24px;
|
||||||
|
}
|
||||||
|
.tapjoin.show { display: flex; }
|
||||||
|
.tapjoin .big { font-family: var(--display); font-size: 22px; }
|
||||||
|
|
||||||
|
/* --- present: QR sidebar --- */
|
||||||
|
.share { display: grid; grid-template-columns: 260px 1fr; gap: 20px; align-items: center; }
|
||||||
|
.ticket {
|
||||||
|
background: #fff; color: #111; border-radius: 16px; padding: 16px; text-align: center; box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.ticket img { width: 100%; display: block; border-radius: 8px; }
|
||||||
|
.ticket .cap { font-family: var(--display); font-weight: 600; margin-top: 10px; font-size: 14px; color: #333; }
|
||||||
|
.linkbox { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
.linkbox input { flex: 1; font: inherit; background: var(--bg); color: var(--ink); border: 1px solid var(--line-strong); border-radius: 10px; padding: 10px 12px; }
|
||||||
|
|
||||||
|
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%) translateY(20px);
|
||||||
|
background: var(--ink); color: #111; padding: 10px 16px; border-radius: 10px; font-weight: 600;
|
||||||
|
opacity: 0; pointer-events: none; transition: all .25s ease; z-index: 50; }
|
||||||
|
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
|
||||||
|
/* modal (login + share) */
|
||||||
|
.overlay { position: fixed; inset: 0; background: rgba(6, 7, 18, 0.7); display: none; align-items: center; justify-content: center; padding: 20px; z-index: 40; }
|
||||||
|
.overlay.show { display: flex; }
|
||||||
|
.modal { width: 100%; max-width: 420px; padding: 26px; }
|
||||||
|
.modal h2 { font-size: 22px; }
|
||||||
|
.modal p { color: var(--muted); margin: 8px 0 18px; }
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.hero { grid-template-columns: 1fr; }
|
||||||
|
.share { grid-template-columns: 1fr; }
|
||||||
|
.ticket { max-width: 300px; margin: 0 auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== v1.2 additions ===================== */
|
||||||
|
|
||||||
|
/* footer (every page except the login page) */
|
||||||
|
.app-footer {
|
||||||
|
text-align: center; color: var(--faint); font-size: 12.5px; letter-spacing: 0.01em;
|
||||||
|
padding: 22px 16px; border-top: 1px solid var(--line); margin-top: 40px;
|
||||||
|
}
|
||||||
|
.stage-page .app-footer { margin-top: 0; padding: 7px 16px; font-size: 11.5px; flex: none; }
|
||||||
|
|
||||||
|
/* login page */
|
||||||
|
.login-shell { min-height: 100dvh; display: grid; place-items: center; padding: 22px; }
|
||||||
|
.login-card { width: 100%; max-width: 400px; padding: 30px; }
|
||||||
|
.login-card .brand { justify-content: center; margin-bottom: 4px; font-size: 20px; }
|
||||||
|
.login-card h1 { font-size: 24px; text-align: center; margin-top: 14px; }
|
||||||
|
.login-card p.sub { color: var(--muted); text-align: center; margin: 6px 0 22px; }
|
||||||
|
.form-row { margin-bottom: 12px; }
|
||||||
|
.form-row label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; }
|
||||||
|
.input {
|
||||||
|
width: 100%; font: inherit; color: var(--ink); background: var(--bg);
|
||||||
|
border: 1px solid var(--line-strong); padding: 11px 13px; border-radius: 10px;
|
||||||
|
}
|
||||||
|
.input:focus-visible { outline: 2px solid var(--indigo); outline-offset: 1px; }
|
||||||
|
.err { color: var(--danger); font-size: 13px; min-height: 18px; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* user menu in the top bar */
|
||||||
|
.usermenu { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.usermenu .who { font-size: 13px; color: var(--muted); }
|
||||||
|
.usermenu .who b { color: var(--ink); }
|
||||||
|
.role-badge { font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; padding: 2px 7px; border-radius: 99px; border: 1px solid var(--line-strong); color: var(--muted); }
|
||||||
|
.role-badge.admin { color: var(--accent); border-color: rgba(255,176,32,0.4); }
|
||||||
|
|
||||||
|
/* tabs (management page) */
|
||||||
|
.tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--line); margin: 18px 0 22px; }
|
||||||
|
.tabs button {
|
||||||
|
font: inherit; font-weight: 600; background: transparent; border: 0; color: var(--muted);
|
||||||
|
padding: 12px 14px; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tabs button.active { color: var(--ink); border-bottom-color: var(--accent); }
|
||||||
|
.tab-panel { display: none; }
|
||||||
|
.tab-panel.active { display: block; }
|
||||||
|
|
||||||
|
/* tables */
|
||||||
|
.table { width: 100%; border-collapse: collapse; }
|
||||||
|
.table th, .table td { text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--line); font-size: 14px; }
|
||||||
|
.table th { color: var(--faint); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||||
|
.table td .row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.mono { font-variant-numeric: tabular-nums; color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
/* forms grid */
|
||||||
|
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||||
|
.grid2 .full { grid-column: 1 / -1; }
|
||||||
|
.check { display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: 14px; }
|
||||||
|
.check input { width: 17px; height: 17px; accent-color: var(--accent); }
|
||||||
|
.panel-pad { padding: 22px; }
|
||||||
|
.hint { color: var(--faint); font-size: 13px; margin: 4px 0 0; }
|
||||||
|
|
||||||
|
/* log stream */
|
||||||
|
.logs { font-size: 13px; }
|
||||||
|
.log-line { display: grid; grid-template-columns: 150px 80px 130px 1fr; gap: 12px; padding: 8px 10px; border-bottom: 1px solid var(--line); align-items: baseline; }
|
||||||
|
.log-line .lv { text-transform: uppercase; font-size: 10px; letter-spacing: 0.1em; }
|
||||||
|
.log-line .lv.warn { color: var(--accent); } .log-line .lv.error { color: var(--danger); } .log-line .lv.info { color: var(--faint); }
|
||||||
|
.log-line .actor { color: var(--muted); }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.grid2 { grid-template-columns: 1fr; }
|
||||||
|
.log-line { grid-template-columns: 1fr; gap: 2px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== v1.3 additions ===================== */
|
||||||
|
|
||||||
|
/* brand: logo image + full product name */
|
||||||
|
.brand-logo { height: 30px; width: auto; display: block; flex: none; }
|
||||||
|
.brand-name { font-family: var(--display); font-weight: 600; letter-spacing: -0.01em; }
|
||||||
|
.brand-name small { color: var(--muted); font-weight: 400; }
|
||||||
|
.login-logo { display: block; width: 190px; max-width: 72%; height: auto; margin: 2px auto 8px; }
|
||||||
|
|
||||||
|
/* presentation cards: twice as wide and 50% taller */
|
||||||
|
.grid { grid-template-columns: repeat(auto-fill, minmax(min(600px, 100%), 1fr)); }
|
||||||
|
.tile { min-height: 200px; padding: 22px; gap: 20px; align-items: center; }
|
||||||
|
.tile .qr { width: 132px; height: 132px; }
|
||||||
|
.tile .title { font-size: 19px; }
|
||||||
|
|
||||||
|
/* large-format QR page */
|
||||||
|
.qr-page { min-height: 100dvh; display: flex; flex-direction: column; }
|
||||||
|
.qr-head { padding: 14px 20px; border-bottom: 1px solid var(--line); }
|
||||||
|
.qr-stage { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 26px; gap: 14px; }
|
||||||
|
.qr-stage h1 { font-size: clamp(24px, 4vw, 40px); }
|
||||||
|
.qr-big { background: #fff; border-radius: 18px; padding: 22px; box-shadow: var(--shadow); }
|
||||||
|
.qr-big img { display: block; width: min(58vh, 78vw); height: auto; image-rendering: pixelated; }
|
||||||
|
.qr-big .msg { width: min(58vh, 78vw); aspect-ratio: 1; display: flex; align-items: center; justify-content: center; color: var(--faint); }
|
||||||
|
.qr-link { color: var(--indigo); word-break: break-all; font-size: 15px; }
|
||||||
|
.qr-actions { display: flex; gap: 10px; margin-top: 6px; flex-wrap: wrap; justify-content: center; }
|
||||||
|
@media print {
|
||||||
|
.qr-head, .qr-actions, .qr-link, .app-footer, .eyebrow { display: none !important; }
|
||||||
|
body { background: #fff; }
|
||||||
|
.qr-big { box-shadow: none; }
|
||||||
|
.qr-stage h1 { color: #111; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== v1.5 additions ===================== */
|
||||||
|
|
||||||
|
/* chrome-free projection surface (Present Local Only) */
|
||||||
|
.display-stage {
|
||||||
|
position: fixed; inset: 0; background: #000;
|
||||||
|
display: flex; align-items: center; justify-content: center; overflow: hidden;
|
||||||
|
}
|
||||||
|
.display-stage .pdf-canvas,
|
||||||
|
.display-stage img,
|
||||||
|
.display-stage video {
|
||||||
|
max-width: 100vw; max-height: 100vh; width: auto; height: auto;
|
||||||
|
border-radius: 0; box-shadow: none; display: block;
|
||||||
|
}
|
||||||
|
.display-stage video { background: #000; }
|
||||||
|
.display-msg {
|
||||||
|
color: var(--muted); font-family: var(--body); font-size: 18px; text-align: center; padding: 24px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>Presentation — MPS</title>
|
||||||
|
<link rel="stylesheet" href="/css/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Chrome-free display: the presentation only, no header text and no controls.
|
||||||
|
It mirrors whatever the presenter console (the other tab) is showing. -->
|
||||||
|
<div class="display-stage" id="stage"></div>
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script type="module" src="/js/display.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
@@ -0,0 +1,79 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>QR Presentation — Host</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/css/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand"><img class="brand-logo" src="/img/mps-icon.png" alt="" /> <span class="brand-name">MPS – Martinhal Presentation System</span></div>
|
||||||
|
<div class="usermenu" id="usermenu">
|
||||||
|
<span class="who"></span>
|
||||||
|
<a class="btn ghost small" id="manageBtn" href="/users" style="display:none;">Manage users</a>
|
||||||
|
<button class="btn ghost small" id="logoutBtn">Sign out</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow">Host console</div>
|
||||||
|
<h1>Put your deck on every phone in the room.</h1>
|
||||||
|
<p class="lede">Drop in a video, PowerPoint, Word doc, Excel sheet, PDF, or image. We turn it into one shareable link and a QR code. Whoever scans it watches along — in sync with you, or at their own pace.</p>
|
||||||
|
<p class="note" id="hostWarn"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="padding:20px;">
|
||||||
|
<div class="eyebrow">New presentation</div>
|
||||||
|
<div class="field">
|
||||||
|
<input type="text" id="title" placeholder="Title (optional)" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="drop" id="drop">
|
||||||
|
<div class="big">Drop a file here</div>
|
||||||
|
<div class="sub" id="dropSub">or click to browse</div>
|
||||||
|
<input type="file" id="file" hidden />
|
||||||
|
</div>
|
||||||
|
<div class="progress" id="progress"><i></i></div>
|
||||||
|
<p class="note" id="uploadNote"></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Your presentations</h2>
|
||||||
|
<button class="btn ghost small" id="refresh">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="list"><div class="empty">Loading…</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- email share modal -->
|
||||||
|
<div class="overlay" id="emailOverlay">
|
||||||
|
<div class="card modal">
|
||||||
|
<div class="eyebrow">Share by email</div>
|
||||||
|
<h2 id="emailTitle">Send the join link</h2>
|
||||||
|
<p>The recipient gets the link and a QR code to open the presentation.</p>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Recipients (comma or space separated)</label>
|
||||||
|
<input class="input" id="emailTo" placeholder="alex@example.com, sam@example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Message (optional)</label>
|
||||||
|
<input class="input" id="emailMsg" placeholder="Join my talk at 3pm" />
|
||||||
|
</div>
|
||||||
|
<div class="err" id="emailErr"></div>
|
||||||
|
<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:8px;">
|
||||||
|
<button class="btn ghost" id="emailCancel">Cancel</button>
|
||||||
|
<button class="btn primary" id="emailSend">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="app-footer">© 2026 Martinhal IT - Joao Vaz - Version 1.7</footer>
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script type="module" src="/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
let config = { supportedHint: '', maxUploadMb: 500, mailAvailable: false };
|
||||||
|
let me = null;
|
||||||
|
|
||||||
|
function toast(msg) {
|
||||||
|
const t = $('#toast');
|
||||||
|
t.textContent = msg; t.classList.add('show');
|
||||||
|
clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.remove('show'), 2200);
|
||||||
|
}
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(path, opts);
|
||||||
|
if (res.status === 401) { location.href = '/login'; throw new Error('Not signed in'); }
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, (c) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- session guard + user menu --------------------------------------------
|
||||||
|
async function boot() {
|
||||||
|
me = await api('/api/me');
|
||||||
|
if (!me.authed) { location.href = '/login'; return; }
|
||||||
|
try { config = await api('/config.json'); } catch {}
|
||||||
|
$('#usermenu .who').innerHTML =
|
||||||
|
`Signed in as <b>${escapeHtml(me.user.username)}</b> <span class="role-badge ${me.user.role}">${me.user.role}</span>`;
|
||||||
|
if (me.user.role === 'admin') $('#manageBtn').style.display = '';
|
||||||
|
$('#dropSub').textContent = `or click to browse — ${config.supportedHint}`;
|
||||||
|
hostWarning();
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#logoutBtn').addEventListener('click', async () => {
|
||||||
|
await fetch('/api/logout', { method: 'POST' });
|
||||||
|
location.href = '/login';
|
||||||
|
});
|
||||||
|
|
||||||
|
function hostWarning() {
|
||||||
|
const h = location.hostname;
|
||||||
|
if (h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0') {
|
||||||
|
$('#hostWarn').innerHTML =
|
||||||
|
'<span class="warn">Heads up:</span> you\'re on localhost, so QR codes will point at this machine only. ' +
|
||||||
|
'Open the app via this computer\'s LAN address (or set PUBLIC_BASE_URL) so phones can reach it.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- upload ----------------------------------------------------------------
|
||||||
|
const drop = $('#drop');
|
||||||
|
const fileInput = $('#file');
|
||||||
|
drop.addEventListener('click', () => fileInput.click());
|
||||||
|
['dragenter', 'dragover'].forEach((ev) =>
|
||||||
|
drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('drag'); }));
|
||||||
|
['dragleave', 'drop'].forEach((ev) =>
|
||||||
|
drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('drag'); }));
|
||||||
|
drop.addEventListener('drop', (e) => { if (e.dataTransfer.files[0]) startUpload(e.dataTransfer.files[0]); });
|
||||||
|
fileInput.addEventListener('change', () => { if (fileInput.files[0]) startUpload(fileInput.files[0]); });
|
||||||
|
|
||||||
|
function startUpload(file) {
|
||||||
|
const maxBytes = config.maxUploadMb * 1024 * 1024;
|
||||||
|
if (file.size > maxBytes) { toast(`That file is over the ${config.maxUploadMb} MB limit.`); return; }
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('title', $('#title').value);
|
||||||
|
|
||||||
|
const bar = $('#progress');
|
||||||
|
const fill = bar.querySelector('i');
|
||||||
|
bar.style.display = 'block'; fill.style.width = '0%';
|
||||||
|
$('#uploadNote').textContent = `Uploading ${file.name}…`;
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/api/sessions');
|
||||||
|
xhr.upload.onprogress = (e) => { if (e.lengthComputable) fill.style.width = `${Math.round((e.loaded / e.total) * 100)}%`; };
|
||||||
|
xhr.onload = async () => {
|
||||||
|
bar.style.display = 'none';
|
||||||
|
if (xhr.status === 401) { location.href = '/login'; return; }
|
||||||
|
let data = {}; try { data = JSON.parse(xhr.responseText); } catch {}
|
||||||
|
if (xhr.status >= 400) { $('#uploadNote').textContent = data.error || 'Upload failed.'; return; }
|
||||||
|
$('#uploadNote').textContent = 'Converting… this can take a moment for large files.';
|
||||||
|
$('#title').value = '';
|
||||||
|
await pollUntilReady(data.id);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
xhr.onerror = () => { bar.style.display = 'none'; $('#uploadNote').textContent = 'Upload failed.'; };
|
||||||
|
xhr.send(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollUntilReady(id) {
|
||||||
|
for (let i = 0; i < 240; i++) {
|
||||||
|
try {
|
||||||
|
const m = await api(`/api/sessions/${id}`);
|
||||||
|
if (m.status === 'ready') { $('#uploadNote').textContent = 'Ready.'; return; }
|
||||||
|
if (m.status === 'error') { $('#uploadNote').textContent = `Conversion failed: ${m.error}`; return; }
|
||||||
|
} catch {}
|
||||||
|
load();
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- session list ----------------------------------------------------------
|
||||||
|
$('#refresh').addEventListener('click', async () => {
|
||||||
|
// Rotate every presentation's QR code so links from finished sessions stop working.
|
||||||
|
try { await api('/api/sessions/rotate-codes', { method: 'POST' }); toast('New QR codes generated'); }
|
||||||
|
catch {}
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
let items;
|
||||||
|
try { items = await api('/api/sessions'); } catch { return; }
|
||||||
|
const list = $('#list');
|
||||||
|
if (!items.length) {
|
||||||
|
list.innerHTML = '<div class="empty">No presentations yet. Upload a file above to get your first QR code.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'grid';
|
||||||
|
for (const s of items) grid.appendChild(tile(s));
|
||||||
|
list.replaceChildren(grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tile(s) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'card tile';
|
||||||
|
const when = new Date(s.createdAt).toLocaleString();
|
||||||
|
const presentUrl = `/present/${s.id}?token=${encodeURIComponent(s.presenterToken)}`;
|
||||||
|
const displayUrl = `/display/${s.id}?token=${encodeURIComponent(s.presenterToken)}&k=${encodeURIComponent(s.accessToken || '')}`;
|
||||||
|
const viewUrl = `${location.origin}/view/${s.id}`;
|
||||||
|
const owner = (me.user.role === 'admin' && s.ownerName) ? ` · ${escapeHtml(s.ownerName)}` : '';
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="qr"><img alt="QR code" src="/api/sessions/${s.id}/qr.svg?v=${encodeURIComponent(s.accessToken || '')}" /></div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="title" title="${escapeHtml(s.title)}">${escapeHtml(s.title)}</div>
|
||||||
|
<div class="row" style="margin:6px 0 0;">
|
||||||
|
<span class="tag ${s.status}"><span class="dot"></span>${s.status}</span>
|
||||||
|
<span class="tag"><span class="dot"></span>${s.kind || '…'}</span>
|
||||||
|
${s.viewers ? `<span class="tag"><span class="dot"></span>${s.viewers} watching</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="note" style="margin-top:6px;">${when}${owner}</div>
|
||||||
|
${s.status === 'error' ? `<div class="note" style="color:var(--danger)">${escapeHtml(s.error || '')}</div>` : ''}
|
||||||
|
<div class="row">
|
||||||
|
<a class="btn primary small" href="${presentUrl}" target="_blank" rel="noopener" ${s.status !== 'ready' ? 'aria-disabled="true" style="pointer-events:none;opacity:.45"' : ''}>Present</a>
|
||||||
|
${s.status === 'ready' ? `<a class="btn small" href="${presentUrl}" target="_blank" rel="noopener" data-display="${displayUrl}">Present Local Only</a>` : ''}
|
||||||
|
${s.status === 'ready' ? `<a class="btn ghost small" href="/qr/${s.id}" target="_blank" rel="noopener">QR Code</a>` : ''}
|
||||||
|
${config.mailAvailable ? `<button class="btn ghost small" data-email="${s.id}" data-title="${escapeHtml(s.title)}">Email</button>` : ''}
|
||||||
|
<button class="btn ghost small" data-copy="${viewUrl}">Copy link</button>
|
||||||
|
<a class="btn ghost small" href="/view/${s.id}" target="_blank" rel="noopener">Open viewer</a>
|
||||||
|
<button class="btn danger small" data-del="${s.id}">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
el.querySelector('[data-copy]').addEventListener('click', (e) =>
|
||||||
|
navigator.clipboard.writeText(e.target.dataset.copy).then(() => toast('Audience link copied')));
|
||||||
|
const emailBtn = el.querySelector('[data-email]');
|
||||||
|
if (emailBtn) emailBtn.addEventListener('click', (e) => openEmail(e.target.dataset.email, e.target.dataset.title));
|
||||||
|
const localBtn = el.querySelector('[data-display]');
|
||||||
|
if (localBtn) localBtn.addEventListener('click', (e) => {
|
||||||
|
// The anchor itself opens the presenter console (tab 1). Open the clean,
|
||||||
|
// controls-free display as a second browser tab (no features string, so it's
|
||||||
|
// a tab — not a popup window — and isn't blocked like the old version).
|
||||||
|
const w = window.open(e.currentTarget.dataset.display, '_blank');
|
||||||
|
if (w) w.opener = null; else toast('Allow pop-ups to open the display tab');
|
||||||
|
});
|
||||||
|
el.querySelector('[data-del]').addEventListener('click', async (e) => {
|
||||||
|
if (!confirm('Delete this presentation for everyone?')) return;
|
||||||
|
await api(`/api/sessions/${e.target.dataset.del}`, { method: 'DELETE' });
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- email share modal -----------------------------------------------------
|
||||||
|
let emailSessionId = null;
|
||||||
|
function openEmail(id, title) {
|
||||||
|
emailSessionId = id;
|
||||||
|
$('#emailTitle').textContent = `Send "${title}"`;
|
||||||
|
$('#emailTo').value = ''; $('#emailMsg').value = ''; $('#emailErr').textContent = '';
|
||||||
|
$('#emailOverlay').classList.add('show');
|
||||||
|
$('#emailTo').focus();
|
||||||
|
}
|
||||||
|
function closeEmail() { $('#emailOverlay').classList.remove('show'); }
|
||||||
|
$('#emailCancel').addEventListener('click', closeEmail);
|
||||||
|
$('#emailOverlay').addEventListener('click', (e) => { if (e.target.id === 'emailOverlay') closeEmail(); });
|
||||||
|
$('#emailSend').addEventListener('click', async () => {
|
||||||
|
$('#emailErr').textContent = '';
|
||||||
|
$('#emailSend').disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await api(`/api/sessions/${emailSessionId}/email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ to: $('#emailTo').value, message: $('#emailMsg').value })
|
||||||
|
});
|
||||||
|
closeEmail();
|
||||||
|
toast(`Sent to ${r.sentTo.join(', ')}`);
|
||||||
|
} catch (e) {
|
||||||
|
$('#emailErr').textContent = e.message;
|
||||||
|
} finally {
|
||||||
|
$('#emailSend').disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
boot();
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { PdfViewer } from './pdf-viewer.js';
|
||||||
|
|
||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
const parts = location.pathname.split('/').filter(Boolean);
|
||||||
|
const sessionId = parts[parts.length - 1];
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const token = params.get('token') || ''; // presenter token: mirror the console even after code rotation
|
||||||
|
const accessCode = params.get('k') || ''; // fallback for asset access
|
||||||
|
|
||||||
|
const socket = io();
|
||||||
|
let kind = null, built = false, viewer = null, videoEl = null;
|
||||||
|
|
||||||
|
function msg(text) { $('#stage').innerHTML = `<div class="display-msg">${text}</div>`; }
|
||||||
|
|
||||||
|
// Join as presenter (display-only — it never sends control) so it stays in sync
|
||||||
|
// with the console and isn't counted as an audience viewer.
|
||||||
|
socket.on('connect', () => socket.emit('join', { sessionId, role: 'presenter', token, k: accessCode }));
|
||||||
|
socket.on('error:join', (m) => msg(m));
|
||||||
|
socket.on('ended', () => msg('The presentation has ended.'));
|
||||||
|
socket.on('state', onState);
|
||||||
|
|
||||||
|
async function onState(s) {
|
||||||
|
if (!s.meta) return;
|
||||||
|
document.title = (s.meta.title || 'Presentation') + ' — MPS';
|
||||||
|
if (s.meta.status === 'processing') { msg('Preparing…'); return; }
|
||||||
|
if (s.meta.status === 'error') { msg('This presentation could not be prepared.'); return; }
|
||||||
|
if (!built) { await build(s.meta); built = true; }
|
||||||
|
if (kind === 'pdf') viewer.render(s.currentPage || 1);
|
||||||
|
else if (kind === 'video') applyVideo(s.video);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function build(meta) {
|
||||||
|
kind = meta.kind;
|
||||||
|
const stage = $('#stage');
|
||||||
|
stage.innerHTML = '';
|
||||||
|
const src = `/s/${meta.id}/${meta.viewerFile}${accessCode ? `?k=${encodeURIComponent(accessCode)}` : ''}`;
|
||||||
|
if (kind === 'pdf') {
|
||||||
|
viewer = new PdfViewer(stage);
|
||||||
|
await viewer.load(src);
|
||||||
|
} else if (kind === 'video') {
|
||||||
|
videoEl = document.createElement('video');
|
||||||
|
videoEl.src = src;
|
||||||
|
videoEl.playsInline = true;
|
||||||
|
videoEl.preload = 'auto';
|
||||||
|
videoEl.setAttribute('webkit-playsinline', '');
|
||||||
|
stage.appendChild(videoEl);
|
||||||
|
} else if (kind === 'image') {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = src; img.alt = meta.title || '';
|
||||||
|
stage.appendChild(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVideo(v) {
|
||||||
|
if (!videoEl || !v) return;
|
||||||
|
const rate = v.rate || 1;
|
||||||
|
if (v.playing) {
|
||||||
|
const elapsed = (Date.now() - (v.updatedAt || Date.now())) / 1000;
|
||||||
|
const expected = (v.time || 0) + elapsed * rate;
|
||||||
|
if (Math.abs(videoEl.currentTime - expected) > 0.8) videoEl.currentTime = expected;
|
||||||
|
videoEl.playbackRate = rate;
|
||||||
|
// Play with sound where allowed; fall back to muted so the image still tracks.
|
||||||
|
videoEl.play().catch(() => { videoEl.muted = true; videoEl.play().catch(() => {}); });
|
||||||
|
} else {
|
||||||
|
videoEl.pause();
|
||||||
|
if (typeof v.time === 'number' && Math.abs(videoEl.currentTime - v.time) > 0.4) videoEl.currentTime = v.time;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
|
||||||
|
// Decide whether to show the sign-in form or the first-run setup form.
|
||||||
|
fetch('/api/me').then((r) => r.json()).then((m) => {
|
||||||
|
if (m.authed) { location.href = '/'; return; }
|
||||||
|
if (m.needsSetup) {
|
||||||
|
$('#loginView').style.display = 'none';
|
||||||
|
$('#setupView').style.display = 'block';
|
||||||
|
$('#s_username').focus();
|
||||||
|
} else {
|
||||||
|
$('#username').focus();
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// --- sign in ---------------------------------------------------------------
|
||||||
|
async function signIn() {
|
||||||
|
const username = $('#username').value.trim();
|
||||||
|
const password = $('#password').value;
|
||||||
|
$('#err').textContent = '';
|
||||||
|
if (!username || !password) { $('#err').textContent = 'Enter your username and password.'; return; }
|
||||||
|
$('#signin').disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/login', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Sign in failed.');
|
||||||
|
location.href = '/';
|
||||||
|
} catch (e) { $('#err').textContent = e.message; $('#signin').disabled = false; }
|
||||||
|
}
|
||||||
|
$('#signin').addEventListener('click', signIn);
|
||||||
|
|
||||||
|
// --- first-run setup -------------------------------------------------------
|
||||||
|
async function createAdmin() {
|
||||||
|
const username = $('#s_username').value.trim();
|
||||||
|
const email = $('#s_email').value.trim();
|
||||||
|
const password = $('#s_password').value;
|
||||||
|
const password2 = $('#s_password2').value;
|
||||||
|
$('#s_err').textContent = '';
|
||||||
|
if (!username || !password) { $('#s_err').textContent = 'Choose a username and password.'; return; }
|
||||||
|
if (password !== password2) { $('#s_err').textContent = 'The passwords do not match.'; return; }
|
||||||
|
$('#createAdmin').disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/setup', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, email, password })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Could not create the account.');
|
||||||
|
location.href = '/';
|
||||||
|
} catch (e) { $('#s_err').textContent = e.message; $('#createAdmin').disabled = false; }
|
||||||
|
}
|
||||||
|
$('#createAdmin').addEventListener('click', createAdmin);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
if ($('#setupView').style.display !== 'none') createAdmin();
|
||||||
|
else signIn();
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Loads PDF.js (vendored first, CDN fallback) and renders one page at a time,
|
||||||
|
// scaled to fit its container and sharp on high-DPI screens.
|
||||||
|
|
||||||
|
let readyPromise = null;
|
||||||
|
|
||||||
|
function loadScript(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = src;
|
||||||
|
s.onload = resolve;
|
||||||
|
s.onerror = () => reject(new Error('Failed to load ' + src));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensurePdfjs() {
|
||||||
|
if (window.pdfjsLib) return Promise.resolve();
|
||||||
|
if (readyPromise) return readyPromise;
|
||||||
|
|
||||||
|
const CDN = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174';
|
||||||
|
readyPromise = loadScript('/vendor/pdfjs/pdf.min.js')
|
||||||
|
.then(() => {
|
||||||
|
if (!window.pdfjsLib) throw new Error('vendor missing');
|
||||||
|
window.__pdfWorker = '/vendor/pdfjs/pdf.worker.min.js';
|
||||||
|
})
|
||||||
|
.catch(() => loadScript(`${CDN}/pdf.min.js`).then(() => {
|
||||||
|
window.__pdfWorker = `${CDN}/pdf.worker.min.js`;
|
||||||
|
}))
|
||||||
|
.then(() => {
|
||||||
|
window.pdfjsLib.GlobalWorkerOptions.workerSrc = window.__pdfWorker;
|
||||||
|
});
|
||||||
|
return readyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PdfViewer {
|
||||||
|
constructor(container) {
|
||||||
|
this.container = container;
|
||||||
|
this.canvas = document.createElement('canvas');
|
||||||
|
this.canvas.className = 'pdf-canvas';
|
||||||
|
this.ctx = this.canvas.getContext('2d');
|
||||||
|
container.appendChild(this.canvas);
|
||||||
|
this.pdf = null;
|
||||||
|
this.numPages = 0;
|
||||||
|
this.page = 1;
|
||||||
|
this._busy = false;
|
||||||
|
this._queued = null;
|
||||||
|
this._onResize = () => this.refit();
|
||||||
|
window.addEventListener('resize', this._onResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(url) {
|
||||||
|
await ensurePdfjs();
|
||||||
|
this.pdf = await window.pdfjsLib.getDocument(url).promise;
|
||||||
|
this.numPages = this.pdf.numPages;
|
||||||
|
return this.numPages;
|
||||||
|
}
|
||||||
|
|
||||||
|
async render(n) {
|
||||||
|
if (!this.pdf) return;
|
||||||
|
n = Math.max(1, Math.min(this.numPages, Math.trunc(n) || 1));
|
||||||
|
this.page = n;
|
||||||
|
if (this._busy) { this._queued = n; return; }
|
||||||
|
this._busy = true;
|
||||||
|
try {
|
||||||
|
const page = await this.pdf.getPage(n);
|
||||||
|
const base = page.getViewport({ scale: 1 });
|
||||||
|
const cw = this.container.clientWidth || 800;
|
||||||
|
const ch = this.container.clientHeight || 600;
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
const fit = Math.min(cw / base.width, ch / base.height);
|
||||||
|
const vp = page.getViewport({ scale: fit * dpr });
|
||||||
|
this.canvas.width = Math.floor(vp.width);
|
||||||
|
this.canvas.height = Math.floor(vp.height);
|
||||||
|
this.canvas.style.width = Math.floor(vp.width / dpr) + 'px';
|
||||||
|
this.canvas.style.height = Math.floor(vp.height / dpr) + 'px';
|
||||||
|
await page.render({ canvasContext: this.ctx, viewport: vp }).promise;
|
||||||
|
} finally {
|
||||||
|
this._busy = false;
|
||||||
|
if (this._queued !== null) {
|
||||||
|
const q = this._queued;
|
||||||
|
this._queued = null;
|
||||||
|
if (q !== n) this.render(q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refit() { if (this.pdf) this.render(this.page); }
|
||||||
|
|
||||||
|
destroy() { window.removeEventListener('resize', this._onResize); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { PdfViewer } from './pdf-viewer.js';
|
||||||
|
|
||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
const sessionId = location.pathname.split('/').filter(Boolean).pop();
|
||||||
|
const token = new URLSearchParams(location.search).get('token') || '';
|
||||||
|
|
||||||
|
const socket = io();
|
||||||
|
let kind = null, built = false, mode = 'synced';
|
||||||
|
let viewer = null, videoEl = null, page = 1, numPages = 1;
|
||||||
|
|
||||||
|
function toast(msg) {
|
||||||
|
const t = $('#toast'); t.textContent = msg; t.classList.add('show');
|
||||||
|
clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.remove('show'), 2000);
|
||||||
|
}
|
||||||
|
function stageMsg(html) { $('#stage').innerHTML = `<div class="msg">${html}</div>`; }
|
||||||
|
|
||||||
|
$('#qrPageBtn').href = `/qr/${sessionId}`;
|
||||||
|
|
||||||
|
socket.on('connect', () => socket.emit('join', { sessionId, role: 'presenter', token }));
|
||||||
|
socket.on('error:join', (m) => stageMsg(m));
|
||||||
|
socket.on('ended', () => showEnded());
|
||||||
|
socket.on('joined', ({ isPresenter }) => {
|
||||||
|
if (!isPresenter) stageMsg('Your presenter link is invalid. Reopen it from the host console.');
|
||||||
|
});
|
||||||
|
socket.on('state', onState);
|
||||||
|
|
||||||
|
async function onState(s) {
|
||||||
|
if (!s.meta) return;
|
||||||
|
$('#deckTitle').textContent = s.meta.title || 'Presenting';
|
||||||
|
$('#viewerCount').textContent = s.counts.viewers;
|
||||||
|
mode = s.mode; syncModeButtons();
|
||||||
|
if (s.meta.status === 'processing') { stageMsg('<div class="spinner"></div>Still converting your file…'); return; }
|
||||||
|
if (s.meta.status === 'error') { stageMsg('Conversion failed: ' + (s.meta.error || '')); return; }
|
||||||
|
if (!built) { await build(s.meta); built = true; }
|
||||||
|
if (kind === 'pdf') { page = s.currentPage || 1; viewer.render(page); updatePageInd(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function build(meta) {
|
||||||
|
kind = meta.kind;
|
||||||
|
const stage = $('#stage'); stage.innerHTML = '';
|
||||||
|
$('#controls').style.display = 'flex';
|
||||||
|
const src = `/s/${meta.id}/${meta.viewerFile}`;
|
||||||
|
if (kind === 'pdf') {
|
||||||
|
viewer = new PdfViewer(stage);
|
||||||
|
numPages = await viewer.load(src);
|
||||||
|
$('#pdfControls').style.display = 'flex';
|
||||||
|
updatePageInd(); viewer.render(page);
|
||||||
|
} else if (kind === 'video') {
|
||||||
|
videoEl = document.createElement('video');
|
||||||
|
videoEl.src = src; videoEl.controls = true; videoEl.playsInline = true; videoEl.preload = 'auto';
|
||||||
|
stage.appendChild(videoEl);
|
||||||
|
$('#videoHint').style.display = 'block';
|
||||||
|
wireVideo();
|
||||||
|
} else if (kind === 'image') {
|
||||||
|
const img = document.createElement('img'); img.src = src; img.alt = meta.title || 'Image';
|
||||||
|
stage.appendChild(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PDF paging (with first / last) ---------------------------------------
|
||||||
|
function updatePageInd() { $('#pageInd').textContent = `${page} / ${numPages}`; }
|
||||||
|
function goto(n) {
|
||||||
|
if (kind !== 'pdf') return;
|
||||||
|
page = Math.max(1, Math.min(numPages, n));
|
||||||
|
viewer.render(page); updatePageInd();
|
||||||
|
socket.emit('control', { currentPage: page });
|
||||||
|
}
|
||||||
|
$('#first').addEventListener('click', () => goto(1));
|
||||||
|
$('#prev').addEventListener('click', () => goto(page - 1));
|
||||||
|
$('#next').addEventListener('click', () => goto(page + 1));
|
||||||
|
$('#last').addEventListener('click', () => goto(numPages));
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (kind !== 'pdf') return;
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); goto(page + 1); }
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); goto(page - 1); }
|
||||||
|
if (e.key === 'Home') { e.preventDefault(); goto(1); }
|
||||||
|
if (e.key === 'End') { e.preventDefault(); goto(numPages); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- video sync ------------------------------------------------------------
|
||||||
|
function pushVideo() {
|
||||||
|
if (!videoEl) return;
|
||||||
|
socket.emit('control', { video: { playing: !videoEl.paused, time: videoEl.currentTime, rate: videoEl.playbackRate } });
|
||||||
|
}
|
||||||
|
function wireVideo() {
|
||||||
|
['play', 'pause', 'seeked', 'ratechange'].forEach((ev) => videoEl.addEventListener(ev, pushVideo));
|
||||||
|
setInterval(() => { if (videoEl && !videoEl.paused) pushVideo(); }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- mode toggle -----------------------------------------------------------
|
||||||
|
$('#modeSeg').addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('button'); if (!btn) return;
|
||||||
|
mode = btn.dataset.mode; syncModeButtons();
|
||||||
|
socket.emit('control', { mode });
|
||||||
|
toast(mode === 'synced' ? 'Audience now follows you' : 'Audience can browse freely');
|
||||||
|
});
|
||||||
|
function syncModeButtons() {
|
||||||
|
document.querySelectorAll('#modeSeg button').forEach((b) => b.classList.toggle('active', b.dataset.mode === mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- end session (invalidate QR + kick everyone) ---------------------------
|
||||||
|
let ended = false;
|
||||||
|
$('#endBtn').addEventListener('click', async () => {
|
||||||
|
if (ended) return;
|
||||||
|
if (!confirm('End the presentation for everyone? The QR code will stop working and all viewers will be disconnected.')) return;
|
||||||
|
$('#endBtn').disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sessions/${sessionId}/terminate`, { method: 'POST' });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Could not end the session.');
|
||||||
|
ended = true;
|
||||||
|
showEnded();
|
||||||
|
} catch (e) {
|
||||||
|
$('#endBtn').disabled = false;
|
||||||
|
toast(e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showEnded() {
|
||||||
|
ended = true;
|
||||||
|
document.querySelector('.lamp')?.classList.remove('on');
|
||||||
|
$('#controls').style.display = 'none';
|
||||||
|
$('#endBtn').textContent = 'Session ended';
|
||||||
|
stageMsg('This presentation has ended. Its QR code is no longer valid.<br><br><a class="btn ghost small" href="/">Back to dashboard</a>');
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
const id = location.pathname.split('/').filter(Boolean).pop();
|
||||||
|
let joinUrl = '';
|
||||||
|
|
||||||
|
function toast(msg) {
|
||||||
|
const t = $('#toast'); t.textContent = msg; t.classList.add('show');
|
||||||
|
clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.remove('show'), 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const meta = await (await fetch(`/api/sessions/${id}`)).json();
|
||||||
|
if (meta && meta.title) $('#qrTitle').textContent = meta.title;
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const q = await (await fetch(`/api/sessions/${id}/qr`)).json();
|
||||||
|
joinUrl = q.url;
|
||||||
|
const img = new Image();
|
||||||
|
img.alt = 'QR code'; img.src = q.dataUrl;
|
||||||
|
$('#qrBig').replaceChildren(img);
|
||||||
|
const link = $('#qrLink'); link.href = q.url; link.textContent = q.url;
|
||||||
|
} catch {
|
||||||
|
$('#qrBig').innerHTML = '<div class="msg">This presentation is no longer available.</div>';
|
||||||
|
$('#eyebrow').textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#copyBtn').addEventListener('click', () => {
|
||||||
|
if (joinUrl) navigator.clipboard.writeText(joinUrl).then(() => toast('Link copied'));
|
||||||
|
});
|
||||||
|
$('#fsBtn').addEventListener('click', () => {
|
||||||
|
const el = document.documentElement;
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen();
|
||||||
|
else el.requestFullscreen?.();
|
||||||
|
});
|
||||||
|
$('#printBtn').addEventListener('click', () => window.print());
|
||||||
|
|
||||||
|
load();
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
let me = null;
|
||||||
|
|
||||||
|
function toast(msg) {
|
||||||
|
const t = $('#toast');
|
||||||
|
t.textContent = msg; t.classList.add('show');
|
||||||
|
clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.remove('show'), 2200);
|
||||||
|
}
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(path, opts);
|
||||||
|
if (res.status === 401) { location.href = '/login'; throw new Error('auth'); }
|
||||||
|
if (res.status === 403) { location.href = '/'; throw new Error('forbidden'); }
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
|
||||||
|
$('#logoutBtn').addEventListener('click', async () => {
|
||||||
|
await fetch('/api/logout', { method: 'POST' }); location.href = '/login';
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- tabs ------------------------------------------------------------------
|
||||||
|
$('#tabs').addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('button'); if (!btn) return;
|
||||||
|
document.querySelectorAll('#tabs button').forEach((b) => b.classList.toggle('active', b === btn));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach((p) => p.classList.toggle('active', p.id === `tab-${btn.dataset.tab}`));
|
||||||
|
if (btn.dataset.tab === 'logs') loadLogs();
|
||||||
|
if (btn.dataset.tab === 'mail') loadMail();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- users -----------------------------------------------------------------
|
||||||
|
async function loadUsers() {
|
||||||
|
const users = await api('/api/users');
|
||||||
|
const tbody = $('#userTable tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
for (const u of users) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const isMe = me && u.id === me.user.id;
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${esc(u.username)}${isMe ? ' <span class="hint">(you)</span>' : ''}</td>
|
||||||
|
<td class="mono">${esc(u.email) || '—'}</td>
|
||||||
|
<td><span class="role-badge ${u.role}">${u.role}</span></td>
|
||||||
|
<td class="mono">${new Date(u.createdAt).toLocaleDateString()}</td>
|
||||||
|
<td><div class="row">
|
||||||
|
<button class="btn ghost small" data-role="${u.id}" data-current="${u.role}">${u.role === 'admin' ? 'Make user' : 'Make admin'}</button>
|
||||||
|
<button class="btn ghost small" data-pw="${u.id}">Reset password</button>
|
||||||
|
<button class="btn danger small" data-del="${u.id}" ${isMe ? 'disabled' : ''}>Delete</button>
|
||||||
|
</div></td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
tbody.querySelectorAll('[data-role]').forEach((b) => b.addEventListener('click', async () => {
|
||||||
|
const next = b.dataset.current === 'admin' ? 'user' : 'admin';
|
||||||
|
try { await api(`/api/users/${b.dataset.role}/role`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: next }) }); loadUsers(); }
|
||||||
|
catch (e) { toast(e.message); }
|
||||||
|
}));
|
||||||
|
tbody.querySelectorAll('[data-pw]').forEach((b) => b.addEventListener('click', async () => {
|
||||||
|
const pw = prompt('New password for this user:');
|
||||||
|
if (!pw) return;
|
||||||
|
try { await api(`/api/users/${b.dataset.pw}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) }); toast('Password updated'); }
|
||||||
|
catch (e) { toast(e.message); }
|
||||||
|
}));
|
||||||
|
tbody.querySelectorAll('[data-del]').forEach((b) => b.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Delete this user?')) return;
|
||||||
|
try { await api(`/api/users/${b.dataset.del}`, { method: 'DELETE' }); loadUsers(); }
|
||||||
|
catch (e) { toast(e.message); }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
$('#nu_add').addEventListener('click', async () => {
|
||||||
|
$('#nu_err').textContent = '';
|
||||||
|
try {
|
||||||
|
await api('/api/users', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: $('#nu_name').value, email: $('#nu_email').value, password: $('#nu_pass').value, role: $('#nu_role').value })
|
||||||
|
});
|
||||||
|
$('#nu_name').value = $('#nu_email').value = $('#nu_pass').value = '';
|
||||||
|
toast('User created'); loadUsers();
|
||||||
|
} catch (e) { $('#nu_err').textContent = e.message; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- mail ------------------------------------------------------------------
|
||||||
|
async function loadMail() {
|
||||||
|
const c = await api('/api/mail');
|
||||||
|
$('#m_enabled').checked = c.enabled;
|
||||||
|
$('#m_username').value = c.username || '';
|
||||||
|
$('#m_password').value = '';
|
||||||
|
$('#m_password').placeholder = c.hasPassword ? '•••••••• (unchanged)' : 'password';
|
||||||
|
$('#m_fromName').value = c.fromName || '';
|
||||||
|
$('#m_smtpHost').value = c.smtpHost; $('#m_smtpPort').value = c.smtpPort;
|
||||||
|
$('#m_imapHost').value = c.imapHost; $('#m_imapPort').value = c.imapPort;
|
||||||
|
$('#m_sentFolder').value = c.sentFolder; $('#m_saveToSent').checked = c.saveToSent;
|
||||||
|
}
|
||||||
|
$('#m_save').addEventListener('click', async () => {
|
||||||
|
$('#m_err').textContent = '';
|
||||||
|
const body = {
|
||||||
|
enabled: $('#m_enabled').checked, username: $('#m_username').value.trim(),
|
||||||
|
password: $('#m_password').value, fromName: $('#m_fromName').value,
|
||||||
|
smtpHost: $('#m_smtpHost').value.trim(), smtpPort: $('#m_smtpPort').value,
|
||||||
|
imapHost: $('#m_imapHost').value.trim(), imapPort: $('#m_imapPort').value,
|
||||||
|
sentFolder: $('#m_sentFolder').value.trim(), saveToSent: $('#m_saveToSent').checked
|
||||||
|
};
|
||||||
|
try { await api('/api/mail', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); toast('Mail settings saved'); loadMail(); }
|
||||||
|
catch (e) { $('#m_err').textContent = e.message; }
|
||||||
|
});
|
||||||
|
$('#m_test').addEventListener('click', async () => {
|
||||||
|
$('#m_err').textContent = '';
|
||||||
|
const to = $('#m_testTo').value.trim();
|
||||||
|
try { await api('/api/mail/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to }) }); toast('Test email sent'); }
|
||||||
|
catch (e) { $('#m_err').textContent = e.message; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- logs ------------------------------------------------------------------
|
||||||
|
async function loadLogs() {
|
||||||
|
const logs = await api('/api/logs');
|
||||||
|
const box = $('#logList');
|
||||||
|
if (!logs.length) { box.innerHTML = '<div class="empty">No activity logged yet.</div>'; return; }
|
||||||
|
box.innerHTML = logs.map((l) => `
|
||||||
|
<div class="log-line">
|
||||||
|
<span class="mono">${new Date(l.ts).toLocaleString()}</span>
|
||||||
|
<span class="lv ${l.level || 'info'}">${l.level || 'info'}</span>
|
||||||
|
<span class="actor">${esc(l.actor || '')}</span>
|
||||||
|
<span>${esc(l.message || '')}</span>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
$('#logRefresh').addEventListener('click', loadLogs);
|
||||||
|
$('#logClear').addEventListener('click', async () => {
|
||||||
|
if (!confirm('Clear the activity log?')) return;
|
||||||
|
await api('/api/logs', { method: 'DELETE' }); loadLogs();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- boot ------------------------------------------------------------------
|
||||||
|
(async () => {
|
||||||
|
try { me = await api('/api/me'); } catch { return; }
|
||||||
|
if (!me.authed) { location.href = '/login'; return; }
|
||||||
|
if (me.user.role !== 'admin') { location.href = '/'; return; }
|
||||||
|
loadUsers();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { PdfViewer } from './pdf-viewer.js';
|
||||||
|
|
||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
const sessionId = location.pathname.split('/').filter(Boolean).pop();
|
||||||
|
const accessCode = new URLSearchParams(location.search).get('k') || '';
|
||||||
|
|
||||||
|
const socket = io();
|
||||||
|
let kind = null, built = false, mode = 'synced', following = true;
|
||||||
|
let viewer = null, videoEl = null, joinedAudio = false;
|
||||||
|
let localPage = 1, numPages = 1, lastVideo = null;
|
||||||
|
|
||||||
|
function toast(msg) {
|
||||||
|
const t = $('#toast'); t.textContent = msg; t.classList.add('show');
|
||||||
|
clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.remove('show'), 2000);
|
||||||
|
}
|
||||||
|
function stageMsg(html) {
|
||||||
|
$('#stage').querySelector('.msg')?.remove();
|
||||||
|
const d = document.createElement('div'); d.className = 'msg'; d.innerHTML = html;
|
||||||
|
$('#stage').prepend(d);
|
||||||
|
}
|
||||||
|
function clearMsg() { $('#stage').querySelector('.msg')?.remove(); }
|
||||||
|
function setStatus(text, live) { $('#statusText').textContent = text; $('#lamp').classList.toggle('on', !!live); }
|
||||||
|
|
||||||
|
socket.on('connect', () => { setStatus('Connecting…', false); socket.emit('join', { sessionId, role: 'viewer', k: accessCode }); });
|
||||||
|
socket.on('disconnect', () => setStatus('Reconnecting…', false));
|
||||||
|
socket.on('error:join', (m) => stageMsg(m));
|
||||||
|
socket.on('ended', () => { stageMsg('The presenter ended this session.'); setStatus('Ended', false); });
|
||||||
|
socket.on('state', onState);
|
||||||
|
|
||||||
|
async function onState(s) {
|
||||||
|
if (!s.meta) return;
|
||||||
|
$('#deckTitle').textContent = s.meta.title || 'Presentation';
|
||||||
|
if (s.meta.status === 'processing') { stageMsg('<div class="spinner"></div>The presenter is preparing this. Hang tight…'); setStatus('Preparing', false); return; }
|
||||||
|
if (s.meta.status === 'error') { stageMsg('This presentation could not be prepared.'); setStatus('Unavailable', false); return; }
|
||||||
|
if (!built) { await build(s.meta); built = true; }
|
||||||
|
mode = s.mode;
|
||||||
|
setStatus(mode === 'free' ? 'Browse freely' : (following ? 'Live · in sync' : 'Live · you moved away'), true);
|
||||||
|
if (kind === 'pdf') {
|
||||||
|
updateFollowBar();
|
||||||
|
if (mode === 'synced' && following) { localPage = s.currentPage || 1; viewer.render(localPage); updatePageInd(); }
|
||||||
|
$('#controls').style.display = 'flex';
|
||||||
|
} else if (kind === 'video') {
|
||||||
|
lastVideo = s.video; applyVideo(s.video);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function build(meta) {
|
||||||
|
kind = meta.kind; clearMsg();
|
||||||
|
const src = `/s/${meta.id}/${meta.viewerFile}?k=${encodeURIComponent(accessCode)}`;
|
||||||
|
if (kind === 'pdf') {
|
||||||
|
viewer = new PdfViewer($('#stage'));
|
||||||
|
numPages = await viewer.load(src); updatePageInd();
|
||||||
|
} else if (kind === 'video') {
|
||||||
|
videoEl = document.createElement('video');
|
||||||
|
videoEl.src = src; videoEl.playsInline = true; videoEl.preload = 'auto'; videoEl.muted = true;
|
||||||
|
videoEl.setAttribute('webkit-playsinline', '');
|
||||||
|
$('#stage').appendChild(videoEl);
|
||||||
|
$('#tapjoin').classList.add('show');
|
||||||
|
} else if (kind === 'image') {
|
||||||
|
const img = document.createElement('img'); img.src = src; img.alt = meta.title || 'Image';
|
||||||
|
$('#stage').appendChild(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PDF: audience self-navigation (with first / last) --------------------
|
||||||
|
function updatePageInd() { $('#pageInd').textContent = `${localPage} / ${numPages}`; }
|
||||||
|
function updateFollowBar() { $('#followbar').classList.toggle('show', mode === 'synced' && !following); }
|
||||||
|
function localGoto(n) {
|
||||||
|
if (kind !== 'pdf') return;
|
||||||
|
localPage = Math.max(1, Math.min(numPages, n));
|
||||||
|
viewer.render(localPage); updatePageInd();
|
||||||
|
if (mode === 'synced') { following = false; updateFollowBar(); setStatus('Live · you moved away', true); }
|
||||||
|
}
|
||||||
|
$('#first').addEventListener('click', () => localGoto(1));
|
||||||
|
$('#prev').addEventListener('click', () => localGoto(localPage - 1));
|
||||||
|
$('#next').addEventListener('click', () => localGoto(localPage + 1));
|
||||||
|
$('#last').addEventListener('click', () => localGoto(numPages));
|
||||||
|
$('#followBtn').addEventListener('click', () => {
|
||||||
|
following = true; updateFollowBar();
|
||||||
|
socket.emit('join', { sessionId, role: 'viewer', k: accessCode });
|
||||||
|
setStatus('Live · in sync', true);
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (kind !== 'pdf') return;
|
||||||
|
if (e.key === 'ArrowRight') localGoto(localPage + 1);
|
||||||
|
if (e.key === 'ArrowLeft') localGoto(localPage - 1);
|
||||||
|
if (e.key === 'Home') localGoto(1);
|
||||||
|
if (e.key === 'End') localGoto(numPages);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- video following -------------------------------------------------------
|
||||||
|
$('#tapBtn').addEventListener('click', () => {
|
||||||
|
joinedAudio = true;
|
||||||
|
if (videoEl) videoEl.muted = false;
|
||||||
|
$('#tapjoin').classList.remove('show');
|
||||||
|
if (lastVideo) applyVideo(lastVideo);
|
||||||
|
});
|
||||||
|
function applyVideo(v) {
|
||||||
|
if (!videoEl || !v) return;
|
||||||
|
const rate = v.rate || 1;
|
||||||
|
if (v.playing) {
|
||||||
|
const elapsed = (Date.now() - (v.updatedAt || Date.now())) / 1000;
|
||||||
|
const expected = (v.time || 0) + elapsed * rate;
|
||||||
|
if (Math.abs(videoEl.currentTime - expected) > 0.8) videoEl.currentTime = expected;
|
||||||
|
videoEl.playbackRate = rate;
|
||||||
|
videoEl.play().catch(() => { if (!joinedAudio) $('#tapjoin').classList.add('show'); });
|
||||||
|
} else {
|
||||||
|
videoEl.pause();
|
||||||
|
if (typeof v.time === 'number' && Math.abs(videoEl.currentTime - v.time) > 0.4) videoEl.currentTime = v.time;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Sign in — MPS</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/css/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-shell">
|
||||||
|
<div class="card login-card">
|
||||||
|
|
||||||
|
<!-- SIGN IN -->
|
||||||
|
<div id="loginView">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input class="input" id="username" autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input class="input" id="password" type="password" autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<div class="err" id="err"></div>
|
||||||
|
<button class="btn primary" id="signin" style="width:100%; justify-content:center;">Sign in</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FIRST-RUN SETUP -->
|
||||||
|
<div id="setupView" style="display:none;">
|
||||||
|
<h1>Welcome</h1>
|
||||||
|
<p class="sub">This is the first run. Create the administrator account to get started.</p>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="s_username">Admin username</label>
|
||||||
|
<input class="input" id="s_username" autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="s_email">Email <span class="hint">(optional, used for mail tests)</span></label>
|
||||||
|
<input class="input" id="s_email" autocomplete="email" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="s_password">Password</label>
|
||||||
|
<input class="input" id="s_password" type="password" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="s_password2">Confirm password</label>
|
||||||
|
<input class="input" id="s_password2" type="password" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="err" id="s_err"></div>
|
||||||
|
<button class="btn primary" id="createAdmin" style="width:100%; justify-content:center;">Create admin account</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/js/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Presenting — QR Presentation</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/css/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="stage-page">
|
||||||
|
<div class="stage-bar">
|
||||||
|
<div class="brand"><img class="brand-logo" src="/img/mps-icon.png" alt="" /> <span class="lamp on"></span> <span id="deckTitle">Presenting</span></div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span class="pill"><b id="viewerCount">0</b> watching</span>
|
||||||
|
<a class="btn small" id="qrPageBtn" target="_blank" rel="noopener">QR Code</a>
|
||||||
|
<button class="btn danger small" id="endBtn" title="Invalidate the QR code and end for everyone">End session</button>
|
||||||
|
<a class="btn ghost small" href="/">Exit</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stage" id="stage">
|
||||||
|
<div class="msg"><div class="spinner"></div><div id="stageMsg">Connecting…</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls" id="controls" style="display:none;">
|
||||||
|
<div class="seg" id="modeSeg" title="Synced: everyone follows you. Free: audience browses on their own.">
|
||||||
|
<button data-mode="synced" class="active">Synced</button>
|
||||||
|
<button data-mode="free">Free browse</button>
|
||||||
|
</div>
|
||||||
|
<div class="spacer" style="flex:1"></div>
|
||||||
|
<div id="pdfControls" style="display:none; align-items:center; gap:8px;">
|
||||||
|
<button class="btn" id="first" title="First slide (Home)">« First</button>
|
||||||
|
<button class="btn" id="prev" title="Previous (←)">‹ Prev</button>
|
||||||
|
<span class="pageind" id="pageInd">– / –</span>
|
||||||
|
<button class="btn" id="next" title="Next (→)">Next ›</button>
|
||||||
|
<button class="btn" id="last" title="Last slide (End)">Last »</button>
|
||||||
|
</div>
|
||||||
|
<div id="videoHint" class="note" style="display:none;">Use the video controls — play, pause, and seeking sync to the audience.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="app-footer">© 2026 Martinhal IT - Joao Vaz - Version 1.7</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script type="module" src="/js/present.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>QR Code — MPS</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/css/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="qr-page">
|
||||||
|
<div class="qr-head">
|
||||||
|
<div class="brand"><img class="brand-logo" src="/img/mps-icon.png" alt="" /> <span class="brand-name">MPS – Martinhal Presentation System</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="qr-stage">
|
||||||
|
<div class="eyebrow" id="eyebrow">Scan to watch along</div>
|
||||||
|
<h1 id="qrTitle">Presentation</h1>
|
||||||
|
<div class="qr-big" id="qrBig"><div class="msg"><div class="spinner"></div>Loading QR code…</div></div>
|
||||||
|
<a class="qr-link" id="qrLink" href="#" target="_blank" rel="noopener"></a>
|
||||||
|
<div class="qr-actions">
|
||||||
|
<button class="btn" id="copyBtn">Copy link</button>
|
||||||
|
<button class="btn ghost" id="fsBtn">Fullscreen</button>
|
||||||
|
<button class="btn ghost" id="printBtn">Print</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="app-footer">© 2026 Martinhal IT - Joao Vaz - Version 1.7</footer>
|
||||||
|
</div>
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script type="module" src="/js/qr.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>User management — QR Presentation</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/css/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand"><img class="brand-logo" src="/img/mps-icon.png" alt="" /> <span class="brand-name">MPS <small>/ management</small></span></div>
|
||||||
|
<div class="usermenu">
|
||||||
|
<a class="btn ghost small" href="/">← Presentations</a>
|
||||||
|
<button class="btn ghost small" id="logoutBtn">Sign out</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="eyebrow">Admin</div>
|
||||||
|
<h1 style="font-size:30px;margin-top:6px;">Management</h1>
|
||||||
|
|
||||||
|
<div class="tabs" id="tabs">
|
||||||
|
<button data-tab="users" class="active">Users</button>
|
||||||
|
<button data-tab="mail">Mail</button>
|
||||||
|
<button data-tab="logs">Logs</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- USERS -->
|
||||||
|
<section class="tab-panel active" id="tab-users">
|
||||||
|
<div class="card panel-pad" style="margin-bottom:18px;">
|
||||||
|
<div class="eyebrow">Add a user</div>
|
||||||
|
<div class="grid2" style="margin-top:12px;">
|
||||||
|
<div><label class="hint">Username</label><input class="input" id="nu_name" /></div>
|
||||||
|
<div><label class="hint">Email</label><input class="input" id="nu_email" placeholder="optional" /></div>
|
||||||
|
<div><label class="hint">Password</label><input class="input" id="nu_pass" type="password" /></div>
|
||||||
|
<div><label class="hint">Role</label>
|
||||||
|
<select class="input" id="nu_role"><option value="user">User</option><option value="admin">Admin</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="err" id="nu_err"></div>
|
||||||
|
<button class="btn primary" id="nu_add" style="margin-top:6px;">Create user</button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<table class="table" id="userTable">
|
||||||
|
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Created</th><th></th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- MAIL -->
|
||||||
|
<section class="tab-panel" id="tab-mail">
|
||||||
|
<div class="card panel-pad">
|
||||||
|
<div class="eyebrow">Mail account (PurelyMail)</div>
|
||||||
|
<p class="hint">Enter the full email address and password of a PurelyMail account. Servers are prefilled; leave the password blank to keep the stored one.</p>
|
||||||
|
<label class="check" style="margin:14px 0;"><input type="checkbox" id="m_enabled" /> Enable email sending</label>
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="full"><label class="hint">Account email (SMTP/IMAP username)</label><input class="input" id="m_username" placeholder="you@yourdomain.com" /></div>
|
||||||
|
<div class="full"><label class="hint">Password</label><input class="input" id="m_password" type="password" placeholder="•••••••• (unchanged)" /></div>
|
||||||
|
<div class="full"><label class="hint">From name</label><input class="input" id="m_fromName" /></div>
|
||||||
|
<div><label class="hint">SMTP host</label><input class="input" id="m_smtpHost" /></div>
|
||||||
|
<div><label class="hint">SMTP port</label><input class="input" id="m_smtpPort" /></div>
|
||||||
|
<div><label class="hint">IMAP host</label><input class="input" id="m_imapHost" /></div>
|
||||||
|
<div><label class="hint">IMAP port</label><input class="input" id="m_imapPort" /></div>
|
||||||
|
<div><label class="hint">Sent folder</label><input class="input" id="m_sentFolder" /></div>
|
||||||
|
<div style="display:flex;align-items:flex-end;">
|
||||||
|
<label class="check"><input type="checkbox" id="m_saveToSent" /> Save a copy to Sent</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="err" id="m_err"></div>
|
||||||
|
<div class="row" style="display:flex;gap:8px;margin-top:6px;">
|
||||||
|
<button class="btn primary" id="m_save">Save settings</button>
|
||||||
|
<input class="input" id="m_testTo" placeholder="test recipient" style="max-width:240px;" />
|
||||||
|
<button class="btn ghost" id="m_test">Send test</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- LOGS -->
|
||||||
|
<section class="tab-panel" id="tab-logs">
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end;margin-bottom:10px;">
|
||||||
|
<button class="btn ghost small" id="logRefresh">Refresh</button>
|
||||||
|
<button class="btn danger small" id="logClear">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="card logs" id="logList"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="app-footer">© 2026 Martinhal IT - Joao Vaz - Version 1.7</footer>
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script type="module" src="/js/users.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Vendored
+22
File diff suppressed because one or more lines are too long
+22
File diff suppressed because one or more lines are too long
@@ -0,0 +1,51 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>Watching — QR Presentation</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/css/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="stage-page">
|
||||||
|
<div class="stage-bar">
|
||||||
|
<div class="brand"><img class="brand-logo" src="/img/mps-icon.png" alt="" /> <span class="lamp on" id="lamp"></span> <span id="deckTitle">Presentation</span></div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span class="pill" id="statusPill"><b id="statusText">Connecting…</b></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stage" id="stage">
|
||||||
|
<div class="msg"><div class="spinner"></div><div id="stageMsg">Connecting…</div></div>
|
||||||
|
|
||||||
|
<div class="tapjoin" id="tapjoin">
|
||||||
|
<div class="big">Tap to join with sound</div>
|
||||||
|
<button class="btn primary" id="tapBtn">Join presentation</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="followbar" id="followbar">
|
||||||
|
<span>You're browsing on your own</span>
|
||||||
|
<button class="btn primary small" id="followBtn">Rejoin presenter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls" id="controls" style="display:none;">
|
||||||
|
<div class="spacer" style="flex:1"></div>
|
||||||
|
<button class="btn" id="first" title="First slide">«</button>
|
||||||
|
<button class="btn" id="prev" title="Previous">‹</button>
|
||||||
|
<span class="pageind" id="pageInd">– / –</span>
|
||||||
|
<button class="btn" id="next" title="Next">›</button>
|
||||||
|
<button class="btn" id="last" title="Last slide">»</button>
|
||||||
|
<div class="spacer" style="flex:1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="app-footer">© 2026 Martinhal IT - Joao Vaz - Version 1.7</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script type="module" src="/js/view.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Download PDF.js locally so the app runs without internet access.
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "$0")/.." && pwd)/public/vendor/pdfjs"
|
||||||
|
VER="3.11.174"
|
||||||
|
BASE="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${VER}"
|
||||||
|
mkdir -p "$DIR"
|
||||||
|
curl -fsSL "${BASE}/pdf.min.js" -o "$DIR/pdf.min.js"
|
||||||
|
curl -fsSL "${BASE}/pdf.worker.min.js" -o "$DIR/pdf.worker.min.js"
|
||||||
|
echo "PDF.js ${VER} vendored into $DIR"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export const ROOT = path.resolve(__dirname, '..');
|
||||||
|
export const PUBLIC_DIR = path.join(ROOT, 'public');
|
||||||
|
|
||||||
|
// All mutable state lives under DATA_DIR so it can be a mounted volume and
|
||||||
|
// survive an "unzip the new version over the old one" upgrade untouched.
|
||||||
|
export const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data');
|
||||||
|
export const UPLOAD_DIR = path.join(DATA_DIR, 'uploads');
|
||||||
|
export const SESSION_DIR = path.join(DATA_DIR, 'sessions');
|
||||||
|
export const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||||
|
export const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
||||||
|
export const LOG_FILE = path.join(DATA_DIR, 'app.log');
|
||||||
|
|
||||||
|
export const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||||
|
export const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
|
||||||
|
// Absolute URL the audience's phones can reach. If empty we derive it from the
|
||||||
|
// incoming request, which is fine on a LAN but should be set behind a proxy.
|
||||||
|
export const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
// The first time the app runs with no users file, it seeds one admin account
|
||||||
|
// from these values. After that they're ignored — manage users in the app.
|
||||||
|
export const SEED_ADMIN_USER = process.env.ADMIN_USERNAME || 'admin';
|
||||||
|
export const SEED_ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin';
|
||||||
|
export const SEED_ADMIN_EMAIL = process.env.ADMIN_EMAIL || '';
|
||||||
|
|
||||||
|
export const MAX_UPLOAD_MB = parseInt(process.env.MAX_UPLOAD_MB || '500', 10);
|
||||||
|
export const CONVERT_TIMEOUT_MS = parseInt(process.env.CONVERT_TIMEOUT_MS || '240000', 10);
|
||||||
|
|
||||||
|
export const SOFFICE_BIN = process.env.SOFFICE_BIN || 'libreoffice';
|
||||||
|
export const FFMPEG_BIN = process.env.FFMPEG_BIN || 'ffmpeg';
|
||||||
|
|
||||||
|
export const APP_VERSION = '1.7';
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { SOFFICE_BIN, FFMPEG_BIN, CONVERT_TIMEOUT_MS } from './config.js';
|
||||||
|
|
||||||
|
// Anything LibreOffice can turn into a PDF becomes a paged "deck".
|
||||||
|
const DOC_EXT = new Set([
|
||||||
|
'ppt', 'pptx', 'odp', 'ppsx', 'pps',
|
||||||
|
'doc', 'docx', 'odt', 'rtf', 'txt', 'md',
|
||||||
|
'xls', 'xlsx', 'ods', 'csv', 'tsv'
|
||||||
|
]);
|
||||||
|
const VIDEO_EXT = new Set([
|
||||||
|
'mp4', 'webm', 'ogg', 'ogv', 'mov', 'm4v',
|
||||||
|
'avi', 'mkv', 'wmv', 'flv', '3gp', 'mpeg', 'mpg', 'ts'
|
||||||
|
]);
|
||||||
|
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'avif']);
|
||||||
|
// Formats browsers can generally stream without a re-encode.
|
||||||
|
const WEB_VIDEO = new Set(['mp4', 'webm', 'ogg', 'ogv']);
|
||||||
|
|
||||||
|
function run(cmd, args, { timeout = CONVERT_TIMEOUT_MS, cwd, env } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(cmd, args, { cwd, env: { ...process.env, ...env } });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
reject(new Error(`${cmd} timed out after ${timeout}ms`));
|
||||||
|
}, timeout);
|
||||||
|
child.stdout.on('data', (d) => (stdout += d.toString()));
|
||||||
|
child.stderr.on('data', (d) => (stderr += d.toString()));
|
||||||
|
child.on('error', (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error(`Could not start ${cmd}: ${err.message}`));
|
||||||
|
});
|
||||||
|
child.on('close', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (code === 0) resolve({ stdout, stderr });
|
||||||
|
else reject(new Error(`${cmd} exited with code ${code}: ${(stderr || stdout).slice(-500)}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertToPdf(inputPath, outDir) {
|
||||||
|
// A throwaway per-job profile avoids the shared-lock problems LibreOffice
|
||||||
|
// hits when several conversions run at once.
|
||||||
|
const profileDir = path.join(outDir, '.lo-profile');
|
||||||
|
await run(SOFFICE_BIN, [
|
||||||
|
'--headless', '--norestore', '--nologo', '--nolockcheck', '--nodefault',
|
||||||
|
`-env:UserInstallation=file://${profileDir}`,
|
||||||
|
'--convert-to', 'pdf',
|
||||||
|
'--outdir', outDir,
|
||||||
|
inputPath
|
||||||
|
]);
|
||||||
|
const base = path.basename(inputPath, path.extname(inputPath));
|
||||||
|
const produced = path.join(outDir, `${base}.pdf`);
|
||||||
|
await fs.access(produced); // throws if LibreOffice silently failed
|
||||||
|
await fs.rm(profileDir, { recursive: true, force: true });
|
||||||
|
return produced;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toMp4(inputPath, outDir) {
|
||||||
|
const out = path.join(outDir, 'video.mp4');
|
||||||
|
try {
|
||||||
|
// Fast path: just repackage the existing streams into an mp4 container.
|
||||||
|
await run(FFMPEG_BIN, ['-y', '-i', inputPath, '-c', 'copy', '-movflags', '+faststart', out]);
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
// Fall back to a real transcode for exotic codecs.
|
||||||
|
await run(FFMPEG_BIN, [
|
||||||
|
'-y', '-i', inputPath,
|
||||||
|
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-pix_fmt', 'yuv420p',
|
||||||
|
'-c:a', 'aac', '-b:a', '160k',
|
||||||
|
'-movflags', '+faststart',
|
||||||
|
out
|
||||||
|
]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn an uploaded file into something a browser can display.
|
||||||
|
* Returns { kind: 'pdf'|'video'|'image', viewerFile }.
|
||||||
|
*/
|
||||||
|
export async function processUpload({ inputPath, originalName, outDir }) {
|
||||||
|
const ext = path.extname(originalName).toLowerCase().replace('.', '');
|
||||||
|
await fs.mkdir(outDir, { recursive: true });
|
||||||
|
|
||||||
|
if (ext === 'pdf') {
|
||||||
|
const dest = path.join(outDir, 'document.pdf');
|
||||||
|
await fs.copyFile(inputPath, dest);
|
||||||
|
return { kind: 'pdf', viewerFile: 'document.pdf' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DOC_EXT.has(ext)) {
|
||||||
|
const pdf = await convertToPdf(inputPath, outDir);
|
||||||
|
const dest = path.join(outDir, 'document.pdf');
|
||||||
|
if (pdf !== dest) await fs.rename(pdf, dest);
|
||||||
|
return { kind: 'pdf', viewerFile: 'document.pdf' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (VIDEO_EXT.has(ext)) {
|
||||||
|
if (WEB_VIDEO.has(ext)) {
|
||||||
|
const outExt = ext === 'ogv' ? 'ogg' : ext;
|
||||||
|
const dest = path.join(outDir, `video.${outExt}`);
|
||||||
|
await fs.copyFile(inputPath, dest);
|
||||||
|
return { kind: 'video', viewerFile: path.basename(dest) };
|
||||||
|
}
|
||||||
|
const mp4 = await toMp4(inputPath, outDir);
|
||||||
|
return { kind: 'video', viewerFile: path.basename(mp4) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IMAGE_EXT.has(ext)) {
|
||||||
|
const dest = path.join(outDir, `image.${ext}`);
|
||||||
|
await fs.copyFile(inputPath, dest);
|
||||||
|
return { kind: 'image', viewerFile: path.basename(dest) };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported file type: .${ext}. Upload a video, PDF, image, or an Office/OpenDocument file.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUPPORTED_HINT = 'PowerPoint, Word, Excel, PDF, images, and common video formats';
|
||||||
+478
@@ -0,0 +1,478 @@
|
|||||||
|
import { createServer } from 'node:http';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import mime from 'mime-types';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import { Server as SocketServer } from 'socket.io';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PORT, HOST, PUBLIC_DIR, UPLOAD_DIR, PUBLIC_BASE_URL, MAX_UPLOAD_MB, APP_VERSION
|
||||||
|
} from './config.js';
|
||||||
|
import { processUpload, SUPPORTED_HINT } from './convert.js';
|
||||||
|
import {
|
||||||
|
initSessions, getSession, listSessions, saveSession, deleteSession, outDir, publicMeta
|
||||||
|
} from './sessions.js';
|
||||||
|
import {
|
||||||
|
initUsers, hasUsers, createFirstAdmin, verifyCredentials, createAuthSession, getAuthUser, destroyAuthSession,
|
||||||
|
listUsers, createUser, deleteUser, setPassword, setRole, getUserById, publicUser
|
||||||
|
} from './users.js';
|
||||||
|
import { initMail, isAvailable as mailAvailable, getMailConfig, saveMailConfig, sendMail } from './mailer.js';
|
||||||
|
import { logEvent, readLogs, clearLogs } from './logger.js';
|
||||||
|
|
||||||
|
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
||||||
|
await initSessions();
|
||||||
|
await initUsers();
|
||||||
|
await initMail();
|
||||||
|
|
||||||
|
// Migrate pre-1.4 sessions: ensure every one has an access code.
|
||||||
|
for (const m of listSessions()) {
|
||||||
|
if (!m.accessToken) { m.accessToken = nanoid(12); await saveSession(m); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.set('trust proxy', true);
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Live (ephemeral) per-session state: what everyone is currently looking at.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const live = new Map();
|
||||||
|
function ensureLive(id) {
|
||||||
|
let s = live.get(id);
|
||||||
|
if (!s) {
|
||||||
|
s = {
|
||||||
|
currentPage: 1,
|
||||||
|
mode: 'synced',
|
||||||
|
video: { playing: false, time: 0, rate: 1, updatedAt: Date.now() },
|
||||||
|
viewers: new Set(),
|
||||||
|
presenters: new Set()
|
||||||
|
};
|
||||||
|
live.set(id, s);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function stateFor(id) {
|
||||||
|
const s = ensureLive(id);
|
||||||
|
return {
|
||||||
|
meta: publicMeta(getSession(id)),
|
||||||
|
mode: s.mode,
|
||||||
|
currentPage: s.currentPage,
|
||||||
|
video: s.video,
|
||||||
|
counts: { viewers: s.viewers.size, presenters: s.presenters.size }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function parseCookies(req) {
|
||||||
|
const out = {};
|
||||||
|
for (const part of (req.headers.cookie || '').split(';')) {
|
||||||
|
const i = part.indexOf('=');
|
||||||
|
if (i > -1) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function currentUser(req) { return getAuthUser(parseCookies(req).sid); }
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
const u = currentUser(req);
|
||||||
|
if (!u) return res.status(401).json({ error: 'Please sign in.' });
|
||||||
|
req.user = u; next();
|
||||||
|
}
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
const u = currentUser(req);
|
||||||
|
if (!u) return res.status(401).json({ error: 'Please sign in.' });
|
||||||
|
if (u.role !== 'admin') return res.status(403).json({ error: 'Admins only.' });
|
||||||
|
req.user = u; next();
|
||||||
|
}
|
||||||
|
function setSessionCookie(res, token) {
|
||||||
|
res.setHeader('Set-Cookie', `sid=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 7}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth routes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// First-run: create the initial admin account. Only works while no users exist.
|
||||||
|
app.post('/api/setup', async (req, res) => {
|
||||||
|
if (hasUsers()) return res.status(403).json({ error: 'Setup has already been completed.' });
|
||||||
|
try {
|
||||||
|
const user = await createFirstAdmin(req.body || {});
|
||||||
|
setSessionCookie(res, createAuthSession(user.id));
|
||||||
|
await logEvent({ actor: user.username, action: 'setup', message: 'Created the first admin account' });
|
||||||
|
res.status(201).json({ user });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(400).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/login', async (req, res) => {
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
const user = verifyCredentials(username, password);
|
||||||
|
if (!user) {
|
||||||
|
await logEvent({ level: 'warn', actor: String(username || '?'), action: 'login', message: 'Failed login attempt' });
|
||||||
|
return res.status(401).json({ error: 'Incorrect username or password.' });
|
||||||
|
}
|
||||||
|
setSessionCookie(res, createAuthSession(user.id));
|
||||||
|
await logEvent({ actor: user.username, action: 'login', message: 'Signed in' });
|
||||||
|
res.json({ user: publicUser(user) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/logout', (req, res) => {
|
||||||
|
destroyAuthSession(parseCookies(req).sid);
|
||||||
|
res.setHeader('Set-Cookie', 'sid=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0');
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/me', (req, res) => {
|
||||||
|
const u = currentUser(req);
|
||||||
|
res.json({ authed: Boolean(u), user: publicUser(u), needsSetup: !hasUsers() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Presentations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
|
||||||
|
filename: (req, file, cb) => cb(null, `${nanoid(16)}${path.extname(file.originalname)}`)
|
||||||
|
}),
|
||||||
|
limits: { fileSize: MAX_UPLOAD_MB * 1024 * 1024 }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/sessions', requireAuth, upload.single('file'), async (req, res) => {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'No file was uploaded.' });
|
||||||
|
const id = nanoid(10);
|
||||||
|
const meta = {
|
||||||
|
id,
|
||||||
|
presenterToken: nanoid(24),
|
||||||
|
accessToken: nanoid(12), // embedded in the audience QR/link; rotated to invalidate old codes
|
||||||
|
owner: req.user.id,
|
||||||
|
ownerName: req.user.username,
|
||||||
|
title: (req.body?.title || '').trim() || req.file.originalname,
|
||||||
|
originalName: req.file.originalname,
|
||||||
|
kind: null,
|
||||||
|
viewerFile: null,
|
||||||
|
status: 'processing',
|
||||||
|
error: null,
|
||||||
|
createdAt: Date.now()
|
||||||
|
};
|
||||||
|
await saveSession(meta);
|
||||||
|
await logEvent({ actor: req.user.username, action: 'deck_create', message: `Uploaded "${meta.title}"` });
|
||||||
|
res.status(201).json({ id, presenterToken: meta.presenterToken });
|
||||||
|
|
||||||
|
processUpload({ inputPath: req.file.path, originalName: req.file.originalname, outDir: outDir(id) })
|
||||||
|
.then(async (result) => {
|
||||||
|
meta.kind = result.kind; meta.viewerFile = result.viewerFile; meta.status = 'ready';
|
||||||
|
await saveSession(meta);
|
||||||
|
io.to(id).emit('state', stateFor(id));
|
||||||
|
})
|
||||||
|
.catch(async (err) => {
|
||||||
|
meta.status = 'error'; meta.error = String(err.message || err);
|
||||||
|
await saveSession(meta);
|
||||||
|
await logEvent({ level: 'error', actor: req.user.username, action: 'deck_convert', message: `Failed "${meta.title}": ${meta.error}` });
|
||||||
|
io.to(id).emit('state', stateFor(id));
|
||||||
|
})
|
||||||
|
.finally(() => fs.unlink(req.file.path).catch(() => {}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/sessions', requireAuth, (req, res) => {
|
||||||
|
const all = listSessions();
|
||||||
|
const mine = req.user.role === 'admin' ? all : all.filter((m) => m.owner === req.user.id);
|
||||||
|
res.json(mine.map((m) => ({
|
||||||
|
id: m.id, title: m.title, kind: m.kind, status: m.status, error: m.error || null,
|
||||||
|
createdAt: m.createdAt, presenterToken: m.presenterToken, accessToken: m.accessToken,
|
||||||
|
ownerName: m.ownerName || null, viewers: live.get(m.id)?.viewers.size || 0
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/sessions/:id', (req, res) => {
|
||||||
|
const meta = getSession(req.params.id);
|
||||||
|
if (!meta) return res.status(404).json({ error: 'Presentation not found.' });
|
||||||
|
res.json(publicMeta(meta));
|
||||||
|
});
|
||||||
|
|
||||||
|
function canManage(user, meta) {
|
||||||
|
return user.role === 'admin' || meta.owner === user.id || !meta.owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.delete('/api/sessions/:id', requireAuth, async (req, res) => {
|
||||||
|
const meta = getSession(req.params.id);
|
||||||
|
if (!meta) return res.status(404).json({ error: 'Presentation not found.' });
|
||||||
|
if (!canManage(req.user, meta)) return res.status(403).json({ error: 'This is not your presentation.' });
|
||||||
|
await deleteSession(req.params.id);
|
||||||
|
live.delete(req.params.id);
|
||||||
|
io.to(req.params.id).emit('ended');
|
||||||
|
await logEvent({ actor: req.user.username, action: 'deck_delete', message: `Deleted "${meta.title}"` });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// QR + audience link
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function baseUrl(req) { return PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`; }
|
||||||
|
function viewUrl(req, meta) { return `${baseUrl(req)}/view/${meta.id}?k=${meta.accessToken}`; }
|
||||||
|
|
||||||
|
// Rotate the access code so any previously generated QR / link stops working.
|
||||||
|
async function rotateCode(meta) {
|
||||||
|
meta.accessToken = nanoid(12);
|
||||||
|
await saveSession(meta);
|
||||||
|
return meta.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// QR generation is owner/admin only, so a stale link can't be used to mint a fresh code.
|
||||||
|
app.get('/api/sessions/:id/qr', requireAuth, async (req, res) => {
|
||||||
|
const meta = getSession(req.params.id);
|
||||||
|
if (!meta) return res.status(404).json({ error: 'Presentation not found.' });
|
||||||
|
if (!canManage(req.user, meta)) return res.status(403).json({ error: 'This is not your presentation.' });
|
||||||
|
const url = viewUrl(req, meta);
|
||||||
|
const dataUrl = await QRCode.toDataURL(url, { errorCorrectionLevel: 'M', margin: 1, width: 640 });
|
||||||
|
res.json({ url, dataUrl });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/sessions/:id/qr.svg', requireAuth, async (req, res) => {
|
||||||
|
const meta = getSession(req.params.id);
|
||||||
|
if (!meta) return res.status(404).send('Not found');
|
||||||
|
if (!canManage(req.user, meta)) return res.status(403).send('Forbidden');
|
||||||
|
const svg = await QRCode.toString(viewUrl(req, meta), { type: 'svg', errorCorrectionLevel: 'M', margin: 1 });
|
||||||
|
res.type('image/svg+xml').send(svg);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rotate the codes for every presentation the caller manages (used by dashboard Refresh),
|
||||||
|
// so links from a finished session can no longer be opened.
|
||||||
|
app.post('/api/sessions/rotate-codes', requireAuth, async (req, res) => {
|
||||||
|
const targets = listSessions().filter((m) => canManage(req.user, m));
|
||||||
|
for (const m of targets) await rotateCode(m);
|
||||||
|
res.json({ ok: true, rotated: targets.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
// End a live presentation: invalidate its QR code and kick everyone watching.
|
||||||
|
app.post('/api/sessions/:id/terminate', requireAuth, async (req, res) => {
|
||||||
|
const meta = getSession(req.params.id);
|
||||||
|
if (!meta) return res.status(404).json({ error: 'Presentation not found.' });
|
||||||
|
if (!canManage(req.user, meta)) return res.status(403).json({ error: 'This is not your presentation.' });
|
||||||
|
await rotateCode(meta);
|
||||||
|
io.to(meta.id).emit('ended');
|
||||||
|
live.delete(meta.id);
|
||||||
|
await logEvent({ actor: req.user.username, action: 'deck_terminate', message: `Ended "${meta.title}" and invalidated its QR code` });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Share a presentation link + QR by email.
|
||||||
|
app.post('/api/sessions/:id/email', requireAuth, async (req, res) => {
|
||||||
|
const meta = getSession(req.params.id);
|
||||||
|
if (!meta) return res.status(404).json({ error: 'Presentation not found.' });
|
||||||
|
if (!canManage(req.user, meta)) return res.status(403).json({ error: 'This is not your presentation.' });
|
||||||
|
if (!mailAvailable()) return res.status(400).json({ error: 'Email is not set up yet. Ask an admin to configure Mail.' });
|
||||||
|
|
||||||
|
const to = String(req.body?.to || '').split(/[,;\s]+/).filter(Boolean);
|
||||||
|
if (!to.length) return res.status(400).json({ error: 'Add at least one recipient email address.' });
|
||||||
|
const note = String(req.body?.message || '').trim();
|
||||||
|
|
||||||
|
const url = viewUrl(req, meta);
|
||||||
|
const qrPng = await QRCode.toBuffer(url, { errorCorrectionLevel: 'M', margin: 1, width: 480 });
|
||||||
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
const html = `
|
||||||
|
<div style="font-family:Arial,Helvetica,sans-serif;max-width:520px;margin:auto;color:#111">
|
||||||
|
<h2 style="margin:0 0 6px">${esc(meta.title)}</h2>
|
||||||
|
<p style="color:#555;margin:0 0 16px">You're invited to watch this presentation.</p>
|
||||||
|
${note ? `<p>${esc(note)}</p>` : ''}
|
||||||
|
<p><a href="${esc(url)}" style="display:inline-block;background:#ffb020;color:#2a1c00;padding:11px 18px;border-radius:8px;text-decoration:none;font-weight:bold">Open the presentation</a></p>
|
||||||
|
<p style="color:#555;font-size:13px">Or scan this QR code:</p>
|
||||||
|
<img src="cid:qrcode" alt="QR code" width="220" height="220" style="border:1px solid #eee;border-radius:8px"/>
|
||||||
|
<p style="color:#888;font-size:12px;margin-top:16px">${esc(url)}</p>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendMail({
|
||||||
|
to, actor: req.user.username,
|
||||||
|
subject: `Join the presentation: ${meta.title}`,
|
||||||
|
text: `You're invited to watch "${meta.title}".\n\nOpen: ${url}\n${note ? '\n' + note + '\n' : ''}`,
|
||||||
|
html,
|
||||||
|
attachments: [{ filename: 'join-qr.png', content: qrPng, cid: 'qrcode' }]
|
||||||
|
});
|
||||||
|
res.json({ ok: true, sentTo: to });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// User management (admin)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.get('/api/users', requireAdmin, (req, res) => res.json(listUsers()));
|
||||||
|
|
||||||
|
app.post('/api/users', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const u = await createUser(req.body || {});
|
||||||
|
await logEvent({ actor: req.user.username, action: 'user_create', message: `Created user "${u.username}" (${u.role})` });
|
||||||
|
res.status(201).json(u);
|
||||||
|
} catch (e) { res.status(400).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/users/:id', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const target = getUserById(req.params.id);
|
||||||
|
await deleteUser(req.params.id, req.user.id);
|
||||||
|
await logEvent({ actor: req.user.username, action: 'user_delete', message: `Deleted user "${target?.username || req.params.id}"` });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) { res.status(400).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/users/:id/password', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await setPassword(req.params.id, req.body?.password);
|
||||||
|
await logEvent({ actor: req.user.username, action: 'user_password', message: `Reset password for "${getUserById(req.params.id)?.username}"` });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) { res.status(400).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/users/:id/role', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await setRole(req.params.id, req.body?.role, req.user.id);
|
||||||
|
await logEvent({ actor: req.user.username, action: 'user_role', message: `Set "${getUserById(req.params.id)?.username}" to ${req.body?.role}` });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) { res.status(400).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mail config + logs (admin)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.get('/api/mail', requireAdmin, (req, res) => res.json(getMailConfig()));
|
||||||
|
|
||||||
|
app.put('/api/mail', requireAdmin, async (req, res) => {
|
||||||
|
const saved = await saveMailConfig(req.body || {});
|
||||||
|
await logEvent({ actor: req.user.username, action: 'mail_config', message: 'Updated mail settings' });
|
||||||
|
res.json(saved);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/mail/test', requireAdmin, async (req, res) => {
|
||||||
|
const to = req.body?.to || req.user.email;
|
||||||
|
if (!to) return res.status(400).json({ error: 'Provide a test recipient address.' });
|
||||||
|
try {
|
||||||
|
await sendMail({
|
||||||
|
to, actor: req.user.username,
|
||||||
|
subject: 'QR Presentation — test email',
|
||||||
|
text: 'This is a test message from your QR Presentation server. Mail is working.',
|
||||||
|
html: '<p>This is a test message from your <b>QR Presentation</b> server. Mail is working.</p>'
|
||||||
|
});
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) { res.status(502).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/logs', requireAdmin, async (req, res) => res.json(await readLogs(400)));
|
||||||
|
app.delete('/api/logs', requireAdmin, async (req, res) => {
|
||||||
|
await clearLogs();
|
||||||
|
await logEvent({ actor: req.user.username, action: 'logs_clear', message: 'Cleared activity log' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Serve converted assets. The audience must present the current access code
|
||||||
|
// (?k=), so a rotated/terminated code stops working. Owners/admins bypass it.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.get('/s/:id/:file', (req, res) => {
|
||||||
|
const meta = getSession(req.params.id);
|
||||||
|
if (!meta) return res.status(404).end();
|
||||||
|
const u = currentUser(req);
|
||||||
|
const privileged = u && canManage(u, meta);
|
||||||
|
if (!privileged && req.query.k !== meta.accessToken) {
|
||||||
|
return res.status(403).end();
|
||||||
|
}
|
||||||
|
const safe = path.basename(req.params.file);
|
||||||
|
res.sendFile(path.join(outDir(req.params.id), safe), {
|
||||||
|
headers: { 'Content-Type': mime.lookup(safe) || 'application/octet-stream' }
|
||||||
|
}, (err) => { if (err && !res.headersSent) res.status(404).end(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pages
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const page = (name) => path.join(PUBLIC_DIR, name);
|
||||||
|
app.use(express.static(PUBLIC_DIR, { index: false }));
|
||||||
|
app.get('/config.json', (req, res) =>
|
||||||
|
res.json({ supportedHint: SUPPORTED_HINT, maxUploadMb: MAX_UPLOAD_MB, version: APP_VERSION, mailAvailable: mailAvailable() }));
|
||||||
|
|
||||||
|
app.get('/login', (req, res) => res.sendFile(page('login.html')));
|
||||||
|
app.get('/', (req, res) => currentUser(req) ? res.sendFile(page('index.html')) : res.redirect('/login'));
|
||||||
|
app.get('/users', (req, res) => {
|
||||||
|
const u = currentUser(req);
|
||||||
|
if (!u) return res.redirect('/login');
|
||||||
|
if (u.role !== 'admin') return res.redirect('/');
|
||||||
|
res.sendFile(page('users.html'));
|
||||||
|
});
|
||||||
|
app.get('/present/:id', (req, res) => res.sendFile(page('present.html')));
|
||||||
|
app.get('/view/:id', (req, res) => res.sendFile(page('view.html')));
|
||||||
|
app.get('/display/:id', (req, res) => res.sendFile(page('display.html')));
|
||||||
|
app.get('/qr/:id', (req, res) => res.sendFile(page('qr.html')));
|
||||||
|
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
return res.status(413).json({ error: `File is larger than the ${MAX_UPLOAD_MB} MB limit.` });
|
||||||
|
}
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Something went wrong on the server.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Real-time sync
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const server = createServer(app);
|
||||||
|
const io = new SocketServer(server, { maxHttpBufferSize: 1e6 });
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('join', ({ sessionId, role, token, k } = {}) => {
|
||||||
|
const meta = getSession(sessionId);
|
||||||
|
if (!meta) { socket.emit('error:join', 'This presentation is no longer available.'); return; }
|
||||||
|
const s = ensureLive(sessionId);
|
||||||
|
const asPresenter = role === 'presenter' && token && token === meta.presenterToken;
|
||||||
|
// Audience must carry the current access code from the QR/link.
|
||||||
|
if (!asPresenter && k !== meta.accessToken) {
|
||||||
|
socket.emit('error:join', 'This QR code is no longer valid. Ask the presenter for the current one.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.data.sessionId = sessionId;
|
||||||
|
socket.data.isPresenter = asPresenter;
|
||||||
|
socket.join(sessionId);
|
||||||
|
(asPresenter ? s.presenters : s.viewers).add(socket.id);
|
||||||
|
socket.emit('joined', { isPresenter: asPresenter });
|
||||||
|
io.to(sessionId).emit('state', stateFor(sessionId));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('control', (payload = {}) => {
|
||||||
|
const id = socket.data.sessionId;
|
||||||
|
if (!id || !socket.data.isPresenter) return;
|
||||||
|
const s = ensureLive(id);
|
||||||
|
if (Number.isFinite(payload.currentPage)) s.currentPage = Math.max(1, Math.trunc(payload.currentPage));
|
||||||
|
if (payload.mode === 'synced' || payload.mode === 'free') s.mode = payload.mode;
|
||||||
|
if (payload.video && typeof payload.video === 'object') {
|
||||||
|
s.video = {
|
||||||
|
playing: Boolean(payload.video.playing),
|
||||||
|
time: Number(payload.video.time) || 0,
|
||||||
|
rate: Number(payload.video.rate) || 1,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
io.to(id).emit('state', stateFor(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
const id = socket.data.sessionId;
|
||||||
|
if (!id) return;
|
||||||
|
const s = live.get(id);
|
||||||
|
if (!s) return;
|
||||||
|
s.viewers.delete(socket.id);
|
||||||
|
s.presenters.delete(socket.id);
|
||||||
|
io.to(id).emit('state', stateFor(id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
console.log(`\n QR Presentation v${APP_VERSION} on http://${HOST}:${PORT}`);
|
||||||
|
if (PUBLIC_BASE_URL) console.log(` Audience links use: ${PUBLIC_BASE_URL}`);
|
||||||
|
else console.log(' Tip: set PUBLIC_BASE_URL so phones off this machine can open the QR link.');
|
||||||
|
console.log('');
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { LOG_FILE } from './config.js';
|
||||||
|
|
||||||
|
// Simple JSON-lines log stored on the data volume. Newest entries are returned
|
||||||
|
// first for the management page.
|
||||||
|
export async function logEvent({ level = 'info', actor = 'system', action = 'event', message = '', meta } = {}) {
|
||||||
|
const entry = { ts: Date.now(), level, actor, action, message, ...(meta ? { meta } : {}) };
|
||||||
|
try {
|
||||||
|
await fs.mkdir(path.dirname(LOG_FILE), { recursive: true });
|
||||||
|
await fs.appendFile(LOG_FILE, JSON.stringify(entry) + '\n');
|
||||||
|
} catch {
|
||||||
|
// Logging must never take the app down.
|
||||||
|
}
|
||||||
|
const tag = level === 'error' ? 'ERROR' : level === 'warn' ? 'warn' : 'log';
|
||||||
|
console.log(`[${tag}] ${action}: ${message}`);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readLogs(limit = 300) {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(LOG_FILE, 'utf8');
|
||||||
|
const lines = raw.split('\n').filter(Boolean);
|
||||||
|
return lines.slice(-limit).reverse().map((l) => {
|
||||||
|
try { return JSON.parse(l); } catch { return { ts: 0, level: 'info', message: l }; }
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearLogs() {
|
||||||
|
try { await fs.writeFile(LOG_FILE, ''); } catch {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { ImapFlow } from 'imapflow';
|
||||||
|
import { CONFIG_FILE } from './config.js';
|
||||||
|
import { readJson, writeJson } from './store.js';
|
||||||
|
import { logEvent } from './logger.js';
|
||||||
|
|
||||||
|
// Prefilled for PurelyMail. The admin only needs to add the account address and
|
||||||
|
// password in User management → Mail.
|
||||||
|
const DEFAULTS = {
|
||||||
|
enabled: false,
|
||||||
|
fromName: 'QR Presentation',
|
||||||
|
username: '', // full PurelyMail email address, used as SMTP/IMAP login
|
||||||
|
password: '',
|
||||||
|
smtpHost: 'smtp.purelymail.com',
|
||||||
|
smtpPort: 465,
|
||||||
|
smtpSecure: true, // SSL/TLS on 465
|
||||||
|
imapHost: 'imap.purelymail.com',
|
||||||
|
imapPort: 993,
|
||||||
|
imapSecure: true, // SSL/TLS on 993
|
||||||
|
saveToSent: true,
|
||||||
|
sentFolder: 'Sent'
|
||||||
|
};
|
||||||
|
|
||||||
|
let cfg = { ...DEFAULTS };
|
||||||
|
|
||||||
|
export async function initMail() {
|
||||||
|
const saved = await readJson(CONFIG_FILE, null);
|
||||||
|
cfg = { ...DEFAULTS, ...(saved && saved.mail ? saved.mail : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAvailable() {
|
||||||
|
return Boolean(cfg.enabled && cfg.username && cfg.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never leak the stored password.
|
||||||
|
export function getMailConfig() {
|
||||||
|
const { password, ...safe } = cfg;
|
||||||
|
return { ...safe, hasPassword: Boolean(password), available: isAvailable() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveMailConfig(patch = {}) {
|
||||||
|
const next = { ...cfg, ...patch };
|
||||||
|
// Blank password field means "keep what's stored".
|
||||||
|
if (patch.password === undefined || patch.password === '') next.password = cfg.password;
|
||||||
|
next.smtpPort = Number(next.smtpPort) || DEFAULTS.smtpPort;
|
||||||
|
next.imapPort = Number(next.imapPort) || DEFAULTS.imapPort;
|
||||||
|
next.enabled = Boolean(next.enabled);
|
||||||
|
next.smtpSecure = Boolean(next.smtpSecure);
|
||||||
|
next.imapSecure = Boolean(next.imapSecure);
|
||||||
|
next.saveToSent = Boolean(next.saveToSent);
|
||||||
|
cfg = next;
|
||||||
|
await writeJson(CONFIG_FILE, { mail: cfg });
|
||||||
|
return getMailConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireConfigured() {
|
||||||
|
if (!cfg.username || !cfg.password) {
|
||||||
|
throw new Error('Mail account is not set up. Add it under User management → Mail.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile a MIME message to a Buffer using nodemailer's stream transport, so we
|
||||||
|
// can both send it over SMTP and APPEND the exact same bytes to the Sent folder.
|
||||||
|
async function buildRaw({ to, subject, text, html, attachments }) {
|
||||||
|
const composer = nodemailer.createTransport({ streamTransport: true, buffer: true, newline: 'crlf' });
|
||||||
|
const built = await composer.sendMail({
|
||||||
|
from: `"${cfg.fromName}" <${cfg.username}>`,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
attachments
|
||||||
|
});
|
||||||
|
return built.message; // Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMail({ to, subject, text, html, attachments, actor = 'system' }) {
|
||||||
|
requireConfigured();
|
||||||
|
const recipients = (Array.isArray(to) ? to : [to]).map((s) => String(s).trim()).filter(Boolean);
|
||||||
|
if (!recipients.length) throw new Error('No recipient address provided.');
|
||||||
|
|
||||||
|
const raw = await buildRaw({ to: recipients.join(', '), subject, text, html, attachments });
|
||||||
|
|
||||||
|
const smtp = nodemailer.createTransport({
|
||||||
|
host: cfg.smtpHost,
|
||||||
|
port: cfg.smtpPort,
|
||||||
|
secure: cfg.smtpSecure,
|
||||||
|
auth: { user: cfg.username, pass: cfg.password }
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await smtp.sendMail({ envelope: { from: cfg.username, to: recipients }, raw });
|
||||||
|
} catch (err) {
|
||||||
|
await logEvent({ level: 'error', actor, action: 'mail_send',
|
||||||
|
message: `Failed sending to ${recipients.join(', ')}: ${err.message}` });
|
||||||
|
throw new Error(`Could not send the email: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a copy in the account's Sent folder via IMAP APPEND.
|
||||||
|
if (cfg.saveToSent) {
|
||||||
|
try {
|
||||||
|
const client = new ImapFlow({
|
||||||
|
host: cfg.imapHost, port: cfg.imapPort, secure: cfg.imapSecure,
|
||||||
|
auth: { user: cfg.username, pass: cfg.password }, logger: false
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
await client.append(cfg.sentFolder || 'Sent', raw, ['\\Seen']);
|
||||||
|
await client.logout();
|
||||||
|
} catch (err) {
|
||||||
|
await logEvent({ level: 'warn', actor, action: 'mail_sent_copy',
|
||||||
|
message: `Sent to ${recipients.join(', ')} but could not save to "${cfg.sentFolder}": ${err.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logEvent({ actor, action: 'mail_send',
|
||||||
|
message: `Email sent to ${recipients.join(', ')} — "${subject}"` });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { SESSION_DIR } from './config.js';
|
||||||
|
|
||||||
|
// id -> meta. Backed by data/sessions/<id>/meta.json so restarts keep decks.
|
||||||
|
const sessions = new Map();
|
||||||
|
|
||||||
|
export async function initSessions() {
|
||||||
|
await fs.mkdir(SESSION_DIR, { recursive: true });
|
||||||
|
const entries = await fs.readdir(SESSION_DIR, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(path.join(SESSION_DIR, entry.name, 'meta.json'), 'utf8');
|
||||||
|
const meta = JSON.parse(raw);
|
||||||
|
// A deck still "processing" when the server died can never finish; mark it.
|
||||||
|
if (meta.status === 'processing') {
|
||||||
|
meta.status = 'error';
|
||||||
|
meta.error = 'Processing was interrupted by a restart. Please re-upload.';
|
||||||
|
}
|
||||||
|
sessions.set(meta.id, meta);
|
||||||
|
} catch {
|
||||||
|
// Skip unreadable session folders rather than crash on boot.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSession(id) {
|
||||||
|
return sessions.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSessions() {
|
||||||
|
return [...sessions.values()].sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionDir(id) {
|
||||||
|
return path.join(SESSION_DIR, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function outDir(id) {
|
||||||
|
return path.join(SESSION_DIR, id, 'out');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSession(meta) {
|
||||||
|
sessions.set(meta.id, meta);
|
||||||
|
const dir = sessionDir(meta.id);
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta, null, 2));
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(id) {
|
||||||
|
sessions.delete(id);
|
||||||
|
await fs.rm(sessionDir(id), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fields safe to expose to an audience member (no presenter token).
|
||||||
|
export function publicMeta(meta) {
|
||||||
|
if (!meta) return null;
|
||||||
|
return {
|
||||||
|
id: meta.id,
|
||||||
|
title: meta.title,
|
||||||
|
kind: meta.kind,
|
||||||
|
status: meta.status,
|
||||||
|
error: meta.error || null,
|
||||||
|
viewerFile: meta.viewerFile || null,
|
||||||
|
createdAt: meta.createdAt
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export async function readJson(file, fallback = null) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await fs.readFile(file, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeJson(file, data) {
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||||
|
const tmp = `${file}.tmp`;
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(data, null, 2));
|
||||||
|
await fs.rename(tmp, file); // atomic replace
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { USERS_FILE, SEED_ADMIN_USER, SEED_ADMIN_PASSWORD, SEED_ADMIN_EMAIL } from './config.js';
|
||||||
|
import { readJson, writeJson } from './store.js';
|
||||||
|
import { logEvent } from './logger.js';
|
||||||
|
|
||||||
|
let users = []; // { id, username, email, role, salt, hash, createdAt }
|
||||||
|
const authSessions = new Map(); // token -> { userId, expires }
|
||||||
|
const WEEK = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function hashPassword(password, salt) {
|
||||||
|
return crypto.scryptSync(String(password), salt, 64).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUser(username, email, password, role) {
|
||||||
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
return {
|
||||||
|
id: nanoid(10),
|
||||||
|
username: username.trim(),
|
||||||
|
email: (email || '').trim(),
|
||||||
|
role: role === 'admin' ? 'admin' : 'user',
|
||||||
|
salt,
|
||||||
|
hash: hashPassword(password, salt),
|
||||||
|
createdAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initUsers() {
|
||||||
|
const saved = await readJson(USERS_FILE, null);
|
||||||
|
users = Array.isArray(saved) ? saved : [];
|
||||||
|
// No auto-seed: on first run the operator creates the admin in the browser.
|
||||||
|
// For unattended deploys, ADMIN_USERNAME + ADMIN_PASSWORD may pre-seed one.
|
||||||
|
if (users.length === 0 && process.env.ADMIN_PASSWORD) {
|
||||||
|
users = [buildUser(SEED_ADMIN_USER, SEED_ADMIN_EMAIL, SEED_ADMIN_PASSWORD, 'admin')];
|
||||||
|
await persist();
|
||||||
|
await logEvent({ action: 'seed', message: `Pre-seeded admin "${SEED_ADMIN_USER}" from environment` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasUsers() {
|
||||||
|
return users.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the very first admin account (first-run setup). Refuses once any user exists.
|
||||||
|
export async function createFirstAdmin({ username, email, password }) {
|
||||||
|
if (users.length > 0) throw new Error('Setup has already been completed.');
|
||||||
|
if (!username || !password) throw new Error('Username and password are required.');
|
||||||
|
const u = buildUser(username, email, password, 'admin');
|
||||||
|
users.push(u);
|
||||||
|
await persist();
|
||||||
|
return publicUser(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist() {
|
||||||
|
await writeJson(USERS_FILE, users);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicUser(u) {
|
||||||
|
return u ? { id: u.id, username: u.username, email: u.email, role: u.role, createdAt: u.createdAt } : null;
|
||||||
|
}
|
||||||
|
export function listUsers() { return users.map(publicUser); }
|
||||||
|
export function getUserById(id) { return users.find((u) => u.id === id); }
|
||||||
|
export function getUserByName(name) {
|
||||||
|
const n = String(name || '').toLowerCase();
|
||||||
|
return users.find((u) => u.username.toLowerCase() === n);
|
||||||
|
}
|
||||||
|
export function adminCount() { return users.filter((u) => u.role === 'admin').length; }
|
||||||
|
|
||||||
|
export async function createUser({ username, email, password, role }) {
|
||||||
|
if (!username || !password) throw new Error('Username and password are required.');
|
||||||
|
if (getUserByName(username)) throw new Error('That username is already taken.');
|
||||||
|
const u = buildUser(username, email, password, role);
|
||||||
|
users.push(u);
|
||||||
|
await persist();
|
||||||
|
return publicUser(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id, actingUserId) {
|
||||||
|
const u = getUserById(id);
|
||||||
|
if (!u) throw new Error('User not found.');
|
||||||
|
if (id === actingUserId) throw new Error('You cannot delete your own account.');
|
||||||
|
if (u.role === 'admin' && adminCount() <= 1) throw new Error('Cannot delete the last remaining admin.');
|
||||||
|
users = users.filter((x) => x.id !== id);
|
||||||
|
await persist();
|
||||||
|
for (const [token, s] of authSessions) if (s.userId === id) authSessions.delete(token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPassword(id, password) {
|
||||||
|
const u = getUserById(id);
|
||||||
|
if (!u) throw new Error('User not found.');
|
||||||
|
if (!password) throw new Error('Password cannot be empty.');
|
||||||
|
u.salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
u.hash = hashPassword(password, u.salt);
|
||||||
|
await persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRole(id, role, actingUserId) {
|
||||||
|
const u = getUserById(id);
|
||||||
|
if (!u) throw new Error('User not found.');
|
||||||
|
const next = role === 'admin' ? 'admin' : 'user';
|
||||||
|
if (u.role === 'admin' && next === 'user' && adminCount() <= 1) {
|
||||||
|
throw new Error('Cannot demote the last remaining admin.');
|
||||||
|
}
|
||||||
|
if (id === actingUserId && next === 'user') {
|
||||||
|
throw new Error('You cannot remove your own admin rights.');
|
||||||
|
}
|
||||||
|
u.role = next;
|
||||||
|
await persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyCredentials(username, password) {
|
||||||
|
const u = getUserByName(username);
|
||||||
|
if (!u) return null;
|
||||||
|
const attempt = Buffer.from(hashPassword(password, u.salt));
|
||||||
|
const known = Buffer.from(u.hash);
|
||||||
|
if (attempt.length !== known.length || !crypto.timingSafeEqual(attempt, known)) return null;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- auth sessions (cookie tokens, in-memory) ------------------------------
|
||||||
|
export function createAuthSession(userId) {
|
||||||
|
const token = nanoid(32);
|
||||||
|
authSessions.set(token, { userId, expires: Date.now() + WEEK });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
export function getAuthUser(token) {
|
||||||
|
const s = token && authSessions.get(token);
|
||||||
|
if (!s) return null;
|
||||||
|
if (s.expires < Date.now()) { authSessions.delete(token); return null; }
|
||||||
|
return getUserById(s.userId) || null;
|
||||||
|
}
|
||||||
|
export function destroyAuthSession(token) {
|
||||||
|
if (token) authSessions.delete(token);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user