Merge dev: burn fix, version label, theme persistence, admin/settings UI, theme grid, CSP fix, docs
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user