feat(block-23): preserve restore identity and unify administration
CI / validate (pull_request) Successful in 26m32s

This commit is contained in:
2026-08-26 22:35:42 +02:00
parent 8d282fd63d
commit da0492c348
16 changed files with 221 additions and 127 deletions
+3 -5
View File
@@ -1,11 +1,9 @@
# V Rising RCON module # V Rising RCON module
This template-local adapter uses the documented Source RCON interface of the This template-local adapter uses the documented Source RCON interface of the
V Rising dedicated server. The official server documentation lists only V Rising dedicated server. It exposes bounded `announce` and `shutdown`
`announce` and `announcerestart`; the adapter currently exposes only bounded operations. Player listing, save, kick, ban and unban are intentionally not
connectivity/status until command behavior is validated end-to-end on a real advertised without real-server RCON validation.
server. It does not claim announcement, player, save, shutdown, kick, ban or
unban support.
Build from the repository root: Build from the repository root:
+4 -2
View File
@@ -17,7 +17,9 @@ compatibility:
manager_api: ">=1.0.0 <2.0.0" manager_api: ">=1.0.0 <2.0.0"
module_api: ">=1.0.0 <2.0.0" module_api: ">=1.0.0 <2.0.0"
capabilities: [] capabilities:
- announcement
- graceful_shutdown
permissions: permissions:
network: network:
@@ -49,4 +51,4 @@ configuration:
artifacts: artifacts:
wasm: module.wasm wasm: module.wasm
sha256: "d0b34c7724309e18f3e6e474886a6d4cf992cfaf9109415d20e46ff54fd9ab69" sha256: "bdafc0de4c517288c208bd0f1cb1d212858a54f9939a8e305621d32105f81f5c"
Binary file not shown.
+13 -1
View File
@@ -136,7 +136,7 @@ func authFailure(result authResult) *moduleError {
return rconFailure(errors.New(string(result))) return rconFailure(errors.New(string(result)))
} }
var capabilities = []string{"announcement"} var capabilities = []string{"announcement", "graceful_shutdown"}
//go:wasmexport initialize //go:wasmexport initialize
func initialize(_, _ uint32, outPtr, outCap uint32) int32 { func initialize(_, _ uint32, outPtr, outCap uint32) int32 {
@@ -194,5 +194,17 @@ func sendAnnouncement(inPtr, inLen, outPtr, outCap uint32) int32 {
} }
return result(outPtr, outCap, map[string]any{"accepted": failure == nil}, failure) return result(outPtr, outCap, map[string]any{"accepted": failure == nil}, failure)
} }
//go:wasmexport shutdown
func shutdown(_, _, outPtr, outCap uint32) int32 {
password, failure := credentials()
if failure == nil {
command := execute(tcp, password, "shutdown")
if command != "" {
failure = rconFailure(errors.New(string(command)))
}
}
return result(outPtr, outCap, map[string]any{"accepted": failure == nil}, failure)
}
func utf8Valid(value string) bool { return strings.ToValidUTF8(value, "") == value } func utf8Valid(value string) bool { return strings.ToValidUTF8(value, "") == value }
func main() {} func main() {}
+14
View File
@@ -82,6 +82,20 @@ func TestExecuteHandlesMultiPacketResponse(t *testing.T) {
} }
} }
func TestExecuteShutdownUsesOfficialCommand(t *testing.T) {
exchange := fakeRCON(t, func(request []byte) []byte {
packets, err := parseRCONPackets(request)
if err != nil || len(packets) != 2 || packets[1].body != "shutdown" {
t.Errorf("unexpected shutdown request: %#v %v", packets, err)
return nil
}
return append(packetBytes(t, 1, rconAuthReply, ""), packetBytes(t, 2, rconCommandOut, "accepted")...)
})
if result := execute(exchange, "secret", "shutdown"); result != "" {
t.Fatal(result)
}
}
func TestRCONRejectsMalformedAndOversizedPackets(t *testing.T) { func TestRCONRejectsMalformedAndOversizedPackets(t *testing.T) {
for _, raw := range [][]byte{{1, 2}, make([]byte, maxRCONResponse+1), packetBytes(t, 1, rconAuthReply, "x")[:12]} { for _, raw := range [][]byte{{1, 2}, make([]byte, maxRCONResponse+1), packetBytes(t, 1, rconAuthReply, "x")[:12]} {
if _, err := parseRCONPackets(raw); !errors.Is(err, errRCONMalformed) { if _, err := parseRCONPackets(raw); !errors.Is(err, errRCONMalformed) {
+3 -1
View File
@@ -71,7 +71,9 @@ container:
publish: false publish: false
required: true required: true
capabilities: [] capabilities:
- announcement
- graceful_shutdown
storage: storage:
mounts: mounts:
+1 -1
View File
@@ -84,7 +84,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Session policy is stored in the existing `system_settings` table. `created_at` remains the absolute lifetime anchor, `last_seen_at` tracks inactivity with writes no more often than every five minutes, and logout/deactivation/expiry revoke server-side session rows. - Session policy is stored in the existing `system_settings` table. `created_at` remains the absolute lifetime anchor, `last_seen_at` tracks inactivity with writes no more often than every five minutes, and logout/deactivation/expiry revoke server-side session rows.
- The local template directory (`/var/lib/dogama/templates`, under the application data bind mount) is the catalog source of truth for administrator-owned customizations. Bundled templates are copied only when their destination files are absent; after every local scan, the current bundled immutable snapshots are synchronized into SQLite and selected for new deployments while older snapshots remain available for existing instances, audit and diagnostics. - The local template directory (`/var/lib/dogama/templates`, under the application data bind mount) is the catalog source of truth for administrator-owned customizations. Bundled templates are copied only when their destination files are absent; after every local scan, the current bundled immutable snapshots are synchronized into SQLite and selected for new deployments while older snapshots remain available for existing instances, audit and diagnostics.
- Administrators can persist bounded HTTP(S) template-repository definitions for future use. They are configuration only: remote retrieval, authentication, synchronization and automatic updates are deliberately unavailable, and Catalog Scan remains local-only. - Administrators can persist bounded HTTP(S) template-repository definitions for future use. They are configuration only: remote retrieval, authentication, synchronization and automatic updates are deliberately unavailable, and Catalog Scan remains local-only.
- V Rising is the first template-scoped TCP RCON module. Its manifest currently declares only verified connectivity/status; command operations are not advertised until validated end-to-end against a real server. Go/WASI reactor modules use `-buildmode=c-shared` and initialize through `_initialize`; the V Rising success path uses concrete results to avoid Go 1.26 WASI reactor nil-interface traps. Wazero is pinned at v1.12.0. - V Rising is the first template-scoped TCP RCON module. Its manifest declares the bounded `announcement` and `graceful_shutdown` operations, implemented as `announce <message>` and `shutdown`; players/save/kick/ban/unban remain unadvertised until a real server confirms RCON support. Go/WASI reactor modules use `-buildmode=c-shared` and initialize through `_initialize`; the V Rising success path uses concrete results to avoid Go 1.26 WASI reactor nil-interface traps. Wazero is pinned at v1.12.0.
- Module TCP access is instance-scoped and template-bound: the guest supplies no destination, only bounded bytes; the host pins the instance network address and declared integration port, enforces deadlines and response limits, and rejects arbitrary/unsafe destinations. - Module TCP access is instance-scoped and template-bound: the guest supplies no destination, only bounded bytes; the host pins the instance network address and declared integration port, enforces deadlines and response limits, and rejects arbitrary/unsafe destinations.
- Published port selection is generic: templates own container ports and protocols, while administrators choose host ports at deployment; TCP and UDP may reuse a host number because Docker treats those bindings independently. - Published port selection is generic: templates own container ports and protocols, while administrators choose host ports at deployment; TCP and UDP may reuse a host number because Docker treats those bindings independently.
+58
View File
@@ -0,0 +1,58 @@
package backup
import (
"os"
"path/filepath"
"syscall"
"testing"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
)
func TestApplyRuntimeOwnershipUsesTargetIdentity(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("changing ownership to a distinct UID requires root")
}
root := t.TempDir()
file := filepath.Join(root, "save.dat")
if err := os.WriteFile(file, []byte("save"), 0o640); err != nil {
t.Fatal(err)
}
preview := instance.Preview{DockerUser: instance.DockerUser{Mode: instance.DockerUserDoGaMa}, DockerUserValue: "1234:1234"}
if err := applyRuntimeOwnership(root, preview); err != nil {
t.Fatal(err)
}
for _, name := range []string{root, file} {
info, err := os.Stat(name)
if err != nil {
t.Fatal(err)
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || uint32(stat.Uid) != 1234 || uint32(stat.Gid) != 1234 {
t.Fatalf("%s ownership = %v", name, info.Sys())
}
}
}
func TestApplyRuntimeOwnershipDoesNotOverrideImageUser(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "save.dat")
if err := os.WriteFile(file, []byte("save"), 0o640); err != nil {
t.Fatal(err)
}
before, err := os.Stat(file)
if err != nil {
t.Fatal(err)
}
preview := instance.Preview{DockerUser: instance.DockerUser{Mode: instance.DockerUserImage}, DockerUserValue: "1234:1234"}
if err := applyRuntimeOwnership(root, preview); err != nil {
t.Fatal(err)
}
after, err := os.Stat(file)
if err != nil {
t.Fatal(err)
}
if before.Sys().(*syscall.Stat_t).Uid != after.Sys().(*syscall.Stat_t).Uid || before.Sys().(*syscall.Stat_t).Gid != after.Sys().(*syscall.Stat_t).Gid {
t.Fatal("image-defined ownership was changed")
}
}
+36
View File
@@ -17,6 +17,7 @@ import (
"path" "path"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -615,6 +616,10 @@ func (s *Service) restoreArchive(archive string, current instance.StoredInstance
rollback() rollback()
return ErrIntegrity return ErrIntegrity
} }
if err := applyRuntimeOwnership(staged, current.Preview); err != nil {
rollback()
return err
}
previous := live + ".dogama-previous-" + manifest.BackupID previous := live + ".dogama-previous-" + manifest.BackupID
if err := os.Rename(live, previous); err != nil { if err := os.Rename(live, previous); err != nil {
rollback() rollback()
@@ -635,6 +640,37 @@ func (s *Service) restoreArchive(archive string, current instance.StoredInstance
return nil return nil
} }
// applyRuntimeOwnership deliberately ignores tar ownership metadata. The
// effective Docker user is part of the target instance preview and is the
// only ownership source accepted for a DoGaMa-managed container. Image-owned
// templates retain ownership chosen by their image entrypoint.
func applyRuntimeOwnership(root string, preview instance.Preview) error {
if preview.DockerUser.Mode == instance.DockerUserImage {
return nil
}
parts := strings.Split(preview.DockerUserValue, ":")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return ErrInvalidState
}
uid, err := strconv.ParseUint(parts[0], 10, 32)
if err != nil {
return ErrInvalidState
}
gid, err := strconv.ParseUint(parts[1], 10, 32)
if err != nil {
return ErrInvalidState
}
return filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if info.Mode()&os.ModeSymlink != 0 {
return ErrUnsafePath
}
return os.Chown(path, int(uid), int(gid))
})
}
func (s *Service) applyRetention(ctx context.Context, instanceID string, count int) error { func (s *Service) applyRetention(ctx context.Context, instanceID string, count int) error {
candidates, err := s.repository.RetentionCandidates(ctx, instanceID, count) candidates, err := s.repository.RetentionCandidates(ctx, instanceID, count)
if err != nil { if err != nil {
+7
View File
@@ -2353,6 +2353,13 @@ func (s *server) settingsPage(w http.ResponseWriter, r *http.Request) {
if s.audit != nil { if s.audit != nil {
data.AuditPolicy, _ = s.audit.Policy(r.Context()) data.AuditPolicy, _ = s.audit.Policy(r.Context())
} }
if err := s.populateAdminUsers(r, &data); err != nil {
s.problem(w, http.StatusInternalServerError, "The users are unavailable.")
return
}
if s.repository != nil {
data.TemplateRepositories, _ = s.repository.ListTemplateRepositories(r.Context())
}
if settings, ok := s.repository.(interface { if settings, ok := s.repository.(interface {
GetWebAccessPolicy(context.Context) (webaccess.Policy, error) GetWebAccessPolicy(context.Context) (webaccess.Policy, error)
}); ok { }); ok {
+28
View File
@@ -70,6 +70,34 @@ if (deploymentForm) {
}); });
} }
const adminTabs = document.querySelectorAll('.app-main > nav.tabs a[href^="#"]');
if (adminTabs.length) {
const panels = [...document.querySelectorAll('.settings-section[id]')];
const selectAdminTab = (requested, updateHash) => {
const valid = panels.some((panel) => panel.id === requested);
const selected = valid ? requested : (panels[0]?.id || "");
panels.forEach((panel) => { panel.hidden = panel.id !== selected; });
adminTabs.forEach((tab) => {
const active = tab.getAttribute("href") === `#${selected}`;
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
});
if (updateHash && selected && window.location.hash !== `#${selected}`) history.replaceState(null, "", `#${selected}`);
};
adminTabs.forEach((tab, index) => {
tab.setAttribute("role", "tab");
tab.addEventListener("click", (event) => { event.preventDefault(); selectAdminTab(tab.hash.slice(1), true); });
tab.addEventListener("keydown", (event) => {
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
event.preventDefault();
const next = (index + (event.key === "ArrowRight" ? 1 : -1) + adminTabs.length) % adminTabs.length;
adminTabs[next].focus(); selectAdminTab(adminTabs[next].hash.slice(1), true);
});
});
selectAdminTab(window.location.hash.slice(1), false);
window.addEventListener("hashchange", () => selectAdminTab(window.location.hash.slice(1), false));
}
// The backend, not a timer, is the source for this history. A 403 simply // The backend, not a timer, is the source for this history. A 403 simply
// means the signed-in user is not an administrator and leaves no technical // means the signed-in user is not an administrator and leaves no technical
// data in the DOM. // data in the DOM.
+18 -4
View File
@@ -9,7 +9,17 @@ import (
) )
func (s *server) templateRepositoriesPage(w http.ResponseWriter, r *http.Request) { func (s *server) templateRepositoriesPage(w http.ResponseWriter, r *http.Request) {
s.templateRepositoriesRender(w, r, http.StatusOK, templaterepo.Input{}, "") data, ok := s.adminPageData(w, r, "Administration", "administration")
if !ok {
return
}
entries, err := s.repository.ListTemplateRepositories(r.Context())
if err != nil {
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
data.TemplateRepositories = entries
s.render(w, http.StatusOK, "settings.html", data)
} }
func (s *server) templateRepositoryCreateForm(w http.ResponseWriter, r *http.Request) { func (s *server) templateRepositoryCreateForm(w http.ResponseWriter, r *http.Request) {
@@ -26,7 +36,7 @@ func (s *server) templateRepositoryCreateForm(w http.ResponseWriter, r *http.Req
s.templateRepositoriesRender(w, r, http.StatusUnprocessableEntity, in, localized(s.language(r, actor.Language), "template_repositories.duplicate_error")) s.templateRepositoriesRender(w, r, http.StatusUnprocessableEntity, in, localized(s.language(r, actor.Language), "template_repositories.duplicate_error"))
return return
} }
http.Redirect(w, r, "/administration/template-repositories", http.StatusSeeOther) http.Redirect(w, r, "/administration#repositories", http.StatusSeeOther)
} }
func (s *server) templateRepositoryDeleteForm(w http.ResponseWriter, r *http.Request) { func (s *server) templateRepositoryDeleteForm(w http.ResponseWriter, r *http.Request) {
@@ -42,7 +52,7 @@ func (s *server) templateRepositoryDeleteForm(w http.ResponseWriter, r *http.Req
s.problem(w, http.StatusInternalServerError, message("error.internal")) s.problem(w, http.StatusInternalServerError, message("error.internal"))
return return
} }
http.Redirect(w, r, "/administration/template-repositories", http.StatusSeeOther) http.Redirect(w, r, "/administration#repositories", http.StatusSeeOther)
} }
func (s *server) templateRepositoriesRender(w http.ResponseWriter, r *http.Request, status int, in templaterepo.Input, formError string) { func (s *server) templateRepositoriesRender(w http.ResponseWriter, r *http.Request, status int, in templaterepo.Input, formError string) {
@@ -56,5 +66,9 @@ func (s *server) templateRepositoriesRender(w http.ResponseWriter, r *http.Reque
return return
} }
data.TemplateRepositories, data.TemplateRepositoryInput, data.Error = entries, in, formError data.TemplateRepositories, data.TemplateRepositoryInput, data.Error = entries, in, formError
s.render(w, status, "template-repositories.html", data) if err := s.populateAdminUsers(r, &data); err != nil {
s.problem(w, http.StatusInternalServerError, "The users are unavailable.")
return
}
s.render(w, status, "settings.html", data)
} }
+5 -3
View File
@@ -27,8 +27,8 @@
</p> </p>
</div> </div>
</div> </div>
<nav class="tabs" aria-label="{{.Msg "settings.tabs"}}"> <nav class="tabs" role="tablist" aria-label="{{.Msg "settings.tabs"}}">
<a href="/administration/users"> <a href="#users" role="tab" aria-controls="users">
{{.Msg "template_repositories.users"}} {{.Msg "template_repositories.users"}}
</a> </a>
<a href="#web-access"> <a href="#web-access">
@@ -40,7 +40,7 @@
<a href="#notifications"> <a href="#notifications">
{{.Msg "settings.notifications"}} {{.Msg "settings.notifications"}}
</a> </a>
<a href="/administration/template-repositories"> <a href="#repositories" role="tab" aria-controls="repositories">
{{.Msg "template_repositories.heading"}} {{.Msg "template_repositories.heading"}}
</a> </a>
<a href="#audit"> <a href="#audit">
@@ -366,6 +366,8 @@
</button> </button>
</form> </form>
</section> </section>
{{template "users-panel" .}}
{{template "repositories-panel" .}}
</main> </main>
<script src="/static/app.js"> <script src="/static/app.js">
</script> </script>
@@ -1,50 +1,5 @@
{{define "template-repositories.html"}} {{define "repositories-panel"}}
<!doctype html> <section class="panel settings-section" id="repositories">
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>
{{.Title}}
· DoGaMa
</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/theme.css">
</head>
<body class="app-body">
{{template "sidebar" .}}
<main class="app-main">
<div class="page-heading">
<div>
<p class="eyebrow">
Administration
</p>
<h1>
{{.Msg "template_repositories.heading"}}
</h1>
<p>
{{.Msg "template_repositories.introduction"}}
</p>
</div>
<a class="button-secondary link-button" href="/administration">
Administration
</a>
</div>
<nav class="tabs" aria-label="{{.Msg "settings.tabs"}}">
<a href="/administration/users">
{{.Msg "template_repositories.users"}}
</a>
<a href="/administration#notifications">
{{.Msg "settings.notifications"}}
</a>
<a href="/administration/template-repositories">
{{.Msg "template_repositories.heading"}}
</a>
<a href="/administration#audit">
{{.Msg "settings.audit"}}
</a>
</nav>
<section class="panel settings-section">
<details{{if .Error}} open{{end}}> <details{{if .Error}} open{{end}}>
<summary class="button-secondary link-button"> <summary class="button-secondary link-button">
{{.Msg "template_repositories.new"}} {{.Msg "template_repositories.new"}}
@@ -107,9 +62,4 @@
</p> </p>
{{end}} {{end}}
</section> </section>
</main>
<script src="/static/app.js">
</script>
</body>
</html>
{{end}} {{end}}
+2 -37
View File
@@ -1,35 +1,5 @@
{{define "users.html"}} {{define "users-panel"}}
<!doctype html> <section class="panel settings-section" id="users">
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>
Users · DoGaMa
</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/theme.css">
</head>
<body class="app-body">
{{template "sidebar" .}}
<main class="app-main">
<div class="page-heading">
<div>
<p class="eyebrow">
Administration
</p>
<h1>
Users
</h1>
<p>
Create identities, assign global roles and control access to each game server.
</p>
</div>
<a class="button-secondary link-button" href="/administration">
Administration settings
</a>
</div>
<section class="panel settings-section">
<h2> <h2>
Create user Create user
</h2> </h2>
@@ -204,9 +174,4 @@
</p> </p>
</section> </section>
{{end}} {{end}}
</main>
<script src="/static/app.js">
</script>
</body>
</html>
{{end}} {{end}}
+27 -21
View File
@@ -13,33 +13,39 @@ func (s *server) usersPage(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
users, err := s.auth.ListUsers(r.Context()) if err := s.populateAdminUsers(r, &data); err != nil {
if err != nil {
s.problem(w, http.StatusInternalServerError, "The users are unavailable.") s.problem(w, http.StatusInternalServerError, "The users are unavailable.")
return return
} }
data.Users = users s.render(w, http.StatusOK, "settings.html", data)
data.Permissions = authorization.Permissions() }
func (s *server) populateAdminUsers(r *http.Request, data *pageData) error {
users, err := s.auth.ListUsers(r.Context())
if err != nil {
return err
}
data.Users, data.Permissions = users, authorization.Permissions()
data.UserAccess = make(map[string]map[string]authorization.Membership, len(users)) data.UserAccess = make(map[string]map[string]authorization.Membership, len(users))
for _, user := range users { for _, user := range users {
data.UserAccess[user.ID] = make(map[string]authorization.Membership) data.UserAccess[user.ID] = make(map[string]authorization.Membership)
} }
if s.repository != nil { if s.repository == nil {
data.Instances = mustLifecycleInstances(r.Context(), s.repository) return nil
for _, current := range data.Instances { }
memberships, membershipErr := s.permissions.ListMemberships(r.Context(), data.User, current.ID) data.Instances = mustLifecycleInstances(r.Context(), s.repository)
if membershipErr != nil { for _, current := range data.Instances {
s.problem(w, http.StatusInternalServerError, "Instance access is unavailable.") memberships, membershipErr := s.permissions.ListMemberships(r.Context(), data.User, current.ID)
return if membershipErr != nil {
} return errors.New("instance access unavailable")
for _, membership := range memberships { }
if data.UserAccess[membership.UserID] != nil { for _, membership := range memberships {
data.UserAccess[membership.UserID][current.ID] = membership if data.UserAccess[membership.UserID] != nil {
} data.UserAccess[membership.UserID][current.ID] = membership
} }
} }
} }
s.render(w, http.StatusOK, "users.html", data) return nil
} }
func (s *server) adminUserForm(w http.ResponseWriter, r *http.Request) (auth.User, bool) { func (s *server) adminUserForm(w http.ResponseWriter, r *http.Request) (auth.User, bool) {
@@ -68,7 +74,7 @@ func (s *server) userCreateForm(w http.ResponseWriter, r *http.Request) {
s.problem(w, http.StatusUnprocessableEntity, "The user could not be created.") s.problem(w, http.StatusUnprocessableEntity, "The user could not be created.")
return return
} }
http.Redirect(w, r, "/administration/users", http.StatusSeeOther) http.Redirect(w, r, "/administration#users", http.StatusSeeOther)
} }
func (s *server) userUpdateForm(w http.ResponseWriter, r *http.Request) { func (s *server) userUpdateForm(w http.ResponseWriter, r *http.Request) {
@@ -84,7 +90,7 @@ func (s *server) userUpdateForm(w http.ResponseWriter, r *http.Request) {
s.problem(w, http.StatusUnprocessableEntity, "The user could not be updated.") s.problem(w, http.StatusUnprocessableEntity, "The user could not be updated.")
return return
} }
http.Redirect(w, r, "/administration/users", http.StatusSeeOther) http.Redirect(w, r, "/administration#users", http.StatusSeeOther)
} }
func (s *server) userMembershipForm(w http.ResponseWriter, r *http.Request) { func (s *server) userMembershipForm(w http.ResponseWriter, r *http.Request) {
@@ -107,7 +113,7 @@ func (s *server) userMembershipForm(w http.ResponseWriter, r *http.Request) {
s.authorizationProblem(w, err) s.authorizationProblem(w, err)
return return
} }
http.Redirect(w, r, "/administration/users", http.StatusSeeOther) http.Redirect(w, r, "/administration#users", http.StatusSeeOther)
} }
func (s *server) userPermissionForm(w http.ResponseWriter, r *http.Request) { func (s *server) userPermissionForm(w http.ResponseWriter, r *http.Request) {
@@ -130,5 +136,5 @@ func (s *server) userPermissionForm(w http.ResponseWriter, r *http.Request) {
s.authorizationProblem(w, err) s.authorizationProblem(w, err)
return return
} }
http.Redirect(w, r, "/administration/users", http.StatusSeeOther) http.Redirect(w, r, "/administration#users", http.StatusSeeOther)
} }