Remove mastodon posts on delete and OTP

This commit is contained in:
2026-08-26 14:16:29 +02:00
parent 80a3a3d541
commit f503dbaef2
24 changed files with 388 additions and 11 deletions
+19 -2
View File
@@ -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
+48
View File
@@ -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
+45
View File
@@ -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()