diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ebc20ad..b62111e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 770886b..e50b7a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 51ee774..0c15dbd 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/internal/api/burnreads_test.go b/internal/api/burnreads_test.go index bd74e69..07f578b 100644 --- a/internal/api/burnreads_test.go +++ b/internal/api/burnreads_test.go @@ -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() diff --git a/internal/store/store.go b/internal/store/store.go index e836e04..8755207 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 diff --git a/internal/web/securityheaders_test.go b/internal/web/securityheaders_test.go index b6d54c7..61d0b56 100644 --- a/internal/web/securityheaders_test.go +++ b/internal/web/securityheaders_test.go @@ -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) } diff --git a/internal/web/templates/admin.html b/internal/web/templates/admin.html index 86f10ee..6af4d74 100644 --- a/internal/web/templates/admin.html +++ b/internal/web/templates/admin.html @@ -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. })(); {{template "foot" .}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index d63bb50..aaf33db 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -3,9 +3,10 @@ @@ -13,7 +14,7 @@ {{define "topbar"}}
- +