Skip to content

Commit 3a30153

Browse files
author
Viswamedha Nalabotu
committed
Ensured invalid links have default page and validated slug generation
1 parent 4029ede commit 3a30153

7 files changed

Lines changed: 231 additions & 4 deletions

File tree

internal/handler/errors.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package handler
2+
3+
import "net/http"
4+
5+
func (h *Handler) NotFoundPage(w http.ResponseWriter, r *http.Request) {
6+
h.serveHTMLWithStatus(w, "404.html", http.StatusNotFound)
7+
}

internal/handler/handler.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,16 @@ func generateSessionID() (string, error) {
4545
}
4646

4747
func (h *Handler) serveHTML(w http.ResponseWriter, name string) {
48+
h.serveHTMLWithStatus(w, name, http.StatusOK)
49+
}
50+
51+
func (h *Handler) serveHTMLWithStatus(w http.ResponseWriter, name string, status int) {
4852
data, err := fs.ReadFile(h.static, name)
4953
if err != nil {
5054
http.Error(w, "not found", http.StatusNotFound)
5155
return
5256
}
5357
w.Header().Set("Content-Type", "text/html; charset=utf-8")
58+
w.WriteHeader(status)
5459
w.Write(data)
5560
}

internal/handler/integration_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ func testRouter(t *testing.T) (*sql.DB, http.Handler) {
3737
"login.html": &fstest.MapFile{Data: []byte("<html>login</html>")},
3838
"setup.html": &fstest.MapFile{Data: []byte("<html>setup</html>")},
3939
"admin.html": &fstest.MapFile{Data: []byte("<html>admin</html>")},
40+
"404.html": &fstest.MapFile{Data: []byte("<html>custom 404</html>")},
4041
}
4142

4243
h := handler.New(db, "http://localhost:8080", staticFS)
@@ -70,6 +71,7 @@ func testRouter(t *testing.T) (*sql.DB, http.Handler) {
7071
})
7172

7273
r.Get("/{slug}", h.RedirectSlug)
74+
r.NotFound(h.NotFoundPage)
7375

7476
return db, r
7577
}
@@ -530,3 +532,35 @@ func TestAnalyticsFiltersAndDateValidation(t *testing.T) {
530532
t.Fatalf("expected 400 for invalid date range, got %d", rrInvalidDate.Code)
531533
}
532534
}
535+
536+
func TestMissingSlugRendersCustom404Page(t *testing.T) {
537+
db, router := testRouter(t)
538+
defer db.Close()
539+
540+
rr := httptest.NewRecorder()
541+
req := httptest.NewRequest(http.MethodGet, "/no-such-slug", nil)
542+
router.ServeHTTP(rr, req)
543+
544+
if rr.Code != http.StatusNotFound {
545+
t.Fatalf("expected 404 for unknown slug, got %d", rr.Code)
546+
}
547+
if !strings.Contains(rr.Body.String(), "custom 404") {
548+
t.Fatalf("expected custom 404 page body, got %q", rr.Body.String())
549+
}
550+
}
551+
552+
func TestUnmatchedPathRendersCustom404Page(t *testing.T) {
553+
db, router := testRouter(t)
554+
defer db.Close()
555+
556+
rr := httptest.NewRecorder()
557+
req := httptest.NewRequest(http.MethodGet, "/totally/missing/path", nil)
558+
router.ServeHTTP(rr, req)
559+
560+
if rr.Code != http.StatusNotFound {
561+
t.Fatalf("expected 404 for unmatched path, got %d", rr.Code)
562+
}
563+
if !strings.Contains(rr.Body.String(), "custom 404") {
564+
t.Fatalf("expected custom 404 page body, got %q", rr.Body.String())
565+
}
566+
}

internal/handler/links.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,12 +127,17 @@ func (h *Handler) ShortenURL(w http.ResponseWriter, r *http.Request) {
127127
if _, err := h.db.ExecContext(r.Context(),
128128
`INSERT INTO links (slug, url) VALUES (?, ?)`, slug, longURL,
129129
); err != nil {
130-
http.Error(w, "slug already exists", http.StatusConflict)
130+
if isSlugConflictError(err) {
131+
http.Error(w, "slug already exists", http.StatusConflict)
132+
return
133+
}
134+
http.Error(w, "database error", http.StatusInternalServerError)
131135
return
132136
}
133137
} else {
138+
const maxAttempts = 8
134139
var insertErr error
135-
for range 8 {
140+
for i := 0; i < maxAttempts; i++ {
136141
slug = generateSlug()
137142
if isReservedSlug(slug) {
138143
continue
@@ -143,9 +148,13 @@ func (h *Handler) ShortenURL(w http.ResponseWriter, r *http.Request) {
143148
if insertErr == nil {
144149
break
145150
}
151+
if !isSlugConflictError(insertErr) {
152+
http.Error(w, "database error", http.StatusInternalServerError)
153+
return
154+
}
146155
}
147156
if insertErr != nil {
148-
http.Error(w, "database error", http.StatusInternalServerError)
157+
http.Error(w, "could not generate a unique slug, try again", http.StatusConflict)
149158
return
150159
}
151160
}
@@ -248,3 +257,10 @@ func isReservedSlug(slug string) bool {
248257
}
249258
return strings.HasPrefix(normalized, "api/") || strings.HasPrefix(normalized, "static/")
250259
}
260+
261+
func isSlugConflictError(err error) bool {
262+
if err == nil {
263+
return false
264+
}
265+
return strings.Contains(strings.ToLower(err.Error()), "unique constraint failed: links.slug")
266+
}

internal/handler/redirect.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ func (h *Handler) RedirectSlug(w http.ResponseWriter, r *http.Request) {
1616
`SELECT id, url FROM links WHERE slug = ?`, slug,
1717
).Scan(&linkID, &longURL)
1818
if err == sql.ErrNoRows {
19-
http.NotFound(w, r)
19+
h.NotFoundPage(w, r)
2020
return
2121
}
2222
if err != nil {

main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ func main() {
6666
})
6767

6868
r.Get("/{slug}", h.RedirectSlug)
69+
r.NotFound(h.NotFoundPage)
6970

7071
log.Printf("Lynx listening on :%s (base URL: %s)", cfg.Port, cfg.BaseURL)
7172
if err := http.ListenAndServe(":"+cfg.Port, r); err != nil {

static/404.html

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Lynx - Page Not Found</title>
7+
<style>
8+
:root {
9+
--bgA: #fff6e8;
10+
--bgB: #ebf9f7;
11+
--card: rgba(255, 255, 255, 0.85);
12+
--line: rgba(15, 42, 49, 0.13);
13+
--ink: #1f3138;
14+
--muted: #5b6870;
15+
--brand: #0d7f76;
16+
--brand-dark: #085e59;
17+
}
18+
* { box-sizing: border-box; }
19+
body {
20+
margin: 0;
21+
min-height: 100vh;
22+
color: var(--ink);
23+
font-family: "Segoe UI", "Trebuchet MS", system-ui, sans-serif;
24+
background:
25+
radial-gradient(1000px 500px at 8% -10%, #ffd089 0%, transparent 60%),
26+
radial-gradient(900px 500px at 110% 10%, #a9efe7 0%, transparent 56%),
27+
linear-gradient(135deg, var(--bgA), var(--bgB));
28+
display: grid;
29+
place-items: center;
30+
padding: 1rem;
31+
}
32+
.card {
33+
width: min(100%, 640px);
34+
background: var(--card);
35+
border: 1px solid var(--line);
36+
border-radius: 1rem;
37+
backdrop-filter: blur(8px);
38+
box-shadow: 0 20px 40px rgba(12, 45, 44, 0.1);
39+
padding: 1.5rem;
40+
}
41+
.brand {
42+
display: flex;
43+
align-items: center;
44+
gap: 0.6rem;
45+
margin-bottom: 0.9rem;
46+
font-weight: 600;
47+
letter-spacing: 0.2px;
48+
}
49+
.code {
50+
display: inline-flex;
51+
align-items: center;
52+
justify-content: center;
53+
border-radius: 999px;
54+
padding: 0.15rem 0.65rem;
55+
background: rgba(13, 127, 118, 0.1);
56+
color: var(--brand);
57+
border: 1px solid rgba(13, 127, 118, 0.2);
58+
font-weight: 700;
59+
font-size: 0.82rem;
60+
}
61+
h1 {
62+
margin: 0.25rem 0 0.35rem;
63+
font-size: clamp(1.35rem, 3.8vw, 2rem);
64+
line-height: 1.2;
65+
}
66+
p {
67+
margin: 0.4rem 0;
68+
color: var(--muted);
69+
line-height: 1.5;
70+
}
71+
.actions {
72+
margin-top: 1rem;
73+
display: flex;
74+
gap: 0.55rem;
75+
flex-wrap: wrap;
76+
}
77+
.btn {
78+
border-radius: 0.7rem;
79+
padding: 0.6rem 0.9rem;
80+
text-decoration: none;
81+
border: 1px solid transparent;
82+
font-size: 0.92rem;
83+
font-weight: 600;
84+
transition: background 160ms ease, color 160ms ease, border-color 160ms ease;
85+
}
86+
.btn-primary {
87+
color: #fff;
88+
background: linear-gradient(135deg, var(--brand), #0aa395);
89+
}
90+
.btn-primary:hover {
91+
background: linear-gradient(135deg, var(--brand-dark), var(--brand));
92+
}
93+
.btn-secondary {
94+
color: var(--ink);
95+
border-color: rgba(15, 42, 49, 0.2);
96+
background: rgba(255, 255, 255, 0.75);
97+
}
98+
.btn-secondary:hover {
99+
background: rgba(255, 255, 255, 0.95);
100+
}
101+
#auth-note {
102+
margin-top: 0.9rem;
103+
font-size: 0.92rem;
104+
color: var(--muted);
105+
min-height: 1.3rem;
106+
}
107+
</style>
108+
</head>
109+
<body>
110+
<main class="card">
111+
<div class="brand">
112+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#0d7f76">
113+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
114+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
115+
</svg>
116+
Lynx
117+
</div>
118+
119+
<span class="code">404</span>
120+
<h1>This short link does not exist</h1>
121+
<p>The URL you requested could not be found. It may have been deleted, mistyped, or never created.</p>
122+
123+
<p id="auth-note" aria-live="polite">Checking session...</p>
124+
125+
<div class="actions">
126+
<a href="/" class="btn btn-primary">Go to Dashboard</a>
127+
<a href="/login" class="btn btn-secondary">Go to Login</a>
128+
</div>
129+
</main>
130+
131+
<script>
132+
(function () {
133+
const authNote = document.getElementById('auth-note');
134+
let timer;
135+
136+
function startCountdown() {
137+
let remaining = 5;
138+
authNote.textContent = 'You are signed in. Redirecting to dashboard in ' + remaining + 's...';
139+
timer = window.setInterval(function () {
140+
remaining -= 1;
141+
if (remaining <= 0) {
142+
window.clearInterval(timer);
143+
window.location.href = '/';
144+
return;
145+
}
146+
authNote.textContent = 'You are signed in. Redirecting to dashboard in ' + remaining + 's...';
147+
}, 1000);
148+
}
149+
150+
fetch('/api/me', { credentials: 'same-origin' })
151+
.then(function (res) {
152+
if (!res.ok) {
153+
authNote.textContent = 'Sign in to manage links, or stay here and check the URL.';
154+
return;
155+
}
156+
startCountdown();
157+
})
158+
.catch(function () {
159+
authNote.textContent = 'Sign in to manage links, or stay here and check the URL.';
160+
});
161+
})();
162+
</script>
163+
</body>
164+
</html>

0 commit comments

Comments
 (0)