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}) }