package web import ( "fmt" "net/url" "strings" ) // webRoutes is the single source of truth for browser-facing dynamic paths. // Parameter values are passed to webPath in the order in which they occur here. var webRoutes = map[string]string{ "home": "/", "healthcheck": "/healtcheck", "privacy": "/datenschutz", "login": "/login", "activate": "/activate", "logout": "/logout", "account": "/account", "account.profile": "/account/profile", "account.password": "/account/password", "account.email": "/account/email", "account.email-confirm": "/account/email-confirm", "account.session.delete": "/account/sessions/:sessionID/delete", "settings": "/settings", "admin": "/admin", "admin.user.invite": "/admin/user-invite", "admin.user.role": "/admin/users/:userID/role", "admin.user.delete": "/admin/users/:userID/delete", "admin.role.new": "/admin/roles/new", "admin.role.update": "/admin/roles/update", "admin.role.delete": "/admin/roles/delete", "admin.application-settings": "/admin/application-settings", "admin.test-mail": "/admin/test-mail", "admin.species-category.new": "/admin/species-category-new", "admin.species-category.edit": "/admin/species-category-edit/:categoryID", "admin.species-category.delete": "/admin/species-category-delete/:categoryID", "admin.task-priority.new": "/admin/task-priority-new", "admin.task-priority.edit": "/admin/task-priority-edit/:priorityID", "admin.task-priority.delete": "/admin/task-priority-delete/:priorityID", "gardens": "/gardens", "garden.new": "/gardens/new", "garden.edit": "/gardens/edit/:gardenID", "garden.delete": "/gardens/delete/:gardenID", "garden.dashboard": "/g/:gardenID", "garden.search": "/g/:gardenID/search", "garden.journal": "/g/:gardenID/journal", "garden.pinboard": "/g/:gardenID/pinboard", "garden.images": "/g/:gardenID/images", "garden.image.data": "/g/:gardenID/images/:imageID/data", "journal.new": "/g/:gardenID/journal/new", "journal.edit": "/g/:gardenID/journal/edit/:entryID", "journal.delete": "/g/:gardenID/journal/delete/:entryID", "journal.attachment": "/g/:gardenID/journal/attachments/:entryID/:attachmentID", "pinboard.new": "/g/:gardenID/pinboard/new", "pinboard.edit": "/g/:gardenID/pinboard/edit/:entryID", "pinboard.delete": "/g/:gardenID/pinboard/delete/:entryID", "pinboard.attachment": "/g/:gardenID/pinboard/attachments/:entryID/:attachmentID", "garden.members": "/g/:gardenID/members", "garden.invite.new": "/g/:gardenID/member-invite-new", "garden.member.role": "/g/:gardenID/members/:userID/role", "garden.member.delete": "/g/:gardenID/members/:userID/delete", "garden.member.transfer": "/g/:gardenID/members/:userID/transfer", "garden.invite.delete": "/g/:gardenID/member-invite-delete/:inviteID", "garden.role.update": "/g/:gardenID/roles/update", "garden.role.new": "/g/:gardenID/roles/new", "garden.role.delete": "/g/:gardenID/roles/delete", "invite": "/invite", "plants": "/g/:gardenID/plants", "plant.new": "/g/:gardenID/plants/new", "plant.edit": "/g/:gardenID/plants/edit/:plantID", "plant.delete": "/g/:gardenID/plants/delete/:plantID", "plant.status": "/g/:gardenID/plants/status/:plantID", "plant.template-opt-out": "/g/:gardenID/plant-template-opt-out/:plantID/:templateID", "plant.task-row": "/g/:gardenID/plant-task-row", "plant.template-tasks": "/g/:gardenID/plant-template-tasks", "plant.template-task": "/g/:gardenID/plant-template-task/:templateID", "plant.location.new": "/g/:gardenID/plant-location-new/:plantID", "plant.location.edit": "/g/:gardenID/plant-location-edit/:plantID/:assignmentID", "plant.location.delete": "/g/:gardenID/plant-location-delete/:plantID/:assignmentID", "locations": "/g/:gardenID/locations", "location.new": "/g/:gardenID/locations/new", "location.view": "/g/:gardenID/locations/view/:locationID", "location.edit": "/g/:gardenID/locations/edit/:locationID", "location.delete": "/g/:gardenID/locations/delete/:locationID", "species": "/g/:gardenID/species", "species.new": "/g/:gardenID/species/new", "species.edit": "/g/:gardenID/species/edit/:speciesID", "species.delete": "/g/:gardenID/species/delete/:speciesID", "species.care.new": "/g/:gardenID/species-care/:speciesID/new", "species.care.edit": "/g/:gardenID/species-care/:speciesID/edit/:instructionID", "species.care.delete": "/g/:gardenID/species-care/:speciesID/delete/:instructionID", "tasks": "/g/:gardenID/tasks", "tasks.calendar": "/g/:gardenID/tasks/calendar", "task.new": "/g/:gardenID/tasks/new", "task.edit": "/g/:gardenID/tasks/edit/:taskID", "task.complete": "/g/:gardenID/tasks/complete/:taskID", "task.reopen": "/g/:gardenID/tasks/reopen/:taskID", "task.delete": "/g/:gardenID/tasks/delete/:taskID", "task-template.new": "/g/:gardenID/task-templates/new", "task-template.edit": "/g/:gardenID/task-templates/edit/:templateID", "task-template.delete": "/g/:gardenID/task-templates/delete/:templateID", } func routePath(name string) string { pattern, ok := webRoutes[name] if !ok { panic("unknown web route: " + name) } return pattern } func webPath(name string, params ...any) string { parts := strings.Split(routePath(name), "/") paramIndex := 0 for i, part := range parts { if !strings.HasPrefix(part, ":") { continue } if paramIndex >= len(params) { panic(fmt.Sprintf("route %q needs more parameters", name)) } parts[i] = url.PathEscape(fmt.Sprint(params[paramIndex])) paramIndex++ } if paramIndex != len(params) { panic(fmt.Sprintf("route %q received too many parameters", name)) } return strings.Join(parts, "/") } func pathWithQuery(path string, pairs ...any) string { if len(pairs)%2 != 0 { panic("pathWithQuery requires key/value pairs") } values := url.Values{} for i := 0; i < len(pairs); i += 2 { values.Set(fmt.Sprint(pairs[i]), fmt.Sprint(pairs[i+1])) } if len(values) == 0 { return path } return path + "?" + values.Encode() } func safeReturnPath(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } target, err := url.Parse(value) if err != nil || target.IsAbs() || target.Host != "" || !strings.HasPrefix(target.Path, "/") || strings.HasPrefix(target.Path, "//") || strings.Contains(target.Path, `\`) { return "" } return target.RequestURI() }