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.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

View File

@ -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:

View File

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

View File

@ -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",

View File

@ -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",

View File

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

View File

@ -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;
}

View File

@ -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,
};

View File

@ -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;

View File

@ -2,169 +2,57 @@
<div id="editor-container">
<header-bar>
<action icon="close" :label="$t('buttons.close')" @action="close()" />
<title>{{ req.name }}</title>
<title>{{ fileStore.req?.name ?? "" }}</title>
</header-bar>
<breadcrumbs base="/files" noLink />
<div id="editor"></div>
<errors v-if="error" :errorCode="error.status" />
<div id="editor" v-if="clientConfig">
<DocumentEditor
v-if="clientConfig"
id="onlyoffice-editor"
:documentServerUrl="onlyOfficeUrl"
:config="clientConfig"
/>
</div>
</div>
</template>
<style scoped>
#editor-container {
height: 100vh;
width: 100vw;
}
</style>
<script>
import { mapState } from "vuex";
<script setup lang="ts">
import url from "@/utils/url";
import { baseURL, onlyOffice } from "@/utils/constants";
import * as jose from "jose";
import { onlyOfficeUrl } from "@/utils/constants";
import HeaderBar from "@/components/header/HeaderBar.vue";
import Action from "@/components/header/Action.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 {
name: "onlyofficeeditor",
components: {
HeaderBar,
Action,
Breadcrumbs,
},
data: function () {
return {};
},
computed: {
...mapState(["req", "user", "jwt"]),
breadcrumbs() {
let parts = this.$route.path.split("/");
const fileStore = useFileStore();
const route = useRoute();
const router = useRouter();
const error = ref<StatusError | null>(null);
const clientConfig = ref<any>(null);
if (parts[0] === "") {
parts.shift();
}
if (parts[parts.length - 1] === "") {
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`
onMounted(async () => {
try {
const isMobile = window.innerWidth <= 736;
clientConfig.value = await fetchJSON(
`/api/onlyoffice/client-config${fileStore.req!.path}?isMobile=${isMobile}`
);
document.head.appendChild(onlyofficeScript);
} catch (err) {
if (err instanceof Error) {
error.value = err;
}
}
});
/*eslint-disable */
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 alg = 'HS256';
new jose.SignJWT(config)
.setProtectedHeader({ alg })
.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 });
},
},
const close = () => {
fileStore.updateRequest(null);
let uri = url.removeLastDir(route.path) + "/";
router.push({ path: uri });
};
</script>

1
go.mod
View File

@ -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

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/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=

View File

@ -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

View File

@ -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.

View File

@ -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")

View File

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

View File

@ -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 != "" {

BIN
test.docx

Binary file not shown.