-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
- Loading branch information
There are no files selected for viewing
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
|
||
} | ||
|
||
// 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
|
||
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
|
||
} | ||
|
||
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 | ||
} |
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
|
||
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) | ||
} |
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"` | ||
} |