initial commit: Artifacto v0.1
Self-hosted HTML artifact publisher. Single Go binary, SQLite metadata, HTML on disk. Features: password-protected artifacts, per-artifact expiration, admin dashboard with view metrics (privacy-preserving daily-salt visitor hash, sparklines, 30-day SVG chart, top referrers), rate-limited unlock endpoint. Packaged as a Docker image (two-stage, CGO_ENABLED=0, pure-Go SQLite). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
121
internal/server/templates/dashboard.html
Normal file
121
internal/server/templates/dashboard.html
Normal file
@@ -0,0 +1,121 @@
|
||||
{{ define "dashboard" }}
|
||||
{{ template "head" "Dashboard" }}
|
||||
{{ template "header" true }}
|
||||
<main class="max-w-6xl mx-auto px-6 py-8">
|
||||
|
||||
<!-- Stat tiles -->
|
||||
<section class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
|
||||
<div class="rounded border border-slate-800 bg-slate-900/50 px-4 py-3">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-400">Artifacts</div>
|
||||
<div class="text-2xl font-semibold mt-1">{{ .Stats.TotalArtifacts }}</div>
|
||||
</div>
|
||||
<div class="rounded border border-slate-800 bg-slate-900/50 px-4 py-3">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-400">Views (7d)</div>
|
||||
<div class="text-2xl font-semibold mt-1">{{ .Stats.Views7d }}</div>
|
||||
</div>
|
||||
<div class="rounded border border-slate-800 bg-slate-900/50 px-4 py-3">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-400">Views (30d)</div>
|
||||
<div class="text-2xl font-semibold mt-1">{{ .Stats.Views30d }}</div>
|
||||
</div>
|
||||
<div class="rounded border border-slate-800 bg-slate-900/50 px-4 py-3">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-400">Storage</div>
|
||||
<div class="text-2xl font-semibold mt-1">{{ humanSize .Stats.StorageBytes }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Publish form -->
|
||||
<section class="rounded border border-slate-800 bg-slate-900/50 p-4 mb-8">
|
||||
<h2 class="text-lg font-semibold mb-3">Publish</h2>
|
||||
<form method="post" action="/api/publish" enctype="multipart/form-data" class="space-y-3">
|
||||
<textarea name="html" rows="8" placeholder="Paste HTML here, or use the file picker below"
|
||||
class="w-full bg-slate-950 border border-slate-800 rounded px-3 py-2 text-sm focus:outline-none focus:border-indigo-500"></textarea>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-400 mb-1">Custom slug (optional)</span>
|
||||
<input type="text" name="slug" pattern="[a-z0-9][a-z0-9-]{2,31}" placeholder="auto"
|
||||
class="w-full bg-slate-950 border border-slate-800 rounded px-3 py-2 text-sm">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-400 mb-1">Title (optional)</span>
|
||||
<input type="text" name="title" placeholder="from <title> if blank"
|
||||
class="w-full bg-slate-950 border border-slate-800 rounded px-3 py-2 text-sm">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-400 mb-1">Password (optional)</span>
|
||||
<input type="password" name="password" autocomplete="new-password"
|
||||
class="w-full bg-slate-950 border border-slate-800 rounded px-3 py-2 text-sm">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-400 mb-1">Expires</span>
|
||||
<select name="expires_in" class="w-full bg-slate-950 border border-slate-800 rounded px-3 py-2 text-sm">
|
||||
<option value="never">never</option>
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="24h">1 day</option>
|
||||
<option value="7d">7 days</option>
|
||||
<option value="30d">30 days</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-slate-400">
|
||||
Or upload: <input type="file" name="file" accept=".html,text/html" class="ml-2 text-xs">
|
||||
</label>
|
||||
<button class="bg-indigo-600 hover:bg-indigo-500 text-white rounded px-4 py-2 font-medium text-sm">Publish</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{ if .FlashSlug }}
|
||||
<div class="rounded border border-emerald-800 bg-emerald-900/30 text-emerald-100 px-4 py-3 mb-6 text-sm flex items-center justify-between">
|
||||
<div>
|
||||
Published: <code class="bg-slate-900 px-2 py-0.5 rounded">{{ .BaseURL }}/p/{{ .FlashSlug }}</code>
|
||||
</div>
|
||||
<button onclick="navigator.clipboard.writeText('{{ .BaseURL }}/p/{{ .FlashSlug }}')"
|
||||
class="bg-emerald-700 hover:bg-emerald-600 rounded px-3 py-1 text-xs">Copy link</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<!-- Artifact list -->
|
||||
<section class="rounded border border-slate-800 bg-slate-900/30 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-900 text-slate-400 text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-2">Slug</th>
|
||||
<th class="text-left px-4 py-2">Title</th>
|
||||
<th class="text-right px-4 py-2">Views</th>
|
||||
<th class="text-left px-4 py-2">Last viewed</th>
|
||||
<th class="text-left px-4 py-2">7-day</th>
|
||||
<th class="text-left px-4 py-2">Size</th>
|
||||
<th class="text-right px-4 py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Items }}
|
||||
<tr id="row-{{ .Slug }}" class="border-t border-slate-800 hover:bg-slate-900/50">
|
||||
<td class="px-4 py-2">
|
||||
<a class="font-mono text-indigo-300 hover:text-indigo-200" href="/a/{{ .Slug }}">{{ .Slug }}</a>
|
||||
{{ if .HasPassword }}<span class="ml-1 text-xs text-amber-400" title="Password-protected">🔒</span>{{ end }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-slate-300">{{ if .Title }}{{ .Title }}{{ else }}<span class="text-slate-500">—</span>{{ end }}</td>
|
||||
<td class="px-4 py-2 text-right tabular-nums">{{ .ViewCount }}</td>
|
||||
<td class="px-4 py-2 text-slate-400">{{ humanTime .LastViewedAt }}</td>
|
||||
<td class="px-4 py-2 spark text-indigo-300">{{ sparkline .Spark }}</td>
|
||||
<td class="px-4 py-2 text-slate-400">{{ humanSize .SizeBytes }}</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
<button onclick="navigator.clipboard.writeText('{{ $.BaseURL }}/p/{{ .Slug }}')"
|
||||
class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700">Copy</button>
|
||||
<a href="/p/{{ .Slug }}" target="_blank" class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700">Open</a>
|
||||
<button hx-delete="/a/{{ .Slug }}" hx-target="#row-{{ .Slug }}" hx-swap="outerHTML"
|
||||
hx-confirm="Delete {{ .Slug }}? This cannot be undone."
|
||||
class="text-xs px-2 py-1 rounded bg-red-900/60 hover:bg-red-800">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr><td colspan="7" class="px-4 py-10 text-center text-slate-500">No artifacts yet. Paste some HTML above to publish your first one.</td></tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
{{ template "footer" }}
|
||||
{{ end }}
|
||||
52
internal/server/templates/detail.html
Normal file
52
internal/server/templates/detail.html
Normal file
@@ -0,0 +1,52 @@
|
||||
{{ define "detail" }}
|
||||
{{ template "head" (print "Artifact " .Artifact.Slug) }}
|
||||
{{ template "header" true }}
|
||||
<main class="max-w-6xl mx-auto px-6 py-8">
|
||||
<nav class="text-sm text-slate-400 mb-4"><a href="/" class="hover:text-slate-100">← back to dashboard</a></nav>
|
||||
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold font-mono">{{ .Artifact.Slug }} {{ if .Artifact.HasPassword }}<span class="text-base text-amber-400 align-middle">🔒</span>{{ end }}</h1>
|
||||
{{ if .Artifact.Title }}<p class="text-slate-400 mt-1">{{ .Artifact.Title }}</p>{{ end }}
|
||||
<p class="text-xs text-slate-500 mt-2">
|
||||
Created {{ humanTime .Artifact.CreatedAt }} ·
|
||||
{{ humanSize .Artifact.SizeBytes }} ·
|
||||
{{ .Artifact.ViewCount }} views
|
||||
{{ if .Artifact.ExpiresAt }} · expires {{ humanTime .Artifact.ExpiresAt }}{{ end }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm"><a class="text-indigo-300 hover:text-indigo-200" href="{{ .BaseURL }}/p/{{ .Artifact.Slug }}" target="_blank">{{ .BaseURL }}/p/{{ .Artifact.Slug }}</a></p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="navigator.clipboard.writeText('{{ .BaseURL }}/p/{{ .Artifact.Slug }}')"
|
||||
class="text-sm px-3 py-1.5 rounded bg-slate-800 hover:bg-slate-700">Copy link</button>
|
||||
<button hx-delete="/a/{{ .Artifact.Slug }}" hx-confirm="Delete {{ .Artifact.Slug }}? This cannot be undone."
|
||||
hx-on::after-request="window.location='/'"
|
||||
class="text-sm px-3 py-1.5 rounded bg-red-900/60 hover:bg-red-800">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="rounded border border-slate-800 bg-slate-900/30 p-4 mb-6">
|
||||
<h2 class="text-sm uppercase tracking-wide text-slate-400 mb-3">Views — last 30 days</h2>
|
||||
{{ .ChartSVG }}
|
||||
</section>
|
||||
|
||||
<section class="rounded border border-slate-800 bg-slate-900/30 p-4">
|
||||
<h2 class="text-sm uppercase tracking-wide text-slate-400 mb-3">Top referrers</h2>
|
||||
{{ if .Referrers }}
|
||||
<table class="w-full text-sm">
|
||||
<tbody>
|
||||
{{ range .Referrers }}
|
||||
<tr class="border-t border-slate-800 first:border-t-0">
|
||||
<td class="py-1.5 text-slate-300">{{ if .Host }}{{ .Host }}{{ else }}<span class="text-slate-500">(direct)</span>{{ end }}</td>
|
||||
<td class="py-1.5 text-right tabular-nums">{{ .Count }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ else }}
|
||||
<p class="text-sm text-slate-500">No referrer data yet.</p>
|
||||
{{ end }}
|
||||
</section>
|
||||
</main>
|
||||
{{ template "footer" }}
|
||||
{{ end }}
|
||||
39
internal/server/templates/layout.html
Normal file
39
internal/server/templates/layout.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{{ define "head" }}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ . }} · Artifacto</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif; }
|
||||
.spark { font-variant-numeric: tabular-nums; letter-spacing: 2px; }
|
||||
textarea { font-family: ui-monospace, SF Mono, Menlo, monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||
{{ end }}
|
||||
|
||||
{{ define "header" }}
|
||||
<header class="border-b border-slate-800">
|
||||
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-2 font-semibold text-lg">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-indigo-400"></span>
|
||||
Artifacto
|
||||
</a>
|
||||
{{ if . }}
|
||||
<form method="post" action="/logout"><button class="text-sm text-slate-400 hover:text-slate-100">Log out</button></form>
|
||||
{{ end }}
|
||||
</div>
|
||||
</header>
|
||||
{{ end }}
|
||||
|
||||
{{ define "footer" }}
|
||||
<footer class="max-w-6xl mx-auto px-6 py-8 text-xs text-slate-500">
|
||||
Artifacto · self-hosted · <a class="underline" href="/healthz">/healthz</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
22
internal/server/templates/login.html
Normal file
22
internal/server/templates/login.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{{ define "login" }}
|
||||
{{ template "head" "Log in" }}
|
||||
{{ template "header" false }}
|
||||
<main class="max-w-6xl mx-auto px-6 py-8">
|
||||
<div class="max-w-sm mx-auto mt-16">
|
||||
<h1 class="text-2xl font-semibold mb-4">Log in</h1>
|
||||
{{ if .Error }}
|
||||
<div class="rounded bg-red-900/40 border border-red-800 text-red-200 px-3 py-2 mb-3 text-sm">{{ .Error }}</div>
|
||||
{{ end }}
|
||||
<form method="post" action="/login" class="space-y-3">
|
||||
<input type="hidden" name="next" value="{{ .Next }}">
|
||||
<label class="block">
|
||||
<span class="block text-sm text-slate-400 mb-1">Password</span>
|
||||
<input type="password" name="password" required autofocus autocomplete="current-password"
|
||||
class="w-full bg-slate-900 border border-slate-800 rounded px-3 py-2 focus:outline-none focus:border-indigo-500">
|
||||
</label>
|
||||
<button class="w-full bg-indigo-600 hover:bg-indigo-500 text-white rounded px-3 py-2 font-medium">Log in</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
{{ template "footer" }}
|
||||
{{ end }}
|
||||
21
internal/server/templates/unlock.html
Normal file
21
internal/server/templates/unlock.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{{ define "unlock" }}
|
||||
{{ template "head" "Protected" }}
|
||||
<main class="max-w-6xl mx-auto px-6 py-8">
|
||||
<div class="max-w-sm mx-auto mt-24 text-center">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-slate-900 border border-slate-800 mb-4">
|
||||
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11v2m-4 4h8a2 2 0 002-2v-5a2 2 0 00-2-2H8a2 2 0 00-2 2v5a2 2 0 002 2zM10 9V7a2 2 0 114 0v2"/></svg>
|
||||
</div>
|
||||
<h1 class="text-xl font-semibold mb-2">Protected artifact</h1>
|
||||
<p class="text-sm text-slate-400 mb-6">Enter the password to view.</p>
|
||||
{{ if .Error }}
|
||||
<div class="rounded bg-red-900/40 border border-red-800 text-red-200 px-3 py-2 mb-3 text-sm">{{ .Error }}</div>
|
||||
{{ end }}
|
||||
<form method="post" action="/p/{{ .Slug }}/unlock" class="space-y-3 text-left">
|
||||
<input type="password" name="password" required autofocus
|
||||
class="w-full bg-slate-900 border border-slate-800 rounded px-3 py-2 focus:outline-none focus:border-indigo-500">
|
||||
<button class="w-full bg-indigo-600 hover:bg-indigo-500 text-white rounded px-3 py-2 font-medium">Unlock</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
{{ template "footer" }}
|
||||
{{ end }}
|
||||
Reference in New Issue
Block a user