Update previous links

This commit is contained in:
2026-08-26 16:22:19 +02:00
parent 6772bf7107
commit 01a4ed42bd
12 changed files with 129 additions and 7 deletions
+12
View File
@@ -849,6 +849,18 @@ Change the title of the field “One-time password” to “One-time password (w
### Assistant outcome ### Assistant outcome
Updated the OTP field label in the English, Spanish, German, French, and Dutch extension locale catalogs to clarify that the code is needed only when OTP is configured. Updated the OTP field label in the English, Spanish, German, French, and Dutch extension locale catalogs to clarify that the code is needed only when OTP is configured.
### User
When a URL with the same title is already in the database, warn the user, allow comment and hashtag changes, and retrigger plugins such as Mastodon reposting.
### Assistant outcome
Added authenticated duplicate detection for matching title and normalized URL. The existing link is updated with the submitted comment and tags, plugins are redispatched, and the popup displays a localized duplicate warning.
### User
When the plugin is activated and the link already exists, show “This link already exists. Comment and tags can be updated, and by submitting publishing will be retriggered.”
### Assistant outcome
Added an authenticated duplicate-check request when the popup opens after loading the active page. Existing links now show the requested warning before submission; comment and tag editing plus plugin retriggering remain available.
### User ### User
The plugin settings still show `emailLabel`; make that `email` as the title for the email field. The plugin settings still show `emailLabel`; make that `email` as the title for the email field.
+2
View File
@@ -148,6 +148,8 @@
139. Remove DEFAULT_BACKEND setting in the plugin. 139. Remove DEFAULT_BACKEND setting in the plugin.
140. Change the title of the field "One-time password" to "One-time password (when configured)". 140. Change the title of the field "One-time password" to "One-time password (when configured)".
157. The plugin settings still show 'emailLabel'; make that 'email' as title for the email field. 157. The plugin settings still show 'emailLabel'; make that 'email' as title for the email field.
163. When a URL with the same title is already in the database, warn the user, allow comment and hashtag changes, and retrigger plugins such as Mastodon reposting.
164. When the plugin is activated and the link already exists, show "This link already exists. Comment and tags can be updated, and by submitting publishing will be retriggered."
141. Remove any leading and trailing spaces when entering fields in the settings page of the plugin. 141. Remove any leading and trailing spaces when entering fields in the settings page of the plugin.
142. Do a full security audit document in what you have done in detail in Security-audit.md 142. Do a full security audit document in what you have done in detail in Security-audit.md
143. Address issue 1. and improve password storage 143. Address issue 1. and improve password storage
Binary file not shown.
+26 -4
View File
@@ -2,11 +2,11 @@
## SPDX-License-Identifier: GPL-3.0-or-later ## SPDX-License-Identifier: GPL-3.0-or-later
import json import json
from fastapi import APIRouter, Header, HTTPException, status from fastapi import APIRouter, Header, HTTPException, Response, status
import logging import logging
from pydantic import BaseModel from pydantic import BaseModel
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.services.link_service import create_link, delete_link, find_owned_link_by_title_url, 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.database import get_connection
from backend.app.services.plugin_manager import plugin_manager from backend.app.services.plugin_manager import plugin_manager
from backend.app.services.token_service import validate_token from backend.app.services.token_service import validate_token
@@ -35,8 +35,23 @@ def available_tags():
return list_tags() return list_tags()
@router.get('/links/check')
def check_existing_link(
title: str,
url: str,
authorization: str | None = Header(default=None),
):
if not authorization or not authorization.startswith('Bearer '):
raise HTTPException(status_code=401, detail='Missing or invalid Authorization header')
info = validate_token(authorization.replace('Bearer ', '', 1))
if info is None:
raise HTTPException(status_code=401, detail='Token expired or invalid')
record = find_owned_link_by_title_url(info['user_id'], title, url)
return {'exists': record is not None}
@router.post('/links', status_code=status.HTTP_201_CREATED) @router.post('/links', status_code=status.HTTP_201_CREATED)
def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header(default=None)): def create_link_endpoint(payload: LinkCreate, response: Response, authorization: str | None = Header(default=None)):
if not authorization or not authorization.startswith('Bearer '): if not authorization or not authorization.startswith('Bearer '):
raise HTTPException(status_code=401, detail='Missing or invalid Authorization header') raise HTTPException(status_code=401, detail='Missing or invalid Authorization header')
token = authorization.replace('Bearer ', '', 1) token = authorization.replace('Bearer ', '', 1)
@@ -45,16 +60,23 @@ def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header
raise HTTPException(status_code=401, detail='Token expired or invalid') raise HTTPException(status_code=401, detail='Token expired or invalid')
try: try:
record = find_owned_link_by_title_url(info['user_id'], payload.title, payload.url)
duplicate = record is not None
if duplicate:
record = update_link(record['id'], info['user_id'], payload.title, payload.url, payload.comment, payload.tags)
else:
record = create_link(info['user_id'], payload.title, payload.url, payload.comment, payload.timestamp, payload.tags) record = create_link(info['user_id'], payload.title, payload.url, payload.comment, payload.timestamp, payload.tags)
except ValueError as error: except ValueError as error:
raise HTTPException(status_code=422, detail=str(error)) from error raise HTTPException(status_code=422, detail=str(error)) from error
if duplicate:
response.status_code = status.HTTP_200_OK
plugin_results = plugin_manager.dispatch({'type': 'link_created', **record}) plugin_results = plugin_manager.dispatch({'type': 'link_created', **record})
mastodon_result = next((result for result in plugin_results if result.get('plugin') == 'mastodon'), None) mastodon_result = next((result for result in plugin_results if result.get('plugin') == 'mastodon'), None)
if mastodon_result and mastodon_result.get('status') == 'posted': if mastodon_result and mastodon_result.get('status') == 'posted':
mark_mastodon_posted(record['id'], info['user_id'], mastodon_result.get('post_id')) mark_mastodon_posted(record['id'], info['user_id'], mastodon_result.get('post_id'))
if any(result.get('status') == 'failed' for result in plugin_results): if any(result.get('status') == 'failed' for result in plugin_results):
logger.warning('One or more plugins failed for link_id=%s results=%s', record['id'], plugin_results) logger.warning('One or more plugins failed for link_id=%s results=%s', record['id'], plugin_results)
return record return {**record, 'duplicate': duplicate}
@router.put('/links/{link_id}') @router.put('/links/{link_id}')
+14
View File
@@ -109,6 +109,20 @@ def create_link(
return record return record
def find_owned_link_by_title_url(user_id: str, title: str, url: str) -> dict | None:
cleaned_url = clean_url(url)
with get_connection() as conn:
row = conn.execute(
'SELECT * FROM links WHERE user_id = ? AND title = ? AND url = ? ORDER BY created_at DESC LIMIT 1',
(user_id, title, cleaned_url),
).fetchone()
if row is None:
return None
record = dict(row)
record['tags'] = get_link_tags(conn, record['id'])
return record
def list_public_links(username: str | None = None): def list_public_links(username: str | None = None):
with get_connection() as conn: with get_connection() as conn:
rows = conn.execute( rows = conn.execute(
+39
View File
@@ -415,6 +415,45 @@ def test_submit_link_stores_cleaned_url_and_public_feed():
assert 'alice' in users_response.json() assert 'alice' in users_response.json()
def test_duplicate_link_updates_comment_tags_and_retriggers_plugins():
headers = login_headers()
payload = {
'title': 'Duplicate candidate',
'url': 'https://example.com/duplicate?utm_source=campaign',
'comment': 'first comment',
'tags': ['#First'],
}
first = client.post('/api/links', headers=headers, json=payload)
assert first.status_code == 201
with patch('backend.app.api.links.plugin_manager.dispatch', return_value=[]) as dispatch:
duplicate = client.post('/api/links', headers=headers, json={
**payload,
'comment': 'updated comment',
'tags': ['#Second'],
})
assert duplicate.status_code == 200
assert duplicate.json()['duplicate'] is True
assert duplicate.json()['id'] == first.json()['id']
dispatch.assert_called_once()
assert dispatch.call_args.args[0]['comment'] == 'updated comment'
assert dispatch.call_args.args[0]['tags'] == ['#Second']
feed_item = next(item for item in client.get('/api/public/feed').json() if item['id'] == first.json()['id'])
assert feed_item['comment'] == 'updated comment'
assert feed_item['tags'] == ['#Second']
def test_duplicate_link_check_is_authenticated_and_detects_existing_entry():
headers = login_headers()
payload = {'title': 'Check candidate', 'url': 'https://example.com/check-candidate'}
assert client.get('/api/links/check', params=payload).status_code == 401
created = client.post('/api/links', headers=headers, json=payload)
assert created.status_code == 201
check = client.get('/api/links/check', headers=headers, params=payload)
assert check.status_code == 200
assert check.json()['exists'] is True
def test_links_support_tags_and_tag_filtering(): def test_links_support_tags_and_tag_filtering():
headers = login_headers() headers = login_headers()
response = client.post('/api/links', headers=headers, json={ response = client.post('/api/links', headers=headers, json={
+2
View File
@@ -41,6 +41,8 @@
"sessionExpired": {"message": "Sitzung abgelaufen. Authentifiziere dich in den Einstellungen erneut."}, "sessionExpired": {"message": "Sitzung abgelaufen. Authentifiziere dich in den Einstellungen erneut."},
"submissionFailed": {"message": "Senden fehlgeschlagen"}, "submissionFailed": {"message": "Senden fehlgeschlagen"},
"linkSaved": {"message": "Link erfolgreich gespeichert"}, "linkSaved": {"message": "Link erfolgreich gespeichert"},
"linkAlreadyExists": {"message": "Dieser Link existiert bereits. Kommentar und Tags wurden aktualisiert und die Veröffentlichung erneut ausgelöst."},
"duplicateLinkWarning": {"message": "Dieser Link existiert bereits. Kommentar und Tags können aktualisiert werden; beim Absenden wird die Veröffentlichung erneut ausgelöst."},
"submissionFailedConnection": {"message": "Senden fehlgeschlagen. Überprüfe die Verbindung zum Backend."}, "submissionFailedConnection": {"message": "Senden fehlgeschlagen. Überprüfe die Verbindung zum Backend."},
"loggedInAt": {"message": "$USERNAME$ ist bei $BACKEND$ angemeldet", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}}, "loggedInAt": {"message": "$USERNAME$ ist bei $BACKEND$ angemeldet", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}},
"fillAllFields": {"message": "Fülle alle Felder aus"}, "fillAllFields": {"message": "Fülle alle Felder aus"},
@@ -125,6 +125,12 @@
"linkSaved": { "linkSaved": {
"message": "Link saved successfully" "message": "Link saved successfully"
}, },
"linkAlreadyExists": {
"message": "This link already exists. Comment and tags were updated, and publishing was retriggered."
},
"duplicateLinkWarning": {
"message": "This link already exists. Comment and tags can be updated, and by submitting publishing will be retriggered."
},
"submissionFailedConnection": { "submissionFailedConnection": {
"message": "Submission failed. Check your backend connection." "message": "Submission failed. Check your backend connection."
}, },
+2
View File
@@ -41,6 +41,8 @@
"sessionExpired": {"message": "La sesión ha caducado. Vuelve a autenticarte en la configuración."}, "sessionExpired": {"message": "La sesión ha caducado. Vuelve a autenticarte en la configuración."},
"submissionFailed": {"message": "Error al enviar"}, "submissionFailed": {"message": "Error al enviar"},
"linkSaved": {"message": "Enlace guardado correctamente"}, "linkSaved": {"message": "Enlace guardado correctamente"},
"linkAlreadyExists": {"message": "Este enlace ya existe. Se actualizaron el comentario y las etiquetas, y se volvió a activar la publicación."},
"duplicateLinkWarning": {"message": "Este enlace ya existe. Puedes actualizar el comentario y las etiquetas; al enviarlo se volverá a activar la publicación."},
"submissionFailedConnection": {"message": "Error al enviar. Comprueba la conexión con el servidor."}, "submissionFailedConnection": {"message": "Error al enviar. Comprueba la conexión con el servidor."},
"loggedInAt": {"message": "$USERNAME$ ha iniciado sesión en $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}}, "loggedInAt": {"message": "$USERNAME$ ha iniciado sesión en $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}},
"fillAllFields": {"message": "Rellena todos los campos"}, "fillAllFields": {"message": "Rellena todos los campos"},
+2
View File
@@ -41,6 +41,8 @@
"sessionExpired": {"message": "Session expirée. Reconnectez-vous dans les paramètres."}, "sessionExpired": {"message": "Session expirée. Reconnectez-vous dans les paramètres."},
"submissionFailed": {"message": "Échec de lenvoi"}, "submissionFailed": {"message": "Échec de lenvoi"},
"linkSaved": {"message": "Lien enregistré"}, "linkSaved": {"message": "Lien enregistré"},
"linkAlreadyExists": {"message": "Ce lien existe déjà. Le commentaire et les étiquettes ont été mis à jour et la publication a été relancée."},
"duplicateLinkWarning": {"message": "Ce lien existe déjà. Le commentaire et les étiquettes peuvent être mis à jour ; lenvoi relancera la publication."},
"submissionFailedConnection": {"message": "Échec de lenvoi. Vérifiez la connexion au serveur."}, "submissionFailedConnection": {"message": "Échec de lenvoi. Vérifiez la connexion au serveur."},
"loggedInAt": {"message": "$USERNAME$ est connecté à $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}}, "loggedInAt": {"message": "$USERNAME$ est connecté à $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}},
"fillAllFields": {"message": "Veuillez remplir tous les champs"}, "fillAllFields": {"message": "Veuillez remplir tous les champs"},
+2
View File
@@ -41,6 +41,8 @@
"sessionExpired": {"message": "Sessie verlopen. Verifieer opnieuw in de instellingen."}, "sessionExpired": {"message": "Sessie verlopen. Verifieer opnieuw in de instellingen."},
"submissionFailed": {"message": "Verzenden mislukt"}, "submissionFailed": {"message": "Verzenden mislukt"},
"linkSaved": {"message": "Koppeling opgeslagen"}, "linkSaved": {"message": "Koppeling opgeslagen"},
"linkAlreadyExists": {"message": "Deze koppeling bestaat al. De opmerking en tags zijn bijgewerkt en publiceren is opnieuw gestart."},
"duplicateLinkWarning": {"message": "Deze koppeling bestaat al. De opmerking en tags kunnen worden bijgewerkt; na verzenden wordt publiceren opnieuw gestart."},
"submissionFailedConnection": {"message": "Verzenden mislukt. Controleer de verbinding met de backend."}, "submissionFailedConnection": {"message": "Verzenden mislukt. Controleer de verbinding met de backend."},
"loggedInAt": {"message": "$USERNAME$ is ingelogd op $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}}, "loggedInAt": {"message": "$USERNAME$ is ingelogd op $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}},
"fillAllFields": {"message": "Vul alle velden in"}, "fillAllFields": {"message": "Vul alle velden in"},
+21 -2
View File
@@ -142,6 +142,24 @@ async function populateCurrentTab() {
urlInput.value = tab.url || ''; urlInput.value = tab.url || '';
} }
async function checkExistingLink() {
const settings = await getSettings();
if (!settings.backendUrl || !settings.accessToken || !titleInput.value || !urlInput.value) return;
try {
const response = await fetch(`${settings.backendUrl}/api/links/check?${new URLSearchParams({
title: titleInput.value,
url: removeKnownTrackingParams(urlInput.value),
})}`, {
headers: {'Authorization': `Bearer ${settings.accessToken}`},
});
if (response.ok && (await response.json()).exists) {
setStatus(t('duplicateLinkWarning'), true);
}
} catch (error) {
// Duplicate checking is advisory; submission remains available.
}
}
async function handleSubmit(event) { async function handleSubmit(event) {
event.preventDefault(); event.preventDefault();
setStatus(t('submitting'), false); setStatus(t('submitting'), false);
@@ -190,7 +208,8 @@ async function handleSubmit(event) {
throw new Error(t('submissionFailed')); throw new Error(t('submissionFailed'));
} }
setStatus(t('linkSaved')); const result = await response.json();
setStatus(result.duplicate ? t('linkAlreadyExists') : t('linkSaved'), Boolean(result.duplicate));
} catch (error) { } catch (error) {
setStatus(t('submissionFailedConnection'), true); setStatus(t('submissionFailedConnection'), true);
} }
@@ -199,6 +218,6 @@ async function handleSubmit(event) {
openSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage()); openSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage());
warningSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage()); warningSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage());
form.addEventListener('submit', handleSubmit); form.addEventListener('submit', handleSubmit);
populateCurrentTab(); populateCurrentTab().then(checkExistingLink);
loadExistingTags(); loadExistingTags();
updateFeedLink(); updateFeedLink();