diff --git a/internal/server/server_test.go b/internal/server/server_test.go index db5427c..27f5fd5 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1627,6 +1627,65 @@ func TestUploadPageKeepsTheOneOffTokenField(t *testing.T) { } } +// The server reads the multipart body as a stream and stops at the file part, +// so anything the upload depends on has to be in the markup ahead of it. The +// form is laid out to look otherwise, which is exactly why this is pinned: a +// tidy-up that moves the drop zone back up in the markup would silently strip +// the expiry, the custom name and the one-off token from every upload. +func TestUploadFormSendsTheFileLast(t *testing.T) { + h := newHarness(t, nil) + resp := h.get(t, "/", "") + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + page := string(raw) + + file := strings.Index(page, `name="file"`) + if file < 0 { + t.Fatal("the upload form has no file field") + } + for _, field := range []string{"expiry", "vanity", "token"} { + at := strings.Index(page, `name="`+field+`"`) + if at < 0 { + t.Errorf("the upload form has no %s field", field) + continue + } + if at > file { + t.Errorf("the %s field follows the file part, where the server can no longer read it", field) + } + } +} + +// Anonymous visitors may type a custom name: the token that permits it can be +// supplied in the same form, for this upload only. The rule itself is the +// server's to enforce, not the markup's. +func TestCustomNameFieldIsAlwaysUsable(t *testing.T) { + h := newHarness(t, nil) + resp := h.get(t, "/", "") + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + form := string(raw) + if i := strings.Index(form, `name="vanity"`); i < 0 { + t.Fatal("the upload form has no custom name field") + } else if j := strings.Index(form[i:], ">"); strings.Contains(form[i:i+j], "disabled") { + t.Error("the custom name field is disabled, so a one-off token cannot be used with it") + } + + // Enabled in the page, still refused on the wire without a token. + res := h.formUpload(t, map[string]string{"vanity": "anonymous-pick"}, "f.txt", "hello") + defer res.Body.Close() + if res.StatusCode != http.StatusForbidden { + t.Errorf("anonymous vanity upload = %s, want 403", res.Status) + } + + // And accepted when the form carries a token that allows it. + res2 := h.formUpload(t, map[string]string{"vanity": "chosen-name", "token": h.token}, "f.txt", "hello") + defer res2.Body.Close() + if res2.StatusCode != http.StatusCreated { + t.Errorf("one-off token vanity upload = %s, want 201", res2.Status) + } +} + // A chosen passphrase has to work everywhere a generated token does: at the // login form, on an upload, and as a session. func TestChosenPassphraseWorksEndToEnd(t *testing.T) { diff --git a/web/static/app.js b/web/static/app.js index ff3ad36..2591673 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -108,8 +108,9 @@ submit.disabled = true; progress.hidden = false; - // FormData follows DOM order, so the token, expiry and vanity fields all - // precede the file part, which is exactly what the server requires. + // FormData follows markup order, not painted order, so the options and the + // token precede the file part exactly as the server requires. The drop + // zone only looks like it comes first; see the order rules in style.css. var data = new FormData(form); var started = Date.now(); diff --git a/web/static/style.css b/web/static/style.css index cdda2ec..28890d0 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -77,6 +77,14 @@ input[type=text], input[type=password] { .row { display: flex; gap: 1rem; flex-wrap: wrap; } .row > .field { flex: 1 1 12rem; } +/* The file input has to be the last part in the submitted body, so it sits at + the bottom of the form's markup and is lifted back to the top here. Only the + painting order moves; the submission order, which the server depends on, is + the markup's. */ +#upload { display: flex; flex-direction: column; } +#upload > .drop { order: -1; } +#upload > button { align-self: flex-start; } + .drop { border: 2px dashed var(--line); border-radius: var(--radius); diff --git a/web/templates/index.html b/web/templates/index.html index b30fe2a..5b80b9b 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -6,10 +6,10 @@
-
- -

Choose a file, or drop one here.

-
+
@@ -35,6 +35,11 @@ +
+ +

Choose a file, or drop one here.

+
+