diff --git a/assets/outlook_published_devtools.png b/assets/outlook_published_devtools.png new file mode 100644 index 0000000..fdbf1fc Binary files /dev/null and b/assets/outlook_published_devtools.png differ diff --git a/docs/adapters.md b/docs/adapters.md index 72a458e..5ff4973 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -85,3 +85,35 @@ and a `clientSecret`. Make sure to add the `clientSecret` to the `clientKey` setting in your CalendarSync configuration. If you want to use the created OAuth Application also with accounts outside of your Google Workspace, make sure to set the Usertype to `external` in the `OAuth Consent Screen` Menu. + + +## Outlook Published Calendar Adapter Setup +The Outlook Published Calendar adapter allows you to sync with publicly shared Outlook calendars without OAuth authentication. + +### Configuration +```yaml +source: + adapter: + type: "outlook_published" + config: + url: "" + postData: "" + ``` + +### How to Get Configuration Values + +3. Get the URL and postData: + - Open the calendar.html URL in your browser with Developer Tools open (F12) + - In the Network tab, look for a request to `service.svc` with parameters `action=FindItem&app=PublishedCalendar` + - From this request: + - For `url`: Copy the full request URL (example format: `https://outlook.office365.com/owa/published/@//service.svc?action=FindItem&app=PublishedCalendar&n=2`) + - For `postData`: In the request headers, find and copy the value of the `x-owa-urlpostdata` header + +![](../assets/outlook_published_devtools.png) + +Note: The postData value contains time range information that the adapter will automatically update during synchronization. + +### Limitations +- Read-only access (cannot create, update, or delete events) +- Requires the calendar to be publicly accessible +- Some event details might be limited compared to the OAuth-based adapter \ No newline at end of file diff --git a/go.sum b/go.sum index 6addaf0..e70d403 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,11 @@ cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4 cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= filippo.io/age v1.2.0 h1:vRDp7pUMaAJzXNIWJVAZnEf/Dyi4Vu4wI8S1LBzufhE= filippo.io/age v1.2.0/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/aquilax/truncate v1.0.0 h1:UgIGS8U/aZ4JyOJ2h3xcF5cSQ06+gGBnjxH2RUHJe0U= +github.com/aquilax/truncate v1.0.0/go.mod h1:BeMESIDMlvlS3bmg4BVvBbbZUNwWtS8uzYPAKXwwhLw= +github.com/arran4/golang-ical v0.3.1 h1:v13B3eQZ9VDHTAvT6M11vVzxYgcYmjyPBE2eAZl3VZk= +github.com/arran4/golang-ical v0.3.1/go.mod h1:LZWxF8ZIu/sjBVUCV0udiVPrQAgq3V0aa0RfbO99Qkk= github.com/aquilax/truncate v1.0.1 h1:+hqGSRxnQ0F5wdPCGbi1XW4ipQ6vzpli23V9Rd+I/mc= github.com/aquilax/truncate v1.0.1/go.mod h1:BeMESIDMlvlS3bmg4BVvBbbZUNwWtS8uzYPAKXwwhLw= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -24,6 +29,7 @@ github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8 github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM= @@ -52,6 +58,9 @@ github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrk github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= @@ -65,6 +74,7 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -125,5 +135,7 @@ google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/g google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 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/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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/internal/adapter/adapter.go b/internal/adapter/adapter.go index 6c1698f..a32fc3a 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -7,9 +7,10 @@ import ( type Type string const ( - GoogleCalendarType Type = "google" - ZepCalendarType Type = "zep" - OutlookHttpCalendarType Type = "outlook_http" + GoogleCalendarType Type = "google" + ZepCalendarType Type = "zep" + OutlookHttpCalendarType Type = "outlook_http" + OutlookPublishedCalendarType Type = "outlook_published" ) // ConfigReader provides an interface for adapters to load their own configuration map. diff --git a/internal/adapter/google/adapter.go b/internal/adapter/google/adapter.go index 1bdd15d..13ba217 100644 --- a/internal/adapter/google/adapter.go +++ b/internal/adapter/google/adapter.go @@ -108,7 +108,7 @@ func (c *CalendarAPI) Initialize(ctx context.Context, openBrowser bool, config m if !c.authenticated { c.oAuthUrl = c.oAuthHandler.Configuration().AuthCodeURL("state", oauth2.AccessTypeOffline) if openBrowser { - c.logger.Infof("opening browser window for authentication of %s\n", c.Name()) + c.logger.Infof("opening browser window for authentication of %s - %s\n", c.Name(), c.calendarID) err := browser.OpenURL(c.oAuthUrl) if err != nil { c.logger.Infof("browser did not open, please authenticate adapter %s:\n\n %s\n\n\n", c.Name(), c.oAuthUrl) diff --git a/internal/adapter/outlook_http/adapter.go b/internal/adapter/outlook_http/adapter.go index 0e75384..a42dc98 100644 --- a/internal/adapter/outlook_http/adapter.go +++ b/internal/adapter/outlook_http/adapter.go @@ -166,7 +166,7 @@ func (c *CalendarAPI) Initialize(ctx context.Context, openBrowser bool, config m c.oAuthUrl = c.oAuthHandler.Configuration().AuthCodeURL("state", oauth2.AccessTypeOffline) if openBrowser { - c.logger.Infof("opening browser window for authentication of %s\n", c.Name()) + c.logger.Infof("opening browser window for authentication of %s - %s\n", c.Name(), c.calendarID) err := browser.OpenURL(c.oAuthUrl) if err != nil { c.logger.Infof("browser did not open, please authenticate adapter %s:\n\n %s\n\n\n", c.Name(), c.oAuthUrl) diff --git a/internal/adapter/outlook_published/adapter.go b/internal/adapter/outlook_published/adapter.go new file mode 100644 index 0000000..10e89a0 --- /dev/null +++ b/internal/adapter/outlook_published/adapter.go @@ -0,0 +1,50 @@ +package outlook_published + +import ( + "context" + "github.com/charmbracelet/log" + "github.com/inovex/CalendarSync/internal/models" + "time" +) + +type OutlookPublishedClient interface { + ListEvents(ctx context.Context, starttime time.Time, enddtime time.Time) ([]models.Event, error) +} + +type CalendarAPI struct { + opb OutlookPublishedClient + calendarUrl string + urlPostData string + logger *log.Logger +} + +func (c *CalendarAPI) EventsInTimeframe(ctx context.Context, start time.Time, end time.Time) ([]models.Event, error) { + events, err := c.opb.ListEvents(ctx, start, end) + if err != nil { + return nil, err + } + + c.logger.Infof("loaded %d events between %s and %s.", len(events), start.Format(time.RFC1123), end.Format(time.RFC1123)) + + return events, nil +} + +func (c *CalendarAPI) Name() string { + return "outlook_published" +} + +func (c *CalendarAPI) GetCalendarID() string { + return c.calendarUrl +} + +func (c *CalendarAPI) Initialize(ctx context.Context, openBrowser bool, config map[string]interface{}) error { + c.calendarUrl = config["url"].(string) + c.urlPostData = config["postData"].(string) + + c.opb = &OutlookPubClient{url: c.calendarUrl, urlPostData: c.urlPostData} + return nil +} + +func (c *CalendarAPI) SetLogger(logger *log.Logger) { + c.logger = logger +} diff --git a/internal/adapter/outlook_published/client.go b/internal/adapter/outlook_published/client.go new file mode 100644 index 0000000..d1ec795 --- /dev/null +++ b/internal/adapter/outlook_published/client.go @@ -0,0 +1,113 @@ +package outlook_published + +import ( + "context" + "encoding/json" + "fmt" + "github.com/inovex/CalendarSync/internal/models" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type OutlookPubClient struct { + url string + urlPostData string +} + +func (opb *OutlookPubClient) ListEvents(ctx context.Context, starttime time.Time, enddtime time.Time) ([]models.Event, error) { + var loadedEvents []models.Event + var filteredEvents []models.Event + + decodedUrlPostData, _ := url.QueryUnescape(opb.urlPostData) + + startTimeFormatted := starttime.Format("2006-01-02T15:04:05.000") + endTimeFormatted := enddtime.Format("2006-01-02T15:04:05.000") + + decodedUrlPostData = strings.Replace(decodedUrlPostData, "2024-11-01T00:00:00.000", startTimeFormatted, 1) + decodedUrlPostData = strings.Replace(decodedUrlPostData, "2025-01-05T23:59:59.999", endTimeFormatted, 1) + + // Encode the modified URL post data + encodedUrlPostData := url.QueryEscape(decodedUrlPostData) + encodedUrlPostData = strings.Replace(encodedUrlPostData, "+", "%20", -1) + + req, err := http.NewRequestWithContext(ctx, "POST", opb.url, nil) + if err != nil { + return nil, err + } + + req.Header.Add("action", "FindItem") + req.Header.Add("content-length", "0") + req.Header.Add("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + req.Header.Add("x-owa-urlpostdata", encodedUrlPostData) + + client := http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) // Read the body from the response. + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch events: %s", resp.Status) + } + + var response Response + err = json.Unmarshal([]byte(body), &response) + if err != nil { + return nil, err + } + + for _, event := range response.Body.ResponseMessages.Items[0].RootFolder.Items { + loadedEvents = append(loadedEvents, opb.calendarEventToEvent(event)) + } + + for _, loadedEvent := range loadedEvents { + if loadedEvent.StartTime.After(starttime) && loadedEvent.EndTime.Before(enddtime) { + filteredEvents = append(filteredEvents, loadedEvent) + } + } + + return filteredEvents, nil +} + +func (opb *OutlookPubClient) calendarEventToEvent(e ItemRootFolder) models.Event { + var metadata models.Metadata + metadata = models.Metadata{SyncID: e.ItemId.Id, SourceID: opb.url} + + var dateStart time.Time + var dateEnd time.Time + + dateStart, err := time.Parse(time.RFC3339, e.Start) + if err != nil { + fmt.Printf("error parsing start date: %v\n", err) + } + + dateEnd, err = time.Parse(time.RFC3339, e.End) + if err != nil { + fmt.Printf("error parsing end date: %v\n", err) + } + + return models.Event{ + ICalUID: e.ItemId.Id, + ID: e.ItemId.Id, + Title: e.Subject, + Description: "", + Location: "", + AllDay: false, + StartTime: dateStart, + EndTime: dateEnd, + Metadata: &metadata, + //Attendees: + //Reminders: + //MeetingLink: + Accepted: true, + } +} diff --git a/internal/adapter/outlook_published/outlook_json_event.go b/internal/adapter/outlook_published/outlook_json_event.go new file mode 100644 index 0000000..fdc50ca --- /dev/null +++ b/internal/adapter/outlook_published/outlook_json_event.go @@ -0,0 +1,33 @@ +package outlook_published + +type Response struct { + Body Body `json:"Body"` +} + +type Body struct { + ResponseMessages ResponseMessages `json:"ResponseMessages"` +} + +type ResponseMessages struct { + Items []Item `json:"Items"` +} + +type Item struct { + RootFolder RootFolder `json:"RootFolder"` +} + +type RootFolder struct { + Items []ItemRootFolder `json:"Items"` +} + +type ItemRootFolder struct { + Subject string `json:"Subject"` + Start string `json:"Start"` + End string `json:"End"` + CalendarItemType string `json:"CalendarItemType"` + ItemId ItemId `json:"ItemId"` +} + +type ItemId struct { + Id string `json:"Id"` +} diff --git a/internal/adapter/source_adapter.go b/internal/adapter/source_adapter.go index 7184ea3..fe0a918 100644 --- a/internal/adapter/source_adapter.go +++ b/internal/adapter/source_adapter.go @@ -3,6 +3,7 @@ package adapter import ( "context" "fmt" + "github.com/inovex/CalendarSync/internal/adapter/outlook_published" "time" "github.com/charmbracelet/log" @@ -22,6 +23,8 @@ func SourceClientFactory(typ Type) (sync.Source, error) { switch typ { case GoogleCalendarType: return new(google.CalendarAPI), nil + case OutlookPublishedCalendarType: + return new(outlook_published.CalendarAPI), nil case ZepCalendarType: return new(zep.CalendarAPI), nil case OutlookHttpCalendarType: