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
This commit is contained in:
commit
cb30223fd4
47 changed files with 6599 additions and 0 deletions
270
backend/internal/calendar/ics.go
Normal file
270
backend/internal/calendar/ics.go
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
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 == ""
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue