386 lines
10 KiB
Go
386 lines
10 KiB
Go
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
|
|
}
|