mirror of
https://github.com/mickael-kerjean/filestash.git
synced 2025-11-03 21:17:33 +08:00
611 lines
18 KiB
Go
611 lines
18 KiB
Go
package plg_editor_onlyoffice
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
. "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/gorilla/mux"
|
|
"github.com/patrickmn/go-cache"
|
|
)
|
|
|
|
var (
|
|
SECRET_KEY_DERIVATE_FOR_ONLYOFFICE string
|
|
|
|
onlyoffice_cache *cache.Cache
|
|
plugin_enable func() bool
|
|
server_url func() string
|
|
can_chat func() bool
|
|
can_copy func() bool
|
|
can_comment func() bool
|
|
can_download func() bool
|
|
can_edit func() bool
|
|
can_print func() bool
|
|
)
|
|
|
|
type onlyOfficeCacheData struct {
|
|
Path string
|
|
Save func(path string, file io.Reader) error
|
|
Cat func(path string) (io.ReadCloser, error)
|
|
}
|
|
|
|
func init() {
|
|
SECRET_KEY_DERIVATE_FOR_ONLYOFFICE = Hash("ONLYOFFICE_"+SECRET_KEY, len(SECRET_KEY))
|
|
onlyoffice_cache = cache.New(720*time.Minute, 720*time.Minute)
|
|
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", "onlyoffice_can_chat", "onlyoffice_can_copy", "onlyoffice_can_comment", "onlyoffice_can_download", "onlyoffice_can_edit", "onlyoffice_can_print"}
|
|
f.Description = "Enable/Disable the office suite and options to manage word, excel and powerpoint documents."
|
|
f.Default = false
|
|
if u := os.Getenv("ONLYOFFICE_URL"); u != "" {
|
|
f.Default = true
|
|
}
|
|
return f
|
|
}).Bool()
|
|
}
|
|
|
|
server_url = func() string {
|
|
return 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 = "http://127.0.0.1:8080"
|
|
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
|
|
}).String()
|
|
}
|
|
|
|
can_chat = func() bool {
|
|
return Config.Get("features.office.can_chat").Schema(func(f *FormElement) *FormElement {
|
|
if f == nil {
|
|
f = &FormElement{}
|
|
}
|
|
f.Id = "onlyoffice_can_chat"
|
|
f.Name = "can_chat"
|
|
f.Type = "boolean"
|
|
f.Description = "Enable/Disable chat in onlyoffice"
|
|
f.Default = false
|
|
return f
|
|
}).Bool()
|
|
}
|
|
|
|
can_copy = func() bool {
|
|
return Config.Get("features.office.can_copy").Schema(func(f *FormElement) *FormElement {
|
|
if f == nil {
|
|
f = &FormElement{}
|
|
}
|
|
f.Id = "onlyoffice_can_copy"
|
|
f.Name = "can_copy"
|
|
f.Type = "boolean"
|
|
f.Description = "Enable/Disable copy text in onlyoffice"
|
|
f.Default = false
|
|
return f
|
|
}).Bool()
|
|
}
|
|
|
|
can_comment = func() bool {
|
|
return Config.Get("features.office.can_comment").Schema(func(f *FormElement) *FormElement {
|
|
if f == nil {
|
|
f = &FormElement{}
|
|
}
|
|
f.Id = "onlyoffice_can_comment"
|
|
f.Name = "can_comment"
|
|
f.Type = "boolean"
|
|
f.Description = "Enable/Disable comments in onlyoffice"
|
|
f.Default = false
|
|
return f
|
|
}).Bool()
|
|
}
|
|
|
|
can_edit = func() bool {
|
|
return Config.Get("features.office.can_edit").Schema(func(f *FormElement) *FormElement {
|
|
if f == nil {
|
|
f = &FormElement{}
|
|
}
|
|
f.Id = "onlyoffice_can_edit"
|
|
f.Name = "can_edit"
|
|
f.Type = "boolean"
|
|
f.Description = "Enable/Disable editing in onlyoffice"
|
|
f.Default = true
|
|
return f
|
|
}).Bool()
|
|
}
|
|
|
|
can_download = func() bool {
|
|
return Config.Get("features.office.can_download").Schema(func(f *FormElement) *FormElement {
|
|
if f == nil {
|
|
f = &FormElement{}
|
|
}
|
|
f.Id = "onlyoffice_can_download"
|
|
f.Name = "can_download"
|
|
f.Type = "boolean"
|
|
f.Description = "Display Download button in onlyoffice"
|
|
f.Default = true
|
|
return f
|
|
}).Bool()
|
|
}
|
|
can_print = func() bool {
|
|
return Config.Get("features.office.can_print").Schema(func(f *FormElement) *FormElement {
|
|
if f == nil {
|
|
f = &FormElement{}
|
|
}
|
|
f.Id = "onlyoffice_can_print"
|
|
f.Name = "can_print"
|
|
f.Type = "boolean"
|
|
f.Description = "Enable/Disable printing in onlyoffice"
|
|
f.Default = false
|
|
return f
|
|
}).Bool()
|
|
}
|
|
|
|
Hooks.Register.Onload(func() {
|
|
plugin_enable()
|
|
server_url()
|
|
can_chat()
|
|
can_copy()
|
|
can_comment()
|
|
can_download()
|
|
can_edit()
|
|
can_print()
|
|
})
|
|
|
|
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",
|
|
middleware.NewMiddlewareChain(
|
|
IframeContentHandler,
|
|
[]Middleware{middleware.SessionStart, middleware.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"}];
|
|
}
|
|
`)
|
|
}
|
|
|
|
func StaticHandler(res http.ResponseWriter, req *http.Request) {
|
|
if plugin_enable() == false {
|
|
return
|
|
}
|
|
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/onlyoffice/static")
|
|
u, err := url.Parse(server_url())
|
|
if err != nil {
|
|
SendErrorResult(res, err)
|
|
return
|
|
}
|
|
req.Header.Set("X-Forwarded-Host", req.Host+"/onlyoffice/static")
|
|
req.Header.Set("X-Forwarded-Proto", func() string {
|
|
if scheme := req.Header.Get("X-Forwarded-Proto"); scheme != "" {
|
|
return scheme
|
|
} else if req.TLS != nil {
|
|
return "https"
|
|
}
|
|
return "http"
|
|
}())
|
|
|
|
// This code is a copy and paste from httputil.NewSingleHostReverseProxy with 1 single change
|
|
// to do SSL termination.
|
|
reverseProxy := &httputil.ReverseProxy{
|
|
Director: func(rq *http.Request) {
|
|
rq.URL.Scheme = "http" // <- this is the only change from NewSingleHostReverseProxy
|
|
rq.URL.Host = u.Host
|
|
rq.URL.Path = func(a, b string) string {
|
|
aslash := strings.HasSuffix(a, "/")
|
|
bslash := strings.HasPrefix(b, "/")
|
|
switch {
|
|
case aslash && bslash:
|
|
return a + b[1:]
|
|
case !aslash && !bslash:
|
|
return a + "/" + b
|
|
}
|
|
return a + b
|
|
}(u.Path, rq.URL.Path)
|
|
if u.RawQuery == "" || rq.URL.RawQuery == "" {
|
|
rq.URL.RawQuery = u.RawQuery + rq.URL.RawQuery
|
|
} else {
|
|
rq.URL.RawQuery = u.RawQuery + "&" + rq.URL.RawQuery
|
|
}
|
|
},
|
|
}
|
|
reverseProxy.ErrorHandler = func(rw http.ResponseWriter, rq *http.Request, err error) {
|
|
Log.Warning("[onlyoffice] %s", err.Error())
|
|
SendErrorResult(rw, NewError(err.Error(), http.StatusBadGateway))
|
|
}
|
|
reverseProxy.ServeHTTP(res, req)
|
|
}
|
|
|
|
func IframeContentHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
|
|
if plugin_enable() == false {
|
|
Log.Warning("plg_editor_onlyoffice::handler request_disabled")
|
|
return
|
|
}
|
|
if model.CanRead(ctx) == false {
|
|
SendErrorResult(res, ErrPermissionDenied)
|
|
return
|
|
} else if server_url() == "" {
|
|
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.Session)
|
|
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 ""
|
|
}
|
|
|
|
maybeips := []string{}
|
|
for _, address := range addrs {
|
|
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
|
if ipnet.IP.To4() != nil {
|
|
maybeips = append(maybeips, ipnet.IP.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// if there is just one interface, we can just pick that one
|
|
if len(maybeips) == 1 {
|
|
return maybeips[0]
|
|
}
|
|
|
|
// if not, fallback to capturing our outgoing local ip
|
|
conn, err := net.Dial("udp", "8.8.8.8:80")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer conn.Close()
|
|
|
|
localAddr := conn.LocalAddr().(*net.UDPAddr)
|
|
|
|
return localAddr.IP.String()
|
|
}()
|
|
filestashServerLocation = fmt.Sprintf(
|
|
"%s://%s:%d",
|
|
func() string { // proto
|
|
if req.TLS == nil {
|
|
return "http"
|
|
}
|
|
return "https"
|
|
}(),
|
|
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), ".")
|
|
onlyoffice_cache.Set(key, &onlyOfficeCacheData{path, ctx.Backend.Save, ctx.Backend.Cat}, cache.DefaultExpiration)
|
|
|
|
tmpl, err := template.New("onlyoffice").Parse(`<!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": "{{ .token }}",
|
|
"documentType": "{{ .contentType }}",
|
|
"type": "{{ .device }}",
|
|
"document": {
|
|
"title": "{{ .filename }}",
|
|
"url": "{{ .base }}/onlyoffice/content?key={{ .key }}",
|
|
"fileType": "{{ .filetype }}",
|
|
"key": "{{ .key }}",
|
|
"permissions": {
|
|
"chat": {{ .can_chat }},
|
|
"copy": {{ .can_copy }},
|
|
"comment": {{ .can_comment }},
|
|
"download": {{ .can_download }},
|
|
"edit": {{ .can_edit }},
|
|
"print": {{ .can_print }}
|
|
}
|
|
},
|
|
"editorConfig": {
|
|
"callbackUrl": "{{ .base }}/onlyoffice/event",
|
|
"mode": "{{ .mode }}",
|
|
"customization": {
|
|
"autosave": false,
|
|
"forcesave": true,
|
|
"compactHeader": true
|
|
},
|
|
"user": {
|
|
"id": "{{ .userID }}",
|
|
"name": "{{ .userName }}"
|
|
}
|
|
}
|
|
});
|
|
}
|
|
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>
|
|
`)
|
|
if err != nil {
|
|
res.Write([]byte(err.Error()))
|
|
return
|
|
}
|
|
if err := tmpl.Execute(res, map[string]interface{}{
|
|
"base": filestashServerLocation,
|
|
"can_chat": can_chat(),
|
|
"can_copy": can_copy(),
|
|
"can_comment": can_comment(),
|
|
"can_download": can_download(),
|
|
"can_edit": can_edit(),
|
|
"can_print": can_print(),
|
|
"contentType": contentType,
|
|
"device": oodsDevice,
|
|
"filename": filename,
|
|
"filetype": filetype,
|
|
"key": key,
|
|
"mode": oodsMode,
|
|
"token": "foobar",
|
|
"type": contentType,
|
|
"userID": userId,
|
|
"userName": username,
|
|
}); err != nil {
|
|
res.Write([]byte(err.Error()))
|
|
return
|
|
}
|
|
}
|
|
|
|
func FetchContentHandler(res http.ResponseWriter, req *http.Request) {
|
|
if plugin_enable() == false {
|
|
return
|
|
}
|
|
var key string
|
|
if key = req.URL.Query().Get("key"); key == "" {
|
|
SendErrorResult(res, NewError("unspecified key", http.StatusBadRequest))
|
|
return
|
|
}
|
|
c, found := onlyoffice_cache.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) {
|
|
if plugin_enable() == false {
|
|
return
|
|
}
|
|
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 := onlyoffice_cache.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}`))
|
|
}
|