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
126
backend/internal/calendar/caldav.go
Normal file
126
backend/internal/calendar/caldav.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-ical"
|
||||
"github.com/emersion/go-webdav/caldav"
|
||||
)
|
||||
|
||||
// CalDAVConnConfig deckt iCloud (https://caldav.icloud.com mit App-spezifischem
|
||||
// Passwort) sowie Fastmail, Nextcloud und andere CalDAV-Server ab.
|
||||
type CalDAVConnConfig struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
CalendarPath string `json:"calendar_path"`
|
||||
}
|
||||
|
||||
type CalDAVProvider struct {
|
||||
cfg CalDAVConnConfig
|
||||
}
|
||||
|
||||
func NewCalDAVProvider(cfg CalDAVConnConfig) *CalDAVProvider {
|
||||
return &CalDAVProvider{cfg: cfg}
|
||||
}
|
||||
|
||||
type basicAuthTransport struct {
|
||||
username, password string
|
||||
}
|
||||
|
||||
func (t *basicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
clone := req.Clone(req.Context())
|
||||
clone.SetBasicAuth(t.username, t.password)
|
||||
return http.DefaultTransport.RoundTrip(clone)
|
||||
}
|
||||
|
||||
func newCalDAVClient(cfg CalDAVConnConfig) (*caldav.Client, error) {
|
||||
hc := &http.Client{Transport: &basicAuthTransport{cfg.Username, cfg.Password}}
|
||||
return caldav.NewClient(hc, cfg.ServerURL)
|
||||
}
|
||||
|
||||
// DiscoverCalDAV findet automatisch den ersten Kalender mit Terminen (VEVENT)
|
||||
// auf dem Server. Für iCloud: Apple-ID als Benutzername und ein im Apple-ID-
|
||||
// Konto erzeugtes App-spezifisches Passwort.
|
||||
func DiscoverCalDAV(ctx context.Context, cfg CalDAVConnConfig) (path, name string, err error) {
|
||||
c, err := newCalDAVClient(cfg)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("CalDAV-Server-URL ungültig: %w", err)
|
||||
}
|
||||
principal, err := c.FindCurrentUserPrincipal(ctx)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("CalDAV: Anmeldung/Principal fehlgeschlagen (Zugangsdaten prüfen; iCloud braucht ein App-spezifisches Passwort): %w", err)
|
||||
}
|
||||
homeSet, err := c.FindCalendarHomeSet(ctx, principal)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("CalDAV: Kalender-Verzeichnis nicht gefunden: %w", err)
|
||||
}
|
||||
cals, err := c.FindCalendars(ctx, homeSet)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("CalDAV: Kalender konnten nicht geladen werden: %w", err)
|
||||
}
|
||||
for _, cal := range cals {
|
||||
for _, comp := range cal.SupportedComponentSet {
|
||||
if strings.EqualFold(comp, "VEVENT") {
|
||||
return cal.Path, cal.Name, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("CalDAV: kein Kalender mit Terminen gefunden")
|
||||
}
|
||||
|
||||
func (p *CalDAVProvider) FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) {
|
||||
c, err := newCalDAVClient(p.cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Der Zeitfilter auf dem Server expandiert auch Serientermine (RFC 4791).
|
||||
query := &caldav.CalendarQuery{
|
||||
CompRequest: caldav.CalendarCompRequest{
|
||||
Name: "VCALENDAR",
|
||||
Comps: []caldav.CalendarCompRequest{{Name: "VEVENT"}},
|
||||
},
|
||||
CompFilter: caldav.CompFilter{
|
||||
Name: "VCALENDAR",
|
||||
Comps: []caldav.CompFilter{{
|
||||
Name: "VEVENT",
|
||||
Start: from,
|
||||
End: to,
|
||||
}},
|
||||
},
|
||||
}
|
||||
objects, err := c.QueryCalendar(ctx, p.cfg.CalendarPath, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CalDAV-Abfrage fehlgeschlagen: %w", err)
|
||||
}
|
||||
|
||||
var out []Interval
|
||||
for _, obj := range objects {
|
||||
if obj.Data == nil {
|
||||
continue
|
||||
}
|
||||
for _, ev := range obj.Data.Events() {
|
||||
if prop := ev.Props.Get(ical.PropStatus); prop != nil && strings.EqualFold(prop.Value, "CANCELLED") {
|
||||
continue
|
||||
}
|
||||
// Transparente Termine ("Verfügbar") blockieren keine Zeit.
|
||||
if prop := ev.Props.Get("TRANSP"); prop != nil && strings.EqualFold(prop.Value, "TRANSPARENT") {
|
||||
continue
|
||||
}
|
||||
start, err := ev.DateTimeStart(time.UTC)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
end, err := ev.DateTimeEnd(time.UTC)
|
||||
if err != nil || !end.After(start) {
|
||||
continue
|
||||
}
|
||||
out = append(out, Interval{Start: start, End: end})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
151
backend/internal/calendar/google.go
Normal file
151
backend/internal/calendar/google.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
)
|
||||
|
||||
// GoogleConnConfig ist der verschlüsselt gespeicherte Zustand einer
|
||||
// Google-Kalenderverbindung.
|
||||
type GoogleConnConfig struct {
|
||||
Token *oauth2.Token `json:"token"`
|
||||
CalendarID string `json:"calendar_id"`
|
||||
}
|
||||
|
||||
// GoogleOAuthConfig baut die OAuth2-Konfiguration. Die FreeBusy-API von Google
|
||||
// liefert ausschließlich busy-Zeiträume – gar keine Termindetails.
|
||||
func GoogleOAuthConfig(clientID, clientSecret, redirectURL string) *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: []string{"https://www.googleapis.com/auth/calendar.readonly"},
|
||||
Endpoint: google.Endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
type GoogleProvider struct {
|
||||
conf *oauth2.Config
|
||||
cfg GoogleConnConfig
|
||||
onTokenSaved func(*oauth2.Token) // wird aufgerufen, wenn das Token erneuert wurde
|
||||
}
|
||||
|
||||
func NewGoogleProvider(conf *oauth2.Config, cfg GoogleConnConfig, onTokenSaved func(*oauth2.Token)) *GoogleProvider {
|
||||
return &GoogleProvider{conf: conf, cfg: cfg, onTokenSaved: onTokenSaved}
|
||||
}
|
||||
|
||||
type googleFreeBusyRequest struct {
|
||||
TimeMin string `json:"timeMin"`
|
||||
TimeMax string `json:"timeMax"`
|
||||
Items []googleFreeBusyRequestItem `json:"items"`
|
||||
}
|
||||
|
||||
type googleFreeBusyRequestItem struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type googleFreeBusyResponse struct {
|
||||
Calendars map[string]struct {
|
||||
Busy []struct {
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
} `json:"busy"`
|
||||
Errors []struct {
|
||||
Reason string `json:"reason"`
|
||||
} `json:"errors"`
|
||||
} `json:"calendars"`
|
||||
}
|
||||
|
||||
func (p *GoogleProvider) FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) {
|
||||
ts := p.conf.TokenSource(ctx, p.cfg.Token)
|
||||
tok, err := ts.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Google-Zugriff ungültig (neu verbinden): %w", err)
|
||||
}
|
||||
if tok.AccessToken != p.cfg.Token.AccessToken {
|
||||
saved := *tok
|
||||
p.cfg.Token = &saved
|
||||
if p.onTokenSaved != nil {
|
||||
p.onTokenSaved(&saved)
|
||||
}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(googleFreeBusyRequest{
|
||||
TimeMin: from.UTC().Format(time.RFC3339),
|
||||
TimeMax: to.UTC().Format(time.RFC3339),
|
||||
Items: []googleFreeBusyRequestItem{{ID: p.cfg.CalendarID}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := oauth2.NewClient(ctx, ts)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
"https://www.googleapis.com/calendar/v3/freeBusy", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Google FreeBusy-Anfrage fehlgeschlagen: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Google FreeBusy-Fehler (HTTP %d): %.200s", resp.StatusCode, string(raw))
|
||||
}
|
||||
|
||||
var out googleFreeBusyResponse
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("Google FreeBusy-Antwort unlesbar: %w", err)
|
||||
}
|
||||
|
||||
cal, ok := out.Calendars[p.cfg.CalendarID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Google: Kalender %s nicht in Antwort", p.cfg.CalendarID)
|
||||
}
|
||||
if len(cal.Errors) > 0 {
|
||||
return nil, fmt.Errorf("Google: %s", cal.Errors[0].Reason)
|
||||
}
|
||||
intervals := make([]Interval, 0, len(cal.Busy))
|
||||
for _, b := range cal.Busy {
|
||||
intervals = append(intervals, Interval{Start: b.Start, End: b.End})
|
||||
}
|
||||
return intervals, nil
|
||||
}
|
||||
|
||||
// GooglePrimaryCalendar liefert ID und Namen des Primärkalenders – genutzt
|
||||
// direkt nach dem OAuth-Flow.
|
||||
func GooglePrimaryCalendar(ctx context.Context, client *http.Client) (id, summary string, err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
"https://www.googleapis.com/calendar/v3/calendars/primary", nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("Google: Primärkalender nicht lesbar (HTTP %d): %.200s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var cal struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cal); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return cal.ID, cal.Summary, nil
|
||||
}
|
||||
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 == ""
|
||||
}
|
||||
145
backend/internal/calendar/ics_test.go
Normal file
145
backend/internal/calendar/ics_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package calendar
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sampleICS = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//EN
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/Berlin
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19701025T030000
|
||||
TZOFFSETFROM:+0200
|
||||
TZOFFSETTO:+0100
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
UID:1
|
||||
DTSTART;TZID=Europe/Berlin:20260825T090000
|
||||
DTEND;TZID=Europe/Berlin:20260825T100000
|
||||
SUMMARY:Zahnarzt
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:2
|
||||
DTSTART:20260826T120000Z
|
||||
DURATION:PT45M
|
||||
SUMMARY:Call
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:3
|
||||
DTSTART;VALUE=DATE:20260827
|
||||
SUMMARY:Ganztägig
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:4
|
||||
DTSTART:20260828T080000Z
|
||||
DTEND:20260828T090000Z
|
||||
STATUS:CANCELLED
|
||||
SUMMARY:Abgesagt
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:5
|
||||
DTSTART:20260828T100000Z
|
||||
DTEND:20260828T110000Z
|
||||
TRANSP:TRANSPARENT
|
||||
SUMMARY:Verfügbar
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:6
|
||||
DTSTART:20260829T080000Z
|
||||
DTEND:202609
|
||||
01T080000Z
|
||||
SUMMARY:Gefaltet
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
`
|
||||
|
||||
func TestParseICS(t *testing.T) {
|
||||
berlin, err := time.LoadLocation("Europe/Berlin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events := parseICS(sampleICS, berlin)
|
||||
if len(events) != 6 {
|
||||
t.Fatalf("6 Events erwartet, got %d", len(events))
|
||||
}
|
||||
|
||||
// TZID-Event
|
||||
if !events[0].start.Equal(time.Date(2026, 8, 25, 9, 0, 0, 0, berlin)) {
|
||||
t.Errorf("TZID-Start falsch: %v", events[0].start)
|
||||
}
|
||||
if !events[0].end.Equal(time.Date(2026, 8, 25, 10, 0, 0, 0, berlin)) {
|
||||
t.Errorf("TZID-Ende falsch: %v", events[0].end)
|
||||
}
|
||||
|
||||
// UTC + DURATION
|
||||
if !events[1].end.Equal(events[1].start.Add(45*time.Minute)) {
|
||||
t.Errorf("DURATION nicht angewandt: %v", events[1].end)
|
||||
}
|
||||
|
||||
// Ganztägig
|
||||
if !events[2].allDay {
|
||||
t.Error("VALUE=DATE nicht erkannt")
|
||||
}
|
||||
|
||||
// Cancelled / Transparent markiert
|
||||
if !events[3].cancelled {
|
||||
t.Error("CANCELLED nicht erkannt")
|
||||
}
|
||||
if !events[4].transparent {
|
||||
t.Error("TRANSPARENT nicht erkannt")
|
||||
}
|
||||
|
||||
// Gefaltete Zeile (DTEND über zwei Zeilen)
|
||||
if !events[5].end.Equal(time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("Folding nicht aufgelöst, Ende: %v", events[5].end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseISODuration(t *testing.T) {
|
||||
cases := map[string]time.Duration{
|
||||
"PT15M": 15 * time.Minute,
|
||||
"PT1H30M": 90 * time.Minute,
|
||||
"P1D": 24 * time.Hour,
|
||||
"P2W": 14 * 24 * time.Hour,
|
||||
"PT45S": 45 * time.Second,
|
||||
"": 0,
|
||||
"X": 0,
|
||||
"P-1D": 0,
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, ok := parseISODuration(in)
|
||||
if in == "" || in == "X" || in == "P-1D" {
|
||||
if ok {
|
||||
t.Errorf("%q sollte ungültig sein", in)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !ok || got != want {
|
||||
t.Errorf("parseISODuration(%q) = %v (ok=%v), want %v", in, got, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampIntervals(t *testing.T) {
|
||||
from := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC)
|
||||
to := from.Add(48 * time.Hour)
|
||||
in := []Interval{
|
||||
{Start: from.Add(-2 * time.Hour), End: from.Add(time.Hour)},
|
||||
{Start: from.Add(3 * time.Hour), End: from.Add(2 * time.Hour)}, // ungültig
|
||||
{Start: from.Add(10 * time.Hour), End: to.Add(time.Hour)},
|
||||
}
|
||||
out := clampIntervals(in, from, to)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("2 Intervalle erwartet, got %d", len(out))
|
||||
}
|
||||
if !out[0].Start.Equal(from) || !out[0].End.Equal(from.Add(time.Hour)) {
|
||||
t.Errorf("Intervall 1 nicht geklemmt: %v–%v", out[0].Start, out[0].End)
|
||||
}
|
||||
if !out[1].End.Equal(to) {
|
||||
t.Errorf("Intervall 2 nicht geklemmt: %v", out[1].End)
|
||||
}
|
||||
}
|
||||
41
backend/internal/calendar/provider.go
Normal file
41
backend/internal/calendar/provider.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Interval ist ein "beschäftigt"-Zeitraum. Enthält bewusst KEINE Termindetails,
|
||||
// nur Start und Ende – mehr verlässt den Kalender des Nutzers nie.
|
||||
type Interval struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
// FetchBusy liefert alle busy-Zeiträume im Fenster [from, to).
|
||||
FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error)
|
||||
}
|
||||
|
||||
// clampIntervals schneidet Intervalle aufs Fenster zurecht, verwirft ungültige
|
||||
// und sortiert das Ergebnis.
|
||||
func clampIntervals(in []Interval, from, to time.Time) []Interval {
|
||||
out := make([]Interval, 0, len(in))
|
||||
for _, iv := range in {
|
||||
if !iv.End.After(iv.Start) {
|
||||
continue
|
||||
}
|
||||
if iv.Start.Before(from) {
|
||||
iv.Start = from
|
||||
}
|
||||
if iv.End.After(to) {
|
||||
iv.End = to
|
||||
}
|
||||
if iv.End.After(iv.Start) {
|
||||
out = append(out, iv)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Start.Before(out[j].Start) })
|
||||
return out
|
||||
}
|
||||
139
backend/internal/calendar/sync.go
Normal file
139
backend/internal/calendar/sync.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"wannpassts/internal/config"
|
||||
"wannpassts/internal/crypto"
|
||||
"wannpassts/internal/db"
|
||||
)
|
||||
|
||||
const syncStaleAfter = 5 * time.Minute
|
||||
|
||||
type Syncer struct {
|
||||
DB *db.DB
|
||||
Cfg *config.Config
|
||||
}
|
||||
|
||||
// NeedsSync meldet, ob eine der Verbindungen älter als syncStaleAfter ist.
|
||||
func NeedsSync(conns []*db.Connection, now time.Time) bool {
|
||||
for _, c := range conns {
|
||||
if !c.LastSyncedTs.Valid || now.Unix()-c.LastSyncedTs.Int64 > int64(syncStaleAfter.Seconds()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SyncUser aktualisiert die busy-Zeiträume aller Verbindungen eines Nutzers
|
||||
// für das Buchungsfenster [jetzt, jetzt+horizon]. Fehler einzelner Provider
|
||||
// werden pro Verbindung gespeichert und werfen den Gesamtsync nicht ab.
|
||||
func (s *Syncer) SyncUser(ctx context.Context, user *db.User) {
|
||||
conns, err := s.DB.ConnectionsForUser(user.ID)
|
||||
if err != nil {
|
||||
log.Printf("sync: Verbindungen von User %d nicht lesbar: %v", user.ID, err)
|
||||
return
|
||||
}
|
||||
if len(conns) == 0 {
|
||||
return
|
||||
}
|
||||
from := time.Now().Add(-time.Hour)
|
||||
to := time.Now().Add(time.Duration(user.HorizonDays) * 24 * time.Hour)
|
||||
|
||||
for _, conn := range conns {
|
||||
s.syncConnection(ctx, conn, user, from, to)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Syncer) syncConnection(ctx context.Context, conn *db.Connection, user *db.User, from, to time.Time) {
|
||||
prov, err := s.provider(conn)
|
||||
if err != nil {
|
||||
_ = s.DB.SetConnectionState(conn.ID, false, err.Error())
|
||||
return
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
busy, err := prov.FetchBusy(cctx, from, to)
|
||||
if err != nil {
|
||||
_ = s.DB.SetConnectionState(conn.ID, false, cleanErr(err.Error()))
|
||||
return
|
||||
}
|
||||
slots := make([]db.BusySlot, 0, len(busy))
|
||||
for _, iv := range busy {
|
||||
slots = append(slots, db.BusySlot{StartTs: iv.Start.Unix(), EndTs: iv.End.Unix()})
|
||||
}
|
||||
if err := s.DB.ReplaceBusyForConnection(conn.ID, user.ID, slots); err != nil {
|
||||
_ = s.DB.SetConnectionState(conn.ID, false, "Speichern fehlgeschlagen: "+err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.DB.SetConnectionState(conn.ID, true, "")
|
||||
}
|
||||
|
||||
// SyncInBackground startet den Sync ohne den Aufrufer zu blocken.
|
||||
func (s *Syncer) SyncInBackground(user *db.User) {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
s.SyncUser(ctx, user)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Syncer) provider(conn *db.Connection) (Provider, error) {
|
||||
plain, err := crypto.Decrypt(s.Cfg.EncryptionKey, conn.ConfigEnc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gespeicherte Verbindung nicht entschlüsselbar (ENCRYPTION_KEY geändert?): %w", err)
|
||||
}
|
||||
switch conn.Provider {
|
||||
case "google":
|
||||
var cfg GoogleConnConfig
|
||||
if err := json.Unmarshal(plain, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("Google-Konfiguration unlesbar: %w", err)
|
||||
}
|
||||
oconf := GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
|
||||
s.Cfg.AppURL+"/api/calendars/google/callback")
|
||||
calendarID := cfg.CalendarID
|
||||
return NewGoogleProvider(oconf, cfg, func(t *oauth2.Token) {
|
||||
s.saveGoogleToken(conn.ID, calendarID, t)
|
||||
}), nil
|
||||
case "caldav":
|
||||
var cfg CalDAVConnConfig
|
||||
if err := json.Unmarshal(plain, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("CalDAV-Konfiguration unlesbar: %w", err)
|
||||
}
|
||||
return NewCalDAVProvider(cfg), nil
|
||||
case "ics":
|
||||
var cfg ICSConnConfig
|
||||
if err := json.Unmarshal(plain, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("ICS-Konfiguration unlesbar: %w", err)
|
||||
}
|
||||
return NewICSProvider(cfg, ""), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unbekannter Provider %q", conn.Provider)
|
||||
}
|
||||
|
||||
func (s *Syncer) saveGoogleToken(connID int64, calendarID string, t *oauth2.Token) {
|
||||
data, err := json.Marshal(GoogleConnConfig{Token: t, CalendarID: calendarID})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
enc, err := crypto.Encrypt(s.Cfg.EncryptionKey, data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = s.DB.UpdateConnectionConfig(connID, enc)
|
||||
}
|
||||
|
||||
func cleanErr(msg string) string {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if len(msg) > 300 {
|
||||
msg = msg[:300] + "…"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
64
backend/internal/config/config.go
Normal file
64
backend/internal/config/config.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
DBPath string
|
||||
AppURL string
|
||||
FrontendURL string
|
||||
JWTSecret string
|
||||
EncryptionKey string
|
||||
GoogleClientID string
|
||||
GoogleClientSecret string
|
||||
StaticDir string
|
||||
}
|
||||
|
||||
// Load liest die Konfiguration aus Umgebungsvariablen (.env wird unterstützt).
|
||||
// Fehlende Secrets werden nur für die Entwicklung zufällig erzeugt.
|
||||
func Load() Config {
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg := Config{
|
||||
Port: env("PORT", "8080"),
|
||||
DBPath: env("DB_PATH", "wannpassts.db"),
|
||||
AppURL: env("APP_URL", "http://localhost:8080"),
|
||||
FrontendURL: env("FRONTEND_URL", "http://localhost:5173"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
EncryptionKey: os.Getenv("ENCRYPTION_KEY"),
|
||||
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
|
||||
GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
|
||||
StaticDir: env("STATIC_DIR", ""),
|
||||
}
|
||||
if cfg.JWTSecret == "" {
|
||||
cfg.JWTSecret = randomHex(32)
|
||||
log.Println("WARNUNG: JWT_SECRET nicht gesetzt – temporäres Secret erzeugt (Sessions verfallen bei Neustart).")
|
||||
}
|
||||
if cfg.EncryptionKey == "" {
|
||||
cfg.EncryptionKey = randomHex(32)
|
||||
log.Println("WARNUNG: ENCRYPTION_KEY nicht gesetzt – temporärer Schlüssel erzeugt (Kalender-Tokens verfallen bei Neustart).")
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
log.Fatalf("Zufallszahl nicht verfügbar: %v", err)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
52
backend/internal/crypto/secret.go
Normal file
52
backend/internal/crypto/secret.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Encrypt verschlüsselt plaintext mit AES-256-GCM (Key = SHA256(secret))
|
||||
// und liefert base64(nonce + ciphertext).
|
||||
func Encrypt(secret string, plaintext []byte) (string, error) {
|
||||
k := sha256.Sum256([]byte(secret))
|
||||
block, err := aes.NewCipher(k[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
return base64.StdEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
// Decrypt macht Encrypt rückgängig.
|
||||
func Decrypt(secret, s string) ([]byte, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k := sha256.Sum256([]byte(secret))
|
||||
block, err := aes.NewCipher(k[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) < gcm.NonceSize() {
|
||||
return nil, errors.New("ciphertext zu kurz")
|
||||
}
|
||||
return gcm.Open(nil, data[:gcm.NonceSize()], data[gcm.NonceSize():], nil)
|
||||
}
|
||||
354
backend/internal/db/db.go
Normal file
354
backend/internal/db/db.go
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("nicht gefunden")
|
||||
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Email string
|
||||
Name string
|
||||
PasswordHash string
|
||||
Slug string
|
||||
Timezone string
|
||||
SlotMinutes int
|
||||
DayStartMin int
|
||||
DayEndMin int
|
||||
HorizonDays int
|
||||
Durations string
|
||||
Weekdays string
|
||||
CreatedTs int64
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Provider string // google | caldav | ics
|
||||
DisplayName string
|
||||
ConfigEnc string
|
||||
LastSyncedTs sql.NullInt64
|
||||
LastError sql.NullString
|
||||
CreatedTs int64
|
||||
}
|
||||
|
||||
type BusySlot struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
ConnectionID sql.NullInt64
|
||||
StartTs int64
|
||||
EndTs int64
|
||||
BookingID sql.NullInt64
|
||||
}
|
||||
|
||||
type Booking struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
StartTs int64
|
||||
EndTs int64
|
||||
RequesterName string
|
||||
RequesterEmail string
|
||||
Message string
|
||||
Status string // pending | accepted | declined
|
||||
CreatedTs int64
|
||||
}
|
||||
|
||||
func Open(path string) (*DB, error) {
|
||||
d, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// SQLite verträgt parallele Writer nicht gut – ein Writer reicht hier.
|
||||
d.SetMaxOpenConns(1)
|
||||
if err := d.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbase := &DB{d}
|
||||
if err := dbase.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dbase, nil
|
||||
}
|
||||
|
||||
func (d *DB) migrate() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
timezone TEXT NOT NULL DEFAULT 'Europe/Berlin',
|
||||
slot_minutes INTEGER NOT NULL DEFAULT 30,
|
||||
day_start_min INTEGER NOT NULL DEFAULT 540,
|
||||
day_end_min INTEGER NOT NULL DEFAULT 1080,
|
||||
horizon_days INTEGER NOT NULL DEFAULT 21,
|
||||
durations TEXT NOT NULL DEFAULT '15,30,60,120',
|
||||
weekdays TEXT NOT NULL DEFAULT '1,2,3,4,5',
|
||||
created_ts INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS calendar_connections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
config_enc TEXT NOT NULL,
|
||||
last_synced_ts INTEGER,
|
||||
last_error TEXT,
|
||||
created_ts INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS busy_slots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
connection_id INTEGER REFERENCES calendar_connections(id) ON DELETE CASCADE,
|
||||
booking_id INTEGER,
|
||||
start_ts INTEGER NOT NULL,
|
||||
end_ts INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_busy_user_time ON busy_slots(user_id, start_ts)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_busy_booking ON busy_slots(booking_id)`,
|
||||
`CREATE TABLE IF NOT EXISTS booking_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
start_ts INTEGER NOT NULL,
|
||||
end_ts INTEGER NOT NULL,
|
||||
requester_name TEXT NOT NULL,
|
||||
requester_email TEXT NOT NULL,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_ts INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_bookings_user ON booking_requests(user_id, created_ts)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := d.Exec(s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
func (d *DB) CreateUser(u *User) error {
|
||||
return d.QueryRow(
|
||||
`INSERT INTO users (email, name, password_hash, slug, timezone, created_ts)
|
||||
VALUES (?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||
u.Email, u.Name, u.PasswordHash, u.Slug, u.Timezone, time.Now().Unix(),
|
||||
).Scan(&u.ID)
|
||||
}
|
||||
|
||||
const userCols = `id, email, name, password_hash, slug, timezone, slot_minutes, day_start_min, day_end_min, horizon_days, durations, weekdays, created_ts`
|
||||
|
||||
func scanUser(row interface{ Scan(...any) error }) (*User, error) {
|
||||
u := &User{}
|
||||
err := row.Scan(&u.ID, &u.Email, &u.Name, &u.PasswordHash, &u.Slug, &u.Timezone,
|
||||
&u.SlotMinutes, &u.DayStartMin, &u.DayEndMin, &u.HorizonDays, &u.Durations, &u.Weekdays, &u.CreatedTs)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (d *DB) UserByEmail(email string) (*User, error) {
|
||||
return scanUser(d.QueryRow(`SELECT `+userCols+` FROM users WHERE email = ?`, email))
|
||||
}
|
||||
|
||||
func (d *DB) UserByID(id int64) (*User, error) {
|
||||
return scanUser(d.QueryRow(`SELECT `+userCols+` FROM users WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
func (d *DB) UserBySlug(slug string) (*User, error) {
|
||||
return scanUser(d.QueryRow(`SELECT `+userCols+` FROM users WHERE slug = ?`, slug))
|
||||
}
|
||||
|
||||
func (d *DB) SlugExists(slug string) (bool, error) {
|
||||
var one int
|
||||
err := d.QueryRow(`SELECT 1 FROM users WHERE slug = ?`, slug).Scan(&one)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func (d *DB) UpdateUserSettings(u *User) error {
|
||||
_, err := d.Exec(`UPDATE users SET name=?, timezone=?, slot_minutes=?, day_start_min=?, day_end_min=?, horizon_days=?, durations=?, weekdays=? WHERE id=?`,
|
||||
u.Name, u.Timezone, u.SlotMinutes, u.DayStartMin, u.DayEndMin, u.HorizonDays, u.Durations, u.Weekdays, u.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) UpdateUserSlug(id int64, slug string) error {
|
||||
_, err := d.Exec(`UPDATE users SET slug=? WHERE id=?`, slug, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Connections ---
|
||||
|
||||
func (d *DB) CreateConnection(userID int64, provider, displayName, configEnc string) (int64, error) {
|
||||
var id int64
|
||||
err := d.QueryRow(
|
||||
`INSERT INTO calendar_connections (user_id, provider, display_name, config_enc, created_ts)
|
||||
VALUES (?, ?, ?, ?, ?) RETURNING id`,
|
||||
userID, provider, displayName, configEnc, time.Now().Unix(),
|
||||
).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const connCols = `id, user_id, provider, display_name, config_enc, last_synced_ts, last_error, created_ts`
|
||||
|
||||
func (d *DB) ConnectionsForUser(userID int64) ([]*Connection, error) {
|
||||
rows, err := d.Query(`SELECT `+connCols+` FROM calendar_connections WHERE user_id = ? ORDER BY created_ts`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Connection
|
||||
for rows.Next() {
|
||||
c := &Connection{}
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Provider, &c.DisplayName, &c.ConfigEnc, &c.LastSyncedTs, &c.LastError, &c.CreatedTs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) ConnectionByID(id, userID int64) (*Connection, error) {
|
||||
c := &Connection{}
|
||||
err := d.QueryRow(`SELECT `+connCols+` FROM calendar_connections WHERE id = ? AND user_id = ?`, id, userID).
|
||||
Scan(&c.ID, &c.UserID, &c.Provider, &c.DisplayName, &c.ConfigEnc, &c.LastSyncedTs, &c.LastError, &c.CreatedTs)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (d *DB) DeleteConnection(id, userID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM calendar_connections WHERE id = ? AND user_id = ?`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetConnectionState(id int64, synced bool, errMsg string) error {
|
||||
var syncedTs any
|
||||
if synced {
|
||||
syncedTs = time.Now().Unix()
|
||||
}
|
||||
_, err := d.Exec(`UPDATE calendar_connections SET last_synced_ts = ?, last_error = ? WHERE id = ?`, syncedTs, errMsg, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) UpdateConnectionConfig(id int64, configEnc string) error {
|
||||
_, err := d.Exec(`UPDATE calendar_connections SET config_enc = ? WHERE id = ?`, configEnc, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Busy slots ---
|
||||
|
||||
func (d *DB) ReplaceBusyForConnection(connID, userID int64, slots []BusySlot) error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM busy_slots WHERE connection_id = ?`, connID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range slots {
|
||||
if _, err := tx.Exec(`INSERT INTO busy_slots (user_id, connection_id, start_ts, end_ts) VALUES (?, ?, ?, ?)`,
|
||||
userID, connID, s.StartTs, s.EndTs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (d *DB) BusyForUser(userID, fromTs, toTs int64) ([]BusySlot, error) {
|
||||
rows, err := d.Query(`SELECT id, user_id, connection_id, start_ts, end_ts, booking_id FROM busy_slots
|
||||
WHERE user_id = ? AND end_ts > ? AND start_ts < ? ORDER BY start_ts`, userID, fromTs, toTs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []BusySlot
|
||||
for rows.Next() {
|
||||
var s BusySlot
|
||||
if err := rows.Scan(&s.ID, &s.UserID, &s.ConnectionID, &s.StartTs, &s.EndTs, &s.BookingID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// --- Bookings ---
|
||||
|
||||
func (d *DB) CreateBooking(b *Booking) error {
|
||||
return d.QueryRow(
|
||||
`INSERT INTO booking_requests (user_id, start_ts, end_ts, requester_name, requester_email, message, status, created_ts)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?) RETURNING id`,
|
||||
b.UserID, b.StartTs, b.EndTs, b.RequesterName, b.RequesterEmail, b.Message, time.Now().Unix(),
|
||||
).Scan(&b.ID)
|
||||
}
|
||||
|
||||
const bookingCols = `id, user_id, start_ts, end_ts, requester_name, requester_email, message, status, created_ts`
|
||||
|
||||
func (d *DB) BookingsForUser(userID int64) ([]*Booking, error) {
|
||||
rows, err := d.Query(`SELECT `+bookingCols+` FROM booking_requests WHERE user_id = ? ORDER BY (status='pending') DESC, start_ts ASC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Booking
|
||||
for rows.Next() {
|
||||
b := &Booking{}
|
||||
if err := rows.Scan(&b.ID, &b.UserID, &b.StartTs, &b.EndTs, &b.RequesterName, &b.RequesterEmail, &b.Message, &b.Status, &b.CreatedTs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) BookingByID(id, userID int64) (*Booking, error) {
|
||||
b := &Booking{}
|
||||
err := d.QueryRow(`SELECT `+bookingCols+` FROM booking_requests WHERE id = ? AND user_id = ?`, id, userID).
|
||||
Scan(&b.ID, &b.UserID, &b.StartTs, &b.EndTs, &b.RequesterName, &b.RequesterEmail, &b.Message, &b.Status, &b.CreatedTs)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (d *DB) SetBookingStatus(id int64, status string) error {
|
||||
_, err := d.Exec(`UPDATE booking_requests SET status = ? WHERE id = ?`, status, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) InsertBusyForBooking(b *Booking) error {
|
||||
_, err := d.Exec(`INSERT INTO busy_slots (user_id, booking_id, start_ts, end_ts) VALUES (?, ?, ?, ?)`,
|
||||
b.UserID, b.ID, b.StartTs, b.EndTs)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteBusyByBooking(bookingID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM busy_slots WHERE booking_id = ?`, bookingID)
|
||||
return err
|
||||
}
|
||||
289
backend/internal/httpapi/auth.go
Normal file
289
backend/internal/httpapi/auth.go
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"wannpassts/internal/db"
|
||||
)
|
||||
|
||||
const tokenTTL = 7 * 24 * time.Hour
|
||||
|
||||
func (s *Server) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||
return
|
||||
}
|
||||
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if !validEmail(in.Email) {
|
||||
jsonError(w, http.StatusBadRequest, "Bitte eine gültige E-Mail-Adresse angeben")
|
||||
return
|
||||
}
|
||||
if len(in.Password) < 8 || len(in.Password) > 200 {
|
||||
jsonError(w, http.StatusBadRequest, "Das Passwort muss 8–200 Zeichen lang sein")
|
||||
return
|
||||
}
|
||||
if in.Name == "" || len(in.Name) > 80 {
|
||||
jsonError(w, http.StatusBadRequest, "Bitte einen Namen (max. 80 Zeichen) angeben")
|
||||
return
|
||||
}
|
||||
tz := in.Timezone
|
||||
if tz == "" {
|
||||
tz = "Europe/Berlin"
|
||||
}
|
||||
if _, err := time.LoadLocation(tz); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Unbekannte Zeitzone")
|
||||
return
|
||||
}
|
||||
if _, err := s.DB.UserByEmail(in.Email); err == nil {
|
||||
jsonError(w, http.StatusConflict, "Diese E-Mail-Adresse ist bereits registriert")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), 10)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Passwort konnte nicht verarbeitet werden")
|
||||
return
|
||||
}
|
||||
slug, err := s.uniqueSlug()
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Interner Fehler")
|
||||
return
|
||||
}
|
||||
u := &db.User{Email: in.Email, Name: in.Name, PasswordHash: string(hash), Slug: slug, Timezone: tz}
|
||||
if err := s.DB.CreateUser(u); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Konto konnte nicht erstellt werden")
|
||||
return
|
||||
}
|
||||
token, err := s.signJWT(u.ID, tokenTTL)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"token": token, "user": userToJSON(u)})
|
||||
}
|
||||
|
||||
func (s *Server) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||
return
|
||||
}
|
||||
u, err := s.DB.UserByEmail(strings.ToLower(strings.TrimSpace(in.Email)))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusUnauthorized, "E-Mail oder Passwort falsch")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(in.Password)) != nil {
|
||||
jsonError(w, http.StatusUnauthorized, "E-Mail oder Passwort falsch")
|
||||
return
|
||||
}
|
||||
token, err := s.signJWT(u.ID, tokenTTL)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": userToJSON(u)})
|
||||
}
|
||||
|
||||
func (s *Server) Me(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(userFrom(r))})
|
||||
}
|
||||
|
||||
func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
u := userFrom(r)
|
||||
var in struct {
|
||||
Name *string `json:"name"`
|
||||
Timezone *string `json:"timezone"`
|
||||
SlotMinutes *int `json:"slot_minutes"`
|
||||
DayStartMin *int `json:"day_start_min"`
|
||||
DayEndMin *int `json:"day_end_min"`
|
||||
HorizonDays *int `json:"horizon_days"`
|
||||
Durations []int `json:"durations"`
|
||||
Weekdays []int `json:"weekdays"`
|
||||
}
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||
return
|
||||
}
|
||||
if in.Name != nil {
|
||||
name := strings.TrimSpace(*in.Name)
|
||||
if name == "" || len(name) > 80 {
|
||||
jsonError(w, http.StatusBadRequest, "Name muss 1–80 Zeichen haben")
|
||||
return
|
||||
}
|
||||
u.Name = name
|
||||
}
|
||||
if in.Timezone != nil {
|
||||
if _, err := time.LoadLocation(*in.Timezone); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Unbekannte Zeitzone")
|
||||
return
|
||||
}
|
||||
u.Timezone = *in.Timezone
|
||||
}
|
||||
if in.SlotMinutes != nil {
|
||||
if *in.SlotMinutes < 5 || *in.SlotMinutes > 180 {
|
||||
jsonError(w, http.StatusBadRequest, "Slot-Größe muss 5–180 Minuten sein")
|
||||
return
|
||||
}
|
||||
u.SlotMinutes = *in.SlotMinutes
|
||||
}
|
||||
if in.DayStartMin != nil {
|
||||
if *in.DayStartMin < 0 || *in.DayStartMin > 1439 {
|
||||
jsonError(w, http.StatusBadRequest, "Tagesbeginn ungültig")
|
||||
return
|
||||
}
|
||||
u.DayStartMin = *in.DayStartMin
|
||||
}
|
||||
if in.DayEndMin != nil {
|
||||
if *in.DayEndMin < 1 || *in.DayEndMin > 1440 {
|
||||
jsonError(w, http.StatusBadRequest, "Tagesende ungültig")
|
||||
return
|
||||
}
|
||||
u.DayEndMin = *in.DayEndMin
|
||||
}
|
||||
if u.DayStartMin >= u.DayEndMin {
|
||||
jsonError(w, http.StatusBadRequest, "Tagesbeginn muss vor dem Tagesende liegen")
|
||||
return
|
||||
}
|
||||
if in.HorizonDays != nil {
|
||||
if *in.HorizonDays < 1 || *in.HorizonDays > 120 {
|
||||
jsonError(w, http.StatusBadRequest, "Buchungshorizont muss 1–120 Tage sein")
|
||||
return
|
||||
}
|
||||
u.HorizonDays = *in.HorizonDays
|
||||
}
|
||||
if in.Durations != nil {
|
||||
durations, err := cleanDurations(in.Durations)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
u.Durations = intsCSV(durations)
|
||||
}
|
||||
if in.Weekdays != nil {
|
||||
weekdays, err := cleanWeekdays(in.Weekdays)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
u.Weekdays = intsCSV(weekdays)
|
||||
}
|
||||
if err := s.DB.UpdateUserSettings(u); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(u)})
|
||||
}
|
||||
|
||||
func (s *Server) RegenSlug(w http.ResponseWriter, r *http.Request) {
|
||||
u := userFrom(r)
|
||||
slug, err := s.uniqueSlug()
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Interner Fehler")
|
||||
return
|
||||
}
|
||||
if err := s.DB.UpdateUserSlug(u.ID, slug); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
u.Slug = slug
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(u)})
|
||||
}
|
||||
|
||||
func (s *Server) uniqueSlug() (string, error) {
|
||||
for i := 0; i < 8; i++ {
|
||||
b := make([]byte, 9)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
slug := base64.RawURLEncoding.EncodeToString(b)
|
||||
exists, err := s.DB.SlugExists(slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return slug, nil
|
||||
}
|
||||
}
|
||||
return "", http.ErrBodyNotAllowed
|
||||
}
|
||||
|
||||
func validEmail(s string) bool {
|
||||
if len(s) < 5 || len(s) > 254 || strings.Count(s, "@") != 1 {
|
||||
return false
|
||||
}
|
||||
at := strings.IndexByte(s, '@')
|
||||
local, domain := s[:at], s[at+1:]
|
||||
if local == "" || !strings.Contains(domain, ".") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
|
||||
return false
|
||||
}
|
||||
return !strings.ContainsAny(s, " \t")
|
||||
}
|
||||
|
||||
func cleanDurations(list []int) ([]int, error) {
|
||||
if len(list) == 0 || len(list) > 8 {
|
||||
return nil, errNew("1–8 Dauerangaben benötigt")
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
var out []int
|
||||
for _, v := range list {
|
||||
if v < 5 || v > 480 {
|
||||
return nil, errNew("Dauern müssen 5–480 Minuten sein")
|
||||
}
|
||||
if !seen[v] {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cleanWeekdays(list []int) ([]int, error) {
|
||||
if len(list) == 0 || len(list) > 7 {
|
||||
return nil, errNew("Mindestens ein Buchungstag nötig")
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
var out []int
|
||||
for _, v := range list {
|
||||
if v < 1 || v > 7 {
|
||||
return nil, errNew("Wochentage müssen 1 (Mo) – 7 (So) sein")
|
||||
}
|
||||
if !seen[v] {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func errNew(msg string) error { return &simpleError{msg} }
|
||||
|
||||
type simpleError struct{ msg string }
|
||||
|
||||
func (e *simpleError) Error() string { return e.msg }
|
||||
93
backend/internal/httpapi/bookings.go
Normal file
93
backend/internal/httpapi/bookings.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *Server) ListBookings(w http.ResponseWriter, r *http.Request) {
|
||||
bookings, err := s.DB.BookingsForUser(userFrom(r).ID)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Anfragen nicht lesbar")
|
||||
return
|
||||
}
|
||||
out := make([]BookingJSON, 0, len(bookings))
|
||||
for _, b := range bookings {
|
||||
out = append(out, bookingToJSON(b))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) AcceptBooking(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r)
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
booking, err := s.DB.BookingByID(id, user.ID)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, "Anfrage nicht gefunden")
|
||||
return
|
||||
}
|
||||
if booking.Status != "pending" {
|
||||
jsonError(w, http.StatusConflict, "Diese Anfrage wurde bereits bearbeitet")
|
||||
return
|
||||
}
|
||||
// Kollision mit bestehenden busy-Zeiträumen prüfen (angommene Buchungen
|
||||
// und Kalendertermine liegen beide in busy_slots).
|
||||
busy, err := s.DB.BusyForUser(user.ID, booking.StartTs-1, booking.EndTs+1)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Belegung nicht prüfbar")
|
||||
return
|
||||
}
|
||||
for _, b := range busy {
|
||||
if b.BookingID.Valid && b.BookingID.Int64 == booking.ID {
|
||||
continue
|
||||
}
|
||||
if b.StartTs < booking.EndTs && booking.StartTs < b.EndTs {
|
||||
jsonError(w, http.StatusConflict, "Der Zeitraum kollidiert inzwischen mit einem anderen Termin")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.DB.InsertBusyForBooking(booking); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Termin konnte nicht eingetragen werden")
|
||||
return
|
||||
}
|
||||
if err := s.DB.SetBookingStatus(booking.ID, "accepted"); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
booking.Status = "accepted"
|
||||
writeJSON(w, http.StatusOK, bookingToJSON(booking))
|
||||
}
|
||||
|
||||
func (s *Server) DeclineBooking(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r)
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
booking, err := s.DB.BookingByID(id, user.ID)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, "Anfrage nicht gefunden")
|
||||
return
|
||||
}
|
||||
if booking.Status == "pending" {
|
||||
if err := s.DB.SetBookingStatus(booking.ID, "declined"); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
} else if booking.Status == "accepted" {
|
||||
if err := s.DB.DeleteBusyByBooking(booking.ID); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Termin konnte nicht entfernt werden")
|
||||
return
|
||||
}
|
||||
if err := s.DB.SetBookingStatus(booking.ID, "declined"); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
}
|
||||
booking.Status = "declined"
|
||||
writeJSON(w, http.StatusOK, bookingToJSON(booking))
|
||||
}
|
||||
247
backend/internal/httpapi/calendars.go
Normal file
247
backend/internal/httpapi/calendars.go
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"wannpassts/internal/calendar"
|
||||
"wannpassts/internal/crypto"
|
||||
)
|
||||
|
||||
func (s *Server) ListCalendars(w http.ResponseWriter, r *http.Request) {
|
||||
conns, err := s.DB.ConnectionsForUser(userFrom(r).ID)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Verbindungen nicht lesbar")
|
||||
return
|
||||
}
|
||||
out := make([]ConnectionJSON, 0, len(conns))
|
||||
for _, c := range conns {
|
||||
out = append(out, connToJSON(c))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) DeleteCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
if err := s.DB.DeleteConnection(id, userFrom(r).ID); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func (s *Server) ConnectCalDAV(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r)
|
||||
var in struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
CalendarPath string `json:"calendar_path"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||
return
|
||||
}
|
||||
in.ServerURL = strings.TrimSuffix(strings.TrimSpace(in.ServerURL), "/")
|
||||
if !strings.HasPrefix(in.ServerURL, "http://") && !strings.HasPrefix(in.ServerURL, "https://") {
|
||||
jsonError(w, http.StatusBadRequest, "Server-URL muss mit http:// oder https:// beginnen")
|
||||
return
|
||||
}
|
||||
if in.Username == "" || in.Password == "" {
|
||||
jsonError(w, http.StatusBadRequest, "Benutzername und Passwort werden benötigt (iCloud: App-spezifisches Passwort)")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := calendar.CalDAVConnConfig{
|
||||
ServerURL: in.ServerURL,
|
||||
Username: in.Username,
|
||||
Password: in.Password,
|
||||
CalendarPath: strings.TrimSpace(in.CalendarPath),
|
||||
}
|
||||
if cfg.CalendarPath == "" {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
|
||||
defer cancel()
|
||||
path, name, err := calendar.DiscoverCalDAV(ctx, cfg)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
cfg.CalendarPath = path
|
||||
if in.DisplayName == "" {
|
||||
in.DisplayName = name
|
||||
}
|
||||
}
|
||||
if in.DisplayName == "" {
|
||||
if u, err := url.Parse(in.ServerURL); err == nil {
|
||||
in.DisplayName = u.Host
|
||||
} else {
|
||||
in.DisplayName = "CalDAV-Kalender"
|
||||
}
|
||||
}
|
||||
|
||||
enc, err := s.encryptConfig(cfg)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
id, err := s.DB.CreateConnection(user.ID, "caldav", in.DisplayName, enc)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Verbindung konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
s.Syncer.SyncInBackground(user)
|
||||
writeJSON(w, http.StatusCreated, ConnectionJSON{ID: id, Provider: "caldav", DisplayName: in.DisplayName, CreatedTs: time.Now().Unix()})
|
||||
}
|
||||
|
||||
func (s *Server) ConnectICS(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r)
|
||||
var in struct {
|
||||
URL string `json:"url"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||
return
|
||||
}
|
||||
rawURL := strings.TrimSpace(in.URL)
|
||||
if strings.HasPrefix(rawURL, "webcal://") {
|
||||
rawURL = "https://" + strings.TrimPrefix(rawURL, "webcal://")
|
||||
}
|
||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||
jsonError(w, http.StatusBadRequest, "Der Link muss mit http://, https:// oder webcal:// beginnen")
|
||||
return
|
||||
}
|
||||
|
||||
// Einmal direkt abrufen, um den Link zu validieren.
|
||||
prov := calendar.NewICSProvider(calendar.ICSConnConfig{URL: rawURL}, user.Timezone)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
if _, err := prov.FetchBusy(ctx, time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour)); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "ICS-Link prüfen: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if in.DisplayName == "" {
|
||||
if u, err := url.Parse(rawURL); err == nil {
|
||||
in.DisplayName = u.Host
|
||||
} else {
|
||||
in.DisplayName = "ICS-Kalender"
|
||||
}
|
||||
}
|
||||
enc, err := s.encryptConfig(calendar.ICSConnConfig{URL: rawURL})
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
id, err := s.DB.CreateConnection(user.ID, "ics", in.DisplayName, enc)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Verbindung konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
s.Syncer.SyncInBackground(user)
|
||||
writeJSON(w, http.StatusCreated, ConnectionJSON{ID: id, Provider: "ics", DisplayName: in.DisplayName, CreatedTs: time.Now().Unix()})
|
||||
}
|
||||
|
||||
func (s *Server) GoogleConnectStart(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Cfg.GoogleClientID == "" || s.Cfg.GoogleClientSecret == "" {
|
||||
jsonError(w, http.StatusServiceUnavailable, "Google-Anmeldung ist nicht konfiguriert (GOOGLE_CLIENT_ID und GOOGLE_CLIENT_SECRET in .env setzen)")
|
||||
return
|
||||
}
|
||||
state, err := s.signJWT(userFrom(r).ID, 10*time.Minute)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "State konnte nicht erzeugt werden")
|
||||
return
|
||||
}
|
||||
conf := calendar.GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
|
||||
s.Cfg.AppURL+"/api/calendars/google/callback")
|
||||
authURL := conf.AuthCodeURL(state,
|
||||
oauth2.AccessTypeOffline,
|
||||
oauth2.SetAuthURLParam("prompt", "consent select_account"),
|
||||
)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": authURL})
|
||||
}
|
||||
|
||||
func (s *Server) GoogleConnectCallback(w http.ResponseWriter, r *http.Request) {
|
||||
fail := func(reason string) {
|
||||
http.Redirect(w, r, s.Cfg.FrontendURL+"/app?google=error&reason="+url.QueryEscape(reason), http.StatusFound)
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if reason := q.Get("error"); reason != "" {
|
||||
fail("Google-Anmeldung abgelehnt: " + reason)
|
||||
return
|
||||
}
|
||||
uid, err := s.verifyJWT(q.Get("state"))
|
||||
if err != nil {
|
||||
fail("Ungültiger or abgelaufener Verbindungsversuch – bitte erneut versuchen")
|
||||
return
|
||||
}
|
||||
user, err := s.DB.UserByID(uid)
|
||||
if err != nil {
|
||||
fail("Konto nicht gefunden")
|
||||
return
|
||||
}
|
||||
conf := calendar.GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
|
||||
s.Cfg.AppURL+"/api/calendars/google/callback")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
|
||||
defer cancel()
|
||||
tok, err := conf.Exchange(ctx, q.Get("code"))
|
||||
if err != nil {
|
||||
fail("Google-Token-Austausch fehlgeschlagen: "+cleanReason(err.Error()))
|
||||
return
|
||||
}
|
||||
calID, summary, err := calendar.GooglePrimaryCalendar(ctx, conf.Client(ctx, tok))
|
||||
if err != nil {
|
||||
fail("Google-Kalender nicht lesbar: "+cleanReason(err.Error()))
|
||||
return
|
||||
}
|
||||
name := summary
|
||||
if name == "" {
|
||||
name = "Google Kalender"
|
||||
}
|
||||
enc, err := s.encryptConfig(calendar.GoogleConnConfig{Token: tok, CalendarID: calID})
|
||||
if err != nil {
|
||||
fail("Speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
if _, err := s.DB.CreateConnection(user.ID, "google", name, enc); err != nil {
|
||||
fail("Verbindung konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
s.Syncer.SyncInBackground(user)
|
||||
http.Redirect(w, r, s.Cfg.FrontendURL+"/app?google=ok", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) SyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 75*time.Second)
|
||||
defer cancel()
|
||||
s.Syncer.SyncUser(ctx, user)
|
||||
s.ListCalendars(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) encryptConfig(cfg any) (string, error) {
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return crypto.Encrypt(s.Cfg.EncryptionKey, data)
|
||||
}
|
||||
|
||||
func cleanReason(msg string) string {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if len(msg) > 200 {
|
||||
msg = msg[:200] + "…"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
201
backend/internal/httpapi/public.go
Normal file
201
backend/internal/httpapi/public.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"wannpassts/internal/calendar"
|
||||
"wannpassts/internal/db"
|
||||
)
|
||||
|
||||
var emailRX = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||
|
||||
type publicInterval struct {
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
}
|
||||
|
||||
type publicInfo struct {
|
||||
Name string `json:"name"`
|
||||
Timezone string `json:"timezone"`
|
||||
SlotMinutes int `json:"slot_minutes"`
|
||||
DayStartMin int `json:"day_start_min"`
|
||||
DayEndMin int `json:"day_end_min"`
|
||||
HorizonDays int `json:"horizon_days"`
|
||||
Durations []int `json:"durations"`
|
||||
Weekdays []int `json:"weekdays"`
|
||||
Busy []publicInterval `json:"busy"`
|
||||
}
|
||||
|
||||
// PublicInfo liefert die Buchungsseite: Name, Buchungsregeln und ausschließlich
|
||||
// busy-Zeiträume – niemals Termintitel, Beschreibungen oder Teilnehmer.
|
||||
func (s *Server) PublicInfo(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.DB.UserBySlug(chi.URLParam(r, "slug"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, "Buchungsseite nicht gefunden")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
if conns, err := s.DB.ConnectionsForUser(user.ID); err == nil && calendar.NeedsSync(conns, now) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
s.Syncer.SyncUser(ctx, user)
|
||||
cancel()
|
||||
}
|
||||
|
||||
horizonEnd := now.Add(time.Duration(user.HorizonDays+1) * 24 * time.Hour)
|
||||
busy, err := s.DB.BusyForUser(user.ID, now.Add(-time.Hour).Unix(), horizonEnd.Unix())
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Belegung nicht lesbar")
|
||||
return
|
||||
}
|
||||
|
||||
resp := publicInfo{
|
||||
Name: user.Name, Timezone: user.Timezone,
|
||||
SlotMinutes: user.SlotMinutes, DayStartMin: user.DayStartMin, DayEndMin: user.DayEndMin,
|
||||
HorizonDays: user.HorizonDays,
|
||||
Durations: csvInts(user.Durations), Weekdays: csvInts(user.Weekdays),
|
||||
Busy: make([]publicInterval, 0, len(busy)),
|
||||
}
|
||||
for _, iv := range mergeBusy(busy) {
|
||||
resp.Busy = append(resp.Busy, publicInterval{
|
||||
Start: time.Unix(iv.StartTs, 0).UTC().Format(time.RFC3339),
|
||||
End: time.Unix(iv.EndTs, 0).UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func mergeBusy(in []db.BusySlot) []db.BusySlot {
|
||||
var out []db.BusySlot
|
||||
for _, s := range in {
|
||||
if n := len(out); n > 0 && s.StartTs <= out[n-1].EndTs {
|
||||
if s.EndTs > out[n-1].EndTs {
|
||||
out[n-1].EndTs = s.EndTs
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CreateBooking nimmt eine Buchungsanfrage an: Validiert gegen Buchungsregeln
|
||||
// und Belegung, legt die Anfrage mit Status "pending" an.
|
||||
func (s *Server) CreateBooking(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.DB.UserBySlug(chi.URLParam(r, "slug"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, "Buchungsseite nicht gefunden")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Start string `json:"start"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
in.Email = strings.TrimSpace(in.Email)
|
||||
in.Message = strings.TrimSpace(in.Message)
|
||||
if in.Name == "" || len(in.Name) > 100 {
|
||||
jsonError(w, http.StatusBadRequest, "Bitte einen Namen (max. 100 Zeichen) angeben")
|
||||
return
|
||||
}
|
||||
if !emailRX.MatchString(in.Email) || len(in.Email) > 254 {
|
||||
jsonError(w, http.StatusBadRequest, "Bitte eine gültige E-Mail-Adresse angeben")
|
||||
return
|
||||
}
|
||||
if len(in.Message) > 2000 {
|
||||
jsonError(w, http.StatusBadRequest, "Nachricht zu lang (max. 2000 Zeichen)")
|
||||
return
|
||||
}
|
||||
|
||||
allowed := false
|
||||
for _, d := range csvInts(user.Durations) {
|
||||
if d == in.DurationMinutes {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
jsonError(w, http.StatusBadRequest, "Ungültige Dauer")
|
||||
return
|
||||
}
|
||||
|
||||
start, err := time.Parse(time.RFC3339, strings.TrimSpace(in.Start))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Ungültiger Startzeitpunkt")
|
||||
return
|
||||
}
|
||||
end := start.Add(time.Duration(in.DurationMinutes) * time.Minute)
|
||||
now := time.Now()
|
||||
|
||||
if start.Unix() < now.Unix()-120 {
|
||||
jsonError(w, http.StatusBadRequest, "Der Zeitpunkt liegt in der Vergangenheit")
|
||||
return
|
||||
}
|
||||
if start.After(now.Add(time.Duration(user.HorizonDays) * 24 * time.Hour)) {
|
||||
jsonError(w, http.StatusBadRequest, "Der Zeitpunkt liegt außerhalb des Buchungszeitraums")
|
||||
return
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation(user.Timezone)
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
local := start.In(loc)
|
||||
isoWeekday := (int(local.Weekday())+6)%7 + 1 // Mo=1 … So=7
|
||||
if !containsInt(csvInts(user.Weekdays), isoWeekday) {
|
||||
jsonError(w, http.StatusBadRequest, "An diesem Tag werden keine Buchungen angenommen")
|
||||
return
|
||||
}
|
||||
minuteOfDay := local.Hour()*60 + local.Minute()
|
||||
if minuteOfDay < user.DayStartMin-2 || minuteOfDay+in.DurationMinutes > user.DayEndMin+2 {
|
||||
jsonError(w, http.StatusBadRequest, "Außerhalb der Buchungszeiten")
|
||||
return
|
||||
}
|
||||
|
||||
busy, err := s.DB.BusyForUser(user.ID, start.Unix()-1, end.Unix()+1)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Belegung nicht prüfbar")
|
||||
return
|
||||
}
|
||||
for _, b := range busy {
|
||||
if b.StartTs < end.Unix() && start.Unix() < b.EndTs {
|
||||
jsonError(w, http.StatusConflict, "Dieser Zeitraum ist leider bereits belegt")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
booking := &db.Booking{
|
||||
UserID: user.ID, StartTs: start.Unix(), EndTs: end.Unix(),
|
||||
RequesterName: in.Name, RequesterEmail: in.Email, Message: in.Message,
|
||||
}
|
||||
if err := s.DB.CreateBooking(booking); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Anfrage konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
booking.Status = "pending"
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"id": booking.ID, "status": booking.Status,
|
||||
})
|
||||
}
|
||||
|
||||
func containsInt(list []int, v int) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
280
backend/internal/httpapi/server.go
Normal file
280
backend/internal/httpapi/server.go
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"wannpassts/internal/calendar"
|
||||
"wannpassts/internal/config"
|
||||
"wannpassts/internal/db"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
DB *db.DB
|
||||
Cfg *config.Config
|
||||
Syncer *calendar.Syncer
|
||||
}
|
||||
|
||||
func New(database *db.DB, cfg *config.Config, syncer *calendar.Syncer) *Server {
|
||||
return &Server{DB: database, Cfg: cfg, Syncer: syncer}
|
||||
}
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const ctxUser ctxKey = 1
|
||||
|
||||
func (s *Server) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID, middleware.RealIP, middleware.Logger, middleware.Recoverer)
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: allowedOrigins(s.Cfg.FrontendURL),
|
||||
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Authorization", "Content-Type"},
|
||||
MaxAge: 300,
|
||||
}))
|
||||
r.Get("/api/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Post("/auth/register", s.Register)
|
||||
api.Post("/auth/login", s.Login)
|
||||
|
||||
api.Get("/calendars/google/callback", s.GoogleConnectCallback)
|
||||
api.Get("/public/{slug}", s.PublicInfo)
|
||||
api.Post("/public/{slug}/bookings", s.CreateBooking)
|
||||
|
||||
api.Group(func(prt chi.Router) {
|
||||
prt.Use(s.AuthMiddleware)
|
||||
prt.Get("/me", s.Me)
|
||||
prt.Patch("/me", s.UpdateMe)
|
||||
prt.Post("/me/slug", s.RegenSlug)
|
||||
prt.Get("/calendars", s.ListCalendars)
|
||||
prt.Delete("/calendars/{id}", s.DeleteCalendar)
|
||||
prt.Post("/calendars/caldav", s.ConnectCalDAV)
|
||||
prt.Post("/calendars/ics", s.ConnectICS)
|
||||
prt.Get("/calendars/google/start", s.GoogleConnectStart)
|
||||
prt.Post("/calendars/sync", s.SyncNow)
|
||||
prt.Get("/bookings", s.ListBookings)
|
||||
prt.Post("/bookings/{id}/accept", s.AcceptBooking)
|
||||
prt.Post("/bookings/{id}/decline", s.DeclineBooking)
|
||||
})
|
||||
})
|
||||
|
||||
if s.Cfg.StaticDir != "" {
|
||||
if st, err := os.Stat(s.Cfg.StaticDir); err == nil && st.IsDir() {
|
||||
static := s.Cfg.StaticDir
|
||||
r.Get("/*", func(w http.ResponseWriter, req *http.Request) {
|
||||
p := filepath.Join(static, filepath.Clean(req.URL.Path))
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
http.ServeFile(w, req, p)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, req, filepath.Join(static, "index.html"))
|
||||
})
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func allowedOrigins(frontendURL string) []string {
|
||||
origins := []string{"http://localhost:5173", "http://127.0.0.1:5173"}
|
||||
for _, o := range strings.Split(frontendURL, ",") {
|
||||
o = strings.TrimSpace(o)
|
||||
if o != "" && !contains(origins, o) {
|
||||
origins = append(origins, o)
|
||||
}
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
func contains(list []string, v string) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Auth Middleware & JWT ---
|
||||
|
||||
func (s *Server) signJWT(userID int64, ttl time.Duration) (string, error) {
|
||||
claims := jwt.RegisteredClaims{
|
||||
Subject: strconv.FormatInt(userID, 10),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return tok.SignedString([]byte(s.Cfg.JWTSecret))
|
||||
}
|
||||
|
||||
func (s *Server) verifyJWT(tokenStr string) (int64, error) {
|
||||
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unerwartete Signaturmethode")
|
||||
}
|
||||
return []byte(s.Cfg.JWTSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return 0, errors.New("Token ungültig oder abgelaufen")
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return 0, errors.New("Claims unlesbar")
|
||||
}
|
||||
sub, _ := claims["sub"].(string)
|
||||
id, err := strconv.ParseInt(sub, 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.New("Subject unlesbar")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Server) AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, "Bearer ") {
|
||||
jsonError(w, http.StatusUnauthorized, "Nicht angemeldet")
|
||||
return
|
||||
}
|
||||
uid, err := s.verifyJWT(strings.TrimSpace(h[len("Bearer "):]))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusUnauthorized, "Sitzung ungültig oder abgelaufen")
|
||||
return
|
||||
}
|
||||
user, err := s.DB.UserByID(uid)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusUnauthorized, "Konto nicht gefunden")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, user)))
|
||||
})
|
||||
}
|
||||
|
||||
func userFrom(r *http.Request) *db.User {
|
||||
return r.Context().Value(ctxUser).(*db.User)
|
||||
}
|
||||
|
||||
// --- JSON helpers ---
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if v != nil {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func decodeBody(r *http.Request, v any) error {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
||||
return dec.Decode(v)
|
||||
}
|
||||
|
||||
func pathID(r *http.Request) (int64, error) {
|
||||
return strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
}
|
||||
|
||||
// --- JSON Darstellungen ---
|
||||
|
||||
type UserJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Timezone string `json:"timezone"`
|
||||
SlotMinutes int `json:"slot_minutes"`
|
||||
DayStartMin int `json:"day_start_min"`
|
||||
DayEndMin int `json:"day_end_min"`
|
||||
HorizonDays int `json:"horizon_days"`
|
||||
Durations []int `json:"durations"`
|
||||
Weekdays []int `json:"weekdays"`
|
||||
CreatedTs int64 `json:"created_ts"`
|
||||
}
|
||||
|
||||
func userToJSON(u *db.User) UserJSON {
|
||||
return UserJSON{
|
||||
ID: u.ID, Email: u.Email, Name: u.Name, Slug: u.Slug, Timezone: u.Timezone,
|
||||
SlotMinutes: u.SlotMinutes, DayStartMin: u.DayStartMin, DayEndMin: u.DayEndMin,
|
||||
HorizonDays: u.HorizonDays, Durations: csvInts(u.Durations), Weekdays: csvInts(u.Weekdays),
|
||||
CreatedTs: u.CreatedTs,
|
||||
}
|
||||
}
|
||||
|
||||
func csvInts(csv string) []int {
|
||||
var out []int
|
||||
for _, p := range strings.Split(csv, ",") {
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func intsCSV(list []int) string {
|
||||
parts := make([]string, len(list))
|
||||
for i, v := range list {
|
||||
parts[i] = strconv.Itoa(v)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
type ConnectionJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
DisplayName string `json:"display_name"`
|
||||
LastSyncedTs *int64 `json:"last_synced_ts"`
|
||||
LastError *string `json:"last_error"`
|
||||
CreatedTs int64 `json:"created_ts"`
|
||||
}
|
||||
|
||||
func connToJSON(c *db.Connection) ConnectionJSON {
|
||||
out := ConnectionJSON{ID: c.ID, Provider: c.Provider, DisplayName: c.DisplayName, CreatedTs: c.CreatedTs}
|
||||
if c.LastSyncedTs.Valid {
|
||||
v := c.LastSyncedTs.Int64
|
||||
out.LastSyncedTs = &v
|
||||
}
|
||||
if c.LastError.Valid && c.LastError.String != "" {
|
||||
v := c.LastError.String
|
||||
out.LastError = &v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type BookingJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
RequesterName string `json:"requester_name"`
|
||||
RequesterEmail string `json:"requester_email"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
CreatedTs int64 `json:"created_ts"`
|
||||
}
|
||||
|
||||
func bookingToJSON(b *db.Booking) BookingJSON {
|
||||
return BookingJSON{
|
||||
ID: b.ID,
|
||||
Start: time.Unix(b.StartTs, 0).UTC().Format(time.RFC3339),
|
||||
End: time.Unix(b.EndTs, 0).UTC().Format(time.RFC3339),
|
||||
RequesterName: b.RequesterName, RequesterEmail: b.RequesterEmail,
|
||||
Message: b.Message, Status: b.Status, CreatedTs: b.CreatedTs,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue