Remove cans feature; add Secure flag to pw_/tok_ cookies
Owner decision 2026-10-16: the cans feature (bundles of pastes, #4) was half-integrated and has zero prod usage; removing it eliminates four audit findings by deletion. Removed: - internal/api/cans.go and all can routes (/api/pastes/can, /api/cans/*, /can/{id}) plus handleCanPage/renderCan - is_can from /api/mine and /api/public responses - store layer: CanRow, can store funcs, paste_cans UNION in listings, cans sweep; reserved slugs 'can'/'cans' released - templates/can.html, can CSS, is_can badges in history.js/mine.js - cans tests (cans_test.go, cans_flow_test.go, cans_expiry_test.go, TestErrorCodeCanValidation) - docs/api.md cans section, README/LICENSE/docker-compose mentions Store migration: drops paste_cans only when empty (non-empty table is kept and logged; prod verified clean 2026-10-16) and drops the dormant pastes.can_id column. Covered by new TestMigrateDropsCansSchema. Security hardening (audit findings): pw_<id> (unlock) and tok_<id> (deletion-token) cookies now carry Secure: true in addition to HttpOnly + SameSite=Lax; all deployments are HTTPS-only. cookie_set_test now asserts Secure. go vet ./... and go test ./... pass.
This commit is contained in:
1 parent
651bc0cb86
commit
e54fbc36ef
20 files changed
+176
-1356
No files matched your search
@@ -694,26 +694,6 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; }
|
||||
.center .foot { font-size: 13px; }
|
||||
}
|
||||
|
||||
/* #4: can view page + can builder */
|
||||
.can-page h1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.can-items { display: flex; flex-direction: column; gap: 12px; margin-top: 14px; }
|
||||
.can-item { padding: 12px 14px; }
|
||||
.can-item-head { display: flex; align-items: center; gap: 10px; }
|
||||
.can-item-head strong { flex: 1; word-break: break-all; }
|
||||
.can-item-body { margin-top: 8px; }
|
||||
.can-item-body summary { cursor: pointer; font-size: 19px; color: var(--muted, #888); }
|
||||
/* #169: divider between the dropdown title and body must be persistent
|
||||
(not hover dependent) and extend the full width of the card left and right */
|
||||
.can-item-body[open] summary {
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin: 0 -14px 8px;
|
||||
padding: 0 14px 8px;
|
||||
}
|
||||
.can-item-body pre.code { margin: 8px 0 0; overflow-x: auto; }
|
||||
.can-item-row { margin-top: 8px; }
|
||||
.can-item-row .can-item-title { width: 100%; margin-bottom: 6px; }
|
||||
.can-item-row .can-item-content { width: 100%; font-family: var(--mono, monospace); resize: vertical; }
|
||||
|
||||
/* ============================================================
|
||||
Styled controls (#123) — buttons, selects, checkboxes, radios
|
||||
match the custom URL box house pattern: 1px var(--border) on
|
||||
@@ -892,7 +872,6 @@ button[type="submit"]:focus-visible,
|
||||
|
||||
/* #139: CSP-safe replacements for inline style attributes (style-src 'self') */
|
||||
.hidden { display: none; }
|
||||
.can-page { max-width: 900px; width: 100%; }
|
||||
.col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; }
|
||||
.col-d { width: 90px; } .col-d2 { width: 150px; } .col-e { width: 150px; }
|
||||
.col-f { width: 100px; } .col-g { width: 120px; }
|
||||
|
||||
@@ -6,8 +6,8 @@ const t = PaletteTable.init({
|
||||
rowHtml: it =>
|
||||
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
|
||||
(it.title
|
||||
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
|
||||
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
|
||||
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>`
|
||||
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
|
||||
`</td>` +
|
||||
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
|
||||
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
|
||||
|
||||
@@ -18,8 +18,8 @@ const t = PaletteTable.init({
|
||||
rowHtml: it =>
|
||||
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
|
||||
(it.title
|
||||
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
|
||||
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
|
||||
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>`
|
||||
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
|
||||
`</td>` +
|
||||
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
|
||||
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
{{template "head" .}}
|
||||
{{template "topbar" .}}
|
||||
<div class="center">
|
||||
<div class="float can-page">
|
||||
<div class="inner">
|
||||
<h1>{{.Title}} <span class="badge" title="This is a can — a bundle of pastes">can</span></h1>
|
||||
{{if .HasDescription}}<p class="sub">{{.Description}}</p>{{end}}
|
||||
<p class="hint">{{.ItemCount}} item{{if ne .ItemCount 1}}s{{end}} · {{.SizeHuman}} · created <span data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>{{if .ExpiresAt}} · expires in {{.ExpiresIn}}{{end}}{{if .HasPassword}} · password protected{{end}}</p>
|
||||
|
||||
<div class="can-items">
|
||||
{{range .Items}}
|
||||
<div class="can-item float">
|
||||
<div class="can-item-head">
|
||||
<strong>{{.Title}}</strong>
|
||||
<span class="dim">{{.Size}}</span>
|
||||
{{if .IsFile}}
|
||||
<a class="btn btn-icon" href="/api/cans/{{$.ID}}/items/{{.ID}}" download="{{.Title}}" title="Download file" aria-label="Download {{.Title}}">↓</a>
|
||||
{{else}}
|
||||
<a class="btn btn-icon" href="/api/cans/{{$.ID}}/items/{{.ID}}" title="Raw item" aria-label="Raw {{.Title}}">⧉</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if not .IsFile}}
|
||||
<details class="can-item-body">
|
||||
<summary>Show content</summary>
|
||||
<pre class="code"><code>{{.ContentHTML}}</code></pre>
|
||||
</details>
|
||||
{{else}}
|
||||
<div class="hint">Attached file — use the download button above.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "foot" .}}
|
||||
+5
-28
@@ -1,5 +1,5 @@
|
||||
// Package web serves palette's HTML routes: paste pages, cans, unlock, and
|
||||
// the admin page. Templates and static assets are embedded in this package.
|
||||
// Package web serves palette's HTML routes: paste pages, unlock, and the
|
||||
// admin page. Templates and static assets are embedded in this package.
|
||||
package web
|
||||
|
||||
import (
|
||||
@@ -111,29 +111,6 @@ func (h *Handlers) RenderNotFoundPage(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderNotFound(w, r, r.URL.Path)
|
||||
}
|
||||
|
||||
// RenderPage is the exported wrapper used by the api package (#4 can pages).
|
||||
func (h *Handlers) RenderPage(w http.ResponseWriter, name string, data any) {
|
||||
h.renderPage(w, name, data)
|
||||
}
|
||||
|
||||
// WriteRateLimited is the exported rate-limit response used by the api package (#4).
|
||||
func (h *Handlers) WriteRateLimited(w http.ResponseWriter, retryAfterSecs int) {
|
||||
h.writeRateLimited(w, retryAfterSecs)
|
||||
}
|
||||
|
||||
// UnlockToken returns the per-id HMAC unlock token (cookie value, #34).
|
||||
// Exported for the api package so can pages share paste cookie semantics (#4).
|
||||
func UnlockToken(id string) string { return unlockToken(id) }
|
||||
|
||||
// AgoString formats a relative "N ago" string (exported for the api package, #4).
|
||||
func AgoString(ts int64) string { return agoString(ts) }
|
||||
|
||||
// HumanSize formats a byte count as a human string (exported for the api package, #4).
|
||||
func HumanSize(n int) string { return humanSize(n) }
|
||||
|
||||
// ExpiryString formats remaining time until an epoch seconds expiry (#4).
|
||||
func ExpiryString(expiresAt int64) string { return expiryString(expiresAt) }
|
||||
|
||||
// #34: per-paste unlock tokens. unlockSecret is generated once at startup
|
||||
// (also derivable from PALETTE_UNLOCK_SECRET for multi-instance deploys) and
|
||||
// used to HMAC paste ids, so a client can only hold a valid pw_<id> cookie by
|
||||
@@ -369,7 +346,7 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
|
||||
// the paste id under the server's random secret.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "pw_" + row.ID, Value: unlockToken(row.ID), Path: "/",
|
||||
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true,
|
||||
})
|
||||
// re-render without lock, or redirect if ?next= was given (#26)
|
||||
if next := r.FormValue("next"); next != "" {
|
||||
@@ -403,7 +380,7 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if justCreated && token != "" {
|
||||
// one-time display of the deletion token via sessionStorage (#143)
|
||||
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true})
|
||||
}
|
||||
// Count the view for real page renders, deduped per-viewer within the
|
||||
// burn window (#95): a reload by the same vwr cookie doesn't inflate
|
||||
@@ -466,7 +443,7 @@ func (h *Handlers) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} }
|
||||
|
||||
// #59: security headers for rendered HTML pages. Applied wherever the
|
||||
// response is text/html (page templates and the inline can page); JSON API
|
||||
// response is text/html (page templates); JSON API
|
||||
// responses and /raw content pass through untouched.
|
||||
// #139: script-src and style-src no longer allow 'unsafe-inline'. All
|
||||
// previously-inline scripts moved to external files under static/ (page data
|
||||
|
||||
Reference in new issue
Block a user