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
|
### 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
@@ -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}')
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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 l’envoi"},
|
"submissionFailed": {"message": "Échec de l’envoi"},
|
||||||
"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 ; l’envoi relancera la publication."},
|
||||||
"submissionFailedConnection": {"message": "Échec de l’envoi. Vérifiez la connexion au serveur."},
|
"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"}}},
|
"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"},
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user