fixes and improvements for new server settings menu

This commit is contained in:
onysd 2026-08-26 00:37:25 +03:00
parent 902f3606c2
commit 66f9c0bc1e
27 changed files with 2244 additions and 40 deletions

View file

@ -0,0 +1,152 @@
// Package identity stores the admin-editable server name/description/icon
// shown to clients over the same-port HTTP endpoints in internal/mtprotoedge
// (/owpengram/server-info, /owpengram/server-icon). It is deliberately not
// part of internal/config's Config: config is loaded once at process start
// from .env, while identity is meant to be edited from the admin web panel
// and take effect immediately, with no server restart -- so it lives as
// plain files on disk, read fresh on every request instead of cached in
// memory.
package identity
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
metaFileName = "identity.json"
iconBaseName = "icon"
)
// Info is the editable identity shown to clients.
type Info struct {
Name string `json:"name"`
Description string `json:"description"`
// IconExt is the icon file's extension (e.g. ".png"), empty when no
// icon has been uploaded. Kept alongside Name/Description so Store can
// find the icon file without a directory listing.
IconExt string `json:"icon_ext,omitempty"`
}
// Store reads/writes Info and the icon file under a directory (typically
// Config.IdentityDir). All methods are safe to call from multiple goroutines
// and multiple processes (the admin binary writes, the main server binary
// reads) -- writes are atomic via a temp file + rename.
type Store struct {
dir string
}
func NewStore(dir string) *Store {
return &Store{dir: dir}
}
func (s *Store) metaPath() string {
return filepath.Join(s.dir, metaFileName)
}
func (s *Store) iconPath(ext string) string {
return filepath.Join(s.dir, iconBaseName+ext)
}
// Get reads the current identity. A missing file is not an error -- it just
// means nothing has been configured yet, so Info{} (all empty) is returned.
func (s *Store) Get() (Info, error) {
data, err := os.ReadFile(s.metaPath())
if os.IsNotExist(err) {
return Info{}, nil
}
if err != nil {
return Info{}, fmt.Errorf("identity: read: %w", err)
}
var info Info
if err := json.Unmarshal(data, &info); err != nil {
return Info{}, fmt.Errorf("identity: decode: %w", err)
}
return info, nil
}
// SetText updates name/description, preserving whatever icon is already
// configured.
func (s *Store) SetText(name, description string) error {
info, err := s.Get()
if err != nil {
return err
}
info.Name = strings.TrimSpace(name)
info.Description = strings.TrimSpace(description)
return s.save(info)
}
// SetIcon replaces the icon file (removing any previous one under a
// different extension) and records its extension in identity.json.
// ext must include the leading dot (e.g. ".png").
func (s *Store) SetIcon(data []byte, ext string) error {
info, err := s.Get()
if err != nil {
return err
}
if err := os.MkdirAll(s.dir, 0o755); err != nil {
return fmt.Errorf("identity: mkdir: %w", err)
}
if info.IconExt != "" && info.IconExt != ext {
_ = os.Remove(s.iconPath(info.IconExt))
}
if err := writeFileAtomic(s.iconPath(ext), data, 0o644); err != nil {
return fmt.Errorf("identity: write icon: %w", err)
}
info.IconExt = ext
return s.save(info)
}
// RemoveIcon deletes the configured icon, if any.
func (s *Store) RemoveIcon() error {
info, err := s.Get()
if err != nil {
return err
}
if info.IconExt == "" {
return nil
}
_ = os.Remove(s.iconPath(info.IconExt))
info.IconExt = ""
return s.save(info)
}
// Icon returns the icon's raw bytes and its file extension, or ("", nil,
// false) when no icon is configured.
func (s *Store) Icon() (data []byte, ext string, ok bool) {
info, err := s.Get()
if err != nil || info.IconExt == "" {
return nil, "", false
}
raw, err := os.ReadFile(s.iconPath(info.IconExt))
if err != nil {
return nil, "", false
}
return raw, info.IconExt, true
}
func (s *Store) save(info Info) error {
if err := os.MkdirAll(s.dir, 0o755); err != nil {
return fmt.Errorf("identity: mkdir: %w", err)
}
data, err := json.MarshalIndent(info, "", " ")
if err != nil {
return fmt.Errorf("identity: encode: %w", err)
}
if err := writeFileAtomic(s.metaPath(), data, 0o644); err != nil {
return fmt.Errorf("identity: write: %w", err)
}
return nil
}
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, perm); err != nil {
return err
}
return os.Rename(tmp, path)
}

View file

@ -0,0 +1,95 @@
package identity
import (
"os"
"path/filepath"
"testing"
)
func TestStoreTextRoundTrip(t *testing.T) {
s := NewStore(t.TempDir())
info, err := s.Get()
if err != nil {
t.Fatal(err)
}
if info != (Info{}) {
t.Fatalf("expected zero-value Info before any write, got %+v", info)
}
if err := s.SetText(" OwpenGram ", " A self-hosted server. "); err != nil {
t.Fatal(err)
}
info, err = s.Get()
if err != nil {
t.Fatal(err)
}
if info.Name != "OwpenGram" || info.Description != "A self-hosted server." {
t.Fatalf("got %+v", info)
}
}
func TestStoreIconRoundTrip(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if _, _, ok := s.Icon(); ok {
t.Fatal("expected no icon before any upload")
}
png := []byte{0x89, 'P', 'N', 'G', 1, 2, 3}
if err := s.SetIcon(png, ".png"); err != nil {
t.Fatal(err)
}
data, ext, ok := s.Icon()
if !ok || ext != ".png" || string(data) != string(png) {
t.Fatalf("Icon() = %v, %q, %v", data, ext, ok)
}
if _, err := os.Stat(filepath.Join(dir, "icon.png")); err != nil {
t.Fatal(err)
}
// Replacing with a different extension removes the old file.
jpg := []byte{0xFF, 0xD8, 0xFF}
if err := s.SetIcon(jpg, ".jpg"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "icon.png")); !os.IsNotExist(err) {
t.Fatal("old icon.png should have been removed")
}
data, ext, ok = s.Icon()
if !ok || ext != ".jpg" || string(data) != string(jpg) {
t.Fatalf("Icon() after replace = %v, %q, %v", data, ext, ok)
}
if err := s.RemoveIcon(); err != nil {
t.Fatal(err)
}
if _, _, ok := s.Icon(); ok {
t.Fatal("expected no icon after RemoveIcon")
}
// Name/description set earlier (none here) must survive icon churn --
// Get() after all this should still report a clean, non-error zero name.
info, err := s.Get()
if err != nil {
t.Fatal(err)
}
if info.IconExt != "" {
t.Fatalf("IconExt should be empty after removal, got %q", info.IconExt)
}
}
func TestStorePreservesIconAcrossTextEdits(t *testing.T) {
s := NewStore(t.TempDir())
if err := s.SetIcon([]byte{1, 2, 3}, ".webp"); err != nil {
t.Fatal(err)
}
if err := s.SetText("New Name", "New description"); err != nil {
t.Fatal(err)
}
_, ext, ok := s.Icon()
if !ok || ext != ".webp" {
t.Fatalf("icon lost after unrelated SetText: ext=%q ok=%v", ext, ok)
}
}