253 lines
6.2 KiB
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)
|
|
}
|
|
}
|