Merge dev: burn fix, version label, theme persistence, admin/settings UI, theme grid, CSP fix, docs

This commit is contained in:
fen
2026-09-09 21:32:30 -05:00
10 changed files with 74 additions and 24 deletions
+5 -4
View File
@@ -2,7 +2,7 @@ name: CI
on:
push:
branches: [main]
branches: [main, dev]
tags: ['v*']
pull_request:
@@ -23,8 +23,8 @@ jobs:
run: go test -v ./...
docker:
# build & push image only on tags (releases)
if: startsWith(github.ref, 'refs/tags/')
# build & push image only on tags (releases) and dev branch (dev deploy)
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/dev'
needs: test
runs-on: [debian-latest]
env:
@@ -55,8 +55,9 @@ jobs:
with:
context: .
push: true
build-args: VERSION=${{ gitea.ref_name }}
tags: |
git.archfox.org/poslop/palette:${{ gitea.ref_name }}
git.archfox.org/poslop/palette:latest
${{ startsWith(gitea.ref, 'refs/tags/') && 'git.archfox.org/poslop/palette:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
+2 -1
View File
@@ -9,7 +9,8 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /palette ./cmd/palette
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X palette/internal/web.Version=${VERSION}" -o /palette ./cmd/palette
# ---- runtime stage ----
FROM alpine:3.20
+3 -3
View File
@@ -45,10 +45,10 @@ every environment variable documented, including which are required (only the
### Build from source
Requires Go 1.21+.
Requires Go 1.27+.
```bash
go build -o palette .
go build -o palette ./cmd/palette
./palette
# open http://localhost:8080
```
@@ -76,7 +76,7 @@ Full REST API: [wiki/API](https://git.archfox.org/poslop/palette/wiki/API). One
curl -X POST http://localhost:8080/api/pastes -d '{"content":"hello"}'
```
Design docs: [wiki/design](https://git.archfox.org/poslop/palette/wiki/design/attachments-storage) (e2e encryption, attachments & storage, cookie preferences).
Design docs: [wiki/design](https://git.archfox.org/poslop/palette/wiki/design-attachments-storage) (e2e encryption, attachments & storage, cookie preferences).
+27
View File
@@ -46,6 +46,33 @@ func getWithCookie(t *testing.T, h anyHandler, id, viewer string) *httptest.Resp
return rec
}
// #82: burn_after_reads > 0 alone must enable burn-after-read
// even without burn_after_read: true.
func TestBurnReadsImpliedByBurnAfterReads(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"implied","burn_after_reads":2}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create burn_after_reads-only: %d %s", rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if rec := getWithCookie(t, h, created.ID, "aaa"); rec.Code != 200 {
t.Fatalf("read 1: %d", rec.Code)
}
if rec := getWithCookie(t, h, created.ID, "bbb"); rec.Code != 200 {
t.Fatalf("read 2: %d", rec.Code)
}
if rec := getWithCookie(t, h, created.ID, "ccc"); rec.Code != 404 {
t.Fatalf("read 3 expected 404 (burned), got %d", rec.Code)
}
}
func TestBurnAfterNReadsDistinctViewers(t *testing.T) {
s := testServer(t)
h := s.routes()
+6 -2
View File
@@ -211,13 +211,17 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
}
}
// #49: burn-after-read pastes carry a read budget (default 1 read)
if p.BurnAfterRead {
// #49/#82: burn-after-read pastes carry a read budget (default 1 read).
// burn_after_reads > 0 alone implies burn mode even without burn_after_read.
if p.BurnAfterRead || (p.BurnAfterReads != nil && *p.BurnAfterReads > 0) {
limit := int64(1)
if p.BurnAfterReads != nil && *p.BurnAfterReads > 0 {
limit = int64(*p.BurnAfterReads)
}
p.readsLimit = &limit
if !p.BurnAfterRead {
p.BurnAfterRead = true
}
}
visibility := p.Visibility
+1 -1
View File
@@ -16,7 +16,7 @@ func TestSecurityHeaders(t *testing.T) {
h := SecurityHeaders(pages)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
wantCSP := "default-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'"
wantCSP := "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'"
if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
t.Errorf("CSP = %q, want %q", got, wantCSP)
}
+3 -1
View File
@@ -103,7 +103,9 @@
});
});
if (key()) loadSettings();
// #112: always show the lock on fresh load — do not auto-restore the
// panel from a stale sessionStorage key. The key is only written after a
// successful unlock (above) so in-page actions still work within this visit.
})();
</script>
{{template "foot" .}}
+3 -2
View File
@@ -3,9 +3,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/app.css">
<script>
// preset preview hook (#16): ?theme=<name> sets data-preset for screenshots only
// preset hook (#16): ?theme= wins; else persisted choice (#100)
(function () {
var t = new URLSearchParams(location.search).get('theme');
if (!t) try { t = localStorage.getItem('palette-theme'); } catch (e) {}
if (t) document.documentElement.dataset.preset = t;
})();
</script>
@@ -13,7 +14,7 @@
{{define "topbar"}}
<div class="topbar">
<a class="logo" href="/history">Palette <em>/ beta</em></a>
<a class="logo" href="/history">Palette <em>/ {{ version }}</em></a>
<nav>
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a>
<a href="/history" {{if eq .Page "history"}}class="on"{{end}}>Public</a>
+23 -9
View File
@@ -8,29 +8,43 @@
<div class="settings-body">
<h3>Theme</h3>
<div class="theme-grid" id="theme-grid"></div>
<p class="hint" style="margin-top:10px">Applies instantly and is saved in this browser.</p>
<p class="admin-link-row"><a class="admin-link" href="/admin">Admin</a></p>
</div>
</div>
</div>
<script>
(function () {
var themes = [
{ id: 'midnight', name: 'Midnight', colors: ['#241B30', '#2D2340', '#3A2D52', '#7A6A9E', '#C4A8F0'] },
{ id: 'smooth', name: 'Smooth', colors: ['#F6F5FA', '#DAD7E6', '#B5B1C9', '#7A7796', '#2A2A36'] },
{ id: 'pastel-lavender', name: 'Pastel Lavender', colors: ['#e6e0f5', '#cbb8e7', '#b29edb', '#9b85cf', '#806bb8'] },
{ id: 'pastel-peach', name: 'Pastel Peach', colors: ['#ffe0d6', '#ffc4a8', '#ffa78f', '#ff8b76', '#f9826c'] },
{ id: 'pastel-cloud', name: 'Pastel Cloud', colors: ['#cdb4db', '#ffc8dd', '#ffafcc', '#bde0fe', '#a2d2ff'] }
// #112: derive each preset's swatches from the real CSS variables in
// app.css by temporarily applying data-preset, so they can never drift.
var themeNames = [
{ id: 'midnight', name: 'Midnight' },
{ id: 'smooth', name: 'Smooth' },
{ id: 'pastel-lavender', name: 'Pastel Lavender' },
{ id: 'pastel-peach', name: 'Pastel Peach' },
{ id: 'pastel-cloud', name: 'Pastel Cloud' }
];
var SWATCH_VARS = ['--bg', '--surface', '--surface-2', '--muted', '--accent'];
function presetColors(id) {
var root = document.documentElement;
var prev = root.getAttribute('data-preset');
root.setAttribute('data-preset', id);
var cs = getComputedStyle(root);
var colors = SWATCH_VARS.map(function (v) { return cs.getPropertyValue(v).trim(); });
if (prev === null) root.removeAttribute('data-preset'); else root.setAttribute('data-preset', prev);
return colors;
}
var grid = document.getElementById('theme-grid');
var current = document.documentElement.dataset.preset || 'midnight';
themes.forEach(function (t) {
themeNames.forEach(function (t) {
var colors = presetColors(t.id);
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'theme-card';
btn.setAttribute('aria-pressed', current === t.id ? 'true' : 'false');
btn.innerHTML = '<strong>' + t.name + '</strong>' +
'<span class="swatches">' + t.colors.map(function (c) {
'<span class="swatches">' + colors.map(function (c) {
return '<span class="swatch" style="background:' + c + '"></span>';
}).join('') + '</span>';
btn.addEventListener('click', function () {
+1 -1
View File
@@ -344,7 +344,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
// is harmless and arguably desirable.
h := w.Header()
h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'")
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)