Skip to content

Commit 0bed7c2

Browse files
authored
Merge pull request #33 from cartabinaria/images
Images
2 parents 9cf545b + d8fa4f7 commit 0bed7c2

File tree

12 files changed

+637
-22
lines changed

12 files changed

+637
-22
lines changed

api/images.go

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"log/slog"
9+
"net/http"
10+
"os"
11+
"path/filepath"
12+
13+
"github.com/cartabinaria/auth/pkg/httputil"
14+
"github.com/cartabinaria/auth/pkg/middleware"
15+
"github.com/cartabinaria/polleg/models"
16+
"github.com/cartabinaria/polleg/util"
17+
"github.com/google/uuid"
18+
"github.com/kataras/muxie"
19+
)
20+
21+
type ImageType string
22+
23+
var (
24+
// File signatures (magic numbers)
25+
pngSignature = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
26+
jpegSignature = []byte{0xFF, 0xD8, 0xFF}
27+
28+
ImageTypePNG ImageType = "image/png"
29+
ImageTypeJPEG ImageType = "image/jpeg"
30+
)
31+
32+
const (
33+
MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5 MB
34+
MAX_TOTAL_SIZE = 200 * 1024 * 1024 // 200 MB per user
35+
MAX_NUMBER = 100 // 100 images per user
36+
)
37+
38+
// checkFileType reads the first few bytes of a file and compares them with known signatures.
39+
// As it takes a reader as input, the caller should ensure to reset the reader's position if needed (e.g., using Seek).
40+
func checkFileType(reader io.Reader) (ImageType, error) {
41+
// Read first 8 bytes for signature checking
42+
buff := make([]byte, 8)
43+
n, err := reader.Read(buff)
44+
if err != nil || n < 8 {
45+
return "", fmt.Errorf("error reading file header: %v", err)
46+
}
47+
48+
// Check signatures
49+
if bytes.HasPrefix(buff, pngSignature) {
50+
return ImageTypePNG, nil
51+
}
52+
if bytes.HasPrefix(buff, jpegSignature) {
53+
return ImageTypeJPEG, nil
54+
}
55+
56+
return "", fmt.Errorf("unsupported file type")
57+
}
58+
59+
// @Summary Get an image
60+
// @Description Given an image ID, return the image
61+
// @Tags image
62+
// @Param id path string true "Image id"
63+
// @Produce json
64+
// @Success 200 {file} binary
65+
// @Failure 400 {object} httputil.ApiError
66+
// @Router /questions/{id} [get]
67+
func GetImageHandler(imagesPath string) http.HandlerFunc {
68+
return func(res http.ResponseWriter, req *http.Request) {
69+
if req.Method != http.MethodGet {
70+
http.Error(res, "invalid method", http.StatusMethodNotAllowed)
71+
return
72+
}
73+
74+
imgID := muxie.GetParam(res, "id")
75+
76+
_, err := uuid.Parse(imgID)
77+
if err != nil {
78+
httputil.WriteError(res, http.StatusBadRequest, "invalid image id")
79+
return
80+
}
81+
82+
fullPath := filepath.Join(imagesPath, imgID)
83+
84+
http.ServeFile(res, req, fullPath)
85+
}
86+
}
87+
88+
// @Summary Insert a new image
89+
// @Description Insert a new image
90+
// @Tags image
91+
// @Accept multipart/form-data
92+
// @Param image formData file true "Image to upload"
93+
// @Produce json
94+
// @Success 200 {object} models.ImageResponse
95+
// @Failure 400 {object} httputil.ApiError
96+
// @Router /images [post]
97+
func PostImageHandler(imagesPath string) http.HandlerFunc {
98+
return func(w http.ResponseWriter, r *http.Request) {
99+
if r.Method != http.MethodPost {
100+
http.Error(w, "invalid method", http.StatusMethodNotAllowed)
101+
return
102+
}
103+
104+
db := util.GetDb()
105+
user := middleware.MustGetUser(r)
106+
_, err := util.GetOrCreateUserByID(db, user.ID, user.Username)
107+
if err != nil {
108+
slog.With("user", user, "err", err).Error("error while getting or creating the user-alias association")
109+
httputil.WriteError(w, http.StatusBadRequest, "could not insert the answer")
110+
return
111+
}
112+
113+
totalSize, err := util.GetTotalSizeOfImagesByUser(db, user.ID)
114+
if err != nil {
115+
slog.With("user", user, "err", err).Error("error while getting total size of images by user")
116+
httputil.WriteError(w, http.StatusInternalServerError, "could not insert the image")
117+
return
118+
}
119+
120+
if totalSize > MAX_TOTAL_SIZE {
121+
httputil.WriteError(w, http.StatusBadRequest, "user quota exceeded")
122+
return
123+
}
124+
125+
totalNumber, err := util.GetNumberOfImagesByUser(db, user.ID)
126+
if err != nil {
127+
slog.With("user", user, "err", err).Error("error while getting total number of images by user")
128+
httputil.WriteError(w, http.StatusInternalServerError, "could not insert the image")
129+
return
130+
}
131+
132+
if totalNumber >= MAX_NUMBER {
133+
httputil.WriteError(w, http.StatusBadRequest, "user image count quota exceeded")
134+
return
135+
}
136+
137+
file, fileHeader, err := r.FormFile("file")
138+
if err != nil {
139+
slog.With("err", err).Error("couldn't get file from form")
140+
httputil.WriteError(w, http.StatusBadRequest, "couldn't get file from form")
141+
return
142+
}
143+
defer file.Close()
144+
145+
slog.With("filename", fileHeader.Filename, "size", fileHeader.Size, "Type: ", fileHeader.Header.Get("Content-Type")).Info("received file")
146+
147+
if fileHeader.Size > MAX_IMAGE_SIZE {
148+
httputil.WriteError(w, http.StatusBadRequest, "file too large")
149+
return
150+
}
151+
152+
fType := fileHeader.Header.Get("Content-Type")
153+
if fType != "image/png" && fType != "image/jpeg" {
154+
httputil.WriteError(w, http.StatusBadRequest, "unsupported file type")
155+
return
156+
}
157+
158+
if fpCheck, err := checkFileType(file); err != nil {
159+
slog.With("err", err).Error("couldn't check file type")
160+
httputil.WriteError(w, http.StatusBadRequest, "couldn't check file type")
161+
return
162+
} else if string(fpCheck) != fType {
163+
slog.With("expected", fType, "got", fpCheck).Error("file type mismatch")
164+
httputil.WriteError(w, http.StatusBadRequest, "file type mismatch")
165+
return
166+
}
167+
168+
_, err = file.Seek(0, io.SeekStart)
169+
if err != nil {
170+
slog.With("err", err).Error("couldn't seek file")
171+
httputil.WriteError(w, http.StatusInternalServerError, "couldn't seek file")
172+
return
173+
}
174+
175+
uuid, err := uuid.NewV7()
176+
if err != nil {
177+
slog.With("err", err).Error("couldn't generate uuid")
178+
httputil.WriteError(w, http.StatusInternalServerError, "couldn't generate uuid")
179+
return
180+
}
181+
fullPath := filepath.Join(imagesPath, uuid.String())
182+
183+
destFile, err := os.Create(fullPath)
184+
if err != nil {
185+
slog.With("err", err).Error("couldn't create file")
186+
httputil.WriteError(w, http.StatusInternalServerError, "couldn't create file")
187+
return
188+
}
189+
defer destFile.Close()
190+
191+
written, err := io.CopyN(destFile, file, MAX_IMAGE_SIZE+1)
192+
switch {
193+
case err == io.EOF:
194+
// File is within size limits - this is good!
195+
slog.With("path", fullPath, "size", written).Info("file successfully saved")
196+
case err != nil:
197+
// Unexpected error occurred
198+
slog.With("err", err).Error("couldn't save file")
199+
if cleanupErr := os.Remove(fullPath); cleanupErr != nil {
200+
slog.With("err", cleanupErr, "path", fullPath).Error("couldn't remove file after failed save")
201+
}
202+
httputil.WriteError(w, http.StatusInternalServerError, "couldn't save file")
203+
return
204+
case written > MAX_IMAGE_SIZE:
205+
// File exceeded size limit
206+
slog.With("size", written, "max", MAX_IMAGE_SIZE).Error("file too large")
207+
if cleanupErr := os.Remove(fullPath); cleanupErr != nil {
208+
slog.With("err", cleanupErr, "path", fullPath).Error("couldn't remove file after failed save")
209+
}
210+
httputil.WriteError(w, http.StatusBadRequest, "file too large")
211+
return
212+
}
213+
214+
_, err = util.CreateImage(db, uuid.String(), user.ID, uint(written))
215+
if err != nil {
216+
slog.With("err", err).Error("couldn't create image record")
217+
if cleanupErr := os.Remove(fullPath); cleanupErr != nil {
218+
slog.With("err", cleanupErr, "path", fullPath).Error("couldn't remove file after failed db record creation")
219+
}
220+
httputil.WriteError(w, http.StatusInternalServerError, "could not insert the image")
221+
return
222+
}
223+
224+
w.Header().Set("Content-Type", "application/json")
225+
json.NewEncoder(w).Encode(models.ImageResponse{
226+
ID: uuid.String(),
227+
URL: fullPath,
228+
})
229+
}
230+
}

cmd/polleg.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,16 @@ type Config struct {
2424
DbURI string `toml:"db_uri" required:"true"`
2525
AuthURI string `toml:"auth_uri" required:"true"`
2626
DbTrace bool `toml:"db_trace"`
27+
28+
ImagesPath string `toml:"images_path"`
2729
}
2830

2931
var (
3032
// Default config values
3133
config = Config{
32-
Listen: "0.0.0.0:3001",
33-
AuthURI: "http://localhost:3000",
34+
Listen: "0.0.0.0:3001",
35+
AuthURI: "http://localhost:3000",
36+
ImagesPath: "./images",
3437
}
3538
)
3639

@@ -59,12 +62,18 @@ func main() {
5962
os.Exit(1)
6063
}
6164
db := util.GetDb()
62-
err = db.AutoMigrate(&proposal.Proposal{}, &models.Question{}, &models.Answer{}, &models.Vote{}, &models.User{})
65+
err = db.AutoMigrate(&proposal.Proposal{}, &models.Question{}, &models.Answer{}, &models.Vote{}, &models.User{}, &models.Image{})
6366
if err != nil {
6467
slog.Error("AutoMigrate failed", "err", err)
6568
os.Exit(1)
6669
}
6770

71+
err = os.Mkdir(config.ImagesPath, 0755)
72+
if err != nil && !os.IsExist(err) {
73+
slog.Error("failed to create images directory", "err", err)
74+
os.Exit(1)
75+
}
76+
6877
mux := muxie.NewMux()
6978
authMiddleware, err := middleware.NewAuthMiddleware(config.AuthURI)
7079
if err != nil {
@@ -80,6 +89,8 @@ func main() {
8089
Handle("GET", authOptionalChain.ForFunc(api.GetQuestionHandler)).
8190
Handle("DELETE", authChain.ForFunc(api.DelQuestionHandler)))
8291

92+
mux.Handle("/images/:id", authOptionalChain.ForFunc(api.GetImageHandler(config.ImagesPath)))
93+
8394
// authenticated queries
8495
// insert new answer
8596
mux.Handle("/answers", authChain.ForFunc(api.PostAnswerHandler))
@@ -88,11 +99,18 @@ func main() {
8899
// insert new doc and quesions
89100
mux.Handle("/documents", authChain.ForFunc(api.PostDocumentHandler))
90101
mux.Handle("/answers/:id", authChain.ForFunc(api.DelAnswerHandler))
102+
103+
// Images
104+
mux.Handle("/images", authChain.ForFunc(api.PostImageHandler(config.ImagesPath)))
105+
91106
// proposal managers
92107
mux.Handle("/proposals", authChain.ForFunc(proposal.ProposalHandler))
93108
mux.Handle("/proposals/:id", authChain.ForFunc(proposal.ProposalByIdHandler))
94109
mux.Handle("/proposals/document/:id", authChain.ForFunc(proposal.ProposalByDocumentHandler))
95110

111+
// start garbage collector
112+
go util.GarbageCollector(config.ImagesPath)
113+
96114
slog.Info("listening at", "address", config.Listen)
97115
err = http.ListenAndServe(config.Listen, mux)
98116
if err != nil {

config.example.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
listen = "0.0.0.0:3001"
22
client_urls = ["http://localhost:5173"]
3-
db_uri = "host=localhost user=postgres password=postgres dbname=postgres port=5432 sslmode=disable TimeZone=Europe/Rome"
3+
db_uri = "host=localhost user=user password=password123 dbname=postgres port=5432 sslmode=disable TimeZone=Europe/Rome"
44
auth_uri = "http://localhost:3000"
55
db_trace = true
6+
images_path = "./images"

docker-compose.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ services:
88
ports:
99
- 5432:5432
1010
environment:
11-
POSTGRES_USER: berlusconi
12-
POSTGRES_PASSWORD: bungabunga
11+
POSTGRES_USER: user
12+
POSTGRES_PASSWORD: password123
1313

1414
adminer:
1515
image: adminer

0 commit comments

Comments
 (0)