424 lines
9.9 KiB
Go
424 lines
9.9 KiB
Go
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() {
|
|
if err := godotenv.Load(); err != nil {
|
|
panic("couldn't parse env")
|
|
}
|
|
|
|
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()
|
|
|
|
db, err := sqlx.Connect("sqlite3", "./sqlite.db")
|
|
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)
|
|
}
|
|
}
|