Complete authentication and profile features
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "' 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
|
||||
|
||||
Reference in New Issue
Block a user