2025-01-21 14:02:43 +00:00
|
|
|
package http
|
|
|
|
|
|
|
|
import (
|
|
|
|
"encoding/json"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"net/http"
|
|
|
|
"net/url"
|
|
|
|
"path/filepath"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
|
2025-02-16 14:07:38 +00:00
|
|
|
jwt "github.com/golang-jwt/jwt/v4"
|
2025-01-21 14:02:43 +00:00
|
|
|
"github.com/gtsteffaniak/filebrowser/backend/cache"
|
|
|
|
"github.com/gtsteffaniak/filebrowser/backend/files"
|
|
|
|
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
onlyOfficeStatusDocumentClosedWithChanges = 2
|
|
|
|
onlyOfficeStatusDocumentClosedWithNoChanges = 4
|
|
|
|
onlyOfficeStatusForceSaveWhileDocumentStillOpen = 6
|
|
|
|
)
|
|
|
|
|
|
|
|
type OnlyOfficeCallback struct {
|
|
|
|
ChangesURL string `json:"changesurl,omitempty"`
|
|
|
|
Key string `json:"key,omitempty"`
|
|
|
|
Status int `json:"status,omitempty"`
|
|
|
|
URL string `json:"url,omitempty"`
|
|
|
|
Users []string `json:"users,omitempty"`
|
|
|
|
UserData string `json:"userdata,omitempty"`
|
|
|
|
}
|
|
|
|
|
|
|
|
func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
|
|
|
if settings.Config.Integrations.OnlyOffice.Url == "" {
|
|
|
|
return http.StatusInternalServerError, errors.New("only-office integration must be configured in settings")
|
|
|
|
}
|
|
|
|
|
|
|
|
if !d.user.Perm.Modify {
|
|
|
|
return http.StatusForbidden, nil
|
|
|
|
}
|
|
|
|
encodedUrl := r.URL.Query().Get("url")
|
|
|
|
source := r.URL.Query().Get("source")
|
|
|
|
if source == "" {
|
|
|
|
source = "default"
|
|
|
|
}
|
|
|
|
// Decode the URL-encoded path
|
|
|
|
url, err := url.QueryUnescape(encodedUrl)
|
|
|
|
if err != nil {
|
|
|
|
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// get path from url
|
|
|
|
pathParts := strings.Split(url, "/api/raw?files=/")
|
|
|
|
path := pathParts[len(pathParts)-1]
|
|
|
|
urlFirst := pathParts[0]
|
|
|
|
if settings.Config.Server.InternalUrl != "" {
|
2025-02-16 14:07:38 +00:00
|
|
|
urlFirst = strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
|
2025-01-21 14:02:43 +00:00
|
|
|
replacement := strings.Split(url, "/api/raw")[0]
|
|
|
|
url = strings.Replace(url, replacement, settings.Config.Server.InternalUrl, 1)
|
|
|
|
}
|
|
|
|
fileInfo, err := files.FileInfoFaster(files.FileOptions{
|
2025-01-27 00:21:12 +00:00
|
|
|
Path: filepath.Join(d.user.Scope, path),
|
|
|
|
Modify: d.user.Perm.Modify,
|
|
|
|
Source: source,
|
|
|
|
Expand: false,
|
|
|
|
Checker: d.user,
|
2025-01-21 14:02:43 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return errToStatus(err), err
|
|
|
|
}
|
|
|
|
|
|
|
|
id, err := getOnlyOfficeId(source, fileInfo.Path)
|
|
|
|
if err != nil {
|
|
|
|
return http.StatusNotFound, err
|
|
|
|
}
|
|
|
|
split := strings.Split(fileInfo.Name, ".")
|
|
|
|
fileType := split[len(split)-1]
|
|
|
|
|
|
|
|
theme := "light"
|
|
|
|
if d.user.DarkMode {
|
|
|
|
theme = "dark"
|
|
|
|
}
|
|
|
|
|
|
|
|
clientConfig := map[string]interface{}{
|
|
|
|
"document": map[string]interface{}{
|
|
|
|
"fileType": fileType,
|
|
|
|
"key": id,
|
|
|
|
"title": fileInfo.Name,
|
|
|
|
"url": url + "&auth=" + d.token,
|
|
|
|
"permissions": map[string]interface{}{
|
|
|
|
"edit": d.user.Perm.Modify,
|
|
|
|
"download": d.user.Perm.Download,
|
|
|
|
"print": d.user.Perm.Download,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
"editorConfig": map[string]interface{}{
|
|
|
|
"callbackUrl": fmt.Sprintf("%v/api/onlyoffice/callback?path=%v&auth=%v", urlFirst, path, d.token),
|
|
|
|
"user": map[string]interface{}{
|
|
|
|
"id": strconv.FormatUint(uint64(d.user.ID), 10),
|
|
|
|
"name": d.user.Username,
|
|
|
|
},
|
|
|
|
"customization": map[string]interface{}{
|
|
|
|
"autosave": true,
|
|
|
|
"forcesave": true,
|
|
|
|
"uiTheme": theme,
|
|
|
|
},
|
|
|
|
"lang": d.user.Locale,
|
|
|
|
"mode": "edit",
|
|
|
|
},
|
|
|
|
}
|
|
|
|
if settings.Config.Integrations.OnlyOffice.Secret != "" {
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(clientConfig))
|
|
|
|
signature, err := token.SignedString([]byte(settings.Config.Integrations.OnlyOffice.Secret))
|
|
|
|
if err != nil {
|
|
|
|
return http.StatusInternalServerError, fmt.Errorf("failed to sign JWT")
|
|
|
|
}
|
|
|
|
clientConfig["token"] = signature
|
|
|
|
}
|
|
|
|
return renderJSON(w, r, clientConfig)
|
|
|
|
}
|
|
|
|
|
|
|
|
func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
|
|
if err != nil {
|
|
|
|
return http.StatusInternalServerError, err
|
|
|
|
}
|
|
|
|
|
|
|
|
var data OnlyOfficeCallback
|
|
|
|
err = json.Unmarshal(body, &data)
|
|
|
|
if err != nil {
|
|
|
|
return http.StatusInternalServerError, err
|
|
|
|
}
|
|
|
|
|
|
|
|
encodedPath := r.URL.Query().Get("path")
|
|
|
|
source := r.URL.Query().Get("source")
|
|
|
|
if source == "" {
|
|
|
|
source = "default"
|
|
|
|
}
|
|
|
|
// Decode the URL-encoded path
|
|
|
|
path, err := url.QueryUnescape(encodedPath)
|
|
|
|
if err != nil {
|
|
|
|
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
|
|
|
}
|
|
|
|
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
|
|
|
|
data.Status == onlyOfficeStatusDocumentClosedWithNoChanges {
|
|
|
|
// Refer to only-office documentation
|
|
|
|
// - https://api.onlyoffice.com/editors/coedit
|
|
|
|
// - https://api.onlyoffice.com/editors/callback
|
|
|
|
//
|
|
|
|
// When the document is fully closed by all editors,
|
|
|
|
// then the document key should no longer be re-used.
|
|
|
|
deleteOfficeId(source, path)
|
|
|
|
}
|
|
|
|
|
|
|
|
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
|
|
|
|
data.Status == onlyOfficeStatusForceSaveWhileDocumentStillOpen {
|
|
|
|
if !d.user.Perm.Modify {
|
|
|
|
return http.StatusForbidden, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
doc, err := http.Get(data.URL)
|
|
|
|
if err != nil {
|
|
|
|
return http.StatusInternalServerError, err
|
|
|
|
}
|
|
|
|
defer doc.Body.Close()
|
|
|
|
|
|
|
|
err = d.Runner.RunHook(func() error {
|
|
|
|
fileOpts := files.FileOptions{
|
|
|
|
Path: path,
|
|
|
|
Source: source,
|
|
|
|
}
|
|
|
|
writeErr := files.WriteFile(fileOpts, doc.Body)
|
|
|
|
if writeErr != nil {
|
|
|
|
return writeErr
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}, "save", path, "", d.user)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return http.StatusInternalServerError, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
resp := map[string]int{
|
|
|
|
"error": 0,
|
|
|
|
}
|
|
|
|
return renderJSON(w, r, resp)
|
|
|
|
}
|
|
|
|
|
|
|
|
func getOnlyOfficeId(source, path string) (string, error) {
|
|
|
|
idx := files.GetIndex(source)
|
|
|
|
realpath, _, _ := idx.GetRealPath(path)
|
|
|
|
// error is intentionally ignored in order treat errors
|
|
|
|
// the same as a cache-miss
|
|
|
|
cachedDocumentKey, ok := cache.OnlyOffice.Get(realpath).(string)
|
|
|
|
if ok {
|
|
|
|
return cachedDocumentKey, nil
|
|
|
|
}
|
|
|
|
return "", fmt.Errorf("document key not found")
|
|
|
|
}
|
|
|
|
func deleteOfficeId(source, path string) {
|
|
|
|
idx := files.GetIndex(source)
|
|
|
|
realpath, _, _ := idx.GetRealPath(path)
|
|
|
|
cache.OnlyOffice.Delete(realpath)
|
|
|
|
}
|