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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, dev]
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
@@ -23,8 +23,8 @@ jobs:
|
|||||||
run: go test -v ./...
|
run: go test -v ./...
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
# build & push image only on tags (releases)
|
# build & push image only on tags (releases) and dev branch (dev deploy)
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/dev'
|
||||||
needs: test
|
needs: test
|
||||||
runs-on: [debian-latest]
|
runs-on: [debian-latest]
|
||||||
env:
|
env:
|
||||||
@@ -55,8 +55,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
build-args: VERSION=${{ gitea.ref_name }}
|
||||||
tags: |
|
tags: |
|
||||||
git.archfox.org/poslop/palette:${{ gitea.ref_name }}
|
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-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,8 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
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 ----
|
# ---- runtime stage ----
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ every environment variable documented, including which are required (only the
|
|||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
Requires Go 1.21+.
|
Requires Go 1.27+.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -o palette .
|
go build -o palette ./cmd/palette
|
||||||
./palette
|
./palette
|
||||||
# open http://localhost:8080
|
# 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"}'
|
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
|
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) {
|
func TestBurnAfterNReadsDistinctViewers(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
h := s.routes()
|
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)
|
// #49/#82: burn-after-read pastes carry a read budget (default 1 read).
|
||||||
if p.BurnAfterRead {
|
// 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)
|
limit := int64(1)
|
||||||
if p.BurnAfterReads != nil && *p.BurnAfterReads > 0 {
|
if p.BurnAfterReads != nil && *p.BurnAfterReads > 0 {
|
||||||
limit = int64(*p.BurnAfterReads)
|
limit = int64(*p.BurnAfterReads)
|
||||||
}
|
}
|
||||||
p.readsLimit = &limit
|
p.readsLimit = &limit
|
||||||
|
if !p.BurnAfterRead {
|
||||||
|
p.BurnAfterRead = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
visibility := p.Visibility
|
visibility := p.Visibility
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func TestSecurityHeaders(t *testing.T) {
|
|||||||
h := SecurityHeaders(pages)
|
h := SecurityHeaders(pages)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
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 {
|
if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
|
||||||
t.Errorf("CSP = %q, want %q", 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>
|
</script>
|
||||||
{{template "foot" .}}
|
{{template "foot" .}}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
<script>
|
<script>
|
||||||
// preset preview hook (#16): ?theme=<name> sets data-preset for screenshots only
|
// preset hook (#16): ?theme= wins; else persisted choice (#100)
|
||||||
(function () {
|
(function () {
|
||||||
var t = new URLSearchParams(location.search).get('theme');
|
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;
|
if (t) document.documentElement.dataset.preset = t;
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
|
|
||||||
{{define "topbar"}}
|
{{define "topbar"}}
|
||||||
<div class="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>
|
<nav>
|
||||||
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a>
|
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a>
|
||||||
<a href="/history" {{if eq .Page "history"}}class="on"{{end}}>Public</a>
|
<a href="/history" {{if eq .Page "history"}}class="on"{{end}}>Public</a>
|
||||||
|
|||||||
@@ -8,29 +8,43 @@
|
|||||||
<div class="settings-body">
|
<div class="settings-body">
|
||||||
<h3>Theme</h3>
|
<h3>Theme</h3>
|
||||||
<div class="theme-grid" id="theme-grid"></div>
|
<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>
|
<p class="admin-link-row"><a class="admin-link" href="/admin">Admin</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var themes = [
|
// #112: derive each preset's swatches from the real CSS variables in
|
||||||
{ id: 'midnight', name: 'Midnight', colors: ['#241B30', '#2D2340', '#3A2D52', '#7A6A9E', '#C4A8F0'] },
|
// app.css by temporarily applying data-preset, so they can never drift.
|
||||||
{ id: 'smooth', name: 'Smooth', colors: ['#F6F5FA', '#DAD7E6', '#B5B1C9', '#7A7796', '#2A2A36'] },
|
var themeNames = [
|
||||||
{ id: 'pastel-lavender', name: 'Pastel Lavender', colors: ['#e6e0f5', '#cbb8e7', '#b29edb', '#9b85cf', '#806bb8'] },
|
{ id: 'midnight', name: 'Midnight' },
|
||||||
{ id: 'pastel-peach', name: 'Pastel Peach', colors: ['#ffe0d6', '#ffc4a8', '#ffa78f', '#ff8b76', '#f9826c'] },
|
{ id: 'smooth', name: 'Smooth' },
|
||||||
{ id: 'pastel-cloud', name: 'Pastel Cloud', colors: ['#cdb4db', '#ffc8dd', '#ffafcc', '#bde0fe', '#a2d2ff'] }
|
{ 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 grid = document.getElementById('theme-grid');
|
||||||
var current = document.documentElement.dataset.preset || 'midnight';
|
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');
|
var btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.className = 'theme-card';
|
btn.className = 'theme-card';
|
||||||
btn.setAttribute('aria-pressed', current === t.id ? 'true' : 'false');
|
btn.setAttribute('aria-pressed', current === t.id ? 'true' : 'false');
|
||||||
btn.innerHTML = '<strong>' + t.name + '</strong>' +
|
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>';
|
return '<span class="swatch" style="background:' + c + '"></span>';
|
||||||
}).join('') + '</span>';
|
}).join('') + '</span>';
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
|
|||||||
+1
-1
@@ -344,7 +344,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
|
|||||||
// is harmless and arguably desirable.
|
// is harmless and arguably desirable.
|
||||||
h := w.Header()
|
h := w.Header()
|
||||||
h.Set("Content-Security-Policy",
|
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("Referrer-Policy", "no-referrer")
|
||||||
h.Set("X-Content-Type-Options", "nosniff")
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|||||||
Reference in New Issue
Block a user