From fc474b1416c48e085131c21ff949a3fd110e223b Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski <janne@kuschku.de> Date: Sat, 31 Jul 2021 17:36:50 +0200 Subject: [PATCH] Further improve image APIs --- api/image_delete.go | 11 ++++ api/image_upload.go | 8 ++- environment/backend.go | 3 + environment/frontend.go | 3 + environment/repositories.go | 7 ++- model/image.go | 2 +- repo/image_metadata.go | 95 ++++++++++++++++++++++++++++++++ repo/images.go | 2 + storage/storage.go | 20 +++++++ task/image_resize_processor.go | 32 ++++++++++- ui/src/App.tsx | 2 + ui/src/api/useDeleteImage.ts | 19 +++++++ ui/src/api/useListAlbums.ts | 2 +- ui/src/api/useListImages.ts | 2 +- ui/src/api/useUpdateImage.ts | 20 +++++++ ui/src/api/useUploadImage.ts | 26 +++++++++ ui/src/components/ImageView.tsx | 49 +++++++++++++++- ui/src/components/UploadView.tsx | 22 ++++++++ util/cors_wrapper.go | 1 + 19 files changed, 315 insertions(+), 11 deletions(-) create mode 100644 repo/image_metadata.go create mode 100644 ui/src/api/useDeleteImage.ts create mode 100644 ui/src/api/useUpdateImage.ts create mode 100644 ui/src/api/useUploadImage.ts create mode 100644 ui/src/components/UploadView.tsx diff --git a/api/image_delete.go b/api/image_delete.go index fcb2cd9..731798a 100644 --- a/api/image_delete.go +++ b/api/image_delete.go @@ -1,6 +1,7 @@ package api import ( + "context" "database/sql" "git.kuschku.de/justjanne/imghost-frontend/environment" "github.com/gorilla/mux" @@ -27,6 +28,16 @@ func DeleteImage(env environment.FrontendEnvironment) http.Handler { return } + err = env.Storage.DeleteFiles( + context.Background(), + env.Configuration.Storage.ImageBucket, + image.Id, + ) + if err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + writer.WriteHeader(http.StatusNoContent) }) } diff --git a/api/image_upload.go b/api/image_upload.go index 4c8035e..d4b05aa 100644 --- a/api/image_upload.go +++ b/api/image_upload.go @@ -33,16 +33,21 @@ func generateId() string { } func determineMimeType(header string, filename string) string { + println("Determining mime header from " + header + " and " + filename) mediaType, _, err := mime.ParseMediaType(header) if err == nil { + println("mediatype is " + mediaType) return mediaType } mediaType = mime.TypeByExtension(filepath.Ext(filename)) + println("extensiontype is " + mediaType) return mediaType } func determineExtension(filename string, mimeType string) (extension string, err error) { + println("determining extension for " + filename + " and " + mimeType) extension = filepath.Ext(filename) + println("found file extension " + extension) if extension != "" { return } @@ -56,6 +61,7 @@ func determineExtension(filename string, mimeType string) (extension string, err return } extension = extensions[0] + println("found mime extension " + extension) return } @@ -81,7 +87,7 @@ func UploadImage(env environment.FrontendEnvironment) http.Handler { } var files []model.Image - for _, file := range request.MultipartForm.File["images[]"] { + for _, file := range request.MultipartForm.File["images"] { println("processing file") var image model.Image image.Id = generateId() diff --git a/environment/backend.go b/environment/backend.go index 73fd20a..aa8e10e 100644 --- a/environment/backend.go +++ b/environment/backend.go @@ -24,6 +24,9 @@ func NewBackendEnvironment(config configuration.BackendConfiguration) (env Backe if env.Repositories.Images, err = repo.NewImageRepo(env.Database); err != nil { return } + if env.Repositories.ImageMetadata, err = repo.NewImageMetadataRepo(env.Database); err != nil { + return + } if env.Repositories.Albums, err = repo.NewAlbumRepo(env.Database); err != nil { return } diff --git a/environment/frontend.go b/environment/frontend.go index 607103d..42d5a5a 100644 --- a/environment/frontend.go +++ b/environment/frontend.go @@ -26,6 +26,9 @@ func NewFrontendEnvironment(config configuration.FrontendConfiguration) (env Fro if env.Repositories.Images, err = repo.NewImageRepo(env.Database); err != nil { return } + if env.Repositories.ImageMetadata, err = repo.NewImageMetadataRepo(env.Database); err != nil { + return + } if env.Repositories.Albums, err = repo.NewAlbumRepo(env.Database); err != nil { return } diff --git a/environment/repositories.go b/environment/repositories.go index ff2a7ec..55cca90 100644 --- a/environment/repositories.go +++ b/environment/repositories.go @@ -3,7 +3,8 @@ package environment import "git.kuschku.de/justjanne/imghost-frontend/repo" type Repositories struct { - Images repo.Images - Albums repo.Albums - AlbumImages repo.AlbumImages + Images repo.Images + ImageMetadata repo.ImageMetadata + Albums repo.Albums + AlbumImages repo.AlbumImages } diff --git a/model/image.go b/model/image.go index a095a9e..79c204a 100644 --- a/model/image.go +++ b/model/image.go @@ -15,7 +15,7 @@ type Image struct { CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` OriginalName string `json:"original_name" db:"original_name"` - MimeType string `json:"mime_type" db:"mime_type"` + MimeType string `json:"mime_type" db:"type"` State string `json:"state" db:"state"` Url string `json:"url"` } diff --git a/repo/image_metadata.go b/repo/image_metadata.go new file mode 100644 index 0000000..fe9aabd --- /dev/null +++ b/repo/image_metadata.go @@ -0,0 +1,95 @@ +package repo + +import ( + "git.kuschku.de/justjanne/imghost-frontend/model" + "github.com/jmoiron/sqlx" +) + +type ImageMetadata struct { + db *sqlx.DB + queryList *sqlx.NamedStmt + stmtCreate *sqlx.NamedStmt + stmtDelete *sqlx.NamedStmt +} + +func NewImageMetadataRepo(db *sqlx.DB) (repo ImageMetadata, err error) { + repo.db = db + repo.queryList, err = db.PrepareNamed(` + SELECT name, content + FROM image_metadata + WHERE image = :imageId + `) + if err != nil { + return + } + repo.stmtCreate, err = db.PrepareNamed(` + INSERT INTO image_metadata (image, name, content) + VALUES (:imageId, :name, :content) + `) + if err != nil { + return + } + repo.stmtDelete, err = db.PrepareNamed(` + DELETE FROM image_metadata + WHERE image = :imageId + `) + if err != nil { + return + } + + return repo, nil +} + +func (repo ImageMetadata) List(image model.Image) (map[string]string, error) { + rows, err := repo.queryList.Queryx(map[string]interface{}{ + "imageId": image.Id, + }) + if err != nil { + return nil, err + } + metadata := make(map[string]string) + for rows.Next() { + var key string + var value string + err = rows.Scan(&key, &value) + if err != nil { + return nil, err + } + metadata[key] = value + } + return metadata, nil +} + +func (repo ImageMetadata) Update(imageId string, metadata map[string]string) (err error) { + tx, err := repo.db.Beginx() + if err != nil { + return + } + println("Deleting metadata for " + imageId) + _, err = tx.NamedStmt(repo.stmtDelete).Exec(map[string]interface{}{ + "imageId": imageId, + }) + if err != nil { + return + } + for key, value := range metadata { + println("Adding metadata for " + imageId + " with " + key + "=" + value) + _, err = tx.NamedStmt(repo.stmtCreate).Exec(map[string]interface{}{ + "imageId": imageId, + "name": key, + "content": value, + }) + if err != nil { + return + } + } + err = tx.Commit() + return +} + +func (repo ImageMetadata) Delete(changed model.Image) (err error) { + _, err = repo.stmtDelete.Exec(map[string]interface{}{ + "imageId": changed.Id, + }) + return +} diff --git a/repo/images.go b/repo/images.go index 57e3338..c6d7824 100644 --- a/repo/images.go +++ b/repo/images.go @@ -31,6 +31,7 @@ func NewImageRepo(db *sqlx.DB) (repo Images, err error) { title, description, original_name, + type, created_at, updated_at, state @@ -47,6 +48,7 @@ func NewImageRepo(db *sqlx.DB) (repo Images, err error) { title, description, original_name, + type, created_at, updated_at, state diff --git a/storage/storage.go b/storage/storage.go index ffb3571..a8a41bf 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -60,6 +60,26 @@ func (storage Storage) DownloadFile(ctx context.Context, bucketName string, file return } +func (storage Storage) DeleteFiles(ctx context.Context, bucketName string, prefix string) error { + objects := storage.s3client.ListObjects( + ctx, + bucketName, + minio.ListObjectsOptions{Prefix: prefix}, + ) + errors := storage.s3client.RemoveObjects( + ctx, + bucketName, + objects, + minio.RemoveObjectsOptions{}, + ) + for err := range errors { + if err.Err != nil { + return err.Err + } + } + return nil +} + func (storage Storage) UrlFor(bucketName string, fileName string) *url.URL { fileUrl := *storage.s3client.EndpointURL() fileUrl.Path = filepath.Join(fileUrl.Path, bucketName, fileName) diff --git a/task/image_resize_processor.go b/task/image_resize_processor.go index 8b79df8..414478e 100644 --- a/task/image_resize_processor.go +++ b/task/image_resize_processor.go @@ -11,6 +11,7 @@ import ( "github.com/justjanne/imgconv" "gopkg.in/gographics/imagick.v2/imagick" "io/ioutil" + "strings" ) type ImageProcessor struct { @@ -26,6 +27,8 @@ func NewImageProcessor(env environment.BackendEnvironment) *ImageProcessor { func (processor *ImageProcessor) ProcessTask(ctx context.Context, task *asynq.Task) (err error) { var payload ImageResizePayload if err = json.Unmarshal(task.Payload(), &payload); err != nil { + println("Could not unmarshal task") + println(err.Error()) return } @@ -69,7 +72,22 @@ func (processor *ImageProcessor) ProcessTask(ctx context.Context, task *asynq.Ta println("failed to load file: " + sourceFile.Name()) println(err.Error()) _ = processor.env.Repositories.Images.UpdateState(payload.ImageId, repo.StateError) - return err + return + } + + metadata := make(map[string]string) + for _, key := range wand.GetImageProperties("exif:*") { + if strings.HasPrefix(key, "exif:thumbnail:") { + continue + } + metadata[strings.TrimPrefix(key, "exif:")] = wand.GetImageProperty(key) + } + err = processor.env.Repositories.ImageMetadata.Update(payload.ImageId, metadata) + if err != nil { + println("failed to write metadata: " + payload.ImageId) + println(err.Error()) + _ = processor.env.Repositories.Images.UpdateState(payload.ImageId, repo.StateError) + return } err = util.LaunchGoroutines(len(payload.Sizes), func(index int) error { @@ -106,6 +124,18 @@ func (processor *ImageProcessor) ProcessTask(ctx context.Context, task *asynq.Ta return } + err = processor.env.Storage.DeleteFiles( + ctx, + processor.env.Configuration.Storage.ConversionBucket, + payload.ImageId, + ) + if err != nil { + println("failed to delete temp file: " + payload.ImageId) + println(err.Error()) + _ = processor.env.Repositories.Images.UpdateState(payload.ImageId, repo.StateError) + return + } + if err = processor.env.Repositories.Images.UpdateState(payload.ImageId, repo.StateDone); err != nil { return } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8fae9dd..b89a5bb 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -3,6 +3,7 @@ import './App.css'; import ImageList from "./components/ImageList"; import {BaseUrlProvider} from './api/baseUrlContext'; import {QueryClient, QueryClientProvider} from "react-query"; +import UploadView from "./components/UploadView"; const queryClient = new QueryClient(); @@ -10,6 +11,7 @@ function App() { return ( <BaseUrlProvider value="http://localhost:8080/"> <QueryClientProvider client={queryClient}> + <UploadView /> <ImageList/> </QueryClientProvider> </BaseUrlProvider> diff --git a/ui/src/api/useDeleteImage.ts b/ui/src/api/useDeleteImage.ts new file mode 100644 index 0000000..46cb251 --- /dev/null +++ b/ui/src/api/useDeleteImage.ts @@ -0,0 +1,19 @@ +import {Image} from "./model/Image"; +import {useBaseUrl} from "./baseUrlContext"; +import {useMutation, useQueryClient} from "react-query"; +import axios from "axios"; + +export const useDeleteImage = () => { + const baseUrl = useBaseUrl(); + const queryClient = useQueryClient(); + return useMutation<unknown, unknown, Image>((image: Image) => axios.delete( + `api/v1/images/${image.id}`, + { + baseURL: baseUrl + } + ), { + onSuccess: () => { + queryClient.invalidateQueries('image') + }, + }) +} diff --git a/ui/src/api/useListAlbums.ts b/ui/src/api/useListAlbums.ts index 6f40830..6e7b041 100644 --- a/ui/src/api/useListAlbums.ts +++ b/ui/src/api/useListAlbums.ts @@ -6,7 +6,7 @@ import {Album} from "./model/Album"; export const useListAlbums = () => { const baseUrl = useBaseUrl(); return useQuery( - "albums", + "album", () => axios.get<Album[]>( "api/v1/albums", { diff --git a/ui/src/api/useListImages.ts b/ui/src/api/useListImages.ts index 76d74e0..9d858b5 100644 --- a/ui/src/api/useListImages.ts +++ b/ui/src/api/useListImages.ts @@ -6,7 +6,7 @@ import {Image} from "./model/Image"; export const useListImages = () => { const baseUrl = useBaseUrl(); return useQuery( - "images", + "image", () => axios.get<Image[]>( "api/v1/images", { diff --git a/ui/src/api/useUpdateImage.ts b/ui/src/api/useUpdateImage.ts new file mode 100644 index 0000000..98dcffa --- /dev/null +++ b/ui/src/api/useUpdateImage.ts @@ -0,0 +1,20 @@ +import {Image} from "./model/Image"; +import {useBaseUrl} from "./baseUrlContext"; +import {useMutation, useQueryClient} from "react-query"; +import axios from "axios"; + +export const useUpdateImage = () => { + const baseUrl = useBaseUrl(); + const queryClient = useQueryClient(); + return useMutation<void, unknown, Image>((image: Image) => axios.post( + `api/v1/images/${image.id}`, + image, + { + baseURL: baseUrl + } + ), { + onSuccess: () => { + queryClient.invalidateQueries('image') + }, + }) +} diff --git a/ui/src/api/useUploadImage.ts b/ui/src/api/useUploadImage.ts new file mode 100644 index 0000000..925fc67 --- /dev/null +++ b/ui/src/api/useUploadImage.ts @@ -0,0 +1,26 @@ +import {Image} from "./model/Image"; +import {useBaseUrl} from "./baseUrlContext"; +import {useMutation, useQueryClient} from "react-query"; +import axios from "axios"; + +export const useUploadImage = () => { + const baseUrl = useBaseUrl(); + const queryClient = useQueryClient(); + return useMutation<Image, unknown, FileList>((files: FileList) => { + const formData = new FormData(); + for (let i = 0; i < files.length; i++) { + formData.append("images", files[i]); + } + return axios.post<Image>( + `api/v1/images`, + formData, + { + baseURL: baseUrl + } + ).then(it => it.data); + }, { + onSuccess: () => { + queryClient.invalidateQueries('image') + }, + }) +} diff --git a/ui/src/components/ImageView.tsx b/ui/src/components/ImageView.tsx index 83bb5c5..e6e62e6 100644 --- a/ui/src/components/ImageView.tsx +++ b/ui/src/components/ImageView.tsx @@ -1,23 +1,66 @@ import {Image} from "../api/model/Image"; -import React from "react"; +import React, {useState} from "react"; +import {useUpdateImage} from "../api/useUpdateImage"; +import {useDeleteImage} from "../api/useDeleteImage"; export interface ImageProps { image: Image } export default function ImageView({image}: ImageProps) { + const {mutate: update, error: updateError, isLoading: updateLoading} = useUpdateImage(); + const {mutate: remove, error: removeError, isLoading: removeLoading} = useDeleteImage(); + const [title, setTitle] = useState<string>(image.title); + const [description, setDescription] = useState<string>(image.description); + return ( <div> + <p>UpdateError: {JSON.stringify(updateError, null, 2)}</p> + <p>RemoveError: {JSON.stringify(removeError, null, 2)}</p> + <p>UpdateLoading: {JSON.stringify(updateLoading, null, 2)}</p> + <p>RemoveLoading: {JSON.stringify(removeLoading, null, 2)}</p> <p>{image.id}</p> <p>{image.owner}</p> - <p>{image.title}</p> - <p>{image.description}</p> + <label> + Title + <input + type="text" + value={title} + onChange={({target: {value}}) => + setTitle(value)} + /> + </label> + <br/> + <label> + Description + <input + type="text" + value={description} + onChange={({target: {value}}) => + setDescription(value)} + /> + </label> <p>{image.original_name}</p> <p>{image.mime_type}</p> <p>{image.created_at}</p> <p>{image.updated_at}</p> <p>{image.state}</p> <img src={image.url + "t"} alt=""/> + <br/> + <input + type="submit" + value="Save" + onClick={() => update({ + ...image, + title, + description, + })} + /> + <input + type="submit" + value="Delete" + onClick={() => remove(image)} + /> </div> ) } diff --git a/ui/src/components/UploadView.tsx b/ui/src/components/UploadView.tsx new file mode 100644 index 0000000..5f4dee2 --- /dev/null +++ b/ui/src/components/UploadView.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import {useUploadImage} from "../api/useUploadImage"; + +export default function UploadView() { + const {mutate: upload, error: uploadError, isLoading: uploadLoading} = useUploadImage(); + + return ( + <div> + <pre>Error: {JSON.stringify(uploadError, null, 2)}</pre> + <pre>Loading: {JSON.stringify(uploadLoading, null, 2)}</pre> + <input + type="file" + onChange={async ({target}) => { + if (target.files) { + await upload(target.files) + target.files = null; + } + }} + /> + </div> + ) +} diff --git a/util/cors_wrapper.go b/util/cors_wrapper.go index 89ee96e..8bd3290 100644 --- a/util/cors_wrapper.go +++ b/util/cors_wrapper.go @@ -6,6 +6,7 @@ func CorsWrapper(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Headers", "*") + w.Header().Set("Access-Control-Allow-Methods", "*") if r.Method == http.MethodOptions { w.WriteHeader(200) -- GitLab