mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
153 lines
4.5 KiB
Go
153 lines
4.5 KiB
Go
// Package api is the inbound HTTP adapter for the /api/v1 surface.
|
|
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/snip/backend/internal/config"
|
|
"github.com/snip/backend/internal/domain"
|
|
"github.com/snip/backend/internal/port"
|
|
"github.com/snip/backend/internal/service"
|
|
)
|
|
|
|
const (
|
|
sessionCookie = "snip_session"
|
|
stateCookie = "snip_oauth_state"
|
|
)
|
|
|
|
// API wires the link and auth use cases to HTTP.
|
|
type API struct {
|
|
links *service.LinkService
|
|
auth *service.AuthService
|
|
sessions port.SessionManager
|
|
cfg config.Config
|
|
}
|
|
|
|
func New(links *service.LinkService, auth *service.AuthService, sessions port.SessionManager, cfg config.Config) *API {
|
|
return &API{links: links, auth: auth, sessions: sessions, cfg: cfg}
|
|
}
|
|
|
|
// Handler returns the routed, CORS-wrapped /api/v1 handler.
|
|
func (a *API) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/healthz", a.health)
|
|
mux.HandleFunc("/api/v1/config", a.config)
|
|
mux.HandleFunc("/api/v1/links/stats", a.linkStats) // exact: wins over /links/
|
|
mux.HandleFunc("/api/v1/links", a.linksCollection)
|
|
mux.HandleFunc("/api/v1/links/", a.linkItem)
|
|
mux.HandleFunc("/api/v1/auth/", a.authRoutes)
|
|
return cors(mux)
|
|
}
|
|
|
|
func (a *API) health(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// config exposes client-relevant, non-secret settings (e.g. the short domain),
|
|
// so the frontend doesn't hardcode them.
|
|
func (a *API) config(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"shortDomain": a.cfg.ShortDomain})
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, err error) {
|
|
status := http.StatusInternalServerError
|
|
switch {
|
|
case errors.Is(err, domain.ErrUnauthorized):
|
|
status = http.StatusUnauthorized
|
|
case errors.Is(err, domain.ErrForbidden):
|
|
status = http.StatusForbidden
|
|
case errors.Is(err, domain.ErrNotFound):
|
|
status = http.StatusNotFound
|
|
case errors.Is(err, domain.ErrCodeTaken), errors.Is(err, domain.ErrReserved):
|
|
status = http.StatusConflict
|
|
case errors.Is(err, domain.ErrInvalidURL), errors.Is(err, domain.ErrInvalidAlias),
|
|
errors.Is(err, domain.ErrInvalidPin), errors.Is(err, domain.ErrPinInvalid),
|
|
errors.Is(err, domain.ErrPinRequired):
|
|
status = http.StatusBadRequest
|
|
}
|
|
writeJSON(w, status, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// userID extracts and verifies the caller's session, or "" if anonymous.
|
|
func (a *API) userID(r *http.Request) string {
|
|
c, err := r.Cookie(sessionCookie)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
id, err := a.sessions.Verify(c.Value)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return id
|
|
}
|
|
|
|
func (a *API) setSession(w http.ResponseWriter, token string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookie,
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: a.cfg.CookieSecure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Expires: time.Now().Add(30 * 24 * time.Hour),
|
|
})
|
|
}
|
|
|
|
func (a *API) clearCookie(w http.ResponseWriter, name string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: name, Value: "", Path: "/", HttpOnly: true,
|
|
Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1,
|
|
})
|
|
}
|
|
|
|
func randomState() string {
|
|
b := make([]byte, 24)
|
|
_, _ = rand.Read(b)
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
func decode(r *http.Request, v any) error {
|
|
defer r.Body.Close()
|
|
return json.NewDecoder(r.Body).Decode(v)
|
|
}
|
|
|
|
// cors reflects the request Origin and allows credentials (cookies). In
|
|
// production the SPA and API are same-origin behind a gateway; this keeps local
|
|
// dev (vite :5173 → api :8080) working.
|
|
func cors(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
if origin != "" {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Set("Vary", "Origin")
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,PUT,DELETE,OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// pathTail returns the part of the request path after a prefix.
|
|
func pathTail(path, prefix string) string {
|
|
return strings.TrimPrefix(path, prefix)
|
|
}
|