Merge pull request 'feat(admin): add template repository management' (#36) from codex/block-09-template-repositories into main
CI / validate (push) Failing after 8m4s
CI / validate (push) Failing after 8m4s
Reviewed-on: #36 Reviewed-by: tony <1+tony@noreply.localhost>
This commit was merged in pull request #36.
This commit is contained in:
@@ -68,6 +68,8 @@ Templates are stored under `${DOGAMA_DATA_PATH:-./data}/templates` on the Docker
|
||||
|
||||
Create one directory per game and place its `template.yaml` (plus any local assets it references) in that directory. Open **Catalog** as an administrator and select **Scan** to apply additions, edits and removals without restarting the application. Invalid templates are ignored while valid ones remain available; the Scan result identifies each invalid directory without exposing host paths or secrets. See [the template guide](docs/operations/local-templates.md) for the complete format.
|
||||
|
||||
Administrators can also record future sources in **Administration → Template repositories**. These name/HTTP(S)-URL definitions are stored locally only. They do not download, clone, fetch, authenticate to, or synchronize templates; **Catalog → Scan** continues to read only `/var/lib/dogama/templates`.
|
||||
|
||||
## Ports
|
||||
|
||||
| Port | Exposure | Purpose |
|
||||
|
||||
@@ -72,6 +72,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- Audit retention defaults to 30 days and 10,000 entries; zero explicitly selects unlimited retention/count within documented bounds.
|
||||
- At least one active global administrator is always retained; deactivation revokes that user's sessions atomically.
|
||||
- The local template directory (`/var/lib/dogama/templates`, under the application data bind mount) is the catalog source of truth. Bundled templates are copied only when their destination files are absent; an administrator Scan validates each directory independently and refreshes the available SQLite index without network fetches.
|
||||
- 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.
|
||||
|
||||
## Known limitations and debt
|
||||
|
||||
|
||||
@@ -65,3 +65,9 @@ Absolute paths, traversal and symlinked parent directories are refused. INI chan
|
||||
Administrators deploy from **Catalog → game → Deploy**. The visible instance name is converted deterministically to the safe technical slug used for mount paths (for example `Été / Serveur #2` becomes `ete-serveur-2`). DoGaMa derives every mount below its configured server root, validates a canonical preview and asks only the restricted agent to create and start the container.
|
||||
|
||||
When the template supports imports, the form accepts ZIP, TAR, TAR.GZ and TAR.ZST save archives. Uploads are size-bounded, staged under the configured import root with generated names, checked for traversal and expected save layout. Deployment order is **install (stopped) → import → apply resolved configuration → start**, so DoGaMa form values intentionally take priority over configuration files present in an imported save. Invalid staging data is removed and a configuration failure prevents startup.
|
||||
|
||||
## Configured template repositories
|
||||
|
||||
**Administration → Template repositories** lets administrators store a name and an HTTP(S) URL for a future template source. The URL is validated and persisted in SQLite, but it is never requested by the browser or application. Remote synchronization, Git clone/fetch, HTTP downloads, provider APIs, authentication and scheduled updates are not implemented.
|
||||
|
||||
Configured repositories therefore do not affect **Catalog → Scan**. Scan remains strictly local and reads only `/var/lib/dogama/templates`.
|
||||
|
||||
@@ -36,6 +36,13 @@ CREATE TABLE templates (
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE template_repositories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 120),
|
||||
url TEXT NOT NULL UNIQUE COLLATE NOCASE CHECK (length(url) BETWEEN 1 AND 2048),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX template_repositories_name_idx ON template_repositories(name COLLATE NOCASE, id);
|
||||
CREATE TABLE template_versions (
|
||||
template_id TEXT NOT NULL REFERENCES templates(id) ON DELETE RESTRICT,
|
||||
version TEXT NOT NULL,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/templaterepo"
|
||||
)
|
||||
|
||||
func (r *Repository) CreateTemplateRepository(ctx context.Context, in templaterepo.Input) (templaterepo.Entry, error) {
|
||||
in, err := templaterepo.Validate(in)
|
||||
if err != nil {
|
||||
return templaterepo.Entry{}, err
|
||||
}
|
||||
id, err := templateRepositoryID()
|
||||
if err != nil {
|
||||
return templaterepo.Entry{}, err
|
||||
}
|
||||
entry := templaterepo.Entry{ID: id, Name: in.Name, URL: in.URL, CreatedAt: r.now().UTC().Format(time.RFC3339Nano)}
|
||||
if _, err := r.db.ExecContext(ctx, `INSERT INTO template_repositories(id,name,url,created_at) VALUES(?,?,?,?)`, entry.ID, entry.Name, entry.URL, entry.CreatedAt); err != nil {
|
||||
return templaterepo.Entry{}, fmt.Errorf("create template repository: %w", err)
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListTemplateRepositories(ctx context.Context) ([]templaterepo.Entry, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id,name,url,created_at FROM template_repositories ORDER BY name COLLATE NOCASE, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var entries []templaterepo.Entry
|
||||
for rows.Next() {
|
||||
var entry templaterepo.Entry
|
||||
if err := rows.Scan(&entry.ID, &entry.Name, &entry.URL, &entry.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteTemplateRepository(ctx context.Context, id string) error {
|
||||
if id == "" {
|
||||
return templaterepo.ErrInvalid
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM template_repositories WHERE id=?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func templateRepositoryID() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", errors.New("generate template repository identifier")
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/templaterepo"
|
||||
)
|
||||
|
||||
func TestTemplateRepositoriesPersistValidateAndDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dogama.db")
|
||||
db, err := sqlite.Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository := sqlite.NewRepository(db)
|
||||
if _, err := repository.CreateTemplateRepository(ctx, templaterepo.Input{Name: "", URL: "https://templates.example.test"}); !errors.Is(err, templaterepo.ErrInvalid) {
|
||||
t.Fatalf("empty name error = %v", err)
|
||||
}
|
||||
for _, invalid := range []string{"", "javascript:alert(1)", "data:text/plain,no", "file:///tmp/templates", "ftp://templates.example.test", "https:///missing-host"} {
|
||||
if _, err := repository.CreateTemplateRepository(ctx, templaterepo.Input{Name: "Invalid", URL: invalid}); !errors.Is(err, templaterepo.ErrInvalid) {
|
||||
t.Fatalf("invalid URL %q error = %v", invalid, err)
|
||||
}
|
||||
}
|
||||
created, err := repository.CreateTemplateRepository(ctx, templaterepo.Input{Name: " Official ", URL: " https://templates.example.test/index.json "})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.Name != "Official" || created.URL != "https://templates.example.test/index.json" || created.ID == "" {
|
||||
t.Fatalf("created = %#v", created)
|
||||
}
|
||||
if _, err := repository.CreateTemplateRepository(ctx, templaterepo.Input{Name: "Duplicate", URL: created.URL}); err == nil {
|
||||
t.Fatal("duplicate URL was accepted")
|
||||
}
|
||||
// A fresh repository wrapper observes the persisted SQLite row.
|
||||
repository = sqlite.NewRepository(db)
|
||||
entries, err := repository.ListTemplateRepositories(ctx)
|
||||
if err != nil || len(entries) != 1 || entries[0] != created {
|
||||
t.Fatalf("persisted entries = %#v, error = %v", entries, err)
|
||||
}
|
||||
if err := repository.DeleteTemplateRepository(ctx, created.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err = repository.ListTemplateRepositories(ctx)
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("entries after deletion = %#v, error = %v", entries, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Package templaterepo defines locally configured future template sources.
|
||||
// It deliberately does not retrieve, synchronize, or parse remote content.
|
||||
package templaterepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxNameLength = 120
|
||||
maxURLLength = 2048
|
||||
)
|
||||
|
||||
var ErrInvalid = errors.New("invalid template repository")
|
||||
|
||||
type Entry struct {
|
||||
ID, Name, URL string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type Input struct {
|
||||
Name string
|
||||
URL string
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
CreateTemplateRepository(context.Context, Input) (Entry, error)
|
||||
ListTemplateRepositories(context.Context) ([]Entry, error)
|
||||
DeleteTemplateRepository(context.Context, string) error
|
||||
}
|
||||
|
||||
// Validate accepts only bounded absolute HTTP(S) URLs. It performs no network
|
||||
// operation: configured sources are data for a future milestone only.
|
||||
func Validate(in Input) (Input, error) {
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
in.URL = strings.TrimSpace(in.URL)
|
||||
if in.Name == "" || len(in.Name) > maxNameLength || in.URL == "" || len(in.URL) > maxURLLength {
|
||||
return Input{}, ErrInvalid
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(in.URL)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return Input{}, ErrInvalid
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
@@ -56,6 +56,14 @@ func init() {
|
||||
messages[language][key] = value
|
||||
}
|
||||
}
|
||||
for language, values := range map[string]map[string]string{
|
||||
"en": {"template_repositories.title": "Template repositories", "template_repositories.heading": "Template repositories", "template_repositories.introduction": "Configure future template sources. Remote synchronization is not implemented.", "template_repositories.users": "Users", "template_repositories.new": "New repository", "template_repositories.name": "Name", "template_repositories.url": "URL", "template_repositories.add": "Add", "template_repositories.delete": "Delete", "template_repositories.delete_confirm": "Delete this template repository?", "template_repositories.not_implemented": "Feature not implemented", "template_repositories.empty": "No template repositories configured.", "template_repositories.validation_error": "Enter a name and a valid HTTP or HTTPS URL.", "template_repositories.duplicate_error": "This template repository URL is already configured.", "template_repositories.not_found": "Template repository not found."},
|
||||
"fr": {"template_repositories.title": "Dépôts de templates", "template_repositories.heading": "Dépôts de templates", "template_repositories.introduction": "Configurez de futures sources de templates. La synchronisation distante n’est pas implémentée.", "template_repositories.users": "Utilisateurs", "template_repositories.new": "Nouveau dépôt", "template_repositories.name": "Nom", "template_repositories.url": "URL", "template_repositories.add": "Ajouter", "template_repositories.delete": "Supprimer", "template_repositories.delete_confirm": "Supprimer ce dépôt de templates ?", "template_repositories.not_implemented": "Fonctionnalité non implémentée", "template_repositories.empty": "Aucun dépôt de templates configuré.", "template_repositories.validation_error": "Saisissez un nom et une URL HTTP ou HTTPS valide.", "template_repositories.duplicate_error": "Cette URL de dépôt de templates est déjà configurée.", "template_repositories.not_found": "Dépôt de templates introuvable."},
|
||||
} {
|
||||
for key, value := range values {
|
||||
messages[language][key] = value
|
||||
}
|
||||
}
|
||||
for language, values := range map[string]map[string]string{
|
||||
"en": {"settings.notification_general": "General", "settings.notification_language_help": "Choose the language used in delivered notifications.", "settings.notification_email": "Email / SMTP", "settings.notification_discord": "Discord", "settings.notification_gotify": "Gotify", "settings.sender_name": "Sender name", "settings.sender_email": "Sender email", "settings.smtp_server": "SMTP server", "settings.port": "Port", "settings.username": "Username", "settings.password": "Password", "settings.password_retain": "leave empty to retain on edit", "settings.tls_mode": "TLS mode", "settings.test_recipient": "Test recipient (optional)", "settings.save_smtp": "Save SMTP", "settings.webhook_url": "Webhook URL", "settings.save_discord": "Save Discord", "settings.gotify_help": "Create an application in Gotify, then enter its server URL and application token here.", "settings.server_url": "Server URL", "settings.application_token": "Application token", "settings.save_gotify": "Save Gotify", "settings.configured": "configured", "settings.not_configured": "not configured"},
|
||||
"fr": {"settings.notification_general": "Général", "settings.notification_language_help": "Choisissez la langue utilisée pour les notifications envoyées.", "settings.notification_email": "E-mail / SMTP", "settings.notification_discord": "Discord", "settings.notification_gotify": "Gotify", "settings.sender_name": "Nom de l'expéditeur", "settings.sender_email": "E-mail de l'expéditeur", "settings.smtp_server": "Serveur SMTP", "settings.port": "Port", "settings.username": "Nom d'utilisateur", "settings.password": "Mot de passe", "settings.password_retain": "laissez vide pour le conserver lors d'une modification", "settings.tls_mode": "Mode TLS", "settings.test_recipient": "Destinataire de test (facultatif)", "settings.save_smtp": "Enregistrer SMTP", "settings.webhook_url": "URL du webhook", "settings.save_discord": "Enregistrer Discord", "settings.gotify_help": "Créez une application dans Gotify, puis saisissez ici l'URL du serveur et le jeton de l'application.", "settings.server_url": "URL du serveur", "settings.application_token": "Jeton d'application", "settings.save_gotify": "Enregistrer Gotify", "settings.configured": "configuré", "settings.not_configured": "non configuré"},
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/templaterepo"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/webaccess"
|
||||
)
|
||||
|
||||
@@ -79,6 +80,7 @@ type repository interface {
|
||||
instance.Repository
|
||||
instance.LifecycleRepository
|
||||
authorization.Repository
|
||||
templaterepo.Repository
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
@@ -115,6 +117,8 @@ type pageData struct {
|
||||
CatalogDetail *catalog.Template
|
||||
CatalogScan *catalog.ScanResult
|
||||
CatalogScanner bool
|
||||
TemplateRepositories []templaterepo.Entry
|
||||
TemplateRepositoryInput templaterepo.Input
|
||||
Deployment *deploymentPage
|
||||
InstanceDetail *instanceDetailPage
|
||||
}
|
||||
@@ -347,6 +351,8 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
|
||||
mux.HandleFunc("POST /admin/notification-channels/{id}/delete", s.notificationDeleteForm)
|
||||
mux.HandleFunc("POST /admin/audit-policy", s.auditPolicyForm)
|
||||
mux.HandleFunc("POST /admin/audit-purge", s.auditPurgeForm)
|
||||
mux.HandleFunc("POST /administration/template-repositories", s.templateRepositoryCreateForm)
|
||||
mux.HandleFunc("POST /administration/template-repositories/{id}/delete", s.templateRepositoryDeleteForm)
|
||||
mux.HandleFunc("GET /audit", s.auditPage)
|
||||
mux.HandleFunc("GET /catalog", s.catalogPage)
|
||||
mux.HandleFunc("POST /catalog/scan", s.catalogScanForm)
|
||||
@@ -360,6 +366,7 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
|
||||
mux.HandleFunc("POST /account/language", s.accountLanguageForm)
|
||||
mux.HandleFunc("GET /administration", s.settingsPage)
|
||||
mux.HandleFunc("GET /administration/users", s.usersPage)
|
||||
mux.HandleFunc("GET /administration/template-repositories", s.templateRepositoriesPage)
|
||||
mux.HandleFunc("POST /administration/users", s.userCreateForm)
|
||||
mux.HandleFunc("POST /administration/users/{id}", s.userUpdateForm)
|
||||
mux.HandleFunc("POST /administration/users/{id}/memberships/{instanceID}", s.userMembershipForm)
|
||||
@@ -439,6 +446,10 @@ func auditAction(method, path string) string {
|
||||
switch {
|
||||
case method == http.MethodPost && (path == "/api/v1/admin/users" || path == "/administration/users"):
|
||||
return "user.create"
|
||||
case method == http.MethodPost && path == "/administration/template-repositories":
|
||||
return "template_repository.create"
|
||||
case method == http.MethodPost && strings.HasPrefix(path, "/administration/template-repositories/"):
|
||||
return "template_repository.delete"
|
||||
case (method == http.MethodPut && strings.HasPrefix(path, "/api/v1/admin/users/")) || (method == http.MethodPost && strings.HasPrefix(path, "/administration/users/") && !strings.Contains(path, "/memberships/")):
|
||||
return "user.change"
|
||||
case strings.Contains(path, "/memberships/") && strings.Contains(path, "/permissions/"):
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/templaterepo"
|
||||
)
|
||||
|
||||
func (s *server) templateRepositoriesPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.templateRepositoriesRender(w, r, http.StatusOK, templaterepo.Input{}, "")
|
||||
}
|
||||
|
||||
func (s *server) templateRepositoryCreateForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in := templaterepo.Input{Name: r.FormValue("name"), URL: r.FormValue("url")}
|
||||
if _, err := s.repository.CreateTemplateRepository(r.Context(), in); err != nil {
|
||||
if errors.Is(err, templaterepo.ErrInvalid) {
|
||||
s.templateRepositoriesRender(w, r, http.StatusUnprocessableEntity, in, localized(s.language(r, actor.Language), "template_repositories.validation_error"))
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *server) templateRepositoryDeleteForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.repository.DeleteTemplateRepository(r.Context(), r.PathValue("id")); err != nil {
|
||||
if errors.Is(err, templaterepo.ErrInvalid) || errors.Is(err, sql.ErrNoRows) {
|
||||
s.problem(w, http.StatusNotFound, localized(s.language(r, actor.Language), "template_repositories.not_found"))
|
||||
return
|
||||
}
|
||||
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/administration/template-repositories", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *server) templateRepositoriesRender(w http.ResponseWriter, r *http.Request, status int, in templaterepo.Input, formError string) {
|
||||
data, ok := s.adminPageData(w, r, "Template repositories", "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, data.TemplateRepositoryInput, data.Error = entries, in, formError
|
||||
s.render(w, status, "template-repositories.html", data)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
func TestTemplateRepositoriesAdministration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository := sqlite.NewRepository(db)
|
||||
authService := auth.New(db)
|
||||
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := authService.CreateUser(ctx, "manager", "correct horse battery staple", "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
viewer, err := authService.CreateUser(ctx, "viewer", "correct horse battery staple", "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminSession, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
managerSession, err := authService.Login(ctx, manager.Username, "correct horse battery staple", "192.0.2.2:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
viewerSession, err := authService.Login(ctx, viewer.Username, "correct horse battery staple", "192.0.2.3:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := NewHandlerWithRepository(authService, repository, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminCookies := []*http.Cookie{{Name: sessionCookie, Value: adminSession.Token}, {Name: csrfCookie, Value: adminSession.CSRFToken}}
|
||||
page := request(t, handler, http.MethodGet, "/administration/template-repositories", adminCookies)
|
||||
assertStatus(t, page, http.StatusOK)
|
||||
for _, expected := range []string{"Template repositories", "New repository", "No template repositories configured.", "Feature not implemented"} {
|
||||
if !strings.Contains(page.Body.String(), expected) {
|
||||
t.Fatalf("page missing %q: %s", expected, page.Body.String())
|
||||
}
|
||||
}
|
||||
assertStatus(t, request(t, handler, http.MethodGet, "/administration/template-repositories", []*http.Cookie{{Name: sessionCookie, Value: managerSession.Token}, {Name: csrfCookie, Value: managerSession.CSRFToken}}), http.StatusForbidden)
|
||||
assertStatus(t, request(t, handler, http.MethodGet, "/administration/template-repositories", []*http.Cookie{{Name: sessionCookie, Value: viewerSession.Token}, {Name: csrfCookie, Value: viewerSession.CSRFToken}}), http.StatusForbidden)
|
||||
denied := formRequest(t, handler, "/administration/template-repositories", url.Values{"name": {"Denied"}, "url": {"https://denied.example.test"}}, adminCookies[0])
|
||||
assertStatus(t, denied, http.StatusForbidden)
|
||||
created := formRequest(t, handler, "/administration/template-repositories", url.Values{"csrf_token": {adminSession.CSRFToken}, "name": {"Community"}, "url": {"https://community.example.test/templates"}}, adminCookies...)
|
||||
assertStatus(t, created, http.StatusSeeOther)
|
||||
entries, err := repository.ListTemplateRepositories(ctx)
|
||||
if err != nil || len(entries) != 1 || entries[0].Name != "Community" {
|
||||
t.Fatalf("created entries = %#v, error = %v", entries, err)
|
||||
}
|
||||
invalid := formRequest(t, handler, "/administration/template-repositories", url.Values{"csrf_token": {adminSession.CSRFToken}, "name": {"Unsafe"}, "url": {"javascript:alert(1)"}}, adminCookies...)
|
||||
assertStatus(t, invalid, http.StatusUnprocessableEntity)
|
||||
if !strings.Contains(invalid.Body.String(), "Enter a name and a valid HTTP or HTTPS URL.") {
|
||||
t.Fatalf("validation feedback missing: %s", invalid.Body.String())
|
||||
}
|
||||
deleteResponse := formRequest(t, handler, "/administration/template-repositories/"+entries[0].ID+"/delete", url.Values{"csrf_token": {adminSession.CSRFToken}}, adminCookies...)
|
||||
assertStatus(t, deleteResponse, http.StatusSeeOther)
|
||||
entries, err = repository.ListTemplateRepositories(ctx)
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("entries after deletion = %#v, error = %v", entries, err)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<body class="app-body">{{template "sidebar" .}}
|
||||
<main class="app-main">
|
||||
<div class="page-heading"><div><p class="eyebrow">{{.Msg "settings.eyebrow"}}</p><h1>Administration</h1><p>{{.Msg "settings.introduction"}}</p></div></div>
|
||||
<nav class="tabs" aria-label="{{.Msg "settings.tabs"}}"><a href="/administration/users">Users</a><a href="#web-access">{{.Msg "settings.web_access"}}</a><a href="#notifications">{{.Msg "settings.notifications"}}</a><a href="#audit">{{.Msg "settings.audit"}}</a><a href="#containers">{{.Msg "settings.containers"}}</a></nav>
|
||||
<nav class="tabs" aria-label="{{.Msg "settings.tabs"}}"><a href="/administration/users">{{.Msg "template_repositories.users"}}</a><a href="#web-access">{{.Msg "settings.web_access"}}</a><a href="#notifications">{{.Msg "settings.notifications"}}</a><a href="/administration/template-repositories">{{.Msg "template_repositories.heading"}}</a><a href="#audit">{{.Msg "settings.audit"}}</a><a href="#containers">{{.Msg "settings.containers"}}</a></nav>
|
||||
<section class="panel settings-section" id="web-access"><h2>{{.Msg "settings.web_access"}}</h2><p>{{.Msg "settings.web_help"}}</p><form method="post" action="/admin/web-access"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label class="check"><input type="checkbox" name="require_https"{{if .RequireHTTPS}} checked{{end}}> {{.Msg "settings.require_https"}}</label><p class="help">{{.Msg "settings.https_help"}}</p><label>{{.Msg "settings.canonical_url"}}<input name="canonical_url" type="url" value="{{.CanonicalURL}}" placeholder="https://dogama.example.com"></label><p class="help">{{.Msg "settings.canonical_help"}}</p><button type="submit">{{.Msg "settings.save_web"}}</button></form></section>
|
||||
<section class="panel settings-section" id="notifications"><h2>{{.Msg "settings.channels"}}</h2><p>{{.Msg "settings.channels_help"}}</p><div class="tabs"><a href="#notification-general">{{.Msg "settings.notification_general"}}</a><a href="#email">{{.Msg "settings.notification_email"}}</a><a href="#discord">{{.Msg "settings.notification_discord"}}</a><a href="#gotify">{{.Msg "settings.notification_gotify"}}</a></div>
|
||||
<section id="notification-general"><h3>{{.Msg "settings.notification_general"}}</h3><p>{{.Msg "settings.notification_language_help"}}</p><form method="post" action="/admin/notification-language"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>{{.Msg "language"}}<select name="language">{{range .Languages}}<option value="{{.Code}}"{{if eq $.NotificationLanguage .Code}} selected{{end}}>{{.Display}}</option>{{end}}</select></label><button type="submit">{{.Msg "language.save"}}</button></form></section>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{{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.v1.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}}><summary class="button-secondary link-button">{{.Msg "template_repositories.new"}}</summary>{{if .Error}}<p role="alert">{{.Error}}</p>{{end}}<form method="post" action="/administration/template-repositories"><div class="form-grid"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>{{.Msg "template_repositories.name"}}<input required maxlength="120" name="name" value="{{.TemplateRepositoryInput.Name}}"></label><label>{{.Msg "template_repositories.url"}}<input required maxlength="2048" type="url" name="url" value="{{.TemplateRepositoryInput.URL}}" placeholder="https://templates.example.com"></label></div><button type="submit">{{.Msg "template_repositories.add"}}</button></form></details></section>
|
||||
<section class="panel settings-section"><h2>{{.Msg "template_repositories.heading"}}</h2><p>{{.Msg "template_repositories.not_implemented"}}</p>{{if .TemplateRepositories}}<ul class="channel-list">{{range .TemplateRepositories}}<li><strong>{{.Name}}</strong><small>{{.URL}} · {{$.Msg "template_repositories.not_implemented"}}</small><form method="post" action="/administration/template-repositories/{{.ID}}/delete" onsubmit="return confirm('{{$.Msg "template_repositories.delete_confirm"}}');"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button type="submit" aria-label="{{$.Msg "template_repositories.delete"}} {{.Name}}">🗑 {{$.Msg "template_repositories.delete"}}</button></form></li>{{end}}</ul>{{else}}<p>{{.Msg "template_repositories.empty"}}</p>{{end}}</section>
|
||||
</main></body></html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user