This commit is contained in:
@@ -20,6 +20,8 @@ LINKLOG_SMTP_FROM=LinkLog <no-reply@localhost>
|
|||||||
LINKLOG_SMTP_USE_TLS=true
|
LINKLOG_SMTP_USE_TLS=true
|
||||||
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS=24
|
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS=24
|
||||||
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS=1
|
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS=1
|
||||||
|
LINKLOG_MASTODON_CLIENT_NAME=LinkLog
|
||||||
|
LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES=10
|
||||||
# Optional comma-separated override. Leave empty to use the built-in list.
|
# Optional comma-separated override. Leave empty to use the built-in list.
|
||||||
LINKLOG_TRACKING_PARAMS=
|
LINKLOG_TRACKING_PARAMS=
|
||||||
# Keep the default path when using the named linklog_data volume.
|
# Keep the default path when using the named linklog_data volume.
|
||||||
|
|||||||
@@ -226,12 +226,13 @@ For a real deployment, start with the docker-compose-example.yaml file. replace
|
|||||||
|
|
||||||
## Mastodon Configuration
|
## Mastodon Configuration
|
||||||
|
|
||||||
After logging in, open <http://localhost:8000/profile> and save the Mastodon settings:
|
After logging in, open <http://localhost:8000/profile>, enter the Mastodon instance, and select **Connect Mastodon**. LinkLog registers an OAuth application on that instance, opens Mastodon authorization, and stores the returned per-user access token after the callback. The requested scopes are `read:accounts` and `write:statuses`.
|
||||||
|
|
||||||
- **Instance**: hostname or URL such as `mastodon.social` or `https://mastodon.social`
|
- **Instance**: hostname or URL such as `mastodon.social` or `https://mastodon.social`
|
||||||
- **Access token**: a Mastodon API token with permission to create statuses
|
|
||||||
- **Post prefix**: text placed immediately before the link; defaults to `From my #LinkLog: `
|
- **Post prefix**: text placed immediately before the link; defaults to `From my #LinkLog: `
|
||||||
|
|
||||||
|
`LINKLOG_PUBLIC_URL` must be the URL users can reach for the OAuth callback. For local development it can remain `http://localhost:8000`; for a deployment it must be the public LinkLog URL. Existing manually entered access tokens remain compatible with the plugin configuration API.
|
||||||
|
|
||||||
Enable the plugin from the admin API or the admin page. New links are saved first and then posted to the configured instance at `/api/v1/statuses`. A Mastodon network failure does not undo the saved link.
|
Enable the plugin from the admin API or the admin page. New links are saved first and then posted to the configured instance at `/api/v1/statuses`. A Mastodon network failure does not undo the saved link.
|
||||||
|
|
||||||
## Useful API Calls
|
## Useful API Calls
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
## Copyright © 2026 Olaf Kolkman
|
||||||
|
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from backend.app.api.dependencies import get_current_user
|
||||||
|
from backend.app.services.mastodon_oauth import finish_authorization, start_authorization
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/oauth/start')
|
||||||
|
def oauth_start(instance: str = 'mastodon.social', user: dict = Depends(get_current_user)):
|
||||||
|
try:
|
||||||
|
authorization_url = start_authorization(user['id'], instance)
|
||||||
|
except Exception as error:
|
||||||
|
raise HTTPException(status_code=502, detail=f'Could not register with Mastodon: {error}') from error
|
||||||
|
return {'authorization_url': authorization_url}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/oauth/callback')
|
||||||
|
def oauth_callback(request: Request, code: str | None = None, state: str | None = None, error: str | None = None):
|
||||||
|
if error or not code or not state:
|
||||||
|
return RedirectResponse(f'/profile?mastodon_error={quote(error or "Authorization was cancelled")}')
|
||||||
|
try:
|
||||||
|
finish_authorization(code, state)
|
||||||
|
except Exception as callback_error:
|
||||||
|
return RedirectResponse(f'/profile?mastodon_error={quote(str(callback_error))}')
|
||||||
|
return RedirectResponse('/profile?mastodon=connected')
|
||||||
@@ -26,6 +26,8 @@ class Settings:
|
|||||||
smtp_use_tls: bool = os.getenv('LINKLOG_SMTP_USE_TLS', 'true').lower() in {'1', 'true', 'yes'}
|
smtp_use_tls: bool = os.getenv('LINKLOG_SMTP_USE_TLS', 'true').lower() in {'1', 'true', 'yes'}
|
||||||
email_verification_expiry_hours: int = int(os.getenv('LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS', '24'))
|
email_verification_expiry_hours: int = int(os.getenv('LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS', '24'))
|
||||||
password_reset_expiry_hours: int = int(os.getenv('LINKLOG_PASSWORD_RESET_EXPIRY_HOURS', '1'))
|
password_reset_expiry_hours: int = int(os.getenv('LINKLOG_PASSWORD_RESET_EXPIRY_HOURS', '1'))
|
||||||
|
mastodon_client_name: str = os.getenv('LINKLOG_MASTODON_CLIENT_NAME', 'LinkLog')
|
||||||
|
mastodon_oauth_expiry_minutes: int = int(os.getenv('LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES', '10'))
|
||||||
tracking_params: list[str] = None
|
tracking_params: list[str] = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
|
|||||||
@@ -135,6 +135,22 @@ CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id
|
||||||
ON password_reset_tokens(user_id);
|
ON password_reset_tokens(user_id);
|
||||||
|
'''),
|
||||||
|
(8, '''
|
||||||
|
CREATE TABLE IF NOT EXISTS mastodon_oauth_states (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
state_hash TEXT NOT NULL UNIQUE,
|
||||||
|
instance TEXT NOT NULL,
|
||||||
|
client_id TEXT NOT NULL,
|
||||||
|
client_secret TEXT NOT NULL,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mastodon_oauth_states_state_hash
|
||||||
|
ON mastodon_oauth_states(state_hash);
|
||||||
'''),
|
'''),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from starlette.requests import Request
|
|||||||
from backend.app.api.admin import router as admin_router
|
from backend.app.api.admin import router as admin_router
|
||||||
from backend.app.api.auth import router as auth_router
|
from backend.app.api.auth import router as auth_router
|
||||||
from backend.app.api.links import router as links_router
|
from backend.app.api.links import router as links_router
|
||||||
|
from backend.app.api.mastodon import router as mastodon_router
|
||||||
from backend.app.api.public import router as public_router
|
from backend.app.api.public import router as public_router
|
||||||
from backend.app.api.setup import router as setup_router
|
from backend.app.api.setup import router as setup_router
|
||||||
from backend.app.api.setup import has_administrator
|
from backend.app.api.setup import has_administrator
|
||||||
@@ -24,6 +25,7 @@ app.mount('/static', StaticFiles(directory='frontend/static'), name='static')
|
|||||||
app.mount('/media', StaticFiles(directory=AVATARS_DIR), name='media')
|
app.mount('/media', StaticFiles(directory=AVATARS_DIR), name='media')
|
||||||
app.include_router(auth_router, prefix='/api/auth')
|
app.include_router(auth_router, prefix='/api/auth')
|
||||||
app.include_router(links_router, prefix='/api')
|
app.include_router(links_router, prefix='/api')
|
||||||
|
app.include_router(mastodon_router, prefix='/api/mastodon')
|
||||||
app.include_router(public_router, prefix='/api/public')
|
app.include_router(public_router, prefix='/api/public')
|
||||||
app.include_router(admin_router, prefix='/api/admin')
|
app.include_router(admin_router, prefix='/api/admin')
|
||||||
app.include_router(user_config_router, prefix='/api/user')
|
app.include_router(user_config_router, prefix='/api/user')
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
## Copyright © 2026 Olaf Kolkman
|
||||||
|
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from secrets import token_urlsafe
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from backend.app.core.config import settings
|
||||||
|
from backend.app.database import get_connection
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_instance(instance: str) -> str:
|
||||||
|
value = instance.strip().rstrip('/')
|
||||||
|
if not value:
|
||||||
|
raise ValueError('Mastodon instance is required')
|
||||||
|
return value if '://' in value else f'https://{value}'
|
||||||
|
|
||||||
|
|
||||||
|
def post_form(url: str, values: dict) -> dict:
|
||||||
|
request = Request(
|
||||||
|
url,
|
||||||
|
data=urlencode(values).encode('utf-8'),
|
||||||
|
headers={'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
method='POST',
|
||||||
|
)
|
||||||
|
with urlopen(request, timeout=10) as response:
|
||||||
|
return json.loads(response.read().decode('utf-8'))
|
||||||
|
|
||||||
|
|
||||||
|
def start_authorization(user_id: str, instance: str) -> str:
|
||||||
|
instance = normalize_instance(instance)
|
||||||
|
redirect_uri = f'{settings.public_url}/api/mastodon/oauth/callback'
|
||||||
|
app = post_form(f'{instance}/api/v1/apps', {
|
||||||
|
'client_name': settings.mastodon_client_name,
|
||||||
|
'redirect_uris': redirect_uri,
|
||||||
|
'scopes': 'read:accounts write:statuses',
|
||||||
|
'website': settings.public_url,
|
||||||
|
})
|
||||||
|
state = token_urlsafe(32)
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.mastodon_oauth_expiry_minutes)
|
||||||
|
with get_connection() as conn:
|
||||||
|
conn.execute('DELETE FROM mastodon_oauth_states WHERE user_id = ?', (user_id,))
|
||||||
|
conn.execute(
|
||||||
|
'''INSERT INTO mastodon_oauth_states
|
||||||
|
(id, user_id, state_hash, instance, client_id, client_secret, redirect_uri, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
|
||||||
|
(str(uuid4()), user_id, sha256(state.encode()).hexdigest(), instance,
|
||||||
|
app['client_id'], app['client_secret'], redirect_uri, expires_at.isoformat()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return f'{instance}/oauth/authorize?' + urlencode({
|
||||||
|
'client_id': app['client_id'],
|
||||||
|
'redirect_uri': redirect_uri,
|
||||||
|
'response_type': 'code',
|
||||||
|
'scope': 'read:accounts write:statuses',
|
||||||
|
'state': state,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def finish_authorization(code: str, state: str) -> str:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
with get_connection() as conn:
|
||||||
|
record = conn.execute(
|
||||||
|
'''SELECT * FROM mastodon_oauth_states
|
||||||
|
WHERE state_hash = ? AND expires_at > ?''',
|
||||||
|
(sha256(state.encode()).hexdigest(), now),
|
||||||
|
).fetchone()
|
||||||
|
if record is None:
|
||||||
|
raise ValueError('OAuth state is invalid or expired')
|
||||||
|
conn.execute('DELETE FROM mastodon_oauth_states WHERE id = ?', (record['id'],))
|
||||||
|
conn.commit()
|
||||||
|
token = post_form(f"{record['instance']}/oauth/token", {
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'code': code,
|
||||||
|
'client_id': record['client_id'],
|
||||||
|
'client_secret': record['client_secret'],
|
||||||
|
'redirect_uri': record['redirect_uri'],
|
||||||
|
})
|
||||||
|
access_token = token.get('access_token')
|
||||||
|
if not access_token:
|
||||||
|
raise ValueError('Mastodon did not return an access token')
|
||||||
|
with get_connection() as conn:
|
||||||
|
current = conn.execute(
|
||||||
|
'SELECT config FROM user_plugin_config WHERE user_id = ? AND plugin_name = ?',
|
||||||
|
(record['user_id'], 'mastodon'),
|
||||||
|
).fetchone()
|
||||||
|
config = json.loads(current['config']) if current and current['config'] else {}
|
||||||
|
config.update({'instance': record['instance'], 'access_token': access_token})
|
||||||
|
if current:
|
||||||
|
conn.execute(
|
||||||
|
'UPDATE user_plugin_config SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND plugin_name = ?',
|
||||||
|
(json.dumps(config), record['user_id'], 'mastodon'),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
'''INSERT INTO user_plugin_config
|
||||||
|
(id, user_id, plugin_name, config, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)''',
|
||||||
|
(str(uuid4()), record['user_id'], 'mastodon', json.dumps(config)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return record['user_id']
|
||||||
@@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent():
|
|||||||
connection = sqlite3.connect(':memory:')
|
connection = sqlite3.connect(':memory:')
|
||||||
|
|
||||||
apply_migrations(connection)
|
apply_migrations(connection)
|
||||||
assert get_schema_version(connection) == 7
|
assert get_schema_version(connection) == 8
|
||||||
tables = {
|
tables = {
|
||||||
row[0]
|
row[0]
|
||||||
for row in connection.execute(
|
for row in connection.execute(
|
||||||
@@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent():
|
|||||||
assert set(DEFAULT_TAGS) <= seeded_tags
|
assert set(DEFAULT_TAGS) <= seeded_tags
|
||||||
|
|
||||||
apply_migrations(connection)
|
apply_migrations(connection)
|
||||||
assert get_schema_version(connection) == 7
|
assert get_schema_version(connection) == 8
|
||||||
|
|
||||||
connection.close()
|
connection.close()
|
||||||
@@ -23,6 +23,8 @@ services:
|
|||||||
LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true}
|
LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true}
|
||||||
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24}
|
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24}
|
||||||
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1}
|
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1}
|
||||||
|
LINKLOG_MASTODON_CLIENT_NAME: ${LINKLOG_MASTODON_CLIENT_NAME:-LinkLog}
|
||||||
|
LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES: ${LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES:-10}
|
||||||
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
|
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
|
||||||
restart: ${APP_RESTART_POLICY:-unless-stopped}
|
restart: ${APP_RESTART_POLICY:-unless-stopped}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ services:
|
|||||||
LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true}
|
LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true}
|
||||||
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24}
|
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24}
|
||||||
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1}
|
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1}
|
||||||
|
LINKLOG_MASTODON_CLIENT_NAME: ${LINKLOG_MASTODON_CLIENT_NAME:-LinkLog}
|
||||||
|
LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES: ${LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES:-10}
|
||||||
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
|
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
|
||||||
restart: ${APP_RESTART_POLICY:-unless-stopped}
|
restart: ${APP_RESTART_POLICY:-unless-stopped}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const profileLogoutButton = document.querySelector('#logout-button');
|
|||||||
const accessToken = localStorage.getItem('linklogAccessToken');
|
const accessToken = localStorage.getItem('linklogAccessToken');
|
||||||
const defaultMastodonInstance = 'mastodon.social';
|
const defaultMastodonInstance = 'mastodon.social';
|
||||||
const defaultPostPrefix = 'From my #LinkLog: ';
|
const defaultPostPrefix = 'From my #LinkLog: ';
|
||||||
|
const mastodonConnectButton = document.querySelector('#mastodon-connect');
|
||||||
|
|
||||||
function authHeaders(includeJson = false) {
|
function authHeaders(includeJson = false) {
|
||||||
return {
|
return {
|
||||||
@@ -46,7 +47,6 @@ async function loadMastodonConfig() {
|
|||||||
if (!response.ok) throw new Error('Could not load Mastodon settings');
|
if (!response.ok) throw new Error('Could not load Mastodon settings');
|
||||||
const config = await response.json();
|
const config = await response.json();
|
||||||
document.querySelector('#mastodon-instance').value = config.instance || defaultMastodonInstance;
|
document.querySelector('#mastodon-instance').value = config.instance || defaultMastodonInstance;
|
||||||
document.querySelector('#mastodon-access-token').value = config.access_token || '';
|
|
||||||
document.querySelector('#mastodon-post-prefix').value = config.post_prefix || defaultPostPrefix;
|
document.querySelector('#mastodon-post-prefix').value = config.post_prefix || defaultPostPrefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +94,24 @@ mastodonForm.addEventListener('submit', async (event) => {
|
|||||||
setStatus('#mastodon-status', response.ok ? 'Mastodon settings saved.' : 'Could not save Mastodon settings.', !response.ok);
|
setStatus('#mastodon-status', response.ok ? 'Mastodon settings saved.' : 'Could not save Mastodon settings.', !response.ok);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mastodonConnectButton.addEventListener('click', async () => {
|
||||||
|
mastodonConnectButton.disabled = true;
|
||||||
|
const instance = document.querySelector('#mastodon-instance').value || defaultMastodonInstance;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/mastodon/oauth/start?instance=${encodeURIComponent(instance)}`, {headers: authHeaders()});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.authorization_url) throw new Error(result.error || 'Could not start Mastodon authorization.');
|
||||||
|
window.location.assign(result.authorization_url);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus('#mastodon-status', error.message, true);
|
||||||
|
mastodonConnectButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mastodonParams = new URLSearchParams(window.location.search);
|
||||||
|
if (mastodonParams.get('mastodon') === 'connected') setStatus('#mastodon-status', 'Mastodon connected.');
|
||||||
|
if (mastodonParams.get('mastodon_error')) setStatus('#mastodon-status', mastodonParams.get('mastodon_error'), true);
|
||||||
|
|
||||||
passwordForm.addEventListener('submit', async (event) => {
|
passwordForm.addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const response = await fetch('/api/user/password', {
|
const response = await fetch('/api/user/password', {
|
||||||
|
|||||||
@@ -84,14 +84,11 @@
|
|||||||
Instance
|
Instance
|
||||||
<input id="mastodon-instance" name="instance" type="text" value="mastodon.social" placeholder="mastodon.social" />
|
<input id="mastodon-instance" name="instance" type="text" value="mastodon.social" placeholder="mastodon.social" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
|
||||||
Access token
|
|
||||||
<input id="mastodon-access-token" name="access_token" type="password" autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
Post prefix
|
Post prefix
|
||||||
<input id="mastodon-post-prefix" name="post_prefix" type="text" value="From my #LinkLog: " />
|
<input id="mastodon-post-prefix" name="post_prefix" type="text" value="From my #LinkLog: " />
|
||||||
</label>
|
</label>
|
||||||
|
<button id="mastodon-connect" type="button">Connect Mastodon</button>
|
||||||
<button type="submit">Save Mastodon settings</button>
|
<button type="submit">Save Mastodon settings</button>
|
||||||
<p id="mastodon-status" class="status" role="status"></p>
|
<p id="mastodon-status" class="status" role="status"></p>
|
||||||
</form>
|
</form>
|
||||||
@@ -100,6 +97,6 @@
|
|||||||
<footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer>
|
<footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer>
|
||||||
<script src="/static/auth-header.js?v=3"></script>
|
<script src="/static/auth-header.js?v=3"></script>
|
||||||
<script src="/static/logout.js?v=2"></script>
|
<script src="/static/logout.js?v=2"></script>
|
||||||
<script src="/static/profile.js?v=3"></script>
|
<script src="/static/profile.js?v=4"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user