From dff4d455e07d639b82a0bed814f41d0656e9b6d0 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 21 Sep 2024 14:03:45 +0300 Subject: cleanup --- Taskfile.yml | 2 - cmd/gos/main.go | 35 --- cmd/gosd/main.go | 64 ----- docs/sample-payload.json | 3 - examples/gos.json | 1 - examples/gosd.json | 9 - internal/client/tui/compose.go | 64 ----- internal/client/tui/finishedmsg.go | 21 -- internal/client/tui/submit.go | 51 ---- internal/client/tui/tui.go | 136 ---------- internal/config/client/client.go | 41 --- internal/config/config.go | 38 --- internal/config/config_test.go | 195 -------------- internal/config/enver.go | 104 -------- internal/config/server/mastodon.go | 1 - internal/config/server/secrets.go | 44 ---- internal/config/server/server.go | 62 ----- internal/easyhttp/easyhttp.go | 110 -------- internal/easyhttp/saferrors.go | 31 --- internal/server/cron/cron.go | 45 ---- internal/server/handler/handler.go | 72 ----- internal/server/health/health.go | 107 -------- internal/server/health/health_test.go | 24 -- internal/server/repository/pending.go | 57 ---- internal/server/repository/pending_test.go | 66 ----- internal/server/repository/repository.go | 320 ----------------------- internal/server/repository/repository_test.go | 363 -------------------------- internal/server/repository/stats.go | 22 -- internal/server/scheduler/scheduler.go | 16 -- internal/server/server.go | 49 ---- internal/social/mastodon.go | 15 -- internal/types/entry.go | 199 -------------- internal/types/entry_test.go | 145 ---------- internal/types/platformname.go | 9 - internal/types/shared.go | 29 -- internal/types/shared_test.go | 22 -- internal/vfs/memoryfs.go | 34 --- internal/vfs/memoryfs_test.go | 108 -------- internal/vfs/realfs.go | 44 ---- 39 files changed, 2758 deletions(-) delete mode 100644 cmd/gos/main.go delete mode 100644 cmd/gosd/main.go delete mode 100644 docs/sample-payload.json delete mode 100644 examples/gos.json delete mode 100644 examples/gosd.json delete mode 100644 internal/client/tui/compose.go delete mode 100644 internal/client/tui/finishedmsg.go delete mode 100644 internal/client/tui/submit.go delete mode 100644 internal/client/tui/tui.go delete mode 100644 internal/config/client/client.go delete mode 100644 internal/config/config.go delete mode 100644 internal/config/config_test.go delete mode 100644 internal/config/enver.go delete mode 100644 internal/config/server/mastodon.go delete mode 100644 internal/config/server/secrets.go delete mode 100644 internal/config/server/server.go delete mode 100644 internal/easyhttp/easyhttp.go delete mode 100644 internal/easyhttp/saferrors.go delete mode 100644 internal/server/cron/cron.go delete mode 100644 internal/server/handler/handler.go delete mode 100644 internal/server/health/health.go delete mode 100644 internal/server/health/health_test.go delete mode 100644 internal/server/repository/pending.go delete mode 100644 internal/server/repository/pending_test.go delete mode 100644 internal/server/repository/repository.go delete mode 100644 internal/server/repository/repository_test.go delete mode 100644 internal/server/repository/stats.go delete mode 100644 internal/server/scheduler/scheduler.go delete mode 100644 internal/server/server.go delete mode 100644 internal/social/mastodon.go delete mode 100644 internal/types/entry.go delete mode 100644 internal/types/entry_test.go delete mode 100644 internal/types/platformname.go delete mode 100644 internal/types/shared.go delete mode 100644 internal/types/shared_test.go delete mode 100644 internal/vfs/memoryfs.go delete mode 100644 internal/vfs/memoryfs_test.go delete mode 100644 internal/vfs/realfs.go diff --git a/Taskfile.yml b/Taskfile.yml index df9afcc..cdcc51f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -11,12 +11,10 @@ tasks: GOS_EDITOR: hx build: cmds: - - go build -o gosd cmd/gosd/main.go - go build -o gos cmd/gos/main.go dev: deps: ["test", "vet", "lint"] cmds: - - go build -race -o gosd cmd/gosd/main.go - go build -race -o gos cmd/gos/main.go test: cmds: diff --git a/cmd/gos/main.go b/cmd/gos/main.go deleted file mode 100644 index 7042755..0000000 --- a/cmd/gos/main.go +++ /dev/null @@ -1,35 +0,0 @@ -package main - -import ( - "flag" - "log" - "os" - - "codeberg.org/snonux/gos/internal/client/tui" - config "codeberg.org/snonux/gos/internal/config/client" -) - -func main() { - configFile := flag.String("cfg", "/etc/gos.json", "The configuration file") - flag.Parse() - - conf, err := config.New(*configFile) - if err != nil { - log.Fatal("error building config:", err) - } - - var logFD *os.File - if conf.LogFile != "" { - var err error - logFD, err = os.OpenFile(conf.LogFile, os.O_APPEND|os.O_RDWR|os.O_CREATE, 0644) - if err != nil { - panic(err) - } - log.SetOutput(logFD) - } - defer logFD.Close() - - if err := tui.Run(conf); err != nil { - log.Fatal("error running TUI:", err) - } -} diff --git a/cmd/gosd/main.go b/cmd/gosd/main.go deleted file mode 100644 index baf62ae..0000000 --- a/cmd/gosd/main.go +++ /dev/null @@ -1,64 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "log" - "net/http" - - config "codeberg.org/snonux/gos/internal/config/server" - "codeberg.org/snonux/gos/internal/server" - "codeberg.org/snonux/gos/internal/server/cron" - "codeberg.org/snonux/gos/internal/server/handler" - "codeberg.org/snonux/gos/internal/server/health" -) - -func main() { - configFile := flag.String("cfg", "/etc/gosd.json", "The configuration file") - secretsFile := flag.String("secrets", "/etc/gosdsecrets.json", "The secrets file") - - flag.Parse() - - conf, err := config.New(*configFile, *secretsFile) - if err != nil { - log.Fatal("error building config:", err) - } - - var ( - status = health.NewStatus() - serv = server.New(conf, status) - hand = handler.New(conf) - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - go cron.Run(ctx, conf, status) - - serv.Handle("health", func(w http.ResponseWriter, r *http.Request) error { - fmt.Fprint(w, serv.Status.String()) - return nil - }) - - serv.Handle("submit", func(w http.ResponseWriter, r *http.Request) error { - return hand.Submit(ctx, w, r) - }) - - serv.Handle("list", func(w http.ResponseWriter, r *http.Request) error { - return hand.List(w, r) - }) - - serv.Handle("get", func(w http.ResponseWriter, r *http.Request) error { - return hand.Get(w, r) - }) - - serv.Handle("merge", func(w http.ResponseWriter, r *http.Request) error { - return hand.Merge(ctx, w, r) - }) - - log.Println("Server is starting on", conf.ListenAddr) - if err := http.ListenAndServe(conf.ListenAddr, nil); err != err { - log.Fatal("error starting server:", err) - } -} diff --git a/docs/sample-payload.json b/docs/sample-payload.json deleted file mode 100644 index 470d21f..0000000 --- a/docs/sample-payload.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "body": "hello world man" -} diff --git a/examples/gos.json b/examples/gos.json deleted file mode 100644 index ffcd441..0000000 --- a/examples/gos.json +++ /dev/null @@ -1 +0,0 @@ -{ } diff --git a/examples/gosd.json b/examples/gosd.json deleted file mode 100644 index 27fe548..0000000 --- a/examples/gosd.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "ListenAddr": "127.0.0.1:8080", - "Partners": ["partner1", "partner2"], - "ApiKey": "", - "DataDir": "./data", - "EmailTo": "recipient@example.com", - "EmailFrom": "sender@example.com", - "SMTPServer": "smtp.example.com:587" -} diff --git a/internal/client/tui/compose.go b/internal/client/tui/compose.go deleted file mode 100644 index 61883f8..0000000 --- a/internal/client/tui/compose.go +++ /dev/null @@ -1,64 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "log" - "os" - "os/exec" - "time" - - config "codeberg.org/snonux/gos/internal/config/client" - tea "github.com/charmbracelet/bubbletea" -) - -type composePostAction int - -const ( - noPostAction composePostAction = iota - queueAfterCompose - submitAfterCompose -) - -func composeActionCmd(ctx context.Context, conf config.ClientConfig, postAction composePostAction) tea.Cmd { - err := ensureDirectoryExists(conf.DataDir) - composeFile := fmt.Sprintf("%s/%s", conf.DataDir, conf.ComposeFile) - log.Println("Composing", composeFile) - - return openEditorCmd(conf.Editor, composeFile, func() error { - if err != nil { - return err - } - - switch postAction { - case submitAfterCompose: - return submitEntry(ctx, conf, composeFile) - case queueAfterCompose: - timestamp := time.Now().Format("20060102-150405") - queuedFile := fmt.Sprintf("%s/queued-%s.txt", conf.DataDir, timestamp) - return os.Rename(composeFile, queuedFile) - } - - return nil - }) -} - -func openEditorCmd(editor, filePath string, cb func() error) tea.Cmd { - return tea.ExecProcess(exec.Command(editor, filePath), func(err error) tea.Msg { - return finishedMsg{ - cb: cb, - err: err, - } - }) -} - -func ensureDirectoryExists(dir string) error { - info, err := os.Stat(dir) - if err != nil && os.IsNotExist(err) { - return os.MkdirAll(dir, os.ModePerm) - } - if info.IsDir() { - return nil - } - return fmt.Errorf("path %s is not a directory", dir) -} diff --git a/internal/client/tui/finishedmsg.go b/internal/client/tui/finishedmsg.go deleted file mode 100644 index d1e9165..0000000 --- a/internal/client/tui/finishedmsg.go +++ /dev/null @@ -1,21 +0,0 @@ -package tui - -import tea "github.com/charmbracelet/bubbletea" - -type finishedMsg struct { - cb func() error - err error -} - -func (f finishedMsg) Error() string { - return f.err.Error() -} - -func finishedCmd(cb func() error, err error) tea.Cmd { - return func() tea.Msg { - return finishedMsg{ - cb: cb, - err: err, - } - } -} diff --git a/internal/client/tui/submit.go b/internal/client/tui/submit.go deleted file mode 100644 index cc1ff0d..0000000 --- a/internal/client/tui/submit.go +++ /dev/null @@ -1,51 +0,0 @@ -package tui - -import ( - "context" - "errors" - "fmt" - "log" - "os" - "time" - - "codeberg.org/snonux/gos/internal/config/client" - config "codeberg.org/snonux/gos/internal/config/client" - "codeberg.org/snonux/gos/internal/easyhttp" - "codeberg.org/snonux/gos/internal/types" - tea "github.com/charmbracelet/bubbletea" -) - -func submitActionCmd(ctx context.Context, conf config.ClientConfig) tea.Cmd { - composeFile := fmt.Sprintf("%s/%s", conf.DataDir, conf.ComposeFile) - log.Println("Submitting", composeFile) - - return submitEntryCmd(ctx, conf, composeFile, func() error { - // This is the cb to call when the entry was submitted succesfully - return nil - }) -} - -func submitEntryCmd(ctx context.Context, conf client.ClientConfig, composeFile string, cb func() error) tea.Cmd { - return finishedCmd(cb, submitEntry(ctx, conf, composeFile)) -} - -func submitEntry(ctx context.Context, conf client.ClientConfig, composeFile string) error { - if len(conf.Servers) == 0 { - return errors.New("no server configured") - } - - entry, err := types.NewEntryFromTextFile(composeFile) - if err != nil { - return err - } - - if err := easyhttp.PostData(ctx, "submit", conf.APIKey, &entry, conf.Servers...); err != nil { - return err - } - - timestamp := time.Now().Format("20060102-150405") - submittedFile := fmt.Sprintf("%s/submitted-%s.txt", conf.DataDir, timestamp) - - log.Println("Renaming", composeFile, "to", submittedFile) - return os.Rename(composeFile, submittedFile) -} diff --git a/internal/client/tui/tui.go b/internal/client/tui/tui.go deleted file mode 100644 index c084d9c..0000000 --- a/internal/client/tui/tui.go +++ /dev/null @@ -1,136 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "log" - - config "codeberg.org/snonux/gos/internal/config/client" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -var style = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - Background(lipgloss.Color("#0000FF")). - PaddingTop(2).PaddingLeft(4).PaddingRight(4). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#FFFFFF")). - BorderBackground(lipgloss.Color("#0000FF")) - -var errroStyle = lipgloss.NewStyle(). - Bold(true). - Align(lipgloss.Center). - Foreground(lipgloss.Color("#FFFFFF")). - Background(lipgloss.Color("#FF0000")). - PaddingTop(0).PaddingBottom(0).PaddingLeft(2).PaddingRight(2). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#FFFFFF")). - BorderBackground(lipgloss.Color("#FF0000")) - -func Run(conf config.ClientConfig) error { - p := tea.NewProgram(initModel(conf)) - _, err := p.Run() - return err -} - -type model struct { - choices []string - cursor int - conf config.ClientConfig - altscreenActive bool - ctx context.Context - err error -} - -const ( - cursorCompose = iota - cursorSubmit - cursorComposeAndSubmit -) - -func initModel(conf config.ClientConfig) model { - return model{ - choices: []string{"Compose post", "Submit post", "Compose & submit post"}, - ctx: context.Background(), - conf: conf, - } -} - -func (m model) Init() tea.Cmd { - return nil -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - if m.cursor < len(m.choices)-1 { - m.cursor++ - } - case "enter": - switch m.cursor { - case cursorCompose: - return m, composeActionCmd(m.ctx, m.conf, noPostAction) - case cursorSubmit: - return m, submitActionCmd(m.ctx, m.conf) - case cursorComposeAndSubmit: - return m, composeActionCmd(m.ctx, m.conf, submitAfterCompose) - } - case "1": - return m, composeActionCmd(m.ctx, m.conf, noPostAction) - case "2": - return m, submitActionCmd(m.ctx, m.conf) - case "3": - return m, composeActionCmd(m.ctx, m.conf, submitAfterCompose) - - case "a": - m.altscreenActive = !m.altscreenActive - cmd := tea.EnterAltScreen - if !m.altscreenActive { - cmd = tea.ExitAltScreen - } - return m, cmd - case "ctrl+c", "q": - return m, tea.Quit - } - case finishedMsg: - m.err = msg.err - if m.err != nil { - return m, nil - } - if err := msg.cb(); err != nil { - m.err = err - } - } - - return m, nil -} - -func (m model) View() string { - s := "Please choose your destiny\n\n" - - for i, choice := range m.choices { - cursor := " " // no cursor - if m.cursor == i { - cursor = "==>" - } - - s += fmt.Sprintf("%s %d. %s\n", cursor, i+1, choice) - } - - if m.err != nil { - log.Println(m.err) - s += "\n" - s += errroStyle.Render(fmt.Sprintf("\nERROR: %s\n", m.err)) - s += "\n" - } - - s += "\nPress q to quit.\n" - return style.Render(s) -} diff --git a/internal/config/client/client.go b/internal/config/client/client.go deleted file mode 100644 index f551686..0000000 --- a/internal/config/client/client.go +++ /dev/null @@ -1,41 +0,0 @@ -package client - -import ( - "fmt" - "log" - "os" - - "codeberg.org/snonux/gos/internal/config" -) - -type ClientConfig struct { - Servers []string `json:"Servers,omitempty"` - APIKey string `json:"APIKey,omitempty"` - Editor string `json:"Editor,omitempty"` - DataDir string `json:"StateDir,omitempty"` - ComposeFile string `json:"ComposeFile,omitempty"` - LogFile string `json:"LogFile,omitempty"` -} - -func New(configFile string) (ClientConfig, error) { - conf, err := config.FromFile[ClientConfig](configFile) - if err != nil { - if _, ok := err.(*os.PathError); !ok { - return conf, err - } - log.Println("Skipping config file:", err) - } - - conf.Servers = config.StrSlice("GOS_SERVERS", conf.Servers) - conf.APIKey = config.Str("GOS_API_KEY", conf.APIKey) - conf.Editor = config.Str("GOS_EDITOR", "EDITOR", conf.Editor, "vi") - - defaultDataDir := fmt.Sprintf("%s/.gos/data", os.Getenv("HOME")) - conf.DataDir = config.Str("GOS_DATA_DIR", conf.DataDir, defaultDataDir) - conf.ComposeFile = config.Str("GOS_COMPOSE_FILE", conf.ComposeFile, "compose.txt") - - defaultLogFile := fmt.Sprintf("%s/.gos/gos.log", os.Getenv("HOME")) - conf.LogFile = config.Str("GOS_LOG_FILE", conf.LogFile, defaultLogFile) - - return conf, nil -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 6f697e7..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,38 +0,0 @@ -package config - -import ( - "encoding/json" - "io" - "os" - "unicode" -) - -func FromFile[T any](configFile string) (T, error) { - var conf T - - file, err := os.Open(configFile) - if err != nil { - return conf, err - } - defer file.Close() - - bytes, err := io.ReadAll(file) - if err != nil { - return conf, err - } - - err = json.Unmarshal(bytes, &conf) - return conf, err -} - -func isAllUpperCase(s string) bool { - for _, r := range s { - if unicode.IsLetter(r) && !unicode.IsUpper(r) { - return false - } - if unicode.IsDigit(r) { - return false - } - } - return true -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index eca163f..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package config - -import ( - "os" - "slices" - "testing" -) - -func TestEnvToStr(t *testing.T) { - t.Parallel() - - os.Unsetenv("NON_EXISTENT_ENV") - os.Setenv("GOS_TEST_FROM_ENV", "foobarbaz") - - var ( - expected = "foobarbaz" - got = Str("GOS_TEST_FROM_ENV") - ) - - if got != expected { - t.Errorf("got '%s' but expected '%s'", got, expected) - } - - expected = "default value" - got = Str("NON_EXISTENT_ENV", expected) - if got != expected { - t.Errorf("got '%s' but expected '%s'", got, expected) - } - - if got = Str("NON_EXISTENT_ENV"); got != "" { - t.Errorf("got '%s' but expected empty string", got) - } - - expected = "casio g-shock" - os.Setenv("GOS_WATCH", expected) - got = Str("GOS_WATCH", "", "", "", expected, "") - if got != expected { - t.Errorf("got '%s' but expected '%s'", got, expected) - } -} - -func TestEnvToStrSlice(t *testing.T) { - t.Parallel() - - os.Setenv("GOS_TEST_SLICE_FROM_ENV", "foo,bar,baz") - - var ( - expected = []string{"foo", "bar", "baz"} - got = StrSlice("GOS_TEST_SLICE_FROM_ENV") - ) - if !slices.Equal(got, expected) { - t.Errorf("got '%v' but expected '%v'", got, expected) - } - - expected = []string{"default value"} - got = StrSlice("NON_EXISTENT_ENV_SLICE", "default value") - if !slices.Equal(got, expected) { - t.Errorf("got '%v' but expected '%v'", got, expected) - } - - os.Unsetenv("NON_EXISTENT_ENV") - if got = StrSlice("NON_EXISTENT_ENV"); len(got) > 0 { - t.Errorf("got '%s' of len '%d' but expected empty slice", got, len(got)) - } - - expected = []string{"casio", "g-shock"} - got = StrSlice("NON_EXISTENT_ENV", "", "", "", "casio,g-shock", "") - if !slices.Equal(got, expected) { - t.Errorf("got '%v' but expected '%v'", got, expected) - } -} - -func TestEnvToInt(t *testing.T) { - t.Parallel() - - os.Unsetenv("NON_EXISTENT_ENV") - os.Setenv("GOS_TEST_INT_FROM_ENV", "1") - - var ( - expected = 1 - got = Int(t, "GOS_TEST_INT_FROM_ENV") - ) - - if got != expected { - t.Errorf("got '%d' but expected '%d'", got, expected) - } - - expected = 999 - got = Int("NON_EXISTENT_ENV", expected) - if got != expected { - t.Errorf("got '%d' but expected '%d'", got, expected) - } - - if got = Int("NON_EXISTENT_ENV"); got != 0 { - t.Errorf("got '%d' but expected zero", got) - } - - expected = 1234 - got = Int("GOS_WATCH", "", "", "", expected, "") - if got != expected { - t.Errorf("got '%d' but expected '%d'", got, expected) - } -} - -func TestEnvToBool(t *testing.T) { - t.Parallel() - - os.Unsetenv("NON_EXISTENT_ENV") - os.Setenv("GOS_TEST_BOOL_FROM_ENV", "true") - - var ( - expected = true - got = Bool("GOS_TEST_BOOL_FROM_ENV") - ) - - if got != expected { - t.Errorf("got '%t' but expected '%t'", got, expected) - } - - expected = false - got = Bool("NON_EXISTENT_ENV", expected) - if got != expected { - t.Errorf("got '%t' but expected '%t'", got, expected) - } - - if got = Bool("NON_EXISTENT_ENV"); got { - t.Errorf("got '%t' but expected false", got) - } - - expected = true - got = Bool("NON_EXISTENT_ENV", "", "", "", expected, "") - if got != expected { - t.Errorf("got '%t' but expected '%t'", got, expected) - } -} - -func TestSecondENV(t *testing.T) { - t.Parallel() - - os.Unsetenv("GOS_NONEXISTANT") - os.Setenv("EDITOR", "hx") - - var ( - expected = "hx" - got = Str("GOS_NONEXISTANT", "EDITOR", "notepad.exe") - ) - - if expected != got { - t.Errorf("got '%s' but expected '%s'", got, expected) - } -} - -func TestIsAllUpperCase(t *testing.T) { - if isAllUpperCase("foo_bar") { - t.Errorf("lowercas letters in test case") - } - if isAllUpperCase("FOO123") { - t.Errorf("numbers in string should not evaluate to is all upper") - } - if !isAllUpperCase("FOO_BAR") { - t.Errorf("should be all upper") - } -} - -func TestDefaultStrCB(t *testing.T) { - t.Parallel() - os.Unsetenv("GOS_NONEXISTANT") - - var ( - expected = "hello" - got = Str("GOS_NONEXISTANT", func() string { - return "hello" - }) - ) - - if expected != got { - t.Errorf("got '%s' but expected '%s'", got, expected) - } -} - -func TestDefaultIntCB(t *testing.T) { - t.Parallel() - os.Unsetenv("GOS_NONEXISTANT") - - var ( - expected = 666 - got = Int("GOS_NONEXISTANT", func() int { - return 666 - }) - ) - - if expected != got { - t.Errorf("got '%d' but expected '%d'", got, expected) - } -} diff --git a/internal/config/enver.go b/internal/config/enver.go deleted file mode 100644 index f3f8d29..0000000 --- a/internal/config/enver.go +++ /dev/null @@ -1,104 +0,0 @@ -package config - -import ( - "os" - "strconv" - "strings" -) - -type configTypes interface { - ~int | ~bool | ~string | []string -} - -type enver[T configTypes] interface { - fromStr(value string) (T, error) // Return T value from input string - zero() T // Return T's zero value -} - -func Str(keys ...any) string { - return fromEnv[toStr](keys...) -} - -func StrSlice(keys ...any) []string { - return fromEnv[toStrSlice](keys...) -} - -func Int(keys ...any) int { - return fromEnv[toInt](keys...) -} - -func Bool(keys ...any) bool { - return fromEnv[toBool](keys...) -} - -func fromEnv[U enver[T], T configTypes](keys ...any) T { - var enver U - - for _, key := range keys { - switch key := key.(type) { - case string: - if key == "" { - continue - } - if !isAllUpperCase(key) { - if val, err := enver.fromStr(key); err == nil { - return val - } - } else if strVal := os.Getenv(key); strVal != "" { - if val, err := enver.fromStr(strVal); err == nil { - return val - } - } - case T: - return key - case func() T: - return key() - } - } - - return enver.zero() -} - -type toStr struct{} - -func (toStr) fromStr(str string) (string, error) { - return str, nil -} - -func (toStr) zero() string { - return "" -} - -type toStrSlice struct{} - -func (s toStrSlice) fromStr(str string) ([]string, error) { - result := strings.Split(str, ",") - if len(result) == 1 && result[0] == "" { - return s.zero(), nil - } - return result, nil -} - -func (toStrSlice) zero() []string { - return []string{} -} - -type toInt struct{} - -func (toInt) fromStr(str string) (int, error) { - return strconv.Atoi(str) -} - -func (toInt) zero() int { - return 0 -} - -type toBool struct{} - -func (toBool) fromStr(str string) (bool, error) { - return strconv.ParseBool(str) -} - -func (toBool) zero() bool { - return false -} diff --git a/internal/config/server/mastodon.go b/internal/config/server/mastodon.go deleted file mode 100644 index abb4e43..0000000 --- a/internal/config/server/mastodon.go +++ /dev/null @@ -1 +0,0 @@ -package server diff --git a/internal/config/server/secrets.go b/internal/config/server/secrets.go deleted file mode 100644 index 0e8aab2..0000000 --- a/internal/config/server/secrets.go +++ /dev/null @@ -1,44 +0,0 @@ -package server - -import ( - "fmt" - "log" - "os" - - "codeberg.org/snonux/gos/internal/config" -) - -type SecretsConfig struct { - MastodonEnable bool `json:"MastodonEnable,omitempty"` - MastodonDomain string `json:"MastodonDomain,omitempty"` - MastodonAccessToken string `json:"MastodonAccessToken,omitempty"` -} - -func newSecretsConfig(secretsFile string) (SecretsConfig, error) { - if isWorldReadable(secretsFile) { - return SecretsConfig{}, fmt.Errorf("config '%s' is world readable", secretsFile) - } - - conf, err := config.FromFile[SecretsConfig](secretsFile) - if err != nil { - if _, ok := err.(*os.PathError); !ok { - return conf, err - } - log.Println("Skipping config file:", err) - } - - conf.MastodonEnable = config.Bool("GOS_MASTODON_ENABLE", conf.MastodonEnable) - conf.MastodonDomain = config.Str("GOS_MASTODON_DOMAIN", conf.MastodonDomain) - conf.MastodonAccessToken = config.Str("GOS_MASTODON_ACCESS_TOKEN", conf.MastodonAccessToken) - - return conf, nil -} - -func isWorldReadable(file string) bool { - fileInfo, err := os.Stat(file) - if err != nil { - return false - } - - return fileInfo.Mode().Perm()&00004 != 0 -} diff --git a/internal/config/server/server.go b/internal/config/server/server.go deleted file mode 100644 index 16da14c..0000000 --- a/internal/config/server/server.go +++ /dev/null @@ -1,62 +0,0 @@ -package server - -import ( - "fmt" - "log" - "os" - - "codeberg.org/snonux/gos/internal/config" - "codeberg.org/snonux/gos/internal/types" -) - -type ServerConfig struct { - ListenAddr string `json:"ListenAddr,omitempty"` - Partners []string `json:"Partners,omitempty"` - APIKey string `json:"APIKey,omitempty"` - DataDir string `json:"StateDir,omitempty"` - EmailTo string `json:"EmailTo,omitempty"` - EmailFrom string `json:"EmailFrom,omitempty"` - SMTPServer string `json:"SMTPServer,omitempty"` - MergeIntervalS int `json:"MergeInterval,omitempty"` - ScheduleIntervalS int `json:"ScheduleInterval,omitempty"` - SocialPlatformsEnabled []string `json:"SocialPlatformsEnabled,omitempty"` - PostsPerWeek int `json:"PostsPerWeek,omitempty"` - Secrets SecretsConfig `json:"Secrets,omitempty"` -} - -func New(configFile, secretsFile string) (ServerConfig, error) { - conf, err := config.FromFile[ServerConfig](configFile) - if err != nil { - if _, ok := err.(*os.PathError); !ok { - return conf, err - } - log.Println("Skipping config file:", err) - } - - if conf.Secrets, err = newSecretsConfig(secretsFile); err != nil { - return conf, err - } - - conf.ListenAddr = config.Str("GOS_LISTEN_ADDR", conf.ListenAddr, "localhost:8080") - conf.Partners = config.StrSlice("GOS_PARTNERS", conf.Partners) - conf.APIKey = config.Str("GOS_API_KEY", conf.APIKey) - conf.DataDir = config.Str("GOS_DATA_DIR", conf.DataDir, "data") - conf.EmailTo = config.Str("GOS_EMAIL_TO", conf.EmailTo) - conf.EmailFrom = config.Str("GOS_EMAIL_FROM", conf.EmailFrom) - conf.SocialPlatformsEnabled = config.StrSlice("GOS_SOCIAL_PLATFORMS_ENABLED", - []string{types.Mastodon, types.LinkedIn, types.Textfile}) - conf.PostsPerWeek = config.Int("GOS_POSTS_PER_WEEK", conf.PostsPerWeek, 2) - conf.SMTPServer = config.Str("GOS_SMTP_SERVER", conf.SMTPServer, func() string { - hostname, err := os.Hostname() - if err != nil { - log.Fatal(err) - } - return fmt.Sprintf("%s:25", hostname) - }) - - const oneHour = 3600 - conf.MergeIntervalS = config.Int("GOS_MERGE_INTERVAL", oneHour) - conf.ScheduleIntervalS = config.Int("GOS_SCHEDULER_INTERVAL", oneHour*6) - - return conf, nil -} diff --git a/internal/easyhttp/easyhttp.go b/internal/easyhttp/easyhttp.go deleted file mode 100644 index 2968454..0000000 --- a/internal/easyhttp/easyhttp.go +++ /dev/null @@ -1,110 +0,0 @@ -package easyhttp - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "sync" -) - -func Get(ctx context.Context, uri, apiKey string) ([]byte, error) { - var ( - client = &http.Client{} - bytes []byte - ) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) - if err != nil { - return bytes, fmt.Errorf("%s: %w", uri, err) - } - - req.Header.Set("X-API-KEY", apiKey) - - resp, err := client.Do(req) - if err != nil { - return bytes, fmt.Errorf("%s: %w", uri, err) - } - defer resp.Body.Close() - - bytes, err = io.ReadAll(resp.Body) - if err != nil { - return bytes, fmt.Errorf("%s: %w", uri, err) - } - - return bytes, nil -} - -// Get data from JSON -func GetData[T any](ctx context.Context, uri, apiKey string, data *T) error { - bytes, err := Get(ctx, uri, apiKey) - if err != nil { - return err - } - - return json.Unmarshal(bytes, data) -} - -func Post(ctx context.Context, uri, apiKey string, data []byte) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, uri, bytes.NewBuffer(data)) - if err != nil { - return []byte{}, fmt.Errorf("%s: %w", uri, err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-API-KEY", apiKey) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return []byte{}, fmt.Errorf("%s: %w", uri, err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return []byte{}, fmt.Errorf("%s: %w", uri, err) - } - - switch resp.StatusCode { - case 200: - return body, nil - case 401: - return body, fmt.Errorf("unauthorized, API key configured?") - default: - return body, fmt.Errorf("unexpected HTTP response code %d", resp.StatusCode) - } -} - -// Submit structure as JSON to API -func PostData[T any](ctx context.Context, uri, apiKey string, data *T, servers ...string) error { - if len(servers) == 0 { - return fmt.Errorf("no server configured") - } - var errs safErrors - var wg sync.WaitGroup - - for _, server := range servers { - log.Println("Submitting data to", server) - wg.Add(1) - go func(server string) { - defer wg.Done() - errs.Append(postData[T](ctx, fmt.Sprintf("%s/%s", server, uri), apiKey, data)) - }(server) - } - - wg.Wait() - return errs.Join() -} - -func postData[T any](ctx context.Context, uri, apiKey string, data *T) error { - jsonData, err := json.Marshal(data) - if err != nil { - return err - } - _, err = Post(ctx, uri, apiKey, jsonData) - return err -} diff --git a/internal/easyhttp/saferrors.go b/internal/easyhttp/saferrors.go deleted file mode 100644 index 464c90d..0000000 --- a/internal/easyhttp/saferrors.go +++ /dev/null @@ -1,31 +0,0 @@ -package easyhttp - -import ( - "errors" - "sync" -) - -// Safe errors -type safErrors struct { - errs []error - mutex sync.Mutex -} - -func (errs *safErrors) Append(err error) { - if err == nil { - return - } - errs.mutex.Lock() - defer errs.mutex.Unlock() - errs.errs = append(errs.errs, err) -} - -func (errs *safErrors) Join() error { - errs.mutex.Lock() - defer errs.mutex.Unlock() - return errors.Join(errs.errs...) -} - -func (errs *safErrors) Error() string { - return errs.Join().Error() -} diff --git a/internal/server/cron/cron.go b/internal/server/cron/cron.go deleted file mode 100644 index 3d6b9cf..0000000 --- a/internal/server/cron/cron.go +++ /dev/null @@ -1,45 +0,0 @@ -package cron - -import ( - "context" - "log" - "time" - - config "codeberg.org/snonux/gos/internal/config/server" - "codeberg.org/snonux/gos/internal/server/health" - "codeberg.org/snonux/gos/internal/server/repository" - "codeberg.org/snonux/gos/internal/server/scheduler" -) - -func Run(ctx context.Context, conf config.ServerConfig, status health.Status) { - helloTicker := time.NewTicker(time.Hour) - mergeTicker := time.NewTicker(time.Second * time.Duration(conf.MergeIntervalS)) - scheduleTicker := time.NewTicker(time.Second * time.Duration(conf.ScheduleIntervalS)) - - for { - select { - case <-ctx.Done(): - return - case <-helloTicker.C: - run(ctx, "cron->Hello", status, func(ctx context.Context) error { - log.Println("hello world") - return nil - }) - case <-mergeTicker.C: - run(ctx, "cron->repository.Merge", status, repository.Instance(conf).MergeRemotely) - case <-scheduleTicker.C: - run(ctx, "cron->scheduler.Run", status, func(ctx context.Context) error { - return scheduler.Run(ctx, conf) - }) - } - } -} - -func run(ctx context.Context, what string, status health.Status, cb func(ctx context.Context) error) { - log.Println("CRON ticker initiating", what) - if err := cb(ctx); err != nil { - status.Set(health.Critical, what, err) - return - } - status.Clear(what) -} diff --git a/internal/server/handler/handler.go b/internal/server/handler/handler.go deleted file mode 100644 index a108f93..0000000 --- a/internal/server/handler/handler.go +++ /dev/null @@ -1,72 +0,0 @@ -package handler - -import ( - "context" - "fmt" - "io" - "net/http" - - "codeberg.org/snonux/gos/internal/config/server" - "codeberg.org/snonux/gos/internal/server/repository" - "codeberg.org/snonux/gos/internal/types" -) - -type Handler struct { - conf server.ServerConfig -} - -func New(conf server.ServerConfig) Handler { - return Handler{ - conf: conf, - } -} - -func (h Handler) Submit(ctx context.Context, w http.ResponseWriter, r *http.Request) error { - if r.Method != "POST" { - return fmt.Errorf("expected POST request, but got %s", r.Method) - } - - bytes, err := io.ReadAll(r.Body) - if err != nil { - return err - } - - entry, err := types.NewEntry(bytes) - if err != nil { - return err - } - return repository.Instance(h.conf).Merge(entry) -} - -func (h Handler) List(w http.ResponseWriter, r *http.Request) error { - if r.Method != "GET" { - return fmt.Errorf("expexted GET request") - } - - list, err := repository.Instance(h.conf).ListBytes() - if err != nil { - return err - } - - _, err = w.Write(list) - return err -} - -func (h Handler) Get(w http.ResponseWriter, r *http.Request) error { - json, err := repository.Instance(h.conf).GetJSON(r.URL.Query().Get("id")) - if err != nil { - return err - } - - fmt.Fprint(w, json) - return nil -} - -func (h Handler) Merge(ctx context.Context, w http.ResponseWriter, r *http.Request) error { - if err := repository.Instance(h.conf).MergeRemotely(ctx); err != nil { - return err - } - - fmt.Fprint(w, "Repository merge went well") - return nil -} diff --git a/internal/server/health/health.go b/internal/server/health/health.go deleted file mode 100644 index 5144416..0000000 --- a/internal/server/health/health.go +++ /dev/null @@ -1,107 +0,0 @@ -package health - -import ( - "fmt" - "log" - "strings" - "sync" -) - -type Severity int - -const ( - OK Severity = iota - Warning - Critical - Unknown -) - -func (s Severity) String() string { - switch s { - case OK: - return "OK" - case Warning: - return "WARNING" - case Critical: - return "CRITICAL" - case Unknown: - fallthrough - default: - return "UNKNOWN" - } -} - -type alert struct { - text string - severity Severity -} - -func (a alert) String() string { - return fmt.Sprintf("%s: %s", a.severity, a.text) -} - -type Status struct { - alerts map[string]alert - mu *sync.Mutex -} - -func NewStatus() Status { - return Status{ - alerts: make(map[string]alert), - mu: &sync.Mutex{}, - } -} - -func (hs Status) Set(s Severity, healthStatusKey string, info any) { - hs.mu.Lock() - defer hs.mu.Unlock() - - infoStr := fmt.Sprintf("%v", info) - log.Printf("status: alerting %s as %s: %s", healthStatusKey, s, infoStr) - - hs.alerts[healthStatusKey] = alert{ - text: infoStr, - severity: s, - } -} - -func (hs Status) Clear(healthStatusKey string) { - hs.mu.Lock() - defer hs.mu.Unlock() - - if _, ok := hs.alerts[healthStatusKey]; ok { - log.Println("status: clearing ", healthStatusKey) - delete(hs.alerts, healthStatusKey) - } -} - -func (hs Status) String() string { - var ( - alerts [4][]string // Alerts by severity - sb strings.Builder - ) - - hs.mu.Lock() - defer hs.mu.Unlock() - - for healthStatusKey, alert := range hs.alerts { - str := fmt.Sprintf("%s (handler %s)", alert, healthStatusKey) - alerts[alert.severity] = append(alerts[alert.severity], str) - } - - possible := [4]Severity{Unknown, Critical, Warning, OK} - for _, severity := range possible { - if len(alerts[severity]) == 0 { - continue - } - for _, alert := range alerts[severity] { - sb.WriteString(alert) - sb.WriteString("\n") - } - } - - if result := sb.String(); result != "" { - return result - } - return "OK: all is fine :-)\n" -} diff --git a/internal/server/health/health_test.go b/internal/server/health/health_test.go deleted file mode 100644 index 8d16d4b..0000000 --- a/internal/server/health/health_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package health - -import "testing" - -func TestHealthStatus(t *testing.T) { - t.Parallel() - - hs := NewStatus() - hs.Set(Warning, "foo", "this is not good") - hs.Set(Critical, "bar", "this is not good either") - hs.Set(Warning, "baz", "urgh!") - hs.Set(Unknown, "baz", "don't know what happened here!") - hs.Clear("foo") - - result := hs.String() - expected := `UNKNOWN: don't know what happened here! (handler baz) -CRITICAL: this is not good either (handler bar) -` - - if result != expected { - t.Error("expected", expected, "but got", result) - } - t.Log("got as expexted", result) -} diff --git a/internal/server/repository/pending.go b/internal/server/repository/pending.go deleted file mode 100644 index a1fd7a7..0000000 --- a/internal/server/repository/pending.go +++ /dev/null @@ -1,57 +0,0 @@ -package repository - -import "codeberg.org/snonux/gos/internal/types" - -type pendingEntries map[types.EntryID]struct{} - -// Keep track of pending entries per social platform -type pending struct { - platforms map[types.PlatformName]pendingEntries -} - -func newPending() pending { - return pending{make(map[types.PlatformName]pendingEntries)} -} - -// Returns number of pending entries for the platform -// func (p pending) num(platform types.PlatformName) int { -// pe, ok := p.platforms[platform] -// if !ok { -// return 0 -// } -// return len(pe) -// } - -func (p pending) add(platform types.PlatformName, id types.EntryID) { - pe, ok := p.platforms[platform] - if !ok { - pe = make(pendingEntries) - } - pe[id] = struct{}{} - p.platforms[platform] = pe -} - -func (p pending) delete(platform types.PlatformName, id types.EntryID) { - pe, ok := p.platforms[platform] - if !ok { - return - } - delete(pe, id) - p.platforms[platform] = pe -} - -func (p pending) get(platform types.PlatformName) (pendingEntries, bool) { - pe, ok := p.platforms[platform] - return pe, ok && len(pe) > 0 -} - -func (p pending) next(platform types.PlatformName) (types.EntryID, bool) { - pe, ok := p.get(platform) - if !ok { - return "", false - } - for id := range pe { - return id, true - } - return "", false -} diff --git a/internal/server/repository/pending_test.go b/internal/server/repository/pending_test.go deleted file mode 100644 index 28563a6..0000000 --- a/internal/server/repository/pending_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package repository - -import ( - "testing" - - "codeberg.org/snonux/gos/internal/types" -) - -func TestPendingAdd(t *testing.T) { - pending := newPending() - - entries, ok := pending.get(types.LinkedIn) - if ok { - t.Error("expected no ok return status") - } - if len(entries) != 0 { - t.Error("expected no entries") - } - - pending.add(types.LinkedIn, "fooid") - pending.add(types.LinkedIn, "barid") - - entries, ok = pending.get(types.LinkedIn) - if !ok { - t.Error("expected ok return status") - } - if len(entries) != 2 { - t.Error("expected two entries") - } -} - -func TestPendingDelete(t *testing.T) { - pending := newPending() - pending.add(types.LinkedIn, "fooid") - - entries, ok := pending.get(types.LinkedIn) - if !ok { - t.Error("expected ok return status") - } - if len(entries) != 1 { - t.Error("expected one entry") - } - - pending.delete(types.LinkedIn, "fooid") - if entries, ok = pending.get(types.LinkedIn); ok { - t.Error("expected not an ok", entries) - } -} - -func TestPendingNext(t *testing.T) { - pending := newPending() - - id, ok := pending.next(types.LinkedIn) - if ok { - t.Error("not expected ok return status", id) - } - - pending.add(types.LinkedIn, "fooid") - id, ok = pending.next(types.LinkedIn) - if !ok { - t.Error("expected ok return status") - } - if id != "fooid" { - t.Error("expected entry ID fooid") - } -} diff --git a/internal/server/repository/repository.go b/internal/server/repository/repository.go deleted file mode 100644 index d277884..0000000 --- a/internal/server/repository/repository.go +++ /dev/null @@ -1,320 +0,0 @@ -package repository - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log" - "regexp" - "sync" - "time" - - "codeberg.org/snonux/gos/internal/config/server" - "codeberg.org/snonux/gos/internal/easyhttp" - "codeberg.org/snonux/gos/internal/types" - "codeberg.org/snonux/gos/internal/vfs" -) - -var ( - instance Repository - once sync.Once -) - -type fs interface { - ReadFile(name string) ([]byte, error) - WriteFile(filePath string, bytes []byte) error - FindFiles(dataPath, suffix string) ([]string, error) -} - -// Contains an Entry ID and its checksumm, for the list and merge operations. -type entryPair struct { - ID, Checksum string -} - -// Holds all entries in the database / stores them to the disks.. -// TODO: Keep track of how many posts were made this week already. -type Repository struct { - pending pending - stats stats - conf server.ServerConfig - entries map[types.EntryID]types.Entry - mu *sync.Mutex - fs fs - loaded *bool - getIdRe *regexp.Regexp -} - -func Instance(conf server.ServerConfig) Repository { - once.Do(func() { - instance = newRepository(conf, vfs.RealFS{}) - }) - return instance -} - -// Need to register all social platforms for in-memory representation of shared posts and so on. -func newRepository(conf server.ServerConfig, fs fs) Repository { - var loaded bool - return Repository{ - pending: newPending(), // TODO: Make use of the pending for the selection algoritmh for the next post - stats: newStats(), // TODO: Make use of this. - conf: conf, - entries: make(map[types.EntryID]types.Entry), - mu: &sync.Mutex{}, - fs: fs, - loaded: &loaded, - getIdRe: regexp.MustCompile(`^[a-z0-9]{64}$`), - } -} - -// Gets next entry to be shared for the given social platform. -func (r Repository) Next(platform types.PlatformName) (types.Entry, bool) { - r.mu.Lock() - defer r.mu.Unlock() - - id, ok := r.pending.next(platform) - if !ok { - return types.Entry{}, false // No entry found - } - - var entry types.Entry - entry, ok = r.entries[id] - if !ok { - panic("did not expect that!") - } - return entry, true -} - -// Load repository into memory if not done yet. -func (r Repository) load() error { - if *r.loaded { - return nil - } - - filePaths, err := r.fs.FindFiles(r.conf.DataDir, ".json") - if err != nil { - return err - } - - var errs []error - for _, filePath := range filePaths { - log.Println("loading entry", filePath) - - bytes, err := r.fs.ReadFile(filePath) - if err != nil { - errs = append(errs, err) - continue - } - - entry, err := types.NewEntry(bytes) - if err != nil { - errs = append(errs, err) - continue - } - r.mu.Lock() - r.add(entry) - r.mu.Unlock() - } - - if len(errs) == 0 { - *r.loaded = true - } - - return errors.Join(errs...) -} - -func (r Repository) List() ([]entryPair, error) { - if err := r.load(); err != nil { - return []entryPair{}, err - } - - var pairs []entryPair - r.mu.Lock() - defer r.mu.Unlock() - - for _, entry := range r.entries { - pairs = append(pairs, entryPair{entry.ID, entry.Checksum()}) - } - - return pairs, nil -} - -func (r Repository) ListBytes() ([]byte, error) { - pairs, err := r.List() - if err != nil { - return []byte{}, err - } - return json.Marshal(pairs) -} - -func (r Repository) add(entry types.Entry) { - r.entries[entry.ID] = entry - - for _, platform := range r.conf.SocialPlatformsEnabled { - if entry.IsShared(platform) { - r.pending.delete(platform, entry.ID) - } else { - r.pending.add(platform, entry.ID) - } - } -} - -func (r Repository) persist(entry types.Entry) error { - r.add(entry) - - bytes, err := entry.JSONMarshal() - if err != err { - return err - } - return r.fs.WriteFile(r.entryPath(entry), bytes) -} - -func (r Repository) Get(id types.EntryID) (types.Entry, error) { - if !r.getIdRe.MatchString(id) { - return types.Entry{}, fmt.Errorf("invalid id %s", id) - } - if err := r.load(); err != nil { - return types.Entry{}, err - } - - r.mu.Lock() - defer r.mu.Unlock() - - entry, ok := r.entries[id] - if !ok { - return entry, fmt.Errorf("no entry with id %s found", id) - } - return entry, nil -} - -func (r Repository) GetJSON(id types.EntryID) (string, error) { - entry, err := r.Get(id) - if err != nil { - return "", err - } - - bytes, err := entry.JSONMarshal() - if err != nil { - return "", err - } - - return string(bytes), err -} - -func (r Repository) hasSameEntry(pair entryPair) bool { - r.mu.Lock() - defer r.mu.Unlock() - - entry, ok := r.entries[pair.ID] - if !ok || entry.Checksum() != pair.Checksum { - return false - } - return true -} - -func (r Repository) entryPath(ent types.Entry) string { - return fmt.Sprintf("%s/%s/%s.json", r.conf.DataDir, time.Now().Format("2006"), ent.ID) -} - -func (r Repository) Merge(otherEnt types.Entry) error { - if err := r.load(); err != nil { - return err - } - - r.mu.Lock() - defer r.mu.Unlock() - - entry, ok := r.entries[otherEnt.ID] - if !ok { - log.Println("can't find entry with ID", otherEnt.ID, "in local db, create new from copy") - var err error - if entry, err = types.NewEntryFromCopy(otherEnt); err != nil { - return err - } - return r.persist(entry) - } - - if entry, changed, err := entry.Update(otherEnt); changed { - if err != nil { - return err - } - return r.persist(entry) - } - return nil -} - -func (r Repository) MergeRemotely(ctx context.Context) error { - var errs []error - - if len(r.conf.Partners) == 0 { - log.Println("No partners configured - skipping remote merge operation") - return nil - } - - for _, partner := range r.conf.Partners { - if err := r.mergeRemotelyFromPartner(ctx, partner); err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} - -// Makes it mockable/testable -type getPairDataFunc func(context.Context, string, *[]entryPair) error -type getEntryDataFunc func(context.Context, string, string, *types.Entry) error - -func (r Repository) mergeRemotelyFromPartner(ctx context.Context, partner string) error { - getPair := func(ctx context.Context, partner string, pairs *[]entryPair) error { - uri := fmt.Sprintf("%s/list", partner) - return easyhttp.GetData(ctx, uri, r.conf.APIKey, pairs) - } - - getEntry := func(ctx context.Context, partner, id types.EntryID, entry *types.Entry) error { - uri := fmt.Sprintf("%s/get?id=%s", partner, id) - return easyhttp.GetData(ctx, uri, r.conf.APIKey, entry) - } - - return r.mergeFromPartner(ctx, partner, getPair, getEntry) -} - -func (r Repository) mergeFromPartner(ctx context.Context, partner string, - getPair getPairDataFunc, getEntry getEntryDataFunc) error { - - if err := r.load(); err != nil { - return err - } - - var ( - errs []error - pairs []entryPair - ) - - if err := getPair(ctx, partner, &pairs); err != nil { - return err - } - - for _, pair := range pairs { - if r.hasSameEntry(pair) { - continue - } - - log.Println("pair", pair, "missing in local reposotory, going to merge it") - - var entry types.Entry - if err := getEntry(ctx, partner, pair.ID, &entry); err != nil { - errs = append(errs, err) - continue - } - - // In theory, this should never happen - if pair.ID != entry.ID { - errs = append(errs, fmt.Errorf("pair ID %s does not match entry id %s", pair.ID, entry.ID)) - continue - } - - errs = append(errs, r.Merge(entry)) - } - - return errors.Join(errs...) -} diff --git a/internal/server/repository/repository_test.go b/internal/server/repository/repository_test.go deleted file mode 100644 index cdde29c..0000000 --- a/internal/server/repository/repository_test.go +++ /dev/null @@ -1,363 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "testing" - - "codeberg.org/snonux/gos/internal/config/server" - "codeberg.org/snonux/gos/internal/types" - "codeberg.org/snonux/gos/internal/vfs" -) - -func TestRepositoryPutGet(t *testing.T) { - t.Parallel() - - fs := make(vfs.MemoryFS) - repo := newRepository(server.ServerConfig{DataDir: "./data"}, fs) - - for _, entry := range makeEntries(t) { - t.Run(entry.ID, func(t *testing.T) { - _ = repo.persist(entry) - entGot, err := repo.Get(entry.ID) - if err != nil { - t.Error(err) - } - if !entGot.Equals(entry) { - t.Error("expected to get", entry, "but got", entGot) - } - }) - } -} - -func TestRepositoryLoad(t *testing.T) { - t.Parallel() - - fs := make(vfs.MemoryFS) - repo := newRepository(server.ServerConfig{DataDir: "./data"}, fs) - entries := makeEntries(t) - - // Write entries into the VFS - for _, entry := range entries { - bytes, _ := entry.JSONMarshal() - _ = repo.fs.WriteFile(repo.entryPath(entry), bytes) - } - - // Load entries from VFS into the repo - if err := repo.load(); err != nil { - t.Error(err) - } - - for _, entry := range entries { - t.Run(entry.ID, func(t *testing.T) { - entGot, err := repo.Get(entry.ID) - if err != nil { - t.Error(err) - } - if !entGot.Equals(entry) { - t.Error("expected to get", entry, "but got", entGot) - } - }) - } -} - -func TestRepositoryList(t *testing.T) { - t.Parallel() - - fs := make(vfs.MemoryFS) - repo := newRepository(server.ServerConfig{DataDir: "./data"}, fs) - entries := makeEntries(t) - - for _, entry := range entries { - _ = repo.persist(entry) - } - - pairs, _ := repo.List() - if len(entries) != len(pairs) { - t.Error("expected as many entries as pairs") - } - - for _, entry := range entries { - var found bool - for _, pair := range pairs { - if entry.ID == pair.ID && entry.Checksum() == pair.Checksum { - found = true - t.Log("entry matches pair", entry, pair) - break - } - } - if !found { - t.Error("could not find entry", entry, "in", pairs) - } - } -} - -func TestRepositoryHasSameEntry(t *testing.T) { - t.Parallel() - - fs := make(vfs.MemoryFS) - repo := newRepository(server.ServerConfig{DataDir: "./data"}, fs) - entry, _ := makeAnEntry() - _ = repo.persist(entry) - - pair := entryPair{entry.ID, entry.Checksum()} - if !repo.hasSameEntry(pair) { - t.Error("repo does not contain entry corresponding to pair", pair) - } - - pair = entryPair{"nonexistent", "nonexistent"} - if repo.hasSameEntry(pair) { - t.Error("repo does contain entry corresponding to pair", pair, "but that should not be") - } -} - -func TestRepositoryMerge(t *testing.T) { - t.Parallel() - - fs := make(vfs.MemoryFS) - repo := newRepository(server.ServerConfig{DataDir: "./data"}, fs) - entry1, _ := makeAnEntry() - _ = repo.persist(entry1) - - entry2, _ := makeAnotherEntry() - // Need to have the same IDs so that the entries will actually be merged - entry2.ID = entry1.ID - // Merge a modified entry2 into the repository. - entry2.Body = "merged" - entry2.Epoch = 12345 - _ = repo.Merge(entry2) - - pairs, _ := repo.List() - // Ensuring the merge didn't add a new entry - if len(pairs) != 1 { - t.Error("expected exactly one element in the repo but got", pairs) - } - - entGot, _ := repo.Get(entry1.ID) - if entGot.Body != "merged" { - t.Error("unexpected body", entGot.Body) - } - if entGot.Epoch != 12345 { - t.Error("unexpected epoch", entGot.Epoch) - } -} - -func TestRepositoryMergeFromPartner(t *testing.T) { - fs1 := make(vfs.MemoryFS) - repo1 := newRepository(server.ServerConfig{DataDir: "./data1"}, fs1) - fs2 := make(vfs.MemoryFS) - repo2 := newRepository(server.ServerConfig{DataDir: "./data2"}, fs2) - - entry1, _ := makeAnEntry() - _ = repo1.persist(entry1) - entry2, _ := makeAnotherEntry() - _ = repo2.persist(entry2) - - getPair := func(ctx context.Context, partner string, pairs *[]entryPair) error { - var ( - pairs_ []entryPair - err error - ) - - switch partner { - case "repo1": - pairs_, err = repo1.List() - case "repo2": - pairs_, err = repo2.List() - } - - if err != nil { - return err - } - *pairs = pairs_ - - t.Log("got pairs", *pairs, "from repo", partner) - return nil - } - - getEntry := func(ctx context.Context, partner, id string, entry *types.Entry) error { - var ( - entry_ types.Entry - err error - ) - - switch partner { - case "repo1": - entry_, err = repo1.Get(id) - case "repo2": - entry_, err = repo2.Get(id) - } - - if err != nil { - return err - } - *entry = entry_ - - t.Log("got entry", *entry, "from repo", partner) - return nil - } - - // Compare both repos, they should now contain the same entries - compare := func(repo1, repo2 Repository) error { - pairs, err := repo1.List() - if err != nil { - return err - } - - for _, pair := range pairs { - entry1, err := repo1.Get(pair.ID) - if err != nil { - return err - } - entry2, err := repo2.Get(pair.ID) - if err != nil { - return err - } - - t.Log("comparing entries") - t.Log("entry1", entry1) - t.Log("entry2", entry2) - - if !entry1.Equals(entry2) { - return fmt.Errorf("entries entry1 and entry2 don't equal") - } - } - - return nil - } - - t.Run("Merge entries from repo2 into repo1", func(t *testing.T) { - if err := repo1.mergeFromPartner(context.Background(), "repo2", getPair, getEntry); err != nil { - t.Error(err) - } - if err := compare(repo2, repo1); err != nil { - t.Error(err) - } - }) - - t.Run("Merge entries from repo1 into repo2", func(t *testing.T) { - if err := repo2.mergeFromPartner(context.Background(), "repo1", getPair, getEntry); err != nil { - t.Error(err) - } - if err := compare(repo1, repo2); err != nil { - t.Error(err) - } - }) - - t.Run("Change shared flag and merge to partner", func(t *testing.T) { - entry, err := repo1.Get(entry1.ID) - if err != nil { - t.Error(err) - } - - // Validate the correct test setup - if entry.IsShared(types.LinkedIn) { - t.Error("for the test expected LinkedIn not to be shared") - } - - // Simulate that the entry was shared to LinkedIn social media! - linkedIn, ok := entry.Shared[types.LinkedIn] - if !ok { - t.Error("expected to have a LinkedIn shared entry") - } - linkedIn.Is = true - entry.Shared[types.LinkedIn] = linkedIn - - if err := repo1.Merge(entry); err != nil { - t.Error(err) - } - - // Before merging, repos should be out of sync. - if err := compare(repo1, repo2); err == nil { - t.Log("as expected repos are out of sync", err) - } - - // Partner is merging the repo. - if err := repo1.mergeFromPartner(context.Background(), "repo2", getPair, getEntry); err != nil { - t.Error(err) - } - - // Still out of sync, as we merged the repos the wrong direction. - if err := compare(repo1, repo2); err == nil { - t.Log("as expected repos are out of sync", err) - } - - // Partner is merging the repo the right direction. - if err := repo2.mergeFromPartner(context.Background(), "repo1", getPair, getEntry); err != nil { - t.Error(err) - } - - // Now, partners should be in sync. - if err := compare(repo1, repo2); err != nil { - t.Error(err) - } - }) -} - -func TestRepositoryNext(t *testing.T) { - t.Parallel() - - fs := make(vfs.MemoryFS) - repo := newRepository(server.ServerConfig{ - DataDir: "./data", - SocialPlatformsEnabled: []types.PlatformName{ - types.LinkedIn, types.Mastodon, types.Textfile, - }, - }, fs) - entries := makeEntries(t) - - for _, entry := range entries { - _ = repo.persist(entry) - } - - if entry, ok := repo.Next(types.Mastodon); ok { - t.Error("expected no Mastodon entry to be found", entry) - } - - if _, ok := repo.Next(types.LinkedIn); !ok { - t.Error("expected an unshared LinkedIn entry to be found") - } - - if _, ok := repo.Next(types.Textfile); !ok { - t.Error("expected an unshared Textfile entry to be found") - } -} - -func makeEntries(t *testing.T) []types.Entry { - entry1, err := makeAnEntry() - if err != nil { - t.Error(err) - } - entry2, err := makeAnotherEntry() - if err != nil { - t.Error(err) - } - return []types.Entry{entry1, entry2} -} - -func makeAnEntry() (types.Entry, error) { - entry := ` - { - "body": "Body text here", - "shared": { - "Mastodon": { "is": true }, - "LinkedIn": { "is": false } - } - } - ` - return types.NewEntry([]byte(entry)) -} - -func makeAnotherEntry() (types.Entry, error) { - entry := ` - { - "body": "Another text here", - "shared": { - "Mastodon": { "is": true }, - "LinkedIn": { "is": true }, - "Textfile": { "is": false } - } - } - ` - return types.NewEntry([]byte(entry)) -} diff --git a/internal/server/repository/stats.go b/internal/server/repository/stats.go deleted file mode 100644 index 005ef80..0000000 --- a/internal/server/repository/stats.go +++ /dev/null @@ -1,22 +0,0 @@ -package repository - -import "codeberg.org/snonux/gos/internal/types" - -// Keeps track of how many messages were posted to social media over the last week and month. -type stats struct { - // Sliding window of entries shared last 7 days - last7Days map[types.PlatformName][]types.UnixEpoch - // Sliding window of entries shared last 30 days - last30Days map[types.PlatformName][]types.UnixEpoch -} - -func newStats() stats { - return stats{ - last7Days: make(map[types.PlatformName][]types.UnixEpoch), - last30Days: make(map[types.PlatformName][]types.UnixEpoch), - } -} - -// func (s stats) add(platform types.PlatformName, entry types.Entry) { - -// } diff --git a/internal/server/scheduler/scheduler.go b/internal/server/scheduler/scheduler.go deleted file mode 100644 index bbcb59a..0000000 --- a/internal/server/scheduler/scheduler.go +++ /dev/null @@ -1,16 +0,0 @@ -package scheduler - -import ( - "context" - "log" - - "codeberg.org/snonux/gos/internal/config/server" -) - -// TODO: Finish implementing this -func Run(ctx context.Context, config server.ServerConfig) error { - for _, platform := range config.SocialPlatformsEnabled { - log.Println("TODO: implement ... posting a post now or what on", platform) - } - return nil -} diff --git a/internal/server/server.go b/internal/server/server.go deleted file mode 100644 index b2cb0d0..0000000 --- a/internal/server/server.go +++ /dev/null @@ -1,49 +0,0 @@ -package server - -import ( - "fmt" - "log" - "net/http" - - config "codeberg.org/snonux/gos/internal/config/server" - "codeberg.org/snonux/gos/internal/server/health" -) - -type Server struct { - Status health.Status - Conf config.ServerConfig -} - -type HandlerFuncWithError func(http.ResponseWriter, *http.Request) error - -func New(conf config.ServerConfig, status health.Status) Server { - return Server{Conf: conf, Status: status} -} - -func (serv Server) Handle(name string, handler HandlerFuncWithError) { - var ( - handlerPath = fmt.Sprintf("/%s", name) - handlerName = fmt.Sprintf("%sHandler", name) - ) - - http.HandleFunc(handlerPath, func(w http.ResponseWriter, r *http.Request) { - log.Println("Someone requested", handlerName) - - // The health endpoint doesn't require an API key - if handlerName != "healthHandler" { - accessHealthStatusKey := "server.Handler.Access" - if r.Header.Get("X-API-KEY") != serv.Conf.APIKey { - http.Error(w, "Invalid API key", http.StatusUnauthorized) - serv.Status.Set(health.Critical, accessHealthStatusKey, fmt.Errorf("Unauthorized access attempt to %s", handlerName)) - return - } - serv.Status.Clear(accessHealthStatusKey) - } - - if err := handler(w, r); err != nil { - serv.Status.Set(health.Critical, handlerName, err) - return - } - serv.Status.Clear(handlerName) - }) -} diff --git a/internal/social/mastodon.go b/internal/social/mastodon.go deleted file mode 100644 index bf4bcff..0000000 --- a/internal/social/mastodon.go +++ /dev/null @@ -1,15 +0,0 @@ -package social - -import "codeberg.org/snonux/gos/internal/config/server" - -type Mastodon struct { - conf server.ServerConfig -} - -func NewMastodon(conf server.ServerConfig) Mastodon { - return Mastodon{conf} -} - -func Post(content string) error { - return nil -} diff --git a/internal/types/entry.go b/internal/types/entry.go deleted file mode 100644 index bac8fd1..0000000 --- a/internal/types/entry.go +++ /dev/null @@ -1,199 +0,0 @@ -package types - -import ( - "crypto/sha256" - "encoding/json" - "fmt" - "maps" - "os" - "sort" - "strings" -) - -type EntryID = string -type Epoch = int - -type Entry struct { - // The unique ID of this entry. - ID EntryID `json:"id,omitempty"` - Body string `json:"body"` - Shared map[PlatformName]Shared `json:"shared,omitempty"` - Epoch Epoch `json:"epoch,omitempty"` - - // The checksum of the whole entry, can change depending on the state. - checksum string - checksumDirty bool -} - -func NewEntry(bytes []byte) (Entry, error) { - var e Entry - if err := json.Unmarshal(bytes, &e); err != nil { - return e, fmt.Errorf("unable to deserialise payload: %w", err) - } - - e.initialize() - if e.ID == "" { - e.ID = fmt.Sprintf("%x", sha256.Sum256([]byte(e.Body))) - } - - return e, nil -} - -func NewEntryFromCopy(other Entry) (Entry, error) { - e := other - e.initialize() - e.Shared = maps.Clone(other.Shared) - - return e, nil -} - -func NewEntryFromTextFile(filePath string) (Entry, error) { - var e Entry - - data, err := os.ReadFile(filePath) - if err != nil { - return e, err - } - - e.Body = string(data) - if e.ID == "" { - e.ID = fmt.Sprintf("%x", sha256.Sum256([]byte(e.Body))) - } - - e.initialize() - e.Checksum() - - return e, nil -} - -func (e *Entry) initialize() { - e.checksumDirty = true - - if e.Shared == nil { - e.Shared = make(map[PlatformName]Shared) - return - } -} - -func (e Entry) Equals(other Entry) bool { - switch { - case e.Body != other.Body: - return false - case e.Epoch != other.Epoch: - return false - case e.ID != other.ID: - return false - } - - return maps.Equal(e.Shared, other.Shared) -} - -func (e Entry) IsShared(platform PlatformName) bool { - shared, ok := e.Shared[platform] - if !ok { - return false - } - return shared.Is -} - -func (e Entry) SetShared(platform PlatformName) Entry { - e.Shared[platform] = newShared(true) - return e -} - -/** - * This updates the entry with the other entry. The Shared slice will also be - * updated. If entry is missing, it will be added. If entry is there, the shared - * Is status will eventually flip to true but never to false. - */ -func (e Entry) Update(other Entry) (Entry, bool, error) { - if e.ID != other.ID { - return e, false, fmt.Errorf("can update entry only with other entry with same ID: this(%s) other(%s)", e, other) - } - - var changed bool - - if e.Body != other.Body { - e.Body = other.Body - changed = true - } - - if e.Epoch != other.Epoch { - e.Epoch = other.Epoch - changed = true - } - - for otherPlatform, otherShared := range other.Shared { - shared, ok := e.Shared[otherPlatform] - switch { - case !ok: - e.Shared[otherPlatform] = shared - changed = true - case otherShared.Is && !shared.Is: - shared.Is = true - e.Shared[otherPlatform] = shared - changed = true - } - } - - if changed { - e.checksumDirty = true - } - - return e, changed, nil -} - -func (e Entry) JSONMarshal() ([]byte, error) { - return json.Marshal(e) -} - -func (e Entry) String() string { - return e.checksumBase() -} - -/** - * Used to calculate the checksum, better don't change the output, otherwise - * repository database will get confused with entry checksum mismatches. - */ -func (e Entry) checksumBase() string { - var sb strings.Builder - - sb.WriteString("ID:") - sb.WriteString(e.ID) - sb.WriteString(";") - sb.WriteString(fmt.Sprintf("Epoch:%d;", e.Epoch)) - sb.WriteString("Shared:{") - - platforms := make([]PlatformName, 0, len(e.Shared)) - for platform := range e.Shared { - platforms = append(platforms, platform) - } - sort.Strings(platforms) - - for i, patform := range platforms { - if i > 0 { - sb.WriteString(",") - } - shared := e.Shared[patform] - sb.WriteString(patform) - sb.WriteString(":{") - sb.WriteString(shared.String()) - sb.WriteString("}") - } - - sb.WriteString("};") - sb.WriteString("Body:") - sb.WriteString(e.Body) - - return sb.String() -} - -func (e *Entry) Checksum() str