package httpapi import ( "errors" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/kristianwind/verdande/internal/store" ) type projectJSON struct { ID string `json:"id"` Name string `json:"name"` Color string `json:"color"` Icon string `json:"icon,omitempty"` ViewMode string `json:"view_mode"` OwnerID string `json:"owner_id"` IsInbox bool `json:"is_inbox"` Archived bool `json:"archived"` SortOrder float64 `json:"sort_order"` Role string `json:"role"` MemberCount int `json:"member_count"` OpenCount int `json:"open_count"` Shared bool `json:"shared"` // GroupID is the sidebar group the asking user has filed it under, empty for // none. Always empty on a project shared with them: grouping is a column on // the project row, so it is the owner's filing and nobody else's. GroupID string `json:"group_id,omitempty"` } func toProjectJSON(p store.Project) projectJSON { return projectJSON{ ID: p.ID, Name: p.Name, Color: p.Color, Icon: p.Icon, ViewMode: p.ViewMode, OwnerID: p.OwnerID, IsInbox: p.IsInbox, Archived: p.Archived, SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount, OpenCount: p.OpenCount, Shared: p.MemberCount > 1, GroupID: p.GroupID, } } func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) { user := userFrom(r.Context()) includeArchived := r.URL.Query().Get("archived") == "true" projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived) if err != nil { s.internal(w, r, "list projects", err) return } out := make([]projectJSON, 0, len(projects)) for _, p := range projects { out = append(out, toProjectJSON(p)) } writeJSON(w, http.StatusOK, map[string]any{"projects": out}) } func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { user := userFrom(r.Context()) p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID) if err != nil { s.storeError(w, r, "get project", err) return } writeJSON(w, http.StatusOK, toProjectJSON(*p)) } type projectRequest struct { Name *string `json:"name"` Color *string `json:"color"` Icon *string `json:"icon"` ViewMode *string `json:"view_mode"` Archived *bool `json:"archived"` // GroupID files the project under a sidebar group; "" takes it out of one. // The empty string rather than a JSON null, because null and absent are the // same nil pointer once decoded — and telling "put it nowhere" apart from // "I did not mention it" is the whole point of a PATCH. GroupID *string `json:"group_id"` } type reorderProjectsRequest struct { // IDs in the order they should appear, top to bottom. IDs []string `json:"ids"` } // handleReorderProjects sets the sidebar order. // // The whole list rather than one move: a person has a handful of projects, and // sending the order you want is both simpler to reason about and impossible to // get into the half-ordered state a sequence of individual moves can produce if // one of them fails. // // Projects the caller does not own are skipped — sort_order is a column on the // project, so a shared one sits where its owner put it. func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) { var req reorderProjectsRequest if err := decodeJSON(w, r, &req); err != nil { return } // A ceiling rather than a guess at what is reasonable: this is a bound on // the transaction, not an opinion about how many projects somebody may have. if len(req.IDs) > 500 { writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"}) return } if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil { s.internal(w, r, "reorder projects", err) return } w.WriteHeader(http.StatusNoContent) } func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { var req projectRequest if err := decodeJSON(w, r, &req); err != nil { return } user := userFrom(r.Context()) name := "" if req.Name != nil { name = strings.TrimSpace(*req.Name) } if fields := validateProject(name, req.ViewMode, req.Color); len(fields) > 0 { writeFieldErrors(w, fields) return } p := &store.Project{Name: name, OwnerID: user.ID} if req.Color != nil { p.Color = *req.Color } if req.Icon != nil { p.Icon = *req.Icon } if req.ViewMode != nil { p.ViewMode = *req.ViewMode } if err := s.db.CreateProject(r.Context(), p); err != nil { s.internal(w, r, "create project", err) return } // Filed under a group in the same call. `group_id` was declared on this // request type, documented on it, applied by the update handler — and silently // dropped here, so creating a project into a group answered 201 and put it // nowhere. A field that is accepted and thrown away is worse than one that is // refused. if req.GroupID != nil && *req.GroupID != "" { if err := s.db.SetProjectGroup(r.Context(), p.ID, *req.GroupID, user.ID); err != nil { s.storeError(w, r, "set project group", err) return } p.GroupID = *req.GroupID } s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name}) s.publishToOwner(r, "project.created", toProjectJSON(*p)) writeJSON(w, http.StatusCreated, toProjectJSON(*p)) } func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) { var req projectRequest if err := decodeJSON(w, r, &req); err != nil { return } projectID := chi.URLParam(r, "projectID") var name string if req.Name != nil { name = strings.TrimSpace(*req.Name) req.Name = &name } // The name is only checked when it was sent — but the colour is checked // whenever it was, which is why this is two conditions rather than one. fields := validateProject(name, req.ViewMode, req.Color) if req.Name == nil { delete(fields, "name") } if len(fields) > 0 { writeFieldErrors(w, fields) return } err := s.db.UpdateProject(r.Context(), projectID, store.ProjectUpdate{ Name: req.Name, Color: req.Color, Icon: req.Icon, ViewMode: req.ViewMode, Archived: req.Archived, }) if err != nil { s.storeError(w, r, "update project", err) return } // Grouping is written separately because it is scoped differently: the fields // above belong to the project, and this one belongs to whoever is filing it. // The route already requires ownership, so the check inside is a second lock // on the same door rather than a different one. if req.GroupID != nil { if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID, userFrom(r.Context()).ID); err != nil { s.storeError(w, r, "set project group", err) return } } s.activity(r, projectID, "", "project.updated", nil) p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID) if err != nil { s.storeError(w, r, "get project", err) return } s.publishToOwner(r, "project.updated", toProjectJSON(*p)) writeJSON(w, http.StatusOK, toProjectJSON(*p)) } func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") if err := s.db.DeleteProject(r.Context(), projectID); err != nil { if strings.Contains(err.Error(), "Inbox cannot be deleted") { writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted") return } s.storeError(w, r, "delete project", err) return } s.activity(r, projectID, "", "project.deleted", nil) s.publishToOwner(r, "project.deleted", map[string]string{"id": projectID}) w.WriteHeader(http.StatusNoContent) } type trashedProjectJSON struct { projectJSON DeletedAt string `json:"deleted_at"` // PurgeAfter is when it stops being recoverable. Sent rather than left for // the frontend to work out, because the retention window is the server's and // a copy of it in the interface is a copy that will one day disagree. PurgeAfter string `json:"purge_after"` TaskCount int `json:"task_count"` } // handleListTrashedProjects is what makes restoring reachable. The restore // endpoint has always existed; without this, using it meant knowing the id of // something that had already vanished from the interface. func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) { trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID) if err != nil { s.internal(w, r, "list trashed projects", err) return } out := make([]trashedProjectJSON, 0, len(trashed)) for _, t := range trashed { out = append(out, trashedProjectJSON{ projectJSON: toProjectJSON(t.Project), DeletedAt: t.DeletedAt.Format(time.RFC3339), PurgeAfter: t.DeletedAt.Add(s.cfg.TrashRetention).Format(time.RFC3339), TaskCount: t.TaskCount, }) } writeJSON(w, http.StatusOK, map[string]any{"projects": out}) } func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") // Permission cannot be checked through ProjectRole here: a trashed project is // deliberately invisible to it. Ownership is what governs restoring. owner, err := s.db.ProjectOwner(r.Context(), projectID) if err != nil { s.storeError(w, r, "project owner", err) return } if owner != userFrom(r.Context()).ID { writeError(w, http.StatusNotFound, CodeNotFound, "not found") return } if err := s.db.RestoreProject(r.Context(), projectID); err != nil { s.storeError(w, r, "restore project", err) return } w.WriteHeader(http.StatusNoContent) } // colorFieldError names the closed set rather than listing it. The list is ten // long and lives in three places already; a caller that got it wrong needs to be // pointed at the documentation, not handed a copy that will one day be stale. const colorFieldError = "must be one of the colours in the palette — see the Color schema" func validateProject(name string, viewMode, color *string) map[string]string { fields := map[string]string{} switch { case name == "": fields["name"] = "required" case len([]rune(name)) > 200: fields["name"] = "must be 200 characters or fewer" } if viewMode != nil { switch *viewMode { case "list", "board", "calendar": default: fields["view_mode"] = "must be list, board or calendar" } } // Refused rather than stored and quietly painted as the default, which is what // an unknown name would do — and which looks like a colour that did not save. if color != nil && !store.ValidColor(*color) { fields["color"] = colorFieldError } return fields } // --- sections ----------------------------------------------------------------- type sectionJSON struct { ID string `json:"id"` ProjectID string `json:"project_id"` Name string `json:"name"` SortOrder float64 `json:"sort_order"` } func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) { sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID")) if err != nil { s.internal(w, r, "list sections", err) return } out := make([]sectionJSON, 0, len(sections)) for _, sec := range sections { out = append(out, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder}) } writeJSON(w, http.StatusOK, map[string]any{"sections": out}) } type sectionRequest struct { Name *string `json:"name"` SortOrder *float64 `json:"sort_order"` } func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) { var req sectionRequest if err := decodeJSON(w, r, &req); err != nil { return } if req.Name == nil || strings.TrimSpace(*req.Name) == "" { writeFieldErrors(w, map[string]string{"name": "required"}) return } sec := &store.Section{ProjectID: chi.URLParam(r, "projectID"), Name: strings.TrimSpace(*req.Name)} if req.SortOrder != nil { sec.SortOrder = *req.SortOrder } if err := s.db.CreateSection(r.Context(), sec); err != nil { s.internal(w, r, "create section", err) return } s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name}) writeJSON(w, http.StatusCreated, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder}) } // handleReorderSections sets the order of the sections inside one project. // // Under /projects/{projectID}/ rather than beside the other section routes, which // hang off a section id: the thing being changed here is the project's list, not // any one section in it. That also puts it behind the same project permission // check as everything else in this subtree, instead of resolving a section to its // project one id at a time and hoping they all named the same one. func (s *Server) handleReorderSections(w http.ResponseWriter, r *http.Request) { var req reorderProjectsRequest if err := decodeJSON(w, r, &req); err != nil { return } // The same ceiling as the projects list, and a bound on the transaction rather // than an opinion about how many sections a project may have. if len(req.IDs) > 500 { writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"}) return } projectID := chi.URLParam(r, "projectID") if err := s.db.ReorderSections(r.Context(), projectID, req.IDs); err != nil { s.internal(w, r, "reorder sections", err) return } s.activity(r, projectID, "", "section.updated", nil) w.WriteHeader(http.StatusNoContent) } // sectionAccess resolves a section to its project and checks the caller may edit // it. Sections have no permissions of their own — they belong to a project, and // that is where access is decided. func (s *Server) sectionAccess(w http.ResponseWriter, r *http.Request, min store.Role) (string, string, bool) { sectionID := chi.URLParam(r, "sectionID") projectID, err := s.db.SectionProject(r.Context(), sectionID) if err != nil { s.storeError(w, r, "section project", err) return "", "", false } if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil { writeError(w, http.StatusNotFound, CodeNotFound, "not found") return "", "", false } return sectionID, projectID, true } func (s *Server) handleUpdateSection(w http.ResponseWriter, r *http.Request) { var req sectionRequest if err := decodeJSON(w, r, &req); err != nil { return } sectionID, projectID, ok := s.sectionAccess(w, r, store.RoleEditor) if !ok { return } if req.Name != nil { trimmed := strings.TrimSpace(*req.Name) if trimmed == "" { writeFieldErrors(w, map[string]string{"name": "required"}) return } req.Name = &trimmed } if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{ Name: req.Name, SortOrder: req.SortOrder, }); err != nil { s.storeError(w, r, "update section", err) return } s.activity(r, projectID, "", "section.updated", nil) w.WriteHeader(http.StatusNoContent) } func (s *Server) handleDeleteSection(w http.ResponseWriter, r *http.Request) { sectionID, projectID, ok := s.sectionAccess(w, r, store.RoleEditor) if !ok { return } if err := s.db.DeleteSection(r.Context(), sectionID); err != nil { s.storeError(w, r, "delete section", err) return } s.activity(r, projectID, "", "section.deleted", nil) w.WriteHeader(http.StatusNoContent) } // --- members ------------------------------------------------------------------ type memberJSON struct { UserID string `json:"user_id"` Email string `json:"email"` Name string `json:"name"` AvatarColor string `json:"avatar_color"` Role string `json:"role"` } // projectInviteJSON er en invitation, der venter på en konto. Der er intet navn og // ingen farve at vise: adressen er alt, hvad der findes om personen, indtil de // dukker op. type projectInviteJSON struct { ID string `json:"id"` Email string `json:"email"` Role string `json:"role"` } // handleListMembers is everything the share panel needs, in one answer: who is on // the project, who can be added, and who has been invited and not arrived. // // Kandidaterne kommer med her frem for fra et endepunkt for sig, så panelet åbner // færdigt i ét kald — samme form som notens delingspanel. Og de er nødvendige: // indtil nu kunne et projekt kun deles ved at skrive en e-mailadresse, hvilket // betyder, at man skal kende adressen på en kollega, man kender navnet på. // // De inviterede står ved siden af medlemmerne, fordi de ellers hverken er i den // ene liste eller den anden — de har ingen konto at være medlem med, og de er // derfor også fraværende fra kandidaterne. Uden dem ser en invitation sendt i går // ud som om den aldrig blev sendt, og så bliver den sendt igen. func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") members, err := s.db.ListMembers(r.Context(), projectID) if err != nil { s.internal(w, r, "list members", err) return } out := make([]memberJSON, 0, len(members)) already := map[string]bool{} for _, m := range members { out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)}) already[m.UserID] = true } project, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID) if err != nil { s.storeError(w, r, "get project", err) return } already[project.OwnerID] = true people, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID) if err != nil { s.internal(w, r, "share candidates", err) return } candidates := make([]personJSON, 0, len(people)) for _, p := range people { if already[p.ID] { continue } candidates = append(candidates, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor}) } invites, err := s.db.ListProjectInvites(r.Context(), projectID) if err != nil { s.internal(w, r, "list project invites", err) return } pending := make([]projectInviteJSON, 0, len(invites)) for _, i := range invites { pending = append(pending, projectInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)}) } writeJSON(w, http.StatusOK, map[string]any{ "members": out, "candidates": candidates, "pending": pending, }) } // handleDeleteProjectInvite withdraws an invitation that has not been taken up. // // Uden den er den ventende liste kun til at se på, og en invitation sendt til en // forkert adresse kan ikke tages tilbage — den bliver bare liggende, til den // udløber, med et link, der virker hele tiden. func (s *Server) handleDeleteProjectInvite(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") err := s.db.DeleteProjectInvite(r.Context(), projectID, chi.URLParam(r, "inviteID")) if err != nil { s.storeError(w, r, "delete project invite", err) return } w.WriteHeader(http.StatusNoContent) } type inviteRequest struct { // UserID er den, man har peget på i listen. Email er den, man kender adressen // på — og den er også den, der virker, når der ingen konto er endnu. UserID string `json:"user_id"` Email string `json:"email"` Role string `json:"role"` } type inviteResponse struct { // Link is returned so the UI can offer to copy it. On an instance with no // mail server that is the only way the invite reaches anybody, and even with // one it saves waiting on delivery. Link string `json:"link"` Emailed bool `json:"emailed"` } // handleInvite shares a project with somebody. // // Der er to måder at sige, hvem det er — samme to som en note deles med. `user_id` // er den, man har peget på i listen, og den er grunden til at det her blev skrevet // om: indtil nu kunne et projekt kun deles ved at stave en e-mailadresse, altså // ved at kende adressen på en kollega, man kender navnet på. `email` er stadig // vejen til dem, der ikke er her endnu, og den bliver til et link, der opretter // kontoen og medlemskabet på én gang. func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) { var req inviteRequest if err := decodeJSON(w, r, &req); err != nil { return } projectID := chi.URLParam(r, "projectID") inviter := userFrom(r.Context()) role := store.Role(req.Role) if role == "" { role = store.RoleEditor } if !role.Valid() || role == store.RoleOwner { writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"}) return } email := store.NormalizeEmail(req.Email) if req.UserID == "" && email == "" { writeFieldErrors(w, map[string]string{"user_id": "required"}) return } if req.UserID == "" && !strings.Contains(email, "@") { writeFieldErrors(w, map[string]string{"email": "must be an email address"}) return } project, err := s.db.GetProject(r.Context(), projectID, inviter.ID) if err != nil { s.storeError(w, r, "get project", err) return } // En adresse, der viser sig at høre til en konto, ER den konto: at sende dem // gennem en oprettelsesside, de ikke kan komme igennem, ville være en // blindgyde. Derfor slås adressen op først, og de to veje mødes her. if req.UserID == "" { if existing, err := s.db.UserByEmail(r.Context(), email); err == nil { req.UserID = existing.ID } } if req.UserID != "" { if req.UserID == project.OwnerID { writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project") return } // Slået op frem for taget på ordet: et id, der ikke findes, ville ellers // blive til en medlemsrække, der peger på ingen — og panelet ville vise en // tom plads, ingen kan fjerne. person, err := s.db.PersonByID(r.Context(), req.UserID) if err != nil { writeError(w, http.StatusNotFound, CodeNotFound, "no such person") return } if err := s.db.AddMember(r.Context(), projectID, person.ID, role); err != nil { s.internal(w, r, "add member", err) return } s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)}) // Og de får det at vide. Før stod projektet bare i sidebjælken næste gang, // de kiggede — uden at noget sagde hvornår eller fra hvem. s.notifyProjectShared(r, project, person.ID) writeJSON(w, http.StatusOK, map[string]any{ "added": true, // Personen sendes med retur, fordi fladen kan have delt med en adresse // og derfor ikke selv ved, hvem den ramte. "user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor}, "role": string(role), }) return } // Et andet link til den samme indbakke hjælper ingen: det første virker // stadig, og nu er der to at holde styr på og to at trække tilbage. if pending, err := s.db.PendingProjectInvite(r.Context(), projectID, email); err == nil && pending { writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited") return } token, _, err := s.db.CreateInvite(r.Context(), email, projectID, role, inviter.ID, s.cfg.InviteTTL) if err != nil { s.internal(w, r, "create invite", err) return } link := s.cfg.BaseURL + "/invite?token=" + token emailed := false if s.mail.Configured() { if err := s.mail.SendInvite(r.Context(), email, inviter.Name, project.Name, link, s.cfg.InviteTTL); err != nil { s.log.Error("send invite", "err", err, "to", email) } else { emailed = true } } s.activity(r, projectID, "", "member.invited", map[string]any{"email": email, "role": string(role)}) writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed}) } type memberRoleRequest struct { Role string `json:"role"` } // handleSetMemberRole changes an existing member between editor and viewer. // // Until now the only way to correct an invite sent with the wrong role was to // remove the person and invite them again — which also unassigns everything they // were responsible for, because removing a member clears their tasks' assignee. // Fixing a dropdown should not cost somebody their work. func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) { var req memberRoleRequest if err := decodeJSON(w, r, &req); err != nil { return } projectID := chi.URLParam(r, "projectID") userID := chi.URLParam(r, "userID") role := store.Role(req.Role) if !role.Valid() || role == store.RoleOwner { writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"}) return } // The owner has no project_members row at all — ownership lives on the project // — so this would report "not a member", which is true and completely // unhelpful. Say the real reason instead. owner, err := s.db.ProjectOwner(r.Context(), projectID) if err != nil { s.storeError(w, r, "project owner", err) return } if userID == owner { writeError(w, http.StatusConflict, CodeConflict, "the owner's standing cannot be changed; ownership is transferred, not granted") return } if err := s.db.SetMemberRole(r.Context(), projectID, userID, role); err != nil { s.storeError(w, r, "set member role", err) return } s.activity(r, projectID, "", "member.role_changed", map[string]any{"user_id": userID, "role": string(role)}) w.WriteHeader(http.StatusNoContent) } // handleRemoveMember takes somebody off a project — the owner removing anybody, or // anybody removing themselves. // // Den anden halvdel er ny, og den er grunden til at ruten er flyttet ud af // ejer-gruppen. Man blev lukket ind i et projekt uden at blive spurgt, og kunne så // ikke komme ud igen: kun ejeren kunne fjerne en, også en selv. To af de tre ting // kan man leve med; alle tre er det, der gør en deling til noget, der sker for en. // // Ejeren kan stadig ikke gå fra sit eget projekt. Det er ikke en udgang, det er en // overdragelse, og den findes ikke endnu. func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") userID := chi.URLParam(r, "userID") me := userFrom(r.Context()) owner, err := s.db.ProjectOwner(r.Context(), projectID) if err != nil { s.storeError(w, r, "project owner", err) return } // 404 og ikke 403: at sige "det må du ikke" til en, der peger på en anden // persons medlemskab, bekræfter at medlemskabet findes. if me.ID != owner && userID != me.ID { writeError(w, http.StatusNotFound, CodeNotFound, "no such member") return } if userID == owner { writeError(w, http.StatusConflict, CodeConflict, "the owner cannot be removed from their own project") return } if err := s.db.RemoveMember(r.Context(), projectID, userID); err != nil { s.storeError(w, r, "remove member", err) return } s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID}) w.WriteHeader(http.StatusNoContent) } // storeError maps a store error onto a status. // // Everything a caller is not allowed to see is reported as 404 rather than 403: a // 403 confirms the thing exists, which is exactly what somebody probing ids wants // to learn. func (s *Server) storeError(w http.ResponseWriter, r *http.Request, what string, err error) { switch { case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess): writeError(w, http.StatusNotFound, CodeNotFound, "not found") case errors.Is(err, store.ErrEmailInUse): writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account") default: s.internal(w, r, what, err) } }