mirror of
https://github.com/usememos/memos.git
synced 2025-01-15 00:54:53 +08:00
Add support for image thumbnail (#1641)
* Add a common function for resize image blob * Auto generate thumbnail for image resources * Auto thumbnail support for fetch image resources * Add support for image thumbnail in view * Fix missing error check * Fix es-lint check * Fix uncontrolled data used in path expression * Remove thumbnail while origin resource been deleted * Change the thumbnail's storage path --------- Co-authored-by: Athurg Feng <athurg@gooth.org>
This commit is contained in:
parent
9eafb6bfb5
commit
041be46732
3 changed files with 112 additions and 5 deletions
67
common/image.go
Normal file
67
common/image.go
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ThumbnailPath = ".thumbnail_cache"
|
||||||
|
|
||||||
|
func ResizeImageBlob(data []byte, maxSize int, mime string) ([]byte, error) {
|
||||||
|
var err error
|
||||||
|
var oldImage image.Image
|
||||||
|
|
||||||
|
switch mime {
|
||||||
|
case "image/jpeg":
|
||||||
|
oldImage, err = jpeg.Decode(bytes.NewReader(data))
|
||||||
|
case "image/png":
|
||||||
|
oldImage, err = png.Decode(bytes.NewReader(data))
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("mime %s is not support", mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := oldImage.Bounds()
|
||||||
|
if bounds.Dx() <= maxSize && bounds.Dy() <= maxSize {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
oldBounds := oldImage.Bounds()
|
||||||
|
|
||||||
|
dy := maxSize
|
||||||
|
r := float32(oldBounds.Dy()) / float32(maxSize)
|
||||||
|
dx := int(float32(oldBounds.Dx()) / r)
|
||||||
|
if oldBounds.Dx() > oldBounds.Dy() {
|
||||||
|
dx = maxSize
|
||||||
|
r = float32(oldBounds.Dx()) / float32(maxSize)
|
||||||
|
dy = int(float32(oldBounds.Dy()) / r)
|
||||||
|
}
|
||||||
|
|
||||||
|
newBounds := image.Rect(0, 0, dx, dy)
|
||||||
|
newImage := image.NewRGBA(newBounds)
|
||||||
|
for x := 0; x < newBounds.Dx(); x++ {
|
||||||
|
for y := 0; y < newBounds.Dy(); y++ {
|
||||||
|
newImage.Set(x, y, oldImage.At(int(float32(x)*r), int(float32(y)*r)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var newBuffer bytes.Buffer
|
||||||
|
switch mime {
|
||||||
|
case "image/jpeg":
|
||||||
|
err = jpeg.Encode(&newBuffer, newImage, nil)
|
||||||
|
case "image/png":
|
||||||
|
err = png.Encode(&newBuffer, newImage)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newBuffer.Bytes(), nil
|
||||||
|
}
|
|
@ -116,6 +116,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal storage service id").SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal storage service id").SetInternal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
publicID := common.GenUUID()
|
||||||
if storageServiceID == api.DatabaseStorage {
|
if storageServiceID == api.DatabaseStorage {
|
||||||
fileBytes, err := io.ReadAll(sourceFile)
|
fileBytes, err := io.ReadAll(sourceFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -162,6 +163,32 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to copy file").SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to copy file").SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if filetype == "image/jpeg" || filetype == "image/png" {
|
||||||
|
_, err := sourceFile.Seek(0, io.SeekStart)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to seek file").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileBytes, err := io.ReadAll(sourceFile)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to load file").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnailBytes, err := common.ResizeImageBlob(fileBytes, 302, filetype)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate thumbnail").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join(s.Profile.Data, common.ThumbnailPath)
|
||||||
|
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create directory").SetInternal(err)
|
||||||
|
}
|
||||||
|
err = os.WriteFile(filepath.Join(dir, publicID), thumbnailBytes, 0666)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create thumbnail").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resourceCreate = &api.ResourceCreate{
|
resourceCreate = &api.ResourceCreate{
|
||||||
CreatorID: userID,
|
CreatorID: userID,
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
|
@ -212,7 +239,6 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
publicID := common.GenUUID()
|
|
||||||
resourceCreate.PublicID = publicID
|
resourceCreate.PublicID = publicID
|
||||||
resource, err := s.Store.CreateResource(ctx, resourceCreate)
|
resource, err := s.Store.CreateResource(ctx, resourceCreate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -319,6 +345,12 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn(fmt.Sprintf("failed to delete local file with path %s", resource.InternalPath), zap.Error(err))
|
log.Warn(fmt.Sprintf("failed to delete local file with path %s", resource.InternalPath), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
thumbnailPath := filepath.Join(s.Profile.Data, common.ThumbnailPath, resource.PublicID)
|
||||||
|
err = os.Remove(thumbnailPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(fmt.Sprintf("failed to delete local thumbnail with path %s", thumbnailPath), zap.Error(err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resourceDelete := &api.ResourceDelete{
|
resourceDelete := &api.ResourceDelete{
|
||||||
|
@ -408,14 +440,22 @@ func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
|
||||||
|
|
||||||
blob := resource.Blob
|
blob := resource.Blob
|
||||||
if resource.InternalPath != "" {
|
if resource.InternalPath != "" {
|
||||||
src, err := os.Open(resource.InternalPath)
|
resourcePath := resource.InternalPath
|
||||||
|
if c.QueryParam("thumbnail") == "1" && (resource.Type == "image/jpeg" || resource.Type == "image/png") {
|
||||||
|
thumbnailPath := filepath.Join(s.Profile.Data, common.ThumbnailPath, resource.PublicID)
|
||||||
|
if _, err := os.Stat(thumbnailPath); err == nil {
|
||||||
|
resourcePath = thumbnailPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := os.Open(resourcePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to open the local resource: %s", resource.InternalPath)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to open the local resource: %s", resourcePath)).SetInternal(err)
|
||||||
}
|
}
|
||||||
defer src.Close()
|
defer src.Close()
|
||||||
blob, err = io.ReadAll(src)
|
blob, err = io.ReadAll(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to read the local resource: %s", resource.InternalPath)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to read the local resource: %s", resourcePath)).SetInternal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -46,7 +46,7 @@ const MemoResources: React.FC<Props> = (props: Props) => {
|
||||||
if (resource.type.startsWith("image")) {
|
if (resource.type.startsWith("image")) {
|
||||||
return (
|
return (
|
||||||
<SquareDiv key={resource.id} className="memo-resource">
|
<SquareDiv key={resource.id} className="memo-resource">
|
||||||
<img src={absolutifyLink(url)} onClick={() => handleImageClick(url)} decoding="async" loading="lazy" />
|
<img src={absolutifyLink(url) + "?thumbnail=1"} onClick={() => handleImageClick(url)} decoding="async" loading="lazy" />
|
||||||
</SquareDiv>
|
</SquareDiv>
|
||||||
);
|
);
|
||||||
} else if (resource.type.startsWith("video")) {
|
} else if (resource.type.startsWith("video")) {
|
||||||
|
|
Loading…
Reference in a new issue