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
270 lines
6.8 KiB
Go
270 lines
6.8 KiB
Go
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 == ""
|
||
}
|