From 01a4ed42bd2457576c021af1eded35648cdc8c4b Mon Sep 17 00:00:00 2001 From: Kolkman Date: Wed, 26 Aug 2026 16:22:19 +0200 Subject: [PATCH] Update previous links --- VIBE/CHAT_LOG.md | 12 +++++++ VIBE/PROMPTS.md | 2 ++ XPI/unsigned/LinkLog-0.1.0.xpi | Bin 48274 -> 48918 bytes backend/app/api/links.py | 32 +++++++++++++++--- backend/app/services/link_service.py | 14 ++++++++ backend/tests/test_api.py | 39 ++++++++++++++++++++++ webextension/_locales/de/messages.json | 2 ++ webextension/_locales/en-US/messages.json | 6 ++++ webextension/_locales/es/messages.json | 2 ++ webextension/_locales/fr/messages.json | 2 ++ webextension/_locales/nl/messages.json | 2 ++ webextension/popup.js | 23 +++++++++++-- 12 files changed, 129 insertions(+), 7 deletions(-) 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 ca769e292687e814c728c828205ccefb5ed3b5f2..a124ad7cc8971f343d7eff17d0ad91bf6de66c7d 100644 GIT binary patch delta 9580 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 KJfY)XU;hP)K)WRX 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();