Compare commits
11
Commits
eca8533d70
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb1b16ae41 | ||
|
|
880f3cd958 | ||
|
|
1149989d07 | ||
|
|
ed435c5e13 | ||
|
|
c190dd9ea6 | ||
|
|
d4da322031 | ||
|
|
8901a3c82c | ||
|
|
3460d54fce | ||
|
|
bc52f0a608 | ||
|
|
84d19556fc | ||
|
|
f3fe2335d4 |
@@ -21,6 +21,7 @@ a web UI for sharing text and small files.
|
||||
- Cookie based saved pastes and settings
|
||||
- Five base themes (midnight, smooth, pastel-lavender, pastel-peach, pastel-cloud), each with a dark and light variant
|
||||
- Dark mode toggle in the topbar and settings, with a configurable default
|
||||
- Polished code viewer: line-number gutter sized to the widest number and pinned during horizontal scroll, optional line wrap, jump-to-top/bottom buttons, and theme-aware scrollbars
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -30,7 +31,6 @@ a web UI for sharing text and small files.
|
||||
|  |  |
|
||||
|
||||
|
||||
Mobile previews (375x812): [paste view](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-paste-midnight-dark.png), [public list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-public.png), [settings](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-settings.png).
|
||||
|
||||
## Get Started
|
||||
|
||||
@@ -66,7 +66,7 @@ go build -o palette ./cmd/palette
|
||||
| `PALETTE_ADDR` | `:8080` | Listen address |
|
||||
| `PALETTE_DB` | `palette.db` | SQLite database path |
|
||||
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
|
||||
| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) |
|
||||
| `PALETTE_MAX_ITEM` | `26214400` | Max can item / file attachment size in bytes (25 MB) |
|
||||
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) |
|
||||
| `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. |
|
||||
| `PALETTE_UNLOCK_SECRET` | random per start | HMAC secret for password-unlock cookies. Set a fixed value to keep unlock sessions across restarts or across replicas. |
|
||||
@@ -102,4 +102,4 @@ data loss, or legal issues arising from use of the software. You use it
|
||||
at your own risk.
|
||||
|
||||
If you run a modified version of Palette as a network service, the AGPL
|
||||
requires you to offer your modified source code to its users.
|
||||
requires you to offer your modified source code to its users.
|
||||
+7
-1
@@ -32,10 +32,16 @@ services:
|
||||
# Default: 5242880 (5 MiB).
|
||||
# PALETTE_MAX_TEXT: "5242880"
|
||||
|
||||
# Max size in bytes of a single can item (file/text inside a can).
|
||||
# Max size in bytes of a single can item (file/text inside a can) or a
|
||||
# paste file attachment.
|
||||
# Default: 26214400 (25 MiB).
|
||||
# PALETTE_MAX_ITEM: "26214400"
|
||||
|
||||
# Default dark mode for new visitors. Unset = dark on; set to "false",
|
||||
# "0" or "off" to default to light mode. Visitors who toggle dark mode
|
||||
# keep their choice in their browser.
|
||||
# PALETTE_DEFAULT_DARK: "false"
|
||||
|
||||
# HMAC secret for password-unlock cookies. Default: random per start,
|
||||
# which logs out every unlocked browser session on restart. Set a fixed
|
||||
# secret (any random string) to keep unlock sessions across restarts,
|
||||
|
||||
@@ -91,17 +91,6 @@ func (l *limitReader) Read(p []byte) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// isImageMime reports whether the sniffed mime is a raster image the viewer
|
||||
// can render inline (#221). SVG is excluded: it is forced to text/plain on
|
||||
// serving by the active-content rule and must never render as an image.
|
||||
func isImageMime(mime string) bool {
|
||||
switch mime {
|
||||
case "image/png", "image/jpeg", "image/gif", "image/webp":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleCreatePasteMultipart implements POST /api/pastes with
|
||||
// multipart/form-data (#38). Fields mirror the JSON create path; a 'file'
|
||||
// part makes the paste a file paste (1 file = 1 paste: if text content is
|
||||
|
||||
@@ -306,3 +306,64 @@ func TestMultipartPasswordFieldAccepted(t *testing.T) {
|
||||
t.Fatalf("paste should require password, got %d", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// #281: /raw/{id} must stream the attachment blob for ALL attachment mimes,
|
||||
// not just raster images (the old isImageMime gate left non-image
|
||||
// attachments serving an empty body from row.Content).
|
||||
func TestRawStreamsNonImageAttachment(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
body := []byte("hello, this is a plain text attachment body")
|
||||
rec, resp := multipartCreate(t, h, "notes.txt", body, nil)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if resp["attachment"] == nil {
|
||||
t.Fatalf("no attachment in response: %v", resp)
|
||||
}
|
||||
id, _ := resp["id"].(string)
|
||||
|
||||
req := httptest.NewRequest("GET", "/raw/"+id, nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req)
|
||||
if rec2.Code != 200 {
|
||||
t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
||||
t.Fatalf("Content-Type = %q", got)
|
||||
}
|
||||
if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Fatalf("nosniff = %q", got)
|
||||
}
|
||||
if !bytes.Equal(rec2.Body.Bytes(), body) {
|
||||
t.Fatalf("raw bytes differ: got %d bytes want %d", rec2.Body.Len(), len(body))
|
||||
}
|
||||
}
|
||||
|
||||
// #281: active-content attachment types still get forced to text/plain on
|
||||
// /raw, same rule as the /f/ serving path (#34).
|
||||
func TestRawHtmlAttachmentServesAsPlainText(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
html := []byte("<html><body><script>alert(1)</script></body></html>")
|
||||
rec, resp := multipartCreate(t, h, "page.html", html, nil)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
id, _ := resp["id"].(string)
|
||||
|
||||
req := httptest.NewRequest("GET", "/raw/"+id, nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req)
|
||||
if rec2.Code != 200 {
|
||||
t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
||||
t.Fatalf("Content-Type = %q", got)
|
||||
}
|
||||
if !bytes.Equal(rec2.Body.Bytes(), html) {
|
||||
t.Fatal("raw bytes differ from upload")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,15 +501,18 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not found", 404)
|
||||
return
|
||||
}
|
||||
// #221: raw view of an image paste serves the image bytes themselves as
|
||||
// an image, not the (empty) text content.
|
||||
if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil && isImageMime(att.Mime) {
|
||||
// #221: raw view of a paste backed by an attachment serves the stored
|
||||
// blob bytes with the sniffed mime, not the (empty) text content — for
|
||||
// ALL attachment mimes (#281); /raw/{id} is the raw fetch for the file
|
||||
// too. serveContentType still forces active-content types (html, svg,
|
||||
// xml) to text/plain per the #34 rule below.
|
||||
if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil {
|
||||
blobs := a.store.Blobs()
|
||||
if blobs != nil {
|
||||
if blob, err := blobs.Get(row.ID + "/" + att.SHA256); err == nil {
|
||||
defer blob.Close()
|
||||
a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95)
|
||||
w.Header().Set("Content-Type", att.Mime)
|
||||
w.Header().Set("Content-Type", serveContentType(att.Mime))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size))
|
||||
http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob)
|
||||
|
||||
@@ -38,4 +38,14 @@
|
||||
window.addEventListener('resize', refresh);
|
||||
if (scroller !== window && scroller) scroller.addEventListener('input', refresh);
|
||||
refresh();
|
||||
/* #282: the first evaluation can run before the layout settles (media
|
||||
queries, web fonts, async highlighting) and under-measure the content,
|
||||
leaving the nav hidden on long pages. Re-check once a real layout exists
|
||||
and after load; the ResizeObserver also catches late content growth. */
|
||||
requestAnimationFrame(function () { requestAnimationFrame(refresh); });
|
||||
window.addEventListener('load', refresh);
|
||||
window.setTimeout(refresh, 300);
|
||||
if (window.ResizeObserver && scroller === window && document.body) {
|
||||
new ResizeObserver(refresh).observe(document.body);
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user