diff --git a/backend/db/init/01-schema.sql b/backend/db/init/01-schema.sql index 9d2cf8cd..521277fd 100644 --- a/backend/db/init/01-schema.sql +++ b/backend/db/init/01-schema.sql @@ -53,6 +53,10 @@ CREATE TABLE file_metadata( -- owner UUID NOT NULL REFERENCES users(id) ); +-- INDEX FOR FILE METADATA PAGINATION +CREATE INDEX CONCURRENTLY idx_file_owner_modified_desc +ON file_metadata (owner, modified_at DESC, id DESC); + -- DIRECTORY METADATA CREATE TABLE dir_metadata( id UUID PRIMARY KEY NOT NULL, @@ -68,4 +72,4 @@ CREATE TABLE file_metadata_access( user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, file_id UUID NOT NULL REFERENCES file_metadata(id) ON DELETE CASCADE, PRIMARY KEY (user_id, file_id) -); +); \ No newline at end of file diff --git a/backend/src/core/files/dto/file_request.go b/backend/src/core/files/dto/file_request.go new file mode 100644 index 00000000..1cce4ed2 --- /dev/null +++ b/backend/src/core/files/dto/file_request.go @@ -0,0 +1,13 @@ +package dto + +import "github.com/google/uuid" + +type GetAllFilesRequest struct { + UserID uuid.UUID `json:"user_id"` + Index uuid.UUID `json:"index"` +} + +type GetFileRequest struct { + UserID uuid.UUID `json:"user_id"` + FileID uuid.UUID `json:"file_id"` +} diff --git a/backend/src/core/files/dto/file_response.go b/backend/src/core/files/dto/file_response.go new file mode 100644 index 00000000..6e322b7e --- /dev/null +++ b/backend/src/core/files/dto/file_response.go @@ -0,0 +1,41 @@ +package dto + +import ( + files "backend/src/core/files/model" + "time" + + "github.com/google/uuid" +) + +type FileDTO struct { + ID uuid.UUID `json:"uuid"` + FileName string `json:"file_name"` + Path string `json:"path"` + Size uint64 `json:"size"` + FileType string `json:"file_type"` + //Mode fs.FileMode + //IsDir bool + ModifiedAt time.Time `json:"modified_at"` + CreatedAt time.Time `json:"created_at"` + Owner uuid.UUID `json:"owner_id"` + AccessTo []uuid.UUID `json:"access_to"` + Group []uuid.UUID `json:"group_id"` + //Links *uint64 + Version time.Time `json:"version"` +} + +func MapToResponse(m files.MetaData) FileDTO { + return FileDTO{ + ID: m.ID, + FileName: m.FileName, + Path: m.Path, + Size: m.Size, + FileType: m.FileType, + CreatedAt: m.CreatedAt, + ModifiedAt: m.ModifiedAt, + Owner: m.Owner, + AccessTo: m.AccessTo, + Group: m.Group, + Version: m.Version, + } +} diff --git a/backend/src/core/files/dto/metadata.go b/backend/src/core/files/dto/metadata_response.go similarity index 100% rename from backend/src/core/files/dto/metadata.go rename to backend/src/core/files/dto/metadata_response.go diff --git a/backend/src/core/files/handler/handler.go b/backend/src/core/files/handler/handler.go index 99658304..2ad86237 100644 --- a/backend/src/core/files/handler/handler.go +++ b/backend/src/core/files/handler/handler.go @@ -2,7 +2,7 @@ package files import ( service "backend/src/core/files/service" - "encoding/json" + "backend/src/internal/api/message" "log" "net/http" ) @@ -28,12 +28,20 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not save file", http.StatusInternalServerError) } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - err := json.NewEncoder(w).Encode(map[string]string{ - "status": "uploaded", - }) + err := message.Response(w, "uploaded") if err != nil { + log.Print(err) return } } + +func (h *Handler) GetAll(w http.ResponseWriter, r *http.Request) { + svc := h.svc + + userUUID := r.URL.Query().Get("user_uuid") + files, err := svc.GetAll(userUUID, r.Context()) + if err != nil { + log.Printf("couldn't get all user's files: %v", err) + http.Error(w, "unable to get user's files", http.StatusInternalServerError) + } +} diff --git a/backend/src/core/files/repository/repository.go b/backend/src/core/files/repository/repository.go index 6451c4b0..b8e1e59c 100644 --- a/backend/src/core/files/repository/repository.go +++ b/backend/src/core/files/repository/repository.go @@ -1,6 +1,7 @@ package files import ( + "backend/src/core/files/dto" files "backend/src/core/files/model" "backend/src/internal/metadb" "context" @@ -84,3 +85,67 @@ func (repo *Repository) SaveFileData( return nil } + +func (repo *Repository) GetAllFiles(ctx context.Context, req dto.GetAllFilesRequest) ([]files.MetaData, error) { + var db = repo.db.Pool + + const query = ` + SELECT + id, + file_name, + path, + size, + file_type, + modified_at, + uploaded_at, + version, + checksum, + owner + FROM file_metadata + WHERE owner = $1 + AND (modified_at, id) < ($2, $3) + ORDER BY modified_at, id + LIMIT 20; + ` + + var result []files.MetaData + + rows, err := db.Query( + ctx, + query, + req.UserID, + req.Cursor.ModifiedAt, + req.Cursor.ID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var model files.MetaData + if err := rows.Scan( + &model.ID, + &model.FileName, + &model.Path, + &model.Size, + &model.FileType, + &model.ModifiedAt, + &model.CreatedAt, + &model.Owner, + &model.AccessTo, + &model.Group, + &model.Version, + ); err != nil { + return nil, err + } + + result = append(result, model) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return result, nil +} diff --git a/backend/src/core/files/service/service_test.go b/backend/src/core/files/service/service_test.go new file mode 100644 index 00000000..013f2b6d --- /dev/null +++ b/backend/src/core/files/service/service_test.go @@ -0,0 +1,19 @@ +package files + +// TODO: Finish test, verify upload works correctly. +//func UploadTest_TestFileUpload(t *testing.T) { +// fileContents := []byte("This is a test file") +// fileName := "testfile.txt" +// +// var requestBody bytes.Buffer +// writer := multipart.NewWriter(&requestBody) +// +// part, err := writer.CreateFormFile("file", fileName) +// if err != nil { +// t.Fatalf("failed to create form file: %v", err) +// } +// +// Writer.Close() +// +// req := httptest.NewRequest(rest.MethodPut, "files/upload", &requestBody) +//} diff --git a/backend/src/internal/api/message/dto_mapper.go b/backend/src/internal/api/message/dto_mapper.go new file mode 100644 index 00000000..48c453cf --- /dev/null +++ b/backend/src/internal/api/message/dto_mapper.go @@ -0,0 +1,51 @@ +package message + +import ( + "log" + "reflect" +) + +func mapFields[T any](src any) (T, error) { + var response T + + dstValue := reflect.ValueOf(&response).Elem() + srcValue := reflect.Indirect(reflect.ValueOf(src)) + dstType := dstValue.Type() + + for i := 0; i < dstType.NumField(); i++ { + df := dstType.Field(i) + dv := dstValue.Field(i) + + if !dv.CanSet() { + log.Printf("could not map value: %v, is it unexported or read-only?", dv) + continue + } + + name := df.Tag.Get("map") + if name == "" { + name = df.Name + } + + sf := srcValue.FieldByName(name) + if !sf.IsValid() || sf.Type() != dv.Type() { + log.Printf("src field invalid %v", sf) + continue + } + + dv.Set(sf) + } + + return response, nil +} + +func ToRequest[T any](src any) (T, error) { + return mapFields[T](src) +} + +func ToResponse[T any](src any) (T, error) { + return mapFields[T](src) +} + +func ToModel[T any](src any) (T, error) { + return mapFields[T](src) +} diff --git a/backend/src/internal/api/message/response.go b/backend/src/internal/api/message/response.go new file mode 100644 index 00000000..d02f7cbf --- /dev/null +++ b/backend/src/internal/api/message/response.go @@ -0,0 +1,3 @@ +package message + +func Response() diff --git a/backend/src/internal/api/rest/http.go b/backend/src/internal/api/rest/http.go new file mode 100644 index 00000000..fab1aec4 --- /dev/null +++ b/backend/src/internal/api/rest/http.go @@ -0,0 +1,55 @@ +package rest + +import ( + "backend/src/internal/app" + "bytes" + "encoding/json" + "net/http" +) + +type AppConfig struct { + Config *app.Config +} + +func (app AppConfig) Post(endpointURL string, payload any) (*http.Response, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + url := app.buildURL(endpointURL) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + client := app.Config.HTTPClient + + return client.Do(req) +} + +func (app AppConfig) Get() (http.Response, error) { + panic("Not implemented.") +} + +func (app AppConfig) Delete() (http.Response, error) { + panic("Not implemented.") +} + +func (app AppConfig) Put() (http.Response, error) { + panic("Not implemented.") +} + +func (app AppConfig) Trace() (http.Response, error) { + panic("Not implemented.") +} + +func (app AppConfig) Patch() (http.Response, error) { + panic("Not implemented.") +} + +func (app AppConfig) buildURL(endpointUrl string) string { + return app.Config.BaseURL + endpointUrl +} diff --git a/backend/src/internal/api/rest/http_test.go b/backend/src/internal/api/rest/http_test.go new file mode 100644 index 00000000..76416faf --- /dev/null +++ b/backend/src/internal/api/rest/http_test.go @@ -0,0 +1,8 @@ +package rest + +//func TestHTTP_Post(t *testing.T) { +// var gotMethod string +// var gotContentType string +// var gotBody map[string]any +// +//} diff --git a/backend/src/internal/api/rest/json_handler.go b/backend/src/internal/api/rest/json_handler.go new file mode 100644 index 00000000..ba267bc1 --- /dev/null +++ b/backend/src/internal/api/rest/json_handler.go @@ -0,0 +1,96 @@ +package rest + +import ( + "context" + "encoding/json" + "errors" + "net/http" +) + +type HTTPError struct { + Status int + Err error +} + +func (e HTTPError) Error() string { + return e.Err.Error() +} + +type JSONHandlerConfig struct { + Method string `json:"method,omitempty"` + Headers map[string]string + Body any + SuccessStatus int +} + +func defaultJSONHandlerConfig() JSONHandlerConfig { + return JSONHandlerConfig{ + Method: http.MethodPost, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + Body: nil, + SuccessStatus: http.StatusOK, + } +} + +type JSONHandlerOption func(*JSONHandlerConfig) + +func JSONHandler[TReq any, TResp any]( + fn func(context.Context, TReq) (TResp, error), + opts ...JSONHandlerOption) http.HandlerFunc { + + cfg := defaultJSONHandlerConfig() + for _, opt := range opts { + opt(&cfg) + } + + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != cfg.Method { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req TReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + } + + resp, err := fn(r.Context(), req) + if err != nil { + var httpErr HTTPError + if errors.As(err, &httpErr) { + http.Error(w, httpErr.Error(), httpErr.Status) + return + } + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + //k: "Content-Type" v: "application/json" + for k, v := range cfg.Headers { + w.Header().Set(k, v) + } + + w.WriteHeader(cfg.SuccessStatus) + _ = json.NewEncoder(w).Encode(resp) + } +} + +func WithHeader(key, value string) JSONHandlerOption { + return func(c *JSONHandlerConfig) { + c.Headers[key] = value + } +} + +func WithMethod(method string) JSONHandlerOption { + return func(c *JSONHandlerConfig) { + c.Method = method + } +} + +func WithSuccessStatus(status int) JSONHandlerOption { + return func(c *JSONHandlerConfig) { + c.SuccessStatus = status + } +} diff --git a/backend/src/internal/api/rest/json_handler_test.go b/backend/src/internal/api/rest/json_handler_test.go new file mode 100644 index 00000000..0dbb1f16 --- /dev/null +++ b/backend/src/internal/api/rest/json_handler_test.go @@ -0,0 +1,56 @@ +package rest + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestJSONHandler_ReceivePost(t *testing.T) { + type request struct { + Name string `json:"name"` + } + + type response struct { + Message string `json:"message"` + } + + handler := JSONHandler[request, response]( + func(ctx context.Context, req request) (response, error) { + if req.Name != "test" { + t.Fatalf("unexpected request payload: %+v", req) + } + return response{Message: "ok"}, nil + }, + WithMethod(http.MethodPost), + ) + + body := bytes.NewBufferString(`{"name":"test"}`) + + req := httptest.NewRequest( + http.MethodPost, + "/test", + body, + ) + recorder := httptest.NewRecorder() + handler(recorder, req) + + resp := recorder.Result() + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200 but got: %v", resp.StatusCode) + } + + var got response + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if got.Message != "ok" { + t.Fatalf("unexpected response: %+v", got) + } +} diff --git a/backend/src/internal/app/app.go b/backend/src/internal/app/app.go index 8a97b77c..5124dd1c 100644 --- a/backend/src/internal/app/app.go +++ b/backend/src/internal/app/app.go @@ -43,6 +43,9 @@ func registerRoutes(metadataDB *metadb.MetadataDatabase) *http.ServeMux { "POST /api/files/upload", http.HandlerFunc(filesHandler.Upload), ), + router.Handle("POST /api/files/GetAll", + http.HandlerFunc(filesHandler.GetAll), + ), ) } diff --git a/backend/src/internal/metadb/dsn/obj_db_dsn.txt b/backend/src/internal/metadb/dsn/obj_db_dsn.txt new file mode 100644 index 00000000..949701be --- /dev/null +++ b/backend/src/internal/metadb/dsn/obj_db_dsn.txt @@ -0,0 +1,3 @@ +postgres://user:pass@host:5432/objstore +postgresql://user:pass@host:5432/objstore +user=foo password=bar host=localhost port=5432 dbname=objstore sslmode=disable \ No newline at end of file diff --git a/backend/src/internal/metadb/obj_db.go b/backend/src/internal/metadb/obj_db.go new file mode 100644 index 00000000..e4cc33d1 --- /dev/null +++ b/backend/src/internal/metadb/obj_db.go @@ -0,0 +1 @@ +package metadb diff --git a/backend/src/internal/metadb/postgres/files/files_repo.go b/backend/src/internal/metadb/postgres/files/files_repo.go new file mode 100644 index 00000000..49438081 --- /dev/null +++ b/backend/src/internal/metadb/postgres/files/files_repo.go @@ -0,0 +1,15 @@ +package files + +import ( + model "backend/src/core/files/model" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type FileRepository struct { + db *pgxpool.Pool +} + +func (r *FileRepository) SaveMetadata(meta *model.MetaData) (bool, error) { + // SQL +} diff --git a/backend/src/internal/middleware/auth.go b/backend/src/internal/middleware/auth.go new file mode 100644 index 00000000..be4616c8 --- /dev/null +++ b/backend/src/internal/middleware/auth.go @@ -0,0 +1,23 @@ +package middleware + +import ( + "fmt" + "net/http" + + jwt "github.com/golang-jwt/jwt/v5" +) + +var jwtRequest struct { + alg string `json:"alg"` + typ string `json:"typ"` + sub string `json:"sub"` + claims string `json:"name"` + admin string `json:"admin"` + iat string `json:"iat"` +} + +func ValidateJWT(req jwtRequest) (bool, error) { + token, err := jwt.Parse(req) + if err != nil { + return false, fmt.Errorf("unable to parse jwt request: %v", err) + }