chore: prepare standalone public repo
- Remove Mattermost integration (OAuth, channel bridge, file sync watcher, frontend import modal). RefBoard now ships as a self-contained app. - Replace SSO Login screen with email/password form (+ optional register link gated by ALLOW_SELF_REGISTRATION). - Add SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD env-var bootstrap so a fresh install ships with an admin account on first boot (idempotent). - ALLOW_SELF_REGISTRATION flag (default false) gates POST /api/auth/register. First user can always register (auto-promoted to admin). - Drop mattermost_id and mm_file_id columns + board_channel_links table from the schema; remove related db helpers and exports. - Add MIT LICENSE, comprehensive README, .env.example, docker-compose.yml (bundles MinIO so one command boots a working stack). - Expand .gitignore for typical Node + Docker dev artefacts.
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
# ────────────────────────────────────────────────
|
||||||
|
# RefBoard — Environment Configuration
|
||||||
|
# Copy this file to `.env` and edit values for your install.
|
||||||
|
# ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ---- Server ----
|
||||||
|
PORT=8000
|
||||||
|
NODE_ENV=development
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
|
||||||
|
# ---- JWT auth ----
|
||||||
|
# REQUIRED in production. Generate with: openssl rand -base64 64
|
||||||
|
JWT_SECRET=change-me-to-a-long-random-string
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# ---- SQLite database ----
|
||||||
|
# Path inside the running process. Defaults to /app/data/refboard.db (Docker).
|
||||||
|
# For local non-Docker dev, set this to ./data/refboard.db (relative to backend/).
|
||||||
|
DB_PATH=/app/data/refboard.db
|
||||||
|
|
||||||
|
# ---- Object storage (MinIO / any S3-compatible) ----
|
||||||
|
MINIO_ENDPOINT=minio
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_USE_SSL=false
|
||||||
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
MINIO_SECRET_KEY=minioadmin
|
||||||
|
MINIO_BUCKET=refboard
|
||||||
|
|
||||||
|
# Public-facing base URL for media. Leave empty to serve via the backend proxy
|
||||||
|
# (recommended for self-hosted single-box installs).
|
||||||
|
PUBLIC_URL=
|
||||||
|
|
||||||
|
# Max upload size in MB (per file). Defaults to 200.
|
||||||
|
MAX_FILE_SIZE_MB=200
|
||||||
|
|
||||||
|
# ---- First-run admin bootstrap ----
|
||||||
|
# When the server starts and no user with this email exists, RefBoard will
|
||||||
|
# create one with role=admin. Idempotent — runs every boot but only seeds once.
|
||||||
|
# Leave blank to skip seeding.
|
||||||
|
SEED_ADMIN_EMAIL=
|
||||||
|
SEED_ADMIN_PASSWORD=
|
||||||
|
SEED_ADMIN_USERNAME=
|
||||||
|
SEED_ADMIN_DISPLAY_NAME=
|
||||||
|
|
||||||
|
# ---- Self-registration ----
|
||||||
|
# When "true", the public /api/auth/register endpoint accepts new signups.
|
||||||
|
# When "false" (default), only admins can create accounts (via /api/admin/users).
|
||||||
|
# The very first user can always register, regardless of this flag, and is
|
||||||
|
# auto-promoted to admin.
|
||||||
|
ALLOW_SELF_REGISTRATION=false
|
||||||
|
|
||||||
|
# Frontend mirror — controls whether the Login screen shows the "Register" link.
|
||||||
|
# Must be set at *build* time (Vite) for the bundled frontend, not at runtime.
|
||||||
|
VITE_ALLOW_SELF_REGISTRATION=false
|
||||||
|
|
||||||
|
# ---- Optional: API key for bot / programmatic upload ----
|
||||||
|
# If set, /api/upload/api-key endpoints require this header value.
|
||||||
|
REFBOARD_API_KEY=
|
||||||
+43
-1
@@ -1,6 +1,48 @@
|
|||||||
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Local data
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
.worktrees/
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Test output
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Temp
|
||||||
|
*.tmp
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Internal tooling (not for public repo)
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
.claude/
|
||||||
|
docs/superpowers/
|
||||||
|
|
||||||
|
# Docker volumes (local dev)
|
||||||
|
.docker-data/
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Hiren Kangad
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# RefBoard
|
||||||
|
|
||||||
|
A self-hosted, real-time collaborative reference board — like PureRef, but on the web, multiplayer, and with markdown notes, threaded review comments, and PDF support baked in.
|
||||||
|
|
||||||
|
Drop images, videos, and PDFs onto an infinite GPU canvas. Pan, zoom, group, align, annotate. Share a board with your team and watch each others' cursors in real time. Pin a comment to a thumbnail and resolve it like a code review.
|
||||||
|
|
||||||
|
Built because we needed PureRef's painlessness, Miro's collaboration, and a code-review's threading — without paying three different SaaS subscriptions for them.
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
**Canvas**
|
||||||
|
- Infinite, GPU-accelerated canvas (Pixi.js v8) — handles thousands of items without dropping frames
|
||||||
|
- Drag & drop images, videos, and PDFs from your filesystem or clipboard
|
||||||
|
- Drop image URLs directly from the browser
|
||||||
|
- Pan / zoom / fit-all, selection, lasso, group, ungroup
|
||||||
|
- Undo / redo, locked layers, hidden layers
|
||||||
|
- Pen / draw tool, sticky notes, text labels
|
||||||
|
- Markdown cards (BlockNote-powered editor) right on the canvas
|
||||||
|
|
||||||
|
**PureRef-parity power tools**
|
||||||
|
- 40+ keyboard shortcuts mapped to PureRef defaults
|
||||||
|
- Align, distribute, normalize size / scale / width / height
|
||||||
|
- Auto-arrange in grid / row / column / by name / by z-order / random / stack
|
||||||
|
- Optimal pack, overlay compare (Ctrl+Y), flip H/V, reset transform, grayscale, lock
|
||||||
|
- Right-click context menu with everything
|
||||||
|
|
||||||
|
**Real-time collaboration**
|
||||||
|
- Live cursors with name labels
|
||||||
|
- Full-scene sync over Socket.IO with interaction-aware deferral
|
||||||
|
- Presence (online avatars), follow-user mode, share dialog with role-based access (owner / editor / viewer)
|
||||||
|
- Public, read-only sharing links via collection share token
|
||||||
|
|
||||||
|
**Review & threads**
|
||||||
|
- Pin a comment to any object on the canvas → starts a thread
|
||||||
|
- Threaded comments with status (open / resolved)
|
||||||
|
- Review mode toggles a clean overlay for walking through feedback
|
||||||
|
|
||||||
|
**Media pipeline**
|
||||||
|
- Image variants (thumbnail / hires / LOD) generated on upload via Sharp
|
||||||
|
- Video poster + duration + dimensions extracted via ffmpeg
|
||||||
|
- PDF → page thumbnails + hires renders via poppler
|
||||||
|
- Background media worker so the upload feels instant
|
||||||
|
|
||||||
|
**Auth & admin**
|
||||||
|
- JWT-based email/password auth
|
||||||
|
- First user is auto-admin
|
||||||
|
- `SEED_ADMIN_*` env vars to bootstrap an admin on first boot
|
||||||
|
- `ALLOW_SELF_REGISTRATION` flag — when off, only admins can create accounts
|
||||||
|
- Admin endpoints to list/create/deactivate users (UI dashboard coming next)
|
||||||
|
|
||||||
|
**Deployment**
|
||||||
|
- One `docker compose up` starts RefBoard + a bundled MinIO for object storage
|
||||||
|
- Dockerfile is multi-stage and self-contained
|
||||||
|
- SQLite (WAL mode) for metadata — zero external DB dependency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start (Docker, recommended)
|
||||||
|
|
||||||
|
Requires Docker + Docker Compose v2.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/metalfinger/refboard
|
||||||
|
cd refboard
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Edit .env and at minimum set:
|
||||||
|
# JWT_SECRET=<run: openssl rand -base64 64>
|
||||||
|
# SEED_ADMIN_EMAIL=you@example.com
|
||||||
|
# SEED_ADMIN_PASSWORD=<a long password>
|
||||||
|
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open <http://localhost:8000> and sign in with the seeded admin email / password.
|
||||||
|
|
||||||
|
MinIO console (S3 dashboard) is at <http://localhost:9001> — login is whatever you set as `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` in `.env` (defaults to `minioadmin` / `minioadmin`).
|
||||||
|
|
||||||
|
Persistent state lives under `./.docker-data/` (SQLite + MinIO objects). Back this up.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual install (without Docker)
|
||||||
|
|
||||||
|
Requires Node.js 20+, ffmpeg, and poppler-utils on your `PATH`. You also need an S3-compatible object store reachable from the backend — easiest is to run MinIO standalone.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Object storage
|
||||||
|
docker run -d --name minio -p 9000:9000 -p 9001:9001 \
|
||||||
|
-e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \
|
||||||
|
-v $(pwd)/.docker-data/minio:/data \
|
||||||
|
minio/minio server /data --console-address ":9001"
|
||||||
|
|
||||||
|
# 2. Backend
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
DB_PATH=./data/refboard.db \
|
||||||
|
JWT_SECRET=$(openssl rand -base64 64) \
|
||||||
|
MINIO_ENDPOINT=localhost \
|
||||||
|
SEED_ADMIN_EMAIL=you@example.com \
|
||||||
|
SEED_ADMIN_PASSWORD=changeme \
|
||||||
|
node server.js
|
||||||
|
|
||||||
|
# 3. Frontend (dev mode, separate terminal)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
In dev mode the Vite server proxies `/api` and `/socket.io` to the backend on port 8000 — open the URL Vite prints.
|
||||||
|
|
||||||
|
For production, run `npm run build` in `frontend/` — the backend serves the built `frontend/dist` automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All knobs live in `.env`. See [`.env.example`](.env.example) for the full annotated list. Highlights:
|
||||||
|
|
||||||
|
| Variable | Required? | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| `JWT_SECRET` | yes (in prod) | Signs auth tokens. Make it long and random. |
|
||||||
|
| `DB_PATH` | no | SQLite file path. Defaults to `/app/data/refboard.db` (Docker). |
|
||||||
|
| `MINIO_ENDPOINT` / `_PORT` / `_ACCESS_KEY` / `_SECRET_KEY` / `_BUCKET` | yes | S3-compatible storage. |
|
||||||
|
| `SEED_ADMIN_EMAIL` + `SEED_ADMIN_PASSWORD` | no | Idempotent first-boot admin bootstrap. |
|
||||||
|
| `ALLOW_SELF_REGISTRATION` | no | When `true`, anyone can register. Default `false`. |
|
||||||
|
| `MAX_FILE_SIZE_MB` | no | Per-file upload cap. Default 200. |
|
||||||
|
| `REFBOARD_API_KEY` | no | Enables programmatic upload via X-API-Key header. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser │
|
||||||
|
│ React + TypeScript + Vite │
|
||||||
|
│ Pixi.js v8 canvas · BlockNote markdown · Socket.IO client │
|
||||||
|
└──────────────────┬─────────────────────────────┬───────────┘
|
||||||
|
│ HTTPS / WS │
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ Express + Socket.IO │ │ Static frontend │
|
||||||
|
│ /api/* REST │ │ (served by backend) │
|
||||||
|
│ /socket.io WS rooms │ └──────────────────────┘
|
||||||
|
│ Media worker (queue) │
|
||||||
|
└────┬──────────┬───────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────┐ ┌──────────────────┐
|
||||||
|
│ SQLite │ │ MinIO (S3) │
|
||||||
|
│ (WAL) │ │ images / videos │
|
||||||
|
│ metadata │ │ pdf renders │
|
||||||
|
└──────────┘ └──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Frontend** — React + Vite + TypeScript. Pixi.js v8 powers the canvas (LOD-aware, viewport-culled, GPU-batched). BlockNote provides the markdown editor used for sticky cards. Socket.IO syncs scene state.
|
||||||
|
- **Backend** — Express for REST, Socket.IO for real-time, better-sqlite3 for metadata (WAL mode), Sharp + ffmpeg + poppler for media processing. A background worker handles thumbnails/hires/PDF rasterization out of the request path.
|
||||||
|
- **Storage** — MinIO (or any S3 API). The backend proxies media bytes through `/api/images/*` so URLs stay stable across deployment moves.
|
||||||
|
|
||||||
|
See [CHANGELOG.md](CHANGELOG.md) for the version history (v0.1.0 → v0.5.0).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [ ] Per-board activity log (who added/deleted what, when)
|
||||||
|
- [ ] Admin dashboard frontend (backend endpoints already exist at `/api/admin/users`)
|
||||||
|
- [ ] Mobile-friendly read-only board view
|
||||||
|
- [ ] Export board → PDF / image grid
|
||||||
|
- [ ] Optional remote storage adapters (S3 direct, R2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Issues and PRs welcome. The codebase is single-language on the frontend (TypeScript + React) and small on the backend (a few hundred lines per route file). The hardest parts are in `frontend/src/canvas/` (the Pixi-powered scene graph and sync engine).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE). Built and maintained by [Hiren Kangad](https://metalfinger.xyz).
|
||||||
+49
-71
@@ -84,18 +84,6 @@ db.exec(`
|
|||||||
CREATE INDEX IF NOT EXISTS idx_boards_created_by ON boards(created_by);
|
CREATE INDEX IF NOT EXISTS idx_boards_created_by ON boards(created_by);
|
||||||
CREATE INDEX IF NOT EXISTS idx_images_board ON images(board_id);
|
CREATE INDEX IF NOT EXISTS idx_images_board ON images(board_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS board_channel_links (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
board_id TEXT NOT NULL,
|
|
||||||
channel_id TEXT NOT NULL,
|
|
||||||
channel_name TEXT,
|
|
||||||
created_by TEXT NOT NULL,
|
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(board_id, channel_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_board_channel_links_board ON board_channel_links(board_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS media_jobs (
|
CREATE TABLE IF NOT EXISTS media_jobs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||||
@@ -197,11 +185,6 @@ try {
|
|||||||
} catch {
|
} catch {
|
||||||
db.exec("ALTER TABLE images ADD COLUMN media_type TEXT DEFAULT 'image'");
|
db.exec("ALTER TABLE images ADD COLUMN media_type TEXT DEFAULT 'image'");
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
db.prepare("SELECT mm_file_id FROM images LIMIT 0").get();
|
|
||||||
} catch {
|
|
||||||
db.exec("ALTER TABLE images ADD COLUMN mm_file_id TEXT");
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
db.prepare("SELECT poster_asset_key FROM images LIMIT 0").get();
|
db.prepare("SELECT poster_asset_key FROM images LIMIT 0").get();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -218,12 +201,6 @@ try {
|
|||||||
db.exec("ALTER TABLE images ADD COLUMN native_width INTEGER");
|
db.exec("ALTER TABLE images ADD COLUMN native_width INTEGER");
|
||||||
db.exec("ALTER TABLE images ADD COLUMN native_height INTEGER");
|
db.exec("ALTER TABLE images ADD COLUMN native_height INTEGER");
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
db.prepare("SELECT mattermost_id FROM users LIMIT 0").get();
|
|
||||||
} catch {
|
|
||||||
db.exec("ALTER TABLE users ADD COLUMN mattermost_id TEXT");
|
|
||||||
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_mattermost_id ON users(mattermost_id)");
|
|
||||||
}
|
|
||||||
try { db.prepare('SELECT page_count FROM images LIMIT 0').get(); }
|
try { db.prepare('SELECT page_count FROM images LIMIT 0').get(); }
|
||||||
catch { db.exec('ALTER TABLE images ADD COLUMN page_count INTEGER'); }
|
catch { db.exec('ALTER TABLE images ADD COLUMN page_count INTEGER'); }
|
||||||
|
|
||||||
@@ -245,14 +222,6 @@ function getUserByUsername(username) {
|
|||||||
return db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username);
|
return db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUserByMattermostId(mmId) {
|
|
||||||
return db.prepare('SELECT * FROM users WHERE mattermost_id = ? AND is_active = 1').get(mmId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUserMattermostId(userId, mmId) {
|
|
||||||
db.prepare("UPDATE users SET mattermost_id = ?, updated_at = datetime('now') WHERE id = ?").run(mmId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createUser({ id, email, username, passwordHash, displayName, role }) {
|
function createUser({ id, email, username, passwordHash, displayName, role }) {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO users (id, email, username, password_hash, display_name, role)
|
INSERT INTO users (id, email, username, password_hash, display_name, role)
|
||||||
@@ -461,11 +430,11 @@ function saveBoardCanvas(boardId, canvasState, thumbnail) {
|
|||||||
// ---------------------
|
// ---------------------
|
||||||
// Images
|
// Images
|
||||||
// ---------------------
|
// ---------------------
|
||||||
function createImage({ id, boardId, filename, mimeType, fileSize, width, height, minioPath, publicUrl, uploadedBy, assetKey, mediaType, mmFileId }) {
|
function createImage({ id, boardId, filename, mimeType, fileSize, width, height, minioPath, publicUrl, uploadedBy, assetKey, mediaType }) {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO images (id, board_id, filename, mime_type, file_size, width, height, minio_path, public_url, uploaded_by, asset_key, media_type, mm_file_id)
|
INSERT INTO images (id, board_id, filename, mime_type, file_size, width, height, minio_path, public_url, uploaded_by, asset_key, media_type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(id, boardId, filename, mimeType, fileSize, width || null, height || null, minioPath, publicUrl || null, uploadedBy, assetKey || null, mediaType || 'image', mmFileId || null);
|
`).run(id, boardId, filename, mimeType, fileSize, width || null, height || null, minioPath, publicUrl || null, uploadedBy, assetKey || null, mediaType || 'image');
|
||||||
return db.prepare('SELECT * FROM images WHERE id = ?').get(id);
|
return db.prepare('SELECT * FROM images WHERE id = ?').get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,37 +458,6 @@ function deleteBoardImageRecords(boardId) {
|
|||||||
return images;
|
return images;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------
|
|
||||||
// Board–Channel Links
|
|
||||||
// ---------------------
|
|
||||||
function createBoardChannelLink({ id, boardId, channelId, channelName, createdBy }) {
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO board_channel_links (id, board_id, channel_id, channel_name, created_by)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
`).run(id, boardId, channelId, channelName || null, createdBy);
|
|
||||||
return db.prepare('SELECT * FROM board_channel_links WHERE id = ?').get(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBoardChannelLinks(boardId) {
|
|
||||||
return db.prepare('SELECT * FROM board_channel_links WHERE board_id = ? ORDER BY created_at DESC').all(boardId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBoardChannelLink(linkId) {
|
|
||||||
return db.prepare('SELECT * FROM board_channel_links WHERE id = ?').get(linkId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteBoardChannelLink(linkId) {
|
|
||||||
db.prepare('DELETE FROM board_channel_links WHERE id = ?').run(linkId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAllBoardChannelLinks() {
|
|
||||||
return db.prepare('SELECT * FROM board_channel_links ORDER BY created_at DESC').all();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getImageByMmFileId(boardId, mmFileId) {
|
|
||||||
return db.prepare('SELECT * FROM images WHERE board_id = ? AND mm_file_id = ?').get(boardId, mmFileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------
|
// ---------------------
|
||||||
// Media Jobs
|
// Media Jobs
|
||||||
// ---------------------
|
// ---------------------
|
||||||
@@ -698,11 +636,52 @@ function deleteComment(commentId) {
|
|||||||
db.prepare('DELETE FROM comments WHERE id = ?').run(commentId);
|
db.prepare('DELETE FROM comments WHERE id = ?').run(commentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------
|
||||||
|
// Seed admin from env (idempotent)
|
||||||
|
// ---------------------
|
||||||
|
async function seedAdminFromEnv() {
|
||||||
|
const email = process.env.SEED_ADMIN_EMAIL;
|
||||||
|
const password = process.env.SEED_ADMIN_PASSWORD;
|
||||||
|
if (!email || !password) return;
|
||||||
|
|
||||||
|
const existing = getUserByEmail(email);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.role !== 'admin') {
|
||||||
|
db.prepare("UPDATE users SET role = 'admin', updated_at = datetime('now') WHERE id = ?").run(existing.id);
|
||||||
|
console.log(`[db] Promoted ${email} to admin via SEED_ADMIN_EMAIL.`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const username = (process.env.SEED_ADMIN_USERNAME || email.split('@')[0]).replace(/[^a-zA-Z0-9_-]/g, '');
|
||||||
|
const displayName = process.env.SEED_ADMIN_DISPLAY_NAME || username;
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
// Avoid username collision
|
||||||
|
let finalUsername = username;
|
||||||
|
let suffix = 1;
|
||||||
|
while (getUserByUsername(finalUsername)) {
|
||||||
|
finalUsername = `${username}${suffix++}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
createUser({
|
||||||
|
id: uuidv4(),
|
||||||
|
email,
|
||||||
|
username: finalUsername,
|
||||||
|
passwordHash,
|
||||||
|
displayName,
|
||||||
|
role: 'admin',
|
||||||
|
});
|
||||||
|
console.log(`[db] Seeded admin user: ${email} (username: ${finalUsername})`);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
db,
|
db,
|
||||||
// Users
|
// Users
|
||||||
getUserByEmail, getUserById, getUserByUsername, getUserByMattermostId,
|
getUserByEmail, getUserById, getUserByUsername,
|
||||||
createUser, updateUserMattermostId,
|
createUser,
|
||||||
getAllUsers, updateUserPassword, deactivateUser, getUserCount,
|
getAllUsers, updateUserPassword, deactivateUser, getUserCount,
|
||||||
// Collections
|
// Collections
|
||||||
getCollections, getCollection, getCollectionByShareToken,
|
getCollections, getCollection, getCollectionByShareToken,
|
||||||
@@ -713,9 +692,6 @@ module.exports = {
|
|||||||
getCollectionBoards, getBoard, createBoard, updateBoard, deleteBoard, saveBoardCanvas,
|
getCollectionBoards, getBoard, createBoard, updateBoard, deleteBoard, saveBoardCanvas,
|
||||||
// Images
|
// Images
|
||||||
createImage, getBoardImages, getImage, deleteImage, deleteBoardImageRecords,
|
createImage, getBoardImages, getImage, deleteImage, deleteBoardImageRecords,
|
||||||
// Board–Channel Links
|
|
||||||
createBoardChannelLink, getBoardChannelLinks, getBoardChannelLink, deleteBoardChannelLink,
|
|
||||||
getAllBoardChannelLinks, getImageByMmFileId,
|
|
||||||
// Media Jobs
|
// Media Jobs
|
||||||
createMediaJob, updateMediaJob, getMediaJob, getPendingMediaJobs, updateImageMedia,
|
createMediaJob, updateMediaJob, getMediaJob, getPendingMediaJobs, updateImageMedia,
|
||||||
// PDF Pages
|
// PDF Pages
|
||||||
@@ -725,4 +701,6 @@ module.exports = {
|
|||||||
incrementThreadCommentCount, decrementThreadCommentCount,
|
incrementThreadCommentCount, decrementThreadCommentCount,
|
||||||
// Comments
|
// Comments
|
||||||
getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment,
|
getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment,
|
||||||
|
// Bootstrap
|
||||||
|
seedAdminFromEnv,
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-3
@@ -22,7 +22,8 @@ const router = Router();
|
|||||||
*/
|
*/
|
||||||
router.post('/register', async (req, res) => {
|
router.post('/register', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email, username, password, display_name } = req.body;
|
const { email, username, password, display_name, displayName } = req.body;
|
||||||
|
const dn = display_name || displayName;
|
||||||
|
|
||||||
if (!email || !username || !password) {
|
if (!email || !username || !password) {
|
||||||
return res.status(400).json({ error: 'Email, username, and password are required' });
|
return res.status(400).json({ error: 'Email, username, and password are required' });
|
||||||
@@ -31,6 +32,14 @@ router.post('/register', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowRegistration = (process.env.ALLOW_SELF_REGISTRATION || '').toLowerCase() === 'true';
|
||||||
|
const userCount = getUserCount();
|
||||||
|
if (!allowRegistration && userCount > 0) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Self-registration is disabled. Ask an admin to create your account.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const existing = getUserByEmail(email);
|
const existing = getUserByEmail(email);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return res.status(409).json({ error: 'Email already registered' });
|
return res.status(409).json({ error: 'Email already registered' });
|
||||||
@@ -42,7 +51,6 @@ router.post('/register', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const userCount = getUserCount();
|
|
||||||
const role = userCount === 0 ? 'admin' : 'member';
|
const role = userCount === 0 ? 'admin' : 'member';
|
||||||
|
|
||||||
const user = createUser({
|
const user = createUser({
|
||||||
@@ -50,7 +58,7 @@ router.post('/register', async (req, res) => {
|
|||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
displayName: display_name || username,
|
displayName: dn || username,
|
||||||
role,
|
role,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
const { Router } = require('express');
|
|
||||||
const { v4: uuidv4 } = require('uuid');
|
|
||||||
const { authMiddleware } = require('../auth');
|
|
||||||
const {
|
|
||||||
getBoard, getCollectionMember,
|
|
||||||
createBoardChannelLink, getBoardChannelLinks, getBoardChannelLink, deleteBoardChannelLink,
|
|
||||||
createImage, getImageByMmFileId,
|
|
||||||
} = require('../db');
|
|
||||||
const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio');
|
|
||||||
const sharp = require('sharp');
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
const MM_URL = process.env.MM_URL || 'http://mattermost:8065';
|
|
||||||
const MM_BOT_TOKEN = process.env.MM_BOT_TOKEN || '';
|
|
||||||
|
|
||||||
const IMAGE_MIME_TYPES = [
|
|
||||||
'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml',
|
|
||||||
];
|
|
||||||
const VIDEO_MIME_TYPES = [
|
|
||||||
'video/mp4', 'video/webm', 'video/quicktime',
|
|
||||||
];
|
|
||||||
const ALLOWED_MIME_TYPES = [...IMAGE_MIME_TYPES, ...VIDEO_MIME_TYPES];
|
|
||||||
|
|
||||||
// All routes require auth
|
|
||||||
router.use(authMiddleware);
|
|
||||||
|
|
||||||
// ---------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check board access for editor+ role.
|
|
||||||
* Returns board or null (sends error response).
|
|
||||||
*/
|
|
||||||
function checkEditorAccess(req, res) {
|
|
||||||
const board = getBoard(req.params.boardId);
|
|
||||||
if (!board) {
|
|
||||||
res.status(404).json({ error: 'Board not found' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const member = getCollectionMember(board.collection_id, req.user.id);
|
|
||||||
const hierarchy = { owner: 3, editor: 2, viewer: 1 };
|
|
||||||
if (!member || (hierarchy[member.role] || 0) < 2) {
|
|
||||||
res.status(403).json({ error: 'Editor or owner access required' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return board;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check board access for any authenticated member (viewer+).
|
|
||||||
*/
|
|
||||||
function checkViewerAccess(req, res) {
|
|
||||||
const board = getBoard(req.params.boardId);
|
|
||||||
if (!board) {
|
|
||||||
res.status(404).json({ error: 'Board not found' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const member = getCollectionMember(board.collection_id, req.user.id);
|
|
||||||
if (!member) {
|
|
||||||
res.status(403).json({ error: 'Access denied' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return board;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make authenticated request to Mattermost API.
|
|
||||||
*/
|
|
||||||
async function mmFetch(path, options = {}) {
|
|
||||||
const url = `${MM_URL}/api/v4${path}`;
|
|
||||||
const resp = await fetch(url, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${MM_BOT_TOKEN}`,
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
const body = await resp.text().catch(() => '');
|
|
||||||
throw new Error(`Mattermost API error ${resp.status}: ${body}`);
|
|
||||||
}
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classify MIME type as image or video.
|
|
||||||
*/
|
|
||||||
function classifyMedia(mimeType) {
|
|
||||||
if (VIDEO_MIME_TYPES.includes(mimeType)) return 'video';
|
|
||||||
return 'image';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a media file (image or video) to MinIO as a single file.
|
|
||||||
* GPU handles all scaling natively — no LOD tiers needed.
|
|
||||||
*/
|
|
||||||
async function uploadMedia(boardId, imageId, buffer, mimetype) {
|
|
||||||
const ext = MIME_TO_EXT[mimetype] || '.bin';
|
|
||||||
const minioPath = `boards/${boardId}/${imageId}${ext}`;
|
|
||||||
await putBuffer(minioPath, buffer, mimetype);
|
|
||||||
|
|
||||||
let width = null, height = null;
|
|
||||||
if (mimetype !== 'image/svg+xml' && !mimetype.startsWith('video/')) {
|
|
||||||
try {
|
|
||||||
const metadata = await sharp(buffer).metadata();
|
|
||||||
width = metadata.width || null;
|
|
||||||
height = metadata.height || null;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return { assetKey: minioPath, minioPath, width, height };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------
|
|
||||||
// Channel Link Routes
|
|
||||||
// ---------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/boards/:boardId/mm-link
|
|
||||||
* Link a Mattermost channel to a board.
|
|
||||||
*/
|
|
||||||
router.post('/:boardId/mm-link', (req, res) => {
|
|
||||||
try {
|
|
||||||
const board = checkEditorAccess(req, res);
|
|
||||||
if (!board) return;
|
|
||||||
|
|
||||||
const { channelId, channelName } = req.body;
|
|
||||||
if (!channelId) {
|
|
||||||
return res.status(400).json({ error: 'channelId is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const link = createBoardChannelLink({
|
|
||||||
id: uuidv4(),
|
|
||||||
boardId: board.id,
|
|
||||||
channelId,
|
|
||||||
channelName: channelName || null,
|
|
||||||
createdBy: req.user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(201).json(link);
|
|
||||||
} catch (err) {
|
|
||||||
if (err.message && err.message.includes('UNIQUE constraint failed')) {
|
|
||||||
return res.status(409).json({ error: 'Channel already linked to this board' });
|
|
||||||
}
|
|
||||||
console.error('[mm-bridge] link error:', err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/boards/:boardId/mm-link
|
|
||||||
* List linked channels for a board.
|
|
||||||
*/
|
|
||||||
router.get('/:boardId/mm-link', (req, res) => {
|
|
||||||
try {
|
|
||||||
const board = checkViewerAccess(req, res);
|
|
||||||
if (!board) return;
|
|
||||||
|
|
||||||
const links = getBoardChannelLinks(board.id);
|
|
||||||
return res.json({ links });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[mm-bridge] list links error:', err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/boards/:boardId/mm-link/:linkId
|
|
||||||
* Unlink a channel from a board.
|
|
||||||
*/
|
|
||||||
router.delete('/:boardId/mm-link/:linkId', (req, res) => {
|
|
||||||
try {
|
|
||||||
const board = checkEditorAccess(req, res);
|
|
||||||
if (!board) return;
|
|
||||||
|
|
||||||
const link = getBoardChannelLink(req.params.linkId);
|
|
||||||
if (!link || link.board_id !== board.id) {
|
|
||||||
return res.status(404).json({ error: 'Link not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteBoardChannelLink(link.id);
|
|
||||||
return res.json({ ok: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[mm-bridge] unlink error:', err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------
|
|
||||||
// Media Pull Route
|
|
||||||
// ---------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/boards/:boardId/mm-pull
|
|
||||||
* Manual pull media from a Mattermost channel or thread.
|
|
||||||
*/
|
|
||||||
router.post('/:boardId/mm-pull', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const board = checkEditorAccess(req, res);
|
|
||||||
if (!board) return;
|
|
||||||
|
|
||||||
if (!MM_BOT_TOKEN) {
|
|
||||||
return res.status(503).json({ error: 'Mattermost bot token not configured' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { channelId, threadId } = req.body;
|
|
||||||
if (!channelId && !threadId) {
|
|
||||||
return res.status(400).json({ error: 'channelId or threadId is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Fetch posts from Mattermost
|
|
||||||
let postsData;
|
|
||||||
if (threadId) {
|
|
||||||
const resp = await mmFetch(`/posts/${threadId}/thread`);
|
|
||||||
postsData = await resp.json();
|
|
||||||
} else {
|
|
||||||
const resp = await mmFetch(`/channels/${channelId}/posts`);
|
|
||||||
postsData = await resp.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// postsData.order is array of post IDs, postsData.posts is { id: post }
|
|
||||||
const posts = postsData.posts || {};
|
|
||||||
const order = postsData.order || Object.keys(posts);
|
|
||||||
|
|
||||||
// 2. Collect all file_ids from posts
|
|
||||||
const fileIds = [];
|
|
||||||
for (const postId of order) {
|
|
||||||
const post = posts[postId];
|
|
||||||
if (post && post.file_ids && post.file_ids.length > 0) {
|
|
||||||
fileIds.push(...post.file_ids);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileIds.length === 0) {
|
|
||||||
return res.json({ assets: [], message: 'No files found in posts' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Process each file
|
|
||||||
const assets = [];
|
|
||||||
const errors = [];
|
|
||||||
|
|
||||||
for (const fileId of fileIds) {
|
|
||||||
try {
|
|
||||||
// Skip if already imported (dedup by mm_file_id)
|
|
||||||
const existing = getImageByMmFileId(board.id, fileId);
|
|
||||||
if (existing) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file info
|
|
||||||
const infoResp = await mmFetch(`/files/${fileId}/info`);
|
|
||||||
const fileInfo = await infoResp.json();
|
|
||||||
|
|
||||||
const mimeType = fileInfo.mime_type || '';
|
|
||||||
if (!ALLOWED_MIME_TYPES.includes(mimeType)) {
|
|
||||||
continue; // skip non-media files
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download file
|
|
||||||
const fileResp = await mmFetch(`/files/${fileId}`);
|
|
||||||
const arrayBuf = await fileResp.arrayBuffer();
|
|
||||||
const buffer = Buffer.from(arrayBuf);
|
|
||||||
|
|
||||||
const imageId = uuidv4();
|
|
||||||
const mediaType = classifyMedia(mimeType);
|
|
||||||
|
|
||||||
const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimeType);
|
|
||||||
|
|
||||||
const publicUrl = getImageUrl(minioPath);
|
|
||||||
|
|
||||||
const image = createImage({
|
|
||||||
id: imageId,
|
|
||||||
boardId: board.id,
|
|
||||||
filename: fileInfo.name || `mm-${fileId}`,
|
|
||||||
mimeType,
|
|
||||||
fileSize: buffer.length,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
minioPath,
|
|
||||||
publicUrl,
|
|
||||||
uploadedBy: req.user.id,
|
|
||||||
assetKey,
|
|
||||||
mediaType,
|
|
||||||
mmFileId: fileId,
|
|
||||||
});
|
|
||||||
|
|
||||||
assets.push({
|
|
||||||
id: image.id,
|
|
||||||
url: publicUrl,
|
|
||||||
public_url: publicUrl,
|
|
||||||
width: image.width,
|
|
||||||
height: image.height,
|
|
||||||
file_size: image.file_size,
|
|
||||||
mime_type: image.mime_type,
|
|
||||||
asset_key: image.asset_key,
|
|
||||||
media_type: image.media_type,
|
|
||||||
mm_file_id: fileId,
|
|
||||||
});
|
|
||||||
} catch (fileErr) {
|
|
||||||
console.error(`[mm-bridge] failed to process file ${fileId}:`, fileErr.message);
|
|
||||||
errors.push({ fileId, error: fileErr.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
assets,
|
|
||||||
total_files: fileIds.length,
|
|
||||||
imported: assets.length,
|
|
||||||
skipped: fileIds.length - assets.length - errors.length,
|
|
||||||
errors: errors.length > 0 ? errors : undefined,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[mm-bridge] pull error:', err);
|
|
||||||
return res.status(500).json({ error: 'Failed to pull media from Mattermost' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
const { Router } = require('express');
|
|
||||||
const { v4: uuidv4 } = require('uuid');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const {
|
|
||||||
getUserByEmail,
|
|
||||||
getUserByUsername,
|
|
||||||
createUser,
|
|
||||||
getUserByMattermostId,
|
|
||||||
updateUserMattermostId,
|
|
||||||
} = require('../db');
|
|
||||||
const { generateToken } = require('../auth');
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// OAuth config from env
|
|
||||||
const CLIENT_ID = process.env.MATTERMOST_OAUTH_CLIENT_ID || '';
|
|
||||||
const CLIENT_SECRET = process.env.MATTERMOST_OAUTH_CLIENT_SECRET || '';
|
|
||||||
const AUTHORIZE_URL = process.env.MATTERMOST_OAUTH_AUTHORIZE_URL || '';
|
|
||||||
const TOKEN_URL = process.env.MATTERMOST_OAUTH_TOKEN_URL || '';
|
|
||||||
const USERINFO_URL = process.env.MATTERMOST_OAUTH_USERINFO_URL || '';
|
|
||||||
const PUBLIC_URL = process.env.PUBLIC_URL || process.env.REFBOARD_PUBLIC_URL || '';
|
|
||||||
const CALLBACK_PATH = '/api/auth/mattermost/callback';
|
|
||||||
|
|
||||||
// CSRF state store: state -> timestamp (expire after 10 min)
|
|
||||||
const _pendingStates = new Map();
|
|
||||||
const STATE_TTL = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
function _cleanupStates() {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [state, ts] of _pendingStates) {
|
|
||||||
if (now - ts > STATE_TTL) _pendingStates.delete(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isConfigured() {
|
|
||||||
return !!(CLIENT_ID && CLIENT_SECRET && AUTHORIZE_URL && TOKEN_URL && USERINFO_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/auth/mattermost
|
|
||||||
* Initiates the OAuth flow — redirects browser to Mattermost authorize page.
|
|
||||||
*/
|
|
||||||
router.get('/mattermost', (req, res) => {
|
|
||||||
if (!isConfigured()) {
|
|
||||||
return res.status(503).json({ error: 'Mattermost OAuth not configured' });
|
|
||||||
}
|
|
||||||
|
|
||||||
_cleanupStates();
|
|
||||||
const state = crypto.randomBytes(24).toString('hex');
|
|
||||||
_pendingStates.set(state, Date.now());
|
|
||||||
|
|
||||||
const callbackUrl = `${PUBLIC_URL}${CALLBACK_PATH}`;
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
response_type: 'code',
|
|
||||||
client_id: CLIENT_ID,
|
|
||||||
redirect_uri: callbackUrl,
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.redirect(`${AUTHORIZE_URL}?${params.toString()}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/auth/mattermost/callback
|
|
||||||
* Handles the OAuth callback from Mattermost.
|
|
||||||
*/
|
|
||||||
router.get('/mattermost/callback', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { code, state, error: oauthError } = req.query;
|
|
||||||
|
|
||||||
if (oauthError) {
|
|
||||||
console.error('[oauth] Mattermost returned error:', oauthError);
|
|
||||||
return res.redirect(`/login?error=${encodeURIComponent('Login was denied')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate CSRF state
|
|
||||||
if (!state || !_pendingStates.has(state)) {
|
|
||||||
return res.redirect('/login?error=' + encodeURIComponent('Invalid login session. Please try again.'));
|
|
||||||
}
|
|
||||||
_pendingStates.delete(state);
|
|
||||||
|
|
||||||
if (!code) {
|
|
||||||
return res.redirect('/login?error=' + encodeURIComponent('No authorization code received'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exchange code for access token
|
|
||||||
const callbackUrl = `${PUBLIC_URL}${CALLBACK_PATH}`;
|
|
||||||
const tokenResp = await fetch(TOKEN_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
client_id: CLIENT_ID,
|
|
||||||
client_secret: CLIENT_SECRET,
|
|
||||||
code,
|
|
||||||
redirect_uri: callbackUrl,
|
|
||||||
}).toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tokenResp.ok) {
|
|
||||||
const text = await tokenResp.text();
|
|
||||||
console.error('[oauth] Token exchange failed:', tokenResp.status, text);
|
|
||||||
return res.redirect('/login?error=' + encodeURIComponent('Login failed. Please try again.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenData = await tokenResp.json();
|
|
||||||
const accessToken = tokenData.access_token;
|
|
||||||
|
|
||||||
// Fetch user info from Mattermost
|
|
||||||
const userResp = await fetch(USERINFO_URL, {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userResp.ok) {
|
|
||||||
console.error('[oauth] Userinfo fetch failed:', userResp.status);
|
|
||||||
return res.redirect('/login?error=' + encodeURIComponent('Failed to get user info'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const mmUser = await userResp.json();
|
|
||||||
const mmId = mmUser.id;
|
|
||||||
const mmEmail = mmUser.email;
|
|
||||||
const mmUsername = mmUser.username;
|
|
||||||
const mmDisplayName = [mmUser.first_name, mmUser.last_name].filter(Boolean).join(' ')
|
|
||||||
|| mmUser.nickname || mmUsername;
|
|
||||||
|
|
||||||
// Try to find existing RefBoard user
|
|
||||||
let user = getUserByMattermostId(mmId);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
// Try matching by email
|
|
||||||
user = getUserByEmail(mmEmail);
|
|
||||||
if (user) {
|
|
||||||
// Link existing account
|
|
||||||
updateUserMattermostId(user.id, mmId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
// Auto-create new user
|
|
||||||
// Handle username collision
|
|
||||||
let finalUsername = mmUsername;
|
|
||||||
const existingUsername = getUserByUsername(finalUsername);
|
|
||||||
if (existingUsername) {
|
|
||||||
finalUsername = `${mmUsername}_mm`;
|
|
||||||
}
|
|
||||||
|
|
||||||
user = createUser({
|
|
||||||
id: uuidv4(),
|
|
||||||
email: mmEmail,
|
|
||||||
username: finalUsername,
|
|
||||||
passwordHash: `oauth:mattermost:${crypto.randomBytes(16).toString('hex')}`,
|
|
||||||
displayName: mmDisplayName,
|
|
||||||
role: 'member',
|
|
||||||
});
|
|
||||||
|
|
||||||
updateUserMattermostId(user.id, mmId);
|
|
||||||
console.log(`[oauth] Created RefBoard user for MM user ${mmUsername} (${mmEmail})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate JWT and redirect to frontend
|
|
||||||
const jwt = generateToken(user);
|
|
||||||
const userPayload = encodeURIComponent(JSON.stringify({
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
username: user.username,
|
|
||||||
display_name: user.display_name,
|
|
||||||
role: user.role,
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.redirect(`/login?token=${jwt}&user=${userPayload}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[oauth] Callback error:', err);
|
|
||||||
res.redirect('/login?error=' + encodeURIComponent('Something went wrong. Please try again.'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/auth/mattermost/status
|
|
||||||
* Check if Mattermost OAuth is configured (for frontend to show/hide button).
|
|
||||||
*/
|
|
||||||
router.get('/mattermost/status', (_req, res) => {
|
|
||||||
res.json({ enabled: isConfigured() });
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
+7
-18
@@ -95,22 +95,18 @@ app.get('/api/users/search', (req, res) => {
|
|||||||
|
|
||||||
// ---- API routes ----
|
// ---- API routes ----
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const oauthRoutes = require('./routes/oauth');
|
|
||||||
const collectionRoutes = require('./routes/collections');
|
const collectionRoutes = require('./routes/collections');
|
||||||
const boardRoutes = require('./routes/boards');
|
const boardRoutes = require('./routes/boards');
|
||||||
const uploadRoutes = require('./routes/upload');
|
const uploadRoutes = require('./routes/upload');
|
||||||
const adminRoutes = require('./routes/admin');
|
const adminRoutes = require('./routes/admin');
|
||||||
const mmBridgeRoutes = require('./routes/mattermost-bridge');
|
|
||||||
const threadRoutes = require('./routes/threads');
|
const threadRoutes = require('./routes/threads');
|
||||||
const pdfRoutes = require('./routes/pdf');
|
const pdfRoutes = require('./routes/pdf');
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/auth', oauthRoutes);
|
|
||||||
app.use('/api/collections', collectionRoutes);
|
app.use('/api/collections', collectionRoutes);
|
||||||
app.use('/api/boards', boardRoutes);
|
app.use('/api/boards', boardRoutes);
|
||||||
app.use('/api/upload', uploadRoutes);
|
app.use('/api/upload', uploadRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
app.use('/api/boards', mmBridgeRoutes);
|
|
||||||
app.use('/api/boards', threadRoutes);
|
app.use('/api/boards', threadRoutes);
|
||||||
app.use('/api/boards', pdfRoutes);
|
app.use('/api/boards', pdfRoutes);
|
||||||
|
|
||||||
@@ -161,9 +157,15 @@ app.set('io', io);
|
|||||||
|
|
||||||
// ---- Initialize services and start ----
|
// ---- Initialize services and start ----
|
||||||
async function start() {
|
async function start() {
|
||||||
require('./db');
|
const dbModule = require('./db');
|
||||||
console.log('[server] Database initialized');
|
console.log('[server] Database initialized');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dbModule.seedAdminFromEnv();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[server] SEED_ADMIN bootstrap failed:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { initBucket } = require('./minio');
|
const { initBucket } = require('./minio');
|
||||||
await initBucket();
|
await initBucket();
|
||||||
@@ -173,14 +175,6 @@ async function start() {
|
|||||||
console.error('[server] Image uploads will not work until MinIO is available');
|
console.error('[server] Image uploads will not work until MinIO is available');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start Mattermost auto-sync watcher (no-op if env vars missing)
|
|
||||||
try {
|
|
||||||
const { startWatcher } = require('./services/mm-watcher');
|
|
||||||
startWatcher(io);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[server] MM watcher failed to start:', err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start media processing worker
|
// Start media processing worker
|
||||||
try {
|
try {
|
||||||
const { startMediaWorker } = require('./services/media-worker');
|
const { startMediaWorker } = require('./services/media-worker');
|
||||||
@@ -198,11 +192,6 @@ async function start() {
|
|||||||
function shutdown(signal) {
|
function shutdown(signal) {
|
||||||
console.log(`[server] Received ${signal}, shutting down gracefully...`);
|
console.log(`[server] Received ${signal}, shutting down gracefully...`);
|
||||||
|
|
||||||
try {
|
|
||||||
const { stopWatcher } = require('./services/mm-watcher');
|
|
||||||
stopWatcher();
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stopMediaWorker } = require('./services/media-worker');
|
const { stopMediaWorker } = require('./services/media-worker');
|
||||||
stopMediaWorker();
|
stopMediaWorker();
|
||||||
|
|||||||
@@ -1,323 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mattermost Auto-Sync Watcher
|
|
||||||
*
|
|
||||||
* Polls linked Mattermost channels for new file attachments and
|
|
||||||
* automatically imports them into the corresponding RefBoard boards.
|
|
||||||
*
|
|
||||||
* Requires MM_URL and MM_BOT_TOKEN env vars. Silently skips if not set.
|
|
||||||
*/
|
|
||||||
const https = require('https');
|
|
||||||
const http = require('http');
|
|
||||||
const { URL } = require('url');
|
|
||||||
const { v4: uuidv4 } = require('uuid');
|
|
||||||
const sharp = require('sharp');
|
|
||||||
const { getAllBoardChannelLinks, getImageByMmFileId, createImage, getBoard } = require('../db');
|
|
||||||
const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio');
|
|
||||||
|
|
||||||
const MM_URL = process.env.MM_URL;
|
|
||||||
const MM_BOT_TOKEN = process.env.MM_BOT_TOKEN;
|
|
||||||
const POLL_INTERVAL_MS = parseInt(process.env.MM_WATCHER_INTERVAL || '30000', 10);
|
|
||||||
|
|
||||||
const IMAGE_MIME_TYPES = [
|
|
||||||
'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml',
|
|
||||||
];
|
|
||||||
const VIDEO_MIME_TYPES = [
|
|
||||||
'video/mp4', 'video/webm', 'video/quicktime',
|
|
||||||
];
|
|
||||||
const ALLOWED_MIME_TYPES = [...IMAGE_MIME_TYPES, ...VIDEO_MIME_TYPES];
|
|
||||||
|
|
||||||
// Track last-check timestamp per channel link (in-memory)
|
|
||||||
const lastCheckMap = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make an authenticated GET request to the Mattermost API.
|
|
||||||
* Returns parsed JSON.
|
|
||||||
*/
|
|
||||||
function mmGet(apiPath) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const url = new URL(apiPath, MM_URL);
|
|
||||||
const client = url.protocol === 'https:' ? https : http;
|
|
||||||
|
|
||||||
const req = client.get(url.toString(), {
|
|
||||||
headers: { Authorization: `Bearer ${MM_BOT_TOKEN}` },
|
|
||||||
timeout: 15000,
|
|
||||||
}, (res) => {
|
|
||||||
if (res.statusCode !== 200) {
|
|
||||||
// Drain and reject
|
|
||||||
res.resume();
|
|
||||||
return reject(new Error(`MM API ${apiPath} returned ${res.statusCode}`));
|
|
||||||
}
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', (chunk) => chunks.push(chunk));
|
|
||||||
res.on('end', () => {
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(Buffer.concat(chunks).toString()));
|
|
||||||
} catch (e) {
|
|
||||||
reject(new Error(`MM API ${apiPath}: invalid JSON`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
res.on('error', reject);
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download a file from Mattermost by file ID.
|
|
||||||
* Returns { buffer, mimeType, filename }.
|
|
||||||
*/
|
|
||||||
function mmDownloadFile(fileId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const url = new URL(`/api/v4/files/${fileId}`, MM_URL);
|
|
||||||
const client = url.protocol === 'https:' ? https : http;
|
|
||||||
|
|
||||||
const req = client.get(url.toString(), {
|
|
||||||
headers: { Authorization: `Bearer ${MM_BOT_TOKEN}` },
|
|
||||||
timeout: 30000,
|
|
||||||
}, (res) => {
|
|
||||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
||||||
// Follow redirect
|
|
||||||
return mmDownloadFileUrl(res.headers.location).then(resolve).catch(reject);
|
|
||||||
}
|
|
||||||
if (res.statusCode !== 200) {
|
|
||||||
res.resume();
|
|
||||||
return reject(new Error(`MM file download ${fileId} returned ${res.statusCode}`));
|
|
||||||
}
|
|
||||||
const contentType = (res.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
|
|
||||||
const chunks = [];
|
|
||||||
let totalSize = 0;
|
|
||||||
const MAX_SIZE = MAX_FILE_SIZE;
|
|
||||||
|
|
||||||
res.on('data', (chunk) => {
|
|
||||||
totalSize += chunk.length;
|
|
||||||
if (totalSize > MAX_SIZE) {
|
|
||||||
res.destroy();
|
|
||||||
return reject(new Error(`File ${fileId} too large`));
|
|
||||||
}
|
|
||||||
chunks.push(chunk);
|
|
||||||
});
|
|
||||||
res.on('end', () => {
|
|
||||||
resolve({ buffer: Buffer.concat(chunks), mimeType: contentType });
|
|
||||||
});
|
|
||||||
res.on('error', reject);
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Follow a redirect URL for file download.
|
|
||||||
*/
|
|
||||||
function mmDownloadFileUrl(downloadUrl) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const parsed = new URL(downloadUrl);
|
|
||||||
const client = parsed.protocol === 'https:' ? https : http;
|
|
||||||
|
|
||||||
const req = client.get(downloadUrl, { timeout: 30000 }, (res) => {
|
|
||||||
if (res.statusCode !== 200) {
|
|
||||||
res.resume();
|
|
||||||
return reject(new Error(`File redirect download returned ${res.statusCode}`));
|
|
||||||
}
|
|
||||||
const contentType = (res.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', (chunk) => chunks.push(chunk));
|
|
||||||
res.on('end', () => {
|
|
||||||
resolve({ buffer: Buffer.concat(chunks), mimeType: contentType });
|
|
||||||
});
|
|
||||||
res.on('error', reject);
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get file metadata from Mattermost.
|
|
||||||
*/
|
|
||||||
async function mmGetFileInfo(fileId) {
|
|
||||||
return mmGet(`/api/v4/files/${fileId}/info`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classify MIME type as image or video.
|
|
||||||
*/
|
|
||||||
function classifyMedia(mimeType) {
|
|
||||||
if (VIDEO_MIME_TYPES.includes(mimeType)) return 'video';
|
|
||||||
return 'image';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a media file (image or video) to MinIO as a single file.
|
|
||||||
* GPU handles all image scaling natively — no LOD tiers needed.
|
|
||||||
*/
|
|
||||||
async function uploadMedia(boardId, imageId, buffer, mimetype) {
|
|
||||||
const ext = MIME_TO_EXT[mimetype] || '.bin';
|
|
||||||
const minioPath = `boards/${boardId}/${imageId}${ext}`;
|
|
||||||
await putBuffer(minioPath, buffer, mimetype);
|
|
||||||
|
|
||||||
let width = null, height = null;
|
|
||||||
if (mimetype !== 'image/svg+xml' && !mimetype.startsWith('video/')) {
|
|
||||||
try {
|
|
||||||
const meta = await sharp(buffer).metadata();
|
|
||||||
width = meta.width || null;
|
|
||||||
height = meta.height || null;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return { assetKey: minioPath, minioPath, width, height };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a single channel link: fetch new posts, import new files.
|
|
||||||
* Returns array of newly imported assets for socket notification.
|
|
||||||
*/
|
|
||||||
async function processLink(link) {
|
|
||||||
const { board_id: boardId, channel_id: channelId, id: linkId } = link;
|
|
||||||
|
|
||||||
// Verify board still exists
|
|
||||||
const board = getBoard(boardId);
|
|
||||||
if (!board) return [];
|
|
||||||
|
|
||||||
const since = lastCheckMap.get(linkId) || Date.now() - POLL_INTERVAL_MS;
|
|
||||||
lastCheckMap.set(linkId, Date.now());
|
|
||||||
|
|
||||||
let postsData;
|
|
||||||
try {
|
|
||||||
postsData = await mmGet(`/api/v4/channels/${channelId}/posts?since=${since}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[mm-watcher] Failed to fetch posts for channel ${channelId}:`, err.message);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!postsData || !postsData.order || !postsData.posts) return [];
|
|
||||||
|
|
||||||
const newAssets = [];
|
|
||||||
|
|
||||||
for (const postId of postsData.order) {
|
|
||||||
const post = postsData.posts[postId];
|
|
||||||
if (!post || !post.file_ids || post.file_ids.length === 0) continue;
|
|
||||||
|
|
||||||
for (const fileId of post.file_ids) {
|
|
||||||
try {
|
|
||||||
// Deduplication check
|
|
||||||
const existing = getImageByMmFileId(boardId, fileId);
|
|
||||||
if (existing) continue;
|
|
||||||
|
|
||||||
// Get file info to check MIME type
|
|
||||||
const fileInfo = await mmGetFileInfo(fileId);
|
|
||||||
const mimeType = fileInfo.mime_type || 'application/octet-stream';
|
|
||||||
|
|
||||||
if (!ALLOWED_MIME_TYPES.includes(mimeType)) {
|
|
||||||
continue; // Skip non-image/video files
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download the file
|
|
||||||
const { buffer } = await mmDownloadFile(fileId);
|
|
||||||
const imageId = uuidv4();
|
|
||||||
const mediaType = classifyMedia(mimeType);
|
|
||||||
|
|
||||||
const { assetKey, minioPath, width, height } = await uploadMedia(boardId, imageId, buffer, mimeType);
|
|
||||||
|
|
||||||
const publicUrl = getImageUrl(minioPath);
|
|
||||||
|
|
||||||
// Use the link creator as the uploader
|
|
||||||
const image = createImage({
|
|
||||||
id: imageId,
|
|
||||||
boardId,
|
|
||||||
filename: fileInfo.name || `mm-${fileId}`,
|
|
||||||
mimeType,
|
|
||||||
fileSize: buffer.length,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
minioPath,
|
|
||||||
publicUrl,
|
|
||||||
uploadedBy: link.created_by,
|
|
||||||
assetKey,
|
|
||||||
mediaType,
|
|
||||||
mmFileId: fileId,
|
|
||||||
});
|
|
||||||
|
|
||||||
newAssets.push({
|
|
||||||
assetKey: image.asset_key,
|
|
||||||
name: image.filename,
|
|
||||||
width: image.width,
|
|
||||||
height: image.height,
|
|
||||||
mediaType: image.media_type,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[mm-watcher] Imported file ${fileInfo.name || fileId} → board ${boardId}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[mm-watcher] Failed to import file ${fileId}:`, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newAssets;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single poll cycle: process all links.
|
|
||||||
*/
|
|
||||||
async function pollOnce(io) {
|
|
||||||
let links;
|
|
||||||
try {
|
|
||||||
links = getAllBoardChannelLinks();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[mm-watcher] Failed to query links:', err.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!links || links.length === 0) return;
|
|
||||||
|
|
||||||
for (const link of links) {
|
|
||||||
try {
|
|
||||||
const newAssets = await processLink(link);
|
|
||||||
if (newAssets.length > 0 && io) {
|
|
||||||
io.to(`board:${link.board_id}`).emit('board:media-arrived', {
|
|
||||||
boardId: link.board_id,
|
|
||||||
assets: newAssets,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[mm-watcher] Error processing link ${link.id}:`, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let pollTimer = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the Mattermost watcher. Requires Socket.IO server instance.
|
|
||||||
* Silently does nothing if MM_URL or MM_BOT_TOKEN are not set.
|
|
||||||
*/
|
|
||||||
function startWatcher(io) {
|
|
||||||
if (!MM_URL || !MM_BOT_TOKEN) {
|
|
||||||
console.log('[mm-watcher] MM_URL or MM_BOT_TOKEN not set — watcher disabled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[mm-watcher] Starting watcher (interval: ${POLL_INTERVAL_MS}ms)`);
|
|
||||||
|
|
||||||
// Run first poll after a short delay to let the server finish starting
|
|
||||||
setTimeout(() => {
|
|
||||||
pollOnce(io).catch(err => console.error('[mm-watcher] Poll error:', err.message));
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
pollTimer = setInterval(() => {
|
|
||||||
pollOnce(io).catch(err => console.error('[mm-watcher] Poll error:', err.message));
|
|
||||||
}, POLL_INTERVAL_MS);
|
|
||||||
|
|
||||||
// Don't prevent process exit
|
|
||||||
if (pollTimer.unref) pollTimer.unref();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the watcher (for graceful shutdown).
|
|
||||||
*/
|
|
||||||
function stopWatcher() {
|
|
||||||
if (pollTimer) {
|
|
||||||
clearInterval(pollTimer);
|
|
||||||
pollTimer = null;
|
|
||||||
console.log('[mm-watcher] Watcher stopped');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { startWatcher, stopWatcher };
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
services:
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: refboard-minio
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
|
||||||
|
ports:
|
||||||
|
- "9000:9000" # S3 API
|
||||||
|
- "9001:9001" # Web console
|
||||||
|
volumes:
|
||||||
|
- ./.docker-data/minio:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
refboard:
|
||||||
|
build: .
|
||||||
|
container_name: refboard
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PORT: 8000
|
||||||
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set in .env}
|
||||||
|
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
|
||||||
|
DB_PATH: /app/data/refboard.db
|
||||||
|
MINIO_ENDPOINT: minio
|
||||||
|
MINIO_PORT: 9000
|
||||||
|
MINIO_USE_SSL: "false"
|
||||||
|
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||||
|
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
|
||||||
|
MINIO_BUCKET: ${MINIO_BUCKET:-refboard}
|
||||||
|
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||||
|
MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-200}
|
||||||
|
CORS_ORIGIN: ${CORS_ORIGIN:-*}
|
||||||
|
SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-}
|
||||||
|
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-}
|
||||||
|
SEED_ADMIN_USERNAME: ${SEED_ADMIN_USERNAME:-}
|
||||||
|
SEED_ADMIN_DISPLAY_NAME: ${SEED_ADMIN_DISPLAY_NAME:-}
|
||||||
|
ALLOW_SELF_REGISTRATION: ${ALLOW_SELF_REGISTRATION:-false}
|
||||||
|
REFBOARD_API_KEY: ${REFBOARD_API_KEY:-}
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./.docker-data/refboard:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
/**
|
|
||||||
* MattermostImport — modal dialog for importing media from Mattermost.
|
|
||||||
*
|
|
||||||
* Allows pulling images from a Mattermost thread/channel URL and managing
|
|
||||||
* linked channels for auto-sync.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import api from '../api';
|
|
||||||
|
|
||||||
interface MattermostImportProps {
|
|
||||||
boardId: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onMediaArrived?: (assets: { assetKey: string; w: number; h: number }[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LinkedChannel {
|
|
||||||
id: string;
|
|
||||||
channelName: string;
|
|
||||||
channelId: string;
|
|
||||||
linkedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MattermostImport({ boardId, onClose, onMediaArrived }: MattermostImportProps) {
|
|
||||||
const [url, setUrl] = useState('');
|
|
||||||
const [pulling, setPulling] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [success, setSuccess] = useState('');
|
|
||||||
const [linkedChannels, setLinkedChannels] = useState<LinkedChannel[]>([]);
|
|
||||||
const [loadingChannels, setLoadingChannels] = useState(false);
|
|
||||||
const [linkUrl, setLinkUrl] = useState('');
|
|
||||||
const [linking, setLinking] = useState(false);
|
|
||||||
|
|
||||||
// Load linked channels on mount
|
|
||||||
const loadLinkedChannels = useCallback(async () => {
|
|
||||||
setLoadingChannels(true);
|
|
||||||
try {
|
|
||||||
const res = await api.get(`/api/boards/${boardId}/mm-links`);
|
|
||||||
setLinkedChannels(res.data?.links || []);
|
|
||||||
} catch {
|
|
||||||
// Endpoint may not exist yet — silently ignore
|
|
||||||
setLinkedChannels([]);
|
|
||||||
} finally {
|
|
||||||
setLoadingChannels(false);
|
|
||||||
}
|
|
||||||
}, [boardId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadLinkedChannels();
|
|
||||||
}, [loadLinkedChannels]);
|
|
||||||
|
|
||||||
// Close on Escape
|
|
||||||
useEffect(() => {
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') onClose();
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', onKey);
|
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
// Pull images from URL
|
|
||||||
const handlePull = async () => {
|
|
||||||
if (!url.trim()) return;
|
|
||||||
setPulling(true);
|
|
||||||
setError('');
|
|
||||||
setSuccess('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await api.post(`/api/boards/${boardId}/mm-pull`, { url: url.trim() });
|
|
||||||
const assets = res.data?.assets || [];
|
|
||||||
const count = assets.length;
|
|
||||||
setSuccess(`Pulled ${count} image${count !== 1 ? 's' : ''}`);
|
|
||||||
setUrl('');
|
|
||||||
|
|
||||||
if (count > 0 && onMediaArrived) {
|
|
||||||
onMediaArrived(assets);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-close after brief delay on success
|
|
||||||
if (count > 0) {
|
|
||||||
setTimeout(onClose, 800);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.response?.data?.error || 'Failed to pull images');
|
|
||||||
} finally {
|
|
||||||
setPulling(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Link a channel
|
|
||||||
const handleLink = async () => {
|
|
||||||
if (!linkUrl.trim()) return;
|
|
||||||
setLinking(true);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.post(`/api/boards/${boardId}/mm-links`, { url: linkUrl.trim() });
|
|
||||||
setLinkUrl('');
|
|
||||||
loadLinkedChannels();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.response?.data?.error || 'Failed to link channel');
|
|
||||||
} finally {
|
|
||||||
setLinking(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Unlink a channel
|
|
||||||
const handleUnlink = async (linkId: string) => {
|
|
||||||
try {
|
|
||||||
await api.delete(`/api/boards/${boardId}/mm-links/${linkId}`);
|
|
||||||
loadLinkedChannels();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.response?.data?.error || 'Failed to unlink channel');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
position: 'fixed', inset: 0, zIndex: 2000,
|
|
||||||
background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(6px)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
background: '#141414', border: '1px solid #222', borderRadius: '16px',
|
|
||||||
width: '100%', maxWidth: '480px', maxHeight: '85vh',
|
|
||||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
|
||||||
boxShadow: '0 24px 64px rgba(0,0,0,0.6)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
||||||
padding: '20px 24px 16px', borderBottom: '1px solid #1e1e1e', flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
<h2 style={{
|
|
||||||
margin: 0, fontSize: '16px', fontWeight: 600,
|
|
||||||
color: '#e0e0e0', letterSpacing: '-0.3px',
|
|
||||||
}}>
|
|
||||||
Import from Mattermost
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
background: 'none', border: '1px solid #333', borderRadius: '6px',
|
|
||||||
color: '#888', padding: '4px 12px', cursor: 'pointer', fontSize: '11px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
ESC
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px 24px' }}>
|
|
||||||
{/* Error / Success */}
|
|
||||||
{error && (
|
|
||||||
<div style={{
|
|
||||||
padding: '8px 12px', background: 'rgba(255,80,80,0.1)',
|
|
||||||
border: '1px solid rgba(255,80,80,0.2)', borderRadius: '8px',
|
|
||||||
color: '#ff6b6b', fontSize: '12px', marginBottom: '12px',
|
|
||||||
}}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{success && (
|
|
||||||
<div style={{
|
|
||||||
padding: '8px 12px', background: 'rgba(74,222,128,0.1)',
|
|
||||||
border: '1px solid rgba(74,222,128,0.2)', borderRadius: '8px',
|
|
||||||
color: '#4ade80', fontSize: '12px', marginBottom: '12px',
|
|
||||||
}}>
|
|
||||||
{success}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pull images section */}
|
|
||||||
<SectionLabel text="Pull Images from Thread" />
|
|
||||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handlePull(); }}
|
|
||||||
placeholder="https://chat.metalfinger.xyz/team/pl/..."
|
|
||||||
style={{
|
|
||||||
flex: 1, padding: '8px 12px', background: '#1a1a1a',
|
|
||||||
border: '1px solid #333', borderRadius: '8px',
|
|
||||||
color: '#e0e0e0', fontSize: '13px', outline: 'none',
|
|
||||||
}}
|
|
||||||
onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }}
|
|
||||||
onBlur={(e) => { e.currentTarget.style.borderColor = '#333'; }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handlePull}
|
|
||||||
disabled={pulling || !url.trim()}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
background: pulling ? '#333' : 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
|
||||||
border: 'none', borderRadius: '8px',
|
|
||||||
color: '#fff', fontSize: '12px', fontWeight: 600,
|
|
||||||
cursor: pulling ? 'wait' : 'pointer',
|
|
||||||
opacity: !url.trim() ? 0.5 : 1,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pulling ? 'Pulling...' : 'Pull Images'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Linked channels section */}
|
|
||||||
<SectionLabel text="Linked Channels" />
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
{loadingChannels ? (
|
|
||||||
<div style={{ fontSize: '12px', color: '#555', padding: '8px 0' }}>
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : linkedChannels.length === 0 ? (
|
|
||||||
<div style={{ fontSize: '12px', color: '#555', padding: '8px 0' }}>
|
|
||||||
No linked channels. Link one below for auto-sync.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
|
||||||
{linkedChannels.map((ch) => (
|
|
||||||
<div
|
|
||||||
key={ch.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
||||||
padding: '8px 12px', background: '#1a1a1a',
|
|
||||||
border: '1px solid #2a2a2a', borderRadius: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '13px', color: '#ccc', fontWeight: 500 }}>
|
|
||||||
{ch.channelName || ch.channelId}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '10px', color: '#555', marginTop: '2px' }}>
|
|
||||||
Linked {new Date(ch.linkedAt).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleUnlink(ch.id)}
|
|
||||||
style={{
|
|
||||||
padding: '4px 10px', background: 'transparent',
|
|
||||||
border: '1px solid #333', borderRadius: '6px',
|
|
||||||
color: '#888', fontSize: '11px', cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = '#ff6b6b';
|
|
||||||
e.currentTarget.style.color = '#ff6b6b';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = '#333';
|
|
||||||
e.currentTarget.style.color = '#888';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Unlink
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Link new channel */}
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={linkUrl}
|
|
||||||
onChange={(e) => setLinkUrl(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleLink(); }}
|
|
||||||
placeholder="Channel URL to link..."
|
|
||||||
style={{
|
|
||||||
flex: 1, padding: '8px 12px', background: '#1a1a1a',
|
|
||||||
border: '1px solid #333', borderRadius: '8px',
|
|
||||||
color: '#e0e0e0', fontSize: '13px', outline: 'none',
|
|
||||||
}}
|
|
||||||
onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }}
|
|
||||||
onBlur={(e) => { e.currentTarget.style.borderColor = '#333'; }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleLink}
|
|
||||||
disabled={linking || !linkUrl.trim()}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px', background: '#1a1a1a',
|
|
||||||
border: '1px solid #333', borderRadius: '8px',
|
|
||||||
color: '#ccc', fontSize: '12px', fontWeight: 500,
|
|
||||||
cursor: linking ? 'wait' : 'pointer',
|
|
||||||
opacity: !linkUrl.trim() ? 0.5 : 1,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (linkUrl.trim()) {
|
|
||||||
e.currentTarget.style.borderColor = '#4a9eff';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = '#333';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{linking ? 'Linking...' : 'Link Channel'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionLabel({ text }: { text: string }) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
fontSize: '10px', fontWeight: 700, color: '#4a9eff', letterSpacing: '0.8px',
|
|
||||||
textTransform: 'uppercase', padding: '0 0 8px', marginBottom: '4px',
|
|
||||||
}}>
|
|
||||||
{text}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,6 @@ interface ToolbarProps {
|
|||||||
onToggleLayers?: () => void;
|
onToggleLayers?: () => void;
|
||||||
showLayers?: boolean;
|
showLayers?: boolean;
|
||||||
onToggleHelp?: () => void;
|
onToggleHelp?: () => void;
|
||||||
onMmImport?: () => void;
|
|
||||||
onExport?: () => void;
|
onExport?: () => void;
|
||||||
onRefreshPreview?: () => void;
|
onRefreshPreview?: () => void;
|
||||||
previewRefreshing?: boolean;
|
previewRefreshing?: boolean;
|
||||||
@@ -165,7 +164,6 @@ export default function Toolbar({
|
|||||||
onToggleLayers,
|
onToggleLayers,
|
||||||
showLayers,
|
showLayers,
|
||||||
onToggleHelp,
|
onToggleHelp,
|
||||||
onMmImport,
|
|
||||||
onExport,
|
onExport,
|
||||||
onRefreshPreview,
|
onRefreshPreview,
|
||||||
previewRefreshing,
|
previewRefreshing,
|
||||||
@@ -330,16 +328,6 @@ export default function Toolbar({
|
|||||||
</ActionBtn>
|
</ActionBtn>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mattermost import */}
|
|
||||||
{onMmImport && (
|
|
||||||
<ActionBtn onClick={onMmImport} title="Import from Mattermost">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
|
||||||
<path d="M2 10V4a2 2 0 012-2h6a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2z" />
|
|
||||||
<path d="M5 7h4M7 5v4" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
</ActionBtn>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Export */}
|
{/* Export */}
|
||||||
{onExport && (
|
{onExport && (
|
||||||
<ActionBtn onClick={onExport} title="Export as image (Ctrl+Shift+E)">
|
<ActionBtn onClick={onExport} title="Export as image (Ctrl+Shift+E)">
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import TextFormatToolbar from '../components/TextFormatToolbar';
|
|||||||
import { getStickyWidthForSize } from '../canvas/stickyPresets';
|
import { getStickyWidthForSize } from '../canvas/stickyPresets';
|
||||||
import VideoControls from '../components/VideoControls';
|
import VideoControls from '../components/VideoControls';
|
||||||
import ShortcutsHelp from '../components/ShortcutsHelp';
|
import ShortcutsHelp from '../components/ShortcutsHelp';
|
||||||
import MattermostImport from '../components/MattermostImport';
|
|
||||||
import Minimap from '../components/Minimap';
|
import Minimap from '../components/Minimap';
|
||||||
import UploadPanel from '../components/UploadPanel';
|
import UploadPanel from '../components/UploadPanel';
|
||||||
import ExportDialog from '../components/ExportDialog';
|
import ExportDialog from '../components/ExportDialog';
|
||||||
@@ -118,7 +117,6 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const [showLayers, setShowLayers] = useState(false);
|
const [showLayers, setShowLayers] = useState(false);
|
||||||
const [showGrid, setShowGrid] = useState(true);
|
const [showGrid, setShowGrid] = useState(true);
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [showMmImport, setShowMmImport] = useState(false);
|
|
||||||
const [showExport, setShowExport] = useState(false);
|
const [showExport, setShowExport] = useState(false);
|
||||||
const [refreshingPreview, setRefreshingPreview] = useState(false);
|
const [refreshingPreview, setRefreshingPreview] = useState(false);
|
||||||
const [reviewMode, setReviewMode] = useState(false);
|
const [reviewMode, setReviewMode] = useState(false);
|
||||||
@@ -1093,7 +1091,6 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
onToggleLayers={() => setShowLayers((v) => !v)}
|
onToggleLayers={() => setShowLayers((v) => !v)}
|
||||||
showLayers={showLayers}
|
showLayers={showLayers}
|
||||||
onToggleHelp={() => setShowHelp((v) => !v)}
|
onToggleHelp={() => setShowHelp((v) => !v)}
|
||||||
onMmImport={() => setShowMmImport(true)}
|
|
||||||
onExport={() => setShowExport(true)}
|
onExport={() => setShowExport(true)}
|
||||||
onRefreshPreview={readOnly || isPublicView ? undefined : handleRefreshPreview}
|
onRefreshPreview={readOnly || isPublicView ? undefined : handleRefreshPreview}
|
||||||
previewRefreshing={refreshingPreview}
|
previewRefreshing={refreshingPreview}
|
||||||
@@ -1701,19 +1698,6 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
<ShortcutsHelp shortcuts={shortcutDefs} onClose={() => setShowHelp(false)} />
|
<ShortcutsHelp shortcuts={shortcutDefs} onClose={() => setShowHelp(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mattermost import modal */}
|
|
||||||
{showMmImport && resolvedBoardId && (
|
|
||||||
<MattermostImport
|
|
||||||
boardId={resolvedBoardId}
|
|
||||||
onClose={() => setShowMmImport(false)}
|
|
||||||
onMediaArrived={(assets) => {
|
|
||||||
if (inboxZoneRef.current) {
|
|
||||||
inboxZoneRef.current.addMedia(assets);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Markdown card portals (read-only previews on canvas) */}
|
{/* Markdown card portals (read-only previews on canvas) */}
|
||||||
{mdCardIds.map(id => {
|
{mdCardIds.map(id => {
|
||||||
const mountPoint = mdOverlay?.getMountPoint(id);
|
const mountPoint = mdOverlay?.getMountPoint(id);
|
||||||
|
|||||||
+111
-58
@@ -1,48 +1,55 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth';
|
import { useAuth } from '../auth';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
const ALLOW_REGISTER = (import.meta.env.VITE_ALLOW_SELF_REGISTRATION || '').toLowerCase() === 'true';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
|
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login, user } = useAuth();
|
const { login, user } = useAuth();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
// Handle OAuth callback — token + user in URL params
|
React.useEffect(() => {
|
||||||
useEffect(() => {
|
if (user) navigate('/', { replace: true });
|
||||||
const token = searchParams.get('token');
|
|
||||||
const userParam = searchParams.get('user');
|
|
||||||
const errorParam = searchParams.get('error');
|
|
||||||
|
|
||||||
if (errorParam) {
|
|
||||||
setError(errorParam);
|
|
||||||
window.history.replaceState({}, '', '/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token && userParam) {
|
|
||||||
try {
|
|
||||||
const userData = JSON.parse(userParam);
|
|
||||||
login(token, userData);
|
|
||||||
navigate('/', { replace: true });
|
|
||||||
} catch {
|
|
||||||
setError('Failed to complete login. Please try again.');
|
|
||||||
window.history.replaceState({}, '', '/login');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [searchParams, login, navigate]);
|
|
||||||
|
|
||||||
// If already logged in, redirect
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
navigate('/', { replace: true });
|
|
||||||
}
|
|
||||||
}, [user, navigate]);
|
}, [user, navigate]);
|
||||||
|
|
||||||
function handleMattermostLogin() {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
window.location.href = '/api/auth/mattermost';
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const path = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
||||||
|
const body = mode === 'login'
|
||||||
|
? { email, password }
|
||||||
|
: { email, username, displayName: displayName || username, password };
|
||||||
|
const res = await api.post(path, body);
|
||||||
|
if (res.data?.token && res.data?.user) {
|
||||||
|
login(res.data.token, res.data.user);
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
} else {
|
||||||
|
setError('Unexpected response from server.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error || 'Authentication failed.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%', padding: '12px 14px', marginBottom: '12px',
|
||||||
|
background: '#0d0d0d', color: '#f0f0f0',
|
||||||
|
border: '1px solid #2a2a2a', borderRadius: '8px',
|
||||||
|
fontSize: '14px', boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
@@ -50,12 +57,11 @@ export default function Login() {
|
|||||||
backgroundImage: 'radial-gradient(ellipse at 50% 0%, rgba(74,158,255,0.08) 0%, transparent 60%)',
|
backgroundImage: 'radial-gradient(ellipse at 50% 0%, rgba(74,158,255,0.08) 0%, transparent 60%)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#161616', borderRadius: '16px', padding: '48px 40px',
|
background: '#161616', borderRadius: '16px', padding: '40px 36px',
|
||||||
width: '100%', maxWidth: '400px',
|
width: '100%', maxWidth: '400px',
|
||||||
border: '1px solid #222', boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
|
border: '1px solid #222', boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
|
||||||
}}>
|
}}>
|
||||||
{/* Logo */}
|
<div style={{ textAlign: 'center', marginBottom: '28px' }}>
|
||||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
width: '48px', height: '48px', borderRadius: '12px',
|
width: '48px', height: '48px', borderRadius: '12px',
|
||||||
@@ -72,39 +78,86 @@ export default function Login() {
|
|||||||
<h1 style={{ margin: '0 0 4px', fontSize: '24px', fontWeight: 700, color: '#f0f0f0', letterSpacing: '-0.5px' }}>
|
<h1 style={{ margin: '0 0 4px', fontSize: '24px', fontWeight: 700, color: '#f0f0f0', letterSpacing: '-0.5px' }}>
|
||||||
RefBoard
|
RefBoard
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ margin: 0, fontSize: '13px', color: '#555', letterSpacing: '0.2px' }}>
|
<p style={{ margin: 0, fontSize: '13px', color: '#666' }}>
|
||||||
Sign in with your team account
|
{mode === 'login' ? 'Sign in to continue' : 'Create your account'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'rgba(255,107,107,0.08)', border: '1px solid rgba(255,107,107,0.15)',
|
background: 'rgba(255,107,107,0.08)', border: '1px solid rgba(255,107,107,0.15)',
|
||||||
color: '#ff8a8a', padding: '10px 14px', borderRadius: '8px', marginBottom: '20px',
|
color: '#ff8a8a', padding: '10px 14px', borderRadius: '8px', marginBottom: '16px',
|
||||||
fontSize: '13px', lineHeight: '1.4',
|
fontSize: '13px', lineHeight: '1.4',
|
||||||
}}>
|
}}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<form onSubmit={handleSubmit}>
|
||||||
onClick={handleMattermostLogin}
|
<input
|
||||||
style={{
|
type="email"
|
||||||
width: '100%', padding: '14px', marginBottom: '0',
|
placeholder="Email"
|
||||||
background: '#386fe5', color: '#fff', border: 'none', borderRadius: '8px',
|
value={email}
|
||||||
fontSize: '15px', fontWeight: 600, cursor: 'pointer',
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '10px',
|
autoComplete="email"
|
||||||
transition: 'background 0.2s',
|
required
|
||||||
boxShadow: '0 2px 12px rgba(56,111,229,0.3)',
|
style={inputStyle}
|
||||||
}}
|
/>
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#2f5fc4')}
|
{mode === 'register' && (
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = '#386fe5')}
|
<>
|
||||||
>
|
<input
|
||||||
<svg width="18" height="18" viewBox="0 0 500 500" fill="currentColor">
|
type="text"
|
||||||
<path d="M250 0C111.93 0 0 111.93 0 250s111.93 250 250 250 250-111.93 250-250S388.07 0 250 0zm127.55 354.07c-2.93 5.77-9.18 8.85-15.57 8.85-2.68 0-5.4-.59-7.97-1.85l-72.76-35.72c-19.7 14.88-43.19 22.66-67.58 22.66-6.2 0-12.5-.5-18.73-1.53-24.83-4.07-47.17-16.41-63.38-34.95-16.63-19.03-25.78-43.42-25.78-68.68 0-12.07 2.1-23.86 6.15-35.12.58-1.62 1.54-3.07 2.79-4.22L250.07 91.53c3.08-2.62 7.51-2.62 10.59 0l135.28 112c1.56 1.29 2.65 3.04 3.13 5-.04 11.84-2.08 23.56-6.02 34.7l-72.76-35.72c-7.72-3.79-17.03-.59-20.82 7.13-3.79 7.72-.59 17.03 7.13 20.82l72.76 35.72c-9.28 21.84-26.16 39.95-47.98 50.72l.01-.01 72.76 35.72c7.72 3.79 10.92 13.1 7.13 20.82l.27-.36z"/>
|
placeholder="Username"
|
||||||
</svg>
|
value={username}
|
||||||
Sign in with Mattermost
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
</button>
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Display name (optional)"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '14px', marginTop: '4px',
|
||||||
|
background: loading ? '#2a4f9a' : '#386fe5', color: '#fff',
|
||||||
|
border: 'none', borderRadius: '8px',
|
||||||
|
fontSize: '15px', fontWeight: 600,
|
||||||
|
cursor: loading ? 'wait' : 'pointer',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
boxShadow: '0 2px 12px rgba(56,111,229,0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Please wait…' : (mode === 'login' ? 'Sign in' : 'Create account')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{ALLOW_REGISTER && (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: '20px', fontSize: '13px', color: '#666' }}>
|
||||||
|
{mode === 'login' ? (
|
||||||
|
<>Need an account? <a href="#" onClick={(e) => { e.preventDefault(); setMode('register'); setError(''); }} style={{ color: '#4a9eff' }}>Register</a></>
|
||||||
|
) : (
|
||||||
|
<>Already have an account? <a href="#" onClick={(e) => { e.preventDefault(); setMode('login'); setError(''); }} style={{ color: '#4a9eff' }}>Sign in</a></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user