Fix disabled vanity field

This commit is contained in:
2026-09-13 09:32:06 +02:00
parent ad611e9652
commit 26186ac738
4 changed files with 80 additions and 7 deletions
+59
View File
@@ -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 // A chosen passphrase has to work everywhere a generated token does: at the
// login form, on an upload, and as a session. // login form, on an upload, and as a session.
func TestChosenPassphraseWorksEndToEnd(t *testing.T) { func TestChosenPassphraseWorksEndToEnd(t *testing.T) {
+3 -2
View File
@@ -108,8 +108,9 @@
submit.disabled = true; submit.disabled = true;
progress.hidden = false; progress.hidden = false;
// FormData follows DOM order, so the token, expiry and vanity fields all // FormData follows markup order, not painted order, so the options and the
// precede the file part, which is exactly what the server requires. // 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 data = new FormData(form);
var started = Date.now(); var started = Date.now();
+8
View File
@@ -77,6 +77,14 @@ input[type=text], input[type=password] {
.row { display: flex; gap: 1rem; flex-wrap: wrap; } .row { display: flex; gap: 1rem; flex-wrap: wrap; }
.row > .field { flex: 1 1 12rem; } .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 { .drop {
border: 2px dashed var(--line); border: 2px dashed var(--line);
border-radius: var(--radius); border-radius: var(--radius);
+10 -5
View File
@@ -6,10 +6,10 @@
<form id="upload" class="card" method="post" action="{{.Base}}api/upload" <form id="upload" class="card" method="post" action="{{.Base}}api/upload"
enctype="multipart/form-data" data-max-size="{{.MaxSizeBytes}}"> enctype="multipart/form-data" data-max-size="{{.MaxSizeBytes}}">
<div class="drop" id="drop"> <!-- Field order is load-bearing: the server streams this body rather than
<input type="file" name="file" id="file" required> spooling it, so every option and the token have to arrive before the
<p class="hint" id="drop-hint">Choose a file, or drop one here.</p> file part does. The drop zone is lifted back to the top of the card with
</div> CSS order, which changes what is seen without changing what is sent. -->
<div class="row"> <div class="row">
<label class="field"> <label class="field">
@@ -19,7 +19,7 @@
<label class="field"> <label class="field">
<span>Custom name <em>({{if .AllowVanity}}optional{{else}}needs a token{{end}})</em></span> <span>Custom name <em>({{if .AllowVanity}}optional{{else}}needs a token{{end}})</em></span>
<input type="text" name="vanity" id="vanity" placeholder="auto" autocomplete="off" <input type="text" name="vanity" id="vanity" placeholder="auto" autocomplete="off"
pattern="[A-Za-z0-9][A-Za-z0-9._-]{1,63}"{{if not .AllowVanity}} disabled{{end}}> pattern="[A-Za-z0-9][A-Za-z0-9._-]{1,63}">
</label> </label>
</div> </div>
@@ -35,6 +35,11 @@
</label> </label>
</details> </details>
<div class="drop" id="drop">
<input type="file" name="file" id="file" required>
<p class="hint" id="drop-hint">Choose a file, or drop one here.</p>
</div>
<div class="progress" id="progress" hidden> <div class="progress" id="progress" hidden>
<div class="bar"><div class="fill" id="bar-fill"></div></div> <div class="bar"><div class="fill" id="bar-fill"></div></div>
<p class="hint" id="progress-text"></p> <p class="hint" id="progress-text"></p>