feat: images

This commit is contained in:
Florian Sylvain
2024-06-13 13:34:10 +02:00
parent eb98a1003e
commit c25fb1b0a3
19 changed files with 387 additions and 100 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ tmp_dir = "tmp"
bin = "tmp\\main.exe"
cmd = "go build -o ./tmp/main.exe ./main"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "api\\static\\tinymce", "api\\static\\bootstrap-icons"]
exclude_dir = ["assets", "tmp", "vendor", "testdata", "api\\static\\tinymce", "api\\static\\bootstrap-icons", "api\\static\\uploadedImages"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
+1 -1
View File
@@ -46,7 +46,7 @@ of course required, but not necessarily via the `.env` file.
| PORT | int | port the API will use | required |
| JWT_SECRET | string | secret for the jwt auth | required |
| CORS_ALLOWED_ORIGINS | string | allowed origins for CORS | required, semicolon separated list |
| DB_FILE | string | path to the sqlite db file | required, can be at the root but name still required (e.g. `./gocms.db`) ; have to end up with `.db` |
| DB_FILE | string | path to the sqlite db file | required, can be at the root but name still required (e.g. `./gocms.db`) ; have to end up with `.db` |
| DOCKER_DB_FOLDER | string | path to the sqlite db file's folder | required with docker, this will basically be the host machine folder (e.g. `./data`) that contains the sqlite db file |
| SMTP_EMAIL | string | sender email | required |
| SMTP_PASSWORD | string | smtp account password | required |
@@ -0,0 +1,85 @@
package gateways
import (
entity "GoCMS/adapters/secondary/gateways/models"
"GoCMS/domain/gateways"
domain "GoCMS/domain/image"
"errors"
"github.com/google/uuid"
"gorm.io/gorm"
"mime/multipart"
"os"
)
type ImageRepository struct {
db *gorm.DB
}
func NewImageRepository(db *gorm.DB) *ImageRepository {
return &ImageRepository{db}
}
var contentTypeExtensions = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpeg",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
func mapImageToDomain(image entity.Image) domain.Image {
return domain.FromDB(
image.ID,
image.Path,
image.PostID,
image.CreatedAt,
image.UpdatedAt,
)
}
func (i ImageRepository) Create(file multipart.File, fileHeader multipart.FileHeader) (domain.Image, error) {
fileBytes := make([]byte, fileHeader.Size)
_, err := file.Read(fileBytes)
if err != nil {
return domain.Image{}, err
}
err = os.MkdirAll("api/static/uploadedImages/", 0666)
if err != nil {
return domain.Image{}, err
}
contentType := fileHeader.Header.Get("Content-Type")
extension := contentTypeExtensions[contentType]
if extension == "" {
return domain.Image{}, errors.New("the file must be a PNG, JPEG, WEBP, or SVG image")
}
newName := uuid.NewString() + extension
err = os.WriteFile("api/static/uploadedImages/"+newName, fileBytes, 0666)
if err != nil {
return domain.Image{}, err
}
newImage := i.db.Create(&domain.Image{Path: "/static/uploadedImages/" + newName})
var createdImage entity.Image
newImage.Scan(&createdImage)
return mapImageToDomain(createdImage), nil
}
func (i ImageRepository) Delete(id uint32) error {
var image entity.Image
err := i.db.Model(&entity.Image{}).First(&image, id).Error
if err != nil {
return err
}
err = os.Remove("api" + image.Path)
if err != nil {
return err
}
return i.db.Delete(&entity.Image{}, id).Error
}
var _ gateways.IImageRepository = &ImageRepository{}
@@ -0,0 +1,15 @@
package models
import (
"gorm.io/gorm"
"time"
)
type Image struct {
gorm.Model
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Path string
PostID uint32
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -10,6 +10,7 @@ type Post struct {
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Title string
Body string
Images []*Image `gorm:"many2many:post_images;"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
+38 -8
View File
@@ -16,12 +16,12 @@ func NewPostRepository(db *gorm.DB) *PostRepository {
}
func mapPostToDomain(post entity.Post) domain.Post {
return domain.FromDb(post.ID, post.Title, post.Body, post.CreatedAt, post.UpdatedAt)
return domain.FromDb(post.ID, post.Title, post.Body, post.Images, post.CreatedAt, post.UpdatedAt)
}
func (a *PostRepository) Get(id uint32) (domain.Post, error) {
var post entity.Post
err := a.db.Model(&entity.Post{}).First(&post, id).Error
err := a.db.Model(&entity.Post{}).Preload("Images").First(&post, id).Error
if err != nil {
return domain.Post{}, err
}
@@ -69,15 +69,49 @@ func (a *PostRepository) GetAll() []domain.Post {
return domainPosts
}
func (a *PostRepository) UpdateBody(id uint32, body string) error {
func (a *PostRepository) UpdateBody(id uint32, body string) (domain.Post, error) {
var localPost entity.Post
err := a.db.Model(&entity.Post{}).First(&localPost, id).Error
if err != nil {
return err
return domain.Post{}, err
}
localPost.Body = body
err = a.db.Save(&localPost).Error
if err != nil {
return domain.Post{}, err
}
newPost := domain.FromDb(
localPost.ID,
localPost.Title,
localPost.Body,
localPost.Images,
localPost.CreatedAt,
localPost.UpdatedAt,
)
return newPost, nil
}
func (a *PostRepository) Delete(id uint32) error {
return a.db.Delete(&entity.Post{}, id).Error
}
func (a *PostRepository) AddImage(postId uint32, imageId uint32) error {
var localPost entity.Post
err := a.db.Model(&entity.Post{}).First(&localPost, postId).Error
if err != nil {
return err
}
var localImage entity.Image
err = a.db.Model(&entity.Image{}).First(&localImage, imageId).Error
if err != nil {
return err
}
err = a.db.Model(&localPost).Association("Images").Append(&localImage)
if err != nil {
return err
}
@@ -85,8 +119,4 @@ func (a *PostRepository) UpdateBody(id uint32, body string) error {
return nil
}
func (a *PostRepository) Delete(id uint32) error {
return a.db.Delete(&entity.Post{}, id).Error
}
var _ gateways.IPostRepository = &PostRepository{}
@@ -30,7 +30,7 @@
</div>
</div>
<div class="text-black-50 d-flex align-items-center justify-content-center h-100 py-3">
<textarea id="postBody" name="postBody">{{.Body}}</textarea>
<textarea id="postBody" name="postBody">{{.Post.Body}}</textarea>
</div>
</form>
@@ -49,43 +49,43 @@
},
license_key: 'gpl',
images_upload_handler: (blobInfo, progress) => new Promise((resolve, reject) => {
// const xhr = new XMLHttpRequest();
// xhr.withCredentials = false;
// xhr.open('POST', 'postAcceptor.php');
//
// xhr.upload.onprogress = (e) => {
// progress(e.loaded / e.total * 100);
// };
//
// xhr.onload = () => {
// if (xhr.status === 403) {
// reject({message: 'HTTP Error: ' + xhr.status, remove: true});
// return;
// }
//
// if (xhr.status < 200 || xhr.status >= 300) {
// reject('HTTP Error: ' + xhr.status);
// return;
// }
//
// const json = JSON.parse(xhr.responseText);
//
// if (!json || typeof json.location != 'string') {
// reject('Invalid JSON: ' + xhr.responseText);
// return;
// }
//
// resolve(json.location);
// };
//
// xhr.onerror = () => {
// reject('Image upload failed due to a XHR Transport error. Code: ' + xhr.status);
// };
//
// const formData = new FormData();
// formData.append('file', blobInfo.blob(), blobInfo.filename());
//
// xhr.send(formData);
const xhr = new XMLHttpRequest();
xhr.withCredentials = false;
xhr.open('POST', `/post/{{.Post.ID}}/image/create`);
xhr.upload.onprogress = (e) => {
progress(e.loaded / e.total * 100);
};
xhr.onload = () => {
if (xhr.status === 403) {
reject({message: 'HTTP Error: ' + xhr.status, remove: true});
return;
}
if (xhr.status < 200 || xhr.status >= 300) {
reject('HTTP Error: ' + xhr.status);
return;
}
const json = JSON.parse(xhr.responseText);
if (!json || typeof json.location != 'string') {
reject('Invalid JSON: ' + xhr.responseText);
return;
}
resolve(`http{{ if .Secured }}s{{ end }}://${window.location.host}${json.location}`);
};
xhr.onerror = () => {
reject('Image upload failed due to a XHR Transport error. Code: ' + xhr.status);
};
const formData = new FormData();
formData.append('file', blobInfo.blob(), blobInfo.filename());
xhr.send(formData);
}),
})
</script>
+28 -24
View File
@@ -11,18 +11,20 @@ import (
)
type UseCases struct {
CreatePostUseCase *useCases.CreatePostUseCase
GetPostUseCase *useCases.GetPostUseCase
ListPostsUseCase *useCases.ListPostsUseCase
UpdatePostUseCase *useCases.UpdatePostUseCase
DeletePostUseCase *useCases.DeletePostUseCase
GetUserUseCase *useCases.GetUserUseCase
CreateUserUseCase *useCases.CreateUserUseCase
UpdateUserUseCase *useCases.UpdateUserUseCase
DeleteUserUseCase *useCases.DeleteUserUseCase
ListUsersUseCase *useCases.ListUsersUseCase
GetPageUseCase *useCases.GetPageUseCase
SendMailUseCase *useCases.SendMailUseCase
CreatePostUseCase *useCases.CreatePostUseCase
GetPostUseCase *useCases.GetPostUseCase
ListPostsUseCase *useCases.ListPostsUseCase
UpdatePostUseCase *useCases.UpdatePostUseCase
DeletePostUseCase *useCases.DeletePostUseCase
GetUserUseCase *useCases.GetUserUseCase
CreateUserUseCase *useCases.CreateUserUseCase
UpdateUserUseCase *useCases.UpdateUserUseCase
DeleteUserUseCase *useCases.DeleteUserUseCase
ListUsersUseCase *useCases.ListUsersUseCase
GetPageUseCase *useCases.GetPageUseCase
SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase
DeleteImageUseCase *useCases.DeleteImageUseCase
}
var Container *UseCases
@@ -48,18 +50,20 @@ func InitContainer() {
_ = digContainer.Provide(func(db *gorm.DB) *UseCases {
return &UseCases{
CreatePostUseCase: useCases.NewCreatePostUseCase(db),
GetPostUseCase: useCases.NewGetPostUseCase(db),
ListPostsUseCase: useCases.NewListPostsUseCase(db),
UpdatePostUseCase: useCases.NewUpdatePostUseCase(db),
DeletePostUseCase: useCases.NewDeletePostUseCase(db),
GetUserUseCase: useCases.NewGetUserUseCase(db),
CreateUserUseCase: useCases.NewCreateUserUseCase(db),
UpdateUserUseCase: useCases.NewUpdateUserUseCase(db),
DeleteUserUseCase: useCases.NewDeleteUserUseCase(db),
ListUsersUseCase: useCases.NewListUsersUseCase(db),
GetPageUseCase: useCases.NewGetPageUseCase(),
SendMailUseCase: useCases.NewSendMailUseCase(),
CreatePostUseCase: useCases.NewCreatePostUseCase(db),
GetPostUseCase: useCases.NewGetPostUseCase(db),
ListPostsUseCase: useCases.NewListPostsUseCase(db),
UpdatePostUseCase: useCases.NewUpdatePostUseCase(db),
DeletePostUseCase: useCases.NewDeletePostUseCase(db),
GetUserUseCase: useCases.NewGetUserUseCase(db),
CreateUserUseCase: useCases.NewCreateUserUseCase(db),
UpdateUserUseCase: useCases.NewUpdateUserUseCase(db),
DeleteUserUseCase: useCases.NewDeleteUserUseCase(db),
ListUsersUseCase: useCases.NewListUsersUseCase(db),
GetPageUseCase: useCases.NewGetPageUseCase(),
SendMailUseCase: useCases.NewSendMailUseCase(),
CreateImageUseCase: useCases.NewCreateImageUseCase(db),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(db),
}
})
err := digContainer.Invoke(func(useCases *UseCases) { Container = useCases })
+38
View File
@@ -0,0 +1,38 @@
package api
import (
"encoding/json"
"github.com/go-chi/chi/v5"
"net/http"
"strconv"
)
func postImage(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
id := chi.URLParam(r, "id")
idInt, _ := strconv.Atoi(id)
file, fileHeader, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
newImage, err := Container.CreateImageUseCase.CreateImage(file, *fileHeader)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = Container.UpdatePostUseCase.AddImage(uint32(idInt), newImage.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
newJson := map[string]interface{}{"location": newImage.Path}
newJsonBytes, _ := json.Marshal(newJson)
_, _ = w.Write(newJsonBytes)
}
+3 -8
View File
@@ -1,18 +1,13 @@
package api
import (
"embed"
"github.com/go-chi/chi/v5"
"html/template"
"io/fs"
"net/http"
"path/filepath"
"strings"
)
//go:embed static
var staticFolder embed.FS
var headTmpl template.HTML
var contentTypes = map[string]string{
@@ -97,11 +92,10 @@ func InitHeadTmpl() {
func NewPageRouter() http.Handler {
r := chi.NewRouter()
contentStatic := fs.FS(staticFolder)
InitHeadTmpl()
r.Handle("/static/*", StaticFileServerWithContentType(http.FS(contentStatic)))
fileServer := StaticFileServerWithContentType(http.Dir("api/static"))
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
r.Get("/", GetLogin)
r.Get(LoginRoute, GetLoginPageHandler(EmptyLoginPage))
@@ -128,6 +122,7 @@ func NewPageRouter() http.Handler {
r.Get("/post/{id}/delete", GetPostDeletePage)
r.Get("/post/create", GetPostCreatePage)
r.Post("/post/create", PostPostCreatePage)
r.Post("/post/{id}/image/create", postImage)
})
return r
+16
View File
@@ -1,6 +1,7 @@
package api
import (
"fmt"
"github.com/go-chi/chi/v5"
"net/http"
"strconv"
@@ -13,6 +14,21 @@ func GetPostDeletePage(w http.ResponseWriter, r *http.Request) {
return
}
localPost, err := Container.GetPostUseCase.GetPost(uint32(id))
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
fmt.Println(localPost.Images)
for _, image := range localPost.Images {
err = Container.DeleteImageUseCase.DeleteImage(image.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
}
err = Container.DeletePostUseCase.DeletePost(uint32(id))
if err != nil {
http.Error(w, http.StatusText(400), http.StatusBadRequest)
+15 -12
View File
@@ -1,9 +1,11 @@
package api
import (
"GoCMS/domain/post"
"github.com/go-chi/chi/v5"
"html/template"
"net/http"
"os"
"strconv"
)
@@ -12,13 +14,14 @@ type PostEditPageAlert struct {
Message string
}
func getPostEditPageTemplate(body string, alert PostEditPageAlert) []byte {
func getPostEditPageTemplate(post post.Post, alert PostEditPageAlert) []byte {
navbarTmpl, _ := Container.GetPageUseCase.GetPage("componentNavbar", nil)
postTmpl, _ := Container.GetPageUseCase.GetPage("postEdit", map[string]interface{}{
"Navbar": template.HTML(navbarTmpl),
"Head": headTmpl,
"Body": body,
"Alert": alert,
"Navbar": template.HTML(navbarTmpl),
"Head": headTmpl,
"Post": post,
"Alert": alert,
"Secured": os.Getenv("ENVIRONMENT") == "production",
})
return postTmpl
}
@@ -27,7 +30,7 @@ func PostPostEditPage(w http.ResponseWriter, r *http.Request) {
postID := chi.URLParam(r, "id")
postIDint, err := strconv.Atoi(postID)
if err != nil {
_, _ = w.Write(getPostEditPageTemplate("", PostEditPageAlert{
_, _ = w.Write(getPostEditPageTemplate(post.Post{}, PostEditPageAlert{
IsError: true,
Message: "Could not find the requested post.",
}))
@@ -37,17 +40,17 @@ func PostPostEditPage(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
postBody := r.FormValue("postBody")
post, _ := Container.GetPostUseCase.GetPost(uint32(postIDint))
err = Container.UpdatePostUseCase.UpdateBody(post.ID, postBody)
getPost, _ := Container.GetPostUseCase.GetPost(uint32(postIDint))
updatedPost, err := Container.UpdatePostUseCase.UpdateBody(getPost.ID, postBody)
if err != nil {
_, _ = w.Write(getPostEditPageTemplate(postBody, PostEditPageAlert{
_, _ = w.Write(getPostEditPageTemplate(post.Post{Body: postBody}, PostEditPageAlert{
IsError: true,
Message: "Could not save the post: " + err.Error(),
}))
return
}
_, _ = w.Write(getPostEditPageTemplate(postBody, PostEditPageAlert{
_, _ = w.Write(getPostEditPageTemplate(updatedPost, PostEditPageAlert{
IsError: false,
Message: "Post successfully edited!",
}))
@@ -61,9 +64,9 @@ func GetPostEditPage(w http.ResponseWriter, r *http.Request) {
return
}
post, _ := Container.GetPostUseCase.GetPost(uint32(postIDint))
getPost, _ := Container.GetPostUseCase.GetPost(uint32(postIDint))
_, _ = w.Write(getPostEditPageTemplate(post.Body, PostEditPageAlert{
_, _ = w.Write(getPostEditPageTemplate(getPost, PostEditPageAlert{
IsError: false,
Message: "",
}))
+11
View File
@@ -0,0 +1,11 @@
package gateways
import (
"GoCMS/domain/image"
"mime/multipart"
)
type IImageRepository interface {
Create(file multipart.File, fileHeader multipart.FileHeader) (image.Image, error)
Delete(id uint32) error
}
+2 -1
View File
@@ -9,6 +9,7 @@ type IPostRepository interface {
GetByName(name string) (post.Post, error)
GetAll() []post.Post
Create(post post.Post) (post.Post, error)
UpdateBody(id uint32, body string) error
UpdateBody(id uint32, body string) (post.Post, error)
Delete(id uint32) error
AddImage(postId uint32, imageId uint32) error
}
+23
View File
@@ -0,0 +1,23 @@
package image
import (
"time"
)
type Image struct {
ID uint32
Path string
PostID uint32
CreatedAt time.Time
UpdatedAt time.Time
}
func FromDB(id uint32, path string, postId uint32, createdAt time.Time, updatedAt time.Time) Image {
return Image{
ID: id,
Path: path,
PostID: postId,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
}
+24 -6
View File
@@ -1,13 +1,18 @@
package post
import "time"
import (
entity "GoCMS/adapters/secondary/gateways/models"
domain "GoCMS/domain/image"
"time"
)
type Post struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint32 `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Images []*domain.Image `json:"images"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func FromApi(
@@ -24,13 +29,26 @@ func FromDb(
id uint32,
title string,
body string,
images []*entity.Image,
createdAt time.Time,
updatedAt time.Time,
) Post {
domainImages := make([]*domain.Image, len(images))
for i, img := range images {
domainImage := domain.FromDB(
img.ID,
img.Path,
img.PostID,
img.CreatedAt,
img.UpdatedAt,
)
domainImages[i] = &domainImage
}
return Post{
ID: id,
Title: title,
Body: body,
Images: domainImages,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
+22
View File
@@ -0,0 +1,22 @@
package useCases
import (
"GoCMS/adapters/secondary/gateways"
"GoCMS/domain/image"
"gorm.io/gorm"
"mime/multipart"
)
type CreateImageUseCase struct {
imageRepository gateways.ImageRepository
}
func NewCreateImageUseCase(db *gorm.DB) *CreateImageUseCase {
return &CreateImageUseCase{
imageRepository: *gateways.NewImageRepository(db),
}
}
func (g *CreateImageUseCase) CreateImage(file multipart.File, fileHeader multipart.FileHeader) (image.Image, error) {
return g.imageRepository.Create(file, fileHeader)
}
+20
View File
@@ -0,0 +1,20 @@
package useCases
import (
"GoCMS/adapters/secondary/gateways"
"gorm.io/gorm"
)
type DeleteImageUseCase struct {
imageRepository gateways.ImageRepository
}
func NewDeleteImageUseCase(db *gorm.DB) *DeleteImageUseCase {
return &DeleteImageUseCase{
imageRepository: *gateways.NewImageRepository(db),
}
}
func (g *DeleteImageUseCase) DeleteImage(imageId uint32) error {
return g.imageRepository.Delete(imageId)
}
+6 -1
View File
@@ -2,6 +2,7 @@ package useCases
import (
"GoCMS/adapters/secondary/gateways"
"GoCMS/domain/post"
"gorm.io/gorm"
)
@@ -15,6 +16,10 @@ func NewUpdatePostUseCase(db *gorm.DB) *UpdatePostUseCase {
}
}
func (g *UpdatePostUseCase) UpdateBody(id uint32, body string) error {
func (g *UpdatePostUseCase) UpdateBody(id uint32, body string) (post.Post, error) {
return g.postRepository.UpdateBody(id, body)
}
func (g *UpdatePostUseCase) AddImage(postId uint32, imageId uint32) error {
return g.postRepository.AddImage(postId, imageId)
}