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