1
0

2 Коммиты d5c6747856 ... 373ec1fdbe

Автор SHA1 Сообщение Дата
  Toby Chui 373ec1fdbe Overhaul file sharing with secure multi-link 1 неделя назад
  Toby Chui 8b7229ada7 Fix docs panel placement and footnote backspace 1 неделя назад
32 измененных файлов с 6614 добавлено и 1889 удалено
  1. 5 7
      src/file_system.go
  2. 4 1
      src/mod/agi/README.md
  3. 10 9
      src/mod/agi/agi.share.go
  4. 389 0
      src/mod/share/access.go
  5. 639 0
      src/mod/share/api.go
  6. 349 0
      src/mod/share/manager.go
  7. 315 0
      src/mod/share/manager_test.go
  8. 135 0
      src/mod/share/notify.go
  9. 109 0
      src/mod/share/notify_test.go
  10. 124 768
      src/mod/share/share.go
  11. 232 126
      src/mod/share/shareEntry/shareEntry.go
  12. 7 10
      src/mod/share/shareEntry/shareEntry_test.go
  13. 234 3
      src/mod/share/shareEntry/shareOptions.go
  14. 387 0
      src/mod/share/shareEntry/shareOptions_test.go
  15. 195 0
      src/mod/share/upload.go
  16. 4 2
      src/notification_settings.go
  17. 16 0
      src/system/share/downloadPage.html
  18. 147 12
      src/system/share/downloadPageFolder.html
  19. 293 0
      src/system/share/password.html
  20. 4 4
      src/web/Office/docs/docs.css
  21. 43 0
      src/web/Office/docs/docs.js
  22. 26 0
      src/web/SystemAO/file_system/file_explorer.css
  23. 10 17
      src/web/SystemAO/file_system/file_explorer.html
  24. 1008 0
      src/web/SystemAO/file_system/file_share.css
  25. 242 612
      src/web/SystemAO/file_system/file_share.html
  26. 73 97
      src/web/SystemAO/file_system/js/explorer/share.js
  27. 0 3
      src/web/SystemAO/file_system/js/explorer/state.js
  28. 0 3
      src/web/SystemAO/file_system/js/explorer/ui.js
  29. 1071 0
      src/web/SystemAO/file_system/js/file_share.js
  30. 509 199
      src/web/SystemAO/locale/file_share.json
  31. 1 1
      src/web/Terminal/docs/api.json
  32. 33 15
      src/web/desktop.html

+ 5 - 7
src/file_system.go

@@ -258,14 +258,12 @@ func FileSystemInit() {
 		UserHandler:     userHandler,
 		UserHandler:     userHandler,
 		HostName:        *host_name,
 		HostName:        *host_name,
 		TmpFolder:       *tmp_directory,
 		TmpFolder:       *tmp_directory,
+		//Resolved per call, so it works although notificationInit runs later
+		NotificationSender: sendUserNotification,
 	})
 	})
 
 
-	//Share related functions
-	router.HandleFunc("/system/file_system/share/new", shareManager.HandleCreateNewShare)
-	router.HandleFunc("/system/file_system/share/delete", shareManager.HandleDeleteShare)
-	router.HandleFunc("/system/file_system/share/edit", shareManager.HandleEditShare)
-	router.HandleFunc("/system/file_system/share/checkShared", shareManager.HandleShareCheck)
-	router.HandleFunc("/system/file_system/share/list", shareManager.HandleListAllShares)
+	//Share management API (/system/file_system/share/*), behind the File Manager permission
+	shareManager.RegisterAPIEndpoints(router)
 
 
 	//Handle the main share function
 	//Handle the main share function
 	//Share function is now routed by the main router
 	//Share function is now routed by the main router
@@ -297,7 +295,7 @@ func FileSystemInit() {
 	//Clear tmp folder if files is placed here too long
 	//Clear tmp folder if files is placed here too long
 	nightlyManager.RegisterNightlyTask(system_fs_clearOldTmpFiles)
 	nightlyManager.RegisterNightlyTask(system_fs_clearOldTmpFiles)
 
 
-	//Clear shares that its parent file no longer exists in the system
+	//Clear shares that have expired or whose parent file no longer exists in the system
 	shareManager.ValidateAndClearShares()
 	shareManager.ValidateAndClearShares()
 	nightlyManager.RegisterNightlyTask(shareManager.ValidateAndClearShares)
 	nightlyManager.RegisterNightlyTask(shareManager.ValidateAndClearShares)
 
 

+ 4 - 1
src/mod/agi/README.md

@@ -537,7 +537,10 @@ requirelib("share");
 ```
 ```
 
 
 ### `share.shareFile(vpath, timeoutSec)`
 ### `share.shareFile(vpath, timeoutSec)`
-`timeoutSec=0` means no auto-expire.
+`timeoutSec=0` returns the file's existing share link (creating an "anyone with
+the link" share if there is none). `timeoutSec>0` always creates a separate share
+that expires after that many seconds; the expiry survives restarts and the share
+is cleaned up by the nightly maintenance task.
 
 
 ```javascript
 ```javascript
 var uuid = share.shareFile("user:/report.pdf", 3600);
 var uuid = share.shareFile("user:/report.pdf", 3600);

+ 10 - 9
src/mod/agi/agi.share.go

@@ -8,6 +8,7 @@ import (
 	"github.com/robertkrimen/otto"
 	"github.com/robertkrimen/otto"
 	"imuslab.com/arozos/mod/agi/static"
 	"imuslab.com/arozos/mod/agi/static"
 	"imuslab.com/arozos/mod/info/logger"
 	"imuslab.com/arozos/mod/info/logger"
+	"imuslab.com/arozos/mod/share/shareEntry"
 )
 )
 
 
 func (g *Gateway) ShareLibRegister() {
 func (g *Gateway) ShareLibRegister() {
@@ -41,20 +42,20 @@ func (g *Gateway) injectShareFunctions(payload *static.AgiLibInjectionPayload) {
 
 
 		//Create a share object for this request
 		//Create a share object for this request
 		vpathSourceFsh := u.GetRootFSHFromVpathInUserScope(vpath)
 		vpathSourceFsh := u.GetRootFSHFromVpathInUserScope(vpath)
-		shareID, err := g.Option.ShareManager.CreateNewShare(u, vpathSourceFsh, vpath)
+		var shareID *shareEntry.ShareOption
+		if timeout > 0 {
+			//A share with a lifetime is its own share, so expiring it never
+			//removes a share the user created by hand
+			expireAt := time.Now().Unix() + timeout
+			shareID, err = g.Option.ShareManager.CreateShare(u, vpath, shareEntry.ShareSettings{ExpireAt: &expireAt})
+		} else {
+			shareID, err = g.Option.ShareManager.CreateNewShare(u, vpathSourceFsh, vpath)
+		}
 		if err != nil {
 		if err != nil {
 			logger.PrintAndLog("Agi", "[AGI] Create Share Failed: "+err.Error(), nil)
 			logger.PrintAndLog("Agi", "[AGI] Create Share Failed: "+err.Error(), nil)
 			return otto.New().MakeCustomError("Share failed", err.Error())
 			return otto.New().MakeCustomError("Share failed", err.Error())
 		}
 		}
 
 
-		if timeout > 0 {
-			go func(timeout int) {
-				time.Sleep(time.Duration(timeout) * time.Second)
-				g.Option.ShareManager.RemoveShareByUUID(u, shareID.UUID)
-				logger.PrintAndLog("Agi", "[AGI] Share auto-removed: "+shareID.UUID, nil)
-			}(int(timeout))
-		}
-
 		r, _ := otto.ToValue(shareID.UUID)
 		r, _ := otto.ToValue(shareID.UUID)
 		return r
 		return r
 	})
 	})

+ 389 - 0
src/mod/share/access.go

@@ -0,0 +1,389 @@
+package share
+
+/*
+	Share access control
+
+	Decides whether a request to /share/... may open a share:
+
+	1. The share must exist and not have expired
+	2. The visitor must be in the share's audience (anyone / signed in /
+	   same group / listed groups / listed users)
+	3. A password protected share must have been unlocked. Unlocking sets a
+	   signed, HttpOnly cookie scoped to /share/ that is bound to the share
+	   and to its current password, so changing the password signs everyone out.
+*/
+
+import (
+	"crypto/hmac"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/hex"
+	"net"
+	"net/http"
+	"os"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"imuslab.com/arozos/mod/share/shareEntry"
+	"imuslab.com/arozos/mod/utils"
+)
+
+const (
+	shareMetaTable           = "share_meta"
+	shareCookieSecretKey     = "cookie_secret"
+	shareSessionCookiePrefix = "ao_share_"
+	shareSessionLifetime     = 12 * time.Hour
+
+	//Password guessing limit, per share and client address
+	maxPasswordAttempts   = 10
+	passwordAttemptWindow = 10 * time.Minute
+)
+
+// accessGuard holds the state needed for password sessions
+type accessGuard struct {
+	secret []byte
+
+	mu       sync.Mutex
+	attempts map[string]*passwordAttempts
+}
+
+type passwordAttempts struct {
+	count       int
+	windowStart time.Time
+}
+
+// newAccessGuard loads (or creates) the cookie signing secret. Storing it in
+// the database keeps unlocked shares unlocked across restarts.
+func (s *Manager) newAccessGuard() *accessGuard {
+	guard := &accessGuard{attempts: map[string]*passwordAttempts{}}
+	if s.options.ShareEntryTable == nil || s.options.ShareEntryTable.Database == nil {
+		guard.secret = randomSecret()
+		return guard
+	}
+
+	db := s.options.ShareEntryTable.Database
+	db.NewTable(shareMetaTable)
+	secretHex := ""
+	if err := db.Read(shareMetaTable, shareCookieSecretKey, &secretHex); err == nil {
+		if secret, err := hex.DecodeString(secretHex); err == nil && len(secret) == 32 {
+			guard.secret = secret
+			return guard
+		}
+	}
+
+	guard.secret = randomSecret()
+	db.Write(shareMetaTable, shareCookieSecretKey, hex.EncodeToString(guard.secret))
+	return guard
+}
+
+func randomSecret() []byte {
+	secret := make([]byte, 32)
+	if _, err := rand.Read(secret); err != nil {
+		//crypto/rand failing is not recoverable in a meaningful way
+		panic(err)
+	}
+	return secret
+}
+
+func sessionCookieName(so *shareEntry.ShareOption) string {
+	return shareSessionCookiePrefix + so.UUID
+}
+
+// sign returns the session signature for the given share and expiry time
+func (g *accessGuard) sign(so *shareEntry.ShareOption, expires int64) string {
+	mac := hmac.New(sha256.New, g.secret)
+	mac.Write([]byte(so.UUID + "\n" + so.PasswordHash + "\n" + strconv.FormatInt(expires, 10)))
+	return hex.EncodeToString(mac.Sum(nil))
+}
+
+// issueSession sets the cookie that unlocks a password protected share
+func (g *accessGuard) issueSession(w http.ResponseWriter, r *http.Request, so *shareEntry.ShareOption) {
+	expiresAt := time.Now().Add(shareSessionLifetime)
+	if so.ExpireAt > 0 && so.ExpireAt < expiresAt.Unix() {
+		expiresAt = time.Unix(so.ExpireAt, 0)
+	}
+	expires := expiresAt.Unix()
+	http.SetCookie(w, &http.Cookie{
+		Name:     sessionCookieName(so),
+		Value:    strconv.FormatInt(expires, 10) + "." + g.sign(so, expires),
+		Path:     "/share/",
+		Expires:  expiresAt,
+		HttpOnly: true,
+		Secure:   r.TLS != nil,
+		SameSite: http.SameSiteLaxMode,
+	})
+}
+
+// hasValidSession reports whether the request carries an unexpired session
+// for the share's current password.
+func (g *accessGuard) hasValidSession(r *http.Request, so *shareEntry.ShareOption) bool {
+	cookie, err := r.Cookie(sessionCookieName(so))
+	if err != nil {
+		return false
+	}
+	expiresStr, signature, found := strings.Cut(cookie.Value, ".")
+	if !found {
+		return false
+	}
+	expires, err := strconv.ParseInt(expiresStr, 10, 64)
+	if err != nil || time.Now().Unix() >= expires {
+		return false
+	}
+	return hmac.Equal([]byte(signature), []byte(g.sign(so, expires)))
+}
+
+// allowAttempt reports whether the client may try another password. Failed
+// attempts are counted by recordFailure.
+func (g *accessGuard) allowAttempt(key string, now time.Time) bool {
+	g.mu.Lock()
+	defer g.mu.Unlock()
+	record, ok := g.attempts[key]
+	if !ok || now.Sub(record.windowStart) > passwordAttemptWindow {
+		return true
+	}
+	return record.count < maxPasswordAttempts
+}
+
+func (g *accessGuard) recordFailure(key string, now time.Time) {
+	g.mu.Lock()
+	defer g.mu.Unlock()
+
+	//Drop stale records so the map cannot grow without bound
+	for k, record := range g.attempts {
+		if now.Sub(record.windowStart) > passwordAttemptWindow {
+			delete(g.attempts, k)
+		}
+	}
+
+	record, ok := g.attempts[key]
+	if !ok {
+		record = &passwordAttempts{windowStart: now}
+		g.attempts[key] = record
+	}
+	record.count++
+}
+
+func (g *accessGuard) clearFailures(key string) {
+	g.mu.Lock()
+	defer g.mu.Unlock()
+	delete(g.attempts, key)
+}
+
+func attemptKey(r *http.Request, so *shareEntry.ShareOption) string {
+	host, _, err := net.SplitHostPort(r.RemoteAddr)
+	if err != nil {
+		host = r.RemoteAddr
+	}
+	return so.UUID + "|" + host
+}
+
+// lookupActiveShare returns the share with the given id if it exists and has not expired
+func (s *Manager) lookupActiveShare(id string) *shareEntry.ShareOption {
+	so := s.GetShareObjectFromUUID(id)
+	if so == nil || so.IsExpired(time.Now()) {
+		return nil
+	}
+	return so
+}
+
+/*
+authorizeShareAccess checks the audience and the password of a share. It
+writes the rejection itself and returns false if the request must stop.
+
+raw is true for requests that fetch content (download, preview, zip,
+upload) rather than a page, which get a status code instead of a redirect
+to the login page or the password form.
+*/
+func (s *Manager) authorizeShareAccess(w http.ResponseWriter, r *http.Request, so *shareEntry.ShareOption, raw bool) bool {
+	if !s.checkAudience(w, r, so, raw) {
+		return false
+	}
+
+	if so.HasPassword() && !s.guard.hasValidSession(r, so) {
+		if raw {
+			w.WriteHeader(http.StatusUnauthorized)
+			w.Write([]byte("401 - Password required"))
+		} else {
+			s.servePasswordPage(w, r, so, false, http.StatusOK)
+		}
+		return false
+	}
+	return true
+}
+
+// checkAudience checks that the visitor is someone the share is for
+func (s *Manager) checkAudience(w http.ResponseWriter, r *http.Request, so *shareEntry.ShareOption, raw bool) bool {
+	rejectUnauthorized := func() {
+		if raw {
+			w.WriteHeader(http.StatusUnauthorized)
+			w.Write([]byte("401 - Unauthorized"))
+		} else {
+			http.Redirect(w, r, utils.ConstructRelativePathFromRequestURL(r.RequestURI, "login.html")+"?redirect=/share/"+so.UUID, http.StatusTemporaryRedirect)
+		}
+	}
+	rejectForbidden := func() {
+		if raw {
+			w.WriteHeader(http.StatusForbidden)
+			w.Write([]byte("403 - Forbidden"))
+		} else {
+			ServePermissionDeniedPage(w)
+		}
+	}
+
+	switch so.Permission {
+	case shareEntry.PermissionAnyone:
+		return true
+	case shareEntry.PermissionSignedIn:
+		if !s.options.AuthAgent.CheckAuth(r) {
+			rejectUnauthorized()
+			return false
+		}
+		return true
+	case shareEntry.PermissionSameGroup, shareEntry.PermissionGroups, shareEntry.PermissionUsers:
+		thisuserinfo, err := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+		if err != nil {
+			//User not logged in. Redirect to login page
+			rejectUnauthorized()
+			return false
+		}
+
+		//The owner can always open their own share
+		if thisuserinfo.Username == so.Owner {
+			return true
+		}
+
+		thisUsersGroupByName := thisuserinfo.GetUserPermissionGroupNames()
+		allowed := false
+		switch so.Permission {
+		case shareEntry.PermissionSameGroup:
+			//Every group of the owner (at share time) must be one of the user's groups
+			allowed = true
+			for _, allowedpg := range so.Accessibles {
+				if !utils.StringInArray(thisUsersGroupByName, allowedpg) {
+					allowed = false
+				}
+			}
+		case shareEntry.PermissionGroups:
+			for _, thisUserPg := range thisUsersGroupByName {
+				if utils.StringInArray(so.Accessibles, thisUserPg) {
+					allowed = true
+				}
+			}
+		case shareEntry.PermissionUsers:
+			allowed = utils.StringInArray(so.Accessibles, thisuserinfo.Username)
+		}
+
+		if !allowed {
+			rejectForbidden()
+			return false
+		}
+		return true
+	}
+
+	//Unsupported mode. Show notfound
+	http.NotFound(w, r)
+	return false
+}
+
+/*
+handlePasswordSubmit handles POST /share/auth/{uuid} from the password page.
+On success the share is unlocked and the visitor is sent back to it.
+*/
+func (s *Manager) handlePasswordSubmit(w http.ResponseWriter, r *http.Request, id string) {
+	so := s.lookupActiveShare(id)
+	if so == nil {
+		s.serveNotFoundPage(w, r, id)
+		return
+	}
+
+	if r.Method != http.MethodPost {
+		http.Redirect(w, r, "/share/"+so.UUID+"/", http.StatusSeeOther)
+		return
+	}
+
+	if !s.checkAudience(w, r, so, false) {
+		return
+	}
+
+	if !so.HasPassword() {
+		http.Redirect(w, r, "/share/"+so.UUID+"/", http.StatusSeeOther)
+		return
+	}
+
+	now := time.Now()
+	key := attemptKey(r, so)
+	if !s.guard.allowAttempt(key, now) {
+		s.servePasswordPage(w, r, so, true, http.StatusTooManyRequests)
+		return
+	}
+
+	r.ParseForm()
+	if !so.CheckPassword(r.PostForm.Get("password")) {
+		s.guard.recordFailure(key, now)
+		s.servePasswordPage(w, r, so, true, http.StatusUnauthorized)
+		return
+	}
+
+	s.guard.clearFailures(key)
+	s.guard.issueSession(w, r, so)
+	http.Redirect(w, r, "/share/"+so.UUID+"/", http.StatusSeeOther)
+}
+
+// servePasswordPage renders the password form. It deliberately shows nothing
+// about the shared file.
+func (s *Manager) servePasswordPage(w http.ResponseWriter, r *http.Request, so *shareEntry.ShareOption, failed bool, status int) {
+	errorMessage := ""
+	if failed {
+		errorMessage = "Incorrect password, please try again."
+		if status == http.StatusTooManyRequests {
+			errorMessage = "Too many attempts. Please wait a few minutes and try again."
+		}
+	}
+
+	content, err := utils.Templateload("./system/share/password.html", map[string]string{
+		"hostname": s.options.HostName,
+		"reqid":    so.UUID,
+		"error":    errorMessage,
+		"reqtime":  strconv.Itoa(int(time.Now().Unix())),
+	})
+	if err != nil {
+		w.WriteHeader(http.StatusUnauthorized)
+		w.Write([]byte("401 - This share is password protected"))
+		return
+	}
+
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	w.Header().Set("Cache-Control", "no-store")
+	w.WriteHeader(status)
+	w.Write([]byte(content))
+}
+
+// serveNotFoundPage renders the "share not found" page, also used for expired shares
+func (s *Manager) serveNotFoundPage(w http.ResponseWriter, r *http.Request, id string) {
+	content, err := utils.Templateload("./system/share/notfound.html", map[string]string{
+		"hostname": s.options.HostName,
+		"reqid":    id,
+		"reqtime":  strconv.Itoa(int(time.Now().Unix())),
+	})
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	w.Header().Set("Content-Type", "text/html")
+	w.WriteHeader(http.StatusNotFound)
+	w.Write([]byte(content))
+}
+
+func ServePermissionDeniedPage(w http.ResponseWriter) {
+	w.WriteHeader(http.StatusForbidden)
+	pageContent := []byte("Permissioned Denied")
+	if utils.FileExists("system/share/permissionDenied.html") {
+		content, err := os.ReadFile("system/share/permissionDenied.html")
+		if err == nil {
+			pageContent = content
+		}
+	}
+	w.Write([]byte(pageContent))
+}

+ 639 - 0
src/mod/share/api.go

@@ -0,0 +1,639 @@
+package share
+
+/*
+	Share management API
+
+	Authenticated endpoints under /system/file_system/share/, registered by
+	RegisterAPIEndpoints. Reading the share state of a path (info, checkShared)
+	never creates a share; shares are only added through create (or the legacy
+	get-or-create endpoint "new").
+*/
+
+import (
+	"encoding/json"
+	"errors"
+	"net/http"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+
+	"imuslab.com/arozos/mod/filesystem/arozfs"
+	"imuslab.com/arozos/mod/share/shareEntry"
+	"imuslab.com/arozos/mod/user"
+	"imuslab.com/arozos/mod/utils"
+)
+
+// APIPrefix is the path every share management endpoint is mounted under
+const APIPrefix = "/system/file_system/share/"
+
+// EndpointRegistrar is anything that can mount an authenticated endpoint,
+// usually the permission router (prouter.RouterDef) of the File Manager.
+type EndpointRegistrar interface {
+	HandleFunc(endpoint string, handler func(http.ResponseWriter, *http.Request)) error
+}
+
+// RegisterAPIEndpoints mounts the share management API on the given router.
+// The router is expected to handle login and module permission checks.
+func (s *Manager) RegisterAPIEndpoints(router EndpointRegistrar) {
+	router.HandleFunc(APIPrefix+"info", s.HandleShareInfo)
+	router.HandleFunc(APIPrefix+"create", s.HandleCreateShare)
+	router.HandleFunc(APIPrefix+"update", s.HandleUpdateShare)
+	router.HandleFunc(APIPrefix+"delete", s.HandleDeleteShare)
+	router.HandleFunc(APIPrefix+"recipients", s.HandleListRecipients)
+	router.HandleFunc(APIPrefix+"checkShared", s.HandleShareCheck)
+	router.HandleFunc(APIPrefix+"list", s.HandleListAllShares)
+
+	//Legacy endpoints, kept for older web apps and float window callers
+	router.HandleFunc(APIPrefix+"new", s.HandleCreateNewShare)
+	router.HandleFunc(APIPrefix+"edit", s.HandleEditShare)
+}
+
+// ShareInfo is the client facing view of a share. It never carries the
+// password hash or the real path of the shared file.
+type ShareInfo struct {
+	UUID            string
+	FileVirtualPath string
+	Owner           string
+	Permission      string
+	Accessibles     []string
+	IsFolder        bool
+	AccessLevel     string
+	AllowDownload   bool
+	ShowFileList    bool
+	HasPassword     bool
+	ExpireAt        int64
+	CreatedAt       int64
+	IsExpired       bool
+	SharePath       string //Relative share link, e.g. /share/<uuid>
+}
+
+func newShareInfo(so *shareEntry.ShareOption, now time.Time) *ShareInfo {
+	return &ShareInfo{
+		UUID:            so.UUID,
+		FileVirtualPath: so.FileVirtualPath,
+		Owner:           so.Owner,
+		Permission:      so.Permission,
+		Accessibles:     append([]string{}, so.Accessibles...),
+		IsFolder:        so.IsFolder,
+		AccessLevel:     so.AccessLevel,
+		AllowDownload:   so.AllowDownload,
+		ShowFileList:    so.ShowFileList,
+		HasPassword:     so.HasPassword(),
+		ExpireAt:        so.ExpireAt,
+		CreatedAt:       so.CreatedAt,
+		IsExpired:       so.IsExpired(now),
+		SharePath:       "/share/" + so.UUID,
+	}
+}
+
+func sendJSON(w http.ResponseWriter, v interface{}) {
+	js, err := json.Marshal(v)
+	if err != nil {
+		utils.SendErrorResponse(w, "Unable to encode response")
+		return
+	}
+	utils.SendJSONResponse(w, string(js))
+}
+
+// requirePost rejects requests that change state through GET, so a share
+// cannot be created or edited by an image tag or a link on another site.
+func requirePost(w http.ResponseWriter, r *http.Request) bool {
+	if r.Method != http.MethodPost {
+		w.WriteHeader(http.StatusMethodNotAllowed)
+		utils.SendErrorResponse(w, "Method not allowed")
+		return false
+	}
+	return true
+}
+
+/*
+parseShareSettings reads the share settings from the request. Every field
+is optional, a missing field is left unchanged.
+
+	permission     anyone / signedin / samegroup / groups / users
+	accessibles    comma separated user or group names (groups / users)
+	mode           legacy form of the two above, e.g. "users:alice,bob"
+	accessLevel    view / edit (edit is folder only)
+	allowDownload  true / false
+	showFileList   true / false
+	password       new password for the share
+	clearPassword  true to remove the password
+	expireAt       unix timestamp in seconds, 0 for never
+	expireIn       seconds from now, takes priority over expireAt
+*/
+func parseShareSettings(r *http.Request) (shareEntry.ShareSettings, error) {
+	settings := shareEntry.ShareSettings{}
+	r.ParseForm()
+
+	permission := strings.TrimSpace(r.Form.Get("permission"))
+	if permission == "" {
+		if mode := strings.TrimSpace(r.Form.Get("mode")); mode != "" {
+			ok, sharetype, names := validateShareModes(mode)
+			if !ok {
+				return settings, errors.New("Invalid share setting")
+			}
+			permission = sharetype
+			settings.Accessibles = names
+		}
+	} else {
+		settings.Accessibles = splitNameList(r.Form.Get("accessibles"))
+	}
+	if permission != "" {
+		settings.Permission = &permission
+	}
+
+	if accessLevel := strings.TrimSpace(r.Form.Get("accessLevel")); accessLevel != "" {
+		settings.AccessLevel = &accessLevel
+	}
+
+	if v, err := utils.PostBool(r, "allowDownload"); err == nil {
+		settings.AllowDownload = &v
+	}
+
+	if v, err := utils.PostBool(r, "showFileList"); err == nil {
+		settings.ShowFileList = &v
+	}
+
+	if clear, _ := utils.PostBool(r, "clearPassword"); clear {
+		empty := ""
+		settings.Password = &empty
+	} else if password := r.Form.Get("password"); password != "" {
+		settings.Password = &password
+	}
+
+	if expireIn := strings.TrimSpace(r.Form.Get("expireIn")); expireIn != "" {
+		seconds, err := strconv.ParseInt(expireIn, 10, 64)
+		if err != nil || seconds <= 0 {
+			return settings, errors.New("Invalid expiry duration")
+		}
+		expireAt := time.Now().Unix() + seconds
+		settings.ExpireAt = &expireAt
+	} else if expireAtStr := strings.TrimSpace(r.Form.Get("expireAt")); expireAtStr != "" {
+		expireAt, err := strconv.ParseInt(expireAtStr, 10, 64)
+		if err != nil {
+			return settings, errors.New("Invalid expiry date")
+		}
+		settings.ExpireAt = &expireAt
+	}
+
+	return settings, nil
+}
+
+func splitNameList(list string) []string {
+	results := []string{}
+	for _, name := range strings.Split(list, ",") {
+		if name = strings.TrimSpace(name); name != "" {
+			results = append(results, name)
+		}
+	}
+	return results
+}
+
+/*
+Validate Share Mode string
+will return
+1. bool => Is valid
+2. permission type: {basic / groups / users}
+3. mode string
+*/
+func validateShareModes(mode string) (bool, string, []string) {
+	// user:a,b,c,d
+	validModes := []string{shareEntry.PermissionAnyone, shareEntry.PermissionSignedIn, shareEntry.PermissionSameGroup}
+	if utils.StringInArray(validModes, mode) {
+		//Standard modes
+		return true, mode, []string{}
+	} else if len(mode) > 7 && mode[:7] == "groups:" {
+		//Handle custom group case like groups:a,b,c,d
+		groups := splitNameList(mode[7:])
+		return len(groups) > 0, shareEntry.PermissionGroups, groups
+	} else if len(mode) > 6 && mode[:6] == "users:" {
+		//Handle custom usersname like users:a,b,c,d
+		users := splitNameList(mode[6:])
+		return len(users) > 0, shareEntry.PermissionUsers, users
+	}
+
+	return false, "", []string{}
+}
+
+// pathShareInfo is the response of the info endpoint
+type pathShareInfo struct {
+	Path      string
+	Filename  string
+	IsFolder  bool
+	Size      int64 //Bytes, -1 if not calculated
+	FileCount int   //Files inside a folder, -1 if not calculated
+	IsShared  bool
+	CanManage bool //The user can edit or remove every share listed below
+	Shares    []*ShareInfo
+}
+
+/*
+HandleShareInfo returns the share metadata of a path without changing anything.
+
+	GET share/info?path=user:/Music/album[&folderStat=true]
+
+folderStat walks a shared folder to report its size and file count, which can
+be slow on a large folder, so the dialog asks for it separately.
+*/
+func (s *Manager) HandleShareInfo(w http.ResponseWriter, r *http.Request) {
+	userinfo, err := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, "User not logged in")
+		return
+	}
+
+	vpath, err := utils.GetPara(r, "path")
+	if err != nil {
+		utils.SendErrorResponse(w, "Invalid path given")
+		return
+	}
+
+	fsh, err := userinfo.GetFileSystemHandlerFromVirtualPath(vpath)
+	if err != nil {
+		utils.SendErrorResponse(w, "Invalid path given")
+		return
+	}
+	fshAbs := fsh.FileSystemAbstraction
+	rpath, err := fshAbs.VirtualPathToRealPath(vpath, userinfo.Username)
+	if err != nil || !fshAbs.FileExists(rpath) {
+		utils.SendErrorResponse(w, "File not exists")
+		return
+	}
+
+	result := pathShareInfo{
+		Path:      vpath,
+		Filename:  arozfs.Base(rpath),
+		IsFolder:  fshAbs.IsDir(rpath),
+		Size:      -1,
+		FileCount: -1,
+		Shares:    []*ShareInfo{},
+	}
+
+	if !result.IsFolder {
+		result.Size = fshAbs.GetFileSize(rpath)
+	} else if folderStat, _ := utils.GetBool(r, "folderStat"); folderStat {
+		result.Size, result.FileCount = fsh.GetDirctorySizeFromRealPath(rpath, false)
+	}
+
+	now := time.Now()
+	result.IsShared = s.FileIsShared(userinfo, vpath)
+	for _, so := range s.ListSharesOfPath(userinfo, vpath) {
+		result.Shares = append(result.Shares, newShareInfo(so, now))
+	}
+	//Shares listed are the ones this user can manage. A shared path with
+	//none listed belongs to someone else.
+	result.CanManage = !result.IsShared || len(result.Shares) > 0
+
+	sendJSON(w, result)
+}
+
+// HandleCreateShare always adds a new share to the given path (POST path + settings)
+func (s *Manager) HandleCreateShare(w http.ResponseWriter, r *http.Request) {
+	if !requirePost(w, r) {
+		return
+	}
+	userinfo, err := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, "User not logged in")
+		return
+	}
+
+	vpath, err := utils.PostPara(r, "path")
+	if err != nil {
+		utils.SendErrorResponse(w, "Invalid path given")
+		return
+	}
+
+	settings, err := parseShareSettings(r)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	so, err := s.CreateShare(userinfo, vpath, settings)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+	sendJSON(w, newShareInfo(so, time.Now()))
+}
+
+// HandleUpdateShare changes the settings of a share (POST uuid + settings)
+func (s *Manager) HandleUpdateShare(w http.ResponseWriter, r *http.Request) {
+	if !requirePost(w, r) {
+		return
+	}
+	userinfo, err := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, "User not logged in")
+		return
+	}
+
+	uuid, err := utils.PostPara(r, "uuid")
+	if err != nil {
+		utils.SendErrorResponse(w, "Invalid share uuid given")
+		return
+	}
+
+	settings, err := parseShareSettings(r)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	so, err := s.UpdateShare(userinfo, uuid, settings)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+	sendJSON(w, newShareInfo(so, time.Now()))
+}
+
+// Check if a file is shared. Returns the first share of the path for older callers.
+func (s *Manager) HandleShareCheck(w http.ResponseWriter, r *http.Request) {
+	//Get the vpath from paramters
+	vpath, err := utils.PostPara(r, "path")
+	if err != nil {
+		utils.SendErrorResponse(w, "Invalid path given")
+		return
+	}
+
+	//Get userinfo
+	userinfo, err := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, "User not logged in")
+		return
+	}
+
+	type Result struct {
+		IsShared   bool
+		ShareCount int
+		ShareUUID  *ShareInfo
+	}
+
+	result := Result{
+		IsShared:  s.FileIsShared(userinfo, vpath),
+		ShareUUID: &ShareInfo{},
+	}
+	shares := s.ListSharesOfPath(userinfo, vpath)
+	result.ShareCount = len(shares)
+	if result.IsShared {
+		now := time.Now()
+		for _, so := range shares {
+			if !so.IsExpired(now) {
+				result.ShareUUID = newShareInfo(so, now)
+				break
+			}
+		}
+	}
+	sendJSON(w, result)
+}
+
+// Legacy: return the first share of the path, creating one if it is not shared yet
+func (s *Manager) HandleCreateNewShare(w http.ResponseWriter, r *http.Request) {
+	//Get the vpath from paramters
+	vpath, err := utils.PostPara(r, "path")
+	if err != nil {
+		utils.SendErrorResponse(w, "Invalid path given")
+		return
+	}
+
+	//Get userinfo
+	userinfo, err := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, "User not logged in")
+		return
+	}
+
+	//Get the target fsh that this vpath come from
+	vpathSourceFsh := userinfo.GetRootFSHFromVpathInUserScope(vpath)
+	if vpathSourceFsh == nil {
+		utils.SendErrorResponse(w, "Invalid vpath given")
+		return
+	}
+
+	share, err := s.CreateNewShare(userinfo, vpathSourceFsh, vpath)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	sendJSON(w, newShareInfo(share, time.Now()))
+}
+
+// Handle Share Edit (legacy, prefer share/update).
+// For allowing groups / users, use the following syntax
+// groups:group1,group2,group3
+// users:user1,user2,user3
+// For basic modes, use the following keywords
+// anyone / signedin / samegroup
+// anyone: Anyone who has the link
+// signedin: Anyone logged in to this system
+// samegroup: The requesting user has the same (or more) user group as the share owner
+func (s *Manager) HandleEditShare(w http.ResponseWriter, r *http.Request) {
+	userinfo, err := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, "User not logged in")
+		return
+	}
+
+	uuid, err := utils.PostPara(r, "uuid")
+	if err != nil {
+		utils.SendErrorResponse(w, "Invalid path given")
+		return
+	}
+
+	shareMode, _ := utils.PostPara(r, "mode")
+	if shareMode == "" {
+		shareMode = shareEntry.PermissionSignedIn
+	}
+
+	//Validate and extract the storage mode
+	ok, sharetype, names := validateShareModes(shareMode)
+	if !ok {
+		utils.SendErrorResponse(w, "Invalid share setting")
+		return
+	}
+
+	_, err = s.UpdateShare(userinfo, uuid, shareEntry.ShareSettings{
+		Permission:  &sharetype,
+		Accessibles: names,
+	})
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	utils.SendOK(w)
+}
+
+// Delete a share by uuid, or every share on a path by vpath
+func (s *Manager) HandleDeleteShare(w http.ResponseWriter, r *http.Request) {
+	//Get userinfo
+	userinfo, err := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, "User not logged in")
+		return
+	}
+
+	//Get the vpath from paramters
+	uuid, err := utils.PostPara(r, "uuid")
+	if err == nil {
+		err = s.DeleteShareByUUID(userinfo, uuid)
+	} else {
+		//Try to get it from vpath (or path, as sent by the Photo app)
+		vpath, perr := utils.PostPara(r, "vpath")
+		if perr != nil {
+			vpath, perr = utils.PostPara(r, "path")
+		}
+		if perr != nil {
+			utils.SendErrorResponse(w, "Invalid uuid or vpath given")
+			return
+		}
+		if !s.FileIsShared(userinfo, vpath) && len(s.ListSharesOfPath(userinfo, vpath)) == 0 {
+			utils.SendErrorResponse(w, "Invalid uuid or vpath given")
+			return
+		}
+		err = s.DeleteShareByVpath(userinfo, vpath)
+	}
+
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+	} else {
+		utils.SendOK(w)
+	}
+}
+
+/*
+HandleListRecipients lists the users and permission groups a share can be
+given to, for the invite pickers of the share dialog.
+*/
+func (s *Manager) HandleListRecipients(w http.ResponseWriter, r *http.Request) {
+	userinfo, err := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, "User not logged in")
+		return
+	}
+
+	type Recipient struct {
+		Username string
+		Groups   []string
+		IsSelf   bool
+	}
+
+	type Result struct {
+		Users  []Recipient
+		Groups []string
+	}
+
+	result := Result{Users: []Recipient{}, Groups: []string{}}
+	permissionHandler := s.options.UserHandler.GetPermissionHandler()
+	for _, username := range s.options.UserHandler.GetAuthAgent().ListUsers() {
+		groups := []string{}
+		if pgs, err := permissionHandler.GetUsersPermissionGroup(username); err == nil {
+			for _, pg := range pgs {
+				groups = append(groups, pg.Name)
+			}
+		}
+		result.Users = append(result.Users, Recipient{
+			Username: username,
+			Groups:   groups,
+			IsSelf:   username == userinfo.Username,
+		})
+	}
+	for _, pg := range permissionHandler.PermissionGroups {
+		result.Groups = append(result.Groups, pg.Name)
+	}
+
+	sort.Slice(result.Users, func(i, j int) bool {
+		return strings.ToLower(result.Users[i].Username) < strings.ToLower(result.Users[j].Username)
+	})
+	sort.Strings(result.Groups)
+	sendJSON(w, result)
+}
+
+// List all the shares the user can see (used by the Shares Manager)
+func (s *Manager) HandleListAllShares(w http.ResponseWriter, r *http.Request) {
+	userinfo, err := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, "User not logged in")
+		return
+	}
+	fshId, _ := utils.GetPara(r, "fsh")
+	results := []*shareEntry.ShareOption{}
+	if fshId == "" {
+		//List all
+		allFsh := userinfo.GetAllFileSystemHandler()
+		for _, thisFsh := range allFsh {
+			allShares := s.ListAllShareByFshId(thisFsh.UUID, userinfo)
+			for _, thisShare := range allShares {
+				if s.ShareIsValid(thisShare) {
+					results = append(results, thisShare)
+				}
+			}
+
+		}
+	} else {
+		//List fsh only
+		targetFsh, err := userinfo.GetFileSystemHandlerFromVirtualPath(fshId)
+		if err != nil {
+			utils.SendErrorResponse(w, err.Error())
+			return
+		}
+		sharesInThisFsh := s.ListAllShareByFshId(targetFsh.UUID, userinfo)
+		for _, thisShare := range sharesInThisFsh {
+			if s.ShareIsValid(thisShare) {
+				results = append(results, thisShare)
+			}
+		}
+	}
+
+	//Reduce the data
+	type Share struct {
+		UUID                 string
+		FileVirtualPath      string
+		Owner                string
+		Permission           string
+		IsFolder             bool
+		IsOwnerOfShare       bool
+		CanAccess            bool
+		CanOpenInFileManager bool
+		CanDelete            bool
+		HasPassword          bool
+		ExpireAt             int64
+		IsExpired            bool
+	}
+
+	now := time.Now()
+	reducedResult := []*Share{}
+	for _, result := range results {
+		permissionText := result.Permission
+		if result.Permission == shareEntry.PermissionGroups || result.Permission == shareEntry.PermissionUsers {
+			permissionText = permissionText + " (" + strings.Join(result.Accessibles, ", ") + ")"
+		}
+		thisShareInfo := Share{
+			UUID:                 result.UUID,
+			FileVirtualPath:      result.FileVirtualPath,
+			Owner:                result.Owner,
+			Permission:           permissionText,
+			IsFolder:             result.IsFolder,
+			IsOwnerOfShare:       userinfo.Username == result.Owner,
+			CanAccess:            result.IsAccessibleBy(userinfo.Username, userinfo.GetUserPermissionGroupNames()),
+			CanOpenInFileManager: s.UserCanOpenShareInFileManager(result, userinfo),
+			CanDelete:            s.CanModifyShare(userinfo, result),
+			HasPassword:          result.HasPassword(),
+			ExpireAt:             result.ExpireAt,
+			IsExpired:            result.IsExpired(now),
+		}
+
+		reducedResult = append(reducedResult, &thisShareInfo)
+	}
+
+	sendJSON(w, reducedResult)
+}
+
+// shareOwnerOf is a small helper for handlers that need the owner of a share
+func (s *Manager) shareOwnerOf(so *shareEntry.ShareOption) (*user.User, error) {
+	return s.options.UserHandler.GetUserInfoFromUsername(so.Owner)
+}

+ 349 - 0
src/mod/share/manager.go

@@ -0,0 +1,349 @@
+package share
+
+/*
+	Share management operations
+
+	Everything that creates, edits, removes or lists shares on behalf of a
+	signed in user. The HTTP handlers in api.go and the AGI share library both
+	go through these, so permission checks live here and only here.
+*/
+
+import (
+	"errors"
+	"fmt"
+	"sort"
+	"time"
+
+	filesystem "imuslab.com/arozos/mod/filesystem"
+	"imuslab.com/arozos/mod/info/logger"
+	"imuslab.com/arozos/mod/share/shareEntry"
+	"imuslab.com/arozos/mod/user"
+)
+
+// Craete a new file or folder share, or return the existing first share of the path
+func (s *Manager) CreateNewShare(userinfo *user.User, srcFsh *filesystem.FileSystemHandler, vpath string) (*shareEntry.ShareOption, error) {
+	if srcFsh == nil {
+		return nil, errors.New("Invalid vpath given")
+	}
+	//Translate the vpath to realpath
+	return s.options.ShareEntryTable.CreateNewShare(srcFsh, vpath, userinfo.Username, userinfo.GetUserPermissionGroupNames())
+}
+
+// CreateShare always adds a new share with the given settings to the path
+func (s *Manager) CreateShare(userinfo *user.User, vpath string, settings shareEntry.ShareSettings) (*shareEntry.ShareOption, error) {
+	srcFsh := userinfo.GetRootFSHFromVpathInUserScope(vpath)
+	if srcFsh == nil {
+		return nil, errors.New("Invalid vpath given")
+	}
+
+	settings, err := s.resolveRecipients(userinfo, settings)
+	if err != nil {
+		return nil, err
+	}
+
+	so, err := s.options.ShareEntryTable.CreateShare(srcFsh, vpath, userinfo.Username, settings)
+	if err != nil {
+		return nil, err
+	}
+
+	//Invited users have access right away; let them know
+	s.notifyInvitees(nil, so, userinfo.Username)
+	return so, nil
+}
+
+// UpdateShare changes the settings of an existing share
+func (s *Manager) UpdateShare(userinfo *user.User, uuid string, settings shareEntry.ShareSettings) (*shareEntry.ShareOption, error) {
+	so := s.GetShareObjectFromUUID(uuid)
+	if so == nil {
+		return nil, errors.New("Share UUID not exists")
+	}
+
+	if !s.CanModifyShare(userinfo, so) {
+		return nil, errors.New("Permission Denied")
+	}
+
+	//Same group shares snapshot the groups of the share owner, not the editor
+	owner, err := s.options.UserHandler.GetUserInfoFromUsername(so.Owner)
+	if err != nil {
+		return nil, errors.New("Share owner not exists")
+	}
+	settings, err = s.resolveRecipients(owner, settings)
+	if err != nil {
+		return nil, err
+	}
+
+	updated, err := s.options.ShareEntryTable.UpdateShare(uuid, settings)
+	if err != nil {
+		return nil, err
+	}
+
+	//Only the users / groups added by this change are notified
+	s.notifyInvitees(so, updated, userinfo.Username)
+	return updated, nil
+}
+
+// resolveRecipients fills in the owner's groups for same group shares and
+// checks that invited users and groups exist.
+func (s *Manager) resolveRecipients(owner *user.User, settings shareEntry.ShareSettings) (shareEntry.ShareSettings, error) {
+	if settings.Permission == nil {
+		return settings, nil
+	}
+
+	switch *settings.Permission {
+	case shareEntry.PermissionSameGroup:
+		settings.Accessibles = owner.GetUserPermissionGroupNames()
+	case shareEntry.PermissionUsers:
+		authAgent := s.options.UserHandler.GetAuthAgent()
+		for _, username := range settings.Accessibles {
+			if username != "" && !authAgent.UserExists(username) {
+				return settings, errors.New("User not exists: " + username)
+			}
+		}
+	case shareEntry.PermissionGroups:
+		permissionHandler := s.options.UserHandler.GetPermissionHandler()
+		for _, group := range settings.Accessibles {
+			if group != "" && !permissionHandler.GroupExists(group) {
+				return settings, errors.New("Group not exists: " + group)
+			}
+		}
+	}
+	return settings, nil
+}
+
+/*
+Check if the user can open the share in File Manager
+
+There are two conditions where the user can open the file in file manager
+1. If the user is the owner of the file
+2. If the user is NOT the owner of the file but the target fsh is public accessible and in user's fsh list
+*/
+func (s *Manager) UserCanOpenShareInFileManager(share *shareEntry.ShareOption, userinfo *user.User) bool {
+	if share.Owner == userinfo.Username {
+		return true
+	}
+
+	fsh, err := userinfo.GetFileSystemHandlerFromVirtualPath(share.FileVirtualPath)
+	if err != nil {
+		//User do not have permission to access this fsh
+		return false
+	}
+
+	rpath, _ := fsh.FileSystemAbstraction.VirtualPathToRealPath(share.FileVirtualPath, userinfo.Username)
+	if fsh.Hierarchy == "public" && fsh.FileSystemAbstraction.FileExists(rpath) {
+		return true
+	}
+
+	return false
+}
+
+func (s *Manager) ListAllShareByFshId(fshId string, userinfo *user.User) []*shareEntry.ShareOption {
+	results := []*shareEntry.ShareOption{}
+	for _, thisShareOption := range s.options.ShareEntryTable.ListAllShares() {
+		if userinfo.IsAdmin() || thisShareOption.IsAccessibleBy(userinfo.Username, userinfo.GetUserPermissionGroupNames()) {
+			id, _, _ := filesystem.GetIDFromVirtualPath(thisShareOption.FileVirtualPath)
+			if id == fshId {
+				results = append(results, thisShareOption)
+			}
+		}
+	}
+	return results
+}
+
+// ListSharesOfPath returns the shares on the given path that the user can
+// manage, oldest first.
+func (s *Manager) ListSharesOfPath(userinfo *user.User, vpath string) []*shareEntry.ShareOption {
+	ps, err := getPathHashFromUsernameAndVpath(userinfo, vpath)
+	if err != nil {
+		return []*shareEntry.ShareOption{}
+	}
+
+	results := []*shareEntry.ShareOption{}
+	for _, so := range s.options.ShareEntryTable.GetSharesFromPathHash(ps) {
+		if s.CanModifyShare(userinfo, so) {
+			results = append(results, so)
+		}
+	}
+
+	sort.SliceStable(results, func(i, j int) bool {
+		return results[i].CreatedAt < results[j].CreatedAt
+	})
+	return results
+}
+
+func (s *Manager) ShareIsValid(thisShareOption *shareEntry.ShareOption) bool {
+	vpath := thisShareOption.FileVirtualPath
+	userinfo, err := s.options.UserHandler.GetUserInfoFromUsername(thisShareOption.Owner)
+	if err != nil {
+		return false
+	}
+	fsh, err := userinfo.GetFileSystemHandlerFromVirtualPath(vpath)
+	if err != nil {
+		return false
+	}
+
+	fshAbs := fsh.FileSystemAbstraction
+	rpath, _ := fshAbs.VirtualPathToRealPath(vpath, userinfo.Username)
+
+	if !fshAbs.FileExists(rpath) {
+		return false
+	}
+
+	return true
+}
+
+func (s *Manager) GetPathHashFromShare(thisShareOption *shareEntry.ShareOption) (string, error) {
+	vpath := thisShareOption.FileVirtualPath
+	userinfo, err := s.options.UserHandler.GetUserInfoFromUsername(thisShareOption.Owner)
+	if err != nil {
+		return "", err
+	}
+	fsh, err := userinfo.GetFileSystemHandlerFromVirtualPath(vpath)
+	if err != nil {
+		return "", err
+	}
+	return shareEntry.GetPathHash(fsh, vpath, userinfo.Username)
+}
+
+/*
+Nightly share maintenance. Registered on the system nightly task ticker.
+
+1. Remove shares that have expired
+2. Remove shares whose shared file no longer exists in the system
+*/
+func (s *Manager) ValidateAndClearShares() {
+	for _, expired := range s.options.ShareEntryTable.RemoveExpiredShares(time.Now()) {
+		logger.PrintAndLog("Share", "Removing expired share "+expired.UUID+" to file: "+expired.FileVirtualPath, nil)
+	}
+
+	//Iterate through all shares within the system
+	for _, thisShareOption := range s.options.ShareEntryTable.ListAllShares() {
+		if _, err := s.GetPathHashFromShare(thisShareOption); err != nil {
+			//Unable to resolve path hash. Filesystem handler (or owner) is gone,
+			//maybe only temporarily (e.g. an unmounted drive). Keep the share.
+			continue
+		}
+		if !s.ShareIsValid(thisShareOption) {
+			//This share source file don't exists anymore. Remove it
+			err := s.options.ShareEntryTable.DeleteShareByUUID(thisShareOption.UUID)
+			if err != nil {
+				logger.PrintAndLog("Share", fmt.Sprint("Failed to remove share ", thisShareOption.UUID), err)
+				continue
+			}
+			logger.PrintAndLog("Share", "Removing share to file: "+thisShareOption.FileRealPath+" as it no longer exists", nil)
+		}
+	}
+}
+
+// Check if the user has the permission to modify the shares on this path
+func (s *Manager) CanModifyShareEntry(userinfo *user.User, vpath string) bool {
+	shareEntry := s.GetShareObjectFromUserAndVpath(userinfo, vpath)
+	if shareEntry == nil {
+		//Share entry not found
+		return false
+	}
+	return s.CanModifyShare(userinfo, shareEntry)
+}
+
+// CanModifyShare reports whether the user can edit or remove the given share:
+// admins, the share owner, and writers of a public storage the file sits on.
+func (s *Manager) CanModifyShare(userinfo *user.User, so *shareEntry.ShareOption) bool {
+	//Check if the user is the share owner or the user is admin
+	if userinfo.IsAdmin() {
+		return true
+	} else if userinfo.Username == so.Owner {
+		return true
+	}
+
+	//Public fsh where the user and owner both can access
+	vpath := so.FileVirtualPath
+	fsh, err := userinfo.GetFileSystemHandlerFromVirtualPath(vpath)
+	if err != nil {
+		return false
+	}
+	rpath, _ := fsh.FileSystemAbstraction.VirtualPathToRealPath(vpath, userinfo.Username)
+	if userinfo.CanWrite(vpath) && fsh.Hierarchy == "public" && fsh.FileSystemAbstraction.FileExists(rpath) {
+		return true
+	}
+
+	return false
+}
+
+// Remove all the shares on the given path that the user can modify
+func (s *Manager) DeleteShareByVpath(userinfo *user.User, vpath string) error {
+	shares := s.ListSharesOfPath(userinfo, vpath)
+	if len(shares) == 0 {
+		if s.FileIsShared(userinfo, vpath) {
+			return errors.New("Permission denied")
+		}
+		return nil
+	}
+	for _, so := range shares {
+		if err := s.options.ShareEntryTable.DeleteShareByUUID(so.UUID); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (s *Manager) DeleteShareByUUID(userinfo *user.User, uuid string) error {
+	so := s.GetShareObjectFromUUID(uuid)
+	if so == nil {
+		return errors.New("Invalid share uuid")
+	}
+
+	if !s.CanModifyShare(userinfo, so) {
+		return errors.New("Permission denied")
+	}
+
+	return s.options.ShareEntryTable.DeleteShareByUUID(uuid)
+}
+
+func (s *Manager) GetShareUUIDFromUserAndVpath(userinfo *user.User, vpath string) string {
+	ps, err := getPathHashFromUsernameAndVpath(userinfo, vpath)
+	if err != nil {
+		return ""
+	}
+	return s.options.ShareEntryTable.GetShareUUIDFromPathHash(ps)
+}
+
+// GetShareObjectFromUserAndVpath returns the first share on the path, or nil
+func (s *Manager) GetShareObjectFromUserAndVpath(userinfo *user.User, vpath string) *shareEntry.ShareOption {
+	ps, err := getPathHashFromUsernameAndVpath(userinfo, vpath)
+	if err != nil {
+		return nil
+	}
+	return s.options.ShareEntryTable.GetShareObjectFromPathHash(ps)
+}
+
+func (s *Manager) GetShareObjectFromUUID(uuid string) *shareEntry.ShareOption {
+	return s.options.ShareEntryTable.GetShareObjectFromUUID(uuid)
+}
+
+// FileIsShared reports whether the path has at least one active share
+func (s *Manager) FileIsShared(userinfo *user.User, vpath string) bool {
+	ps, err := getPathHashFromUsernameAndVpath(userinfo, vpath)
+	if err != nil {
+		return false
+	}
+
+	return s.options.ShareEntryTable.FileIsShared(ps)
+}
+
+func (s *Manager) RemoveShareByUUID(userinfo *user.User, uuid string) error {
+	shareObject := s.GetShareObjectFromUUID(uuid)
+	if shareObject == nil {
+		return errors.New("Share entry not found")
+	}
+	if !s.CanModifyShare(userinfo, shareObject) {
+		return errors.New("Permission denied")
+	}
+	return s.options.ShareEntryTable.RemoveShareByUUID(uuid)
+}
+
+func getPathHashFromUsernameAndVpath(userinfo *user.User, vpath string) (string, error) {
+	fsh, err := userinfo.GetFileSystemHandlerFromVirtualPath(vpath)
+	if err != nil {
+		return "", err
+	}
+	return shareEntry.GetPathHash(fsh, vpath, userinfo.Username)
+}

+ 315 - 0
src/mod/share/manager_test.go

@@ -0,0 +1,315 @@
+package share
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	db "imuslab.com/arozos/mod/database"
+	"imuslab.com/arozos/mod/share/shareEntry"
+)
+
+func newPostRequest(form url.Values) *http.Request {
+	r := httptest.NewRequest(http.MethodPost, "/system/file_system/share/update", strings.NewReader(form.Encode()))
+	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	return r
+}
+
+func TestValidateShareModes(t *testing.T) {
+	tests := []struct {
+		mode      string
+		wantOK    bool
+		wantType  string
+		wantNames []string
+	}{
+		{"anyone", true, "anyone", []string{}},
+		{"signedin", true, "signedin", []string{}},
+		{"samegroup", true, "samegroup", []string{}},
+		{"users:alice,bob", true, "users", []string{"alice", "bob"}},
+		{"groups:staff", true, "groups", []string{"staff"}},
+		{"users:", false, "", nil},
+		{"users: , ", false, "users", []string{}},
+		{"everyone", false, "", []string{}},
+	}
+	for _, tc := range tests {
+		t.Run(tc.mode, func(t *testing.T) {
+			ok, sharetype, names := validateShareModes(tc.mode)
+			if ok != tc.wantOK {
+				t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
+			}
+			if !ok {
+				return
+			}
+			if sharetype != tc.wantType || strings.Join(names, ",") != strings.Join(tc.wantNames, ",") {
+				t.Errorf("got %q %v, want %q %v", sharetype, names, tc.wantType, tc.wantNames)
+			}
+		})
+	}
+}
+
+func TestParseShareSettings(t *testing.T) {
+	t.Run("empty request changes nothing", func(t *testing.T) {
+		settings, err := parseShareSettings(newPostRequest(url.Values{}))
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if settings.Permission != nil || settings.AccessLevel != nil || settings.AllowDownload != nil ||
+			settings.ShowFileList != nil || settings.Password != nil || settings.ExpireAt != nil {
+			t.Errorf("expected no settings, got %+v", settings)
+		}
+	})
+
+	t.Run("full settings", func(t *testing.T) {
+		settings, err := parseShareSettings(newPostRequest(url.Values{
+			"permission":    {"users"},
+			"accessibles":   {"alice, bob,"},
+			"accessLevel":   {"edit"},
+			"allowDownload": {"false"},
+			"showFileList":  {"true"},
+			"password":      {"pw"},
+			"expireAt":      {"1900000000"},
+		}))
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if *settings.Permission != "users" || strings.Join(settings.Accessibles, ",") != "alice,bob" {
+			t.Errorf("permission %q %v", *settings.Permission, settings.Accessibles)
+		}
+		if *settings.AccessLevel != "edit" || *settings.AllowDownload || !*settings.ShowFileList {
+			t.Errorf("flags not parsed: %+v", settings)
+		}
+		if *settings.Password != "pw" || *settings.ExpireAt != 1900000000 {
+			t.Errorf("password or expiry not parsed")
+		}
+	})
+
+	t.Run("legacy mode", func(t *testing.T) {
+		settings, err := parseShareSettings(newPostRequest(url.Values{"mode": {"groups:staff"}}))
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if *settings.Permission != "groups" || settings.Accessibles[0] != "staff" {
+			t.Errorf("legacy mode not parsed: %+v", settings)
+		}
+	})
+
+	t.Run("clear password wins", func(t *testing.T) {
+		settings, _ := parseShareSettings(newPostRequest(url.Values{"password": {"pw"}, "clearPassword": {"true"}}))
+		if settings.Password == nil || *settings.Password != "" {
+			t.Error("clearPassword should set an empty password")
+		}
+	})
+
+	t.Run("expire in", func(t *testing.T) {
+		before := time.Now().Unix()
+		settings, err := parseShareSettings(newPostRequest(url.Values{"expireIn": {"3600"}, "expireAt": {"1"}}))
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if *settings.ExpireAt < before+3600 || *settings.ExpireAt > time.Now().Unix()+3600 {
+			t.Errorf("ExpireAt = %d, want about now+3600", *settings.ExpireAt)
+		}
+	})
+
+	for _, bad := range []url.Values{
+		{"expireIn": {"-5"}},
+		{"expireIn": {"soon"}},
+		{"expireAt": {"tomorrow"}},
+		{"mode": {"everyone"}},
+	} {
+		if _, err := parseShareSettings(newPostRequest(bad)); err == nil {
+			t.Errorf("expected an error for %v", bad)
+		}
+	}
+}
+
+func TestNewShareInfo_HidesSecrets(t *testing.T) {
+	so := shareEntry.DefaultShareOption()
+	so.UUID = "abc"
+	so.FileRealPath = "/srv/data/users/alice/secret.txt"
+	if err := so.SetPassword("pw"); err != nil {
+		t.Fatalf("SetPassword: %v", err)
+	}
+
+	info := newShareInfo(so, time.Now())
+	if !info.HasPassword || info.SharePath != "/share/abc" {
+		t.Errorf("unexpected info: %+v", info)
+	}
+	js, _ := json.Marshal(info)
+	if strings.Contains(string(js), so.PasswordHash) || strings.Contains(string(js), "/srv/data") {
+		t.Errorf("share info leaks secrets: %s", js)
+	}
+}
+
+func TestPageSettingsJSON(t *testing.T) {
+	so := shareEntry.DefaultShareOption()
+	so.IsFolder = true
+	so.AccessLevel = shareEntry.AccessLevelEdit
+	so.AllowDownload = false
+	so.ExpireAt = 42
+
+	var got map[string]interface{}
+	if err := json.Unmarshal([]byte(pageSettingsJSON(so)), &got); err != nil {
+		t.Fatalf("invalid JSON: %v", err)
+	}
+	if got["allowDownload"] != false || got["showFileList"] != true || got["canUpload"] != true || got["expireAt"] != float64(42) {
+		t.Errorf("unexpected settings: %v", got)
+	}
+}
+
+func newTestManager(t *testing.T) *Manager {
+	t.Helper()
+	database, err := db.NewDatabase(filepath.Join(t.TempDir(), "share.db"), false)
+	if err != nil {
+		t.Fatalf("NewDatabase: %v", err)
+	}
+	return NewShareManager(Options{ShareEntryTable: shareEntry.NewShareEntryTable(database)})
+}
+
+func TestAccessGuard_SecretPersists(t *testing.T) {
+	m := newTestManager(t)
+	again := NewShareManager(m.options)
+	if string(m.guard.secret) != string(again.guard.secret) || len(m.guard.secret) != 32 {
+		t.Error("cookie secret should be generated once and reloaded from the database")
+	}
+}
+
+func TestAccessGuard_Session(t *testing.T) {
+	m := newTestManager(t)
+	so := shareEntry.DefaultShareOption()
+	so.UUID = "share-1"
+	so.SetPassword("pw")
+
+	//No cookie
+	if m.guard.hasValidSession(httptest.NewRequest(http.MethodGet, "/share/share-1/", nil), so) {
+		t.Fatal("a request without cookie must not be unlocked")
+	}
+
+	rec := httptest.NewRecorder()
+	m.guard.issueSession(rec, httptest.NewRequest(http.MethodPost, "/share/auth/share-1", nil), so)
+	cookies := rec.Result().Cookies()
+	if len(cookies) != 1 || !cookies[0].HttpOnly || cookies[0].Path != "/share/" {
+		t.Fatalf("unexpected session cookie: %+v", cookies)
+	}
+
+	withCookie := func(c *http.Cookie) *http.Request {
+		r := httptest.NewRequest(http.MethodGet, "/share/share-1/", nil)
+		r.AddCookie(c)
+		return r
+	}
+
+	if !m.guard.hasValidSession(withCookie(cookies[0]), so) {
+		t.Fatal("issued session should be valid")
+	}
+
+	//Tampered expiry
+	tampered := *cookies[0]
+	_, sig, _ := strings.Cut(tampered.Value, ".")
+	tampered.Value = "9999999999." + sig
+	if m.guard.hasValidSession(withCookie(&tampered), so) {
+		t.Error("a session with a forged expiry must be rejected")
+	}
+
+	//Session of another share
+	other := so.Clone()
+	other.UUID = "share-2"
+	forged := *cookies[0]
+	forged.Name = sessionCookieName(other)
+	if m.guard.hasValidSession(withCookie(&forged), other) {
+		t.Error("a session must not unlock another share")
+	}
+
+	//Changing the password signs everyone out
+	changed := so.Clone()
+	changed.SetPassword("new password")
+	if m.guard.hasValidSession(withCookie(cookies[0]), changed) {
+		t.Error("a session must not survive a password change")
+	}
+}
+
+func TestAccessGuard_AttemptLimit(t *testing.T) {
+	m := newTestManager(t)
+	now := time.Unix(1_000_000, 0)
+	key := "share|1.2.3.4"
+
+	for i := 0; i < maxPasswordAttempts; i++ {
+		if !m.guard.allowAttempt(key, now) {
+			t.Fatalf("attempt %d should be allowed", i+1)
+		}
+		m.guard.recordFailure(key, now)
+	}
+	if m.guard.allowAttempt(key, now) {
+		t.Fatal("attempts over the limit must be refused")
+	}
+	if !m.guard.allowAttempt("share|5.6.7.8", now) {
+		t.Error("another client must not be affected")
+	}
+	if !m.guard.allowAttempt(key, now.Add(passwordAttemptWindow+time.Second)) {
+		t.Error("the limit should reset after the window")
+	}
+
+	m.guard.clearFailures(key)
+	if !m.guard.allowAttempt(key, now) {
+		t.Error("a successful login should clear the failures")
+	}
+}
+
+func TestSanitizeUploadFilename(t *testing.T) {
+	tests := []struct {
+		in      string
+		want    string
+		wantErr bool
+	}{
+		{"photo.jpg", "photo.jpg", false},
+		{"  notes.txt ", "notes.txt", false},
+		{"../../etc/passwd", "passwd", false},
+		{`C:\Users\bob\report.pdf`, "report.pdf", false},
+		{"100%.txt", "100_.txt", false},
+		{"..", "", true},
+		{"", "", true},
+		{"a<b>.txt", "", true},
+		{"bad\x00name", "", true},
+	}
+	for _, tc := range tests {
+		t.Run(tc.in, func(t *testing.T) {
+			got, err := sanitizeUploadFilename(tc.in)
+			if tc.wantErr {
+				if err == nil {
+					t.Errorf("expected an error, got %q", got)
+				}
+				return
+			}
+			if err != nil || got != tc.want {
+				t.Errorf("got %q, %v; want %q", got, err, tc.want)
+			}
+		})
+	}
+}
+
+func TestUniqueDestination(t *testing.T) {
+	taken := map[string]bool{
+		"/data/a.txt":     true,
+		"/data/a (1).txt": true,
+		"/data/archive":   true,
+	}
+	exists := func(p string) bool { return taken[p] }
+
+	tests := []struct {
+		name string
+		want string
+	}{
+		{"b.txt", "/data/b.txt"},
+		{"a.txt", "/data/a (2).txt"},
+		{"archive", "/data/archive (1)"},
+	}
+	for _, tc := range tests {
+		if got := uniqueDestination(exists, "/data", tc.name); got != tc.want {
+			t.Errorf("uniqueDestination(%q) = %q, want %q", tc.name, got, tc.want)
+		}
+	}
+}

+ 135 - 0
src/mod/share/notify.go

@@ -0,0 +1,135 @@
+package share
+
+/*
+	Share invitation notices
+
+	Inviting a user or a group grants access straight away - there is nothing
+	to accept. The invited people get an ArozOS notification as a reminder that
+	they can now open the file, with the share link as its click target.
+*/
+
+import (
+	"encoding/json"
+	"strconv"
+	"time"
+
+	uuid "github.com/satori/go.uuid"
+	"imuslab.com/arozos/mod/filesystem/arozfs"
+	"imuslab.com/arozos/mod/info/logger"
+	"imuslab.com/arozos/mod/notification"
+	"imuslab.com/arozos/mod/share/shareEntry"
+)
+
+const notificationSender = "File Share"
+
+// invitedNames returns the user or group names newly given access by a share
+// change. before is nil for a new share.
+func invitedNames(before *shareEntry.ShareOption, after *shareEntry.ShareOption) []string {
+	if after.Permission != shareEntry.PermissionUsers && after.Permission != shareEntry.PermissionGroups {
+		return []string{}
+	}
+
+	previous := map[string]bool{}
+	if before != nil && before.Permission == after.Permission {
+		for _, name := range before.Accessibles {
+			previous[name] = true
+		}
+	}
+
+	added := []string{}
+	for _, name := range after.Accessibles {
+		if !previous[name] {
+			added = append(added, name)
+		}
+	}
+	return added
+}
+
+// resolveInvitees expands invited users / groups into the usernames to notify,
+// without duplicates and without the person who shared the file.
+func (s *Manager) resolveInvitees(so *shareEntry.ShareOption, names []string, sharedBy string) []string {
+	seen := map[string]bool{so.Owner: true, sharedBy: true}
+	receivers := []string{}
+	add := func(username string) {
+		if !seen[username] {
+			seen[username] = true
+			receivers = append(receivers, username)
+		}
+	}
+
+	for _, name := range names {
+		if so.Permission == shareEntry.PermissionUsers {
+			add(name)
+			continue
+		}
+		members, err := s.options.UserHandler.GetUsersInPermissionGroup(name)
+		if err != nil {
+			continue
+		}
+		for _, member := range members {
+			add(member.Username)
+		}
+	}
+	return receivers
+}
+
+// buildInviteNotification builds the notice sent to invited users
+func buildInviteNotification(so *shareEntry.ShareOption, sharedBy string, receivers []string) *notification.NotificationPayload {
+	filename := arozfs.Base(so.FileVirtualPath)
+	kind := "file"
+	if so.IsFolder {
+		kind = "folder"
+	}
+	access := "view"
+	if so.AccessLevel == shareEntry.AccessLevelEdit {
+		access = "view and upload to"
+	}
+
+	message := sharedBy + " shared the " + kind + " \"" + filename + "\" with you. You can now " + access + " it, no action needed."
+	if so.ExpireAt > 0 {
+		message += " Access ends on " + time.Unix(so.ExpireAt, 0).Format("2006-01-02") + "."
+	}
+
+	//Float window options the desktop opens when the notification is clicked
+	openOption, _ := json.Marshal(map[string]interface{}{
+		"url":     "share/" + so.UUID + "/",
+		"title":   filename,
+		"appicon": "SystemAO/file_system/img/share.svg",
+		"width":   960,
+		"height":  640,
+	})
+
+	return &notification.NotificationPayload{
+		ID:        strconv.FormatInt(time.Now().UnixNano(), 10) + "-" + uuid.NewV4().String(),
+		Title:     "A " + kind + " was shared with you",
+		Message:   message,
+		Receiver:  receivers,
+		Sender:    notificationSender,
+		Priority:  notification.PriorityMedium,
+		Timestamp: time.Now().Unix(),
+		Payload:   string(openOption),
+	}
+}
+
+// notifyInvitees tells the people newly invited by a share change that they
+// now have access. before is nil for a new share. Failures are only logged:
+// the share itself has already been saved.
+func (s *Manager) notifyInvitees(before *shareEntry.ShareOption, after *shareEntry.ShareOption, sharedBy string) {
+	if s.options.NotificationSender == nil || after == nil || after.IsExpired(time.Now()) {
+		return
+	}
+
+	names := invitedNames(before, after)
+	if len(names) == 0 {
+		return
+	}
+
+	receivers := s.resolveInvitees(after, names, sharedBy)
+	if len(receivers) == 0 {
+		return
+	}
+
+	if err := s.options.NotificationSender(buildInviteNotification(after, sharedBy, receivers)); err != nil {
+		logger.PrintAndLog("Share", "Unable to notify invited users of share "+after.UUID, err)
+	}
+}

+ 109 - 0
src/mod/share/notify_test.go

@@ -0,0 +1,109 @@
+package share
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+	"time"
+
+	"imuslab.com/arozos/mod/notification"
+	"imuslab.com/arozos/mod/share/shareEntry"
+)
+
+func shareFor(permission string, names ...string) *shareEntry.ShareOption {
+	so := shareEntry.DefaultShareOption()
+	so.UUID = "share-uuid"
+	so.Owner = "owner"
+	so.FileVirtualPath = "user:/Music/album"
+	so.IsFolder = true
+	so.Permission = permission
+	so.Accessibles = names
+	return so
+}
+
+func TestInvitedNames(t *testing.T) {
+	tests := []struct {
+		name   string
+		before *shareEntry.ShareOption
+		after  *shareEntry.ShareOption
+		want   []string
+	}{
+		{"new user share", nil, shareFor("users", "alice", "bob"), []string{"alice", "bob"}},
+		{"new group share", nil, shareFor("groups", "staff"), []string{"staff"}},
+		{"public link", nil, shareFor("anyone"), []string{}},
+		{"user added", shareFor("users", "alice"), shareFor("users", "alice", "bob"), []string{"bob"}},
+		{"user removed", shareFor("users", "alice", "bob"), shareFor("users", "alice"), []string{}},
+		{"settings only", shareFor("users", "alice"), shareFor("users", "alice"), []string{}},
+		{"public to users", shareFor("anyone"), shareFor("users", "alice"), []string{"alice"}},
+		{"users to groups", shareFor("users", "staff"), shareFor("groups", "staff"), []string{"staff"}},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			got := invitedNames(tc.before, tc.after)
+			if strings.Join(got, ",") != strings.Join(tc.want, ",") {
+				t.Errorf("got %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestBuildInviteNotification(t *testing.T) {
+	so := shareFor("users", "alice")
+	so.AccessLevel = shareEntry.AccessLevelEdit
+	so.ExpireAt = time.Date(2030, 1, 2, 12, 0, 0, 0, time.Local).Unix()
+
+	payload := buildInviteNotification(so, "owner", []string{"alice"})
+	if payload.Sender != notificationSender || payload.Priority != notification.PriorityMedium {
+		t.Errorf("unexpected sender / priority: %+v", payload)
+	}
+	if len(payload.Receiver) != 1 || payload.Receiver[0] != "alice" {
+		t.Errorf("Receiver = %v", payload.Receiver)
+	}
+	for _, want := range []string{"owner", `"album"`, "folder", "upload", "2030-01-02", "no action needed"} {
+		if !strings.Contains(payload.Message, want) {
+			t.Errorf("message %q should mention %q", payload.Message, want)
+		}
+	}
+
+	var open map[string]interface{}
+	if err := json.Unmarshal([]byte(payload.Payload), &open); err != nil {
+		t.Fatalf("payload is not JSON: %v", err)
+	}
+	if open["url"] != "share/share-uuid/" {
+		t.Errorf("click target = %v, want the share link", open["url"])
+	}
+}
+
+func TestNotifyInvitees(t *testing.T) {
+	var sent []*notification.NotificationPayload
+	m := newTestManager(t)
+	m.options.NotificationSender = func(p *notification.NotificationPayload) error {
+		sent = append(sent, p)
+		return nil
+	}
+
+	//Owner and the person sharing are never notified
+	m.notifyInvitees(nil, shareFor("users", "alice", "owner", "editor", "alice"), "editor")
+	if len(sent) != 1 || strings.Join(sent[0].Receiver, ",") != "alice" {
+		t.Fatalf("sent %+v, want one notice to alice", sent)
+	}
+
+	//Nothing new: nothing sent
+	sent = nil
+	m.notifyInvitees(shareFor("users", "alice"), shareFor("users", "alice"), "owner")
+	if len(sent) != 0 {
+		t.Errorf("expected no notice, got %+v", sent)
+	}
+
+	//Expired shares grant nothing
+	expired := shareFor("users", "bob")
+	expired.ExpireAt = time.Now().Unix() - 10
+	m.notifyInvitees(nil, expired, "owner")
+	if len(sent) != 0 {
+		t.Errorf("expected no notice for an expired share, got %+v", sent)
+	}
+
+	//No sender configured: no panic
+	m.options.NotificationSender = nil
+	m.notifyInvitees(nil, shareFor("users", "carol"), "owner")
+}

Разница между файлами не показана из-за своего большого размера
+ 124 - 768
src/mod/share/share.go


+ 232 - 126
src/mod/share/shareEntry/shareEntry.go

@@ -4,8 +4,10 @@ import (
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
 	"path/filepath"
 	"path/filepath"
+	"sort"
 	"strings"
 	"strings"
 	"sync"
 	"sync"
+	"time"
 
 
 	uuid "github.com/satori/go.uuid"
 	uuid "github.com/satori/go.uuid"
 	"imuslab.com/arozos/mod/database"
 	"imuslab.com/arozos/mod/database"
@@ -18,153 +20,227 @@ import (
 	This module is designed to isolate the entry operatiosn with the
 	This module is designed to isolate the entry operatiosn with the
 	handle operations so as to reduce the complexity of recursive import
 	handle operations so as to reduce the complexity of recursive import
 	during development
 	during development
+
+	A path can carry more than one share. Shares are indexed by their UUID
+	(the id in the share link) and by the path hash of the shared file. All
+	getters return copies, so a caller can never race against an update -
+	change a share through UpdateShare instead of writing to it.
 */
 */
 
 
+const shareTableName = "share"
+
 type ShareEntryTable struct {
 type ShareEntryTable struct {
-	FileToUrlMap *sync.Map
-	UrlToFileMap *sync.Map
-	Database     *database.Database
-}
+	Database *database.Database
 
 
-type ShareOption struct {
-	UUID            string
-	PathHash        string //Path Hash, the key for loading a share from vpath and fsh specific config
-	FileVirtualPath string
-	FileRealPath    string
-	Owner           string
-	Accessibles     []string //Use to store username or group names if permission is groups or users
-	Permission      string   //Access permission, allow {anyone / signedin / samegroup / groups / users}
-	IsFolder        bool
+	mu     sync.RWMutex
+	byUUID map[string]*ShareOption
+	byPath map[string][]string //path hash -> share UUIDs, oldest first
 }
 }
 
 
 func NewShareEntryTable(db *database.Database) *ShareEntryTable {
 func NewShareEntryTable(db *database.Database) *ShareEntryTable {
 	//Create the share table if not exists
 	//Create the share table if not exists
-	db.NewTable("share")
+	db.NewTable(shareTableName)
 
 
-	FileToUrlMap := sync.Map{}
-	UrlToFileMap := sync.Map{}
+	table := &ShareEntryTable{
+		Database: db,
+		byUUID:   map[string]*ShareOption{},
+		byPath:   map[string][]string{},
+	}
 
 
 	//Load the old share links
 	//Load the old share links
-	entries, _ := db.ListTable("share")
+	entries, _ := db.ListTable(shareTableName)
+	loaded := []*ShareOption{}
 	for _, keypairs := range entries {
 	for _, keypairs := range entries {
 		shareObject := new(ShareOption)
 		shareObject := new(ShareOption)
-		json.Unmarshal(keypairs[1], &shareObject)
-		if shareObject != nil {
-			//Append this to the maps
-			FileToUrlMap.Store(shareObject.PathHash, shareObject)
-			UrlToFileMap.Store(shareObject.UUID, shareObject)
+		if err := json.Unmarshal(keypairs[1], shareObject); err != nil || shareObject.UUID == "" {
+			continue
+		}
+		if shareObject.migrate() {
+			db.Write(shareTableName, shareObject.UUID, shareObject)
 		}
 		}
+		loaded = append(loaded, shareObject)
+	}
 
 
+	//Keep the per path order stable: oldest share first
+	sort.SliceStable(loaded, func(i, j int) bool {
+		return loaded[i].CreatedAt < loaded[j].CreatedAt
+	})
+	for _, shareObject := range loaded {
+		table.index(shareObject)
 	}
 	}
 
 
-	return &ShareEntryTable{
-		FileToUrlMap: &FileToUrlMap,
-		UrlToFileMap: &UrlToFileMap,
-		Database:     db,
+	return table
+}
+
+// index adds a share into the in-memory maps. Caller must hold the write lock
+// (or own the table exclusively).
+func (s *ShareEntryTable) index(so *ShareOption) {
+	s.byUUID[so.UUID] = so
+	if !stringInSlice(so.UUID, s.byPath[so.PathHash]) {
+		s.byPath[so.PathHash] = append(s.byPath[so.PathHash], so.UUID)
 	}
 	}
 }
 }
 
 
-func (s *ShareEntryTable) CreateNewShare(srcFsh *filesystem.FileSystemHandler, vpath string, username string, usergroups []string) (*ShareOption, error) {
+// unindex removes a share from the in-memory maps. Caller must hold the write lock.
+func (s *ShareEntryTable) unindex(so *ShareOption) {
+	delete(s.byUUID, so.UUID)
+	remaining := []string{}
+	for _, id := range s.byPath[so.PathHash] {
+		if id != so.UUID {
+			remaining = append(remaining, id)
+		}
+	}
+	if len(remaining) == 0 {
+		delete(s.byPath, so.PathHash)
+	} else {
+		s.byPath[so.PathHash] = remaining
+	}
+}
+
+// resolveShareTarget translates and validates the shared path, returning the
+// real path and the path hash of the share target.
+func resolveShareTarget(srcFsh *filesystem.FileSystemHandler, vpath string, username string) (string, string, error) {
 	rpath, err := srcFsh.FileSystemAbstraction.VirtualPathToRealPath(vpath, username)
 	rpath, err := srcFsh.FileSystemAbstraction.VirtualPathToRealPath(vpath, username)
 	if err != nil {
 	if err != nil {
-		return nil, errors.New("Unable to translate path given")
+		return "", "", errors.New("Unable to translate path given")
 	}
 	}
 
 
 	rpath = filepath.ToSlash(filepath.Clean(rpath))
 	rpath = filepath.ToSlash(filepath.Clean(rpath))
 	//Check if source file exists
 	//Check if source file exists
 	if !srcFsh.FileSystemAbstraction.FileExists(rpath) {
 	if !srcFsh.FileSystemAbstraction.FileExists(rpath) {
-		return nil, errors.New("Unable to find the file on disk")
+		return "", "", errors.New("Unable to find the file on disk")
 	}
 	}
 
 
 	sharePathHash, err := GetPathHash(srcFsh, vpath, username)
 	sharePathHash, err := GetPathHash(srcFsh, vpath, username)
+	if err != nil {
+		return "", "", err
+	}
+	return rpath, sharePathHash, nil
+}
 
 
+// CreateNewShare returns the first share of the given path, creating a default
+// "anyone with the link" share if the path is not shared yet.
+//
+// Kept for callers that think of a file as having one share (the AGI share
+// library and the legacy share/new endpoint). Use CreateShare to add another
+// share to an already shared path.
+func (s *ShareEntryTable) CreateNewShare(srcFsh *filesystem.FileSystemHandler, vpath string, username string, usergroups []string) (*ShareOption, error) {
+	_, sharePathHash, err := resolveShareTarget(srcFsh, vpath, username)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
 	//Check if the share already exists. If yes, use the previous link
 	//Check if the share already exists. If yes, use the previous link
-	val, ok := s.FileToUrlMap.Load(sharePathHash)
-	if ok {
-		//Exists. Send back the old share url
-		ShareOption := val.(*ShareOption)
-		return ShareOption, nil
+	if existing := s.GetShareObjectFromPathHash(sharePathHash); existing != nil {
+		return existing, nil
+	}
 
 
-	} else {
-		//Create new link for this file
-		shareUUID := uuid.NewV4().String()
-
-		//Create a share object
-		shareOption := ShareOption{
-			UUID:            shareUUID,
-			PathHash:        sharePathHash,
-			FileVirtualPath: vpath,
-			FileRealPath:    rpath,
-			Owner:           username,
-			Accessibles:     usergroups,
-			Permission:      "anyone",
-			IsFolder:        srcFsh.FileSystemAbstraction.IsDir(rpath),
-		}
+	return s.CreateShare(srcFsh, vpath, username, ShareSettings{})
+}
 
 
-		//Store results on two map to make sure O(1) Lookup time
-		s.FileToUrlMap.Store(sharePathHash, &shareOption)
-		s.UrlToFileMap.Store(shareUUID, &shareOption)
+// CreateShare always creates a new share for the given path with the default
+// settings overridden by the given ones.
+func (s *ShareEntryTable) CreateShare(srcFsh *filesystem.FileSystemHandler, vpath string, username string, settings ShareSettings) (*ShareOption, error) {
+	rpath, sharePathHash, err := resolveShareTarget(srcFsh, vpath, username)
+	if err != nil {
+		return nil, err
+	}
 
 
-		//Write object to database
-		s.Database.Write("share", shareUUID, shareOption)
+	shareOption := DefaultShareOption()
+	shareOption.UUID = uuid.NewV4().String()
+	shareOption.PathHash = sharePathHash
+	shareOption.FileVirtualPath = vpath
+	shareOption.FileRealPath = rpath
+	shareOption.Owner = username
+	shareOption.IsFolder = srcFsh.FileSystemAbstraction.IsDir(rpath)
 
 
-		return &shareOption, nil
+	if err := shareOption.Apply(settings, time.Now()); err != nil {
+		return nil, err
 	}
 	}
+
+	if err := s.AddShare(shareOption); err != nil {
+		return nil, err
+	}
+	return shareOption.Clone(), nil
 }
 }
 
 
-// Delete the share on this vpath
-func (s *ShareEntryTable) DeleteShareByPathHash(pathhash string) error {
-	//Check if the share already exists. If yes, use the previous link
-	val, ok := s.FileToUrlMap.Load(pathhash)
-	if ok {
-		//Exists. Send back the old share url
-		uuid := val.(*ShareOption).UUID
+// AddShare persists and indexes a fully populated share
+func (s *ShareEntryTable) AddShare(so *ShareOption) error {
+	if so.UUID == "" || so.PathHash == "" {
+		return errors.New("share UUID and path hash cannot be empty")
+	}
+	so = so.Clone()
 
 
-		//Remove this from the database
-		err := s.Database.Delete("share", uuid)
-		if err != nil {
-			return err
-		}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if err := s.Database.Write(shareTableName, so.UUID, so); err != nil {
+		return err
+	}
+	s.index(so)
+	return nil
+}
 
 
-		//Remove this form the current sync map
-		s.UrlToFileMap.Delete(uuid)
-		s.FileToUrlMap.Delete(pathhash)
+// UpdateShare applies the given settings to a share and persists the result.
+// Nothing is changed if any of the settings is invalid.
+func (s *ShareEntryTable) UpdateShare(uuid string, settings ShareSettings) (*ShareOption, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
 
 
-		return nil
+	current, ok := s.byUUID[uuid]
+	if !ok {
+		return nil, errors.New("Share with given uuid not exists")
+	}
 
 
-	} else {
-		//Already deleted from buffered record.
-		return nil
+	next := current.Clone()
+	if err := next.Apply(settings, time.Now()); err != nil {
+		return nil, err
 	}
 	}
 
 
+	if err := s.Database.Write(shareTableName, uuid, next); err != nil {
+		return nil, err
+	}
+	s.byUUID[uuid] = next
+	return next.Clone(), nil
 }
 }
 
 
-func (s *ShareEntryTable) DeleteShareByUUID(uuid string) error {
-	//Check if the share already exists. If yes, use the previous link
-	val, ok := s.UrlToFileMap.Load(uuid)
-	if ok {
-		//Exists. Send back the old share url
-		so := val.(*ShareOption)
+// Delete all the shares on this path hash
+func (s *ShareEntryTable) DeleteShareByPathHash(pathhash string) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
 
 
+	for _, id := range append([]string{}, s.byPath[pathhash]...) {
+		so, ok := s.byUUID[id]
+		if !ok {
+			continue
+		}
 		//Remove this from the database
 		//Remove this from the database
-		err := s.Database.Delete("share", so.UUID)
-		if err != nil {
+		if err := s.Database.Delete(shareTableName, id); err != nil {
 			return err
 			return err
 		}
 		}
+		s.unindex(so)
+	}
 
 
-		//Remove this form the current sync map
-		s.FileToUrlMap.Delete(so.PathHash)
-		s.UrlToFileMap.Delete(uuid)
-		return nil
+	//Already deleted from buffered record if nothing is left
+	return nil
+}
 
 
-	} else {
+// Delete a share by its UUID. Deleting a share that does not exist is not an error.
+func (s *ShareEntryTable) DeleteShareByUUID(uuid string) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	so, ok := s.byUUID[uuid]
+	if !ok {
 		//Already deleted from buffered record.
 		//Already deleted from buffered record.
 		return nil
 		return nil
 	}
 	}
+
+	//Remove this from the database
+	if err := s.Database.Delete(shareTableName, so.UUID); err != nil {
+		return err
+	}
+	s.unindex(so)
+	return nil
 }
 }
 
 
 func (s *ShareEntryTable) GetShareUUIDFromPathHash(pathhash string) string {
 func (s *ShareEntryTable) GetShareUUIDFromPathHash(pathhash string) string {
@@ -176,67 +252,97 @@ func (s *ShareEntryTable) GetShareUUIDFromPathHash(pathhash string) string {
 	}
 	}
 }
 }
 
 
+// GetShareObjectFromPathHash returns the oldest share on the path, or nil
 func (s *ShareEntryTable) GetShareObjectFromPathHash(pathhash string) *ShareOption {
 func (s *ShareEntryTable) GetShareObjectFromPathHash(pathhash string) *ShareOption {
-	var targetShareOption *ShareOption = nil
-	s.FileToUrlMap.Range(func(k, v interface{}) bool {
-		thisPathhash := k.(string)
-		shareObject := v.(*ShareOption)
-
-		if thisPathhash == pathhash {
-			targetShareOption = shareObject
-		}
+	shares := s.GetSharesFromPathHash(pathhash)
+	if len(shares) == 0 {
+		return nil
+	}
+	return shares[0]
+}
 
 
-		return true
-	})
+// GetSharesFromPathHash returns all the shares on the path, oldest first
+func (s *ShareEntryTable) GetSharesFromPathHash(pathhash string) []*ShareOption {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
 
 
-	return targetShareOption
+	results := []*ShareOption{}
+	for _, id := range s.byPath[pathhash] {
+		if so, ok := s.byUUID[id]; ok {
+			results = append(results, so.Clone())
+		}
+	}
+	return results
 }
 }
 
 
 func (s *ShareEntryTable) GetShareObjectFromUUID(uuid string) *ShareOption {
 func (s *ShareEntryTable) GetShareObjectFromUUID(uuid string) *ShareOption {
-	var targetShareOption *ShareOption
-	s.UrlToFileMap.Range(func(k, v interface{}) bool {
-		thisUuid := k.(string)
-		shareObject := v.(*ShareOption)
+	s.mu.RLock()
+	defer s.mu.RUnlock()
 
 
-		if thisUuid == uuid {
-			targetShareOption = shareObject
-		}
+	so, ok := s.byUUID[uuid]
+	if !ok {
+		return nil
+	}
+	return so.Clone()
+}
 
 
-		return true
-	})
+// ListAllShares returns every share in the system, sorted by UUID
+func (s *ShareEntryTable) ListAllShares() []*ShareOption {
+	s.mu.RLock()
+	results := make([]*ShareOption, 0, len(s.byUUID))
+	for _, so := range s.byUUID {
+		results = append(results, so.Clone())
+	}
+	s.mu.RUnlock()
 
 
-	return targetShareOption
+	sort.Slice(results, func(i, j int) bool {
+		return results[i].UUID < results[j].UUID
+	})
+	return results
 }
 }
 
 
+// FileIsShared reports whether the path has at least one share that has not expired
 func (s *ShareEntryTable) FileIsShared(pathhash string) bool {
 func (s *ShareEntryTable) FileIsShared(pathhash string) bool {
-	shareUUID := s.GetShareUUIDFromPathHash(pathhash)
-	return shareUUID != ""
+	now := time.Now()
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	for _, id := range s.byPath[pathhash] {
+		if so, ok := s.byUUID[id]; ok && !so.IsExpired(now) {
+			return true
+		}
+	}
+	return false
 }
 }
 
 
 func (s *ShareEntryTable) RemoveShareByPathHash(pathhash string) error {
 func (s *ShareEntryTable) RemoveShareByPathHash(pathhash string) error {
-	so, ok := s.FileToUrlMap.Load(pathhash)
-	if ok {
-		shareUUID := so.(*ShareOption).UUID
-		s.UrlToFileMap.Delete(shareUUID)
-		s.FileToUrlMap.Delete(pathhash)
-		s.Database.Delete("share", shareUUID)
-	} else {
+	s.mu.RLock()
+	_, ok := s.byPath[pathhash]
+	s.mu.RUnlock()
+	if !ok {
 		return errors.New("Share with given pathhash not exists. Given: " + pathhash)
 		return errors.New("Share with given pathhash not exists. Given: " + pathhash)
 	}
 	}
-	return nil
+	return s.DeleteShareByPathHash(pathhash)
 }
 }
 
 
 func (s *ShareEntryTable) RemoveShareByUUID(uuid string) error {
 func (s *ShareEntryTable) RemoveShareByUUID(uuid string) error {
-	so, ok := s.UrlToFileMap.Load(uuid)
-	if ok {
-		shareOption := so.(*ShareOption)
-		s.FileToUrlMap.Delete(shareOption.PathHash)
-		s.UrlToFileMap.Delete(uuid)
-		s.Database.Delete("share", uuid)
-	} else {
+	if s.GetShareObjectFromUUID(uuid) == nil {
 		return errors.New("Share with given uuid not exists")
 		return errors.New("Share with given uuid not exists")
 	}
 	}
-	return nil
+	return s.DeleteShareByUUID(uuid)
+}
+
+// RemoveExpiredShares deletes every share that has expired at the given time
+// and returns the removed shares.
+func (s *ShareEntryTable) RemoveExpiredShares(now time.Time) []*ShareOption {
+	removed := []*ShareOption{}
+	for _, so := range s.ListAllShares() {
+		if so.IsExpired(now) {
+			if err := s.DeleteShareByUUID(so.UUID); err == nil {
+				removed = append(removed, so)
+			}
+		}
+	}
+	return removed
 }
 }
 
 
 func (s *ShareEntryTable) ResolveShareOptionFromShareSubpath(subpath string) (*ShareOption, error) {
 func (s *ShareEntryTable) ResolveShareOptionFromShareSubpath(subpath string) (*ShareOption, error) {

+ 7 - 10
src/mod/share/shareEntry/shareEntry_test.go

@@ -3,7 +3,6 @@ package shareEntry
 import (
 import (
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
-	"sync"
 	"testing"
 	"testing"
 
 
 	db "imuslab.com/arozos/mod/database"
 	db "imuslab.com/arozos/mod/database"
@@ -29,8 +28,9 @@ func newTestTable(t *testing.T) *ShareEntryTable {
 // insertShareOption injects a ShareOption directly into the maps (bypassing
 // insertShareOption injects a ShareOption directly into the maps (bypassing
 // filesystem checks) so we can test lookup/deletion logic in isolation.
 // filesystem checks) so we can test lookup/deletion logic in isolation.
 func insertShareOption(table *ShareEntryTable, opt *ShareOption) {
 func insertShareOption(table *ShareEntryTable, opt *ShareOption) {
-	table.FileToUrlMap.Store(opt.PathHash, opt)
-	table.UrlToFileMap.Store(opt.UUID, opt)
+	table.mu.Lock()
+	table.index(opt)
+	table.mu.Unlock()
 }
 }
 
 
 func makeShareOption(uuid, pathHash, owner string) *ShareOption {
 func makeShareOption(uuid, pathHash, owner string) *ShareOption {
@@ -53,8 +53,8 @@ func TestNewShareEntryTable(t *testing.T) {
 	if table == nil {
 	if table == nil {
 		t.Fatal("NewShareEntryTable returned nil")
 		t.Fatal("NewShareEntryTable returned nil")
 	}
 	}
-	if table.FileToUrlMap == nil || table.UrlToFileMap == nil {
-		t.Error("sync.Maps should be initialised")
+	if table.byUUID == nil || table.byPath == nil {
+		t.Error("share indexes should be initialised")
 	}
 	}
 	if table.Database == nil {
 	if table.Database == nil {
 		t.Error("Database reference should not be nil")
 		t.Error("Database reference should not be nil")
@@ -195,7 +195,7 @@ func TestRemoveShareByUUID(t *testing.T) {
 		t.Fatalf("RemoveShareByUUID error: %v", err)
 		t.Fatalf("RemoveShareByUUID error: %v", err)
 	}
 	}
 	if table.GetShareObjectFromUUID("uuid-rm") != nil {
 	if table.GetShareObjectFromUUID("uuid-rm") != nil {
-		t.Error("entry should be removed from UrlToFileMap")
+		t.Error("entry should be removed from the uuid index")
 	}
 	}
 }
 }
 
 
@@ -345,7 +345,7 @@ func TestNewShareEntryTable_LoadsExistingEntries(t *testing.T) {
 
 
 	got := table.GetShareObjectFromUUID("uuid-preload")
 	got := table.GetShareObjectFromUUID("uuid-preload")
 	if got == nil {
 	if got == nil {
-		t.Fatal("expected preloaded entry to be present in UrlToFileMap")
+		t.Fatal("expected preloaded entry to be present in the uuid index")
 	}
 	}
 	if got.PathHash != "hash-preload" {
 	if got.PathHash != "hash-preload" {
 		t.Errorf("expected PathHash 'hash-preload', got %q", got.PathHash)
 		t.Errorf("expected PathHash 'hash-preload', got %q", got.PathHash)
@@ -500,6 +500,3 @@ func TestCreateNewShare_FileNotFound(t *testing.T) {
 		t.Error("expected error for non-existent file, got nil")
 		t.Error("expected error for non-existent file, got nil")
 	}
 	}
 }
 }
-
-// Compile-time check that sync is imported
-var _ sync.Map

+ 234 - 3
src/mod/share/shareEntry/shareOptions.go

@@ -1,20 +1,129 @@
 package shareEntry
 package shareEntry
 
 
+import (
+	"errors"
+	"strings"
+	"time"
+
+	"golang.org/x/crypto/bcrypt"
+)
+
+/*
+	Share Options
+
+	A ShareOption is one share link. A file or folder can carry any number of
+	them (a public link, a password protected link, one per invited user or
+	group ...), each with its own audience, access level, password and expiry.
+*/
+
+// Audiences of a share (stored in ShareOption.Permission for backward compatibility)
+const (
+	PermissionAnyone    = "anyone"    //Anyone who has the link
+	PermissionSignedIn  = "signedin"  //Anyone signed in to this system
+	PermissionSameGroup = "samegroup" //Users in all the owner's groups (snapshot in Accessibles)
+	PermissionGroups    = "groups"    //Users in any of the groups listed in Accessibles
+	PermissionUsers     = "users"     //Users listed in Accessibles (and the owner)
+)
+
+// Access levels of a share
+const (
+	AccessLevelView = "view" //Recipients can view (and download, if allowed)
+	AccessLevelEdit = "edit" //Recipients can also upload into a shared folder
+)
+
+// currentSchemaVersion is bumped whenever ShareOption gains fields that need a
+// default other than their zero value. See migrate().
+const currentSchemaVersion = 2
+
+// bcrypt refuses passwords longer than 72 bytes
+const maxPasswordLength = 72
+
+type ShareOption struct {
+	UUID            string
+	PathHash        string //Path Hash, the key for loading a share from vpath and fsh specific config
+	FileVirtualPath string
+	FileRealPath    string
+	Owner           string
+	Accessibles     []string //Use to store username or group names if permission is groups or users
+	Permission      string   //Access permission, allow {anyone / signedin / samegroup / groups / users}
+	IsFolder        bool
+
+	//Schema version 2
+	SchemaVersion int
+	AccessLevel   string //{view / edit}, edit only takes effect on folders
+	AllowDownload bool   //Recipients can download the file (or the folder as zip)
+	ShowFileList  bool   //Recipients can browse the content of a shared folder
+	PasswordHash  string //bcrypt hash of the share password, empty if not protected
+	ExpireAt      int64  //Unix timestamp (seconds) this share expires at, 0 = never
+	CreatedAt     int64  //Unix timestamp (seconds) this share is created at
+}
+
+// ShareSettings are the user editable options of a share. On update, nil
+// fields are left unchanged.
+type ShareSettings struct {
+	Permission    *string
+	Accessibles   []string //Only read when Permission is set
+	AccessLevel   *string
+	AllowDownload *bool
+	ShowFileList  *bool
+	Password      *string //Empty string removes the password
+	ExpireAt      *int64  //0 removes the expiry
+}
+
+// DefaultShareOption returns a share with the default settings: anyone with
+// the link can view and download it, forever.
+func DefaultShareOption() *ShareOption {
+	return &ShareOption{
+		Accessibles:   []string{},
+		Permission:    PermissionAnyone,
+		SchemaVersion: currentSchemaVersion,
+		AccessLevel:   AccessLevelView,
+		AllowDownload: true,
+		ShowFileList:  true,
+		CreatedAt:     time.Now().Unix(),
+	}
+}
+
+// migrate upgrades a share loaded from an older database record. It returns
+// true if the record was changed and should be written back.
+func (s *ShareOption) migrate() bool {
+	if s.SchemaVersion >= currentSchemaVersion {
+		return false
+	}
+	//Version 1 shares had no notion of these and behaved as below
+	s.AccessLevel = AccessLevelView
+	s.AllowDownload = true
+	s.ShowFileList = true
+	if s.Accessibles == nil {
+		s.Accessibles = []string{}
+	}
+	s.SchemaVersion = currentSchemaVersion
+	return true
+}
+
+// Clone returns a deep copy of this share, so callers can read it without
+// racing against updates.
+func (s *ShareOption) Clone() *ShareOption {
+	c := *s
+	c.Accessibles = append([]string{}, s.Accessibles...)
+	return &c
+}
+
 func (s *ShareOption) IsOwnedBy(username string) bool {
 func (s *ShareOption) IsOwnedBy(username string) bool {
 	return s.Owner == username
 	return s.Owner == username
 }
 }
 
 
 func (s *ShareOption) IsAccessibleBy(username string, usergroup []string) bool {
 func (s *ShareOption) IsAccessibleBy(username string, usergroup []string) bool {
-	if s.Permission == "anyone" || s.Permission == "signedin" {
+	if s.Permission == PermissionAnyone || s.Permission == PermissionSignedIn {
 		return true
 		return true
-	} else if s.Permission == "samegroup" || s.Permission == "groups" {
+	} else if s.Permission == PermissionSameGroup || s.Permission == PermissionGroups {
 		for _, thisUserGroup := range usergroup {
 		for _, thisUserGroup := range usergroup {
 			if stringInSlice(thisUserGroup, s.Accessibles) {
 			if stringInSlice(thisUserGroup, s.Accessibles) {
 				//User's group is in the allowed group
 				//User's group is in the allowed group
 				return true
 				return true
 			}
 			}
 		}
 		}
-	} else if s.Permission == "users" {
+	} else if s.Permission == PermissionUsers {
 		if stringInSlice(username, s.Accessibles) {
 		if stringInSlice(username, s.Accessibles) {
 			//User's name is in the allowed group
 			//User's name is in the allowed group
 			return true
 			return true
@@ -25,3 +134,125 @@ func (s *ShareOption) IsAccessibleBy(username string, usergroup []string) bool {
 	}
 	}
 	return false
 	return false
 }
 }
+
+// RequireLogin reports whether the recipient must be signed in to open this share
+func (s *ShareOption) RequireLogin() bool {
+	return s.Permission != PermissionAnyone
+}
+
+// IsExpired reports whether this share has an expiry and it has passed
+func (s *ShareOption) IsExpired(now time.Time) bool {
+	return s.ExpireAt > 0 && now.Unix() >= s.ExpireAt
+}
+
+// HasPassword reports whether this share is password protected
+func (s *ShareOption) HasPassword() bool {
+	return s.PasswordHash != ""
+}
+
+// CanUpload reports whether recipients may upload files into this share
+func (s *ShareOption) CanUpload() bool {
+	return s.IsFolder && s.AccessLevel == AccessLevelEdit
+}
+
+// SetPassword hashes and stores the given password. An empty password
+// removes the protection.
+func (s *ShareOption) SetPassword(password string) error {
+	if password == "" {
+		s.PasswordHash = ""
+		return nil
+	}
+	if len(password) > maxPasswordLength {
+		return errors.New("password too long")
+	}
+	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	if err != nil {
+		return err
+	}
+	s.PasswordHash = string(hash)
+	return nil
+}
+
+// CheckPassword reports whether the given password unlocks this share. A
+// share without password accepts anything.
+func (s *ShareOption) CheckPassword(password string) bool {
+	if !s.HasPassword() {
+		return true
+	}
+	return bcrypt.CompareHashAndPassword([]byte(s.PasswordHash), []byte(password)) == nil
+}
+
+// Apply validates the given settings and writes them into this share. The
+// share is left untouched if any setting is invalid.
+func (s *ShareOption) Apply(settings ShareSettings, now time.Time) error {
+	next := s.Clone()
+	if settings.Permission != nil {
+		accessibles := cleanNameList(settings.Accessibles)
+		switch *settings.Permission {
+		case PermissionAnyone, PermissionSignedIn:
+			accessibles = []string{}
+		case PermissionSameGroup:
+			//Accessibles holds the owner's groups, filled in by the caller
+		case PermissionGroups, PermissionUsers:
+			if len(accessibles) == 0 {
+				return errors.New("select at least one " + strings.TrimSuffix(*settings.Permission, "s"))
+			}
+		default:
+			return errors.New("invalid share permission")
+		}
+		next.Permission = *settings.Permission
+		next.Accessibles = accessibles
+	}
+
+	if settings.AccessLevel != nil {
+		switch *settings.AccessLevel {
+		case AccessLevelView:
+		case AccessLevelEdit:
+			if !next.IsFolder {
+				return errors.New("edit access is only available for folders")
+			}
+		default:
+			return errors.New("invalid access level")
+		}
+		next.AccessLevel = *settings.AccessLevel
+	}
+
+	if settings.AllowDownload != nil {
+		next.AllowDownload = *settings.AllowDownload
+	}
+
+	if settings.ShowFileList != nil {
+		next.ShowFileList = *settings.ShowFileList
+	}
+
+	if settings.ExpireAt != nil {
+		if *settings.ExpireAt < 0 {
+			return errors.New("invalid expiry date")
+		}
+		if *settings.ExpireAt != 0 && *settings.ExpireAt <= now.Unix() {
+			return errors.New("expiry date must be in the future")
+		}
+		next.ExpireAt = *settings.ExpireAt
+	}
+
+	if settings.Password != nil {
+		if err := next.SetPassword(*settings.Password); err != nil {
+			return err
+		}
+	}
+
+	*s = *next
+	return nil
+}
+
+// cleanNameList trims the given names and drops empty and duplicated ones
+func cleanNameList(names []string) []string {
+	results := []string{}
+	for _, name := range names {
+		name = strings.TrimSpace(name)
+		if name != "" && !stringInSlice(name, results) {
+			results = append(results, name)
+		}
+	}
+	return results
+}

+ 387 - 0
src/mod/share/shareEntry/shareOptions_test.go

@@ -0,0 +1,387 @@
+package shareEntry
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+func strPtr(s string) *string { return &s }
+func boolPtr(b bool) *bool    { return &b }
+func int64Ptr(i int64) *int64 { return &i }
+
+// writeUserFile creates <root>/users/<username>/<name> so a "testfsh:/<name>"
+// virtual path resolves for that user. It returns the virtual path.
+func writeUserFile(t *testing.T, root, username, name string, isDir bool) string {
+	t.Helper()
+	target := filepath.Join(root, "users", username, name)
+	if isDir {
+		if err := os.MkdirAll(target, 0755); err != nil {
+			t.Fatalf("MkdirAll: %v", err)
+		}
+	} else {
+		if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
+			t.Fatalf("MkdirAll: %v", err)
+		}
+		if err := os.WriteFile(target, []byte("hello"), 0644); err != nil {
+			t.Fatalf("WriteFile: %v", err)
+		}
+	}
+	return "testfsh:/" + name
+}
+
+func TestDefaultShareOption(t *testing.T) {
+	so := DefaultShareOption()
+	if so.Permission != PermissionAnyone {
+		t.Errorf("Permission = %q, want %q", so.Permission, PermissionAnyone)
+	}
+	if so.AccessLevel != AccessLevelView {
+		t.Errorf("AccessLevel = %q, want %q", so.AccessLevel, AccessLevelView)
+	}
+	if !so.AllowDownload || !so.ShowFileList {
+		t.Error("default share should allow download and show the file list")
+	}
+	if so.HasPassword() || so.ExpireAt != 0 {
+		t.Error("default share should have no password and no expiry")
+	}
+}
+
+func TestShareOption_Migrate(t *testing.T) {
+	legacy := &ShareOption{UUID: "u", PathHash: "p", Permission: PermissionSignedIn}
+	if !legacy.migrate() {
+		t.Fatal("legacy share should be migrated")
+	}
+	if !legacy.AllowDownload || !legacy.ShowFileList || legacy.AccessLevel != AccessLevelView {
+		t.Errorf("legacy share should keep its old behaviour, got %+v", legacy)
+	}
+	if legacy.Permission != PermissionSignedIn {
+		t.Errorf("migration must not change the permission, got %q", legacy.Permission)
+	}
+	if legacy.migrate() {
+		t.Error("an up to date share should not be migrated again")
+	}
+
+	//A current share that disabled download must stay disabled
+	current := DefaultShareOption()
+	current.AllowDownload = false
+	if current.migrate() || current.AllowDownload {
+		t.Error("migration must not touch a current share")
+	}
+}
+
+func TestShareOption_IsExpired(t *testing.T) {
+	now := time.Unix(1_000_000, 0)
+	tests := []struct {
+		name     string
+		expireAt int64
+		want     bool
+	}{
+		{"never", 0, false},
+		{"future", now.Unix() + 60, false},
+		{"exactly now", now.Unix(), true},
+		{"past", now.Unix() - 60, true},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			so := &ShareOption{ExpireAt: tc.expireAt}
+			if got := so.IsExpired(now); got != tc.want {
+				t.Errorf("IsExpired() = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestShareOption_Password(t *testing.T) {
+	so := DefaultShareOption()
+	if !so.CheckPassword("anything") {
+		t.Error("a share without password should accept any password")
+	}
+	if err := so.SetPassword("s3cret"); err != nil {
+		t.Fatalf("SetPassword: %v", err)
+	}
+	if !so.HasPassword() {
+		t.Fatal("HasPassword should be true after SetPassword")
+	}
+	if so.PasswordHash == "s3cret" {
+		t.Fatal("password must not be stored in plain text")
+	}
+	if !so.CheckPassword("s3cret") {
+		t.Error("correct password rejected")
+	}
+	if so.CheckPassword("wrong") || so.CheckPassword("") {
+		t.Error("wrong password accepted")
+	}
+	if err := so.SetPassword(""); err != nil {
+		t.Fatalf("SetPassword(\"\"): %v", err)
+	}
+	if so.HasPassword() {
+		t.Error("empty password should remove the protection")
+	}
+
+	tooLong := make([]byte, maxPasswordLength+1)
+	for i := range tooLong {
+		tooLong[i] = 'a'
+	}
+	if err := so.SetPassword(string(tooLong)); err == nil {
+		t.Error("expected an error for a password over the bcrypt limit")
+	}
+}
+
+func TestShareOption_Apply(t *testing.T) {
+	now := time.Unix(1_000_000, 0)
+	tests := []struct {
+		name     string
+		isFolder bool
+		settings ShareSettings
+		wantErr  bool
+		check    func(t *testing.T, so *ShareOption)
+	}{
+		{
+			name:     "users with list",
+			settings: ShareSettings{Permission: strPtr(PermissionUsers), Accessibles: []string{" alice ", "bob", "alice", ""}},
+			check: func(t *testing.T, so *ShareOption) {
+				if so.Permission != PermissionUsers || len(so.Accessibles) != 2 {
+					t.Errorf("got %q %v, want users [alice bob]", so.Permission, so.Accessibles)
+				}
+			},
+		},
+		{
+			name:     "users without list",
+			settings: ShareSettings{Permission: strPtr(PermissionUsers)},
+			wantErr:  true,
+		},
+		{
+			name:     "anyone clears list",
+			settings: ShareSettings{Permission: strPtr(PermissionAnyone), Accessibles: []string{"x"}},
+			check: func(t *testing.T, so *ShareOption) {
+				if len(so.Accessibles) != 0 {
+					t.Errorf("Accessibles = %v, want empty", so.Accessibles)
+				}
+			},
+		},
+		{
+			name:     "unknown permission",
+			settings: ShareSettings{Permission: strPtr("everyone")},
+			wantErr:  true,
+		},
+		{
+			name:     "edit on file",
+			settings: ShareSettings{AccessLevel: strPtr(AccessLevelEdit)},
+			wantErr:  true,
+		},
+		{
+			name:     "edit on folder",
+			isFolder: true,
+			settings: ShareSettings{AccessLevel: strPtr(AccessLevelEdit)},
+			check: func(t *testing.T, so *ShareOption) {
+				if !so.CanUpload() {
+					t.Error("edit folder share should allow upload")
+				}
+			},
+		},
+		{
+			name:     "unknown access level",
+			settings: ShareSettings{AccessLevel: strPtr("admin")},
+			wantErr:  true,
+		},
+		{
+			name:     "expiry in the past",
+			settings: ShareSettings{ExpireAt: int64Ptr(now.Unix() - 1)},
+			wantErr:  true,
+		},
+		{
+			name:     "expiry in the future",
+			settings: ShareSettings{ExpireAt: int64Ptr(now.Unix() + 3600)},
+			check: func(t *testing.T, so *ShareOption) {
+				if so.ExpireAt != now.Unix()+3600 {
+					t.Errorf("ExpireAt = %d", so.ExpireAt)
+				}
+			},
+		},
+		{
+			name:     "flags and password",
+			settings: ShareSettings{AllowDownload: boolPtr(false), ShowFileList: boolPtr(false), Password: strPtr("pw")},
+			check: func(t *testing.T, so *ShareOption) {
+				if so.AllowDownload || so.ShowFileList || !so.CheckPassword("pw") || so.CheckPassword("nope") {
+					t.Errorf("flags or password not applied: %+v", so)
+				}
+			},
+		},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			so := DefaultShareOption()
+			so.IsFolder = tc.isFolder
+			before := *so
+			err := so.Apply(tc.settings, now)
+			if tc.wantErr {
+				if err == nil {
+					t.Fatal("expected an error")
+				}
+				if so.Permission != before.Permission || so.AccessLevel != before.AccessLevel || so.ExpireAt != before.ExpireAt {
+					t.Error("a failed Apply must leave the share untouched")
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if tc.check != nil {
+				tc.check(t, so)
+			}
+		})
+	}
+}
+
+func TestCreateShare_MultipleSharesOnOnePath(t *testing.T) {
+	fsh, root := newTestFSH(t)
+	vpath := writeUserFile(t, root, "alice", "report.pdf", false)
+	table := newTestTable(t)
+
+	first, err := table.CreateShare(fsh, vpath, "alice", ShareSettings{})
+	if err != nil {
+		t.Fatalf("CreateShare: %v", err)
+	}
+	second, err := table.CreateShare(fsh, vpath, "alice", ShareSettings{Password: strPtr("pw")})
+	if err != nil {
+		t.Fatalf("CreateShare: %v", err)
+	}
+	if first.UUID == second.UUID {
+		t.Fatal("CreateShare should always create a new share")
+	}
+
+	shares := table.GetSharesFromPathHash(first.PathHash)
+	if len(shares) != 2 {
+		t.Fatalf("got %d shares on the path, want 2", len(shares))
+	}
+	if !shares[1].HasPassword() {
+		t.Error("second share should be password protected")
+	}
+
+	//The legacy get-or-create entry point returns the first share
+	legacy, err := table.CreateNewShare(fsh, vpath, "alice", nil)
+	if err != nil {
+		t.Fatalf("CreateNewShare: %v", err)
+	}
+	if legacy.UUID != first.UUID {
+		t.Errorf("CreateNewShare returned %q, want the first share %q", legacy.UUID, first.UUID)
+	}
+
+	//Removing one share keeps the other
+	if err := table.DeleteShareByUUID(first.UUID); err != nil {
+		t.Fatalf("DeleteShareByUUID: %v", err)
+	}
+	if !table.FileIsShared(first.PathHash) {
+		t.Error("path should still be shared by the second share")
+	}
+	if err := table.DeleteShareByPathHash(first.PathHash); err != nil {
+		t.Fatalf("DeleteShareByPathHash: %v", err)
+	}
+	if table.FileIsShared(first.PathHash) || table.GetShareObjectFromUUID(second.UUID) != nil {
+		t.Error("deleting by path hash should remove every share on the path")
+	}
+}
+
+func TestCreateShare_InvalidSettings(t *testing.T) {
+	fsh, root := newTestFSH(t)
+	vpath := writeUserFile(t, root, "alice", "notes.txt", false)
+	table := newTestTable(t)
+
+	if _, err := table.CreateShare(fsh, vpath, "alice", ShareSettings{AccessLevel: strPtr(AccessLevelEdit)}); err == nil {
+		t.Fatal("expected an error for edit access on a file")
+	}
+	if len(table.ListAllShares()) != 0 {
+		t.Error("an invalid share must not be stored")
+	}
+}
+
+func TestUpdateShare(t *testing.T) {
+	fsh, root := newTestFSH(t)
+	vpath := writeUserFile(t, root, "alice", "album", true)
+	table := newTestTable(t)
+
+	so, err := table.CreateShare(fsh, vpath, "alice", ShareSettings{})
+	if err != nil {
+		t.Fatalf("CreateShare: %v", err)
+	}
+	if !so.IsFolder {
+		t.Fatal("share of a directory should be a folder share")
+	}
+
+	updated, err := table.UpdateShare(so.UUID, ShareSettings{AccessLevel: strPtr(AccessLevelEdit), AllowDownload: boolPtr(false)})
+	if err != nil {
+		t.Fatalf("UpdateShare: %v", err)
+	}
+	if !updated.CanUpload() || updated.AllowDownload {
+		t.Errorf("update not applied: %+v", updated)
+	}
+
+	//The change survives a reload from the database
+	reloaded := NewShareEntryTable(table.Database).GetShareObjectFromUUID(so.UUID)
+	if reloaded == nil || !reloaded.CanUpload() || reloaded.AllowDownload {
+		t.Errorf("update not persisted: %+v", reloaded)
+	}
+
+	if _, err := table.UpdateShare(so.UUID, ShareSettings{Permission: strPtr("bogus")}); err == nil {
+		t.Error("expected an error for an invalid update")
+	}
+	if _, err := table.UpdateShare("missing", ShareSettings{}); err == nil {
+		t.Error("expected an error for an unknown share")
+	}
+}
+
+func TestGetters_ReturnCopies(t *testing.T) {
+	table := newTestTable(t)
+	insertShareOption(table, makeShareOption("uuid-copy", "hash-copy", "alice"))
+
+	got := table.GetShareObjectFromUUID("uuid-copy")
+	got.Permission = PermissionUsers
+	got.Accessibles = append(got.Accessibles, "mallory")
+
+	again := table.GetShareObjectFromUUID("uuid-copy")
+	if again.Permission != PermissionAnyone || len(again.Accessibles) != 0 {
+		t.Errorf("mutating a returned share leaked into the table: %+v", again)
+	}
+}
+
+func TestRemoveExpiredShares(t *testing.T) {
+	table := newTestTable(t)
+	now := time.Unix(2_000_000, 0)
+
+	expired := makeShareOption("uuid-old", "hash-a", "alice")
+	expired.ExpireAt = now.Unix() - 10
+	active := makeShareOption("uuid-new", "hash-a", "alice")
+	active.ExpireAt = now.Unix() + 10
+	forever := makeShareOption("uuid-forever", "hash-b", "alice")
+	for _, so := range []*ShareOption{expired, active, forever} {
+		if err := table.AddShare(so); err != nil {
+			t.Fatalf("AddShare: %v", err)
+		}
+	}
+
+	removed := table.RemoveExpiredShares(now)
+	if len(removed) != 1 || removed[0].UUID != "uuid-old" {
+		t.Fatalf("removed = %v, want only uuid-old", removed)
+	}
+	if table.GetShareObjectFromUUID("uuid-old") != nil {
+		t.Error("expired share should be gone")
+	}
+	if table.GetShareObjectFromUUID("uuid-new") == nil || table.GetShareObjectFromUUID("uuid-forever") == nil {
+		t.Error("active shares must be kept")
+	}
+	if len(NewShareEntryTable(table.Database).ListAllShares()) != 2 {
+		t.Error("expired share should also be removed from the database")
+	}
+}
+
+func TestFileIsShared_IgnoresExpired(t *testing.T) {
+	table := newTestTable(t)
+	so := makeShareOption("uuid-exp", "hash-exp", "alice")
+	so.ExpireAt = time.Now().Unix() - 1
+	insertShareOption(table, so)
+
+	if table.FileIsShared("hash-exp") {
+		t.Error("a path with only expired shares should not count as shared")
+	}
+}

+ 195 - 0
src/mod/share/upload.go

@@ -0,0 +1,195 @@
+package share
+
+/*
+	Share upload
+
+	Recipients of a folder share with the "edit" access level can upload files
+	into the shared folder:
+
+		POST /share/upload/{uuid}/{optional/sub/folder}
+		multipart/form-data, one or more "file" fields
+
+	Files are written as the share owner, count against the owner's storage
+	quota and never overwrite an existing file (a "name (1).ext" copy is made).
+*/
+
+import (
+	"errors"
+	"io"
+	"net/http"
+	"path"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	"imuslab.com/arozos/mod/filesystem/arozfs"
+	"imuslab.com/arozos/mod/info/logger"
+	"imuslab.com/arozos/mod/utils"
+)
+
+// handleShareUpload handles POST /share/upload/{uuid}/{subfolder...}
+func (s *Manager) handleShareUpload(w http.ResponseWriter, r *http.Request, id string, subfolder string) {
+	so := s.lookupActiveShare(id)
+	if so == nil {
+		http.NotFound(w, r)
+		return
+	}
+
+	if r.Method != http.MethodPost {
+		w.WriteHeader(http.StatusMethodNotAllowed)
+		utils.SendErrorResponse(w, "Method not allowed")
+		return
+	}
+
+	if !s.authorizeShareAccess(w, r, so, true) {
+		return
+	}
+
+	if !so.CanUpload() {
+		w.WriteHeader(http.StatusForbidden)
+		utils.SendErrorResponse(w, "Upload is not allowed on this share")
+		return
+	}
+
+	if subfolder != "" && !so.ShowFileList {
+		w.WriteHeader(http.StatusForbidden)
+		utils.SendErrorResponse(w, "The content of this folder is not shared")
+		return
+	}
+
+	owner, err := s.shareOwnerOf(so)
+	if err != nil {
+		w.WriteHeader(http.StatusForbidden)
+		utils.SendErrorResponse(w, "Share account not exists")
+		return
+	}
+
+	accessMode := owner.GetPathAccessPermission(so.FileVirtualPath)
+	if accessMode != arozfs.FsReadWrite {
+		w.WriteHeader(http.StatusForbidden)
+		utils.SendErrorResponse(w, "The shared folder is read only")
+		return
+	}
+
+	targetFsh, err := owner.GetFileSystemHandlerFromVirtualPath(so.FileVirtualPath)
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		utils.SendErrorResponse(w, "Unable to load shared folder")
+		return
+	}
+	fshAbs := targetFsh.FileSystemAbstraction
+	rootRpath, err := fshAbs.VirtualPathToRealPath(so.FileVirtualPath, owner.Username)
+	if err != nil || !fshAbs.IsDir(rootRpath) {
+		http.NotFound(w, r)
+		return
+	}
+
+	targetDir := rootRpath
+	if subfolder != "" {
+		targetDir, err = arozfs.ResolvePathWithinRoot(rootRpath, subfolder)
+		if err != nil || !fshAbs.IsDir(targetDir) {
+			w.WriteHeader(http.StatusBadRequest)
+			utils.SendErrorResponse(w, "Invalid upload folder")
+			return
+		}
+	}
+
+	//Content-Length is the whole multipart body, a slight over estimate of the
+	//file sizes. Good enough to refuse an upload that cannot fit.
+	if r.ContentLength > 0 && !owner.StorageQuota.HaveSpace(r.ContentLength) {
+		w.WriteHeader(http.StatusInsufficientStorage)
+		utils.SendErrorResponse(w, "Storage quota of the share owner exceeded")
+		return
+	}
+
+	reader, err := r.MultipartReader()
+	if err != nil {
+		w.WriteHeader(http.StatusBadRequest)
+		utils.SendErrorResponse(w, "Invalid upload request")
+		return
+	}
+
+	uploaded := []string{}
+	for {
+		part, err := reader.NextPart()
+		if err != nil {
+			//io.EOF: all parts read. Anything else is a broken request.
+			if !errors.Is(err, io.EOF) {
+				logger.PrintAndLog("Share", "Share upload interrupted", err)
+			}
+			break
+		}
+
+		if part.FormName() != "file" || part.FileName() == "" {
+			part.Close()
+			continue
+		}
+
+		filename, err := sanitizeUploadFilename(part.FileName())
+		if err != nil {
+			part.Close()
+			w.WriteHeader(http.StatusBadRequest)
+			utils.SendErrorResponse(w, err.Error())
+			return
+		}
+
+		destination := uniqueDestination(fshAbs.FileExists, targetDir, filename)
+		err = fshAbs.WriteStream(destination, part, 0775)
+		part.Close()
+		if err != nil {
+			logger.PrintAndLog("Share", "Unable to write uploaded file to share "+so.UUID, err)
+			w.WriteHeader(http.StatusInternalServerError)
+			utils.SendErrorResponse(w, "Unable to write uploaded file")
+			return
+		}
+
+		//Count the file against the owner's quota
+		if vpath, err := fshAbs.RealPathToVirtualPath(destination, owner.Username); err == nil {
+			owner.SetOwnerOfFile(targetFsh, vpath)
+		}
+		uploaded = append(uploaded, arozfs.Base(destination))
+	}
+
+	if len(uploaded) == 0 {
+		w.WriteHeader(http.StatusBadRequest)
+		utils.SendErrorResponse(w, "No file uploaded")
+		return
+	}
+
+	logger.PrintAndLog("Share", strconv.Itoa(len(uploaded))+" file(s) uploaded to share "+so.UUID+" ("+so.FileVirtualPath+")", nil)
+	sendJSON(w, uploaded)
+}
+
+// sanitizeUploadFilename keeps only the base name of an uploaded file and
+// refuses names that cannot be stored safely.
+func sanitizeUploadFilename(name string) (string, error) {
+	//Browsers may send a path (e.g. from a folder upload). Keep the last element.
+	name = strings.ReplaceAll(name, "\\", "/")
+	name = strings.TrimSpace(path.Base(name))
+	//Same rule as the File Manager upload: no % in stored filenames
+	name = strings.ReplaceAll(name, "%", "_")
+	if name == "" || name == "." || name == ".." || name == "/" {
+		return "", errors.New("Invalid filename")
+	}
+	if strings.ContainsAny(name, "\x00:*?\"<>|") {
+		return "", errors.New("Filename contains invalid characters: " + name)
+	}
+	return name, nil
+}
+
+// uniqueDestination returns dir/name, or dir/name (n).ext if that is taken
+func uniqueDestination(exists func(string) bool, dir string, name string) string {
+	candidate := arozfs.ToSlash(filepath.Join(dir, name))
+	if !exists(candidate) {
+		return candidate
+	}
+
+	ext := path.Ext(name)
+	stem := strings.TrimSuffix(name, ext)
+	for i := 1; ; i++ {
+		candidate = arozfs.ToSlash(filepath.Join(dir, stem+" ("+strconv.Itoa(i)+")"+ext))
+		if !exists(candidate) {
+			return candidate
+		}
+	}
+}

+ 4 - 2
src/notification_settings.go

@@ -45,9 +45,11 @@ func NotificationSettingInit() {
 		RequireAdmin: false,
 		RequireAdmin: false,
 	})
 	})
 
 
-	//Router for endpoints available to any logged-in user.
+	//Router for endpoints available to any logged-in user. They only touch
+	//the caller's own notifications and preferences, so no module permission
+	//is required (users without System Setting must still get notified).
 	userRouter := prout.NewModuleRouter(prout.RouterOption{
 	userRouter := prout.NewModuleRouter(prout.RouterOption{
-		ModuleName:  "System Setting",
+		ModuleName:  "",
 		AdminOnly:   false,
 		AdminOnly:   false,
 		UserHandler: userHandler,
 		UserHandler: userHandler,
 		DeniedHandler: func(w http.ResponseWriter, r *http.Request) {
 		DeniedHandler: func(w http.ResponseWriter, r *http.Request) {

+ 16 - 0
src/system/share/downloadPage.html

@@ -406,6 +406,7 @@
       <div class="card-footer">
       <div class="card-footer">
         <span class="card-footer-item">ID: <strong>{{reqid}}</strong></span>
         <span class="card-footer-item">ID: <strong>{{reqid}}</strong></span>
         <span class="card-footer-item">Timestamp: <strong>{{reqtime}}</strong></span>
         <span class="card-footer-item">Timestamp: <strong>{{reqtime}}</strong></span>
+        <span class="card-footer-item" id="expiryNote" style="display:none;">Available until: <strong id="expiryDate"></strong></span>
       </div>
       </div>
     </div>
     </div>
   </main>
   </main>
@@ -415,6 +416,9 @@
   </footer>
   </footer>
 
 
   <script>
   <script>
+    // Share options set by the owner (download allowed, expiry ...)
+    var SHARE_SETTINGS = {{sharesettings}};
+
     var ICONS = {
     var ICONS = {
       moon: '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path></svg>',
       moon: '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path></svg>',
       sun: '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><line x1="12" y1="2" x2="12" y2="5"></line><line x1="12" y1="19" x2="12" y2="22"></line><line x1="4.22" y1="4.22" x2="6.34" y2="6.34"></line><line x1="17.66" y1="17.66" x2="19.78" y2="19.78"></line><line x1="2" y1="12" x2="5" y2="12"></line><line x1="19" y1="12" x2="22" y2="12"></line><line x1="4.22" y1="19.78" x2="6.34" y2="17.66"></line><line x1="17.66" y1="6.34" x2="19.78" y2="4.22"></line></svg>',
       sun: '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><line x1="12" y1="2" x2="12" y2="5"></line><line x1="12" y1="19" x2="12" y2="22"></line><line x1="4.22" y1="4.22" x2="6.34" y2="6.34"></line><line x1="17.66" y1="17.66" x2="19.78" y2="19.78"></line><line x1="2" y1="12" x2="5" y2="12"></line><line x1="19" y1="12" x2="22" y2="12"></line><line x1="4.22" y1="19.78" x2="6.34" y2="17.66"></line><line x1="17.66" y1="6.34" x2="19.78" y2="4.22"></line></svg>',
@@ -472,6 +476,18 @@
       document.getElementById("fileIcon").innerHTML = icon;
       document.getElementById("fileIcon").innerHTML = icon;
     })();
     })();
 
 
+    // Apply the owner's share options
+    (function() {
+      if (!SHARE_SETTINGS.allowDownload) {
+        var dl = document.getElementById('downloadBtn');
+        if (dl) dl.remove();
+      }
+      if (SHARE_SETTINGS.expireAt > 0) {
+        document.getElementById('expiryDate').textContent = new Date(SHARE_SETTINGS.expireAt * 1000).toLocaleString();
+        document.getElementById('expiryNote').style.display = '';
+      }
+    })();
+
     // Show preview section only if previewer has content
     // Show preview section only if previewer has content
     (function() {
     (function() {
       var inner = document.querySelector('.preview-inner');
       var inner = document.querySelector('.preview-inner');

+ 147 - 12
src/system/share/downloadPageFolder.html

@@ -285,6 +285,24 @@
       line-height: 1.5;
       line-height: 1.5;
     }
     }
 
 
+    .layout.single { grid-template-columns: minmax(0, 420px); justify-content: center; }
+
+    .upload-status {
+      font-size: 0.78rem;
+      color: var(--muted);
+      padding: 0 26px 18px;
+      line-height: 1.5;
+      display: none;
+    }
+    .upload-status.error { color: #d64545; }
+    .upload-bar-wrap {
+      height: 4px; border-radius: 4px; background: var(--border);
+      overflow: hidden; margin-top: 6px;
+    }
+    .upload-bar { height: 100%; width: 0%; background: var(--accent); transition: width 0.2s; }
+
+    .browser-card.drop-target { outline: 2px dashed var(--accent); outline-offset: -6px; }
+
     .info-footer {
     .info-footer {
       padding: 12px 26px;
       padding: 12px 26px;
       background: var(--surface2);
       background: var(--surface2);
@@ -766,6 +784,11 @@
             <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
             <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
             <span>Download All</span>
             <span>Download All</span>
           </button>
           </button>
+          <button class="btn btn-secondary" id="uploadBtn" style="display:none;" onclick="document.getElementById('uploadInput').click()">
+            <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
+            <span>Upload Files</span>
+          </button>
+          <input type="file" id="uploadInput" multiple style="display:none;" onchange="uploadFiles(this.files); this.value='';">
           <button class="btn btn-secondary" id="sharebtn" onclick="doShare()">
           <button class="btn btn-secondary" id="sharebtn" onclick="doShare()">
             <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line></svg>
             <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line></svg>
             <span>Share Link</span>
             <span>Share Link</span>
@@ -781,9 +804,15 @@
           Media files (video, audio, images) are already compressed, so this option can speed up zipping.
           Media files (video, audio, images) are already compressed, so this option can speed up zipping.
         </div>
         </div>
 
 
+        <div class="upload-status" id="uploadStatus">
+          <span id="uploadStatusText"></span>
+          <div class="upload-bar-wrap"><div class="upload-bar" id="uploadBar"></div></div>
+        </div>
+
         <div class="info-footer">
         <div class="info-footer">
           <span class="info-footer-item">ID: <strong>{{reqid}}</strong></span>
           <span class="info-footer-item">ID: <strong>{{reqid}}</strong></span>
           <span class="info-footer-item">Timestamp: <strong>{{reqtime}}</strong></span>
           <span class="info-footer-item">Timestamp: <strong>{{reqtime}}</strong></span>
+          <span class="info-footer-item" id="expiryNote" style="display:none;">Available until: <strong id="expiryDate"></strong></span>
         </div>
         </div>
       </div>
       </div>
 
 
@@ -991,6 +1020,8 @@
 
 
     var treeFileList = {{treelist}};
     var treeFileList = {{treelist}};
     var downloadUUID = '{{downloaduuid}}';
     var downloadUUID = '{{downloaduuid}}';
+    // Share options set by the owner (download allowed, folder content shown, upload ...)
+    var SHARE_SETTINGS = {{sharesettings}};
     var currentPath = '.';
     var currentPath = '.';
     var pathStack = [];
     var pathStack = [];
 
 
@@ -1007,10 +1038,12 @@
       return IMAGE_EXTS.has(ext) || VIDEO_PREVIEW_EXTS.has(ext) || AUDIO_PREVIEW_EXTS.has(ext) || ext === 'pdf' || TEXT_PREVIEW_EXTS.has(ext);
       return IMAGE_EXTS.has(ext) || VIDEO_PREVIEW_EXTS.has(ext) || AUDIO_PREVIEW_EXTS.has(ext) || ext === 'pdf' || TEXT_PREVIEW_EXTS.has(ext);
     }
     }
 
 
-    function openPreview(filename, url, ext) {
+    function openPreview(filename, url, ext, downloadUrl) {
       document.getElementById('preview-title').textContent = filename;
       document.getElementById('preview-title').textContent = filename;
       url = url.replace("//", "/"); // Workaround for url containing double slash
       url = url.replace("//", "/"); // Workaround for url containing double slash
-      document.getElementById('preview-dl-link').href = url;
+      var dlLink = document.getElementById('preview-dl-link');
+      dlLink.style.display = SHARE_SETTINGS.allowDownload ? '' : 'none';
+      dlLink.href = (downloadUrl || url).replace("//", "/");
       var body = document.getElementById('preview-body');
       var body = document.getElementById('preview-body');
       body.innerHTML = '';
       body.innerHTML = '';
       body.removeAttribute('style');
       body.removeAttribute('style');
@@ -1056,12 +1089,12 @@
       document.body.style.overflow = 'hidden';
       document.body.style.overflow = 'hidden';
     }
     }
 
 
-    function previewOrDownload(file, url) {
+    function previewOrDownload(file, viewUrl, downloadUrl) {
       var ext = file.Filename.split('.').pop().toLowerCase();
       var ext = file.Filename.split('.').pop().toLowerCase();
       if (isPreviewable(ext)) {
       if (isPreviewable(ext)) {
-        openPreview(file.Filename, url, ext);
-      } else {
-        window.open(url, '_blank');
+        openPreview(file.Filename, viewUrl, ext, downloadUrl);
+      } else if (SHARE_SETTINGS.allowDownload) {
+        window.open(downloadUrl, '_blank');
       }
       }
     }
     }
 
 
@@ -1128,8 +1161,9 @@
 
 
         var icon = iconSvg(file.Filename, file.IsDir);
         var icon = iconSvg(file.Filename, file.IsDir);
         var dlUrl = '../../share/download/' + downloadUUID + '/' + file.RelPath;
         var dlUrl = '../../share/download/' + downloadUUID + '/' + file.RelPath;
+        var viewUrl = '../../share/preview/' + downloadUUID + '/' + file.RelPath;
         var nameCell = '<div class="file-name">' + icon + '<span class="file-name-text">' + escHtml(file.Filename) + '</span></div>';
         var nameCell = '<div class="file-name">' + icon + '<span class="file-name-text">' + escHtml(file.Filename) + '</span></div>';
-        var dlCell = file.IsDir
+        var dlCell = (file.IsDir || !SHARE_SETTINGS.allowDownload)
           ? '<div></div>'
           ? '<div></div>'
           : '<a class="dl-btn" href="' + dlUrl + '" target="_blank" onclick="event.stopPropagation()" title="Download ' + escHtml(file.Filename) + '">' + ICONS.download + '</a>';
           : '<a class="dl-btn" href="' + dlUrl + '" target="_blank" onclick="event.stopPropagation()" title="Download ' + escHtml(file.Filename) + '">' + ICONS.download + '</a>';
 
 
@@ -1146,7 +1180,7 @@
           if (!file.IsDir) {
           if (!file.IsDir) {
             clearTimeout(singleClickTimer);
             clearTimeout(singleClickTimer);
             singleClickTimer = setTimeout(function() {
             singleClickTimer = setTimeout(function() {
-              previewOrDownload(file, dlUrl);
+              previewOrDownload(file, viewUrl, dlUrl);
             }, 220);
             }, 220);
           }
           }
         });
         });
@@ -1156,13 +1190,15 @@
           clearTimeout(singleClickTimer);
           clearTimeout(singleClickTimer);
           if (file.IsDir) {
           if (file.IsDir) {
             navigateTo(file.RelPath);
             navigateTo(file.RelPath);
-          } else {
+          } else if (SHARE_SETTINGS.allowDownload) {
             window.open(dlUrl, '_blank');
             window.open(dlUrl, '_blank');
+          } else {
+            previewOrDownload(file, viewUrl, dlUrl);
           }
           }
         });
         });
 
 
         if (IMAGE_EXTS.has(file.Filename.split('.').pop().toLowerCase())) {
         if (IMAGE_EXTS.has(file.Filename.split('.').pop().toLowerCase())) {
-          var thumbUrl = '../../share/download/' + downloadUUID + '/' + file.RelPath;
+          var thumbUrl = viewUrl;
           row.addEventListener('mouseenter', function(e) {
           row.addEventListener('mouseenter', function(e) {
             document.getElementById('thumb-img').src = thumbUrl;
             document.getElementById('thumb-img').src = thumbUrl;
             var popup = document.getElementById('thumb-popup');
             var popup = document.getElementById('thumb-popup');
@@ -1259,8 +1295,9 @@
         });
         });
       });
       });
 
 
-      if (totalSize > 0 && compressedSize / totalSize >= 0.5) {
-        document.getElementById('uncompressedCheck').checked = true;
+      var check = document.getElementById('uncompressedCheck');
+      if (check && totalSize > 0 && compressedSize / totalSize >= 0.5) {
+        check.checked = true;
       }
       }
     })();
     })();
 
 
@@ -1272,6 +1309,104 @@
       return parseFloat(m[1]) * (units[m[2]] || 1);
       return parseFloat(m[1]) * (units[m[2]] || 1);
     }
     }
 
 
+    // Apply the owner's share options
+    (function applyShareSettings() {
+      if (!SHARE_SETTINGS.allowDownload) {
+        ['downloadAllBtn'].forEach(function(id) {
+          var el = document.getElementById(id);
+          if (el) el.remove();
+        });
+        document.querySelectorAll('.compress-row, .compress-hint').forEach(function(el) { el.remove(); });
+      }
+      if (!SHARE_SETTINGS.showFileList) {
+        document.querySelector('.browser-card').style.display = 'none';
+        document.querySelector('.layout').classList.add('single');
+      }
+      if (SHARE_SETTINGS.canUpload) {
+        document.getElementById('uploadBtn').style.display = '';
+        enableDropUpload();
+      }
+      if (SHARE_SETTINGS.expireAt > 0) {
+        document.getElementById('expiryDate').textContent = new Date(SHARE_SETTINGS.expireAt * 1000).toLocaleString();
+        document.getElementById('expiryNote').style.display = '';
+      }
+    })();
+
+    // --- Upload (shares with edit access) ---
+    function currentUploadUrl() {
+      var url = '../../share/upload/' + encodeURIComponent(downloadUUID);
+      if (SHARE_SETTINGS.showFileList && currentPath !== '.') {
+        currentPath.split('/').forEach(function(seg) {
+          if (seg) url += '/' + encodeURIComponent(seg);
+        });
+      }
+      return url;
+    }
+
+    function setUploadStatus(text, progress, isError) {
+      var box = document.getElementById('uploadStatus');
+      box.style.display = 'block';
+      box.classList.toggle('error', !!isError);
+      document.getElementById('uploadStatusText').textContent = text;
+      document.getElementById('uploadBar').style.width = (progress || 0) + '%';
+    }
+
+    function uploadFiles(fileList) {
+      if (!fileList || fileList.length === 0) return;
+      var form = new FormData();
+      for (var i = 0; i < fileList.length; i++) {
+        form.append('file', fileList[i], fileList[i].name);
+      }
+
+      var btn = document.getElementById('uploadBtn');
+      btn.disabled = true;
+      setUploadStatus('Uploading ' + fileList.length + ' file(s)...', 0, false);
+
+      var xhr = new XMLHttpRequest();
+      xhr.open('POST', currentUploadUrl());
+      xhr.upload.onprogress = function(e) {
+        if (e.lengthComputable) {
+          setUploadStatus('Uploading ' + fileList.length + ' file(s)... ' + Math.round(e.loaded / e.total * 100) + '%', e.loaded / e.total * 100, false);
+        }
+      };
+      xhr.onload = function() {
+        btn.disabled = false;
+        var resp = null;
+        try { resp = JSON.parse(xhr.responseText); } catch (e) {}
+        if (xhr.status >= 200 && xhr.status < 300 && resp && !resp.error) {
+          setUploadStatus('Uploaded ' + resp.length + ' file(s). Refreshing...', 100, false);
+          setTimeout(function() { window.location.reload(); }, 700);
+        } else {
+          setUploadStatus((resp && resp.error) ? resp.error : ('Upload failed (HTTP ' + xhr.status + ')'), 0, true);
+        }
+      };
+      xhr.onerror = function() {
+        btn.disabled = false;
+        setUploadStatus('Network error, please try again.', 0, true);
+      };
+      xhr.send(form);
+    }
+
+    function enableDropUpload() {
+      var target = document.querySelector('.browser-card');
+      if (!target || !SHARE_SETTINGS.showFileList) return;
+      ['dragenter', 'dragover'].forEach(function(evt) {
+        target.addEventListener(evt, function(e) {
+          if (e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types, 'Files') >= 0) {
+            e.preventDefault();
+            target.classList.add('drop-target');
+          }
+        });
+      });
+      ['dragleave', 'drop'].forEach(function(evt) {
+        target.addEventListener(evt, function() { target.classList.remove('drop-target'); });
+      });
+      target.addEventListener('drop', function(e) {
+        e.preventDefault();
+        if (e.dataTransfer && e.dataTransfer.files.length > 0) uploadFiles(e.dataTransfer.files);
+      });
+    }
+
     updateBreadcrumb('.');
     updateBreadcrumb('.');
     renderFileList('.');
     renderFileList('.');
   </script>
   </script>

+ 293 - 0
src/system/share/password.html

@@ -0,0 +1,293 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+  <title>Password Required - {{hostname}} File Share</title>
+  <meta name="robots" content="noindex">
+  <script type="application/javascript" src="../../script/jquery.min.js"></script>
+  <link rel="icon" type="image/png" href="../../img/public/share/share.png" />
+  <style>
+    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+    :root {
+      --bg:        #f0f2f5;
+      --surface:   #ffffff;
+      --surface2:  #f7f8fa;
+      --border:    #e4e6ea;
+      --text:      #1a1a2e;
+      --muted:     #6b7280;
+      --accent:    #d4920d;
+      --accent-h:  #b87c09;
+      --accent-fg: #1a1a1a;
+      --shadow:    none;
+      --radius:    16px;
+      --trans:     0.2s ease;
+    }
+
+    [data-theme="dark"] {
+      --bg:        #0f0f12;
+      --surface:   #1c1c22;
+      --surface2:  #26262f;
+      --border:    #32323c;
+      --text:      #e8e8f0;
+      --muted:     #888899;
+      --accent:    #fbbf24;
+      --accent-h:  #f59e0b;
+      --shadow:    none;
+    }
+
+    @media (prefers-color-scheme: dark) {
+      :root:not([data-theme="light"]) {
+        --bg:        #0f0f12;
+        --surface:   #1c1c22;
+        --surface2:  #26262f;
+        --border:    #32323c;
+        --text:      #e8e8f0;
+        --muted:     #888899;
+        --accent:    #fbbf24;
+        --accent-h:  #f59e0b;
+        --shadow:    none;
+      }
+    }
+
+    body {
+      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+      background: var(--bg); color: var(--text);
+      min-height: 100vh; display: flex; flex-direction: column;
+      transition: background var(--trans), color var(--trans);
+    }
+
+    .icon {
+      width: 16px; height: 16px; display: inline-block; vertical-align: middle;
+      fill: none; stroke: currentColor; stroke-width: 1.9;
+      stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0;
+    }
+
+    .topbar {
+      display: flex; align-items: center; justify-content: space-between;
+      padding: 14px 28px; background: var(--surface);
+      border-bottom: 1px solid var(--border);
+      position: sticky; top: 0; z-index: 100;
+    }
+    .topbar-brand {
+      font-size: 0.95rem; font-weight: 700;
+      color: var(--accent); letter-spacing: 0.5px; text-decoration: none;
+    }
+    .topbar-brand span { color: var(--text); font-weight: 400; }
+
+    .theme-toggle {
+      background: var(--surface2); border: 1px solid var(--border);
+      border-radius: 50px; cursor: pointer; padding: 6px 14px;
+      font-size: 0.82rem; color: var(--muted);
+      display: flex; align-items: center; gap: 6px;
+      transition: border-color var(--trans), color var(--trans);
+    }
+    #themeIcon { display: inline-flex; align-items: center; justify-content: center; line-height: 0; }
+    .theme-toggle:hover { border-color: var(--accent); color: var(--text); }
+
+    .page-wrap {
+      flex: 1; display: flex; align-items: flex-start;
+      justify-content: center; padding: 40px 20px 100px;
+    }
+
+    .card {
+      background: var(--surface); border: 1px solid var(--border);
+      border-radius: var(--radius); box-shadow: var(--shadow);
+      width: 100%; max-width: 560px; overflow: hidden;
+    }
+
+    .card-hero {
+      padding: 36px 36px 28px;
+      display: flex; align-items: flex-start; gap: 28px;
+    }
+
+    .file-icon-wrap {
+      flex-shrink: 0; width: 80px; height: 80px; border-radius: 18px;
+      display: flex; align-items: center; justify-content: center;
+      background: var(--surface2); border: 1px solid var(--border);
+    }
+    .file-icon-wrap svg {
+      width: 40px; height: 40px; display: block;
+      stroke: currentColor; fill: none;
+      stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round;
+    }
+
+    .hero-info { flex: 1; min-width: 0; }
+    .hero-tag {
+      font-size: 0.72rem; font-weight: 700;
+      letter-spacing: 1.2px; text-transform: uppercase;
+      color: var(--accent); margin-bottom: 6px;
+    }
+    .hero-filename {
+      font-size: 1.45rem; font-weight: 700;
+      word-break: break-word; line-height: 1.25; margin-bottom: 8px;
+    }
+    .hero-host { font-size: 0.82rem; color: var(--muted); }
+
+    .divider { height: 1px; background: var(--border); margin: 0 36px; }
+
+    .meta-grid {
+      padding: 24px 36px;
+      display: grid;
+      grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+      gap: 16px;
+    }
+    .meta-item { display: flex; flex-direction: column; gap: 4px; }
+    .meta-label {
+      font-size: 0.7rem; font-weight: 700;
+      letter-spacing: 0.8px; text-transform: uppercase;
+      color: var(--muted);
+    }
+    .meta-value { font-size: 0.9rem; font-weight: 500; word-break: break-all; }
+
+    .card-footer {
+      padding: 14px 36px; background: var(--surface2);
+      border-top: 1px solid var(--border);
+      display: flex; gap: 24px; flex-wrap: wrap;
+    }
+    .card-footer-item { font-size: 0.75rem; color: var(--muted); }
+    .card-footer-item strong { color: var(--text); }
+
+    .page-footer {
+      text-align: center; padding: 16px;
+      font-size: 0.75rem; color: var(--muted);
+      border-top: 1px solid var(--border);
+    }
+    .page-footer a { color: var(--accent); text-decoration: none; }
+
+
+    .unlock-form { padding: 24px 36px 28px; display: flex; flex-direction: column; gap: 12px; }
+    .unlock-form label {
+      font-size: 0.7rem; font-weight: 700;
+      letter-spacing: 0.8px; text-transform: uppercase; color: var(--muted);
+    }
+    .pw-row { display: flex; gap: 10px; flex-wrap: wrap; }
+    .pw-field {
+      flex: 1; min-width: 200px; position: relative; display: flex; align-items: center;
+    }
+    .pw-field input {
+      width: 100%; padding: 12px 44px 12px 14px; border-radius: 10px;
+      border: 1px solid var(--border); background: var(--surface2); color: var(--text);
+      font-size: 0.95rem; outline: none; transition: border-color var(--trans);
+    }
+    .pw-field input:focus { border-color: var(--accent); }
+    .pw-toggle {
+      position: absolute; right: 8px; background: none; border: none; cursor: pointer;
+      color: var(--muted); padding: 6px; display: flex; align-items: center;
+    }
+    .pw-toggle:hover { color: var(--text); }
+    .btn {
+      display: inline-flex; align-items: center; justify-content: center; gap: 8px;
+      padding: 12px 26px; border-radius: 10px; font-size: 0.92rem; font-weight: 700;
+      cursor: pointer; border: none; background: var(--accent); color: var(--accent-fg);
+      transition: background var(--trans), transform 0.1s;
+    }
+    .btn:hover { background: var(--accent-h); }
+    .btn:active { transform: scale(0.97); }
+    .form-error {
+      font-size: 0.85rem; color: #d64545; display: flex; align-items: center; gap: 6px;
+    }
+    .form-error:empty { display: none; }
+    .form-hint { font-size: 0.85rem; color: var(--muted); line-height: 1.5; }
+
+    @media (max-width: 580px) {
+      .unlock-form { padding: 18px 20px 22px; }
+      .card-hero { padding: 24px 20px 18px; gap: 16px; }
+      .meta-grid { padding: 18px 20px; }
+      .card-footer { padding: 12px 20px; }
+      .divider { margin: 0 20px; }
+      .file-icon-wrap { width: 62px; height: 62px; border-radius: 14px; }
+      .hero-filename { font-size: 1.2rem; }
+    }
+  </style>
+</head>
+<body>
+
+  <header class="topbar">
+    <a class="topbar-brand" href="#">{{hostname}} <span>File Share</span></a>
+    <button class="theme-toggle" id="themeToggle" onclick="toggleTheme()">
+      <span id="themeIcon"></span>
+      <span id="themeLabel">Dark</span>
+    </button>
+  </header>
+
+  <main class="page-wrap">
+    <div class="card">
+      <div class="card-hero">
+        <div class="file-icon-wrap">
+          <svg viewBox="0 0 24 24"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/><circle cx="12" cy="16" r="1.2"/></svg>
+        </div>
+        <div class="hero-info">
+          <div class="hero-tag">Protected Share</div>
+          <div class="hero-filename">Password Required</div>
+          <div class="hero-host">Shared from <strong>{{hostname}}</strong></div>
+        </div>
+      </div>
+
+      <div class="divider"></div>
+
+      <form class="unlock-form" method="POST" action="/share/auth/{{reqid}}" autocomplete="off">
+        <p class="form-hint">The owner protected this share with a password. Enter it to open the shared content.</p>
+        <label for="password">Password</label>
+        <div class="pw-row">
+          <div class="pw-field">
+            <input id="password" name="password" type="password" required autofocus>
+            <button type="button" class="pw-toggle" onclick="togglePasswordVisibility()" title="Show password" aria-label="Show password">
+              <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"></path><circle cx="12" cy="12" r="3"></circle></svg>
+            </button>
+          </div>
+          <button class="btn" type="submit">
+            <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"></rect><path d="M8 11V7a4 4 0 0 1 7.8-1.2"></path></svg>
+            Unlock
+          </button>
+        </div>
+        <div class="form-error">{{error}}</div>
+      </form>
+
+      <div class="card-footer">
+        <span class="card-footer-item">ID: <strong>{{reqid}}</strong></span>
+        <span class="card-footer-item">Timestamp: <strong>{{reqtime}}</strong></span>
+      </div>
+    </div>
+  </main>
+
+  <footer class="page-footer">
+    Cloud File Sharing Interface - Powered by <a href="https://os.aroz.org">arozos</a>
+  </footer>
+
+  <script>
+    var ICONS = {
+      moon: '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path></svg>',
+      sun:  '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><line x1="12" y1="2" x2="12" y2="5"></line><line x1="12" y1="19" x2="12" y2="22"></line><line x1="4.22" y1="4.22" x2="6.34" y2="6.34"></line><line x1="17.66" y1="17.66" x2="19.78" y2="19.78"></line><line x1="2" y1="12" x2="5" y2="12"></line><line x1="19" y1="12" x2="22" y2="12"></line><line x1="4.22" y1="19.78" x2="6.34" y2="17.66"></line><line x1="17.66" y1="6.34" x2="19.78" y2="4.22"></line></svg>'
+    };
+
+    (function() {
+      var saved = localStorage.getItem('shareTheme');
+      var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+      var isDark = saved ? saved === 'dark' : prefersDark;
+      if (isDark) document.documentElement.setAttribute('data-theme', 'dark');
+      updateThemeBtn(isDark);
+    })();
+
+    function updateThemeBtn(isDark) {
+      document.getElementById('themeIcon').innerHTML = isDark ? ICONS.sun : ICONS.moon;
+      document.getElementById('themeLabel').textContent = isDark ? 'Light' : 'Dark';
+    }
+
+    function togglePasswordVisibility() {
+      var input = document.getElementById('password');
+      input.type = input.type === 'password' ? 'text' : 'password';
+      input.focus();
+    }
+
+    function toggleTheme() {
+      var root = document.documentElement;
+      var isDark = root.getAttribute('data-theme') === 'dark';
+      root.setAttribute('data-theme', isDark ? 'light' : 'dark');
+      localStorage.setItem('shareTheme', isDark ? 'light' : 'dark');
+      updateThemeBtn(!isDark);
+    }
+  </script>
+</body>
+</html>

+ 4 - 4
src/web/Office/docs/docs.css

@@ -482,8 +482,8 @@ body.doc-show-boxes #editor > .col-span-all::after {
 /* ============ Find panel ============ */
 /* ============ Find panel ============ */
 .of-find-panel {
 .of-find-panel {
     position: fixed;
     position: fixed;
-    top: 92px;
-    right: 26px;
+    top: 92px;   /* placeFindPanel() moves it to the ribbon's bottom edge */
+    right: 4px;
     z-index: 1000;
     z-index: 1000;
     background: var(--of-chrome-bg);
     background: var(--of-chrome-bg);
     border: 1px solid var(--of-border);
     border: 1px solid var(--of-border);
@@ -697,8 +697,8 @@ body.dark #editor span.doc-cmt { background: rgba(255, 213, 79, 0.22); }
 
 
 #docCmtPanel {
 #docCmtPanel {
     position: fixed;
     position: fixed;
-    top: 92px;
-    right: 12px;
+    top: 92px;   /* placeFindPanel() moves it to the ribbon's bottom edge */
+    right: 4px;
     bottom: 40px;
     bottom: 40px;
     width: 270px;
     width: 270px;
     z-index: 850;
     z-index: 850;

+ 43 - 0
src/web/Office/docs/docs.js

@@ -2044,8 +2044,18 @@
     }
     }
 
 
     /* ================= find and replace ================= */
     /* ================= find and replace ================= */
+    // the panel hangs from the bottom edge of the ribbon, whose height
+    // changes as it is collapsed, expanded or wraps
+    function placeFindPanel() {
+        var bar = document.getElementById("toolbar");
+        if (!bar) return;
+        document.getElementById("findPanel").style.top = Math.round(bar.getBoundingClientRect().bottom) + 4 + "px";
+    }
+    if (window.ResizeObserver) new ResizeObserver(placeFindPanel).observe(document.getElementById("toolbar"));
+    window.addEventListener("resize", placeFindPanel);
     function openFind(withReplace) {
     function openFind(withReplace) {
         $("#findPanel").show();
         $("#findPanel").show();
+        placeFindPanel();
         if (withReplace) $("#replaceRow").show();
         if (withReplace) $("#replaceRow").show();
         var selTxt = savedRange ? savedRange.toString() : "";
         var selTxt = savedRange ? savedRange.toString() : "";
         if (selTxt && selTxt.length <= 80 && selTxt.indexOf("\n") < 0) {
         if (selTxt && selTxt.length <= 80 && selTxt.indexOf("\n") < 0) {
@@ -2460,6 +2470,33 @@
         fnMeasureEl.innerHTML = "";
         fnMeasureEl.innerHTML = "";
         return h;
         return h;
     }
     }
+    // a note with nothing typed in it but its number
+    function footnoteIsEmpty(fn) {
+        var clone = fn.cloneNode(true);
+        var marks = clone.querySelectorAll(".doc-fnnum");
+        for (var m = 0; m < marks.length; m++) marks[m].parentNode.removeChild(marks[m]);
+        return !clone.textContent.replace(/[\s​]/g, "") &&
+            !clone.querySelector("img,svg,table,hr,video,iframe");
+    }
+    // Backspace in an empty note deletes it: its references leave the
+    // text and the caret goes back to where the first one was
+    function removeFootnote(id) {
+        var refs = editor.querySelectorAll('sup.doc-fnref[data-fn="' + id + '"]');
+        if (!refs.length) return;
+        var range = document.createRange();
+        range.setStartBefore(refs[0]);
+        range.collapse(true);
+        for (var i = 0; i < refs.length; i++) refs[i].parentNode.removeChild(refs[i]);
+        delete footnotes[id];
+        editor.focus();
+        var sel = window.getSelection();
+        sel.removeAllRanges();
+        sel.addRange(range);
+        savedRange = range.cloneRange();
+        afterEdit(true);
+        clearTimeout(countTimer);
+        updateCounts();
+    }
     function renderFootnoteAreas(pages, geo) {
     function renderFootnoteAreas(pages, geo) {
         var old = pageEl.querySelectorAll(".doc-fn-area");
         var old = pageEl.querySelectorAll(".doc-fn-area");
         for (var i = 0; i < old.length; i++) {
         for (var i = 0; i < old.length; i++) {
@@ -2481,6 +2518,12 @@
                     OfficeApp.markDirty();
                     OfficeApp.markDirty();
                     undo.pushDebounced(snapshot, 600);
                     undo.pushDebounced(snapshot, 600);
                 });
                 });
+                fn.addEventListener("keydown", function (e) {
+                    if (e.key !== "Backspace" || e.ctrlKey || e.metaKey || e.altKey) return;
+                    if (!footnoteIsEmpty(fn)) return;
+                    e.preventDefault();
+                    removeFootnote(fn.getAttribute("data-fn"));
+                });
             });
             });
             pageEl.appendChild(area);
             pageEl.appendChild(area);
             runLayoutPasses(area);
             runLayoutPasses(area);

+ 26 - 0
src/web/SystemAO/file_system/file_explorer.css

@@ -1226,6 +1226,32 @@ body.fmResizing *{
     width: 44em;
     width: 44em;
 }
 }
 
 
+/*
+    Share dialog: centred and fixed size, since the settings page inside it has
+    tabs whose height should not jump. Centred with inset + auto margins instead
+    of a transform, which the Semantic UI show/hide transitions overwrite.
+*/
+.popup.shareDialog{
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    margin: auto;
+    width: 760px;
+    height: 640px;
+    max-width: calc(100% - 24px);
+    max-height: calc(100% - 24px);
+    border-radius: 14px;
+}
+
+.popup.shareDialog .shareFrame{
+    flex: 1 1 auto;
+    min-height: 0;
+    width: 100%;
+    border: 0;
+    background: transparent;
+}
+
 /*
 /*
     The header carries the card's own surface now. It used to be a dark bar with
     The header carries the card's own surface now. It used to be a dark bar with
     an accent underline, which read as a separate title bar sitting on top of
     an accent underline, which read as a separate title bar sitting on top of

+ 10 - 17
src/web/SystemAO/file_system/file_explorer.html

@@ -371,28 +371,21 @@
             </div>
             </div>
         </div>
         </div>
 
 
-        <!-- Share File Dialog-->
-        <div id="shareFile" class="popup" style="display:none;">
+        <!--
+            Share File Dialog
+
+            A centred dialog rather than the docked card the other operations use:
+            the share settings (file_share.html, shared with the desktop) need the
+            room. The page inside draws everything below the title bar.
+        -->
+        <div id="shareFile" class="popup shareDialog" style="display:none;">
             <div class="popupheader">
             <div class="popupheader">
-                <i class="external icon"></i> <span locale="opr/share/title">Share File</span>
+                <i class="share alternate icon"></i> <span locale="opr/share/title">Share File</span>
                 <div class="popupcloser" onclick="hideShare();">
                 <div class="popupcloser" onclick="hideShare();">
                     <i class="remove icon"></i>
                     <i class="remove icon"></i>
                 </div>
                 </div>
             </div>
             </div>
-            <div>
-                <iframe id="shareFileEmbedded" style="border: 0px solid transparent; width: 100%; height: 70vh;" allowtransparency="true">
-
-                </iframe>
-            </div>
-            <div style="padding:12px;">
-                <div class="ui divider"></div>
-                <div id="sharingRemoveBtn" class="popupbuttons negative allowHover" onclick="removeSharing()">
-                    <i class="remove icon"></i> <span locale="opr/share/remove">Remove Sharing</span>
-                </div> 
-                <div class="popupbuttons allowHover" onclick="hideShare();">
-                    <i class="checkmark icon"></i> <span locale="opr/share/ok">OK</span>
-                </div> 
-            </div>
+            <iframe id="shareFileEmbedded" class="shareFrame" title="Share File" allowtransparency="true"></iframe>
         </div>
         </div>
 
 
         <!-- Open With Dialog-->
         <!-- Open With Dialog-->

+ 1008 - 0
src/web/SystemAO/file_system/file_share.css

@@ -0,0 +1,1008 @@
+/*
+    file_share.css
+
+    Styles of the File Share dialog (file_share.html). Colours come from the
+    shared file system tokens in shared/fs-theme.css, so the dialog follows the
+    File Manager's light and dark themes (body.darkTheme).
+*/
+
+:root {
+    --sh-success: #1e8e3e;
+    --sh-success-soft: #e6f4ea;
+    --sh-danger: #d93025;
+    --sh-danger-soft: #fce8e6;
+    --sh-panel: #f7f9fc;
+    --sh-radius: 10px;
+}
+
+body.darkTheme {
+    --sh-success: #5bd17e;
+    --sh-success-soft: #173326;
+    --sh-danger: #ff7b72;
+    --sh-danger-soft: #3a1d1d;
+    --sh-panel: #1a1f27;
+}
+
+* {
+    box-sizing: border-box;
+}
+
+html, body {
+    height: 100%;
+    margin: 0;
+    padding: 0;
+}
+
+body {
+    background: var(--fs-surface);
+    color: var(--fs-text);
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft JhengHei", sans-serif;
+    font-size: 13px;
+    overflow: hidden;
+}
+
+i.icon {
+    margin: 0;
+}
+
+/* ---------- Layout ---------- */
+.shareApp {
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+}
+
+.fileCard {
+    flex: 0 0 auto;
+    display: flex;
+    align-items: center;
+    gap: 16px;
+    padding: 16px 22px 12px 22px;
+}
+
+.fileIcon {
+    flex: 0 0 auto;
+    width: 60px;
+    height: 60px;
+    border-radius: 12px;
+    background: var(--fs-fill);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    overflow: hidden;
+}
+
+.fileIcon .fsBigIcon,
+.fileIcon svg {
+    width: 42px;
+    height: 42px;
+}
+
+.fileIcon .fsBigIcon svg {
+    width: 100%;
+    height: 100%;
+    display: block;
+}
+
+.fileMeta {
+    min-width: 0;
+    flex: 1 1 auto;
+}
+
+.fileName {
+    font-size: 15px;
+    font-weight: 600;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.filePath,
+.fileStat {
+    font-size: 12px;
+    color: var(--fs-text-dim);
+    margin-top: 3px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+/* ---------- Tabs ---------- */
+.tabs {
+    flex: 0 0 auto;
+    display: flex;
+    padding: 0 22px;
+    border-bottom: 1px solid var(--fs-line);
+}
+
+.tab {
+    flex: 1 1 0;
+    background: none;
+    border: none;
+    border-bottom: 2px solid transparent;
+    margin-bottom: -1px;
+    padding: 10px 6px;
+    color: var(--fs-text-dim);
+    font-size: 13px;
+    cursor: pointer;
+    transition: color 0.15s, border-color 0.15s;
+    font-family: inherit;
+}
+
+.tab:hover {
+    color: var(--fs-text);
+}
+
+.tab.active {
+    color: var(--fs-accent);
+    border-bottom-color: var(--fs-accent);
+    font-weight: 600;
+}
+
+.tab:disabled {
+    opacity: 0.4;
+    cursor: default;
+}
+
+.tabBody {
+    flex: 1 1 auto;
+    min-height: 0;
+    overflow-y: auto;
+    padding: 16px 22px;
+    scrollbar-width: thin;
+}
+
+.statePane {
+    height: 100%;
+    min-height: 140px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 10px;
+    color: var(--fs-text-dim);
+    text-align: center;
+    font-size: 14px;
+}
+
+.statePane i.icon {
+    font-size: 22px;
+}
+
+/* ---------- Footer ---------- */
+.dialogFooter {
+    flex: 0 0 auto;
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 12px 22px 16px 22px;
+    border-top: 1px solid var(--fs-line-soft);
+}
+
+.footLeft {
+    flex: 1 1 auto;
+    min-width: 0;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+.footRight {
+    flex: 0 0 auto;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+.footPill {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    width: 100%;
+    padding: 9px 14px;
+    border-radius: 8px;
+    background: var(--sh-panel);
+    color: var(--fs-text-dim);
+    cursor: pointer;
+    border: none;
+    font-family: inherit;
+    font-size: 12px;
+    text-align: left;
+}
+
+.footPill:hover {
+    color: var(--fs-text);
+    background: var(--fs-fill);
+}
+
+.footTip {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    width: 100%;
+    padding: 9px 14px;
+    border-radius: 8px;
+    background: var(--sh-panel);
+    color: var(--fs-text-dim);
+    font-size: 12px;
+    line-height: 1.4;
+}
+
+/* ---------- Buttons ---------- */
+.btn {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    gap: 6px;
+    min-width: 76px;
+    padding: 8px 16px;
+    border-radius: 8px;
+    border: 1px solid transparent;
+    font-family: inherit;
+    font-size: 13px;
+    font-weight: 600;
+    white-space: nowrap;
+    cursor: pointer;
+    transition: background 0.15s, border-color 0.15s, color 0.15s;
+}
+
+.btn.primary {
+    background: var(--fs-accent);
+    color: var(--fs-primary-fg);
+}
+
+.btn.primary:hover {
+    background: var(--fs-primary-hover);
+}
+
+.btn.soft {
+    background: var(--fs-surface);
+    border-color: var(--fs-line);
+    color: var(--fs-text);
+}
+
+.btn.soft:hover {
+    background: var(--fs-fill);
+}
+
+.btn.danger {
+    background: var(--sh-danger);
+    color: #ffffff;
+}
+
+.btn.small {
+    min-width: 0;
+    padding: 6px 12px;
+    font-size: 12px;
+}
+
+.btn:disabled {
+    opacity: 0.5;
+    cursor: default;
+}
+
+.btn.soft.small {
+    color: var(--fs-accent);
+    background: var(--fs-accent-soft);
+    border-color: transparent;
+}
+
+.iconBtn {
+    flex: 0 0 auto;
+    width: 30px;
+    height: 30px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    border: none;
+    background: none;
+    border-radius: 6px;
+    color: var(--fs-icon);
+    cursor: pointer;
+}
+
+.iconBtn:hover {
+    background: var(--fs-fill-hover);
+    color: var(--fs-text);
+}
+
+/* ---------- Quick share panel ---------- */
+.panel {
+    background: var(--sh-panel);
+    border-radius: 12px;
+    padding: 4px 16px;
+}
+
+.panelRow,
+.settingRow {
+    display: flex;
+    align-items: center;
+    gap: 14px;
+    padding: 12px 0;
+}
+
+.settingRow {
+    padding: 4px 0 0 0;
+}
+
+.rowIcon {
+    flex: 0 0 auto;
+    width: 20px;
+    font-size: 17px !important;
+    color: var(--fs-icon);
+    text-align: center;
+}
+
+.rowText {
+    flex: 1 1 auto;
+    min-width: 0;
+}
+
+.rowTitle {
+    font-weight: 600;
+    font-size: 13px;
+}
+
+.rowDesc {
+    font-size: 12px;
+    color: var(--fs-text-dim);
+    margin-top: 2px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.linkRow {
+    display: flex;
+    gap: 10px;
+    padding: 0 0 14px 34px;
+}
+
+.linkField {
+    position: relative;
+    flex: 1 1 auto;
+    min-width: 0;
+    display: flex;
+    align-items: center;
+}
+
+.linkField input {
+    width: 100%;
+    padding: 8px 38px 8px 12px;
+    border-radius: 8px;
+    border: 1px solid var(--fs-line);
+    background: var(--fs-surface);
+    color: var(--fs-accent);
+    font-family: inherit;
+    font-size: 13px;
+    outline: none;
+    text-overflow: ellipsis;
+}
+
+.linkField input::placeholder {
+    color: var(--fs-text-faint);
+}
+
+#advPasswordField input {
+    color: var(--fs-text);
+    letter-spacing: 1px;
+}
+
+.linkField input:focus {
+    border-color: var(--fs-selected-line);
+}
+
+.inlineBtn {
+    position: absolute;
+    right: 4px;
+    width: 30px;
+    height: 28px;
+    border: none;
+    background: none;
+    color: var(--fs-icon);
+    cursor: pointer;
+    border-radius: 6px;
+}
+
+.inlineBtn:hover {
+    background: var(--fs-fill-hover);
+}
+
+.panelDivider,
+.advDivider {
+    height: 1px;
+    background: var(--fs-line);
+}
+
+/* ---------- Switch ---------- */
+.switch {
+    position: relative;
+    flex: 0 0 auto;
+    display: inline-block;
+    width: 38px;
+    height: 22px;
+    margin-left: auto;
+}
+
+.switch input {
+    opacity: 0;
+    width: 0;
+    height: 0;
+}
+
+.switch span {
+    position: absolute;
+    inset: 0;
+    border-radius: 22px;
+    background: var(--fs-fill-active);
+    cursor: pointer;
+    transition: background 0.18s;
+}
+
+.switch span::before {
+    content: "";
+    position: absolute;
+    left: 3px;
+    top: 3px;
+    width: 16px;
+    height: 16px;
+    border-radius: 50%;
+    background: #ffffff;
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
+    transition: transform 0.18s;
+}
+
+.switch input:checked + span {
+    background: var(--fs-accent);
+}
+
+.switch input:checked + span::before {
+    transform: translateX(16px);
+}
+
+.switch input:disabled + span {
+    opacity: 0.45;
+    cursor: default;
+}
+
+.switch input:focus-visible + span {
+    outline: 2px solid var(--fs-selected-line);
+    outline-offset: 2px;
+}
+
+/* ---------- Share list ---------- */
+.listHeader {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 10px;
+    margin-bottom: 10px;
+}
+
+.listTitle {
+    font-size: 14px;
+    font-weight: 600;
+}
+
+.shareList {
+    border: 1px solid var(--fs-line);
+    border-radius: 12px;
+    overflow: hidden;
+}
+
+.shareRow {
+    display: flex;
+    align-items: center;
+    gap: 14px;
+    padding: 12px 14px;
+    cursor: pointer;
+    transition: background 0.12s;
+}
+
+.shareRow + .shareRow {
+    border-top: 1px solid var(--fs-line-soft);
+}
+
+.shareRow:hover {
+    background: var(--fs-row-hover);
+}
+
+.shareRow .rowIcon {
+    font-size: 18px !important;
+}
+
+.shareRow .rowDesc {
+    color: var(--fs-text-faint);
+    white-space: nowrap;
+}
+
+.statusPill {
+    flex: 0 0 auto;
+    padding: 2px 10px;
+    border-radius: 6px;
+    font-size: 11px;
+    font-weight: 600;
+    background: var(--sh-success-soft);
+    color: var(--sh-success);
+}
+
+.statusPill.expired {
+    background: var(--sh-danger-soft);
+    color: var(--sh-danger);
+}
+
+.expiryText {
+    flex: 0 0 82px;
+    font-size: 12px;
+    color: var(--fs-text-dim);
+}
+
+.emptyRow {
+    padding: 18px 14px;
+    text-align: center;
+    color: var(--fs-text-faint);
+    font-size: 12px;
+}
+
+/* ---------- Advanced settings ---------- */
+.editContext {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-wrap: wrap;
+    margin-bottom: 12px;
+    font-size: 12px;
+    color: var(--fs-text-dim);
+}
+
+.editContext .chip {
+    padding: 3px 10px;
+    border-radius: 6px;
+    background: var(--fs-accent-soft);
+    color: var(--fs-accent-strong);
+    font-weight: 600;
+}
+
+.editContext a {
+    color: var(--fs-accent);
+    cursor: pointer;
+}
+
+.advGrid {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    border: 1px solid var(--fs-line);
+    border-radius: 12px;
+}
+
+.advCol {
+    padding: 4px 18px;
+    min-width: 0;
+}
+
+.advCol + .advCol {
+    border-left: 1px solid var(--fs-line);
+}
+
+.advSection {
+    padding: 12px 0;
+}
+
+.sectionTitle {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    font-weight: 600;
+    margin-bottom: 10px;
+}
+
+.sectionTitle i.icon {
+    width: 18px;
+    font-size: 15px;
+    color: var(--fs-icon);
+}
+
+.sectionTitle.withSwitch {
+    margin-bottom: 10px;
+}
+
+.radioRow {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    min-height: 34px;
+    padding-left: 28px;
+    cursor: pointer;
+}
+
+.radioRow input[type="radio"] {
+    margin: 0;
+    width: 15px;
+    height: 15px;
+    accent-color: var(--fs-accent);
+}
+
+.radioLabel {
+    flex: 0 0 auto;
+    min-width: 86px;
+}
+
+.field {
+    width: 100%;
+    padding: 7px 10px;
+    border-radius: 8px;
+    border: 1px solid var(--fs-line);
+    background: var(--fs-surface);
+    color: var(--fs-text);
+    font-family: inherit;
+    font-size: 12px;
+    outline: none;
+}
+
+.field:focus {
+    border-color: var(--fs-selected-line);
+}
+
+.field:disabled {
+    opacity: 0.55;
+}
+
+.field.small {
+    flex: 1 1 auto;
+    min-width: 0;
+    width: auto;
+}
+
+.field.narrow {
+    flex: 0 0 110px;
+    width: 110px;
+}
+
+body.darkTheme .field {
+    color-scheme: dark;
+}
+
+.fieldWithUnit {
+    flex: 1 1 auto;
+    min-width: 0;
+    display: flex;
+    align-items: center;
+    gap: 6px;
+}
+
+.unit {
+    color: var(--fs-text-dim);
+    font-size: 12px;
+}
+
+.fieldHint {
+    margin-top: 6px;
+    font-size: 11px;
+    color: var(--fs-text-dim);
+}
+
+#advPasswordField {
+    padding-left: 28px;
+}
+
+.advSection > select.field,
+.advSection > .fieldHint {
+    margin-left: 28px;
+    width: calc(100% - 28px);
+}
+
+.advSection .settingRow {
+    padding-left: 28px;
+}
+
+/* ---------- Permission management ---------- */
+.peopleGrid {
+    display: grid;
+    grid-template-columns: 214px 1fr;
+    gap: 14px;
+    min-height: 100%;
+}
+
+.peopleNav {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    padding-right: 14px;
+    border-right: 1px solid var(--fs-line);
+}
+
+.navItem {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 10px 12px;
+    border-radius: 10px;
+    cursor: pointer;
+}
+
+.navItem i.icon {
+    width: 18px;
+    font-size: 16px;
+    color: var(--fs-icon);
+}
+
+.navItem:hover {
+    background: var(--fs-row-hover);
+}
+
+.navItem .rowDesc {
+    white-space: nowrap;
+}
+
+.navItem.active {
+    background: var(--fs-accent-soft);
+}
+
+.navItem.active .rowTitle,
+.navItem.active i.icon {
+    color: var(--fs-accent);
+}
+
+.peoplePanel {
+    min-width: 0;
+}
+
+.peopleSection + .peopleSection {
+    margin-top: 18px;
+}
+
+.peopleTable {
+    border-radius: 10px;
+    overflow: hidden;
+}
+
+.peopleHead,
+.peopleRow {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr) 104px 82px 30px;
+    gap: 10px;
+    align-items: center;
+    padding: 8px 10px;
+}
+
+.peopleHead {
+    background: var(--sh-panel);
+    color: var(--fs-text-dim);
+    font-size: 11px;
+    border-radius: 8px;
+}
+
+.peopleRow + .peopleRow {
+    border-top: 1px solid var(--fs-line-soft);
+}
+
+.personCell {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    min-width: 0;
+}
+
+.avatar {
+    flex: 0 0 auto;
+    width: 30px;
+    height: 30px;
+    border-radius: 50%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: #ffffff;
+    font-weight: 600;
+    font-size: 13px;
+    text-transform: uppercase;
+}
+
+.avatar.group {
+    border-radius: 8px;
+}
+
+.peopleRow .field {
+    padding: 5px 8px;
+}
+
+.peopleRow .expiryText {
+    cursor: pointer;
+}
+
+.peopleRow .expiryText:hover {
+    color: var(--fs-accent);
+}
+
+.inviteRow {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 10px;
+    padding: 10px;
+    border-radius: 10px;
+    background: var(--sh-panel);
+}
+
+.shareList.compact .shareRow {
+    padding: 10px 12px;
+}
+
+/* ---------- Menu, overlays, toast ---------- */
+.popMenu {
+    position: fixed;
+    z-index: 50;
+    min-width: 170px;
+    padding: 6px;
+    border-radius: 10px;
+    background: var(--fs-surface);
+    border: 1px solid var(--fs-line);
+    box-shadow: 0 8px 24px var(--fs-shadow);
+}
+
+.popMenu .menuItem {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 8px 10px;
+    border-radius: 6px;
+    cursor: pointer;
+    color: var(--fs-text);
+}
+
+.popMenu .menuItem i.icon {
+    width: 16px;
+    color: var(--fs-icon);
+}
+
+.popMenu .menuItem:hover {
+    background: var(--fs-fill-hover);
+}
+
+.popMenu .menuItem.danger,
+.popMenu .menuItem.danger i.icon {
+    color: var(--sh-danger);
+}
+
+.overlay {
+    position: fixed;
+    inset: 0;
+    z-index: 60;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: var(--fs-overlay);
+}
+
+.overlayCard {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 14px;
+    max-width: calc(100% - 40px);
+    padding: 20px 22px;
+    border-radius: 14px;
+    background: var(--fs-surface);
+    border: 1px solid var(--fs-line);
+    box-shadow: 0 8px 28px var(--fs-shadow);
+}
+
+#qrCode {
+    padding: 10px;
+    background: #ffffff;
+    border-radius: 8px;
+}
+
+#qrCode img,
+#qrCode canvas {
+    width: 180px;
+    height: 180px;
+}
+
+.qrLink {
+    max-width: 260px;
+    font-size: 11px;
+    color: var(--fs-text-dim);
+    word-break: break-all;
+    text-align: center;
+}
+
+.confirmText {
+    max-width: 320px;
+    text-align: center;
+    line-height: 1.5;
+}
+
+.confirmButtons {
+    display: flex;
+    gap: 10px;
+}
+
+.toast {
+    position: fixed;
+    left: 50%;
+    bottom: 76px;
+    z-index: 70;
+    transform: translate(-50%, 12px);
+    padding: 9px 16px;
+    border-radius: 8px;
+    background: var(--fs-toast-bg);
+    color: var(--fs-toast-fg);
+    font-size: 12px;
+    opacity: 0;
+    pointer-events: none;
+    transition: opacity 0.2s, transform 0.2s;
+    max-width: calc(100% - 40px);
+}
+
+.toast.show {
+    opacity: 1;
+    transform: translate(-50%, 0);
+}
+
+.toast.error {
+    background: var(--fs-toast-error-bg);
+    color: var(--fs-toast-error-fg);
+}
+
+/* ---------- Narrow windows ---------- */
+@media (max-width: 620px) {
+    .fileCard,
+    .tabs,
+    .tabBody,
+    .dialogFooter {
+        padding-left: 14px;
+        padding-right: 14px;
+    }
+
+    .advGrid {
+        grid-template-columns: 1fr;
+    }
+
+    .advCol + .advCol {
+        border-left: none;
+        border-top: 1px solid var(--fs-line);
+    }
+
+    .peopleGrid {
+        grid-template-columns: 1fr;
+    }
+
+    .peopleNav {
+        flex-direction: row;
+        overflow-x: auto;
+        padding-right: 0;
+        padding-bottom: 8px;
+        border-right: none;
+        border-bottom: 1px solid var(--fs-line);
+    }
+
+    .navItem {
+        flex: 0 0 auto;
+    }
+
+    .navItem .rowDesc {
+        display: none;
+    }
+
+    .linkRow {
+        padding-left: 0;
+        flex-wrap: wrap;
+    }
+
+    .linkRow .btn {
+        width: 100%;
+    }
+
+    .expiryText {
+        display: none;
+    }
+
+    .peopleHead,
+    .peopleRow {
+        grid-template-columns: minmax(0, 1fr) 96px 30px;
+    }
+
+    .peopleHead .expiryText,
+    .peopleRow .expiryText {
+        display: none;
+    }
+}

+ 242 - 612
src/web/SystemAO/file_system/file_share.html

@@ -1,645 +1,275 @@
 <!DOCTYPE html>
 <!DOCTYPE html>
 <html>
 <html>
     <head>
     <head>
-        <title locale="title/title">File Share</title>
+        <title locale="title/title">Share File</title>
+        <meta charset="UTF-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no">
         <link rel="stylesheet" href="../../script/semantic/semantic.min.css">
         <link rel="stylesheet" href="../../script/semantic/semantic.min.css">
-        <link rel="stylesheet" href="../../script/ao.css">
+        <link rel="stylesheet" href="shared/fs-theme.css">
+        <link rel="stylesheet" href="file_share.css">
         <script src="../../script/jquery.min.js"></script>
         <script src="../../script/jquery.min.js"></script>
-        <script src="../../script/semantic/semantic.min.js"></script>
         <script src="../../script/ao_module.js"></script>
         <script src="../../script/ao_module.js"></script>
-        <script type="text/javascript" src="../../script/applocale.js"></script>
-        <script type="text/javascript" src="../../script/qrcode.min.js"></script>
-        <meta charset="UTF-8">
-        <meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no">
-        <style>
-            body{
-                background: var(--body_background) !important;
-            }
-
-            .sub.header.darkTheme{
-                color: rgba(39, 19, 19, 0.623) !important;
-            }
-
-            .darkTheme:not(a){
-                color: #eeeeee;
-            }
-
-            .yellow.message.darkTheme{
-                box-shadow: none !important;
-                border: 0px solid transparent !important;
-                background-color: #5e550e !important;
-            }
-
-            .dropDown.darkTheme:not(.icon){
-                background-color: #413f57 !important;
-                border: 1px solid white;
-            }
+        <script src="../../script/applocale.js"></script>
+        <script src="../../script/qrcode.min.js"></script>
+        <script src="shared/filethumb.js"></script>
+    </head>
+    <!--
+        File Share dialog
+
+        Opened by the File Manager (embedded in its share dialog) and by the
+        desktop (float window). Pass the file to share in the hash:
+
+            file_share.html#[{"filename":"a.txt","filepath":"user:/a.txt"}]
+
+        Optional flags on the file object:
+            Embedded: true      hosted inside another page's dialog, which
+                                draws the title bar and close button
+            shareMode: "anyone" | "signedin" | "samegroup" | "remove"
+                                apply a quick share mode before showing the
+                                dialog (used by the desktop context menu)
+    -->
+    <body class="whiteTheme">
+        <div id="app" class="shareApp">
+            <header class="fileCard">
+                <div class="fileIcon" id="fileIcon"></div>
+                <div class="fileMeta">
+                    <div class="fileName" id="fileName">&nbsp;</div>
+                    <div class="filePath" id="filePath">&nbsp;</div>
+                    <div class="fileStat" id="fileStat">&nbsp;</div>
+                </div>
+            </header>
 
 
-            .dropdown.darkThem  .ui.label{
-                color: rgb(175, 175, 175);
-                background-color: #2f2d3d !important;
-            }
+            <nav class="tabs" role="tablist">
+                <button class="tab active" data-tab="quick" role="tab"><span locale="tab/quick">Quick Share</span></button>
+                <button class="tab" data-tab="advanced" role="tab"><span locale="tab/advanced">Advanced Settings</span></button>
+                <button class="tab" data-tab="people" role="tab"><span locale="tab/people">Permissions</span></button>
+            </nav>
 
 
-            .dropdown.darkThem  .ui.label:hover{
-                color: white;
-                opacity: 0.8;
-            }
+            <main class="tabBody">
+                <div id="loadingPane" class="statePane">
+                    <i class="spinner loading icon"></i>
+                </div>
 
 
+                <div id="notManageablePane" class="statePane" style="display:none;">
+                    <i class="lock icon"></i>
+                    <p locale="msg/notManageable">This file is shared by another user. Only its owner can change the share settings.</p>
+                </div>
 
 
-        </style>
-    </head>
-    <body>
-        <div id="main" class="ui stackable grid">
-            <div class="eight wide column">
-                <div style="width: 100%;" class="qrcode" align="center">
-                    <div style="display: block; margin-left: auto; margin-right: auto;" align="center">
-                        <div id="qrcode" style="border: 10px solid white; background-color: white;">
-                            <h1><br><i class="ui loading spinner icon"></i><br></h1>
+                <!-- ============ Quick share ============ -->
+                <section id="tab-quick" class="tabPane">
+                    <!-- Zero or one share: the share link panel -->
+                    <div id="quickSingle" class="panel">
+                        <div class="panelRow">
+                            <i class="rowIcon globe icon" id="quickAudienceIcon"></i>
+                            <div class="rowText">
+                                <div class="rowTitle" locale="quick/link">Share Link</div>
+                                <div class="rowDesc" id="quickAudience">Anyone with the link can access</div>
+                            </div>
+                            <button class="iconBtn" id="quickMenuBtn" title="More" style="display:none;"><i class="ellipsis horizontal icon"></i></button>
+                        </div>
+                        <div class="linkRow">
+                            <div class="linkField">
+                                <input type="text" id="quickLink" readonly placeholder="No share link yet">
+                                <button class="inlineBtn" id="quickCopyIcon" title="Copy Link"><i class="copy outline icon"></i></button>
+                            </div>
+                            <button class="btn primary" id="quickLinkBtn"></button>
+                        </div>
+                        <div class="panelDivider"></div>
+                        <div class="panelRow">
+                            <i class="rowIcon download icon"></i>
+                            <div class="rowText">
+                                <div class="rowTitle" locale="quick/allowDownload">Allow Download</div>
+                                <div class="rowDesc" locale="quick/allowDownloadDesc">Recipients can download the file</div>
+                            </div>
+                            <label class="switch"><input type="checkbox" id="quickAllowDownload" checked><span></span></label>
                         </div>
                         </div>
                     </div>
                     </div>
-                    <div style="width: 80%; text-align: center; margin-top:0.4em; ">
-                    <a id="sharelink" href="" target="_blank" style="font-size: 120%; padding-left: 20px; padding-right: 20px; word-break: break-all; overflow-wrap: anywhere;"></a>
+
+                    <!-- Two or more shares: the list of share links -->
+                    <div id="quickList" style="display:none;">
+                        <div class="listHeader">
+                            <div class="listTitle" id="quickListTitle">Share Links Created</div>
+                            <button class="btn primary small" id="quickNewShareBtn"><i class="plus icon"></i><span locale="quick/newShare">New Share</span></button>
+                        </div>
+                        <div class="shareList" id="quickListBody"></div>
                     </div>
                     </div>
-                    
-                </div>
-            </div>
-            <div id="shareSettingOptions" class="eight wide column" style="padding-left: 12px; display:none;">
-                <div class="ui header">
-                    <span locale="share/setting/title">Share Settings</span>
-                    <div class="sub header" locale="share/setting/subtitle">Change who can see this file</div>
-                </div>
-                <div id="shareSettingForm" class="ui form">
-                    <div class="field">
-                        <label><p class="" locale="share/setting/options">Visable options:</p></label>
-                        <div class="ui checkboxes">
-                            <div class="ui radio checkbox">
-                                <input id="anyone" type="radio" class="shareoption" name="shareopt" value="anyone" onchange="updateSharePermission(this);">
-                                <label for="anyone">
-                                    <div class="ui header">
-                                        <div class="content ">
-                                            <i class="globe icon"></i> <span locale="share/setting/anyoneWithLink">Anyone with the link</span>
-                                            <div class="sub header" locale="share/setting/anyoneWithLink/desc">Anyone who has the link can access this file</div>
-                                        </div>
-                                    </div>
+                </section>
+
+                <!-- ============ Advanced settings ============ -->
+                <section id="tab-advanced" class="tabPane" style="display:none;">
+                    <div class="editContext" id="advContext"></div>
+                    <div class="advGrid">
+                        <div class="advCol">
+                            <div class="advSection">
+                                <div class="sectionTitle"><i class="calendar alternate outline icon"></i><span locale="adv/expiry">Share Expiry</span></div>
+                                <label class="radioRow">
+                                    <input type="radio" name="expiryMode" value="never" checked>
+                                    <span locale="adv/forever">Never expires</span>
                                 </label>
                                 </label>
-                            </div>
-                            <br><br>
-                            <div class="ui radio checkbox">
-                                <input id="signedin" type="radio" class="shareoption" value="signedin" name="shareopt" onchange="updateSharePermission(this);">
-                                <label for="signedin">
-                                    <div class="ui header">
-                                        <div class="content">
-                                            <i class="user circle outline icon"></i> <span locale="share/setting/anyoneSignedIn">Anyone signed in</span>
-                                            <div class="sub header" locale="share/setting/anyoneSignedIn/desc">Anyone who has signed in can access this file</div>
-                                        </div>
-                                    </div>
+                                <label class="radioRow">
+                                    <input type="radio" name="expiryMode" value="date">
+                                    <span class="radioLabel" locale="adv/date">Expire on date</span>
+                                    <input type="date" class="field small" id="advExpiryDate">
                                 </label>
                                 </label>
-                            </div>
-                            <br><br>
-                            <div class="ui radio checkbox">
-                                <input id="samegroup" type="radio" class="shareoption" value="samegroup" name="shareopt" onchange="updateSharePermission(this);">
-                                <label for="samegroup">
-                                    <div class="ui header">
-                                        <div class="content">
-                                            <i class="users icon"></i> <span locale="share/setting/sameGroup">Users in the same group</span>
-                                            <div class="sub header" locale="share/setting/sameGroup/desc">Anyone who is in the same group as you do</div>
-                                        </div>
-                                    </div>
+                                <label class="radioRow">
+                                    <input type="radio" name="expiryMode" value="days">
+                                    <span class="radioLabel" locale="adv/duration">Custom duration</span>
+                                    <span class="fieldWithUnit">
+                                        <input type="number" class="field small" id="advExpiryDays" min="1" max="3650" placeholder="e.g. 7">
+                                        <span class="unit" locale="adv/days">days</span>
+                                    </span>
                                 </label>
                                 </label>
                             </div>
                             </div>
-                            <br><br>
-                            <div class="ui accordion" id="advanceShare">
-                                <div class="title">
-                                    <i class="dropdown icon"></i>
-                                    <span locale="share/setting/advance/title">Advance Share Options</span>
+                            <div class="advDivider"></div>
+                            <div class="advSection">
+                                <div class="sectionTitle withSwitch">
+                                    <i class="lock icon"></i><span locale="adv/password">Password Protection</span>
+                                    <label class="switch"><input type="checkbox" id="advPasswordToggle"><span></span></label>
                                 </div>
                                 </div>
-                                <div class="content">
-                                    <div class="ui radio checkbox">
-                                        <input id="users" type="radio" class="shareoption" value="users" name="shareopt" onchange="updateSharePermission(this);">
-                                        <label for="users">
-                                            <div class="ui header">
-                                                <div class="content">
-                                                    <i class="clipboard check icon"></i> <span locale="share/setting/advance/users">Selected Users</span>
-                                                    <div class="sub header" locale="share/setting/advance/usersDesc">Selected users with matching user name</div>
-                                                </div>
-                                            </div>
-                                        </label>
-                                    </div>
-                                    <div id="userselector">
-                                        <p style="margin-top: 1em;" locale="share/setting/advance/usersInstruct"">Select target users from the list below</p>
-                                        <select id="targetUsersList" class="ui fluid search dropdown" multiple="">
-                                            <option value="">Users</option>
-                                        </select>
-                                        <div id="noUserWarning" class="ui yellow message"  style="display:none;">
-                                            <i class="caret up icon"></i> <span locale="share/setting/advance/addUserToSave">Add at least one target share user to save changes</span>
-                                        </div>
-                                    </div>
-                                    <div class="ui radio checkbox" style="margin-top: 1em;">
-                                        <input id="groups" type="radio" class="shareoption" value="groups" name="shareopt" onchange="updateSharePermission(this);">
-                                        <label for="groups">
-                                            <div class="ui header">
-                                                <div class="content">
-                                                    <i class="sitemap icon"></i> <span locale="share/setting/advance/groups">Selected Groups</span>
-                                                    <div class="sub header" locale="share/setting/advance/groupsDesc">All the users that has access to any one of the selected group(s)</div>
-                                                </div>
-                                            </div>
-                                        </label>
+                                <div class="linkField" id="advPasswordField">
+                                    <input type="password" id="advPassword" autocomplete="new-password" maxlength="72">
+                                    <button class="inlineBtn" id="advPasswordEye" title="Show password"><i class="eye icon"></i></button>
+                                </div>
+                                <div class="fieldHint" id="advPasswordHint" style="display:none;" locale="adv/passwordKeep">Leave empty to keep the current password</div>
+                            </div>
+                            <div class="advDivider"></div>
+                            <div class="advSection" id="advAudienceSection">
+                                <div class="sectionTitle"><i class="globe icon"></i><span locale="adv/audience">Who Can Access</span></div>
+                                <select class="field" id="advAudience">
+                                    <option value="anyone" locale="aud/anyone">Anyone with the link can access</option>
+                                    <option value="signedin" locale="aud/signedin">Signed-in users with the link can access</option>
+                                    <option value="samegroup" locale="aud/samegroup">Users in the same group can access</option>
+                                </select>
+                                <div class="fieldHint" id="advAudienceHint" style="display:none;" locale="adv/audienceInvite">Invited users / groups (manage in Permissions)</div>
+                            </div>
+                        </div>
+                        <div class="advCol">
+                            <div class="advSection">
+                                <div class="sectionTitle"><i class="user friends icon"></i><span locale="adv/permission">Share Permission</span></div>
+                                <select class="field" id="advAccessLevel">
+                                    <option value="view" locale="level/viewDefault">Can View (Default)</option>
+                                    <option value="edit" locale="level/edit">Can Edit</option>
+                                </select>
+                                <div class="fieldHint" id="advAccessLevelDesc"></div>
+                            </div>
+                            <div class="advSection">
+                                <div class="sectionTitle"><i class="download icon"></i><span locale="adv/download">Download Settings</span></div>
+                                <div class="settingRow">
+                                    <div class="rowText">
+                                        <div class="rowTitle" locale="adv/allowDownload">Allow Download</div>
+                                        <div class="rowDesc" locale="adv/allowDownloadDesc">Allow recipients to download the files</div>
                                     </div>
                                     </div>
-                                    <div id="groupselector">
-                                        <p style="margin-top: 1em;" locale="share/setting/advance/groupsInstruct">Select target groups from the list below</p>
-                                        <select id="targetGroupList" class="ui fluid search dropdown" multiple="">
-                                            <option value="">Groups</option>
-                                        </select>
-                                        <div id="noGroupWarning" class="ui yellow message" style="display:none;">
-                                            <i class="caret up icon"></i> <span locale="share/setting/advance/addGroupToSave">Add at least one target share group to save changes</span>
-                                        </div>
+                                    <label class="switch"><input type="checkbox" id="advAllowDownload" checked><span></span></label>
+                                </div>
+                            </div>
+                            <div class="advSection">
+                                <div class="sectionTitle"><i class="list ul icon"></i><span locale="adv/display">Display Settings</span></div>
+                                <div class="settingRow">
+                                    <div class="rowText">
+                                        <div class="rowTitle" locale="adv/showList">Show File List</div>
+                                        <div class="rowDesc" id="advShowListDesc" locale="adv/showListDesc">Recipients can browse the shared files</div>
                                     </div>
                                     </div>
+                                    <label class="switch"><input type="checkbox" id="advShowFileList" checked><span></span></label>
                                 </div>
                                 </div>
                             </div>
                             </div>
-                        <br><br>
-                        <div id="udpateNotification" style="display:none; position: fixed; bottom: 1em; right: 1em;" class="ui green inverted segment">
-                                <i class=" checkmark icon"></i> <span locale="share/setting/updated">Share Setting Updated</span>
-                        </div>
                         </div>
                         </div>
                     </div>
                     </div>
-                </div>
-            </div>
-        </div>
-        <div class="ui divider actionButton"></div>
-        <div class="actionButton" style="width: 100%; padding-right: 12px;" align="right">
-            <div class="ui basic button popupbuttons allowHover" onclick="copyLinkToClipboard(this)">
-                <i class="copy icon"></i> <span locale="button/copy">Copy</span>
-            </div> 
-            <div id="sharingRemoveBtn" class="ui basic button popupbuttons allowHover" onclick="removeSharing()">
-                <i class="red remove icon"></i> <span locale="button/remove">Remove</span>
-            </div> 
-        </div>
-        <script>
-            /*
-                Usage: Pass in file descriptor to start share a file
-                var fd = encodeURIComponent(JSON.stringify({
-                        filename: "test.txt",
-                        filepath: "user:/Desktop/test.txt"
-                    })
-                );
-                window.open("file_share.html#" + fd);
-                
-                If you want to preset a file sharing mode (aka your module handle the share mode picking),
-                Pass in with the "shareMode" paramter. Example fd as follow:
-                {
-                    filename: "test.txt",
-                    filepath: "user:/Desktop/test.txt",
-                    shareMode: "signedin"
-                }
-
-                To remove a share, pass in "remove" for the share mode
-                Supported Share Mode keywords {anyone/signedin/samegroup/remove}
-
-                Other supported flags
-                {
-                    QRCode: {true/false} //Show or Hide the QR Code and link
-                    ActionButtons: {true/false} //Show or Hide the action button on the bottom
-                }
-
-            */
-            var shareCurrentEditingUUID = "";
-            var shareingFileData = {};
-            var darkTheme = false;
-            var initialized = false;
-            var fileSharingURL = "";
-            var relpath = "../../";
-            
-            function applyDarkThemeMode(){
-                $(".ui.message").addClass("inverted");
-                darkTheme = true;
-            }
-
-            function removeQRCodeInDarkTheme(){
-                if (darkTheme){
-                    $("#qrcode").css({
-                        "border":"0px solid transparent",
-                        "background-color":"#242330",
-                    })
-                }
-            }
-
-            if ($(parent) && $(parent.document).find("body").hasClass("darkTheme")){
-                //Switch to darkTheme mode immediately
-                applyDarkThemeMode();
-            }   
-
-            PageReady();
-            function PageReady(){
-                if (initialized){
-                    return;
-                }
-
-                //Load User & Group List
-                $.get(relpath + "../system/users/list", function(data){
-                    var groups = {};
-                    $("#targetUsersList").html(`<option value="">Users</option>`);
-                    data.forEach(user => {
-                        $("#targetUsersList").append(`<option value="${user[0]}">${user[0]}</option>`);
-                        var userGroups = user[1];
-                        userGroups.forEach(thisGroup => {
-                            groups[thisGroup] = true;
-                        });
-                    });
-                    $("#targetGroupList").html(`<option value="">Groups</option>`);
-                    for (var [key, value] of Object.entries(groups)) {
-                        $("#targetGroupList").append(`<option value="${key}">${key}</option>`);
-                    }
-
-                    //Initiate the share details
-                    initFileDetails(shareingFileData, function(shareUUID){
-                        //Set the mode of share if it is defined
-                        if (shareingFileData.shareMode !== undefined && shareingFileData.shareMode == "remove"){
-                            //Remove the share UUID
-                            removeSharing();
-                            return;
-                        }
-                        if (shareingFileData.shareMode !== undefined){
-                            //As the share mode is defined by the caller, hide the setting interface
-                            $("#shareSettingOptions").hide();
-                            $("#sharelink").parent().css({
-                                "width": "100%",
-                                "text-align": "left",
-                            });
-                            $.ajax({
-                                url: relpath + "../system/file_system/share/edit",
-                                data: {uuid: shareCurrentEditingUUID, mode: shareingFileData.shareMode},
-                                success: function(data){
-                                    if (data.error !== undefined){
-                                        alert(data.error);
-                                        return;
-                                    }
-                                    
-                                    //Update the checkbox
-                                    $(".shareoption").each(function(){
-                                        if ($(this)[0].value != shareingFileData.shareMode){
-                                            $(this)[0].checked = false;
-                                        }else{
-                                            $(this)[0].checked = true;
-                                        }
-                                    
-                                    });
-                                }
-                            });
-                        }else{
-                            //Default: show the setting to allow user adjustment
-                            $("#main").css({
-                                "margin-top":"0em",
-                                "padding": "4px"
-                            });
-                            $("#shareSettingOptions").show();
-                        }
-
-                        $(".accordion").accordion();
-                    });
-                });
-
-                //Load theme style
-                $.get(relpath + "../system/file_system/preference?key=file_explorer/theme",function(data){
-                    if (data == "darkTheme"){
-                        $("body").addClass("darkTheme");
-                        applyDarkThemeMode();
-                    }else{
-                        $("body").addClass("whiteTheme");
-                    }
-                });
-
-
-
-                initialized = true;
-                //Do localization
-                applocale.init(relpath + "../SystemAO/locale/file_share.json", function(){
-                    applocale.translate();
-                });
-
-                $(".checkbox").checkbox();
-                $(".dropdown").dropdown();
-                var inputFile = ao_module_loadInputFiles();
-                if (inputFile == null){
-                    //No file selected
-                    $(".shareoption").parent().addClass("disabled");
-                    $("#qrcode").html(`<h1>No File</h1>`);
-                    $("#sharelink").text(``);
-                    $("#sharingRemoveBtn").addClass("disabled");
-                    return
-                }
-
-
-                //Make sure one file is choicen each time
-                inputFile = inputFile[0]; 
-                console.log("inputFile", inputFile);
-                shareingFileData = inputFile;
-
-                //Filter out the nessary display flags
-                if (shareingFileData.QRCode !== undefined && shareingFileData.QRCode == false){
-                    $(".qrcode").hide();
-                    $("#shareSettingOptions").attr("class", "sixteen wide column");
-                    $("#shareSettingOptions").css("padding", "1em");
-                    $(".eight.wide").hide();
-                }
-
-                if (shareingFileData.ActionButtons !== undefined && shareingFileData.ActionButtons == false){
-                    $(".actionButton").hide();
-                }
-            }
-
-            function initFileDetails(shareingFileData, callback=undefined){
-                $.ajax({
-                    url: relpath + "../system/file_system/share/new",
-                    data: {path: shareingFileData.filepath},
-                    success: function(data){
-                        if (data.error !== undefined){
-                            alert(data.error);
-                        }else{
-                            console.log(data);
-                            updateShareLinkInfo(data.UUID);
-                            shareCurrentEditingUUID = data.UUID;
-                            $(".shareoption").each(function(){
-                                if ($(this)[0].value != data.Permission){
-                                    $(this)[0].checked = false;
-                                }else{
-                                    $(this)[0].checked = true;
-                                    if (data.Permission == "users"){
-                                        $("#advanceShare").accordion("open", 0);
-                                        $("#targetUsersList").dropdown("set selected", data.Accessibles);
-                                        $("#targetUsersList").parent().removeClass("disabled");
-                                        $("#targetGroupList").parent().addClass("disabled");
-                                    }else if (data.Permission == "groups"){
-                                        $("#advanceShare").accordion("open", 0);
-                                        $("#targetGroupList").dropdown("set selected", data.Accessibles);
-                                        $("#targetUsersList").parent().addClass("disabled");
-                                        $("#targetGroupList").parent().removeClass("disabled");
-                                    }else{
-                                        $("#targetGroupList").parent().addClass("disabled");
-                                        $("#targetUsersList").parent().addClass("disabled");
-                                    }
-                                }
-                               
-                            });
-
-                          
-                            $("#targetUsersList").on("change", function(evt){
-                                updateSharePermissionByType("users");
-                            });
-
-                            $("#targetGroupList").on("change", function(evt){
-                                updateSharePermissionByType("groups");
-                            });
-               
-                            
-
-                            //If the file is from desktop, set share icon
-                            if (ao_module_virtualDesktop == true){
-                                var fileDir = shareingFileData.filepath.split("/");
-                                fileDir.pop();
-                                fileDir = fileDir.join("/");
-                                if (fileDir == "user:/Desktop"){
-                                    //Remove share icon
-                                    parent.setFileShareIndicator(shareingFileData.filename);
-                                }
-                            }
-
-                            if (callback != undefined){
-                                callback(data.UUID)
-                            }
-                        }
-                    }
-                });
-            }
-
-            function removeSharing(){
-                if (shareCurrentEditingUUID == ""){
-                    return
-                }
-
-                //The target file to remove
-                $.ajax({
-                    url: relpath + "../system/file_system/share/delete",
-                    data: {uuid: shareCurrentEditingUUID},
-                    success: function(data){
-                        if (data.error !== undefined){
-                            alert(data.error);
-                        }else{
-                            //Removed!
-                            $(".button").addClass('disabled');
-                            $(".checkbox").addClass("disabled");
-                            $("#sharelink").text("");
-                            $("#sharelink").attr("href", "#");
-                            $("#qrcode").html(`<br><br><h1><i class="green checkmark icon"></i> ${applocale.getString("message/removed", "Share Removed")}</h1>`);
-                            removeQRCodeInDarkTheme();
-                            //If the file is located on desktop and it is web desktop mode
-                            if (ao_module_virtualDesktop == true){
-                                var fileDir = shareingFileData.filepath.split("/");
-                                fileDir.pop();
-                                fileDir = fileDir.join("/");
-                                if (fileDir == "user:/Desktop"){
-                                    //Remove share icon
-                                    parent.removeFileShareIndicator(shareingFileData.filename);
-                                }
-                            }
-                        }
-                    }
-                });
-            }
-
-            function updateSharePermission(object){
-                var newPermission = $(object).attr('value');
-                updateSharePermissionByType(newPermission);
-            }   
-
-            function updateSharePermissionByType(newPermission){
-                if (newPermission == "users"){
-                    //Build the user list
-                    $("#targetUsersList").parent().removeClass("disabled");
-                    $("#targetGroupList").parent().addClass("disabled");
-                    var selectedUsers = $("#targetUsersList").val();
-                    $("#noGroupWarning").slideUp("fast");
-                    if (selectedUsers.length == 0){
-                        //Show tips message
-                        $("#noUserWarning").slideDown("fast");
-                        return;
-                    }else{
-                        $("#noUserWarning").slideUp("fast");
-                    }
-                    //Rewrite it to permission handling description
-                    newPermission = "users:" + selectedUsers.join(",");
-                }else if (newPermission == "groups"){
-                    //Build the group list
-                    $("#targetUsersList").parent().addClass("disabled");
-                    $("#targetGroupList").parent().removeClass("disabled");
-                    var selectedGroups = $("#targetGroupList").val();
-                    $("#noUserWarning").slideUp("fast");
-                    if (selectedGroups.length == 0){
-                        //Show tips message
-                        $("#noGroupWarning").slideDown("fast");
-                        return;
-                    }else{
-                        $("#noGroupWarning").slideUp("fast");
-                    }
-                    //Rewrite it to permission handling description
-                    newPermission = "groups:" + selectedGroups.join(",");
-                }
-
-                $.ajax({
-                    url: relpath + "../system/file_system/share/edit",
-                    data: {uuid: shareCurrentEditingUUID, mode: newPermission},
-                    success: function(data){
-                        if (data.error !== undefined){
-                            alert(data.error);
-                            return;
-                        }
-                        $("#udpateNotification").stop().finish().fadeIn("fast").delay(3000).fadeOut("fast");
-                    }
-                });
-            }
-
-            function updateShareLinkInfo(uuid){
-                $("#qrcode").html("");
-                let protocol = "https://";
-                if (location.protocol !== 'https:') {
-                    protocol = "http://";
-                }
-
-                var port = ":" + window.location.port;
-                if (window.location.port == ""){
-                    port = "";
-                }
-                var shareURL = protocol + window.location.hostname + port + "/share/" + uuid;
-                shareCurrentEditingUUID = uuid;
-                fileSharingURL = shareURL;
-                new QRCode(document.getElementById("qrcode"), shareURL);
-                $("#sharelink").text(shareURL);
-                $("#sharelink").attr("href", shareURL)
-            }
-
-            /*
-                Dynamic Script Loader
-
-                This is a really experimental implementation of importing a script from anywhere
-                under the ArozOS web root. This section of code must be written in plain JS to make sure
-                it works without jQuery and other libraries.
-
-                This function try to load jQuery and ao_module from the script folder.
-                Also loading the semantic js and the css main body
-            */
-           /*
-            //The possible location for desktop.system, standard webapp module, SystemAO interfaces and iui sub-interfaces
-            let possibleLocations = ["script/", "../script/", "../../script/", "../../../script/"];
-            let loopCount = Math.min(possibleLocations.length, JSON.parse(JSON.stringify(window.location.toString())).split("/").length - 3);
-            function tryLoad(relpath, filename, successCallback=undefined){
-                var request = new XMLHttpRequest();  
-                request.open('GET', relpath + filename, true);
-                request.onreadystatechange = function(){
-                    if (request.readyState === 4){
-                        if (request.status == 200 || request.status == 304) {  
-                            if (typeof(successCallback) != "undefined"){
-                                successCallback(relpath, filename);
-                            }
-                        }
-                    }
-                };
-                request.send();
-            }
-
-            function injectOtherJavaScriptLibrary(relpath){
-                //Check if ao_module is loaded
-                if (typeof(ao_module_virtualDesktop) == "undefined"){
-                    var script = document.createElement('script');
-                    script.setAttribute('src', relpath + "ao_module.js");
-                    document.getElementsByTagName('head')[0].appendChild(script);
-                }else{
-                    //This routine already run
-                    return;
-                }
-
-                //Inject QR Code library
-                var script = document.createElement('script');
-                script.setAttribute('src', relpath + "qrcode.min.js");
-                document.getElementsByTagName('head')[0].appendChild(script);
-
-                //Inject applocale
-                script = document.createElement('script');
-                script.setAttribute('src', relpath + "applocale.js");
-                document.getElementsByTagName('head')[0].appendChild(script);
-
-                //Inject semmantic css and js anyway
-                var head  = document.getElementsByTagName('head')[0];
-                var link  = document.createElement('link');
-                link.id   = "semantic";
-                link.rel  = 'stylesheet';
-                link.type = 'text/css';
-                link.href = relpath + 'semantic/semantic.min.css';
-                link.media = 'all';
-                head.appendChild(link);
-
-                var script = document.createElement('script');
-                script.setAttribute('src', relpath + "semantic/semantic.min.js");
-                document.getElementsByTagName('head')[0].appendChild(script);
+                </section>
+
+                <!-- ============ Permission management ============ -->
+                <section id="tab-people" class="tabPane" style="display:none;">
+                    <div class="peopleGrid">
+                        <div class="peopleNav">
+                            <div class="navItem" data-view="public">
+                                <i class="globe icon"></i>
+                                <div class="rowText">
+                                    <div class="rowTitle" locale="people/public">Public Share</div>
+                                    <div class="rowDesc" id="navPublicDesc"></div>
+                                </div>
+                            </div>
+                            <div class="navItem" data-view="people">
+                                <i class="user friends icon"></i>
+                                <div class="rowText">
+                                    <div class="rowTitle" locale="people/shared">Shared Users / Groups</div>
+                                    <div class="rowDesc" id="navPeopleDesc"></div>
+                                </div>
+                            </div>
+                        </div>
+                        <div class="peoplePanel" id="peoplePanel">
+                            <!-- Public links -->
+                            <div class="peopleSection" id="sectionPublic">
+                                <div class="listHeader">
+                                    <div class="listTitle" locale="people/publicLinks">Public Links</div>
+                                    <button class="btn soft small" id="addPublicBtn"><i class="plus icon"></i><span locale="btn/add">Add</span></button>
+                                </div>
+                                <div class="shareList compact" id="publicList"></div>
+                            </div>
 
 
-                setTimeout(function(){
-                    PageReady();
-                }, 1000);
-            }
+                            <!-- Invited users -->
+                            <div class="peopleSection" id="sectionUsers">
+                                <div class="listHeader">
+                                    <div class="listTitle" locale="people/users">Invited Users</div>
+                                    <button class="btn soft small" id="addUserBtn"><i class="plus icon"></i><span locale="btn/add">Add</span></button>
+                                </div>
+                                <div class="inviteRow" id="inviteUserRow" style="display:none;">
+                                    <select class="field" id="inviteUserSelect"></select>
+                                    <select class="field narrow" id="inviteUserLevel"></select>
+                                    <button class="btn primary small" id="inviteUserConfirm" locale="btn/invite">Invite</button>
+                                    <button class="iconBtn" id="inviteUserCancel" title="Cancel"><i class="remove icon"></i></button>
+                                </div>
+                                <div class="peopleTable" id="userTable"></div>
+                            </div>
 
 
-            //Load jQuery first
-            if (typeof(window.jQuery) == "undefined"){
-                //jQuery not found. Load it
-                for (var i = 0; i < loopCount; i++){
-                    var relpath = possibleLocations[i];
-                    tryLoad(relpath, "jquery.min.js", function(relpath, filename){
-                        //Generate the jquery script element
-                        var script = document.createElement('script');
-                        
-                        script.setAttribute('src', relpath + filename);
-                        document.getElementsByTagName('head')[0].appendChild(script);
-                        
-                        doAfterJqueryLoaded(function(){
-                            injectOtherJavaScriptLibrary(relpath);
-                        });
-                        
-                    });
-                }
-            }else{
-                //jQuery exists. Load ao_module
-                injectOtherJavaScriptLibrary();
-            }
+                            <!-- Groups -->
+                            <div class="peopleSection" id="sectionGroups">
+                                <div class="listHeader">
+                                    <div class="listTitle" locale="people/groups">Group Share</div>
+                                    <button class="btn soft small" id="addGroupBtn"><i class="plus icon"></i><span locale="btn/add">Add</span></button>
+                                </div>
+                                <div class="inviteRow" id="inviteGroupRow" style="display:none;">
+                                    <select class="field" id="inviteGroupSelect"></select>
+                                    <select class="field narrow" id="inviteGroupLevel"></select>
+                                    <button class="btn primary small" id="inviteGroupConfirm" locale="btn/invite">Invite</button>
+                                    <button class="iconBtn" id="inviteGroupCancel" title="Cancel"><i class="remove icon"></i></button>
+                                </div>
+                                <div class="peopleTable" id="groupTable"></div>
+                            </div>
+                        </div>
+                    </div>
+                </section>
+            </main>
 
 
-            function doAfterJqueryLoaded(callback){
-                setTimeout(function(){
-                    //Wait until it is loaded
-                    if (typeof(window.jQuery) == "undefined"){
-                        doAfterJqueryLoaded(callback);
-                        return
-                    }else{
-                        callback();
-                    }
-                }), 300;
-            }
-            */
+            <footer class="dialogFooter">
+                <div class="footLeft" id="footLeft"></div>
+                <div class="footRight" id="footRight"></div>
+            </footer>
+        </div>
 
 
-            function copyLinkToClipboard(btn){
-                //Copy text
-                const area = document.createElement('textarea');
-                document.body.appendChild(area);
+        <!-- Row menu ("...") -->
+        <div id="popMenu" class="popMenu" style="display:none;"></div>
 
 
-                area.value = fileSharingURL;
-                area.select();
-                document.execCommand('copy');
+        <!-- QR code overlay -->
+        <div id="qrOverlay" class="overlay" style="display:none;">
+            <div class="overlayCard">
+                <div id="qrCode"></div>
+                <div class="qrLink" id="qrLink"></div>
+                <button class="btn soft" id="qrClose" locale="btn/close">Close</button>
+            </div>
+        </div>
 
 
-                document.body.removeChild(area);
+        <!-- Confirm overlay -->
+        <div id="confirmOverlay" class="overlay" style="display:none;">
+            <div class="overlayCard">
+                <div class="confirmText" id="confirmText"></div>
+                <div class="confirmButtons">
+                    <button class="btn soft" id="confirmCancel" locale="btn/cancel">Cancel</button>
+                    <button class="btn danger" id="confirmOk" locale="menu/remove">Remove Share</button>
+                </div>
+            </div>
+        </div>
 
 
-                //Do visual feedback
-                let oldContent = $(btn).html();
-                $(btn).html(`<i class="green checkmark icon"></i> ${applocale.getString("button/copied","Copied!")}`);
-                setTimeout(function(){
-                    $(btn).html(oldContent);
-                }, 3000);
-            }
+        <div id="toast" class="toast"></div>
 
 
-        </script>
+        <script src="js/file_share.js"></script>
     </body>
     </body>
-</html>
+</html>

+ 73 - 97
src/web/SystemAO/file_system/js/explorer/share.js

@@ -5,130 +5,111 @@
 
 
     Part of the ArozOS File Manager. Loaded as a plain script from
     Part of the ArozOS File Manager. Loaded as a plain script from
     file_explorer.html - see the <script> block at the end of that file.
     file_explorer.html - see the <script> block at the end of that file.
+
+    Opening the dialog does not create a share: file_share.html reads the
+    share state of the file and only creates or changes shares when the user
+    asks it to. It reports every change back through onShareStateChanged().
 */
 */
 
 
 function handleShareFilebuttonClick(event, object){
 function handleShareFilebuttonClick(event, object){
-    event.preventDefault(); 
+    event.preventDefault();
     event.stopImmediatePropagation();
     event.stopImmediatePropagation();
     $(".fileObject.selected").removeClass("selected");
     $(".fileObject.selected").removeClass("selected");
     $(object).closest(".fileObject").addClass("selected");
     $(object).closest(".fileObject").addClass("selected");
-    
-    shareFile();
-}
 
 
-/*
-    Sized against the viewport, not against #shareFile.
-
-    The dialog is content-height now, so its height is whatever this iframe
-    reports - measuring the dialog here would feed that back into the iframe and
-    shave another 126px off on every resize until it collapsed.
-*/
-function resizeShareIframe(){
-    let available = window.innerHeight - 200;
-    let height = Math.max(260, Math.min(available, 560));
-    $("#shareFileEmbedded").css("height", height + "px");
+    shareFile();
 }
 }
 
 
 function shareFile(){
 function shareFile(){
-    var selectedFiles = [];
     var selectedFileObjects = [];
     var selectedFileObjects = [];
     $(".fileObject.selected").each(function(){
     $(".fileObject.selected").each(function(){
-        var thisFilepath = $(this).attr("filepath");
-        var thisFilename = $(this).attr("filename");
-        selectedFiles.push(thisFilepath);
-        selectedFileObjects.push({"filepath": thisFilepath, "filename": thisFilename});
+        selectedFileObjects.push({
+            "filepath": $(this).attr("filepath"),
+            "filename": $(this).attr("filename")
+        });
     });
     });
 
 
-    if (selectedFiles.length == 0){
+    if (selectedFileObjects.length == 0){
         msgbox("question", applocale.getString("message/No file selected", "No file selected"));
         msgbox("question", applocale.getString("message/No file selected", "No file selected"));
-        console.log("No file is selected for sharing");
         return;
         return;
-    }else if (selectedFiles.length > 1){
+    }else if (selectedFileObjects.length > 1){
         //Try to share more than 1 files, which is not supported
         //Try to share more than 1 files, which is not supported
         msgbox("yellow exclamation", applocale.getString("message/Multiple files share is currently not supported", "Multiple files share is currently not supported"));
         msgbox("yellow exclamation", applocale.getString("message/Multiple files share is currently not supported", "Multiple files share is currently not supported"));
-        console.log("Multi share is current not supported");
         return
         return
     }
     }
 
 
-    //OK! Continue to generate link
-    var selectedFile = selectedFiles[0];
+    //Close whatever is open first: hideAllPopupWindows() blanks the share
+    //iframe if the share dialog was the one showing
+    hideAllPopupWindows();
+
     var selectedFileObject = selectedFileObjects[0];
     var selectedFileObject = selectedFileObjects[0];
-    shareEditingObject = selectedFile;
-    $.ajax({
-        url: "../../system/file_system/share/new",
-        data: {path: selectedFile},
-        success: function(data){
-            if (data.error !== undefined){
-                msgbox("red remove",applocale.getString("message/" + data.error,data.error), 5000);
-            }else{
-                //Build the predicted share endpoint
-                selectedFileObject["QRCode"] = true;
-                selectedFileObject["ActionButtons"] = false;
-                var payload = encodeURIComponent(JSON.stringify([selectedFileObject]));
-                var requestURL = "file_share.html#" + payload;
-                $("#shareFileEmbedded").attr("src", requestURL);
-                resizeShareIframe();
-
-                //Show the share file interface
-                hideAllPopupWindows();
-                showPopupWrapper();
-                $("#shareFile").transition('slide left');
-
-                //Reload the list
-                listDirectory(currentPath);
-            }
-            
-        }
-    });
-    
+    selectedFileObject["Embedded"] = true;
+    var payload = encodeURIComponent(JSON.stringify([selectedFileObject]));
+    $("#shareFileEmbedded").attr("src", "file_share.html#" + payload);
+
+    showPopupWrapper();
+    $("#shareFile").transition('fade in');
 }
 }
 
 
+function hideShare(){
+    hideAllPopupWindows();
+    $("#shareFileEmbedded").attr("src", "");
+}
+
+/*
+    Remove every share of the selected file. The dialog has its own per-link
+    controls; this is kept for callers that want a one shot "stop sharing".
+*/
 function removeSharing(){
 function removeSharing(){
-    if (shareEditingObject == ""){
-        return
+    var selected = $(".fileObject.selected").first();
+    if (selected.length == 0){
+        return;
     }
     }
-
-    //The target file to remove
-    var selectedFile = shareEditingObject;
-    $("#shareFileEmbedded").attr("src", "");
     $.ajax({
     $.ajax({
         url: "../../system/file_system/share/delete",
         url: "../../system/file_system/share/delete",
-        data: {vpath: selectedFile},
+        method: "POST",
+        data: {vpath: selected.attr("filepath")},
         success: function(data){
         success: function(data){
-            $("#qrcode").html(`<img src="img/private.png">`);
-            $(".shareoption").parent().addClass("disabled");
-            $("#sharelink").text("(Sharing Removed)");
-            $("#sharelink").removeAttr("href");
-            //Reload the current filelist and hide the share interface
-            listDirectory(currentPath);
-
-            //Reset sharing file settings
-            shareEditingObject = ""
+            if (data.error !== undefined){
+                msgbox("red remove", applocale.getString("message/" + data.error, data.error), 5000);
+                return;
+            }
+            onShareStateChanged(selected.attr("filepath"), false);
+            msgbox("checkmark", applocale.getString("message/share/removed", "File share removed"));
         }
         }
     });
     });
-
-    hideAllPopupWindows();
-    msgbox("checkmark", applocale.getString("message/share/removed", "File share removed"))
 }
 }
 
 
-function hideShare(){
-    hideAllPopupWindows();
-    $("#shareFileEmbedded").attr("src", "");
+/*
+    Called by the embedded file_share.html whenever it learns the share state
+    of its file (on load and after every change). Re-list only when the badge
+    in our listing is out of date, and keep the desktop icon in step.
+*/
+let shareRelistTimer = null;
+function onShareStateChanged(filepath, isShared){
+    var fileObject = $(".fileObject").filter(function(){
+        return $(this).attr("filepath") == filepath;
+    });
+    var badgeShown = fileObject.find(".sharebtn").length > 0;
+    if (fileObject.length > 0 && badgeShown != isShared){
+        clearTimeout(shareRelistTimer);
+        shareRelistTimer = setTimeout(function(){
+            listDirectory(currentPath);
+        }, 300);
+    }
+
+    var parts = filepath.split("/");
+    var filename = parts.pop();
+    if (parts.join("/") == "user:/Desktop"){
+        forwardShareIndicatorToDesktop(isShared ? "setFileShareIndicator" : "removeFileShareIndicator", filename);
+    }
 }
 }
 
 
 /*
 /*
-    Cross frame hooks called by the embedded file_share.html iframe.
-
-    file_share.html calls parent.setFileShareIndicator(filename) after a
-    share is created and parent.removeFileShareIndicator(filename) after
-    one is revoked. When that page runs as a float window its parent is
-    desktop.html, which defines both. When the File Manager embeds it in
-    #shareFileEmbedded the parent is this document instead, so without
-    these the call threw and the share flow broke for anything under
-    user:/Desktop.
-
-    Here we update our own listing's share badge and forward to the
-    desktop so its icon stays in step too.
+    Cross frame hooks kept for pages that still call parent.setFileShareIndicator
+    / parent.removeFileShareIndicator directly. When the File Manager runs inside
+    the desktop, the desktop draws the share badge on its own icons, so forward
+    the call there.
 */
 */
 function setFileShareIndicator(filename){
 function setFileShareIndicator(filename){
     forwardShareIndicatorToDesktop("setFileShareIndicator", filename);
     forwardShareIndicatorToDesktop("setFileShareIndicator", filename);
@@ -138,12 +119,6 @@ function removeFileShareIndicator(filename){
     forwardShareIndicatorToDesktop("removeFileShareIndicator", filename);
     forwardShareIndicatorToDesktop("removeFileShareIndicator", filename);
 }
 }
 
 
-/*
-    The share badge in our own listing comes from the IsShared flag on
-    each listDir entry, and both shareFile() and removeSharing() already
-    re-list the directory afterwards, so there is nothing to repaint here.
-    Only the desktop needs telling.
-*/
 function forwardShareIndicatorToDesktop(fname, filename){
 function forwardShareIndicatorToDesktop(fname, filename){
     if (!ao_module_virtualDesktop){
     if (!ao_module_virtualDesktop){
         return;
         return;
@@ -165,8 +140,9 @@ function forwardShareIndicatorToDesktop(fname, filename){
     of these means updating those call sites too.
     of these means updating those call sites too.
 */
 */
 window.handleShareFilebuttonClick = handleShareFilebuttonClick;
 window.handleShareFilebuttonClick = handleShareFilebuttonClick;
-window.hideShare = hideShare;
-window.removeFileShareIndicator = removeFileShareIndicator;   // the embedded file_share.html iframe calls parent.*
+window.hideShare = hideShare;                       // file_share.html calls parent.hideShare() to close
+window.onShareStateChanged = onShareStateChanged;   // file_share.html reports share changes here
+window.removeFileShareIndicator = removeFileShareIndicator;
 window.removeSharing = removeSharing;
 window.removeSharing = removeSharing;
-window.setFileShareIndicator = setFileShareIndicator;   // the embedded file_share.html iframe calls parent.*
+window.setFileShareIndicator = setFileShareIndicator;
 window.shareFile = shareFile;
 window.shareFile = shareFile;

+ 0 - 3
src/web/SystemAO/file_system/js/explorer/state.js

@@ -158,9 +158,6 @@ let responsiveLayoutTimer = null;
 //Module-registered extension icons (ext without dot → web-root-relative path)
 //Module-registered extension icons (ext without dot → web-root-relative path)
 var extIconRegistry = {};
 var extIconRegistry = {};
 
 
-//File Sharing related
-let shareEditingObject = "";
-
 //System Information
 //System Information
 
 
 //Browser detection
 //Browser detection

+ 0 - 3
src/web/SystemAO/file_system/js/explorer/ui.js

@@ -56,9 +56,6 @@ function applyResponsiveLayout(){
         toggleSidebar(false);
         toggleSidebar(false);
     }
     }
 
 
-    //Resize the share iframe
-    resizeShareIframe()
-
     //Resize the path display content
     //Resize the path display content
     if (!pathInputMode){
     if (!pathInputMode){
         updatePathDisplay(currentPath);
         updatePathDisplay(currentPath);

+ 1071 - 0
src/web/SystemAO/file_system/js/file_share.js

@@ -0,0 +1,1071 @@
+/*
+    file_share.js
+
+    Logic of the File Share dialog (file_share.html).
+
+    The dialog only reads the share state through share/info and changes it
+    through share/create, share/update and share/delete - opening the dialog
+    never creates a share by itself.
+
+    A file can carry several shares. Each is one link with its own audience
+    (anyone / signed in / same group / invited users / invited groups),
+    access level, password, download and listing options and expiry:
+
+        Quick Share          one link at a glance, or the list of all links
+        Advanced Settings    create a link, or edit one, with every option
+        Permissions          public links, invited users and invited groups
+*/
+
+var ShareDialog = (function () {
+    "use strict";
+
+    var API = "../../system/file_system/share/";
+    var DAY = 86400;
+
+    var state = {
+        file: null,            //{filename, filepath, ...flags} from the hash
+        info: null,            //Response of share/info
+        stat: null,            //{Size, FileCount} once the folder walk returns
+        recipients: null,      //Response of share/recipients (lazy)
+        tab: "quick",
+        editingUUID: null,     //Share edited in Advanced Settings, null = new share
+        peopleView: "people",
+        pendingAllowDownload: true
+    };
+
+    /* ------------------------------------------------------------------ */
+    /*  Helpers                                                            */
+    /* ------------------------------------------------------------------ */
+
+    //Localised string with an English fallback and {placeholders}
+    function T(key, fallback, vars) {
+        var text = (typeof applocale !== "undefined") ? applocale.getString(key, fallback) : fallback;
+        if (vars) {
+            Object.keys(vars).forEach(function (k) {
+                text = text.split("{" + k + "}").join(vars[k]);
+            });
+        }
+        return text;
+    }
+
+    function esc(str) {
+        return String(str == null ? "" : str)
+            .replace(/&/g, "&amp;").replace(/</g, "&lt;")
+            .replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
+    }
+
+    function api(endpoint, data, method) {
+        var deferred = $.Deferred();
+        $.ajax({
+            url: API + endpoint,
+            method: method || "GET",
+            data: data || {},
+            success: function (resp) {
+                if (resp && resp.error !== undefined) {
+                    deferred.reject(resp.error);
+                } else {
+                    deferred.resolve(resp);
+                }
+            },
+            error: function (xhr) {
+                deferred.reject("HTTP " + xhr.status);
+            }
+        });
+        return deferred.promise();
+    }
+
+    function shareURL(share) {
+        return window.location.origin + "/share/" + share.UUID;
+    }
+
+    function formatDate(unix) {
+        var d = new Date(unix * 1000);
+        var pad = function (n) { return (n < 10 ? "0" : "") + n; };
+        return d.getFullYear() + "/" + pad(d.getMonth() + 1) + "/" + pad(d.getDate());
+    }
+
+    function expiryText(share) {
+        return share.ExpireAt > 0 ? formatDate(share.ExpireAt) : T("expiry/never", "Forever");
+    }
+
+    function formatSize(bytes) {
+        if (bytes == null || bytes < 0) {
+            return "";
+        }
+        var units = ["B", "KB", "MB", "GB", "TB"];
+        var i = 0;
+        var value = bytes;
+        while (value >= 1024 && i < units.length - 1) {
+            value /= 1024;
+            i++;
+        }
+        return (i == 0 ? value : value.toFixed(1)) + " " + units[i];
+    }
+
+    function isFolder() {
+        return state.info && state.info.IsFolder;
+    }
+
+    function shares() {
+        return (state.info && state.info.Shares) ? state.info.Shares : [];
+    }
+
+    function findShare(uuid) {
+        return shares().filter(function (s) { return s.UUID == uuid; })[0] || null;
+    }
+
+    function isPublicShare(share) {
+        return share.Permission == "anyone" || share.Permission == "signedin" || share.Permission == "samegroup";
+    }
+
+    function levelText(level) {
+        return level == "edit" ? T("level/edit", "Can Edit") : T("level/view", "Can View");
+    }
+
+    function shareTitle(share) {
+        switch (share.Permission) {
+            case "anyone":
+                return share.HasPassword ? T("kind/private", "Private Share (Password)") : T("kind/public", "Public Share");
+            case "signedin":
+                return T("kind/signedin", "Signed-in Users");
+            case "samegroup":
+                return T("kind/samegroup", "Same Group");
+            case "users":
+                return T("kind/user", "Invited User: {name}", {name: share.Accessibles.join(", ")});
+            case "groups":
+                return T("kind/group", "Group Share: {name}", {name: share.Accessibles.join(", ")});
+        }
+        return share.Permission;
+    }
+
+    function shareIcon(share) {
+        if (share.HasPassword) {
+            return "lock";
+        }
+        switch (share.Permission) {
+            case "anyone": return "globe";
+            case "signedin": return "user circle outline";
+            case "samegroup": return "users";
+            case "users": return "user friends";
+            case "groups": return "sitemap";
+        }
+        return "share alternate";
+    }
+
+    function audienceText(share) {
+        var key = {
+            anyone: ["aud/anyone", "Anyone with the link can access"],
+            signedin: ["aud/signedin", "Signed-in users with the link can access"],
+            samegroup: ["aud/samegroup", "Users in the same group can access"],
+            users: ["aud/users", "Only invited users can access"],
+            groups: ["aud/groups", "Only members of the selected groups can access"]
+        }[share ? share.Permission : "anyone"];
+        var text = T(key[0], key[1]);
+        if (share && share.HasPassword) {
+            text += " · " + T("aud/password", "Password protected");
+        }
+        return text;
+    }
+
+    //Stable colour per name for the avatar circles
+    function avatarColor(name) {
+        var palette = ["#1a73e8", "#e8710a", "#188038", "#a142f4", "#d01884", "#007b83", "#c5221f", "#5f6368"];
+        var hash = 0;
+        for (var i = 0; i < name.length; i++) {
+            hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
+        }
+        return palette[hash % palette.length];
+    }
+
+    var toastTimer = null;
+    function toast(message, isError) {
+        var el = $("#toast");
+        el.text(message).toggleClass("error", !!isError).addClass("show");
+        clearTimeout(toastTimer);
+        toastTimer = setTimeout(function () { el.removeClass("show"); }, isError ? 4000 : 2200);
+    }
+
+    function copyText(text) {
+        if (navigator.clipboard && window.isSecureContext) {
+            return navigator.clipboard.writeText(text);
+        }
+        var area = document.createElement("textarea");
+        area.value = text;
+        document.body.appendChild(area);
+        area.select();
+        document.execCommand("copy");
+        document.body.removeChild(area);
+        return $.Deferred().resolve().promise();
+    }
+
+    function copyLink(share, btn) {
+        copyText(shareURL(share));
+        toast(T("quick/copied", "Copied!"));
+        if (btn) {
+            var original = $(btn).html();
+            $(btn).html(`<i class="check icon"></i> ${esc(T("quick/copied", "Copied!"))}`);
+            setTimeout(function () { $(btn).html(original); }, 1800);
+        }
+    }
+
+    function confirmAction(message, okLabel, callback) {
+        $("#confirmText").text(message);
+        $("#confirmOk").text(okLabel || T("menu/remove", "Remove Share"));
+        $("#confirmOverlay").fadeIn(100);
+        $("#confirmOk").off("click").on("click", function () {
+            $("#confirmOverlay").fadeOut(100);
+            callback();
+        });
+    }
+
+    function showQRCode(share) {
+        $("#qrCode").empty();
+        new QRCode(document.getElementById("qrCode"), shareURL(share));
+        $("#qrLink").text(shareURL(share));
+        $("#qrOverlay").fadeIn(100);
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Parent frame integration                                           */
+    /* ------------------------------------------------------------------ */
+
+    //Tell whoever opened us that the share state of the file changed, so the
+    //share badge in the File Manager / on the desktop stays in step.
+    function notifyParent() {
+        if (!state.info || window.parent === window) {
+            return;
+        }
+        var isShared = !!state.info.IsShared;
+        try {
+            if (typeof parent.onShareStateChanged === "function") {
+                parent.onShareStateChanged(state.file.filepath, isShared);
+                return;
+            }
+            var dir = state.file.filepath.split("/").slice(0, -1).join("/");
+            if (dir == "user:/Desktop") {
+                var fn = isShared ? "setFileShareIndicator" : "removeFileShareIndicator";
+                if (typeof parent[fn] === "function") {
+                    parent[fn](state.file.filename);
+                }
+            }
+        } catch (ex) {
+            //Parent is gone or cross origin - not fatal
+        }
+    }
+
+    function closeDialog() {
+        try {
+            if (window.parent !== window && typeof parent.hideShare === "function") {
+                parent.hideShare();
+                return;
+            }
+        } catch (ex) {}
+        if (typeof ao_module_virtualDesktop !== "undefined" && ao_module_virtualDesktop) {
+            ao_module_close();
+        } else {
+            window.close();
+        }
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Loading                                                            */
+    /* ------------------------------------------------------------------ */
+
+    function reload() {
+        return api("info", {path: state.file.filepath}).then(function (info) {
+            state.info = info;
+            if (state.editingUUID && !findShare(state.editingUUID)) {
+                state.editingUUID = null;
+            }
+            render();
+            notifyParent();
+            return info;
+        }, function (err) {
+            $("#loadingPane").html(`<i class="exclamation circle icon"></i><p>${esc(err)}</p>`);
+        });
+    }
+
+    function loadFolderStat() {
+        if (!isFolder()) {
+            return;
+        }
+        api("info", {path: state.file.filepath, folderStat: true}).then(function (info) {
+            state.stat = {Size: info.Size, FileCount: info.FileCount};
+            renderFileCard();
+        });
+    }
+
+    function loadRecipients() {
+        if (state.recipients) {
+            return $.Deferred().resolve(state.recipients).promise();
+        }
+        return api("recipients").then(function (data) {
+            state.recipients = data;
+            return data;
+        });
+    }
+
+    //Legacy quick modes from the desktop context menu
+    function applyShareMode(mode) {
+        if (mode == "remove") {
+            return api("delete", {vpath: state.file.filepath}, "POST").then(function () {
+                toast(T("msg/removed", "Share removed"));
+            }, function () {});
+        }
+        return api("info", {path: state.file.filepath}).then(function (info) {
+            if (info.Shares.length > 0) {
+                return api("update", {uuid: info.Shares[0].UUID, permission: mode}, "POST");
+            }
+            return api("create", {path: state.file.filepath, permission: mode}, "POST");
+        }).then(function () {}, function (err) {
+            toast(err, true);
+        });
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Rendering                                                          */
+    /* ------------------------------------------------------------------ */
+
+    function render() {
+        $("#loadingPane").hide();
+        renderFileCard();
+
+        if (state.info && !state.info.CanManage) {
+            $(".tabPane").hide();
+            $(".tab").prop("disabled", true);
+            $("#notManageablePane").show();
+            setFooter("", `<button class="btn primary" data-act="close">${esc(T("btn/done", "Done"))}</button>`);
+            return;
+        }
+
+        $(".tab").removeClass("active").filter(`[data-tab="${state.tab}"]`).addClass("active");
+        $(".tabPane").hide();
+        $("#tab-" + state.tab).show();
+
+        if (state.tab == "quick") {
+            renderQuick();
+        } else if (state.tab == "advanced") {
+            renderAdvanced();
+        } else {
+            renderPeople();
+        }
+    }
+
+    function renderFileCard() {
+        var info = state.info;
+        var name = info ? info.Filename : state.file.filename;
+        var folder = info ? info.IsFolder : false;
+        $("#fileName").text(name).attr("title", name);
+        $("#filePath").text(state.file.filepath).attr("title", state.file.filepath);
+        if (typeof FileThumb !== "undefined") {
+            $("#fileIcon").html(FileThumb.largeGlyph(name, folder));
+        }
+
+        var stat = "";
+        if (info && !folder) {
+            stat = formatSize(info.Size);
+        } else if (folder && state.stat && state.stat.FileCount >= 0) {
+            stat = T("file/items", "{n} files", {n: state.stat.FileCount}) + " · " + formatSize(state.stat.Size);
+        } else if (folder) {
+            stat = T("file/folder", "Folder");
+        }
+        $("#fileStat").text(stat || " ");
+    }
+
+    function setFooter(leftHTML, rightHTML) {
+        $("#footLeft").html(leftHTML);
+        $("#footRight").html(rightHTML);
+    }
+
+    function switchTab(tab) {
+        state.tab = tab;
+        render();
+        $(".tabBody").scrollTop(0);
+    }
+
+    /* ---------- Quick share ---------- */
+
+    function renderQuick() {
+        var list = shares();
+        if (list.length >= 2) {
+            $("#quickSingle").hide();
+            $("#quickList").show();
+            $("#quickListTitle").text(T("quick/created", "Share Links Created ({n})", {n: list.length}));
+            $("#quickListBody").html(list.map(shareRowHTML).join(""));
+            setFooter(
+                `<div class="footTip"><i class="lightbulb outline icon"></i><span>${esc(T("quick/tip", "Tip: You can revoke a share link or change its permission and expiry at any time."))}</span></div>`,
+                `<button class="btn soft" data-act="close">${esc(T("btn/close", "Close"))}</button>`
+            );
+            return;
+        }
+
+        $("#quickList").hide();
+        $("#quickSingle").show();
+        var share = list[0] || null;
+        $("#quickAudienceIcon").attr("class", "rowIcon " + (share ? shareIcon(share) : "globe") + " icon");
+        $("#quickAudience").text(audienceText(share));
+        $("#quickMenuBtn").toggle(!!share);
+        if (share) {
+            $("#quickLink").val(shareURL(share));
+            $("#quickCopyIcon").show();
+            $("#quickLinkBtn").html(esc(T("quick/copy", "Copy Link")));
+            $("#quickAllowDownload").prop("checked", share.AllowDownload);
+        } else {
+            $("#quickLink").val("");
+            $("#quickCopyIcon").hide();
+            $("#quickLinkBtn").html(`<i class="linkify icon"></i> ${esc(T("quick/create", "Create Link"))}`);
+            $("#quickAllowDownload").prop("checked", state.pendingAllowDownload);
+        }
+
+        var expiry = share ? expiryText(share) : T("expiry/never", "Forever");
+        setFooter(
+            `<button class="footPill" data-act="editExpiry"><span>${esc(T("quick/expiry", "Expiry: {x}", {x: expiry}))}</span><i class="angle right icon"></i></button>`,
+            `<button class="btn primary" data-act="close">${esc(T("btn/done", "Done"))}</button>`
+        );
+    }
+
+    function shareRowHTML(share) {
+        var status = share.IsExpired
+            ? `<span class="statusPill expired">${esc(T("status/expired", "Expired"))}</span>`
+            : `<span class="statusPill">${esc(T("status/active", "Active"))}</span>`;
+        var desc = shareURL(share);
+        if (!isPublicShare(share)) {
+            desc = levelText(share.AccessLevel) + " · " + desc;
+        }
+        return `<div class="shareRow" data-uuid="${esc(share.UUID)}" data-act="editShare">
+            <i class="rowIcon ${shareIcon(share)} icon"></i>
+            <div class="rowText">
+                <div class="rowTitle">${esc(shareTitle(share))}</div>
+                <div class="rowDesc">${esc(desc)}</div>
+            </div>
+            ${status}
+            <span class="expiryText">${esc(expiryText(share))}</span>
+            <button class="iconBtn" data-act="shareMenu" data-uuid="${esc(share.UUID)}" title="More"><i class="ellipsis horizontal icon"></i></button>
+        </div>`;
+    }
+
+    function quickCreateLink() {
+        var btn = $("#quickLinkBtn").prop("disabled", true);
+        api("create", {
+            path: state.file.filepath,
+            permission: "anyone",
+            allowDownload: state.pendingAllowDownload
+        }, "POST").then(function () {
+            toast(T("msg/created", "Share created"));
+            return reload();
+        }, function (err) {
+            toast(err, true);
+        }).always(function () {
+            btn.prop("disabled", false);
+        });
+    }
+
+    /* ---------- Advanced settings ---------- */
+
+    function renderAdvanced() {
+        var share = state.editingUUID ? findShare(state.editingUUID) : null;
+        var folder = isFolder();
+
+        //What is being edited
+        if (share) {
+            $("#advContext").html(`<span>${esc(T("adv/editing", "Editing:"))}</span>
+                <span class="chip">${esc(shareTitle(share))}</span>
+                <a data-act="newShare">${esc(T("adv/createInstead", "Create a new share instead"))}</a>`);
+        } else {
+            $("#advContext").html(`<span class="chip">${esc(T("adv/new", "New share link"))}</span>`);
+        }
+
+        //Expiry
+        var mode = "never";
+        $("#advExpiryDays").val("");
+        $("#advExpiryDate").val("");
+        if (share && share.ExpireAt > 0) {
+            mode = "date";
+            var d = new Date(share.ExpireAt * 1000);
+            var pad = function (n) { return (n < 10 ? "0" : "") + n; };
+            $("#advExpiryDate").val(d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()));
+        }
+        $(`input[name="expiryMode"][value="${mode}"]`).prop("checked", true);
+        var tomorrow = new Date(Date.now() + DAY * 1000);
+        $("#advExpiryDate").attr("min", tomorrow.toISOString().substring(0, 10));
+
+        //Password
+        var hasPassword = share ? share.HasPassword : false;
+        $("#advPasswordToggle").prop("checked", hasPassword);
+        $("#advPassword").val("").attr("type", "password")
+            .attr("placeholder", hasPassword ? "••••••••" : T("adv/passwordPlaceholder", "Enter a password"));
+        $("#advPasswordEye i").attr("class", "eye icon");
+        $("#advPasswordHint").toggle(hasPassword);
+        syncPasswordField();
+
+        //Access level (edit is folder only)
+        $("#advAccessLevel option[value='edit']").prop("disabled", !folder);
+        $("#advAccessLevel").val(share && folder ? share.AccessLevel : "view");
+        syncAccessLevelDesc();
+
+        $("#advAllowDownload").prop("checked", share ? share.AllowDownload : true);
+        $("#advShowFileList").prop("checked", folder ? (share ? share.ShowFileList : true) : false)
+            .prop("disabled", !folder);
+        $("#advShowListDesc").text(folder ? T("adv/showListDesc", "Recipients can browse the shared files") : T("adv/folderOnly", "Only available for folders"));
+
+        //Audience (public links only; invites are managed in Permissions)
+        var invite = share && !isPublicShare(share);
+        $("#advAudience").val(share && !invite ? share.Permission : "anyone").prop("disabled", !!invite);
+        $("#advAudienceHint").toggle(!!invite);
+
+        setFooter(
+            "",
+            `<button class="btn primary" data-act="saveAdvanced" id="advSaveBtn">${esc(share ? T("btn/save", "Save Changes") : T("btn/createShare", "Create Share"))}</button>`
+        );
+    }
+
+    function syncPasswordField() {
+        var on = $("#advPasswordToggle").prop("checked");
+        $("#advPasswordField").css("opacity", on ? 1 : 0.5);
+        $("#advPassword").prop("disabled", !on);
+    }
+
+    function syncAccessLevelDesc() {
+        var edit = $("#advAccessLevel").val() == "edit";
+        $("#advAccessLevelDesc").text(edit
+            ? T("level/editDesc", "Can also upload files into this folder")
+            : T("level/viewDesc", "Can only view the content"));
+    }
+
+    function collectAdvancedSettings(share) {
+        var data = {
+            accessLevel: $("#advAccessLevel").val(),
+            allowDownload: $("#advAllowDownload").prop("checked")
+        };
+        if (isFolder()) {
+            data.showFileList = $("#advShowFileList").prop("checked");
+        }
+
+        //Expiry
+        var mode = $('input[name="expiryMode"]:checked').val();
+        if (mode == "never") {
+            data.expireAt = 0;
+        } else if (mode == "date") {
+            var value = $("#advExpiryDate").val();
+            if (!value) {
+                throw T("msg/needDate", "Please choose an expiry date");
+            }
+            var parts = value.split("-");
+            //Valid until the end of the chosen day, local time
+            var end = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10), 23, 59, 59);
+            data.expireAt = Math.floor(end.getTime() / 1000);
+        } else {
+            var days = parseInt($("#advExpiryDays").val(), 10);
+            if (!(days > 0)) {
+                throw T("msg/needDays", "Please enter the number of days");
+            }
+            data.expireIn = days * DAY;
+        }
+
+        //Password
+        var passwordOn = $("#advPasswordToggle").prop("checked");
+        var password = $("#advPassword").val();
+        if (passwordOn) {
+            if (password != "") {
+                data.password = password;
+            } else if (!share || !share.HasPassword) {
+                throw T("msg/needPassword", "Please enter a password");
+            }
+        } else if (share && share.HasPassword) {
+            data.clearPassword = true;
+        }
+
+        //Audience of public links
+        if (!share || isPublicShare(share)) {
+            data.permission = $("#advAudience").val();
+        }
+        return data;
+    }
+
+    function saveAdvanced() {
+        var share = state.editingUUID ? findShare(state.editingUUID) : null;
+        var data;
+        try {
+            data = collectAdvancedSettings(share);
+        } catch (message) {
+            toast(message, true);
+            return;
+        }
+
+        var request;
+        if (share) {
+            data.uuid = share.UUID;
+            request = api("update", data, "POST");
+        } else {
+            data.path = state.file.filepath;
+            request = api("create", data, "POST");
+        }
+
+        var btn = $("#advSaveBtn").prop("disabled", true);
+        request.then(function (saved) {
+            toast(share ? T("msg/saved", "Changes saved") : T("msg/created", "Share created"));
+            state.editingUUID = saved.UUID;
+            if (!share) {
+                state.tab = "quick";
+            }
+            return reload();
+        }, function (err) {
+            toast(err, true);
+            btn.prop("disabled", false);
+        });
+    }
+
+    function editShare(uuid) {
+        state.editingUUID = uuid;
+        switchTab("advanced");
+    }
+
+    /* ---------- Permission management ---------- */
+
+    //Invites, one row per user / group, so a legacy share listing several
+    //users still shows each of them
+    function inviteRows(permission) {
+        var rows = [];
+        shares().forEach(function (share) {
+            if (share.Permission == permission) {
+                share.Accessibles.forEach(function (name) {
+                    rows.push({name: name, share: share});
+                });
+            }
+        });
+        return rows;
+    }
+
+    function renderPeople() {
+        var publicShares = shares().filter(isPublicShare);
+        var userRows = inviteRows("users");
+        var groupRows = inviteRows("groups");
+
+        $("#navPublicDesc").text(T("people/publicDesc", "{n} links · Anyone can access", {n: publicShares.length}));
+        $("#navPeopleDesc").text(T("people/sharedDesc", "{u} users · {g} groups", {u: userRows.length, g: groupRows.length}));
+
+        var view = state.peopleView;
+        $(".navItem").removeClass("active").filter(`[data-view="${view}"]`).addClass("active");
+        $("#sectionPublic").toggle(view == "public");
+        $("#sectionUsers, #sectionGroups").toggle(view == "people");
+
+        //Public links
+        $("#publicList").html(publicShares.length > 0
+            ? publicShares.map(shareRowHTML).join("")
+            : `<div class="emptyRow">${esc(T("people/noPublic", "No public link yet"))}</div>`);
+
+        //Invites
+        var head = `<div class="peopleHead">
+            <span>${esc(T("people/name", "Name"))}</span>
+            <span>${esc(T("people/permission", "Permission"))}</span>
+            <span class="expiryText">${esc(T("people/expiry", "Expiry"))}</span>
+            <span></span>
+        </div>`;
+        $("#userTable").html(userRows.length > 0
+            ? head + userRows.map(function (r) { return personRowHTML(r, false); }).join("")
+            : `<div class="emptyRow">${esc(T("people/noUsers", "No user has been invited yet"))}</div>`);
+        $("#groupTable").html(groupRows.length > 0
+            ? head + groupRows.map(function (r) { return personRowHTML(r, true); }).join("")
+            : `<div class="emptyRow">${esc(T("people/noGroups", "No group has been invited yet"))}</div>`);
+
+        //Level pickers of the invite rows
+        var levelOptions = `<option value="view">${esc(T("level/view", "Can View"))}</option>` +
+            (isFolder() ? `<option value="edit">${esc(T("level/edit", "Can Edit"))}</option>` : "");
+        $("#inviteUserLevel, #inviteGroupLevel").each(function () {
+            var current = $(this).val();
+            $(this).html(levelOptions).val(current || "view");
+        });
+
+        setFooter("", `<button class="btn primary" data-act="close">${esc(T("btn/done", "Done"))}</button>`);
+    }
+
+    function personRowHTML(row, isGroup) {
+        var share = row.share;
+        var subtitle = "";
+        if (isGroup) {
+            subtitle = T("people/groupMember", "Permission group");
+        } else if (state.recipients) {
+            var user = state.recipients.Users.filter(function (u) { return u.Username == row.name; })[0];
+            subtitle = user ? user.Groups.join(", ") : "";
+        }
+        if (share.IsExpired) {
+            subtitle = T("status/expired", "Expired") + (subtitle ? " · " + subtitle : "");
+        }
+
+        var levelSelect = `<select class="field" data-act="setLevel" data-uuid="${esc(share.UUID)}" ${isFolder() ? "" : "disabled"}>
+            <option value="view" ${share.AccessLevel == "view" ? "selected" : ""}>${esc(T("level/view", "Can View"))}</option>
+            ${isFolder() ? `<option value="edit" ${share.AccessLevel == "edit" ? "selected" : ""}>${esc(T("level/edit", "Can Edit"))}</option>` : ""}
+        </select>`;
+
+        return `<div class="peopleRow">
+            <div class="personCell">
+                <div class="avatar ${isGroup ? "group" : ""}" style="background:${avatarColor(row.name)};">${isGroup ? '<i class="users icon"></i>' : esc(row.name.substring(0, 1))}</div>
+                <div class="rowText">
+                    <div class="rowTitle">${esc(row.name)}</div>
+                    <div class="rowDesc">${esc(subtitle)}</div>
+                </div>
+            </div>
+            ${levelSelect}
+            <span class="expiryText" data-act="editShare" data-uuid="${esc(share.UUID)}">${esc(expiryText(share))}</span>
+            <button class="iconBtn" data-act="inviteMenu" data-uuid="${esc(share.UUID)}" data-name="${esc(row.name)}" title="More"><i class="ellipsis horizontal icon"></i></button>
+        </div>`;
+    }
+
+    function openInviteRow(isGroup) {
+        var row = isGroup ? $("#inviteGroupRow") : $("#inviteUserRow");
+        var select = isGroup ? $("#inviteGroupSelect") : $("#inviteUserSelect");
+        loadRecipients().then(function (data) {
+            var taken = inviteRows(isGroup ? "groups" : "users").map(function (r) { return r.name; });
+            var options = isGroup
+                ? data.Groups.filter(function (g) { return taken.indexOf(g) < 0; })
+                    .map(function (g) { return `<option value="${esc(g)}">${esc(g)}</option>`; })
+                : data.Users.filter(function (u) { return !u.IsSelf && taken.indexOf(u.Username) < 0; })
+                    .map(function (u) { return `<option value="${esc(u.Username)}">${esc(u.Username)}${u.Groups.length ? " (" + esc(u.Groups.join(", ")) + ")" : ""}</option>`; });
+            var placeholder = isGroup ? T("people/selectGroup", "Select a group") : T("people/selectUser", "Select a user");
+            select.html(`<option value="">${esc(placeholder)}</option>` + options.join(""));
+            row.slideDown(120);
+            if (!isGroup) {
+                //Subtitles of the user rows come from the recipient list
+                renderPeople();
+            }
+        }, function (err) {
+            toast(err, true);
+        });
+    }
+
+    function confirmInvite(isGroup) {
+        var name = (isGroup ? $("#inviteGroupSelect") : $("#inviteUserSelect")).val();
+        if (!name) {
+            toast(isGroup ? T("people/selectGroup", "Select a group") : T("people/selectUser", "Select a user"), true);
+            return;
+        }
+        api("create", {
+            path: state.file.filepath,
+            permission: isGroup ? "groups" : "users",
+            accessibles: name,
+            accessLevel: (isGroup ? $("#inviteGroupLevel") : $("#inviteUserLevel")).val() || "view"
+        }, "POST").then(function () {
+            toast(T("msg/invited", "Access granted and a notification was sent"));
+            (isGroup ? $("#inviteGroupRow") : $("#inviteUserRow")).slideUp(120);
+            return reload();
+        }, function (err) {
+            toast(err, true);
+        });
+    }
+
+    //Remove one invited user / group. Shares listing several names only lose that name.
+    function removeInvite(share, name) {
+        var remaining = share.Accessibles.filter(function (n) { return n != name; });
+        var request = remaining.length == 0
+            ? api("delete", {uuid: share.UUID}, "POST")
+            : api("update", {uuid: share.UUID, permission: share.Permission, accessibles: remaining.join(",")}, "POST");
+        request.then(function () {
+            toast(T("msg/removed", "Share removed"));
+            return reload();
+        }, function (err) {
+            toast(err, true);
+        });
+    }
+
+    function removeShare(share) {
+        confirmAction(T("msg/confirmRemove", "Remove this share link? People using it will lose access."), T("menu/remove", "Remove Share"), function () {
+            api("delete", {uuid: share.UUID}, "POST").then(function () {
+                toast(T("msg/removed", "Share removed"));
+                return reload();
+            }, function (err) {
+                toast(err, true);
+            });
+        });
+    }
+
+    /* ---------- "..." menus ---------- */
+
+    function openMenu(anchor, items) {
+        var menu = $("#popMenu");
+        menu.html(items.map(function (item, i) {
+            return `<div class="menuItem ${item.danger ? "danger" : ""}" data-index="${i}"><i class="${item.icon} icon"></i><span>${esc(item.label)}</span></div>`;
+        }).join(""));
+        menu.find(".menuItem").each(function () {
+            var item = items[parseInt($(this).attr("data-index"), 10)];
+            $(this).on("click", function (e) {
+                e.stopPropagation();
+                closeMenu();
+                item.action();
+            });
+        });
+
+        var rect = anchor.getBoundingClientRect();
+        menu.css({visibility: "hidden"}).show();
+        var left = Math.min(rect.right - menu.outerWidth(), window.innerWidth - menu.outerWidth() - 8);
+        var top = rect.bottom + 4;
+        if (top + menu.outerHeight() > window.innerHeight - 8) {
+            top = rect.top - menu.outerHeight() - 4;
+        }
+        menu.css({left: Math.max(8, left), top: Math.max(8, top), visibility: "visible"});
+    }
+
+    function closeMenu() {
+        $("#popMenu").hide();
+    }
+
+    function shareMenuItems(share) {
+        return [
+            {icon: "copy outline", label: T("menu/copy", "Copy Link"), action: function () { copyLink(share); }},
+            {icon: "external alternate", label: T("menu/open", "Open Link"), action: function () { window.open(shareURL(share), "_blank"); }},
+            {icon: "qrcode", label: T("menu/qr", "QR Code"), action: function () { showQRCode(share); }},
+            {icon: "edit outline", label: T("menu/edit", "Edit Settings"), action: function () { editShare(share.UUID); }},
+            {icon: "trash alternate outline", label: T("menu/remove", "Remove Share"), danger: true, action: function () { removeShare(share); }}
+        ];
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Events                                                             */
+    /* ------------------------------------------------------------------ */
+
+    function bindEvents() {
+        $(".tab").on("click", function () {
+            var tab = $(this).attr("data-tab");
+            if (tab == "advanced" && state.tab != "advanced") {
+                //Opening the tab directly edits the only share, or starts a new one
+                var list = shares();
+                state.editingUUID = list.length == 1 ? list[0].UUID : null;
+            }
+            switchTab(tab);
+        });
+
+        //Delegated actions
+        $(document).on("click", "[data-act]", function (e) {
+            var act = $(this).attr("data-act");
+            var uuid = $(this).attr("data-uuid");
+            var share = uuid ? findShare(uuid) : null;
+            switch (act) {
+                case "close":
+                    closeDialog();
+                    break;
+                case "editExpiry":
+                    editShare(shares().length == 1 ? shares()[0].UUID : null);
+                    break;
+                case "editShare":
+                    if (share) {
+                        editShare(share.UUID);
+                    }
+                    break;
+                case "shareMenu":
+                    e.stopPropagation();
+                    if (share) {
+                        openMenu(this, shareMenuItems(share));
+                    }
+                    break;
+                case "inviteMenu":
+                    e.stopPropagation();
+                    if (share) {
+                        var name = $(this).attr("data-name");
+                        openMenu(this, [
+                            {icon: "copy outline", label: T("menu/copy", "Copy Link"), action: function () { copyLink(share); }},
+                            {icon: "calendar alternate outline", label: T("menu/edit", "Edit Settings"), action: function () { editShare(share.UUID); }},
+                            {icon: "user times", label: T("menu/removeAccess", "Remove Access"), danger: true, action: function () { removeInvite(share, name); }}
+                        ]);
+                    }
+                    break;
+                case "newShare":
+                    state.editingUUID = null;
+                    renderAdvanced();
+                    break;
+                case "saveAdvanced":
+                    saveAdvanced();
+                    break;
+            }
+        });
+
+        //Keep a select inside a row from triggering the row's own action
+        $(document).on("click", "select[data-act]", function (e) {
+            e.stopPropagation();
+        });
+
+        $(document).on("change", "select[data-act='setLevel']", function () {
+            var share = findShare($(this).attr("data-uuid"));
+            if (!share) {
+                return;
+            }
+            api("update", {uuid: share.UUID, accessLevel: $(this).val()}, "POST").then(function () {
+                toast(T("msg/saved", "Changes saved"));
+                return reload();
+            }, function (err) {
+                toast(err, true);
+                renderPeople();
+            });
+        });
+
+        $(document).on("click", function (e) {
+            if (!$(e.target).closest("#popMenu").length) {
+                closeMenu();
+            }
+        });
+        $(".tabBody").on("scroll", closeMenu);
+
+        //Quick share
+        $("#quickLinkBtn").on("click", function () {
+            var share = shares()[0];
+            if (share) {
+                copyLink(share, this);
+            } else {
+                quickCreateLink();
+            }
+        });
+        $("#quickCopyIcon").on("click", function () {
+            var share = shares()[0];
+            if (share) {
+                copyLink(share);
+            }
+        });
+        $("#quickLink").on("focus click", function () {
+            $(this).select();
+        });
+        $("#quickMenuBtn").on("click", function (e) {
+            e.stopPropagation();
+            var share = shares()[0];
+            if (share) {
+                openMenu(this, shareMenuItems(share));
+            }
+        });
+        $("#quickAllowDownload").on("change", function () {
+            var checked = $(this).prop("checked");
+            var share = shares()[0];
+            if (!share) {
+                state.pendingAllowDownload = checked;
+                return;
+            }
+            api("update", {uuid: share.UUID, allowDownload: checked}, "POST").then(function () {
+                toast(T("msg/saved", "Changes saved"));
+                return reload();
+            }, function (err) {
+                toast(err, true);
+                $("#quickAllowDownload").prop("checked", !checked);
+            });
+        });
+        $("#quickNewShareBtn").on("click", function () {
+            editShare(null);
+        });
+
+        //Advanced settings
+        $("#advPasswordToggle").on("change", function () {
+            syncPasswordField();
+            if ($(this).prop("checked")) {
+                $("#advPassword").focus();
+            }
+        });
+        $("#advPasswordEye").on("click", function () {
+            var input = $("#advPassword");
+            var show = input.attr("type") == "password";
+            input.attr("type", show ? "text" : "password");
+            $(this).find("i").attr("class", show ? "eye slash icon" : "eye icon");
+        });
+        $("#advAccessLevel").on("change", syncAccessLevelDesc);
+        $("#advExpiryDate").on("focus change", function () {
+            $('input[name="expiryMode"][value="date"]').prop("checked", true);
+        });
+        $("#advExpiryDays").on("focus input", function () {
+            $('input[name="expiryMode"][value="days"]').prop("checked", true);
+        });
+
+        //Permission management
+        $(".navItem").on("click", function () {
+            state.peopleView = $(this).attr("data-view");
+            renderPeople();
+            $(".tabBody").scrollTop(0);
+        });
+        $("#addPublicBtn").on("click", function () {
+            editShare(null);
+        });
+        $("#addUserBtn").on("click", function () { openInviteRow(false); });
+        $("#addGroupBtn").on("click", function () { openInviteRow(true); });
+        $("#inviteUserConfirm").on("click", function () { confirmInvite(false); });
+        $("#inviteGroupConfirm").on("click", function () { confirmInvite(true); });
+        $("#inviteUserCancel").on("click", function () { $("#inviteUserRow").slideUp(120); });
+        $("#inviteGroupCancel").on("click", function () { $("#inviteGroupRow").slideUp(120); });
+
+        //Overlays
+        $("#qrClose, #qrOverlay").on("click", function (e) {
+            if (e.target === this) {
+                $("#qrOverlay").fadeOut(100);
+            }
+        });
+        $("#confirmCancel").on("click", function () {
+            $("#confirmOverlay").fadeOut(100);
+        });
+
+        $(document).on("keydown", function (e) {
+            if (e.key == "Escape") {
+                if ($("#popMenu").is(":visible")) {
+                    closeMenu();
+                } else if ($(".overlay:visible").length > 0) {
+                    $(".overlay").fadeOut(100);
+                }
+            }
+        });
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Theme and start up                                                 */
+    /* ------------------------------------------------------------------ */
+
+    function applyTheme(isDark) {
+        $("body").toggleClass("darkTheme", isDark).toggleClass("whiteTheme", !isDark);
+    }
+
+    function initTheme() {
+        //Embedded in the File Manager: follow its theme straight away
+        try {
+            if (window.parent !== window && parent.document.body.classList.contains("darkTheme")) {
+                applyTheme(true);
+            }
+        } catch (ex) {}
+
+        $.get("../../system/file_system/preference?key=file_explorer/theme", function (data) {
+            applyTheme(data == "darkTheme");
+        });
+
+        if (typeof ao_module_onThemeChanged === "function") {
+            ao_module_onThemeChanged(function (theme) {
+                applyTheme(theme == "dark" || theme == "darkTheme");
+            });
+        }
+    }
+
+    function start() {
+        initTheme();
+        bindEvents();
+
+        var input = ao_module_loadInputFiles();
+        if (input == null || input.length == 0) {
+            $("#loadingPane").html(`<i class="question circle outline icon"></i><p>${esc(T("msg/noFile", "No file selected"))}</p>`);
+            setFooter("", "");
+            return;
+        }
+        state.file = input[0];
+        renderFileCard();
+
+        var begin = function () {
+            var mode = state.file.shareMode;
+            var ready = mode ? applyShareMode(mode) : $.Deferred().resolve().promise();
+            ready.always(function () {
+                reload().then(loadFolderStat);
+            });
+        };
+
+        //Translate first so dynamic strings come out localised
+        applocale.init("../locale/file_share.json", function () {
+            applocale.translate();
+            begin();
+        }, begin);
+    }
+
+    return {
+        start: start,
+        reload: reload
+    };
+})();
+
+$(function () {
+    ShareDialog.start();
+});

+ 509 - 199
src/web/SystemAO/locale/file_share.json

@@ -1,160 +1,330 @@
 {
 {
     "author": "tobychui",
     "author": "tobychui",
-    "version": "1.0",
+    "version": "2.0",
     "keys": {
     "keys": {
         "zh-tw": {
         "zh-tw": {
-            "fontFamily":"\"Microsoft JhengHei\",\"SimHei\", \"Apple LiGothic Medium\", \"STHeiti\"",
-            "fwtitle" : "檔案分享",
-            "strings":{
-                "title/title": "檔案分享",
-                "button/copy":"複製到剪貼簿",
-                "button/remove":"移除分享",
-                "button/copied":"已複製!",
-
-                "share/setting/title":"分享設定",
-                "share/setting/subtitle":"設定誰可以存取此檔案",
-                "share/setting/options":"取存設定:",
-                "share/setting/anyoneWithLink":"擁有連接的使用者",
-                "share/setting/anyoneWithLink/desc":"任何擁有分享連接的使用者皆可下載此檔案",
-                "share/setting/anyoneSignedIn":"已登入的使用者",
-                "share/setting/anyoneSignedIn/desc":"任何已登入並擁有分享連接的使用者皆可下載此檔案",
-                "share/setting/sameGroup":"在同一權限群組內的使用者",
-                "share/setting/sameGroup/desc":"任何擁有相同使用者權限的使用者皆可下載此檔案",
-                "share/setting/updated":"存取權限已更新",
-
-                "share/setting/advance/title":"進階分享選項",
-                "share/setting/advance/users":"指定使用者",
-                "share/setting/advance/usersDesc":"指定已登入之使用者帳戶名稱",
-                "share/setting/advance/usersInstruct":"於下方列表中選擇指定分享的用戶",
-                "share/setting/advance/addUserToSave":"新增最少一名使用者以儲存變更",
-                "share/setting/advance/groups":"指定權限群組",
-                "share/setting/advance/groupsDesc":"使用者只要擁有下述指定之任何一個群組權限皆可存取",
-                "share/setting/advance/groupsInstruct":"於下方列表中選擇指定分享的群組",
-                "share/setting/advance/addGroupToSave":"新增最少一個權限群組以儲存變更",
-
-                "message/removed": "分享已移除",
-                "qr/loading": "載入中"
-               
-            },
-            "titles":{
-
+            "fontFamily": "\"Microsoft JhengHei\",\"SimHei\", \"Apple LiGothic Medium\", \"STHeiti\"",
+            "fwtitle": "分享檔案",
+            "strings": {
+                "title/title": "分享檔案",
+                "tab/quick": "快速分享",
+                "tab/advanced": "進階設定",
+                "tab/people": "權限管理",
+                "file/items": "共 {n} 個檔案",
+                "file/folder": "資料夾",
+                "quick/link": "分享連結",
+                "quick/create": "建立連結",
+                "quick/copy": "複製連結",
+                "quick/copied": "已複製!",
+                "quick/allowDownload": "允許下載",
+                "quick/allowDownloadDesc": "收件者可以下載檔案",
+                "quick/expiry": "有效期限:{x}",
+                "quick/created": "已建立的分享連結({n})",
+                "quick/newShare": "建立分享",
+                "quick/tip": "小提示:你可以隨時撤銷分享連結,或修改分享權限與到期時間。",
+                "btn/done": "完成",
+                "btn/close": "關閉",
+                "btn/createShare": "建立分享",
+                "btn/save": "儲存變更",
+                "btn/cancel": "取消",
+                "btn/invite": "邀請",
+                "btn/add": "新增",
+                "aud/anyone": "任何人有連結即可存取",
+                "aud/signedin": "已登入的使用者可透過連結存取",
+                "aud/samegroup": "同一權限群組的使用者可以存取",
+                "aud/users": "只有受邀的使用者可以存取",
+                "aud/groups": "只有指定群組的成員可以存取",
+                "aud/password": "密碼保護",
+                "kind/public": "公開分享",
+                "kind/private": "私人分享(有密碼)",
+                "kind/signedin": "已登入使用者",
+                "kind/samegroup": "同群組使用者",
+                "kind/user": "邀請使用者:{name}",
+                "kind/group": "群組分享:{name}",
+                "status/active": "有效",
+                "status/expired": "已過期",
+                "expiry/never": "永久",
+                "level/view": "可檢視",
+                "level/edit": "可編輯",
+                "level/viewDefault": "可檢視(預設)",
+                "level/viewDesc": "僅可檢視檔案內容",
+                "level/editDesc": "可上傳檔案到此資料夾",
+                "adv/expiry": "分享有效期限",
+                "adv/forever": "永久有效",
+                "adv/date": "設定到期日",
+                "adv/duration": "自訂有效時間",
+                "adv/days": "天",
+                "adv/password": "密碼保護",
+                "adv/passwordKeep": "留空即保留目前的密碼",
+                "adv/passwordPlaceholder": "輸入密碼",
+                "adv/permission": "分享權限",
+                "adv/download": "下載設定",
+                "adv/allowDownload": "允許下載",
+                "adv/allowDownloadDesc": "允許分享者下載檔案",
+                "adv/display": "顯示設定",
+                "adv/showList": "顯示檔案清單",
+                "adv/showListDesc": "對方可查看分享的檔案列表",
+                "adv/folderOnly": "僅適用於資料夾",
+                "adv/audience": "存取對象",
+                "adv/audienceInvite": "受邀使用者 / 群組(請在權限管理中設定)",
+                "adv/editing": "正在編輯:",
+                "adv/new": "新的分享連結",
+                "adv/createInstead": "改為建立新的分享",
+                "people/public": "公開分享",
+                "people/publicDesc": "{n} 個連結 · 任何人可存取",
+                "people/users": "已邀請的使用者",
+                "people/groups": "群組分享",
+                "people/name": "名稱",
+                "people/permission": "權限",
+                "people/expiry": "到期日",
+                "people/noUsers": "尚未邀請任何使用者",
+                "people/noGroups": "尚未邀請任何群組",
+                "people/noPublic": "尚未建立公開連結",
+                "people/selectUser": "選擇使用者",
+                "people/selectGroup": "選擇群組",
+                "people/publicLinks": "公開連結",
+                "people/groupMember": "權限群組",
+                "menu/copy": "複製連結",
+                "menu/open": "開啟連結",
+                "menu/qr": "QR 碼",
+                "menu/edit": "編輯設定",
+                "menu/remove": "移除分享",
+                "menu/removeAccess": "移除存取權",
+                "msg/created": "已建立分享",
+                "msg/saved": "已儲存變更",
+                "msg/removed": "已移除分享",
+                "msg/invited": "已授予存取權,並已通知對方",
+                "msg/needPassword": "請輸入密碼",
+                "msg/needDate": "請選擇到期日",
+                "msg/needDays": "請輸入天數",
+                "msg/noFile": "未選擇檔案",
+                "msg/notManageable": "此檔案由其他使用者分享,只有擁有者可以變更分享設定。",
+                "msg/confirmRemove": "要移除此分享連結嗎?使用此連結的人將無法再存取。",
+                "people/shared": "共享使用者 / 群組",
+                "people/sharedDesc": "{u} 位使用者 · {g} 個群組"
             },
             },
-            "placeholder":{
-
+            "titles": {},
+            "placeholder": {
+                "No share link yet": "尚未建立分享連結",
+                "e.g. 7": "例如:7"
             }
             }
         },
         },
         "zh-hk": {
         "zh-hk": {
-            "fontFamily":"\"Microsoft JhengHei\",\"SimHei\", \"Apple LiGothic Medium\", \"STHeiti\"",
-            "fwtitle" : "檔案分享",
-            "strings":{
-                "title/title": "檔案分享",
-                "button/copy":"複製到剪貼簿",
-                "button/remove":"移除分享",
-                "button/copied":"已複製!",
-
-                "share/setting/title":"分享設定",
-                "share/setting/subtitle":"設定誰可以存取此檔案",
-                "share/setting/options":"取存設定:",
-                "share/setting/anyoneWithLink":"擁有連接的使用者",
-                "share/setting/anyoneWithLink/desc":"任何擁有分享連接的使用者皆可下載此檔案",
-                "share/setting/anyoneSignedIn":"已登入的使用者",
-                "share/setting/anyoneSignedIn/desc":"任何已登入並擁有分享連接的使用者皆可下載此檔案",
-                "share/setting/sameGroup":"在同一權限群組內的使用者",
-                "share/setting/sameGroup/desc":"任何擁有相同使用者權限的使用者皆可下載此檔案",
-                "share/setting/updated":"存取權限已更新",
-
-                "share/setting/advance/title":"進階分享選項",
-                "share/setting/advance/users":"指定使用者",
-                "share/setting/advance/usersDesc":"指定已登入之使用者帳戶名稱",
-                "share/setting/advance/usersInstruct":"於下方列表中選擇指定分享的用戶",
-                "share/setting/advance/addUserToSave":"新增最少一名使用者以儲存變更",
-                "share/setting/advance/groups":"指定權限群組",
-                "share/setting/advance/groupsDesc":"使用者只要擁有下述指定之任何一個群組權限皆可存取",
-                "share/setting/advance/groupsInstruct":"於下方列表中選擇指定分享的群組",
-                "share/setting/advance/addGroupToSave":"新增最少一個權限群組以儲存變更",
-
-                "message/removed": "分享已移除",
-                "qr/loading": "載入中"
-            },
-            "titles":{
-
+            "fontFamily": "\"Microsoft JhengHei\",\"SimHei\", \"Apple LiGothic Medium\", \"STHeiti\"",
+            "fwtitle": "分享檔案",
+            "strings": {
+                "title/title": "分享檔案",
+                "tab/quick": "快速分享",
+                "tab/advanced": "進階設定",
+                "tab/people": "權限管理",
+                "file/items": "共 {n} 個檔案",
+                "file/folder": "資料夾",
+                "quick/link": "分享連結",
+                "quick/create": "建立連結",
+                "quick/copy": "複製連結",
+                "quick/copied": "已複製!",
+                "quick/allowDownload": "允許下載",
+                "quick/allowDownloadDesc": "收件者可以下載檔案",
+                "quick/expiry": "有效期限:{x}",
+                "quick/created": "已建立的分享連結({n})",
+                "quick/newShare": "建立分享",
+                "quick/tip": "小提示:你可以隨時撤銷分享連結,或修改分享權限與到期時間。",
+                "btn/done": "完成",
+                "btn/close": "關閉",
+                "btn/createShare": "建立分享",
+                "btn/save": "儲存變更",
+                "btn/cancel": "取消",
+                "btn/invite": "邀請",
+                "btn/add": "新增",
+                "aud/anyone": "任何人有連結即可存取",
+                "aud/signedin": "已登入的使用者可透過連結存取",
+                "aud/samegroup": "同一權限群組的使用者可以存取",
+                "aud/users": "只有受邀的使用者可以存取",
+                "aud/groups": "只有指定群組的成員可以存取",
+                "aud/password": "密碼保護",
+                "kind/public": "公開分享",
+                "kind/private": "私人分享(有密碼)",
+                "kind/signedin": "已登入使用者",
+                "kind/samegroup": "同群組使用者",
+                "kind/user": "邀請使用者:{name}",
+                "kind/group": "群組分享:{name}",
+                "status/active": "有效",
+                "status/expired": "已過期",
+                "expiry/never": "永久",
+                "level/view": "可檢視",
+                "level/edit": "可編輯",
+                "level/viewDefault": "可檢視(預設)",
+                "level/viewDesc": "僅可檢視檔案內容",
+                "level/editDesc": "可上傳檔案到此資料夾",
+                "adv/expiry": "分享有效期限",
+                "adv/forever": "永久有效",
+                "adv/date": "設定到期日",
+                "adv/duration": "自訂有效時間",
+                "adv/days": "天",
+                "adv/password": "密碼保護",
+                "adv/passwordKeep": "留空即保留目前的密碼",
+                "adv/passwordPlaceholder": "輸入密碼",
+                "adv/permission": "分享權限",
+                "adv/download": "下載設定",
+                "adv/allowDownload": "允許下載",
+                "adv/allowDownloadDesc": "允許分享者下載檔案",
+                "adv/display": "顯示設定",
+                "adv/showList": "顯示檔案清單",
+                "adv/showListDesc": "對方可查看分享的檔案列表",
+                "adv/folderOnly": "僅適用於資料夾",
+                "adv/audience": "存取對象",
+                "adv/audienceInvite": "受邀使用者 / 群組(請在權限管理中設定)",
+                "adv/editing": "正在編輯:",
+                "adv/new": "新的分享連結",
+                "adv/createInstead": "改為建立新的分享",
+                "people/public": "公開分享",
+                "people/publicDesc": "{n} 個連結 · 任何人可存取",
+                "people/users": "已邀請的使用者",
+                "people/groups": "群組分享",
+                "people/name": "名稱",
+                "people/permission": "權限",
+                "people/expiry": "到期日",
+                "people/noUsers": "尚未邀請任何使用者",
+                "people/noGroups": "尚未邀請任何群組",
+                "people/noPublic": "尚未建立公開連結",
+                "people/selectUser": "選擇使用者",
+                "people/selectGroup": "選擇群組",
+                "people/publicLinks": "公開連結",
+                "people/groupMember": "權限群組",
+                "menu/copy": "複製連結",
+                "menu/open": "開啟連結",
+                "menu/qr": "QR Code",
+                "menu/edit": "編輯設定",
+                "menu/remove": "移除分享",
+                "menu/removeAccess": "移除存取權",
+                "msg/created": "已建立分享",
+                "msg/saved": "已儲存變更",
+                "msg/removed": "已移除分享",
+                "msg/invited": "已授予存取權,並已通知對方",
+                "msg/needPassword": "請輸入密碼",
+                "msg/needDate": "請選擇到期日",
+                "msg/needDays": "請輸入天數",
+                "msg/noFile": "未選擇檔案",
+                "msg/notManageable": "此檔案由其他使用者分享,只有擁有者可以變更分享設定。",
+                "msg/confirmRemove": "要移除此分享連結嗎?使用此連結的人將無法再存取。",
+                "people/shared": "共享使用者 / 群組",
+                "people/sharedDesc": "{u} 位使用者 · {g} 個群組"
             },
             },
-            "placeholder":{
-
+            "titles": {},
+            "placeholder": {
+                "No share link yet": "尚未建立分享連結",
+                "e.g. 7": "例如:7"
             }
             }
         },
         },
         "zh-cn": {
         "zh-cn": {
-            "fwtitle" : "文件分享",
-            "strings":{
-                "title/title": "文件分享",
-                "button/copy":"复制到剪切板",
-                "button/remove":"移除分享",
-                "button/copied":"已复制!",
-
-                "share/setting/title":"分享设置",
-                "share/setting/subtitle":"设置谁可以存取此文件",
-                "share/setting/options":"存取设置:",
-                "share/setting/anyoneWithLink":"拥有链接的使用者",
-                "share/setting/anyoneWithLink/desc":"任何拥有分享链接的使用者均可下载此文件",
-                "share/setting/anyoneSignedIn":"已登录的使用者",
-                "share/setting/anyoneSignedIn/desc":"任何已登录并拥有分享链接的使用者均可下载此文件",
-                "share/setting/sameGroup":"在同一权限群组内的使用者",
-                "share/setting/sameGroup/desc":"任何拥有相同使用者权限的使用者均可下载此文件",
-                "share/setting/updated":"存取权限已更新",
-
-                "share/setting/advance/title":"高级分享选项",
-                "share/setting/advance/users":"指定使用者",
-                "share/setting/advance/usersDesc":"指定已登录的使用者帐户名称",
-                "share/setting/advance/usersInstruct":"于下方列表中选择指定分享的用户",
-                "share/setting/advance/addUserToSave":"新增最少一名使用者以存储变更",
-                "share/setting/advance/groups":"指定权限群组",
-                "share/setting/advance/groupsDesc":"使用者只要拥有下述指定之任何一个群组权限均可存取",
-                "share/setting/advance/groupsInstruct":"于下方列表中选择指定分享的群组",
-                "share/setting/advance/addGroupToSave":"新增最少一个权限群组以存储变更",
-                
-                "message/removed": "分享已移除",
-                "qr/loading": "载入中" 
-            },
-            "titles":{
-
+            "fwtitle": "分享文件",
+            "strings": {
+                "title/title": "分享文件",
+                "tab/quick": "快速分享",
+                "tab/advanced": "高级设置",
+                "tab/people": "权限管理",
+                "file/items": "共 {n} 个文件",
+                "file/folder": "文件夹",
+                "quick/link": "分享链接",
+                "quick/create": "创建链接",
+                "quick/copy": "复制链接",
+                "quick/copied": "已复制!",
+                "quick/allowDownload": "允许下载",
+                "quick/allowDownloadDesc": "收件人可以下载文件",
+                "quick/expiry": "有效期:{x}",
+                "quick/created": "已创建的分享链接({n})",
+                "quick/newShare": "创建分享",
+                "quick/tip": "小提示:你可以随时撤销分享链接,或修改分享权限与到期时间。",
+                "btn/done": "完成",
+                "btn/close": "关闭",
+                "btn/createShare": "创建分享",
+                "btn/save": "保存更改",
+                "btn/cancel": "取消",
+                "btn/invite": "邀请",
+                "btn/add": "添加",
+                "aud/anyone": "任何人有链接即可访问",
+                "aud/signedin": "已登录的用户可通过链接访问",
+                "aud/samegroup": "同一权限组的用户可以访问",
+                "aud/users": "只有受邀的用户可以访问",
+                "aud/groups": "只有指定组的成员可以访问",
+                "aud/password": "密码保护",
+                "kind/public": "公开分享",
+                "kind/private": "私人分享(有密码)",
+                "kind/signedin": "已登录用户",
+                "kind/samegroup": "同组用户",
+                "kind/user": "邀请用户:{name}",
+                "kind/group": "组分享:{name}",
+                "status/active": "有效",
+                "status/expired": "已过期",
+                "expiry/never": "永久",
+                "level/view": "可查看",
+                "level/edit": "可编辑",
+                "level/viewDefault": "可查看(默认)",
+                "level/viewDesc": "仅可查看文件内容",
+                "level/editDesc": "可上传文件到此文件夹",
+                "adv/expiry": "分享有效期",
+                "adv/forever": "永久有效",
+                "adv/date": "设置到期日",
+                "adv/duration": "自定义有效时间",
+                "adv/days": "天",
+                "adv/password": "密码保护",
+                "adv/passwordKeep": "留空即保留当前密码",
+                "adv/passwordPlaceholder": "输入密码",
+                "adv/permission": "分享权限",
+                "adv/download": "下载设置",
+                "adv/allowDownload": "允许下载",
+                "adv/allowDownloadDesc": "允许分享对象下载文件",
+                "adv/display": "显示设置",
+                "adv/showList": "显示文件列表",
+                "adv/showListDesc": "对方可查看分享的文件列表",
+                "adv/folderOnly": "仅适用于文件夹",
+                "adv/audience": "访问对象",
+                "adv/audienceInvite": "受邀用户 / 组(请在权限管理中设置)",
+                "adv/editing": "正在编辑:",
+                "adv/new": "新的分享链接",
+                "adv/createInstead": "改为创建新的分享",
+                "people/public": "公开分享",
+                "people/publicDesc": "{n} 个链接 · 任何人可访问",
+                "people/users": "已邀请的用户",
+                "people/groups": "组分享",
+                "people/name": "名称",
+                "people/permission": "权限",
+                "people/expiry": "到期日",
+                "people/noUsers": "尚未邀请任何用户",
+                "people/noGroups": "尚未邀请任何组",
+                "people/noPublic": "尚未创建公开链接",
+                "people/selectUser": "选择用户",
+                "people/selectGroup": "选择组",
+                "people/publicLinks": "公开链接",
+                "people/groupMember": "权限组",
+                "menu/copy": "复制链接",
+                "menu/open": "打开链接",
+                "menu/qr": "二维码",
+                "menu/edit": "编辑设置",
+                "menu/remove": "移除分享",
+                "menu/removeAccess": "移除访问权",
+                "msg/created": "已创建分享",
+                "msg/saved": "已保存更改",
+                "msg/removed": "已移除分享",
+                "msg/invited": "已授予访问权限,并已通知对方",
+                "msg/needPassword": "请输入密码",
+                "msg/needDate": "请选择到期日",
+                "msg/needDays": "请输入天数",
+                "msg/noFile": "未选择文件",
+                "msg/notManageable": "此文件由其他用户分享,只有所有者可以更改分享设置。",
+                "msg/confirmRemove": "要移除此分享链接吗?使用此链接的人将无法再访问。",
+                "people/shared": "共享用户 / 组",
+                "people/sharedDesc": "{u} 位用户 · {g} 个组"
             },
             },
-            "placeholder":{
-
+            "titles": {},
+            "placeholder": {
+                "No share link yet": "尚未创建分享链接",
+                "e.g. 7": "例如:7"
             }
             }
         },
         },
         "en-us": {
         "en-us": {
-            "fontFamily": "",
-            "fwtitle": "File Sharing",
-            "strings": {
-                "title/title": "File Sharing",
-                "button/copy": "Copy to Clipboard",
-                "button/remove": "Remove Sharing",
-                "button/copied": "Copied!",
-        
-                "share/setting/title": "Sharing Settings",
-                "share/setting/subtitle": "Specify who can access this file",
-                "share/setting/options": "Access Settings:",
-                "share/setting/anyoneWithLink": "Anyone with the link",
-                "share/setting/anyoneWithLink/desc": "Anyone with the shared link can download this file",
-                "share/setting/anyoneSignedIn": "Anyone signed in",
-                "share/setting/anyoneSignedIn/desc": "Anyone signed in and with the shared link can download this file",
-                "share/setting/sameGroup": "Users in the same permission group",
-                "share/setting/sameGroup/desc": "Anyone with the same user permission can download this file",
-                "share/setting/updated": "Access permission updated",
-        
-                "share/setting/advance/title": "Advanced Sharing Options",
-                "share/setting/advance/users": "Specified Users",
-                "share/setting/advance/usersDesc": "Specify logged-in user account names",
-                "share/setting/advance/usersInstruct": "Select specific users from the list below",
-                "share/setting/advance/addUserToSave": "Add at least one user to save changes",
-                "share/setting/advance/groups": "Specified Permission Groups",
-                "share/setting/advance/groupsDesc": "Users with any of the specified group permissions can access",
-                "share/setting/advance/groupsInstruct": "Select specified groups for sharing from the list below",
-                "share/setting/advance/addGroupToSave": "Add at least one permission group to save changes",
-        
-                "message/removed": "Sharing has been removed",
-                "qr/loading": "Loading"
-            },
+            "fwtitle": "Share File",
+            "strings": {},
             "titles": {},
             "titles": {},
             "placeholder": {}
             "placeholder": {}
         },
         },
@@ -163,73 +333,213 @@
             "fwtitle": "ファイル共有",
             "fwtitle": "ファイル共有",
             "strings": {
             "strings": {
                 "title/title": "ファイル共有",
                 "title/title": "ファイル共有",
-                "button/copy": "クリップボードにコピー",
-                "button/remove": "共有を解除",
-                "button/copied": "コピー済み!",
-        
-                "share/setting/title": "共有設定",
-                "share/setting/subtitle": "誰がこのファイルにアクセスできるかを指定",
-                "share/setting/options": "アクセス設定:",
-                "share/setting/anyoneWithLink": "リンクを持つ全員",
-                "share/setting/anyoneWithLink/desc": "リンクを持つ全員がこのファイルをダウンロードできます",
-                "share/setting/anyoneSignedIn": "サインインしている全員",
-                "share/setting/anyoneSignedIn/desc": "サインインしていてリンクを持つ全員がこのファイルをダウンロードできます",
-                "share/setting/sameGroup": "同じ権限グループのユーザー",
-                "share/setting/sameGroup/desc": "同じユーザー権限を持つユーザーがこのファイルをダウンロードできます",
-                "share/setting/updated": "アクセス権限が更新されました",
-        
-                "share/setting/advance/title": "高度な共有オプション",
-                "share/setting/advance/users": "指定ユーザー",
-                "share/setting/advance/usersDesc": "ログインしているユーザーアカウント名を指定",
-                "share/setting/advance/usersInstruct": "以下のリストから特定のユーザーを選択",
-                "share/setting/advance/addUserToSave": "変更を保存するには少なくとも1人のユーザーを追加",
-                "share/setting/advance/groups": "指定権限グループ",
-                "share/setting/advance/groupsDesc": "指定されたグループ権限を持つユーザーはすべてアクセスできます",
-                "share/setting/advance/groupsInstruct": "以下のリストから共有するための特定のグループを選択",
-                "share/setting/advance/addGroupToSave": "変更を保存するには少なくとも1つの権限グループを追加",
-        
-                "message/removed": "共有が解除されました",
-                "qr/loading": "読み込み中"
+                "tab/quick": "クイック共有",
+                "tab/advanced": "詳細設定",
+                "tab/people": "権限管理",
+                "file/items": "{n} 個のファイル",
+                "file/folder": "フォルダ",
+                "quick/link": "共有リンク",
+                "quick/create": "リンクを作成",
+                "quick/copy": "リンクをコピー",
+                "quick/copied": "コピーしました!",
+                "quick/allowDownload": "ダウンロードを許可",
+                "quick/allowDownloadDesc": "受信者はファイルをダウンロードできます",
+                "quick/expiry": "有効期限:{x}",
+                "quick/created": "作成済みの共有リンク({n})",
+                "quick/newShare": "共有を作成",
+                "quick/tip": "ヒント:共有リンクはいつでも取り消したり、権限や有効期限を変更したりできます。",
+                "btn/done": "完了",
+                "btn/close": "閉じる",
+                "btn/createShare": "共有を作成",
+                "btn/save": "変更を保存",
+                "btn/cancel": "キャンセル",
+                "btn/invite": "招待",
+                "btn/add": "追加",
+                "aud/anyone": "リンクを知っている全員がアクセス可能",
+                "aud/signedin": "サインイン済みのユーザーがリンクでアクセス可能",
+                "aud/samegroup": "同じグループのユーザーがアクセス可能",
+                "aud/users": "招待されたユーザーのみアクセス可能",
+                "aud/groups": "指定グループのメンバーのみアクセス可能",
+                "aud/password": "パスワード保護",
+                "kind/public": "公開共有",
+                "kind/private": "プライベート共有(パスワード)",
+                "kind/signedin": "サインイン済みユーザー",
+                "kind/samegroup": "同じグループ",
+                "kind/user": "招待ユーザー:{name}",
+                "kind/group": "グループ共有:{name}",
+                "status/active": "有効",
+                "status/expired": "期限切れ",
+                "expiry/never": "無期限",
+                "level/view": "閲覧可",
+                "level/edit": "編集可",
+                "level/viewDefault": "閲覧可(デフォルト)",
+                "level/viewDesc": "内容の閲覧のみ可能",
+                "level/editDesc": "このフォルダにファイルをアップロードできます",
+                "adv/expiry": "共有の有効期限",
+                "adv/forever": "無期限",
+                "adv/date": "有効期限日を設定",
+                "adv/duration": "期間を指定",
+                "adv/days": "日",
+                "adv/password": "パスワード保護",
+                "adv/passwordKeep": "空欄のままにすると現在のパスワードを維持します",
+                "adv/passwordPlaceholder": "パスワードを入力",
+                "adv/permission": "共有権限",
+                "adv/download": "ダウンロード設定",
+                "adv/allowDownload": "ダウンロードを許可",
+                "adv/allowDownloadDesc": "受信者にファイルのダウンロードを許可します",
+                "adv/display": "表示設定",
+                "adv/showList": "ファイル一覧を表示",
+                "adv/showListDesc": "受信者は共有ファイルの一覧を閲覧できます",
+                "adv/folderOnly": "フォルダのみ利用可能",
+                "adv/audience": "アクセスできるユーザー",
+                "adv/audienceInvite": "招待ユーザー / グループ(権限管理で設定)",
+                "adv/editing": "編集中:",
+                "adv/new": "新しい共有リンク",
+                "adv/createInstead": "新しい共有を作成する",
+                "people/public": "公開共有",
+                "people/publicDesc": "{n} 件のリンク · 誰でもアクセス可",
+                "people/users": "招待済みユーザー",
+                "people/groups": "グループ共有",
+                "people/name": "名前",
+                "people/permission": "権限",
+                "people/expiry": "有効期限",
+                "people/noUsers": "まだユーザーを招待していません",
+                "people/noGroups": "まだグループを招待していません",
+                "people/noPublic": "公開リンクはまだありません",
+                "people/selectUser": "ユーザーを選択",
+                "people/selectGroup": "グループを選択",
+                "people/publicLinks": "公開リンク",
+                "people/groupMember": "権限グループ",
+                "menu/copy": "リンクをコピー",
+                "menu/open": "リンクを開く",
+                "menu/qr": "QR コード",
+                "menu/edit": "設定を編集",
+                "menu/remove": "共有を削除",
+                "menu/removeAccess": "アクセスを削除",
+                "msg/created": "共有を作成しました",
+                "msg/saved": "変更を保存しました",
+                "msg/removed": "共有を削除しました",
+                "msg/invited": "アクセス権を付与し、通知を送信しました",
+                "msg/needPassword": "パスワードを入力してください",
+                "msg/needDate": "有効期限日を選択してください",
+                "msg/needDays": "日数を入力してください",
+                "msg/noFile": "ファイルが選択されていません",
+                "msg/notManageable": "このファイルは他のユーザーが共有しています。共有設定を変更できるのは所有者のみです。",
+                "msg/confirmRemove": "この共有リンクを削除しますか?リンクを使用している人はアクセスできなくなります。",
+                "people/shared": "共有ユーザー / グループ",
+                "people/sharedDesc": "{u} 人のユーザー · {g} 個のグループ"
             },
             },
             "titles": {},
             "titles": {},
-            "placeholder": {}
+            "placeholder": {
+                "No share link yet": "共有リンクはまだありません",
+                "e.g. 7": "例:7"
+            }
         },
         },
         "ko-kr": {
         "ko-kr": {
-            "fontFamily": "",
             "fwtitle": "파일 공유",
             "fwtitle": "파일 공유",
             "strings": {
             "strings": {
                 "title/title": "파일 공유",
                 "title/title": "파일 공유",
-                "button/copy": "클립보드에 복사",
-                "button/remove": "공유 제거",
-                "button/copied": "복사 완료!",
-                
-                "share/setting/title": "공유 설정",
-                "share/setting/subtitle": "이 파일에 접근할 수 있는 사람 지정",
-                "share/setting/options": "접근 설정:",
-                "share/setting/anyoneWithLink": "링크가 있는 모든 사용자",
-                "share/setting/anyoneWithLink/desc": "공유된 링크가 있는 누구나 이 파일을 다운로드할 수 있습니다",
-                "share/setting/anyoneSignedIn": "로그인한 모든 사용자",
-                "share/setting/anyoneSignedIn/desc": "로그인하고 공유된 링크가 있는 누구나 이 파일을 다운로드할 수 있습니다",
-                "share/setting/sameGroup": "동일한 권한 그룹의 사용자",
-                "share/setting/sameGroup/desc": "동일한 사용자 권한을 가진 누구나 이 파일을 다운로드할 수 있습니다",
-                "share/setting/updated": "접근 권한이 업데이트되었습니다",
-                
-                "share/setting/advance/title": "고급 공유 옵션",
-                "share/setting/advance/users": "지정된 사용자",
-                "share/setting/advance/usersDesc": "로그인한 사용자 계정 이름 지정",
-                "share/setting/advance/usersInstruct": "아래 목록에서 특정 사용자를 선택하세요",
-                "share/setting/advance/addUserToSave": "변경 사항을 저장하려면 최소한 한 명의 사용자를 추가하세요",
-                "share/setting/advance/groups": "지정된 권한 그룹",
-                "share/setting/advance/groupsDesc": "지정된 그룹 권한을 가진 사용자가 접근할 수 있습니다",
-                "share/setting/advance/groupsInstruct": "아래 목록에서 공유할 지정된 그룹을 선택하세요",
-                "share/setting/advance/addGroupToSave": "변경 사항을 저장하려면 최소한 한 개의 권한 그룹을 추가하세요",
-                
-                "message/removed": "공유가 제거되었습니다",
-                "qr/loading": "로딩 중"
+                "tab/quick": "빠른 공유",
+                "tab/advanced": "고급 설정",
+                "tab/people": "권한 관리",
+                "file/items": "파일 {n}개",
+                "file/folder": "폴더",
+                "quick/link": "공유 링크",
+                "quick/create": "링크 만들기",
+                "quick/copy": "링크 복사",
+                "quick/copied": "복사됨!",
+                "quick/allowDownload": "다운로드 허용",
+                "quick/allowDownloadDesc": "받는 사람이 파일을 다운로드할 수 있습니다",
+                "quick/expiry": "만료: {x}",
+                "quick/created": "생성된 공유 링크 ({n})",
+                "quick/newShare": "공유 만들기",
+                "quick/tip": "팁: 공유 링크는 언제든지 취소하거나 권한과 만료일을 변경할 수 있습니다.",
+                "btn/done": "완료",
+                "btn/close": "닫기",
+                "btn/createShare": "공유 만들기",
+                "btn/save": "변경 사항 저장",
+                "btn/cancel": "취소",
+                "btn/invite": "초대",
+                "btn/add": "추가",
+                "aud/anyone": "링크가 있는 모든 사용자가 액세스 가능",
+                "aud/signedin": "로그인한 사용자가 링크로 액세스 가능",
+                "aud/samegroup": "같은 그룹의 사용자가 액세스 가능",
+                "aud/users": "초대된 사용자만 액세스 가능",
+                "aud/groups": "선택한 그룹의 구성원만 액세스 가능",
+                "aud/password": "비밀번호 보호",
+                "kind/public": "공개 공유",
+                "kind/private": "비공개 공유 (비밀번호)",
+                "kind/signedin": "로그인한 사용자",
+                "kind/samegroup": "같은 그룹",
+                "kind/user": "초대된 사용자: {name}",
+                "kind/group": "그룹 공유: {name}",
+                "status/active": "유효",
+                "status/expired": "만료됨",
+                "expiry/never": "영구",
+                "level/view": "보기 가능",
+                "level/edit": "편집 가능",
+                "level/viewDefault": "보기 가능 (기본값)",
+                "level/viewDesc": "내용 보기만 가능",
+                "level/editDesc": "이 폴더에 파일을 업로드할 수 있습니다",
+                "adv/expiry": "공유 만료",
+                "adv/forever": "만료 없음",
+                "adv/date": "만료일 설정",
+                "adv/duration": "기간 지정",
+                "adv/days": "일",
+                "adv/password": "비밀번호 보호",
+                "adv/passwordKeep": "비워 두면 현재 비밀번호를 유지합니다",
+                "adv/passwordPlaceholder": "비밀번호 입력",
+                "adv/permission": "공유 권한",
+                "adv/download": "다운로드 설정",
+                "adv/allowDownload": "다운로드 허용",
+                "adv/allowDownloadDesc": "받는 사람이 파일을 다운로드하도록 허용",
+                "adv/display": "표시 설정",
+                "adv/showList": "파일 목록 표시",
+                "adv/showListDesc": "받는 사람이 공유된 파일 목록을 볼 수 있습니다",
+                "adv/folderOnly": "폴더에서만 사용 가능",
+                "adv/audience": "액세스 대상",
+                "adv/audienceInvite": "초대된 사용자 / 그룹 (권한 관리에서 설정)",
+                "adv/editing": "편집 중:",
+                "adv/new": "새 공유 링크",
+                "adv/createInstead": "새 공유를 만들기",
+                "people/public": "공개 공유",
+                "people/publicDesc": "링크 {n}개 · 누구나 액세스 가능",
+                "people/users": "초대된 사용자",
+                "people/groups": "그룹 공유",
+                "people/name": "이름",
+                "people/permission": "권한",
+                "people/expiry": "만료일",
+                "people/noUsers": "아직 초대된 사용자가 없습니다",
+                "people/noGroups": "아직 초대된 그룹이 없습니다",
+                "people/noPublic": "아직 공개 링크가 없습니다",
+                "people/selectUser": "사용자 선택",
+                "people/selectGroup": "그룹 선택",
+                "people/publicLinks": "공개 링크",
+                "people/groupMember": "권한 그룹",
+                "menu/copy": "링크 복사",
+                "menu/open": "링크 열기",
+                "menu/qr": "QR 코드",
+                "menu/edit": "설정 편집",
+                "menu/remove": "공유 삭제",
+                "menu/removeAccess": "액세스 삭제",
+                "msg/created": "공유를 만들었습니다",
+                "msg/saved": "변경 사항을 저장했습니다",
+                "msg/removed": "공유를 삭제했습니다",
+                "msg/invited": "액세스 권한을 부여하고 알림을 보냈습니다",
+                "msg/needPassword": "비밀번호를 입력하세요",
+                "msg/needDate": "만료일을 선택하세요",
+                "msg/needDays": "일 수를 입력하세요",
+                "msg/noFile": "선택된 파일이 없습니다",
+                "msg/notManageable": "이 파일은 다른 사용자가 공유했습니다. 소유자만 공유 설정을 변경할 수 있습니다.",
+                "msg/confirmRemove": "이 공유 링크를 삭제하시겠습니까? 링크를 사용하는 사람은 더 이상 액세스할 수 없습니다.",
+                "people/shared": "공유 사용자 / 그룹",
+                "people/sharedDesc": "사용자 {u}명 · 그룹 {g}개"
             },
             },
             "titles": {},
             "titles": {},
-            "placeholder": {}
+            "placeholder": {
+                "No share link yet": "아직 공유 링크가 없습니다",
+                "e.g. 7": "예: 7"
+            }
         }
         }
-        
     }
     }
 }
 }

+ 1 - 1
src/web/Terminal/docs/api.json

@@ -434,7 +434,7 @@
         {
         {
           "name": "share.shareFile",
           "name": "share.shareFile",
           "sig": "share.shareFile(vpath, timeoutSec)",
           "sig": "share.shareFile(vpath, timeoutSec)",
-          "desc": "Create a public share link. timeoutSec=0 means no expiry. Returns the share UUID.",
+          "desc": "Create a public share link. timeoutSec=0 returns the file's existing share (or creates one) with no expiry; timeoutSec>0 always creates a separate share that expires after that many seconds. Returns the share UUID.",
           "ret": "string (uuid)",
           "ret": "string (uuid)",
           "example": "requirelib(\"share\");\nvar uuid = share.shareFile(\"user:/report.pdf\", 3600);"
           "example": "requirelib(\"share\");\nvar uuid = share.shareFile(\"user:/report.pdf\", 3600);"
         },
         },

+ 33 - 15
src/web/desktop.html

@@ -6193,10 +6193,8 @@
                 let targetURL = "SystemAO/file_system/file_share.html#" + encodeURIComponent(JSON.stringify([afd]));
                 let targetURL = "SystemAO/file_system/file_share.html#" + encodeURIComponent(JSON.stringify([afd]));
                 newFloatWindow({
                 newFloatWindow({
                     url: targetURL,
                     url: targetURL,
-                    width: 330,
-                    height: 480,
-                    left: left,
-                    top: top,
+                    width: 760,
+                    height: 600,
                     appicon: "SystemAO/file_system/img/share.svg",
                     appicon: "SystemAO/file_system/img/share.svg",
                     title: "Share File"
                     title: "Share File"
                 });
                 });
@@ -6210,28 +6208,27 @@
 
 
 
 
             //Calculate the window spawn position
             //Calculate the window spawn position
+            var shareWindowWidth = 760;
+            var shareWindowHeight = 600;
             var basePosition = $(selectedFileObjects).offset();
             var basePosition = $(selectedFileObjects).offset();
             var left = basePosition.left + 100;
             var left = basePosition.left + 100;
-            if (left + 320 > window.innerWidth){
-                left = basePosition.left - 350;
+            if (left + shareWindowWidth > window.innerWidth){
+                left = Math.max(0, window.innerWidth - shareWindowWidth - 20);
             }
             }
 
 
             var top = basePosition.top;
             var top = basePosition.top;
-            if (top + 480 > window.innerHeight){
-                top = window.innerHeight - 480;
+            if (top + shareWindowHeight > window.innerHeight){
+                top = Math.max(0, window.innerHeight - shareWindowHeight);
             }
             }
 
 
             newFloatWindow({
             newFloatWindow({
                 url: targetURL,
                 url: targetURL,
-                width: 330,
-                height: 480,
+                width: shareWindowWidth,
+                height: shareWindowHeight,
                 left: left,
                 left: left,
                 top: top,
                 top: top,
                 appicon: "SystemAO/file_system/img/share.svg",
                 appicon: "SystemAO/file_system/img/share.svg",
                 title: "Share File"
                 title: "Share File"
-            }, function(windowUUID){
-                //Set the window to fixed window
-                setFloatWindowResizePolicy(windowUUID, false);
             });
             });
 
 
             hideAllContextMenus();
             hideAllContextMenus();
@@ -7184,6 +7181,12 @@
             } catch (e) { /* ignore push errors */ }
             } catch (e) { /* ignore push errors */ }
         }
         }
 
 
+        function escapeNotificationText(text){
+            return String(text)
+                .replace(/&/g, "&amp;").replace(/</g, "&lt;")
+                .replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
+        }
+
         function renderServerNotification(item){
         function renderServerNotification(item){
             if (!item) return;
             if (!item) return;
             if (item.id && arozNotificationSeenIds[item.id]) return;
             if (item.id && arozNotificationSeenIds[item.id]) return;
@@ -7194,8 +7197,23 @@
             if (item.sender){
             if (item.sender){
                 message = message + (message ? " " : "") + "(" + item.sender + ")";
                 message = message + (message ? " " : "") + "(" + item.sender + ")";
             }
             }
-            //Always add it to the persistent notification list.
-            sendNotification(title, message, arozNotificationIcon(item.priority), null);
+
+            //An optional payload carries the float window to open when the
+            //notification is clicked (e.g. the share link of a file shared
+            //with this user). sendNotification takes it double encoded.
+            var clickTarget = null;
+            if (item.payload){
+                try {
+                    var openOption = JSON.parse(item.payload);
+                    if (openOption && typeof openOption.url === "string"){
+                        clickTarget = JSON.stringify(JSON.stringify(openOption));
+                    }
+                } catch (e) { /* not a window option, ignore */ }
+            }
+
+            //Always add it to the persistent notification list. Server text is
+            //plain text (it can carry file names), so escape it for the list.
+            sendNotification(escapeNotificationText(title), escapeNotificationText(message), arozNotificationIcon(item.priority), clickTarget);
 
 
             //Surface it: the in-page toast is shown whether or not the desktop
             //Surface it: the in-page toast is shown whether or not the desktop
             //is focused, so the notification is never silently swallowed; a
             //is focused, so the notification is never silently swallowed; a

Некоторые файлы не были показаны из-за большого количества измененных файлов