diff --git a/go.mod b/go.mod index 350f51d..3e151c4 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ugorji/go/codec v1.2.6 // indirect golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce // indirect + golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect golang.org/x/text v0.3.7 // indirect google.golang.org/protobuf v1.27.1 // indirect diff --git a/go.sum b/go.sum index 969d35b..1b5c38d 100644 --- a/go.sum +++ b/go.sum @@ -429,6 +429,8 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 h1:/eM0PCrQI2xd471rI+snWuu251/+/jpBpZqir2mPdnU= +golang.org/x/image v0.0.0-20220722155232-062f8c9fd539/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/main.go b/main.go index 150ab8b..7d3ac13 100644 --- a/main.go +++ b/main.go @@ -28,6 +28,9 @@ func main() { minZoomArg := flag.Int("min-zoom", 1, "Minimum zoom that will be sent to clients.") defaultCenterLatArg := flag.Float64("default-center-lat", 0, "Latitude of the default map center.") defaultCenterLngArg := flag.Float64("default-center-lng", 0, "Longitude of the default map center.") + maxPhotoXArg := flag.Int("max-photo-width", 0, "Maximum width of photos. 0 means no limit.") + maxPhotoYArg := flag.Int("max-photo-height", 0, "Maximum height of photos. 0 means no limit.") + photoQualityArg := flag.Int("photo-quality", 90, "Photo JPEG quality.") flag.Parse() @@ -42,6 +45,11 @@ func main() { t = time.Now() } + if *maxPhotoXArg < 0 || *maxPhotoYArg < 0 { + fmt.Fprintln(os.Stderr, "Max photo width and height cannot be less than 0.") + os.Exit(1) + } + s := server.New(server.ServerConfig{ VersionHash: sha1ver, BuildTime: &t, @@ -55,6 +63,9 @@ func main() { Lat: *defaultCenterLatArg, Lng: *defaultCenterLngArg, }, + MaxPhotoX: *maxPhotoXArg, + MaxPhotoY: *maxPhotoYArg, + PhotoQuality: *photoQualityArg, }) sigs := make(chan os.Signal, 1) diff --git a/server/handling.go b/server/handling.go index 7d53233..9052779 100644 --- a/server/handling.go +++ b/server/handling.go @@ -4,7 +4,7 @@ import ( "encoding/json" "errors" "fmt" - "io/ioutil" + "io" "math" "net/http" "os" @@ -257,7 +257,7 @@ func (s *Server) handlePOSTDataMultipart(gc *gin.Context) { internalError(gc, fmt.Errorf("failed to open 'data' 'file': %w", err)) return } - dataBytes, err := ioutil.ReadAll(df) + dataBytes, err := io.ReadAll(df) if err != nil { internalError(gc, fmt.Errorf("failed to open 'data' 'file': %w", err)) return diff --git a/server/server.go b/server/server.go index a8db931..335f768 100644 --- a/server/server.go +++ b/server/server.go @@ -2,6 +2,7 @@ package server import ( "archive/zip" + "bytes" "context" "encoding/json" "fmt" @@ -48,6 +49,9 @@ type ServerConfig struct { ReinitDB bool MinZoom int DefaultCenter models.Coords + MaxPhotoX int + MaxPhotoY int + PhotoQuality int } func New(config ServerConfig) *Server { @@ -62,6 +66,8 @@ func (s *Server) Run(ctx context.Context) { s.ctx = ctx s.setupDB() + defer s.cleanupDb() + s.setupTiles() router := s.setupRouter() @@ -86,6 +92,9 @@ func (s *Server) Run(ctx context.Context) { if err := server.Shutdown(ctx); err != nil { s.log.WithError(err).Fatal("Server forced to shutdown.") } +} + +func (s *Server) cleanupDb() { close(s.checkpointNotice) s.log.Info("Closing db connection pool...") s.dbpool.Close() @@ -96,6 +105,10 @@ func (s *Server) Run(ctx context.Context) { s.log.WithError(err).Error("Failed to open connection for final checkpoint.") return } + err = sqlitex.Exec(conn, "vacuum", nil) + if err != nil { + s.log.WithError(err).Error("Failed to vacuum db.") + } s.checkpointDb(conn, true) conn.Close() } @@ -710,11 +723,22 @@ func (s *Server) addPhotos(conn *sqlite.Conn, createdFeatureMapping, addedFeatur return fmt.Errorf("failed to clear bindings of prepared statement: %w", err) } + resizedPhoto, err := func() ([]byte, error) { + defer photo.File.Close() + + resized, err := resizePhoto(s.config.MaxPhotoX, s.config.MaxPhotoY, s.config.PhotoQuality, photo.File) + if err != nil { + return nil, err + } + + return resized, nil + }() + stmt.BindInt64(1, int64(featureID)) stmt.BindText(2, thumbnail.ContentType) - stmt.BindText(3, photo.ContentType) + stmt.BindText(3, "image/jpeg") stmt.BindZeroBlob(4, thumbnail.Size) - stmt.BindZeroBlob(5, photo.Size) + stmt.BindZeroBlob(5, int64(len(resizedPhoto))) _, err = stmt.Step() if err != nil { @@ -742,9 +766,8 @@ func (s *Server) addPhotos(conn *sqlite.Conn, createdFeatureMapping, addedFeatur } err = func() error { defer blob.Close() - defer photo.File.Close() - _, err := io.Copy(blob, photo.File) + _, err = io.Copy(blob, bytes.NewBuffer(resizedPhoto)) return err }() if err != nil { diff --git a/server/utils.go b/server/utils.go index 2c92cfb..b08b295 100644 --- a/server/utils.go +++ b/server/utils.go @@ -1,6 +1,15 @@ package server import ( + "bytes" + "fmt" + "image" + "image/jpeg" + "io" + "math" + + "golang.org/x/image/draw" + "cernobor.cz/oko-server/models" ) @@ -32,3 +41,57 @@ func isUniqueFeatureID(features []models.Feature) bool { } return true } + +func resizePhoto(maxX, maxY, quality int, data io.Reader) ([]byte, error) { + src, _, err := image.Decode(data) + if err != nil { + return nil, fmt.Errorf("failed to decode image: %w", err) + } + + if maxX == 0 && maxY == 0 { + output := &bytes.Buffer{} + jpeg.Encode(output, src, &jpeg.Options{ + Quality: 90, + }) + + return output.Bytes(), nil + } + + var dst draw.Image + srcX := src.Bounds().Max.X + srcY := src.Bounds().Max.Y + srcRatio := float64(srcX) / float64(srcY) + if maxX == 0 && srcY > maxY { + newX := int(math.Round(float64(maxY) * srcRatio)) + newY := maxY + dst = image.NewRGBA(image.Rect(0, 0, newX, newY)) + draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + } else if maxY == 0 && srcX > maxX { + newX := maxX + newY := int(math.Round(float64(maxX) / srcRatio)) + dst = image.NewRGBA(image.Rect(0, 0, newX, newY)) + draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + } else if srcX > maxX || srcY > maxY { + tgtRatio := float64(maxX) / float64(maxY) + var newX, newY int + if srcRatio > tgtRatio { + newX = maxX + newY = int(math.Round(float64(maxX) / srcRatio)) + } else { + newX = int(math.Round(float64(maxY) * srcRatio)) + newY = maxY + } + dst = image.NewRGBA(image.Rect(0, 0, newX, newY)) + draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + } else { + dst = image.NewRGBA(image.Rect(0, 0, srcX, srcY)) + draw.Copy(dst, image.Point{X: 0, Y: 0}, src, src.Bounds(), draw.Over, nil) + } + + output := &bytes.Buffer{} + jpeg.Encode(output, dst, &jpeg.Options{ + Quality: quality, + }) + + return output.Bytes(), nil +}