init
This commit is contained in:
384
app.go
Normal file
384
app.go
Normal file
@@ -0,0 +1,384 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrCancelled = errors.New("cancelled")
|
||||
|
||||
type UserManager interface {
|
||||
UserExists(name string) bool
|
||||
CreateUser(name string) error
|
||||
}
|
||||
|
||||
type CommandRunner interface {
|
||||
Run(name string, args ...string) error
|
||||
}
|
||||
|
||||
type RealUserManager struct{}
|
||||
|
||||
func (RealUserManager) UserExists(name string) bool {
|
||||
_, err := user.Lookup(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (RealUserManager) CreateUser(name string) error {
|
||||
runner := OSCommandRunner{}
|
||||
return runner.Run("useradd", "-M", "-s", "/usr/sbin/nologin", name)
|
||||
}
|
||||
|
||||
type OSCommandRunner struct{}
|
||||
|
||||
func (OSCommandRunner) Run(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
type App struct {
|
||||
configPath string
|
||||
users UserManager
|
||||
reader *bufio.Reader
|
||||
writer *bufio.Writer
|
||||
}
|
||||
|
||||
func NewApp(configPath string, users UserManager) *App {
|
||||
return &App{
|
||||
configPath: configPath,
|
||||
users: users,
|
||||
reader: bufio.NewReader(os.Stdin),
|
||||
writer: bufio.NewWriter(os.Stdout),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) Run() error {
|
||||
doc, err := ParseConfigFile(a.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
for {
|
||||
shareSections := doc.ShareSections()
|
||||
a.println("")
|
||||
a.println("Samba share editor")
|
||||
a.println("==================")
|
||||
a.println("Current shares:")
|
||||
if len(shareSections) == 0 {
|
||||
a.println(" (none)")
|
||||
} else {
|
||||
for i, section := range shareSections {
|
||||
cfg := ShareFromSection(section)
|
||||
a.printf(" %d. %s -> %s\n", i+1, cfg.Name, cfg.Path)
|
||||
}
|
||||
}
|
||||
a.println("")
|
||||
a.println("[a] add share")
|
||||
a.println("[e] edit share")
|
||||
a.println("[d] delete share")
|
||||
a.println("[w] write config and exit")
|
||||
a.println("[q] quit without saving")
|
||||
a.flush()
|
||||
|
||||
choice, err := a.prompt("Select an action")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch strings.ToLower(choice) {
|
||||
case "a", "add":
|
||||
if err := a.addShare(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
case "e", "edit":
|
||||
if err := a.editShare(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
case "d", "delete":
|
||||
if err := a.deleteShare(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
case "w", "write":
|
||||
return a.writeConfig(doc)
|
||||
case "q", "quit":
|
||||
return nil
|
||||
default:
|
||||
a.println("Unknown choice.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) addShare(doc *Document) error {
|
||||
cfg, err := a.collectShareConfig(ShareConfig{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if doc.Section(cfg.Name) != nil {
|
||||
a.println("A share with that name already exists.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := a.ensureUsers(cfg.ValidUsers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
doc.UpsertSection(BuildShareSection(nil, cfg))
|
||||
a.println("Share added.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) editShare(doc *Document) error {
|
||||
section, err := a.selectShare(doc)
|
||||
if err != nil || section == nil {
|
||||
return err
|
||||
}
|
||||
|
||||
originalName := section.Name
|
||||
cfg, err := a.collectShareConfig(ShareFromSection(section))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.EqualFold(originalName, cfg.Name) && doc.Section(cfg.Name) != nil {
|
||||
a.println("A share with that new name already exists.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := a.ensureUsers(cfg.ValidUsers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.EqualFold(originalName, cfg.Name) {
|
||||
doc.DeleteSection(originalName)
|
||||
}
|
||||
doc.UpsertSection(BuildShareSection(section, cfg))
|
||||
a.println("Share updated.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) deleteShare(doc *Document) error {
|
||||
section, err := a.selectShare(doc)
|
||||
if err != nil || section == nil {
|
||||
return err
|
||||
}
|
||||
|
||||
confirm, err := a.confirm(fmt.Sprintf("Delete share %q", section.Name), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !confirm {
|
||||
a.println("Delete cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
doc.DeleteSection(section.Name)
|
||||
a.println("Share deleted.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) writeConfig(doc *Document) error {
|
||||
backup := fmt.Sprintf("%s.bak.%s", a.configPath, time.Now().UTC().Format("20060102T150405Z"))
|
||||
if err := copyFile(a.configPath, backup); err != nil {
|
||||
return fmt.Errorf("create backup %s: %w", backup, err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(a.configPath, []byte(doc.Serialize()), 0o644); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
|
||||
a.printf("Config written to %s\n", a.configPath)
|
||||
a.printf("Backup saved to %s\n", backup)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) selectShare(doc *Document) (*Section, error) {
|
||||
shares := doc.ShareSections()
|
||||
if len(shares) == 0 {
|
||||
a.println("There are no shares to select.")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
raw, err := a.prompt("Share number")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
index, err := strconv.Atoi(raw)
|
||||
if err != nil || index < 1 || index > len(shares) {
|
||||
a.println("Invalid selection.")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return shares[index-1], nil
|
||||
}
|
||||
|
||||
func (a *App) collectShareConfig(existing ShareConfig) (ShareConfig, error) {
|
||||
name, err := a.promptDefault("Share name", existing.Name)
|
||||
if err != nil {
|
||||
return ShareConfig{}, err
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ShareConfig{}, errors.New("share name cannot be empty")
|
||||
}
|
||||
|
||||
path, err := a.promptDefault("Path", existing.Path)
|
||||
if err != nil {
|
||||
return ShareConfig{}, err
|
||||
}
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ShareConfig{}, errors.New("path cannot be empty")
|
||||
}
|
||||
|
||||
comment, err := a.promptDefault("Comment", existing.Comment)
|
||||
if err != nil {
|
||||
return ShareConfig{}, err
|
||||
}
|
||||
|
||||
browseable, err := a.promptDefault("Browseable (yes/no)", defaultString(existing.Browseable, "yes"))
|
||||
if err != nil {
|
||||
return ShareConfig{}, err
|
||||
}
|
||||
|
||||
readOnly, err := a.promptDefault("Read only (yes/no)", defaultString(existing.ReadOnly, "no"))
|
||||
if err != nil {
|
||||
return ShareConfig{}, err
|
||||
}
|
||||
|
||||
guestOK, err := a.promptDefault("Guest ok (yes/no)", defaultString(existing.GuestOK, "no"))
|
||||
if err != nil {
|
||||
return ShareConfig{}, err
|
||||
}
|
||||
|
||||
usersDefault := strings.Join(existing.ValidUsers, ", ")
|
||||
usersRaw, err := a.promptDefault("Valid users (comma or space separated, blank for none)", usersDefault)
|
||||
if err != nil {
|
||||
return ShareConfig{}, err
|
||||
}
|
||||
|
||||
return ShareConfig{
|
||||
Name: name,
|
||||
Path: filepath.Clean(path),
|
||||
Comment: strings.TrimSpace(comment),
|
||||
Browseable: browseable,
|
||||
ReadOnly: readOnly,
|
||||
GuestOK: guestOK,
|
||||
ValidUsers: splitUsers(usersRaw),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) ensureUsers(users []string) error {
|
||||
for _, name := range users {
|
||||
if a.users.UserExists(name) {
|
||||
continue
|
||||
}
|
||||
|
||||
create, err := a.confirm(fmt.Sprintf("User %q does not exist. Create it with useradd", name), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !create {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := a.users.CreateUser(name); err != nil {
|
||||
return fmt.Errorf("create user %s: %w", name, err)
|
||||
}
|
||||
|
||||
a.printf("Created local user %s\n", name)
|
||||
a.println("If Samba authentication is required, add a Samba password with: smbpasswd -a " + name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) prompt(label string) (string, error) {
|
||||
a.printf("%s: ", label)
|
||||
a.flush()
|
||||
text, err := a.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(text), nil
|
||||
}
|
||||
|
||||
func (a *App) promptDefault(label, defaultValue string) (string, error) {
|
||||
if defaultValue == "" {
|
||||
return a.prompt(label)
|
||||
}
|
||||
|
||||
a.printf("%s [%s]: ", label, defaultValue)
|
||||
a.flush()
|
||||
text, err := a.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func (a *App) confirm(label string, defaultYes bool) (bool, error) {
|
||||
suffix := "y/N"
|
||||
if defaultYes {
|
||||
suffix = "Y/n"
|
||||
}
|
||||
|
||||
answer, err := a.prompt(fmt.Sprintf("%s [%s]", label, suffix))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if answer == "" {
|
||||
return defaultYes, nil
|
||||
}
|
||||
|
||||
switch strings.ToLower(answer) {
|
||||
case "y", "yes":
|
||||
return true, nil
|
||||
case "n", "no":
|
||||
return false, nil
|
||||
default:
|
||||
return false, errors.New("expected yes or no")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) println(line string) {
|
||||
fmt.Fprintln(a.writer, line)
|
||||
}
|
||||
|
||||
func (a *App) printf(format string, args ...any) {
|
||||
fmt.Fprintf(a.writer, format, args...)
|
||||
}
|
||||
|
||||
func (a *App) flush() {
|
||||
_ = a.writer.Flush()
|
||||
}
|
||||
|
||||
func defaultString(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, data, 0o644)
|
||||
}
|
||||
Reference in New Issue
Block a user