package main import ( "bytes" "compress/gzip" "encoding/json" "flag" "fmt" "io" "log" "net/http" "os" "strconv" "strings" "time" "archive.local/app" "github.com/golang-jwt/jwt/v5" "github.com/jmoiron/sqlx" "github.com/joho/godotenv" "golang.org/x/crypto/bcrypt" _ "github.com/mattn/go-sqlite3" ) var ( addr = flag.String("addr", ":5000", "http server address") ) type User struct { ID int64 `json:"id" db:"id"` Email string `json:"email" db:"email"` Password string `json:"-" db:"password"` CreatedAt string `json:"created_at" db:"created_at"` UpdatedAt string `json:"updated_at" db:"updated_at"` } type Article struct { ID int64 `json:"id" db:"id"` Title string `json:"title" db:"title"` URL string `json:"url" db:"url"` Body []byte `json:"-" db:"body"` UserID int64 `json:"-" db:"user_id"` CreatedAt string `json:"created_at" db:"created_at"` UpdatedAt string `json:"updated_at" db:"updated_at"` } func readJSON(r *http.Request, s any) error { return json.NewDecoder(r.Body).Decode(s) } func sendJSON(w http.ResponseWriter, data any, status int) error { w.WriteHeader(status) return json.NewEncoder(w).Encode(data) } func sendApiError(w http.ResponseWriter, msg string, err error, status int) { var e struct { Message string `json:"message"` Error string `json:"error,omitempty"` } e.Message = msg if err != nil { e.Error = err.Error() } sendJSON(w, e, status) } var jwtSecretKey []byte func init() { godotenv.Load() s := os.Getenv("JWT_SECRET") if s == "" { panic("JWT_SECRET env doesn't exist") } jwtSecretKey = []byte(s) } func generateAccessToken(userId int64) (string, error) { exp := time.Now().Add(time.Hour * 24 * 7) claims := jwt.MapClaims{ "id": userId, "exp": exp.Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(jwtSecretKey) } func getUserIdFromAccessToken(accessToken string) (int64, error) { token, err := jwt.Parse(accessToken, func(t *jwt.Token) (any, error) { return jwtSecretKey, nil }) if err != nil { return -1, err } if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { if id, ok := claims["id"].(float64); ok { return int64(id), nil } else { return -1, fmt.Errorf("couldn't convert id to float64") } } else { return -1, fmt.Errorf("invalid token") } } func getUserIdFromRequest(r *http.Request) (int64, error) { token, err := r.Cookie("access_token") if err != nil { return -1, err } userId, err := getUserIdFromAccessToken(token.Value) if err != nil { return -1, err } return userId, nil } type AuthResponse struct { AccessToken string `json:"access_token"` } func handler(mux *http.ServeMux) http.HandlerFunc { fmt.Printf("starting http server at %s\n", *addr) return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000") w.Header().Set("Access-Control-Allow-Headers", "Authorization,Content-Type,Content-Length") w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS") mux.ServeHTTP(w, r) } } func main() { flag.Parse() dbPath := os.Getenv("DB_PATH") if dbPath == "" { panic("no DB_PATH env") } db, err := sqlx.Connect("sqlite3", dbPath) if err != nil { log.Fatalf("failed to connect to db: %v\n", err) } defer db.Close() if err := db.Ping(); err != nil { log.Fatalf("failed to ping db: %v\n", err) } mux := http.NewServeMux() mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) fmt.Fprintf(w, "ok") }) mux.HandleFunc("POST /auth/register", func(w http.ResponseWriter, r *http.Request) { var body struct { Email string `json:"email"` Password string `json:"password"` } if err := readJSON(r, &body); err != nil { sendApiError(w, "couldn't parse body", err, 500) return } if body.Email == "" || body.Password == "" { sendApiError(w, "invalid request", nil, 400) return } p, err := bcrypt.GenerateFromPassword([]byte(body.Password), 10) if err != nil { sendApiError(w, "couldn't hash password", err, 500) return } res, err := db.Exec("INSERT INTO users (email, password) VALUES (?, ?)", body.Email, string(p)) if err != nil { sendApiError(w, "couldn't create user", err, 500) return } _ = res id, err := res.LastInsertId() if err != nil { sendApiError(w, "couldn't get user id", err, 500) return } token, err := generateAccessToken(id) if err != nil { sendApiError(w, "couldn't generate token", err, 500) return } sendJSON(w, AuthResponse{token}, 201) }) mux.HandleFunc("POST /auth/login", func(w http.ResponseWriter, r *http.Request) { var body struct { Email string `json:"email"` Password string `json:"password"` } if err := readJSON(r, &body); err != nil { sendApiError(w, "couldn't parse body", err, 500) return } if body.Email == "" || body.Password == "" { sendApiError(w, "invalid request", nil, 400) return } row := db.QueryRowx("SELECT * FROM users WHERE email = ?", body.Email) if row.Err() != nil { sendApiError(w, "couldn't find user", err, 404) return } var user User if err := row.StructScan(&user); err != nil { sendApiError(w, "couldn't find user", err, 404) return } if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(body.Password)); err != nil { sendApiError(w, "invalid password", nil, 400) return } token, err := generateAccessToken(user.ID) if err != nil { sendApiError(w, "couldn't generate token", err, 500) return } sendJSON(w, AuthResponse{token}, 200) }) mux.HandleFunc("GET /articles", func(w http.ResponseWriter, r *http.Request) { userId, err := getUserIdFromRequest(r) if err != nil { sendApiError(w, "invalid token", err, 401) return } rows, err := db.Queryx("SELECT * FROM articles WHERE user_id = ?", userId) if err != nil { sendApiError(w, "couldn't find articles", err, 500) return } articles := []Article{} for rows.Next() { var article Article if err := rows.StructScan(&article); err != nil { continue } articles = append(articles, article) } sendJSON(w, articles, 200) }) mux.HandleFunc("POST /articles", func(w http.ResponseWriter, r *http.Request) { userId, err := getUserIdFromRequest(r) if err != nil { sendApiError(w, "invalid token", err, 401) return } var body struct { URL string `json:"url"` } if err := readJSON(r, &body); err != nil { sendApiError(w, "couldn't parse body", err, 500) return } if body.URL == "" { sendApiError(w, "invalid request", nil, 400) return } title, html, err := app.FetchArticleHTML(body.URL) if err != nil { sendApiError(w, "couldn't fetch article", err, 500) return } var b bytes.Buffer gw := gzip.NewWriter(&b) gw.Write([]byte(html)) gw.Close() res, err := db.Exec("INSERT INTO articles (title, url, body, user_id) VALUES (?, ?, ?, ?)", title, body.URL, b.Bytes(), userId) if err != nil { sendApiError(w, "couldn't save article", err, 500) return } _ = res fmt.Fprint(w, "ok") }) mux.HandleFunc("PATCH /articles/{id}", func(w http.ResponseWriter, r *http.Request) { userId, err := getUserIdFromRequest(r) if err != nil { sendApiError(w, "invalid token", err, 401) return } articleIdStr := r.PathValue("id") articleId, err := strconv.Atoi(articleIdStr) if err != nil { sendApiError(w, "couldn't parse article id from url", err, 500) return } var body struct { Title string `json:"title"` } if err := readJSON(r, &body); err != nil { sendApiError(w, "couldn't parse body", err, 500) return } clauses := []string{} args := map[string]any{"id": articleId, "user_id": userId} if body.Title != "" { clauses = append(clauses, "title = :title") args["title"] = body.Title } q := "UPDATE articles SET " + strings.Join(clauses, ", ") + " WHERE id = :id AND user_id = :user_id" res, err := db.NamedExec(q, args) if err != nil { sendApiError(w, "couldn't update article", err, 500) return } _ = res fmt.Fprint(w, "ok") }) mux.HandleFunc("GET /articles/{id}/body", func(w http.ResponseWriter, r *http.Request) { userId, err := getUserIdFromRequest(r) if err != nil { sendApiError(w, "invalid token", err, 401) return } articleIdStr := r.PathValue("id") articleId, err := strconv.Atoi(articleIdStr) if err != nil { sendApiError(w, "couldn't parse article id from url", err, 500) return } row := db.QueryRowx("SELECT * FROM articles WHERE id = ? AND user_id = ?", articleId, userId) if row.Err() != nil { sendApiError(w, "article not found", err, 404) return } var article Article if err := row.StructScan(&article); err != nil { sendApiError(w, "couldn't scan article to struct", err, 500) return } gzr, err := gzip.NewReader(bytes.NewReader(article.Body)) if err != nil { sendApiError(w, "couldn't parse article", err, 500) return } data, err := io.ReadAll(gzr) if err != nil { sendApiError(w, "couldn't decompress article", err, 500) return } w.WriteHeader(200) w.Header().Set("Content-Type", "text/html") w.Write(data) }) mux.HandleFunc("DELETE /articles/{id}", func(w http.ResponseWriter, r *http.Request) { userId, err := getUserIdFromRequest(r) if err != nil { sendApiError(w, "invalid token", err, 401) return } articleIdStr := r.PathValue("id") articleId, err := strconv.Atoi(articleIdStr) if err != nil { sendApiError(w, "couldn't parse article id from url", err, 500) return } _, err = db.Exec("DELETE FROM articles WHERE id = ? AND user_id = ?", articleId, userId) if err != nil { sendApiError(w, "article not found", err, 404) return } fmt.Fprint(w, "ok") }) if err := http.ListenAndServe(*addr, handler(mux)); err != nil { log.Fatalf("failed to start http server: %v\n", err) } }