mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-19 07:28:46 +07:00
feat: first commit
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/snip/backend/internal/adapter/memory"
|
||||
"github.com/snip/backend/internal/adapter/security"
|
||||
"github.com/snip/backend/internal/config"
|
||||
"github.com/snip/backend/internal/domain"
|
||||
"github.com/snip/backend/internal/httpx/api"
|
||||
"github.com/snip/backend/internal/service"
|
||||
)
|
||||
|
||||
type harness struct {
|
||||
h http.Handler
|
||||
token string
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
repo := memory.NewLinkRepo()
|
||||
cache := memory.NewCache()
|
||||
users := memory.NewUserRepo()
|
||||
hasher := security.NewBcryptHasher()
|
||||
sessions := security.NewHMACSessions("test-secret")
|
||||
|
||||
links := service.NewLinkService(repo, cache, hasher)
|
||||
auth := service.NewAuthService(nil, users, sessions)
|
||||
cfg := config.Config{PostLoginRedirect: "/dashboard"}
|
||||
|
||||
user, err := users.Upsert(context.Background(), &domain.User{
|
||||
Email: "[email protected]", Name: "Sam", Provider: "test", Subject: "s1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _ := sessions.Issue(user.ID, time.Hour)
|
||||
|
||||
return &harness{h: api.New(links, auth, sessions, cfg).Handler(), token: token}
|
||||
}
|
||||
|
||||
func (h *harness) do(t *testing.T, method, path, body, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var r *http.Request
|
||||
if body != "" {
|
||||
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
} else {
|
||||
r = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
if token != "" {
|
||||
r.AddCookie(&http.Cookie{Name: "snip_session", Value: token})
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func decodeLink(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
|
||||
t.Fatalf("bad json (%d): %s", w.Code, w.Body.String())
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestAnonCanCreateRandomButNotList(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com/x","mode":"random"}`, "")
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if code, _ := decodeLink(t, w)["code"].(string); code == "" {
|
||||
t.Fatal("expected a code")
|
||||
}
|
||||
|
||||
if w := h.do(t, http.MethodGet, "/api/v1/links", "", ""); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anon list: want 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomAndReservedAndConflict(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
// reserved alias → 409
|
||||
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"dashboard"}`, h.token); w.Code != http.StatusConflict {
|
||||
t.Fatalf("reserved: want 409, got %d", w.Code)
|
||||
}
|
||||
// valid custom → 201
|
||||
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"promo"}`, h.token); w.Code != http.StatusCreated {
|
||||
t.Fatalf("custom: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
// duplicate → 409
|
||||
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"promo"}`, h.token); w.Code != http.StatusConflict {
|
||||
t.Fatalf("dupe: want 409, got %d", w.Code)
|
||||
}
|
||||
// anon custom → 401
|
||||
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"x9z"}`, ""); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anon custom: want 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullLifecycleWithPin(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
// create
|
||||
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com/a","mode":"random"}`, h.token)
|
||||
id := decodeLink(t, w)["id"].(string)
|
||||
|
||||
// list shows it
|
||||
w = h.do(t, http.MethodGet, "/api/v1/links", "", h.token)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list: %d", w.Code)
|
||||
}
|
||||
if total := listTotal(t, w); total != 1 {
|
||||
t.Fatalf("want total 1, got %d", total)
|
||||
}
|
||||
|
||||
// patch destination
|
||||
w = h.do(t, http.MethodPatch, "/api/v1/links/"+id, `{"longUrl":"acme.com/b"}`, h.token)
|
||||
if w.Code != http.StatusOK || decodeLink(t, w)["longUrl"] != "https://acme.com/b" {
|
||||
t.Fatalf("patch failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// set pin
|
||||
w = h.do(t, http.MethodPut, "/api/v1/links/"+id+"/pin", `{"pin":"123456"}`, h.token)
|
||||
if w.Code != http.StatusOK || decodeLink(t, w)["hasPin"] != true {
|
||||
t.Fatalf("set pin failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// bad pin → 400
|
||||
if w := h.do(t, http.MethodPut, "/api/v1/links/"+id+"/pin", `{"pin":"12"}`, h.token); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad pin: want 400, got %d", w.Code)
|
||||
}
|
||||
// remove pin
|
||||
w = h.do(t, http.MethodDelete, "/api/v1/links/"+id+"/pin", "", h.token)
|
||||
if w.Code != http.StatusOK || decodeLink(t, w)["hasPin"] != false {
|
||||
t.Fatalf("remove pin failed: %d", w.Code)
|
||||
}
|
||||
|
||||
// delete
|
||||
if w := h.do(t, http.MethodDelete, "/api/v1/links/"+id, "", h.token); w.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: want 204, got %d", w.Code)
|
||||
}
|
||||
// gone from list
|
||||
w = h.do(t, http.MethodGet, "/api/v1/links", "", h.token)
|
||||
if total := listTotal(t, w); total != 0 {
|
||||
t.Fatalf("want 0 links after delete, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func listTotal(t *testing.T, w *httptest.ResponseRecorder) int {
|
||||
t.Helper()
|
||||
var resp struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("bad list json: %s", w.Body.String())
|
||||
}
|
||||
return resp.Total
|
||||
}
|
||||
|
||||
func TestPatchForbiddenForOtherUser(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"random"}`, h.token)
|
||||
id := decodeLink(t, w)["id"].(string)
|
||||
|
||||
other := security.NewHMACSessions("test-secret")
|
||||
otherToken, _ := other.Issue("someone-else", time.Hour)
|
||||
if w := h.do(t, http.MethodPatch, "/api/v1/links/"+id, `{"longUrl":"https://evil.com"}`, otherToken); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("want 403 for non-owner, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeRequiresSession(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
if w := h.do(t, http.MethodGet, "/api/v1/auth/me", "", ""); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("me anon: want 401, got %d", w.Code)
|
||||
}
|
||||
w := h.do(t, http.MethodGet, "/api/v1/auth/me", "", h.token)
|
||||
if w.Code != http.StatusOK || decodeLink(t, w)["email"] != "[email protected]" {
|
||||
t.Fatalf("me: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
if w := h.do(t, http.MethodGet, "/api/v1/healthz", "", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("healthz: %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/snip/backend/internal/domain"
|
||||
)
|
||||
|
||||
// /api/v1/auth/...
|
||||
// GET providers
|
||||
// GET {provider}/login
|
||||
// GET {provider}/callback
|
||||
// GET me
|
||||
// POST logout
|
||||
func (a *API) authRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
rest := pathTail(r.URL.Path, "/api/v1/auth/")
|
||||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||
|
||||
switch {
|
||||
case len(parts) == 1 && parts[0] == "providers":
|
||||
writeJSON(w, http.StatusOK, map[string][]string{"providers": a.auth.Providers()})
|
||||
case len(parts) == 1 && parts[0] == "me":
|
||||
a.me(w, r)
|
||||
case len(parts) == 1 && parts[0] == "logout":
|
||||
a.clearCookie(w, sessionCookie)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case len(parts) == 2 && parts[1] == "login":
|
||||
a.beginLogin(w, r, parts[0])
|
||||
case len(parts) == 2 && parts[1] == "callback":
|
||||
a.callback(w, r, parts[0])
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) me(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
writeError(w, domain.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
user, err := a.auth.Me(r.Context(), c.Value)
|
||||
if err != nil {
|
||||
writeError(w, domain.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, userDTO{ID: user.ID, Email: user.Email, Name: user.Name})
|
||||
}
|
||||
|
||||
func (a *API) beginLogin(w http.ResponseWriter, r *http.Request, provider string) {
|
||||
state := randomState()
|
||||
url, err := a.auth.AuthURL(provider, state)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
// Double-submit: stash state in a short-lived cookie, compare on callback.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookie, Value: state, Path: "/", HttpOnly: true,
|
||||
Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(10 * time.Minute),
|
||||
})
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *API) callback(w http.ResponseWriter, r *http.Request, provider string) {
|
||||
cookie, err := r.Cookie(stateCookie)
|
||||
if err != nil || cookie.Value == "" || cookie.Value != r.URL.Query().Get("state") {
|
||||
writeError(w, domain.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
a.clearCookie(w, stateCookie)
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
writeError(w, domain.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
_, token, err := a.auth.Complete(r.Context(), provider, code)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
a.setSession(w, token)
|
||||
http.Redirect(w, r, a.cfg.PostLoginRedirect, http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/snip/backend/internal/domain"
|
||||
)
|
||||
|
||||
// linkDTO is the JSON shape returned to the SPA. It intentionally matches the
|
||||
// frontend's `ShortLink` type so the mock API is a drop-in swap. PinHash is
|
||||
// never included.
|
||||
type linkDTO struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
LongURL string `json:"longUrl"`
|
||||
Mode string `json:"mode"`
|
||||
HasPin bool `json:"hasPin"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
TotalClicks int64 `json:"totalClicks"`
|
||||
Last7Days []domain.DayCount `json:"last7Days"`
|
||||
Owned bool `json:"owned"`
|
||||
}
|
||||
|
||||
func toDTO(l *domain.Link) linkDTO {
|
||||
week := l.Last7Days
|
||||
if week == nil {
|
||||
week = []domain.DayCount{}
|
||||
}
|
||||
return linkDTO{
|
||||
ID: l.ID,
|
||||
Code: l.Code,
|
||||
LongURL: l.LongURL,
|
||||
Mode: string(l.Mode),
|
||||
HasPin: l.HasPin,
|
||||
CreatedAt: l.CreatedAt.Format(time.RFC3339),
|
||||
TotalClicks: l.TotalClicks,
|
||||
Last7Days: week,
|
||||
Owned: l.OwnerID != "",
|
||||
}
|
||||
}
|
||||
|
||||
// listResponse is the paginated link listing returned to the dashboard.
|
||||
type listResponse struct {
|
||||
Items []linkDTO `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type createReq struct {
|
||||
LongURL string `json:"longUrl"`
|
||||
Mode string `json:"mode"`
|
||||
CustomAlias string `json:"customAlias"`
|
||||
Pin string `json:"pin"`
|
||||
}
|
||||
|
||||
type updateReq struct {
|
||||
LongURL string `json:"longUrl"`
|
||||
}
|
||||
|
||||
type pinReq struct {
|
||||
Pin string `json:"pin"`
|
||||
}
|
||||
|
||||
type userDTO struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/snip/backend/internal/domain"
|
||||
"github.com/snip/backend/internal/service"
|
||||
)
|
||||
|
||||
// /api/v1/links — POST create (auth optional), GET list (auth required)
|
||||
func (a *API) linksCollection(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
a.createLink(w, r)
|
||||
case http.MethodGet:
|
||||
a.listLinks(w, r)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) createLink(w http.ResponseWriter, r *http.Request) {
|
||||
var req createReq
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeError(w, domain.ErrInvalidURL)
|
||||
return
|
||||
}
|
||||
mode := domain.Mode(req.Mode)
|
||||
if mode != domain.ModeRandom && mode != domain.ModeMemorable && mode != domain.ModeCustom {
|
||||
mode = domain.ModeRandom
|
||||
}
|
||||
link, err := a.links.Create(r.Context(), service.CreateInput{
|
||||
LongURL: req.LongURL,
|
||||
Mode: mode,
|
||||
CustomAlias: req.CustomAlias,
|
||||
Pin: req.Pin,
|
||||
}, a.userID(r))
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toDTO(link))
|
||||
}
|
||||
|
||||
func (a *API) listLinks(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
pageSize, _ := strconv.Atoi(q.Get("pageSize"))
|
||||
|
||||
result, err := a.links.List(r.Context(), a.userID(r), q.Get("q"), page, pageSize)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]linkDTO, 0, len(result.Items))
|
||||
for i := range result.Items {
|
||||
items = append(items, toDTO(&result.Items[i]))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listResponse{
|
||||
Items: items,
|
||||
Total: result.Total,
|
||||
Page: result.Page,
|
||||
PageSize: result.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) linkStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := a.links.Stats(r.Context(), a.userID(r))
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// /api/v1/links/{id} — PATCH, DELETE
|
||||
// /api/v1/links/{id}/pin — PUT, DELETE
|
||||
func (a *API) linkItem(w http.ResponseWriter, r *http.Request) {
|
||||
rest := pathTail(r.URL.Path, "/api/v1/links/")
|
||||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||
id := parts[0]
|
||||
if id == "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
owner := a.userID(r)
|
||||
|
||||
// /{id}/pin
|
||||
if len(parts) == 2 && parts[1] == "pin" {
|
||||
a.managePin(w, r, id, owner)
|
||||
return
|
||||
}
|
||||
if len(parts) != 1 {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPatch:
|
||||
var req updateReq
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeError(w, domain.ErrInvalidURL)
|
||||
return
|
||||
}
|
||||
link, err := a.links.UpdateDestination(r.Context(), id, owner, req.LongURL)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toDTO(link))
|
||||
case http.MethodDelete:
|
||||
if err := a.links.Delete(r.Context(), id, owner); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) managePin(w http.ResponseWriter, r *http.Request, id, owner string) {
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
var req pinReq
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeError(w, domain.ErrInvalidPin)
|
||||
return
|
||||
}
|
||||
link, err := a.links.SetPin(r.Context(), id, owner, req.Pin)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toDTO(link))
|
||||
case http.MethodDelete:
|
||||
link, err := a.links.SetPin(r.Context(), id, owner, "")
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toDTO(link))
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package frontend_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/snip/backend/internal/httpx/frontend"
|
||||
)
|
||||
|
||||
func tempDist(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<!doctype html><div id=root>SPA</div>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "assets", "app.js"), []byte("console.log('hi')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestServesSPARoutes(t *testing.T) {
|
||||
h := frontend.New(tempDist(t)).Handler()
|
||||
|
||||
for _, route := range []string{"/", "/login", "/dashboard"} {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, route, nil))
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SPA") {
|
||||
t.Fatalf("%s should render index, got %d", route, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServesAssets(t *testing.T) {
|
||||
h := frontend.New(tempDist(t)).Handler()
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil))
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "console.log") {
|
||||
t.Fatalf("asset should be served, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownPathIs404(t *testing.T) {
|
||||
h := frontend.New(tempDist(t)).Handler()
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/some/short-code", nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown path: want 404 (redirect cmd owns it), got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Package frontend serves the built SPA. It owns exactly the app routes
|
||||
// (/, /login, /dashboard) plus static assets; everything else is 404 here and
|
||||
// handled by the redirect command in production.
|
||||
package frontend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
dist string
|
||||
fs http.Handler
|
||||
spa map[string]bool
|
||||
indexAbs string
|
||||
}
|
||||
|
||||
func New(dist string) *Server {
|
||||
return &Server{
|
||||
dist: dist,
|
||||
fs: http.FileServer(http.Dir(dist)),
|
||||
spa: map[string]bool{"/": true, "/login": true, "/dashboard": true},
|
||||
indexAbs: filepath.Join(dist, "index.html"),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
mux.HandleFunc("/", s.serve)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
clean := path.Clean(r.URL.Path)
|
||||
|
||||
// App routes always render the SPA shell.
|
||||
if s.spa[clean] {
|
||||
http.ServeFile(w, r, s.indexAbs)
|
||||
return
|
||||
}
|
||||
|
||||
// Real static file (assets, favicon, …)?
|
||||
if clean != "/" {
|
||||
full := filepath.Join(s.dist, filepath.FromSlash(clean))
|
||||
if st, err := os.Stat(full); err == nil && !st.IsDir() {
|
||||
s.fs.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown path: not this command's concern.
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package redirect
|
||||
|
||||
import "html"
|
||||
|
||||
// notFoundHTML is a tiny, self-contained 404 in the snip style.
|
||||
func notFoundHTML(shortHost, homeURL string) string {
|
||||
h := html.EscapeString(shortHost)
|
||||
href := html.EscapeString(homeURL)
|
||||
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Link not found</title><style>
|
||||
:root{--bg:#f4f2ea;--surface:#fffdf7;--ink:#16170f;--muted:#6b6c5e;--line:#e2decf;--accent:#c6f24e}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:#0e100a;--surface:#181b11;--ink:#f1efe3;--muted:#9b9d8a;--line:#2c3020}}
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--ink);
|
||||
font-family:ui-sans-serif,system-ui,sans-serif;text-align:center;padding:24px}
|
||||
.card{background:var(--surface);border:1.5px solid var(--line);border-radius:24px;padding:36px;max-width:360px}
|
||||
h1{font-size:54px;margin:0;letter-spacing:-.03em}
|
||||
p{color:var(--muted);margin:8px 0 20px}
|
||||
a{display:inline-block;background:var(--accent);color:#16170f;text-decoration:none;font-weight:600;
|
||||
padding:12px 20px;border-radius:14px}
|
||||
</style></head><body><div class="card"><h1>404</h1>
|
||||
<p>This short link doesn't exist or was removed.</p>
|
||||
<a href="` + href + `">Go to ` + h + `</a></div></body></html>`
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package redirect
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// pinData feeds the enter-PIN template.
|
||||
type pinData struct {
|
||||
Code string
|
||||
ShortHost string
|
||||
ActionPath string
|
||||
HasError bool
|
||||
}
|
||||
|
||||
// The page is fully self-contained (no external CSS/JS/fonts) so it paints in a
|
||||
// single round trip — the redirect path must stay fast. It mirrors the snip
|
||||
// look: warm paper + ink, electric-lime accent, with a dark-mode variant.
|
||||
var pinTmpl = template.Must(template.New("pin").Parse(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#16170f">
|
||||
<title>Enter PIN · {{.ShortHost}}/{{.Code}}</title>
|
||||
<style>
|
||||
:root{--bg:#f4f2ea;--surface:#fffdf7;--ink:#16170f;--muted:#6b6c5e;--line:#e2decf;--accent:#c6f24e;--accent-ink:#16170f}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#0e100a;--surface:#181b11;--ink:#f1efe3;--muted:#9b9d8a;--line:#2c3020;--accent:#c6f24e}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;
|
||||
background:var(--bg);color:var(--ink);
|
||||
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
background-image:radial-gradient(60% 50% at 12% 8%,rgba(198,242,78,.30),transparent 60%),radial-gradient(60% 50% at 90% 90%,rgba(198,242,78,.22),transparent 60%)}
|
||||
.card{width:100%;max-width:380px;background:var(--surface);border:1.5px solid var(--line);
|
||||
border-radius:24px;padding:28px;box-shadow:0 30px 80px -30px rgba(0,0,0,.45);
|
||||
animation:pop .45s cubic-bezier(.2,.9,.25,1.2)}
|
||||
@keyframes pop{from{opacity:0;transform:translateY(16px) scale(.96)}to{opacity:1;transform:none}}
|
||||
.lock{width:48px;height:48px;border-radius:16px;background:var(--ink);display:grid;place-items:center;margin-bottom:18px}
|
||||
.lock svg{width:24px;height:24px}
|
||||
h1{font-size:24px;margin:0 0 6px;letter-spacing:-.02em}
|
||||
p{margin:0 0 20px;color:var(--muted);font-size:14px;line-height:1.5}
|
||||
p b{color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
.pins{display:flex;gap:8px;margin-bottom:14px}
|
||||
.pins input{flex:1;width:100%;height:54px;text-align:center;font-size:22px;font-weight:700;
|
||||
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink);
|
||||
background:var(--bg);border:1.5px solid var(--line);border-radius:14px;outline:none;transition:transform .12s,border-color .12s}
|
||||
.pins input:focus{transform:translateY(-2px) scale(1.05);border-color:var(--accent)}
|
||||
.err{color:#ef4444;font-size:13px;font-weight:600;margin:0 0 14px;min-height:18px}
|
||||
button{width:100%;height:52px;border:0;border-radius:16px;background:var(--accent);color:var(--accent-ink);
|
||||
font-size:15px;font-weight:600;cursor:pointer;box-shadow:0 8px 24px -8px rgba(198,242,78,.6);transition:transform .12s}
|
||||
button:active{transform:scale(.96)}
|
||||
.foot{margin-top:16px;text-align:center;font-size:12px;color:var(--muted)}
|
||||
.foot b{color:var(--ink)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="lock"><svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="11" rx="3" fill="#c6f24e"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="#c6f24e" stroke-width="2.4" fill="none"/><circle cx="12" cy="15.5" r="1.7" fill="#16170f"/></svg></div>
|
||||
<h1>This link is protected</h1>
|
||||
<p>Enter the 6-digit PIN to continue to <b>{{.ShortHost}}/{{.Code}}</b>.</p>
|
||||
<form method="post" action="{{.ActionPath}}" id="f" autocomplete="off">
|
||||
<div class="pins" id="pins">
|
||||
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 1" required>
|
||||
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 2" required>
|
||||
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 3" required>
|
||||
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 4" required>
|
||||
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 5" required>
|
||||
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 6" required>
|
||||
</div>
|
||||
<p class="err">{{if .HasError}}That PIN didn't match. Try again.{{end}}</p>
|
||||
<input type="hidden" name="pin" id="pin">
|
||||
<button type="submit">Unlock →</button>
|
||||
</form>
|
||||
<div class="foot">Secured by <b>{{.ShortHost}}</b></div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var boxes=[].slice.call(document.querySelectorAll('#pins input')),hidden=document.getElementById('pin'),f=document.getElementById('f');
|
||||
function sync(){hidden.value=boxes.map(function(b){return b.value}).join('')}
|
||||
boxes.forEach(function(b,i){
|
||||
b.addEventListener('input',function(){
|
||||
b.value=b.value.replace(/\D/g,'').slice(0,1);
|
||||
if(b.value&&i<boxes.length-1)boxes[i+1].focus();
|
||||
sync();
|
||||
if(hidden.value.length===6)f.submit();
|
||||
});
|
||||
b.addEventListener('keydown',function(e){if(e.key==='Backspace'&&!b.value&&i>0)boxes[i-1].focus()});
|
||||
b.addEventListener('paste',function(e){
|
||||
var d=(e.clipboardData.getData('text')||'').replace(/\D/g,'').slice(0,6);
|
||||
if(!d)return;e.preventDefault();
|
||||
d.split('').forEach(function(c,j){if(boxes[j])boxes[j].value=c});
|
||||
boxes[Math.min(d.length,5)].focus();sync();if(d.length===6)f.submit();
|
||||
});
|
||||
});
|
||||
if(boxes[0])boxes[0].focus();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
// renderPinPage writes the enter-PIN page. status is 200 on first view, 401 on
|
||||
// a wrong-PIN retry.
|
||||
func renderPinPage(w http.ResponseWriter, status int, d pinData) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = pinTmpl.Execute(w, d)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package redirect_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/snip/backend/internal/adapter/memory"
|
||||
"github.com/snip/backend/internal/adapter/security"
|
||||
"github.com/snip/backend/internal/domain"
|
||||
"github.com/snip/backend/internal/httpx/redirect"
|
||||
"github.com/snip/backend/internal/service"
|
||||
)
|
||||
|
||||
func setup(t *testing.T) (*service.LinkService, http.Handler) {
|
||||
t.Helper()
|
||||
repo := memory.NewLinkRepo()
|
||||
cache := memory.NewCache()
|
||||
hasher := security.NewBcryptHasher()
|
||||
links := service.NewLinkService(repo, cache, hasher)
|
||||
redir := service.NewRedirectService(repo, cache, hasher, service.NoopRecorder{})
|
||||
srv := redirect.New(redir, "snip.to", "https://snip.to")
|
||||
return links, srv.Handler()
|
||||
}
|
||||
|
||||
func TestRedirectFound(t *testing.T) {
|
||||
links, h := setup(t)
|
||||
l, _ := links.Create(context.Background(), service.CreateInput{LongURL: "acme.com/go", Mode: domain.ModeRandom}, "")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/"+l.Code, nil))
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("want 302, got %d", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Location"); got != "https://acme.com/go" {
|
||||
t.Fatalf("bad location: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectNotFound(t *testing.T) {
|
||||
_, h := setup(t)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ghost", nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApexRedirectsHome(t *testing.T) {
|
||||
_, h := setup(t)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if w.Code != http.StatusFound || w.Header().Get("Location") != "https://snip.to" {
|
||||
t.Fatalf("apex should redirect home, got %d %s", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinPageAndUnlock(t *testing.T) {
|
||||
links, h := setup(t)
|
||||
l, _ := links.Create(context.Background(), service.CreateInput{LongURL: "acme.com/secret", Mode: domain.ModeRandom, Pin: "424242"}, "owner")
|
||||
|
||||
// GET shows the enter-PIN page, not a redirect.
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/"+l.Code, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("pin page: want 200, got %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "protected") || !strings.Contains(body, l.Code) {
|
||||
t.Fatal("pin page missing expected content")
|
||||
}
|
||||
if strings.Contains(body, "acme.com/secret") {
|
||||
t.Fatal("pin page must not leak the destination")
|
||||
}
|
||||
|
||||
// Wrong pin → 401 + error page.
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, postForm("/"+l.Code, "pin", "000000"))
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong pin: want 401, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Correct pin → 302 to destination.
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, postForm("/"+l.Code, "pin", "424242"))
|
||||
if w.Code != http.StatusFound || w.Header().Get("Location") != "https://acme.com/secret" {
|
||||
t.Fatalf("unlock: want 302 to target, got %d %s", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func postForm(path, key, val string) *http.Request {
|
||||
form := url.Values{key: {val}}
|
||||
r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Package redirect is the inbound adapter for the high-traffic redirect path.
|
||||
// It resolves a code to a destination (cache-first) and renders the enter-PIN
|
||||
// page for protected links.
|
||||
package redirect
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/snip/backend/internal/domain"
|
||||
"github.com/snip/backend/internal/service"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
svc *service.RedirectService
|
||||
shortHost string
|
||||
homeURL string
|
||||
}
|
||||
|
||||
func New(svc *service.RedirectService, shortHost, homeURL string) *Server {
|
||||
return &Server{svc: svc, shortHost: shortHost, homeURL: homeURL}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
mux.HandleFunc("/", s.handle)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
code := strings.Trim(r.URL.Path, "/")
|
||||
if code == "" {
|
||||
http.Redirect(w, r, s.homeURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
if strings.Contains(code, "/") {
|
||||
s.notFound(w)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
out, err := s.svc.Resolve(r.Context(), code)
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
s.notFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if out.RequiresPin {
|
||||
renderPinPage(w, http.StatusOK, pinData{Code: code, ShortHost: s.shortHost, ActionPath: "/" + code})
|
||||
return
|
||||
}
|
||||
redirectOut(w, r, out.LongURL)
|
||||
|
||||
case http.MethodPost:
|
||||
_ = r.ParseForm()
|
||||
long, err := s.svc.VerifyPin(r.Context(), code, r.FormValue("pin"))
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrNotFound):
|
||||
s.notFound(w)
|
||||
case errors.Is(err, domain.ErrPinInvalid):
|
||||
renderPinPage(w, http.StatusUnauthorized, pinData{Code: code, ShortHost: s.shortHost, ActionPath: "/" + code, HasError: true})
|
||||
case err != nil:
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
default:
|
||||
redirectOut(w, r, long)
|
||||
}
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func redirectOut(w http.ResponseWriter, r *http.Request, target string) {
|
||||
// Don't let intermediaries cache the bounce.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) notFound(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(notFoundHTML(s.shortHost, s.homeURL)))
|
||||
}
|
||||
Reference in New Issue
Block a user