Compare commits
2
Commits
8d282fd63d
...
972d5ef156
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
972d5ef156 | ||
|
|
da0492c348 |
@@ -1,11 +1,9 @@
|
||||
# V Rising RCON module
|
||||
|
||||
This template-local adapter uses the documented Source RCON interface of the
|
||||
V Rising dedicated server. The official server documentation lists only
|
||||
`announce` and `announcerestart`; the adapter currently exposes only bounded
|
||||
connectivity/status until command behavior is validated end-to-end on a real
|
||||
server. It does not claim announcement, player, save, shutdown, kick, ban or
|
||||
unban support.
|
||||
V Rising dedicated server. It exposes bounded `announce` and `shutdown`
|
||||
operations. Player listing, save, kick, ban and unban are intentionally not
|
||||
advertised without real-server RCON validation.
|
||||
|
||||
Build from the repository root:
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ compatibility:
|
||||
manager_api: ">=1.0.0 <2.0.0"
|
||||
module_api: ">=1.0.0 <2.0.0"
|
||||
|
||||
capabilities: []
|
||||
capabilities:
|
||||
- announcement
|
||||
- graceful_shutdown
|
||||
|
||||
permissions:
|
||||
network:
|
||||
@@ -49,4 +51,4 @@ configuration:
|
||||
|
||||
artifacts:
|
||||
wasm: module.wasm
|
||||
sha256: "d0b34c7724309e18f3e6e474886a6d4cf992cfaf9109415d20e46ff54fd9ab69"
|
||||
sha256: "bdafc0de4c517288c208bd0f1cb1d212858a54f9939a8e305621d32105f81f5c"
|
||||
|
||||
Binary file not shown.
@@ -136,7 +136,7 @@ func authFailure(result authResult) *moduleError {
|
||||
return rconFailure(errors.New(string(result)))
|
||||
}
|
||||
|
||||
var capabilities = []string{"announcement"}
|
||||
var capabilities = []string{"announcement", "graceful_shutdown"}
|
||||
|
||||
//go:wasmexport initialize
|
||||
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)
|
||||
}
|
||||
|
||||
//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 main() {}
|
||||
|
||||
@@ -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) {
|
||||
for _, raw := range [][]byte{{1, 2}, make([]byte, maxRCONResponse+1), packetBytes(t, 1, rconAuthReply, "x")[:12]} {
|
||||
if _, err := parseRCONPackets(raw); !errors.Is(err, errRCONMalformed) {
|
||||
|
||||
@@ -71,7 +71,9 @@ container:
|
||||
publish: false
|
||||
required: true
|
||||
|
||||
capabilities: []
|
||||
capabilities:
|
||||
- announcement
|
||||
- graceful_shutdown
|
||||
|
||||
storage:
|
||||
mounts:
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -615,6 +616,10 @@ func (s *Service) restoreArchive(archive string, current instance.StoredInstance
|
||||
rollback()
|
||||
return ErrIntegrity
|
||||
}
|
||||
if err := applyRuntimeOwnership(staged, current.Preview); err != nil {
|
||||
rollback()
|
||||
return err
|
||||
}
|
||||
previous := live + ".dogama-previous-" + manifest.BackupID
|
||||
if err := os.Rename(live, previous); err != nil {
|
||||
rollback()
|
||||
@@ -635,6 +640,37 @@ func (s *Service) restoreArchive(archive string, current instance.StoredInstance
|
||||
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 {
|
||||
candidates, err := s.repository.RetentionCandidates(ctx, instanceID, count)
|
||||
if err != nil {
|
||||
|
||||
@@ -2353,6 +2353,13 @@ func (s *server) settingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
if s.audit != nil {
|
||||
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 {
|
||||
GetWebAccessPolicy(context.Context) (webaccess.Policy, error)
|
||||
}); ok {
|
||||
|
||||
@@ -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
|
||||
// means the signed-in user is not an administrator and leaves no technical
|
||||
// data in the DOM.
|
||||
|
||||
@@ -9,7 +9,17 @@ import (
|
||||
)
|
||||
|
||||
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) {
|
||||
@@ -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"))
|
||||
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) {
|
||||
@@ -42,7 +52,7 @@ func (s *server) templateRepositoryDeleteForm(w http.ResponseWriter, r *http.Req
|
||||
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
||||
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) {
|
||||
@@ -56,5 +66,9 @@ func (s *server) templateRepositoriesRender(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="tabs" aria-label="{{.Msg "settings.tabs"}}">
|
||||
<a href="/administration/users">
|
||||
<nav class="tabs" role="tablist" aria-label="{{.Msg "settings.tabs"}}">
|
||||
<a href="#users" role="tab" aria-controls="users">
|
||||
{{.Msg "template_repositories.users"}}
|
||||
</a>
|
||||
<a href="#web-access">
|
||||
@@ -40,7 +40,7 @@
|
||||
<a href="#notifications">
|
||||
{{.Msg "settings.notifications"}}
|
||||
</a>
|
||||
<a href="/administration/template-repositories">
|
||||
<a href="#repositories" role="tab" aria-controls="repositories">
|
||||
{{.Msg "template_repositories.heading"}}
|
||||
</a>
|
||||
<a href="#audit">
|
||||
@@ -366,6 +366,8 @@
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
{{template "users-panel" .}}
|
||||
{{template "repositories-panel" .}}
|
||||
</main>
|
||||
<script src="/static/app.js">
|
||||
</script>
|
||||
|
||||
@@ -1,50 +1,5 @@
|
||||
{{define "template-repositories.html"}}
|
||||
<!doctype html>
|
||||
<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">
|
||||
{{define "repositories-panel"}}
|
||||
<section class="panel settings-section" id="repositories">
|
||||
<details{{if .Error}} open{{end}}>
|
||||
<summary class="button-secondary link-button">
|
||||
{{.Msg "template_repositories.new"}}
|
||||
@@ -107,9 +62,4 @@
|
||||
</p>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/app.js">
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
{{define "users.html"}}
|
||||
<!doctype html>
|
||||
<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">
|
||||
{{define "users-panel"}}
|
||||
<section class="panel settings-section" id="users">
|
||||
<h2>
|
||||
Create user
|
||||
</h2>
|
||||
@@ -204,9 +174,4 @@
|
||||
</p>
|
||||
</section>
|
||||
{{end}}
|
||||
</main>
|
||||
<script src="/static/app.js">
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
+27
-21
@@ -13,33 +13,39 @@ func (s *server) usersPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
users, err := s.auth.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
if err := s.populateAdminUsers(r, &data); err != nil {
|
||||
s.problem(w, http.StatusInternalServerError, "The users are unavailable.")
|
||||
return
|
||||
}
|
||||
data.Users = users
|
||||
data.Permissions = authorization.Permissions()
|
||||
s.render(w, http.StatusOK, "settings.html", data)
|
||||
}
|
||||
|
||||
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))
|
||||
for _, user := range users {
|
||||
data.UserAccess[user.ID] = make(map[string]authorization.Membership)
|
||||
}
|
||||
if s.repository != nil {
|
||||
data.Instances = mustLifecycleInstances(r.Context(), s.repository)
|
||||
for _, current := range data.Instances {
|
||||
memberships, membershipErr := s.permissions.ListMemberships(r.Context(), data.User, current.ID)
|
||||
if membershipErr != nil {
|
||||
s.problem(w, http.StatusInternalServerError, "Instance access is unavailable.")
|
||||
return
|
||||
}
|
||||
for _, membership := range memberships {
|
||||
if data.UserAccess[membership.UserID] != nil {
|
||||
data.UserAccess[membership.UserID][current.ID] = membership
|
||||
}
|
||||
if s.repository == nil {
|
||||
return nil
|
||||
}
|
||||
data.Instances = mustLifecycleInstances(r.Context(), s.repository)
|
||||
for _, current := range data.Instances {
|
||||
memberships, membershipErr := s.permissions.ListMemberships(r.Context(), data.User, current.ID)
|
||||
if membershipErr != nil {
|
||||
return errors.New("instance access unavailable")
|
||||
}
|
||||
for _, membership := range memberships {
|
||||
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) {
|
||||
@@ -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.")
|
||||
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) {
|
||||
@@ -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.")
|
||||
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) {
|
||||
@@ -107,7 +113,7 @@ func (s *server) userMembershipForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.authorizationProblem(w, err)
|
||||
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) {
|
||||
@@ -130,5 +136,5 @@ func (s *server) userPermissionForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.authorizationProblem(w, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/administration/users", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/administration#users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user