Users couldn't see Excalidraw's built-in Save as / Export image options:
the app's custom toolbar was drawn exactly on top of the native hamburger
menu button, hiding it. Removed the overlay and integrated Back to
Library / Save now / Rename into the native menu, so the native export
formats (.excalidraw file, PNG, SVG, clipboard) are now reachable.
Viktor asked for exports to work via the native Excalidraw feature and
for drawings to be renameable by clicking their name.
Rename: new PATCH /api/drawings/{id} endpoint (server-side name
sanitization, 409 on conflict) + click-to-rename title pill in the
editor (updates URL in place) + Rename button/modal in the dashboard.
Existing GET/PUT/DELETE semantics unchanged for API compatibility
(emo's upload pipeline). Added main_test.go (httptest) covering rename
+ existing handler behavior; dashboard rows now DOM-built (XSS-safe).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
249 lines
7.9 KiB
Go
249 lines
7.9 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const testDrawing = `{"type":"excalidraw","version":2,"source":"excalidraw-library","elements":[{"id":"e1"}],"appState":{"viewBackgroundColor":"#ffffff"}}`
|
|
|
|
func setupDataDir(t *testing.T) {
|
|
t.Helper()
|
|
dataDir = t.TempDir()
|
|
}
|
|
|
|
// doDrawing sends a request to handleDrawing for the given user and returns the recorder.
|
|
func doDrawing(t *testing.T, method, id, body, user string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var reader *strings.Reader
|
|
if body == "" {
|
|
reader = strings.NewReader("")
|
|
} else {
|
|
reader = strings.NewReader(body)
|
|
}
|
|
req := httptest.NewRequest(method, "/api/drawings/"+id, reader)
|
|
if user != "" {
|
|
req.Header.Set("X-Authentik-Username", user)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
handleDrawing(w, req)
|
|
return w
|
|
}
|
|
|
|
func listDrawings(t *testing.T, user string) []Drawing {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/drawings", nil)
|
|
if user != "" {
|
|
req.Header.Set("X-Authentik-Username", user)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
handleListDrawings(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list: expected 200, got %d", w.Code)
|
|
}
|
|
var drawings []Drawing
|
|
if err := json.Unmarshal(w.Body.Bytes(), &drawings); err != nil {
|
|
t.Fatalf("list: bad JSON: %v", err)
|
|
}
|
|
return drawings
|
|
}
|
|
|
|
func TestPutGetRoundtrip(t *testing.T) {
|
|
setupDataDir(t)
|
|
if w := doDrawing(t, http.MethodPut, "foo", testDrawing, "alice"); w.Code != http.StatusOK {
|
|
t.Fatalf("PUT: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
w := doDrawing(t, http.MethodGet, "foo", "", "alice")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET: expected 200, got %d", w.Code)
|
|
}
|
|
if w.Body.String() != testDrawing {
|
|
t.Errorf("GET: content mismatch: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetMissing(t *testing.T) {
|
|
setupDataDir(t)
|
|
if w := doDrawing(t, http.MethodGet, "nope", "", "alice"); w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestListDrawings(t *testing.T) {
|
|
setupDataDir(t)
|
|
doDrawing(t, http.MethodPut, "one", testDrawing, "alice")
|
|
doDrawing(t, http.MethodPut, "two", testDrawing, "alice")
|
|
drawings := listDrawings(t, "alice")
|
|
if len(drawings) != 2 {
|
|
t.Fatalf("expected 2 drawings, got %d", len(drawings))
|
|
}
|
|
ids := map[string]bool{drawings[0].ID: true, drawings[1].ID: true}
|
|
if !ids["one"] || !ids["two"] {
|
|
t.Errorf("unexpected ids: %v", ids)
|
|
}
|
|
for _, d := range drawings {
|
|
if d.Name != d.ID {
|
|
t.Errorf("name should equal id: %+v", d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDelete(t *testing.T) {
|
|
setupDataDir(t)
|
|
doDrawing(t, http.MethodPut, "foo", testDrawing, "alice")
|
|
if w := doDrawing(t, http.MethodDelete, "foo", "", "alice"); w.Code != http.StatusOK {
|
|
t.Fatalf("DELETE: expected 200, got %d", w.Code)
|
|
}
|
|
if w := doDrawing(t, http.MethodGet, "foo", "", "alice"); w.Code != http.StatusNotFound {
|
|
t.Fatalf("GET after delete: expected 404, got %d", w.Code)
|
|
}
|
|
if w := doDrawing(t, http.MethodDelete, "foo", "", "alice"); w.Code != http.StatusNotFound {
|
|
t.Fatalf("second DELETE: expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestPerUserIsolation(t *testing.T) {
|
|
setupDataDir(t)
|
|
doDrawing(t, http.MethodPut, "secret", testDrawing, "alice")
|
|
if w := doDrawing(t, http.MethodGet, "secret", "", "bob"); w.Code != http.StatusNotFound {
|
|
t.Fatalf("bob should not see alice's drawing, got %d", w.Code)
|
|
}
|
|
if drawings := listDrawings(t, "bob"); len(drawings) != 0 {
|
|
t.Fatalf("bob's list should be empty, got %d", len(drawings))
|
|
}
|
|
}
|
|
|
|
// --- rename (PATCH) ---
|
|
|
|
func renameReq(t *testing.T, id, newName, user string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
return doDrawing(t, http.MethodPatch, id, `{"name":`+strconv(newName)+`}`, user)
|
|
}
|
|
|
|
// strconv JSON-quotes a string without importing encoding/json for a one-liner.
|
|
func strconv(s string) string {
|
|
b, _ := json.Marshal(s)
|
|
return string(b)
|
|
}
|
|
|
|
func TestRenameSuccess(t *testing.T) {
|
|
setupDataDir(t)
|
|
doDrawing(t, http.MethodPut, "foo", testDrawing, "alice")
|
|
w := renameReq(t, "foo", "bar", "alice")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("PATCH: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]string
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("PATCH: bad JSON: %v", err)
|
|
}
|
|
if resp["id"] != "bar" || resp["status"] != "renamed" {
|
|
t.Errorf("unexpected response: %v", resp)
|
|
}
|
|
if w := doDrawing(t, http.MethodGet, "bar", "", "alice"); w.Code != http.StatusOK || w.Body.String() != testDrawing {
|
|
t.Errorf("GET new id: code=%d content=%q", w.Code, w.Body.String())
|
|
}
|
|
if w := doDrawing(t, http.MethodGet, "foo", "", "alice"); w.Code != http.StatusNotFound {
|
|
t.Errorf("GET old id: expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRenameConflict(t *testing.T) {
|
|
setupDataDir(t)
|
|
doDrawing(t, http.MethodPut, "a", testDrawing, "alice")
|
|
doDrawing(t, http.MethodPut, "b", testDrawing, "alice")
|
|
if w := renameReq(t, "a", "b", "alice"); w.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409, got %d", w.Code)
|
|
}
|
|
// both drawings intact
|
|
for _, id := range []string{"a", "b"} {
|
|
if w := doDrawing(t, http.MethodGet, id, "", "alice"); w.Code != http.StatusOK {
|
|
t.Errorf("drawing %q should be intact, got %d", id, w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenameMissing(t *testing.T) {
|
|
setupDataDir(t)
|
|
if w := renameReq(t, "nope", "new", "alice"); w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRenameSameName(t *testing.T) {
|
|
setupDataDir(t)
|
|
doDrawing(t, http.MethodPut, "foo", testDrawing, "alice")
|
|
w := renameReq(t, "foo", "foo", "alice")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("same-name rename: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if w := doDrawing(t, http.MethodGet, "foo", "", "alice"); w.Code != http.StatusOK {
|
|
t.Errorf("drawing should be intact, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRenameInvalidNames(t *testing.T) {
|
|
setupDataDir(t)
|
|
doDrawing(t, http.MethodPut, "foo", testDrawing, "alice")
|
|
for _, name := range []string{"", " ", "../..", "---"} {
|
|
if w := renameReq(t, "foo", name, "alice"); w.Code != http.StatusBadRequest {
|
|
t.Errorf("rename to %q: expected 400, got %d", name, w.Code)
|
|
}
|
|
}
|
|
// malformed body
|
|
if w := doDrawing(t, http.MethodPatch, "foo", `{not json`, "alice"); w.Code != http.StatusBadRequest {
|
|
t.Errorf("malformed body: expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRenameSanitization(t *testing.T) {
|
|
setupDataDir(t)
|
|
cases := []struct{ in, want string }{
|
|
{"My Drawing!", "My-Drawing-"},
|
|
{"net diag.excalidraw", "net-diag"}, // .excalidraw suffix stripped, not mangled
|
|
{"a/b\\c", "a-b-c"},
|
|
}
|
|
for _, c := range cases {
|
|
doDrawing(t, http.MethodPut, "src", testDrawing, "alice")
|
|
w := renameReq(t, "src", c.in, "alice")
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("rename to %q: expected 200, got %d: %s", c.in, w.Code, w.Body.String())
|
|
continue
|
|
}
|
|
var resp map[string]string
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
if resp["id"] != c.want {
|
|
t.Errorf("rename to %q: expected id %q, got %q", c.in, c.want, resp["id"])
|
|
}
|
|
// file must be inside the user dir under the sanitized name
|
|
if _, err := os.Stat(filepath.Join(dataDir, "alice", c.want+".excalidraw")); err != nil {
|
|
t.Errorf("rename to %q: expected file %q on disk: %v", c.in, c.want, err)
|
|
}
|
|
doDrawing(t, http.MethodDelete, resp["id"], "", "alice")
|
|
}
|
|
}
|
|
|
|
func TestRenameTraversalStaysInUserDir(t *testing.T) {
|
|
setupDataDir(t)
|
|
doDrawing(t, http.MethodPut, "foo", testDrawing, "alice")
|
|
w := renameReq(t, "foo", "../../../etc/passwd", "alice")
|
|
if w.Code == http.StatusOK {
|
|
var resp map[string]string
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
if strings.Contains(resp["id"], "/") || strings.Contains(resp["id"], "..") {
|
|
t.Fatalf("traversal characters survived: %q", resp["id"])
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dataDir, "alice", resp["id"]+".excalidraw")); err != nil {
|
|
t.Fatalf("renamed file escaped user dir: %v", err)
|
|
}
|
|
}
|
|
// nothing outside the data dir
|
|
if _, err := os.Stat(filepath.Join(dataDir, "..", "etc")); err == nil {
|
|
t.Fatal("file escaped the data dir")
|
|
}
|
|
}
|