diff --git a/database/imagestore.go b/database/imagestore.go new file mode 100644 index 0000000..da6cfd3 --- /dev/null +++ b/database/imagestore.go @@ -0,0 +1,127 @@ +/* + * Amsterdam Web Communities System + * Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +// The database package contains database management and storage logic. +package database + +import ( + "database/sql" + "fmt" +) + +// ImageStore is the structure for the image store table. +type ImageStore struct { + ImgId int32 `db:"imgid"` + TypeCode int16 `db:"typecode"` + OwnerID *int32 `db:"ownerid"` + MimeType string `db:"mimetype"` + Length int32 `db:"length"` + Data []byte `db:"data"` +} + +// Values for the TypeCode field of ImageStore. +const ( + ImageTypeUserPhoto = int16(1) + ImageTypeCommunityLogo = int16(2) +) + +// Save persists the ImageStore record to the database. +func (img *ImageStore) Save() error { + var err error + if img.ImgId > 0 { + _, err = amdb.NamedExec(`UPDATE imagestore SET typecode = :typecode, ownerid = :ownerid, mimetype = :mimetype, + length = :length, data = :data WHERE imgid = :imgid`, img) + } else { + var rs sql.Result + rs, err = amdb.NamedExec(`INSERT INTO imagestore (typecode, ownerid, mimetype, length, data) + VALUES (:typecode, :ownerid, :mimetype, :length, :data)`, img) + if err == nil { + var lii int64 + lii, err = rs.LastInsertId() + if err == nil { + img.ImgId = int32(lii) + } + } + } + return err +} + +/* AmLoadImage loads an image from the database. + * Parameters: + * id - The ID of the image to be loaded. + * Returns: + * Pointer to ImageStore, or nil. + * Standard Go error status. + */ +func AmLoadImage(id int32) (*ImageStore, error) { + var dbdata []ImageStore + err := amdb.Select(&dbdata, "SELECT * FROM imagestore WHERE imgid = ?", id) + if err != nil { + return nil, err + } + if len(dbdata) == 0 { + return nil, fmt.Errorf("image ID %d not found", id) + } else if len(dbdata) > 1 { + return nil, fmt.Errorf("image ID %d too many images (%d)", id, len(dbdata)) + } + return &(dbdata[0]), nil +} + +/* AmStoreImage stores an image in the database, overwriting one with the same type code and owner if it exists. + * Parameters: + * typecode - Type code for the image. + * owner - Owner Id for the image (UID or community ID) + * mimetype - MIME type of the image. + * data - Bytes of the actual image. + * Returns: + * Pointer to ImageStore, or nil. + * Standard Go error status. + */ +func AmStoreImage(typecode int16, owner int32, mimetype string, data []byte) (*ImageStore, error) { + rs, err := amdb.Query("SELECT imgid FROM imagestore WHERE typecode = ? AND ownerid = ?", typecode, owner) + if err != nil { + return nil, err + } + var img *ImageStore + if rs.Next() { + var id int32 + rs.Scan(&id) + img, err = AmLoadImage(id) + if err != nil { + return nil, err + } + img.MimeType = mimetype + img.Length = int32(len(data)) + img.Data = data + } else { + img = &ImageStore{ + ImgId: -1, + TypeCode: typecode, + OwnerID: &owner, + MimeType: mimetype, + Length: int32(len(data)), + Data: data, + } + } + err = img.Save() + if err != nil { + return nil, err + } + return img, nil +} + +/* AmDeleteImage erases an image from the database. + * Parameters: + * id - The ID of the image to be deleted. + * Returns: + * Standard Go error status. + */ +func AmDeleteImage(id int32) error { + _, err := amdb.Exec("DELETE FROM imagestore WHERE imgid = ?", id) + return err +} diff --git a/go.mod b/go.mod index 8e7ccb7..9476a4f 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/alexflint/go-arg v1.6.0 github.com/biter777/countries v1.7.5 github.com/bits-and-blooms/bitset v1.24.0 + github.com/disintegration/imaging v1.6.2 github.com/go-sql-driver/mysql v1.9.3 github.com/gorilla/sessions v1.4.0 github.com/hashicorp/golang-lru v1.0.2 @@ -31,6 +32,7 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.38.0 // indirect + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect golang.org/x/net v0.40.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/time v0.11.0 // indirect diff --git a/go.sum b/go.sum index 5f96b0f..7e8783d 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= @@ -61,12 +63,15 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= diff --git a/main.go b/main.go index 72cef4e..31ea613 100644 --- a/main.go +++ b/main.go @@ -55,6 +55,7 @@ func setupEcho() *echo.Echo { e.GET("/profile", ui.AmWrap(EditProfileForm)) e.POST("/profile", ui.AmWrap(EditProfile)) e.GET("/profile_photo", ui.AmWrap(ProfilePhotoForm)) + e.POST("/profile_photo", ui.AmWrap(ProfilePhoto)) return e } diff --git a/ui/amcontext.go b/ui/amcontext.go index 297ec3f..73a916e 100644 --- a/ui/amcontext.go +++ b/ui/amcontext.go @@ -12,6 +12,7 @@ package ui import ( "bytes" + "mime/multipart" "net/http" "strconv" "time" @@ -34,6 +35,7 @@ type AmContext interface { FormField(string) string FormFieldInt(string) (int, error) FormFieldIsSet(string) bool + FormFile(string) (*multipart.FileHeader, error) RC() int OutputType() string Parameter(string) string @@ -127,6 +129,11 @@ func (c *amContext) FormFieldIsSet(name string) bool { return req.Form.Has(name) } +// FormFile returns a "file" parameter from a multipart upload form. +func (c *amContext) FormFile(name string) (*multipart.FileHeader, error) { + return c.echoContext.FormFile(name) +} + // RC returns the HTTP result code for the current operation. func (c *amContext) RC() int { return c.httprc diff --git a/ui/images.go b/ui/images.go index c831d27..d27117b 100644 --- a/ui/images.go +++ b/ui/images.go @@ -11,12 +11,22 @@ package ui import ( + "bytes" "embed" "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" "mime" + "mime/multipart" "net/http" "path/filepath" + "strconv" "strings" + + "git.erbosoft.com/amy/amsterdam/database" + "github.com/disintegration/imaging" ) //go:embed static_images/* @@ -43,17 +53,69 @@ func mimeTypeFromFilename(filename string) string { func AmServeImage(ctxt AmContext) (string, any, error) { components := strings.SplitAfter(ctxt.URLPath(), "/") var err error = nil - var b []byte if len(components) == 4 { - if components[2] == "builtin/" { + switch components[2] { + case "builtin/": + var b []byte b, err = static_images.ReadFile(filepath.Join("static_images", components[3])) if err == nil { ctxt.SetOutputType(mimeTypeFromFilename(components[3])) return "bytes", b, nil } + case "store/": + var id int + id, err = strconv.Atoi(components[3]) + if err == nil { + var img *database.ImageStore + img, err = database.AmLoadImage(int32(id)) + if err == nil { + ctxt.SetOutputType(img.MimeType) + return "bytes", img.Data, nil + } + } } } ctxt.SetRC(http.StatusNotFound) // TODO: improve this error reporting return "string", fmt.Sprintf("File not found: %s", ctxt.URLPath()), err } + +func AmProcessUploadedImage(fileheader *multipart.FileHeader, width, height int) ([]byte, string, error) { + // open the file + file, err := fileheader.Open() + if err != nil { + return nil, "", err + } + defer file.Close() + + // load the image from the file + img, format, err := image.Decode(file) + if err != nil { + return nil, "", err + } + + // resize the image using high-quality Lanczos filter + resized := imaging.Resize(img, width, height, imaging.Lanczos) + + // re-encode it to the original format, or JPEG if that's not possible + var buf bytes.Buffer + var outType string + switch strings.ToLower(format) { + case "jpeg", "jpg": + err = jpeg.Encode(&buf, resized, &jpeg.Options{Quality: 90}) + outType = "image/jpeg" + case "gif": + err = gif.Encode(&buf, resized, &gif.Options{NumColors: 256}) + outType = "image/gif" + case "png": + err = png.Encode(&buf, resized) + outType = "image/png" + default: + err = jpeg.Encode(&buf, resized, &jpeg.Options{Quality: 90}) + outType = "image/jpeg" + } + if err != nil { + return nil, "", err + } + return buf.Bytes(), outType, nil +} diff --git a/ui/views/photo_upload.jet b/ui/views/photo_upload.jet index 7efa93d..5b05e7f 100644 --- a/ui/views/photo_upload.jet +++ b/ui/views/photo_upload.jet @@ -13,6 +13,20 @@
{{ CapitalizeString(errorMessage) }}.
+