init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View 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, "&amp;", "&")
v = strings.ReplaceAll(v, "&lt;", "<")
v = strings.ReplaceAll(v, "&gt;", ">")
v = strings.ReplaceAll(v, "&#39;", "'")
v = strings.ReplaceAll(v, "&quot;", "\"")
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
}