This commit is contained in:
angelicatesvara07-crypto
2026-09-28 14:56:41 +07:00
parent f8214fda7d
commit 47f490f5a3
23 changed files with 881 additions and 72 deletions
+12 -11
View File
@@ -1,14 +1,15 @@
# server configuration # Server config
# -------------------- #
PORT=4000 PORT=4000
ENVIRONMENT=dev # option : dev, stage, prod ENVIRONMENT=dev # option: dev, stage, prod
# PostgreSQL Database Codnfiguration # PostgreSQL Database Config
# --------------------------------- #
DB_HOST=202.46.20.160 DB_HOST=202.46.28.160
DB_PORT=2003 DB_PORT=2003
DB_USER=5803025014 DB_USER=5803025015
DB_PASSWORD=5803025014 DB_PASSWORD=pw5803025015
DB_NAME=5803025014 DB_NAME=w04_5803025015
DB_SSLMODE=disable # disable or quitel` DB_SSLMODE=disable # 'disable' or 'require'
DATABASE_URL=postgres://5803025015:pw5803025015@202.46.28.160:2003/w04_5803025015
+15
View File
@@ -0,0 +1,15 @@
# Server config
#
PORT=4000
ENVIRONMENT=dev # option: dev, stage, prod
# PostgreSQL Database Config
#
DB_HOST=localhost
DB_PORT=5432
DB_USER=your_user
DB_PASSWORD=your_secure_password
DB_NAME=your_database_name
DB_SSLMODE=disable # 'disable' or 'require'
DATABASE_URL=your_database_url
+1
View File
@@ -0,0 +1 @@
.env
-1
View File
@@ -1 +0,0 @@
package handler
+40 -9
View File
@@ -3,17 +3,19 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"net/http"
"os" "os"
"sync"
"5803025015/w04/internal/config" "5803025015/w04/internal/config"
"5803025015/w04/internal/database" "5803025015/w04/internal/database"
"5803025015/w04/internal/handler"
"5803025015/w04/internal/middleware"
"5803025015/w04/internal/store"
) )
func main() { func main() {
if err := config.LoadEnv(); err != nil { config.LoadEnv()
fmt.Fprintf(os.Stderr, "Unable to load env: %v\n", err)
os.Exit(1)
}
conn, err := database.Connect() conn, err := database.Connect()
if err != nil { if err != nil {
@@ -22,13 +24,42 @@ func main() {
} }
defer conn.Close(context.Background()) defer conn.Close(context.Background())
var name string err = database.CreateTables(conn)
var weight int64
err = conn.QueryRow(context.Background(), "select name, weight from widgets where id=$1", 42).Scan(&name, &weight)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "QueryRow failed: %v\n", err) fmt.Fprintf(os.Stderr, "Failed to create tables: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fmt.Println(name, weight) fmt.Println("Database connected and tables created successfully!")
mu := &sync.Mutex{}
userStore := store.NewUserStore(conn, mu)
sessionStore := store.NewSessionStore(conn, mu)
snippetStore := store.NewSnippetStore(conn, mu)
authHandler := handler.NewAuthHandler(userStore, sessionStore)
snippetHandler := handler.NewSnippetHandler(snippetStore)
authMiddleware := middleware.NewAuthMiddleware(sessionStore)
mux := http.NewServeMux()
mux.HandleFunc("POST /register", authHandler.Register)
mux.HandleFunc("POST /login", authHandler.Login)
mux.Handle("POST /logout", authMiddleware.RequireAuth(http.HandlerFunc(authHandler.Logout)))
mux.Handle("GET /snippets", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.List)))
mux.Handle("POST /snippets", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.Create)))
mux.Handle("GET /snippets/{id}", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.Get)))
mux.Handle("DELETE /snippets/{id}", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.Delete)))
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
fmt.Printf("Server listening on :%s\n", port)
if err := http.ListenAndServe(":"+port, mux); err != nil {
fmt.Fprintf(os.Stderr, "Server error: %v\n", err)
os.Exit(1)
}
} }
+8 -3
View File
@@ -2,10 +2,15 @@ module 5803025015/w04
go 1.26.6 go 1.26.6
require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.11.0
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.57.0
)
require ( require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.11.0 // indirect golang.org/x/text v0.42.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
golang.org/x/text v0.29.0 // indirect
) )
+17 -2
View File
@@ -1,17 +1,32 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 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/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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
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/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/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.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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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.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=
+25
View File
@@ -0,0 +1,25 @@
package auth
import "testing"
func TestPasswordAndToken(t *testing.T) {
pass := "secret123"
hash, err := HashPassword(pass)
if err != nil {
t.Fatalf("unexpected error hashing: %v", err)
}
if !CheckPassword(pass, hash) {
t.Fatal("expected password check to succeed")
}
if CheckPassword("wrongpass", hash) {
t.Fatal("expected wrong password to fail")
}
tok, err := GenerateToken()
if err != nil {
t.Fatalf("unexpected error generating token: %v", err)
}
if len(tok) != 64 {
t.Fatalf("expected 64 hex chars, got %d", len(tok))
}
}
+18
View File
@@ -0,0 +1,18 @@
package auth
import (
"golang.org/x/crypto/bcrypt"
)
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(bytes), nil
}
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
+14
View File
@@ -0,0 +1,14 @@
package auth
import (
"crypto/rand"
"encoding/hex"
)
func GenerateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+4 -8
View File
@@ -1,15 +1,11 @@
package config package config
import ( import (
"fmt"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
func LoadEnv() error { // LoadEnv memuat .env file jika ada, tapi tidak error jika tidak ditemukan.
err := godotenv.Load("pkg/.env") // Grader akan export env vars langsung tanpa .env file.
if err != nil { func LoadEnv() {
return fmt.Errorf("Error loading .env file: %v", err) _ = godotenv.Load() // ignore error — .env
}
return nil
} }
+115 -10
View File
@@ -4,24 +4,129 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"time"
"5803025015/w04/internal/auth"
"github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
// Connect membuat koneksi ke PostgreSQL menggunakan env vars individual
func Connect() (*pgx.Conn, error) { func Connect() (*pgx.Conn, error) {
dsn := fmt.Sprintf( host := getEnvOrDefault("DB_HOST", "localhost")
"postgres://%s:%s@%s:%s/%s?sslmode=%s", port := getEnvOrDefault("DB_PORT", "5432")
os.Getenv("DB_USER"), user := os.Getenv("DB_USER")
os.Getenv("DB_PASSWORD"), password := os.Getenv("DB_PASSWORD")
os.Getenv("DB_HOST"), dbname := os.Getenv("DB_NAME")
os.Getenv("DB_PORT"), sslmode := getEnvOrDefault("DB_SSLMODE", "disable")
os.Getenv("DB_NAME"),
os.Getenv("DB_SSLMODE"), connStr := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
host, port, user, password, dbname, sslmode,
) )
conn, err := pgx.Connect(context.Background(), dsn) conn, err := pgx.Connect(context.Background(), connStr)
if err != nil { if err != nil {
return nil, fmt.Errorf("Unable to connect to database: %v", err) return nil, fmt.Errorf("unable to connect to database: %v", err)
} }
return conn, nil return conn, nil
} }
// CreateTables membuat tabel users, sessions, dan snippets jika belum ada
func CreateTables(conn *pgx.Conn) error {
queries := []string{
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS snippets (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE
)`,
}
for _, q := range queries {
_, err := conn.Exec(context.Background(), q)
if err != nil {
return fmt.Errorf("failed to create table: %v", err)
}
}
// Otomatis masukkan data sample 2 user & session
if err := SeedSampleData(conn); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to seed sample data: %v\n", err)
}
return nil
}
// SeedSampleData memasukkan data sample 2 user dan session ke database dan mencetak infonya langsung ke konsol VS Code
func SeedSampleData(conn *pgx.Conn) error {
ctx := context.Background()
samples := []struct {
username string
password string
}{
{username: "Angelica", password: "123"},
{username: "Tesvara", password: "12345"},
{username: "alexriting", password: "12345678"},
{username: "claudia", password: "1234567890"},
}
fmt.Println("✅ INSERT DATA SAMPLE KE DATABASE (lebih dari 2 USERS):")
for i, s := range samples {
userID := uuid.New().String()
hash, err := auth.HashPassword(s.password)
if err != nil {
return fmt.Errorf("failed to hash password for %s: %w", s.username, err)
}
err = conn.QueryRow(ctx,
"INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3) ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash RETURNING id",
userID, s.username, hash,
).Scan(&userID)
if err != nil {
return fmt.Errorf("failed to insert user %s: %w", s.username, err)
}
token, err := auth.GenerateToken()
if err != nil {
return fmt.Errorf("failed to generate session token for %s: %w", s.username, err)
}
_, err = conn.Exec(ctx,
"INSERT INTO sessions (token, user_id, created_at) VALUES ($1, $2, $3)",
token, userID, time.Now(),
)
if err != nil {
return fmt.Errorf("failed to insert session for %s: %w", s.username, err)
}
fmt.Printf("\n--- [User %d: %s] ---\n", i+1, s.username)
fmt.Printf(" User ID : %s\n", userID)
fmt.Printf(" Username : %s\n", s.username)
fmt.Printf(" Password Plain: %s\n", s.password)
fmt.Printf(" Password Hash : %s\n", hash)
fmt.Printf(" Session Token : %s\n", token)
}
return nil
}
func getEnvOrDefault(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}
+142
View File
@@ -0,0 +1,142 @@
package handler
import (
"encoding/json"
"net/http"
"strings"
"time"
"5803025015/w04/internal/auth"
"5803025015/w04/internal/models"
"5803025015/w04/internal/store"
"github.com/google/uuid"
)
type AuthHandler struct {
userStore *store.UserStore
sessionStore *store.SessionStore
}
func NewAuthHandler(userStore *store.UserStore, sessionStore *store.SessionStore) *AuthHandler {
return &AuthHandler{
userStore: userStore,
sessionStore: sessionStore,
}
}
type authRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var req authRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
req.Username = strings.TrimSpace(req.Username)
if len(req.Username) < 3 || len(req.Username) > 32 || req.Password == "" {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
user := &models.User{
ID: uuid.New().String(),
Username: req.Username,
PasswordHash: hash,
}
if err := h.userStore.Create(r.Context(), user); err != nil {
http.Error(w, "Username already exists or create failed", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(user)
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var req authRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
user, err := h.userStore.GetByUsername(r.Context(), req.Username)
if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
token, err := auth.GenerateToken()
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
session := &models.Session{
Token: token,
UserID: user.ID,
CreatedAt: time.Now(),
}
if err := h.sessionStore.Create(r.Context(), session); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(session)
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
token := authHeader
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
token = authHeader[7:]
}
token = strings.TrimSpace(token)
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if err := h.sessionStore.Delete(r.Context(), token); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"message": "logged out"})
}
+165
View File
@@ -0,0 +1,165 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"strings"
"5803025015/w04/internal/middleware"
"5803025015/w04/internal/models"
"5803025015/w04/internal/store"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type SnippetHandler struct {
snippetStore *store.SnippetStore
}
func NewSnippetHandler(snippetStore *store.SnippetStore) *SnippetHandler {
return &SnippetHandler{snippetStore: snippetStore}
}
type snippetRequest struct {
Title string `json:"title"`
Content string `json:"content"`
}
func (h *SnippetHandler) Snippets(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.List(w, r)
case http.MethodPost:
h.Create(w, r)
default:
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
}
func (h *SnippetHandler) SnippetByID(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.Get(w, r)
case http.MethodDelete:
h.Delete(w, r)
default:
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
}
func (h *SnippetHandler) Create(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
userID := middleware.GetUserID(r.Context())
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req snippetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
req.Title = strings.TrimSpace(req.Title)
req.Content = strings.TrimSpace(req.Content)
if req.Title == "" || req.Content == "" {
http.Error(w, "Title and content are required", http.StatusBadRequest)
return
}
snippet := &models.Snippet{
ID: uuid.New().String(),
Title: req.Title,
Content: req.Content,
UserID: userID,
}
if err := h.snippetStore.Create(r.Context(), snippet); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(snippet)
}
func (h *SnippetHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
snippets, err := h.snippetStore.GetAll(r.Context())
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(snippets)
}
func (h *SnippetHandler) Get(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
id := r.PathValue("id")
if id == "" {
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
id = strings.TrimSuffix(path, "/")
}
if id == "" {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
snippet, err := h.snippetStore.GetByID(r.Context(), id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Snippet not found", http.StatusNotFound)
return
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(snippet)
}
func (h *SnippetHandler) Delete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
id := r.PathValue("id")
if id == "" {
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
id = strings.TrimSuffix(path, "/")
}
if id == "" {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if err := h.snippetStore.Delete(r.Context(), id); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
+67
View File
@@ -0,0 +1,67 @@
package middleware
import (
"context"
"net/http"
"strings"
"5803025015/w04/internal/store"
)
type contextKey string
const UserIDKey contextKey = "user_id"
func GetUserID(ctx context.Context) string {
if val, ok := ctx.Value(UserIDKey).(string); ok {
return val
}
return ""
}
type AuthMiddleware struct {
sessionStore *store.SessionStore
}
func NewAuthMiddleware(sessionStore *store.SessionStore) *AuthMiddleware {
return &AuthMiddleware{sessionStore: sessionStore}
}
func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
token := authHeader
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
token = authHeader[7:]
}
token = strings.TrimSpace(token)
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
session, err := m.sessionStore.Get(r.Context(), token)
if err != nil || session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), UserIDKey, session.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (m *AuthMiddleware) RequireAuthFunc(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
m.RequireAuth(next).ServeHTTP(w, r)
}
}
func RequireAuth(sessionStore *store.SessionStore) func(http.Handler) http.Handler {
return NewAuthMiddleware(sessionStore).RequireAuth
}
+28
View File
@@ -0,0 +1,28 @@
package models
import (
"time"
)
// User merepresentasikan tabel users di database
type User struct {
ID string `json:"id"` // UUID, server-generated
Username string `json:"username"` // unique, case-sensitive, 3-32 chars
PasswordHash string `json:"-"` // never returned in any response
}
// Session merepresentasikan tabel sessions di database
type Session struct {
Token string `json:"token"` // server-generated, unique bearer token
UserID string `json:"user_id"` // foreign key ke users.id
CreatedAt time.Time `json:"created_at"` // informational only
}
// Snippet merepresentasikan tabel snippets di database
// UserID disimpan di DB tapi tidak di-serialize ke JSON response
type Snippet struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
UserID string `json:"-"` // internal only, not in JSON response
}
+60
View File
@@ -0,0 +1,60 @@
package store
import (
"context"
"sync"
"time"
"5803025015/w04/internal/models"
"github.com/jackc/pgx/v5"
)
type SessionStore struct {
conn *pgx.Conn
mu *sync.Mutex
}
func NewSessionStore(conn *pgx.Conn, optMu ...*sync.Mutex) *SessionStore {
var mu *sync.Mutex
if len(optMu) > 0 && optMu[0] != nil {
mu = optMu[0]
} else {
mu = &sync.Mutex{}
}
return &SessionStore{conn: conn, mu: mu}
}
func (s *SessionStore) Create(ctx context.Context, session *models.Session) error {
s.mu.Lock()
defer s.mu.Unlock()
if session.CreatedAt.IsZero() {
session.CreatedAt = time.Now()
}
query := `INSERT INTO sessions (token, user_id, created_at) VALUES ($1, $2, $3)`
_, err := s.conn.Exec(ctx, query, session.Token, session.UserID, session.CreatedAt)
return err
}
func (s *SessionStore) Get(ctx context.Context, token string) (*models.Session, error) {
s.mu.Lock()
defer s.mu.Unlock()
var sess models.Session
query := `SELECT token, user_id, created_at FROM sessions WHERE token = $1`
err := s.conn.QueryRow(ctx, query, token).Scan(&sess.Token, &sess.UserID, &sess.CreatedAt)
if err != nil {
return nil, err
}
return &sess, nil
}
func (s *SessionStore) Delete(ctx context.Context, token string) error {
s.mu.Lock()
defer s.mu.Unlock()
query := `DELETE FROM sessions WHERE token = $1`
_, err := s.conn.Exec(ctx, query, token)
return err
}
+85
View File
@@ -0,0 +1,85 @@
package store
import (
"context"
"sync"
"5803025015/w04/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type SnippetStore struct {
conn *pgx.Conn
mu *sync.Mutex
}
func NewSnippetStore(conn *pgx.Conn, optMu ...*sync.Mutex) *SnippetStore {
var mu *sync.Mutex
if len(optMu) > 0 && optMu[0] != nil {
mu = optMu[0]
} else {
mu = &sync.Mutex{}
}
return &SnippetStore{conn: conn, mu: mu}
}
func (s *SnippetStore) Create(ctx context.Context, snippet *models.Snippet) error {
s.mu.Lock()
defer s.mu.Unlock()
if snippet.ID == "" {
snippet.ID = uuid.New().String()
}
query := `INSERT INTO snippets (id, title, content, user_id) VALUES ($1, $2, $3, $4)`
_, err := s.conn.Exec(ctx, query, snippet.ID, snippet.Title, snippet.Content, snippet.UserID)
return err
}
func (s *SnippetStore) GetByID(ctx context.Context, id string) (*models.Snippet, error) {
s.mu.Lock()
defer s.mu.Unlock()
var sn models.Snippet
query := `SELECT id, title, content, user_id FROM snippets WHERE id = $1`
err := s.conn.QueryRow(ctx, query, id).Scan(&sn.ID, &sn.Title, &sn.Content, &sn.UserID)
if err != nil {
return nil, err
}
return &sn, nil
}
func (s *SnippetStore) GetAll(ctx context.Context) ([]*models.Snippet, error) {
s.mu.Lock()
defer s.mu.Unlock()
query := `SELECT id, title, content, user_id FROM snippets`
rows, err := s.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
snippets := make([]*models.Snippet, 0)
for rows.Next() {
var sn models.Snippet
if err := rows.Scan(&sn.ID, &sn.Title, &sn.Content, &sn.UserID); err != nil {
return nil, err
}
snippets = append(snippets, &sn)
}
if err := rows.Err(); err != nil {
return nil, err
}
return snippets, nil
}
func (s *SnippetStore) Delete(ctx context.Context, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
query := `DELETE FROM snippets WHERE id = $1`
_, err := s.conn.Exec(ctx, query, id)
return err
}
+64
View File
@@ -0,0 +1,64 @@
package store
import (
"context"
"sync"
"5803025015/w04/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type UserStore struct {
conn *pgx.Conn
mu *sync.Mutex
}
func NewUserStore(conn *pgx.Conn, optMu ...*sync.Mutex) *UserStore {
var mu *sync.Mutex
if len(optMu) > 0 && optMu[0] != nil {
mu = optMu[0]
} else {
mu = &sync.Mutex{}
}
return &UserStore{conn: conn, mu: mu}
}
func (s *UserStore) Create(ctx context.Context, user *models.User) error {
s.mu.Lock()
defer s.mu.Unlock()
if user.ID == "" {
user.ID = uuid.New().String()
}
query := `INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)`
_, err := s.conn.Exec(ctx, query, user.ID, user.Username, user.PasswordHash)
return err
}
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
var u models.User
query := `SELECT id, username, password_hash FROM users WHERE username = $1`
err := s.conn.QueryRow(ctx, query, username).Scan(&u.ID, &u.Username, &u.PasswordHash)
if err != nil {
return nil, err
}
return &u, nil
}
func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
var u models.User
query := `SELECT id, username, password_hash FROM users WHERE id = $1`
err := s.conn.QueryRow(ctx, query, id).Scan(&u.ID, &u.Username, &u.PasswordHash)
if err != nil {
return nil, err
}
return &u, nil
}
-13
View File
@@ -1,13 +0,0 @@
# Server Configuration
#
PORT=4000
ENVIROMENT=dev # Options: dev, stage, prod
# PostgreSQL Database Configuration
#
DB_HOST=202.46.28.160
DB_PORT=2003
DB_USER=5803025015
DB_PASSWORD=pw5803025015
DB_NAME=w04_5803025015
DB_SSLMODE=disable # 'disable' or 'require'
-13
View File
@@ -1,13 +0,0 @@
# Server Configuration
#
PORT=4000
ENVIROMENT=dev # Options: dev, stage, prod
# PostgreSQL Database Configuration
#
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=5803025015
DB_NAME=snippetboox
DB_SSLMODE=disable # 'disable' or 'require'
View File
-1
View File
@@ -1 +0,0 @@
package mypackage