3 Komitmen f14a9ab3ad ... 9cb6b3fbbf

Pembuat SHA1 Pesan Tanggal
  Toby Chui 9cb6b3fbbf Fix FTP root listing and WebDAV lock persistence 19 jam lalu
  Toby Chui d6edf36db0 Fixed icon duplicate bug 1 hari lalu
  Toby Chui 2e0c0a0230 Exclude per-install secrets from release tarball and repo 1 hari lalu

+ 5 - 0
.gitignore

@@ -56,3 +56,8 @@ src/system/git/
 /src/system/cache
 /src/system/mail
 /test
+
+# Per-install runtime data (mail secret key/db, cluster node identity)
+src/system/mail/
+src/system/cluster/
+src/system/cluster.db

+ 6 - 1
src/Makefile

@@ -48,6 +48,11 @@ web.tar.gz:
 	-rm ./dist/system/cron.json
 	-rm ./dist/system/bridge.json
 	-rm ./dist/system/auth/authlog.db
+	-rm -rf ./dist/system/git/
+	-rm -rf ./dist/system/mail/
+	-rm -rf ./dist/system/cluster/
+	-rm ./dist/system/cluster.db
+	-rm ./dist/system/telegram_conf.json
 	
 	@ echo "Remove ip2country, todo: Add binary base searching for this function"
 	-rm -rf "./dist/system/ip2country"
@@ -60,7 +65,7 @@ web.tar.gz:
 
 	@echo "Creating tarball for all required files"
 	-rm ./dist/web.tar.gz
-	-cd ./dist/ && tar --exclude='*.psd' -czf ./web.tar.gz system/ web/
+	-cd ./dist/ && COPYFILE_DISABLE=1 tar --exclude='*.psd' --exclude='.DS_Store' --exclude='._*' -czf ./web.tar.gz system/ web/
 
 	@echo "Clearing up unzipped folder structures"
 	-rm -rf "./dist/web"

+ 1 - 1
src/mod/fileservers/servers/ftpserv/ftpserv.go

@@ -101,7 +101,7 @@ func (m *Manager) StartFtpServer() error {
 		passiveModeIP = externalIP
 	}
 
-	h, err := ftp.NewFTPHandler(m.option.UserManager, m.option.Hostname, serverPort, m.option.TmpFolder, passiveModeIP)
+	h, err := ftp.NewFTPHandler(m.option.UserManager, m.option.Hostname, serverPort, passiveModeIP)
 	if err != nil {
 		return err
 	}

+ 40 - 25
src/mod/storage/ftp/aofs.go

@@ -21,8 +21,34 @@ var (
 )
 
 type aofs struct {
-	userinfo  *user.User
-	tmpFolder string
+	userinfo *user.User
+}
+
+// isVirtualRootPath reports whether name is served by the in-memory FTP root
+// (the root listing itself and its README.txt) rather than a real storage.
+func isVirtualRootPath(name string) bool {
+	path := filepath.ToSlash(filepath.Clean(name))
+	return path == "/" || path == "/README.txt"
+}
+
+// rootFs builds the FTP root as a read-only in-memory file system: one folder
+// per storage the user can reach (buffered remote storages are not supported
+// by the FTP driver) plus a README.txt. It is rebuilt on every root access so
+// newly mounted or closed storages show up immediately.
+func (a aofs) rootFs() afero.Fs {
+	memfs := afero.NewMemMapFs()
+	for _, fsh := range a.userinfo.GetAllFileSystemHandler() {
+		if !fsh.RequireBuffer {
+			memfs.Mkdir("/"+fsh.UUID, 0555)
+		}
+	}
+
+	readmeContent, err := os.ReadFile(filepath.Join("system", "ftp", "README.txt"))
+	if err != nil {
+		readmeContent = []byte("DO NOT UPLOAD FILES INTO THE ROOT DIRECTORY")
+	}
+	afero.WriteFile(memfs, "/README.txt", readmeContent, 0444)
+	return afero.NewReadOnlyFs(memfs)
 }
 
 func (a aofs) Create(name string) (afero.File, error) {
@@ -71,6 +97,9 @@ func (a aofs) MkdirAll(path string, perm os.FileMode) error {
 
 func (a aofs) Open(name string) (afero.File, error) {
 	//fmt.Println("FTP OPEN")
+	if isVirtualRootPath(name) {
+		return a.rootFs().Open(name)
+	}
 	fsh, rewritePath, err := a.pathRewrite(name)
 	if err != nil {
 		return nil, err
@@ -84,6 +113,9 @@ func (a aofs) Open(name string) (afero.File, error) {
 
 func (a aofs) Stat(name string) (os.FileInfo, error) {
 	//fmt.Println("FTP STAT")
+	if isVirtualRootPath(name) {
+		return a.rootFs().Stat(name)
+	}
 	fsh, rewritePath, err := a.pathRewrite(name)
 	if err != nil {
 		return nil, err
@@ -96,6 +128,9 @@ func (a aofs) Stat(name string) (os.FileInfo, error) {
 
 func (a aofs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
 	//fmt.Println("FTP OPEN FILE")
+	if isVirtualRootPath(name) {
+		return a.rootFs().OpenFile(name, flag, perm)
+	}
 	fsh, rewritePath, err := a.pathRewrite(name)
 	if err != nil {
 		return nil, err
@@ -212,29 +247,9 @@ func (a aofs) Chtimes(name string, atime time.Time, mtime time.Time) error {
 func (a aofs) pathRewrite(path string) (*filesystem.FileSystemHandler, string, error) {
 	path = filepath.ToSlash(filepath.Clean(path))
 	//log.Println("Original path: ", path)
-	if path == "/" {
-		//Roots. Show ftpbuf root
-		fshs := a.userinfo.GetAllFileSystemHandler()
-		for _, fsh := range fshs {
-			//Create a folder representation for this virtual directory
-
-			if !fsh.RequireBuffer {
-				os.MkdirAll(filepath.Join(a.tmpFolder, fsh.UUID), 0755)
-			}
-		}
-
-		readmeContent, err := os.ReadFile("./system/ftp/README.txt")
-		if err != nil {
-			readmeContent = []byte("DO NOT UPLOAD FILES INTO THE ROOT DIRECTORY")
-		}
-		os.WriteFile(filepath.Join(a.tmpFolder, "README.txt"), readmeContent, 0755)
-
-		//Return the tmpFolder root
-		tmpfs, _ := a.userinfo.GetFileSystemHandlerFromVirtualPath("tmp:/")
-		return tmpfs, a.tmpFolder, nil
-	} else if path == "/README.txt" {
-		tmpfs, _ := a.userinfo.GetFileSystemHandlerFromVirtualPath("tmp:/")
-		return tmpfs, a.tmpFolder + "README.txt", nil
+	if isVirtualRootPath(path) {
+		//The root is an in-memory read-only listing, see rootFs
+		return nil, "", errors.New("FTP root directory is read only")
 	} else if len(path) > 0 {
 		//Rewrite the path for any alternative filepath
 		//Get the uuid of the filepath

+ 114 - 0
src/mod/storage/ftp/aofs_test.go

@@ -0,0 +1,114 @@
+package ftp
+
+import (
+	"os"
+	"sort"
+	"testing"
+
+	"imuslab.com/arozos/mod/filesystem"
+	"imuslab.com/arozos/mod/storage"
+	"imuslab.com/arozos/mod/user"
+)
+
+// newTestAofs returns an aofs whose user can reach a local storage, a
+// buffered remote storage and a closed storage.
+func newTestAofs() aofs {
+	return aofs{
+		userinfo: &user.User{
+			Username: "alice",
+			HomeDirectories: &storage.StoragePool{
+				Storages: []*filesystem.FileSystemHandler{
+					{UUID: "user"},
+					{UUID: "remote", RequireBuffer: true},
+					{UUID: "closed", Closed: true},
+				},
+			},
+		},
+	}
+}
+
+func TestIsVirtualRootPath(t *testing.T) {
+	cases := []struct {
+		path string
+		want bool
+	}{
+		{"/", true},
+		{"/README.txt", true},
+		{"/user/../README.txt", true},
+		{"/user", false},
+		{"/user/README.txt", false},
+		{"/readme.txt", false},
+	}
+	for _, c := range cases {
+		if got := isVirtualRootPath(c.path); got != c.want {
+			t.Errorf("isVirtualRootPath(%q) = %v, want %v", c.path, got, c.want)
+		}
+	}
+}
+
+func TestRootListing(t *testing.T) {
+	a := newTestAofs()
+
+	fi, err := a.Stat("/")
+	if err != nil {
+		t.Fatalf("Stat root: %v", err)
+	}
+	if !fi.IsDir() {
+		t.Error("expected root to be a directory")
+	}
+
+	f, err := a.Open("/")
+	if err != nil {
+		t.Fatalf("Open root: %v", err)
+	}
+	defer f.Close()
+	names, err := f.Readdirnames(-1)
+	if err != nil {
+		t.Fatalf("Readdirnames: %v", err)
+	}
+	sort.Strings(names)
+	want := []string{"README.txt", "user"}
+	if len(names) != len(want) {
+		t.Fatalf("root entries = %v, want %v", names, want)
+	}
+	for i := range want {
+		if names[i] != want[i] {
+			t.Errorf("root entries = %v, want %v", names, want)
+			break
+		}
+	}
+}
+
+func TestRootReadmeReadable(t *testing.T) {
+	a := newTestAofs()
+	f, err := a.Open("/README.txt")
+	if err != nil {
+		t.Fatalf("Open README: %v", err)
+	}
+	defer f.Close()
+	buf := make([]byte, 64)
+	n, _ := f.Read(buf)
+	if n == 0 {
+		t.Error("expected README.txt to have content")
+	}
+}
+
+func TestRootIsReadOnly(t *testing.T) {
+	a := newTestAofs()
+
+	if _, err := a.OpenFile("/README.txt", os.O_WRONLY|os.O_TRUNC, 0644); err == nil {
+		t.Error("expected writing README.txt to fail")
+	}
+	if _, err := a.Create("/README.txt"); err == nil {
+		t.Error("expected creating README.txt to fail")
+	}
+	if err := a.Remove("/README.txt"); err == nil {
+		t.Error("expected removing README.txt to fail")
+	}
+	if err := a.Mkdir("/", 0755); err == nil {
+		t.Error("expected mkdir on root to fail")
+	}
+	if err := a.Rename("/README.txt", "/user/README.txt"); err == nil {
+		t.Error("expected renaming README.txt to fail")
+	}
+}

+ 1 - 7
src/mod/storage/ftp/drivers.go

@@ -4,7 +4,6 @@ import (
 	"crypto/tls"
 	"errors"
 	"fmt"
-	"os"
 	"time"
 
 	ftp "github.com/fclairamb/ftpserverlib"
@@ -76,18 +75,13 @@ func (m mainDriver) AuthUser(cc ftp.ClientContext, user string, pass string) (ft
 			return nil, errors.New("User " + userinfo.Username + " has no permission to access FTP endpoint")
 		}
 
-		//Create tmp buffer for this user
-		tmpFolder := m.tmpFolder + "users/" + userinfo.Username + "/ftpbuf/"
-		os.MkdirAll(tmpFolder, 0755)
-
 		//Record username into connected user list
 		m.connectedUserList.Store(cc.ID(), userinfo.Username)
 		//log the signin request
 		m.userHandler.GetAuthAgent().Logger.LogAuthByRequestInfo(userinfo.Username, cc.RemoteAddr().String(), time.Now().Unix(), true, "ftp")
 		//Return the aofs object
 		return aofs{
-			userinfo:  userinfo,
-			tmpFolder: tmpFolder,
+			userinfo: userinfo,
 		}, nil
 	} else {
 		//log the signin request

+ 1 - 3
src/mod/storage/ftp/ftp.go

@@ -25,12 +25,11 @@ type Handler struct {
 type mainDriver struct {
 	setting           ftp.Settings
 	userHandler       *user.UserHandler
-	tmpFolder         string
 	connectedUserList *sync.Map
 }
 
 // NewFTPHandler creates a new handler for FTP Server as a wrapper to the ftpserverlib
-func NewFTPHandler(userHandler *user.UserHandler, ServerName string, Port int, tmpFolder string, PassiveModeIP string) (*Handler, error) {
+func NewFTPHandler(userHandler *user.UserHandler, ServerName string, Port int, PassiveModeIP string) (*Handler, error) {
 	//Create table for ftp if it doesn't exists
 	db := userHandler.GetDatabase()
 	db.NewTable("ftp")
@@ -46,7 +45,6 @@ func NewFTPHandler(userHandler *user.UserHandler, ServerName string, Port int, t
 			},
 		},
 		userHandler:       userHandler,
-		tmpFolder:         tmpFolder,
 		connectedUserList: &sync.Map{},
 	})
 	return &Handler{

+ 12 - 2
src/mod/storage/webdav/webdav.go

@@ -32,7 +32,7 @@ import (
 type Server struct {
 	hostname    string            //The hostname of this devices
 	userHandler *user.UserHandler //The central userHandler
-	filesystems sync.Map          //The syncmap for storing opened file server
+	filesystems sync.Map          //Lock system per user and root, shared across requests
 	prefix      string            //The prefix to strip away from filepath
 	tlsMode     bool              //Bypass tls windows mode if enabled
 	Enabled     bool              //If the server is enabled. Set this to false for disable this service
@@ -344,13 +344,23 @@ func (s *Server) serveReadOnlyWebDav(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// getLockSystem returns the lock system shared by every request of a user on
+// one root. The handler is rebuilt per request, but locks must outlive it:
+// macOS Finder LOCKs a file and then PUTs the content with the lock token in
+// an If header, which fails with 412 against a fresh lock system and leaves
+// a 0 byte file behind.
+func (s *Server) getLockSystem(username string, prefix string) webdav.LockSystem {
+	ls, _ := s.filesystems.LoadOrStore(username+"\x00"+prefix, webdav.NewMemLS())
+	return ls.(webdav.LockSystem)
+}
+
 func (s *Server) getFsFromRealRoot(fsh *filesystem.FileSystemHandler, username string, prefix string) *webdav.Handler {
 	//Create a webdav adapter from the fsh
 	fshadapter := NewFshWebDAVAdapter(fsh, username)
 	fs := &webdav.Handler{
 		Prefix:     prefix,
 		FileSystem: fshadapter,
-		LockSystem: webdav.NewMemLS(),
+		LockSystem: s.getLockSystem(username, prefix),
 	}
 
 	//Create event listener for the path request

+ 57 - 0
src/mod/storage/webdav/webdav_test.go

@@ -12,6 +12,7 @@ import (
 
 	"imuslab.com/arozos/mod/auth"
 	db "imuslab.com/arozos/mod/database"
+	"imuslab.com/arozos/mod/network/webdav"
 	"imuslab.com/arozos/mod/permission"
 	"imuslab.com/arozos/mod/share/shareEntry"
 	"imuslab.com/arozos/mod/storage"
@@ -597,3 +598,59 @@ func TestHandleConnectionList_Empty(t *testing.T) {
 		t.Errorf("expected JSON array in response, got %q", body)
 	}
 }
+
+func TestGetLockSystem_SharedPerUserAndRoot(t *testing.T) {
+	s := newTestServer()
+	a := s.getLockSystem("alice", "/webdav/user")
+	if a != s.getLockSystem("alice", "/webdav/user") {
+		t.Error("expected the same lock system for the same user and root")
+	}
+	if a == s.getLockSystem("bob", "/webdav/user") {
+		t.Error("expected different users to get different lock systems")
+	}
+	if a == s.getLockSystem("alice", "/webdav/tmp") {
+		t.Error("expected different roots to get different lock systems")
+	}
+}
+
+// Finder LOCKs a file, then PUTs its content with the lock token in an If
+// header, each in its own request. The PUT must succeed and write the body.
+func TestLockThenPut_AcrossRequests(t *testing.T) {
+	s := newTestServer()
+	dir := t.TempDir()
+	newHandler := func() *webdav.Handler {
+		return &webdav.Handler{
+			Prefix:     "/webdav/user",
+			FileSystem: webdav.Dir(dir),
+			LockSystem: s.getLockSystem("alice", "/webdav/user"),
+		}
+	}
+
+	lockBody := `<?xml version="1.0" encoding="utf-8"?><D:lockinfo xmlns:D="DAV:"><D:lockscope><D:exclusive/></D:lockscope><D:locktype><D:write/></D:locktype><D:owner>finder</D:owner></D:lockinfo>`
+	req := httptest.NewRequest("LOCK", "/webdav/user/1.jpg", strings.NewReader(lockBody))
+	req.Header.Set("User-Agent", "WebDAVFS/3.0.0 (03008000) Darwin/25.6.0")
+	w := httptest.NewRecorder()
+	newHandler().ServeHTTP(w, req)
+	token := w.Header().Get("Lock-Token")
+	if w.Code != http.StatusOK && w.Code != http.StatusCreated || token == "" {
+		t.Fatalf("LOCK: got status %d, token %q", w.Code, token)
+	}
+
+	content := "jpeg bytes"
+	req = httptest.NewRequest(http.MethodPut, "/webdav/user/1.jpg", strings.NewReader(content))
+	req.Header.Set("User-Agent", "WebDAVFS/3.0.0 (03008000) Darwin/25.6.0")
+	req.Header.Set("If", "("+token+")")
+	w = httptest.NewRecorder()
+	newHandler().ServeHTTP(w, req)
+	if w.Code != http.StatusCreated {
+		t.Fatalf("PUT with lock token: expected 201, got %d", w.Code)
+	}
+
+	got, err := os.ReadFile(filepath.Join(dir, "1.jpg"))
+	if err != nil {
+		t.Fatalf("read uploaded file: %v", err)
+	}
+	if string(got) != content {
+		t.Errorf("uploaded file content = %q, want %q", got, content)
+	}
+}

+ 0 - 3
src/system/telegram_conf.json

@@ -1,3 +0,0 @@
-{
- "BotToken": ""
-}

+ 2 - 2
src/web/Musicify/index.html

@@ -1905,8 +1905,8 @@
                             <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 5h2v14H6zM19 5.5v13l-9.5-6.5z"/></svg>
                         </button>
                         <button class="np-btn np-play" x-on:click="togglePlay()" :title="isPlaying ? 'Pause' : 'Play'">
-                            <svg x-show="!isPlaying" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 5.5v13l10-6.5z"/></svg>
-                            <svg x-show="isPlaying" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6v12M15 6v12"/></svg>
+                            <!-- One SVG with a bound path: two x-show siblings could both stay visible when isPlaying flips false->true quickly at track end -->
+                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path :d="isPlaying ? 'M9 6v12M15 6v12' : 'M8 5.5v13l10-6.5z'" d="M8 5.5v13l10-6.5z"/></svg>
                         </button>
                         <button class="np-btn" x-on:click="nextTrack()" title="Next">
                             <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16 5h2v14h-2zM5 5.5v13l9.5-6.5z"/></svg>