feat: first commit

This commit is contained in:
sittichok Ouamsiri
2026-06-15 21:25:57 +07:00
commit 3395ab6dd3
88 changed files with 10034 additions and 0 deletions
@@ -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>`
}
+108
View File
@@ -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 &rarr;</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
}
+92
View File
@@ -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)))
}