Skip to content

Commit

Permalink
WIP: outlook_token adapter
Browse files Browse the repository at this point in the history
  • Loading branch information
timonegk committed Feb 7, 2025
1 parent 3403021 commit 668f33f
Show file tree
Hide file tree
Showing 7 changed files with 374 additions and 3 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/charmbracelet/log v0.4.0
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f
github.com/emersion/go-webdav v0.5.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/microcosm-cc/bluemonday v1.0.27
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/stretchr/testify v1.9.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
Expand Down
7 changes: 4 additions & 3 deletions internal/adapter/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
OutlookTokenCalendarType Type = "outlook_token"
)

// ConfigReader provides an interface for adapters to load their own configuration map.
Expand Down
115 changes: 115 additions & 0 deletions internal/adapter/outlook_token/adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package outlook_token

import (
"context"
"errors"
"fmt"
"net/http"
"time"

"github.com/charmbracelet/log"
"github.com/golang-jwt/jwt/v5"
"github.com/pkg/browser"

"github.com/inovex/CalendarSync/internal/adapter/port"
"github.com/inovex/CalendarSync/internal/auth"
"github.com/inovex/CalendarSync/internal/models"
)

const (
graphUrl = "https://developer.microsoft.com/en-us/graph/graph-explorer"
baseUrl = "https://graph.microsoft.com/v1.0"
timeFormat = "2006-01-02T15:04:05.0000000"
)

type ROOutlookCalendarClient interface {
ListEvents(ctx context.Context, starttime time.Time, endtime time.Time) ([]models.Event, error)
GetCalendarHash() string
}

type ROCalendarAPI struct {
outlookClient ROOutlookCalendarClient
calendarID string

accessToken string

logger *log.Logger

storage auth.Storage

Check failure on line 38 in internal/adapter/outlook_token/adapter.go

View workflow job for this annotation

GitHub Actions / GolangCI

field `storage` is unused (unused)

Check failure on line 38 in internal/adapter/outlook_token/adapter.go

View workflow job for this annotation

GitHub Actions / STATICCHECK

field storage is unused (U1000)

Check failure on line 38 in internal/adapter/outlook_token/adapter.go

View workflow job for this annotation

GitHub Actions / STATICCHECK

field storage is unused (U1000)

Check failure on line 38 in internal/adapter/outlook_token/adapter.go

View workflow job for this annotation

GitHub Actions / GolangCI

field `storage` is unused (unused)
}

// Assert that the expected interfaces are implemented
var _ port.Configurable = &ROCalendarAPI{}
var _ port.LogSetter = &ROCalendarAPI{}
var _ port.CalendarIDSetter = &ROCalendarAPI{}

func (c *ROCalendarAPI) SetCalendarID(calendarID string) error {
if calendarID == "" {
return fmt.Errorf("%s adapter 'calendar' cannot be empty", c.Name())
}
c.calendarID = calendarID
return nil
}

func (c *ROCalendarAPI) Initialize(ctx context.Context, openBrowser bool, config map[string]interface{}) error {
if c.accessToken == "" {
if openBrowser {
c.logger.Infof("opening browser window for authentication of %s\n", c.Name())
err := browser.OpenURL(graphUrl)
if err != nil {
c.logger.Infof("browser did not open, please authenticate adapter %s:\n\n %s\n\n\n", c.Name(), graphUrl)
}
} else {
c.logger.Infof("Please authenticate adapter %s:\n\n %s\n\n\n", c.Name(), graphUrl)
}
fmt.Print("Copy access token from \"Access token\" tab: ")
tokenString := ""
fmt.Scanf("%s", &tokenString)

Check failure on line 67 in internal/adapter/outlook_token/adapter.go

View workflow job for this annotation

GitHub Actions / GolangCI

Error return value of `fmt.Scanf` is not checked (errcheck)

Check failure on line 67 in internal/adapter/outlook_token/adapter.go

View workflow job for this annotation

GitHub Actions / GolangCI

Error return value of `fmt.Scanf` is not checked (errcheck)
jwtParser := jwt.Parser{}
token, _, err := jwtParser.ParseUnverified(tokenString, &jwt.RegisteredClaims{})

if err != nil {
return err
}

expirationTime, err := token.Claims.GetExpirationTime()
if err != nil {
return err
}

if expirationTime.Time.Before(time.Now()) {
return errors.New("Access token expired")

Check failure on line 81 in internal/adapter/outlook_token/adapter.go

View workflow job for this annotation

GitHub Actions / STATICCHECK

error strings should not be capitalized (ST1005)

Check failure on line 81 in internal/adapter/outlook_token/adapter.go

View workflow job for this annotation

GitHub Actions / STATICCHECK

error strings should not be capitalized (ST1005)
}

c.accessToken = tokenString
} else {
c.logger.Debug("adapter is already authenticated, loading access token")
}

client := &http.Client{}
c.outlookClient = &ROOutlookClient{Client: client, AccessToken: c.accessToken, CalendarID: c.calendarID}
return nil
}

func (c *ROCalendarAPI) EventsInTimeframe(ctx context.Context, start time.Time, end time.Time) ([]models.Event, error) {
events, err := c.outlookClient.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 *ROCalendarAPI) GetCalendarHash() string {
return c.outlookClient.GetCalendarHash()
}

func (c *ROCalendarAPI) Name() string {
return "Outlook"
}

func (c *ROCalendarAPI) SetLogger(logger *log.Logger) {
c.logger = logger
}
182 changes: 182 additions & 0 deletions internal/adapter/outlook_token/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package outlook_token

import (
"context"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"time"

"github.com/inovex/CalendarSync/internal/models"
)

const (
ExtensionOdataType = "microsoft.graph.openTypeExtension"
ExtensionName = "inovex.calendarsync.meta"
)

// ROOutlookClient implements the ROOutlookCalendarClient interface
type ROOutlookClient struct {
AccessToken string
CalendarID string

Client *http.Client
}

func (o *ROOutlookClient) ListEvents(ctx context.Context, start time.Time, end time.Time) ([]models.Event, error) {
startDate := start.Format(timeFormat)
endDate := end.Format(timeFormat)

// Query can't simply be encoded with the url package for example, microsoft also uses its own encoding here.
// Otherwise this always ends in a 500 return code, see also https://stackoverflow.com/a/62770941
query := "?startDateTime=" + startDate + "&endDateTime=" + endDate + "&$expand=extensions($filter=Id%20eq%20'inovex.calendarsync.meta')"

req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseUrl+"/me/calendars/"+o.CalendarID+"/CalendarView"+query, nil)
if err != nil {
return nil, err
}

// Get all the events in UTC timezone
// when we retrieve them from other adapters they will also be in UTC
req.Header.Add("Prefer", "outlook.timezone=\"UTC\"")
req.Header.Add("Authorization", "Bearer "+o.AccessToken)

resp, err := o.Client.Do(req)
if err != nil {
return nil, err
}

if resp.StatusCode != http.StatusOK {
return nil, err
}

body, _ := io.ReadAll(resp.Body)
resp.Body.Close()

var eventList EventList
err = json.Unmarshal(body, &eventList)
if err != nil {
return nil, fmt.Errorf("cannot unmarshal response: %w", err)
}

nextLink := eventList.NextLink
for nextLink != "" {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, nextLink, nil)

Check failure on line 67 in internal/adapter/outlook_token/client.go

View workflow job for this annotation

GitHub Actions / GolangCI

ineffectual assignment to err (ineffassign)

Check failure on line 67 in internal/adapter/outlook_token/client.go

View workflow job for this annotation

GitHub Actions / STATICCHECK

this value of err is never used (SA4006)

Check failure on line 67 in internal/adapter/outlook_token/client.go

View workflow job for this annotation

GitHub Actions / STATICCHECK

this value of err is never used (SA4006)

Check failure on line 67 in internal/adapter/outlook_token/client.go

View workflow job for this annotation

GitHub Actions / GolangCI

ineffectual assignment to err (ineffassign)
req.Header.Add("Prefer", "outlook.timezone=\"UTC\"")
req.Header.Add("Authorization", "Bearer "+o.AccessToken)

resp, err := o.Client.Do(req)
if err != nil {
return nil, err
}

body, _ := io.ReadAll(resp.Body)
resp.Body.Close()

var nextList EventList
err = json.Unmarshal(body, &nextList)
if err != nil {
return nil, fmt.Errorf("cannot unmarshal response: %w", err)
}

eventList.Events = append(eventList.Events, nextList.Events...)
nextLink = nextList.NextLink
}

var events []models.Event
for _, evt := range eventList.Events {
evt, err := o.outlookEventToEvent(evt, o.GetCalendarHash())
if err != nil {
return nil, err
}
events = append(events, evt)
}

return events, nil
}

func (o ROOutlookClient) GetCalendarHash() string {
var id []byte
sum := sha1.Sum([]byte(o.CalendarID))
id = append(id, sum[:]...)
return base64.URLEncoding.EncodeToString(id)
}

// outlookEventToEvent transforms an outlook event to our form of event representation
// gets called when used as a sink and as a source
func (o ROOutlookClient) outlookEventToEvent(oe Event, adapterSourceID string) (e models.Event, err error) {
var bufEvent models.Event

startTime, err := time.Parse(timeFormat, oe.Start.DateTime)
if err != nil {
return bufEvent, fmt.Errorf("failed to parse startTime, skipping event: %s", err)
}
endTime, err := time.Parse(timeFormat, oe.End.DateTime)
if err != nil {
return bufEvent, fmt.Errorf("failed to parse endTime, skipping event: %s", err)
}

var attendees = make([]models.Attendee, 0)

for _, eventAttendee := range oe.Attendees {
attendees = append(attendees, models.Attendee{
Email: eventAttendee.EmailAddress.Address,
DisplayName: eventAttendee.EmailAddress.Name,
})
}

var reminders = make([]models.Reminder, 0)

if oe.IsReminderOn {
reminders = append(reminders, models.Reminder{
Actions: models.ReminderActionDisplay,
Trigger: models.ReminderTrigger{
PointInTime: startTime.Add(-(time.Minute * time.Duration(oe.ReminderMinutesBeforeStart))),
},
})
}
var hasEventAccepted bool = true
if oe.ResponseStatus.Response == "declined" {
hasEventAccepted = false
}

bufEvent = models.Event{
ICalUID: oe.UID,
ID: oe.ID,
Title: oe.Subject,
Description: oe.Body.Content,
Location: oe.Location.Name,
StartTime: startTime,
EndTime: endTime,
Metadata: ensureMetadata(oe, adapterSourceID),
Attendees: attendees,
Reminders: reminders,
MeetingLink: oe.OnlineMeetingUrl,
Accepted: hasEventAccepted,
}

if oe.IsAllDay {
bufEvent.AllDay = true
}

return bufEvent, nil
}

// Adding metadata is a bit more complicated as in the google adapter
// see also: https://learn.microsoft.com/en-us/graph/api/opentypeextension-post-opentypeextension?view=graph-rest-1.0&tabs=http
// Retrieve metadata if possible otherwise regenerate it
func ensureMetadata(event Event, adapterSourceID string) *models.Metadata {
for _, extension := range event.Extensions {
if extension.ExtensionName == ExtensionName && (len(extension.SyncID) != 0 && len(extension.SourceID) != 0) {
return &models.Metadata{
SyncID: extension.SyncID,
OriginalEventUri: extension.OriginalEventUri,
SourceID: extension.SourceID,
}
}
}
return models.NewEventMetadata(event.ID, event.HtmlLink, adapterSourceID)
}
67 changes: 67 additions & 0 deletions internal/adapter/outlook_token/models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package outlook_token

import (
"github.com/inovex/CalendarSync/internal/models"
)

// https://learn.microsoft.com/en-us/graph/api/resources/event?view=graph-rest-1.0

type EventList struct {
NextLink string `json:"@odata.nextLink"`
Events []Event `json:"value"`
}

type Event struct {
ID string `json:"id"`
UID string `json:"iCalUId"`
ChangeKey string `json:"changeKey"`
HtmlLink string `json:"webLink"`
Subject string `json:"subject"`
Start Time `json:"start"`
End Time `json:"end"`
Body Body `json:"body,omitempty"`
Attendees []Attendee `json:"attendees,omitempty"`
Location Location `json:"location"`
IsReminderOn bool `json:"isReminderOn"`
ReminderMinutesBeforeStart int `json:"reminderMinutesBeforeStart"`
Extensions []Extensions `json:"extensions"`
IsAllDay bool `json:"isAllDay"`
OnlineMeetingUrl string `json:"onlineMeetingUrl"`
ResponseStatus ResponseStatus `json:"responseStatus,omitempty"`
}

type Extensions struct {
OdataType string `json:"@odata.type"`
ExtensionName string `json:"extensionName"`
// needs to be embedded, Microsoft returns a 500 on an non-embedded object
models.Metadata
}

type ResponseStatus struct {
Response string `json:"response,omitempty"`
// there's an additional field called `time` which returns date and time when the response was returned
// but we don't need that
}

type Body struct {
ContentType string `json:"contentType,omitempty"`
Content string `json:"content,omitempty"`
}

type Time struct {
DateTime string `json:"dateTime"`
TimeZone string `json:"timeZone"`
}

type Attendee struct {
EmailAddress EmailAddress `json:"emailAddress,omitempty"`
}

type EmailAddress struct {
Name string `json:"name"`
Address string `json:"address"`
}

type Location struct {
Name string `json:"displayName"`
}
Loading

0 comments on commit 668f33f

Please sign in to comment.