diff --git a/go.mod b/go.mod index 6cb1980..1758010 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module reichard.io/imagini go 1.15 require ( + github.com/codeon/govips v0.0.0-20200329201227-415341c0ce33 // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/disintegration/imaging v1.6.2 // indirect github.com/dsoprea/go-exif/v3 v3.0.0-20201216222538-db167117f483 diff --git a/go.sum b/go.sum index 637d93c..dd43da0 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40/go.mod h1:8rLXio+Wji github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/codeon/govips v0.0.0-20200329201227-415341c0ce33 h1:/wmSAm0UZlQ/NmiwUlThjUUHe2cD36rjM61px1X6ccM= +github.com/codeon/govips v0.0.0-20200329201227-415341c0ce33/go.mod h1:kXwwWC7hMGnPrV6bw8gFi2k0ZLTiYTNMM+G3D9cUBt0= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/api/auth.go b/internal/api/auth.go index 599c2d6..6121f3c 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -14,6 +14,7 @@ import ( ) func (api *API) loginHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") if r.Method != http.MethodPost { errorJSON(w, "Method is not supported.", http.StatusMethodNotAllowed) return @@ -77,72 +78,21 @@ func (api *API) logoutHandler(w http.ResponseWriter, r *http.Request) { successJSON(w, "Logout success.", http.StatusOK) } -func (api *API) refreshLoginHandler(w http.ResponseWriter, r *http.Request) { - refreshCookie, err := r.Cookie("RefreshToken") - if err != nil { - log.Warn("[middleware] Cookie not found") - w.WriteHeader(http.StatusUnauthorized) - return - } - - // Validate Refresh Token - refreshToken, ok := api.Auth.ValidateJWTRefreshToken(refreshCookie.Value) - if !ok { - http.SetCookie(w, &http.Cookie{Name: "AccessToken", Expires: time.Unix(0, 0)}) - http.SetCookie(w, &http.Cookie{Name: "RefreshToken", Expires: time.Unix(0, 0)}) - errorJSON(w, "Invalid credentials.", http.StatusUnauthorized) - return - } - - // Acquire User & Device (Trusted) - did, ok := refreshToken.Get("did") - if !ok { - errorJSON(w, "Invalid credentials.", http.StatusUnauthorized) - return - } - uid, ok := refreshToken.Get(jwt.SubjectKey) - if !ok { - errorJSON(w, "Invalid credentials.", http.StatusUnauthorized) - return - } - deviceID, err := uuid.Parse(fmt.Sprintf("%v", did)) - if err != nil { - errorJSON(w, "Invalid credentials.", http.StatusUnauthorized) - return - } - userID, err := uuid.Parse(fmt.Sprintf("%v", uid)) - if err != nil { - errorJSON(w, "Invalid credentials.", http.StatusUnauthorized) - return - } - - // Device Skeleton - user := models.User{Base: models.Base{UUID: userID}} - device := models.Device{Base: models.Base{UUID: deviceID}} - - // Update token - accessToken, err := api.Auth.CreateJWTAccessToken(user, device) - accessCookie := http.Cookie{Name: "AccessToken", Value: accessToken} - http.SetCookie(w, &accessCookie) - - // Response success - successJSON(w, "Refresh success.", http.StatusOK) -} - /** * This will find or create the requested device based on ID and User. **/ func (api *API) upsertRequestedDevice(user models.User, r *http.Request) (models.Device, error) { requestedDevice := deriveRequestedDevice(r) requestedDevice.Type = deriveDeviceType(r) - requestedDevice.User = user + requestedDevice.UserUUID = user.UUID if requestedDevice.UUID == uuid.Nil { - createdDevice, err := api.DB.CreateDevice(requestedDevice) + err := api.DB.CreateDevice(&requestedDevice) + createdDevice, err := api.DB.Device(&requestedDevice) return createdDevice, err } - foundDevice, err := api.DB.Device(models.Device{ + foundDevice, err := api.DB.Device(&models.Device{ Base: models.Base{ UUID: requestedDevice.UUID }, User: user, }) @@ -213,6 +163,60 @@ func deriveRequestedDevice(r *http.Request) models.Device { return deviceSkeleton } +func (api *API) refreshAccessToken(w http.ResponseWriter, r *http.Request) (jwt.Token, error) { + refreshCookie, err := r.Cookie("RefreshToken") + if err != nil { + log.Warn("[middleware] RefreshToken not found") + return nil, err + } + + // Validate Refresh Token + refreshToken, err := api.Auth.ValidateJWTRefreshToken(refreshCookie.Value) + if err != nil { + http.SetCookie(w, &http.Cookie{Name: "AccessToken", Expires: time.Unix(0, 0)}) + http.SetCookie(w, &http.Cookie{Name: "RefreshToken", Expires: time.Unix(0, 0)}) + return nil, err + } + + // Acquire User & Device (Trusted) + did, ok := refreshToken.Get("did") + if !ok { + return nil, err + } + uid, ok := refreshToken.Get(jwt.SubjectKey) + if !ok { + return nil, err + } + deviceUUID, err := uuid.Parse(fmt.Sprintf("%v", did)) + if err != nil { + return nil, err + } + userUUID, err := uuid.Parse(fmt.Sprintf("%v", uid)) + if err != nil { + return nil, err + } + + // Device & User Skeleton + user := models.User{Base: models.Base{UUID: userUUID}} + device := models.Device{Base: models.Base{UUID: deviceUUID}} + + // Update token + accessTokenString, err := api.Auth.CreateJWTAccessToken(user, device) + if err != nil { + return nil, err + } + accessCookie := http.Cookie{Name: "AccessToken", Value: accessTokenString} + http.SetCookie(w, &accessCookie) + + // TODO: Update Refresh Key & Token + + // Convert to jwt.Token + accessTokenBytes := []byte(accessTokenString) + accessToken, err := jwt.ParseBytes(accessTokenBytes) + + return accessToken, err +} + func trimQuotes(s string) string { if len(s) >= 2 { if s[0] == '"' && s[len(s)-1] == '"' { diff --git a/internal/api/media.go b/internal/api/media.go index cfdeda3..ed714e2 100644 --- a/internal/api/media.go +++ b/internal/api/media.go @@ -1,6 +1,7 @@ package api import ( + "os" "path" "net/http" ) @@ -17,6 +18,17 @@ func (api *API) mediaHandler(w http.ResponseWriter, r *http.Request) { return } + // Acquire Width & Height Parameters + query := r.URL.Query() + width := query["width"] + height := query["height"] + _ = width + _ = height + + // TODO: Caching & Resizing + // - If both, force resize with new scale + // - If one, scale resize proportionally + // Pull out UUIDs reqInfo := r.Context().Value("uuids").(map[string]string) uid := reqInfo["uid"] @@ -26,5 +38,13 @@ func (api *API) mediaHandler(w http.ResponseWriter, r *http.Request) { folderPath := path.Join("/" + api.Config.DataPath + "/media/" + uid) mediaPath := path.Join(folderPath + "/" + fileName) + // Check if File Exists + _, err := os.Stat(mediaPath) + if os.IsNotExist(err) { + // TODO: Different HTTP Response Code? + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + http.ServeFile(w, r, mediaPath) } diff --git a/internal/api/media_items.go b/internal/api/media_items.go index 8913793..9b288ec 100644 --- a/internal/api/media_items.go +++ b/internal/api/media_items.go @@ -3,20 +3,25 @@ package api import ( "io" "os" - "time" - "path" + "fmt" + "path" + "time" + "regexp" "strings" + "errors" + "net/url" "net/http" - "github.com/google/uuid" - "github.com/dsoprea/go-exif/v3" - log "github.com/sirupsen/logrus" - "github.com/gabriel-vasile/mimetype" - "github.com/dsoprea/go-exif/v3/common" + "encoding/json" - "reichard.io/imagini/internal/models" + "github.com/google/uuid" + "github.com/dsoprea/go-exif/v3" + log "github.com/sirupsen/logrus" + "github.com/gabriel-vasile/mimetype" + "github.com/dsoprea/go-exif/v3/common" + + "reichard.io/imagini/internal/models" ) - // GET // - /api/v1/MediaItems/ // - JSON Struct @@ -34,14 +39,88 @@ func (api *API) mediaItemsHandler(w http.ResponseWriter, r *http.Request) { // DELETE } else if r.Method == http.MethodGet { // GET + api.mediaItemGETHandler(w, r) } else { errorJSON(w, "Method is not supported.", http.StatusMethodNotAllowed) return } } +// Paging: +// - Regular Pagination: +// - /api/v1/MediaItems?page[limit]=50&page=2 +// - Meta Count Only +// - /api/v1/MediaItems?page[limit]=0 + +// Sorting: +// - Ascending Sort: +// - /api/v1/MediaItems?sort=created_at +// - Descending Sort: +// - /api/v1/MediaItems?sort=-created_at + +// Filters: +// - Greater Than / Less Than (created_at, updated_at, exif_date) +// - /api/v1/MediaItems?filter[created_at]>=2020-01-01&filter[created_at]<=2021-01-01 +// - Long / Lat Range (latitude, longitude) +// - /api/v1/MediaItems?filter[latitude]>=71.1827&filter[latitude]<=72.0000&filter[longitude]>=100.000&filter[longitude]<=101.0000 +// - Image / Video (media_type) +// - /api/v1/MediaItems?filter[media_type]=Image +// - Tags (tags) +// - /api/v1/MediaItems?filter[tags]=id1,id2,id3 +// - Albums (albums) +// - /api/v1/MediaItems?filter[albums]=id1 +func (api *API) mediaItemGETHandler(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + // Handle error + } + + testObj := models.MediaItem{} + json.NewDecoder().Decode(&testObj) + fmt.Printf("Result: %+v\n", testObj) + + // allParams, err := json.Marshal(r.Form) + // if err != nil { + // // Handle error + // } + + // filter := &models.MediaItem{} + // if err = json.Unmarshal(allParams, filter); err != nil { + // // Handle error + // fmt.Printf("Fuck: %s\n", err) + // } + + // fmt.Printf("Result: %+v\n", filter) + + // err = normalizeForm(r.Form, models.MediaItem{}) + // if err != nil { + // fmt.Printf("Error: %s\n", err) + // } + + // var testItems []models.MediaItem + // api.DB.QueryBuilder(&testItems, allParams) + + // fmt.Printf("\n\nItems: %+v", testItems) + + + // Pull out UUIDs + reqInfo := r.Context().Value("uuids").(map[string]string) + uid := reqInfo["uid"] + userUUID, _ := uuid.Parse(uid) + + // TODO: Can apply multiple filters based on query parameters + mediaItemFilter := &models.MediaItem{UserUUID: userUUID} + mediaItemFilter.UserUUID = userUUID + + mediaItems, count, _ := api.DB.MediaItems(mediaItemFilter) + response := &models.APIResponse{ + Data: &mediaItems, + Meta: &models.APIMeta{Count: count}, + } + responseJSON(w, &response, http.StatusOK) +} + func (api *API) mediaItemPOSTHandler(w http.ResponseWriter, r *http.Request) { - // 64MB limit (TODO: Change this) + // 64MB limit (TODO: Change this - video) r.ParseMultipartForm(64 << 20) // Open form file @@ -116,13 +195,13 @@ func (api *API) mediaItemPOSTHandler(w http.ResponseWriter, r *http.Request) { // Add Additional MediaItem Fields mediaItem.Base.UUID = mediaItemUUID - mediaItem.User.UUID, err = uuid.Parse(uid) + mediaItem.UserUUID, err = uuid.Parse(uid) mediaItem.MediaType = mediaType mediaItem.FileName = fileName mediaItem.OrigName = multipartFileHeader.Filename // Create MediaItem in DB - _, err = api.DB.CreateMediaItem(mediaItem) + err = api.DB.CreateMediaItem(mediaItem) if err != nil { errorJSON(w, "Upload failed.", http.StatusInternalServerError) return @@ -131,14 +210,14 @@ func (api *API) mediaItemPOSTHandler(w http.ResponseWriter, r *http.Request) { successJSON(w, "Upload succeeded.", http.StatusCreated) } -func mediaItemFromEXIFData(filePath string) (models.MediaItem, error) { +func mediaItemFromEXIFData(filePath string) (*models.MediaItem, error) { rawExif, err := exif.SearchFileAndExtractExif(filePath) entries, _, err := exif.GetFlatExifData(rawExif, nil) decLong := float32(1) decLat := float32(1) - var mediaItem models.MediaItem + mediaItem := &models.MediaItem{} for _, v := range entries { if v.TagName == "DateTimeOriginal" { formattedTime, _ := time.Parse("2006:01:02 15:04:05", v.Formatted) @@ -164,8 +243,8 @@ func mediaItemFromEXIFData(filePath string) (models.MediaItem, error) { } } - mediaItem.Latitude = decLat - mediaItem.Longitude = decLong + mediaItem.Latitude = &decLat + mediaItem.Longitude = &decLong return mediaItem, err } @@ -173,3 +252,76 @@ func mediaItemFromEXIFData(filePath string) (models.MediaItem, error) { func deriveDecimalCoordinate(degrees, minutes uint32, seconds float32) float32 { return float32(degrees) + (float32(minutes) / 60) + (seconds / 3600) } + +// { +// filters: [ +// { field: "", operator: ""}, +// { field: "", operator: ""}, +// { field: "", operator: ""}, +// ], +// sort: "" +// page: {} +// } +func normalizeForm(form url.Values, typeObj interface{}) error { + allowedFields := models.JSONFields(typeObj) + + for key, val := range form { + key = strings.ToLower(key) + + re := regexp.MustCompile(`^(filter|page)\[(\w*)]($|>|<)$`) + matches := re.FindStringSubmatch(key) + + if len(matches) == 4 { + cmd := strings.ToLower(matches[1]) + field := strings.ToLower(matches[2]) + operator := strings.ToLower(matches[3]) + + if cmd == "page" && field == "limit" { + fmt.Printf("cmd: %s field: %s op: %s\n", cmd, field, operator) + continue + } + + // Validate field + _, ok := allowedFields[field] + if !ok { + return errors.New("Invalid field.") + } + + // Val assertions + tempObj := make(map[string]string) + tempObj[field] = val[0] + + mi, err := json.Marshal(tempObj) + if err != nil { + // Handle error + fmt.Printf("1 Type Assertion Failed For Field: [%s] with value: [%s]\n", field, val) + } + fmt.Printf("String JSON: %s", string(mi)) + refObj := &models.MediaItem{} + if err = json.Unmarshal(mi, refObj); err != nil { + // Handle error + fmt.Printf("2 Type Assertion Failed For Field: [%s] with value: [%s]\n", field, val[0]) + fmt.Println(err) + } + + fmt.Printf("Result: %+v\n", refObj) + + fmt.Printf("cmd: %s field: %s op: %s\n", cmd, field, operator) + } else if key == "sort" { + field := strings.ToLower(val[0]) + + // Validate field + _, ok := allowedFields[field] + if !ok { + return errors.New("Invalid field.") + } + + // TODO: Validate val + + fmt.Printf("cmd: %s\n", key) + } else { + return errors.New("Invalid parameter(s)") + } + } + return nil +} diff --git a/internal/api/middlewares.go b/internal/api/middlewares.go index b3fd8c1..d168fd7 100644 --- a/internal/api/middlewares.go +++ b/internal/api/middlewares.go @@ -22,33 +22,45 @@ func multipleMiddleware(h http.HandlerFunc, m ...Middleware) http.HandlerFunc { func (api *API) authMiddleware(next http.Handler) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Acquire Token accessCookie, err := r.Cookie("AccessToken") if err != nil { log.Warn("[middleware] AccessToken not found") - w.WriteHeader(http.StatusUnauthorized) + errorJSON(w, "Invalid token.", http.StatusUnauthorized) return } // Validate JWT Tokens - accessToken, accessOK := api.Auth.ValidateJWTAccessToken(accessCookie.Value) + accessToken, err := api.Auth.ValidateJWTAccessToken(accessCookie.Value) - if accessOK { - // Acquire UserID and DeviceID - reqInfo := make(map[string]string) - uid, _ := accessToken.Get("sub") - did, _ := accessToken.Get("did") - reqInfo["uid"] = uid.(string) - reqInfo["did"] = did.(string) - - // Add context - ctx := context.WithValue(r.Context(), "uuids", reqInfo) - sr := r.WithContext(ctx) - - next.ServeHTTP(w, sr) - } else { - w.WriteHeader(http.StatusUnauthorized) + if err != nil && err.Error() == "exp not satisfied" { + log.Info("[middleware] Refreshing AccessToken") + accessToken, err = api.refreshAccessToken(w, r) + if err != nil { + log.Warn("[middleware] Refreshing AccessToken failed: ", err) + errorJSON(w, "Invalid token.", http.StatusUnauthorized) + return + } + log.Info("[middleware] AccessToken Refreshed") + } else if err != nil { + log.Warn("[middleware] AccessToken failed to validate") + errorJSON(w, "Invalid token.", http.StatusUnauthorized) + return } + + // Acquire UserID and DeviceID + reqInfo := make(map[string]string) + uid, _ := accessToken.Get("sub") + did, _ := accessToken.Get("did") + reqInfo["uid"] = uid.(string) + reqInfo["did"] = did.(string) + + // Add context + ctx := context.WithValue(r.Context(), "uuids", reqInfo) + sr := r.WithContext(ctx) + + next.ServeHTTP(w, sr) }) } diff --git a/internal/api/routes.go b/internal/api/routes.go index ce81994..a9a7210 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -3,6 +3,8 @@ package api import ( "encoding/json" "net/http" + + "reichard.io/imagini/internal/models" ) func (api *API) registerRoutes() { @@ -43,19 +45,23 @@ func (api *API) registerRoutes() { api.authMiddleware, )) - api.Router.HandleFunc("/api/v1/Logout", api.logoutHandler) - api.Router.HandleFunc("/api/v1/Login", api.loginHandler) - api.Router.HandleFunc("/api/v1/RefreshLogin", api.refreshLoginHandler) + api.Router.HandleFunc("/api/v1/Logout", api.logoutHandler) + api.Router.HandleFunc("/api/v1/Login", api.loginHandler) } // https://stackoverflow.com/a/59764037 func errorJSON(w http.ResponseWriter, err string, code int) { + errStruct := &models.APIResponse{Error: &models.APIError{Message: err, Code: int64(code)}} + responseJSON(w, errStruct, code) +} + +func responseJSON(w http.ResponseWriter, msg interface{}, code int) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(code) - json.NewEncoder(w).Encode(map[string]interface{}{"error": err}) + json.NewEncoder(w).Encode(msg) } func successJSON(w http.ResponseWriter, msg string, code int) { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 720aa2d..46e225c 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -35,9 +35,9 @@ func NewMgr(db *db.DBManager, c *config.Config) *AuthManager { func (auth *AuthManager) AuthenticateUser(creds models.APICredentials) (bool, models.User) { // By Username - foundUser, err := auth.DB.User(models.User{Username: creds.User}) + foundUser, err := auth.DB.User(&models.User{Username: creds.User}) if errors.Is(err, gorm.ErrRecordNotFound) { - foundUser, err = auth.DB.User(models.User{Email: creds.User}) + foundUser, err = auth.DB.User(&models.User{Email: creds.User}) } // Error Checking @@ -67,22 +67,22 @@ func (auth *AuthManager) getRole(user models.User) string { return "User" } -func (auth *AuthManager) ValidateJWTRefreshToken(refreshJWT string) (jwt.Token, bool) { +func (auth *AuthManager) ValidateJWTRefreshToken(refreshJWT string) (jwt.Token, error) { byteRefreshJWT := []byte(refreshJWT) // Acquire Relevant Device unverifiedToken, err := jwt.ParseBytes(byteRefreshJWT) did, ok := unverifiedToken.Get("did") if !ok { - return nil, false + return nil, errors.New("did does not exist") } deviceID, err := uuid.Parse(fmt.Sprintf("%v", did)) if err != nil { - return nil, false + return nil, errors.New("did does not parse") } - device, err := auth.DB.Device(models.Device{Base: models.Base{UUID: deviceID}}) + device, err := auth.DB.Device(&models.Device{Base: models.Base{UUID: deviceID}}) if err != nil { - return nil, false + return nil, err } // Verify & Validate Token @@ -92,22 +92,23 @@ func (auth *AuthManager) ValidateJWTRefreshToken(refreshJWT string) (jwt.Token, ) if err != nil { fmt.Println("failed to parse payload: ", err) - return nil, false + return nil, err } - return verifiedToken, true + return verifiedToken, nil } -func (auth *AuthManager) ValidateJWTAccessToken(accessJWT string) (jwt.Token, bool) { +func (auth *AuthManager) ValidateJWTAccessToken(accessJWT string) (jwt.Token, error) { byteAccessJWT := []byte(accessJWT) verifiedToken, err := jwt.ParseBytes(byteAccessJWT, jwt.WithValidate(true), jwt.WithVerify(jwa.HS256, []byte(auth.Config.JWTSecret)), ) + if err != nil { - fmt.Println("failed to parse payload: ", err) - return nil, false + return nil, err } - return verifiedToken, true + + return verifiedToken, nil } func (auth *AuthManager) CreateJWTRefreshToken(user models.User, device models.Device) (string, error) { diff --git a/internal/db/db.go b/internal/db/db.go index 23245c2..619f04c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,7 +1,9 @@ package db import ( + "fmt" "path" + "errors" "gorm.io/gorm" // "gorm.io/gorm/logger" @@ -52,7 +54,7 @@ func NewMgr(c *config.Config) *DBManager { func (dbm *DBManager) bootstrapDatabase() { log.Info("[query] Bootstrapping database.") - _, err := dbm.CreateUser(models.User{ + err := dbm.CreateUser(&models.User{ Username: "admin", Password: "admin", AuthType: "Local", @@ -62,3 +64,46 @@ func (dbm *DBManager) bootstrapDatabase() { log.Fatal("[query] Unable to bootstrap database.") } } + +func (dbm *DBManager) QueryBuilder(dest interface{}, params []byte) (int64, error) { + // TODO: + // - Where Filters + // - Sort Filters + // - Paging Filters + + objType := fmt.Sprintf("%T", dest) + if objType == "*[]models.MediaItem" { + // TODO: Validate MediaItem Type + } else { + // Return Error + return 0, errors.New("Invalid type") + } + + var count int64 + err := dbm.db.Find(dest).Count(&count).Error; + return count, err + + // Paging: + // - Regular Pagination: + // - /api/v1/MediaItems?page[limit]=50&page=2 + // - Meta Count Only + // - /api/v1/MediaItems?page[limit]=0 + + // Sorting: + // - Ascending Sort: + // - /api/v1/MediaItems?sort=created_at + // - Descending Sort: + // - /api/v1/MediaItems?sort=-created_at + + // Filters: + // - Greater Than / Less Than (created_at, updated_at, exif_date) + // - /api/v1/MediaItems?filter[created_at]>=2020-01-01&filter[created_at]<=2021-01-01 + // - Long / Lat Range (latitude, longitude) + // - /api/v1/MediaItems?filter[latitude]>=71.1827&filter[latitude]<=72.0000&filter[longitude]>=100.000&filter[longitude]<=101.0000 + // - Image / Video (media_type) + // - /api/v1/MediaItems?filter[media_type]=Image + // - Tags (tags) + // - /api/v1/MediaItems?filter[tags]=id1,id2,id3 + // - Albums (albums) + // - /api/v1/MediaItems?filter[albums]=id1 +} diff --git a/internal/db/devices.go b/internal/db/devices.go index a99f7bf..c64abf7 100644 --- a/internal/db/devices.go +++ b/internal/db/devices.go @@ -7,24 +7,24 @@ import ( "reichard.io/imagini/internal/models" ) -func (dbm *DBManager) CreateDevice (device models.Device) (models.Device, error) { +func (dbm *DBManager) CreateDevice (device *models.Device) error { log.Info("[db] Creating device: ", device.Name) device.RefreshKey = uuid.New().String() err := dbm.db.Create(&device).Error - return device, err + return err } -func (dbm *DBManager) Device (device models.Device) (models.Device, error) { +func (dbm *DBManager) Device (device *models.Device) (models.Device, error) { var foundDevice models.Device var count int64 err := dbm.db.Where(&device).First(&foundDevice).Count(&count).Error return foundDevice, err } -func (dbm *DBManager) DeleteDevice (user models.Device) error { +func (dbm *DBManager) DeleteDevice (user *models.Device) error { return nil } -func (dbm *DBManager) UpdateRefreshToken (device models.Device, refreshToken string) error { +func (dbm *DBManager) UpdateRefreshToken (device *models.Device, refreshToken string) error { return nil } diff --git a/internal/db/media_items.go b/internal/db/media_items.go index 94b07aa..a67f9b8 100644 --- a/internal/db/media_items.go +++ b/internal/db/media_items.go @@ -6,30 +6,16 @@ import ( "reichard.io/imagini/internal/models" ) -func (dbm *DBManager) CreateMediaItem (mediaItem models.MediaItem) (models.MediaItem, error) { +func (dbm *DBManager) CreateMediaItem (mediaItem *models.MediaItem) error { log.Info("[db] Creating media item: ", mediaItem.FileName) err := dbm.db.Create(&mediaItem).Error - return mediaItem, err + return err } -func (dbm *DBManager) MediaItemsFromAlbum(user models.User, album models.Album) ([]models.MediaItem, error) { +func (dbm *DBManager) MediaItems(mediaItemFilter *models.MediaItem) ([]models.MediaItem, int64, error) { var mediaItems []models.MediaItem - // db.Table("media_albums"). - // Select("media_item.*"). - // Joins("INNER JOIN media_items ON media_albums.ID = media_items.Albums"). - // Where("media_albums.album_id = ? AND media_items.User = ?", albumID, userID). + var count int64 - - err := dbm.db. - //Where("album = ? AND user = ?", albumID, userID). - Find(&mediaItems).Error - return mediaItems, err - - // db.Raw(` - // SELECT - // MediaItems.* - // FROM - // MediaAlbums - // INNER JOIN MediaItems ON MediaAlbums.mediaID = MediaItems.mediaID - // WHERE MediaAlbums.albumID = ? AND MediaItems.userID = ?`, albumID, userID) + err := dbm.db.Where(&mediaItemFilter).Find(&mediaItems).Count(&count).Error; + return mediaItems, count, err } diff --git a/internal/db/users.go b/internal/db/users.go index 0215f50..3ba521f 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -7,19 +7,19 @@ import ( "reichard.io/imagini/internal/models" ) -func (dbm *DBManager) CreateUser(user models.User) (models.User, error) { +func (dbm *DBManager) CreateUser(user *models.User) error { log.Info("[db] Creating user: ", user.Username) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) if err != nil { log.Error(err) - return user, err + return err } user.Password = string(hashedPassword) err = dbm.db.Create(&user).Error - return user, err + return err } -func (dbm *DBManager) User (user models.User) (models.User, error) { +func (dbm *DBManager) User (user *models.User) (models.User, error) { var foundUser models.User var count int64 err := dbm.db.Where(&user).First(&foundUser).Count(&count).Error diff --git a/internal/models/api.go b/internal/models/api.go index 6f51a45..47654a0 100644 --- a/internal/models/api.go +++ b/internal/models/api.go @@ -5,18 +5,20 @@ type APICredentials struct { Password string `json:"password"` } +type APIData interface{} + type APIMeta struct { - Count int `json:"count"` - Page int `json:"page"` + Count int64 `json:"count"` + Page int64 `json:"page"` } type APIError struct { Message string `json:"message"` - Code int `json:"code"` + Code int64 `json:"code"` } type APIResponse struct { - Data []interface{} `json:"data"` - Meta APIMeta `json:"meta"` - Error APIError `json:"error"` + Data APIData `json:"data,omitempty"` + Meta *APIMeta `json:"meta,omitempty"` + Error *APIError `json:"error,omitempty"` } diff --git a/internal/models/db.go b/internal/models/db.go index 628885d..00de761 100644 --- a/internal/models/db.go +++ b/internal/models/db.go @@ -2,16 +2,17 @@ package models import ( "time" + "strings" + "reflect" "gorm.io/gorm" "github.com/google/uuid" ) -// Base contains common columns for all tables. type Base struct { UUID uuid.UUID `json:"uuid" gorm:"type:uuid;primarykey"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` } func (base *Base) BeforeCreate(tx *gorm.DB) (err error) { @@ -28,10 +29,11 @@ type ServerSetting struct { type Device struct { Base - User User `json:"user" gorm:"ForeignKey:UUID;not null"` // User UUID - Name string `json:"name" gorm:"not null"` // Name of Device - Type string `json:"type" gorm:"not null"` // Android, iOS, Chrome, FireFox, Edge - RefreshKey string `json:"-"` // Device Specific Refresh Key + UserUUID uuid.UUID `json:"-" gorm:"not null"` + User User `json:"user" gorm:"ForeignKey:UUID;References:UserUUID;not null"` // User + Name string `json:"name" gorm:"not null"` // Name of Device + Type string `json:"type" gorm:"not null"` // Android, iOS, Chrome, FireFox, Edge + RefreshKey string `json:"-"` // Device Specific Refresh Key } type User struct { @@ -47,15 +49,16 @@ type User struct { type MediaItem struct { Base - User User `json:"user" gorm:"ForeignKey:UUID;not null"` // User UUID - EXIFDate time.Time `json:"exif_date"` // EXIF Date - Latitude float32 `json:"latitude" gorm:"type:decimal(10,2)"` // Decimal Latitude - Longitude float32 `json:"longitude" gorm:"type:decimal(10,2)"` // Decimal Longitude - MediaType string `json:"media_type" gorm:"default:Image;not null"` // Image, Video - OrigName string `json:"orig_name" gorm:"not null"` // Original Name - FileName string `json:"file_name" gorm:"not null"` // File Name - Tags []Tag `json:"tags" gorm:"many2many:media_tags;"` // Associated Tag UUIDs - Albums []Album `json:"albums" gorm:"many2many:media_albums;"` // Associated Album UUIDs + UserUUID uuid.UUID `json:"-" gorm:"not null"` + User User `json:"-" gorm:"ForeignKey:UUID;References:UserUUID;not null"` // User + EXIFDate time.Time `json:"exif_date"` // EXIF Date + Latitude *float32 `json:"latitude" gorm:"type:decimal(10,2)"` // Decimal Latitude + Longitude *float32 `json:"longitude" gorm:"type:decimal(10,2)"` // Decimal Longitude + MediaType string `json:"media_type" gorm:"default:Image;not null"` // Image, Video + OrigName string `json:"orig_name" gorm:"not null"` // Original Name + FileName string `json:"file_name" gorm:"not null"` // File Name + Tags []Tag `json:"tags" gorm:"many2many:media_tags;"` // Associated Tag UUIDs + Albums []Album `json:"albums" gorm:"many2many:media_albums;"` // Associated Album UUIDs } type Tag struct { @@ -67,3 +70,27 @@ type Album struct { Base Name string `json:"name" gorm:"not null"` // Album Name } + +func JSONFields(model interface{}) map[string]struct{} { + jsonFields := make(map[string]struct{}) + val := reflect.ValueOf(model) + t := val.Type() + for i := 0; i < t.NumField(); i++ { + jsonField := strings.TrimSpace(t.Field(i).Tag.Get("json")) + + if jsonField == "" { + continue + } + + jsonSplit := strings.Split(jsonField, ",") + fieldVal := strings.TrimSpace(jsonSplit[0]) + + if fieldVal == "" || fieldVal == "-" { + continue + } + + jsonFields[fieldVal] = struct{}{} + } + + return jsonFields +}