diff --git a/cmd/config.go b/cmd/config.go
index de55c28e..3c8878cc 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -48,6 +48,9 @@ func addConfigFlags(flags *pflag.FlagSet) {
flags.String("branding.files", "", "path to directory with images and custom styles")
flags.Bool("branding.disableExternal", false, "disable external links such as GitHub links")
flags.Bool("branding.disableUsedPercentage", false, "disable used disk percentage graph")
+
+ flags.String("onlyoffice.url", "", "onlyoffice integration url")
+ flags.String("onlyoffice.jwtSecret", "", "onlyoffice integration secret")
}
//nolint:gocyclo
diff --git a/files/file.go b/files/file.go
index 7d3a695b..c228cfca 100644
--- a/files/file.go
+++ b/files/file.go
@@ -205,19 +205,10 @@ func (i *FileInfo) Checksum(algo string) error {
}
func (i *FileInfo) RealPath() string {
- if realPathFs, ok := i.Fs.(interface {
- RealPath(name string) (fPath string, err error)
- }); ok {
- realPath, err := realPathFs.RealPath(i.Path)
- if err == nil {
- return realPath
- }
- }
-
- return i.Path
+ return GetRealPath(i.Fs, i.Path)
}
-//nolint:goconst
+//nolint:goconst,gocyclo
func (i *FileInfo) detectType(modify, saveContent, readHeader bool) error {
if IsNamedPipe(i.Mode) {
i.Type = "blob"
@@ -276,7 +267,8 @@ func (i *FileInfo) detectType(modify, saveContent, readHeader bool) error {
i.Content = string(content)
}
return nil
- case strings.HasPrefix(mimetype, "application/vnd.openxmlformats-officedocument"):
+ case strings.HasPrefix(mimetype, "application/vnd.openxmlformats-officedocument"),
+ strings.HasPrefix(mimetype, "application/vnd.oasis.opendocument"):
i.Type = "officedocument"
return nil
default:
diff --git a/files/utils.go b/files/utils.go
index f4b0365d..756c940f 100644
--- a/files/utils.go
+++ b/files/utils.go
@@ -3,6 +3,8 @@ package files
import (
"os"
"unicode/utf8"
+
+ "github.com/spf13/afero"
)
func isBinary(content []byte) bool {
@@ -57,3 +59,16 @@ func IsNamedPipe(mode os.FileMode) bool {
func IsSymlink(mode os.FileMode) bool {
return mode&os.ModeSymlink != 0
}
+
+func GetRealPath(fs afero.Fs, path string) string {
+ if realPathFs, ok := fs.(interface {
+ RealPath(name string) (fPath string, err error)
+ }); ok {
+ realPath, err := realPathFs.RealPath(path)
+ if err == nil {
+ return realPath
+ }
+ }
+
+ return path
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index ce3dcc31..a91166ec 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,6 +9,7 @@
"version": "3.0.0",
"dependencies": {
"@chenfengyuan/vue-number-input": "^2.0.1",
+ "@onlyoffice/document-editor-vue": "^1.4.0",
"@vueuse/core": "^10.9.0",
"@vueuse/integrations": "^10.9.0",
"ace-builds": "^1.32.9",
@@ -2506,6 +2507,14 @@
"node": ">= 8"
}
},
+ "node_modules/@onlyoffice/document-editor-vue": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@onlyoffice/document-editor-vue/-/document-editor-vue-1.4.0.tgz",
+ "integrity": "sha512-Fg5gSc1zF6bmpRapUd7rMpm7kEDF7mQIHQKfcsfJcILdFX9bwIhnkXEucETEA9zdt92nWMS6qiAgVeT61TdCyw==",
+ "peerDependencies": {
+ "vue": "^3.0.0"
+ }
+ },
"node_modules/@pkgr/core": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index a5089c94..47aac7ae 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -19,6 +19,7 @@
},
"dependencies": {
"@chenfengyuan/vue-number-input": "^2.0.1",
+ "@onlyoffice/document-editor-vue": "^1.4.0",
"@vueuse/core": "^10.9.0",
"@vueuse/integrations": "^10.9.0",
"ace-builds": "^1.32.9",
diff --git a/frontend/src/types/file.d.ts b/frontend/src/types/file.d.ts
index db2aa5fe..e0ffe007 100644
--- a/frontend/src/types/file.d.ts
+++ b/frontend/src/types/file.d.ts
@@ -35,7 +35,8 @@ type ResourceType =
| "pdf"
| "text"
| "blob"
- | "textImmutable";
+ | "textImmutable"
+ | "officedocument";
type DownloadFormat =
| "zip"
diff --git a/frontend/src/types/settings.d.ts b/frontend/src/types/settings.d.ts
index a2c19f76..2eedd064 100644
--- a/frontend/src/types/settings.d.ts
+++ b/frontend/src/types/settings.d.ts
@@ -8,6 +8,7 @@ interface ISettings {
tus: SettingsTus;
shell: string[];
commands: SettingsCommand;
+ onlyoffice: SettingsOnlyOffice;
}
interface SettingsDefaults {
@@ -55,3 +56,8 @@ interface SettingsUnit {
GB: number;
TB: number;
}
+
+interface SettingsOnlyOffice {
+ url: string;
+ jwtSecret: string;
+}
diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts
index c437b0d2..59c1db41 100644
--- a/frontend/src/utils/constants.ts
+++ b/frontend/src/utils/constants.ts
@@ -18,7 +18,7 @@ const enableExec: boolean = window.FileBrowser.EnableExec;
const tusSettings = window.FileBrowser.TusSettings;
const origin = window.location.origin;
const tusEndpoint = `/api/tus`;
-const onlyOffice = window.FileBrowser.OnlyOffice;
+const onlyOfficeUrl = window.FileBrowser.OnlyOfficeUrl;
export {
name,
@@ -40,5 +40,5 @@ export {
tusSettings,
origin,
tusEndpoint,
- onlyOffice,
+ onlyOfficeUrl,
};
diff --git a/frontend/src/views/Files.vue b/frontend/src/views/Files.vue
index ea6196d2..89314ede 100644
--- a/frontend/src/views/Files.vue
+++ b/frontend/src/views/Files.vue
@@ -37,7 +37,7 @@ import { storeToRefs } from "pinia";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import { useUploadStore } from "@/stores/upload";
-import { onlyOffice } from "@/utils/constants";
+import { onlyOfficeUrl } from "@/utils/constants";
import HeaderBar from "@/components/header/HeaderBar.vue";
import Breadcrumbs from "@/components/Breadcrumbs.vue";
@@ -48,7 +48,9 @@ import FileListing from "@/views/files/FileListing.vue";
import { StatusError } from "@/api/utils";
const Editor = defineAsyncComponent(() => import("@/views/files/Editor.vue"));
const Preview = defineAsyncComponent(() => import("@/views/files/Preview.vue"));
-const OnlyOfficeEditor = defineAsyncComponent(() => import("@/views/files/OnlyOfficeEditor.vue"));
+const OnlyOfficeEditor = defineAsyncComponent(
+ () => import("@/views/files/OnlyOfficeEditor.vue")
+);
const layoutStore = useLayoutStore();
const fileStore = useFileStore();
@@ -79,7 +81,7 @@ const currentView = computed(() => {
fileStore.req.type === "textImmutable"
) {
return Editor;
- } else if (fileStore.req.type === "officedocument" && onlyOffice !== "") {
+ } else if (fileStore.req.type === "officedocument" && onlyOfficeUrl) {
return OnlyOfficeEditor;
} else {
return Preview;
diff --git a/frontend/src/views/files/OnlyOfficeEditor.vue b/frontend/src/views/files/OnlyOfficeEditor.vue
index bb76dce8..2758694b 100644
--- a/frontend/src/views/files/OnlyOfficeEditor.vue
+++ b/frontend/src/views/files/OnlyOfficeEditor.vue
@@ -2,169 +2,57 @@
- {{ req.name }}
+ {{ fileStore.req?.name ?? "" }}
-
-
-
+
+
+
+
-
-
-
diff --git a/go.mod b/go.mod
index 18bd5895..58f3d466 100644
--- a/go.mod
+++ b/go.mod
@@ -32,6 +32,7 @@ require (
)
require (
+ github.com/allegro/bigcache v1.2.1 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/asticode/go-astikit v0.42.0 // indirect
github.com/asticode/go-astits v1.13.0 // indirect
diff --git a/go.sum b/go.sum
index 8287feff..ed43e6b9 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
+github.com/allegro/bigcache v1.2.1 h1:hg1sY1raCwic3Vnsvje6TT7/pnZba83LeFck5NrFKSc=
+github.com/allegro/bigcache v1.2.1/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
diff --git a/http/auth.go b/http/auth.go
index 23dc7b77..1f02ebfc 100644
--- a/http/auth.go
+++ b/http/auth.go
@@ -84,6 +84,7 @@ func withUser(fn handleFunc) handleFunc {
w.Header().Add("X-Renew-Token", "true")
}
+ d.authToken = token.Raw
d.user, err = d.store.Users.Get(d.server.Root, tk.User.ID)
if err != nil {
return http.StatusInternalServerError, err
diff --git a/http/data.go b/http/data.go
index 5ba87313..2c86fa46 100644
--- a/http/data.go
+++ b/http/data.go
@@ -18,11 +18,12 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, d *data) (int, erro
type data struct {
*runner.Runner
- settings *settings.Settings
- server *settings.Server
- store *storage.Storage
- user *users.User
- raw interface{}
+ authToken string
+ settings *settings.Settings
+ server *settings.Server
+ store *storage.Storage
+ user *users.User
+ raw interface{}
}
// Check implements rules.Checker.
diff --git a/http/http.go b/http/http.go
index 1da832bd..3b2cd488 100644
--- a/http/http.go
+++ b/http/http.go
@@ -60,6 +60,7 @@ func NewHandler(
users.Handle("/{id:[0-9]+}", monkey(userGetHandler, "")).Methods("GET")
users.Handle("/{id:[0-9]+}", monkey(userDeleteHandler, "")).Methods("DELETE")
+ api.PathPrefix("/onlyoffice").Handler(monkey(onlyofficeClientConfigGetHandler, "/api/onlyoffice/client-config")).Methods("GET")
api.PathPrefix("/onlyoffice").Handler(monkey(onlyofficeCallbackHandler, "/api/onlyoffice/callback")).Methods("POST")
api.PathPrefix("/resources").Handler(monkey(resourceGetHandler, "/api/resources")).Methods("GET")
diff --git a/http/onlyoffice.go b/http/onlyoffice.go
index 55d6416e..ad509949 100644
--- a/http/onlyoffice.go
+++ b/http/onlyoffice.go
@@ -1,21 +1,125 @@
package http
import (
+ "crypto/sha256"
+ "encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/allegro/bigcache"
+ "github.com/golang-jwt/jwt/v4"
+
+ "github.com/filebrowser/filebrowser/v2/files"
+)
+
+const (
+ onlyOfficeStatusDocumentClosedWithChanges = 2
+ onlyOfficeStatusDocumentClosedWithNoChanges = 4
+ onlyOfficeStatusForceSaveWhileDocumentStillOpen = 6
+ trueString = "true" // linter-enforced constant
+ twoDays = 48 * time.Hour // linter enforced constant
+)
+
+var (
+ // Refer to only-office documentation on co-editing
+ // https://api.onlyoffice.com/editors/coedit
+ //
+ // a 48 hour TTL here is not required, because the document server will notify
+ // us when keys should be evicted. However, it is added defensively in order to
+ // prevent potential memory leaks.
+ coeditingDocumentKeys, _ = bigcache.NewBigCache(bigcache.DefaultConfig(twoDays))
)
type OnlyOfficeCallback struct {
ChangesURL string `json:"changesurl,omitempty"`
- Key string `json:"key"`
- Status int `json:"status"`
+ 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"`
}
+var onlyofficeClientConfigGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
+ if d.settings.OnlyOffice.JWTSecret == "" {
+ return http.StatusInternalServerError, errors.New("only-office integration must be configured in settings")
+ }
+
+ if !d.user.Perm.Modify || !d.Check(r.URL.Path) {
+ return http.StatusForbidden, nil
+ }
+
+ referrer, err := getReferer(r)
+ if err != nil {
+ return http.StatusInternalServerError, errors.Join(errors.New("could not determine request referrer"), err)
+ }
+
+ file, err := files.NewFileInfo(&files.FileOptions{
+ Fs: d.user.Fs,
+ Path: r.URL.Path,
+ Modify: d.user.Perm.Modify,
+ Expand: false,
+ ReadHeader: d.server.TypeDetectionByHeader,
+ Checker: d,
+ })
+
+ if err != nil {
+ return errToStatus(err), err
+ }
+
+ clientConfig := map[string]interface{}{
+ "document": map[string]interface{}{
+ "fileType": file.Extension[1:],
+ "key": getDocumentKey(file.RealPath()),
+ "title": file.Name,
+ "url": (&url.URL{
+ Scheme: referrer.Scheme,
+ Host: referrer.Host,
+ RawQuery: "auth=" + url.QueryEscape(d.authToken),
+ }).JoinPath(d.server.BaseURL, "/api/raw", file.Path).String(),
+ "permissions": map[string]interface{}{
+ "edit": d.user.Perm.Modify,
+ "download": d.user.Perm.Download,
+ "print": d.user.Perm.Download,
+ },
+ },
+ "editorConfig": map[string]interface{}{
+ "callbackUrl": (&url.URL{
+ Scheme: referrer.Scheme,
+ Host: referrer.Host,
+ RawQuery: "auth=" + url.QueryEscape(d.authToken) + "&save=" + url.QueryEscape(file.Path),
+ }).JoinPath(d.server.BaseURL, "/api/onlyoffice/callback").String(),
+ "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": ternary(d.Settings.Branding.Theme == "dark", "default-dark", "default-light"),
+ },
+ "lang": d.user.Locale,
+ "mode": ternary(d.user.Perm.Modify, "edit", "view"),
+ },
+ "type": ternary(r.URL.Query().Get("isMobile") == trueString, "mobile", "desktop"),
+ }
+
+ signature, err := jwt.
+ NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(clientConfig)).
+ SignedString([]byte(d.Settings.OnlyOffice.JWTSecret))
+
+ if err != nil {
+ return http.StatusInternalServerError, errors.Join(errors.New("could not sign only-office client-config"), err)
+ }
+ clientConfig["token"] = signature
+
+ return renderJSON(w, r, clientConfig)
+})
+
var onlyofficeCallbackHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
@@ -28,12 +132,25 @@ var onlyofficeCallbackHandler = withUser(func(w http.ResponseWriter, r *http.Req
return http.StatusInternalServerError, err
}
- if data.Status == 2 || data.Status == 6 {
- docPath := r.URL.Query().Get("save")
- if docPath == "" {
- return http.StatusInternalServerError, errors.New("unable to get file save path")
- }
+ docPath := r.URL.Query().Get("save")
+ if docPath == "" {
+ return http.StatusInternalServerError, errors.New("unable to get file save path")
+ }
+ 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.
+ realPath := files.GetRealPath(d.user.Fs, docPath)
+ _ = coeditingDocumentKeys.Delete(realPath)
+ }
+
+ if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
+ data.Status == onlyOfficeStatusForceSaveWhileDocumentStillOpen {
if !d.user.Perm.Modify || !d.Check(docPath) {
return http.StatusForbidden, nil
}
@@ -44,7 +161,7 @@ var onlyofficeCallbackHandler = withUser(func(w http.ResponseWriter, r *http.Req
}
defer doc.Body.Close()
- err = d.RunHook(func() error {
+ err = d.Runner.RunHook(func() error {
_, writeErr := writeFile(d.user.Fs, docPath, doc.Body)
if writeErr != nil {
return writeErr
@@ -62,3 +179,38 @@ var onlyofficeCallbackHandler = withUser(func(w http.ResponseWriter, r *http.Req
}
return renderJSON(w, r, resp)
})
+
+func getReferer(r *http.Request) (*url.URL, error) {
+ if len(r.Header["Referer"]) != 1 {
+ return nil, errors.New("expected exactly one Referer header")
+ }
+
+ return url.ParseRequestURI(r.Header["Referer"][0])
+}
+
+func getDocumentKey(realPath string) string {
+ // error is intentionally ignored in order treat errors
+ // the same as a cache-miss
+ cachedDocumentKey, _ := coeditingDocumentKeys.Get(realPath)
+
+ if cachedDocumentKey != nil {
+ return string(cachedDocumentKey)
+ }
+
+ timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
+ documentKey := hashSHA256(realPath + timestamp)
+ _ = coeditingDocumentKeys.Set(realPath, []byte(documentKey))
+ return documentKey
+}
+
+func hashSHA256(data string) string {
+ bytes := sha256.Sum256([]byte(data))
+ return hex.EncodeToString(bytes[:])
+}
+
+func ternary(condition bool, trueValue, falseValue string) string {
+ if condition {
+ return trueValue
+ }
+ return falseValue
+}
diff --git a/http/static.go b/http/static.go
index d8f19c4c..54b7f565 100644
--- a/http/static.go
+++ b/http/static.go
@@ -46,7 +46,7 @@ func handleWithStaticData(w http.ResponseWriter, _ *http.Request, d *data, fSys
"ResizePreview": d.server.ResizePreview,
"EnableExec": d.server.EnableExec,
"TusSettings": d.settings.Tus,
- "OnlyOffice": d.settings.OnlyOffice,
+ "OnlyOfficeUrl": d.settings.OnlyOffice.URL,
}
if d.settings.Branding.Files != "" {
diff --git a/test.docx b/test.docx
deleted file mode 100644
index adf9a8c0..00000000
Binary files a/test.docx and /dev/null differ