From 0129cb7703371fff55fca02a5ebe2b073bb667ee Mon Sep 17 00:00:00 2001
From: Marco Herrn <marco@mherrn.de>
Date: Thu, 15 Jul 2021 16:16:05 +0200
Subject: [PATCH] Treat symlinks to directories like directories

This allows symlinking to create additional subalbums.

Closes: #431
---
 api/scanner/scanner_album.go |  8 +++-
 api/scanner/scanner_user.go  |  9 ++++-
 api/utils/utils.go           | 33 ++++++++++++++++
 api/utils/utils_test.go      | 77 ++++++++++++++++++++++++++++++++++++
 4 files changed, 125 insertions(+), 2 deletions(-)
 create mode 100644 api/utils/utils_test.go

diff --git a/api/scanner/scanner_album.go b/api/scanner/scanner_album.go
index 56b8fa8..e183ae8 100644
--- a/api/scanner/scanner_album.go
+++ b/api/scanner/scanner_album.go
@@ -181,7 +181,13 @@ func findMediaForAlbum(album *models.Album, cache *scanner_cache.AlbumScannerCac
 	for _, item := range dirContent {
 		photoPath := path.Join(album.Path, item.Name())
 
-		if !item.IsDir() && cache.IsPathMedia(photoPath) {
+		isDirSymlink, err := utils.IsDirSymlink(photoPath)
+		if err != nil {
+			log.Printf("Cannot detect whether %s is symlink to a directory. Pretending it is not", photoPath)
+			isDirSymlink = false
+		}
+
+		if !item.IsDir() && !isDirSymlink && cache.IsPathMedia(photoPath) {
 			// Match file against ignore data
 			if albumIgnore.MatchesPath(item.Name()) {
 				log.Printf("File %s ignored\n", item.Name())
diff --git a/api/scanner/scanner_user.go b/api/scanner/scanner_user.go
index 2d60536..c0248b9 100644
--- a/api/scanner/scanner_user.go
+++ b/api/scanner/scanner_user.go
@@ -11,6 +11,7 @@ import (
 	"github.com/photoview/photoview/api/graphql/models"
 	"github.com/photoview/photoview/api/scanner/scanner_cache"
 	"github.com/photoview/photoview/api/scanner/scanner_utils"
+	"github.com/photoview/photoview/api/utils"
 	"github.com/pkg/errors"
 	ignore "github.com/sabhiram/go-gitignore"
 	"gorm.io/gorm"
@@ -198,7 +199,13 @@ func findAlbumsForUser(db *gorm.DB, user *models.User, album_cache *scanner_cach
 				continue
 			}
 
-			if item.IsDir() && directoryContainsPhotos(subalbumPath, album_cache, albumIgnore) {
+			isDirSymlink, err := utils.IsDirSymlink(subalbumPath)
+			if err != nil {
+				scanErrors = append(scanErrors, errors.Wrapf(err, "could not check for symlink target of %s", subalbumPath))
+				continue
+			}
+
+			if (item.IsDir() || isDirSymlink) && directoryContainsPhotos(subalbumPath, album_cache, albumIgnore) {
 				scanQueue.PushBack(scanInfo{
 					path:   subalbumPath,
 					parent: album,
diff --git a/api/utils/utils.go b/api/utils/utils.go
index 5ee1bfd..a69fc0c 100644
--- a/api/utils/utils.go
+++ b/api/utils/utils.go
@@ -5,7 +5,11 @@ import (
 	"fmt"
 	"log"
 	"math/big"
+	"os"
 	"path"
+	"path/filepath"
+
+	"github.com/pkg/errors"
 )
 
 func GenerateToken() string {
@@ -80,3 +84,32 @@ func FaceRecognitionModelsPath() string {
 
 	return EnvFaceRecognitionModelsPath.GetValue()
 }
+
+// IsDirSymlink checks that the given path is a symlink and resolves to a
+// directory.
+func IsDirSymlink(path string) (bool, error) {
+	isDirSymlink := false
+
+	fileInfo, err := os.Lstat(path)
+	if err != nil {
+		return false, errors.Wrapf(err, "could not stat %s", path)
+	}
+
+	//Resolve symlinks
+	if fileInfo.Mode()&os.ModeSymlink == os.ModeSymlink {
+		resolvedPath, err := filepath.EvalSymlinks(path)
+		if err != nil {
+			return false, errors.Wrapf(err, "Cannot resolve linktarget of %s, ignoring it", path)
+		}
+
+		resolvedFile, err := os.Stat(resolvedPath)
+		if err != nil {
+			return false, errors.Wrapf(err, "Cannot get fileinfo of linktarget %s of symlink %s, ignoring it", resolvedPath, path)
+		}
+		isDirSymlink = resolvedFile.IsDir()
+
+		return isDirSymlink, nil
+	}
+
+	return false, nil
+}
diff --git a/api/utils/utils_test.go b/api/utils/utils_test.go
new file mode 100644
index 0000000..ed4ebc9
--- /dev/null
+++ b/api/utils/utils_test.go
@@ -0,0 +1,77 @@
+package utils_test
+
+import (
+	"io/ioutil"
+	"os"
+	"path"
+	"testing"
+
+	"github.com/photoview/photoview/api/test_utils"
+	"github.com/photoview/photoview/api/utils"
+)
+
+func TestMain(m *testing.M) {
+	os.Exit(test_utils.IntegrationTestRun(m))
+}
+
+func TestIsDirSymlink(t *testing.T) {
+	test_utils.FilesystemTest(t)
+
+	// Prepare a temporary directory for testing purposes
+	dir, err := ioutil.TempDir("", "testing")
+	if err != nil {
+		t.Fatalf("unable to create temp directory for testing")
+	}
+	defer os.RemoveAll(dir)
+
+	// Create regular file
+	_, err = os.Create(path.Join(dir, "regular_file"))
+	if err != nil {
+		t.Fatalf("unable to create regular file for testing")
+	}
+
+	// Create directory
+	err = os.Mkdir(path.Join(dir, "directory"), 0755)
+	if err != nil {
+		t.Fatalf("unable to create directory for testing")
+	}
+
+	// Create symlink to regular file
+	err = os.Symlink(path.Join(dir, "regular_file"), path.Join(dir, "file_link"))
+	if err != nil {
+		t.Fatalf("unable to create file link for testing")
+	}
+
+	// Create symlink to directory
+	err = os.Symlink(path.Join(dir, "directory"), path.Join(dir, "dir_link"))
+	if err != nil {
+		t.Fatalf("unable to create dir link for testing")
+	}
+
+	// Execute the actual tests
+
+	isDirLink, _ := utils.IsDirSymlink(path.Join(dir, "regular_file"))
+	if isDirLink {
+		t.Error("Failed detection of regular file")
+	}
+
+	isDirLink, _ = utils.IsDirSymlink(path.Join(dir, "directory"))
+	if isDirLink {
+		t.Error("Failed detection of directory")
+	}
+
+	isDirLink, _ = utils.IsDirSymlink(path.Join(dir, "file_link"))
+	if isDirLink {
+		t.Error("Failed detection of link to regular file")
+	}
+
+	isDirLink, _ = utils.IsDirSymlink(path.Join(dir, "dir_link"))
+	if !isDirLink {
+		t.Error("Failed detection of link to directory")
+	}
+
+	isDirLink, err = utils.IsDirSymlink(path.Join(dir, "non_existant"))
+	if err == nil {
+		t.Error("Missing error for non-existant file")
+	}
+}
