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:
Tronax 2026-08-25 18:50:28 +02:00
commit cb30223fd4
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
47 changed files with 6599 additions and 0 deletions

View 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 == ""
}