package api import ( "context" "io" "database/sql" "errors" "encoding/json" "log" "net/http" "net/url" "time" "wikiapiserver/db" ) const defaultTimeout = 5 * time.Second // Handler holds the DB dependency for all HTTP handlers. type Handler struct { db *db.DB } // NewHandler creates a Handler backed by the given DB. func NewHandler(database *db.DB) *Handler { return &Handler{db: database} } // --- request/response types --- type errResp struct { Error string `json:"error"` } type registerReq struct { Username string `json:"username"` Password string `json:"password"` } type loginReq struct { Username string `json:"username"` Password string `json:"password"` } // tokenResp is returned by GET /token. type tokenResp struct { AccessToken string `json:"access_token"` ValidUntil string `json:"valid_until"` } // --- helper writers --- func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(v) //nolint:errcheck } func badRequest(w http.ResponseWriter, msg string) { writeJSON(w, http.StatusBadRequest, errResp{Error: msg}) } func unauthorized(w http.ResponseWriter) { writeJSON(w, http.StatusUnauthorized, errResp{Error: "unauthorized"}) } func serverError(w http.ResponseWriter, msg string) { writeJSON(w, http.StatusInternalServerError, errResp{Error: msg}) } // --- Register: POST /register --- func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), defaultTimeout) defer cancel() var req registerReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { badRequest(w, "invalid JSON") return } if req.Username == "" || req.Password == "" { badRequest(w, "username and password are required") return } acct, err := h.db.Register(ctx, req.Username, req.Password) if err != nil { if err.Error() == "username already exists" { badRequest(w, "username already exists") return } log.Printf("register error: %v", err) serverError(w, "could not create account") return } writeJSON(w, http.StatusCreated, acct) } // --- Login: POST /login --- func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), defaultTimeout) defer cancel() var req loginReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { badRequest(w, "invalid JSON") return } if req.Username == "" || req.Password == "" { badRequest(w, "username and password are required") return } acct, err := h.db.Authenticate(ctx, req.Username, req.Password) if err != nil { if err.Error() == "invalid credentials" { unauthorized(w) return } serverError(w, "authentication failed") return } writeJSON(w, http.StatusOK, acct) } // --- Refresh: POST /refresh --- // Accepts username and refresh_token. The refresh_token is used to // verify identity; RefreshTokens handles the age-based logic. type refreshReq struct { Username string `json:"username"` RefreshToken string `json:"refresh_token"` } func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), defaultTimeout) defer cancel() var req refreshReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { badRequest(w, "invalid JSON") return } if req.Username == "" || req.RefreshToken == "" { badRequest(w, "username and refresh_token are required") return } acct, err := h.db.RefreshTokens(ctx, req.Username, req.RefreshToken) if err != nil { if err.Error() == "account not found" || err.Error() == "invalid refresh token" { unauthorized(w) return } log.Printf("refresh error: %v", err) serverError(w, "could not refresh token") return } writeJSON(w, http.StatusOK, acct) } // --- Health: GET /health --- func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), defaultTimeout) defer cancel() if err := h.db.Ping(ctx); err != nil { writeJSON(w, http.StatusServiceUnavailable, errResp{Error: "database unavailable"}) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } // --- Get Token: GET /token?username=... --- func (h *Handler) GetToken(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), defaultTimeout) defer cancel() username := r.URL.Query().Get("username") if username == "" { badRequest(w, "username query parameter is required") return } acct, err := h.db.GetAccount(ctx, username) if err != nil { if errors.Is(err, sql.ErrNoRows) { unauthorized(w) return } serverError(w, "could not retrieve token") return } writeJSON(w, http.StatusOK, tokenResp{ AccessToken: acct.AccessToken, ValidUntil: acct.AccessTokenExpiry.Add(24 * time.Hour).Format(time.RFC3339), }) } // --- Get Article: GET /article?username=...&name=... --- func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() username := r.URL.Query().Get("username") article := r.URL.Query().Get("name") if username == "" || article == "" { badRequest(w, "username and name query parameters are required") return } acct, err := h.db.GetAccount(ctx, username) if err != nil { if errors.Is(err, sql.ErrNoRows) { unauthorized(w) return } serverError(w, "could not retrieve token") return } url := "https://api.enterprise.wikimedia.com/v2/structured-contents/" + url.QueryEscape(article) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { serverError(w, "could not build request") return } req.Header.Set("Authorization", "Bearer "+acct.AccessToken) resp, err := http.DefaultClient.Do(req) if err != nil { serverError(w, "wikimedia api error") return } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) http.Error(w, string(b), resp.StatusCode) return } w.Header().Set("Content-Type", "application/json") io.Copy(w, resp.Body) //nolint:errcheck }