DC-053: Public share links + Tailscale-mediated share (Pro-gated)
- Share-store: HMAC-signed tokens bound to serviceId+kind, persistent signing secret in dataDir/.share-secret, atomic writes, auto-prune - Routes: admin endpoints gated on licenseManager.isPro() (402 Free); public endpoints CSRF-exempt (token IS proof) - Tailscale path: mints single-use ephemeral pre-auth key, emails join link, rolls back share record if createAuthKey throws - Email-failure path: exposes urlPath for manual delivery fallback - 53 new tests (24 store + 29 routes), full suite 1372/1372 - Drift-test parser hardened against quoted-word comments - share-store dataDir resolver handles Proxy/function values CHANGELOG + BACKLOG updated.
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Share routes — DC-053.
|
||||
*
|
||||
* Two surfaces, both Pro-gated:
|
||||
*
|
||||
* POST /api/v1/share → issue a public share link
|
||||
* body: { serviceId, ttlMs?, subscribeCap? }
|
||||
* ttlMs ∈ {3600000, 86400000, 604800000} (1h/24h/7d)
|
||||
* requires: licenseManager.isPro() === true
|
||||
* returns: { id, token, urlPath, serviceId, expiresAt }
|
||||
*
|
||||
* POST /api/v1/share/tailscale → issue a Tailscale-mediated share
|
||||
* body: { serviceId, email, ttlMs? } (ttlMs ≤ 24h, default 24h)
|
||||
* requires: licenseManager.isPro() === true
|
||||
* requires: tailscaleCoord configured
|
||||
* side-effects: calls tailscaleCoord.createAuthKey() (single-use, scoped)
|
||||
* + notificationManager.sendEmail() with the join link
|
||||
* returns: { id, kind: 'tailscale', expiresAt, emailedTo }
|
||||
*
|
||||
* GET /api/v1/share → list outstanding shares (admin)
|
||||
* DELETE /api/v1/share/:id → revoke a share
|
||||
*
|
||||
* PUBLIC (no auth, no license check):
|
||||
* GET /api/v1/share/:token/preview → peek the share record + service snapshot
|
||||
* POST /api/v1/share/:token/subscribe
|
||||
* body: { email } → records a subscribe event for the public link
|
||||
* POST /api/v1/share/:token/redeem-tailscale
|
||||
* body: { deviceId } → records a Tailscale join (used by Caddy forward_auth)
|
||||
*
|
||||
* POST /api/v1/share/:token/subscribe and /redeem-tailscale are CSRF-exempt
|
||||
* because they originate from the public share page (cross-origin). Both
|
||||
* are bound to a specific share token, so the abuse surface is bounded.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
|
||||
const { PaymentRequiredError } = require('../src/utilities/errors');
|
||||
const { ok, created, badRequest, notFound } = require('../src/utils/responses');
|
||||
|
||||
const PUBLIC_TTL_OPTIONS = new Set([
|
||||
60 * 60 * 1000,
|
||||
24 * 60 * 60 * 1000,
|
||||
7 * 24 * 60 * 60 * 1000,
|
||||
]);
|
||||
const MAX_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
module.exports = function shareRoutesFactory({
|
||||
shareStore,
|
||||
licenseManager,
|
||||
tailscaleCoord,
|
||||
notificationManager,
|
||||
servicesStateManager,
|
||||
servicesFile,
|
||||
asyncHandler,
|
||||
log = { info() {}, warn() {}, error() {} },
|
||||
} = {}) {
|
||||
const router = require('express').Router();
|
||||
|
||||
// Share-store is required. In production this is always present (created in
|
||||
// src/app.js unconditionally). In test/deps-stub scenarios where the
|
||||
// universal-deps Proxy returns noopFn for shareStore, we return an empty
|
||||
// router rather than throw — that lets the drift test enumerate OTHER
|
||||
// mounted routes and the depth-2 smoke test confirm module load. Real
|
||||
// runtime errors will surface as 404s, not 500s.
|
||||
if (!shareStore || typeof shareStore.issuePublic !== 'function') {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
log.warn && log.warn('share', 'shareStore missing — share routes returning 404 in this environment');
|
||||
} else {
|
||||
throw new Error('shareRoutes requires shareStore');
|
||||
}
|
||||
router.all('*', (_req, res) => res.status(404).json({ success: false, error: '[DC-553] share unavailable' }));
|
||||
return router;
|
||||
}
|
||||
if (!asyncHandler) {
|
||||
// Same lenient policy for asyncHandler — must always be wired in prod.
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('shareRoutes requires asyncHandler');
|
||||
}
|
||||
// Fall back to a noop asyncHandler so route handlers can still register.
|
||||
asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
function _requireAuth(req, _res, next) {
|
||||
if (!req.user || !req.user.email) return next(new ValidationError('authentication required', 'auth'));
|
||||
next();
|
||||
}
|
||||
|
||||
function _requireAdmin(req, _res, next) {
|
||||
const role = req.user && req.user.role;
|
||||
if (role !== 'admin') return next(new ValidationError('admin role required', 'role'));
|
||||
next();
|
||||
}
|
||||
|
||||
function _requirePro(req, _res, next) {
|
||||
if (!licenseManager || typeof licenseManager.isPro !== 'function') {
|
||||
// No license manager at all → conservative Free-equivalent behavior.
|
||||
return next(new PaymentRequiredError('Pro tier required to create share links'));
|
||||
}
|
||||
if (!licenseManager.isPro()) {
|
||||
return next(new PaymentRequiredError('Pro tier required to create share links'));
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
async function _loadService(serviceId) {
|
||||
// Prefer the in-memory state manager; fall back to a synchronous read of
|
||||
// services.json so the share-preview endpoint works even after a restart.
|
||||
let svc = null;
|
||||
if (servicesStateManager && typeof servicesStateManager.get === 'function') {
|
||||
try { svc = await servicesStateManager.get(serviceId); } catch (_) { svc = null; }
|
||||
}
|
||||
if (svc) return svc;
|
||||
if (servicesFile) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const raw = fs.readFileSync(servicesFile, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const arr = Array.isArray(parsed) ? parsed : (parsed.services || []);
|
||||
svc = arr.find(s => s && (s.id === serviceId || s.name === serviceId));
|
||||
} catch (_) { svc = null; }
|
||||
}
|
||||
return svc;
|
||||
}
|
||||
|
||||
function _serviceSnapshot(svc) {
|
||||
if (!svc) return null;
|
||||
return {
|
||||
id: svc.id || svc.name || null,
|
||||
name: svc.name || svc.id || null,
|
||||
description: svc.description || '',
|
||||
url: svc.url || (svc.domain ? `https://${svc.domain}` : null),
|
||||
icon: svc.icon || null,
|
||||
tags: Array.isArray(svc.tags) ? svc.tags : [],
|
||||
category: svc.category || null,
|
||||
// status is best-effort; health is fetched separately by the frontend
|
||||
health: svc.health || svc.status || 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Authenticated admin endpoints ────────────────────────────────────────
|
||||
|
||||
router.post('/share', _requireAuth, _requireAdmin, _requirePro, asyncHandler(async (req, res) => {
|
||||
const { serviceId, ttlMs, subscribeCap } = req.body || {};
|
||||
if (!serviceId) throw new ValidationError('serviceId is required', 'serviceId');
|
||||
const service = await _loadService(serviceId);
|
||||
if (!service) throw new NotFoundError('service not found');
|
||||
|
||||
const effectiveTtl = (typeof ttlMs === 'number' && PUBLIC_TTL_OPTIONS.has(ttlMs))
|
||||
? ttlMs
|
||||
: 24 * 60 * 60 * 1000;
|
||||
|
||||
const result = await shareStore.issuePublic({
|
||||
serviceId,
|
||||
ttlMs: effectiveTtl,
|
||||
createdBy: req.user.email,
|
||||
subscribeCap,
|
||||
});
|
||||
if (!result.ok) throw new ValidationError(result.reason || 'issue_failed', 'share');
|
||||
|
||||
log.info && log.info('share', 'public share issued', {
|
||||
id: result.id, serviceId, createdBy: req.user.email, ttlMs: effectiveTtl,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: result.id,
|
||||
kind: 'public',
|
||||
token: result.token,
|
||||
urlPath: result.urlPath,
|
||||
serviceId: result.serviceId,
|
||||
expiresAt: result.expiresAt,
|
||||
ttlMs: result.ttlMs,
|
||||
},
|
||||
});
|
||||
}, 'share-issue-public'));
|
||||
|
||||
router.post('/share/tailscale', _requireAuth, _requireAdmin, _requirePro, asyncHandler(async (req, res) => {
|
||||
const { serviceId, email, ttlMs } = req.body || {};
|
||||
if (!serviceId) throw new ValidationError('serviceId is required', 'serviceId');
|
||||
if (!email) throw new ValidationError('email is required', 'email');
|
||||
const service = await _loadService(serviceId);
|
||||
if (!service) throw new NotFoundError('service not found');
|
||||
|
||||
if (!tailscaleCoord || typeof tailscaleCoord.createAuthKey !== 'function') {
|
||||
throw new ValidationError('Tailscale is not configured on this host', 'tailscale');
|
||||
}
|
||||
|
||||
const effectiveTtl = (typeof ttlMs === 'number' && ttlMs > 0)
|
||||
? Math.min(ttlMs, MAX_TAILSCALE_TTL_MS)
|
||||
: MAX_TAILSCALE_TTL_MS;
|
||||
|
||||
const issue = await shareStore.issueTailscale({
|
||||
serviceId,
|
||||
email,
|
||||
ttlMs: effectiveTtl,
|
||||
createdBy: req.user.email,
|
||||
});
|
||||
if (!issue.ok) throw new ValidationError(issue.reason || 'issue_failed', 'share');
|
||||
|
||||
// Create the one-shot Tailscale pre-auth key. The auth-key string itself
|
||||
// is what we email — it never touches disk. The share record only holds
|
||||
// the keyId returned by Tailscale so the operator can revoke it.
|
||||
let authKey = null;
|
||||
let authKeyId = null;
|
||||
try {
|
||||
const keyOpts = {
|
||||
reusable: false,
|
||||
ephemeral: true,
|
||||
preauthorized: true,
|
||||
expirySeconds: Math.ceil(effectiveTtl / 1000),
|
||||
description: `dashcaddy-share:${issue.id}:${serviceId}`,
|
||||
};
|
||||
const key = await tailscaleCoord.createAuthKey(keyOpts);
|
||||
authKey = key && (key.key || key.value || (typeof key === 'string' ? key : null));
|
||||
authKeyId = key && key.id;
|
||||
} catch (err) {
|
||||
// Roll the share back so we don't leak "issued but no auth key" state.
|
||||
await shareStore.revoke(issue.id);
|
||||
log.error && log.error('share', 'tailscale createAuthKey failed', { err: err && err.message });
|
||||
throw new ValidationError('failed to mint Tailscale auth key', 'tailscale');
|
||||
}
|
||||
|
||||
if (!authKey) {
|
||||
await shareStore.revoke(issue.id);
|
||||
throw new ValidationError('Tailscale returned no auth key', 'tailscale');
|
||||
}
|
||||
|
||||
await shareStore.attachAuthKey(issue.id, authKeyId);
|
||||
|
||||
// Email the join link to the invitee. If email delivery fails we still
|
||||
// return success but mark it in the response — the admin can copy the
|
||||
// raw URL from the share list and deliver it manually.
|
||||
let emailed = false;
|
||||
let emailError = null;
|
||||
if (notificationManager && typeof notificationManager.sendEmail === 'function') {
|
||||
try {
|
||||
const baseUrl = `${req.protocol}://${req.get('host') || 'status.sami'}`;
|
||||
const joinUrl = `${baseUrl}/share/${issue.token}`;
|
||||
await notificationManager.sendEmail(
|
||||
`[DashCaddy] ${req.user.email} shared a service with you`,
|
||||
[
|
||||
`You've been invited to access "${service.name || serviceId}" on DashCaddy.`,
|
||||
``,
|
||||
`Click this link to join the host's Tailscale network and access the service:`,
|
||||
joinUrl,
|
||||
``,
|
||||
`This link expires in ${Math.round(effectiveTtl / (60 * 60 * 1000))} hours and can only be used once.`,
|
||||
].join('\n')
|
||||
);
|
||||
emailed = true;
|
||||
} catch (err) {
|
||||
emailError = err && err.message;
|
||||
log.warn && log.warn('share', 'email delivery failed; admin can copy the URL manually', {
|
||||
err: emailError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log.info && log.info('share', 'tailscale share issued', {
|
||||
id: issue.id, serviceId, email: issue.email, emailed, authKeyId,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: issue.id,
|
||||
kind: 'tailscale',
|
||||
email: issue.email,
|
||||
serviceId,
|
||||
expiresAt: issue.expiresAt,
|
||||
ttlMs: effectiveTtl,
|
||||
emailed,
|
||||
emailError,
|
||||
// Surface the raw URL only when email failed; admins shouldn't see
|
||||
// working auth keys in the response by default.
|
||||
urlPath: emailed ? null : issue.urlPath,
|
||||
},
|
||||
});
|
||||
}, 'share-issue-tailscale'));
|
||||
|
||||
router.get('/share', _requireAuth, _requireAdmin, asyncHandler(async (req, res) => {
|
||||
const all = await shareStore.list();
|
||||
res.json({ success: true, data: all });
|
||||
}, 'share-list'));
|
||||
|
||||
router.delete('/share/:id', _requireAuth, _requireAdmin, asyncHandler(async (req, res) => {
|
||||
const okRevoked = await shareStore.revoke(req.params.id);
|
||||
if (!okRevoked) throw new NotFoundError('share not found');
|
||||
res.json({ success: true });
|
||||
}, 'share-revoke'));
|
||||
|
||||
// ─── Public endpoints (no auth, no Pro gate) ──────────────────────────────
|
||||
|
||||
router.get('/share/:token/preview', asyncHandler(async (req, res) => {
|
||||
const meta = await shareStore.peek(req.params.token);
|
||||
if (!meta) {
|
||||
return res.status(404).json({ success: false, error: '[DC-553] share not found or expired' });
|
||||
}
|
||||
const service = await _loadService(meta.serviceId);
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
kind: meta.kind,
|
||||
serviceId: meta.serviceId,
|
||||
expiresAt: meta.expiresAt,
|
||||
service: _serviceSnapshot(service),
|
||||
},
|
||||
});
|
||||
}, 'share-preview'));
|
||||
|
||||
router.post('/share/:token/subscribe', asyncHandler(async (req, res) => {
|
||||
const { email } = req.body || {};
|
||||
if (!email || typeof email !== 'string' || !email.includes('@')) {
|
||||
throw new ValidationError('valid email required', 'email');
|
||||
}
|
||||
const result = await shareStore.recordPublicSubscribe(req.params.token);
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'not_found') throw new NotFoundError('share not found');
|
||||
throw new ValidationError(result.reason, 'share');
|
||||
}
|
||||
res.json({ success: true, data: { count: result.count, cap: result.cap } });
|
||||
}, 'share-subscribe'));
|
||||
|
||||
router.post('/share/:token/redeem-tailscale', asyncHandler(async (req, res) => {
|
||||
const { deviceId } = req.body || {};
|
||||
if (!deviceId || typeof deviceId !== 'string') {
|
||||
throw new ValidationError('deviceId required', 'deviceId');
|
||||
}
|
||||
const result = await shareStore.recordTailscaleUse(req.params.token, { deviceId });
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'not_found') throw new NotFoundError('share not found');
|
||||
throw new ValidationError(result.reason, 'share');
|
||||
}
|
||||
res.json({ success: true, data: { redeemed: true, share: result.share } });
|
||||
}, 'share-redeem-tailscale'));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports.PUBLIC_TTL_OPTIONS = PUBLIC_TTL_OPTIONS;
|
||||
Reference in New Issue
Block a user