Remove mastodon posts on delete and OTP
This commit is contained in:
@@ -13,6 +13,7 @@ from backend.app.services.email_service import send_password_reset_email, smtp_c
|
||||
from backend.app.services.email_verification import verify_email
|
||||
from backend.app.services.password_reset import create_reset_token, reset_password
|
||||
from backend.app.services.token_service import issue_token, revoke_token, validate_token
|
||||
from backend.app.services.otp_service import verify_code
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -22,6 +23,7 @@ init_db()
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
otp: str | None = None
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
@@ -44,6 +46,8 @@ def login(payload: LoginRequest):
|
||||
raise HTTPException(status_code=401, detail='Invalid username or password')
|
||||
if not user['email_verified']:
|
||||
raise HTTPException(status_code=403, detail='Email address is not verified')
|
||||
if user['otp_enabled'] and not verify_code(user['otp_secret'], payload.otp):
|
||||
raise HTTPException(status_code=401, detail='One-time password required or invalid')
|
||||
|
||||
token_data = issue_token(user['id'], user['username'])
|
||||
return {
|
||||
@@ -51,7 +55,7 @@ def login(payload: LoginRequest):
|
||||
'token_type': 'bearer',
|
||||
'expires_at': token_data['expires_at'],
|
||||
'refresh_token': token_data['refresh_token'],
|
||||
'user': {'id': user['id'], 'username': user['username'], 'email': user['email']}
|
||||
'user': {'id': user['id'], 'username': user['username'], 'email': user['email'], 'otp_enabled': bool(user['otp_enabled'])}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
## Copyright © 2026 Olaf Kolkman
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
import logging
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.app.services.link_service import create_link, delete_link, get_link_tags, list_public_links, list_tags, mark_mastodon_posted, update_link
|
||||
from backend.app.services.link_service import create_link, delete_link, get_link_tags, get_owned_link, list_public_links, list_tags, mark_mastodon_posted, update_link
|
||||
from backend.app.database import get_connection
|
||||
from backend.app.services.plugin_manager import plugin_manager
|
||||
from backend.app.services.token_service import validate_token
|
||||
@@ -87,6 +88,16 @@ def delete_link_endpoint(
|
||||
info = validate_token(authorization.replace('Bearer ', '', 1))
|
||||
if info is None:
|
||||
raise HTTPException(status_code=401, detail='Token expired or invalid')
|
||||
link = get_owned_link(link_id, info['user_id'])
|
||||
if link is None:
|
||||
raise HTTPException(status_code=404, detail='Link not found or not owned by user')
|
||||
post_ids = json.loads(link['mastodon_post_ids']) if link.get('mastodon_post_ids') else []
|
||||
if not post_ids and link.get('mastodon_post_id'):
|
||||
post_ids = [link['mastodon_post_id']]
|
||||
if post_ids:
|
||||
result = plugin_manager.delete_mastodon_posts({**link, 'mastodon_post_ids': post_ids})
|
||||
if result.get('status') != 'deleted':
|
||||
raise HTTPException(status_code=502, detail=result.get('reason', 'Could not delete Mastodon posts'))
|
||||
if not delete_link(link_id, info['user_id']):
|
||||
raise HTTPException(status_code=404, detail='Link not found or not owned by user')
|
||||
return {'status': 'deleted', 'id': link_id}
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import BaseModel
|
||||
from backend.app.api.dependencies import get_current_user
|
||||
from backend.app.database import AVATARS_DIR, get_connection, hash_password
|
||||
from backend.app.services.link_service import create_label, delete_label, list_user_labels, update_label
|
||||
from backend.app.services.otp_service import create_secret, provisioning_uri, verify_code
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -24,6 +25,11 @@ class PasswordUpdate(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class OtpUpdate(BaseModel):
|
||||
action: str
|
||||
code: str | None = None
|
||||
|
||||
|
||||
class UserPluginConfigUpdate(BaseModel):
|
||||
instance: str | None = None
|
||||
access_token: str | None = None
|
||||
@@ -44,7 +50,10 @@ def get_current_user_profile(user: dict = Depends(get_current_user)):
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail='User not found')
|
||||
return dict(row)
|
||||
profile = dict(row)
|
||||
profile.pop('otp_secret', None)
|
||||
profile.pop('password_hash', None)
|
||||
return profile
|
||||
|
||||
|
||||
@router.put('/me')
|
||||
@@ -89,6 +98,37 @@ def update_password(payload: PasswordUpdate, user: dict = Depends(get_current_us
|
||||
return {'status': 'password_updated'}
|
||||
|
||||
|
||||
@router.get('/otp')
|
||||
def get_otp(user: dict = Depends(get_current_user)):
|
||||
return {'enabled': bool(user['otp_enabled'])}
|
||||
|
||||
|
||||
@router.post('/otp/setup')
|
||||
def setup_otp(user: dict = Depends(get_current_user)):
|
||||
if user['otp_enabled']:
|
||||
raise HTTPException(status_code=409, detail='One-time password is already enabled')
|
||||
secret = create_secret()
|
||||
with get_connection() as conn:
|
||||
conn.execute('UPDATE users SET otp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (secret, user['id']))
|
||||
conn.commit()
|
||||
return {'secret': secret, 'otpauth_url': provisioning_uri(secret, user['username'])}
|
||||
|
||||
|
||||
@router.post('/otp')
|
||||
def update_otp(payload: OtpUpdate, user: dict = Depends(get_current_user)):
|
||||
if payload.action not in {'enable', 'disable'}:
|
||||
raise HTTPException(status_code=422, detail='OTP action must be enable or disable')
|
||||
if not verify_code(user['otp_secret'], payload.code):
|
||||
raise HTTPException(status_code=400, detail='Invalid one-time password')
|
||||
with get_connection() as conn:
|
||||
if payload.action == 'enable':
|
||||
conn.execute('UPDATE users SET otp_enabled = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (user['id'],))
|
||||
else:
|
||||
conn.execute('UPDATE users SET otp_enabled = 0, otp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (user['id'],))
|
||||
conn.commit()
|
||||
return {'status': 'updated', 'enabled': payload.action == 'enable'}
|
||||
|
||||
|
||||
@router.get('/labels')
|
||||
def get_labels(user: dict = Depends(get_current_user)):
|
||||
return list_user_labels(user['id'])
|
||||
|
||||
@@ -156,6 +156,19 @@ CREATE INDEX IF NOT EXISTS idx_mastodon_oauth_states_state_hash
|
||||
ALTER TABLE links ADD COLUMN mastodon_posted INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE links ADD COLUMN mastodon_post_id TEXT;
|
||||
ALTER TABLE links ADD COLUMN mastodon_posted_at TEXT;
|
||||
'''),
|
||||
(10, '''
|
||||
ALTER TABLE links ADD COLUMN mastodon_post_ids TEXT;
|
||||
UPDATE links
|
||||
SET mastodon_post_ids = CASE
|
||||
WHEN mastodon_post_id IS NOT NULL THEN json_array(mastodon_post_id)
|
||||
ELSE '[]'
|
||||
END
|
||||
WHERE mastodon_post_ids IS NULL;
|
||||
'''),
|
||||
(11, '''
|
||||
ALTER TABLE users ADD COLUMN otp_secret TEXT;
|
||||
ALTER TABLE users ADD COLUMN otp_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
'''),
|
||||
]
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from backend.app.core.security import clean_url
|
||||
@@ -176,13 +177,29 @@ def delete_link(link_id: str, user_id: str) -> bool:
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def get_owned_link(link_id: str, user_id: str) -> dict | None:
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT * FROM links WHERE id = ? AND user_id = ?',
|
||||
(link_id, user_id),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def mark_mastodon_posted(link_id: str, user_id: str, post_id: str | None) -> bool:
|
||||
with get_connection() as conn:
|
||||
current = conn.execute(
|
||||
'SELECT mastodon_post_ids FROM links WHERE id = ? AND user_id = ?',
|
||||
(link_id, user_id),
|
||||
).fetchone()
|
||||
post_ids = json.loads(current['mastodon_post_ids']) if current and current['mastodon_post_ids'] else []
|
||||
if post_id and post_id not in post_ids:
|
||||
post_ids.append(post_id)
|
||||
cursor = conn.execute(
|
||||
'''UPDATE links
|
||||
SET mastodon_posted = 1, mastodon_post_id = ?, mastodon_posted_at = CURRENT_TIMESTAMP
|
||||
SET mastodon_posted = 1, mastodon_post_id = ?, mastodon_post_ids = ?, mastodon_posted_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?''',
|
||||
(post_id, link_id, user_id),
|
||||
(post_id, json.dumps(post_ids), link_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
## Copyright © 2026 Olaf Kolkman
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def create_secret() -> str:
|
||||
return base64.b32encode(secrets.token_bytes(20)).decode('ascii').rstrip('=')
|
||||
|
||||
|
||||
def provisioning_uri(secret: str, username: str, issuer: str = 'LinkLog') -> str:
|
||||
return f'otpauth://totp/{quote(issuer)}:{quote(username)}?secret={secret}&issuer={quote(issuer)}'
|
||||
|
||||
|
||||
def current_code(secret: str, timestamp: float | None = None) -> str:
|
||||
padded_secret = secret + '=' * (-len(secret) % 8)
|
||||
key = base64.b32decode(padded_secret, casefold=True)
|
||||
counter = int(timestamp if timestamp is not None else time.time()) // 30
|
||||
digest = hmac.new(key, counter.to_bytes(8, 'big'), hashlib.sha1).digest()
|
||||
index = digest[-1] & 0x0f
|
||||
value = (int.from_bytes(digest[index:index + 4], 'big') & 0x7fffffff) % 1_000_000
|
||||
return f'{value:06d}'
|
||||
|
||||
|
||||
def verify_code(secret: str | None, code: str | None) -> bool:
|
||||
if not secret or not code:
|
||||
return False
|
||||
normalized_code = code.strip()
|
||||
if len(normalized_code) != 6 or not normalized_code.isdigit():
|
||||
return False
|
||||
padded_secret = secret + '=' * (-len(secret) % 8)
|
||||
try:
|
||||
key = base64.b32decode(padded_secret, casefold=True)
|
||||
except (ValueError, base64.binascii.Error):
|
||||
return False
|
||||
counter = int(time.time()) // 30
|
||||
for offset in (-1, 0, 1):
|
||||
digest = hmac.new(key, (counter + offset).to_bytes(8, 'big'), hashlib.sha1).digest()
|
||||
index = digest[-1] & 0x0f
|
||||
value = (int.from_bytes(digest[index:index + 4], 'big') & 0x7fffffff) % 1_000_000
|
||||
if hmac.compare_digest(f'{value:06d}', normalized_code):
|
||||
return True
|
||||
return False
|
||||
@@ -122,6 +122,46 @@ class MastodonPlugin(BasePlugin):
|
||||
'reason': str(error),
|
||||
}
|
||||
|
||||
def delete_posts(self, event):
|
||||
config = dict(self.config)
|
||||
user_id = event.get('user_id')
|
||||
if user_id:
|
||||
from backend.app.database import get_connection
|
||||
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT config FROM user_plugin_config WHERE user_id = ? AND plugin_name = ?',
|
||||
(user_id, self.name),
|
||||
).fetchone()
|
||||
if row and row['config']:
|
||||
config.update(json.loads(row['config']))
|
||||
|
||||
instance = str(config.get('instance', '')).strip().rstrip('/')
|
||||
if instance and '://' not in instance:
|
||||
instance = f'https://{instance}'
|
||||
access_token = str(config.get('access_token', '')).strip()
|
||||
post_ids = event.get('mastodon_post_ids') or []
|
||||
if not post_ids and event.get('mastodon_post_id'):
|
||||
post_ids = [event['mastodon_post_id']]
|
||||
if not instance or not access_token:
|
||||
return {'status': 'failed', 'plugin': self.name, 'reason': 'Mastodon is not configured'}
|
||||
|
||||
try:
|
||||
for post_id in post_ids:
|
||||
request = Request(
|
||||
f'{instance}/api/v1/statuses/{post_id}',
|
||||
headers={'Authorization': f'Bearer {access_token}', 'User-Agent': 'LinkLog/1.0'},
|
||||
method='DELETE',
|
||||
)
|
||||
with urlopen(request, timeout=5) as response:
|
||||
response.read()
|
||||
return {'status': 'deleted', 'plugin': self.name, 'count': len(post_ids)}
|
||||
except HTTPError as error:
|
||||
response_body = error.read().decode('utf-8', errors='replace')
|
||||
return {'status': 'failed', 'plugin': self.name, 'reason': f'HTTP {error.code}: {response_body[:500]}'}
|
||||
except (URLError, TimeoutError, OSError) as error:
|
||||
return {'status': 'failed', 'plugin': self.name, 'reason': str(error)}
|
||||
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self):
|
||||
@@ -157,5 +197,10 @@ class PluginManager:
|
||||
return {'status': 'skipped', 'plugin': 'mastodon', 'reason': 'disabled'}
|
||||
return plugin.handle_event(event)
|
||||
|
||||
def delete_mastodon_posts(self, event):
|
||||
self.refresh_from_db()
|
||||
plugin = next(plugin for plugin in self.plugins if plugin.name == 'mastodon')
|
||||
return plugin.delete_posts(event)
|
||||
|
||||
|
||||
plugin_manager = PluginManager()
|
||||
|
||||
@@ -273,7 +273,50 @@ def test_admin_can_remove_user_with_owned_data():
|
||||
|
||||
removed = client.delete(f'/api/admin/users/{user_id}', headers=headers)
|
||||
assert removed.status_code == 200
|
||||
assert client.get('/api/auth/me', params={'token': user_headers['Authorization'].removeprefix('Bearer ')}).status_code == 401
|
||||
assert client.get('/api/auth/me', params={'token': user_token}).status_code == 401
|
||||
|
||||
|
||||
def test_deleting_link_removes_all_mastodon_posts_first():
|
||||
owner_headers = login_headers('alice')
|
||||
created = client.post('/api/links', headers=owner_headers, json={
|
||||
'title': 'Remote cleanup',
|
||||
'url': 'https://example.com/remote-cleanup',
|
||||
})
|
||||
assert created.status_code == 201
|
||||
link_id = created.json()['id']
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
'UPDATE links SET mastodon_posted = 1, mastodon_post_id = ?, mastodon_post_ids = ? WHERE id = ?',
|
||||
('post-2', json.dumps(['post-1', 'post-2']), link_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
with patch('backend.app.api.links.plugin_manager.delete_mastodon_posts', return_value={'status': 'deleted', 'count': 2}) as delete_posts:
|
||||
deleted = client.delete(f'/api/links/{link_id}', headers=owner_headers)
|
||||
assert deleted.status_code == 200
|
||||
delete_posts.assert_called_once()
|
||||
assert delete_posts.call_args.args[0]['mastodon_post_ids'] == ['post-1', 'post-2']
|
||||
assert client.delete(f'/api/links/{link_id}', headers=owner_headers).status_code == 404
|
||||
|
||||
|
||||
def test_link_is_kept_when_mastodon_cleanup_fails():
|
||||
owner_headers = login_headers('alice')
|
||||
created = client.post('/api/links', headers=owner_headers, json={
|
||||
'title': 'Failed remote cleanup',
|
||||
'url': 'https://example.com/failed-remote-cleanup',
|
||||
})
|
||||
link_id = created.json()['id']
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
'UPDATE links SET mastodon_posted = 1, mastodon_post_id = ?, mastodon_post_ids = ? WHERE id = ?',
|
||||
('post-failed', json.dumps(['post-failed']), link_id),
|
||||
)
|
||||
conn.commit()
|
||||
with patch('backend.app.api.links.plugin_manager.delete_mastodon_posts', return_value={'status': 'failed', 'reason': 'remote refused'}):
|
||||
deleted = client.delete(f'/api/links/{link_id}', headers=owner_headers)
|
||||
assert deleted.status_code == 502
|
||||
assert 'remote refused' in deleted.json()['detail']
|
||||
assert client.get('/api/links').json()
|
||||
|
||||
|
||||
def test_admin_can_toggle_privileges_without_removing_last_admin():
|
||||
|
||||
@@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent():
|
||||
connection = sqlite3.connect(':memory:')
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 9
|
||||
assert get_schema_version(connection) == 11
|
||||
tables = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
@@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent():
|
||||
assert set(DEFAULT_TAGS) <= seeded_tags
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 9
|
||||
assert get_schema_version(connection) == 11
|
||||
|
||||
connection.close()
|
||||
@@ -4,6 +4,7 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app.main import app
|
||||
from backend.app.services.otp_service import current_code
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
@@ -75,3 +76,30 @@ def test_user_config_api_and_profile_page():
|
||||
|
||||
updated_profile = client.get('/api/user/me', headers=headers).json()
|
||||
assert updated_profile['avatar_url'] == avatar_url
|
||||
|
||||
|
||||
def test_user_can_enable_and_use_otp():
|
||||
login = client.post('/api/auth/login', json={'username': 'alice', 'password': 'secret123'}).json()
|
||||
headers = {'Authorization': f"Bearer {login['access_token']}"}
|
||||
setup = client.post('/api/user/otp/setup', headers=headers)
|
||||
assert setup.status_code == 200
|
||||
secret = setup.json()['secret']
|
||||
assert setup.json()['otpauth_url'].startswith('otpauth://totp/')
|
||||
|
||||
enabled = client.post('/api/user/otp', headers=headers, json={
|
||||
'action': 'enable', 'code': current_code(secret),
|
||||
})
|
||||
assert enabled.status_code == 200
|
||||
assert enabled.json()['enabled'] is True
|
||||
assert client.post('/api/auth/login', json={'username': 'alice', 'password': 'secret123'}).status_code == 401
|
||||
|
||||
otp_login = client.post('/api/auth/login', json={
|
||||
'username': 'alice', 'password': 'secret123', 'otp': current_code(secret),
|
||||
})
|
||||
assert otp_login.status_code == 200
|
||||
|
||||
disabled = client.post('/api/user/otp', headers=headers, json={
|
||||
'action': 'disable', 'code': current_code(secret),
|
||||
})
|
||||
assert disabled.status_code == 200
|
||||
assert disabled.json()['enabled'] is False
|
||||
|
||||
Reference in New Issue
Block a user