package service import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "sync" "time" "github.com/golang-jwt/jwt/v5" "wucher/internal/shared/pkg/uuidv7" ) type WOPIPermission string const ( WOPIPermissionRead WOPIPermission = "read" WOPIPermissionWrite WOPIPermission = "write" ) type WOPIConfig struct { TokenSecret string ProofSecret string DiscoveryURL string PublicBaseURL string EditActionURL string TokenTTL time.Duration MaxBodyBytes int RequireProof bool } func LoadWOPIConfigFromEnv() WOPIConfig { return WOPIConfig{ TokenSecret: strings.TrimSpace(os.Getenv("WOPI_TOKEN_SECRET")), ProofSecret: strings.TrimSpace(os.Getenv("WOPI_PROOF_SECRET")), DiscoveryURL: strings.TrimSpace(os.Getenv("WOPI_DISCOVERY_URL")), PublicBaseURL: strings.TrimSpace(os.Getenv("WOPI_PUBLIC_BASE_URL")), EditActionURL: strings.TrimSpace(os.Getenv("WOPI_EDIT_ACTION_URL")), TokenTTL: envDuration("WOPI_TOKEN_TTL", 30*time.Minute), MaxBodyBytes: envInt("WOPI_MAX_BODY_BYTES", 8*1024*1024), RequireProof: envBool("WOPI_REQUIRE_PROOF", false), } } type WOPIClaims struct { jwt.RegisteredClaims FileID string `json:"file_id"` UserID string `json:"user_id"` TakeoverID string `json:"takeover_id"` Permission string `json:"permission"` } type wopiDiscoveryCache struct { URLSrc string Loaded time.Time } type WOPIService struct { cfg WOPIConfig http *http.Client mu sync.RWMutex discovery wopiDiscoveryCache } func NewWOPIService(cfg WOPIConfig) *WOPIService { if cfg.TokenTTL <= 0 { cfg.TokenTTL = 30 * time.Minute } if cfg.MaxBodyBytes <= 0 { cfg.MaxBodyBytes = 8 * 1024 * 1024 } return &WOPIService{ cfg: cfg, http: &http.Client{ Timeout: 10 * time.Second, }, } } func (s *WOPIService) TokenTTL() time.Duration { if s == nil || s.cfg.TokenTTL <= 0 { return 30 * time.Minute } return s.cfg.TokenTTL } func (s *WOPIService) MaxBodyBytes() int { if s == nil || s.cfg.MaxBodyBytes <= 0 { return 8 * 1024 * 1024 } return s.cfg.MaxBodyBytes } func (s *WOPIService) MintAccessToken(fileID, userID, takeoverID []byte, perm WOPIPermission) (string, int64, error) { if s == nil || strings.TrimSpace(s.cfg.TokenSecret) == "" { return "", 0, errors.New("wopi token secret is not configured") } if len(fileID) != 16 || len(userID) != 16 { return "", 0, errors.New("invalid wopi token claims") } takeoverClaim := "" if len(takeoverID) != 0 { if len(takeoverID) != 16 { return "", 0, errors.New("invalid wopi token claims") } takeoverClaim = uuidBytesString(takeoverID) } jti, err := randomToken(24) if err != nil { return "", 0, err } now := time.Now().UTC() expiresAt := now.Add(s.TokenTTL()) claims := WOPIClaims{ RegisteredClaims: jwt.RegisteredClaims{ Subject: uuidBytesString(userID), ID: jti, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(expiresAt), NotBefore: jwt.NewNumericDate(now.Add(-1 * time.Minute)), Audience: jwt.ClaimStrings{"wopi"}, }, FileID: uuidBytesString(fileID), UserID: uuidBytesString(userID), TakeoverID: takeoverClaim, Permission: string(perm), } token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.cfg.TokenSecret)) if err != nil { return "", 0, err } // WOPI/Collabora expects access_token_ttl as an ABSOLUTE expiry time in // epoch milliseconds (equal to the JWT exp claim), not a duration. Returning // the bare duration made Collabora read it as "1970-01-01 + ttl" and show // "session expired" immediately. return token, expiresAt.UnixMilli(), nil } func (s *WOPIService) VerifyAccessToken(token string, fileID []byte) (*WOPIClaims, error) { if s == nil || strings.TrimSpace(s.cfg.TokenSecret) == "" { return nil, errors.New("wopi token secret is not configured") } parsed, err := jwt.ParseWithClaims(strings.TrimSpace(token), &WOPIClaims{}, func(t *jwt.Token) (any, error) { if t.Method.Alg() != jwt.SigningMethodHS256.Alg() { return nil, errors.New("unexpected wopi token algorithm") } return []byte(s.cfg.TokenSecret), nil }, jwt.WithLeeway(30*time.Second)) if err != nil { return nil, err } claims, ok := parsed.Claims.(*WOPIClaims) if !ok || claims == nil || !parsed.Valid { return nil, errors.New("invalid wopi access token") } if !strings.EqualFold(strings.TrimSpace(claims.FileID), uuidBytesString(fileID)) { return nil, errors.New("wopi token file mismatch") } return claims, nil } func (s *WOPIService) BuildActionURL(ctx context.Context, wopiSrc string) (string, error) { urlSrc, err := s.discoveryURLSrc(ctx) if err != nil { return "", err } escaped := url.QueryEscape(strings.TrimSpace(wopiSrc)) switch { case strings.Contains(urlSrc, "{WOPISrc}"): return strings.ReplaceAll(urlSrc, "{WOPISrc}", escaped), nil case strings.Contains(urlSrc, "WOPISrc="): return urlSrc + escaped, nil default: sep := "?" if strings.Contains(urlSrc, "?") { sep = "&" } return urlSrc + sep + "WOPISrc=" + escaped, nil } } func (s *WOPIService) WOPISrc(fileID string) string { fileID = strings.TrimSpace(fileID) base := strings.TrimSpace(s.cfg.PublicBaseURL) if base == "" { return "/wopi/files/" + fileID } return strings.TrimRight(base, "/") + "/wopi/files/" + fileID } // BuildActionURLWithToken builds the Collabora editor action URL for a file. // // Per the WOPI/Collabora contract the action URL carries THREE separate query // parameters: WOPISrc (the BARE file URL, no token), access_token and // access_token_ttl. Collabora echoes access_token/access_token_ttl back on // every WOPI callback (CheckFileInfo, GetFile, PutFile, Lock). Embedding the // token inside the WOPISrc value instead leaves Collabora with an empty // top-level access_token, which the WOPI host rejects — surfacing as a bogus // "file is corrupt" prompt on load and "no write-able session" errors on save. // // accessTokenTTL is an ABSOLUTE expiry time in epoch milliseconds (matching the // JWT exp claim), as required by WOPI/Collabora — not a duration. func (s *WOPIService) BuildActionURLWithToken(ctx context.Context, fileID, token string, accessTokenTTL int64) (string, error) { actionURL, err := s.BuildActionURL(ctx, s.WOPISrc(fileID)) if err != nil { return "", err } token = strings.TrimSpace(token) if token == "" { return actionURL, nil } sep := "?" if strings.Contains(actionURL, "?") { sep = "&" } actionURL += sep + "access_token=" + url.QueryEscape(token) if accessTokenTTL > 0 { actionURL += "&access_token_ttl=" + strconv.FormatInt(accessTokenTTL, 10) } return actionURL, nil } func (s *WOPIService) discoveryURLSrc(ctx context.Context) (string, error) { if s == nil { return "", errors.New("wopi service is nil") } s.mu.RLock() cached := s.discovery s.mu.RUnlock() if strings.TrimSpace(cached.URLSrc) != "" && time.Since(cached.Loaded) < 15*time.Minute { return cached.URLSrc, nil } if strings.TrimSpace(s.cfg.EditActionURL) != "" { s.mu.Lock() s.discovery = wopiDiscoveryCache{URLSrc: strings.TrimSpace(s.cfg.EditActionURL), Loaded: time.Now().UTC()} s.mu.Unlock() return s.cfg.EditActionURL, nil } if strings.TrimSpace(s.cfg.DiscoveryURL) == "" { return "", errors.New("wopi discovery url is not configured") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.cfg.DiscoveryURL, nil) if err != nil { return "", err } resp, err := s.http.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", fmt.Errorf("wopi discovery returned %s", resp.Status) } body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) if err != nil { return "", err } urlSrc := parseWOPIDiscoveryURLSrc(string(body)) if urlSrc == "" { return "", errors.New("wopi discovery missing urlsrc") } s.mu.Lock() s.discovery = wopiDiscoveryCache{URLSrc: urlSrc, Loaded: time.Now().UTC()} s.mu.Unlock() return urlSrc, nil } func parseWOPIDiscoveryURLSrc(raw string) string { start := strings.Index(raw, "urlsrc=\"") if start < 0 { return "" } start += len(`urlsrc="`) end := strings.Index(raw[start:], `"`) if end < 0 { return "" } return htmlUnescape(raw[start : start+end]) } func htmlUnescape(v string) string { v = strings.ReplaceAll(v, "&", "&") v = strings.ReplaceAll(v, "<", "<") v = strings.ReplaceAll(v, ">", ">") v = strings.ReplaceAll(v, "'", "'") v = strings.ReplaceAll(v, """, "\"") return v } func (s *WOPIService) VerifyProof(method, path, accessToken, timestamp, proof, proofOld string) error { if s == nil || !s.cfg.RequireProof { return nil } if strings.TrimSpace(s.cfg.ProofSecret) == "" { return errors.New("wopi proof secret is not configured") } ts, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64) if err != nil { return errors.New("invalid wopi timestamp") } reqTime := time.Unix(0, ts*int64(time.Millisecond)) if d := time.Since(reqTime); d > 10*time.Minute || d < -10*time.Minute { return errors.New("stale wopi request") } base := strings.Join([]string{ strings.ToUpper(strings.TrimSpace(method)), strings.TrimSpace(path), strings.TrimSpace(accessToken), strings.TrimSpace(timestamp), }, "\n") if verifyWOPIProofSignature(s.cfg.ProofSecret, base, proof) { return nil } if verifyWOPIProofSignature(s.cfg.ProofSecret, base, proofOld) { return nil } return errors.New("invalid wopi proof") } func verifyWOPIProofSignature(secret, payload, proof string) bool { if strings.TrimSpace(proof) == "" { return false } sum := hmac.New(sha256.New, []byte(secret)) _, _ = sum.Write([]byte(payload)) expected := base64.RawURLEncoding.EncodeToString(sum.Sum(nil)) return hmac.Equal([]byte(expected), []byte(strings.TrimSpace(proof))) } func envDuration(key string, fallback time.Duration) time.Duration { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallback } v, err := time.ParseDuration(raw) if err != nil { return fallback } return v } func envInt(key string, fallback int) int { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallback } v, err := strconv.Atoi(raw) if err != nil { return fallback } return v } func envBool(key string, fallback bool) bool { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallback } v, err := strconv.ParseBool(raw) if err != nil { return fallback } return v } func uuidBytesString(id []byte) string { if len(id) != 16 { return "" } s, err := uuidv7.BytesToString(id) if err != nil { return "" } return s }