Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
02b9500
User profile photo and access : Remove the Home button from the profi…
raynaldlao Jul 14, 2026
45e80f9
User profile photo and access : Add public user profile pages at /use…
raynaldlao Jul 14, 2026
c2e3d10
User profile photo and access : Add avatar support to user profiles. …
raynaldlao Jul 15, 2026
64e557d
User profile photo and access : Add comment avatars. Threads `avatar_…
raynaldlao Jul 15, 2026
e99ee4a
User profile photo and access : Add article author avatars. Threads `…
raynaldlao Jul 15, 2026
5d66223
User profile photo and access : Anonymous comments show the author as…
raynaldlao Jul 15, 2026
906fe5a
User profile photo and access : Add profile photo removal. Introduces…
raynaldlao Jul 15, 2026
5df9a9d
User profile photo and access : Add admin user list and refine profil…
raynaldlao Jul 15, 2026
a2aac8a
User profile photo and access : Promote the profile photo upload butt…
raynaldlao Jul 15, 2026
da47ec9
User profile photo and access : Delete old avatar file on re‑upload. …
raynaldlao Jul 15, 2026
d25affc
User profile photo and access : Add full integration test for profile…
raynaldlao Jul 15, 2026
daeaa19
User profile photo and access : Upgrade Flyway from 11 to 12 in `dock…
raynaldlao Jul 15, 2026
c50ec2c
User profile photo and access : Unauthorized access to the article ed…
raynaldlao Jul 15, 2026
92eb571
User profile photo and access : Add a client‑side search bar to the a…
raynaldlao Jul 15, 2026
3609211
User profile photo and access : Custom error pages added for 403, 404…
raynaldlao Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions blog_comment_application.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
from datetime import timedelta

from flask import Flask
from flask import Flask, render_template
from flask_compress import Compress
from sqlalchemy.orm import Session

Expand Down Expand Up @@ -120,7 +120,10 @@ def _init_web_adapters(services: dict) -> dict:
"comment_adapter": CommentAdapter(services["comment_service"]),
"login_adapter": LoginAdapter(services["login_service"]),
"registration_adapter": RegistrationAdapter(services["registration_service"]),
"account_session_adapter": AccountSessionAdapter(services["login_service"]),
"account_session_adapter": AccountSessionAdapter(
services["login_service"],
services["file_service"],
),
"file_adapter": FlaskFileAdapter(services["file_service"]),
}

Expand Down Expand Up @@ -171,6 +174,11 @@ def _init_template_utils(app: Flask) -> None:
app.context_processor(inject_vite_assets)


def _error_page(code: int, message: str) -> tuple[str, int]:
"""Render generic error page with given HTTP status code and message."""
return render_template("error.html", code=code, message=message), code


def create_app(db_session=None) -> Flask:
"""
Bootstrap function to initialize the hexagonal application.
Expand All @@ -192,6 +200,9 @@ def create_app(db_session=None) -> Flask:
web_adapters = _init_web_adapters(services)
register_web_routes(app, web_adapters)
web_adapters["account_session_adapter"].register_before_request_handler(app)
app.errorhandler(403)(lambda e: _error_page(403, "You do not have permission to access this page."))
app.errorhandler(404)(lambda e: _error_page(404, "The page you are looking for does not exist."))
app.errorhandler(500)(lambda e: _error_page(500, "An unexpected error occurred. Please try again later."))
return app


Expand Down
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ services:
- pgdata_test:/var/lib/postgresql

flyway_prod:
image: flyway/flyway:11
image: flyway/flyway:12
container_name: flyway_prod
command: >
-url=jdbc:postgresql://db_prod:5432/${POSTGRES_DB}
Expand All @@ -36,7 +36,7 @@ services:
- db_prod

flyway_test:
image: flyway/flyway:11
image: flyway/flyway:12
container_name: flyway_test
command: >
-url=jdbc:postgresql://db_test:5432/${TEST_POSTGRES_DB}
Expand Down
17 changes: 12 additions & 5 deletions flask_setup/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,13 @@ def _add_referrer_policy(response: Response) -> Response:


def _add_cache_headers(response: Response) -> Response:
"""Sets Cache-Control headers for fingerprinted static assets.
"""Sets Cache-Control headers based on request path.

Applies ``public, max-age=31536000, immutable`` to all responses
served from ``/static/`` (Vite dist, CSS, JS, fonts). Fingerprinted
filenames never change, so the ``immutable`` directive eliminates
revalidation even on manual reload.
Fingerprinted Vite dist assets (static/dist/) get long-term immutable
cache. Non-fingerprinted static files (CSS, JS, fonts) get no-cache
to force revalidation. Uploaded files have UUID-based URLs so they
are also cached indefinitely. All other routes (HTML pages) get
no-store to prevent the browser from caching sensitive pages.

Args:
response: The Flask response object to modify.
Expand All @@ -151,6 +152,11 @@ def _add_cache_headers(response: Response) -> Response:
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = "no-cache"
elif path.startswith("/uploads/"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
return response


Expand All @@ -175,6 +181,7 @@ def init_web_security(app: Flask) -> None:
app: The Flask application instance to secure.
"""
app.session_interface = NonPersistentSessionInterface()
app.config["WTF_CSRF_TIME_LIMIT"] = None
csrf_protect = CSRFProtect(app)
csp = CSPConfig()
app.after_request(csp.add_headers)
Expand Down
27 changes: 27 additions & 0 deletions flask_setup/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,39 @@ def _register_auth_routes(app: Flask, adapters: dict) -> None:
log = adapters["login_adapter"]
reg = adapters["registration_adapter"]
acc = adapters["account_session_adapter"]
csrf = app.extensions["csrf"]
app.add_url_rule("/login", view_func=log.render_login_page, methods=["GET"], endpoint="auth.login")
app.add_url_rule("/login", view_func=log.authenticate, methods=["POST"], endpoint="auth.authenticate")
app.add_url_rule("/register", view_func=reg.render_registration_page, methods=["GET"], endpoint="registration.register")
app.add_url_rule("/register", view_func=reg.register, methods=["POST"], endpoint="registration.register_action")
app.add_url_rule("/profile", view_func=acc.display_profile, endpoint="auth.profile")
app.add_url_rule(
"/users/<username>",
view_func=acc.display_user_profile,
endpoint="auth.user_profile",
)
app.add_url_rule("/logout", view_func=acc.logout, methods=["POST"], endpoint="auth.logout")
app.add_url_rule(
"/api/profile/photo",
view_func=acc.upload_profile_photo,
methods=["POST"],
endpoint="auth.upload_profile_photo",
)
csrf.exempt(acc.upload_profile_photo)

app.add_url_rule(
"/profile/photo/delete",
view_func=acc.remove_profile_photo,
methods=["POST"],
endpoint="auth.remove_profile_photo",
)

app.add_url_rule(
"/admin/users",
view_func=acc.list_all_users,
methods=["GET"],
endpoint="auth.list_all_users",
)


def _register_file_routes(app: Flask, adapters: dict) -> None:
Expand Down
50 changes: 48 additions & 2 deletions frontend/static/css/article.css
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,32 @@
font-family: var(--font-mono);
font-size: var(--font-body-sm);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--on-surface-variant);
}

.meta-author {
color: var(--on-surface);
font-weight: 600;
font-style: italic;
text-decoration: none;
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
}

.meta-author-avatar {
width: 1.25rem;
height: 1.25rem;
border-radius: 9999px;
object-fit: cover;
vertical-align: middle;
margin-right: var(--spacing-xs);
}

.meta-author:hover {
color: var(--primary);
text-decoration: underline;
}

.meta-divider {
Expand Down Expand Up @@ -496,13 +513,26 @@ input[type=number] {
justify-content: center;
}

.comment-avatar a {
display: flex;
width: 100%;
height: 100%;
}

.comment-avatar-initial {
font-size: var(--font-body-sm);
font-weight: 600;
color: var(--on-primary-container);
line-height: 1;
}

.comment-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 9999px;
}

.comment-avatar-deleted {
background: var(--surface-container-high);
}
Expand Down Expand Up @@ -559,6 +589,16 @@ input[type=number] {
font-size: var(--font-body-sm);
font-weight: 600;
color: var(--on-surface);
text-decoration: none;
}

.comment-author:hover {
color: var(--primary);
text-decoration: underline;
}

.comment-avatar-link {
text-decoration: none;
}

.comment-timestamp {
Expand All @@ -571,6 +611,12 @@ input[type=number] {
font-style: italic;
}

.comment-deleted > .comment-content > .comment-header-section > .comment-header > .comment-author:hover {
color: inherit;
text-decoration: none;
cursor: default;
}

.comment-body-text {
font-size: var(--font-body-base);
color: var(--on-surface);
Expand Down
49 changes: 49 additions & 0 deletions frontend/static/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ button,
/* Buttons */
button,
.btn {
line-height: 1.5;
padding: var(--spacing-sm) var(--spacing-xl);
background: var(--primary);
color: var(--on-primary);
Expand Down Expand Up @@ -574,6 +575,54 @@ hr {
width: 100%;
}

/* Custom error pages (403, 404, 500) */
.error-card {
max-width: 25rem;
margin: var(--spacing-xl) auto;
text-align: center;
padding: var(--spacing-xl) var(--spacing-lg);
}

.error-card h1 {
font-size: var(--font-headline-xl);
color: var(--error);
margin-bottom: var(--spacing-md);
line-height: 1.1;
font-family: var(--font-mono);
}

.error-card p {
color: var(--on-surface-variant);
margin-bottom: var(--spacing-lg);
font-size: var(--font-body-base);
}

.error-card .back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
color: var(--on-surface);
font-size: var(--font-body-base);
text-decoration: none;
font-weight: 500;
transition: color 0.15s ease;
}

.error-card .back-link:hover {
color: var(--primary);
}

.error-card .back-link .icon {
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-headline-md);
}

.error-card .back-link span:last-child {
transform: translateY(1.5px);
}

/* Shared card for account/profile pages */
.user-card {
background: var(--surface-container);
Expand Down
50 changes: 36 additions & 14 deletions frontend/static/css/profile.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,28 @@
font-size: 1.8rem;
font-weight: bold;
margin: 0 auto var(--spacing-sm);
overflow: hidden;
}

.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: var(--radius-full);
}

#upload-avatar-btn {
margin-bottom: var(--spacing-sm);
}

.remove-avatar-form {
display: inline-block;
margin-bottom: var(--spacing-sm);
}

#upload-avatar-btn,
.remove-avatar-form .btn-sm {
min-width: 8rem;
}

.role-badge {
Expand Down Expand Up @@ -60,25 +82,25 @@
font-weight: 600;
}

.actions {
.logout-form {
margin: 0;
text-align: center;
}

.profile-actions {
display: flex;
gap: var(--spacing-sm);
justify-content: center;
margin-top: var(--spacing-lg);
}

.actions .btn {
flex: 1;
text-decoration: none;
color: var(--on-primary);
}

.actions .btn.btn-secondary {
color: var(--primary);
}

.actions .btn.btn-secondary:hover {
background: color-mix(in srgb, var(--primary) 8%, transparent);
border-color: var(--primary);
@media (max-width: 22.5em) {
.profile-actions {
flex-direction: column;
}
.profile-actions .btn-secondary {
width: 100%;
}
}

.back-link {
Expand Down
Loading
Loading