254 lines
12 KiB
HTML
254 lines
12 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>DashCaddy Share</title>
|
|
<link rel="canonical" href="/share">
|
|
<link rel="stylesheet" href="/assets/dashboard.css">
|
|
<style>
|
|
:root { color-scheme: dark; --bg:#09111f; --card:#111c2e; --text:#e8edf5; --muted:#aab7ca; --accent:#68a4ff; --border:#263750; --ok:#7cf2c0; --danger:#ff9090; --warn:#ffd07f; }
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; background: linear-gradient(145deg,#07101d,#101b31); color: var(--text); font: 16px/1.7 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
|
|
main { width: min(640px, calc(100% - 32px)); margin: 48px auto; padding: clamp(24px,5vw,48px); }
|
|
.eyebrow { color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: .12em; font-size: .85rem; }
|
|
h1 { margin: 8px 0 0; font-size: clamp(1.8rem,5vw,2.5rem); }
|
|
.lede { color: var(--muted); }
|
|
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 28px; margin-top: 24px; }
|
|
.card.error { border-color: var(--danger); }
|
|
.meta { color: var(--muted); font-size: .9rem; margin-top: 12px; }
|
|
.field { margin-top: 16px; }
|
|
.field label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 6px; }
|
|
.field input { width: 100%; padding: 10px 12px; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; font: inherit; font-size: 0.95rem; }
|
|
.button { display: inline-block; cursor: pointer; border: 0; padding: 12px 22px; border-radius: 10px; font: inherit; font-weight: 600; background: var(--accent); color: #04141f; margin-top: 12px; text-decoration: none; }
|
|
.button:disabled { opacity: .6; cursor: not-allowed; }
|
|
.button.secondary { background: #1a2742; color: var(--text); border: 1px solid var(--border); }
|
|
.status-ok { color: var(--ok); }
|
|
.status-err { color: var(--danger); }
|
|
.status-warn { color: var(--warn); }
|
|
.health { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px; border-radius: 999px; font-size: 0.85rem; font-weight: 600; }
|
|
.health.up { background: rgba(124,242,192,0.12); color: var(--ok); }
|
|
.health.down { background: rgba(255,144,144,0.12); color: var(--danger); }
|
|
.health.unknown { background: rgba(255,208,127,0.12); color: var(--warn); }
|
|
.service-meta { display: grid; gap: 4px; font-size: 0.9rem; color: var(--muted); margin-top: 12px; }
|
|
.service-meta strong { color: var(--text); }
|
|
.footer { color: var(--muted); font-size: 0.85rem; margin-top: 24px; text-align: center; }
|
|
.footer a { color: var(--accent); }
|
|
@media (max-width: 600px) { .card { padding: 20px; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<div class="eyebrow">DashCaddy</div>
|
|
<h1 id="title">Loading share…</h1>
|
|
|
|
<div id="card" class="card">
|
|
<div id="loading">Loading service details…</div>
|
|
|
|
<div id="ready" hidden>
|
|
<div id="health-badge" class="health unknown">Checking status…</div>
|
|
<h2 id="service-name" style="margin: 12px 0 0; font-size: 1.5rem;"></h2>
|
|
<p id="service-description" class="lede" style="margin-top: 6px;"></p>
|
|
<div id="service-meta" class="service-meta"></div>
|
|
|
|
<div id="cta-public" style="margin-top: 24px;">
|
|
<a id="open-service" class="button" href="#" target="_blank" rel="noopener">Open service</a>
|
|
</div>
|
|
|
|
<div id="cta-tailscale" style="display: none; margin-top: 24px;">
|
|
<p class="lede">To join this service, install <a href="https://tailscale.com/download" target="_blank" rel="noopener" style="color: var(--accent);">Tailscale</a> on your device, then visit the service URL. Your device will be authorized automatically via the share token carried in this link.</p>
|
|
<a id="tailscale-open" class="button" href="#" target="_blank" rel="noopener">Open service</a>
|
|
<p class="meta" style="margin-top: 12px;">This link is single-use. Only one device can join. The host's operator issued it specifically for you.</p>
|
|
</div>
|
|
|
|
<div id="subscribe-section" style="margin-top: 28px;">
|
|
<div class="eyebrow" style="font-size: 0.75rem;">Get notified</div>
|
|
<p class="lede" style="margin-top: 4px; font-size: 0.9rem;">Enter your email to be notified when this service goes down or recovers.</p>
|
|
<div class="field">
|
|
<label for="subscribe-email">Email address</label>
|
|
<input id="subscribe-email" type="email" placeholder="you@example.com" autocomplete="email" />
|
|
</div>
|
|
<button id="subscribe-btn" class="button secondary" type="button">Subscribe</button>
|
|
<div id="subscribe-status" class="meta"></div>
|
|
</div>
|
|
|
|
<div id="expires" class="meta" style="margin-top: 24px;"></div>
|
|
</div>
|
|
|
|
<div id="error" hidden>
|
|
<h2 style="margin: 0 0 8px; color: var(--danger);">Share link unavailable</h2>
|
|
<p id="error-message" class="lede">This share link is invalid, expired, or has been revoked.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="footer">
|
|
<a href="/legal/privacy">Privacy</a> · <a href="/legal/terms">Terms</a>
|
|
</p>
|
|
</main>
|
|
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
// The token is the last path segment of /share/<token>. Falls back to
|
|
// empty string if the URL pattern is wrong (handled by the "invalid link"
|
|
// error path below).
|
|
function _tokenFromPath() {
|
|
var parts = window.location.pathname.split('/').filter(Boolean);
|
|
// parts[0] === 'share', parts[1] === token
|
|
return parts.length >= 2 ? parts[parts.length - 1] : '';
|
|
}
|
|
|
|
var token = _tokenFromPath();
|
|
var card = document.getElementById('card');
|
|
var loadingEl = document.getElementById('loading');
|
|
var readyEl = document.getElementById('ready');
|
|
var errorEl = document.getElementById('error');
|
|
var errorMsgEl = document.getElementById('error-message');
|
|
var titleEl = document.getElementById('title');
|
|
var serviceNameEl = document.getElementById('service-name');
|
|
var serviceDescEl = document.getElementById('service-description');
|
|
var serviceMetaEl = document.getElementById('service-meta');
|
|
var healthBadgeEl = document.getElementById('health-badge');
|
|
var openLinkEl = document.getElementById('open-service');
|
|
var tailscaleOpenEl = document.getElementById('tailscale-open');
|
|
var ctaPublicEl = document.getElementById('cta-public');
|
|
var ctaTailscaleEl = document.getElementById('cta-tailscale');
|
|
var subscribeBtn = document.getElementById('subscribe-btn');
|
|
var subscribeEmailEl = document.getElementById('subscribe-email');
|
|
var subscribeStatusEl = document.getElementById('subscribe-status');
|
|
var expiresEl = document.getElementById('expires');
|
|
|
|
function showError(message) {
|
|
loadingEl.hidden = true;
|
|
readyEl.hidden = true;
|
|
errorEl.hidden = false;
|
|
card.classList.add('error');
|
|
titleEl.textContent = 'Share link unavailable';
|
|
if (message) errorMsgEl.textContent = message;
|
|
}
|
|
|
|
function showReady(data) {
|
|
loadingEl.hidden = true;
|
|
errorEl.hidden = true;
|
|
readyEl.hidden = false;
|
|
|
|
var service = data.service || {};
|
|
serviceNameEl.textContent = service.name || data.serviceId || 'Unknown service';
|
|
titleEl.textContent = service.name || data.serviceId || 'Shared service';
|
|
serviceDescEl.textContent = service.description || '';
|
|
if (!service.description) serviceDescEl.style.display = 'none';
|
|
|
|
// Service metadata: tags, category
|
|
var meta = [];
|
|
if (service.category) meta.push('<strong>Category:</strong> ' + escapeHtml(service.category));
|
|
if (Array.isArray(service.tags) && service.tags.length) {
|
|
meta.push('<strong>Tags:</strong> ' + service.tags.map(escapeHtml).join(', '));
|
|
}
|
|
serviceMetaEl.innerHTML = meta.join(' · ');
|
|
|
|
// Health badge
|
|
var health = (service.health || 'unknown').toLowerCase();
|
|
healthBadgeEl.className = 'health ' + (health === 'up' ? 'up' : health === 'down' ? 'down' : 'unknown');
|
|
healthBadgeEl.textContent = health === 'up' ? 'Online' : health === 'down' ? 'Offline' : 'Status unknown';
|
|
|
|
// Kind-specific CTA. Both kinds show an "Open service" link — the
|
|
// service URL is the same destination in both cases. For Tailscale
|
|
// shares, the share token is also the auth credential that Caddy
|
|
// forward_auth checks against the store; the user just needs to
|
|
// open the URL after installing Tailscale and the host has already
|
|
// authorized the share via the email they were sent. The redemption
|
|
// flow lives on the SERVER side (Caddy forward_auth checks the share
|
|
// store on each request) — never on the client.
|
|
if (service.url) {
|
|
openLinkEl.href = service.url;
|
|
openLinkEl.textContent = 'Open ' + (service.name || 'service');
|
|
tailscaleOpenEl.href = service.url;
|
|
} else {
|
|
openLinkEl.style.display = 'none';
|
|
tailscaleOpenEl.style.display = 'none';
|
|
}
|
|
if (data.kind === 'tailscale') {
|
|
ctaPublicEl.style.display = 'none';
|
|
ctaTailscaleEl.style.display = '';
|
|
} else {
|
|
ctaPublicEl.style.display = '';
|
|
ctaTailscaleEl.style.display = 'none';
|
|
}
|
|
|
|
// Expiry footer
|
|
if (data.expiresAt) {
|
|
var exp = new Date(data.expiresAt);
|
|
if (!isNaN(exp.getTime())) {
|
|
expiresEl.textContent = 'This share expires ' + exp.toLocaleString() + '.';
|
|
}
|
|
}
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
function fetchPreview() {
|
|
if (!token) {
|
|
showError('Invalid share link.');
|
|
return;
|
|
}
|
|
fetch('/api/v1/share/' + encodeURIComponent(token) + '/preview', { cache: 'no-store' })
|
|
.then(function (r) { return r.json().then(function (b) { return { status: r.status, body: b }; }); })
|
|
.then(function (resp) {
|
|
if (resp.status === 200 && resp.body && resp.body.success && resp.body.data) {
|
|
showReady(resp.body.data);
|
|
return;
|
|
}
|
|
showError((resp.body && resp.body.error) ? resp.body.error.replace(/^\[DC-\d+\]\s*/, '') : 'This share link is invalid, expired, or has been revoked.');
|
|
})
|
|
.catch(function () {
|
|
showError('Could not reach the server. Check your connection and try again.');
|
|
});
|
|
}
|
|
|
|
// Subscribe handler (public — no auth required). Marks the share record
|
|
// as having a subscriber; the host's check-event workflow can then notify
|
|
// on status changes. This is the only POST the public preview page
|
|
// makes against the share API — the Tailscale redemption path is
|
|
// server-side (Caddy forward_auth on each request to the shared service).
|
|
subscribeBtn.addEventListener('click', function () {
|
|
var email = (subscribeEmailEl.value || '').trim();
|
|
if (!email || !email.includes('@')) {
|
|
subscribeStatusEl.textContent = 'Please enter a valid email.';
|
|
subscribeStatusEl.className = 'meta status-err';
|
|
return;
|
|
}
|
|
subscribeBtn.disabled = true;
|
|
subscribeStatusEl.textContent = 'Subscribing…';
|
|
subscribeStatusEl.className = 'meta';
|
|
fetch('/api/v1/share/' + encodeURIComponent(token) + '/subscribe', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: email }),
|
|
})
|
|
.then(function (r) { return r.json().then(function (b) { return { status: r.status, body: b }; }); })
|
|
.then(function (resp) {
|
|
if (resp.status === 200 && resp.body && resp.body.success) {
|
|
subscribeStatusEl.textContent = 'You will be notified when this service changes status.';
|
|
subscribeStatusEl.className = 'meta status-ok';
|
|
subscribeEmailEl.value = '';
|
|
} else {
|
|
subscribeStatusEl.textContent = (resp.body && resp.body.error) ? resp.body.error.replace(/^\[DC-\d+\]\s*/, '') : 'Subscription failed. Please try again.';
|
|
subscribeStatusEl.className = 'meta status-err';
|
|
}
|
|
})
|
|
.catch(function () {
|
|
subscribeStatusEl.textContent = 'Network error. Please try again.';
|
|
subscribeStatusEl.className = 'meta status-err';
|
|
})
|
|
.finally(function () { subscribeBtn.disabled = false; });
|
|
});
|
|
|
|
fetchPreview();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|