aboutsummaryrefslogtreecommitdiffstats
package main

import (
	"bytes"
	"log"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/emersion/go-ical"
	"golang.org/x/crypto/argon2"
)

func main() {
	cfg := loadConfig()
	printConfig(&cfg)

	amsTz, err := time.LoadLocation("Europe/Amsterdam")
	if err != nil {
		log.Fatalln("Failed to load time zone", err)
	}

	handler := handler{
		calURL:    url.URL(cfg.CalendarURL.v),
		ignore:    cfg.Ignore,
		tokens:    cfg.UserTokens,
		amsTz:     amsTz,
		userAgent: cfg.UserAgent,
	}

	mux := http.ServeMux{}
	mux.HandleFunc("/", handler.handle)
	server := http.Server{
		Addr:              ":" + cfg.Port,
		Handler:           &mux,
		ReadTimeout:       5 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       30 * time.Second,
		ReadHeaderTimeout: 2 * time.Second,
	}

	log.Println("Listening on port", cfg.Port)
	log.Fatal(server.ListenAndServe())
}

type handler struct {
	ignore    ignoreRules
	tokens    map[string]userToken
	calURL    url.URL
	amsTz     *time.Location
	userAgent string
}

func (h handler) makeTokenURL(token string) string {
	newURL := h.calURL
	query := newURL.Query()
	query.Set("token", token)
	newURL.RawQuery = query.Encode()
	return newURL.String()
}

func (h handler) handle(w http.ResponseWriter, r *http.Request) {
	brightspaceToken := r.URL.Query().Get("brightspace_token")
	proxyUserID := r.URL.Query().Get("proxy_user_id")
	proxyToken := r.URL.Query().Get("proxy_token")
	if !userOK(h.tokens, proxyUserID, []byte(proxyToken)) {
		http.Error(w, "Bad proxy_user_id or proxy_token",
			http.StatusUnauthorized)
		return
	}

	req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
		h.makeTokenURL(brightspaceToken), nil)
	if err != nil {
		http.Error(w, "Error constructing HTTP request for Brightspace",
			http.StatusInternalServerError)
		return
	}
	if h.userAgent != "" {
		req.Header.Add("User-Agent", h.userAgent)
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		http.Error(w, "Error sending request to Brightspace",
			http.StatusInternalServerError)
		return
	}

	cal, err := ical.NewDecoder(resp.Body).Decode()
	if err != nil {
		http.Error(w, "Could not decode iCal", http.StatusInternalServerError)
		return
	}

	filter(h.ignore, cal.Component)
	skewMidnightDeadlines(h.amsTz, cal.Component)

	if err = ical.NewEncoder(w).Encode(cal); err != nil {
		log.Println("Error writing calendar:", err)
	}
}

func filter(ignore ignoreRules, c *ical.Component) {
	j := 0
	for _, child := range c.Children {
		keep := true

		if child.Name == ical.CompEvent {
			if location := child.Props.Get(ical.PropLocation); location != nil {
				for _, locationRule := range ignore.LocationRegexes {
					if locationRule.MatchString(location.Value) {
						keep = false
					}
				}
			}
			if summary := child.Props.Get(ical.PropSummary); summary != nil {
				for _, summaryRule := range ignore.SummaryRegexes {
					if summaryRule.MatchString(summary.Value) {
						keep = false
					}
				}
			}
		}

		if keep {
			c.Children[j] = child
			j++
		}
	}
	c.Children = c.Children[:j]
}

// We often see events which are scheduled for exactly 'end of day', i.e.
// 23:59:59, with no difference between the start/end time. Unfortunately,
// these are not shown properly in two open-source Android calendar apps, so we
// monkey-hack the event to start at 23:30 and add a note to the description :)
func skewMidnightDeadlines(ams *time.Location, c *ical.Component) {
	for _, child := range c.Children {
		if child.Name == ical.CompEvent {
			if summary := child.Props.Get(ical.PropSummary); summary != nil &&
				strings.HasSuffix(summary.Value, " - Due") {
				// Modify the start time of the event
				startTime, stErr := child.Props.DateTime(ical.PropDateTimeStart, ams)
				endTime, etErr := child.Props.DateTime(ical.PropDateTimeEnd, ams)
				if stErr != nil || etErr != nil || startTime.IsZero() ||
					endTime.IsZero() || !startTime.Equal(endTime) ||
					startTime.In(ams).Hour() != 23 ||
					startTime.Minute() != 59 || startTime.Second() != 59 {
					continue
				}

				newStartTime := time.Date(startTime.Year(), startTime.Month(),
					startTime.Day(), 23, 30, 0, 0, ams)
				child.Props.SetDateTime(ical.PropDateTimeStart, newStartTime)

				// Update the event description to notify about the change
				descProp := child.Props.Get(ical.PropDescription)
				if descProp == nil {
					descProp = ical.NewProp(ical.PropDescription)
				}

				curText, err := descProp.Text()
				if err != nil {
					// forget about editing the text, at least we 'fixed' the
					// event time :)
					continue
				}
				curText = "+++ ICALPROXY: DEADLINE MOVED FROM 23:59:59 TO " +
					"23:30:00 (Europe/Amsterdam) +++\n\n" + curText
				descProp.SetText(curText)

				child.Props.Set(descProp)
			}
		}
	}
}

func userOK(tokens map[string]userToken, id string, token []byte) bool {
	info, ok := tokens[id]
	if !ok {
		return false
	}
	return bytes.Compare(hash(token, info.Salt), info.Hash) == 0
}

func hash(token []byte, salt []byte) []byte {
	return argon2.IDKey(token, salt, 1, 64*1024, 4, 32)
}