Complete authentication and profile features

This commit is contained in:
Olaf
2026-08-24 14:50:05 +02:00
parent 1c827956c4
commit 01702fcadf
17 changed files with 227 additions and 40 deletions
+1
View File
@@ -14,3 +14,4 @@ __pycache__/
*.sqlite
*.sqlite3
*.log
backend/data/avatars/
+1 -1
View File
@@ -76,7 +76,7 @@ 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.
On the profile page, the authenticated username is displayed as read-only. The profile form prepopulates the default avatar URL when it has not been customized. 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.
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
+30
View File
@@ -205,3 +205,33 @@ remove default for bio and email in the profile page
### Assistant outcome
Removed the default bio text from the profile template and browser loader. Email remains API-driven and empty until stored for the user. The avatar, Mastodon instance, and post-prefix defaults remain unchanged. Focused profile validation passed, followed by the strict backend suite with 10 passing tests.
### User
Don't use an avatar URL but allow the user to upload an avatar
### Assistant outcome
Replaced the avatar URL field with a multipart image upload, added persistent avatar storage under the database data directory, mounted `/media` for serving uploaded files, limited uploads to PNG/JPEG/GIF/WebP files up to 2 MB, and added a profile preview. Added `python-multipart` to the backend dependencies and verified upload, persistence, serving, scripts, tests, and Docker image build.
### User
Show the avatar with all posts
### Assistant outcome
Updated feed rendering to display each user's uploaded avatar beside every post, with an initial fallback when no avatar exists or the image fails to load. Filtered profile headers now render uploaded avatars as well. Added avatar data coverage and verified the strict backend suite with 10 passing tests.
### User
Show the avatar with each entry on the home page. On the specific /<user>/ page don't show the avatar and user name with each entry
### Assistant outcome
Updated feed rendering so home-page entries include the avatar and username, while entries on `/<user>/` omit the repeated identity block. The filtered page still displays the user avatar and name once in its profile summary. Added cache-busting for the updated feed script and verified the strict backend suite with 10 passing tests.
### User
For the user filter create a dropdown of all users that are available on the server
### Assistant outcome
Replaced the free-text user filter with a dropdown populated from `GET /api/public/users`, including all registered users ordered by username. Preserved cookie-based selection and fallback to all users when a saved choice is unavailable. Bumped the feed asset version, rebuilt the Docker service, verified the live dropdown, and passed the strict backend suite with 10 tests.
### User
If I select a user filter I want the appropriate page to be opened (e.g. /user/ for user of / -home- for all-users)
### Assistant outcome
Changed the user dropdown to navigate to `/<username>/` for a selected user and `/` for all users, instead of filtering only in the current page. Added direct support for the trailing-slash user route, bumped the feed asset version to `v4`, rebuilt Docker, and verified the live routes and strict backend suite with 10 passing tests.
+5
View File
@@ -37,6 +37,11 @@
33. Check the whole authentication flow as it seems broken.
34. In the admin interface allow to toggle administrative rights for users. But always enforce there to be at least one user with admin rights
35. remove default for bio and email in the profile page
36. Don't use an avatar URL but allow the user to upload an avatar
37. Show the avatar with all posts
38. Show the avatar with each entry on the home page. On the specific /<user>/ page don't show the avatar and user name with each entry
39. For the user filter create a dropdown of all users that are available on the server
40. If I select a user filter I want the appropriate page to be opened (e.g. /user/ for user of / -home- for all-users)
## Future entries
+6 -1
View File
@@ -1,10 +1,15 @@
from fastapi import APIRouter
from backend.app.services.link_service import list_public_links
from backend.app.services.link_service import list_public_links, list_public_users
router = APIRouter()
@router.get('/users')
def public_users():
return list_public_users()
@router.get('/feed')
@router.get('/feed/{username}')
def public_feed(username: str | None = None):
+39 -6
View File
@@ -1,11 +1,11 @@
import json
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
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 get_connection
from backend.app.database import AVATARS_DIR, get_connection
router = APIRouter()
@@ -13,7 +13,6 @@ router = APIRouter()
class UserConfigUpdate(BaseModel):
email: str | None = None
bio: str | None = None
avatar_url: str | None = None
class UserPluginConfigUpdate(BaseModel):
@@ -47,21 +46,55 @@ def update_current_user_profile(
email = payload.email or current['email']
bio = payload.bio if payload.bio is not None else current['bio']
avatar_url = payload.avatar_url if payload.avatar_url is not None else current['avatar_url']
conn.execute(
'''
UPDATE users
SET email = ?, bio = ?, avatar_url = ?, updated_at = CURRENT_TIMESTAMP
SET email = ?, bio = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''',
(email, bio, avatar_url, user['id']),
(email, bio, user['id']),
)
conn.commit()
return {'status': 'updated'}
@router.post('/avatar')
async def upload_avatar(
avatar: UploadFile = File(...),
user: dict = Depends(get_current_user),
):
allowed_types = {
'image/gif': '.gif',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
}
suffix = allowed_types.get(avatar.content_type or '')
if suffix is None:
raise HTTPException(status_code=415, detail='Avatar must be a PNG, JPEG, GIF, or WebP image')
contents = await avatar.read(2 * 1024 * 1024 + 1)
if len(contents) > 2 * 1024 * 1024:
raise HTTPException(status_code=413, detail='Avatar must be 2 MB or smaller')
avatar_path = AVATARS_DIR / f'{user["id"]}{suffix}'
for existing_path in AVATARS_DIR.glob(f'{user["id"]}.*'):
if existing_path != avatar_path:
existing_path.unlink(missing_ok=True)
avatar_path.write_bytes(contents)
avatar_url = f'/media/{avatar_path.name}'
with get_connection() as conn:
conn.execute(
'UPDATE users SET avatar_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(avatar_url, user['id']),
)
conn.commit()
return {'avatar_url': avatar_url}
@router.get('/plugins/{plugin_name}')
def get_user_plugin_config(plugin_name: str, user: dict = Depends(get_current_user)):
with get_connection() as conn:
+2
View File
@@ -6,6 +6,8 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
DB_PATH = Path(os.getenv('LINKLOG_DATABASE_PATH', BASE_DIR / 'data' / 'linklog.db'))
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
AVATARS_DIR = DB_PATH.parent / 'avatars'
AVATARS_DIR.mkdir(parents=True, exist_ok=True)
def hash_password(password: str) -> str:
+3
View File
@@ -9,10 +9,12 @@ from backend.app.api.auth import router as auth_router
from backend.app.api.links import router as links_router
from backend.app.api.public import router as public_router
from backend.app.api.user_config import router as user_config_router
from backend.app.database import AVATARS_DIR
from backend.app.services.link_service import list_public_links
app = FastAPI(title='LinkLog API')
app.mount('/static', StaticFiles(directory='frontend/static'), name='static')
app.mount('/media', StaticFiles(directory=AVATARS_DIR), name='media')
app.include_router(auth_router, prefix='/api/auth')
app.include_router(links_router, prefix='/api')
app.include_router(public_router, prefix='/api/public')
@@ -49,6 +51,7 @@ def health_check():
@app.get('/{username}', response_class=HTMLResponse)
@app.get('/{username}/', response_class=HTMLResponse)
async def public_user_feed(request: Request, username: str):
feed = list_public_links(username)
profile = None
+8
View File
@@ -55,3 +55,11 @@ def list_public_links(username: str | None = None):
(username, username),
).fetchall()
return [dict(row) for row in rows]
def list_public_users():
with get_connection() as conn:
rows = conn.execute(
'SELECT username FROM users ORDER BY username'
).fetchall()
return [row['username'] for row in rows]
+1
View File
@@ -2,6 +2,7 @@ fastapi==0.141.1
uvicorn==0.52.4
pydantic==2.13.4
jinja2==3.1.6
python-multipart==0.0.20
pytest==9.1.1
httpx==0.28.1
httpx2==2.12.0
+11 -1
View File
@@ -100,11 +100,15 @@ def test_submit_link_stores_cleaned_url_and_public_feed():
matching = next(item for item in feed if item['id'] == data['id'])
assert matching['comment'] == 'Interesting read'
assert matching['user']['username'] == 'alice'
assert 'avatar_url' in matching['user']
filtered_response = client.get('/api/public/feed/alice')
assert filtered_response.status_code == 200
assert all(item['user']['username'] == 'alice' for item in filtered_response.json())
assert client.get('/api/public/feed/does-not-exist').json() == []
users_response = client.get('/api/public/users')
assert users_response.status_code == 200
assert 'alice' in users_response.json()
def test_logout_revokes_token_and_admin_can_list_plugins():
@@ -129,7 +133,13 @@ def test_public_and_admin_pages_render_html():
assert 'LinkLog' in root_page.text
assert 'class="login-button" href="/login"' in root_page.text
assert client.get('/alice').status_code == 200
assert 'alice' in client.get('/alice').text
assert client.get('/alice/').status_code == 200
user_page = client.get('/alice').text
assert 'alice' in user_page
assert 'data-user-filter="alice"' in user_page
assert 'profile-summary' in user_page
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
assert client.get('/admin').status_code == 200
+17 -3
View File
@@ -19,11 +19,11 @@ def test_user_config_api_and_profile_page():
assert payload['username'] == 'alice'
update_response = client.put('/api/user/me', json={
'username': 'should-not-change',
'email': 'alice@example.com',
'bio': 'Updated bio',
'avatar_url': 'https://example.com/new-avatar.png'
}, headers=headers)
assert update_response.status_code == 200
assert client.get('/api/user/me', headers=headers).json()['username'] == 'alice'
page_response = client.get('/profile')
assert page_response.status_code == 200
@@ -31,9 +31,23 @@ def test_user_config_api_and_profile_page():
assert '<output id="username" class="readonly-value">Loading...</output>' in page_response.text
assert 'name="username"' not in page_response.text
assert '<textarea id="bio" name="bio" rows="4"></textarea>' in page_response.text
assert 'https://example.com/avatar.png' in page_response.text
assert 'name="avatar" type="file"' in page_response.text
assert 'name="avatar_url"' not in page_response.text
assert 'value="mastodon.social"' in page_response.text
assert 'From my #LinkLog: &quot;' 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
upload_response = client.post(
'/api/user/avatar',
headers=headers,
files={'avatar': ('avatar.png', b'fake-png-data', 'image/png')},
)
assert upload_response.status_code == 200
avatar_url = upload_response.json()['avatar_url']
assert avatar_url.startswith('/media/user-1.png')
assert client.get(avatar_url).content == b'fake-png-data'
updated_profile = client.get('/api/user/me', headers=headers).json()
assert updated_profile['avatar_url'] == avatar_url
+47 -21
View File
@@ -4,6 +4,26 @@ const userFilter = document.getElementById('user-filter');
const cookieName = 'linklog-feed-preferences';
function createAvatar(user) {
const avatar = document.createElement('div');
avatar.className = 'avatar';
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);
}
return avatar;
}
function readPreferences() {
const cookie = document.cookie
.split('; ')
@@ -25,7 +45,16 @@ function writePreferences(pref) {
document.cookie = `${cookieName}=${value}; path=/; max-age=31536000`;
}
function renderFeed(items) {
async function loadUsers() {
const response = await fetch('/api/public/users');
if (!response.ok) throw new Error('Could not load users');
const users = await response.json();
const selectedUser = readPreferences().user;
userFilter.replaceChildren(new Option('All users', ''), ...users.map((username) => new Option(username, username)));
userFilter.value = users.includes(selectedUser) ? selectedUser : '';
}
function renderFeed(items, showIdentity = true) {
feedEl.innerHTML = '';
if (!items.length) {
@@ -37,20 +66,6 @@ function renderFeed(items) {
const article = document.createElement('article');
article.className = 'link-item';
const header = document.createElement('div');
header.className = 'link-header';
const avatar = document.createElement('div');
avatar.className = 'avatar';
avatar.textContent = (item.user?.username || 'U').slice(0, 1).toUpperCase();
const userName = document.createElement('div');
userName.className = 'user-name';
userName.textContent = item.user?.username || 'unknown';
header.appendChild(avatar);
header.appendChild(userName);
const title = document.createElement('h2');
const link = document.createElement('a');
link.href = item.url;
@@ -67,7 +82,17 @@ function renderFeed(items) {
meta.className = 'meta';
meta.textContent = item.created_at || 'updated recently';
article.appendChild(header);
if (showIdentity) {
const header = document.createElement('div');
header.className = 'link-header';
header.appendChild(createAvatar(item.user));
const userName = document.createElement('div');
userName.className = 'user-name';
userName.textContent = item.user?.username || 'unknown';
header.appendChild(userName);
article.appendChild(header);
}
article.appendChild(title);
article.appendChild(comment);
article.appendChild(meta);
@@ -94,7 +119,7 @@ async function loadFeed() {
items = [...items].reverse();
}
renderFeed(items);
renderFeed(items, !routeUser);
}
function syncPreferences() {
@@ -108,12 +133,13 @@ function syncPreferences() {
loadFeed();
});
userFilter.addEventListener('input', (event) => {
const next = { ...readPreferences(), user: event.target.value.trim() };
userFilter.addEventListener('change', (event) => {
const selectedUser = event.target.value.trim();
const next = { ...readPreferences(), user: selectedUser };
writePreferences(next);
loadFeed();
window.location.assign(selectedUser ? `/${encodeURIComponent(selectedUser)}/` : '/');
});
}
syncPreferences();
loadFeed();
loadUsers().then(loadFeed).catch(() => loadFeed());
+27 -2
View File
@@ -3,7 +3,6 @@ const profileForm = document.querySelector('#profile-form');
const mastodonForm = document.querySelector('#mastodon-form');
const profileLogoutButton = document.querySelector('#logout-button');
const accessToken = localStorage.getItem('linklogAccessToken');
const defaultAvatarUrl = 'https://example.com/avatar.png';
const defaultMastodonInstance = 'mastodon.social';
const defaultPostPrefix = 'From my #LinkLog: "';
@@ -27,7 +26,11 @@ async function loadProfile() {
document.querySelector('#username').textContent = profile.username || '';
document.querySelector('#email').value = profile.email || '';
document.querySelector('#bio').value = profile.bio || '';
document.querySelector('#avatar-url').value = profile.avatar_url || defaultAvatarUrl;
const avatarPreview = document.querySelector('#avatar-preview');
if (profile.avatar_url) {
avatarPreview.src = profile.avatar_url;
avatarPreview.classList.remove('hidden');
}
if (profile.is_admin) {
document.querySelector('#admin-link').classList.remove('hidden');
}
@@ -46,6 +49,7 @@ async function loadMastodonConfig() {
profileForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(profileForm);
formData.delete('avatar');
const response = await fetch('/api/user/me', {
method: 'PUT',
headers: authHeaders(true),
@@ -54,6 +58,27 @@ profileForm.addEventListener('submit', async (event) => {
setStatus('#profile-status', response.ok ? 'Profile saved.' : 'Could not save profile.', !response.ok);
});
document.querySelector('#avatar-file').addEventListener('change', async (event) => {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('avatar', file);
const response = await fetch('/api/user/avatar', {
method: 'POST',
headers: authHeaders(),
body: formData,
});
if (!response.ok) {
setStatus('#profile-status', 'Could not upload avatar.', true);
return;
}
const data = await response.json();
const avatarPreview = document.querySelector('#avatar-preview');
avatarPreview.src = data.avatar_url;
avatarPreview.classList.remove('hidden');
setStatus('#profile-status', 'Avatar uploaded.');
});
mastodonForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(mastodonForm);
+15
View File
@@ -229,6 +229,14 @@ button:focus-visible {
font-weight: 600;
}
.profile-avatar-preview {
width: 96px;
height: 96px;
object-fit: cover;
border: 2px solid var(--mauve);
border-radius: 50%;
}
button {
width: fit-content;
border-color: var(--mauve);
@@ -363,6 +371,13 @@ button:disabled {
font-weight: 700;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.user-name {
color: var(--lavender);
font-weight: 700;
+11 -3
View File
@@ -23,7 +23,13 @@
{% if profile %}
<section class="link-item profile-summary">
<div class="link-header">
<div class="avatar">{{ profile.username[:1].upper() }}</div>
<div class="avatar">
{% if profile.avatar_url %}
<img src="{{ profile.avatar_url }}" alt="{{ profile.username }} avatar" />
{% else %}
{{ profile.username[:1].upper() }}
{% endif %}
</div>
<div class="user-name">{{ profile.username }}</div>
</div>
<p>{{ profile.bio or 'No profile information provided.' }}</p>
@@ -39,13 +45,15 @@
</label>
<label>
User filter
<input id="user-filter" type="text" placeholder="alice" />
<select id="user-filter">
<option value="">All users</option>
</select>
</label>
</section>
<section id="feed" class="feed" aria-live="polite"></section>
</main>
<script src="/static/feed.js"></script>
<script src="/static/feed.js?v=4"></script>
</body>
</html>
+3 -2
View File
@@ -37,9 +37,10 @@
<textarea id="bio" name="bio" rows="4"></textarea>
</label>
<label>
Avatar URL
<input id="avatar-url" name="avatar_url" type="url" value="https://example.com/avatar.png" />
Avatar image
<input id="avatar-file" name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp" />
</label>
<img id="avatar-preview" class="profile-avatar-preview hidden" alt="Avatar preview" />
<button type="submit">Save profile</button>
<p id="profile-status" class="status" role="status"></p>
</form>