Compare commits

..

7 Commits

Author SHA1 Message Date
d3bcc785a1 feat: pass file storage instance 2025-05-24 17:17:54 +02:00
64faa4ca5f feat: update user model + update profile picture 2025-05-24 17:17:45 +02:00
24c72800ad feat: file storage 2025-05-24 17:17:31 +02:00
f559f54683 feat: upload avatar route 2025-05-24 17:17:25 +02:00
9b0de4512b feat: add profile_picture field to user table 2025-05-24 17:17:16 +02:00
b6d365cc48 feat: install minio v7 2025-05-24 17:16:55 +02:00
8f755b6d1e feat: minio env vars example 2025-05-24 17:16:45 +02:00
11 changed files with 245 additions and 22 deletions

View File

@ -11,6 +11,10 @@ ADMIN_PASSWORD="secret"
JWT_PRIVATE_KEY="ecdsa"
JWT_PUBLIC_KEY="ecdsa"
MINIO_ENDPOINT="localhost:9000"
MINIO_ACCESS_KEY=""
MINIO_SECRET_KEY=""
GOOSE_DRIVER="postgres"
GOOSE_DBSTRING=$DATABASE_URL
GOOSE_MIGRATION_DIR="./migrations"

View File

@ -9,20 +9,23 @@ import (
"gitea.local/admin/hspguard/internal/auth"
imiddleware "gitea.local/admin/hspguard/internal/middleware"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/storage"
"gitea.local/admin/hspguard/internal/user"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
type APIServer struct {
addr string
repo *repository.Queries
addr string
repo *repository.Queries
storage *storage.FileStorage
}
func NewAPIServer(addr string, db *repository.Queries) *APIServer {
func NewAPIServer(addr string, db *repository.Queries, minio *storage.FileStorage) *APIServer {
return &APIServer{
addr: addr,
repo: db,
addr: addr,
repo: db,
storage: minio,
}
}
@ -37,7 +40,7 @@ func (s *APIServer) Run() error {
router.Route("/api/v1", func(r chi.Router) {
r.Use(imiddleware.WithSkipper(imiddleware.AuthMiddleware, "/api/v1/login", "/api/v1/register"))
userHandler := user.NewUserHandler(s.repo)
userHandler := user.NewUserHandler(s.repo, s.storage)
userHandler.RegisterRoutes(r)
authHandler := auth.NewAuthHandler(s.repo)

View File

@ -8,6 +8,7 @@ import (
"gitea.local/admin/hspguard/cmd/hspguard/api"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/storage"
"gitea.local/admin/hspguard/internal/user"
"github.com/jackc/pgx/v5"
"github.com/joho/godotenv"
@ -30,6 +31,8 @@ func main() {
repo := repository.New(conn)
fStorage := storage.New()
user.EnsureAdminUser(ctx, repo)
host := os.Getenv("HOST")
@ -42,7 +45,7 @@ func main() {
port = "3000"
}
server := api.NewAPIServer(fmt.Sprintf("127.0.0.1:%s", port), repo)
server := api.NewAPIServer(fmt.Sprintf("127.0.0.1:%s", port), repo, fStorage)
if err := server.Run(); err != nil {
log.Fatalln("ERR: Failed to start server:", err)
}

13
go.mod
View File

@ -11,8 +11,21 @@ require (
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/minio/crc64nvme v1.0.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.92 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
)

28
go.sum
View File

@ -1,8 +1,14 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -17,17 +23,39 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY=
github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw=
github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -10,13 +10,14 @@ import (
)
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
PasswordHash string `json:"password_hash"`
IsAdmin bool `json:"is_admin"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
LastLogin pgtype.Timestamptz `json:"last_login"`
PhoneNumber pgtype.Text `json:"phone_number"`
ID uuid.UUID `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
PasswordHash string `json:"password_hash"`
IsAdmin bool `json:"is_admin"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
LastLogin pgtype.Timestamptz `json:"last_login"`
PhoneNumber pgtype.Text `json:"phone_number"`
ProfilePicture pgtype.Text `json:"profile_picture"`
}

View File

@ -9,10 +9,11 @@ import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const findAllUsers = `-- name: FindAllUsers :many
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number FROM users
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture FROM users
`
func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
@ -34,6 +35,7 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
&i.UpdatedAt,
&i.LastLogin,
&i.PhoneNumber,
&i.ProfilePicture,
); err != nil {
return nil, err
}
@ -46,7 +48,7 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
}
const findUserEmail = `-- name: FindUserEmail :one
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number FROM users WHERE email = $1 LIMIT 1
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture FROM users WHERE email = $1 LIMIT 1
`
func (q *Queries) FindUserEmail(ctx context.Context, email string) (User, error) {
@ -62,12 +64,13 @@ func (q *Queries) FindUserEmail(ctx context.Context, email string) (User, error)
&i.UpdatedAt,
&i.LastLogin,
&i.PhoneNumber,
&i.ProfilePicture,
)
return i, err
}
const findUserId = `-- name: FindUserId :one
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number FROM users WHERE id = $1 LIMIT 1
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture FROM users WHERE id = $1 LIMIT 1
`
func (q *Queries) FindUserId(ctx context.Context, id uuid.UUID) (User, error) {
@ -83,6 +86,7 @@ func (q *Queries) FindUserId(ctx context.Context, id uuid.UUID) (User, error) {
&i.UpdatedAt,
&i.LastLogin,
&i.PhoneNumber,
&i.ProfilePicture,
)
return i, err
}
@ -114,3 +118,19 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (uuid.UU
err := row.Scan(&id)
return id, err
}
const updateProfilePicture = `-- name: UpdateProfilePicture :exec
UPDATE users
SET profile_picture = $1
WHERE id = $2
`
type UpdateProfilePictureParams struct {
ProfilePicture pgtype.Text `json:"profile_picture"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateProfilePicture(ctx context.Context, arg UpdateProfilePictureParams) error {
_, err := q.db.Exec(ctx, updateProfilePicture, arg.ProfilePicture, arg.ID)
return err
}

57
internal/storage/mod.go Normal file
View File

@ -0,0 +1,57 @@
package storage
import (
"context"
"io"
"log"
"net/url"
"os"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type FileStorage struct {
client *minio.Client
}
func New() *FileStorage {
endpoint := os.Getenv("MINIO_ENDPOINT")
if endpoint == "" {
log.Fatalln("MINIO_ENDPOINT env var is required")
return nil
}
accessKey := os.Getenv("MINIO_ACCESS_KEY")
if accessKey == "" {
log.Fatalln("MINIO_ACCESS_KEY env var is required")
return nil
}
secretKey := os.Getenv("MINIO_SECRET_KEY")
if secretKey == "" {
log.Fatalln("MINIO_SECRET_KEY env var is required")
return nil
}
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
Secure: false,
})
if err != nil {
log.Fatalln("Failed to create minio client:", err)
return nil
}
return &FileStorage{
client,
}
}
func (fs *FileStorage) PutObject(ctx context.Context, bucketName string, objectName string, reader io.Reader, size int64, opts minio.PutObjectOptions) (minio.UploadInfo, error) {
return fs.client.PutObject(ctx, bucketName, objectName, reader, size, opts)
}
func (fs *FileStorage) EndpointURL() *url.URL {
return fs.client.EndpointURL()
}

View File

@ -3,26 +3,37 @@ package user
import (
"context"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/storage"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/minio/minio-go/v7"
)
type UserHandler struct {
repo *repository.Queries
repo *repository.Queries
minio *storage.FileStorage
}
func NewUserHandler(repo *repository.Queries) *UserHandler {
func NewUserHandler(repo *repository.Queries, minio *storage.FileStorage) *UserHandler {
return &UserHandler{
repo: repo,
repo: repo,
minio: minio,
}
}
func (h *UserHandler) RegisterRoutes(api chi.Router) {
api.Post("/register", h.register)
api.Put("/avatar", h.uploadAvatar)
}
type RegisterParams struct {
@ -81,3 +92,70 @@ func (h *UserHandler) register(w http.ResponseWriter, r *http.Request) {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
return
}
err = r.ParseMultipartForm(10 << 20)
if err != nil {
web.Error(w, "invalid form data", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("image")
if err != nil {
web.Error(w, "missing image file", http.StatusBadRequest)
return
}
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".webp" {
web.Error(w, "unsupported image format", http.StatusBadRequest)
return
}
objectName := fmt.Sprintf("profile_%s_%d%s", userId, time.Now().UnixNano(), ext)
uploadInfo, err := h.minio.PutObject(r.Context(), "guard-storage", objectName, file, header.Size, minio.PutObjectOptions{
ContentType: header.Header.Get("Content-Type"),
})
if err != nil {
web.Error(w, "failed to upload image", http.StatusInternalServerError)
return
}
imageURL := fmt.Sprintf("http://%s/%s/%s", h.minio.EndpointURL().Host, "guard-storage", uploadInfo.Key)
if err := h.repo.UpdateProfilePicture(r.Context(), repository.UpdateProfilePictureParams{
ProfilePicture: pgtype.Text{
String: imageURL,
Valid: true,
},
ID: user.ID,
}); err != nil {
web.Error(w, "failed to update profile picture", http.StatusInternalServerError)
return
}
type Response struct {
URL string `json:"url"`
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
if err := encoder.Encode(Response{URL: imageURL}); err != nil {
web.Error(w, "failed to write response", http.StatusInternalServerError)
}
}

View File

@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE users
ADD profile_picture TEXT;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE users
DROP COLUMN profile_picture;
-- +goose StatementEnd

View File

@ -14,3 +14,8 @@ SELECT * FROM users WHERE email = $1 LIMIT 1;
-- name: FindUserId :one
SELECT * FROM users WHERE id = $1 LIMIT 1;
-- name: UpdateProfilePicture :exec
UPDATE users
SET profile_picture = $1
WHERE id = $2;