owpengram-server/internal/identity/identity.go

152 lines
4.3 KiB
Go

// 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)
}