mirror of
https://github.com/mickael-kerjean/filestash
synced 2025-12-06 08:22:24 +01:00
feature (documents): handle office documents using onlyoffice
This commit is contained in:
parent
2572594930
commit
f3e6c8c030
17 changed files with 574 additions and 55 deletions
|
|
@ -46,6 +46,7 @@
|
|||
- Audio player
|
||||
- Full Text Search
|
||||
- Shared links are full fledge network drive
|
||||
- Office documents (docx, xlsx and more)
|
||||
- User friendly
|
||||
- Mobile friendly
|
||||
- Customisable
|
||||
|
|
|
|||
|
|
@ -1,34 +1,38 @@
|
|||
import Path from 'path';
|
||||
|
||||
export function getMimeType(file){
|
||||
let ext = Path.extname(file).replace(/^\./, '').toLowerCase();
|
||||
let ext = Path.extname(file).replace(/^\./, "").toLowerCase();
|
||||
let mime = CONFIG.mime[ext];
|
||||
if(mime){
|
||||
return mime;
|
||||
}else{
|
||||
return 'text/plain';
|
||||
return "text/plain";
|
||||
}
|
||||
}
|
||||
|
||||
export function opener(file){
|
||||
let mime = getMimeType(file);
|
||||
if(mime.split('/')[0] === 'text'){
|
||||
return 'editor';
|
||||
}else if(mime === 'application/pdf'){
|
||||
return 'pdf';
|
||||
}else if(mime.split('/')[0] === 'image'){
|
||||
return 'image';
|
||||
}else if(['application/javascript', 'application/xml', 'application/json', 'application/x-perl'].indexOf(mime) !== -1){
|
||||
return 'editor';
|
||||
}else if(['audio/wav', 'audio/mp3', 'audio/flac', 'audio/ogg'].indexOf(mime) !== -1){
|
||||
return 'audio';
|
||||
|
||||
let openerFromPlugin = window.overrides["xdg-open"](mime);
|
||||
if(openerFromPlugin !== null){
|
||||
return openerFromPlugin;
|
||||
}else if(mime.split("/")[0] === "text"){
|
||||
return ["editor", null];
|
||||
}else if(mime === "application/pdf"){
|
||||
return ["pdf", null];
|
||||
}else if(mime.split("/")[0] === "image"){
|
||||
return ["image", null];
|
||||
}else if(["application/javascript", "application/xml", "application/json", "application/x-perl"].indexOf(mime) !== -1){
|
||||
return ["editor", null];
|
||||
}else if(["audio/wav", "audio/mp3", "audio/flac", "audio/ogg"].indexOf(mime) !== -1){
|
||||
return ["audio", null];
|
||||
}else if(mime === "application/x-form"){
|
||||
return 'form';
|
||||
}else if(mime.split('/')[0] === 'video' || mime === "application/ogg"){
|
||||
return 'video';
|
||||
}else if(mime.split('/')[0] === "application"){
|
||||
return 'download';
|
||||
return ["form", null];
|
||||
}else if(mime.split("/")[0] === "video" || mime === "application/ogg"){
|
||||
return ["video", null];
|
||||
}else if(mime.split("/")[0] === "application"){
|
||||
return ["download", null];
|
||||
}else{
|
||||
return 'editor';
|
||||
return ["editor", null];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import ReactDOM from 'react-dom';
|
|||
import Router from './router';
|
||||
|
||||
import { Config, Log } from "./model/";
|
||||
import load from "little-loader";
|
||||
|
||||
import './assets/css/reset.scss';
|
||||
|
||||
|
|
@ -35,7 +36,7 @@ window.addEventListener("DOMContentLoaded", () => {
|
|||
return Promise.resolve();
|
||||
}
|
||||
|
||||
Config.refresh().then(() => {
|
||||
Promise.all([Config.refresh(), setup_xdg_open()]).then(() => {
|
||||
const timeSinceBoot = new Date() - window.initTime;
|
||||
if(timeSinceBoot >= 1500){
|
||||
const timeoutToAvoidFlickering = timeSinceBoot > 2500 ? 0 : 500;
|
||||
|
|
@ -55,8 +56,6 @@ window.onerror = function (msg, url, lineNo, colNo, error) {
|
|||
Log.report(msg, url, lineNo, colNo, error)
|
||||
}
|
||||
|
||||
window.overrides = {}; // server generated frontend overrides
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", function() {
|
||||
navigator.serviceWorker.register("/sw_cache.js").catch(function(err){
|
||||
|
|
@ -64,3 +63,14 @@ if ("serviceWorker" in navigator) {
|
|||
});
|
||||
});
|
||||
}
|
||||
|
||||
// server generated frontend overrides
|
||||
window.overrides = {};
|
||||
function setup_xdg_open(){
|
||||
return new Promise((done, err) => {
|
||||
load("/overrides/xdg-open.js", function(error) {
|
||||
if(error) return err(error);
|
||||
done()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,17 +9,22 @@ import { debounce, opener, notify } from '../helpers/';
|
|||
import { FileDownloader, ImageViewer, PDFViewer, FormViewer } from './viewerpage/';
|
||||
|
||||
const VideoPlayer = (props) => (
|
||||
<Bundle loader={import(/* webpackChunkName: "video" */"../pages/viewerpage/videoplayer")} symbol="VideoPlayer" overrides={["/overrides/video-transcoder.js"]} >
|
||||
<Bundle loader={import(/* webpackChunkName: "video" */"./viewerpage/videoplayer")} symbol="VideoPlayer" overrides={["/overrides/video-transcoder.js"]} >
|
||||
{(Comp) => <Comp {...props}/>}
|
||||
</Bundle>
|
||||
);
|
||||
const IDE = (props) => (
|
||||
<Bundle loader={import(/* webpackChunkName: "ide" */"../pages/viewerpage/ide")} symbol="IDE">
|
||||
<Bundle loader={import(/* webpackChunkName: "ide" */"./viewerpage/ide")} symbol="IDE">
|
||||
{(Comp) => <Comp {...props}/>}
|
||||
</Bundle>
|
||||
);
|
||||
const AudioPlayer = (props) => (
|
||||
<Bundle loader={import(/* webpackChunkName: "audioplayer" */"../pages/viewerpage/audioplayer")} symbol="AudioPlayer">
|
||||
<Bundle loader={import(/* webpackChunkName: "audioplayer" */"./viewerpage/audioplayer")} symbol="AudioPlayer">
|
||||
{(Comp) => <Comp {...props}/>}
|
||||
</Bundle>
|
||||
);
|
||||
const Appframe = (props) => (
|
||||
<Bundle loader={import(/* webpackChunkName: "appframe" */"./viewerpage/appframe")} symbol="AppFrame">
|
||||
{(Comp) => <Comp {...props}/>}
|
||||
</Bundle>
|
||||
);
|
||||
|
|
@ -38,7 +43,8 @@ export class ViewerPage extends React.Component {
|
|||
content: null,
|
||||
needSaving: false,
|
||||
isSaving: false,
|
||||
loading: true
|
||||
loading: true,
|
||||
application_arguments: null
|
||||
};
|
||||
this.props.subscribe('file.select', this.onPathUpdate.bind(this));
|
||||
}
|
||||
|
|
@ -53,11 +59,12 @@ export class ViewerPage extends React.Component {
|
|||
componentDidMount(){
|
||||
const metadata = () => {
|
||||
return new Promise((done, err) => {
|
||||
let app_opener = opener(this.state.path);
|
||||
let [app_opener, app_args] = opener(this.state.path);
|
||||
Files.url(this.state.path).then((url) => {
|
||||
this.setState({
|
||||
url: url,
|
||||
opener: app_opener
|
||||
opener: app_opener,
|
||||
application_arguments: app_args
|
||||
}, () => done(app_opener));
|
||||
}).catch(error => {
|
||||
this.props.error(error);
|
||||
|
|
@ -173,6 +180,9 @@ export class ViewerPage extends React.Component {
|
|||
<NgIf cond={this.state.opener === 'download'}>
|
||||
<FileDownloader data={this.state.url} filename={this.state.filename} />
|
||||
</NgIf>
|
||||
<NgIf cond={this.state.opener === 'appframe'}>
|
||||
<Appframe data={this.state.path} filename={this.state.filename} args={this.state.application_arguments} />
|
||||
</NgIf>
|
||||
</NgIf>
|
||||
<NgIf cond={this.state.loading === true}>
|
||||
<Loader/>
|
||||
|
|
|
|||
30
client/pages/viewerpage/appframe.js
Normal file
30
client/pages/viewerpage/appframe.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
|
||||
import { MenuBar } from './menubar';
|
||||
import { currentShare } from '../../helpers/';
|
||||
import './appframe.scss';
|
||||
|
||||
export class AppFrame extends React.Component{
|
||||
constructor(props){
|
||||
super(props);
|
||||
}
|
||||
|
||||
render(){
|
||||
let error = null;
|
||||
if(!this.props.args) {
|
||||
error = "Missing configuration. Contact your administrator";
|
||||
} else if(!this.props.args.endpoint) {
|
||||
error = "Missing endpoint configuration. Contact your administrator";
|
||||
}
|
||||
if(error !== null) return (
|
||||
<div className="component_appframe">
|
||||
<div className="error">{error}</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="component_appframe">
|
||||
<iframe src={this.props.args.endpoint + "?path=" + this.props.data + "&share=" + currentShare()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
17
client/pages/viewerpage/appframe.scss
Normal file
17
client/pages/viewerpage/appframe.scss
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
.component_appframe {
|
||||
text-align: center;
|
||||
background: #525659;
|
||||
width: 100%;
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
.error {
|
||||
color: white;
|
||||
font-size: 17px;
|
||||
margin-top: 10px;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,5 +9,11 @@ services:
|
|||
- GDRIVE_CLIENT_ID=<gdrive_client>
|
||||
- GDRIVE_CLIENT_SECRET=<gdrive_secret>
|
||||
- DROPBOX_CLIENT_ID=<dropbox_key>
|
||||
- ONLYOFFICE_URL=http://onlyoffice
|
||||
ports:
|
||||
- "8334:8334"
|
||||
|
||||
onlyoffice:
|
||||
container_name: filestash_oods
|
||||
image: onlyoffice/documentserver
|
||||
restart: always
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ import (
|
|||
|
||||
var Letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
||||
|
||||
func EncryptString(secret string, json string) (string, error) {
|
||||
d, err := compress([]byte(json))
|
||||
func EncryptString(secret string, data string) (string, error) {
|
||||
d, err := compress([]byte(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,14 @@ func (this Get) FrontendOverrides() []string {
|
|||
return overrides
|
||||
}
|
||||
|
||||
var xdg_open []string
|
||||
func (this Register) XDGOpen(jsString string) {
|
||||
xdg_open = append(xdg_open, jsString)
|
||||
}
|
||||
func (this Get) XDGOpen() []string {
|
||||
return xdg_open
|
||||
}
|
||||
|
||||
const OverrideVideoSourceMapper = "/overrides/video-transcoder.js"
|
||||
func init() {
|
||||
Hooks.Register.FrontendOverrides(OverrideVideoSourceMapper)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ func FileExport(ctx App, res http.ResponseWriter, req *http.Request) {
|
|||
query := req.URL.Query()
|
||||
p := mux.Vars(req)
|
||||
mimeType := fmt.Sprintf("%s/%s", p["mtype0"], p["mtype1"])
|
||||
path, err := pathBuilder(ctx, strings.Replace(req.URL.Path, fmt.Sprintf("/api/export/%s/%s/%s", p["share"], p["mtype0"], p["mtype1"]), "", 1))
|
||||
path, err := PathBuilder(ctx, strings.Replace(req.URL.Path, fmt.Sprintf("/api/export/%s/%s/%s", p["share"], p["mtype0"], p["mtype1"]), "", 1))
|
||||
if err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ func FileLs(ctx App, res http.ResponseWriter, req *http.Request) {
|
|||
SendSuccessResults(res, make([]FileInfo, 0))
|
||||
return
|
||||
}
|
||||
path, err := pathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
path, err := PathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
|
|
@ -121,7 +121,7 @@ func FileCat(ctx App, res http.ResponseWriter, req *http.Request) {
|
|||
SendErrorResult(res, ErrPermissionDenied)
|
||||
return
|
||||
}
|
||||
path, err := pathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
path, err := PathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
|
|
@ -280,7 +280,7 @@ func FileSave(ctx App, res http.ResponseWriter, req *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
path, err := pathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
path, err := PathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
|
|
@ -310,12 +310,12 @@ func FileMv(ctx App, res http.ResponseWriter, req *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
from, err := pathBuilder(ctx, req.URL.Query().Get("from"))
|
||||
from, err := PathBuilder(ctx, req.URL.Query().Get("from"))
|
||||
if err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
}
|
||||
to, err := pathBuilder(ctx, req.URL.Query().Get("to"))
|
||||
to, err := PathBuilder(ctx, req.URL.Query().Get("to"))
|
||||
if err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
|
|
@ -342,7 +342,7 @@ func FileRm(ctx App, res http.ResponseWriter, req *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
path, err := pathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
path, err := PathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
|
|
@ -362,7 +362,7 @@ func FileMkdir(ctx App, res http.ResponseWriter, req *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
path, err := pathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
path, err := PathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
|
|
@ -383,7 +383,7 @@ func FileTouch(ctx App, res http.ResponseWriter, req *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
path, err := pathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
path, err := PathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
|
|
@ -398,7 +398,7 @@ func FileTouch(ctx App, res http.ResponseWriter, req *http.Request) {
|
|||
SendSuccessResult(res, nil)
|
||||
}
|
||||
|
||||
func pathBuilder(ctx App, path string) (string, error) {
|
||||
func PathBuilder(ctx App, path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", NewError("No path available", 400)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ func FileSearch(ctx App, res http.ResponseWriter, req *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
path, err := pathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
path, err := PathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
path = "/"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
)
|
||||
|
||||
func ShareList(ctx App, res http.ResponseWriter, req *http.Request) {
|
||||
path, err := pathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
path, err := PathBuilder(ctx, req.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
. "github.com/mickael-kerjean/filestash/server/common"
|
||||
. "github.com/mickael-kerjean/filestash/server/ctrl"
|
||||
|
|
@ -23,10 +24,6 @@ func Init(a *App) {
|
|||
var middlewares []Middleware
|
||||
r := mux.NewRouter()
|
||||
|
||||
if os.Getenv("DEBUG") == "true" {
|
||||
initDebugRoutes(r)
|
||||
}
|
||||
|
||||
// API for Session
|
||||
session := r.PathPrefix("/api/session").Subrouter()
|
||||
middlewares = []Middleware{ ApiHeaders, SecureHeaders, SecureAjax, SessionStart }
|
||||
|
|
@ -100,18 +97,15 @@ func Init(a *App) {
|
|||
r.HandleFunc("/report", NewMiddlewareChain(ReportHandler, middlewares, *a)).Methods("POST")
|
||||
middlewares = []Middleware{ IndexHeaders }
|
||||
r.HandleFunc("/about", NewMiddlewareChain(AboutHandler, middlewares, *a)).Methods("GET")
|
||||
for _, obj := range Hooks.Get.HttpEndpoint() {
|
||||
obj(r, a)
|
||||
}
|
||||
for _, obj := range Hooks.Get.FrontendOverrides() {
|
||||
r.HandleFunc(obj, func(res http.ResponseWriter, req *http.Request) {
|
||||
res.WriteHeader(http.StatusOK)
|
||||
res.Write([]byte("/* FRONTOFFICE OVERRIDES */"))
|
||||
})
|
||||
}
|
||||
r.HandleFunc("/robots.txt", func(res http.ResponseWriter, req *http.Request) {
|
||||
res.Write([]byte(""))
|
||||
})
|
||||
|
||||
if os.Getenv("DEBUG") == "true" {
|
||||
initDebugRoutes(r)
|
||||
}
|
||||
initPluginsRoutes(r, a)
|
||||
|
||||
r.PathPrefix("/").Handler(http.HandlerFunc(NewMiddlewareChain(IndexHandler(FILE_INDEX), middlewares, *a))).Methods("GET")
|
||||
|
||||
// Routes are served via plugins to avoid getting stuck with plain HTTP. The idea is to
|
||||
|
|
@ -158,3 +152,26 @@ func initDebugRoutes(r *mux.Router) {
|
|||
w.Write([]byte("</p>"))
|
||||
})
|
||||
}
|
||||
|
||||
func initPluginsRoutes(r *mux.Router, a *App) {
|
||||
// Endpoints hanle by plugins
|
||||
for _, obj := range Hooks.Get.HttpEndpoint() {
|
||||
obj(r, a)
|
||||
}
|
||||
// frontoffice overrides: it is the mean by which plugin can interact with the frontoffice
|
||||
for _, obj := range Hooks.Get.FrontendOverrides() {
|
||||
r.HandleFunc(obj, func(res http.ResponseWriter, req *http.Request) {
|
||||
res.WriteHeader(http.StatusOK)
|
||||
res.Write([]byte(fmt.Sprintf("/* Default '%s' */", obj)))
|
||||
})
|
||||
}
|
||||
// map which file can be open with what application
|
||||
r.HandleFunc("/overrides/xdg-open.js", func(res http.ResponseWriter, req *http.Request) {
|
||||
res.Write([]byte(`window.overrides["xdg-open"] = function(mime){`))
|
||||
openers := Hooks.Get.XDGOpen()
|
||||
for i:=0; i<len(openers); i++ {
|
||||
res.Write([]byte(openers[i]))
|
||||
}
|
||||
res.Write([]byte(`return null;}`))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,18 @@ func IndexHeaders(fn func(App, http.ResponseWriter, *http.Request)) func(ctx App
|
|||
header.Set("X-Frame-Options", "DENY")
|
||||
header.Set("X-Powered-By", fmt.Sprintf("Filestash/%s.%s <https://filestash.app>", APP_VERSION, BUILD_DATE))
|
||||
|
||||
cspHeader := "default-src 'none'; style-src 'unsafe-inline'; font-src 'self' data:; manifest-src 'self'; script-src 'self' 'sha256-JNAde5CZQqXtYRLUk8CGgyJXo6C7Zs1lXPPClLM1YM4=' 'sha256-9/gQeQaAmVkFStl6tfCbHXn8mr6PgtxlH+hEp685lzY='; img-src 'self' data: https://maps.wikimedia.org; connect-src 'self'; object-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; base-uri 'self';"
|
||||
cspHeader := "default-src 'none'; "
|
||||
cspHeader += "style-src 'unsafe-inline'; "
|
||||
cspHeader += "font-src 'self' data:; "
|
||||
cspHeader += "manifest-src 'self'; "
|
||||
cspHeader += "script-src 'self' 'sha256-JNAde5CZQqXtYRLUk8CGgyJXo6C7Zs1lXPPClLM1YM4=' 'sha256-9/gQeQaAmVkFStl6tfCbHXn8mr6PgtxlH+hEp685lzY='; "
|
||||
cspHeader += "img-src 'self' data: https://maps.wikimedia.org; "
|
||||
cspHeader += "connect-src 'self'; "
|
||||
cspHeader += "object-src 'self'; "
|
||||
cspHeader += "media-src 'self' blob:; "
|
||||
cspHeader += "worker-src 'self' blob:; "
|
||||
cspHeader += "form-action 'self'; base-uri 'self'; "
|
||||
cspHeader += "frame-src 'self'; "
|
||||
if allowedDomainsForIframe := Config.Get("features.protection.iframe").Schema(func(f *FormElement) *FormElement{
|
||||
if f == nil {
|
||||
f = &FormElement{}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_starter_tunnel"
|
||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_starter_tor"
|
||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_video_transcoder"
|
||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_editor_onlyoffice"
|
||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_image_light"
|
||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_backblaze"
|
||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_dav"
|
||||
|
|
|
|||
404
server/plugin/plg_editor_onlyoffice/index.go
Normal file
404
server/plugin/plg_editor_onlyoffice/index.go
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
package plg_editor_onlyoffice
|
||||
|
||||
// [ ] TODO shared link respect access right
|
||||
// [X] TODO Proper config
|
||||
// [ ] TODO Link to frontend:
|
||||
// Hooks.Register.XDGOpen("mtype", "app", args)
|
||||
// Hooks.Get.XDGOpen()
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
. "github.com/mickael-kerjean/filestash/server/common"
|
||||
"github.com/mickael-kerjean/filestash/server/ctrl"
|
||||
. "github.com/mickael-kerjean/filestash/server/middleware"
|
||||
"github.com/mickael-kerjean/filestash/server/model"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
SECRET_KEY_DERIVATE_FOR_ONLYOFFICE string
|
||||
OnlyOfficeCache *cache.Cache
|
||||
)
|
||||
|
||||
type OnlyOfficeCacheData struct {
|
||||
Path string
|
||||
Save func(path string, file io.Reader) error
|
||||
Cat func(path string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
func init() {
|
||||
plugin_enable := func() bool {
|
||||
return Config.Get("features.office.enable").Schema(func(f *FormElement) *FormElement {
|
||||
if f == nil {
|
||||
f = &FormElement{}
|
||||
}
|
||||
f.Name = "enable"
|
||||
f.Type = "enable"
|
||||
f.Target = []string{"onlyoffice_server"}
|
||||
f.Description = "Enable/Disable the office suite to manage word, excel and powerpoint documents. This setting requires a restart to comes into effect"
|
||||
f.Default = false
|
||||
if u := os.Getenv("ONLYOFFICE_URL"); u != "" {
|
||||
f.Default = true
|
||||
}
|
||||
return f
|
||||
}).Bool()
|
||||
}()
|
||||
Config.Get("features.office.onlyoffice_server").Schema(func(f *FormElement) *FormElement {
|
||||
if f == nil {
|
||||
f = &FormElement{}
|
||||
}
|
||||
f.Id = "onlyoffice_server"
|
||||
f.Name = "onlyoffice_server"
|
||||
f.Type = "text"
|
||||
f.Description = "Location of your OnlyOffice server"
|
||||
f.Default = ""
|
||||
f.Placeholder = "Eg: http://127.0.0.1:8080"
|
||||
if u := os.Getenv("ONLYOFFICE_URL"); u != "" {
|
||||
f.Default = u
|
||||
f.Placeholder = fmt.Sprintf("Default: '%s'", u)
|
||||
}
|
||||
return f
|
||||
})
|
||||
|
||||
if plugin_enable == false {
|
||||
return
|
||||
}
|
||||
|
||||
SECRET_KEY_DERIVATE_FOR_ONLYOFFICE = Hash("ONLYOFFICE_" + SECRET_KEY, len(SECRET_KEY))
|
||||
Hooks.Register.HttpEndpoint(func(r *mux.Router, app *App) error {
|
||||
oods := r.PathPrefix("/onlyoffice").Subrouter()
|
||||
oods.PathPrefix("/static/").HandlerFunc(StaticHandler).Methods("GET", "POST")
|
||||
oods.HandleFunc("/event", OnlyOfficeEventHandler).Methods("POST")
|
||||
oods.HandleFunc("/content", FetchContentHandler).Methods("GET")
|
||||
|
||||
r.HandleFunc(
|
||||
COOKIE_PATH + "onlyoffice/iframe",
|
||||
NewMiddlewareChain(
|
||||
IframeContentHandler,
|
||||
[]Middleware{ SessionStart, LoggedInOnly },
|
||||
*app,
|
||||
),
|
||||
).Methods("GET")
|
||||
return nil
|
||||
})
|
||||
Hooks.Register.XDGOpen(`
|
||||
if(mime === "application/word" || mime === "application/msword" ||
|
||||
mime === "application/vnd.oasis.opendocument.text" || mime === "application/vnd.oasis.opendocument.spreadsheet" ||
|
||||
mime === "application/excel" || mime === "application/vnd.ms-excel" || mime === "application/powerpoint" ||
|
||||
mime === "application/vnd.ms-powerpoint" || mime === "application/vnd.oasis.opendocument.presentation" ) {
|
||||
return ["appframe", {"endpoint": "/api/onlyoffice/iframe"}];
|
||||
}
|
||||
`)
|
||||
OnlyOfficeCache = cache.New(720*time.Minute, 720*time.Minute)
|
||||
}
|
||||
|
||||
func StaticHandler(res http.ResponseWriter, req *http.Request) {
|
||||
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/onlyoffice/static/")
|
||||
oodsLocation := Config.Get("features.office.onlyoffice_server").String()
|
||||
u, err := url.Parse(oodsLocation)
|
||||
if err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("X-Forwarded-Proto", "http")
|
||||
req.Header.Set("X-Forwarded-Host", req.Host + "/onlyoffice/static/")
|
||||
proxy := httputil.NewSingleHostReverseProxy(u)
|
||||
proxy.ErrorHandler = func(rw http.ResponseWriter, rq *http.Request, err error) {
|
||||
Log.Warning("[onlyoffice] %s", err.Error())
|
||||
SendErrorResult(rw, NewError(err.Error(), http.StatusBadGateway))
|
||||
}
|
||||
proxy.ServeHTTP(res, req)
|
||||
}
|
||||
|
||||
func IframeContentHandler(ctx App, res http.ResponseWriter, req *http.Request) {
|
||||
if model.CanRead(&ctx) == false {
|
||||
SendErrorResult(res, ErrPermissionDenied)
|
||||
return
|
||||
} else if oodsLocation := Config.Get("features.office.onlyoffice_server").String(); oodsLocation == "" {
|
||||
res.WriteHeader(http.StatusServiceUnavailable)
|
||||
res.Write([]byte("<p>The Onlyoffice server hasn't been configured</p>"))
|
||||
res.Write([]byte("<style>p {color: white; text-align: center; margin-top: 50px; font-size: 20px; opacity: 0.6; font-family: monospace; } </style>"))
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
path string // path of the file we want to open via onlyoffice
|
||||
filestashServerLocation string // location from which the oods server can reach filestash
|
||||
userId string // as seen by onlyoffice to distinguish different users
|
||||
username string // username as displayed by only office
|
||||
key string // unique identifier for a file as seen be only office
|
||||
contentType string // name of the application in onlyoffice
|
||||
filetype string // extension of the document
|
||||
filename string // filename of the document
|
||||
oodsMode string // edit mode
|
||||
oodsDevice string // mobile, desktop of embedded
|
||||
localip string
|
||||
)
|
||||
query := req.URL.Query()
|
||||
path, err := ctrl.PathBuilder(ctx, query.Get("path"))
|
||||
if err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
}
|
||||
|
||||
userId = GenerateID(&ctx)
|
||||
f, err := ctx.Backend.Cat(path)
|
||||
if err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
}
|
||||
key = HashStream(f, 20)
|
||||
key = Hash(key + userId + path, 20)
|
||||
|
||||
filename = filepath.Base(path)
|
||||
oodsMode = func() string {
|
||||
if model.CanEdit(&ctx) == false {
|
||||
return "view"
|
||||
}
|
||||
return "edit"
|
||||
}()
|
||||
oodsDevice = func() string {
|
||||
ua := req.Header.Get("User-Agent")
|
||||
if ua == "" {
|
||||
return "desktop"
|
||||
} else if strings.Contains(ua, "iPhone") {
|
||||
return "mobile"
|
||||
} else if strings.Contains(ua, "iPad") {
|
||||
return "mobile"
|
||||
} else if strings.Contains(ua, "Android") {
|
||||
return "mobile"
|
||||
} else if strings.Contains(ua, "Mobile") {
|
||||
return "mobile"
|
||||
}
|
||||
|
||||
if oodsMode == "view" {
|
||||
return "embedded"
|
||||
}
|
||||
return "desktop"
|
||||
}()
|
||||
username = func() string {
|
||||
if ctx.Session["username"] != "" {
|
||||
return ctx.Session["username"]
|
||||
}
|
||||
return "Me"
|
||||
}()
|
||||
if ctx.Share.Id != "" {
|
||||
username = "Anonymous"
|
||||
userId = RandomString(10)
|
||||
}
|
||||
localip = func() string { // https://stackoverflow.com/questions/23558425/how-do-i-get-the-local-ip-address-in-go#23558495
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, address := range addrs {
|
||||
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
if ipnet.IP.To4() != nil {
|
||||
return ipnet.IP.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}()
|
||||
filestashServerLocation = fmt.Sprintf("http://%s:%d", localip, Config.Get("general.port").Int())
|
||||
contentType = func(p string) string {
|
||||
var (
|
||||
word string = "text"
|
||||
excel string = "spreadsheet"
|
||||
powerpoint string = "presentation"
|
||||
)
|
||||
switch GetMimeType(p) {
|
||||
case "application/word": return word
|
||||
case "application/msword": return word
|
||||
case "application/vnd.oasis.opendocument.text": return word
|
||||
case "application/vnd.oasis.opendocument.spreadsheet": return excel
|
||||
case "application/excel": return excel
|
||||
case "application/vnd.ms-excel": return excel
|
||||
case "application/powerpoint": return powerpoint
|
||||
case "application/vnd.ms-powerpoint": return powerpoint
|
||||
case "application/vnd.oasis.opendocument.presentation": return powerpoint
|
||||
}
|
||||
return ""
|
||||
}(path)
|
||||
filetype = strings.TrimPrefix(filepath.Ext(filename), ".")
|
||||
OnlyOfficeCache.Set(key, &OnlyOfficeCacheData{ path, ctx.Backend.Save, ctx.Backend.Cat }, cache.DefaultExpiration)
|
||||
res.Write([]byte(fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<style> body { margin: 0; } body, html{ height: 100%%; } iframe { width: 100%%; height: 100%%; } </style>
|
||||
<div id="placeholder"></div>
|
||||
<script type="text/javascript" src="/onlyoffice/static/web-apps/apps/api/documents/api.js"></script>
|
||||
<script>
|
||||
if("DocsAPI" in window) loadApplication();
|
||||
else sendError("[error] Can't reach the onlyoffice server");
|
||||
|
||||
function loadApplication() {
|
||||
new DocsAPI.DocEditor("placeholder", {
|
||||
"token": "foobar",
|
||||
"documentType": "%s",
|
||||
"type": "%s",
|
||||
"document": {
|
||||
"title": "%s",
|
||||
"url": "%s/onlyoffice/content?key=%s",
|
||||
"fileType": "%s",
|
||||
"key": "%s"
|
||||
},
|
||||
"editorConfig": {
|
||||
"callbackUrl": "%s/onlyoffice/event",
|
||||
"mode": "%s",
|
||||
"customization": {
|
||||
"autosave": false,
|
||||
"forcesave": true,
|
||||
"compactHeader": true
|
||||
},
|
||||
"user": {
|
||||
"id": "%s",
|
||||
"name": "%s"
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
function sendError(message){
|
||||
let $el = document.createElement("p");
|
||||
$el.innerHTML = message;
|
||||
$el.setAttribute("style", "text-align: center; color: white; opacity: 0.8; font-size: 20px; font-family: monospace;");
|
||||
document.body.appendChild($el);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`,
|
||||
contentType,
|
||||
oodsDevice,
|
||||
filename,
|
||||
filestashServerLocation, key,
|
||||
filetype,
|
||||
key,
|
||||
filestashServerLocation,
|
||||
oodsMode,
|
||||
userId,
|
||||
username,
|
||||
)))
|
||||
}
|
||||
|
||||
func FetchContentHandler(res http.ResponseWriter, req *http.Request) {
|
||||
var key string
|
||||
if key = req.URL.Query().Get("key"); key == "" {
|
||||
SendErrorResult(res, NewError("unspecified key", http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
c, found := OnlyOfficeCache.Get(key)
|
||||
if found == false {
|
||||
res.WriteHeader(http.StatusInternalServerError)
|
||||
res.Write([]byte(`{"error": 1, "message": "missing data fetcher handler"}`))
|
||||
return
|
||||
}
|
||||
cData, valid := c.(*OnlyOfficeCacheData)
|
||||
if valid == false {
|
||||
res.WriteHeader(http.StatusInternalServerError)
|
||||
res.Write([]byte(`{"error": 1, "message": "invalid cache"}`))
|
||||
return
|
||||
}
|
||||
f, err := cData.Cat(cData.Path)
|
||||
if err != nil {
|
||||
res.WriteHeader(http.StatusNotFound)
|
||||
res.Write([]byte(`{"error": 1, "message": "error while fetching data"}`))
|
||||
return
|
||||
}
|
||||
io.Copy(res, f)
|
||||
f.Close()
|
||||
}
|
||||
|
||||
type OnlyOfficeEventObject struct {
|
||||
Actions []struct {
|
||||
Type int `json: "type"`
|
||||
UserId string `json: "userid" `
|
||||
} `json: "actions"`
|
||||
ChangesURL string `json: "changesurl"`
|
||||
Forcesavetype int `json: "forcesavetype"`
|
||||
History struct {
|
||||
ServerVersion string `json: "serverVersion"`
|
||||
Changes []struct {
|
||||
Created string `json: "created"`
|
||||
User struct {
|
||||
Id string `json: "id"`
|
||||
Name string `json: "name"`
|
||||
}
|
||||
} `json: "changes"`
|
||||
} `json: "history"`
|
||||
Key string `json: "key"`
|
||||
Status int `json: "status"`
|
||||
Url string `json: "url"`
|
||||
UserData string `json: "userdata"`
|
||||
Lastsave string `json: "lastsave"`
|
||||
Users []string `json: "users"`
|
||||
}
|
||||
|
||||
func OnlyOfficeEventHandler(res http.ResponseWriter, req *http.Request) {
|
||||
event := OnlyOfficeEventObject{}
|
||||
if err := json.NewDecoder(req.Body).Decode(&event); err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
}
|
||||
req.Body.Close()
|
||||
|
||||
switch event.Status {
|
||||
case 0: Log.Warning("[onlyoffice] no document with the key identifier could be found. %+v", event)
|
||||
case 1:
|
||||
// document is being edited
|
||||
case 2:
|
||||
// document is ready for saving
|
||||
case 3:
|
||||
// document saving error has occurred
|
||||
Log.Warning("[onlyoffice] document saving error has occurred. %+v", event)
|
||||
case 4:
|
||||
// document is closed with no changes
|
||||
case 5:
|
||||
Log.Warning("[onlyoffice] undocumented status. %+v", event)
|
||||
case 6: // document is being edited, but the current document state is saved
|
||||
saveObject, found := OnlyOfficeCache.Get(event.Key);
|
||||
if found == false {
|
||||
res.WriteHeader(http.StatusInternalServerError)
|
||||
res.Write([]byte(`{"error": 1, "message": "doens't know where to store the given data"}`))
|
||||
return
|
||||
}
|
||||
cData, valid := saveObject.(*OnlyOfficeCacheData)
|
||||
if valid == false {
|
||||
res.WriteHeader(http.StatusInternalServerError)
|
||||
res.Write([]byte(`{"error": 1, "message": "[internal error] invalid save handler"}`))
|
||||
return
|
||||
}
|
||||
|
||||
r, err := http.NewRequest("GET", event.Url, nil)
|
||||
if err != nil {
|
||||
res.WriteHeader(http.StatusInternalServerError)
|
||||
res.Write([]byte(`{"error": 1, "message": "couldn't fetch the document on the oods server"}`))
|
||||
return
|
||||
}
|
||||
f, err := HTTPClient.Do(r)
|
||||
if err = cData.Save(cData.Path, f.Body); err != nil {
|
||||
res.WriteHeader(http.StatusInternalServerError)
|
||||
res.Write([]byte(`{"error": 1, "message": "error while saving the document"}`))
|
||||
return
|
||||
}
|
||||
f.Body.Close()
|
||||
case 7: Log.Warning("[onlyoffice] error has occurred while force saving the document. %+v", event)
|
||||
default: Log.Warning("[onlyoffice] undocumented status. %+v", event)
|
||||
}
|
||||
res.Write([]byte(`{"error": 0}`))
|
||||
}
|
||||
Loading…
Reference in a new issue