Techadamia Backend - Code Documentation
Package-by-package reference of the exported (and a few notable unexported) functions and types, with signatures verified against the source.
Tip
This is the function-level reference. For the bigger picture see ARCHITECTURE.md, and for where files live see CODEBASE_STRUCTURE.md.
Table of Contents
- Main Package (
cmd) - API Package (
internal/api) - Service Package (
internal/service) - Middleware Package (
internal/middleware) - Realtime Package (
internal/realtime) - Mail Package (
internal/mail) - SQL Package (
internal/sql) - Utils Package (
internal/utils) - Migrate Package (
internal/migrate) - Types & Enums
Main Package (cmd)
Location: cmd/main.go (package main)
func main()
Entry point. If the first CLI arg is healthcheck, it runs runHealthcheck()
and returns. Otherwise it loads .env, configures slog, validates config,
builds the DB pool, runs migrations, wires service/handlers/router, starts the
session-cleanup and orphan-media-GC goroutines, and serves HTTP until graceful
shutdown. Exits 1 on config or database errors.
It also builds the realtime hub and the SMTP mailer before serving:
realtime.NewHub() is registered on the service as its Notifier, and
mail.LoadFromEnv() is handed to the handler via SetMailer. SMTP is
optional — see Mail Package for what changes when
it is absent.
func loadConfig() (appConfig, error)
Reads and validates all configuration from the environment, returning an
appConfig (DatabaseURL, AppPort, MediaStorageDir,
SessionCleanupInterval). It also eagerly validates CORS, every rate-limit
spec, the Redis URL, media limits/MIME, trusted proxies, and HSTS values so the
process fails fast on misconfiguration.
func newDBPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error)
Parses databaseURL and returns a tuned pgxpool.Pool: MaxConns 20,
MinConns 2, MaxConnLifetime 1h, MaxConnIdleTime 30m, HealthCheckPeriod
1m, plus a 10% lifetime jitter. Every value is env-overridable
(DB_MAX_CONNS, DB_MIN_CONNS, DB_MAX_CONN_LIFETIME,
DB_MAX_CONN_IDLE_TIME, DB_HEALTH_CHECK_PERIOD).
func runHealthcheck()
Backs the container HEALTHCHECK. Probes http://127.0.0.1:$APP_PORT/health
(default port 8080) with a 2s timeout and exits 0 on HTTP 200, otherwise 1.
Other helpers
func startSessionCleanup(ctx, svc *service.Service, interval time.Duration)- ticker goroutine callingsvc.DeleteExpiredSessionsandsvc.DeleteExpiredAuthTokens(password-reset and email-verification tokens share the sweep); no-op if interval <= 0.func validateTrustedProxies(raw string) error- validates CSV of IPs/CIDRs.func ensureWritableDir(path string) error- creates the media dir and verifies it is writable.func envInt32(name string, fallback int32) int32,func envDuration(name string, fallback time.Duration) time.Duration- env parsing with fallbacks.
API Package (internal/api)
Location: internal/api/
Routing (routes.go)
type Routes struct { Router *gin.Engine; Handler *APIHandler }
func NewRouter(handler *APIHandler, opts ...gin.OptionFunc) *Routes
Creates a Gin engine with gin.Recovery() and any optional engine options.
func (r *Routes) RegisterRoutes() error
Loads CORS config and trusted proxies, optionally connects Redis (and registers
it on the handler as the readiness checker), builds the five rate limiters
(login/register/media/admin/write) via buildLimiter, installs the global
middleware chain, and registers the public, protected/author, and admin route
groups. Returns an error on config/Redis failures.
Unexported helpers: maxRequestBodyBytes() int64 (default 1 MiB, override
MAX_REQUEST_BODY_BYTES) and buildLimiter(client *redis.Client, spec utils.RateLimitSpec, prefix string) middleware.Limiter (Redis limiter when a
client is present, otherwise in-memory).
Handlers (handlers.go)
type APIHandler struct
type APIHandler struct {
Service *service.Service
ready atomic.Bool
redis readinessPinger // nil when Redis is not configured
hub *realtime.Hub // SSE pub/sub; also wired into the service as its Notifier
mailer *mail.Mailer // nil-safe: Enabled() is false when SMTP is not configured
mediaOnce sync.Once
mediaCfg mediaConfig
mediaErr error
}
func NewAPIHandler(svc *service.Service) *APIHandler- also creates the realtime hub and registers it on the service as itsNotifier.func (h *APIHandler) SetReady(ready bool)func (h *APIHandler) SetRedisChecker(p readinessPinger)- registers a Redis client so/readyalso pings it.func (h *APIHandler) SetMailer(m *mail.Mailer)- registers the SMTP mailer. Anilmailer is safe;Enabled()reports false and email sends become log lines.
Endpoint methods
All take (c *gin.Context):
Health-GET /health, static{"status":"ok"}.Ready-GET /ready, returnsstartinguntil ready, then pings DB and (if configured) Redis.RecentPosts-GET /recent, optionaltime_interval(days, default 7), paginated (default 20, max 100). ReturnsPostResponselist +PaginationMeta.PostsSlug-GET /posts/:slug, published post by slug with media. BehindOptionalAuthMiddleware: when a valid session accompanies the request the response also carriesliked_by_me.ListCategories-GET /categories, paginated (default 50, max 200).Register-POST /register, validated byValidateRegisterParams. The optionalroleform field selects the account kind (author, the default, orreader); anything else is a 400. Authors are created pending and the admins are notified (NotifyAdminsOfPendingUser). Readers get a verification email — or, when SMTP is not configured, are activated immediately so local development works without a mail server. The response carriesverification_requiredso the client knows which path was taken.Login-POST /login, setssession_idandcsrf_tokencookies, returnsLoginResponse.Logout-POST /logout, deletes the session and clears both cookies.CreatePost-POST /posts, author/admin; parsescategory_ids/media_ids.UpdatePost-PUT /posts/:slug; admins bypass ownership.DeletePost-DELETE /posts/:slug; admins bypass ownership (soft delete).UploadMedia-POST /media; multipartfile, MIME-sniffed and size-capped.ListUsers-GET /users, admin; paginated, returnsUserResponselist.ListPendingUsers-GET /users/pending, admin; pending accounts.DeleteUser-DELETE /users/:id, admin; self-delete blocked; last-admin guard returns 409.UpdateUserStatus-PUT /users/:id/status, admin; self-change blocked;deletedstatus routes to soft delete; last-admin guard returns 409.ArchivePost-PUT /posts/archive/:id, admin.AddCategory-POST /categories, admin.BootstrapAdmin-POST /admin/bootstrap; requiresX-Bootstrap-Token==BOOTSTRAP_TOKEN(constant-time), succeeds only while no admin exists.
Helpers
paginationParams(c *gin.Context, defaultLimit, maxLimit int) (int, int)- parses?limit/?offsetwith a default and a hard cap on limit; negative offsets clamp to 0.getUserIDFromContext,getSessionIDFromContext,getUserRoleFromContext- pull auth values set byRequireAuthMiddleware.parseUUIDListFromForm,parseUUIDStrings,uniqueUUIDs- parse/dedupe UUID form lists.getUsernameFromContext(c *gin.Context) (string, bool)- the actor's username, needed for notification payloads.uuidString(id pgtype.UUID) string,sessionCookieConfig() (string, bool, http.SameSite).publicAppURL() string- base URL for password-reset and verification links:APP_PUBLIC_URLif set, else the first non-wildcard entry ofCORS_ALLOWED_ORIGINS, else empty. Trailing slashes are trimmed.handleUniqueViolation/handleConstraintViolation- map pg error codes (23505,23503,23514) to 409/400 responses.loadMediaConfig() (mediaConfig, error),extensionForMime(string) (string, bool)- media validation (default 10 MB; default MIMEs jpeg/png/webp).isSafeStorageKey(key string) bool,contentTypeForKey(key string) string- guard and content-type resolution forGET /media/:key.sendVerificationEmail(c, userID, username, email)- issues a verification token and emails the link. Without SMTP it logs the link instead. Failures are logged, never surfaced — the account exists either way and the client can retry viaPOST /verify-email/resend.
Account flows (authflows.go)
Password reset and email verification. Every handler here is written to be enumeration-safe: the response is identical whether or not an account matches, so none of these endpoints can be used to probe which emails are registered.
RequestPasswordReset-POST /password-reset/request. Emails a one-hour reset link when an active account matches; always returns the same 200.ConfirmPasswordReset-POST /password-reset/confirm. Redeems the token, sets the new password, burns the account's tokens, and revokes every session (an attacker holding a stolen cookie is logged out).ResendVerificationEmail-POST /verify-email/resend. Reissues a link only for accounts that still need verification.VerifyEmail-POST /verify-email. Readers becomeactive; authors are marked verified but still await admin approval.
Profiles & account self-service (profile.go)
Me-GET /me, the caller's own account (MeResponse).UpdateMe-PUT /me, display name, bio, and avatar. Avatar media must be owned by the caller.ChangePassword-PUT /me/password. Requires the current password and rotates the session, so other sessions are invalidated.PublicProfile-GET /users/:user. BehindOptionalAuthMiddleware, so a logged-in viewer also getsfollowing.UserPosts-GET /users/:user/posts, that author's published posts.MyPosts-GET /me/posts, the caller's own posts including drafts; optional?status=filter.resolveProfileTarget(c) (pgtype.UUID, bool)- resolves the:userpath param, which accepts either a username or a UUID, and writes the 404 itself when nothing matches.
Note
The path parameter is named :user on every route under /users because Gin
requires one param name per position, and the admin routes share that prefix
while addressing users by UUID.
Social layer (social.go)
Comments, likes, follows, and search.
ListComments-GET /posts/:slug/comments, threaded viaparent_comment_id, paginated.CreateComment-POST /posts/:slug/comments. Notifies the post owner (comment) and, for a reply, the parent comment's author (reply).UpdateComment/DeleteComment-PUT|DELETE /comments/:id. Authors edit and delete their own; admins may delete any.HideComment-PUT /comments/:id/hide, admin-only moderation.LikePost/UnlikePost-PUT|DELETE /posts/:slug/like. Idempotent: the response reports whether the call actually changed anything, so a double-tap is not an error. Liking notifies the post owner.FollowUser/UnfollowUser-PUT|DELETE /users/:user/follow. Also idempotent; following yourself is rejected.Followers/Following-GET /users/:user/followers|following, paginated.SearchPosts-GET /search, full-text over published posts (?q=).- Helpers:
resolvePublishedPost(c) (sql.GetPostBySlugRow, bool)andparseCommentID(c) (pgtype.UUID, bool), both of which write their own error response and reportfalse.
Notifications & SSE (notifications.go)
ListNotifications-GET /notifications, paginated, with an unread count.MarkNotificationRead-PUT /notifications/:id/read.MarkAllNotificationsRead-PUT /notifications/read.Events-GET /events, the Server-Sent Events stream. It clears the connection's write deadline (the server'sWriteTimeoutwould otherwise kill a long-lived response), setstext/event-streamplusX-Accel-Buffering: noso nginx does not buffer it, subscribes to the hub, and emits aconnectedhandshake carryingretry: 3000. A 25-second: pingheartbeat keeps intermediaries from idling the connection out. The loop exits on request cancellation or channel close, and the deferredcancelremoves the subscription.
Event names on the stream: connected, notification, message.
Direct messages (messages.go)
ListConversations-GET /messages, one row per peer with the last message and unread count.MessagesWith-GET /messages/:user, the thread with one peer, paginated.SendMessage-POST /messages/:user. Validated byValidateMessageBody; pushes amessageevent to the recipient's live subscriptions.MarkMessagesRead-PUT /messages/:user/read, returns how many rows were marked.
Response DTOs (responses.go)
JSON shapes use uuid.UUID and *time.Time so output is a UUID string and an
RFC3339 timestamp (omitted when null):
type UserResponse struct {
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
UserRole string `json:"user_role"`
UserStatus string `json:"user_status"`
CreatedAt *time.Time `json:"created_at,omitempty"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
PostCount *int64 `json:"post_count,omitempty"`
}
type LoginResponse struct {
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
UserRole string `json:"user_role"`
}
type CategoryResponse struct {
CategoryID uuid.UUID `json:"category_id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type MediaResponse struct {
MediaID uuid.UUID `json:"media_id"`
StorageKey string `json:"storage_key"`
}
type PostResponse struct {
PostID uuid.UUID `json:"post_id"`
Title string `json:"title"`
Slug string `json:"slug"`
MdContent string `json:"md_content,omitempty"`
Status string `json:"status"`
PublishedAt *time.Time `json:"published_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
AuthorUserID *uuid.UUID `json:"author_user_id,omitempty"`
AuthorUsername string `json:"author_username,omitempty"`
AuthorDisplayName string `json:"author_display_name,omitempty"`
Categories []CategoryResponse `json:"categories"`
LikeCount int64 `json:"like_count"`
CommentCount int64 `json:"comment_count"`
LikedByMe *bool `json:"liked_by_me,omitempty"` // only for logged-in viewers
Media []MediaResponse `json:"media,omitempty"`
}
type PaginationMeta struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
Count int `json:"count"`
}
The social/profile layer adds these shapes:
| Type | Used by | Notes |
|---|---|---|
MeResponse |
GET /me |
Full self view: profile fields, email_verified, follower/following counts, and unread notification/message badges. |
PublicProfileResponse |
GET /users/:user |
Deliberately omits email, phone, and status. following is present only for logged-in viewers. |
AuthorPostResponse |
/users/:user/posts, /me/posts |
Compact post row without body content. |
SearchResultResponse |
GET /search |
Adds a highlighted excerpt. |
CommentResponse |
comment endpoints | Carries parent_comment_id for threading and the author's display name/avatar. |
NotificationResponse |
notification endpoints | read_at absent means unread. |
ConversationResponse |
GET /messages |
One row per peer: last message, direction, unread count. |
MessageResponse |
message endpoints | mine is resolved per viewer rather than exposing both user IDs. |
FollowUserResponse |
followers/following lists |
Converters:
func toUUID(p pgtype.UUID) uuid.UUID-uuid.Nilwhen invalid.func toTime(t pgtype.Timestamptz) *time.Time-nilwhen invalid.func toUUIDPtr(p pgtype.UUID) *uuid.UUID,func textOrEmpty(t pgtype.Text) string- null-safe accessors for optional columns.userFromListRow,userFromPendingRow,categoryFromRow,categoriesFromRows,postFromRecentRow,recentPostsResponse,postFromBySlug,authorPostFromRow,myPostFromRow,searchResultFromRow,meFromRow,publicProfileFromRow,commentFromListRow,commentFromModel,notificationFromRow,conversationFromRow,messageFromModel,followUserFromRow.parseMediaJSON/parseCategoriesJSON- unmarshal the JSON-aggregated media and category arrays the post queries return.
Validation (validator.go)
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
func ValidateUsername(username string) []ValidationError
func ValidatePassword(password string) []ValidationError
func ValidateRegisterParams(params sql.RegisterParams) []ValidationError
func ValidateCommentBody(body string) []ValidationError
func ValidateMessageBody(body string) []ValidationError
func ValidateProfileFields(displayName, bio string) []ValidationError
func ValidateCreatePostParams(params sql.CreatePostParams) []ValidationError
func ValidateAddCategoryParams(params sql.AddCategoryParams) []ValidationError
| Validator | Rules |
|---|---|
ValidateUsername |
3-50 chars; letters, digits, ., _, - only; must start and end with a letter or digit. |
ValidatePassword |
8-72 chars. The upper bound is bcrypt's 72-byte limit — without it, anything past 72 bytes is silently ignored at hashing time. |
ValidateRegisterParams |
Username + password rules above, valid email, optional phone (10 digits when present). |
ValidateCommentBody |
Non-blank, <= 5000 chars. |
ValidateMessageBody |
Non-blank, <= 2000 chars. |
ValidateProfileFields |
Display name <= 100 chars, bio <= 1000 chars. |
ValidateCreatePostParams |
Non-empty title (<= 255), non-empty md_content, status in {draft, published, archived}. |
ValidateAddCategoryParams |
Non-empty name (<= 100). |
Unexported: isValidPhoneNumber(phone string) bool, usernameRe.
Service Package (internal/service)
Location: internal/service/
type Service struct
// Notifier is the realtime push hook (implemented by realtime.Hub). Delivery
// is best-effort: every event worth keeping is also persisted as a
// notifications row before Publish is called.
type Notifier interface {
Publish(userID pgtype.UUID, event string, payload any)
}
type Service struct {
db *pgxpool.Pool
queries *sql.Queries
notifier Notifier // nil until SetNotifier; publish() is nil-safe
}
func NewService(db *pgxpool.Pool) *Service
func (s *Service) SetNotifier(n Notifier) // call once at startup, before serving
func (s *Service) Ping(ctx context.Context) error
The unexported publish(userID, event, payload) wrapper is a no-op when no
notifier is wired, which is what keeps the service usable in tests without a
hub.
Errors
ErrInvalidCredentials, ErrUserNotActive, ErrAdminAlreadyExists
(auth.go); ErrInvalidSlug, ErrUnauthorizedOperation,
ErrInvalidCategoryIDs, ErrInvalidMediaIDs, ErrPostNotFound (posts.go);
ErrLastAdmin (users.go); ErrInvalidToken (tokens.go); ErrUserNotFound
(profile.go); ErrSelfAction (social.go); ErrCommentNotFound,
ErrInvalidParentComment (comments.go).
Auth & sessions (auth.go)
func (s *Service) Register(ctx, params sql.RegisterParams) (pgtype.UUID, error)- bcrypt-hashes the password then inserts.func (s *Service) BootstrapAdmin(ctx, params sql.RegisterParams) (pgtype.UUID, error)- in one transaction, fails withErrAdminAlreadyExistsifCountAdmins > 0, otherwise inserts an admin and immediately activates it.func (s *Service) Login(ctx, email, password string) (sql.LoginRow, pgtype.UUID, pgtype.Timestamptz, error)- bcrypt verify (dummy-hash compare on unknown email to flatten timing), reject non-active users, then in a transaction delete other sessions, add a new 7-day session, andUpdateLastLogin.func (s *Service) GetSessionAndUser(ctx, sessionID pgtype.UUID) (sql.GetSessionAndUserRow, error)func (s *Service) DeleteSession(ctx, sessionID pgtype.UUID) errorfunc (s *Service) DeleteExpiredSessions(ctx) error
Constants: sessionTTL = 7 * 24h; dummyBcryptHash (pre-computed bcrypt hash
used on the user-not-found path).
Posts (posts.go)
func (s *Service) GetRecentPosts(ctx, interval uint16, limit, offset int) ([]sql.Post, error)-intervalis days (default 7).func (s *Service) GetPostBySlug(ctx, slug string) (sql.GetPostBySlugRow, error)- validates the slug (ErrInvalidSlug), published-only.func (s *Service) GetPostBySlugAny(ctx, slug string) (sql.GetPostBySlugAnyRow, error)- any non-deleted status.func (s *Service) CreatePost(ctx, params sql.CreatePostParams, authorID pgtype.UUID, categoryIDs, mediaIDs []pgtype.UUID) (pgtype.UUID, error)- transactional: insert post, link owner, validate+link categories/media.func (s *Service) UpdatePost(ctx, params sql.UpdatePostParams, ownerID pgtype.UUID, categoryIDs, mediaIDs []pgtype.UUID, requireOwnership bool) error- optional ownership check;nilcategory/media slices leave links untouched, empty slices clear them.func (s *Service) ArchivePost(ctx, postID pgtype.UUID) errorfunc (s *Service) DeletePost(ctx, postID, userID pgtype.UUID, requireOwnership bool) error- soft delete.
Users (users.go)
func (s *Service) ListUsers(ctx, requesterID pgtype.UUID, limit, offset int) ([]sql.ListUsersRow, error)func (s *Service) ListPendingUsers(ctx, limit, offset int) ([]sql.ListPendingUsersRow, error)func (s *Service) DeleteUser(ctx, userID pgtype.UUID) error- transactional soft delete; last-admin guard; clears the user's sessions.func (s *Service) UpdateUserStatus(ctx, params sql.UpdateUserStatusParams) error-deletedstatus delegates toDeleteUser; deactivation triggers the last-admin guard and session purge.func (s *Service) guardLastAdmin(ctx, q *sql.Queries, userID pgtype.UUID) error- returnsErrLastAdminwhen removing the last active admin.
Categories (categories.go) & Media (media.go)
func (s *Service) ListCategories(ctx, limit, offset int) ([]sql.Category, error)func (s *Service) AddCategory(ctx, params sql.AddCategoryParams) errorfunc (s *Service) UploadMedia(ctx, params sql.UploadMediaParams) (pgtype.UUID, error)
Auth tokens (tokens.go)
Password-reset and email-verification tokens. Both kinds are stored hashed:
newAuthToken() returns 32 random bytes hex-encoded (what the user receives)
plus its SHA-256 digest (what the row stores), and lookups hash the incoming
token before querying. A database leak therefore yields no usable links.
func (s *Service) RequestPasswordReset(ctx, email string) (token, username string, ok bool, err error)-okis false when no active account matches. Callers must respond identically either way. Issuing a new link invalidates the previous one (one outstanding reset per account).func (s *Service) ConfirmPasswordReset(ctx, rawToken, newPassword string) error- transactional: set the password, burn the account's reset tokens, and delete every session.ErrInvalidTokencovers both unknown and expired.func (s *Service) ResolveUnverifiedUser(ctx, email string) (userID pgtype.UUID, username string, ok bool, err error)-okis false for unknown, already-verified, suspended, and deleted accounts alike, which is what keeps the resend endpoint enumeration-safe.func (s *Service) CreateEmailVerification(ctx, userID pgtype.UUID) (string, error)- replaces any outstanding token.func (s *Service) VerifyEmail(ctx, rawToken string) (sql.MarkEmailVerifiedRow, error)- readers becomeactive; authors are marked verified but stay pending for admin approval (the SQL encodes that rule).func (s *Service) DeleteExpiredAuthTokens(ctx) error- both kinds; called from the background cleanup sweep.
TTLs: passwordResetTTL = 1h, emailVerificationTTL = 24h.
Profiles (profile.go)
func (s *Service) GetMe(ctx, userID pgtype.UUID) (sql.GetMeRow, error)- profile plus counters (followers, following, unread notifications/messages).func (s *Service) GetUserByID(ctx, userID pgtype.UUID) (sql.User, error)func (s *Service) GetPublicProfile(ctx, username string) (sql.GetPublicUserByUsernameRow, error)-ErrUserNotFoundwhen absent.func (s *Service) ListPostsByAuthor(ctx, username string, limit, offset int) ([]sql.ListPostsByAuthorRow, error)- published only.func (s *Service) ListMyPosts(ctx, userID pgtype.UUID, statusFilter string, limit, offset int) ([]sql.ListMyPostsRow, error)- the owner's own posts, drafts included; emptystatusFiltermeans all.type UpdateProfileInput structandfunc (s *Service) UpdateProfile(ctx, userID pgtype.UUID, in UpdateProfileInput) error.func (s *Service) ChangePassword(ctx, userID pgtype.UUID, currentPassword, newPassword string) (pgtype.UUID, pgtype.Timestamptz, error)- verifies the current password, then in one transaction stores the new hash, kills outstanding reset tokens, revokes every session on all devices, and issues a fresh session for the caller (returned as the new session ID and expiry).ErrInvalidCredentialswhen the current password is wrong.
Social (social.go)
Likes and follows. Both are idempotent — the bool return reports whether the
call actually changed anything, so a double-tap is a no-op rather than an error
(and never re-notifies).
func (s *Service) LikePost(ctx, postID, likerID pgtype.UUID, likerUsername, postSlug, postTitle string) (bool, error)- notifies the post owner on a new like.func (s *Service) UnlikePost(ctx, postID, userID pgtype.UUID) (bool, error)func (s *Service) PostLikeInfo(ctx, postID, viewerID pgtype.UUID) (int64, bool, error)- like count, plus whether the viewer liked it (only whenviewerIDis valid).func (s *Service) Follow(ctx, followerID pgtype.UUID, followerUsername string, followeeID pgtype.UUID) (bool, error)-ErrSelfActionwhen the two IDs match; notifies the followee on a new follow.func (s *Service) Unfollow(ctx, followerID, followeeID pgtype.UUID) (bool, error)func (s *Service) IsFollowing(ctx, followerID, followeeID pgtype.UUID) (bool, error)func (s *Service) ListFollowers(ctx, userID pgtype.UUID, limit, offset int) ([]sql.ListFollowersRow, error)func (s *Service) ListFollowing(ctx, userID pgtype.UUID, limit, offset int) ([]sql.ListFollowingRow, error)
Comments (comments.go)
type CreateCommentInput struct { PostID, AuthorID, ParentID pgtype.UUID; PostSlug, PostTitle, AuthorUsername, Body string }- the handler resolves the post (published-only) before calling, so slug and title are already at hand for the notification payload. A zeroParentIDmeans a top-level comment.func (s *Service) CreateComment(ctx, in CreateCommentInput) (sql.Comment, error)- inserts and notifies the post owner (typecomment) and, for a reply, the parent comment's author (typereply).ErrInvalidParentCommentwhen the parent belongs to a different post.func (s *Service) ListPostComments(ctx, postID pgtype.UUID, limit, offset int) ([]sql.ListPostCommentsRow, error)- visible comments only.func (s *Service) UpdateComment(ctx, commentID, editorID pgtype.UUID, body string) error- author-only;ErrUnauthorizedOperationotherwise.func (s *Service) DeleteComment(ctx, commentID, requesterID pgtype.UUID, isAdmin bool) error- soft delete. Admins may delete any comment; everyone else only their own.func (s *Service) HideComment(ctx, commentID pgtype.UUID) error- the admin moderation action: the comment leaves public lists but is retained and recoverable, unlike a delete.
Status transitions use the comment_status_enum (visible → hidden or
deleted); a missing or already-deleted comment returns ErrCommentNotFound
rather than leaking that it once existed.
Notifications (notifications.go)
Every notification is persisted first, pushed second — publish is
best-effort, so a client that missed the live event still finds the row via
GET /notifications.
func (s *Service) ListNotifications(ctx, userID pgtype.UUID, limit, offset int) ([]sql.ListNotificationsRow, error)func (s *Service) CountUnreadNotifications(ctx, userID pgtype.UUID) (int64, error)func (s *Service) MarkNotificationRead(ctx, notificationID, userID pgtype.UUID) (bool, error)- scoped byuserID, so one user cannot mark another's notification read.func (s *Service) MarkAllNotificationsRead(ctx, userID pgtype.UUID) error
Emitters (all fire-and-forget; failures are logged, never returned):
NotifyLike(ctx, actorID, actorUsername, postID, postSlug, postTitle)NotifyFollow(ctx, followeeID, actorID, actorUsername)NotifyFollowersOfNewPost(ctx, postID)- fans out to every follower when a post first becomes published. The owner is resolved from the post rather than taken from the caller, so an admin publishing someone else's draft still notifies the author's followers, attributed to the author.NotifyAdminsOfPendingUser(ctx, newUserID, newUsername)NotifyAccountActivated(ctx, userID, adminID)
Unexported: createAndPushNotification(...) (insert + publish) and
notificationEvent, the SSE payload — it mirrors the list endpoint's fields so
the frontend renders pushed and fetched notifications with the same code.
Messages (messages.go)
func (s *Service) SendMessage(ctx, senderID pgtype.UUID, senderUsername string, recipientID pgtype.UUID, body string) (sql.Message, error)- stores the message and pushes amessageevent to the recipient's live subscriptions. Messaging yourself is rejected (the schema enforces it too).func (s *Service) ListConversations(ctx, userID pgtype.UUID, limit, offset int) ([]sql.ListConversationsRow, error)- conversations are derived from the message rows; there is no conversation table.func (s *Service) ListMessagesWithUser(ctx, userID, peerID pgtype.UUID, limit, offset int) ([]sql.Message, error)func (s *Service) MarkMessagesRead(ctx, userID, peerID pgtype.UUID) (int64, error)- returns how many rows were marked.func (s *Service) CountUnreadMessages(ctx, userID pgtype.UUID) (int64, error)
Middleware Package (internal/middleware)
Location: internal/middleware/
CORS
type CORSConfig struct { AllowedOrigins, AllowedMethods, AllowedHeaders []string; AllowCredentials bool }func LoadCORSConfigFromEnv() (CORSConfig, error)- requiresCORS_ALLOWED_ORIGINS; rejects wildcard origins when credentials are allowed.func CORSMiddleware(config CORSConfig) gin.HandlerFunc- allowlist check; rejects unknown-origin preflights with 403.
Request lifecycle & security
func RequestIDMiddleware() gin.HandlerFunc-X-Request-Idecho + context injection.func AccessLogMiddleware() gin.HandlerFuncfunc MaxBodyBytesMiddleware(maxBytes int64, exemptFullPaths ...string) gin.HandlerFunc- caps body size for POST/PUT/PATCH/DELETE; exempts the given Gin route patterns (e.g./media).func SecurityHeadersMiddleware() gin.HandlerFuncfunc CSRFMiddleware() gin.HandlerFunc- double-submit; skips safe methods and Bearer auth; constant-time token compare.
Auth / role guards
func RequireAuthMiddleware(svc *service.Service) gin.HandlerFunc- validates session, requiresactive, setsuser_id/user_role/user_status/session_id/usernamein context. Any active account passes, including readers.func OptionalAuthMiddleware(svc *service.Service) gin.HandlerFunc- the same context keys, but never rejects: a missing, malformed, expired, or non-active session simply falls through unauthenticated. It backs the personalized public endpoints (GET /posts/:slug→liked_by_me,GET /users/:user→following).func RequireAuthorMiddleware() gin.HandlerFunc- allowsauthororadmin.func RequireAdminMiddleware() gin.HandlerFunc- requiresadmin.
Rate limiting
type Limiter interface {
Allow(ctx context.Context, key string) (int, time.Duration, bool, error)
Limit() int
}
type RateLimiter struct{...}- in-memory fixed-window (mutex + map).func NewRateLimiter(spec utils.RateLimitSpec) *RateLimiterfunc (l *RateLimiter) Allow(...),func (l *RateLimiter) Limit() int
type RedisRateLimiter struct{...}- Redis Lua INCR+PEXPIRE fixed-window.func NewRedisRateLimiter(client *redis.Client, spec utils.RateLimitSpec, prefix string) *RedisRateLimiterfunc (r *RedisRateLimiter) Allow(...),func (r *RedisRateLimiter) Limit() int
func RateLimitMiddleware(limiter Limiter) gin.HandlerFunc- setsX-RateLimit-*headers, returns 429 +Retry-Afterwhen exceeded, 503 on limiter error.- Spec loaders (all return
(utils.RateLimitSpec, error)):LoadRateLimitSpecFromEnv(envName, defaultSpec string),LoadLoginRateLimitSpec,LoadRegisterRateLimitSpec,LoadMediaRateLimitSpec,LoadAdminRateLimitSpec,LoadWriteRateLimitSpec. Defaults: login10/1m, register10/1m, media30/1m, admin60/1m, write60/1m.
Realtime Package (internal/realtime)
Location: internal/realtime/hub.go
A minimal in-process pub/sub hub backing the SSE endpoint. Events are addressed to a single user, and one user may hold several subscriptions at once (multiple tabs).
Note
This is deliberately not Redis-backed. The app runs as a single instance
and every Publish call site is best-effort — a missed live event still
exists as a notification row, so the client re-syncs from the REST endpoints
on reconnect. Running multiple instances would mean users only receive events
raised by the instance they happen to be connected to.
// Event is one server-sent event: Name maps to the SSE "event:" field and
// Data is the pre-marshalled JSON payload for the "data:" field.
type Event struct {
Name string
Data []byte
}
type Hub struct{ ... }
func NewHub() *Hub
func (h *Hub) Subscribe(userID pgtype.UUID) (<-chan Event, func())
func (h *Hub) Publish(userID pgtype.UUID, event string, payload any)
Subscribereturns a receive channel and a cancel function. The cancel function must be called when the connection ends — it removes the subscription and closes the channel. The handler defers it.- The channel is buffered (16).
Publishnever blocks: a subscriber whose buffer is full simply misses the event. Publishis a no-op for an invaliduserID, and marshals the payload once for all of that user's subscriptions.
Hub satisfies service.Notifier, which is how the service layer pushes
without importing the realtime package.
Mail Package (internal/mail)
Location: internal/mail/mail.go
Transactional outbound email (password reset, email verification) over SMTP.
type Mailer struct{ ... }
func LoadFromEnv() (*Mailer, error)
func (m *Mailer) Enabled() bool
func (m *Mailer) Send(to, subject, body string) error
LoadFromEnvreadsSMTP_HOST,SMTP_PORT,SMTP_USERNAME,SMTP_PASSWORD, andSMTP_FROM. An emptySMTP_HOSTreturns(nil, nil)— a disabled mailer, not an error. WhenSMTP_HOSTis set,SMTP_FROMis required and the port must be valid.- Every method is nil-safe:
Enabled()reports false on anilreceiver, andSendlogs the recipient and subject (never the body, which may carry a token) and returns nil. - Port 465 uses implicit TLS (
sendImplicitTLS); everything else attempts STARTTLS (sendStartTLS).
Important
Leaving SMTP unconfigured changes application behavior, not just delivery:
reset and verification links are written to the server log, and readers
registering via POST /register are activated immediately instead of being
asked to verify. That is intended for development — see
DEVELOPMENT.md. Always
configure SMTP in production.
SQL Package (internal/sql)
Location: internal/sql/ (generated by sqlc from schema.sql + query.sql;
edit the SQL, not the .go files).
Constructor & transactions
func New(db DBTX) *Queriesfunc (q *Queries) WithTx(tx pgx.Tx) *Queries
Models (models.go)
type User struct-UserID pgtype.UUID,Username,Email string,UserRole UserRoleEnum,UserStatus UserStatusEnum,PasswordHash string,PhoneNumber pgtype.Text,DisplayName,Bio pgtype.Text,AvatarMediaID pgtype.UUID,EmailVerifiedAt,CreatedAt,LastLoginAt,DeletedAt pgtype.Timestamptz.type Post struct-PostID pgtype.UUID,Title,Slug,MdContent string,Status PostStatusEnum,PublishedAt,UpdatedAt,ArchivedAt,DeletedAt pgtype.Timestamptz.type Medium struct-MediaID,UserID pgtype.UUID,StorageKey string,MimeType pgtype.Text,CreatedAt pgtype.Timestamptz.type Category struct-CategoryID pgtype.UUID,Name,Slug string.type Session struct-SessionID,UserID pgtype.UUID,CreatedAt,ExpiresAt pgtype.Timestamptz.type Comment struct-CommentID,PostID,UserID,ParentCommentID pgtype.UUID(null = top-level),Body string,Status CommentStatusEnum,IsAi bool,CreatedAt,EditedAt pgtype.Timestamptz.type Like struct-UserID,PostID pgtype.UUID,CreatedAt pgtype.Timestamptz(composite key, which is what makes liking idempotent).type Follow struct-FollowerID,FolloweeID pgtype.UUID,CreatedAt pgtype.Timestamptz.type Notification struct-NotificationID,UserID(recipient),ActorID,PostID,CommentID pgtype.UUID,Type NotificationTypeEnum,ReadAt,CreatedAt pgtype.Timestamptz.type Message struct-MessageID,SenderID,RecipientID pgtype.UUID,Body string,CreatedAt,ReadAt pgtype.Timestamptz. Conversations are derived from these rows; there is no conversation table.type PasswordResetToken struct/type EmailVerificationToken struct-TokenHash string(the primary key — the raw token is never stored),UserID pgtype.UUID,ExpiresAt,CreatedAt pgtype.Timestamptz.- Join models:
type UsersPost struct,type PostsMedium struct,type PostsCategory struct.
Query functions (selected, exact signatures)
func (q *Queries) GetUser(ctx, userID pgtype.UUID) (User, error)
func (q *Queries) GetRecentPosts(ctx, arg GetRecentPostsParams) ([]Post, error)
func (q *Queries) GetPostBySlug(ctx, slug string) (GetPostBySlugRow, error)
func (q *Queries) GetPostBySlugAny(ctx, slug string) (GetPostBySlugAnyRow, error)
func (q *Queries) ListCategories(ctx, arg ListCategoriesParams) ([]Category, error)
func (q *Queries) Register(ctx, arg RegisterParams) (pgtype.UUID, error)
func (q *Queries) Login(ctx, email string) (LoginRow, error)
func (q *Queries) CreatePost(ctx, arg CreatePostParams) (pgtype.UUID, error)
func (q *Queries) UploadMedia(ctx, arg UploadMediaParams) (pgtype.UUID, error)
func (q *Queries) LinkPostUser(ctx, arg LinkPostUserParams) error
func (q *Queries) LinkPostCategories(ctx, arg LinkPostCategoriesParams) error
func (q *Queries) LinkPostMediaBulk(ctx, arg LinkPostMediaBulkParams) error
func (q *Queries) ClearPostCategories(ctx, postID pgtype.UUID) error
func (q *Queries) ClearPostMedia(ctx, postID pgtype.UUID) error
func (q *Queries) CountCategoriesByIDs(ctx, categoryIds []pgtype.UUID) (int64, error)
func (q *Queries) CountMediaByUser(ctx, arg CountMediaByUserParams) (int64, error)
func (q *Queries) UpdatePost(ctx, arg UpdatePostParams) (int64, error) // execrows
func (q *Queries) VerifyPostOwnership(ctx, arg VerifyPostOwnershipParams) (bool, error)
func (q *Queries) ArchivePost(ctx, postID pgtype.UUID) (int64, error) // execrows
func (q *Queries) DeletePost(ctx, postID pgtype.UUID) error // soft delete
func (q *Queries) ListUsers(ctx, arg ListUsersParams) ([]ListUsersRow, error)
func (q *Queries) ListPendingUsers(ctx, arg ListPendingUsersParams) ([]ListPendingUsersRow, error)
func (q *Queries) CountAdmins(ctx) (int64, error)
func (q *Queries) DeleteUser(ctx, userID pgtype.UUID) error // soft delete
func (q *Queries) UpdateUserStatus(ctx, arg UpdateUserStatusParams) error
func (q *Queries) AddCategory(ctx, arg AddCategoryParams) error
func (q *Queries) AddSession(ctx, arg AddSessionParams) error
func (q *Queries) UpdateLastLogin(ctx, userID pgtype.UUID) error
func (q *Queries) DeleteSession(ctx, sessionID pgtype.UUID) error
func (q *Queries) GetSessionAndUser(ctx, sessionID pgtype.UUID) (GetSessionAndUserRow, error)
func (q *Queries) DeleteAllUserSessions(ctx, userID pgtype.UUID) error
func (q *Queries) DeleteExpiredSessions(ctx) error
The profile / social / messaging layer adds (81 query functions in total):
// Profiles & account
func (q *Queries) GetMe(ctx, userID pgtype.UUID) (GetMeRow, error)
func (q *Queries) GetPublicUserByUsername(ctx, username string) (GetPublicUserByUsernameRow, error)
func (q *Queries) UpdateUserProfile(ctx, arg UpdateUserProfileParams) (int64, error) // execrows
func (q *Queries) UpdateUserPassword(ctx, arg UpdateUserPasswordParams) error
func (q *Queries) ListPostsByAuthor(ctx, arg ListPostsByAuthorParams) ([]ListPostsByAuthorRow, error)
func (q *Queries) ListMyPosts(ctx, arg ListMyPostsParams) ([]ListMyPostsRow, error)
func (q *Queries) SearchPosts(ctx, arg SearchPostsParams) ([]SearchPostsRow, error)
// Auth tokens (keyed by SHA-256 hash, never the raw token)
func (q *Queries) CreatePasswordResetToken(ctx, arg CreatePasswordResetTokenParams) error
func (q *Queries) GetPasswordResetToken(ctx, tokenHash string) (GetPasswordResetTokenRow, error)
func (q *Queries) DeletePasswordResetTokensForUser(ctx, userID pgtype.UUID) error
func (q *Queries) CreateEmailVerificationToken(ctx, arg CreateEmailVerificationTokenParams) error
func (q *Queries) GetEmailVerificationToken(ctx, tokenHash string) (GetEmailVerificationTokenRow, error)
func (q *Queries) DeleteEmailVerificationTokensForUser(ctx, userID pgtype.UUID) error
func (q *Queries) MarkEmailVerified(ctx, userID pgtype.UUID) (MarkEmailVerifiedRow, error)
func (q *Queries) DeleteExpiredPasswordResetTokens(ctx) error
func (q *Queries) DeleteExpiredEmailVerificationTokens(ctx) error
// Comments
func (q *Queries) CreateComment(ctx, arg CreateCommentParams) (Comment, error)
func (q *Queries) GetComment(ctx, commentID pgtype.UUID) (GetCommentRow, error)
func (q *Queries) ListPostComments(ctx, arg ListPostCommentsParams) ([]ListPostCommentsRow, error)
func (q *Queries) UpdateCommentBody(ctx, arg UpdateCommentBodyParams) (int64, error) // execrows
func (q *Queries) SetCommentStatus(ctx, arg SetCommentStatusParams) (int64, error) // execrows
// Likes & follows — all execrows, which is what makes them idempotent
func (q *Queries) LikePost(ctx, arg LikePostParams) (int64, error)
func (q *Queries) UnlikePost(ctx, arg UnlikePostParams) (int64, error)
func (q *Queries) CountPostLikes(ctx, postID pgtype.UUID) (int64, error)
func (q *Queries) HasUserLikedPost(ctx, arg HasUserLikedPostParams) (bool, error)
func (q *Queries) FollowUser(ctx, arg FollowUserParams) (int64, error)
func (q *Queries) UnfollowUser(ctx, arg UnfollowUserParams) (int64, error)
func (q *Queries) IsFollowing(ctx, arg IsFollowingParams) (bool, error)
func (q *Queries) ListFollowers(ctx, arg ListFollowersParams) ([]ListFollowersRow, error)
func (q *Queries) ListFollowing(ctx, arg ListFollowingParams) ([]ListFollowingRow, error)
// Notifications — the Notify* queries insert and return recipients in one round trip
func (q *Queries) CreateNotification(ctx, arg CreateNotificationParams) (Notification, error)
func (q *Queries) ListNotifications(ctx, arg ListNotificationsParams) ([]ListNotificationsRow, error)
func (q *Queries) CountUnreadNotifications(ctx, userID pgtype.UUID) (int64, error)
func (q *Queries) MarkNotificationRead(ctx, arg MarkNotificationReadParams) (int64, error)
func (q *Queries) MarkAllNotificationsRead(ctx, userID pgtype.UUID) error
func (q *Queries) NotifyFollowersOfPost(ctx, arg NotifyFollowersOfPostParams) ([]NotifyFollowersOfPostRow, error)
func (q *Queries) NotifyAdminsOfPendingUser(ctx, actorID pgtype.UUID) ([]NotifyAdminsOfPendingUserRow, error)
// Messages
func (q *Queries) CreateMessage(ctx, arg CreateMessageParams) (Message, error)
func (q *Queries) ListConversations(ctx, arg ListConversationsParams) ([]ListConversationsRow, error)
func (q *Queries) ListMessagesWithUser(ctx, arg ListMessagesWithUserParams) ([]Message, error)
func (q *Queries) MarkMessagesRead(ctx, arg MarkMessagesReadParams) (int64, error) // execrows
func (q *Queries) CountUnreadMessages(ctx, recipientID pgtype.UUID) (int64, error)
Param/row structs include RegisterParams, LoginRow, CreatePostParams,
UpdatePostParams, UploadMediaParams, AddCategoryParams, AddSessionParams,
UpdateUserStatusParams, VerifyPostOwnershipParams, CountMediaByUserParams,
GetRecentPostsParams, ListCategoriesParams, ListUsersParams/ListUsersRow,
ListPendingUsersParams/ListPendingUsersRow, GetPostBySlugRow,
GetPostBySlugAnyRow, GetSessionAndUserRow, LinkPostUserParams,
LinkPostCategoriesParams, LinkPostMediaBulkParams.
Utils Package (internal/utils)
Location: internal/utils/
Request ID & logging (request_id.go)
func WithRequestID(ctx context.Context, requestID string) context.Contextfunc RequestIDFromContext(ctx context.Context) stringtype RequestIDHandler structandfunc NewRequestIDHandler(handler slog.Handler) slog.Handler- aslog.Handlerwrapper that adds arequest_idattribute to records that carry one in their context.
Config parsing (config.go)
type RateLimitSpec struct { Limit int; Window time.Duration }func ParseRateLimitSpec(spec string) (RateLimitSpec, error)- parsescount/duration(e.g.10/1m).func ParseCSV(value string) []string- trims and drops empty entries.
Security (security.go)
const CSRFTokenCookieName = "csrf_token"const CSRFTokenHeaderName = "X-CSRF-Token"func GenerateCSRFToken() (string, error)- 32 random bytes, base64url (raw) encoded.
Slugs (utils.go)
func GenerateSlug(title string) stringfunc IsValidSlug(s string) bool- true whensis non-empty and already in canonical slug form.
Migrate Package (internal/migrate)
Location: internal/migrate/
func Run(ctx context.Context, db *pgxpool.Pool) error- acquires one connection, takes apg_advisory_lock, ensuresschema_migrationsexists, and applies unapplied embedded migrations in filename order. Files containing-- +no-transactionrun outside a transaction; all others run inside one.
Embedded files, in apply order:
| File | What it adds |
|---|---|
000_initial_schema.sql |
Users, posts, categories, media, sessions, join tables. |
001_add_deleted_status.sql |
deleted on the status enums. |
002_fix_post_automation_trigger.sql |
Post automation trigger fix. |
003_streamline_post_trigger.sql |
Simplified post trigger. |
004_add_reader_role.sql |
reader on user_role_enum — self-service accounts that can comment, like, follow, and message but cannot author posts. Carries -- +no-transaction, since ALTER TYPE … ADD VALUE cannot be used inside the transaction that adds it. |
005_profiles_and_auth_tokens.sql |
Profile columns on users (display_name, bio, avatar_media_id, email_verified_at) plus the password_reset_tokens and email_verification_tokens tables. |
006_social.sql |
comments, likes, follows, notifications, and the comment_status_enum / notification_type_enum types. |
007_messages.sql |
messages. Conversations are derived from these rows. |
008_post_search.sql |
Full-text search over posts, using the turkish text-search configuration. |
Types & Enums
Defined in internal/sql/models.go (sqlc) and mirrored in the DB schema:
type UserRoleEnum string // "admin", "author", "reader"
type UserStatusEnum string // "pending", "active", "suspended", "deleted"
type PostStatusEnum string // "draft", "published", "archived", "deleted"
type AuthorRoleEnum string // "owner", "contributor"
type CommentStatusEnum string // "visible", "hidden", "deleted"
type NotificationTypeEnum string // "comment", "reply", "like", "follow",
// "new_post", "new_pending_user",
// "account_activated"
Constants follow the EnumValue convention, e.g. sql.UserRoleEnumAdmin,
sql.UserRoleEnumAuthor, sql.UserRoleEnumReader,
sql.UserStatusEnumPending/Active/Suspended/Deleted,
sql.PostStatusEnumDraft/Published/Archived/Deleted,
sql.AuthorRoleEnumOwner/Contributor,
sql.CommentStatusEnumVisible/Hidden/Deleted,
sql.NotificationTypeEnumComment/Reply/Like/Follow/NewPost/
NewPendingUser/AccountActivated.
Each enum also has a NullXxx wrapper (sql.NullUserRoleEnum and friends) for
nullable columns.
Role capabilities
| Reader | Author | Admin | |
|---|---|---|---|
| Comment, like, follow, message | ✅ | ✅ | ✅ |
| Upload media (avatars) | ✅ | ✅ | ✅ |
| Create / update / delete own posts | ❌ | ✅ | ✅ |
| Moderate comments, manage users & categories | ❌ | ❌ | ✅ |
| Activation path | email verification | admin approval | bootstrap / admin |
Last Updated: 2026-08-17