diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go index 116b4cd3..c913a961 100644 --- a/internal/bootstrap/config.go +++ b/internal/bootstrap/config.go @@ -133,6 +133,8 @@ func InitConfig() { convertAbsPath(&conf.Conf.Log.Name) convertAbsPath(&conf.Conf.TempDir) convertAbsPath(&conf.Conf.BleveDir) + convertAbsPath(&conf.Conf.Index115.DBFile) + convertAbsPath(&conf.Conf.Index115.BleveDir) convertAbsPath(&conf.Conf.DistDir) err := os.MkdirAll(conf.Conf.TempDir, 0o777) diff --git a/internal/bootstrap/index115.go b/internal/bootstrap/index115.go new file mode 100644 index 00000000..d0a6e5da --- /dev/null +++ b/internal/bootstrap/index115.go @@ -0,0 +1,58 @@ +package bootstrap + +import ( + "context" + "errors" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/index115" + "github.com/OpenListTeam/OpenList/v4/internal/setting" + "github.com/OpenListTeam/OpenList/v4/server" + "github.com/OpenListTeam/OpenList/v4/server/handles" + log "github.com/sirupsen/logrus" +) + +func InitIndex115() { + if err := InitIndex115Service(context.Background()); err != nil { + log.Errorf("init index115 error: %+v", err) + return + } + log.Info("index115 service initialized successfully") +} + +func InitIndex115Service(ctx context.Context) error { + if conf.Conf == nil { + return errors.New("config not initialized") + } + if conf.Conf.Index115.DBFile == "" || conf.Conf.Index115.BleveDir == "" { + return errors.New("index115 paths not configured") + } + store, err := index115.OpenStoreRuntime(ctx, conf.Conf.Index115.DBFile) + if err != nil { + return err + } + searcher, err := index115.NewSearcher(ctx, store, conf.Conf.Index115.BleveDir) + if err != nil { + log.Warnf("index115 search disabled: %+v", err) + } + delay := time.Duration(index115DeleteDelaySeconds()) * time.Second + service := index115.NewService( + store, + searcher, + index115.NewLinkResolver(index115.NewDriver115ShareClient(), delay), + ) + handles.SetIndex115Service(service) + server.SetIndex115BrowseService(service) + return nil +} + +func index115DeleteDelaySeconds() int { + if conf.Conf == nil { + return 900 + } + defer func() { + _ = recover() + }() + return setting.GetInt(conf.DeleteDelayTime, 900) +} diff --git a/internal/bootstrap/index115_test.go b/internal/bootstrap/index115_test.go new file mode 100644 index 00000000..5eb8dacf --- /dev/null +++ b/internal/bootstrap/index115_test.go @@ -0,0 +1,131 @@ +package bootstrap + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/blevesearch/bleve/v2" + _ "github.com/glebarez/go-sqlite" +) + +func TestInitIndex115ServiceReturnsErrorWhenManifestMissing(t *testing.T) { + conf.Conf = conf.DefaultConfig(t.TempDir()) + conf.Conf.Index115.DBFile = filepath.Join(t.TempDir(), "missing.db") + conf.Conf.Index115.BleveDir = filepath.Join(t.TempDir(), "missing-bleve") + + err := InitIndex115Service(context.Background()) + if err == nil { + t.Fatal("expected init error") + } +} + +func TestInitIndex115ServiceUsesConfiguredPaths(t *testing.T) { + rootDir := t.TempDir() + dbPath := filepath.Join(rootDir, "index.db") + bleveRoot := filepath.Join(rootDir, "bleve") + index, db := newRuntimeFixture(t, rootDir, dbPath) + defer func() { _ = db.Close() }() + if err := index.Close(); err != nil { + t.Fatalf("index.Close() error = %v", err) + } + + conf.Conf = conf.DefaultConfig(rootDir) + conf.Conf.Index115.DBFile = dbPath + conf.Conf.Index115.BleveDir = bleveRoot + + if err := InitIndex115Service(context.Background()); err != nil { + t.Fatalf("InitIndex115Service() error = %v", err) + } +} + +func TestInitIndex115ServiceDegradesWhenBleveUnavailable(t *testing.T) { + rootDir := t.TempDir() + dbPath := filepath.Join(rootDir, "index.db") + db := openIndex115RuntimeDB(t, dbPath) + defer func() { _ = db.Close() }() + if _, err := db.Exec(`INSERT INTO index_manifest(id, version, index_path, status, built_at, file_count) VALUES (1, 1, ?, 'READY', 1, 1)`, "bleve/index_000001"); err != nil { + t.Fatalf("insert manifest error = %v", err) + } + if _, err := db.Exec(`INSERT INTO share(share_code, receive_code, share_title, status, last_crawled_at) VALUES ('sw1', 'rc1', 'Share One', 'ACTIVE', 1)`); err != nil { + t.Fatalf("insert share error = %v", err) + } + + conf.Conf = conf.DefaultConfig(rootDir) + conf.Conf.Index115.DBFile = dbPath + conf.Conf.Index115.BleveDir = filepath.Join(rootDir, "bleve") + + if err := InitIndex115Service(context.Background()); err != nil { + t.Fatalf("InitIndex115Service() error = %v", err) + } +} + +func newRuntimeFixture(t *testing.T, rootDir, dbPath string) (closer interface{ Close() error }, db *sql.DB) { + t.Helper() + + db = openIndex115RuntimeDB(t, dbPath) + indexDir := filepath.Join(rootDir, "bleve", "index_000001") + if _, err := db.Exec(`INSERT INTO index_manifest(id, version, index_path, status, built_at, file_count) VALUES (1, 1, ?, 'READY', 1, 1)`, "bleve/index_000001"); err != nil { + t.Fatalf("insert manifest error = %v", err) + } + if _, err := db.Exec(`INSERT INTO share(share_code, receive_code, share_title, status, last_crawled_at) VALUES ('sw1', 'rc1', 'Share One', 'ACTIVE', 1)`); err != nil { + t.Fatalf("insert share error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(indexDir), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + index, err := bleve.New(indexDir, bleve.NewIndexMapping()) + if err != nil { + t.Fatalf("bleve.New() error = %v", err) + } + return index, db +} + +func openIndex115RuntimeDB(t *testing.T, dbPath string) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + stmts := []string{ + `CREATE TABLE share ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + share_code TEXT NOT NULL, + receive_code TEXT NOT NULL DEFAULT '', + share_title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'ACTIVE', + last_crawled_at INTEGER NOT NULL DEFAULT 0 + );`, + `CREATE TABLE file ( + file_id TEXT PRIMARY KEY, + share_code TEXT NOT NULL, + parent_id TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + ext TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + is_dir INTEGER NOT NULL DEFAULT 0, + depth INTEGER NOT NULL DEFAULT 0, + sha1 TEXT NOT NULL DEFAULT '', + updated_at INTEGER, + crawled_at INTEGER NOT NULL DEFAULT 0 + );`, + `CREATE TABLE index_manifest ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL, + index_path TEXT NOT NULL, + status TEXT NOT NULL, + built_at INTEGER NOT NULL, + file_count INTEGER NOT NULL + );`, + } + for _, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("db.Exec(%q) error = %v", stmt, err) + } + } + return db +} diff --git a/internal/bootstrap/run.go b/internal/bootstrap/run.go index 6740dba6..30642d3f 100644 --- a/internal/bootstrap/run.go +++ b/internal/bootstrap/run.go @@ -36,6 +36,7 @@ func Init() { data.InitData() InitStreamLimit() InitIndex() + InitIndex115() InitUpgradePatch() } diff --git a/internal/conf/config.go b/internal/conf/config.go index c5ace800..ec36cd05 100644 --- a/internal/conf/config.go +++ b/internal/conf/config.go @@ -106,6 +106,11 @@ type SFTP struct { Listen string `json:"listen" env:"LISTEN"` } +type Index115 struct { + DBFile string `json:"db_file" env:"DB_FILE"` + BleveDir string `json:"bleve_dir" env:"BLEVE_DIR"` +} + type Config struct { Force bool `json:"force" env:"FORCE"` SiteURL string `json:"site_url" env:"SITE_URL"` @@ -130,6 +135,7 @@ type Config struct { S3 S3 `json:"s3" envPrefix:"S3_"` FTP FTP `json:"ftp" envPrefix:"FTP_"` SFTP SFTP `json:"sftp" envPrefix:"SFTP_"` + Index115 Index115 `json:"index115" envPrefix:"INDEX115_"` LastLaunchedVersion string `json:"last_launched_version"` ProxyAddress string `json:"proxy_address" env:"PROXY_ADDRESS"` } @@ -137,6 +143,7 @@ type Config struct { func DefaultConfig(dataDir string) *Config { tempDir := filepath.Join(dataDir, "temp") indexDir := filepath.Join(dataDir, "bleve") + index115Dir := filepath.Join(dataDir, "index115") logPath := filepath.Join(dataDir, "log/log.log") dbPath := filepath.Join(dataDir, "data.db") return &Config{ @@ -163,6 +170,10 @@ func DefaultConfig(dataDir string) *Config { Index: "openlist", }, BleveDir: indexDir, + Index115: Index115{ + DBFile: filepath.Join(index115Dir, "index.db"), + BleveDir: filepath.Join(index115Dir, "bleve"), + }, Log: LogConfig{ Enable: true, Name: logPath, diff --git a/internal/conf/index115_config_test.go b/internal/conf/index115_config_test.go new file mode 100644 index 00000000..2a8536c6 --- /dev/null +++ b/internal/conf/index115_config_test.go @@ -0,0 +1,16 @@ +package conf + +import ( + "path/filepath" + "testing" +) + +func TestDefaultConfigInitializesIndex115Paths(t *testing.T) { + cfg := DefaultConfig("/tmp/openlist-data") + if cfg.Index115.DBFile != filepath.Join("/tmp/openlist-data", "index115", "index.db") { + t.Fatalf("unexpected index115 db path: %q", cfg.Index115.DBFile) + } + if cfg.Index115.BleveDir != filepath.Join("/tmp/openlist-data", "index115", "bleve") { + t.Fatalf("unexpected index115 bleve path: %q", cfg.Index115.BleveDir) + } +} diff --git a/internal/index115/config_test.go b/internal/index115/config_test.go new file mode 100644 index 00000000..6bb9165d --- /dev/null +++ b/internal/index115/config_test.go @@ -0,0 +1,141 @@ +package index115 + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/blevesearch/bleve/v2" + _ "github.com/glebarez/go-sqlite" +) + +func TestNewRuntimeRejectsMissingManifest(t *testing.T) { + rootDir := t.TempDir() + dbPath := filepath.Join(rootDir, "index.db") + openRuntimeDB(t, dbPath) + + store, searcher, err := NewRuntime(context.Background(), dbPath, rootDir) + if err == nil { + t.Fatalf("expected error, got store=%v searcher=%v", store, searcher) + } +} + +func TestNewRuntimeOpensConfiguredManifestIndex(t *testing.T) { + rootDir := t.TempDir() + dbPath := filepath.Join(rootDir, "index.db") + db := openRuntimeDB(t, dbPath) + + indexDir := filepath.Join(rootDir, "bleve", "index_000001") + if err := os.MkdirAll(filepath.Dir(indexDir), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + index, err := bleve.New(indexDir, bleve.NewIndexMapping()) + if err != nil { + t.Fatalf("bleve.New() error = %v", err) + } + if err := index.Index("f1", map[string]any{"name": "movie", "share_code": "sw1"}); err != nil { + t.Fatalf("index.Index() error = %v", err) + } + if err := index.Close(); err != nil { + t.Fatalf("index.Close() error = %v", err) + } + + if _, err := db.Exec(`INSERT INTO index_manifest(id, version, index_path, status, built_at, file_count) VALUES (1, 1, ?, 'READY', 1, 1)`, "bleve/index_000001"); err != nil { + t.Fatalf("insert manifest error = %v", err) + } + if _, err := db.Exec(`INSERT INTO share(share_code, receive_code, share_title, status, last_crawled_at) VALUES ('sw1', 'rc1', 'Share One', 'ACTIVE', 1)`); err != nil { + t.Fatalf("insert share error = %v", err) + } + + store, searcher, err := NewRuntime(context.Background(), dbPath, filepath.Join(rootDir, "bleve")) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + if store == nil || searcher == nil || searcher.index == nil { + t.Fatalf("expected initialized runtime, got store=%v searcher=%v", store, searcher) + } +} + +func TestNewRuntimeFallsBackToBleveDirBaseForAbsoluteManifestIndexPath(t *testing.T) { + rootDir := t.TempDir() + dbPath := filepath.Join(rootDir, "index.db") + db := openRuntimeDB(t, dbPath) + + indexDir := filepath.Join(rootDir, "bleve", "index_000001") + if err := os.MkdirAll(filepath.Dir(indexDir), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + index, err := bleve.New(indexDir, bleve.NewIndexMapping()) + if err != nil { + t.Fatalf("bleve.New() error = %v", err) + } + if err := index.Close(); err != nil { + t.Fatalf("index.Close() error = %v", err) + } + + manifestPath := filepath.Join("/build-host/indexes", "index_000001") + if _, err := db.Exec(`INSERT INTO index_manifest(id, version, index_path, status, built_at, file_count) VALUES (1, 1, ?, 'READY', 1, 1)`, manifestPath); err != nil { + t.Fatalf("insert manifest error = %v", err) + } + if _, err := db.Exec(`INSERT INTO share(share_code, receive_code, share_title, status, last_crawled_at) VALUES ('sw1', 'rc1', 'Share One', 'ACTIVE', 1)`); err != nil { + t.Fatalf("insert share error = %v", err) + } + + store, searcher, err := NewRuntime(context.Background(), dbPath, filepath.Join(rootDir, "bleve")) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + if store == nil || searcher == nil || searcher.index == nil { + t.Fatalf("expected initialized runtime, got store=%v searcher=%v", store, searcher) + } +} + +func openRuntimeDB(t *testing.T, dbPath string) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + stmts := []string{ + `CREATE TABLE share ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + share_code TEXT NOT NULL, + receive_code TEXT NOT NULL DEFAULT '', + share_title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'ACTIVE', + last_crawled_at INTEGER NOT NULL DEFAULT 0 + );`, + `CREATE TABLE file ( + file_id TEXT PRIMARY KEY, + share_code TEXT NOT NULL, + parent_id TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + ext TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + is_dir INTEGER NOT NULL DEFAULT 0, + depth INTEGER NOT NULL DEFAULT 0, + sha1 TEXT NOT NULL DEFAULT '', + updated_at INTEGER, + crawled_at INTEGER NOT NULL DEFAULT 0 + );`, + `CREATE TABLE index_manifest ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL, + index_path TEXT NOT NULL, + status TEXT NOT NULL, + built_at INTEGER NOT NULL, + file_count INTEGER NOT NULL + );`, + } + for _, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("db.Exec(%q) error = %v", stmt, err) + } + } + return db +} diff --git a/internal/index115/linker_115.go b/internal/index115/linker_115.go new file mode 100644 index 00000000..124cd1db --- /dev/null +++ b/internal/index115/linker_115.go @@ -0,0 +1,109 @@ +package index115 + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "errors" + "sync" + "time" +) + +var ErrLinkClientNotConfigured = errors.New("index115 link client not configured") + +type ResolvedLink struct { + URL string + ExpiredIn int64 +} + +type ShareDownloadClient interface { + ResolveShareLink(ctx context.Context, cookie string, shareCode string, receiveCode string, file FileItem) (ResolvedLink, string, error) + DeleteReceivedByFileID(ctx context.Context, cookie string, fileID string) error +} + +type LinkResolver struct { + client ShareDownloadClient + leases *leaseRegistry + delay time.Duration +} + +func NewLinkResolver(client ShareDownloadClient, delay time.Duration) *LinkResolver { + return &LinkResolver{ + client: client, + leases: newLeaseRegistry(delay), + delay: delay, + } +} + +func (r *LinkResolver) Resolve(ctx context.Context, req LinkRequest, file FileItem) (ResolvedLink, error) { + if r.client == nil { + return ResolvedLink{}, ErrLinkClientNotConfigured + } + receiveCode := r.resolveReceiveCode(req.ReceiveCode, file.ReceiveCode) + link, receivedFileID, err := r.client.ResolveShareLink(ctx, req.Cookie, req.ShareCode, receiveCode, file) + if err != nil { + return ResolvedLink{}, err + } + if receivedFileID != "" { + r.scheduleCleanup(req.Cookie, file.FileID, receivedFileID) + } + return link, nil +} + +func (r *LinkResolver) resolveReceiveCode(requestCode, shareCode string) string { + if requestCode != "" { + return requestCode + } + return shareCode +} + +func (r *LinkResolver) leaseKey(cookie, fileID string) string { + sum := sha1.Sum([]byte(cookie + ":" + fileID)) + return hex.EncodeToString(sum[:]) +} + +func (r *LinkResolver) scheduleCleanup(cookie, fileID, receivedFileID string) { + if r.client == nil || r.leases == nil || r.delay <= 0 { + return + } + key := r.leaseKey(cookie, fileID) + expiresAt := r.leases.Touch(key) + go func() { + time.Sleep(r.delay) + if !r.leases.Expired(key, expiresAt) { + return + } + _ = r.client.DeleteReceivedByFileID(context.Background(), cookie, receivedFileID) + }() +} + +type leaseRegistry struct { + mu sync.Mutex + ttl time.Duration + items map[string]time.Time +} + +func newLeaseRegistry(ttl time.Duration) *leaseRegistry { + return &leaseRegistry{ + ttl: ttl, + items: map[string]time.Time{}, + } +} + +func (r *leaseRegistry) Touch(key string) time.Time { + r.mu.Lock() + defer r.mu.Unlock() + expiresAt := time.Now().Add(r.ttl) + r.items[key] = expiresAt + return expiresAt +} + +func (r *leaseRegistry) Expired(key string, at time.Time) bool { + r.mu.Lock() + defer r.mu.Unlock() + current, ok := r.items[key] + if !ok { + return true + } + return !current.After(at) +} diff --git a/internal/index115/linker_115_adapter.go b/internal/index115/linker_115_adapter.go new file mode 100644 index 00000000..b5b96189 --- /dev/null +++ b/internal/index115/linker_115_adapter.go @@ -0,0 +1,174 @@ +package index115 + +import ( + "context" + "fmt" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + driver115 "github.com/power721/115driver/pkg/driver" +) + +const receiveDirName = "最近接收" + +type driver115Factory interface { + NewClient(ctx context.Context, cookie string) (driver115Client, error) +} + +type driver115Client interface { + DownloadByShareCode(ctx context.Context, shareCode, receiveCode, fileID string) (ResolvedLink, error) + ListDir(ctx context.Context, dirID string) ([]driver115File, error) + Delete(ctx context.Context, fileID string) error +} + +type driver115File struct { + FileID string + Name string + Sha1 string + IsDir bool +} + +type driver115ShareClient struct { + factory driver115Factory +} + +func NewDriver115ShareClient() ShareDownloadClient { + return &driver115ShareClient{ + factory: defaultDriver115Factory{}, + } +} + +func (c *driver115ShareClient) ResolveShareLink(ctx context.Context, cookie string, shareCode string, receiveCode string, file FileItem) (ResolvedLink, string, error) { + client, err := c.factory.NewClient(ctx, cookie) + if err != nil { + return ResolvedLink{}, "", fmt.Errorf("%w: %v", ErrInvalidCookie, err) + } + beforeFiles, _ := listReceiveDirFiles(ctx, client) + link, err := client.DownloadByShareCode(ctx, shareCode, receiveCode, file.FileID) + if err != nil { + return ResolvedLink{}, "", fmt.Errorf("%w: %v", ErrLinkResolveFailed, err) + } + afterFiles, err := listReceiveDirFiles(ctx, client) + if err != nil { + return link, "", nil + } + return link, resolveReceivedFileID(beforeFiles, afterFiles, file), nil +} + +func (c *driver115ShareClient) DeleteReceivedByFileID(ctx context.Context, cookie string, fileID string) error { + if fileID == "" { + return nil + } + client, err := c.factory.NewClient(ctx, cookie) + if err != nil { + return err + } + return client.Delete(ctx, fileID) +} + +type defaultDriver115Factory struct{} + +func (defaultDriver115Factory) NewClient(ctx context.Context, cookie string) (driver115Client, error) { + _ = ctx + cr := &driver115.Credential{} + if err := cr.FromCookie(cookie); err != nil { + return nil, err + } + client := driver115.New( + driver115.UA(conf.UA115Browser), + driver115.InsecureSkipVerify(conf.Conf.TlsInsecureSkipVerify), + ).ImportCredential(cr) + if err := client.CookieCheck(); err != nil { + return nil, err + } + return &defaultDriver115Client{client: client}, nil +} + +type defaultDriver115Client struct { + client *driver115.Pan115Client +} + +func (c *defaultDriver115Client) DownloadByShareCode(ctx context.Context, shareCode, receiveCode, fileID string) (ResolvedLink, error) { + _ = ctx + info, err := c.client.DownloadByShareCode(shareCode, receiveCode, fileID) + if err != nil { + return ResolvedLink{}, err + } + return ResolvedLink{ + URL: info.URL.URL, + ExpiredIn: 4 * 60 * 60, + }, nil +} + +func (c *defaultDriver115Client) ListDir(ctx context.Context, dirID string) ([]driver115File, error) { + _ = ctx + files, err := c.client.ListWithLimit(dirID, driver115.FileListLimit, driver115.WithMultiUrls()) + if err != nil { + return nil, err + } + items := make([]driver115File, 0, len(*files)) + for _, file := range *files { + items = append(items, driver115File{ + FileID: file.FileID, + Name: file.Name, + Sha1: file.Sha1, + IsDir: file.IsDir(), + }) + } + return items, nil +} + +func (c *defaultDriver115Client) Delete(ctx context.Context, fileID string) error { + _ = ctx + return c.client.Delete(fileID) +} + +func listReceiveDirFiles(ctx context.Context, client driver115Client) ([]driver115File, error) { + rootFiles, err := client.ListDir(ctx, "0") + if err != nil { + return nil, err + } + for _, file := range rootFiles { + if file.IsDir && file.Name == receiveDirName { + return client.ListDir(ctx, file.FileID) + } + } + return nil, nil +} + +func resolveReceivedFileID(beforeFiles, afterFiles []driver115File, target FileItem) string { + if len(afterFiles) == 0 { + return "" + } + beforeIDs := make(map[string]struct{}, len(beforeFiles)) + for _, file := range beforeFiles { + beforeIDs[file.FileID] = struct{}{} + } + candidates := make([]driver115File, 0, len(afterFiles)) + for _, file := range afterFiles { + if _, ok := beforeIDs[file.FileID]; ok { + continue + } + if file.IsDir { + continue + } + candidates = append(candidates, file) + } + if len(candidates) == 1 { + return candidates[0].FileID + } + if target.SHA1 != "" { + for _, file := range candidates { + if file.Sha1 == target.SHA1 { + return file.FileID + } + } + } + if target.Name != "" { + for _, file := range candidates { + if file.Name == target.Name { + return file.FileID + } + } + } + return "" +} diff --git a/internal/index115/linker_115_adapter_test.go b/internal/index115/linker_115_adapter_test.go new file mode 100644 index 00000000..b33d9ef6 --- /dev/null +++ b/internal/index115/linker_115_adapter_test.go @@ -0,0 +1,147 @@ +package index115 + +import ( + "context" + "errors" + "testing" +) + +func TestDriver115ShareClientResolveShareLinkUsesCookieClient(t *testing.T) { + factory := &fakeDriver115Factory{ + client: &fakeDriver115Client{ + downloadURL: "https://example.com/video.m3u8", + rootFiles: []driver115File{{FileID: "recv", Name: "最近接收", IsDir: true}}, + dirFilesSeq: map[string][][]driver115File{ + "recv": { + { + {FileID: "existing", Name: "old.mkv", Sha1: "old-sha1"}, + }, + { + {FileID: "existing", Name: "old.mkv", Sha1: "old-sha1"}, + {FileID: "received-file-1", Name: "video.mkv", Sha1: "sha1-value"}, + }, + }, + }, + }, + } + adapter := &driver115ShareClient{factory: factory} + + link, receivedFileID, err := adapter.ResolveShareLink(context.Background(), "UID=1;CID=2;SEID=3", "sw1", "rc1", FileItem{FileID: "file1", SHA1: "sha1-value"}) + if err != nil { + t.Fatalf("ResolveShareLink() error = %v", err) + } + if link.URL != "https://example.com/video.m3u8" { + t.Fatalf("unexpected link: %+v", link) + } + if receivedFileID != "received-file-1" { + t.Fatalf("expected received file id, got %q", receivedFileID) + } + if factory.lastCookie != "UID=1;CID=2;SEID=3" { + t.Fatalf("expected cookie forwarded to factory, got %q", factory.lastCookie) + } + if factory.client.lastShareCode != "sw1" || factory.client.lastReceiveCode != "rc1" || factory.client.lastFileID != "file1" { + t.Fatalf("unexpected download args: %+v", factory.client) + } +} + +func TestDriver115ShareClientDeleteReceivedByFileIDDeletesTargetFile(t *testing.T) { + factory := &fakeDriver115Factory{ + client: &fakeDriver115Client{ + rootFiles: []driver115File{{FileID: "recv", Name: "最近接收", IsDir: true}}, + dirFiles: map[string][]driver115File{ + "recv": { + {FileID: "a", Sha1: "keep"}, + {FileID: "b", Sha1: "target"}, + }, + }, + }, + } + adapter := &driver115ShareClient{factory: factory} + + if err := adapter.DeleteReceivedByFileID(context.Background(), "UID=1;CID=2;SEID=3", "b"); err != nil { + t.Fatalf("DeleteReceivedByFileID() error = %v", err) + } + if len(factory.client.deletedIDs) != 1 || factory.client.deletedIDs[0] != "b" { + t.Fatalf("expected file b to be deleted, got %+v", factory.client.deletedIDs) + } +} + +func TestDriver115ShareClientDeleteReceivedByFileIDIgnoresEmptyID(t *testing.T) { + factory := &fakeDriver115Factory{ + client: &fakeDriver115Client{}, + } + adapter := &driver115ShareClient{factory: factory} + + if err := adapter.DeleteReceivedByFileID(context.Background(), "UID=1;CID=2;SEID=3", ""); err != nil { + t.Fatalf("DeleteReceivedByFileID() error = %v", err) + } + if len(factory.client.deletedIDs) != 0 { + t.Fatalf("expected no deletes, got %+v", factory.client.deletedIDs) + } +} + +func TestDriver115ShareClientResolveShareLinkPropagatesFactoryError(t *testing.T) { + adapter := &driver115ShareClient{ + factory: &fakeDriver115Factory{err: errors.New("bad cookie")}, + } + _, _, err := adapter.ResolveShareLink(context.Background(), "bad", "sw1", "rc1", FileItem{FileID: "file1"}) + if err == nil { + t.Fatal("expected error") + } +} + +type fakeDriver115Factory struct { + client *fakeDriver115Client + err error + lastCookie string +} + +func (f *fakeDriver115Factory) NewClient(ctx context.Context, cookie string) (driver115Client, error) { + if f.err != nil { + return nil, f.err + } + f.lastCookie = cookie + return f.client, nil +} + +type fakeDriver115Client struct { + downloadURL string + lastShareCode string + lastReceiveCode string + lastFileID string + rootFiles []driver115File + dirFiles map[string][]driver115File + dirFilesSeq map[string][][]driver115File + dirCallCount map[string]int + deletedIDs []string +} + +func (f *fakeDriver115Client) DownloadByShareCode(ctx context.Context, shareCode, receiveCode, fileID string) (ResolvedLink, error) { + f.lastShareCode = shareCode + f.lastReceiveCode = receiveCode + f.lastFileID = fileID + return ResolvedLink{URL: f.downloadURL, ExpiredIn: 14400}, nil +} + +func (f *fakeDriver115Client) ListDir(ctx context.Context, dirID string) ([]driver115File, error) { + if dirID == "0" { + return f.rootFiles, nil + } + if len(f.dirFilesSeq[dirID]) > 0 { + if f.dirCallCount == nil { + f.dirCallCount = map[string]int{} + } + idx := f.dirCallCount[dirID] + f.dirCallCount[dirID] = idx + 1 + if idx >= len(f.dirFilesSeq[dirID]) { + idx = len(f.dirFilesSeq[dirID]) - 1 + } + return f.dirFilesSeq[dirID][idx], nil + } + return f.dirFiles[dirID], nil +} + +func (f *fakeDriver115Client) Delete(ctx context.Context, fileID string) error { + f.deletedIDs = append(f.deletedIDs, fileID) + return nil +} diff --git a/internal/index115/linker_115_test.go b/internal/index115/linker_115_test.go new file mode 100644 index 00000000..30a7d57e --- /dev/null +++ b/internal/index115/linker_115_test.go @@ -0,0 +1,98 @@ +package index115 + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +func TestLinkResolverResolveReceiveCodePrefersNonEmptyRequestValue(t *testing.T) { + resolver := &LinkResolver{} + got := resolver.resolveReceiveCode("req-code", "share-code") + if got != "req-code" { + t.Fatalf("expected req-code, got %q", got) + } +} + +func TestLinkResolverResolveReceiveCodeFallsBackToShareValue(t *testing.T) { + resolver := &LinkResolver{} + got := resolver.resolveReceiveCode("", "share-code") + if got != "share-code" { + t.Fatalf("expected share-code, got %q", got) + } +} + +func TestLeaseRegistryRefreshesLease(t *testing.T) { + registry := newLeaseRegistry(time.Minute) + first := registry.Touch("cookie-hash:file-id") + time.Sleep(10 * time.Millisecond) + second := registry.Touch("cookie-hash:file-id") + if !second.After(first) { + t.Fatalf("expected lease to refresh, first=%v second=%v", first, second) + } +} + +func TestLinkResolverResolveSchedulesCleanupWithLease(t *testing.T) { + client := &fakeShareDownloadClient{ + resolvedLink: ResolvedLink{URL: "https://example.com/play", ExpiredIn: 14400}, + receivedFile: "received-file-1", + } + resolver := &LinkResolver{ + client: client, + leases: newLeaseRegistry(5 * time.Millisecond), + delay: 5 * time.Millisecond, + } + + file := FileItem{FileID: "file1", ShareCode: "sw1", ReceiveCode: "share-code", SHA1: "sha1-value"} + link, err := resolver.Resolve(context.Background(), LinkRequest{ + Cookie: "UID=1;CID=2", + ShareCode: "sw1", + FileID: "file1", + }, file) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if link.URL != "https://example.com/play" { + t.Fatalf("unexpected link: %+v", link) + } + + time.Sleep(30 * time.Millisecond) + + client.mu.Lock() + defer client.mu.Unlock() + if len(client.deletedFileIDs) != 1 || client.deletedFileIDs[0] != "received-file-1" { + t.Fatalf("expected cleanup delete call, got %+v", client.deletedFileIDs) + } +} + +func TestLinkResolverResolveReturnsErrorWhenClientMissing(t *testing.T) { + resolver := &LinkResolver{} + _, err := resolver.Resolve(context.Background(), LinkRequest{ + Cookie: "cookie", + ShareCode: "sw1", + FileID: "file1", + }, FileItem{FileID: "file1", ShareCode: "sw1"}) + if !errors.Is(err, ErrLinkClientNotConfigured) { + t.Fatalf("expected ErrLinkClientNotConfigured, got %v", err) + } +} + +type fakeShareDownloadClient struct { + mu sync.Mutex + resolvedLink ResolvedLink + receivedFile string + deletedFileIDs []string +} + +func (f *fakeShareDownloadClient) ResolveShareLink(ctx context.Context, cookie string, shareCode string, receiveCode string, file FileItem) (ResolvedLink, string, error) { + return f.resolvedLink, f.receivedFile, nil +} + +func (f *fakeShareDownloadClient) DeleteReceivedByFileID(ctx context.Context, cookie string, fileID string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.deletedFileIDs = append(f.deletedFileIDs, fileID) + return nil +} diff --git a/internal/index115/model.go b/internal/index115/model.go new file mode 100644 index 00000000..e07a0dad --- /dev/null +++ b/internal/index115/model.go @@ -0,0 +1,47 @@ +package index115 + +type ShareSummary struct { + ShareCode string + ReceiveCode string + ShareTitle string + Path string + IsDir bool + FileCount int64 + DirCount int64 + UpdatedAt int64 +} + +type FileItem struct { + FileID string + ShareCode string + ReceiveCode string + ShareTitle string + ParentID string + Name string + Path string + Size int64 + IsDir bool + Ext string + SHA1 string + UpdatedAt int64 +} + +type SearchRequest struct { + Query string + Page int + PerPage int + ShareCode string +} + +type BrowseRequest struct { + ShareCode string + ReceiveCode string + ParentID string +} + +type LinkRequest struct { + Cookie string `json:"cookie"` + ShareCode string `json:"share_code"` + ReceiveCode string `json:"receive_code"` + FileID string `json:"file_id"` +} diff --git a/internal/index115/runtime.go b/internal/index115/runtime.go new file mode 100644 index 00000000..311c7bd8 --- /dev/null +++ b/internal/index115/runtime.go @@ -0,0 +1,89 @@ +package index115 + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "strings" + + "github.com/blevesearch/bleve/v2" + + _ "github.com/glebarez/go-sqlite" +) + +var ErrManifestNotReady = errors.New("index115 manifest not found or not ready") + +func NewRuntime(ctx context.Context, dbPath, bleveBaseDir string) (*Store, *Searcher, error) { + store, err := OpenStoreRuntime(ctx, dbPath) + if err != nil { + return nil, nil, err + } + searcher, err := NewSearcher(ctx, store, bleveBaseDir) + if err != nil { + _ = store.db.Close() + return nil, nil, err + } + return store, searcher, nil +} + +func OpenStoreRuntime(ctx context.Context, dbPath string) (*Store, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + + store := OpenStore(db) + if err := store.RefreshShares(ctx); err != nil { + _ = db.Close() + return nil, err + } + return store, nil +} + +func NewSearcher(ctx context.Context, store *Store, bleveBaseDir string) (*Searcher, error) { + indexPath, err := loadReadyIndexPath(ctx, store.db, bleveBaseDir) + if err != nil { + return nil, err + } + + index, err := bleve.Open(indexPath) + if err != nil { + return nil, err + } + return &Searcher{ + store: store, + index: index, + }, nil +} + +func loadReadyIndexPath(ctx context.Context, db *sql.DB, bleveBaseDir string) (string, error) { + row := db.QueryRowContext(ctx, ` + SELECT index_path + FROM index_manifest + WHERE id = 1 AND status = 'READY'`) + var relPath string + if err := row.Scan(&relPath); err != nil { + if err == sql.ErrNoRows { + return "", ErrManifestNotReady + } + return "", err + } + return resolveIndexPath(bleveBaseDir, relPath), nil +} + +func resolveIndexPath(bleveBaseDir, manifestPath string) string { + clean := filepath.Clean(manifestPath) + if filepath.IsAbs(clean) { + return filepath.Join(bleveBaseDir, filepath.Base(clean)) + } + baseName := filepath.Base(filepath.Clean(bleveBaseDir)) + prefix := baseName + string(filepath.Separator) + if clean == baseName { + return bleveBaseDir + } + if strings.HasPrefix(clean, prefix) { + clean = strings.TrimPrefix(clean, prefix) + } + return filepath.Join(bleveBaseDir, clean) +} diff --git a/internal/index115/search.go b/internal/index115/search.go new file mode 100644 index 00000000..10106889 --- /dev/null +++ b/internal/index115/search.go @@ -0,0 +1,88 @@ +package index115 + +import ( + "context" + "strings" + + "github.com/blevesearch/bleve/v2" + "github.com/blevesearch/bleve/v2/search/query" +) + +type Searcher struct { + store *Store + index bleve.Index +} + +func (s *Searcher) Search(ctx context.Context, req SearchRequest) ([]FileItem, int, error) { + if req.Page <= 0 { + req.Page = 1 + } + if req.PerPage <= 0 { + req.PerPage = 20 + } + if req.PerPage > 100 { + req.PerPage = 100 + } + + q := buildSearchQuery(req) + offset := 0 + pageStart := (req.Page - 1) * req.PerPage + resolvedTotal := 0 + items := make([]FileItem, 0, req.PerPage) + + for { + searchReq := bleve.NewSearchRequestOptions(q, req.PerPage, offset, false) + res, err := s.index.SearchInContext(ctx, searchReq) + if err != nil { + return nil, 0, err + } + if len(res.Hits) == 0 { + break + } + + ids := make([]string, 0, len(res.Hits)) + for _, hit := range res.Hits { + ids = append(ids, hit.ID) + } + files, err := s.store.FilesByIDs(ctx, ids) + if err != nil { + return nil, 0, err + } + + resolvedBatch := 0 + for _, id := range ids { + item, ok := files[id] + if !ok { + continue + } + if resolvedTotal >= pageStart && len(items) < req.PerPage { + items = append(items, item) + } + resolvedTotal++ + resolvedBatch++ + } + + offset += len(res.Hits) + if len(res.Hits) < req.PerPage { + break + } + if resolvedBatch == 0 && offset >= int(res.Total) { + break + } + } + + return items, resolvedTotal, nil +} + +func buildSearchQuery(req SearchRequest) query.Query { + match := bleve.NewMatchQuery(req.Query) + if strings.TrimSpace(req.ShareCode) == "" { + return match + } + boolQuery := bleve.NewBooleanQuery() + boolQuery.AddMust(match) + shareQuery := bleve.NewTermQuery(req.ShareCode) + shareQuery.SetField("share_code") + boolQuery.AddMust(shareQuery) + return boolQuery +} diff --git a/internal/index115/search_test.go b/internal/index115/search_test.go new file mode 100644 index 00000000..15143fcf --- /dev/null +++ b/internal/index115/search_test.go @@ -0,0 +1,199 @@ +package index115 + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/blevesearch/bleve/v2" + _ "github.com/glebarez/go-sqlite" +) + +func TestSearcherSearchPreservesBleveOrder(t *testing.T) { + fixture := newSearchFixture(t) + + fixture.indexDoc(t, "f2", map[string]any{ + "name": "beta movie", + "path": "/beta movie", + "share_code": "sw1", + }) + fixture.indexDoc(t, "f1", map[string]any{ + "name": "alpha movie", + "path": "/alpha movie", + "share_code": "sw1", + }) + + insertTestShare(t, fixture.store.db, testShareRow{ + ShareCode: "sw1", + ReceiveCode: "rc1", + ShareTitle: "Share One", + Status: "ACTIVE", + LastCrawledAt: 10, + }) + insertTestFile(t, fixture.store.db, testFileRow{ + FileID: "f1", + ShareCode: "sw1", + ParentID: "0", + Name: "alpha movie", + Path: "/alpha movie", + }) + insertTestFile(t, fixture.store.db, testFileRow{ + FileID: "f2", + ShareCode: "sw1", + ParentID: "0", + Name: "beta movie", + Path: "/beta movie", + }) + if err := fixture.store.RefreshShares(context.Background()); err != nil { + t.Fatalf("RefreshShares() error = %v", err) + } + + items, total, err := fixture.searcher.Search(context.Background(), SearchRequest{ + Query: "movie", + Page: 1, + PerPage: 2, + }) + if err != nil { + t.Fatalf("Search() error = %v", err) + } + if total != 2 { + t.Fatalf("expected total 2, got %d", total) + } + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + if items[0].FileID != "f2" || items[1].FileID != "f1" { + t.Fatalf("unexpected ordering: %+v", items) + } +} + +func TestSearcherSearchDropsMissingSQLiteRows(t *testing.T) { + fixture := newSearchFixture(t) + + fixture.indexDoc(t, "missing", map[string]any{ + "name": "ghost movie", + "path": "/ghost movie", + "share_code": "sw1", + }) + + items, total, err := fixture.searcher.Search(context.Background(), SearchRequest{ + Query: "ghost", + Page: 1, + PerPage: 10, + }) + if err != nil { + t.Fatalf("Search() error = %v", err) + } + if total != 0 { + t.Fatalf("expected resolved total 0, got %d", total) + } + if len(items) != 0 { + t.Fatalf("expected empty resolved page, got %+v", items) + } +} + +func TestSearcherSearchBackfillsMissingRowsToKeepPagesStable(t *testing.T) { + fixture := newSearchFixture(t) + + fixture.indexDoc(t, "f1", map[string]any{ + "name": "movie one", + "path": "/movie one", + "share_code": "sw1", + }) + fixture.indexDoc(t, "missing", map[string]any{ + "name": "movie missing", + "path": "/movie missing", + "share_code": "sw1", + }) + fixture.indexDoc(t, "f2", map[string]any{ + "name": "movie two", + "path": "/movie two", + "share_code": "sw1", + }) + + insertTestShare(t, fixture.store.db, testShareRow{ + ShareCode: "sw1", + ReceiveCode: "rc1", + ShareTitle: "Share One", + Status: "ACTIVE", + LastCrawledAt: 10, + }) + insertTestFile(t, fixture.store.db, testFileRow{ + FileID: "f1", + ShareCode: "sw1", + ParentID: "0", + Name: "movie one", + Path: "/movie one", + }) + insertTestFile(t, fixture.store.db, testFileRow{ + FileID: "f2", + ShareCode: "sw1", + ParentID: "0", + Name: "movie two", + Path: "/movie two", + }) + if err := fixture.store.RefreshShares(context.Background()); err != nil { + t.Fatalf("RefreshShares() error = %v", err) + } + + items, total, err := fixture.searcher.Search(context.Background(), SearchRequest{ + Query: "movie", + Page: 2, + PerPage: 1, + }) + if err != nil { + t.Fatalf("Search() error = %v", err) + } + if total != 2 { + t.Fatalf("expected resolved total 2, got %d", total) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if items[0].FileID != "f2" { + t.Fatalf("expected backfilled second page to return f2, got %+v", items[0]) + } +} + +type searchFixture struct { + store *Store + searcher *Searcher + index bleve.Index +} + +func newSearchFixture(t *testing.T) *searchFixture { + t.Helper() + + rootDir := t.TempDir() + dbPath := filepath.Join(rootDir, "index.db") + store := openTestStore(t, dbPath) + + indexPath := filepath.Join(rootDir, "bleve") + index, err := bleve.New(indexPath, bleve.NewIndexMapping()) + if err != nil { + t.Fatalf("bleve.New() error = %v", err) + } + t.Cleanup(func() { _ = index.Close() }) + t.Cleanup(func() { _ = os.RemoveAll(indexPath) }) + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + return &searchFixture{ + store: store, + searcher: &Searcher{store: store, index: index}, + index: index, + } +} + +func (f *searchFixture) indexDoc(t *testing.T, id string, doc map[string]any) { + t.Helper() + if err := f.index.Index(id, doc); err != nil { + t.Fatalf("index.Index(%q) error = %v", id, err) + } +} diff --git a/internal/index115/service.go b/internal/index115/service.go new file mode 100644 index 00000000..fed8a12e --- /dev/null +++ b/internal/index115/service.go @@ -0,0 +1,114 @@ +package index115 + +import ( + "context" + "errors" + "fmt" + "strings" +) + +var ( + ErrEmptyQuery = errors.New("query cannot be empty") + ErrMissingLinkArg = errors.New("cookie, share_code and file_id are required") + ErrFileNotFound = errors.New("file not found") + ErrDirectoryLink = errors.New("cannot link directory") + ErrSearchUnavailable = errors.New("search unavailable") + ErrStoreUnavailable = errors.New("index115 store unavailable") + ErrInvalidCookie = errors.New("invalid or expired 115 cookie") + ErrLinkResolveFailed = errors.New("failed to resolve 115 link") +) + +type StoreReader interface { + ListShares(ctx context.Context) ([]ShareSummary, error) + ListChildren(ctx context.Context, shareCode, parentID string) ([]FileItem, error) + FileByID(ctx context.Context, fileID string) (FileItem, bool, error) +} + +type SearchReader interface { + Search(ctx context.Context, req SearchRequest) ([]FileItem, int, error) +} + +type Linker interface { + Resolve(ctx context.Context, req LinkRequest, file FileItem) (ResolvedLink, error) +} + +type Service struct { + store StoreReader + search SearchReader + linker Linker +} + +func NewService(store StoreReader, search SearchReader, linker Linker) *Service { + return &Service{ + store: store, + search: search, + linker: linker, + } +} + +func (s *Service) Browse(ctx context.Context, req BrowseRequest) ([]FileItem, error) { + if req.ShareCode == "" { + shares, err := s.store.ListShares(ctx) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrStoreUnavailable, err) + } + items := make([]FileItem, 0, len(shares)) + for _, share := range shares { + name := share.ShareTitle + if name == "" { + name = share.ShareCode + } + items = append(items, FileItem{ + ShareCode: share.ShareCode, + ReceiveCode: share.ReceiveCode, + ShareTitle: share.ShareTitle, + Name: name, + Path: "/" + name, + IsDir: true, + UpdatedAt: share.UpdatedAt, + }) + } + return items, nil + } + + parentID := req.ParentID + if parentID == "" { + parentID = "0" + } + items, err := s.store.ListChildren(ctx, req.ShareCode, parentID) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrStoreUnavailable, err) + } + return items, nil +} + +func (s *Service) Search(ctx context.Context, req SearchRequest) ([]FileItem, int, error) { + if strings.TrimSpace(req.Query) == "" { + return nil, 0, ErrEmptyQuery + } + if s.search == nil { + return nil, 0, ErrSearchUnavailable + } + items, total, err := s.search.Search(ctx, req) + if err != nil { + return nil, 0, fmt.Errorf("%w: %v", ErrSearchUnavailable, err) + } + return items, total, nil +} + +func (s *Service) Link(ctx context.Context, req LinkRequest) (ResolvedLink, error) { + if req.Cookie == "" || req.ShareCode == "" || req.FileID == "" { + return ResolvedLink{}, ErrMissingLinkArg + } + file, ok, err := s.store.FileByID(ctx, req.FileID) + if err != nil { + return ResolvedLink{}, fmt.Errorf("%w: %v", ErrStoreUnavailable, err) + } + if !ok || file.ShareCode != req.ShareCode { + return ResolvedLink{}, ErrFileNotFound + } + if file.IsDir { + return ResolvedLink{}, ErrDirectoryLink + } + return s.linker.Resolve(ctx, req, file) +} diff --git a/internal/index115/service_test.go b/internal/index115/service_test.go new file mode 100644 index 00000000..8a1889e7 --- /dev/null +++ b/internal/index115/service_test.go @@ -0,0 +1,119 @@ +package index115 + +import ( + "context" + "errors" + "testing" +) + +func TestServiceBrowseRootReturnsShares(t *testing.T) { + svc := &Service{ + store: stubStore{ + shares: []ShareSummary{{ShareCode: "sw1", ShareTitle: "S1", ReceiveCode: "rc1"}}, + }, + } + + items, err := svc.Browse(context.Background(), BrowseRequest{}) + if err != nil { + t.Fatalf("Browse() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if items[0].ShareCode != "sw1" || items[0].Name != "S1" || !items[0].IsDir { + t.Fatalf("unexpected root item: %+v", items[0]) + } +} + +func TestServiceSearchRejectsEmptyQuery(t *testing.T) { + svc := &Service{} + _, _, err := svc.Search(context.Background(), SearchRequest{}) + if err == nil { + t.Fatal("expected error for empty query") + } +} + +func TestServiceSearchReturnsUnavailableWhenSearcherMissing(t *testing.T) { + svc := &Service{} + _, _, err := svc.Search(context.Background(), SearchRequest{Query: "movie"}) + if !errors.Is(err, ErrSearchUnavailable) { + t.Fatalf("expected ErrSearchUnavailable, got %v", err) + } +} + +func TestServiceLinkRejectsDirectory(t *testing.T) { + svc := &Service{ + store: stubStore{ + file: FileItem{FileID: "dir1", ShareCode: "sw1", IsDir: true}, + ok: true, + }, + linker: &LinkResolver{client: &fakeShareDownloadClient{}}, + } + + _, err := svc.Link(context.Background(), LinkRequest{ + Cookie: "cookie", + ShareCode: "sw1", + FileID: "dir1", + }) + if err == nil { + t.Fatal("expected directory link error") + } +} + +type stubStore struct { + shares []ShareSummary + items []FileItem + file FileItem + ok bool + err error +} + +func (s stubStore) ListShares(ctx context.Context) ([]ShareSummary, error) { + return s.shares, s.err +} + +func (s stubStore) ListChildren(ctx context.Context, shareCode, parentID string) ([]FileItem, error) { + return s.items, s.err +} + +func (s stubStore) FileByID(ctx context.Context, fileID string) (FileItem, bool, error) { + return s.file, s.ok, s.err +} + +type stubSearcher struct { + items []FileItem + total int + err error +} + +func (s stubSearcher) Search(ctx context.Context, req SearchRequest) ([]FileItem, int, error) { + return s.items, s.total, s.err +} + +type stubResolver struct { + link ResolvedLink + err error +} + +func (s stubResolver) Resolve(ctx context.Context, req LinkRequest, file FileItem) (ResolvedLink, error) { + if s.err != nil { + return ResolvedLink{}, s.err + } + return s.link, nil +} + +func TestServiceLinkRejectsMissingFile(t *testing.T) { + svc := &Service{ + store: stubStore{ok: false}, + linker: stubResolver{}, + } + + _, err := svc.Link(context.Background(), LinkRequest{ + Cookie: "cookie", + ShareCode: "sw1", + FileID: "missing", + }) + if !errors.Is(err, ErrFileNotFound) { + t.Fatalf("expected ErrFileNotFound, got %v", err) + } +} diff --git a/internal/index115/store.go b/internal/index115/store.go new file mode 100644 index 00000000..03505c68 --- /dev/null +++ b/internal/index115/store.go @@ -0,0 +1,210 @@ +package index115 + +import ( + "context" + "database/sql" + "fmt" + "sort" + "strings" +) + +type shareMeta struct { + ShareCode string + ReceiveCode string + ShareTitle string + Status string + LastCrawledAt int64 + ID int64 +} + +type Store struct { + db *sql.DB + shares map[string]shareMeta +} + +func OpenStore(db *sql.DB) *Store { + return &Store{ + db: db, + shares: map[string]shareMeta{}, + } +} + +func (s *Store) RefreshShares(ctx context.Context) error { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, share_code, COALESCE(receive_code, ''), COALESCE(share_title, ''), status, COALESCE(last_crawled_at, 0) + FROM share`) + if err != nil { + return err + } + defer rows.Close() + + shares := map[string]shareMeta{} + for rows.Next() { + var meta shareMeta + if err := rows.Scan(&meta.ID, &meta.ShareCode, &meta.ReceiveCode, &meta.ShareTitle, &meta.Status, &meta.LastCrawledAt); err != nil { + return err + } + current, ok := shares[meta.ShareCode] + if !ok || preferShareMeta(meta, current) { + shares[meta.ShareCode] = meta + } + } + if err := rows.Err(); err != nil { + return err + } + s.shares = shares + return nil +} + +func preferShareMeta(next, current shareMeta) bool { + if next.Status == "ACTIVE" && current.Status != "ACTIVE" { + return true + } + if next.Status != "ACTIVE" && current.Status == "ACTIVE" { + return false + } + if next.LastCrawledAt != current.LastCrawledAt { + return next.LastCrawledAt > current.LastCrawledAt + } + return next.ID > current.ID +} + +func (s *Store) ListShares(ctx context.Context) ([]ShareSummary, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT share_code, + MAX(COALESCE(updated_at, 0)) AS updated_at, + SUM(CASE WHEN is_dir = 0 THEN 1 ELSE 0 END) AS file_count, + SUM(CASE WHEN is_dir = 1 THEN 1 ELSE 0 END) AS dir_count + FROM file + GROUP BY share_code`) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []ShareSummary + for rows.Next() { + var item ShareSummary + if err := rows.Scan(&item.ShareCode, &item.UpdatedAt, &item.FileCount, &item.DirCount); err != nil { + return nil, err + } + meta := s.shares[item.ShareCode] + item.ReceiveCode = meta.ReceiveCode + item.ShareTitle = meta.ShareTitle + if item.ShareTitle == "" { + item.ShareTitle = item.ShareCode + } + item.Path = "/" + item.IsDir = true + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + sort.Slice(items, func(i, j int) bool { + return items[i].ShareCode < items[j].ShareCode + }) + return items, nil +} + +func (s *Store) ListChildren(ctx context.Context, shareCode, parentID string) ([]FileItem, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT file_id, share_code, parent_id, name, path, size, is_dir, ext, sha1, COALESCE(updated_at, 0) + FROM file + WHERE share_code = ? AND parent_id = ? + ORDER BY is_dir DESC, name ASC`, shareCode, parentID) + if err != nil { + return nil, err + } + defer rows.Close() + + meta := s.shares[shareCode] + var items []FileItem + for rows.Next() { + item, err := scanFileItem(rows) + if err != nil { + return nil, err + } + applyShareMeta(&item, meta) + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Store) FileByID(ctx context.Context, fileID string) (FileItem, bool, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT file_id, share_code, parent_id, name, path, size, is_dir, ext, sha1, COALESCE(updated_at, 0) + FROM file + WHERE file_id = ?`, fileID) + + var item FileItem + var isDir int + err := row.Scan(&item.FileID, &item.ShareCode, &item.ParentID, &item.Name, &item.Path, &item.Size, &isDir, &item.Ext, &item.SHA1, &item.UpdatedAt) + if err == sql.ErrNoRows { + return FileItem{}, false, nil + } + if err != nil { + return FileItem{}, false, err + } + item.IsDir = isDir == 1 + applyShareMeta(&item, s.shares[item.ShareCode]) + return item, true, nil +} + +func (s *Store) FilesByIDs(ctx context.Context, ids []string) (map[string]FileItem, error) { + if len(ids) == 0 { + return map[string]FileItem{}, nil + } + + placeholders := make([]string, 0, len(ids)) + args := make([]any, 0, len(ids)) + for _, id := range ids { + placeholders = append(placeholders, "?") + args = append(args, id) + } + + query := fmt.Sprintf(` + SELECT file_id, share_code, parent_id, name, path, size, is_dir, ext, sha1, COALESCE(updated_at, 0) + FROM file + WHERE file_id IN (%s)`, strings.Join(placeholders, ",")) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + items := make(map[string]FileItem, len(ids)) + for rows.Next() { + item, err := scanFileItem(rows) + if err != nil { + return nil, err + } + applyShareMeta(&item, s.shares[item.ShareCode]) + items[item.FileID] = item + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +func scanFileItem(scanner interface { + Scan(dest ...any) error +}) (FileItem, error) { + var item FileItem + var isDir int + err := scanner.Scan(&item.FileID, &item.ShareCode, &item.ParentID, &item.Name, &item.Path, &item.Size, &isDir, &item.Ext, &item.SHA1, &item.UpdatedAt) + if err != nil { + return FileItem{}, err + } + item.IsDir = isDir == 1 + return item, nil +} + +func applyShareMeta(item *FileItem, meta shareMeta) { + item.ReceiveCode = meta.ReceiveCode + item.ShareTitle = meta.ShareTitle + if item.ShareTitle == "" { + item.ShareTitle = item.ShareCode + } +} diff --git a/internal/index115/store_test.go b/internal/index115/store_test.go new file mode 100644 index 00000000..200352ae --- /dev/null +++ b/internal/index115/store_test.go @@ -0,0 +1,228 @@ +package index115 + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + + _ "github.com/glebarez/go-sqlite" +) + +type testShareRow struct { + ShareCode string + ReceiveCode string + ShareTitle string + Status string + LastCrawledAt int64 +} + +type testFileRow struct { + FileID string + ShareCode string + ParentID string + Name string + Path string + Ext string + Size int64 + IsDir bool + SHA1 string + UpdatedAt int64 +} + +func TestStoreListSharesAggregatesByShareCode(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "index.db") + store := openTestStore(t, dbPath) + + insertTestShare(t, store.db, testShareRow{ + ShareCode: "sw1", + ReceiveCode: "rc1", + ShareTitle: "Share One", + Status: "ACTIVE", + LastCrawledAt: 10, + }) + insertTestFile(t, store.db, testFileRow{ + FileID: "dir1", + ShareCode: "sw1", + ParentID: "0", + Name: "RootDir", + Path: "/RootDir", + IsDir: true, + UpdatedAt: 100, + }) + insertTestFile(t, store.db, testFileRow{ + FileID: "file1", + ShareCode: "sw1", + ParentID: "0", + Name: "movie.mkv", + Path: "/movie.mkv", + Ext: ".mkv", + Size: 1024, + IsDir: false, + UpdatedAt: 200, + }) + + if err := store.RefreshShares(context.Background()); err != nil { + t.Fatalf("RefreshShares() error = %v", err) + } + + items, err := store.ListShares(context.Background()) + if err != nil { + t.Fatalf("ListShares() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 share, got %d", len(items)) + } + if items[0].ShareCode != "sw1" || items[0].ReceiveCode != "rc1" || items[0].ShareTitle != "Share One" { + t.Fatalf("unexpected share item: %+v", items[0]) + } + if items[0].FileCount != 1 || items[0].DirCount != 1 || items[0].UpdatedAt != 200 { + t.Fatalf("unexpected aggregate counts: %+v", items[0]) + } +} + +func TestStoreListChildrenUsesShareFallbackMetadata(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "index.db") + store := openTestStore(t, dbPath) + + insertTestShare(t, store.db, testShareRow{ + ShareCode: "sw2", + ReceiveCode: "", + ShareTitle: "", + Status: "ACTIVE", + LastCrawledAt: 5, + }) + insertTestFile(t, store.db, testFileRow{ + FileID: "dir2", + ShareCode: "sw2", + ParentID: "0", + Name: "Folder", + Path: "/Folder", + IsDir: true, + UpdatedAt: 100, + }) + + if err := store.RefreshShares(context.Background()); err != nil { + t.Fatalf("RefreshShares() error = %v", err) + } + + items, err := store.ListChildren(context.Background(), "sw2", "0") + if err != nil { + t.Fatalf("ListChildren() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 child, got %d", len(items)) + } + if items[0].ReceiveCode != "" || items[0].ShareTitle != "sw2" { + t.Fatalf("expected share fallback metadata, got %+v", items[0]) + } +} + +func TestStoreFileByIDFindsFile(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "index.db") + store := openTestStore(t, dbPath) + + insertTestShare(t, store.db, testShareRow{ + ShareCode: "sw3", + ReceiveCode: "rc3", + ShareTitle: "Share Three", + Status: "ACTIVE", + LastCrawledAt: 7, + }) + insertTestFile(t, store.db, testFileRow{ + FileID: "file3", + ShareCode: "sw3", + ParentID: "0", + Name: "ep1.mp4", + Path: "/ep1.mp4", + Ext: ".mp4", + Size: 300, + IsDir: false, + SHA1: "sha1-3", + UpdatedAt: 123, + }) + + if err := store.RefreshShares(context.Background()); err != nil { + t.Fatalf("RefreshShares() error = %v", err) + } + + file, ok, err := store.FileByID(context.Background(), "file3") + if err != nil { + t.Fatalf("FileByID() error = %v", err) + } + if !ok { + t.Fatal("expected file to exist") + } + if file.FileID != "file3" || file.ShareCode != "sw3" || file.ReceiveCode != "rc3" { + t.Fatalf("unexpected file result: %+v", file) + } +} + +func openTestStore(t *testing.T, dbPath string) *Store { + t.Helper() + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + stmts := []string{ + `CREATE TABLE share ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + share_code TEXT NOT NULL, + receive_code TEXT NOT NULL DEFAULT '', + share_title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'ACTIVE', + last_crawled_at INTEGER NOT NULL DEFAULT 0 + );`, + `CREATE TABLE file ( + file_id TEXT PRIMARY KEY, + share_code TEXT NOT NULL, + parent_id TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + ext TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + is_dir INTEGER NOT NULL DEFAULT 0, + depth INTEGER NOT NULL DEFAULT 0, + sha1 TEXT NOT NULL DEFAULT '', + updated_at INTEGER, + crawled_at INTEGER NOT NULL DEFAULT 0 + );`, + } + for _, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("db.Exec(%q) error = %v", stmt, err) + } + } + + return &Store{db: db} +} + +func insertTestShare(t *testing.T, db *sql.DB, row testShareRow) { + t.Helper() + _, err := db.Exec( + `INSERT INTO share(share_code, receive_code, share_title, status, last_crawled_at) VALUES (?, ?, ?, ?, ?)`, + row.ShareCode, row.ReceiveCode, row.ShareTitle, row.Status, row.LastCrawledAt, + ) + if err != nil { + t.Fatalf("insert share error = %v", err) + } +} + +func insertTestFile(t *testing.T, db *sql.DB, row testFileRow) { + t.Helper() + isDir := 0 + if row.IsDir { + isDir = 1 + } + _, err := db.Exec( + `INSERT INTO file(file_id, share_code, parent_id, name, path, ext, size, is_dir, depth, sha1, updated_at, crawled_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 0)`, + row.FileID, row.ShareCode, row.ParentID, row.Name, row.Path, row.Ext, row.Size, isDir, row.SHA1, row.UpdatedAt, + ) + if err != nil { + t.Fatalf("insert file error = %v", err) + } +} diff --git a/server/handles/index115.go b/server/handles/index115.go new file mode 100644 index 00000000..a3eb6522 --- /dev/null +++ b/server/handles/index115.go @@ -0,0 +1,132 @@ +package handles + +import ( + "context" + "errors" + "strconv" + + "github.com/OpenListTeam/OpenList/v4/internal/index115" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/gin-gonic/gin" +) + +type index115HTTPService interface { + Browse(ctx context.Context, req index115.BrowseRequest) ([]index115.FileItem, error) + Search(ctx context.Context, req index115.SearchRequest) ([]index115.FileItem, int, error) + Link(ctx context.Context, req index115.LinkRequest) (index115.ResolvedLink, error) +} + +var index115Service index115HTTPService + +func SetIndex115Service(service index115HTTPService) { + index115Service = service +} + +func Index115Browse(c *gin.Context) { + if index115Service == nil { + common.ErrorStrResp(c, "index115 service not initialized", 503) + return + } + + items, err := index115Service.Browse(c.Request.Context(), index115.BrowseRequest{ + ShareCode: c.Query("share_code"), + ReceiveCode: c.Query("receive_code"), + ParentID: c.Query("parent_id"), + }) + if err != nil { + common.ErrorResp(c, err, index115BrowseErrorCode(err)) + return + } + common.SuccessResp(c, items) +} + +func Index115Search(c *gin.Context) { + if index115Service == nil { + common.ErrorStrResp(c, "index115 service not initialized", 503) + return + } + + items, total, err := index115Service.Search(c.Request.Context(), index115.SearchRequest{ + Query: c.Query("q"), + Page: parseInt(c.Query("page"), 1), + PerPage: parseInt(c.Query("per_page"), 20), + ShareCode: c.Query("share_code"), + }) + if err != nil { + common.ErrorResp(c, err, index115SearchErrorCode(err)) + return + } + common.SuccessResp(c, gin.H{ + "query": c.Query("q"), + "total": total, + "items": items, + }) +} + +func Index115Link(c *gin.Context) { + if index115Service == nil { + common.ErrorStrResp(c, "index115 service not initialized", 503) + return + } + + var req index115.LinkRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ErrorResp(c, err, 400) + return + } + link, err := index115Service.Link(c.Request.Context(), req) + if err != nil { + common.ErrorResp(c, err, index115LinkErrorCode(err)) + return + } + common.SuccessResp(c, gin.H{ + "url": link.URL, + "expired_in": link.ExpiredIn, + }) +} + +func parseInt(raw string, fallback int) int { + if raw == "" { + return fallback + } + value, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return value +} + +func index115BrowseErrorCode(err error) int { + if errors.Is(err, index115.ErrStoreUnavailable) { + return 503 + } + return 503 +} + +func index115SearchErrorCode(err error) int { + switch { + case errors.Is(err, index115.ErrEmptyQuery): + return 400 + case errors.Is(err, index115.ErrSearchUnavailable): + return 503 + default: + return 503 + } +} + +func index115LinkErrorCode(err error) int { + switch { + case errors.Is(err, index115.ErrMissingLinkArg), errors.Is(err, index115.ErrDirectoryLink): + return 400 + case errors.Is(err, index115.ErrFileNotFound): + return 404 + case errors.Is(err, index115.ErrInvalidCookie): + return 401 + case errors.Is(err, index115.ErrStoreUnavailable), errors.Is(err, index115.ErrLinkClientNotConfigured): + return 503 + case errors.Is(err, index115.ErrLinkResolveFailed): + return 502 + default: + return 502 + } +} diff --git a/server/handles/index115_test.go b/server/handles/index115_test.go new file mode 100644 index 00000000..06aab116 --- /dev/null +++ b/server/handles/index115_test.go @@ -0,0 +1,167 @@ +package handles + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/index115" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/gin-gonic/gin" +) + +func TestIndex115SearchRejectsEmptyQuery(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + index115Service = stubIndex115HTTPService{} + router.GET("/index115/search", Index115Search) + + req := httptest.NewRequest(http.MethodGet, "/index115/search?q=", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var resp common.Resp[any] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if resp.Code != 400 { + t.Fatalf("expected response code 400, got %+v", resp) + } +} + +func TestIndex115BrowseRootReturnsSuccess(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + index115Service = stubIndex115HTTPService{ + browseItems: []index115.FileItem{{ShareCode: "sw1", ShareTitle: "S1", Name: "S1", IsDir: true}}, + } + router.GET("/index115/browse", Index115Browse) + + req := httptest.NewRequest(http.MethodGet, "/index115/browse", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var resp common.Resp[[]index115.FileItem] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if resp.Code != 200 { + t.Fatalf("expected response code 200, got %+v", resp) + } + if len(resp.Data) != 1 || resp.Data[0].ShareCode != "sw1" { + t.Fatalf("unexpected data: %+v", resp.Data) + } +} + +func TestIndex115LinkBindsRequestBody(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + index115Service = stubIndex115HTTPService{ + link: index115.ResolvedLink{URL: "https://example.com/play", ExpiredIn: 14400}, + } + router.POST("/index115/link", Index115Link) + + body := `{"cookie":"UID=1;CID=2","share_code":"sw1","file_id":"file1"}` + req := httptest.NewRequest(http.MethodPost, "/index115/link", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var resp common.Resp[map[string]any] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if resp.Code != 200 { + t.Fatalf("expected response code 200, got %+v", resp) + } + if resp.Data["url"] != "https://example.com/play" { + t.Fatalf("unexpected link payload: %+v", resp.Data) + } +} + +func TestIndex115SearchReturns503WhenSearcherUnavailable(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + index115Service = stubIndex115HTTPService{err: index115.ErrSearchUnavailable} + router.GET("/index115/search", Index115Search) + + req := httptest.NewRequest(http.MethodGet, "/index115/search?q=movie", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var resp common.Resp[any] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if resp.Code != 503 { + t.Fatalf("expected response code 503, got %+v", resp) + } +} + +func TestIndex115LinkReturns404ForMissingFile(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + index115Service = stubIndex115HTTPService{err: index115.ErrFileNotFound} + router.POST("/index115/link", Index115Link) + + body := `{"cookie":"UID=1;CID=2","share_code":"sw1","file_id":"missing"}` + req := httptest.NewRequest(http.MethodPost, "/index115/link", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var resp common.Resp[map[string]any] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if resp.Code != 404 { + t.Fatalf("expected response code 404, got %+v", resp) + } +} + +func TestIndex115LinkReturns401ForInvalidCookie(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + index115Service = stubIndex115HTTPService{err: index115.ErrInvalidCookie} + router.POST("/index115/link", Index115Link) + + body := `{"cookie":"bad","share_code":"sw1","file_id":"file1"}` + req := httptest.NewRequest(http.MethodPost, "/index115/link", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var resp common.Resp[map[string]any] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if resp.Code != 401 { + t.Fatalf("expected response code 401, got %+v", resp) + } +} + +type stubIndex115HTTPService struct { + browseItems []index115.FileItem + searchItems []index115.FileItem + searchTotal int + link index115.ResolvedLink + err error +} + +func (s stubIndex115HTTPService) Browse(ctx context.Context, req index115.BrowseRequest) ([]index115.FileItem, error) { + return s.browseItems, s.err +} + +func (s stubIndex115HTTPService) Search(ctx context.Context, req index115.SearchRequest) ([]index115.FileItem, int, error) { + if strings.TrimSpace(req.Query) == "" { + return nil, 0, index115.ErrEmptyQuery + } + return s.searchItems, s.searchTotal, s.err +} + +func (s stubIndex115HTTPService) Link(ctx context.Context, req index115.LinkRequest) (index115.ResolvedLink, error) { + return s.link, s.err +} diff --git a/server/index115_webdav.go b/server/index115_webdav.go new file mode 100644 index 00000000..cb2528c2 --- /dev/null +++ b/server/index115_webdav.go @@ -0,0 +1,371 @@ +package server + +import ( + "bytes" + "context" + "crypto/subtle" + "errors" + "io" + "net/http" + "os" + "path" + "sort" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/index115" + "github.com/OpenListTeam/OpenList/v4/internal/setting" + xwebdav "golang.org/x/net/webdav" + + "github.com/gin-gonic/gin" +) + +type index115BrowseProvider interface { + Browse(ctx context.Context, req index115.BrowseRequest) ([]index115.FileItem, error) +} + +var index115BrowseService index115BrowseProvider +var index115DAVHandler *xwebdav.Handler + +func SetIndex115BrowseService(service index115BrowseProvider) { + index115BrowseService = service +} + +func WebDavIndex115(dav *gin.RouterGroup) { + handler := getIndex115DAVHandler() + dav.Use(index115WebDAVAuth, index115WebDAVReadOnly) + dav.Any("/*path", func(c *gin.Context) { + handler.ServeHTTP(c.Writer, c.Request) + }) + dav.Any("", func(c *gin.Context) { + handler.ServeHTTP(c.Writer, c.Request) + }) + dav.Handle("PROPFIND", "/*path", func(c *gin.Context) { + handler.ServeHTTP(c.Writer, c.Request) + }) + dav.Handle("PROPFIND", "", func(c *gin.Context) { + handler.ServeHTTP(c.Writer, c.Request) + }) +} + +func getIndex115DAVHandler() *xwebdav.Handler { + if index115DAVHandler != nil { + return index115DAVHandler + } + index115DAVHandler = &xwebdav.Handler{ + Prefix: index115WebDAVPrefix(), + FileSystem: &index115WebDAVFS{}, + LockSystem: xwebdav.NewMemLS(), + } + return index115DAVHandler +} + +func index115WebDAVPrefix() string { + if conf.URL != nil { + return path.Join(conf.URL.Path, "/dav/index115") + } + return "/dav/index115" +} + +func isIndex115WebDAVPath(p string) bool { + prefix := index115WebDAVPrefix() + return p == prefix || strings.HasPrefix(p, prefix+"/") +} + +func index115WebDAVAuth(c *gin.Context) { + if c.Request.Method == http.MethodOptions { + c.Next() + return + } + token := setting.GetStr(conf.Token) + if token == "" { + c.Next() + return + } + auth := c.GetHeader("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + auth = strings.TrimPrefix(auth, "Bearer ") + if subtle.ConstantTimeCompare([]byte(auth), []byte(token)) == 1 { + c.Next() + return + } + } + c.Writer.Header()["WWW-Authenticate"] = []string{`Bearer realm="openlist-index115"`} + c.AbortWithStatus(http.StatusUnauthorized) +} + +func index115WebDAVReadOnly(c *gin.Context) { + switch c.Request.Method { + case http.MethodOptions, http.MethodGet, http.MethodHead, "PROPFIND": + c.Next() + default: + c.AbortWithStatus(http.StatusForbidden) + } +} + +type index115WebDAVFS struct{} + +func (fs *index115WebDAVFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error { + return os.ErrPermission +} + +func (fs *index115WebDAVFS) RemoveAll(ctx context.Context, name string) error { + return os.ErrPermission +} + +func (fs *index115WebDAVFS) Rename(ctx context.Context, oldName, newName string) error { + return os.ErrPermission +} + +func (fs *index115WebDAVFS) Stat(ctx context.Context, name string) (os.FileInfo, error) { + entry, err := fs.resolve(ctx, name) + if err != nil { + return nil, err + } + return entry.info, nil +} + +func (fs *index115WebDAVFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (xwebdav.File, error) { + if flag&(os.O_WRONLY|os.O_RDWR|os.O_APPEND|os.O_CREATE|os.O_TRUNC) != 0 { + return nil, os.ErrPermission + } + entry, err := fs.resolve(ctx, name) + if err != nil { + return nil, err + } + if entry.info.IsDir() { + return &index115WebDAVFile{ + info: entry.info, + children: entry.children, + dirPos: 0, + reader: bytes.NewReader(nil), + }, nil + } + return &index115WebDAVFile{ + info: entry.info, + reader: bytes.NewReader(nil), + }, nil +} + +func (fs *index115WebDAVFS) resolve(ctx context.Context, name string) (*index115ResolvedEntry, error) { + if index115BrowseService == nil { + return nil, os.ErrNotExist + } + clean := cleanWebDAVPath(name) + if clean == "/" { + items, err := index115BrowseService.Browse(ctx, index115.BrowseRequest{}) + if err != nil { + return nil, err + } + children := make([]os.FileInfo, 0, len(items)) + for _, item := range items { + children = append(children, newIndex115FileInfo(item, true)) + } + sort.Slice(children, func(i, j int) bool { + return children[i].Name() < children[j].Name() + }) + return &index115ResolvedEntry{ + info: newVirtualDirInfo("", "/", time.Now()), + children: children, + }, nil + } + + parts := splitWebDAVPath(clean) + rootItems, err := index115BrowseService.Browse(ctx, index115.BrowseRequest{}) + if err != nil { + return nil, err + } + var current index115.FileItem + found := false + for _, item := range rootItems { + if item.Name == parts[0] { + current = item + found = true + break + } + } + if !found { + return nil, os.ErrNotExist + } + if len(parts) == 1 { + children, err := fs.childInfos(ctx, current.ShareCode, "0") + if err != nil { + return nil, err + } + return &index115ResolvedEntry{ + info: newIndex115FileInfo(current, true), + children: children, + }, nil + } + + parentID := "0" + var currentInfo os.FileInfo = newIndex115FileInfo(current, true) + for idx := 1; idx < len(parts); idx++ { + items, err := index115BrowseService.Browse(ctx, index115.BrowseRequest{ + ShareCode: current.ShareCode, + ParentID: parentID, + }) + if err != nil { + return nil, err + } + match := index115.FileItem{} + found = false + for _, item := range items { + if item.Name == parts[idx] { + match = item + found = true + break + } + } + if !found { + return nil, os.ErrNotExist + } + currentInfo = newIndex115FileInfo(match, idx == 1) + if idx == len(parts)-1 { + children := []os.FileInfo(nil) + if match.IsDir { + children, err = fs.childInfos(ctx, current.ShareCode, match.FileID) + if err != nil { + return nil, err + } + } + return &index115ResolvedEntry{ + info: currentInfo, + children: children, + }, nil + } + parentID = match.FileID + } + return nil, os.ErrNotExist +} + +func (fs *index115WebDAVFS) childInfos(ctx context.Context, shareCode, parentID string) ([]os.FileInfo, error) { + items, err := index115BrowseService.Browse(ctx, index115.BrowseRequest{ + ShareCode: shareCode, + ParentID: parentID, + }) + if err != nil { + return nil, err + } + children := make([]os.FileInfo, 0, len(items)) + for _, item := range items { + children = append(children, newIndex115FileInfo(item, false)) + } + return children, nil +} + +type index115ResolvedEntry struct { + info os.FileInfo + children []os.FileInfo +} + +type index115WebDAVFile struct { + info os.FileInfo + children []os.FileInfo + dirPos int + reader *bytes.Reader +} + +func (f *index115WebDAVFile) Close() error { return nil } +func (f *index115WebDAVFile) Read(p []byte) (int, error) { + return f.reader.Read(p) +} +func (f *index115WebDAVFile) Seek(offset int64, whence int) (int64, error) { + return f.reader.Seek(offset, whence) +} +func (f *index115WebDAVFile) Readdir(count int) ([]os.FileInfo, error) { + if !f.info.IsDir() { + return nil, errors.New("not a directory") + } + if f.dirPos >= len(f.children) && count > 0 { + return nil, io.EOF + } + if count <= 0 { + result := make([]os.FileInfo, len(f.children)-f.dirPos) + copy(result, f.children[f.dirPos:]) + f.dirPos = len(f.children) + return result, nil + } + end := f.dirPos + count + if end > len(f.children) { + end = len(f.children) + } + result := make([]os.FileInfo, end-f.dirPos) + copy(result, f.children[f.dirPos:end]) + f.dirPos = end + return result, nil +} +func (f *index115WebDAVFile) Stat() (os.FileInfo, error) { return f.info, nil } +func (f *index115WebDAVFile) Write(p []byte) (int, error) { + return 0, os.ErrPermission +} + +type index115FileInfo struct { + name string + size int64 + mode os.FileMode + modTime time.Time + dir bool +} + +func newIndex115FileInfo(item index115.FileItem, isRoot bool) os.FileInfo { + mod := unixTimeOrNow(item.UpdatedAt) + name := item.Name + if isRoot && item.ShareTitle != "" { + name = item.ShareTitle + } + mode := os.FileMode(0o444) + if item.IsDir { + mode = os.ModeDir | 0o555 + } + return index115FileInfo{ + name: name, + size: item.Size, + mode: mode, + modTime: mod, + dir: item.IsDir, + } +} + +func newVirtualDirInfo(name, _ string, mod time.Time) os.FileInfo { + return index115FileInfo{ + name: name, + size: 0, + mode: os.ModeDir | 0o555, + modTime: mod, + dir: true, + } +} + +func (i index115FileInfo) Name() string { return i.name } +func (i index115FileInfo) Size() int64 { return i.size } +func (i index115FileInfo) Mode() os.FileMode { return i.mode } +func (i index115FileInfo) ModTime() time.Time { return i.modTime } +func (i index115FileInfo) IsDir() bool { return i.dir } +func (i index115FileInfo) Sys() any { return nil } + +func cleanWebDAVPath(name string) string { + if name == "" { + return "/" + } + clean := path.Clean("/" + strings.TrimPrefix(name, "/")) + if clean == "." { + return "/" + } + return clean +} + +func splitWebDAVPath(clean string) []string { + if clean == "/" { + return nil + } + return strings.Split(strings.TrimPrefix(clean, "/"), "/") +} + +func unixTimeOrNow(ts int64) time.Time { + if ts <= 0 { + return time.Now() + } + return time.Unix(ts, 0) +} diff --git a/server/index115_webdav_test.go b/server/index115_webdav_test.go new file mode 100644 index 00000000..842fb642 --- /dev/null +++ b/server/index115_webdav_test.go @@ -0,0 +1,94 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/index115" + "github.com/gin-gonic/gin" +) + +func TestIndex115WebDAVPropfindRootListsShares(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + index115BrowseService = stubIndex115WebDAVService{ + rootItems: []index115.FileItem{ + {ShareCode: "sw1", ShareTitle: "Share One", Name: "Share One", IsDir: true}, + }, + } + WebDav(router.Group("/dav")) + + req := httptest.NewRequest("PROPFIND", "/dav/index115", nil) + req.Header.Set("Depth", "1") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusMultiStatus { + t.Fatalf("expected 207, got %d body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "/dav/index115/Share%20One") { + t.Fatalf("expected share href in response, got %s", w.Body.String()) + } +} + +func TestIndex115WebDAVPropfindChildListsChildren(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + index115BrowseService = stubIndex115WebDAVService{ + rootItems: []index115.FileItem{ + {ShareCode: "sw1", ShareTitle: "Share One", Name: "Share One", IsDir: true}, + }, + childItems: map[string][]index115.FileItem{ + "sw1:0": { + {FileID: "dir1", ShareCode: "sw1", Name: "Folder", IsDir: true, ParentID: "0"}, + {FileID: "file1", ShareCode: "sw1", Name: "movie.mkv", IsDir: false, ParentID: "0", Size: 123}, + }, + }, + } + WebDav(router.Group("/dav")) + + req := httptest.NewRequest("PROPFIND", "/dav/index115/Share%20One", nil) + req.Header.Set("Depth", "1") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusMultiStatus { + t.Fatalf("expected 207, got %d body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "/dav/index115/Share%20One/Folder") { + t.Fatalf("expected folder href in response, got %s", w.Body.String()) + } + if !strings.Contains(w.Body.String(), "/dav/index115/Share%20One/movie.mkv") { + t.Fatalf("expected file href in response, got %s", w.Body.String()) + } +} + +func TestIndex115WebDAVDisablesMutations(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + index115BrowseService = stubIndex115WebDAVService{} + WebDav(router.Group("/dav")) + + req := httptest.NewRequest(http.MethodPut, "/dav/index115/Share%20One/new.txt", strings.NewReader("x")) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d body=%s", w.Code, w.Body.String()) + } +} + +type stubIndex115WebDAVService struct { + rootItems []index115.FileItem + childItems map[string][]index115.FileItem +} + +func (s stubIndex115WebDAVService) Browse(_ context.Context, req index115.BrowseRequest) ([]index115.FileItem, error) { + if req.ShareCode == "" { + return s.rootItems, nil + } + return s.childItems[req.ShareCode+":"+req.ParentID], nil +} diff --git a/server/router.go b/server/router.go index f46b0b1b..8d73ac51 100644 --- a/server/router.go +++ b/server/router.go @@ -102,6 +102,7 @@ func Init(e *gin.Engine) { public.Any("/archive_extensions", handles.ArchiveExtensions) _fs(auth.Group("/fs")) + _index115(auth.Group("/index115")) fsAndShare(api.Group("/fs", middlewares.Auth(true))) _task(auth.Group("/task", middlewares.AuthNotGuest)) _sharing(auth.Group("/share", middlewares.AuthNotGuest)) @@ -248,6 +249,12 @@ func _sharing(g *gin.RouterGroup) { g.POST("/disable", handles.SetEnableSharing(true)) } +func _index115(g *gin.RouterGroup) { + g.GET("/browse", handles.Index115Browse) + g.GET("/search", handles.Index115Search) + g.POST("/link", handles.Index115Link) +} + func Cors(r *gin.Engine) { config := cors.DefaultConfig() // config.AllowAllOrigins = true diff --git a/server/webdav.go b/server/webdav.go index a949068f..2e919b3c 100644 --- a/server/webdav.go +++ b/server/webdav.go @@ -21,8 +21,12 @@ import ( var handler *webdav.Handler func WebDav(dav *gin.RouterGroup) { + prefix := "/dav" + if conf.URL != nil { + prefix = path.Join(conf.URL.Path, "/dav") + } handler = &webdav.Handler{ - Prefix: path.Join(conf.URL.Path, "/dav"), + Prefix: prefix, LockSystem: webdav.NewMemLS(), Logger: func(request *http.Request, err error) { log.Errorf("%s %s %+v", request.Method, request.URL.Path, err) @@ -44,10 +48,61 @@ func WebDav(dav *gin.RouterGroup) { } func ServeWebDAV(c *gin.Context) { + if isIndex115WebDAVPath(c.Request.URL.Path) { + if !index115WebDAVAuthForServe(c) { + return + } + if !index115WebDAVReadOnlyForServe(c) { + return + } + getIndex115DAVHandler().ServeHTTP(c.Writer, c.Request) + return + } handler.ServeHTTP(c.Writer, c.Request) } +func index115WebDAVAuthForServe(c *gin.Context) bool { + if c.Request.Method == http.MethodOptions { + return true + } + if conf.Conf == nil { + return true + } + token := setting.GetStr(conf.Token) + if token == "" { + return true + } + auth := c.GetHeader("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + auth = strings.TrimPrefix(auth, "Bearer ") + if subtle.ConstantTimeCompare([]byte(auth), []byte(token)) == 1 { + return true + } + } + c.Writer.Header()["WWW-Authenticate"] = []string{`Bearer realm="openlist-index115"`} + c.Status(http.StatusUnauthorized) + c.Abort() + return false +} + +func index115WebDAVReadOnlyForServe(c *gin.Context) bool { + switch c.Request.Method { + case http.MethodOptions, http.MethodGet, http.MethodHead, "PROPFIND": + return true + default: + c.Status(http.StatusForbidden) + c.Abort() + return false + } +} + func WebDAVAuth(c *gin.Context) { + if isIndex115WebDAVPath(c.Request.URL.Path) { + if index115WebDAVAuthForServe(c) { + c.Next() + } + return + } // check count of login ip := c.ClientIP() guest, _ := op.GetGuest()