Skip to content

Refactor API#67

Merged
julsemaan merged 10 commits into
masterfrom
feat/refactor-api
Apr 13, 2026
Merged

Refactor API#67
julsemaan merged 10 commits into
masterfrom
feat/refactor-api

Conversation

@julsemaan
Copy link
Copy Markdown
Owner

No description provided.

@julsemaan julsemaan requested a review from Copilot April 4, 2026 23:09
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 4, 2026

Go coverage report (utils)

github.com/julsemaan/anyfile-notepad/utils/utils.go:16:	SendEmail		100.0%
github.com/julsemaan/anyfile-notepad/utils/utils.go:48:	sendMailWithTLSConfig	0.0%
total:							(statements)		46.5%

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 4, 2026

Go coverage report (api)

github.com/julsemaan/anyfile-notepad/api/cmd/api/main.go:9:				main				0.0%
github.com/julsemaan/anyfile-notepad/api/internal/app/config.go:23:			LoadConfigFromEnv		100.0%
github.com/julsemaan/anyfile-notepad/api/internal/app/config.go:45:			loadMaxContactRequestsPerDay	100.0%
github.com/julsemaan/anyfile-notepad/api/internal/app/email.go:16:			sendEmailWithOptionalTLS	100.0%
github.com/julsemaan/anyfile-notepad/api/internal/app/email.go:47:			sendMailWithTLSConfig		0.0%
github.com/julsemaan/anyfile-notepad/api/internal/app/run.go:19:			Run				0.0%
github.com/julsemaan/anyfile-notepad/api/internal/contact/service.go:39:		NewService			100.0%
github.com/julsemaan/anyfile-notepad/api/internal/contact/service.go:48:		BeforeInsert			85.7%
github.com/julsemaan/anyfile-notepad/api/internal/contact/service.go:73:		AfterInsert			100.0%
github.com/julsemaan/anyfile-notepad/api/internal/contact/service.go:87:		buildMessage			70.0%
github.com/julsemaan/anyfile-notepad/api/internal/httpapi/handler_stats.go:17:		NewStatsHandler			100.0%
github.com/julsemaan/anyfile-notepad/api/internal/httpapi/middleware_access_log.go:14:	WriteHeader			100.0%
github.com/julsemaan/anyfile-notepad/api/internal/httpapi/middleware_access_log.go:19:	withAccessLog			100.0%
github.com/julsemaan/anyfile-notepad/api/internal/httpapi/middleware_auth.go:10:	IsOpenResource			100.0%
github.com/julsemaan/anyfile-notepad/api/internal/httpapi/middleware_auth.go:30:	Authenticate			100.0%
github.com/julsemaan/anyfile-notepad/api/internal/httpapi/middleware_cors.go:5:		withCORS			100.0%
github.com/julsemaan/anyfile-notepad/api/internal/httpapi/router.go:10:			NewRouter			100.0%
github.com/julsemaan/anyfile-notepad/api/internal/resources/index.go:13:		BuildIndex			0.0%
github.com/julsemaan/anyfile-notepad/api/internal/resources/schema.go:19:		MimeTypeSchema			0.0%
github.com/julsemaan/anyfile-notepad/api/internal/resources/schema.go:43:		ExtensionSchema			0.0%
github.com/julsemaan/anyfile-notepad/api/internal/resources/schema.go:72:		SyntaxSchema			0.0%
github.com/julsemaan/anyfile-notepad/api/internal/resources/schema.go:91:		SettingSchema			0.0%
github.com/julsemaan/anyfile-notepad/api/internal/resources/schema.go:110:		ContactRequestSchema		0.0%
github.com/julsemaan/anyfile-notepad/api/internal/resources/schema.go:136:		Validate			100.0%
github.com/julsemaan/anyfile-notepad/api/internal/stats/service.go:31:			NewService			100.0%
github.com/julsemaan/anyfile-notepad/api/internal/stats/service.go:35:			ParsePayload			89.5%
github.com/julsemaan/anyfile-notepad/api/internal/stats/service.go:67:			Record				85.7%
github.com/julsemaan/anyfile-notepad/api/internal/stats/service.go:82:			normalizeIPMetricKey		76.9%
github.com/julsemaan/anyfile-notepad/api/internal/stats/service.go:107:			extractIP			60.0%
total:											(statements)			68.6%

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 4, 2026

Go coverage report (webserver)

github.com/julsemaan/anyfile-notepad/webserver/billing_handlers.go:38:				renderEmailTemplate		85.7%
github.com/julsemaan/anyfile-notepad/webserver/billing_handlers.go:52:				LoadSubscription		100.0%
github.com/julsemaan/anyfile-notepad/webserver/billing_handlers.go:71:				LoadGoogleUser			96.2%
github.com/julsemaan/anyfile-notepad/webserver/billing_handlers.go:122:				handleSubscriptionRead		100.0%
github.com/julsemaan/anyfile-notepad/webserver/billing_handlers.go:126:				handleSubscriptionResume	100.0%
github.com/julsemaan/anyfile-notepad/webserver/billing_handlers.go:153:				handleSubscriptionCancel	86.7%
github.com/julsemaan/anyfile-notepad/webserver/billing_handlers.go:182:				handleSubscriptionUpgrade	100.0%
github.com/julsemaan/anyfile-notepad/webserver/billing_handlers.go:231:				upgradeError			100.0%
github.com/julsemaan/anyfile-notepad/webserver/billing_handlers.go:238:				handleLinkCancel		60.0%
github.com/julsemaan/anyfile-notepad/webserver/billing_handlers.go:294:				handleStripeHook		72.4%
github.com/julsemaan/anyfile-notepad/webserver/cluster_observer.go:16:				NewClusterObserver		100.0%
github.com/julsemaan/anyfile-notepad/webserver/cluster_observer.go:20:				hostURL				75.0%
github.com/julsemaan/anyfile-notepad/webserver/cluster_observer.go:28:				glpclient			100.0%
github.com/julsemaan/anyfile-notepad/webserver/cluster_observer.go:40:				Start				0.0%
github.com/julsemaan/anyfile-notepad/webserver/email.go:16:					sendEmailWithOptionalTLS	100.0%
github.com/julsemaan/anyfile-notepad/webserver/email.go:47:					sendMailWithTLSConfig		0.0%
github.com/julsemaan/anyfile-notepad/webserver/emails_update/update_customers_emails.go:24:	Next				0.0%
github.com/julsemaan/anyfile-notepad/webserver/emails_update/update_customers_emails.go:28:	Customer			0.0%
github.com/julsemaan/anyfile-notepad/webserver/emails_update/update_customers_emails.go:32:	Err				0.0%
github.com/julsemaan/anyfile-notepad/webserver/emails_update/update_customers_emails.go:42:	main				0.0%
github.com/julsemaan/anyfile-notepad/webserver/emails_update/update_customers_emails.go:47:	updateCustomerEmails		100.0%
github.com/julsemaan/anyfile-notepad/webserver/http_handler.go:12:				ServeHTTP			100.0%
github.com/julsemaan/anyfile-notepad/webserver/http_handler.go:24:				setupPlusPlusSession		75.0%
github.com/julsemaan/anyfile-notepad/webserver/http_handler.go:40:				ServeStaticApplication		100.0%
github.com/julsemaan/anyfile-notepad/webserver/main.go:51:					main				0.0%
github.com/julsemaan/anyfile-notepad/webserver/main.go:71:					setupSessionsPersistence	0.0%
github.com/julsemaan/anyfile-notepad/webserver/main.go:95:					setupHandlers			93.3%
github.com/julsemaan/anyfile-notepad/webserver/main.go:147:					setupSubscriptions		0.0%
github.com/julsemaan/anyfile-notepad/webserver/main.go:165:					setupClusterObserver		0.0%
github.com/julsemaan/anyfile-notepad/webserver/main.go:178:					setupBlockedUsersMap		100.0%
github.com/julsemaan/anyfile-notepad/webserver/main.go:184:					setup				0.0%
github.com/julsemaan/anyfile-notepad/webserver/oauth2_handlers.go:23:				getGoogleOauth2Conf		100.0%
github.com/julsemaan/anyfile-notepad/webserver/oauth2_handlers.go:43:				handleGoogleOauth2Authorize	100.0%
github.com/julsemaan/anyfile-notepad/webserver/oauth2_handlers.go:55:				handleGoogleOauth2Callback	100.0%
github.com/julsemaan/anyfile-notepad/webserver/plus_plus_session.go:26:				NewPlusPlusSession		100.0%
github.com/julsemaan/anyfile-notepad/webserver/plus_plus_session.go:38:				NewPlusPlusSessions		100.0%
github.com/julsemaan/anyfile-notepad/webserver/plus_plus_session.go:45:				GenerateSessionID		80.0%
github.com/julsemaan/anyfile-notepad/webserver/plus_plus_session.go:62:				Set				100.0%
github.com/julsemaan/anyfile-notepad/webserver/plus_plus_session.go:68:				Get				100.0%
github.com/julsemaan/anyfile-notepad/webserver/plus_plus_session.go:74:				Maintenance			100.0%
github.com/julsemaan/anyfile-notepad/webserver/plus_plus_session.go:92:				RestoreFromFile			88.9%
github.com/julsemaan/anyfile-notepad/webserver/plus_plus_session.go:108:			SaveToFile			68.4%
github.com/julsemaan/anyfile-notepad/webserver/realtime_handler.go:13:				publishRealtimeEvent		100.0%
github.com/julsemaan/anyfile-notepad/webserver/subscriptions.go:23:				Next				0.0%
github.com/julsemaan/anyfile-notepad/webserver/subscriptions.go:27:				Sub				0.0%
github.com/julsemaan/anyfile-notepad/webserver/subscriptions.go:31:				Err				0.0%
github.com/julsemaan/anyfile-notepad/webserver/subscriptions.go:53:				NewSubscriptions		100.0%
github.com/julsemaan/anyfile-notepad/webserver/subscriptions.go:60:				Empty				100.0%
github.com/julsemaan/anyfile-notepad/webserver/subscriptions.go:64:				CanHaveAccess			100.0%
github.com/julsemaan/anyfile-notepad/webserver/subscriptions.go:68:				ExtractUserId			75.0%
github.com/julsemaan/anyfile-notepad/webserver/subscriptions.go:76:				SetSubscription			100.0%
github.com/julsemaan/anyfile-notepad/webserver/subscriptions.go:90:				DelSubscription			100.0%
github.com/julsemaan/anyfile-notepad/webserver/subscriptions.go:97:				GetSubscription			100.0%
github.com/julsemaan/anyfile-notepad/webserver/subscriptions.go:104:				GetSubscriptionByCustomer	100.0%
github.com/julsemaan/anyfile-notepad/webserver/subscriptions.go:117:				Reload				100.0%
github.com/julsemaan/anyfile-notepad/webserver/subscriptions.go:145:				Maintenance			83.3%
github.com/julsemaan/anyfile-notepad/webserver/utils.go:10:					secureRandomString		60.0%
github.com/julsemaan/anyfile-notepad/webserver/utils.go:19:					InfoPrint			100.0%
github.com/julsemaan/anyfile-notepad/webserver/utils.go:24:					ErrPrint			100.0%
github.com/julsemaan/anyfile-notepad/webserver/utils.go:29:					EnvOrDefault			100.0%
total:												(statements)			73.1%

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the API from a single main file into focused internal packages (app wiring, HTTP routing/middleware, resources/schemas, stats, and contact hooks), and updates the build target accordingly.

Changes:

  • Split the prior monolithic API implementation into internal/app, internal/httpapi, internal/resources, internal/stats, and internal/contact.
  • Introduce dedicated stats parsing/recording and contact-request insert hooks with new unit tests.
  • Update api/Makefile to build a command-based entrypoint (intended under ./cmd/api) and remove the old afn-rest.go main program.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
api/Makefile Changes build command to output api binary and target ./cmd/api.
api/internal/stats/service.go Adds stats payload parsing + metrics recording helpers.
api/internal/stats/service_test.go Unit tests for stats payload parsing and metric recording.
api/internal/resources/schema.go Defines REST-layer schemas and an email validator.
api/internal/resources/index.go Builds/binds the REST-layer resource index and attaches contact hooks.
api/internal/httpapi/router.go Adds top-level router that bypasses auth for stats and applies CORS.
api/internal/httpapi/router_test.go Tests routing behavior (stats bypass, auth required, CORS headers).
api/internal/httpapi/middleware_cors.go Adds simple CORS header middleware.
api/internal/httpapi/middleware_auth.go Adds “open resource” rules + Basic Auth checker.
api/internal/httpapi/middleware_auth_test.go Unit tests for auth/open-resource behavior.
api/internal/httpapi/handler_stats.go Adds HTTP stats handler delegating to stats service.
api/internal/httpapi/handler_stats_test.go Unit tests for stats handler responses and error mapping.
api/internal/contact/service.go Adds contact request throttling + email sending hooks.
api/internal/contact/service_test.go Unit tests for throttling and email hook behavior.
api/internal/app/run.go Wires services/resources/router and starts the HTTP server.
api/internal/app/config.go Introduces config struct + env loader.
api/afn-rest.go Removes old monolithic main implementation.
api/afn-rest_test.go Removes old tests tied to the monolithic main implementation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread api/Makefile
api:
CGO_ENABLED=0 go build -v

CGO_ENABLED=0 go build -v -o api ./cmd/api
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Makefile now builds ./cmd/api, but there is no api/cmd/api directory (and no package main under api/), so make api will fail. Either add the missing cmd/api entrypoint (main package) or update the build target to the actual main package path.

Copilot uses AI. Check for mistakes.
decoder := json.NewDecoder(bytes.NewBuffer(body))
if err := decoder.Decode(&payload); err != nil {
return nil, ErrInvalidJSON
}
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ParsePayload can panic when the request body is valid JSON null: decoding into a map[string]string results in a nil map, and then payload["ip"] = ... will cause an assignment-to-nil-map panic. Consider initializing payload when nil (or rejecting non-object payloads explicitly) before writing the ip field.

Suggested change
}
}
if payload == nil {
payload = make(map[string]string)
}

Copilot uses AI. Check for mistakes.
Comment thread api/internal/stats/service.go Outdated
Comment on lines +52 to +57
ipKey := strings.ReplaceAll(payload["ip"], ".", "_")
s.metrics.Increment("afn.stats-hits." + ipKey)

if payload["type"] == "increment" {
s.metrics.Increment(payload["key"])
}
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractIP can return IPv6 addresses (e.g. "::1"), but Record only replaces '.' with '_' when building the statsd bucket name. A ':' in the metric name will break the statsd line protocol (colon is a reserved separator). Normalize IPv6 too (e.g., replace ':' as well, or hash/encode the IP) before calling Increment.

Copilot uses AI. Check for mistakes.
Comment thread api/internal/httpapi/router.go Outdated
Comment on lines +8 to +14
var pathStatsRE = regexp.MustCompile(`^/stats`)

func NewRouter(apiHandler http.Handler, statsHandler http.Handler, username string, password string) http.Handler {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if pathStatsRE.MatchString(r.URL.Path) {
statsHandler.ServeHTTP(w, r)
return
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stats route match ^/stats will also match paths like /statsanything, which will bypass auth and be routed to the stats handler. If the intent is only to expose /stats (and maybe /stats/...), tighten the matcher (e.g., ^/stats(?:/|$)) to avoid accidentally opening other endpoints.

Copilot uses AI. Check for mistakes.
Comment thread api/internal/httpapi/middleware_auth.go Outdated

func Authenticate(w http.ResponseWriter, r *http.Request, username string, password string) bool {
requestUsername, requestPassword, ok := r.BasicAuth()
if !ok || requestUsername != username || requestPassword != password {
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authenticate returns a 401 without setting WWW-Authenticate. For HTTP Basic Auth, clients (especially browsers) rely on WWW-Authenticate: Basic realm=... to prompt for credentials; without it, auth UX can break. Consider adding that header on unauthorized responses.

Suggested change
if !ok || requestUsername != username || requestPassword != password {
if !ok || requestUsername != username || requestPassword != password {
w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)

Copilot uses AI. Check for mistakes.
Comment thread api/internal/resources/schema.go Outdated
Comment on lines +16 to +22
var emailRegex = regexp.MustCompile(`\S+@\S+`)

func MimeTypeSchema() schema.Schema {
return schema.Schema{
Description: "The mime_type object",
Fields: schema.Fields{
"id": schema.IDField,
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The email validation regex is not anchored (\S+@\S+), so it will accept strings that merely contain an email substring (including values with CRLF/header injection appended). Since contact_email is later written into mail headers (Reply-To:), this is a security risk. Use a stricter/anchored check (e.g. ^\S+@\S+$) or prefer net/mail address parsing and explicitly reject control characters.

Copilot uses AI. Check for mistakes.
Comment on lines +133 to +142
type emailValidator struct{}

func (emailValidator) Validate(value interface{}) (interface{}, error) {
email, ok := value.(string)
if !ok {
return value, errors.New("Invalid email format")
}
if !emailRegex.MatchString(email) {
return email, errors.New("Invalid email format")
}
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new emailValidator behavior (type assertion + regex validation) no longer has direct unit test coverage (the previous TestEmailValidator was deleted with the old main package). Consider adding a resources package test that asserts valid emails pass and invalid/malicious inputs (e.g. strings with whitespace/CRLF) are rejected, to prevent regressions.

Copilot uses AI. Check for mistakes.
Comment thread api/internal/app/run.go Outdated
Comment on lines +23 to +28
statsConn, _ := statsd.New(statsd.Address(cfg.StatsdAddress))
if statsConn != nil {
defer statsConn.Close()
}

statsService := stats.NewService(statsConn)
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

statsd.New(...) error is ignored. If statsd connection/setup fails, the service will silently run without metrics, which is hard to diagnose. Consider handling/logging the returned error and/or making metrics optional explicitly via config.

Copilot uses AI. Check for mistakes.
Comment thread api/internal/app/run.go Outdated
Comment on lines +28 to +31
statsService := stats.NewService(statsConn)
contactCache := cache.New(24*60*60*1000000000, 60*1000000000)
contactService := contact.NewService(contactCache, cfg.MaxContactRequestsPerDay, cfg.SupportEmail, utils.SendEmail)

Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cache.New(24*60*60*1000000000, 60*1000000000) hard-codes nanosecond math, which is easy to misread and error-prone. Prefer time.Hour, time.Minute, etc. (e.g., 24*time.Hour and time.Minute) for clarity and maintainability.

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +38
return Config{
DataDir: dataDir,
ListenAddr: listenAddr,
Username: os.Getenv("AFN_REST_USERNAME"),
Password: os.Getenv("AFN_REST_PASSWORD"),
SupportEmail: os.Getenv("AFN_SUPPORT_EMAIL"),
StatsdAddress: os.Getenv("AFN_STATSD_URI"),
MaxContactRequestsPerDay: defaultContactRequestsPerDay,
}
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MaxContactRequestsPerDay is present in Config, but LoadConfigFromEnv always sets it to the default constant and provides no way to override it via env/config. Either remove the field (if not meant to be configurable) or load it from an environment variable (with parsing + validation) so the config struct reflects actual behavior.

Copilot uses AI. Check for mistakes.
@julsemaan julsemaan requested a review from Copilot April 4, 2026 23:21
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread api/Makefile Outdated
Comment on lines +4 to +10
@if [ -d ./cmd/api ]; then \
CGO_ENABLED=0 go build -v -o api ./cmd/api; \
elif [ -d ./api/cmd/api ]; then \
CGO_ENABLED=0 go build -v -o api ./api/cmd/api; \
else \
echo "cannot find api main package (expected ./cmd/api or ./api/cmd/api)"; \
exit 1; \
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The build logic checks for ./cmd/api and ./api/cmd/api, but neither directory exists in this repo (there is no Go main package under api/ right now). As written, make api will always hit the error branch and fail; either add the expected cmd entrypoint(s) or update the Makefile to build the actual main package path/output binary name.

Also note the root Makefile runs cd api && make afn-rest-32 / afn-rest-64; those targets no longer exist in api/Makefile, so make all-golang will break unless those targets are restored or the root Makefile is updated accordingly.

Copilot uses AI. Check for mistakes.
Comment thread api/internal/stats/service.go Outdated
Comment on lines +55 to +60
ipKey := strings.NewReplacer(".", "_", ":", "_").Replace(payload["ip"])
s.metrics.Increment("afn.stats-hits." + ipKey)

if payload["type"] == "increment" {
s.metrics.Increment(payload["key"])
}
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Record increments a metric name taken directly from the client-provided payload (payload["key"]). Because the stats endpoint is unauthenticated, this allows unbounded/high-cardinality metric creation (and potentially invalid statsd bucket names), which can overload your metrics backend. Consider validating the key against an allowlist/prefix + length/charset constraints before calling Increment, or map known logical events to fixed metric names server-side.

Copilot uses AI. Check for mistakes.
Comment thread api/internal/httpapi/handler_stats.go Outdated
Comment on lines +35 to +37
log.Printf("Stats request from %s", payload["ip"])
if payload["type"] == "increment" {
log.Printf("afn.stats-hits.%s from %s", payload["key"], payload["ip"])
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These log lines interpolate payload["ip"] and payload["key"] directly using %s. Because both are client-controlled (and /stats bypasses auth), this enables log forging/injection (e.g., embedding newlines) and can generate extremely noisy logs. Consider logging with %q and/or sanitizing/validating these fields before logging (or downgrade/remove per-request logs for this endpoint).

Suggested change
log.Printf("Stats request from %s", payload["ip"])
if payload["type"] == "increment" {
log.Printf("afn.stats-hits.%s from %s", payload["key"], payload["ip"])
log.Printf("Stats request from %q", payload["ip"])
if payload["type"] == "increment" {
log.Printf("afn.stats-hits.%q from %q", payload["key"], payload["ip"])

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +36
func Authenticate(w http.ResponseWriter, r *http.Request, username string, password string) bool {
requestUsername, requestPassword, ok := r.BasicAuth()
if !ok || requestUsername != username || requestPassword != password {
w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("Unauthorized"))
return false
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authenticate compares Basic Auth credentials using normal string equality. If these endpoints are exposed beyond fully trusted networks, prefer constant-time comparison (e.g., subtle.ConstantTimeCompare on []byte) to reduce timing side-channels, and consider explicitly rejecting empty configured credentials to avoid accidentally running with a blank username/password.

Copilot uses AI. Check for mistakes.
Comment thread api/internal/contact/service.go Outdated
log.Printf("Unable to build contact request email: %v", buildErr)
continue
}
_ = s.sendEmail(recipients, msg)
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AfterInsert ignores errors returned by sendEmail. If SMTP/transport fails, this will silently drop notifications and make incidents harder to debug. Consider capturing the returned error and logging it (and/or exposing it via metrics), even if you intentionally don't want to fail the request after persistence succeeds.

Suggested change
_ = s.sendEmail(recipients, msg)
if sendErr := s.sendEmail(recipients, msg); sendErr != nil {
log.Printf("Unable to send contact request email: %v", sendErr)
}

Copilot uses AI. Check for mistakes.
Comment on lines +134 to +145
func (emailValidator) Validate(value interface{}) (interface{}, error) {
email, ok := value.(string)
if !ok {
return value, errors.New("Invalid email format")
}
if strings.ContainsAny(email, "\r\n") {
return email, errors.New("Invalid email format")
}
addr, err := mail.ParseAddress(email)
if err != nil || addr.Address != email {
return email, errors.New("Invalid email format")
}
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validator returns an error string starting with a capital letter ("Invalid email format"). In Go, error strings are typically lower-case and without punctuation; also, keeping the exact message stable matters if it is surfaced in API responses. Consider switching to a lower-case message (e.g., "invalid email format") and reusing a package-level sentinel error to avoid allocating the same string multiple times.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +32 to +42
func (s *Service) ParsePayload(r *http.Request) (map[string]string, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, ErrInvalidPayload
}

var payload map[string]string
decoder := json.NewDecoder(bytes.NewBuffer(body))
if err := decoder.Decode(&payload); err != nil {
return nil, ErrInvalidJSON
}
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ParsePayload reads the entire request body with io.ReadAll(r.Body) without any size limit. This allows a client to send an extremely large body and force high memory usage (DoS). Consider enforcing a maximum payload size (e.g., via http.MaxBytesReader in the handler or io.LimitReader here) and returning a 413/400 when exceeded.

Copilot uses AI. Check for mistakes.
Comment thread api/internal/stats/service.go Outdated
Comment on lines +51 to +56
func (s *Service) Record(payload map[string]string) {
if s.metrics == nil {
return
}

ipKey := strings.NewReplacer(".", "_", ":", "_").Replace(payload["ip"])
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Record uses payload["ip"] directly to construct a StatsD bucket name, but ip can come from X-Forwarded-For and may contain arbitrary characters/very long values. This can create invalid metric names and unbounded cardinality. Consider validating/normalizing the IP (e.g., net.ParseIP, stripping ports/brackets) and capping length before using it in a metric key (or falling back to a constant like "unknown").

Suggested change
func (s *Service) Record(payload map[string]string) {
if s.metrics == nil {
return
}
ipKey := strings.NewReplacer(".", "_", ":", "_").Replace(payload["ip"])
func normalizeIPMetricKey(raw string) string {
const unknownIPMetricKey = "unknown"
candidate := strings.TrimSpace(raw)
if candidate == "" {
return unknownIPMetricKey
}
if host, _, err := net.SplitHostPort(candidate); err == nil {
candidate = host
} else {
candidate = strings.Trim(candidate, "[]")
}
ip := net.ParseIP(candidate)
if ip == nil {
return unknownIPMetricKey
}
ipKey := strings.NewReplacer(".", "_", ":", "_").Replace(ip.String())
if ipKey == "" || len(ipKey) > 64 {
return unknownIPMetricKey
}
return ipKey
}
func (s *Service) Record(payload map[string]string) {
if s.metrics == nil {
return
}
ipKey := normalizeIPMetricKey(payload["ip"])

Copilot uses AI. Check for mistakes.
Comment thread api/internal/httpapi/middleware_auth.go Outdated
}

if r.Method == http.MethodGet {
log.Print("Allowing without authentication for namespace that don't modify resources")
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log message has a grammatical error: "namespace that don't modify resources" should be "namespace that doesn't modify resources".

Suggested change
log.Print("Allowing without authentication for namespace that don't modify resources")
log.Print("Allowing without authentication for namespace that doesn't modify resources")

Copilot uses AI. Check for mistakes.
Comment thread api/internal/httpapi/middleware_auth.go Outdated
}

if r.Method == http.MethodGet {
log.Print("Allowing without authentication for namespace that don't modify resources")
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IsOpenResource logs on every GET request (the common case) which can produce very high log volume and noise in production. Consider removing this log line or gating it behind a debug/verbose flag so normal read traffic doesn't spam logs.

Suggested change
log.Print("Allowing without authentication for namespace that don't modify resources")

Copilot uses AI. Check for mistakes.
Comment on lines +96 to +110
var msgBytes bytes.Buffer
if err := messageTemplate.Execute(&msgBytes, struct {
Emails string
Message string
ReplyTo string
}{
Emails: strings.Join(recipients, ";"),
Message: message,
ReplyTo: replyTo,
}); err != nil {
return nil, err
}

return io.ReadAll(&msgBytes)
}
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildMessage writes the rendered template into a bytes.Buffer and then calls io.ReadAll(&msgBytes), which adds an unnecessary read/copy. You can return the buffer contents directly (e.g., msgBytes.Bytes() / msgBytes.String()) to reduce allocations and simplify the code.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread api/internal/app/run.go
}

statsService := stats.NewService(statsConn)
contactCache := cache.New(24*time.Hour, time.Minute)
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

statsConn can be nil when statsd initialization fails, but passing a typed-nil *statsd.Client into the Metrics interface makes s.metrics != nil and can lead to a panic when Increment is called. Consider explicitly passing nil to stats.NewService when statsConn == nil (or wrap it in a no-op implementation).

Suggested change
contactCache := cache.New(24*time.Hour, time.Minute)
statsService := stats.NewService(nil)
if statsConn != nil {
statsService = stats.NewService(statsConn)
}

Copilot uses AI. Check for mistakes.
Comment thread api/internal/contact/service.go Outdated
"github.com/rs/rest-layer/resource"
)

var errTooManyRequests = errors.New("Too many contact requests, try again later")
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Go error strings are conventionally lowercase and without punctuation. Returning errors.New("Too many …") makes the message inconsistent with typical Go error style and couples tests/clients to that exact capitalization; consider using a lowercase message (and updating tests to assert via errors.Is).

Suggested change
var errTooManyRequests = errors.New("Too many contact requests, try again later")
var errTooManyRequests = errors.New("too many contact requests, try again later")

Copilot uses AI. Check for mistakes.
Comment thread api/internal/contact/service_test.go
Comment thread api/internal/stats/service.go Outdated
Comment on lines +33 to +37
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, ErrInvalidPayload
}

Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ParsePayload reads the entire request body into memory (io.ReadAll) and then decodes JSON from a new buffer, which adds an extra allocation and copy. Decode directly from r.Body (optionally with an io.LimitReader) to reduce memory use for large bodies.

Copilot uses AI. Check for mistakes.
Comment thread api/.gitignore Outdated
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pages/contact.markdown Outdated
$.ajax({
type: "POST",
url: "https://api.anyfile-notepad.semaan.ca/contact_requests",
url: "http://localhost:8001/contact_requests",
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contact form now posts to a hard-coded http://localhost:8001/contact_requests, which will break in non-local deployments and downgrades to plain HTTP. Consider using a relative URL (same-origin), or injecting the API base URL via configuration/build-time variable so production points to the correct HTTPS endpoint.

Suggested change
url: "http://localhost:8001/contact_requests",
url: "/contact_requests",

Copilot uses AI. Check for mistakes.
Comment thread api/internal/contact/service.go Outdated
}

func (s *Service) BeforeInsert(_ context.Context, items []*resource.Item) error {
if s.cache != nil && s.cache.ItemCount() >= s.maxPerDay {
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate limiting uses cache.ItemCount() >= s.maxPerDay without accounting for len(items) in a single insert batch. A request inserting multiple items can exceed the daily limit (e.g., 9 cached + 2 new with max=10). Consider checking cache.ItemCount()+len(items) > s.maxPerDay (or equivalent) before allowing the insert.

Suggested change
if s.cache != nil && s.cache.ItemCount() >= s.maxPerDay {
if s.cache != nil && s.cache.ItemCount()+len(items) > s.maxPerDay {

Copilot uses AI. Check for mistakes.
Comment thread utils/utils.go
Comment on lines +23 to 29
var auth smtp.Auth
if user != "" || password != "" {
auth = smtp.PlainAuth("", user, password, host)
}

skipTLSVerify, _ := strconv.ParseBool(os.Getenv("SMTP_SKIP_TLS_VERIFY"))

Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SMTP_SKIP_TLS_VERIFY is parsed directly with strconv.ParseBool(os.Getenv(...)), unlike other implementations in this repo that trim surrounding quotes (e.g. "'true'"). This makes behavior inconsistent and can silently ignore a quoted boolean. Consider applying the same trimming/normalization here (and adding a test for the quoted case).

Copilot uses AI. Check for mistakes.
Comment thread webserver/email.go
Comment on lines +16 to +22
func sendEmailWithOptionalTLS(to []string, msg []byte) error {
host := os.Getenv("SMTP_HOST")
port := os.Getenv("SMTP_PORT")
from := os.Getenv("SMTP_FROM")
user := os.Getenv("SMTP_USER")
password := os.Getenv("SMTP_PASSWORD")
addr := net.JoinHostPort(host, port)
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This SMTP implementation is effectively duplicated across webserver/email.go, api/internal/app/email.go, and utils/utils.go and has already started to diverge (e.g., boolean parsing behavior). Consider extracting the shared logic into a single package/function to avoid future inconsistencies and reduce maintenance overhead.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread webserver/email.go
Comment on lines +24 to +27
var auth smtp.Auth
if user != "" || password != "" {
auth = smtp.PlainAuth("", user, password, host)
}
Comment thread webserver/email.go
Comment on lines +29 to +36
rawSkipTLSVerify := strings.TrimSpace(os.Getenv("SMTP_SKIP_TLS_VERIFY"))
rawSkipTLSVerify = strings.Trim(rawSkipTLSVerify, "\"'")
skipTLSVerify, _ := strconv.ParseBool(rawSkipTLSVerify)

err := error(nil)
if skipTLSVerify {
err = smtpSendMailWithTLSConfig(addr, host, from, to, msg, auth, true)
} else {
Comment thread api/internal/app/email.go
Comment on lines +25 to +26
if user != "" || password != "" {
auth = smtp.PlainAuth("", user, password, host)
Comment thread api/internal/app/email.go
Comment on lines +31 to +34
skipTLSVerify, _ := strconv.ParseBool(rawSkipTLSVerify)

err := error(nil)
if skipTLSVerify {
Comment on lines +48 to +51
func (s *Service) BeforeInsert(_ context.Context, items []*resource.Item) error {
if s.cache != nil && s.cache.ItemCount()+len(items) > s.maxPerDay {
return errTooManyRequests
}
Comment thread utils/utils.go
Comment on lines +25 to +26
if user != "" || password != "" {
auth = smtp.PlainAuth("", user, password, host)
Comment thread utils/utils.go
Comment on lines +29 to +36
rawSkipTLSVerify := strings.TrimSpace(os.Getenv("SMTP_SKIP_TLS_VERIFY"))
rawSkipTLSVerify = strings.Trim(rawSkipTLSVerify, "\"'")
skipTLSVerify, _ := strconv.ParseBool(rawSkipTLSVerify)

// Here we do it all: connect to our server, set up a message and send it
err := smtpSendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), auth, os.Getenv("SMTP_FROM"), to, msg)
err := error(nil)
if skipTLSVerify {
err = smtpSendMailWithTLSConfig(addr, host, from, to, msg, auth, true)
@julsemaan julsemaan merged commit 972898f into master Apr 13, 2026
16 checks passed
@julsemaan julsemaan deleted the feat/refactor-api branch April 13, 2026 23:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants