wannpassts/backend/internal/calendar/ics.go
Tronax cb30223fd4
feat: add WannPassts booking app with Go backend and Vue frontend
Initial project setup for a privacy-focused, self-hosted scheduling app:

- Go backend with JWT auth, SQLite storage, and chi router
- Calendar integrations via Google OAuth (FreeBusy), CalDAV, and ICS links
- Public booking pages with configurable slots and booking requests
- AES-256-GCM encryption for stored provider tokens
- Vue 3 + TypeScript frontend with Vite, Pinia, and Vue Router
- Docs, .gitignore, and .env.example for local development
2026-08-25 18:50:28 +02:00

270 lines
6.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package calendar
import (
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// ICSConnConfig: Abo eines beliebigen Kalenders per ICS-/webcal-Link
// (z. B. die "private Adresse im iCal-Format" von Google oder Outlook-Feeds).
type ICSConnConfig struct {
URL string `json:"url"`
}
type ICSProvider struct {
URL string
// DefaultTZ wird für "floating" Zeiten (ohne TZID/UTC) genutzt,
// typischerweise die Zeitzone des Kalenderbesitzers.
DefaultTZ string
}
func NewICSProvider(cfg ICSConnConfig, defaultTZ string) *ICSProvider {
return &ICSProvider{URL: cfg.URL, DefaultTZ: defaultTZ}
}
type icsEvent struct {
start time.Time
hasStart bool
allDay bool
end time.Time
cancelled bool
transparent bool
}
func (p *ICSProvider) FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "WannPassts/1.0 (+calendar-sync)")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("ICS-Link nicht abrufbar: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ICS-Link antwortet mit HTTP %d", resp.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, fmt.Errorf("ICS-Antwort nicht lesbar: %w", err)
}
defLoc := time.UTC
if p.DefaultTZ != "" {
if loc, err := time.LoadLocation(p.DefaultTZ); err == nil {
defLoc = loc
}
}
events := parseICS(string(raw), defLoc)
var out []Interval
for _, ev := range events {
if ev.cancelled || ev.transparent || !ev.hasStart {
continue
}
end := ev.end
if end.IsZero() || !end.After(ev.start) {
if ev.allDay {
end = ev.start.Add(24 * time.Hour)
} else {
continue
}
}
out = append(out, Interval{Start: ev.start, End: end})
}
return clampIntervals(out, from, to), nil
}
// parseICS liest VEVENTs aus ICS-Daten. Zeilen werden entfaltet (RFC 5545),
// Parameter wie TZID/VALUE=DATE ausgewertet. Serientermine werden als ihre
// Basis-Instanz übernommen ICS-Abos sind damit der "best effort"-Weg,
// CalDAV und Google expandieren Serien serverseitig korrekt.
func parseICS(input string, defLoc *time.Location) []icsEvent {
input = strings.ReplaceAll(input, "\r\n", "\n")
rawLines := strings.Split(input, "\n")
lines := make([]string, 0, len(rawLines))
for _, l := range rawLines {
if strings.HasPrefix(l, " ") || strings.HasPrefix(l, "\t") {
if len(lines) > 0 {
lines[len(lines)-1] += l[1:]
}
continue
}
lines = append(lines, strings.TrimSuffix(l, "\r"))
}
var events []icsEvent
var cur *icsEvent
for _, line := range lines {
upper := strings.ToUpper(strings.TrimSpace(line))
switch {
case upper == "BEGIN:VEVENT":
cur = &icsEvent{}
continue
case upper == "END:VEVENT":
if cur != nil {
events = append(events, *cur)
cur = nil
}
continue
}
if cur == nil {
continue
}
name, params, value := splitICSLine(line)
switch name {
case "DTSTART":
if t, allDay, ok := parseICSDateTime(value, params, defLoc); ok {
cur.start, cur.hasStart, cur.allDay = t, true, allDay
}
case "DTEND":
if t, _, ok := parseICSDateTime(value, params, defLoc); ok {
cur.end = t
}
case "DURATION":
if cur.hasStart {
if d, ok := parseISODuration(value); ok {
cur.end = cur.start.Add(d)
}
}
case "STATUS":
if strings.EqualFold(value, "CANCELLED") {
cur.cancelled = true
}
case "TRANSP":
if strings.EqualFold(value, "TRANSPARENT") {
cur.transparent = true
}
}
}
return events
}
// splitICSLine zerlegt "NAME;PARAM=WERT;...:value" am ersten Doppelpunkt.
func splitICSLine(line string) (name string, params map[string]string, value string) {
params = map[string]string{}
i := strings.IndexByte(line, ':')
if i == -1 {
return strings.ToUpper(strings.TrimSpace(line)), params, ""
}
head, value := line[:i], strings.TrimSpace(line[i+1:])
parts := strings.Split(head, ";")
name = strings.ToUpper(strings.TrimSpace(parts[0]))
for _, p := range parts[1:] {
if eq := strings.IndexByte(p, '='); eq > 0 {
key := strings.ToUpper(strings.TrimSpace(p[:eq]))
params[key] = strings.Trim(strings.TrimSpace(p[eq+1:]), `"`)
}
}
return name, params, value
}
func parseICSDateTime(value string, params map[string]string, defLoc *time.Location) (t time.Time, allDay, ok bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false, false
}
if params["VALUE"] == "DATE" || (len(value) == 8 && allDigits(value)) {
y, err1 := strconv.Atoi(value[0:4])
m, err2 := strconv.Atoi(value[4:6])
d, err3 := strconv.Atoi(value[6:8])
if err1 != nil || err2 != nil || err3 != nil {
return time.Time{}, false, false
}
return time.Date(y, time.Month(m), d, 0, 0, 0, 0, defLoc), true, true
}
layouts := []struct {
layout string
utc bool
}{
{"20060102T150405Z", true},
{"20060102T150405", false},
{"2006-01-02T15:04:05Z07:00", true},
{"2006-01-02T15:04:05", false},
}
for _, l := range layouts {
if parsed, err := time.Parse(l.layout, value); err == nil {
if l.utc {
return parsed.UTC(), false, true
}
// Wanduhrzeit in der Zielzone interpretieren (nicht instant-konvertieren)
loc := defLoc
if tzid := params["TZID"]; tzid != "" {
if loaded, err := time.LoadLocation(tzid); err == nil {
loc = loaded
}
}
y, mo, d := parsed.Date()
hh, mm, ss := parsed.Clock()
return time.Date(y, mo, d, hh, mm, ss, 0, loc), false, true
}
}
return time.Time{}, false, false
}
func allDigits(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}
// parseISODuration versteht die üblichen ICS-Dauerformen: PT15M, PT1H30M,
// P1D, P2W, PT45S …
func parseISODuration(s string) (time.Duration, bool) {
s = strings.TrimSpace(strings.ToUpper(s))
if len(s) < 2 || s[0] != 'P' || strings.Contains(s, "-") {
return 0, false
}
body := s[1:]
if strings.HasSuffix(body, "W") {
v, err := strconv.ParseFloat(strings.TrimSuffix(body, "W"), 64)
if err != nil {
return 0, false
}
return time.Duration(v * float64(7*24*time.Hour)), true
}
if strings.HasPrefix(body, "T") {
body = body[1:]
}
units := map[byte]time.Duration{
'D': 24 * time.Hour,
'H': time.Hour,
'M': time.Minute,
'S': time.Second,
}
var total time.Duration
num := ""
flush := func(unit time.Duration) bool {
if num == "" {
return false
}
v, err := strconv.ParseFloat(num, 64)
if err != nil {
return false
}
total += time.Duration(v * float64(unit))
num = ""
return true
}
for i := 0; i < len(body); i++ {
c := body[i]
if (c >= '0' && c <= '9') || c == '.' {
num += string(c)
continue
}
unit, known := units[c]
if !known || !flush(unit) {
return 0, false
}
}
return total, num == ""
}