generated from tsivinsky/go-template
allow to upload images
but also files, hmmmm
This commit is contained in:
45
auth/cookies.go
Normal file
45
auth/cookies.go
Normal file
@@ -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
|
||||
}
|
||||
48
auth/jwt.go
Normal file
48
auth/jwt.go
Normal file
@@ -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
|
||||
}
|
||||
27
db/migrations/20260316172048_init_images.sql
Normal file
27
db/migrations/20260316172048_init_images.sql
Normal file
@@ -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
|
||||
|
||||
10
db/migrations/20260316180504_images_content_type_field.sql
Normal file
10
db/migrations/20260316180504_images_content_type_field.sql
Normal file
@@ -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
|
||||
|
||||
9
docs/api/.gitignore
vendored
Normal file
9
docs/api/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# Secrets
|
||||
.env*
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
7
docs/api/auth/folder.yml
Normal file
7
docs/api/auth/folder.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
info:
|
||||
name: auth
|
||||
type: folder
|
||||
seq: 1
|
||||
|
||||
request:
|
||||
auth: inherit
|
||||
22
docs/api/auth/login user.yml
Normal file
22
docs/api/auth/login user.yml
Normal file
@@ -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
|
||||
22
docs/api/auth/register user.yml
Normal file
22
docs/api/auth/register user.yml
Normal file
@@ -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
|
||||
4
docs/api/environments/dev.yml
Normal file
4
docs/api/environments/dev.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
name: dev
|
||||
variables:
|
||||
- name: base_url
|
||||
value: http://localhost:5000
|
||||
19
docs/api/images/delete image.yml
Normal file
19
docs/api/images/delete image.yml
Normal file
@@ -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
|
||||
7
docs/api/images/folder.yml
Normal file
7
docs/api/images/folder.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
info:
|
||||
name: images
|
||||
type: folder
|
||||
seq: 2
|
||||
|
||||
request:
|
||||
auth: inherit
|
||||
15
docs/api/images/get user images.yml
Normal file
15
docs/api/images/get user images.yml
Normal file
@@ -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
|
||||
21
docs/api/images/upload new image.yml
Normal file
21
docs/api/images/upload new image.yml
Normal file
@@ -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
|
||||
10
docs/api/opencollection.yml
Normal file
10
docs/api/opencollection.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
opencollection: 1.0.0
|
||||
|
||||
info:
|
||||
name: image-storage
|
||||
bundled: false
|
||||
extensions:
|
||||
bruno:
|
||||
ignore:
|
||||
- node_modules
|
||||
- .git
|
||||
3
go.mod
3
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
104
main.go
104
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)
|
||||
}
|
||||
|
||||
63
model/image.go
Normal file
63
model/image.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user