ultisuite-backend/internal/api/office/handlers.go
R3D347HR4Y 556d5f416d Enhance API and configuration for contact discovery and public sharing
- Introduced new endpoints for contact discovery, including scanning, listing, and managing discovered contacts.
- Implemented retry logic for handling missing DAV credentials during contact operations.
- Added public share functionality for drive API, allowing users to manage public shares, including upload, delete, and rename operations.
- Updated Nextcloud configuration to support public share links and improved error handling for public share permissions.
- Enhanced logging and validation across contact and drive APIs for better error tracking and user feedback.
- Added tests for new contact matching and ranking functionalities to ensure accuracy and reliability.
2026-06-06 20:27:02 +02:00

193 lines
5.8 KiB
Go

package office
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/drive"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/permission"
)
type Handler struct {
svc *Service
drive *drive.Service
logger *slog.Logger
}
func NewHandler(svc *Service, driveSvc *drive.Service) *Handler {
return &Handler{
svc: svc,
drive: driveSvc,
logger: slog.Default().With("component", "office-api"),
}
}
func (h *Handler) PublicRoutes() chi.Router {
r := chi.NewRouter()
r.Get("/document", h.ServeDocument)
r.Post("/callback", h.Callback)
return r
}
func (h *Handler) ProtectedRoutes() chi.Router {
r := chi.NewRouter()
read := middleware.RequirePermission(permission.ResourceDrive, permission.LevelRead)
write := middleware.RequirePermission(permission.ResourceDrive, permission.LevelWrite)
r.With(read).Post("/session", h.CreateSession)
r.With(write).Post("/create", h.CreateDocument)
return r
}
// Routes registers public OnlyOffice callbacks and authenticated session endpoints on one router.
func (h *Handler) Routes(authMiddleware func(http.Handler) http.Handler) chi.Router {
r := chi.NewRouter()
r.Get("/document", h.ServeDocument)
r.Post("/callback", h.Callback)
r.Group(func(pr chi.Router) {
pr.Use(authMiddleware)
read := middleware.RequirePermission(permission.ResourceDrive, permission.LevelRead)
write := middleware.RequirePermission(permission.ResourceDrive, permission.LevelWrite)
pr.With(read).Post("/session", h.CreateSession)
pr.With(write).Post("/create", h.CreateDocument)
})
return r
}
type sessionRequest struct {
Path string `json:"path"`
Mode string `json:"mode"`
}
func (h *Handler) CreateSession(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims)
if err != nil {
h.logger.Error("ensure nextcloud user", "error", err)
apivalidate.WriteInternal(w, r)
return
}
var req sessionRequest
if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil {
return
}
if strings.TrimSpace(req.Path) == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "path", Message: "required"},
))
return
}
mode := strings.TrimSpace(req.Mode)
if mode == "" {
mode = "edit"
}
cfg, err := h.svc.EditorConfig(r.Context(), ncUser, req.Path, mode, claims.Sub, claims.Name)
if err != nil {
h.logger.Error("editor config", "error", err)
apivalidate.WriteInternal(w, r)
return
}
wrapped, err := h.svc.wrapEditorConfig(cfg)
if err != nil {
h.logger.Error("editor config jwt", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
"config": wrapped,
"serverUrl": h.svc.PublicURL(),
})
}
type createRequest struct {
ParentPath string `json:"parent_path"`
Name string `json:"name"`
Kind string `json:"kind"`
}
func (h *Handler) CreateDocument(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims)
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
var req createRequest
if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil {
return
}
kind := drive.NewFileKind(strings.TrimSpace(strings.ToLower(req.Kind)))
target, err := h.drive.CreateNewFile(r.Context(), ncUser, req.ParentPath, req.Name, kind)
if err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"path": target})
}
func (h *Handler) ServeDocument(w http.ResponseWriter, r *http.Request) {
ncUser := r.URL.Query().Get("user")
filePath := r.URL.Query().Get("path")
sig := r.URL.Query().Get("sig")
if h.svc.Cfg.JWTSecret != "" && !VerifyDocAccess(ncUser, filePath, sig, h.svc.Cfg.JWTSecret) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
body, contentType, err := h.svc.OpenDocument(r.Context(), ncUser, filePath)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
defer body.Close()
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
io.Copy(w, body)
}
func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
ncUser := r.URL.Query().Get("user")
filePath := r.URL.Query().Get("path")
var payload struct {
Status int `json:"status"`
URL string `json:"url"`
Key string `json:"key"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
apiresponse.WriteJSON(w, http.StatusOK, map[string]int{"error": 1})
return
}
// status 2 = last editor closed, save final version; 6 = force save while editing
if payload.Status == 2 || payload.Status == 6 {
if payload.URL != "" {
resp, err := http.Get(payload.URL)
if err != nil {
h.logger.Error("office callback fetch", "error", err, "path", filePath, "status", payload.Status)
} else if resp.StatusCode != http.StatusOK {
h.logger.Error("office callback fetch status", "status", resp.StatusCode, "path", filePath, "oo_status", payload.Status)
resp.Body.Close()
} else {
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
if err := h.svc.SaveDocument(r.Context(), ncUser, filePath, resp.Body, ct); err != nil {
h.logger.Error("office callback save", "error", err, "path", filePath, "status", payload.Status)
} else if payload.Status == 2 {
h.svc.RotateDocumentKeyAfterSave(r.Context(), ncUser, filePath)
}
}
}
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]int{"error": 0})
}