浏览代码

web client: allow to preview images and pdf

pdf depends on browser support. It does not work on mobile devices.
Nicola Murino 3 年之前
父节点
当前提交
3f3591bae0

+ 2 - 1
httpd/api_http_user.go

@@ -155,7 +155,8 @@ func getUserFile(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	if status, err := downloadFile(w, r, connection, name, info); err != nil {
+	inline := r.URL.Query().Get("inline") != ""
+	if status, err := downloadFile(w, r, connection, name, info, inline); err != nil {
 		resp := apiResponse{
 		resp := apiResponse{
 			Error:   err.Error(),
 			Error:   err.Error(),
 			Message: http.StatusText(status),
 			Message: http.StatusText(status),

+ 6 - 2
httpd/api_utils.go

@@ -257,7 +257,9 @@ func getZipEntryName(entryPath, baseDir string) string {
 	return strings.TrimPrefix(entryPath, "/")
 	return strings.TrimPrefix(entryPath, "/")
 }
 }
 
 
-func downloadFile(w http.ResponseWriter, r *http.Request, connection *Connection, name string, info os.FileInfo) (int, error) {
+func downloadFile(w http.ResponseWriter, r *http.Request, connection *Connection, name string,
+	info os.FileInfo, inline bool,
+) (int, error) {
 	var err error
 	var err error
 	rangeHeader := r.Header.Get("Range")
 	rangeHeader := r.Header.Get("Range")
 	if rangeHeader != "" && checkIfRange(r, info.ModTime()) == condFalse {
 	if rangeHeader != "" && checkIfRange(r, info.ModTime()) == condFalse {
@@ -295,7 +297,9 @@ func downloadFile(w http.ResponseWriter, r *http.Request, connection *Connection
 	}
 	}
 	w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
 	w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
 	w.Header().Set("Content-Type", ctype)
 	w.Header().Set("Content-Type", ctype)
-	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%#v", path.Base(name)))
+	if !inline {
+		w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%#v", path.Base(name)))
+	}
 	w.Header().Set("Accept-Ranges", "bytes")
 	w.Header().Set("Accept-Ranges", "bytes")
 	w.WriteHeader(responseStatus)
 	w.WriteHeader(responseStatus)
 	if r.Method != http.MethodHead {
 	if r.Method != http.MethodHead {

+ 4 - 1
httpd/httpd.go

@@ -136,13 +136,14 @@ const (
 	webClientPubSharesPathDefault         = "/web/client/pubshares"
 	webClientPubSharesPathDefault         = "/web/client/pubshares"
 	webClientForgotPwdPathDefault         = "/web/client/forgot-password"
 	webClientForgotPwdPathDefault         = "/web/client/forgot-password"
 	webClientResetPwdPathDefault          = "/web/client/reset-password"
 	webClientResetPwdPathDefault          = "/web/client/reset-password"
+	webClientViewPDFPathDefault           = "/web/client/viewpdf"
 	webStaticFilesPathDefault             = "/static"
 	webStaticFilesPathDefault             = "/static"
 	webOpenAPIPathDefault                 = "/openapi"
 	webOpenAPIPathDefault                 = "/openapi"
 	// MaxRestoreSize defines the max size for the loaddata input file
 	// MaxRestoreSize defines the max size for the loaddata input file
 	MaxRestoreSize       = 10485760 // 10 MB
 	MaxRestoreSize       = 10485760 // 10 MB
 	maxRequestSize       = 1048576  // 1MB
 	maxRequestSize       = 1048576  // 1MB
 	maxLoginBodySize     = 262144   // 256 KB
 	maxLoginBodySize     = 262144   // 256 KB
-	httpdMaxEditFileSize = 524288   // 512 KB
+	httpdMaxEditFileSize = 1048576  // 1 MB
 	maxMultipartMem      = 8388608  // 8MB
 	maxMultipartMem      = 8388608  // 8MB
 	osWindows            = "windows"
 	osWindows            = "windows"
 	otpHeaderCode        = "X-SFTPGO-OTP"
 	otpHeaderCode        = "X-SFTPGO-OTP"
@@ -210,6 +211,7 @@ var (
 	webClientLogoutPath            string
 	webClientLogoutPath            string
 	webClientForgotPwdPath         string
 	webClientForgotPwdPath         string
 	webClientResetPwdPath          string
 	webClientResetPwdPath          string
+	webClientViewPDFPath           string
 	webStaticFilesPath             string
 	webStaticFilesPath             string
 	webOpenAPIPath                 string
 	webOpenAPIPath                 string
 	// max upload size for http clients, 1GB by default
 	// max upload size for http clients, 1GB by default
@@ -570,6 +572,7 @@ func updateWebClientURLs(baseURL string) {
 	webClientRecoveryCodesPath = path.Join(baseURL, webClientRecoveryCodesPathDefault)
 	webClientRecoveryCodesPath = path.Join(baseURL, webClientRecoveryCodesPathDefault)
 	webClientForgotPwdPath = path.Join(baseURL, webClientForgotPwdPathDefault)
 	webClientForgotPwdPath = path.Join(baseURL, webClientForgotPwdPathDefault)
 	webClientResetPwdPath = path.Join(baseURL, webClientResetPwdPathDefault)
 	webClientResetPwdPath = path.Join(baseURL, webClientResetPwdPathDefault)
+	webClientViewPDFPath = path.Join(baseURL, webClientViewPDFPathDefault)
 }
 }
 
 
 func updateWebAdminURLs(baseURL string) {
 func updateWebAdminURLs(baseURL string) {

+ 27 - 1
httpd/httpd_test.go

@@ -153,6 +153,7 @@ const (
 	webClientPubSharesPath          = "/web/client/pubshares"
 	webClientPubSharesPath          = "/web/client/pubshares"
 	webClientForgotPwdPath          = "/web/client/forgot-password"
 	webClientForgotPwdPath          = "/web/client/forgot-password"
 	webClientResetPwdPath           = "/web/client/reset-password"
 	webClientResetPwdPath           = "/web/client/reset-password"
+	webClientViewPDFPath            = "/web/client/viewpdf"
 	httpBaseURL                     = "http://127.0.0.1:8081"
 	httpBaseURL                     = "http://127.0.0.1:8081"
 	sftpServerAddr                  = "127.0.0.1:8022"
 	sftpServerAddr                  = "127.0.0.1:8022"
 	smtpServerAddr                  = "127.0.0.1:3525"
 	smtpServerAddr                  = "127.0.0.1:3525"
@@ -9320,13 +9321,38 @@ func TestUserAPIKey(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestWebClientViewPDF(t *testing.T) {
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+
+	webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+
+	req, err := http.NewRequest(http.MethodGet, webClientViewPDFPath, nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+
+	req, err = http.NewRequest(http.MethodGet, webClientViewPDFPath+"?path=test.pdf", nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestWebEditFile(t *testing.T) {
 func TestWebEditFile(t *testing.T) {
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	testFile1 := "testfile1.txt"
 	testFile1 := "testfile1.txt"
 	testFile2 := "testfile2"
 	testFile2 := "testfile2"
 	file1Size := int64(65536)
 	file1Size := int64(65536)
-	file2Size := int64(655360)
+	file2Size := int64(1048576 * 2)
 	err = createTestFile(filepath.Join(user.GetHomeDir(), testFile1), file1Size)
 	err = createTestFile(filepath.Join(user.GetHomeDir(), testFile1), file1Size)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	err = createTestFile(filepath.Join(user.GetHomeDir(), testFile2), file2Size)
 	err = createTestFile(filepath.Join(user.GetHomeDir(), testFile2), file2Size)

+ 2 - 2
httpd/server.go

@@ -1223,10 +1223,10 @@ func (s *httpdServer) initializeRouter() {
 
 
 			router.Get(webClientLogoutPath, handleWebClientLogout)
 			router.Get(webClientLogoutPath, handleWebClientLogout)
 			router.With(s.refreshCookie).Get(webClientFilesPath, handleClientGetFiles)
 			router.With(s.refreshCookie).Get(webClientFilesPath, handleClientGetFiles)
+			router.With(s.refreshCookie).Get(webClientViewPDFPath, handleClientViewPDF)
 			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Post(webClientFilesPath, uploadUserFiles)
 				Post(webClientFilesPath, uploadUserFiles)
-			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), s.refreshCookie).
-				Get(webClientEditFilePath, handleClientEditFile)
+			router.With(s.refreshCookie).Get(webClientEditFilePath, handleClientEditFile)
 			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Patch(webClientFilesPath, renameUserFile)
 				Patch(webClientFilesPath, renameUserFile)
 			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).

+ 36 - 3
httpd/webclient.go

@@ -44,6 +44,7 @@ const (
 	templateClientEditFile          = "editfile.html"
 	templateClientEditFile          = "editfile.html"
 	templateClientShare             = "share.html"
 	templateClientShare             = "share.html"
 	templateClientShares            = "shares.html"
 	templateClientShares            = "shares.html"
+	templateClientViewPDF           = "viewpdf.html"
 	pageClientFilesTitle            = "My Files"
 	pageClientFilesTitle            = "My Files"
 	pageClientSharesTitle           = "Shares"
 	pageClientSharesTitle           = "Shares"
 	pageClientProfileTitle          = "My Profile"
 	pageClientProfileTitle          = "My Profile"
@@ -99,11 +100,18 @@ type dirMapping struct {
 	Href    string
 	Href    string
 }
 }
 
 
+type viewPDFPage struct {
+	Title     string
+	URL       string
+	StaticURL string
+}
+
 type editFilePage struct {
 type editFilePage struct {
 	baseClientPage
 	baseClientPage
 	CurrentDir string
 	CurrentDir string
 	Path       string
 	Path       string
 	Name       string
 	Name       string
+	ReadOnly   bool
 	Data       string
 	Data       string
 }
 }
 
 
@@ -112,6 +120,7 @@ type filesPage struct {
 	CurrentDir    string
 	CurrentDir    string
 	DirsURL       string
 	DirsURL       string
 	DownloadURL   string
 	DownloadURL   string
+	ViewPDFURL    string
 	CanAddFiles   bool
 	CanAddFiles   bool
 	CanCreateDirs bool
 	CanCreateDirs bool
 	CanRename     bool
 	CanRename     bool
@@ -229,6 +238,9 @@ func loadClientTemplates(templatesPath string) {
 	resetPwdPaths := []string{
 	resetPwdPaths := []string{
 		filepath.Join(templatesPath, templateCommonDir, templateResetPassword),
 		filepath.Join(templatesPath, templateCommonDir, templateResetPassword),
 	}
 	}
+	viewPDFPaths := []string{
+		filepath.Join(templatesPath, templateClientDir, templateClientViewPDF),
+	}
 
 
 	filesTmpl := util.LoadTemplate(nil, filesPaths...)
 	filesTmpl := util.LoadTemplate(nil, filesPaths...)
 	profileTmpl := util.LoadTemplate(nil, profilePaths...)
 	profileTmpl := util.LoadTemplate(nil, profilePaths...)
@@ -243,6 +255,7 @@ func loadClientTemplates(templatesPath string) {
 	shareTmpl := util.LoadTemplate(nil, sharePaths...)
 	shareTmpl := util.LoadTemplate(nil, sharePaths...)
 	forgotPwdTmpl := util.LoadTemplate(nil, forgotPwdPaths...)
 	forgotPwdTmpl := util.LoadTemplate(nil, forgotPwdPaths...)
 	resetPwdTmpl := util.LoadTemplate(nil, resetPwdPaths...)
 	resetPwdTmpl := util.LoadTemplate(nil, resetPwdPaths...)
+	viewPDFTmpl := util.LoadTemplate(nil, viewPDFPaths...)
 
 
 	clientTemplates[templateClientFiles] = filesTmpl
 	clientTemplates[templateClientFiles] = filesTmpl
 	clientTemplates[templateClientProfile] = profileTmpl
 	clientTemplates[templateClientProfile] = profileTmpl
@@ -257,6 +270,7 @@ func loadClientTemplates(templatesPath string) {
 	clientTemplates[templateClientShare] = shareTmpl
 	clientTemplates[templateClientShare] = shareTmpl
 	clientTemplates[templateForgotPassword] = forgotPwdTmpl
 	clientTemplates[templateForgotPassword] = forgotPwdTmpl
 	clientTemplates[templateResetPassword] = resetPwdTmpl
 	clientTemplates[templateResetPassword] = resetPwdTmpl
+	clientTemplates[templateClientViewPDF] = viewPDFTmpl
 }
 }
 
 
 func getBaseClientPageData(title, currentURL string, r *http.Request) baseClientPage {
 func getBaseClientPageData(title, currentURL string, r *http.Request) baseClientPage {
@@ -391,12 +405,13 @@ func renderClientMFAPage(w http.ResponseWriter, r *http.Request) {
 	renderClientTemplate(w, templateClientMFA, data)
 	renderClientTemplate(w, templateClientMFA, data)
 }
 }
 
 
-func renderEditFilePage(w http.ResponseWriter, r *http.Request, fileName, fileData string) {
+func renderEditFilePage(w http.ResponseWriter, r *http.Request, fileName, fileData string, readOnly bool) {
 	data := editFilePage{
 	data := editFilePage{
 		baseClientPage: getBaseClientPageData(pageClientEditFileTitle, webClientEditFilePath, r),
 		baseClientPage: getBaseClientPageData(pageClientEditFileTitle, webClientEditFilePath, r),
 		Path:           fileName,
 		Path:           fileName,
 		Name:           path.Base(fileName),
 		Name:           path.Base(fileName),
 		CurrentDir:     path.Dir(fileName),
 		CurrentDir:     path.Dir(fileName),
+		ReadOnly:       readOnly,
 		Data:           fileData,
 		Data:           fileData,
 	}
 	}
 
 
@@ -427,6 +442,7 @@ func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error stri
 		Error:          error,
 		Error:          error,
 		CurrentDir:     url.QueryEscape(dirName),
 		CurrentDir:     url.QueryEscape(dirName),
 		DownloadURL:    webClientDownloadZipPath,
 		DownloadURL:    webClientDownloadZipPath,
+		ViewPDFURL:     webClientViewPDFPath,
 		DirsURL:        webClientDirsPath,
 		DirsURL:        webClientDirsPath,
 		CanAddFiles:    user.CanAddFilesFromWeb(dirName),
 		CanAddFiles:    user.CanAddFilesFromWeb(dirName),
 		CanCreateDirs:  user.CanAddDirsFromWeb(dirName),
 		CanCreateDirs:  user.CanAddDirsFromWeb(dirName),
@@ -650,7 +666,8 @@ func handleClientGetFiles(w http.ResponseWriter, r *http.Request) {
 		renderFilesPage(w, r, name, "", user)
 		renderFilesPage(w, r, name, "", user)
 		return
 		return
 	}
 	}
-	if status, err := downloadFile(w, r, connection, name, info); err != nil && status != 0 {
+	inline := r.URL.Query().Get("inline") != ""
+	if status, err := downloadFile(w, r, connection, name, info, inline); err != nil && status != 0 {
 		if status > 0 {
 		if status > 0 {
 			if status == http.StatusRequestedRangeNotSatisfiable {
 			if status == http.StatusRequestedRangeNotSatisfiable {
 				renderClientMessagePage(w, r, http.StatusText(status), "", status, err, "")
 				renderClientMessagePage(w, r, http.StatusText(status), "", status, err, "")
@@ -723,7 +740,7 @@ func handleClientEditFile(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	renderEditFilePage(w, r, name, b.String())
+	renderEditFilePage(w, r, name, b.String(), util.IsStringInSlice(sdk.WebClientWriteDisabled, user.Filters.WebClient))
 }
 }
 
 
 func handleClientAddShareGet(w http.ResponseWriter, r *http.Request) {
 func handleClientAddShareGet(w http.ResponseWriter, r *http.Request) {
@@ -1035,3 +1052,19 @@ func handleWebClientPasswordReset(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 	renderClientResetPwdPage(w, "")
 	renderClientResetPwdPage(w, "")
 }
 }
+
+func handleClientViewPDF(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
+	name := r.URL.Query().Get("path")
+	if name == "" {
+		renderClientBadRequestPage(w, r, errors.New("no file specified"))
+		return
+	}
+	name = util.CleanPath(name)
+	data := viewPDFPage{
+		Title:     path.Base(name),
+		URL:       fmt.Sprintf("%v?path=%v&inline=1", webClientFilesPath, url.QueryEscape(name)),
+		StaticURL: webStaticFilesPath,
+	}
+	renderClientTemplate(w, templateClientViewPDF, data)
+}

+ 6 - 0
openapi/openapi.yaml

@@ -3546,6 +3546,12 @@ paths:
           description: Path to the file to download. It must be URL encoded, for example the path "my dir/àdir/file.txt" must be sent as "my%20dir%2F%C3%A0dir%2Ffile.txt"
           description: Path to the file to download. It must be URL encoded, for example the path "my dir/àdir/file.txt" must be sent as "my%20dir%2F%C3%A0dir%2Ffile.txt"
           schema:
           schema:
             type: string
             type: string
+        - in: query
+          name: inline
+          required: false
+          description: 'If set, the response will not have the Content-Disposition header set to `attachment`'
+          schema:
+            type: string
       responses:
       responses:
         '200':
         '200':
           description: successful operation
           description: successful operation

+ 20 - 11
static/vendor/codemirror/addon/search/searchcursor.js

@@ -202,6 +202,7 @@
 
 
   function SearchCursor(doc, query, pos, options) {
   function SearchCursor(doc, query, pos, options) {
     this.atOccurrence = false
     this.atOccurrence = false
+    this.afterEmptyMatch = false
     this.doc = doc
     this.doc = doc
     pos = pos ? doc.clipPos(pos) : Pos(0, 0)
     pos = pos ? doc.clipPos(pos) : Pos(0, 0)
     this.pos = {from: pos, to: pos}
     this.pos = {from: pos, to: pos}
@@ -237,21 +238,29 @@
     findPrevious: function() {return this.find(true)},
     findPrevious: function() {return this.find(true)},
 
 
     find: function(reverse) {
     find: function(reverse) {
-      var result = this.matches(reverse, this.doc.clipPos(reverse ? this.pos.from : this.pos.to))
-
-      // Implements weird auto-growing behavior on null-matches for
-      // backwards-compatibility with the vim code (unfortunately)
-      while (result && CodeMirror.cmpPos(result.from, result.to) == 0) {
+      var head = this.doc.clipPos(reverse ? this.pos.from : this.pos.to);
+      if (this.afterEmptyMatch && this.atOccurrence) {
+        // do not return the same 0 width match twice
+        head = Pos(head.line, head.ch)
         if (reverse) {
         if (reverse) {
-          if (result.from.ch) result.from = Pos(result.from.line, result.from.ch - 1)
-          else if (result.from.line == this.doc.firstLine()) result = null
-          else result = this.matches(reverse, this.doc.clipPos(Pos(result.from.line - 1)))
+          head.ch--;
+          if (head.ch < 0) {
+            head.line--;
+            head.ch = (this.doc.getLine(head.line) || "").length;
+          }
         } else {
         } else {
-          if (result.to.ch < this.doc.getLine(result.to.line).length) result.to = Pos(result.to.line, result.to.ch + 1)
-          else if (result.to.line == this.doc.lastLine()) result = null
-          else result = this.matches(reverse, Pos(result.to.line + 1, 0))
+          head.ch++;
+          if (head.ch > (this.doc.getLine(head.line) || "").length) {
+            head.ch = 0;
+            head.line++;
+          }
+        }
+        if (CodeMirror.cmpPos(head, this.doc.clipPos(head)) != 0) {
+           return this.atOccurrence = false
         }
         }
       }
       }
+      var result = this.matches(reverse, head)
+      this.afterEmptyMatch = result && CodeMirror.cmpPos(result.from, result.to) == 0
 
 
       if (result) {
       if (result) {
         this.pos = result
         this.pos = result

+ 7 - 13
static/vendor/codemirror/codemirror.css

@@ -60,19 +60,13 @@
 .cm-fat-cursor div.CodeMirror-cursors {
 .cm-fat-cursor div.CodeMirror-cursors {
   z-index: 1;
   z-index: 1;
 }
 }
-.cm-fat-cursor-mark {
-  background-color: rgba(20, 255, 20, 0.5);
-  -webkit-animation: blink 1.06s steps(1) infinite;
-  -moz-animation: blink 1.06s steps(1) infinite;
-  animation: blink 1.06s steps(1) infinite;
-}
-.cm-animate-fat-cursor {
-  width: auto;
-  -webkit-animation: blink 1.06s steps(1) infinite;
-  -moz-animation: blink 1.06s steps(1) infinite;
-  animation: blink 1.06s steps(1) infinite;
-  background-color: #7e7;
-}
+.cm-fat-cursor .CodeMirror-line::selection,
+.cm-fat-cursor .CodeMirror-line > span::selection, 
+.cm-fat-cursor .CodeMirror-line > span > span::selection { background: transparent; }
+.cm-fat-cursor .CodeMirror-line::-moz-selection,
+.cm-fat-cursor .CodeMirror-line > span::-moz-selection,
+.cm-fat-cursor .CodeMirror-line > span > span::-moz-selection { background: transparent; }
+.cm-fat-cursor { caret-color: transparent; }
 @-moz-keyframes blink {
 @-moz-keyframes blink {
   0% {}
   0% {}
   50% { background-color: transparent; }
   50% { background-color: transparent; }

+ 22 - 14
static/vendor/codemirror/codemirror.js

@@ -2351,12 +2351,14 @@
   function mapFromLineView(lineView, line, lineN) {
   function mapFromLineView(lineView, line, lineN) {
     if (lineView.line == line)
     if (lineView.line == line)
       { return {map: lineView.measure.map, cache: lineView.measure.cache} }
       { return {map: lineView.measure.map, cache: lineView.measure.cache} }
-    for (var i = 0; i < lineView.rest.length; i++)
-      { if (lineView.rest[i] == line)
-        { return {map: lineView.measure.maps[i], cache: lineView.measure.caches[i]} } }
-    for (var i$1 = 0; i$1 < lineView.rest.length; i$1++)
-      { if (lineNo(lineView.rest[i$1]) > lineN)
-        { return {map: lineView.measure.maps[i$1], cache: lineView.measure.caches[i$1], before: true} } }
+    if (lineView.rest) {
+      for (var i = 0; i < lineView.rest.length; i++)
+        { if (lineView.rest[i] == line)
+          { return {map: lineView.measure.maps[i], cache: lineView.measure.caches[i]} } }
+      for (var i$1 = 0; i$1 < lineView.rest.length; i$1++)
+        { if (lineNo(lineView.rest[i$1]) > lineN)
+          { return {map: lineView.measure.maps[i$1], cache: lineView.measure.caches[i$1], before: true} } }
+    }
   }
   }
 
 
   // Render a line into the hidden node display.externalMeasured. Used
   // Render a line into the hidden node display.externalMeasured. Used
@@ -3150,13 +3152,19 @@
     var curFragment = result.cursors = document.createDocumentFragment();
     var curFragment = result.cursors = document.createDocumentFragment();
     var selFragment = result.selection = document.createDocumentFragment();
     var selFragment = result.selection = document.createDocumentFragment();
 
 
+    var customCursor = cm.options.$customCursor;
+    if (customCursor) { primary = true; }
     for (var i = 0; i < doc.sel.ranges.length; i++) {
     for (var i = 0; i < doc.sel.ranges.length; i++) {
       if (!primary && i == doc.sel.primIndex) { continue }
       if (!primary && i == doc.sel.primIndex) { continue }
       var range = doc.sel.ranges[i];
       var range = doc.sel.ranges[i];
       if (range.from().line >= cm.display.viewTo || range.to().line < cm.display.viewFrom) { continue }
       if (range.from().line >= cm.display.viewTo || range.to().line < cm.display.viewFrom) { continue }
       var collapsed = range.empty();
       var collapsed = range.empty();
-      if (collapsed || cm.options.showCursorWhenSelecting)
-        { drawSelectionCursor(cm, range.head, curFragment); }
+      if (customCursor) {
+        var head = customCursor(cm, range);
+        if (head) { drawSelectionCursor(cm, head, curFragment); }
+      } else if (collapsed || cm.options.showCursorWhenSelecting) {
+        drawSelectionCursor(cm, range.head, curFragment);
+      }
       if (!collapsed)
       if (!collapsed)
         { drawSelectionRange(cm, range, selFragment); }
         { drawSelectionRange(cm, range, selFragment); }
     }
     }
@@ -3174,9 +3182,8 @@
 
 
     if (/\bcm-fat-cursor\b/.test(cm.getWrapperElement().className)) {
     if (/\bcm-fat-cursor\b/.test(cm.getWrapperElement().className)) {
       var charPos = charCoords(cm, head, "div", null, null);
       var charPos = charCoords(cm, head, "div", null, null);
-      if (charPos.right - charPos.left > 0) {
-        cursor.style.width = (charPos.right - charPos.left) + "px";
-      }
+      var width = charPos.right - charPos.left;
+      cursor.style.width = (width > 0 ? width : cm.defaultCharWidth()) + "px";
     }
     }
 
 
     if (pos.other) {
     if (pos.other) {
@@ -3649,6 +3656,7 @@
       this.vert.firstChild.style.height =
       this.vert.firstChild.style.height =
         Math.max(0, measure.scrollHeight - measure.clientHeight + totalHeight) + "px";
         Math.max(0, measure.scrollHeight - measure.clientHeight + totalHeight) + "px";
     } else {
     } else {
+      this.vert.scrollTop = 0;
       this.vert.style.display = "";
       this.vert.style.display = "";
       this.vert.firstChild.style.height = "0";
       this.vert.firstChild.style.height = "0";
     }
     }
@@ -4501,7 +4509,7 @@
   function onScrollWheel(cm, e) {
   function onScrollWheel(cm, e) {
     var delta = wheelEventDelta(e), dx = delta.x, dy = delta.y;
     var delta = wheelEventDelta(e), dx = delta.x, dy = delta.y;
     var pixelsPerUnit = wheelPixelsPerUnit;
     var pixelsPerUnit = wheelPixelsPerUnit;
-    if (event.deltaMode === 0) {
+    if (e.deltaMode === 0) {
       dx = e.deltaX;
       dx = e.deltaX;
       dy = e.deltaY;
       dy = e.deltaY;
       pixelsPerUnit = 1;
       pixelsPerUnit = 1;
@@ -8235,7 +8243,7 @@
   }
   }
 
 
   function hiddenTextarea() {
   function hiddenTextarea() {
-    var te = elt("textarea", null, null, "position: absolute; bottom: -1em; padding: 0; width: 1px; height: 1em; outline: none");
+    var te = elt("textarea", null, null, "position: absolute; bottom: -1em; padding: 0; width: 1px; height: 1em; min-height: 1em; outline: none");
     var div = elt("div", [te], null, "overflow: hidden; position: relative; width: 3px; height: 0px;");
     var div = elt("div", [te], null, "overflow: hidden; position: relative; width: 3px; height: 0px;");
     // The textarea is kept positioned near the cursor to prevent the
     // The textarea is kept positioned near the cursor to prevent the
     // fact that it'll be scrolled into view on input from scrolling
     // fact that it'll be scrolled into view on input from scrolling
@@ -9832,7 +9840,7 @@
 
 
   addLegacyProps(CodeMirror);
   addLegacyProps(CodeMirror);
 
 
-  CodeMirror.version = "5.63.1";
+  CodeMirror.version = "5.64.0";
 
 
   return CodeMirror;
   return CodeMirror;
 
 

文件差异内容过多而无法显示
+ 0 - 0
static/vendor/lightbox2/css/lightbox.min.css


二进制
static/vendor/lightbox2/images/close.png


二进制
static/vendor/lightbox2/images/loading.gif


二进制
static/vendor/lightbox2/images/next.png


二进制
static/vendor/lightbox2/images/prev.png


文件差异内容过多而无法显示
+ 13 - 0
static/vendor/lightbox2/js/lightbox.min.js


文件差异内容过多而无法显示
+ 8 - 0
static/vendor/pdfobject/pdfobject.min.js


+ 7 - 0
templates/webclient/editfile.html

@@ -36,7 +36,9 @@
             <span class="font-weight-bold text-primary">Edit file "{{.Path}}"</span>
             <span class="font-weight-bold text-primary">Edit file "{{.Path}}"</span>
             <span class="btn-toolbar">
             <span class="btn-toolbar">
                 <a id="idBack" class="btn btn-secondary mx-1 my-1" href='{{.FilesURL}}?path={{.CurrentDir}}' role="button">Back</a>
                 <a id="idBack" class="btn btn-secondary mx-1 my-1" href='{{.FilesURL}}?path={{.CurrentDir}}' role="button">Back</a>
+                {{if not .ReadOnly}}
                 <a id="idSave" class="btn btn-primary mx-1 my-1" href="#" onclick="saveFile()" role="button">Save</a>
                 <a id="idSave" class="btn btn-primary mx-1 my-1" href="#" onclick="saveFile()" role="button">Save</a>
+                {{end}}
             </span>
             </span>
         </h6>
         </h6>
     </div>
     </div>
@@ -107,6 +109,9 @@
             lineNumbers: true,
             lineNumbers: true,
             styleActiveLine: true,
             styleActiveLine: true,
             extraKeys: {"Alt-F": "findPersistent"},
             extraKeys: {"Alt-F": "findPersistent"},
+            {{if .ReadOnly}}
+            readOnly: true,
+            {{end}}
             autofocus: true
             autofocus: true
         });
         });
         var filename = "{{.Path}}";
         var filename = "{{.Path}}";
@@ -126,6 +131,7 @@
         });
         });
     }
     }
 
 
+    {{if not .ReadOnly}}
     function saveFile() {
     function saveFile() {
         $('#idSave').addClass("disabled");
         $('#idSave').addClass("disabled");
         cm =  document.querySelector('.CodeMirror').CodeMirror;
         cm =  document.querySelector('.CodeMirror').CodeMirror;
@@ -167,5 +173,6 @@
                 }
                 }
             });
             });
     }
     }
+    {{end}}
 </script>
 </script>
 {{end}}
 {{end}}

+ 29 - 6
templates/webclient/files.html

@@ -8,6 +8,7 @@
 <link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
 <link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
 <link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
 <link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
 <link href="{{.StaticURL}}/vendor/datatables/dataTables.checkboxes.css" rel="stylesheet">
 <link href="{{.StaticURL}}/vendor/datatables/dataTables.checkboxes.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/lightbox2/css/lightbox.min.css" rel="stylesheet">
 <style>
 <style>
     div.dataTables_wrapper span.selected-info,
     div.dataTables_wrapper span.selected-info,
     div.dataTables_wrapper span.selected-item {
     div.dataTables_wrapper span.selected-item {
@@ -177,10 +178,10 @@
 <script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
 <script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
 <script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
 <script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
 <script src="{{.StaticURL}}/vendor/datatables/dataTables.checkboxes.min.js"></script>
 <script src="{{.StaticURL}}/vendor/datatables/dataTables.checkboxes.min.js"></script>
-{{if .CanAddFiles}}
+<script src="{{.StaticURL}}/vendor/lightbox2/js/lightbox.min.js"></script>
+<script src="{{.StaticURL}}/vendor/pdfobject/pdfobject.min.js"></script>
 <script src="{{.StaticURL}}/vendor/codemirror/codemirror.js"></script>
 <script src="{{.StaticURL}}/vendor/codemirror/codemirror.js"></script>
 <script src="{{.StaticURL}}/vendor/codemirror/meta.js"></script>
 <script src="{{.StaticURL}}/vendor/codemirror/meta.js"></script>
-{{end}}
 <script type="text/javascript">
 <script type="text/javascript">
 
 
     function getIconForFile(filename) {
     function getIconForFile(filename) {
@@ -613,15 +614,37 @@
                 { "data": "edit_url",
                 { "data": "edit_url",
                     "render": function (data, type, row) {
                     "render": function (data, type, row) {
                         if (type === 'display') {
                         if (type === 'display') {
-                            {{if .CanAddFiles}}
+                            var filename = row["name"];
+                            var extension = filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase();
                             if (data != ""){
                             if (data != ""){
-                                var filename = row["name"];
-                                var extension = filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase();
                                 if (extension == "csv" || extension == "bat" || CodeMirror.findModeByExtension(extension) != null){
                                 if (extension == "csv" || extension == "bat" || CodeMirror.findModeByExtension(extension) != null){
+                                    {{if .CanAddFiles}}
                                     return `<a href="${data}"><i class="fas fa-edit"></i></a>`;
                                     return `<a href="${data}"><i class="fas fa-edit"></i></a>`;
+                                    {{else}}
+                                    return `<a href="${data}"><i class="fas fa-eye"></i></a>`;
+                                    {{end}}
+                                }
+                            }
+                            if (row["type"] == "2") {
+                                switch (extension) {
+                                    case "jpeg":
+                                    case "jpg":
+                                    case "png":
+                                    case "gif":
+                                    case "webp":
+                                    case "bmp":
+                                    case "svg":
+                                    case "ico":
+                                        var view_url = row['url']+"&inline=1";
+                                        return `<a href="${view_url}" data-lightbox="${filename}" data-title="${filename}"><i class="fas fa-eye"></i></a>`;
+                                    case "pdf":
+                                        if (PDFObject.supportsPDFs){
+                                            var view_url = row['url'];
+                                            view_url = view_url.replace('{{.FilesURL}}','{{.ViewPDFURL}}');
+                                            return `<a href="${view_url}" target="_blank"><i class="fas fa-eye"></i></a>`;
+                                        }
                                 }
                                 }
                             }
                             }
-                            {{end}}
                         }
                         }
                         return "";
                         return "";
                     }
                     }

+ 15 - 0
templates/webclient/viewpdf.html

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="UTF-8" />
+        <meta name="viewport" content="width=device-width, initial-scale=1" />
+        <title>{{.Title}}</title>
+    </head>
+
+<body>
+    <script src="{{.StaticURL}}/vendor/pdfobject/pdfobject.min.js"></script>
+    <script type="text/javascript">
+        PDFObject.embed("{{.URL}}", document.body);
+    </script>
+</body>
+</html>

部分文件因为文件数量过多而无法显示