Image Saving & Serving

This commit is contained in:
Evan Reichard 2021-01-22 00:00:55 -05:00
parent 547bb4a8b9
commit 997806b7f0
11 changed files with 167 additions and 78 deletions

View File

@ -24,7 +24,7 @@ func NewServer() *Server {
c := config.Load() c := config.Load()
db := db.NewMgr(c) db := db.NewMgr(c)
auth := auth.NewMgr(db, c) auth := auth.NewMgr(db, c)
api := api.NewApi(db, auth) api := api.NewApi(db, c, auth)
return &Server{ return &Server{
API: api, API: api,

1
go.mod
View File

@ -6,6 +6,7 @@ require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/disintegration/imaging v1.6.2 // indirect github.com/disintegration/imaging v1.6.2 // indirect
github.com/dsoprea/go-exif/v3 v3.0.0-20201216222538-db167117f483 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/google/uuid v1.1.5
github.com/lestrrat-go/jwx v1.0.8 github.com/lestrrat-go/jwx v1.0.8
github.com/mattn/go-sqlite3 v1.14.6 github.com/mattn/go-sqlite3 v1.14.6

2
go.sum
View File

@ -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.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 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/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.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.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1 h1:ljK/pL5ltg3qoN+OtN6yCv9HWSfMwxSx90GJCZQxYNg= github.com/go-errors/errors v1.1.1 h1:ljK/pL5ltg3qoN+OtN6yCv9HWSfMwxSx90GJCZQxYNg=

View File

@ -5,17 +5,20 @@ import (
"reichard.io/imagini/internal/db" "reichard.io/imagini/internal/db"
"reichard.io/imagini/internal/auth" "reichard.io/imagini/internal/auth"
"reichard.io/imagini/internal/config"
) )
type API struct { type API struct {
Router *http.ServeMux Router *http.ServeMux
Config *config.Config
Auth *auth.AuthManager Auth *auth.AuthManager
DB *db.DBManager 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{ api := &API{
Router: http.NewServeMux(), Router: http.NewServeMux(),
Config: c,
Auth: auth, Auth: auth,
DB: db, DB: db,
} }

View File

@ -53,8 +53,8 @@ func (api *API) loginHandler(w http.ResponseWriter, r *http.Request) {
refreshToken, err := api.Auth.CreateJWTRefreshToken(user, device) refreshToken, err := api.Auth.CreateJWTRefreshToken(user, device)
// Set appropriate cookies // Set appropriate cookies
accessCookie := http.Cookie{Name: "AccessToken", Value: accessToken, HttpOnly: true} accessCookie := http.Cookie{Name: "AccessToken", Value: accessToken, Path: "/", HttpOnly: true}
refreshCookie := http.Cookie{Name: "RefreshToken", Value: refreshToken, HttpOnly: true} refreshCookie := http.Cookie{Name: "RefreshToken", Value: refreshToken, Path: "/", HttpOnly: true}
http.SetCookie(w, &accessCookie) http.SetCookie(w, &accessCookie)
http.SetCookie(w, &refreshCookie) http.SetCookie(w, &refreshCookie)

30
internal/api/media.go Normal file
View File

@ -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)
}

View File

@ -3,21 +3,29 @@ package api
import ( import (
"io" "io"
"os" "os"
"fmt" "time"
"path"
"strings" "strings"
"net/http" "net/http"
"encoding/json" "github.com/google/uuid"
log "github.com/sirupsen/logrus"
"github.com/dsoprea/go-exif/v3" "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" "reichard.io/imagini/internal/models"
) )
// GET
// - /api/v1/MediaItems/<GUID>
// - JSON Struct
// - /api/v1/MediaItems/<GUID>/content
// - The raw file
func (api *API) mediaItemsHandler(w http.ResponseWriter, r *http.Request) { func (api *API) mediaItemsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
// CREATE // CREATE
api.createMediaItem(w, r) api.mediaItemPOSTHandler(w, r)
} else if r.Method == http.MethodPut { } else if r.Method == http.MethodPut {
// UPDATE / REPLACE // UPDATE / REPLACE
} else if r.Method == http.MethodPatch { } 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) 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 // Open form file
formFile, multipartFileHeader, err := r.FormFile("file") formFile, multipartFileHeader, err := r.FormFile("file")
if err != nil { if err != nil {
@ -59,7 +53,7 @@ func (api *API) createMediaItem(w http.ResponseWriter, r *http.Request) {
defer formFile.Close() defer formFile.Close()
// File header placeholder // File header placeholder
fileHeader := make([]byte, 512) fileHeader := make([]byte, 64)
// Copy headers into the buffer // Copy headers into the buffer
if _, err := formFile.Read(fileHeader); err != nil { if _, err := formFile.Read(fileHeader); err != nil {
@ -74,8 +68,9 @@ func (api *API) createMediaItem(w http.ResponseWriter, r *http.Request) {
} }
// Determine media type // Determine media type
fileMime := mimetype.Detect(fileHeader)
contentType := fileMime.String()
var mediaType string var mediaType string
contentType := http.DetectContentType(fileHeader)
if strings.HasPrefix(contentType, "image/") { if strings.HasPrefix(contentType, "image/") {
mediaType = "Image" mediaType = "Image"
} else if strings.HasPrefix(contentType, "video/") { } else if strings.HasPrefix(contentType, "video/") {
@ -85,16 +80,18 @@ func (api *API) createMediaItem(w http.ResponseWriter, r *http.Request) {
return return
} }
_ = mediaType // Pull out UUIDs
_ = multipartFileHeader reqInfo := r.Context().Value("uuids").(map[string]string)
uid := reqInfo["uid"]
// Print details // Derive Folder & File Path
// Filename: multipartFileHeader.Filename mediaItemUUID := uuid.New()
// Size: multipartFileHeader.Size fileName := mediaItemUUID.String() + fileMime.Extension()
// ContentType: http.DetectContentType(fileHeader) folderPath := path.Join("/" + api.Config.DataPath + "/media/" + uid)
os.MkdirAll(folderPath, 0700)
filePath := path.Join(folderPath + "/" + fileName)
// Open file to store // Create File
filePath := "./test.png" // TODO: Change dynamic
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE, 0600) f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE, 0600)
if err != nil { if err != nil {
log.Warn("[api] createMediaItem - Unable to open file: ", filePath) log.Warn("[api] createMediaItem - Unable to open file: ", filePath)
@ -110,23 +107,69 @@ func (api *API) createMediaItem(w http.ResponseWriter, r *http.Request) {
return return
} }
// Gather EXIF Data // Create MediaItem From EXIF Data
mediaItem = deriveEXIFData(filePath, mediaItem) 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) 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) rawExif, err := exif.SearchFileAndExtractExif(filePath)
entries, _, err := exif.GetFlatExifData(rawExif, nil) entries, _, err := exif.GetFlatExifData(rawExif, nil)
_ = err
// parsedJSON, err := json.MarshalIndent(entries, "", " ")
// fmt.Printf("EXIF String: %+v\n", string(parsedJSON))
for _, entry := range entries { decLong := float32(1)
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) 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)
} }

View File

@ -2,6 +2,7 @@ package api
import ( import (
"os" "os"
"context"
"net/http" "net/http"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@ -30,17 +31,21 @@ func (api *API) authMiddleware(next http.Handler) http.HandlerFunc {
} }
// Validate JWT Tokens // Validate JWT Tokens
// accessToken, accessOK := api.Auth.ValidateJWTAccessToken(accessCookie.Value) accessToken, accessOK := api.Auth.ValidateJWTAccessToken(accessCookie.Value)
_, accessOK := api.Auth.ValidateJWTAccessToken(accessCookie.Value)
if accessOK { if accessOK {
// Acquire UserID and DeviceID // Acquire UserID and DeviceID
// uid, _ := accessToken.Get("sub") reqInfo := make(map[string]string)
// did, _ := accessToken.Get("did") 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 { } else {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
} }

View File

@ -6,6 +6,10 @@ import (
) )
func (api *API) registerRoutes() { func (api *API) registerRoutes() {
api.Router.HandleFunc("/media/", multipleMiddleware(
api.mediaHandler,
api.authMiddleware,
))
api.Router.HandleFunc("/api/v1/MediaItems", multipleMiddleware( api.Router.HandleFunc("/api/v1/MediaItems", multipleMiddleware(
api.mediaItemsHandler, api.mediaItemsHandler,
api.authMiddleware, api.authMiddleware,

View File

@ -7,7 +7,7 @@ import (
) )
func (dbm *DBManager) CreateMediaItem (mediaItem models.MediaItem) (models.MediaItem, error) { 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 err := dbm.db.Create(&mediaItem).Error
return mediaItem, err return mediaItem, err
} }

View File

@ -8,10 +8,10 @@ import (
// Base contains common columns for all tables. // Base contains common columns for all tables.
type Base struct { type Base struct {
UUID uuid.UUID `gorm:"type:uuid;primarykey"` UUID uuid.UUID `json:"uuid" gorm:"type:uuid;primarykey"`
CreatedAt time.Time CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index"` DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
} }
func (base *Base) BeforeCreate(tx *gorm.DB) (err error) { 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 { type ServerSetting struct {
Base Base
Name string `json:"name" gorm:"not null"` Name string `json:"name" gorm:"not null"`
Description string `json:"description" 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 { type Device struct {
Base Base
User User `json:"user" gorm:"ForeignKey:UUID;not null"` User User `json:"user" gorm:"ForeignKey:UUID;not null"` // User UUID
Name string `json:"name" gorm:"not null"` Name string `json:"name" gorm:"not null"` // Name of Device
Type string `json:"type" gorm:"not null"` // Android, iOS, Chrome, FireFox, Edge, etc Type string `json:"type" gorm:"not null"` // Android, iOS, Chrome, FireFox, Edge
RefreshKey string `json:"-"` RefreshKey string `json:"-"` // Device Specific Refresh Key
} }
type User struct { type User struct {
Base Base
Email string `json:"email" gorm:"unique"` Email string `json:"email" gorm:"unique"` // Email
Username string `json:"username" gorm:"unique"` Username string `json:"username" gorm:"unique"` // Username
FirstName string `json:"first_name"` FirstName string `json:"first_name"` // First Name
LastName string `json:"last_name"` LastName string `json:"last_name"` // Last Name
Role string `json:"role"` Role string `json:"role"` // Role
AuthType string `json:"auth_type" gorm:"default:Local;not null"` AuthType string `json:"auth_type" gorm:"default:Local;not null"` // Auth Type (E.g. Local, LDAP)
Password string `json:"-"` Password string `json:"-"` // Hased & Salted Password
} }
type MediaItem struct { type MediaItem struct {
Base Base
User User `json:"user" gorm:"ForeignKey:UUID;not null"` User User `json:"user" gorm:"ForeignKey:UUID;not null"` // User UUID
EXIFDate time.Time `json:"exif_date"` EXIFDate time.Time `json:"exif_date"` // EXIF Date
Latitude string `json:"latitude"` Latitude float32 `json:"latitude" gorm:"type:decimal(10,2)"` // Decimal Latitude
Longitude string `json:"longitude"` Longitude float32 `json:"longitude" gorm:"type:decimal(10,2)"` // Decimal Longitude
MediaType string `json:"media_type" gorm:"default:Image;not null"` // Image, Video MediaType string `json:"media_type" gorm:"default:Image;not null"` // Image, Video
RelPath string `json:"rel_path" gorm:"not null"` OrigName string `json:"orig_name" gorm:"not null"` // Original Name
Tags []Tag `json:"tags" gorm:"many2many:media_tags;"` FileName string `json:"file_name" gorm:"not null"` // File Name
Albums []Album `json:"albums" gorm:"many2many:media_albums;"` 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 { type Tag struct {
Base Base
Name string `json:"name" gorm:"not null"` Name string `json:"name" gorm:"not null"` // Tag Name
} }
type Album struct { type Album struct {
Base Base
Name string `json:"name" gorm:"not null"` Name string `json:"name" gorm:"not null"` // Album Name
} }