827fea6ea8
The stamp existed as files and appeared nowhere. It now opens the README, sits at the left of the panel's navigation bar in its compact form, and stands above the card on the two pages that carry no navigation — sign-in and first-run setup, where it is the only thing telling an administrator which service just asked them for a password. The tab icon moves to the stamp's small-size variant, so the tab, the panel and the README stop being two different identities; the earlier envelope drawing stays in docs/assets/icon.png, unreferenced. The panel serves its own copies under internal/web/static/ because go:embed cannot reach outside the package; the proof sheet says which file feeds which slot. favicon.png is regenerated from the same icon at 32x32 for browsers with no SVG favicon support, and is declared before the SVG so those that do take the vector. Sign-in and setup also get a column the width of their own card. Both are a single .card.narrow, which centres itself while the heading above it stays at the panel's left edge — with the mark added that would have been three alignments on a page with four elements. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
100 lines
3.1 KiB
Go
100 lines
3.1 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// handleSetup serves the one-time administrator creation flow at
|
|
// /setup/<token> (spec 7.6.1). Once an administrator exists the whole route
|
|
// returns 404; an invalid or expired token is indistinguishable from a missing
|
|
// page, also 404.
|
|
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
|
// Route-specific rate limit, separate from login (spec 7.6.1).
|
|
if !s.setupLimiter.Allow(clientIP(r, s.trustedProxies)) {
|
|
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
token := strings.TrimPrefix(r.URL.Path, "/setup/")
|
|
// Reject nested/garbage paths outright.
|
|
if token == "" || strings.Contains(token, "/") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if !s.setup.validate(token) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.renderSetupForm(w, http.StatusOK, token, "")
|
|
case http.MethodPost:
|
|
s.submitSetup(w, r, token)
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (s *Server) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) {
|
|
s.render(w, status, "setup", map[string]any{
|
|
"Title": "SelfPost — Create administrator",
|
|
"Active": "setup",
|
|
"Token": token,
|
|
"Error": formErr,
|
|
})
|
|
}
|
|
|
|
func (s *Server) submitSetup(w http.ResponseWriter, r *http.Request, token string) {
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.")
|
|
return
|
|
}
|
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
|
password := r.PostFormValue("password")
|
|
confirm := r.PostFormValue("password_confirm")
|
|
|
|
if err := validateUsername(username); err != nil {
|
|
s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
|
return
|
|
}
|
|
if password != confirm {
|
|
s.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.")
|
|
return
|
|
}
|
|
if err := validateAdminPassword(password); err != nil {
|
|
s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
|
return
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
logf("panel: setup: hashing password failed: %v", err)
|
|
s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
|
return
|
|
}
|
|
|
|
if err := s.store.CreateAdmin(username, string(hash)); err != nil {
|
|
// A concurrent submission may have already created the admin; the
|
|
// id=1 / non-empty-table guard makes this the second writer. Treat it
|
|
// as "setup already done" rather than an error.
|
|
if exists, _ := s.store.AdminExists(); exists {
|
|
s.setup.complete()
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
logf("panel: setup: create admin failed: %v", err)
|
|
s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
|
return
|
|
}
|
|
|
|
// Setup is now permanently complete: burn the token (spec 7.6.1).
|
|
s.setup.complete()
|
|
logf("panel: administrator %q created; setup link is now disabled", username)
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
}
|