ical-offset/main.go

253 lines
6.2 KiB
Go

package main
import (
"context"
"flag"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"strconv"
"time"
ical "github.com/arran4/golang-ical"
)
type args struct {
url string
days int
hours int
mins int
}
type writeErrorArgs struct {
w http.ResponseWriter
message string
statusCode int
}
func writeError(args writeErrorArgs) {
if args.statusCode == 0 {
args.statusCode = http.StatusInternalServerError
}
slog.Error(args.message)
args.w.WriteHeader(args.statusCode)
io.WriteString(args.w, args.message+"\n")
}
func updateEventStartEnd(property ical.IANAProperty, reqArgs args) (time.Time, error) {
var dateStr string
if len(property.Value) > 8 {
dateStr = "20060102T150405Z"
} else {
dateStr = "20060102"
}
dt, err := time.Parse(dateStr, property.Value)
if err != nil {
return time.Time{}, err
}
dt = dt.AddDate(0, 0, reqArgs.days)
dt = dt.Add(time.Hour*time.Duration(reqArgs.hours) + time.Minute*time.Duration(reqArgs.mins))
return dt, nil
}
func isPrivateHostname(hostname string) bool {
// Check direct IP first
if ip := net.ParseIP(hostname); ip != nil {
return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()
}
// Resolve domain and check all IPs
ips, err := net.LookupIP(hostname)
if err != nil {
return true // Fail closed - if we can't resolve, block it
}
for _, ip := range ips {
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
return true
}
}
return false
}
func validateURL(w http.ResponseWriter, reqArgs args) bool {
// After parsing, before making request
parsedURL, err := url.Parse(reqArgs.url)
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
writeError(writeErrorArgs{w: w, message: "only http/https URLs allowed", statusCode: http.StatusBadRequest})
return false
}
// Optional: block private IPs
if isPrivateHostname(parsedURL.Hostname()) {
writeError(writeErrorArgs{w: w, message: "private IPs not allowed", statusCode: http.StatusBadRequest})
return false
}
return true
}
func fetchAndUpdate(w http.ResponseWriter, req *http.Request) {
reqArgs := args{}
var err error
for key, val := range req.URL.Query() {
switch key {
case "days":
reqArgs.days, err = strconv.Atoi(val[0])
if err != nil {
writeError(writeErrorArgs{w: w, message: "unable to convert days to an integer", statusCode: http.StatusBadRequest})
return
}
case "hours":
reqArgs.hours, err = strconv.Atoi(val[0])
if err != nil {
writeError(writeErrorArgs{w: w, message: "unable to convert hours to an integer", statusCode: http.StatusBadRequest})
return
}
case "mins":
reqArgs.mins, err = strconv.Atoi(val[0])
if err != nil {
writeError(writeErrorArgs{w: w, message: "unable to convert mins to an integer", statusCode: http.StatusBadRequest})
return
}
case "url":
reqArgs.url = val[0]
}
}
if reqArgs.url == "" {
writeError(writeErrorArgs{w: w, message: "`url` parameter is required", statusCode: http.StatusBadRequest})
return
}
if !validateURL(w, reqArgs) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := http.Client{}
request, err := http.NewRequestWithContext(ctx, "GET", reqArgs.url, nil)
if err != nil {
writeError(writeErrorArgs{w: w, message: fmt.Sprintf("unable to build HTTP request for %s: %s", reqArgs.url, err.Error())})
return
}
userAgent := req.UserAgent()
if userAgent == "" {
userAgent = "ical-offset/1.0"
}
request.Header.Set("user-agent", userAgent)
slog.Info("Fetching", "url", reqArgs.url)
response, err := client.Do(request)
if err != nil {
writeError(writeErrorArgs{w: w, message: fmt.Sprintf("unable to fetch url %s: %s", reqArgs.url, err.Error()), statusCode: http.StatusBadRequest})
return
}
defer response.Body.Close()
if response.StatusCode != 200 {
w.WriteHeader(response.StatusCode)
slog.Info("Non-200 response", "url", reqArgs.url, "status code", response.StatusCode)
body, err := io.ReadAll(response.Body)
if err != nil {
writeError(writeErrorArgs{w: w, message: fmt.Sprintf("unable to read response body: %s", err.Error())})
return
}
w.Write(body)
return
}
origCal, err := ical.ParseCalendar(response.Body)
if err != nil {
writeError(writeErrorArgs{w: w, message: fmt.Sprintf("unable to parse remote calendar: %s", err.Error())})
return
}
cal := ical.NewCalendar()
cal.SetProductId("-//sh.wallace.scott//ical-offset/EN")
for _, component := range origCal.Components {
switch component.(type) {
case *ical.VEvent:
event := ical.VEvent{}
for _, property := range component.UnknownPropertiesIANAProperties() {
// Copy all event contents over. We only update the DTSTART and DTEND.
event.Properties = append(event.Properties, property)
if reqArgs.days == 0 && reqArgs.hours == 0 && reqArgs.mins == 0 {
continue
}
switch property.IANAToken {
case "DTSTART":
dt, err := updateEventStartEnd(property, reqArgs)
if err != nil {
writeError(writeErrorArgs{w: w, message: fmt.Sprintf("unable to update event DTSTART: %s", err.Error())})
return
}
event.SetStartAt(dt)
case "DTEND":
dt, err := updateEventStartEnd(property, reqArgs)
if err != nil {
writeError(writeErrorArgs{w: w, message: fmt.Sprintf("unable to update event DTEND: %s", err.Error())})
return
}
event.SetEndAt(dt)
default:
continue
}
}
cal.Components = append(cal.Components, &event)
default:
cal.Components = append(cal.Components, component)
}
}
w.Header().Add("content-type", "text/calendar; charset=UTF-8")
cal.SerializeTo(w)
}
func main() {
var healthCheck = flag.Bool("health-check", false, "Perform health check and exit")
flag.Parse()
if *healthCheck {
resp, err := http.Get("http://127.0.0.1:8080/health")
if err != nil {
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
os.Exit(1)
}
os.Exit(0)
}
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
io.WriteString(w, "OK\n")
})
http.HandleFunc("/", fetchAndUpdate)
slog.Info("Listening", "port", 8080)
if err := http.ListenAndServe(":8080", nil); err != nil {
slog.Error("Failed to listen", "error", err)
}
}