commit eab296a3cacc4ffb24642fb36d281ea6936b5a9c Author: Seán C McCord Date: Tue Oct 1 13:44:45 2024 -0400 intial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b1469b1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +/test/ +/test.yaml +/gcal-sync diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea5d85f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/test/ +/test.yaml +/gcal-sync +vendor/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..28fa345 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM stagex/busybox:sx2024.09.0@sha256:d34bfa56566aa72d605d6cbdc154de8330cf426cfea1bc4ba8013abcac594395 AS busybox +FROM stagex/ca-certificates:sx2024.09.0@sha256:33787f1feb634be4232a6dfe77578c1a9b890ad82a2cf18c11dd44507b358803 AS ca-certificates +FROM stagex/go:sx2024.09.0@sha256:56f511d92dcc6c1910115d8b19dd984b6cd682d1d3e9b72456e3b95f7b141fee AS go + +FROM scratch AS builder + +ENV CGO_ENABLED=0 + +COPY --from=busybox . . +COPY --from=go . . +COPY --from=ca-certificates . . + +ADD . /src/gcal-sync + +WORKDIR /src/gcal-sync + +RUN go build -o /app + +FROM scratch + +WORKDIR / + +COPY --from=ca-certificates . . +COPY --from=builder /app /app + +ENTRYPOINT ["/app"] diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..deea72d --- /dev/null +++ b/config/config.go @@ -0,0 +1,104 @@ +package config + +import ( + "context" + "fmt" + "net/http" + "os" + + "github.com/emersion/go-ical" + "github.com/emersion/go-webdav" + "github.com/emersion/go-webdav/caldav" + "gopkg.in/yaml.v3" +) + +type Config struct { + Server string `yaml:"server"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Calendars []CalendarSync `yaml:"calendars"` +} + +type CalendarSync struct { + Name string `yaml:"name"` + Destination string `yaml:"destination"` + URL string `yaml:"url"` +} + +func (item *CalendarSync) Sync(ctx context.Context, cfg *Config) error { + webdavClient := webdav.HTTPClientWithBasicAuth(http.DefaultClient, cfg.Username, cfg.Password) + + cc, err := caldav.NewClient(webdavClient, cfg.Server) + if err != nil { + return fmt.Errorf("failed to construct a CalDAV client: %w", err) + } + + calPath, err := CalendarPath(ctx, cc, cfg.Username, item.Destination) + if err != nil { + return fmt.Errorf("failed to find destination calendar path: %w", err) + } + + resp, err := http.Get(item.URL) + if err != nil { + return fmt.Errorf("failed to sync %s: %w", item.Name, err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("source returned non-200 status code: %s", resp.Status) + } + + calendarData, err := ical.NewDecoder(resp.Body).Decode() + if err != nil { + return fmt.Errorf("failed to decode source calendar data: %w", err) + } + + // if err := ical.NewEncoder(os.Stdout).Encode(calendarData); err == nil { + // os.Exit(0) + // } + + if _, err := cc.PutCalendarObject(ctx, calPath, calendarData); err != nil { + return fmt.Errorf("failed to push calendar data to destination: %w", err) + } + + return nil +} + +func CalendarPath(ctx context.Context, cc *caldav.Client, username, calendarName string) (string, error) { + homeSet, err := cc.FindCalendarHomeSet(ctx, username) + if err != nil { + return "", fmt.Errorf("failed to find home set for %s: %w", username, err) + } + + calendars, err := cc.FindCalendars(ctx, homeSet) + if err != nil { + return "", fmt.Errorf("failed to retrieve calendars: %w", err) + } + + for _, cal := range calendars { + if cal.Name == calendarName { + return cal.Path, nil + } + } + + return "", fmt.Errorf("calendar with name %s not found", calendarName) +} + +// Load a configuration file +func Load(configFile string) (*Config, error) { + cfg := new(Config) + + f, err := os.Open(configFile) + if err != nil { + return nil, fmt.Errorf("failed to open config file %s: %w", configFile, err) + } + + defer f.Close() + + if err = yaml.NewDecoder(f).Decode(cfg); err != nil { + return nil, fmt.Errorf("failed to decode config file %s: %w", configFile, err) + } + + return cfg, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..15b1c0e --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module git.cycore.io/scm/gcal-sync + +go 1.22.7 + +require ( + github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 + github.com/emersion/go-webdav v0.5.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require github.com/teambition/rrule-go v1.8.2 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7fd2d8c --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f/go.mod h1:2MKFUgfNMULRxqZkadG1Vh44we3y5gJAtTBlVsx1BKQ= +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM= +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= +github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= +github.com/emersion/go-webdav v0.5.0 h1:Ak/BQLgAihJt/UxJbCsEXDPxS5Uw4nZzgIMOq3rkKjc= +github.com/emersion/go-webdav v0.5.0/go.mod h1:ycyIzTelG5pHln4t+Y32/zBvmrM7+mV7x+V+Gx4ZQno= +github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= +github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..896e0b9 --- /dev/null +++ b/main.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "errors" + "flag" + "log" + "log/slog" + "time" + + "git.cycore.io/scm/gcal-sync/config" +) + +var configFile string +var debug bool +var interval time.Duration + +func init() { + flag.StringVar(&configFile, "config", "/etc/gcal-sync/config.yaml", "name of the file from which to load the configuration") + flag.BoolVar(&debug, "debug", false, "enable debug logging") + flag.DurationVar(&interval, "interval", 5*time.Minute, "refresh frequency") +} + +func main() { + ctx := context.Background() + + flag.Parse() + + if debug { + slog.SetLogLoggerLevel(slog.LevelDebug) + } else { + slog.SetLogLoggerLevel(slog.LevelInfo) + } + + cfg, err := config.Load(configFile) + if err != nil { + log.Fatalf("failed to load configuration file %s: %s", configFile, err.Error()) + } + + var failures error + + for _, item := range cfg.Calendars { + if err := item.Sync(ctx, cfg); err != nil { + slog.Error("failed to sync calendar", + slog.String("name", item.Name), + slog.String("error", err.Error()), + ) + + failures = errors.Join(failures, err) + + continue + } + + slog.Debug("posted updated calendar data", + slog.String("name", item.Name), + ) + } + + for { + if err := Sync(ctx, cfg); err != nil { + slog.Warn("failed to sync all calendars") + } else { + slog.Info("synchronized all calendar data", + slog.Int("count", len(cfg.Calendars)), + ) + } + + time.Sleep(interval) + } +} + +func Sync(ctx context.Context, cfg *config.Config) error { + var failures error + + for _, item := range cfg.Calendars { + if err := item.Sync(ctx, cfg); err != nil { + slog.Error("failed to sync calendar", + slog.String("name", item.Name), + slog.String("url", item.URL), + slog.String("error", err.Error()), + ) + + failures = errors.Join(failures, err) + + continue + } + + slog.Debug("posted updated calendar data", + slog.String("name", item.Name), + ) + } + + return failures +}