diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index f355271..e10ef41 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -849,6 +849,18 @@ Change the title of the field “One-time password” to “One-time password (w ### 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. +### 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 The plugin settings still show `emailLabel`; make that `email` as the title for the email field. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index 791bc6f..83b2c91 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -148,6 +148,8 @@ 139. Remove DEFAULT_BACKEND setting in the plugin. 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. +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. 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 diff --git a/XPI/unsigned/LinkLog-0.1.0.xpi b/XPI/unsigned/LinkLog-0.1.0.xpi index ca769e2..a124ad7 100644 Binary files a/XPI/unsigned/LinkLog-0.1.0.xpi and b/XPI/unsigned/LinkLog-0.1.0.xpi differ diff --git a/backend/app/api/links.py b/backend/app/api/links.py index f3310ed..9d9b791 100644 --- a/backend/app/api/links.py +++ b/backend/app/api/links.py @@ -2,11 +2,11 @@ ## SPDX-License-Identifier: GPL-3.0-or-later import json -from fastapi import APIRouter, Header, HTTPException, status +from fastapi import APIRouter, Header, HTTPException, Response, status import logging 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.services.plugin_manager import plugin_manager from backend.app.services.token_service import validate_token @@ -35,8 +35,23 @@ def available_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) -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 '): raise HTTPException(status_code=401, detail='Missing or invalid Authorization header') 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') try: - record = create_link(info['user_id'], payload.title, payload.url, payload.comment, payload.timestamp, payload.tags) + 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) except ValueError as 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}) mastodon_result = next((result for result in plugin_results if result.get('plugin') == 'mastodon'), None) if mastodon_result and mastodon_result.get('status') == 'posted': mark_mastodon_posted(record['id'], info['user_id'], mastodon_result.get('post_id')) 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) - return record + return {**record, 'duplicate': duplicate} @router.put('/links/{link_id}') diff --git a/backend/app/services/link_service.py b/backend/app/services/link_service.py index 40a9ab6..79584a0 100644 --- a/backend/app/services/link_service.py +++ b/backend/app/services/link_service.py @@ -109,6 +109,20 @@ def create_link( 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): with get_connection() as conn: rows = conn.execute( diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index af47158..ab80df6 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -415,6 +415,45 @@ def test_submit_link_stores_cleaned_url_and_public_feed(): 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(): headers = login_headers() response = client.post('/api/links', headers=headers, json={ diff --git a/webextension/_locales/de/messages.json b/webextension/_locales/de/messages.json index 3cbf0b4..09062ce 100644 --- a/webextension/_locales/de/messages.json +++ b/webextension/_locales/de/messages.json @@ -41,6 +41,8 @@ "sessionExpired": {"message": "Sitzung abgelaufen. Authentifiziere dich in den Einstellungen erneut."}, "submissionFailed": {"message": "Senden fehlgeschlagen"}, "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."}, "loggedInAt": {"message": "$USERNAME$ ist bei $BACKEND$ angemeldet", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}}, "fillAllFields": {"message": "Fülle alle Felder aus"}, diff --git a/webextension/_locales/en-US/messages.json b/webextension/_locales/en-US/messages.json index 106be54..c23f955 100644 --- a/webextension/_locales/en-US/messages.json +++ b/webextension/_locales/en-US/messages.json @@ -125,6 +125,12 @@ "linkSaved": { "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": { "message": "Submission failed. Check your backend connection." }, diff --git a/webextension/_locales/es/messages.json b/webextension/_locales/es/messages.json index 9e435ec..1c81969 100644 --- a/webextension/_locales/es/messages.json +++ b/webextension/_locales/es/messages.json @@ -41,6 +41,8 @@ "sessionExpired": {"message": "La sesión ha caducado. Vuelve a autenticarte en la configuración."}, "submissionFailed": {"message": "Error al enviar"}, "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."}, "loggedInAt": {"message": "$USERNAME$ ha iniciado sesión en $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}}, "fillAllFields": {"message": "Rellena todos los campos"}, diff --git a/webextension/_locales/fr/messages.json b/webextension/_locales/fr/messages.json index 86931b3..d620f72 100644 --- a/webextension/_locales/fr/messages.json +++ b/webextension/_locales/fr/messages.json @@ -41,6 +41,8 @@ "sessionExpired": {"message": "Session expirée. Reconnectez-vous dans les paramètres."}, "submissionFailed": {"message": "Échec de l’envoi"}, "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 ; l’envoi relancera la publication."}, "submissionFailedConnection": {"message": "Échec de l’envoi. Vérifiez la connexion au serveur."}, "loggedInAt": {"message": "$USERNAME$ est connecté à $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}}, "fillAllFields": {"message": "Veuillez remplir tous les champs"}, diff --git a/webextension/_locales/nl/messages.json b/webextension/_locales/nl/messages.json index 4dd0a68..3378357 100644 --- a/webextension/_locales/nl/messages.json +++ b/webextension/_locales/nl/messages.json @@ -41,6 +41,8 @@ "sessionExpired": {"message": "Sessie verlopen. Verifieer opnieuw in de instellingen."}, "submissionFailed": {"message": "Verzenden mislukt"}, "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."}, "loggedInAt": {"message": "$USERNAME$ is ingelogd op $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}}, "fillAllFields": {"message": "Vul alle velden in"}, diff --git a/webextension/popup.js b/webextension/popup.js index e2ade98..c89ac95 100644 --- a/webextension/popup.js +++ b/webextension/popup.js @@ -142,6 +142,24 @@ async function populateCurrentTab() { 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) { event.preventDefault(); setStatus(t('submitting'), false); @@ -190,7 +208,8 @@ async function handleSubmit(event) { 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) { setStatus(t('submissionFailedConnection'), true); } @@ -199,6 +218,6 @@ async function handleSubmit(event) { openSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage()); warningSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage()); form.addEventListener('submit', handleSubmit); -populateCurrentTab(); +populateCurrentTab().then(checkExistingLink); loadExistingTags(); updateFeedLink();