feat: only-office integration pt 2, performed server-side signing, addressed other comments

This commit is contained in:
Alan Castro 2024-01-19 23:28:34 -08:00
parent 08f37b90ce
commit d294cacbe4
18 changed files with 258 additions and 183 deletions

View File

@ -48,6 +48,9 @@ func addConfigFlags(flags *pflag.FlagSet) {
flags.String("branding.files", "", "path to directory with images and custom styles") 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.disableExternal", false, "disable external links such as GitHub links")
flags.Bool("branding.disableUsedPercentage", false, "disable used disk percentage graph") 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 //nolint:gocyclo

View File

@ -205,19 +205,10 @@ func (i *FileInfo) Checksum(algo string) error {
} }
func (i *FileInfo) RealPath() string { func (i *FileInfo) RealPath() string {
if realPathFs, ok := i.Fs.(interface { return GetRealPath(i.Fs, i.Path)
RealPath(name string) (fPath string, err error)
}); ok {
realPath, err := realPathFs.RealPath(i.Path)
if err == nil {
return realPath
}
}
return i.Path
} }
//nolint:goconst //nolint:goconst,gocyclo
func (i *FileInfo) detectType(modify, saveContent, readHeader bool) error { func (i *FileInfo) detectType(modify, saveContent, readHeader bool) error {
if IsNamedPipe(i.Mode) { if IsNamedPipe(i.Mode) {
i.Type = "blob" i.Type = "blob"
@ -276,7 +267,8 @@ func (i *FileInfo) detectType(modify, saveContent, readHeader bool) error {
i.Content = string(content) i.Content = string(content)
} }
return nil 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" i.Type = "officedocument"
return nil return nil
default: default:

View File

@ -3,6 +3,8 @@ package files
import ( import (
"os" "os"
"unicode/utf8" "unicode/utf8"
"github.com/spf13/afero"
) )
func isBinary(content []byte) bool { func isBinary(content []byte) bool {
@ -57,3 +59,16 @@ func IsNamedPipe(mode os.FileMode) bool {
func IsSymlink(mode os.FileMode) bool { func IsSymlink(mode os.FileMode) bool {
return mode&os.ModeSymlink != 0 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
}

View File

@ -9,6 +9,7 @@
"version": "3.0.0", "version": "3.0.0",
"dependencies": { "dependencies": {
"@chenfengyuan/vue-number-input": "^2.0.1", "@chenfengyuan/vue-number-input": "^2.0.1",
"@onlyoffice/document-editor-vue": "^1.4.0",
"@vueuse/core": "^10.9.0", "@vueuse/core": "^10.9.0",
"@vueuse/integrations": "^10.9.0", "@vueuse/integrations": "^10.9.0",
"ace-builds": "^1.32.9", "ace-builds": "^1.32.9",
@ -2506,6 +2507,14 @@
"node": ">= 8" "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": { "node_modules/@pkgr/core": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",

View File

@ -19,6 +19,7 @@
}, },
"dependencies": { "dependencies": {
"@chenfengyuan/vue-number-input": "^2.0.1", "@chenfengyuan/vue-number-input": "^2.0.1",
"@onlyoffice/document-editor-vue": "^1.4.0",
"@vueuse/core": "^10.9.0", "@vueuse/core": "^10.9.0",
"@vueuse/integrations": "^10.9.0", "@vueuse/integrations": "^10.9.0",
"ace-builds": "^1.32.9", "ace-builds": "^1.32.9",

View File

@ -35,7 +35,8 @@ type ResourceType =
| "pdf" | "pdf"
| "text" | "text"
| "blob" | "blob"
| "textImmutable"; | "textImmutable"
| "officedocument";
type DownloadFormat = type DownloadFormat =
| "zip" | "zip"

View File

@ -8,6 +8,7 @@ interface ISettings {
tus: SettingsTus; tus: SettingsTus;
shell: string[]; shell: string[];
commands: SettingsCommand; commands: SettingsCommand;
onlyoffice: SettingsOnlyOffice;
} }
interface SettingsDefaults { interface SettingsDefaults {
@ -55,3 +56,8 @@ interface SettingsUnit {
GB: number; GB: number;
TB: number; TB: number;
} }
interface SettingsOnlyOffice {
url: string;
jwtSecret: string;
}

View File

@ -18,7 +18,7 @@ const enableExec: boolean = window.FileBrowser.EnableExec;
const tusSettings = window.FileBrowser.TusSettings; const tusSettings = window.FileBrowser.TusSettings;
const origin = window.location.origin; const origin = window.location.origin;
const tusEndpoint = `/api/tus`; const tusEndpoint = `/api/tus`;
const onlyOffice = window.FileBrowser.OnlyOffice; const onlyOfficeUrl = window.FileBrowser.OnlyOfficeUrl;
export { export {
name, name,
@ -40,5 +40,5 @@ export {
tusSettings, tusSettings,
origin, origin,
tusEndpoint, tusEndpoint,
onlyOffice, onlyOfficeUrl,
}; };

View File

@ -37,7 +37,7 @@ import { storeToRefs } from "pinia";
import { useFileStore } from "@/stores/file"; import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout"; import { useLayoutStore } from "@/stores/layout";
import { useUploadStore } from "@/stores/upload"; import { useUploadStore } from "@/stores/upload";
import { onlyOffice } from "@/utils/constants"; import { onlyOfficeUrl } from "@/utils/constants";
import HeaderBar from "@/components/header/HeaderBar.vue"; import HeaderBar from "@/components/header/HeaderBar.vue";
import Breadcrumbs from "@/components/Breadcrumbs.vue"; import Breadcrumbs from "@/components/Breadcrumbs.vue";
@ -48,7 +48,9 @@ import FileListing from "@/views/files/FileListing.vue";
import { StatusError } from "@/api/utils"; import { StatusError } from "@/api/utils";
const Editor = defineAsyncComponent(() => import("@/views/files/Editor.vue")); const Editor = defineAsyncComponent(() => import("@/views/files/Editor.vue"));
const Preview = defineAsyncComponent(() => import("@/views/files/Preview.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 layoutStore = useLayoutStore();
const fileStore = useFileStore(); const fileStore = useFileStore();
@ -79,7 +81,7 @@ const currentView = computed(() => {
fileStore.req.type === "textImmutable" fileStore.req.type === "textImmutable"
) { ) {
return Editor; return Editor;
} else if (fileStore.req.type === "officedocument" && onlyOffice !== "") { } else if (fileStore.req.type === "officedocument" && onlyOfficeUrl) {
return OnlyOfficeEditor; return OnlyOfficeEditor;
} else { } else {
return Preview; return Preview;

View File

@ -2,169 +2,57 @@
<div id="editor-container"> <div id="editor-container">
<header-bar> <header-bar>
<action icon="close" :label="$t('buttons.close')" @action="close()" /> <action icon="close" :label="$t('buttons.close')" @action="close()" />
<title>{{ req.name }}</title> <title>{{ fileStore.req?.name ?? "" }}</title>
</header-bar> </header-bar>
<breadcrumbs base="/files" noLink /> <breadcrumbs base="/files" noLink />
<errors v-if="error" :errorCode="error.status" />
<div id="editor"></div> <div id="editor" v-if="clientConfig">
<DocumentEditor
v-if="clientConfig"
id="onlyoffice-editor"
:documentServerUrl="onlyOfficeUrl"
:config="clientConfig"
/>
</div>
</div> </div>
</template> </template>
<style scoped> <script setup lang="ts">
#editor-container {
height: 100vh;
width: 100vw;
}
</style>
<script>
import { mapState } from "vuex";
import url from "@/utils/url"; import url from "@/utils/url";
import { baseURL, onlyOffice } from "@/utils/constants"; import { onlyOfficeUrl } from "@/utils/constants";
import * as jose from "jose";
import HeaderBar from "@/components/header/HeaderBar.vue"; import HeaderBar from "@/components/header/HeaderBar.vue";
import Action from "@/components/header/Action.vue"; import Action from "@/components/header/Action.vue";
import Breadcrumbs from "@/components/Breadcrumbs.vue"; import Breadcrumbs from "@/components/Breadcrumbs.vue";
import Errors from "@/views/Errors.vue";
import { fetchJSON, StatusError } from "@/api/utils";
import { useFileStore } from "@/stores/file";
import { useRoute } from "vue-router";
import { useRouter } from "vue-router";
import { onMounted, ref } from "vue";
import { DocumentEditor } from "@onlyoffice/document-editor-vue";
export default { const fileStore = useFileStore();
name: "onlyofficeeditor", const route = useRoute();
components: { const router = useRouter();
HeaderBar, const error = ref<StatusError | null>(null);
Action, const clientConfig = ref<any>(null);
Breadcrumbs,
},
data: function () {
return {};
},
computed: {
...mapState(["req", "user", "jwt"]),
breadcrumbs() {
let parts = this.$route.path.split("/");
if (parts[0] === "") { onMounted(async () => {
parts.shift(); try {
} const isMobile = window.innerWidth <= 736;
clientConfig.value = await fetchJSON(
if (parts[parts.length - 1] === "") { `/api/onlyoffice/client-config${fileStore.req!.path}?isMobile=${isMobile}`
parts.pop();
}
let breadcrumbs = [];
for (let i = 0; i < parts.length; i++) {
breadcrumbs.push({ name: decodeURIComponent(parts[i]) });
}
breadcrumbs.shift();
if (breadcrumbs.length > 3) {
while (breadcrumbs.length !== 4) {
breadcrumbs.shift();
}
breadcrumbs[0].name = "...";
}
return breadcrumbs;
},
},
created() {
window.addEventListener("keydown", this.keyEvent);
},
beforeDestroy() {
window.removeEventListener("keydown", this.keyEvent);
this.editor.destroyEditor();
},
mounted: function () {
let onlyofficeScript = document.createElement("script");
onlyofficeScript.setAttribute(
"src",
`${onlyOffice.url}/web-apps/apps/api/documents/api.js`
); );
document.head.appendChild(onlyofficeScript); } catch (err) {
if (err instanceof Error) {
/*eslint-disable */ error.value = err;
onlyofficeScript.onload = () => {
let fileUrl = `${window.location.protocol}//${window.location.host}${baseURL}/api/raw${url.encodePath(
this.req.path
)}?auth=${this.jwt}`;
// create a key from the last modified timestamp and the reversed file path (most specific part first)
// replace all special characters (only these symbols are supported: 0-9, a-z, A-Z, -._=)
// and truncate it (max length is 20 characters)
const key = (
Date.parse(this.req.modified).valueOf()
+ url
.encodePath(this.req.path.split('/').reverse().join(''))
.replaceAll(/[!~[\]*'()/,;:\-%+. ]/g, "")
).substring(0, 20);
const config = {
document: {
fileType: this.req.extension.substring(1),
key: key,
title: this.req.name,
url: fileUrl,
permissions: {
edit: this.user.perm.modify,
download: this.user.perm.download,
print: this.user.perm.download
} }
},
editorConfig: {
callbackUrl: `${window.location.protocol}//${window.location.host}${baseURL}/api/onlyoffice/callback?auth=${this.jwt}&save=${encodeURIComponent(this.req.path)}`,
user: {
id: this.user.id,
name: `User ${this.user.id}`
},
customization: {
autosave: true,
forcesave: true
},
lang: this.user.locale,
mode: this.user.perm.modify ? "edit" : "view"
} }
}; });
if(onlyOffice.jwtSecret != "") { const close = () => {
const alg = 'HS256'; fileStore.updateRequest(null);
new jose.SignJWT(config) let uri = url.removeLastDir(route.path) + "/";
.setProtectedHeader({ alg }) router.push({ path: uri });
.sign(new TextEncoder().encode(onlyOffice.jwtSecret)).then((jwt) => {
config.token = jwt;
this.editor = new DocsAPI.DocEditor("editor", config);
})
} else {
this.editor = new DocsAPI.DocEditor("editor", config);
}
};
/*eslint-enable */
},
methods: {
back() {
let uri = url.removeLastDir(this.$route.path) + "/";
this.$router.push({ path: uri });
},
keyEvent(event) {
if (!event.ctrlKey && !event.metaKey) {
return;
}
if (String.fromCharCode(event.which).toLowerCase() !== "s") {
return;
}
event.preventDefault();
this.save();
},
close() {
this.$store.commit("updateRequest", {});
let uri = url.removeLastDir(this.$route.path) + "/";
this.$router.push({ path: uri });
},
},
}; };
</script> </script>

1
go.mod
View File

@ -32,6 +32,7 @@ require (
) )
require ( require (
github.com/allegro/bigcache v1.2.1 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect
github.com/asticode/go-astikit v0.42.0 // indirect github.com/asticode/go-astikit v0.42.0 // indirect
github.com/asticode/go-astits v1.13.0 // indirect github.com/asticode/go-astits v1.13.0 // indirect

2
go.sum
View File

@ -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/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 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM= 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.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=

View File

@ -84,6 +84,7 @@ func withUser(fn handleFunc) handleFunc {
w.Header().Add("X-Renew-Token", "true") w.Header().Add("X-Renew-Token", "true")
} }
d.authToken = token.Raw
d.user, err = d.store.Users.Get(d.server.Root, tk.User.ID) d.user, err = d.store.Users.Get(d.server.Root, tk.User.ID)
if err != nil { if err != nil {
return http.StatusInternalServerError, err return http.StatusInternalServerError, err

View File

@ -18,6 +18,7 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, d *data) (int, erro
type data struct { type data struct {
*runner.Runner *runner.Runner
authToken string
settings *settings.Settings settings *settings.Settings
server *settings.Server server *settings.Server
store *storage.Storage store *storage.Storage

View File

@ -60,6 +60,7 @@ func NewHandler(
users.Handle("/{id:[0-9]+}", monkey(userGetHandler, "")).Methods("GET") users.Handle("/{id:[0-9]+}", monkey(userGetHandler, "")).Methods("GET")
users.Handle("/{id:[0-9]+}", monkey(userDeleteHandler, "")).Methods("DELETE") 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("/onlyoffice").Handler(monkey(onlyofficeCallbackHandler, "/api/onlyoffice/callback")).Methods("POST")
api.PathPrefix("/resources").Handler(monkey(resourceGetHandler, "/api/resources")).Methods("GET") api.PathPrefix("/resources").Handler(monkey(resourceGetHandler, "/api/resources")).Methods("GET")

View File

@ -1,21 +1,125 @@
package http package http
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
"net/http" "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 { type OnlyOfficeCallback struct {
ChangesURL string `json:"changesurl,omitempty"` ChangesURL string `json:"changesurl,omitempty"`
Key string `json:"key"` Key string `json:"key,omitempty"`
Status int `json:"status"` Status int `json:"status,omitempty"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
Users []string `json:"users,omitempty"` Users []string `json:"users,omitempty"`
UserData string `json:"userdata,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) { var onlyofficeCallbackHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
@ -28,12 +132,25 @@ var onlyofficeCallbackHandler = withUser(func(w http.ResponseWriter, r *http.Req
return http.StatusInternalServerError, err return http.StatusInternalServerError, err
} }
if data.Status == 2 || data.Status == 6 {
docPath := r.URL.Query().Get("save") docPath := r.URL.Query().Get("save")
if docPath == "" { if docPath == "" {
return http.StatusInternalServerError, errors.New("unable to get file save path") 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) { if !d.user.Perm.Modify || !d.Check(docPath) {
return http.StatusForbidden, nil return http.StatusForbidden, nil
} }
@ -44,7 +161,7 @@ var onlyofficeCallbackHandler = withUser(func(w http.ResponseWriter, r *http.Req
} }
defer doc.Body.Close() defer doc.Body.Close()
err = d.RunHook(func() error { err = d.Runner.RunHook(func() error {
_, writeErr := writeFile(d.user.Fs, docPath, doc.Body) _, writeErr := writeFile(d.user.Fs, docPath, doc.Body)
if writeErr != nil { if writeErr != nil {
return writeErr return writeErr
@ -62,3 +179,38 @@ var onlyofficeCallbackHandler = withUser(func(w http.ResponseWriter, r *http.Req
} }
return renderJSON(w, r, resp) 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
}

View File

@ -46,7 +46,7 @@ func handleWithStaticData(w http.ResponseWriter, _ *http.Request, d *data, fSys
"ResizePreview": d.server.ResizePreview, "ResizePreview": d.server.ResizePreview,
"EnableExec": d.server.EnableExec, "EnableExec": d.server.EnableExec,
"TusSettings": d.settings.Tus, "TusSettings": d.settings.Tus,
"OnlyOffice": d.settings.OnlyOffice, "OnlyOfficeUrl": d.settings.OnlyOffice.URL,
} }
if d.settings.Branding.Files != "" { if d.settings.Branding.Files != "" {

BIN
test.docx

Binary file not shown.