commit 8762da31645c7f9cf37a8e9785ce0ae986eb0aab Author: mrvasil Date: Sat Jan 31 16:50:50 2026 +0300 first commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5fcbf10 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM golang:1.25-alpine + +RUN apk add --no-cache openssl + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +COPY templates/ ./templates/ +COPY static/ ./static/ + +RUN go build -o /oreshnik ./cmd/oreshnik + +RUN mkdir public +RUN openssl genrsa -out public/private.pem 2048 +RUN openssl rsa -in public/private.pem -pubout -out public/public.pem + +EXPOSE 8080 + +CMD [ "/oreshnik" ] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/cmd/oreshnik/main.go b/cmd/oreshnik/main.go new file mode 100644 index 0000000..ce236d9 --- /dev/null +++ b/cmd/oreshnik/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "oreshnik/internal/app/server" +) + +func main() { + s, err := server.New() + if err != nil { + log.Fatalf("failed to init server: %v", err) + } + fmt.Println("Server started on :8080") + log.Fatal(http.ListenAndServe(":8080", s.Router())) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3dd8f6e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + app: + build: . + ports: + - "8099:8080" + depends_on: + - db + environment: + - DB_HOST=db + - DB_USER=goidactf + - DB_PASSWORD=goidactf + - DB_NAME=goidactf + - DB_PORT=5432 + - FLAG=${FLAG} + + db: + image: postgres:13-alpine + environment: + - POSTGRES_USER=goidactf + - POSTGRES_PASSWORD=goidactf + - POSTGRES_DB=goidactf diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..92f7e79 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module oreshnik + +go 1.25.4 + +require ( + github.com/golang-jwt/jwt/v4 v4.5.2 + golang.org/x/crypto v0.47.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.33.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a298eca --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +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/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/app/auth/jwt.go b/internal/app/auth/jwt.go new file mode 100644 index 0000000..85d39df --- /dev/null +++ b/internal/app/auth/jwt.go @@ -0,0 +1,58 @@ +package auth + +import ( + "crypto/rsa" + "fmt" + "os" + "time" + + "github.com/golang-jwt/jwt/v4" +) + +func LoadKeys() (*rsa.PrivateKey, *rsa.PublicKey, error) { + privBytes, err := os.ReadFile("public/private.pem") + if err != nil { + return nil, nil, fmt.Errorf("read private key: %w", err) + } + priv, err := jwt.ParseRSAPrivateKeyFromPEM(privBytes) + if err != nil { + return nil, nil, fmt.Errorf("parse private key: %w", err) + } + + pubBytes, err := os.ReadFile("public/public.pem") + if err != nil { + return nil, nil, fmt.Errorf("read public key: %w", err) + } + pub, err := jwt.ParseRSAPublicKeyFromPEM(pubBytes) + if err != nil { + return nil, nil, fmt.Errorf("parse public key: %w", err) + } + return priv, pub, nil +} + +func GenerateJWT(userID uint, isAdmin bool, privateKey *rsa.PrivateKey) (string, error) { + claims := jwt.MapClaims{ + "user_id": userID, + "is_admin": isAdmin, + "exp": time.Now().Add(24 * time.Hour).Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + return token.SignedString(privateKey) +} + +func Parse(tokenString string, publicKey *rsa.PublicKey) (*jwt.Token, jwt.MapClaims, error) { + tok, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return publicKey, nil + }) + if err != nil { + return nil, nil, err + } + claims, ok := tok.Claims.(jwt.MapClaims) + if !ok || !tok.Valid { + return nil, nil, fmt.Errorf("invalid token") + } + return tok, claims, nil +} diff --git a/internal/app/db/db.go b/internal/app/db/db.go new file mode 100644 index 0000000..f70e3df --- /dev/null +++ b/internal/app/db/db.go @@ -0,0 +1,41 @@ +package db + +import ( + "fmt" + "log" + "os" + "time" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func ConnectAndMigrate(models ...interface{}) (*gorm.DB, error) { + dsn := fmt.Sprintf( + "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", + os.Getenv("DB_HOST"), + os.Getenv("DB_USER"), + os.Getenv("DB_PASSWORD"), + os.Getenv("DB_NAME"), + os.Getenv("DB_PORT"), + ) + + var database *gorm.DB + var err error + for i := 0; i < 10; i++ { + database, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err == nil { + break + } + log.Println("Failed to connect to database, retrying...") + time.Sleep(2 * time.Second) + } + if err != nil { + return nil, fmt.Errorf("connect database: %w", err) + } + + if err := database.AutoMigrate(models...); err != nil { + return nil, fmt.Errorf("auto migrate: %w", err) + } + return database, nil +} diff --git a/internal/app/models/models.go b/internal/app/models/models.go new file mode 100644 index 0000000..2a8df38 --- /dev/null +++ b/internal/app/models/models.go @@ -0,0 +1,31 @@ +package models + +import "gorm.io/gorm" + +type RevokedToken struct { + ID uint `gorm:"primaryKey"` + Token string `gorm:"unique"` +} + +type User struct { + ID uint `gorm:"primaryKey"` + Username string `gorm:"unique"` + Password string + IsAdmin bool `gorm:"default:false"` +} + +type Product struct { + ID uint `gorm:"primaryKey"` + Name string + Description string + Price float64 +} + +type Purchase struct { + gorm.Model + UserID uint + ProductID uint + Product Product `gorm:"foreignKey:ProductID"` +} + +var _ = gorm.Model{} diff --git a/internal/app/server/server.go b/internal/app/server/server.go new file mode 100644 index 0000000..446bbd8 --- /dev/null +++ b/internal/app/server/server.go @@ -0,0 +1,303 @@ +package server + +import ( + "crypto/rsa" + "encoding/json" + "html/template" + "log" + "math/rand" + "net/http" + "os" + "strings" + "time" + + "oreshnik/internal/app/auth" + "oreshnik/internal/app/db" + "oreshnik/internal/app/models" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type Server struct { + DB *gorm.DB + PrivKey *rsa.PrivateKey + PubKey *rsa.PublicKey + Templates *template.Template + Flag string + rand *rand.Rand +} + +func New() (*Server, error) { + s := &Server{Flag: os.Getenv("FLAG")} + if s.Flag == "" { + s.Flag = "goidactf{fake_flag}" + } + + s.Templates = template.Must(template.ParseGlob("templates/*.html")) + + var err error + s.DB, err = db.ConnectAndMigrate(&models.User{}, &models.RevokedToken{}, &models.Product{}, &models.Purchase{}) + if err != nil { + return nil, err + } + + if _, err := os.Stat("public"); os.IsNotExist(err) { + _ = os.Mkdir("public", 0755) + } + + s.PrivKey, s.PubKey, err = auth.LoadKeys() + if err != nil { + return nil, err + } + + s.rand = rand.New(rand.NewSource(time.Now().UnixNano())) + if err := s.seed(); err != nil { + return nil, err + } + return s, nil +} + +func (s *Server) Router() *http.ServeMux { + mux := http.NewServeMux() + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + mux.HandleFunc("/", s.homeHandler) + mux.HandleFunc("/register", s.registerHandler) + mux.HandleFunc("/login", s.loginHandler) + mux.HandleFunc("/admin", s.adminHandler) + mux.HandleFunc("/revoked", s.revokedHandler) + mux.HandleFunc("/product/", s.productHandler) + mux.HandleFunc("/buy/", s.buyHandler) + mux.HandleFunc("/logout", s.logoutHandler) + mux.HandleFunc("/my-purchases", s.myPurchasesHandler) + mux.HandleFunc("/order/", s.orderHandler) + return mux +} + +func (s *Server) randomPassword(n int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, n) + for i := range b { + b[i] = charset[s.rand.Intn(len(charset))] + } + return string(b) +} + +func (s *Server) seed() error { + s.DB.Exec("DELETE FROM revoked_tokens") + s.DB.Exec("DELETE FROM products") + s.DB.Exec("DELETE FROM users") + + products := []models.Product{ + {Name: "Грецкий орех", Description: "Классический орех, богат омега-3. Отлично подходит для выпечки и салатов.", Price: 250.0}, + {Name: "Миндаль", Description: "Полезен для сердца и кожи. Идеален в качестве перекуса или добавки в мюсли.", Price: 400.0}, + {Name: "Кешью", Description: "Сладкий и маслянистый, идеален для закусок и азиатских блюд.", Price: 550.0}, + {Name: "Фисташки", Description: "Соленые и хрустящие, прекрасная закуска к напиткам.", Price: 600.0}, + {Name: "Фундук", Description: "Лесной орех с насыщенным вкусом, хорош в шоколаде и десертах.", Price: 450.0}, + } + if err := s.DB.Create(&products).Error; err != nil { + return err + } + + adminPass := s.randomPassword(16) + hashAdmin, _ := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost) + log.Printf("Создан администратор с паролем: %s", adminPass) + admin := models.User{Username: "админ", Password: string(hashAdmin), IsAdmin: true} + if err := s.DB.Create(&admin).Error; err != nil { + return err + } + + users := []models.User{{Username: "пользователь1"}, {Username: "тест"}} + for i := range users { + p := s.randomPassword(16) + h, _ := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost) + users[i].Password = string(h) + if err := s.DB.Create(&users[i]).Error; err != nil { + return err + } + log.Printf("Создан пользователь '%s' с паролем: %s", users[i].Username, p) + } + + adminTok, _ := auth.GenerateJWT(admin.ID, admin.IsAdmin, s.PrivKey) + if err := s.DB.Create(&models.RevokedToken{Token: adminTok}).Error; err != nil { + return err + } + return nil +} + +func (s *Server) homeHandler(w http.ResponseWriter, r *http.Request) { + data := s.getTemplateData(r) + if data["LoggedIn"].(bool) { + var products []models.Product + s.DB.Find(&products) + data["Products"] = products + } + _ = s.Templates.ExecuteTemplate(w, "index.html", data) +} + +func (s *Server) registerHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _ = s.Templates.ExecuteTemplate(w, "register.html", s.getTemplateData(r)) + return + } + _ = r.ParseForm() + username := r.FormValue("username") + password := r.FormValue("password") + h, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + u := models.User{Username: username, Password: string(h)} + if err := s.DB.Create(&u).Error; err != nil { + http.Error(w, "Username already exists", http.StatusConflict) + return + } + http.Redirect(w, r, "/login", http.StatusFound) +} + +func (s *Server) loginHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _ = s.Templates.ExecuteTemplate(w, "login.html", s.getTemplateData(r)) + return + } + _ = r.ParseForm() + username := r.FormValue("username") + password := r.FormValue("password") + var user models.User + s.DB.First(&user, "username = ?", username) + if user.ID == 0 || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + tok, _ := auth.GenerateJWT(user.ID, user.IsAdmin, s.PrivKey) + http.SetCookie(w, &http.Cookie{Name: "token", Value: tok, Path: "/"}) + http.Redirect(w, r, "/", http.StatusFound) +} + +func (s *Server) adminHandler(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("token") + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + tokenString := cookie.Value + var revoked models.RevokedToken + if s.DB.First(&revoked, "token = ?", tokenString).Error == nil { + http.Error(w, "Token has been revoked", http.StatusForbidden) + return + } + _, claims, err := auth.Parse(tokenString, s.PubKey) + if err != nil || !claims["is_admin"].(bool) { + http.Error(w, "Invalid token or not admin", http.StatusForbidden) + return + } + data := s.getTemplateData(r) + data["Flag"] = s.Flag + _ = s.Templates.ExecuteTemplate(w, "admin.html", data) +} + +func (s *Server) revokedHandler(w http.ResponseWriter, r *http.Request) { + var tokens []models.RevokedToken + s.DB.Find(&tokens) + var tokenStrings []string + for _, t := range tokens { + tokenStrings = append(tokenStrings, t.Token) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tokenStrings) +} + +func (s *Server) productHandler(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/product/") + var product models.Product + s.DB.First(&product, id) + if product.ID == 0 { + http.NotFound(w, r) + return + } + data := s.getTemplateData(r) + data["Product"] = product + _ = s.Templates.ExecuteTemplate(w, "product.html", data) +} + +func (s *Server) buyHandler(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("token") + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + _, claims, err := auth.Parse(cookie.Value, s.PubKey) + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + userID := uint(claims["user_id"].(float64)) + productID := strings.TrimPrefix(r.URL.Path, "/buy/") + + purchase := models.Purchase{UserID: userID, ProductID: 0} + s.DB.First(&models.Product{}, productID).Scan(&purchase.Product) + if purchase.Product.ID == 0 { + http.NotFound(w, r) + return + } + purchase.ProductID = purchase.Product.ID + + s.DB.Create(&purchase) + + _ = s.Templates.ExecuteTemplate(w, "purchase_success.html", s.getTemplateData(r)) +} + +func (s *Server) logoutHandler(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: "token", Value: "", Path: "/", Expires: time.Unix(0, 0)}) + http.Redirect(w, r, "/", http.StatusFound) +} + +func (s *Server) myPurchasesHandler(w http.ResponseWriter, r *http.Request) { + data := s.getTemplateData(r) + if !data["LoggedIn"].(bool) { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + var purchases []models.Purchase + s.DB.Preload("Product").Where("user_id = ?", data["UserID"]).Find(&purchases) + data["Purchases"] = purchases + + _ = s.Templates.ExecuteTemplate(w, "my_purchases.html", data) +} + +func (s *Server) orderHandler(w http.ResponseWriter, r *http.Request) { + data := s.getTemplateData(r) + if !data["LoggedIn"].(bool) { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + orderID := strings.TrimPrefix(r.URL.Path, "/order/") + var purchase models.Purchase + s.DB.Preload("Product").First(&purchase, orderID) + + data["Purchase"] = purchase + _ = s.Templates.ExecuteTemplate(w, "order_detail.html", data) +} + +func (s *Server) getTemplateData(r *http.Request) map[string]interface{} { + data := map[string]interface{}{ + "LoggedIn": false, + } + cookie, err := r.Cookie("token") + if err != nil { + return data + } + _, claims, err := auth.Parse(cookie.Value, s.PubKey) + if err != nil { + return data + } + var user models.User + s.DB.First(&user, claims["user_id"]) + if user.ID != 0 { + data["LoggedIn"] = true + data["Username"] = user.Username + data["UserID"] = user.ID + } + return data +} diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..1144b7b --- /dev/null +++ b/static/script.js @@ -0,0 +1,14 @@ +document.addEventListener('DOMContentLoaded', () => { + console.log('Магазин "Орешник" загружен и готов к работе!'); + + const productCards = document.querySelectorAll('.product-card-link'); + productCards.forEach(card => { + card.addEventListener('mouseover', () => { + card.style.transform = 'scale(1.05)'; + card.style.transition = 'transform 0.2s'; + }); + card.addEventListener('mouseout', () => { + card.style.transform = 'scale(1)'; + }); + }); +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..50d9dc7 --- /dev/null +++ b/static/style.css @@ -0,0 +1,95 @@ +body { + font-family: sans-serif; + background-color: #f4f4f4; + color: #333; + margin: 0; + padding: 0; +} + +.container { + max-width: 960px; + margin: 0 auto; + padding: 20px; +} + +header { + background-color: #4a3f35; /* A warmer, nut-brown color */ + color: #fff; + padding: 0 40px; + border-radius: 8px; + margin-bottom: 20px; + display: flex; + justify-content: space-between; + align-items: center; + height: 70px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +header h1 { + margin: 0; + font-size: 1.5em; +} + +.logo { + color: #fff; + text-decoration: none; +} + +nav { + display: flex; + align-items: center; +} + +nav a { + color: #fff; + text-decoration: none; + margin-left: 20px; + padding: 8px 12px; + border-radius: 5px; + transition: background-color 0.3s; +} + +nav a:hover { + background-color: #6d5c4b; +} + +nav span { + margin-left: 20px; + font-weight: bold; +} + +.product-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 20px; +} + +.product-card { + background-color: #fff; + border: 1px solid #ddd; + border-radius: 5px; + padding: 20px; +} + +.price { + font-weight: bold; + color: #0056b3; +} + +.auth-form { + display: flex; + flex-direction: column; + max-width: 300px; +} + +.auth-form input { + margin-bottom: 10px; + padding: 8px; +} + +.flag { + font-family: monospace; + background-color: #eee; + padding: 10px; + border: 1px solid #ccc; +} diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..1878a76 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,31 @@ + + + + + Админ-панель + + + + +
+
+

+ +
+
+

Секретная информация

+

Флаг: {{.Flag}}

+
+
+ + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..63f72b4 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,45 @@ + + + + + Магазин "Орешник" + + + + +
+
+

+ +
+
+ {{if .LoggedIn}} +

Наши товары

+ + {{else}} +

Пожалуйста, войдите, чтобы увидеть наши товары.

+ {{end}} +
+
+ + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..ac0a74c --- /dev/null +++ b/templates/login.html @@ -0,0 +1,36 @@ + + + + + Вход - Магазин "Орешник" + + + + +
+
+

+ +
+
+
+ + + + + +
+
+
+ + diff --git a/templates/my_purchases.html b/templates/my_purchases.html new file mode 100644 index 0000000..1e06373 --- /dev/null +++ b/templates/my_purchases.html @@ -0,0 +1,44 @@ + + + + + Мои покупки - Магазин "Орешник" + + + + +
+
+

+ +
+
+

Мои покупки

+ {{if .Purchases}} +
+ {{range .Purchases}} + +
+

{{.Product.Name}}

+

Заказ #{{.ID}}

+
+
+ {{end}} +
+ {{else}} +

Вы еще ничего не купили.

+ {{end}} +
+
+ + diff --git a/templates/order_detail.html b/templates/order_detail.html new file mode 100644 index 0000000..bcd97d2 --- /dev/null +++ b/templates/order_detail.html @@ -0,0 +1,40 @@ + + + + + Детали заказа - Магазин "Орешник" + + + + +
+
+

+ +
+
+

Детали заказа #{{.Purchase.ID}}

+ {{if .Purchase}} +
+

{{.Purchase.Product.Name}}

+

{{.Purchase.Product.Description}}

+

Цена: {{.Purchase.Product.Price}} руб.

+

Куплено: {{.Purchase.CreatedAt.Format "02.01.2006 15:04"}}

+
+ {{else}} +

Заказ не найден.

+ {{end}} +
+
+ + diff --git a/templates/product.html b/templates/product.html new file mode 100644 index 0000000..d947201 --- /dev/null +++ b/templates/product.html @@ -0,0 +1,36 @@ + + + + + {{.Product.Name}} - Магазин "Орешник" + + + + +
+
+

+ +
+
+
+

{{.Product.Description}}

+

Цена: {{.Product.Price}} руб.

+
+ +
+
+
+
+ + diff --git a/templates/purchase_success.html b/templates/purchase_success.html new file mode 100644 index 0000000..de71aee --- /dev/null +++ b/templates/purchase_success.html @@ -0,0 +1,30 @@ + + + + + Спасибо за покупку! + + + + +
+
+

+ +
+
+

Ваш заказ успешно оформлен.

+
+
+ + diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..58bce87 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,36 @@ + + + + + Регистрация - Магазин "Орешник" + + + + +
+
+

+ +
+
+
+ + + + + +
+
+
+ +