From 997806b7f00fa6123f38f2169cc373e4c045379b Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Fri, 22 Jan 2021 00:00:55 -0500 Subject: [PATCH] Image Saving & Serving --- cmd/server/server.go | 2 +- go.mod | 1 + go.sum | 2 + internal/api/api.go | 5 +- internal/api/auth.go | 4 +- internal/api/media.go | 30 +++++++++ internal/api/media_items.go | 123 ++++++++++++++++++++++++------------ internal/api/middlewares.go | 17 +++-- internal/api/routes.go | 4 ++ internal/db/media_items.go | 2 +- internal/models/db.go | 55 ++++++++-------- 11 files changed, 167 insertions(+), 78 deletions(-) create mode 100644 internal/api/media.go diff --git a/cmd/server/server.go b/cmd/server/server.go index d010fcb..4229c1e 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -24,7 +24,7 @@ func NewServer() *Server { c := config.Load() db := db.NewMgr(c) auth := auth.NewMgr(db, c) - api := api.NewApi(db, auth) + api := api.NewApi(db, c, auth) return &Server{ API: api, diff --git a/go.mod b/go.mod index 288e083..6cb1980 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( 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 + github.com/gabriel-vasile/mimetype v1.1.2 github.com/google/uuid v1.1.5 github.com/lestrrat-go/jwx v1.0.8 github.com/mattn/go-sqlite3 v1.14.6 diff --git a/go.sum b/go.sum index 32f4783..637d93c 100644 --- a/go.sum +++ b/go.sum @@ -37,6 +37,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uA github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/gabriel-vasile/mimetype v1.1.2 h1:gaPnPcNor5aZSVCJVSGipcpbgMWiAAj9z182ocSGbHU= +github.com/gabriel-vasile/mimetype v1.1.2/go.mod h1:6CDPel/o/3/s4+bp6kIbsWATq8pmgOisOPG40CJa6To= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= github.com/go-errors/errors v1.1.1 h1:ljK/pL5ltg3qoN+OtN6yCv9HWSfMwxSx90GJCZQxYNg= diff --git a/internal/api/api.go b/internal/api/api.go index e161e21..cfdea96 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -5,17 +5,20 @@ import ( "reichard.io/imagini/internal/db" "reichard.io/imagini/internal/auth" + "reichard.io/imagini/internal/config" ) type API struct { Router *http.ServeMux + Config *config.Config Auth *auth.AuthManager DB *db.DBManager } -func NewApi(db *db.DBManager, auth *auth.AuthManager) *API { +func NewApi(db *db.DBManager, c *config.Config, auth *auth.AuthManager) *API { api := &API{ Router: http.NewServeMux(), + Config: c, Auth: auth, DB: db, } diff --git a/internal/api/auth.go b/internal/api/auth.go index a4e4972..599c2d6 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -53,8 +53,8 @@ func (api *API) loginHandler(w http.ResponseWriter, r *http.Request) { refreshToken, err := api.Auth.CreateJWTRefreshToken(user, device) // Set appropriate cookies - accessCookie := http.Cookie{Name: "AccessToken", Value: accessToken, HttpOnly: true} - refreshCookie := http.Cookie{Name: "RefreshToken", Value: refreshToken, HttpOnly: true} + accessCookie := http.Cookie{Name: "AccessToken", Value: accessToken, Path: "/", HttpOnly: true} + refreshCookie := http.Cookie{Name: "RefreshToken", Value: refreshToken, Path: "/", HttpOnly: true} http.SetCookie(w, &accessCookie) http.SetCookie(w, &refreshCookie) diff --git a/internal/api/media.go b/internal/api/media.go new file mode 100644 index 0000000..cfdeda3 --- /dev/null +++ b/internal/api/media.go @@ -0,0 +1,30 @@ +package api + +import ( + "path" + "net/http" +) + +// Responsible for serving up static images / videos +func (api *API) mediaHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if path.Dir(r.URL.Path) != "/media" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + // Pull out UUIDs + reqInfo := r.Context().Value("uuids").(map[string]string) + uid := reqInfo["uid"] + + // Derive Path + fileName := path.Base(r.URL.Path) + folderPath := path.Join("/" + api.Config.DataPath + "/media/" + uid) + mediaPath := path.Join(folderPath + "/" + fileName) + + http.ServeFile(w, r, mediaPath) +} diff --git a/internal/api/media_items.go b/internal/api/media_items.go index 1f85083..8913793 100644 --- a/internal/api/media_items.go +++ b/internal/api/media_items.go @@ -3,21 +3,29 @@ package api import ( "io" "os" - "fmt" + "time" + "path" "strings" "net/http" - "encoding/json" - log "github.com/sirupsen/logrus" + "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 +// - /api/v1/MediaItems//content +// - The raw file func (api *API) mediaItemsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { // CREATE - api.createMediaItem(w, r) + api.mediaItemPOSTHandler(w, r) } else if r.Method == http.MethodPut { // UPDATE / REPLACE } else if r.Method == http.MethodPatch { @@ -32,24 +40,10 @@ func (api *API) mediaItemsHandler(w http.ResponseWriter, r *http.Request) { } } -func (api *API) createMediaItem(w http.ResponseWriter, r *http.Request) { +func (api *API) mediaItemPOSTHandler(w http.ResponseWriter, r *http.Request) { + // 64MB limit (TODO: Change this) r.ParseMultipartForm(64 << 20) - // Parse metadata - metadata := r.FormValue("metadata") - var mediaItem models.MediaItem - err := json.Unmarshal([]byte(metadata), &mediaItem) - if err != nil { - log.Warn("[api] createMediaItem - Invalid metadata: ", err) - errorJSON(w, "Metadata invalid.", http.StatusBadRequest) - return - } - - // TODO: Verify mediaItem contains appropriate values - - - // fmt.Printf("Media Item: %+v\n", mediaItem) - // Open form file formFile, multipartFileHeader, err := r.FormFile("file") if err != nil { @@ -59,7 +53,7 @@ func (api *API) createMediaItem(w http.ResponseWriter, r *http.Request) { defer formFile.Close() // File header placeholder - fileHeader := make([]byte, 512) + fileHeader := make([]byte, 64) // Copy headers into the buffer if _, err := formFile.Read(fileHeader); err != nil { @@ -74,8 +68,9 @@ func (api *API) createMediaItem(w http.ResponseWriter, r *http.Request) { } // Determine media type + fileMime := mimetype.Detect(fileHeader) + contentType := fileMime.String() var mediaType string - contentType := http.DetectContentType(fileHeader) if strings.HasPrefix(contentType, "image/") { mediaType = "Image" } else if strings.HasPrefix(contentType, "video/") { @@ -85,16 +80,18 @@ func (api *API) createMediaItem(w http.ResponseWriter, r *http.Request) { return } - _ = mediaType - _ = multipartFileHeader + // Pull out UUIDs + reqInfo := r.Context().Value("uuids").(map[string]string) + uid := reqInfo["uid"] - // Print details - // Filename: multipartFileHeader.Filename - // Size: multipartFileHeader.Size - // ContentType: http.DetectContentType(fileHeader) + // Derive Folder & File Path + mediaItemUUID := uuid.New() + fileName := mediaItemUUID.String() + fileMime.Extension() + folderPath := path.Join("/" + api.Config.DataPath + "/media/" + uid) + os.MkdirAll(folderPath, 0700) + filePath := path.Join(folderPath + "/" + fileName) - // Open file to store - filePath := "./test.png" // TODO: Change dynamic + // Create File f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE, 0600) if err != nil { log.Warn("[api] createMediaItem - Unable to open file: ", filePath) @@ -110,23 +107,69 @@ func (api *API) createMediaItem(w http.ResponseWriter, r *http.Request) { return } - // Gather EXIF Data - mediaItem = deriveEXIFData(filePath, mediaItem) + // Create MediaItem From EXIF Data + mediaItem, err := mediaItemFromEXIFData(filePath) + if err != nil { + errorJSON(w, "Upload failed.", http.StatusInternalServerError) + return + } + + // Add Additional MediaItem Fields + mediaItem.Base.UUID = mediaItemUUID + mediaItem.User.UUID, err = uuid.Parse(uid) + mediaItem.MediaType = mediaType + mediaItem.FileName = fileName + mediaItem.OrigName = multipartFileHeader.Filename + + // Create MediaItem in DB + _, err = api.DB.CreateMediaItem(mediaItem) + if err != nil { + errorJSON(w, "Upload failed.", http.StatusInternalServerError) + return + } - // TODO: Add resource location in response successJSON(w, "Upload succeeded.", http.StatusCreated) } -func deriveEXIFData(filePath string, mediaItem models.MediaItem) models.MediaItem { +func mediaItemFromEXIFData(filePath string) (models.MediaItem, error) { rawExif, err := exif.SearchFileAndExtractExif(filePath) entries, _, err := exif.GetFlatExifData(rawExif, nil) - _ = err - // parsedJSON, err := json.MarshalIndent(entries, "", " ") - // fmt.Printf("EXIF String: %+v\n", string(parsedJSON)) - for _, entry := range entries { - fmt.Printf("IFD-PATH=[%s] ID=(0x%04x) NAME=[%s] COUNT=(%d) TYPE=[%s] VALUE=[%s]\n", entry.IfdPath, entry.TagId, entry.TagName, entry.UnitCount, entry.TagTypeName, entry.Formatted) + decLong := float32(1) + decLat := float32(1) + + var mediaItem models.MediaItem + for _, v := range entries { + if v.TagName == "DateTimeOriginal" { + formattedTime, _ := time.Parse("2006:01:02 15:04:05", v.Formatted) + mediaItem.EXIFDate = formattedTime + } else if v.TagName == "GPSLatitude" { + latStruct := v.Value.([]exifcommon.Rational) + decLat *= deriveDecimalCoordinate( + latStruct[0].Numerator / latStruct[0].Denominator, + latStruct[1].Numerator / latStruct[1].Denominator, + float32(latStruct[2].Numerator) / float32(latStruct[2].Denominator), + ) + } else if v.TagName == "GPSLongitude" { + longStruct := v.Value.([]exifcommon.Rational) + decLong *= deriveDecimalCoordinate( + longStruct[0].Numerator / longStruct[0].Denominator, + longStruct[1].Numerator / longStruct[1].Denominator, + float32(longStruct[2].Numerator) / float32(longStruct[2].Denominator), + ) + } else if v.TagName == "GPSLatitudeRef" && v.Formatted == "S" { + decLat *= -1 + } else if v.TagName == "GPSLongitudeRef" && v.Formatted == "W" { + decLong *= -1 + } } - return mediaItem + mediaItem.Latitude = decLat + mediaItem.Longitude = decLong + + return mediaItem, err +} + +func deriveDecimalCoordinate(degrees, minutes uint32, seconds float32) float32 { + return float32(degrees) + (float32(minutes) / 60) + (seconds / 3600) } diff --git a/internal/api/middlewares.go b/internal/api/middlewares.go index 143adc4..b3fd8c1 100644 --- a/internal/api/middlewares.go +++ b/internal/api/middlewares.go @@ -2,6 +2,7 @@ package api import ( "os" + "context" "net/http" log "github.com/sirupsen/logrus" ) @@ -30,17 +31,21 @@ func (api *API) authMiddleware(next http.Handler) http.HandlerFunc { } // Validate JWT Tokens - // accessToken, accessOK := api.Auth.ValidateJWTAccessToken(accessCookie.Value) - _, accessOK := api.Auth.ValidateJWTAccessToken(accessCookie.Value) + accessToken, accessOK := api.Auth.ValidateJWTAccessToken(accessCookie.Value) if accessOK { // Acquire UserID and DeviceID - // uid, _ := accessToken.Get("sub") - // did, _ := accessToken.Get("did") + reqInfo := make(map[string]string) + uid, _ := accessToken.Get("sub") + did, _ := accessToken.Get("did") + reqInfo["uid"] = uid.(string) + reqInfo["did"] = did.(string) - // Set context uid & did + // Add context + ctx := context.WithValue(r.Context(), "uuids", reqInfo) + sr := r.WithContext(ctx) - next.ServeHTTP(w, r) + next.ServeHTTP(w, sr) } else { w.WriteHeader(http.StatusUnauthorized) } diff --git a/internal/api/routes.go b/internal/api/routes.go index 918eae5..ce81994 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -6,6 +6,10 @@ import ( ) func (api *API) registerRoutes() { + api.Router.HandleFunc("/media/", multipleMiddleware( + api.mediaHandler, + api.authMiddleware, + )) api.Router.HandleFunc("/api/v1/MediaItems", multipleMiddleware( api.mediaItemsHandler, api.authMiddleware, diff --git a/internal/db/media_items.go b/internal/db/media_items.go index 26b025b..94b07aa 100644 --- a/internal/db/media_items.go +++ b/internal/db/media_items.go @@ -7,7 +7,7 @@ import ( ) func (dbm *DBManager) CreateMediaItem (mediaItem models.MediaItem) (models.MediaItem, error) { - log.Info("[db] Creating media item: ", mediaItem.RelPath) + log.Info("[db] Creating media item: ", mediaItem.FileName) err := dbm.db.Create(&mediaItem).Error return mediaItem, err } diff --git a/internal/models/db.go b/internal/models/db.go index c47d6a5..628885d 100644 --- a/internal/models/db.go +++ b/internal/models/db.go @@ -8,10 +8,10 @@ import ( // Base contains common columns for all tables. type Base struct { - UUID uuid.UUID `gorm:"type:uuid;primarykey"` - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt gorm.DeletedAt `gorm:"index"` + 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"` } func (base *Base) BeforeCreate(tx *gorm.DB) (err error) { @@ -21,48 +21,49 @@ func (base *Base) BeforeCreate(tx *gorm.DB) (err error) { type ServerSetting struct { Base - Name string `json:"name" gorm:"not null"` + Name string `json:"name" gorm:"not null"` Description string `json:"description" gorm:"not null"` - Value string `json:"value" gorm:"not null"` + Value string `json:"value" gorm:"not null"` } type Device struct { Base - User User `json:"user" gorm:"ForeignKey:UUID;not null"` - Name string `json:"name" gorm:"not null"` - Type string `json:"type" gorm:"not null"` // Android, iOS, Chrome, FireFox, Edge, etc - RefreshKey string `json:"-"` + 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 } type User struct { Base - Email string `json:"email" gorm:"unique"` - Username string `json:"username" gorm:"unique"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Role string `json:"role"` - AuthType string `json:"auth_type" gorm:"default:Local;not null"` - Password string `json:"-"` + Email string `json:"email" gorm:"unique"` // Email + Username string `json:"username" gorm:"unique"` // Username + FirstName string `json:"first_name"` // First Name + LastName string `json:"last_name"` // Last Name + Role string `json:"role"` // Role + AuthType string `json:"auth_type" gorm:"default:Local;not null"` // Auth Type (E.g. Local, LDAP) + Password string `json:"-"` // Hased & Salted Password } type MediaItem struct { Base - User User `json:"user" gorm:"ForeignKey:UUID;not null"` - EXIFDate time.Time `json:"exif_date"` - Latitude string `json:"latitude"` - Longitude string `json:"longitude"` - MediaType string `json:"media_type" gorm:"default:Image;not null"` // Image, Video - RelPath string `json:"rel_path" gorm:"not null"` - Tags []Tag `json:"tags" gorm:"many2many:media_tags;"` - Albums []Album `json:"albums" gorm:"many2many:media_albums;"` + 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 } type Tag struct { Base - Name string `json:"name" gorm:"not null"` + Name string `json:"name" gorm:"not null"` // Tag Name } type Album struct { Base - Name string `json:"name" gorm:"not null"` + Name string `json:"name" gorm:"not null"` // Album Name }