This commit is contained in:
2026-02-18 02:17:13 +03:00
commit 8e0419c56b
23 changed files with 642 additions and 0 deletions

45
auth/cookies.go Normal file
View 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) (uint, error) {
c, err := r.Cookie("token")
if err != nil {
return 0, fmt.Errorf("no token cookie: %v", err)
}
userId, err := ValidateUserToken(c.Value)
if err != nil {
return 0, fmt.Errorf("invalid token: %v", err)
}
return userId, nil
}

48
auth/jwt.go Normal file
View 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 uint
}
func GenerateUserToken(userId uint, 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) (uint, error) {
claims := &UserClaims{}
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
return []byte(secretKey), nil
})
if err != nil {
return 0, fmt.Errorf("failed to parse token: %v", err)
}
if !parsed.Valid {
return 0, fmt.Errorf("invalid token")
}
return claims.UserID, nil
}

8
docs/api/auth/folder.bru Normal file
View File

@@ -0,0 +1,8 @@
meta {
name: auth
seq: 1
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,23 @@
meta {
name: login user
type: http
seq: 2
}
post {
url: {{base_url}}/auth/login
body: json
auth: inherit
}
body:json {
{
"login": "tsivinsky",
"password": "Daniil2000"
}
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,15 @@
meta {
name: logout user
type: http
seq: 3
}
post {
url: {{base_url}}/auth/logout
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,24 @@
meta {
name: register new user
type: http
seq: 1
}
post {
url: {{base_url}}/auth/register
body: json
auth: inherit
}
body:json {
{
"email": "daniil@tsivinsky.com",
"login": "tsivinsky",
"password": "Daniil2000"
}
}
settings {
encodeUrl: true
timeout: 0
}

9
docs/api/bruno.json Normal file
View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "wishlify",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -0,0 +1,3 @@
vars {
base_url: http://localhost:5000
}

8
docs/api/user/folder.bru Normal file
View File

@@ -0,0 +1,8 @@
meta {
name: user
seq: 2
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,15 @@
meta {
name: get user
type: http
seq: 1
}
get {
url: {{base_url}}/user
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,23 @@
meta {
name: create wishlist
type: http
seq: 2
}
post {
url: {{base_url}}/user/wishlists
body: json
auth: inherit
}
body:json {
{
"name": "test2",
"description": ""
}
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,20 @@
meta {
name: delete user wishlist
type: http
seq: 3
}
delete {
url: {{base_url}}/user/wishlists/:uid
body: none
auth: inherit
}
params:path {
uid: 019c6dd3-1832-75c1-8955-d545efeb3474
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,8 @@
meta {
name: wishlists
seq: 3
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,16 @@
meta {
name: get user wishlists
type: http
seq: 1
}
get {
url: {{base_url}}/user/wishlists
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,20 @@
meta {
name: get wishlist
type: http
seq: 4
}
get {
url: {{base_url}}/wishlists/:uid
body: none
auth: inherit
}
params:path {
uid: 019c6dcd-fde9-7374-a731-f03af290ea85
}
settings {
encodeUrl: true
timeout: 0
}

18
go.mod Normal file
View File

@@ -0,0 +1,18 @@
module wishlify
go 1.25.7
require (
github.com/golang-jwt/jwt/v5 v5.3.1
golang.org/x/crypto v0.48.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
golang.org/x/text v0.34.0 // indirect
)

18
go.sum Normal file
View File

@@ -0,0 +1,18 @@
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

239
main.go Normal file
View File

@@ -0,0 +1,239 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
"wishlify/auth"
"wishlify/model"
"wishlify/router"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func main() {
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "./sqlite.db"
}
db, err := gorm.Open(sqlite.Open("./sqlite.db"))
if err != nil {
log.Fatalf("failed to connect to db: %v\n", err)
}
if err := db.AutoMigrate(&model.User{}, &model.Wishlist{}); err != nil {
log.Fatalf("failed to migrate db: %v\n", err)
}
router := router.New()
router.Handle("POST /auth/register", func(w http.ResponseWriter, r *http.Request) error {
var body struct {
Email string `json:"email"`
Login string `json:"login"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(500)
return fmt.Errorf("failed to decode request body: %v", err)
}
if body.Email == "" || body.Login == "" || body.Password == "" {
w.WriteHeader(400)
return fmt.Errorf("invalid request; email, login and password are required")
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), 10)
if err != nil {
w.WriteHeader(500)
return fmt.Errorf("failed to generate password hash: %v", err)
}
user := &model.User{
Email: body.Email,
Login: body.Login,
Password: string(hash),
}
if tx := db.Create(user); tx.Error != nil {
w.WriteHeader(400)
return fmt.Errorf("failed to create user: %v", tx.Error)
}
expiryTime := time.Now().Add(time.Hour * 24 * 7)
token, err := auth.GenerateUserToken(user.ID, expiryTime)
if err != nil {
w.WriteHeader(500)
return fmt.Errorf("failed to generate jwt: %v", err)
}
auth.SetUserCookie(w, token, expiryTime)
w.WriteHeader(201)
return router.SendJSON(w, user)
})
router.Handle("POST /auth/login", func(w http.ResponseWriter, r *http.Request) error {
var body struct {
Login string `json:"login"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(400)
return fmt.Errorf("failed to decode request body: %v", err)
}
if body.Login == "" || body.Password == "" {
w.WriteHeader(400)
return fmt.Errorf("invalid request; login and password are required")
}
user := &model.User{}
if tx := db.First(user, "login = ?", body.Login); tx.Error != nil {
w.WriteHeader(404)
return fmt.Errorf("user not found: %v", tx.Error)
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(body.Password)); err != nil {
w.WriteHeader(400)
return fmt.Errorf("invalid password: %v", err)
}
expiryTime := time.Now().Add(time.Hour * 24 * 7)
token, err := auth.GenerateUserToken(user.ID, expiryTime)
if err != nil {
w.WriteHeader(500)
return fmt.Errorf("failed to generate jwt: %v", err)
}
auth.SetUserCookie(w, token, expiryTime)
w.WriteHeader(200)
return router.SendJSON(w, user)
})
router.Handle("POST /auth/logout", func(w http.ResponseWriter, r *http.Request) error {
auth.RemoveUserCookie(w)
w.WriteHeader(200)
return router.SendJSON(w, struct {
Ok bool `json:"ok"`
}{true})
})
router.Handle("GET /user", func(w http.ResponseWriter, r *http.Request) error {
userId, err := auth.GetUserIdFromRequest(r)
if err != nil {
w.WriteHeader(401)
return fmt.Errorf("unauthorized: %v", err)
}
user := &model.User{}
if tx := db.First(user, "id = ?", userId); tx.Error != nil {
w.WriteHeader(404)
return fmt.Errorf("user not found: %v", tx.Error)
}
return router.SendJSON(w, user)
})
router.Handle("GET /user/wishlists", func(w http.ResponseWriter, r *http.Request) error {
userId, err := auth.GetUserIdFromRequest(r)
if err != nil {
w.WriteHeader(401)
return fmt.Errorf("unauthorized: %v", err)
}
wishlists := []model.Wishlist{}
if tx := db.Find(&wishlists, "user_id = ?", userId); tx.Error != nil {
w.WriteHeader(404)
return fmt.Errorf("no wishlists found: %v", err)
}
w.WriteHeader(200)
return router.SendJSON(w, wishlists)
})
router.Handle("POST /user/wishlists", func(w http.ResponseWriter, r *http.Request) error {
userId, err := auth.GetUserIdFromRequest(r)
if err != nil {
w.WriteHeader(401)
return fmt.Errorf("unauthorized: %v", err)
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(400)
return fmt.Errorf("failed to decode request body: %v", err)
}
if body.Name == "" {
w.WriteHeader(400)
return fmt.Errorf("invalid request; name is required")
}
uid, err := uuid.NewV7()
if err != nil {
w.WriteHeader(500)
return fmt.Errorf("failed to generate uuid for new wishlist: %v", err)
}
wishlist := &model.Wishlist{
UUID: uid.String(),
Name: body.Name,
Description: body.Description,
UserID: userId,
}
if tx := db.Create(wishlist); tx.Error != nil {
w.WriteHeader(500)
return fmt.Errorf("failed to create wishlist: %v", tx.Error)
}
return router.SendJSON(w, wishlist)
})
router.Handle("GET /wishlists/{uid}", func(w http.ResponseWriter, r *http.Request) error {
uid := r.PathValue("uid")
wishlist := &model.Wishlist{}
if tx := db.First(wishlist, "uuid = ?", uid); tx.Error != nil {
w.WriteHeader(404)
return fmt.Errorf("wishlist not found: %v", tx.Error)
}
w.WriteHeader(200)
return router.SendJSON(w, wishlist)
})
router.Handle("DELETE /user/wishlists/{uid}", func(w http.ResponseWriter, r *http.Request) error {
userId, err := auth.GetUserIdFromRequest(r)
if err != nil {
w.WriteHeader(401)
return fmt.Errorf("unauthorized: %v", err)
}
uid := r.PathValue("uid")
if tx := db.Delete(&model.Wishlist{}, "uuid = ? AND user_id = ?", uid, userId); tx.Error != nil {
w.WriteHeader(404)
return fmt.Errorf("failed to delete wishlist: %v", tx.Error)
}
return router.SendJSON(w, struct {
Ok bool `json:"ok"`
}{true})
})
log.Println("starting http server")
if err := http.ListenAndServe(":5000", router.Mux()); err != nil {
log.Fatalf("failed to start http server: %v\n", err)
}
}

10
model/model.go Normal file
View File

@@ -0,0 +1,10 @@
package model
import "time"
type Model struct {
ID uint `json:"id" gorm:"primarykey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *time.Time `json:"deletedAt" gorm:"index"`
}

9
model/user.go Normal file
View File

@@ -0,0 +1,9 @@
package model
type User struct {
Model
Email string `json:"email" gorm:"unique"`
Login string `json:"login" gorm:"unique"`
Password string `json:"-"`
}

14
model/wishlist.go Normal file
View File

@@ -0,0 +1,14 @@
package model
import "time"
type Wishlist struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Description string `json:"description"`
UserID uint `json:"userId"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *time.Time `json:"deletedAt" gorm:"index"`
}

49
router/router.go Normal file
View File

@@ -0,0 +1,49 @@
package router
import (
"encoding/json"
"fmt"
"net/http"
)
type router struct {
mux *http.ServeMux
}
func (router *router) Mux() *http.ServeMux {
return router.mux
}
func (router *router) MsgError(msg string, err error) string {
m := msg
if err != nil {
m = fmt.Sprintf("%s: %v", msg, err)
}
return m
}
func (router *router) SendJSON(w http.ResponseWriter, data any) error {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
return fmt.Errorf("something bad happened: %v", err)
}
return nil
}
func (router *router) Handle(pattern string, handler func(w http.ResponseWriter, r *http.Request) error) {
router.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
if err := handler(w, r); err != nil {
router.SendJSON(w, struct {
Error string `json:"error"`
}{
Error: err.Error(),
})
}
})
}
func New() *router {
return &router{
mux: http.NewServeMux(),
}
}

BIN
sqlite.db Normal file

Binary file not shown.