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