From d8fc6e481f9d7c559a9aeb7cfeaf7866c521078f Mon Sep 17 00:00:00 2001 From: Daniil Tsivinsky Date: Mon, 16 Mar 2026 21:45:39 +0300 Subject: [PATCH] allow to upload images but also files, hmmmm --- auth/cookies.go | 45 ++++++++ auth/jwt.go | 48 ++++++++ db/migrations/20260316172048_init_images.sql | 27 +++++ ...260316180504_images_content_type_field.sql | 10 ++ docs/api/.gitignore | 9 ++ docs/api/auth/folder.yml | 7 ++ docs/api/auth/login user.yml | 22 ++++ docs/api/auth/register user.yml | 22 ++++ docs/api/environments/dev.yml | 4 + docs/api/images/delete image.yml | 19 ++++ docs/api/images/folder.yml | 7 ++ docs/api/images/get user images.yml | 15 +++ docs/api/images/upload new image.yml | 21 ++++ docs/api/opencollection.yml | 10 ++ go.mod | 3 +- go.sum | 2 + main.go | 104 ++++++++++++++++++ model/image.go | 63 +++++++++++ 18 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 auth/cookies.go create mode 100644 auth/jwt.go create mode 100644 db/migrations/20260316172048_init_images.sql create mode 100644 db/migrations/20260316180504_images_content_type_field.sql create mode 100644 docs/api/.gitignore create mode 100644 docs/api/auth/folder.yml create mode 100644 docs/api/auth/login user.yml create mode 100644 docs/api/auth/register user.yml create mode 100644 docs/api/environments/dev.yml create mode 100644 docs/api/images/delete image.yml create mode 100644 docs/api/images/folder.yml create mode 100644 docs/api/images/get user images.yml create mode 100644 docs/api/images/upload new image.yml create mode 100644 docs/api/opencollection.yml create mode 100644 model/image.go diff --git a/auth/cookies.go b/auth/cookies.go new file mode 100644 index 0000000..4100aef --- /dev/null +++ b/auth/cookies.go @@ -0,0 +1,45 @@ +package auth + +import ( + "fmt" + "net/http" + "time" +) + +func SetUserCookie(w http.ResponseWriter, token string, expiryTime time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: token, + Secure: true, + HttpOnly: true, + Path: "/", + Expires: expiryTime, + SameSite: http.SameSiteStrictMode, + }) +} + +func RemoveUserCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: "", + Secure: true, + HttpOnly: true, + Path: "/", + Expires: time.Now().Add(-time.Hour), + SameSite: http.SameSiteStrictMode, + }) +} + +func GetUserIdFromRequest(r *http.Request) (int64, error) { + c, err := r.Cookie("token") + if err != nil { + return -1, fmt.Errorf("no token cookie: %v", err) + } + + userId, err := ValidateUserToken(c.Value) + if err != nil { + return -1, fmt.Errorf("invalid token: %v", err) + } + + return userId, nil +} diff --git a/auth/jwt.go b/auth/jwt.go new file mode 100644 index 0000000..9cd62a9 --- /dev/null +++ b/auth/jwt.go @@ -0,0 +1,48 @@ +package auth + +import ( + "fmt" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var secretKey = os.Getenv("JWT_SECRET_KEY") + +type UserClaims struct { + jwt.RegisteredClaims + UserID int64 +} + +func GenerateUserToken(userId int64, expiryTime time.Time) (string, error) { + now := time.Now() + claims := UserClaims{ + UserID: userId, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expiryTime), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + return token.SignedString([]byte(secretKey)) +} + +func ValidateUserToken(token string) (int64, error) { + claims := &UserClaims{} + + parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) { + return []byte(secretKey), nil + }) + if err != nil { + return -1, fmt.Errorf("failed to parse token: %v", err) + } + + if !parsed.Valid { + return -1, fmt.Errorf("invalid token") + } + + return claims.UserID, nil +} diff --git a/db/migrations/20260316172048_init_images.sql b/db/migrations/20260316172048_init_images.sql new file mode 100644 index 0000000..19c9f6b --- /dev/null +++ b/db/migrations/20260316172048_init_images.sql @@ -0,0 +1,27 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE images ( + id varchar primary key not null, + data blob not null, + user_id integer not null, + created_at datetime default current_timestamp, + updated_at datetime default current_timestamp, + + FOREIGN KEY(user_id) REFERENCES users(id) +); + +CREATE TRIGGER update_images_updated_at +AFTER UPDATE ON images +FOR EACH ROW + BEGIN + UPDATE images + SET updated_at = current_timestamp + WHERE id = OLD.id; + END; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE images; +-- +goose StatementEnd + diff --git a/db/migrations/20260316180504_images_content_type_field.sql b/db/migrations/20260316180504_images_content_type_field.sql new file mode 100644 index 0000000..2d29b57 --- /dev/null +++ b/db/migrations/20260316180504_images_content_type_field.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE images ADD COLUMN content_type varchar default ''; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE images DROP COLUMN content_type; +-- +goose StatementEnd + diff --git a/docs/api/.gitignore b/docs/api/.gitignore new file mode 100644 index 0000000..e19311f --- /dev/null +++ b/docs/api/.gitignore @@ -0,0 +1,9 @@ +# Secrets +.env* + +# Dependencies +node_modules + +# OS files +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/docs/api/auth/folder.yml b/docs/api/auth/folder.yml new file mode 100644 index 0000000..120ac3e --- /dev/null +++ b/docs/api/auth/folder.yml @@ -0,0 +1,7 @@ +info: + name: auth + type: folder + seq: 1 + +request: + auth: inherit diff --git a/docs/api/auth/login user.yml b/docs/api/auth/login user.yml new file mode 100644 index 0000000..cb04f6e --- /dev/null +++ b/docs/api/auth/login user.yml @@ -0,0 +1,22 @@ +info: + name: login user + type: http + seq: 2 + +http: + method: POST + url: "{{base_url}}/api/auth/login" + body: + type: json + data: |- + { + "email": "daniil@tsivinsky.com", + "password": "Daniil2000" + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/docs/api/auth/register user.yml b/docs/api/auth/register user.yml new file mode 100644 index 0000000..cf1ea14 --- /dev/null +++ b/docs/api/auth/register user.yml @@ -0,0 +1,22 @@ +info: + name: register user + type: http + seq: 1 + +http: + method: POST + url: "{{base_url}}/api/auth/register" + body: + type: json + data: |- + { + "email": "daniil@tsivinsky.com", + "password": "Daniil2000" + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/docs/api/environments/dev.yml b/docs/api/environments/dev.yml new file mode 100644 index 0000000..695059d --- /dev/null +++ b/docs/api/environments/dev.yml @@ -0,0 +1,4 @@ +name: dev +variables: + - name: base_url + value: http://localhost:5000 diff --git a/docs/api/images/delete image.yml b/docs/api/images/delete image.yml new file mode 100644 index 0000000..022377a --- /dev/null +++ b/docs/api/images/delete image.yml @@ -0,0 +1,19 @@ +info: + name: delete image + type: http + seq: 3 + +http: + method: DELETE + url: "{{base_url}}/api/images/:id" + params: + - name: id + value: 019cf7de-0844-76d0-9d84-a4bbe7afc475 + type: path + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/docs/api/images/folder.yml b/docs/api/images/folder.yml new file mode 100644 index 0000000..5248075 --- /dev/null +++ b/docs/api/images/folder.yml @@ -0,0 +1,7 @@ +info: + name: images + type: folder + seq: 2 + +request: + auth: inherit diff --git a/docs/api/images/get user images.yml b/docs/api/images/get user images.yml new file mode 100644 index 0000000..afebb07 --- /dev/null +++ b/docs/api/images/get user images.yml @@ -0,0 +1,15 @@ +info: + name: get user images + type: http + seq: 2 + +http: + method: GET + url: "{{base_url}}/api/images" + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/docs/api/images/upload new image.yml b/docs/api/images/upload new image.yml new file mode 100644 index 0000000..208766b --- /dev/null +++ b/docs/api/images/upload new image.yml @@ -0,0 +1,21 @@ +info: + name: upload new image + type: http + seq: 1 + +http: + method: POST + url: "{{base_url}}/api/images" + body: + type: file + data: + - filePath: /home/daniil/Pictures/glizzy.png + contentType: image/png + selected: true + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/docs/api/opencollection.yml b/docs/api/opencollection.yml new file mode 100644 index 0000000..ca517fe --- /dev/null +++ b/docs/api/opencollection.yml @@ -0,0 +1,10 @@ +opencollection: 1.0.0 + +info: + name: image-storage +bundled: false +extensions: + bruno: + ignore: + - node_modules + - .git diff --git a/go.mod b/go.mod index 2103eae..f8b27a4 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module image-storage go 1.26.1 require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 github.com/jmoiron/sqlx v1.4.0 github.com/pressly/goose/v3 v3.27.0 golang.org/x/crypto v0.48.0 @@ -11,7 +13,6 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index 0db0b78..7c14897 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m 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= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/main.go b/main.go index 940cde6..7177571 100644 --- a/main.go +++ b/main.go @@ -3,9 +3,12 @@ package main import ( "encoding/json" "fmt" + "image-storage/auth" "image-storage/model" + "io" "log" "net/http" + "time" "github.com/jmoiron/sqlx" "golang.org/x/crypto/bcrypt" @@ -55,6 +58,13 @@ func main() { return fmt.Errorf("failed to populate user object after creating it: %v", err) } + expiryTime := time.Now().Add(time.Hour * 24 * 30) + token, err := auth.GenerateUserToken(user.ID, expiryTime) + if err != nil { + return fmt.Errorf("failed to generate access token: %v", err) + } + auth.SetUserCookie(w, token, expiryTime) + return srv.JSON(w, user, 201) }) @@ -84,9 +94,103 @@ func main() { return nil } + expiryTime := time.Now().Add(time.Hour * 24 * 30) + token, err := auth.GenerateUserToken(user.ID, expiryTime) + if err != nil { + return fmt.Errorf("failed to generate access token: %v", err) + } + auth.SetUserCookie(w, token, expiryTime) + return srv.JSON(w, user, 200) }) + srv.Handle("POST /api/images", func(w http.ResponseWriter, r *http.Request) error { + userId, err := auth.GetUserIdFromRequest(r) + if err != nil { + srv.Error(w, "unauthorized", err, 401) + return nil + } + + user := &model.User{Model: model.Model{ID: userId}} + if err := user.FindByID(db); err != nil { + srv.Error(w, "user not found", nil, 401) + return nil + } + + data, err := io.ReadAll(r.Body) + if err != nil { + return fmt.Errorf("failed to read request body: %v", err) + } + + img := &model.Image{ + UserID: user.ID, + Data: data, + ContentType: r.Header.Get("Content-Type"), + } + if err := img.Create(db); err != nil { + srv.Error(w, "failed to save image to database: %v", err, 400) + return nil + } + + return srv.JSON(w, img, 201) + }) + + srv.Handle("GET /api/images", func(w http.ResponseWriter, r *http.Request) error { + userId, err := auth.GetUserIdFromRequest(r) + if err != nil { + srv.Error(w, "unauthorized", err, 401) + return nil + } + + rows, err := db.Queryx("SELECT * FROM images WHERE user_id = ?", userId) + if err != nil { + srv.Error(w, "images not found", err, 400) + return nil + } + + images := []model.Image{} + for rows.Next() { + img := model.Image{} + if err := rows.StructScan(&img); err != nil { + log.Printf("failed to save image to struct: %v", err) + continue + } + images = append(images, img) + } + + return srv.JSON(w, images, 200) + }) + + srv.Handle("GET /images/{id}", func(w http.ResponseWriter, r *http.Request) error { + img := &model.Image{ID: r.PathValue("id")} + if err := img.FindByID(db); err != nil { + srv.Error(w, "image not found", nil, 404) + return nil + } + + w.Header().Set("Content-Type", img.ContentType) + w.Write(img.Data) + + return nil + }) + + srv.Handle("DELETE /api/images/{id}", func(w http.ResponseWriter, r *http.Request) error { + img := &model.Image{ID: r.PathValue("id")} + if err := img.FindByID(db); err != nil { + srv.Error(w, "image not found", nil, 404) + return nil + } + + if err := img.DeleteByID(db); err != nil { + srv.Error(w, "failed to delete image: %v", err, 500) + return nil + } + + return srv.JSON(w, struct { + Ok bool `json:"ok"` + }{true}, 200) + }) + if err := srv.ListenAndServe(); err != nil { log.Fatalf("failed to start http server: %v", err) } diff --git a/model/image.go b/model/image.go new file mode 100644 index 0000000..412145d --- /dev/null +++ b/model/image.go @@ -0,0 +1,63 @@ +package model + +import ( + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type Image struct { + // uuid + ID string `json:"id" db:"id"` + Data []byte `json:"-" db:"data"` + ContentType string `json:"contentType" db:"content_type"` + UserID int64 `json:"userId" db:"user_id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +func (img *Image) Create(db *sqlx.DB) error { + if img.ID == "" { + uid, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("failed to generate uuid: %v", err) + } + img.ID = uid.String() + } + + _, err := db.NamedExec("INSERT INTO images (id, data, user_id, content_type) VALUES (:id, :data, :user_id, :content_type)", map[string]any{ + "id": img.ID, + "data": img.Data, + "user_id": img.UserID, + "content_type": img.ContentType, + }) + if err != nil { + return err + } + + return nil +} + +func (img *Image) FindByID(db *sqlx.DB) error { + row := db.QueryRowx("SELECT * FROM images WHERE id = ?", img.ID) + if row.Err() != nil { + return row.Err() + } + + if err := row.StructScan(img); err != nil { + return err + } + + return nil +} + +func (img *Image) DeleteByID(db *sqlx.DB) error { + _, err := db.Exec("DELETE FROM images WHERE id = ?", img.ID) + if err != nil { + return err + } + + return nil +}