diff --git a/README.md b/README.md index a4a4574..71e60b6 100644 --- a/README.md +++ b/README.md @@ -382,6 +382,26 @@ On error, the exact response may either be `text/plain` content containing the e or `application/json` content encoding a JSON object with the `reason` for the error. If the path does not exist in the index, a standard 404 error is returned. +### Identifying registered directories + +We can determine which directories are actually registered by making a GET request to the `/registered` endpoint of the SewerRat API. + +```shell +curl -L ${SEWER_RAT_URL}/registered | jq +``` + +On success, this returns an array of objects containing: + +- `path`, a string containing the path to the registered directory. +- `user`, the identity of the user who registered this directory. +- `time`, the Unix time of the registration. +- `names`, the base names of the metadata files to be indexed in this directory. + +This can be filtered on user by passing the `user=` query parameter in the request. + +On error, the response may either be `text/plain` content containing the error message directly, +or `application/json` content encoding a JSON object with the `reason` for the error. + ## Administration Clone this repository and build the binary. diff --git a/database.go b/database.go index 05d325d..b0d3644 100644 --- a/database.go +++ b/database.go @@ -6,6 +6,7 @@ import ( "time" "sync" "errors" + "strings" "encoding/json" "path/filepath" "io/fs" @@ -743,6 +744,52 @@ func retrievePath(db * sql.DB, path string, include_metadata bool) (*queryResult /**********************************************************************/ +type listRegisteredDirectoriesQuery struct { + User *string `json:"user"` +} + +type listRegisteredDirectoriesResult struct { + Path string `json:"path"` + User string `json:"user"` + Time int64 `json:"time"` + Names json.RawMessage `json:"names"` +} + +func listRegisteredDirectories(db * sql.DB, query *listRegisteredDirectoriesQuery) ([]listRegisteredDirectoriesResult, error) { + q := "SELECT path, user, time, json_extract(names, '$') FROM dirs" + + filters := []string{} + parameters := []interface{}{} + if query.User != nil { + filters = append(filters, "user == ?") + parameters = append(parameters, *(query.User)) + } + + if len(filters) > 0 { + q = q + " WHERE " + strings.Join(filters, " AND ") + } + + rows, err := db.Query(q, parameters...) + if err != nil { + return nil, fmt.Errorf("failed to list registered directories; %w", err) + } + defer rows.Close() + + output := []listRegisteredDirectoriesResult{} + for rows.Next() { + current := listRegisteredDirectoriesResult{} + var names string + err := rows.Scan(&(current.Path), &(current.User), &(current.Time), &names) + current.Names = []byte(names) + if err != nil { + return nil, fmt.Errorf("failed to traverse rows of the 'dir' table; %w", err) + } + output = append(output, current) + } + + return output, nil +} + func isDirectoryRegistered(db * sql.DB, path string) (bool, error) { collected := []interface{}{} for { diff --git a/database_test.go b/database_test.go index b98a4e6..7898066 100644 --- a/database_test.go +++ b/database_test.go @@ -10,6 +10,7 @@ import ( "strings" "errors" "database/sql" + "encoding/json" ) func TestInitializeDatabase(t *testing.T) { @@ -1675,6 +1676,91 @@ func TestRetrievePath(t *testing.T) { }) } +func TestListRegisteredDirectories(t *testing.T) { + tmp, err := os.MkdirTemp("", "") + if err != nil { + t.Fatalf(err.Error()) + } + defer os.RemoveAll(tmp) + + dbpath := filepath.Join(tmp, "db.sqlite3") + dbconn, err := initializeDatabase(dbpath) + if err != nil { + t.Fatalf(err.Error()) + } + defer dbconn.Close() + + tokr, err := newUnicodeTokenizer(false) + if err != nil { + t.Fatalf(err.Error()) + } + + // Mocking up some contents. + for _, name := range []string{ "foo", "bar" } { + to_add := filepath.Join(tmp, name) + err = mockDirectory(to_add) + if err != nil { + t.Fatalf(err.Error()) + } + + comments, err := addNewDirectory(dbconn, to_add, []string{ "metadata.json", "other.json" }, name + "_user", tokr) + if err != nil { + t.Fatalf(err.Error()) + } + if len(comments) > 0 { + t.Fatalf("unexpected comments from the directory addition %v", comments) + } + } + + t.Run("basic", func(t *testing.T) { + query := listRegisteredDirectoriesQuery{} + out, err := listRegisteredDirectories(dbconn, &query) + if err != nil { + t.Fatal(err) + } + if len(out) != 2 { + t.Fatal("should have found two matching paths") + } + + for i := 0; i < 2; i++ { + name := "foo" + if i == 1 { + name = "bar" + } + + if out[i].User != name + "_user" || filepath.Base(out[i].Path) != name || out[i].Time == 0 { + t.Fatalf("unexpected entry for path %d; %v", i, out[i]) + } + + var payload []string + err = json.Unmarshal(out[0].Names, &payload) + if err != nil { + t.Fatalf("failed to unmashal names; %v", string(out[0].Names)) + } + + if len(payload) != 2 || payload[0] != "metadata.json" || payload[1] != "other.json" { + t.Fatalf("unexpected value for names; %v", payload) + } + } + }) + + t.Run("filtered on user", func(t *testing.T) { + query := listRegisteredDirectoriesQuery{} + desired := "bar_user" + query.User = &desired + out, err := listRegisteredDirectories(dbconn, &query) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 { + t.Fatal("should have found one matching path") + } + if out[0].User != desired { + t.Fatalf("unexpected entry %v", out[0]) + } + }) +} + func TestIsDirectoryRegistered(t *testing.T) { tmp, err := os.MkdirTemp("", "") if err != nil { diff --git a/handlers.go b/handlers.go index b558a8f..25c1171 100644 --- a/handlers.go +++ b/handlers.go @@ -605,6 +605,34 @@ func newListFilesHandler(db *sql.DB) func(http.ResponseWriter, *http.Request) { } } +func newListRegisteredDirectoriesHandler(db *sql.DB) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if configureCors(w, r) { + return + } + if r.Method != "GET" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + query := listRegisteredDirectoriesQuery{} + + params := r.URL.Query() + if params.Has("user") { + user := params.Get("user") + query.User = &user + } + + output, err := listRegisteredDirectories(db, &query) + if err != nil { + dumpHttpErrorResponse(w, fmt.Errorf("failed to check registered directories; %w", err)) + return + } + + dumpJsonResponse(w, http.StatusOK, output) + } +} + /**********************************************************************/ func newDefaultHandler() func(http.ResponseWriter, *http.Request) { diff --git a/handlers_test.go b/handlers_test.go index 579649a..1ef34d9 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -1201,3 +1201,107 @@ func TestListFilesHandler(t *testing.T) { } }) } + +func TestListRegisteredDirectoriesHandler(t *testing.T) { + tmp, err := os.MkdirTemp("", "") + if err != nil { + t.Fatalf(err.Error()) + } + defer os.RemoveAll(tmp) + + dbpath := filepath.Join(tmp, "db.sqlite3") + dbconn, err := initializeDatabase(dbpath) + if err != nil { + t.Fatalf(err.Error()) + } + defer dbconn.Close() + + tokr, err := newUnicodeTokenizer(false) + if err != nil { + t.Fatalf(err.Error()) + } + + for _, name := range []string{ "akari", "ai", "alice" } { + to_add := filepath.Join(tmp, "to_add_" + name) + err = mockDirectory(to_add) + if err != nil { + t.Fatalf(err.Error()) + } + + comments, err := addNewDirectory(dbconn, to_add, []string{"metadata.json"}, name, tokr) + if err != nil { + t.Fatal(err) + } + if len(comments) != 0 { + t.Fatal("no comments should be present") + } + } + + handler := http.HandlerFunc(newListRegisteredDirectoriesHandler(dbconn)) + + t.Run("basic", func (t *testing.T) { + req, err := http.NewRequest("GET", "/registered", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("should have succeeded (got %d)", rr.Code) + } + + type lrdResult struct { + Path string + User string + Time int64 + Names []string + } + + r := []lrdResult{} + dec := json.NewDecoder(rr.Body) + err = dec.Decode(&r) + if err != nil { + t.Fatal(err) + } + + if len(r) != 3 || r[0].User != "akari" || r[1].User != "ai" || r[2].User != "alice" { + t.Fatalf("unexpected listing results for the users %q", r) + } + + if filepath.Base(r[0].Path) != "to_add_akari" || r[0].Time == 0 || r[0].Names[0] != "metadata.json" { + t.Fatalf("unexpected listing results for the first entry %q", r) + } + }) + + t.Run("filtered by user", func (t *testing.T) { + req, err := http.NewRequest("GET", "/registered?user=alice", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("should have succeeded (got %d)", rr.Code) + } + + type lrdResult struct { + Path string + User string + Time int64 + Names []string + } + + r := []lrdResult{} + dec := json.NewDecoder(rr.Body) + err = dec.Decode(&r) + if err != nil { + t.Fatal(err) + } + + if len(r) != 1 || r[0].User != "alice" || filepath.Base(r[0].Path) != "to_add_alice" || r[0].Time == 0 || r[0].Names[0] != "metadata.json" { + t.Fatalf("unexpected listing results %q", r) + } + }) +} diff --git a/main.go b/main.go index dda78fe..b54e88b 100644 --- a/main.go +++ b/main.go @@ -52,6 +52,7 @@ func main() { http.HandleFunc("POST " + prefix + "/deregister/start", newDeregisterStartHandler(db, verifier)) http.HandleFunc("POST " + prefix + "/deregister/finish", newDeregisterFinishHandler(db, verifier, timeout)) + http.HandleFunc(prefix + "/registered", newListRegisteredDirectoriesHandler(db)) http.HandleFunc(prefix + "/query", newQueryHandler(db, tokenizer, wild_tokenizer, "/query")) http.HandleFunc(prefix + "/retrieve/metadata", newRetrieveMetadataHandler(db)) http.HandleFunc(prefix + "/retrieve/file", newRetrieveFileHandler(db))