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 周之前

+ 5 - 7
src/file_system.go

@@ -258,14 +258,12 @@ func FileSystemInit() {
 		UserHandler:     userHandler,
 		HostName:        *host_name,
 		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
 	//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
 	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()
 	nightlyManager.RegisterNightlyTask(shareManager.ValidateAndClearShares)
 

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

@@ -537,7 +537,10 @@ requirelib("share");
 ```
 
 ### `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
 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"
 	"imuslab.com/arozos/mod/agi/static"
 	"imuslab.com/arozos/mod/info/logger"
+	"imuslab.com/arozos/mod/share/shareEntry"
 )
 
 func (g *Gateway) ShareLibRegister() {
@@ -41,20 +42,20 @@ func (g *Gateway) injectShareFunctions(payload *static.AgiLibInjectionPayload) {
 
 		//Create a share object for this request
 		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 {
 			logger.PrintAndLog("Agi", "[AGI] Create Share Failed: "+err.Error(), nil)
 			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)
 		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")
+}

File diff suppressed because it is too large
+ 124 - 768
src/mod/share/share.go


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

@@ -4,8 +4,10 @@ import (
 	"encoding/json"
 	"errors"
 	"path/filepath"
+	"sort"
 	"strings"
 	"sync"
+	"time"
 
 	uuid "github.com/satori/go.uuid"
 	"imuslab.com/arozos/mod/database"
@@ -18,153 +20,227 @@ import (
 	This module is designed to isolate the entry operatiosn with the
 	handle operations so as to reduce the complexity of recursive import
 	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 {
-	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 {
 	//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
-	entries, _ := db.ListTable("share")
+	entries, _ := db.ListTable(shareTableName)
+	loaded := []*ShareOption{}
 	for _, keypairs := range entries {
 		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)
 	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))
 	//Check if source file exists
 	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)
+	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 {
 		return nil, err
 	}
 
 	//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
-		err := s.Database.Delete("share", so.UUID)
-		if err != nil {
+		if err := s.Database.Delete(shareTableName, id); err != nil {
 			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.
 		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 {
@@ -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 {
-	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 {
-	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 {
-	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 {
-	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 nil
+	return s.DeleteShareByPathHash(pathhash)
 }
 
 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 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) {

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

@@ -3,7 +3,6 @@ package shareEntry
 import (
 	"os"
 	"path/filepath"
-	"sync"
 	"testing"
 
 	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
 // filesystem checks) so we can test lookup/deletion logic in isolation.
 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 {
@@ -53,8 +53,8 @@ func TestNewShareEntryTable(t *testing.T) {
 	if table == 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 {
 		t.Error("Database reference should not be nil")
@@ -195,7 +195,7 @@ func TestRemoveShareByUUID(t *testing.T) {
 		t.Fatalf("RemoveShareByUUID error: %v", err)
 	}
 	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")
 	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" {
 		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")
 	}
 }
-
-// Compile-time check that sync is imported
-var _ sync.Map

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

@@ -1,20 +1,129 @@
 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 {
 	return s.Owner == username
 }
 
 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
-	} else if s.Permission == "samegroup" || s.Permission == "groups" {
+	} else if s.Permission == PermissionSameGroup || s.Permission == PermissionGroups {
 		for _, thisUserGroup := range usergroup {
 			if stringInSlice(thisUserGroup, s.Accessibles) {
 				//User's group is in the allowed group
 				return true
 			}
 		}
-	} else if s.Permission == "users" {
+	} else if s.Permission == PermissionUsers {
 		if stringInSlice(username, s.Accessibles) {
 			//User's name is in the allowed group
 			return true
@@ -25,3 +134,125 @@ func (s *ShareOption) IsAccessibleBy(username string, usergroup []string) bool {
 	}
 	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,
 	})
 
-	//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{
-		ModuleName:  "System Setting",
+		ModuleName:  "",
 		AdminOnly:   false,
 		UserHandler: userHandler,
 		DeniedHandler: func(w http.ResponseWriter, r *http.Request) {

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

@@ -406,6 +406,7 @@
       <div class="card-footer">
         <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" id="expiryNote" style="display:none;">Available until: <strong id="expiryDate"></strong></span>
       </div>
     </div>
   </main>
@@ -415,6 +416,9 @@
   </footer>
 
   <script>
+    // Share options set by the owner (download allowed, expiry ...)
+    var SHARE_SETTINGS = {{sharesettings}};
+
     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>',
@@ -472,6 +476,18 @@
       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
     (function() {
       var inner = document.querySelector('.preview-inner');

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

@@ -285,6 +285,24 @@
       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 {
       padding: 12px 26px;
       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>
             <span>Download All</span>
           </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()">
             <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>
@@ -781,9 +804,15 @@
           Media files (video, audio, images) are already compressed, so this option can speed up zipping.
         </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">
           <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" id="expiryNote" style="display:none;">Available until: <strong id="expiryDate"></strong></span>
         </div>
       </div>
 
@@ -991,6 +1020,8 @@
 
     var treeFileList = {{treelist}};
     var downloadUUID = '{{downloaduuid}}';
+    // Share options set by the owner (download allowed, folder content shown, upload ...)
+    var SHARE_SETTINGS = {{sharesettings}};
     var currentPath = '.';
     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);
     }
 
-    function openPreview(filename, url, ext) {
+    function openPreview(filename, url, ext, downloadUrl) {
       document.getElementById('preview-title').textContent = filename;
       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');
       body.innerHTML = '';
       body.removeAttribute('style');
@@ -1056,12 +1089,12 @@
       document.body.style.overflow = 'hidden';
     }
 
-    function previewOrDownload(file, url) {
+    function previewOrDownload(file, viewUrl, downloadUrl) {
       var ext = file.Filename.split('.').pop().toLowerCase();
       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 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 dlCell = file.IsDir
+        var dlCell = (file.IsDir || !SHARE_SETTINGS.allowDownload)
           ? '<div></div>'
           : '<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) {
             clearTimeout(singleClickTimer);
             singleClickTimer = setTimeout(function() {
-              previewOrDownload(file, dlUrl);
+              previewOrDownload(file, viewUrl, dlUrl);
             }, 220);
           }
         });
@@ -1156,13 +1190,15 @@
           clearTimeout(singleClickTimer);
           if (file.IsDir) {
             navigateTo(file.RelPath);
-          } else {
+          } else if (SHARE_SETTINGS.allowDownload) {
             window.open(dlUrl, '_blank');
+          } else {
+            previewOrDownload(file, viewUrl, dlUrl);
           }
         });
 
         if (IMAGE_EXTS.has(file.Filename.split('.').pop().toLowerCase())) {
-          var thumbUrl = '../../share/download/' + downloadUUID + '/' + file.RelPath;
+          var thumbUrl = viewUrl;
           row.addEventListener('mouseenter', function(e) {
             document.getElementById('thumb-img').src = thumbUrl;
             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);
     }
 
+    // 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('.');
     renderFileList('.');
   </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 ============ */
 .of-find-panel {
     position: fixed;
-    top: 92px;
-    right: 26px;
+    top: 92px;   /* placeFindPanel() moves it to the ribbon's bottom edge */
+    right: 4px;
     z-index: 1000;
     background: var(--of-chrome-bg);
     border: 1px solid var(--of-border);
@@ -697,8 +697,8 @@ body.dark #editor span.doc-cmt { background: rgba(255, 213, 79, 0.22); }
 
 #docCmtPanel {
     position: fixed;
-    top: 92px;
-    right: 12px;
+    top: 92px;   /* placeFindPanel() moves it to the ribbon's bottom edge */
+    right: 4px;
     bottom: 40px;
     width: 270px;
     z-index: 850;

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

@@ -2044,8 +2044,18 @@
     }
 
     /* ================= 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) {
         $("#findPanel").show();
+        placeFindPanel();
         if (withReplace) $("#replaceRow").show();
         var selTxt = savedRange ? savedRange.toString() : "";
         if (selTxt && selTxt.length <= 80 && selTxt.indexOf("\n") < 0) {
@@ -2460,6 +2470,33 @@
         fnMeasureEl.innerHTML = "";
         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) {
         var old = pageEl.querySelectorAll(".doc-fn-area");
         for (var i = 0; i < old.length; i++) {
@@ -2481,6 +2518,12 @@
                     OfficeApp.markDirty();
                     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);
             runLayoutPasses(area);

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

@@ -1226,6 +1226,32 @@ body.fmResizing *{
     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
     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>
 
-        <!-- 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">
-                <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();">
                     <i class="remove icon"></i>
                 </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>
 
         <!-- 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>
 <html>
     <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/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/semantic/semantic.min.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 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 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>
-                            </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>
-                            </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>
                             </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 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 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>
+                                    <label class="switch"><input type="checkbox" id="advShowFileList" checked><span></span></label>
                                 </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 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>
-</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
     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){
-    event.preventDefault(); 
+    event.preventDefault();
     event.stopImmediatePropagation();
     $(".fileObject.selected").removeClass("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(){
-    var selectedFiles = [];
     var selectedFileObjects = [];
     $(".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"));
-        console.log("No file is selected for sharing");
         return;
-    }else if (selectedFiles.length > 1){
+    }else if (selectedFileObjects.length > 1){
         //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"));
-        console.log("Multi share is current not supported");
         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];
-    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(){
-    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({
         url: "../../system/file_system/share/delete",
-        data: {vpath: selectedFile},
+        method: "POST",
+        data: {vpath: selected.attr("filepath")},
         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){
     forwardShareIndicatorToDesktop("setFileShareIndicator", filename);
@@ -138,12 +119,6 @@ function 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){
     if (!ao_module_virtualDesktop){
         return;
@@ -165,8 +140,9 @@ function forwardShareIndicatorToDesktop(fname, filename){
     of these means updating those call sites too.
 */
 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.setFileShareIndicator = setFileShareIndicator;   // the embedded file_share.html iframe calls parent.*
+window.setFileShareIndicator = setFileShareIndicator;
 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)
 var extIconRegistry = {};
 
-//File Sharing related
-let shareEditingObject = "";
-
 //System Information
 
 //Browser detection

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

@@ -56,9 +56,6 @@ function applyResponsiveLayout(){
         toggleSidebar(false);
     }
 
-    //Resize the share iframe
-    resizeShareIframe()
-
     //Resize the path display content
     if (!pathInputMode){
         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",
-    "version": "1.0",
+    "version": "2.0",
     "keys": {
         "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": {
-            "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": {
-            "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": {
-            "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": {},
             "placeholder": {}
         },
@@ -163,73 +333,213 @@
             "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": "変更を保存するには少なくとも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": {},
-            "placeholder": {}
+            "placeholder": {
+                "No share link yet": "共有リンクはまだありません",
+                "e.g. 7": "例:7"
+            }
         },
         "ko-kr": {
-            "fontFamily": "",
             "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": "로딩 중"
+                "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": {},
-            "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",
           "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)",
           "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]));
                 newFloatWindow({
                     url: targetURL,
-                    width: 330,
-                    height: 480,
-                    left: left,
-                    top: top,
+                    width: 760,
+                    height: 600,
                     appicon: "SystemAO/file_system/img/share.svg",
                     title: "Share File"
                 });
@@ -6210,28 +6208,27 @@
 
 
             //Calculate the window spawn position
+            var shareWindowWidth = 760;
+            var shareWindowHeight = 600;
             var basePosition = $(selectedFileObjects).offset();
             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;
-            if (top + 480 > window.innerHeight){
-                top = window.innerHeight - 480;
+            if (top + shareWindowHeight > window.innerHeight){
+                top = Math.max(0, window.innerHeight - shareWindowHeight);
             }
 
             newFloatWindow({
                 url: targetURL,
-                width: 330,
-                height: 480,
+                width: shareWindowWidth,
+                height: shareWindowHeight,
                 left: left,
                 top: top,
                 appicon: "SystemAO/file_system/img/share.svg",
                 title: "Share File"
-            }, function(windowUUID){
-                //Set the window to fixed window
-                setFloatWindowResizePolicy(windowUUID, false);
             });
 
             hideAllContextMenus();
@@ -7184,6 +7181,12 @@
             } 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){
             if (!item) return;
             if (item.id && arozNotificationSeenIds[item.id]) return;
@@ -7194,8 +7197,23 @@
             if (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
             //is focused, so the notification is never silently swallowed; a

Some files were not shown because too many files changed in this diff