feat: only-office integration pt 2, performed server-side signing, addressed other comments
This commit is contained in:
parent
08f37b90ce
commit
d294cacbe4
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
3
frontend/src/types/file.d.ts
vendored
3
frontend/src/types/file.d.ts
vendored
@ -35,7 +35,8 @@ type ResourceType =
|
|||||||
| "pdf"
|
| "pdf"
|
||||||
| "text"
|
| "text"
|
||||||
| "blob"
|
| "blob"
|
||||||
| "textImmutable";
|
| "textImmutable"
|
||||||
|
| "officedocument";
|
||||||
|
|
||||||
type DownloadFormat =
|
type DownloadFormat =
|
||||||
| "zip"
|
| "zip"
|
||||||
|
|||||||
6
frontend/src/types/settings.d.ts
vendored
6
frontend/src/types/settings.d.ts
vendored
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
error.value = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/*eslint-disable */
|
const close = () => {
|
||||||
onlyofficeScript.onload = () => {
|
fileStore.updateRequest(null);
|
||||||
let fileUrl = `${window.location.protocol}//${window.location.host}${baseURL}/api/raw${url.encodePath(
|
let uri = url.removeLastDir(route.path) + "/";
|
||||||
this.req.path
|
router.push({ path: uri });
|
||||||
)}?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 });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
1
go.mod
1
go.mod
@ -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
2
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/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=
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
11
http/data.go
11
http/data.go
@ -18,11 +18,12 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, d *data) (int, erro
|
|||||||
|
|
||||||
type data struct {
|
type data struct {
|
||||||
*runner.Runner
|
*runner.Runner
|
||||||
settings *settings.Settings
|
authToken string
|
||||||
server *settings.Server
|
settings *settings.Settings
|
||||||
store *storage.Storage
|
server *settings.Server
|
||||||
user *users.User
|
store *storage.Storage
|
||||||
raw interface{}
|
user *users.User
|
||||||
|
raw interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check implements rules.Checker.
|
// Check implements rules.Checker.
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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 != "" {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user