██╗ ██╗██╗███████╗██╗ ██╗███████╗██████╗
██║ ██║██║██╔════╝██║ ██║██╔════╝██╔══██╗
██║ ██║██║█████╗ ██║ █╗ ██║█████╗ ██████╔╝
╚██╗ ██╔╝██║██╔══╝ ██║███╗██║██╔══╝ ██╔══██╗
╚████╔╝ ██║███████╗╚███╔███╔╝███████╗██║ ██║
╚═══╝ ╚═╝╚══════╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═╝
Read lab documents together: PDF, Word and PowerPoint in one reader, marks and sticky-note comments that update live, and every action also an MCP tool.
nextjs postgres nextcloud pdfjs docker mcp
WinLab members upload a paper draft, a report or a deck, share it with the people who should read it, and mark it up together. Each mark carries a sticky note: a thread of comments with names and times, where edited comments keep their earlier wording.
> "What is still open on the proposal?"
⚡ list_annotations { "id": "01e3…", "version": 2 }
✓ [{ "tool": "box", "page": 1, "author": "alice", "resolvedAt": null, "comments": [{ "body": "這段要改寫" }] }]
- Lists your documents: your own and the ones shared with you, with how many marks are still open.
- Reads PDF, Word and PowerPoint: docx and pptx turn into PDF at upload, with stand-in fonts at the same widths (Calibri, Times New Roman, Arial, DFKai-SB).
- Marks with 3 tools: a highlighter on text, a box over any area (a figure, a table, a slide object) and a pen.
- Keeps a sticky note on every mark: comments with author and time, edit history, and a button that marks it resolved.
- Keeps versions: upload a new file to the same document; every version stays readable with its own marks.
- Shares per member: view, comment or edit.
- Updates live: a mark or comment from anyone shows on every open page.
- Serves MCP at
/mcp, with the viewer as the OAuth 2.1 authorization server.
With Docker Compose, on any machine with Docker:
curl -fsSLO https://raw.githubusercontent.com/NYCU-WinLab/viewer/main/compose.yaml
curl -fsSL -o .env https://raw.githubusercontent.com/NYCU-WinLab/viewer/main/.env.example
# set POSTGRES_PASSWORD, BETTER_AUTH_*, KEYCLOAK_* and NEXTCLOUD_* in .env
docker compose up -dThis starts Postgres and the convert service, runs the migrations once, then serves the viewer from ghcr.io/nycu-winlab/viewer on 127.0.0.1:3000.
Important
Put a reverse proxy with TLS in front, at the host in BETTER_AUTH_URL, and let it pass request bodies of 100 MB. MCP clients only accept an HTTPS resource, and the Keycloak redirect URI must match it.
viewer.winlab.tw deploys on every push to main. The release workflow retags the images ci built, then sends a Carrel app release (winfra/viewer). On the machine, carrel-deployd runs deploy/carrel-start.sh: it sets VIEWER_VERSION in /opt/viewer/.env and waits until web is healthy. A failed health check rolls back to the previous release.
- In your Keycloak realm, create a confidential OpenID Connect client with the redirect URI
<BETTER_AUTH_URL>/api/auth/callback/keycloak, and put its ID and secret in.env. Give its service accountview-users, so sharing can list lab members and/mcpcan check that a member still exists. - In Nextcloud, create an app password for the account that holds the files, create the folder
NEXTCLOUD_ROOTin it, and put both in.env. - Optional: to let members import from their own Nextcloud, create an OAuth 2 client there with the redirect URI
<BETTER_AUTH_URL>/nextcloud/callbackand put it in.env. To add "Open in Viewer" (在 Viewer 打開) to the Nextcloud file menu, build and install the app in nextcloud/ (npm ci && npm run build, then./install.sh). - Open
BETTER_AUTH_URL, sign in and press Upload (上傳) in the top right. - Add the MCP server to a client, for example
claude mcp add --transport http viewer <BETTER_AUTH_URL>/mcp, then ask it to callwhoami.
Set these in .env; .env.example documents every one.
| Key | What it sets | Default |
|---|---|---|
POSTGRES_PASSWORD |
The database password, used by every service | required |
BETTER_AUTH_URL |
The public URL; the MCP resource is <url>/mcp |
required |
BETTER_AUTH_SECRET |
Signs sessions and OAuth queries | required |
KEYCLOAK_ISSUER |
The realm URL, e.g. https://auth.example.com/realms/lab |
required |
KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET |
The viewer's Keycloak client | required |
NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_APP_PASSWORD |
Where the files live | required |
NEXTCLOUD_ROOT |
The folder in that account the viewer owns | viewer |
NEXTCLOUD_OAUTH_CLIENT_ID, NEXTCLOUD_OAUTH_CLIENT_SECRET |
The Nextcloud OAuth 2 client members link their own account with; no Nextcloud import when empty | empty |
PORTAL_URL, PORTAL_SERVICE_KEY |
Where guest invitations are mailed from: a portal service key allowed to send invitations; no mail when empty | https://portal.winlab.tw, empty |
VIEWER_VERSION |
Image tag | main |
VIEWER_BIND, VIEWER_PORT |
Where web listens on the host | 127.0.0.1, 3000 |
OTEL_EXPORTER_OTLP_ENDPOINT |
OTLP/HTTP endpoint for traces and error logs; off when empty | empty |
flowchart LR
member[Member] -->|browser| pages[Pages and reader]
agent[MCP client] -->|bearer token| mcp["/mcp"]
pages --> actions[Actions]
mcp --> actions
actions --> pg[(Postgres)]
member -->|"PUT /api/upload"| convert[Convert service]
convert --> nc[(Nextcloud)]
member -->|"GET /files"| nc
An upload streams to a temp file. A docx or pptx goes to the convert service (Gotenberg, LibreOffice inside) and comes back as PDF. Both files go to Nextcloud as <root>/<document id>/v<n>.<ext> and v<n>.pdf; only then is the version row written. The reader loads /files/<id>/<n> (access checked, streamed from Nextcloud) into pdf.js, which draws each page on a canvas with a selectable text layer. Marks are stored in page fractions, so they hold at any zoom. A member can also import from their own Nextcloud: they link it once through OAuth 2 (/nextcloud/connect), the tokens are sealed in Postgres, and the Nextcloud tab of Upload lists their folders. A file is bound to its document by its Nextcloud file id, so a rename, a move or another member opening the same shared file finds the same document. Nextcloud only binds the file: the document is shared in the viewer like any other, and another member who opens the file gets it only when it is shared with them. An editor checks the file for a newer version with one button in the version menu (a WebDAV SEARCH by file id with their own token) and syncs it as the next version. The Nextcloud app in nextcloud/ adds Open in Viewer (在 Viewer 打開) to the file menu, which shows /embed/nextcloud?path=<path> framed in a Nextcloud page; sign-in and linking open in a small window there. Only lib/actions, lib/auth and lib/db touch the database, and CI checks that every action is in the registry the MCP tools come from.
bun install
cp .env.example .env.local # point DATABASE_URL at a local Postgres, CONVERT_URL at Gotenberg
docker build -t viewer-convert convert && docker run -d -p 3001:3000 viewer-convert
bun run db:migrate
bun run devCI runs typecheck, lint, format, the design token check, the action registry check and a build, then builds both images and probes a real docker compose up. See CLAUDE.md for the conventions.
- Conversion is close, not exact: fonts are stand-ins, and pptx animations and some effects are lost.
- A mark belongs to the version it was drawn on; carrying open marks to a new version is not built yet.
- Sharing is per member; groups and share links are not built yet.
MIT, made in a lab that reads every draft twice.