diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12e5024..a9640f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: uses: golangci/golangci-lint-action@v2 with: version: latest - args: --config=.golangci.yml + args: --config=.golangci.yml --fix test: runs-on: ubuntu-latest diff --git a/.golangci.yml b/.golangci.yml index f8198cf..9242408 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -11,14 +11,11 @@ linters: enable: - asciicheck - bodyclose - - deadcode - - depguard - dogsled - dupl - durationcheck - errorlint - exhaustive - - exportloopref - funlen - gci - gocognit @@ -35,7 +32,6 @@ linters: - gosec - gosimple - govet - - ifshort - importas - ineffassign - lll @@ -51,7 +47,6 @@ linters: - rowserrcheck - sqlclosecheck - staticcheck - - structcheck - stylecheck - tagliatelle - thelper @@ -60,6 +55,5 @@ linters: - unconvert - unparam - unused - - varcheck - wastedassign - whitespace \ No newline at end of file diff --git a/database/db.go b/database/db.go new file mode 100644 index 0000000..5888a8e --- /dev/null +++ b/database/db.go @@ -0,0 +1,25 @@ +package database + +import ( + "fmt" + "os" + + "github.com/jmoiron/sqlx" + "github.com/joho/godotenv" + _ "github.com/lib/pq" // Регистрация драйвера PostgreSQL +) + +func InitDB() (*sqlx.DB, error) { + err := godotenv.Load("./.env") + if err != nil { + return nil, fmt.Errorf("error loading .env file: %w", err) + } + + db, err := sqlx.Connect("postgres", os.Getenv("DATABASE_URL")) + if err != nil { + return nil, fmt.Errorf("error connecting to database: %w", err) + } + + fmt.Println("Successfully connected to the database") + return db, nil +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1de4ab0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + db: + image: postgres:latest + container_name: notes_app_db + environment: + POSTGRES_USER: ${APP_DB_USER} + POSTGRES_PASSWORD: ${APP_DB_PASSWORD} + POSTGRES_DB: ${APP_DB_NAME} + ports: + - "${APP_DB_PORT}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data +volumes: + postgres_data: \ No newline at end of file diff --git a/go.mod b/go.mod index bfc07e3..a85ff72 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,14 @@ module NotesWebApp go 1.23.4 + +require ( + github.com/gorilla/mux v1.8.1 + github.com/gorilla/sessions v1.4.0 + github.com/jmoiron/sqlx v1.4.0 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 + golang.org/x/crypto v0.32.0 +) + +require github.com/gorilla/securecookie v1.1.2 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..74eb695 --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= diff --git a/handlers/auth_handler.go b/handlers/auth_handler.go new file mode 100644 index 0000000..1627cf0 --- /dev/null +++ b/handlers/auth_handler.go @@ -0,0 +1,131 @@ +package handlers + +import ( + "html/template" + "log" + "net/http" + + "github.com/jmoiron/sqlx" + "golang.org/x/crypto/bcrypt" + + "NotesWebApp/models" +) + +type AuthHandler struct { + DB *sqlx.DB +} + +func NewAuthHandler(db *sqlx.DB) *AuthHandler { + return &AuthHandler{DB: db} +} + +func (ah *AuthHandler) Index(w http.ResponseWriter, r *http.Request) { + session, err := store.Get(r, sessionName) + if err != nil { + http.Error(w, "Failed to get session", http.StatusBadRequest) + } + _, ok := session.Values["userID"].(int) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/notes", http.StatusFound) +} + +func (ah *AuthHandler) LoginForm(w http.ResponseWriter, _ *http.Request) { + tmpl := template.Must(template.ParseFiles("templates/login.html")) + err := tmpl.Execute(w, nil) + if err != nil { + log.Println("Error while executing login.html template:", err) + return + } +} + +func (ah *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + email := r.FormValue("email") + password := r.FormValue("password") + + user, err := models.GetUserByEmail(ah.DB, email) + if err != nil { + http.Error(w, "User not found: invalid credentials", http.StatusUnauthorized) + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + http.Error(w, "Wrong password", http.StatusUnauthorized) + return + } + + session, err := store.Get(r, sessionName) + if err != nil { + log.Printf("Failed to get session: %v", err) + + http.Error(w, "Failed to get session", http.StatusBadRequest) + return + } + session.Values["userID"] = user.ID + err = session.Save(r, w) + if err != nil { + log.Println("Can't save session:", err) + return + } + log.Println("User logged in successfully:", user.ID) + http.Redirect(w, r, "/notes", http.StatusFound) +} + +func (ah *AuthHandler) RegisterForm(w http.ResponseWriter, _ *http.Request) { + tmpl := template.Must(template.ParseFiles("templates/register.html")) + err := tmpl.Execute(w, nil) + if err != nil { + log.Println("Error while executing register.html template:", err) + return + } +} + +func (ah *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { + email := r.FormValue("email") + password := r.FormValue("password") + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + log.Printf("Failed to hash password for user %s: %v", email, err) + + http.Error(w, "Internal server error: failed to process password", http.StatusInternalServerError) + return + } + + user := models.User{ + Email: email, + Password: string(hashedPassword), + } + + if err := user.CreateUser(ah.DB); err != nil { + log.Printf("Failed to create user %s: %v", email, err) + + http.Error(w, "Internal server error: failed to create user", http.StatusInternalServerError) + return + } + + log.Printf("user created %+v", user) + + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (ah *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + session, err := store.Get(r, sessionName) + if err != nil { + http.Error(w, "Failed to get session", http.StatusInternalServerError) + return + } + + session.Values = make(map[interface{}]interface{}) // удаляем все + session.Options.MaxAge = -1 // ставим срок действия сессии в прошлое (удаление сессии) + + err = session.Save(r, w) + if err != nil { + http.Error(w, "Failed to save session", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/", http.StatusFound) +} diff --git a/handlers/note_handler.go b/handlers/note_handler.go new file mode 100644 index 0000000..b5737f7 --- /dev/null +++ b/handlers/note_handler.go @@ -0,0 +1,218 @@ +package handlers + +import ( + "html/template" + "log" + "net/http" + "strconv" + + "NotesWebApp/models" + "github.com/gorilla/mux" + "github.com/jmoiron/sqlx" +) + +type NoteHandler struct { + DB *sqlx.DB +} + +func NewNoteHandler(db *sqlx.DB) *NoteHandler { + return &NoteHandler{DB: db} +} + +func (nh *NoteHandler) GetNotes(w http.ResponseWriter, r *http.Request) { + session, err := store.Get(r, sessionName) + if err != nil { + http.Error(w, "Failed to get session", http.StatusBadRequest) + return + } + + userID, ok := session.Values["userID"].(int) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + note := models.Note{} + + notes, err := note.GetNotesByUser(nh.DB, userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + tmpl := template.Must(template.ParseFiles("templates/index.html")) + err = tmpl.Execute(w, notes) + if err != nil { + log.Println("Error while executing index.html:", err) + return + } +} + +func (nh *NoteHandler) CreateNoteForm(w http.ResponseWriter, _ *http.Request) { + tmpl := template.Must(template.ParseFiles("templates/create.html")) + err := tmpl.Execute(w, nil) + if err != nil { + log.Println("Error while executing create.html:", err) + return + } +} + +func (nh *NoteHandler) CreateNote(w http.ResponseWriter, r *http.Request) { + session, err := store.Get(r, sessionName) + if err != nil { + http.Error(w, "Failed to get session", http.StatusBadRequest) + return + } + userID, ok := session.Values["userID"].(int) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + title := r.FormValue("title") + content := r.FormValue("content") + + note := &models.Note{ + Title: title, + Content: content, + UserID: userID, + } + + if err := note.CreateNote(nh.DB); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/notes", http.StatusFound) +} + +func (nh *NoteHandler) EditNoteForm(w http.ResponseWriter, r *http.Request) { + session, err := store.Get(r, sessionName) + if err != nil { + http.Error(w, "Failed to get session", http.StatusBadRequest) + return + } + + userID, ok := session.Values["userID"].(int) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + vars := mux.Vars(r) + id, _ := strconv.Atoi(vars["id"]) + + note, err := models.GetNoteByID(nh.DB, id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + log.Printf("полученная заметка GetNoteByID: %+v", note) + + if note.UserID != userID { + log.Printf("User %d tried to edit note %d belonging to user %d", userID, note.ID, note.UserID) + http.Error(w, "You do not have permission to edit this note", http.StatusForbidden) + return + } + + tmpl := template.Must(template.ParseFiles("templates/edit.html")) + err = tmpl.Execute(w, note) + if err != nil { + log.Println("Error while executing edit.html:", err) + return + } +} + +func (nh *NoteHandler) EditNote(w http.ResponseWriter, r *http.Request) { + session, err := store.Get(r, sessionName) + if err != nil { + http.Error(w, "Failed to get session", http.StatusBadRequest) + return + } + + userID, ok := session.Values["userID"].(int) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + vars := mux.Vars(r) + id, err := strconv.Atoi(vars["id"]) + if err != nil { + http.Error(w, "Invalid note ID", http.StatusBadRequest) + return + } + + note, err := models.GetNoteByID(nh.DB, id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + if note == nil { + http.Error(w, "Note not found", http.StatusNotFound) + return + } + + if note.UserID != userID { + log.Printf("User %d tried to edit note %d belonging to user %d", userID, note.ID, note.UserID) + http.Error(w, "You do not have permission to edit this note", http.StatusForbidden) + return + } + + title := r.FormValue("title") + content := r.FormValue("content") + + note.Title = title + note.Content = content + + if err := note.UpdateNote(nh.DB); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/notes", http.StatusFound) +} + +func (nh *NoteHandler) DeleteNote(w http.ResponseWriter, r *http.Request) { + session, err := store.Get(r, sessionName) + if err != nil { + http.Error(w, "Failed to get session", http.StatusBadRequest) + return + } + + userID, ok := session.Values["userID"].(int) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + vars := mux.Vars(r) + id, err := strconv.Atoi(vars["id"]) + if err != nil { + http.Error(w, "Invalid note ID", http.StatusBadRequest) + return + } + + note, err := models.GetNoteByID(nh.DB, id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + if note == nil { + http.Error(w, "Note not found", http.StatusNotFound) + return + } + + if note.UserID != userID { + log.Printf("User %d tried to edit note %d belonging to user %d", userID, note.ID, note.UserID) + http.Error(w, "You do not have permission to edit this note", http.StatusForbidden) + return + } + + if err := note.DeleteNote(nh.DB); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/notes", http.StatusSeeOther) +} diff --git a/handlers/session.go b/handlers/session.go new file mode 100644 index 0000000..a1afa56 --- /dev/null +++ b/handlers/session.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "log" + "os" + + "github.com/gorilla/sessions" +) + +var ( + store *sessions.CookieStore + sessionName string +) + +func InitSession() { + sessionSecret := os.Getenv("SESSION_SECRET") + if sessionSecret == "" { + log.Fatal("SESSION_SECRET is not set in .env file") + } + + sessionName = os.Getenv("SESSION_NAME") + if sessionName == "" { + log.Fatal("SESSION_NAME is not set in .env file") + } + + store = sessions.NewCookieStore([]byte(sessionSecret)) +} + +func GetStore() *sessions.CookieStore { + return store +} + +func GetSessionName() string { + return sessionName +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..11db717 --- /dev/null +++ b/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "log" + "net/http" + "time" + + "NotesWebApp/database" + "NotesWebApp/handlers" + + "github.com/gorilla/mux" + "github.com/jmoiron/sqlx" + "github.com/joho/godotenv" +) + +func runServer(db *sqlx.DB, server *http.Server) error { + defer db.Close() + + log.Println("Server is running on port 8080...") + return server.ListenAndServe() +} + +func main() { + err := godotenv.Load(".env") // Загружаем переменные окружения + if err != nil { + log.Fatal("Error loading .env file") + } + + handlers.InitSession() // Инициализация сессии + + db, err := database.InitDB() // инициализация базы + if err != nil { + log.Fatal(err) + } + + router := mux.NewRouter() // инициализация роутера + + // инициализация обработчиков + noteHandler := handlers.NewNoteHandler(db) + authHandler := handlers.NewAuthHandler(db) + + // маршруты заметок + router.HandleFunc("/notes", noteHandler.GetNotes).Methods("GET") + router.HandleFunc("/notes/create", noteHandler.CreateNoteForm).Methods("GET") + router.HandleFunc("/notes/create", noteHandler.CreateNote).Methods("POST") + router.HandleFunc("/notes/edit/{id}", noteHandler.EditNoteForm).Methods("GET") + router.HandleFunc("/notes/edit/{id}", noteHandler.EditNote).Methods("POST") + router.HandleFunc("/notes/delete/{id}", noteHandler.DeleteNote).Methods("POST") + + // маршруты аутентификации + router.HandleFunc("/", authHandler.Index).Methods("GET") + router.HandleFunc("/login", authHandler.LoginForm).Methods("GET") + router.HandleFunc("/login", authHandler.Login).Methods("POST") + router.HandleFunc("/register", authHandler.RegisterForm).Methods("GET") + router.HandleFunc("/register", authHandler.Register).Methods("POST") + router.HandleFunc("/logout", authHandler.Logout).Methods("POST") + + router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + + server := &http.Server{ + Addr: ":8080", + Handler: router, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 30 * time.Second, + } + + log.Println("Server is running on port 8080...") + if err := runServer(db, server); err != nil { + log.Fatal("Server error:", err) + } +} diff --git a/migrations/20250123095246_initial.sql b/migrations/20250123095246_initial.sql new file mode 100644 index 0000000..1a86573 --- /dev/null +++ b/migrations/20250123095246_initial.sql @@ -0,0 +1,19 @@ +-- +goose Up +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL +); + +CREATE TABLE notes ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + user_id INT REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- +goose Down +DROP TABLE notes; +DROP TABLE users; \ No newline at end of file diff --git a/models/note.go b/models/note.go new file mode 100644 index 0000000..f86179d --- /dev/null +++ b/models/note.go @@ -0,0 +1,59 @@ +package models + +import ( + "database/sql" + "errors" + "time" + + "github.com/jmoiron/sqlx" +) + +type Note struct { + ID int `db:"id"` + Title string `db:"title"` + Content string `db:"content"` + UserID int `db:"user_id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (n *Note) CreateNote(db *sqlx.DB) error { + query := `INSERT INTO notes (title, content, user_id) +VALUES ($1, $2, $3) RETURNING id, created_at, updated_at` + return db.QueryRowx(query, n.Title, n.Content, n.UserID).Scan(&n.ID, &n.CreatedAt, &n.UpdatedAt) +} + +func (n *Note) UpdateNote(db *sqlx.DB) error { + n.UpdatedAt = time.Now() + query := `UPDATE notes SET title=:title, content=:content, updated_at=:updated_at + WHERE id=:id` + _, err := db.NamedExec(query, n) + return err +} + +func (n *Note) DeleteNote(db *sqlx.DB) error { + query := `DELETE FROM notes WHERE id=:id` + _, err := db.NamedExec(query, n) + return err +} + +func (n *Note) GetNotesByUser(db *sqlx.DB, userID int) ([]Note, error) { + var notes []Note + query := `SELECT id, title, content, created_at, updated_at FROM notes WHERE user_id=$1` + err := db.Select(¬es, query, userID) + return notes, err +} + +func GetNoteByID(db *sqlx.DB, id int) (*Note, error) { + var note Note + query := `SELECT id, title, content, user_id, created_at, updated_at FROM notes WHERE id=$1` + + err := db.Get(¬e, query, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return ¬e, err +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..aeff2db --- /dev/null +++ b/models/user.go @@ -0,0 +1,23 @@ +package models + +import ( + "github.com/jmoiron/sqlx" +) + +type User struct { + ID int `db:"id"` + Email string `db:"email"` + Password string `db:"password"` +} + +func (u *User) CreateUser(db *sqlx.DB) error { + query := `INSERT into users (email, password) VALUES ($1, $2) RETURNING id` + return db.QueryRowx(query, u.Email, u.Password).Scan(&u.ID) +} + +func GetUserByEmail(db *sqlx.DB, email string) (*User, error) { + var user User + query := `SELECT id, email, password FROM users WHERE email=$1` + err := db.Get(&user, query, email) + return &user, err +} diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..9d239d8 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,47 @@ +body { + font-family: Arial, sans-serif; + margin: 20px; +} + +h1 { + color: #333; +} + +form { + margin-bottom: 20px; +} + +label { + display: block; + margin-bottom: 5px; +} + +input, textarea { + width: 100%; + padding: 8px; + margin-bottom: 10px; + border: 1px solid #ccc; + border-radius: 4px; +} + +button { + padding: 10px 15px; + background-color: #28a745; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +button:hover { + background-color: #218838; +} + +a { + color: #007bff; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} \ No newline at end of file diff --git a/templates/create.html b/templates/create.html new file mode 100644 index 0000000..abe8031 --- /dev/null +++ b/templates/create.html @@ -0,0 +1,21 @@ + + +
+ + +{{.Content}}
+ Edit + +