From e7cd78b1642f12d83301b83cece67c4930e79a1b Mon Sep 17 00:00:00 2001 From: MickaelK Date: Thu, 6 Nov 2025 15:09:33 +1100 Subject: [PATCH] feature (plg_backend_perkeep): support for perkeep --- server/plugin/index.go | 1 + server/plugin/plg_backend_perkeep/index.go | 333 +++++++++++++++++++++ server/plugin/plg_backend_perkeep/types.go | 36 +++ 3 files changed, 370 insertions(+) create mode 100644 server/plugin/plg_backend_perkeep/index.go create mode 100644 server/plugin/plg_backend_perkeep/types.go diff --git a/server/plugin/index.go b/server/plugin/index.go index 0bead7a6..53dbc6ad 100644 --- a/server/plugin/index.go +++ b/server/plugin/index.go @@ -20,6 +20,7 @@ import ( _ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_mysql" _ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_nfs" _ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_nop" + _ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_perkeep" _ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_psql" _ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_s3" _ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_samba" diff --git a/server/plugin/plg_backend_perkeep/index.go b/server/plugin/plg_backend_perkeep/index.go new file mode 100644 index 00000000..91eaae0c --- /dev/null +++ b/server/plugin/plg_backend_perkeep/index.go @@ -0,0 +1,333 @@ +package plg_backend_perkeep + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + + . "github.com/mickael-kerjean/filestash/server/common" +) + +func init() { + Backend.Register("perkeep", &Perkeep{}) +} + +type Perkeep struct { + serverURL string +} + +func (this Perkeep) Init(params map[string]string, app *App) (IBackend, error) { + url := params["url"] + if url == "" { + url = "http://localhost:3179/" + } + if !strings.HasSuffix(url, "/") { + url += "/" + } + return &Perkeep{ + serverURL: url, + }, nil +} + +func (this Perkeep) Meta(path string) Metadata { + return Metadata{ + CanCreateFile: NewBool(false), // TODO + CanCreateDirectory: NewBool(false), // TODO + CanRename: NewBool(false), // TODO + CanMove: NewBool(false), // TODO + CanUpload: NewBool(false), // TODO: see http://localhost:3179/bs-and-maybe-also-index/camli/upload + + CanDelete: NewBool(false), // TODO + } +} + +func (this Perkeep) LoginForm() Form { + return Form{ + Elmnts: []FormElement{ + { + Name: "type", + Type: "hidden", + Value: "perkeep", + }, + { + Name: "url", + Type: "text", + Placeholder: "eg: http://localhost:3179", + }, + }, + } +} + +func (this Perkeep) Ls(path string) ([]os.FileInfo, error) { + var files []os.FileInfo + + if path == "/" { + response, err := this.query(map[string]interface{}{ // curl 'http://localhost:3179/my-search/camli/search/query' -d '{"sort":"-created","constraint":{"permanode":{"attr": "camliRoot","valueMatches": {}}},"describe":{},"limit":-1}' + "sort": "-created", + "constraint": map[string]interface{}{ + "permanode": map[string]interface{}{ + "attr": "camliRoot", + "valueMatches": map[string]interface{}{}, + }, + }, + "describe": map[string]interface{}{}, + "limit": 1, + }) + if err != nil { + return nil, err + } + for _, blob := range response.Blobs { + if meta, ok := response.Description.Meta[blob.Blob]; ok { + if rootNames, ok := meta.Permanode.Attr["camliRoot"]; ok && len(rootNames) > 0 { + files = append(files, File{ + FName: rootNames[0], + FType: "directory", + FTime: meta.Permanode.ModTime.Unix(), + }) + } + } + } + return files, nil + } + + ref, err := this.getRef(path) + if err != nil { + return nil, err + } + + response, err := this.query(map[string]interface{}{ // curl 'http://localhost:3179/my-search/camli/search/query' -d '{"sort":"-created","constraint":{"permanode":{"relation":{"relation":"parent","any":{"blobRefPrefix":"sha224-ff8f64ab406dc5aec7a35bf182dee79ea20d41bfffc2311fcb4acd9f"}}}},"describe":{"rules":[{"attrs": ["camliContent"]}]},"limit":50}' + "sort": "-created", + "constraint": map[string]interface{}{ + "permanode": map[string]interface{}{ + "relation": map[string]interface{}{ + "relation": "parent", + "any": map[string]interface{}{ + "blobRefPrefix": ref, + }, + }, + }, + }, + "describe": map[string]interface{}{ + "rules": []map[string]interface{}{ + { + "attrs": []string{"camliContent", "title", "camliNodeType"}, + }, + }, + }, + "limit": 50, + }) + if err != nil { + return nil, err + } + for _, blob := range response.Blobs { + if meta, ok := response.Description.Meta[blob.Blob]; ok { + var ( + fileName string + fileType string + fileSize int64 + fileTime int64 = -1 + ) + if nodeType, ok := meta.Permanode.Attr["camliNodeType"]; ok && len(nodeType) > 0 && nodeType[0] == "directory" { + if titles, ok := meta.Permanode.Attr["title"]; ok && len(titles) > 0 { + fileType = "directory" + fileName = titles[0] + fileTime = meta.Permanode.ModTime.Unix() + } + } else if contentRefs, hasContent := meta.Permanode.Attr["camliContent"]; hasContent && len(contentRefs) > 0 { + contentRef := contentRefs[0] + if contentMeta, ok := response.Description.Meta[contentRef]; ok && contentMeta.File != nil { + fileType = "file" + fileName = contentMeta.File.FileName + fileTime = contentMeta.File.Time.Unix() + fileSize = contentMeta.File.Size + } + } + if fileName != "" && fileType != "" { + files = append(files, File{ + FName: fileName, + FType: fileType, + FSize: fileSize, + FTime: fileTime, + }) + } + } + } + return files, nil +} + +func (this Perkeep) Cat(path string) (io.ReadCloser, error) { + ref, err := this.getRef(path) + if err != nil { + return nil, err + } + response, err := this.describe(ref) + if err != nil { + return nil, err + } + contentRefs, hasContent := response.Meta[ref].Permanode.Attr["camliContent"] + if !hasContent || len(contentRefs) == 0 { + return nil, NewError("No content", 400) + } + resp, err := http.Get(this.serverURL + "ui/download/" + contentRefs[0]) + if err != nil { + return nil, NewError("Failed to fetch file: "+err.Error(), 500) + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, NewError("Failed to fetch file", resp.StatusCode) + } + return resp.Body, nil +} + +func (this Perkeep) Mkdir(path string) error { + return ErrNotImplemented +} + +func (this Perkeep) Rm(path string) error { + return ErrNotImplemented +} + +func (this Perkeep) Mv(from, to string) error { + return ErrNotImplemented +} + +func (this Perkeep) Save(path string, content io.Reader) error { + return ErrNotImplemented +} + +func (this Perkeep) Touch(path string) error { + return ErrNotImplemented +} + +func (this *Perkeep) query(searchRequest any) (*SearchResponse, error) { + queryJSON, err := json.Marshal(searchRequest) + if err != nil { + return nil, NewError("Failed to marshal search query: "+err.Error(), 500) + } + req, err := http.NewRequest( + "POST", + this.serverURL+"my-search/camli/search/query", + bytes.NewBuffer(queryJSON), + ) + if err != nil { + return nil, NewError("Failed to create request: "+err.Error(), 500) + } + resp, err := HTTPClient.Do(req) + if err != nil { + return nil, NewError("Failed to query perkeep: "+err.Error(), 500) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, NewError(fmt.Sprintf("Perkeep API error (%d): %s", resp.StatusCode, string(body)), 500) + } + var result SearchResponse + err = json.NewDecoder(resp.Body).Decode(&result) + return &result, err +} + +func (this *Perkeep) describe(blobRef string) (*DescribeResponse, error) { + describeJSON, err := json.Marshal(map[string]interface{}{ + "blobRef": blobRef, + }) + if err != nil { + return nil, NewError("Failed to marshal describe request: "+err.Error(), 500) + } + req, err := http.NewRequest( + "POST", + this.serverURL+"my-search/camli/search/describe", + bytes.NewBuffer(describeJSON), + ) + if err != nil { + return nil, NewError("Failed to create request: "+err.Error(), 500) + } + resp, err := HTTPClient.Do(req) + if err != nil { + return nil, NewError("Failed to describe perkeep: "+err.Error(), 500) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, NewError(fmt.Sprintf("Perkeep API error (%d): %s", resp.StatusCode, string(body)), 500) + } + var result DescribeResponse + err = json.NewDecoder(resp.Body).Decode(&result) + return &result, err +} + +func (this *Perkeep) getRef(path string) (string, error) { + path = strings.Trim(path, "/") + if path == "" { + return "", NewError("Empty path", 400) + } + pathChunks := strings.Split(path, "/") + response, err := this.query(map[string]interface{}{ + "constraint": map[string]interface{}{ + "permanode": map[string]interface{}{ + "attr": "camliRoot", + "value": pathChunks[0], + }, + }, + "describe": map[string]interface{}{}, + "limit": 1, + }) + if err != nil { + return "", err + } else if len(response.Blobs) == 0 { + return "", NewError("Root folder not found: "+pathChunks[0], 404) + } + currentRef := response.Blobs[0].Blob + for i := 1; i < len(pathChunks); i++ { + childResponse, err := this.query(map[string]interface{}{ + "constraint": map[string]interface{}{ + "permanode": map[string]interface{}{ + "relation": map[string]interface{}{ + "relation": "parent", + "any": map[string]interface{}{ + "blobRefPrefix": currentRef, + }, + }, + }, + }, + "describe": map[string]interface{}{ + "rules": []map[string]interface{}{ + { + "attrs": []string{"camliContent"}, + }, + }, + }, + "limit": -1, + }) + if err != nil { + return "", err + } + found := false + for _, blob := range childResponse.Blobs { + if meta, ok := childResponse.Description.Meta[blob.Blob]; ok { + if titles, ok := meta.Permanode.Attr["title"]; ok && len(titles) > 0 { + if titles[0] == pathChunks[i] { + currentRef = blob.Blob + found = true + break + } + } else if contentRefs, hasContent := meta.Permanode.Attr["camliContent"]; hasContent && len(contentRefs) > 0 { + if contentMeta, ok := childResponse.Description.Meta[contentRefs[0]]; ok && contentMeta.File != nil { + if contentMeta.File.FileName == pathChunks[i] { + currentRef = blob.Blob + found = true + break + } + } + } + } + } + if !found { + return "", NewError("Path element not found: "+pathChunks[i], 404) + } + } + return currentRef, nil +} diff --git a/server/plugin/plg_backend_perkeep/types.go b/server/plugin/plg_backend_perkeep/types.go new file mode 100644 index 00000000..2d823669 --- /dev/null +++ b/server/plugin/plg_backend_perkeep/types.go @@ -0,0 +1,36 @@ +package plg_backend_perkeep + +import ( + "time" +) + +type SearchResponse struct { + Blobs []struct { + Blob string `json:"blob"` + } `json:"blobs"` + Description struct { + Meta map[string]struct { + Permanode struct { + Attr map[string][]string `json:"attr"` + ModTime time.Time + } `json:"permanode"` + File *struct { + FileName string `json:"fileName"` + Size int64 `json:"size"` + Time time.Time `json:"time"` + WholeRef string `json:"wholeRef"` + } `json:"file"` + CamliType string `json:"camliType"` + } `json:"meta"` + } `json:"description"` +} + +type DescribeResponse struct { + Meta map[string]struct { + Permanode struct { + Attr map[string][]string `json:"attr"` + ModTime time.Time + } `json:"permanode"` + CamliType string `json:"camliType"` + } `json:"meta"` +}