Literary Lions Forum
A book-discussion forum in Go. Server-rendered on SQLite, with no JavaScript.
Overview
We built a book-discussion forum with accounts, posts, comments, categories and likes. The two-person project connects a Go server and SQLite data to server-rendered pages and forms, with shared responsibilities across the application.
- context
- Coursework
- status
- Archived learning project
- team
- 2 people
- role
- Shared responsibilities
- started
- Sept 2024
- setting
- Server-rendered Go and SQLite coursework application, packaged with Docker.
my contribution
My contributions focused on backend structure, database integration, authentication and sessions, and forum functionality, with additional frontend integration work. My teammate contributed templates, styling and application code.
- Go
- SQLite
- bcrypt
- Docker
Key decisions
- Keep the request path server-rendered
- Go's net/http handlers render pages directly, with SQLite storing discussion and account data. The application does not depend on client-side JavaScript.
- Store password hashes and server-side sessions
- Passwords use bcrypt; a session cookie resolves to an expiring database row so the server can end a session rather than relying only on a client-held token.
Demonstrated results
- Discussion features connect the schema to the pages
- The implementation supports posts, comments, categories, sorting and a liked-posts view, alongside book and profile pages.
- Password changes and logout have server-side handling
- The handlers check the previous password before re-hashing and remove the session on logout. Parameterised query examples keep values separate from SQL text.
Known limitations
- HTML output lacks automatic escaping
- The archived handlers use text/template for HTML, leaving stored user content capable of rendering as script. This later review finding remains unfixed.
- Business logic is coupled to database access
- Handlers issue queries directly, making database-dependent behaviour harder to isolate for testing than in the later layered e-commerce project.
Forum features and request flow
The application handles requests with net/http and renders templates on the
server. SQLite stores the discussion and account data. The feature work connects
book listings, posts and comments with category filters, sorting, likes and profile
views. Forms submit to the backend rather than depending on a browser-side application.
Responsibilities were shared within the two-person team. The contribution section above identifies my backend and integration work without attributing the entire interface or application to one person.
The template package was wrong
During the later review I found a template-package choice that undermines the otherwise parameterised data path.
internal/handlers/helpers.go line 7:
import "text/template"
Go has two template packages with near-identical APIs:
| Package | Escaping | Intended for |
|---|---|---|
html/template |
Automatic, context-aware | HTML output |
text/template |
None | Plain text: config files, code generation |
They are interchangeable in use, which is exactly the problem. text/template
happily parses and executes an HTML file. It just inserts every value verbatim.
Swapping the import is a one-line change that removes an entire vulnerability
class, and there is no compiler error, no test failure, and nothing in the output
that looks wrong.
The chain, start to finish
Comment bodies go from the request straight to the database and back out into the page, with no escaping anywhere in the path.
In. The handler reads the raw form value and checks only that it is non-empty:
content := r.FormValue("content")
if content == "" {
http.Error(w, "Comment content cannot be empty", http.StatusBadRequest)
return
}
Stored. Directly into SQLite, parameterised. There is no SQL injection, which makes the escaping gap easier to miss, because the query looks like it was handled properly:
query := `INSERT INTO Comments (post_id, user_id, content) VALUES (?, ?, ?)`
_, err = database.GetDB().Exec(query, postID, userID, content)
Out. And rendered by the non-escaping template:
<p>{{.Content}}</p>
That is at HTML/comments.html:67, and again at :112. Post titles and usernames
render the same way at HTML/index.html:60, posts.html:65 and books.html:73.
So a comment containing <script>...</script> is stored verbatim and re-emitted
verbatim into the HTML of every subsequent page view. Any logged-in user who posts
it has run script in every other reader's browser, as the forum's own origin. That
is stored XSS, and with cookies on this app the interesting part does not stop at
the script tag.
The fix is to change one import to html/template. It escapes text
interpolations in HTML context by default, and handles the cases that trip people
up. < becomes <, attribute and URL contexts are escaped differently, and
template.JS becomes an explicit, greppable opt-in instead of the default.
Authentication and session handling
The account implementation includes password hashing and server-side session state.
Passwords are bcrypt with DefaultCost, and re-hashing happens on password
change:
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(oldPassword)); err != nil {
...
}
newPasswordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
Sessions are server-side. A session_id cookie resolves to a row in a
sessions table with an expiry. Logging out and session expiry actually revoke,
rather than relying only on a signed token the client holds. This gives the
application a server-side revocation path.
The schema is idempotent. Migrations run at startup with
CREATE TABLE IF NOT EXISTS, so the container starts against an empty volume
without a separate migration step.
The inspected queries use parameters. Examples such as the comment and likes
inserts pass values through placeholders. Fixed ORDER BY clauses select supported
sort options. These examples do not establish that every injection-related risk
was exhaustively audited, and they do not prevent the separate HTML-escaping gap.
No repository layer
Every handler opens database/sql and writes its own queries. comments.go,
books.go, index.go, helpers.go all import it directly. There is no service
layer and no repository, and profile reads live in the same file as password
changes.
That is a fair way to write a 2,000-line forum. It is also the first thing I would
change now. The e-commerce project written later has an explicit three-layer rule:
Handler may not touch the database. The contrast makes the cost concrete. There,
business rules are testable without a live connection. Here, every rule lives next
to a QueryRow.
Shape of the work
The history spans September 2024 to June 2026, but that is not continuous feature development. Most application work landed in September 2024. The later maintenance commit migrated Go module paths and added Docker Compose. The confirmed two-person contributions above describe the implementation work rather than inferring effort from the length of the calendar span.
Three dependencies, all of them doing real work: google/uuid for identifiers,
mattn/go-sqlite3 for the cgo driver, and golang.org/x/crypto for bcrypt.
Stack and source figures
how it fits together
- server
-
-
net/http + ServeMux19 HandleFunc registrations and no web framework. Templates parsed once into package-level vars.
-
- templating
-
-
text/templateThe bug, not the feature. html/template is the context-aware escaping package. text/template escapes nothing, so comment bodies render as raw HTML.
-
- data
-
-
SQLite via mattn/go-sqlite3Six tables created by an idempotent migration runner. The driver is cgo, so the Dockerfile is a necessity rather than a convenience.
-
- auth
-
-
bcrypt + server-side sessionsPasswords at DefaultCost; a session_id cookie resolves to a row with an expiry, so logout and expiry actually revoke.
-
- operations
-
-
DockerSingle-image deployment of the app and its database file.
-
- handlers
- 2,117 lines Across internal/, dominated by SQL and template plumbing.
- routes
- 19 All on the standard library's ServeMux. No web framework.
- tables
- 6 users, posts, comments, categories, likes, sessions.
- dependencies
- 3 uuid, go-sqlite3, golang.org/x/crypto. All three load-bearing.
- commits
- 136 History spans 2024-09-02 to 2026-06-10. Most feature work was in September 2024; the later update migrated module paths and added Docker Compose.
Full issue record · 3 entries
what broke, and what happened
- 3 unresolved / 3
Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.
- unresolved
The template package was text/template, not html/template
`text/template` performs no escaping at all. `html/template` is the context-aware, auto-escaping package; `text/template` is for non-HTML output such as config files. Using it for HTML is the whole bug.
then: Not fixed in the archived source. `internal/handlers/helpers.go:7` imports `text/template`, so running that version retains the stored-XSS vulnerability described below. The template package needs changing to `html/template`.
- unresolved
Comment bodies were stored raw and rendered unescaped
`content := r.FormValue("content")` → `INSERT INTO Comments` → `{{.Content}}` at HTML/comments.html:67 and :112.
then: Unresolved, and it is the same root cause as the entry above rather than a second bug. Every comment body is stored verbatim and re-emitted into the page of whoever reads it next.
- unresolved
Two routes read from the database with no repository layer, unlike the e-commerce project written later
then: Left as-is deliberately. This predates the layering rule, and the project was never refactored. Every rule still lives beside a QueryRow, so nothing here is testable without a live database.