Update previous links
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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}')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"},
|
||||
|
||||
+21
-2
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user