Skip to content

Commit 4aff5fd

Browse files
committed
feat: Add thread and post editing functionality
- Implement thread and post editing capabilities - Enhance CSRF token handling - Improve user authentication - Update database queries for editing support - Add new HTML templates for edit pages - Refactor code for better error handling and logging - Add new routes for edit pages - Update existing routes to use new user middleware Change-Id: I5a8339fea53bac56c60a5207d548cb052d0f55a1 Signed-off-by: Ian Meyer <[email protected]>
1 parent dcc67be commit 4aff5fd

23 files changed

+1760
-361
lines changed

BUILD.bazel

+5
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ go_library(
2525
],
2626
embedsrcs = [
2727
"tmpl/edit-profile.html",
28+
"tmpl/edit-thread.html",
29+
"tmpl/edit-thread-post.html",
2830
"tmpl/error.html",
2931
"tmpl/footer.html",
3032
"tmpl/header.html",
@@ -42,13 +44,15 @@ go_library(
4244
"gitSha": "{STABLE_GIT_SHA}",
4345
},
4446
deps = [
47+
"@com_github_google_uuid//:uuid",
4548
"@com_github_jackc_pgx_v5//:pgx",
4649
"@com_github_jackc_pgx_v5//pgconn",
4750
"@com_github_jackc_pgx_v5//pgtype",
4851
"@com_github_jackc_pgx_v5//pgxpool",
4952
"@com_github_microcosm_cc_bluemonday//:bluemonday",
5053
"@com_github_prometheus_client_golang//prometheus",
5154
"@com_github_prometheus_client_golang//prometheus/promhttp",
55+
"@com_github_prometheus_client_golang//prometheus/testutil",
5256
"@com_github_yuin_goldmark//:goldmark",
5357
"@com_github_yuin_goldmark//extension",
5458
"@com_github_yuin_goldmark//renderer/html",
@@ -76,6 +80,7 @@ go_test(
7680
"config_test.go",
7781
"csrf_test.go",
7882
"helpers_test.go",
83+
"metrics_test.go",
7984
"parser_test.go",
8085
"server_test.go",
8186
],

MODULE.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ go_deps = use_extension("@gazelle//:extensions.bzl", "go_deps")
1616
go_deps.from_file(go_mod = "//:go.mod")
1717
use_repo(
1818
go_deps,
19+
"com_github_google_uuid",
1920
"com_github_jackc_pgx_v5",
2021
"com_github_microcosm_cc_bluemonday",
2122
"com_github_prometheus_client_golang",

Makefile

+8-2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ else
88
$(error Unsupported platform: $(UNAME_S))
99
endif
1010

11+
SUDO_C := $(shell which sudo)
12+
1113
# Detect architecture
1214
UNAME_M := $(shell uname -m)
1315
ifeq ($(UNAME_M),arm64)
@@ -28,9 +30,9 @@ BAZEL_RELEASE_ARGS := build --config=silent --stamp --workspace_status_command="
2830
BAZEL_TEST_ARGS := test --config=silent --build_tests_only --test_output=errors
2931
BAZEL_RUN_ARGS := run
3032
# Change the hostname to anything you wish to use for testing
31-
BAZEL_RUN_TRAILING_ARGS := -hostname discuss-dev
33+
BAZEL_RUN_TRAILING_ARGS := -hostname discuss-dev -debug
3234

33-
.PHONY: all clean test run run-binary genhtml release coverage
35+
.PHONY: all clean test run run-binary genhtml release coverage setup-db
3436

3537
all: test build
3638

@@ -63,6 +65,10 @@ genhtml:
6365
@[ -d "$(shell pwd)/genhtml" ] && rm -rf "$(shell pwd)/genhtml" && echo "Removed previous genhtml/"
6466
@genhtml --branch-coverage --output genhtml "$(shell $(BAZEL) info output_path)/_coverage/_coverage_report.dat" 2>&1>/dev/null
6567

68+
clean-db:
69+
@dropdb -U discuss discuss
70+
@createdb -U discuss discuss
71+
@psql -U discuss discuss < sqlc/schema.sql
6672

6773
clean:
6874
$(BAZEL) clean

csrf.go

+83-33
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@ const (
1515
csrfTokenLength = 32
1616
csrfCookieName = "csrf_token"
1717
csrfHeaderName = "X-CSRF-Token"
18+
csrfContextKey = "CSRFToken"
19+
cleanupInterval = 1 * time.Hour
20+
tokenExpiryTime = 12 * time.Hour
1821
)
1922

2023
var (
2124
tokenStore = make(map[string]time.Time)
22-
tokenStoreMu sync.Mutex
25+
tokenStoreMu sync.RWMutex
2326
timeNow = time.Now
2427
csrfLogger *slog.Logger
2528
)
@@ -28,47 +31,54 @@ func initCSRFLogger(logger *slog.Logger) {
2831
csrfLogger = logger
2932
}
3033

31-
func generateCSRFToken() (string, error) {
34+
func generateCSRFToken() string {
3235
b := make([]byte, csrfTokenLength)
33-
_, err := rand.Read(b)
34-
if err != nil {
35-
return "", err
36+
if _, err := rand.Read(b); err != nil {
37+
if csrfLogger != nil {
38+
csrfLogger.Error("Failed to generate CSRF token", "error", err)
39+
}
40+
return ""
3641
}
37-
return base64.StdEncoding.EncodeToString(b), nil
42+
return base64.StdEncoding.EncodeToString(b)
3843
}
3944

40-
func setCSRFToken(r *http.Request, w http.ResponseWriter) (string, error) {
41-
token, err := generateCSRFToken()
42-
if err != nil {
43-
return "", err
45+
func setCSRFToken(w http.ResponseWriter, r *http.Request) (string, error) {
46+
token := generateCSRFToken()
47+
if token == "" {
48+
return "", errors.New("Failed to generate CSRF token")
4449
}
4550

4651
isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
4752

4853
http.SetCookie(w, &http.Cookie{
4954
Name: csrfCookieName,
5055
Value: token,
56+
Path: "/",
5157
HttpOnly: true,
52-
Secure: isSecure, // Set to true if using HTTPS
58+
Secure: isSecure,
5359
SameSite: http.SameSiteStrictMode,
60+
MaxAge: int(tokenExpiryTime.Seconds()),
5461
})
5562

56-
if csrfLogger != nil {
57-
csrfLogger.DebugContext(r.Context(), "set csrf cookie")
58-
}
59-
6063
tokenStoreMu.Lock()
61-
tokenStore[token] = timeNow().Add(24 * time.Hour) // Token expires in 24 hours
64+
tokenStore[token] = timeNow().Add(tokenExpiryTime)
6265
tokenStoreMu.Unlock()
6366

6467
return token, nil
6568
}
6669

70+
func GetCSRFToken(r *http.Request) string {
71+
if token, ok := r.Context().Value(csrfContextKey).(string); ok {
72+
return token
73+
}
74+
return ""
75+
}
76+
6777
func validateCSRFToken(r *http.Request) error {
6878
cookie, err := r.Cookie(csrfCookieName)
6979
if err != nil {
7080
if csrfLogger != nil {
71-
csrfLogger.DebugContext(r.Context(), "csrf error", slog.String("message", err.Error()))
81+
csrfLogger.Debug("CSRF cookie not found", "error", err)
7282
}
7383
return errors.New("CSRF cookie not found")
7484
}
@@ -78,59 +88,99 @@ func validateCSRFToken(r *http.Request) error {
7888
token = r.FormValue("csrf_token")
7989
if token == "" {
8090
if csrfLogger != nil {
81-
csrfLogger.DebugContext(r.Context(), "csrf token not found in header or form")
91+
csrfLogger.Debug("CSRF token not found in header or form")
8292
}
83-
return errors.New("CSRF token not found in header or form")
93+
return errors.New("CSRF token not found")
8494
}
8595
}
8696

8797
if cookie.Value != token {
8898
if csrfLogger != nil {
89-
csrfLogger.DebugContext(r.Context(), "csrf token mismatch")
99+
csrfLogger.Debug("CSRF token mismatch")
90100
}
91101
return errors.New("CSRF token mismatch")
92102
}
93103

94-
tokenStoreMu.Lock()
95-
defer tokenStoreMu.Unlock()
96-
104+
tokenStoreMu.RLock()
97105
expiry, exists := tokenStore[token]
106+
tokenStoreMu.RUnlock()
107+
98108
if !exists {
99109
if csrfLogger != nil {
100-
csrfLogger.DebugContext(r.Context(), "csrf token not found in store")
110+
csrfLogger.Debug("CSRF token not found in store")
101111
}
102112
return errors.New("CSRF token not found in store")
103113
}
104114

105115
if timeNow().After(expiry) {
106-
delete(tokenStore, token)
107116
if csrfLogger != nil {
108-
csrfLogger.DebugContext(r.Context(), "csrf token expired")
117+
csrfLogger.Debug("CSRF token expired")
109118
}
110119
return errors.New("CSRF token expired")
111120
}
112121

122+
if csrfLogger != nil {
123+
csrfLogger.Debug("CSRF validation successful")
124+
}
113125
return nil
114126
}
115127

116128
func CSRFMiddleware(next http.HandlerFunc) http.HandlerFunc {
117129
return func(w http.ResponseWriter, r *http.Request) {
118-
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
119-
token, err := setCSRFToken(r, w)
130+
var token string
131+
var err error
132+
133+
if r.Method == http.MethodGet || r.Method == http.MethodHead {
134+
token, err = setCSRFToken(w, r)
120135
if err != nil {
121-
http.Error(w, "Failed to set CSRF token", http.StatusInternalServerError)
136+
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
122137
return
123138
}
124139
w.Header().Set(csrfHeaderName, token)
125-
126-
ctx := context.WithValue(r.Context(), "CSRFToken", token)
127-
next.ServeHTTP(w, r.WithContext(ctx))
128140
} else {
129141
if err := validateCSRFToken(r); err != nil {
130142
http.Error(w, "CSRF validation failed", http.StatusForbidden)
131143
return
132144
}
145+
token = r.Header.Get(csrfHeaderName)
146+
if token == "" {
147+
token = r.FormValue("csrf_token")
148+
}
149+
}
150+
151+
ctx := context.WithValue(r.Context(), csrfContextKey, token)
152+
next.ServeHTTP(w, r.WithContext(ctx))
153+
}
154+
}
155+
156+
func cleanupExpiredTokens() {
157+
tokenStoreMu.Lock()
158+
defer tokenStoreMu.Unlock()
159+
now := timeNow()
160+
for token, expiry := range tokenStore {
161+
if now.After(expiry) {
162+
delete(tokenStore, token)
133163
}
134-
next.ServeHTTP(w, r)
135164
}
165+
166+
if csrfLogger != nil {
167+
csrfLogger.Debug("Cleaned up expired CSRF tokens")
168+
}
169+
}
170+
171+
func startCleanupRoutine(ctx context.Context) {
172+
ticker := time.NewTicker(cleanupInterval)
173+
defer ticker.Stop()
174+
for {
175+
select {
176+
case <-ticker.C:
177+
cleanupExpiredTokens()
178+
case <-ctx.Done():
179+
return
180+
}
181+
}
182+
}
183+
184+
func init() {
185+
go startCleanupRoutine(context.Background())
136186
}

0 commit comments

Comments
 (0)