diff --git a/.env.example b/.env.example index 01df4c9..9215c88 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,10 @@ APP_ENV=production APP_CONTAINER_NAME=linklog-app APP_PORT=8000 APP_RESTART_POLICY=unless-stopped +APP_HEALTHCHECK_INTERVAL=30s +APP_HEALTHCHECK_TIMEOUT=5s +APP_HEALTHCHECK_START_PERIOD=10s +APP_HEALTHCHECK_RETRIES=3 LINKLOG_APP_NAME=LinkLog LINKLOG_SECRET_KEY=replace-with-a-long-random-secret LINKLOG_TOKEN_EXPIRY_DAYS=30 @@ -18,6 +22,7 @@ TRAEFIK_HOST=localhost TRAEFIK_HTTP_PORT=80 TRAEFIK_HTTP_INTERNAL_PORT=80 TRAEFIK_DASHBOARD_PORT=8080 +TRAEFIK_DASHBOARD_BIND_ADDRESS=127.0.0.1 TRAEFIK_API_INSECURE=true TRAEFIK_RESTART_POLICY=unless-stopped DOCKER_SOCKET_PATH=/var/run/docker.sock diff --git a/Dockerfile b/Dockerfile index d0a9b5a..014b78b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,18 @@ RUN pip install --no-cache-dir -r backend/requirements.txt COPY backend ./backend COPY frontend ./frontend +RUN useradd --create-home --uid 10001 linklog \ + && mkdir -p /app/backend/data \ + && chown -R linklog:linklog /app + +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod 755 /usr/local/bin/docker-entrypoint.sh EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "from urllib.request import urlopen; urlopen('http://127.0.0.1:8000/health', timeout=3)" + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] + CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index ef059ed..f77598c 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ Appending a username to the root URL, such as `/alice`, opens that user's public Configuration APIs require a bearer token returned by the login endpoint. User configuration uses the identity in that token. Plugin administration additionally requires an administrator account; the development `alice` account is seeded as an administrator, while `bob` is a standard user. +Users can change their password from the profile page. The current password is required, new passwords must contain at least 8 characters, and the endpoint is `PUT /api/user/password`. + On the profile page, the authenticated username is displayed as read-only. Users can upload a PNG, JPEG, GIF, or WebP avatar up to 2 MB; uploaded files are stored in the persistent data volume and served by the application. Bio and email fields remain empty until the user provides values. Mastodon settings default to the `mastodon.social` instance and the `From my #LinkLog: "` post prefix. ## Run Tests @@ -131,6 +133,7 @@ The main configurable values are: | `TRAEFIK_HOST` | hostname routed by Traefik | `localhost` | | `TRAEFIK_HTTP_PORT` | host port for the application proxy | `80` | | `TRAEFIK_DASHBOARD_PORT` | host port for the dashboard | `8080` | +| `TRAEFIK_DASHBOARD_BIND_ADDRESS` | host address for the dashboard | `127.0.0.1` | | `TRAEFIK_API_INSECURE` | enable the local dashboard API | `true` | | `APP_PORT` | direct host port for FastAPI | `8000` | @@ -149,6 +152,7 @@ The services are available at: - Traefik dashboard: The Compose configuration routes the hostname `localhost` through Traefik. The SQLite database is stored in the named Docker volume `linklog_data`, mounted at `/app/backend/data`. +The application runs as a non-root user and reports container health through `/health`; Traefik waits for the application health check before starting. Stop the stack without deleting its database: diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index 3c03253..e6b0b51 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -271,3 +271,27 @@ Render edit action for owned links ### Assistant outcome Verified that the feed renders the Edit button only when `is_owner` is true, while the backend owner check remains enforced by `PUT /api/links/{link_id}`. The live Docker-served feed asset contains the owner-gated action, focused tests passed, and the full strict suite passed with 12 tests. + +### User +On every page, when a user has authenticated show its avatar and name in the header next to a sign out putton. + +### Assistant outcome +Added a shared authenticated header to the feed, login, admin, and profile pages. The shared script validates the stored token through `/api/auth/me`, displays the user's avatar and username beside `Sign out`, and shows `Sign in` when signed out. Uploaded avatars are used with an initial fallback. All pages include the shared header, the strict backend suite passed with 12 tests, and all browser scripts passed syntax checks. + +### User +execute next todo + +### Assistant outcome +Implemented the next unfinished requirements slice: authenticated password changes from the profile page. Added current-password verification, an 8-character minimum for new passwords, profile form controls, regression coverage, and README documentation. The strict backend suite passed with 12 tests. + +### User +execute final todo (nummer 3) + +### Assistant outcome +Completed the third remaining plan item by hardening Docker and Traefik: added non-root application execution, configurable health checks, health-gated Traefik startup, loopback dashboard binding, and a startup entrypoint that repairs ownership on existing SQLite volumes before dropping to UID 10001. Compose validation, a fresh image build, existing-volume startup, application health, and the strict backend suite all passed. + +### User +Pressing signout (on the home page) does not log the user out + +### Assistant outcome +Found that the home page rendered the shared authenticated session header but did not load `logout.js`, so its sign-out button had no event handler. Added the logout handler to the home and login pages with cache-busted asset references. Verified real token revocation and the full strict backend suite with 12 passing tests. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index ddd53c0..da0eb8d 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -48,6 +48,10 @@ 44. Add authenticated link editing 45. Expose ownership if feed data 46. Render edit action for owned links +47. On every page, when a user has authenticated show its avatar and name in the header next to a sign out putton. +48. execute next todo +49. execute final todo (nummer 3) +50. Pressing signout (on the home page) does not log the user out ## Future entries diff --git a/backend/app/api/user_config.py b/backend/app/api/user_config.py index 01d5413..924c4ca 100644 --- a/backend/app/api/user_config.py +++ b/backend/app/api/user_config.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from pydantic import BaseModel from backend.app.api.dependencies import get_current_user -from backend.app.database import AVATARS_DIR, get_connection +from backend.app.database import AVATARS_DIR, get_connection, hash_password router = APIRouter() @@ -15,6 +15,11 @@ class UserConfigUpdate(BaseModel): bio: str | None = None +class PasswordUpdate(BaseModel): + current_password: str + new_password: str + + class UserPluginConfigUpdate(BaseModel): instance: str | None = None access_token: str | None = None @@ -60,6 +65,22 @@ def update_current_user_profile( return {'status': 'updated'} +@router.put('/password') +def update_password(payload: PasswordUpdate, user: dict = Depends(get_current_user)): + if len(payload.new_password) < 8: + raise HTTPException(status_code=422, detail='New password must be at least 8 characters') + if hash_password(payload.current_password) != user['password_hash']: + raise HTTPException(status_code=400, detail='Current password is incorrect') + + with get_connection() as conn: + conn.execute( + 'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + (hash_password(payload.new_password), user['id']), + ) + conn.commit() + return {'status': 'password_updated'} + + @router.post('/avatar') async def upload_avatar( avatar: UploadFile = File(...), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index a8ad47d..f9897c3 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -173,6 +173,8 @@ def test_public_and_admin_pages_render_html(): root_page = client.get('/') assert 'LinkLog' in root_page.text assert 'class="login-button" href="/login"' in root_page.text + assert 'id="auth-session" class="auth-session hidden"' in root_page.text + assert 'logout.js?v=3' in root_page.text assert client.get('/alice').status_code == 200 assert client.get('/alice/').status_code == 200 user_page = client.get('/alice').text @@ -182,14 +184,17 @@ def test_public_and_admin_pages_render_html(): feed_script = TestClient(app).get('/static/feed.js?v=4').text assert 'window.location.assign(selectedUser ? `/${encodeURIComponent(selectedUser)}/` : \'/\')' in feed_script assert client.get('/login').status_code == 200 - assert 'Sign in' in client.get('/login').text + login_page = client.get('/login').text + assert 'Sign in' in login_page + assert 'id="auth-session" class="auth-session hidden"' in login_page + assert 'logout.js?v=3' in login_page assert client.get('/admin').status_code == 200 admin_page = client.get('/admin').text assert 'Admin' in admin_page assert 'id="admin-controls" class="hidden"' in admin_page assert 'id="admin-auth-notice" class="auth-notice hidden"' in admin_page assert 'id="admin-login-button" class="login-button" href="/login"' in admin_page - assert 'id="logout-button" class="logout-button hidden"' in admin_page + assert 'id="auth-session" class="auth-session hidden"' in admin_page feed_script = client.get('/static/feed.js?v=5').text assert 'if (item.is_owner)' in feed_script diff --git a/backend/tests/test_user_config.py b/backend/tests/test_user_config.py index 4704f4a..8ed6885 100644 --- a/backend/tests/test_user_config.py +++ b/backend/tests/test_user_config.py @@ -37,7 +37,26 @@ def test_user_config_api_and_profile_page(): assert 'From my #LinkLog: "' in page_response.text assert 'id="admin-link"' in page_response.text assert 'class="login-button hidden"' in page_response.text - assert 'id="logout-button" class="logout-button hidden"' in page_response.text + assert 'id="auth-session" class="auth-session hidden"' in page_response.text + + bob_login = client.post('/api/auth/login', json={ + 'username': 'bob', + 'password': 'secret123', + }).json() + bob_headers = {'Authorization': f"Bearer {bob_login['access_token']}"} + password_response = client.put('/api/user/password', json={ + 'current_password': 'secret123', + 'new_password': 'new-secret-123', + }, headers=bob_headers) + assert password_response.status_code == 200 + assert client.post('/api/auth/login', json={ + 'username': 'bob', + 'password': 'new-secret-123', + }).status_code == 200 + assert client.put('/api/user/password', json={ + 'current_password': 'new-secret-123', + 'new_password': 'secret123', + }, headers=bob_headers).status_code == 200 upload_response = client.post( '/api/user/avatar', diff --git a/docker-compose.yml b/docker-compose.yml index e8c5e7c..9fba985 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,12 @@ services: LINKLOG_TOKEN_EXPIRY_DAYS: ${LINKLOG_TOKEN_EXPIRY_DAYS:-30} LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-} restart: ${APP_RESTART_POLICY:-unless-stopped} + healthcheck: + test: ["CMD", "python", "-c", "from urllib.request import urlopen; urlopen('http://127.0.0.1:8000/health', timeout=3)"] + interval: ${APP_HEALTHCHECK_INTERVAL:-30s} + timeout: ${APP_HEALTHCHECK_TIMEOUT:-5s} + start_period: ${APP_HEALTHCHECK_START_PERIOD:-10s} + retries: ${APP_HEALTHCHECK_RETRIES:-3} labels: - "traefik.enable=true" - "traefik.http.routers.linklog.rule=Host(`${TRAEFIK_HOST:-localhost}`)" @@ -31,8 +37,11 @@ services: - --api.insecure=${TRAEFIK_API_INSECURE:-true} ports: - "${TRAEFIK_HTTP_PORT:-80}:${TRAEFIK_HTTP_INTERNAL_PORT:-80}" - - "${TRAEFIK_DASHBOARD_PORT:-8080}:8080" + - "${TRAEFIK_DASHBOARD_BIND_ADDRESS:-127.0.0.1}:${TRAEFIK_DASHBOARD_PORT:-8080}:8080" restart: ${TRAEFIK_RESTART_POLICY:-unless-stopped} + depends_on: + app: + condition: service_healthy volumes: - "${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock:ro" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..b420948 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +chown -R linklog:linklog /app/backend/data +exec su -s /bin/sh linklog -c "exec $*" \ No newline at end of file diff --git a/frontend/static/auth-header.js b/frontend/static/auth-header.js new file mode 100644 index 0000000..4727272 --- /dev/null +++ b/frontend/static/auth-header.js @@ -0,0 +1,49 @@ +(() => { + const loginButton = document.querySelector('#auth-login-button'); + const session = document.querySelector('#auth-session'); + const avatar = document.querySelector('#auth-avatar'); + const username = document.querySelector('#auth-username'); + const token = localStorage.getItem('linklogAccessToken'); + + if (!loginButton || !session || !avatar || !username) return; + + function showSignedOut() { + loginButton.classList.remove('hidden'); + session.classList.add('hidden'); + } + + function showSignedIn(user) { + loginButton.classList.add('hidden'); + session.classList.remove('hidden'); + username.textContent = user.username || ''; + const initial = (user.username || 'U').slice(0, 1).toUpperCase(); + avatar.textContent = initial; + if (user.avatar_url) { + const image = document.createElement('img'); + image.src = user.avatar_url; + image.alt = `${user.username || 'User'} avatar`; + image.addEventListener('error', () => { + image.remove(); + avatar.textContent = initial; + }); + avatar.textContent = ''; + avatar.appendChild(image); + } + } + + if (!token) { + showSignedOut(); + return; + } + + fetch(`/api/auth/me?token=${encodeURIComponent(token)}`) + .then((response) => { + if (!response.ok) throw new Error('Session expired'); + return response.json(); + }) + .then(showSignedIn) + .catch(() => { + localStorage.removeItem('linklogAccessToken'); + showSignedOut(); + }); +})(); diff --git a/frontend/static/profile.js b/frontend/static/profile.js index c4956a7..a14761d 100644 --- a/frontend/static/profile.js +++ b/frontend/static/profile.js @@ -1,5 +1,6 @@ (() => { const profileForm = document.querySelector('#profile-form'); +const passwordForm = document.querySelector('#password-form'); const mastodonForm = document.querySelector('#mastodon-form'); const profileLogoutButton = document.querySelector('#logout-button'); const accessToken = localStorage.getItem('linklogAccessToken'); @@ -90,6 +91,19 @@ mastodonForm.addEventListener('submit', async (event) => { setStatus('#mastodon-status', response.ok ? 'Mastodon settings saved.' : 'Could not save Mastodon settings.', !response.ok); }); +passwordForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const response = await fetch('/api/user/password', { + method: 'PUT', + headers: authHeaders(true), + body: JSON.stringify(Object.fromEntries(new FormData(passwordForm))), + }); + const status = document.querySelector('#password-status'); + status.textContent = response.ok ? 'Password changed.' : 'Could not change password.'; + status.style.color = response.ok ? '#94e2d5' : '#f38ba8'; + if (response.ok) passwordForm.reset(); +}); + Promise.all([loadProfile(), loadMastodonConfig()]).catch((error) => { setStatus('#profile-status', accessToken ? error.message : 'Please sign in first.', true); }); diff --git a/frontend/static/style.css b/frontend/static/style.css index 91767e3..f49378e 100644 --- a/frontend/static/style.css +++ b/frontend/static/style.css @@ -105,6 +105,25 @@ body::selection { gap: 8px; } +.auth-session { + display: flex; + align-items: center; + gap: 9px; +} + +.auth-session .avatar { + width: 32px; + height: 32px; + flex-basis: 32px; +} + +.auth-session .user-name { + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .login-button { flex: 0 0 auto; margin-top: 2px; @@ -447,6 +466,10 @@ button:disabled { flex-direction: column; } + .auth-session { + flex-direction: column; + } + .login-button { padding: 8px 11px; font-size: 0.9rem; diff --git a/frontend/templates/admin.html b/frontend/templates/admin.html index 107ee01..d8d7f1b 100644 --- a/frontend/templates/admin.html +++ b/frontend/templates/admin.html @@ -16,7 +16,11 @@
- +
@@ -56,6 +60,7 @@ + diff --git a/frontend/templates/feed.html b/frontend/templates/feed.html index eac53d9..d48e0b6 100644 --- a/frontend/templates/feed.html +++ b/frontend/templates/feed.html @@ -14,7 +14,14 @@

LinkLog

Public link feed

- Sign in +
+ + +
@@ -54,6 +61,8 @@
+ + diff --git a/frontend/templates/login.html b/frontend/templates/login.html index 597ec0d..4d1713c 100644 --- a/frontend/templates/login.html +++ b/frontend/templates/login.html @@ -9,8 +9,20 @@
@@ -29,6 +41,8 @@
+ + diff --git a/frontend/templates/user_profile.html b/frontend/templates/user_profile.html index cdc7e1d..f998b8d 100644 --- a/frontend/templates/user_profile.html +++ b/frontend/templates/user_profile.html @@ -13,7 +13,11 @@

Profile

- +
@@ -46,6 +50,22 @@ + + +