Phase 4: applications + SASL (sasldb2) + sender_login_maps
Adds application accounts bound to domains: a SASL login/password in sasldb2, a per-application address mode (wildcard @domain or an explicit list), and matching smtpd_sender_login_maps bindings — with create, list, edit-mode, delete and password regeneration (spec 4.1, 5.1, 7.2.5-9). Generated passwords are shown exactly once and never stored in plaintext (7.6.1). - internal/store/applications.go: transactional CRUD; globally unique login; ListBindings (address->login) as the map source; logins-by- domain for pre-cascade SASL cleanup. - internal/app: saslpasswd2 wrapper (password via stdin, login as a whitelisted argv element, no shell — 7.6.3); strong base64url password; address validation that enforces domain ownership before any config write (7.6.2); service orchestrating store + sasldb2 + map with full rollback on partial failure. - internal/postfix: sender_login_maps regenerated as a pure function of the registry (many-to-one logins merged per address), atomic write, injection backstop (7.6.4). - Postfix reload, corrected: `postfix start-fg` forks a separate master, so signalling the supervised process never reaches it. Reload now runs the canonical `postfix reload` via a one-shot supervisord program the unprivileged panel triggers over the group control socket. Verified in mail.log. - domain.Service.Delete purges the domain's SASL accounts, then cascades, then rebuilds the sender map and reloads; manual reload now covers both OpenDKIM and Postfix. - web: application management in the domain page, one-time credential shown inline; postfix joins the selfpost group and entrypoint normalises /data/sasl and /data/postfix (setgid, group-readable) with self-heal. Verified on the dev server: gofmt/vet/test green, image builds, and a container e2e covers the full application lifecycle, domain-delete cascade, restart persistence, and a real postfix reload. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// newCred carries a freshly generated login/password to the template so it can
|
||||
// be shown exactly once (spec 7.6.1). It is never read back from storage.
|
||||
type newCred struct {
|
||||
Login string
|
||||
Password string
|
||||
}
|
||||
|
||||
// detailView holds the one-shot, request-specific extras layered on top of a
|
||||
// domain's persistent state when rendering its page: an application-form error,
|
||||
// the values to repopulate that form, and any just-issued credential to show
|
||||
// once.
|
||||
type detailView struct {
|
||||
FormErr string
|
||||
FormLogin string
|
||||
FormMode string
|
||||
FormAddrs string
|
||||
NewCred *newCred
|
||||
}
|
||||
|
||||
// handleDomainDetail shows a single domain: its DKIM DNS record (spec 7.2.10)
|
||||
// and its applications with the controls to add, edit, delete and re-issue
|
||||
// credentials (spec 7.2.5-9).
|
||||
func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := s.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
|
||||
}
|
||||
|
||||
// renderDomainDetail renders the domain page. view supplies request-specific
|
||||
// extras (form error/values, a one-time credential); everything else is loaded
|
||||
// fresh from the stores so the page always reflects committed state.
|
||||
func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
|
||||
record, err := s.domains.DKIMRecord(d)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
apps, err := s.apps.List(d.ID)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: list applications: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, status, "domain_detail", map[string]any{
|
||||
"Title": "SelfPost — " + d.Name,
|
||||
"User": currentUser(r),
|
||||
"Domain": d,
|
||||
"Record": record,
|
||||
"Apps": apps,
|
||||
"Error": view.FormErr,
|
||||
"FormLogin": view.FormLogin,
|
||||
"FormMode": view.FormMode,
|
||||
"FormAddrs": view.FormAddrs,
|
||||
"NewCred": view.NewCred,
|
||||
"Flash": detailFlash(r),
|
||||
"Wildcard": store.AddressModeWildcard,
|
||||
"List": store.AddressModeList,
|
||||
})
|
||||
}
|
||||
|
||||
// detailFlash maps a fixed redirect flag to a fixed message, so status text
|
||||
// after a redirect is never attacker-influenced.
|
||||
func detailFlash(r *http.Request) string {
|
||||
switch {
|
||||
case r.URL.Query().Get("appdeleted") != "":
|
||||
return "Application deleted."
|
||||
case r.URL.Query().Get("modeupdated") != "":
|
||||
return "Application address mode updated."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// handleAddApplication creates an application on a domain and renders the page
|
||||
// back with the generated password shown once (spec 7.2.5, 7.6.1). Because the
|
||||
// password cannot be recovered later, this deliberately renders inline rather
|
||||
// than redirecting.
|
||||
func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := s.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d,
|
||||
detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard})
|
||||
return
|
||||
}
|
||||
login := strings.TrimSpace(r.PostFormValue("login"))
|
||||
mode := r.PostFormValue("mode")
|
||||
addrs := splitAddresses(r.PostFormValue("addresses"))
|
||||
|
||||
repopulate := detailView{
|
||||
FormLogin: login,
|
||||
FormMode: mode,
|
||||
FormAddrs: r.PostFormValue("addresses"),
|
||||
}
|
||||
|
||||
a, password, err := s.apps.Create(d.ID, login, mode, addrs)
|
||||
if err != nil {
|
||||
repopulate.FormErr = applicationErrorMessage(err)
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, store.ErrLoginExists) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
s.renderDomainDetail(w, r, status, d, repopulate)
|
||||
return
|
||||
}
|
||||
s.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
|
||||
FormMode: store.AddressModeWildcard,
|
||||
NewCred: &newCred{Login: a.Login, Password: password},
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateAppMode switches an application's address mode / list (spec 7.2.7).
|
||||
func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := s.lookupApplication(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
mode := r.PostFormValue("mode")
|
||||
addrs := splitAddresses(r.PostFormValue("addresses"))
|
||||
|
||||
if err := s.apps.UpdateMode(a.ID, mode, addrs); err != nil {
|
||||
d, derr := s.domains.Get(a.DomainID)
|
||||
if derr != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||
FormErr: fmt.Sprintf("Could not update %s: %s", a.Login, applicationErrorMessage(err)),
|
||||
FormMode: store.AddressModeWildcard,
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleRegenPassword issues a new password for an application and shows it once
|
||||
// (spec 7.2.9, 7.6.1). Rendered inline, like creation, so the password is visible.
|
||||
func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := s.lookupApplication(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, err := s.domains.Get(a.DomainID)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
password, err := s.apps.RegeneratePassword(a.ID)
|
||||
if err != nil {
|
||||
logf("panel: regenerate password for application %d: %v", a.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.renderDomainDetail(w, r, http.StatusOK, d, detailView{
|
||||
FormMode: store.AddressModeWildcard,
|
||||
NewCred: &newCred{Login: a.Login, Password: password},
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteApplication removes an application and returns to its domain page
|
||||
// (spec 7.2.8).
|
||||
func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := s.lookupApplication(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.apps.Delete(a.ID); err != nil {
|
||||
logf("panel: delete application %d: %v", a.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?appdeleted=1", a.DomainID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// lookupApplication resolves the {aid} path value to an application, writing a
|
||||
// 404 for a bad id or missing application.
|
||||
func (s *Server) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue("aid"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return store.Application{}, false
|
||||
}
|
||||
a, err := s.apps.Get(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrApplicationNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return store.Application{}, false
|
||||
}
|
||||
logf("panel: get application %d: %v", id, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return store.Application{}, false
|
||||
}
|
||||
return a, true
|
||||
}
|
||||
|
||||
// splitAddresses turns the textarea/field input (addresses separated by
|
||||
// newlines, commas or whitespace) into a raw slice. Normalisation and
|
||||
// validation happen in the app service (spec 7.6.2).
|
||||
func splitAddresses(s string) []string {
|
||||
return strings.FieldsFunc(s, func(r rune) bool {
|
||||
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
|
||||
})
|
||||
}
|
||||
|
||||
// applicationErrorMessage turns a service error into a user-facing message,
|
||||
// passing through the validation errors (which are safe, fixed strings) and
|
||||
// masking anything unexpected.
|
||||
func applicationErrorMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrLoginExists):
|
||||
return "That login is already in use. Choose another."
|
||||
case errors.Is(err, store.ErrDomainNotFound), errors.Is(err, store.ErrApplicationNotFound):
|
||||
return "The item no longer exists."
|
||||
default:
|
||||
// Validation errors from the app service are safe to surface verbatim;
|
||||
// they describe what the admin must fix (login/address rules).
|
||||
return err.Error()
|
||||
}
|
||||
}
|
||||
@@ -78,26 +78,6 @@ func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleDomainDetail shows a single domain and its DKIM DNS record (spec 7.2.10).
|
||||
func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := s.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
record, err := s.domains.DKIMRecord(d)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "domain_detail", map[string]any{
|
||||
"Title": "SelfPost — " + d.Name,
|
||||
"User": currentUser(r),
|
||||
"Domain": d,
|
||||
"Record": record,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteConfirm shows the cascade warning before a domain is removed: the
|
||||
// panel must explicitly state that all bound applications go with it (spec 7.2.4).
|
||||
func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -131,11 +111,17 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/?deleted=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleReload re-applies the OpenDKIM configuration on demand (spec 7.2.12).
|
||||
// The Postfix side of the reload button lands in Phase 5.
|
||||
// handleReload re-applies both the OpenDKIM configuration and the Postfix
|
||||
// sender map on demand (spec 7.2.12). Each Resync regenerates its files from the
|
||||
// database and reloads its daemon, so the button doubles as a drift-recovery.
|
||||
func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.domains.Resync(); err != nil {
|
||||
logf("panel: manual reload: %v", err)
|
||||
logf("panel: manual reload (opendkim): %v", err)
|
||||
http.Error(w, "reload failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.apps.Resync(); err != nil {
|
||||
logf("panel: manual reload (postfix): %v", err)
|
||||
http.Error(w, "reload failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -11,6 +11,20 @@
|
||||
|
||||
<a class="back" href="/">← All domains</a>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
{{if .NewCred}}
|
||||
<div class="card credential">
|
||||
<h2>New application password</h2>
|
||||
<p class="muted">This password is shown <strong>once only</strong> and is not
|
||||
stored. Copy it now — if it is lost, regenerate a new one.</p>
|
||||
<label>Login</label>
|
||||
<span class="code">{{.NewCred.Login}}</span>
|
||||
<label>Password</label>
|
||||
<span class="code">{{.NewCred.Password}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>DKIM DNS record</h2>
|
||||
<p class="muted">Publish this TXT record in the DNS for <strong>{{.Domain.Name}}</strong>.
|
||||
@@ -31,8 +45,81 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>Applications</h2>
|
||||
<p class="muted">{{.Domain.AppCount}} application(s) bound to this domain.
|
||||
Creating and managing applications arrives in the next phase.</p>
|
||||
<p class="muted">Each application is a SASL login/password an app or script
|
||||
uses to send mail as this domain. A login may send from any address of the
|
||||
domain (<em>wildcard</em>) or only from a fixed list of addresses.</p>
|
||||
|
||||
{{if .Apps}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Login</th><th>Mode</th><th>Addresses</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Apps}}
|
||||
<tr>
|
||||
<td class="code">{{.Login}}</td>
|
||||
<td>{{if eq .AddressMode $.Wildcard}}Any address (@{{$.Domain.Name}}){{else}}List{{end}}</td>
|
||||
<td class="muted">
|
||||
{{if eq .AddressMode $.Wildcard}}*@{{$.Domain.Name}}{{else}}
|
||||
{{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<details>
|
||||
<summary>Edit mode</summary>
|
||||
<form method="post" action="/applications/{{.ID}}/mode">
|
||||
<label>Address mode</label>
|
||||
<select name="mode">
|
||||
<option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||
<option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option>
|
||||
</select>
|
||||
<label>Addresses (for list mode; one per line or comma-separated)</label>
|
||||
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}}
|
||||
{{end}}{{$a}}{{end}}</textarea>
|
||||
<button type="submit">Save mode</button>
|
||||
</form>
|
||||
</details>
|
||||
<form class="inline" method="post" action="/applications/{{.ID}}/password"
|
||||
onsubmit="return confirm('Regenerate the password for {{.Login}}? The current password stops working immediately.')">
|
||||
<button type="submit">New password</button>
|
||||
</form>
|
||||
<form class="inline" method="post" action="/applications/{{.ID}}/delete"
|
||||
onsubmit="return confirm('Delete application {{.Login}}? Its credentials stop working immediately.')">
|
||||
<button type="submit" class="danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No applications yet. Create one below.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Add an application</h2>
|
||||
<form method="post" action="/domains/{{.Domain.ID}}/applications">
|
||||
<label for="login">Login</label>
|
||||
<input id="login" name="login" type="text" placeholder="prod-server"
|
||||
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
value="{{.FormLogin}}" required>
|
||||
|
||||
<label for="mode">Address mode</label>
|
||||
<select id="mode" name="mode">
|
||||
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
|
||||
</select>
|
||||
|
||||
<label for="addresses">Addresses (for list mode; one per line or comma-separated)</label>
|
||||
<textarea id="addresses" name="addresses" rows="3"
|
||||
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
|
||||
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<button type="submit">Create application</button>
|
||||
</form>
|
||||
<p class="muted">A strong password is generated and shown once. The login must
|
||||
be unique across all domains and may contain letters, digits, '.', '-' and '_'.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
@@ -56,6 +56,24 @@
|
||||
@media (prefers-color-scheme: dark) { .code { background: #14171a !important; border-color: #2b3138 !important; } }
|
||||
h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
|
||||
.back { display: inline-block; margin-bottom: 1rem; }
|
||||
select, textarea {
|
||||
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
|
||||
border: 1px solid #cfd4da; border-radius: 6px; background: #fff; color: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
select, textarea { background: #14171a !important; color: inherit !important; border-color: #2b3138 !important; }
|
||||
}
|
||||
button.danger { background: #b42318; }
|
||||
button.danger:hover { background: #912018; }
|
||||
form.inline button.danger { background: none; color: #b42318; }
|
||||
form.inline button.danger:hover { background: none; }
|
||||
td.actions form.inline, td.actions details { margin-left: 0.6rem; }
|
||||
details summary { cursor: pointer; color: #2563eb; font-weight: 600; }
|
||||
details form { margin-top: 0.6rem; }
|
||||
.credential { border-color: #f5c518; background: #fffbeb; }
|
||||
@media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+10
-2
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/app"
|
||||
"codeberg.org/mix/selfpost/internal/domain"
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
@@ -32,6 +33,7 @@ type Config struct {
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
domains *domain.Service
|
||||
apps *app.Service
|
||||
cfg Config
|
||||
tmpl *templates
|
||||
sessions *sessionStore
|
||||
@@ -43,8 +45,9 @@ type Server struct {
|
||||
|
||||
// New builds the panel server. setupTokenPath is where the current setup token
|
||||
// is mirrored on disk (spec 7.6.1); domains is the sending-domain service that
|
||||
// owns DKIM keys and the OpenDKIM tables (spec 6).
|
||||
func New(st *store.Store, domains *domain.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
||||
// owns DKIM keys and the OpenDKIM tables (spec 6); apps owns application SASL
|
||||
// accounts and the Postfix sender map (spec 5.1).
|
||||
func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -52,6 +55,7 @@ func New(st *store.Store, domains *domain.Service, cfg Config, setupTokenPath st
|
||||
s := &Server{
|
||||
store: st,
|
||||
domains: domains,
|
||||
apps: apps,
|
||||
cfg: cfg,
|
||||
tmpl: tmpl,
|
||||
sessions: newSessionStore(),
|
||||
@@ -97,6 +101,10 @@ func (s *Server) Handler() http.Handler {
|
||||
authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail)
|
||||
authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm)
|
||||
authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain)
|
||||
authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication)
|
||||
authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode)
|
||||
authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword)
|
||||
authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication)
|
||||
authed.HandleFunc("POST /reload", s.handleReload)
|
||||
mux.Handle("/", s.requireAuth(authed))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user