init push
This commit is contained in:
385
internal/service/wopi_service.go
Normal file
385
internal/service/wopi_service.go
Normal file
@@ -0,0 +1,385 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user