5 Commits
Author SHA1 Message Date
olaf 27f26e615a SA-005 addressed by updateing ratelimiting
Build LinkLog Development Image / development-image (push) Successful in 8s
2026-08-26 17:03:32 +02:00
olaf c70850b44d SA-4 SSRF protections implemented. 2026-08-26 16:52:59 +02:00
olaf 3e61302bf6 Addressed SA-3 by encrypting the sqlite content with a .env secret 2026-08-26 16:39:53 +02:00
olaf 94997752b6 disappearing interface after link safe 2026-08-26 16:30:25 +02:00
olaf 01a4ed42bd Update previous links 2026-08-26 16:22:19 +02:00
30 changed files with 574 additions and 72 deletions
+1
View File
@@ -10,6 +10,7 @@ APP_HEALTHCHECK_RETRIES=3
LINKLOG_APP_NAME=LinkLog LINKLOG_APP_NAME=LinkLog
LINKLOG_VERSION=0.1.0 LINKLOG_VERSION=0.1.0
LINKLOG_SECRET_KEY=replace-with-a-long-random-secret LINKLOG_SECRET_KEY=replace-with-a-long-random-secret
LINKLOG_DATA_ENCRYPTION_KEY=generate-with-python-cryptography-fernet-key
LINKLOG_TOKEN_EXPIRY_DAYS=30 LINKLOG_TOKEN_EXPIRY_DAYS=30
LINKLOG_PUBLIC_URL=localhost LINKLOG_PUBLIC_URL=localhost
LINKLOG_SMTP_HOST= LINKLOG_SMTP_HOST=
+7
View File
@@ -110,6 +110,8 @@ Appending a username to the root URL, such as `/alice`, opens that user's public
Configuration APIs require a bearer token returned by the login endpoint. Send it in the `Authorization: Bearer ...` header; query-string tokens are not accepted. Users authenticate with their email address; the username remains the public presentation identity used in profiles and feed URLs. User configuration uses the identity in that token. Plugin administration additionally requires an administrator account. Configuration APIs require a bearer token returned by the login endpoint. Send it in the `Authorization: Bearer ...` header; query-string tokens are not accepted. Users authenticate with their email address; the username remains the public presentation identity used in profiles and feed URLs. User configuration uses the identity in that token. Plugin administration additionally requires an administrator account.
Login failures are throttled per client IP and email. Five failures within 15 minutes trigger a two-minute lockout, including invalid OTP attempts; successful authentication clears the failure counter.
Users can change their password from the profile page. The current password is required, new passwords must contain at least 8 characters, and the endpoint is `PUT /api/user/password`. Users can change their password from the profile page. The current password is required, new passwords must contain at least 8 characters, and the endpoint is `PUT /api/user/password`.
Users can configure a time-based one-time password from the profile page using an authenticator app. The profile displays a provisioning secret and authenticator URI during setup, then requires a current six-digit code to enable or disable OTP. When OTP is enabled, both the web login and Firefox extension settings login require the code. The TOTP secret is never returned by the profile API after setup. Users can configure a time-based one-time password from the profile page using an authenticator app. The profile displays a provisioning secret and authenticator URI during setup, then requires a current six-digit code to enable or disable OTP. When OTP is enabled, both the web login and Firefox extension settings login require the code. The TOTP secret is never returned by the profile API after setup.
@@ -167,6 +169,7 @@ The main configurable values are:
| Variable | Purpose | Default | | Variable | Purpose | Default |
| --- | --- | --- | | --- | --- | --- |
| `LINKLOG_SECRET_KEY` | token signing/security secret | required in Docker | | `LINKLOG_SECRET_KEY` | token signing/security secret | required in Docker |
| `LINKLOG_DATA_ENCRYPTION_KEY` | Fernet key for encrypting SMTP, Mastodon, and OTP secrets at rest | required in Docker |
| `LINKLOG_DATABASE_PATH` | SQLite file path inside the container | `/app/backend/data/linklog.db` | | `LINKLOG_DATABASE_PATH` | SQLite file path inside the container | `/app/backend/data/linklog.db` |
| `LINKLOG_TOKEN_EXPIRY_DAYS` | access-token lifetime | `30` | | `LINKLOG_TOKEN_EXPIRY_DAYS` | access-token lifetime | `30` |
| `LINKLOG_PUBLIC_URL` | Public hostname used by Traefik and expanded to a callback URL by the backend | `localhost` | | `LINKLOG_PUBLIC_URL` | Public hostname used by Traefik and expanded to a callback URL by the backend | `localhost` |
@@ -189,6 +192,8 @@ When a verified user enters the wrong password, LinkLog keeps the response gener
The full set of supported variables is listed in `.env.example`. Application variables are passed into the container by Compose; Docker and Traefik variables are used by Compose itself. The full set of supported variables is listed in `.env.example`. Application variables are passed into the container by Compose; Docker and Traefik variables are used by Compose itself.
`LINKLOG_DATA_ENCRYPTION_KEY` must be a Fernet key kept outside the database. Generate one with a Python environment that has `cryptography` installed, for example `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`, then store it in `.env` or a protected deployment secret. Losing this key makes encrypted SMTP, Mastodon, and OTP values unrecoverable. Existing plaintext values from earlier versions should be rotated by saving them again after configuring the key.
Build and start the application: Build and start the application:
```sh ```sh
@@ -228,6 +233,8 @@ After logging in, open <http://localhost:8000/profile>, enter the Mastodon insta
LinkLog caches the OAuth application credentials per Mastodon server in the persistent SQLite `app_settings` table, so subsequent connections do not register a new application on every attempt. If the server rate-limits application registration, the profile page reports the upstream `429` response and the user can retry after the server's cooldown. LinkLog caches the OAuth application credentials per Mastodon server in the persistent SQLite `app_settings` table, so subsequent connections do not register a new application on every attempt. If the server rate-limits application registration, the profile page reports the upstream `429` response and the user can retry after the server's cooldown.
Mastodon instances must be HTTPS hostname-only URLs that resolve to public IP addresses. Loopback, private, link-local, multicast, unspecified, reserved, and IPv4-mapped IPv6 destinations are rejected, and outbound redirects are refused.
Enable the plugin from the admin API or the admin page. New links are saved first and then posted to the configured instance at `/api/v1/statuses`. A Mastodon network failure does not undo the saved link. Enable the plugin from the admin API or the admin page. New links are saved first and then posted to the configured instance at `/api/v1/statuses`. A Mastodon network failure does not undo the saved link.
## Useful API Calls ## Useful API Calls
+22 -18
View File
@@ -65,33 +65,37 @@ These findings are prioritized below. Severity describes the potential security
### SA-003: Sensitive secrets stored in plaintext SQLite ### SA-003: Sensitive secrets stored in plaintext SQLite
**Severity:** High **Severity:** High, remediated in current worktree for newly written secrets
**Evidence:** `backend/app/services/email_service.py` stores SMTP settings including `smtp_password` in `app_settings`; `backend/app/services/mastodon_oauth.py` stores Mastodon application secrets and user access tokens in `app_settings` and `user_plugin_config`; `backend/app/api/user_config.py` stores `otp_secret` in the `users` table. **Evidence:** `backend/app/services/email_service.py` stores SMTP settings including `smtp_password` in `app_settings`; `backend/app/services/mastodon_oauth.py` stores Mastodon application secrets and user access tokens in `app_settings` and `user_plugin_config`; `backend/app/api/user_config.py` stores `otp_secret` in the `users` table. New writes are encrypted, but legacy plaintext rows require rotation.
**Impact:** Read access to the database exposes SMTP credentials, Mastodon posting authority, OAuth client secrets, and TOTP seeds. TOTP seeds cannot be changed by a user who loses the database copy. Database backups therefore contain reusable credentials, not just application data. **Impact:** Read access to the database exposes SMTP credentials, Mastodon posting authority, OAuth client secrets, and TOTP seeds. TOTP seeds cannot be changed by a user who loses the database copy. Database backups therefore contain reusable credentials, not just application data.
**Recommendation:** Encrypt secrets at rest using an external secret-management system or an application encryption key held outside the database. At minimum, use a dedicated secret key supplied through a protected environment/secret file, encrypt sensitive values before SQLite storage, restrict file and volume permissions, and document backup key management. Rotate all credentials after a suspected database disclosure. Continue omitting secrets from API responses. **Current state:** Newly stored SMTP passwords, Mastodon OAuth client secrets and access tokens, and TOTP seeds are encrypted with Fernet using `LINKLOG_DATA_ENCRYPTION_KEY`. The key is required in Docker and is not stored in SQLite. The user plugin API no longer returns the Mastodon access token.
**Residual impact:** Existing plaintext secrets require a controlled read-and-save rotation after the key is configured. Lost encryption keys make stored secrets unrecoverable.
**Priority:** High. **Recommendation:** Supply `LINKLOG_DATA_ENCRYPTION_KEY` through a protected secret mechanism, encrypt backups, rotate credentials after suspected disclosure, and migrate existing plaintext values. Continue omitting secrets from API responses.
**Priority:** Completed for new writes; existing secret migration and key management remain.
### SA-004: User-controlled Mastodon instance creates SSRF and uncontrolled egress risk ### SA-004: User-controlled Mastodon instance creates SSRF and uncontrolled egress risk
**Severity:** High **Severity:** High, remediated in current worktree
**Evidence:** `normalize_instance()` in `backend/app/services/mastodon_oauth.py` and the outbound requests in `backend/app/services/plugin_manager.py` accept an instance supplied by the user and call `urlopen()` against it. **Evidence before remediation:** Mastodon instance values were passed to outbound `urlopen()` calls with no DNS/IP-range or redirect controls.
**Impact:** A user can potentially configure an internal hostname, loopback address, cloud metadata endpoint, or other private network destination. The backend may send OAuth registration, token, status, or deletion requests to that destination. In addition to SSRF, this bypasses expected network egress policy and may disclose OAuth-related request data to an unintended host. **Current state:** `mastodon_security.py` requires hostname-only HTTPS URLs, resolves DNS, rejects loopback, link-local, private, multicast, unspecified, reserved, and IPv4-mapped IPv6 addresses, and uses an opener that refuses redirects. OAuth, posting, and deletion all use these controls.
**Residual impact:** DNS and network policy can change after validation; production deployments should still use egress firewalling or a restricted outbound proxy.
**Recommendation:** Validate Mastodon instances as HTTPS public hostnames. Resolve DNS and reject loopback, link-local, private, multicast, unspecified, and reserved IP ranges, including IPv4-mapped IPv6 addresses. Re-check after redirects and disable or strictly limit redirects. Prefer an outbound proxy with an allow-list and network egress policy. Set explicit URL and response-size limits and use a vetted HTTP client with safe redirect handling. Do not accept arbitrary schemes. **Recommendation:** Keep outbound firewalling or an allow-listed proxy in production, monitor DNS rebinding risk, and maintain response-size/time limits.
**Priority:** High. **Priority:** Completed in code; network-level egress controls remain.
### SA-005: Login endpoint lacks rate limiting and lockout ### SA-005: Login endpoint lacks rate limiting and lockout
**Severity:** High **Severity:** High, remediated in current worktree
**Evidence:** `POST /api/auth/login` in `backend/app/api/auth.py` has no IP, username, or account rate limit. The OTP verification path is also not rate-limited separately. **Evidence before remediation:** `POST /api/auth/login` had no IP, email, or account rate limit, and OTP failures were not throttled separately.
**Impact:** Attackers can perform password guessing and OTP guessing at high speed. Sending a password-reset email after failed authentication can also be abused to generate mail volume and user harassment, even though the response remains generic. **Current state:** Login failures are tracked in SQLite by a SHA-256 key derived from client IP and normalized email. Five failures within 15 minutes cause a two-minute lockout; the endpoint returns `429` with `Retry-After`, and successful password plus OTP authentication clears the counter. Password-reset mail remains generic and should still be rate-limited operationally.
**Recommendation:** Add a distributed rate limiter keyed by IP and normalized username, with conservative burst limits, exponential backoff, and monitoring. Rate-limit password-reset issuance independently and avoid sending reset mail for every failed attempt. Consider temporary account protection after repeated failures without creating a user-enumeration oracle. Return `Retry-After` where appropriate. **Recommendation:** Use a distributed limiter for multi-instance deployments, add monitoring, and rate-limit password-reset issuance independently. Keep responses generic to avoid account enumeration.
**Priority:** High. **Priority:** Completed for the single-instance SQLite deployment; distributed limiting and reset-mail controls remain.
### SA-006: Firefox extension has broad host access and stores bearer tokens in local storage ### SA-006: Firefox extension has broad host access and stores bearer tokens in local storage
@@ -206,7 +210,7 @@ These findings are prioritized below. Severity describes the potential security
## Data Protection Review ## Data Protection Review
- SQLite is the primary data store and contains profile data, links, password hashes, tokens, SMTP settings, Mastodon credentials, OAuth state, and OTP secrets. - SQLite is the primary data store and contains profile data, links, password hashes, tokens, SMTP settings, Mastodon credentials, OAuth state, and OTP secrets. New sensitive values are encrypted with the externally supplied Fernet key; existing plaintext values must be rotated.
- Database backups must be treated as credential-bearing secrets, encrypted, access-controlled, rotated, and tested for secure deletion. - Database backups must be treated as credential-bearing secrets, encrypted, access-controlled, rotated, and tested for secure deletion.
- Avatar files are persistent and publicly served. Validate and re-encode image content before accepting production uploads. - Avatar files are persistent and publicly served. Validate and re-encode image content before accepting production uploads.
- Link URLs and comments are intentionally public feed data. Operators should document that users must not submit secrets in URLs or comments. - Link URLs and comments are intentionally public feed data. Operators should document that users must not submit secrets in URLs or comments.
@@ -228,9 +232,9 @@ Before production exposure:
- [ ] Replace SHA-256 password hashing with Argon2id, scrypt, or bcrypt and migrate existing accounts. - [ ] Replace SHA-256 password hashing with Argon2id, scrypt, or bcrypt and migrate existing accounts.
- [ ] Remove query-string token authentication and rotate existing access tokens. - [ ] Remove query-string token authentication and rotate existing access tokens.
- [ ] Encrypt SMTP, Mastodon, OAuth, and OTP secrets at rest; protect encryption keys separately. - [x] Encrypt newly written SMTP, Mastodon, OAuth, and OTP secrets at rest; protect the encryption key separately. Rotate legacy plaintext values.
- [ ] Add login, OTP, reset-mail, and setup rate limiting. - [ ] Add login, OTP, reset-mail, and setup rate limiting.
- [ ] Restrict Mastodon instance validation and outbound network egress. - [x] Validate Mastodon instances as HTTPS public hostnames, reject unsafe DNS/IP ranges, and block redirects. Keep network-level egress controls in production.
- [ ] Disable direct host publication of the application port in production. - [ ] Disable direct host publication of the application port in production.
- [ ] Configure HTTPS, HSTS, CSP, Referrer-Policy, frame protections, `nosniff`, and trusted hosts. - [ ] Configure HTTPS, HSTS, CSP, Referrer-Policy, frame protections, `nosniff`, and trusted hosts.
- [ ] Define a restrictive CORS policy or leave CORS disabled. - [ ] Define a restrictive CORS policy or leave CORS disabled.
@@ -249,7 +253,7 @@ Before production exposure:
2. Query-token removal and token rotation. 2. Query-token removal and token rotation.
3. Secret-at-rest protection and backup controls. 3. Secret-at-rest protection and backup controls.
4. Login/setup/OTP rate limiting. 4. Login/setup/OTP rate limiting.
5. Mastodon SSRF and egress restrictions. 5. Production network egress controls for Mastodon.
6. Production network and TLS hardening. 6. Production network and TLS hardening.
7. Extension permission and credential-storage review. 7. Extension permission and credential-storage review.
8. Security headers, trusted hosts, request limits, media validation, recovery codes, and audit logging. 8. Security headers, trusted hosts, request limits, media validation, recovery codes, and audit logging.
+60
View File
@@ -849,6 +849,30 @@ 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
Display any errors that occur during posting.
### Assistant outcome
Save responses now include sanitized plugin failure details, and the popup displays publishing errors alongside the save confirmation, including Mastodon HTTP or connection failures.
### User
When pressing Save link, display “Link saved to <LinkLog server URL>” and hide all other information. Refresh only when the plugin is opened again.
### Assistant outcome
Added a terminal save state to the popup. After a successful save it displays the localized LinkLog server URL, hides the form, session, warnings, and header information, and performs no refresh until the popup is opened again.
### 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.
@@ -921,6 +945,30 @@ Remove the “New primary email address” functionality and keep only selecting
### Assistant outcome ### Assistant outcome
Removed the replacement-email workflow entirely. Added cleanup migration 14 for its unused table; verified alternatives remain the only addresses eligible for primary selection. Removed the replacement-email workflow entirely. Added cleanup migration 14 for its unused table; verified alternatives remain the only addresses eligible for primary selection.
### User
Address SA-003 by supplying a dedicated protected encryption key and encrypting sensitive values before SQLite storage.
### Assistant outcome
Added Fernet encryption with required `LINKLOG_DATA_ENCRYPTION_KEY` handling for SMTP, Mastodon OAuth/client and access-token values, TOTP seeds, and recognized administrator plugin secrets. Updated Docker/Compose configuration, tests, README guidance, and the security audit. Legacy plaintext values require rotation after key configuration.
### User
Address SA-004 by validating Mastodon instances as HTTPS public hostnames, rejecting unsafe DNS/IP ranges including mapped IPv6, and blocking redirects.
### Assistant outcome
Implemented and validated SA-004 with centralized Mastodon URL checks, DNS resolution and blocked-range detection, IPv4-mapped IPv6 handling, and no-redirect outbound requests for OAuth, posting, and deletion. The rebuilt container suite passed with 42 tests.
### User
Implement SA-005: login endpoint lacks rate limiting and lockout.
### Assistant outcome
Added persistent IP/email login throttling with a five-failure limit in a 15-minute window, two-minute lockout, `Retry-After` responses, OTP failure coverage, and counter reset after successful authentication. Updated documentation and audit status.
### User
Address SA-004 by validating Mastodon instances as HTTPS public hostnames, rejecting unsafe DNS/IP ranges including mapped IPv6, and blocking redirects.
### Assistant outcome
Added centralized Mastodon SSRF protection for OAuth, posting, and deletion. Validation requires HTTPS hostname-only instances, resolves and rejects private or reserved destinations, and all outbound requests use a no-redirect opener. Added security tests, documentation, and audit status updates.
### User ### User
When the user is logged in, the web plugin should not display “Please sign in to use LinkLog.” When the user is logged in, the web plugin should not display “Please sign in to use LinkLog.”
@@ -945,6 +993,12 @@ In the popup show that the user has logged in.
### Assistant outcome ### Assistant outcome
Added a localized authenticated-session indicator to the Firefox popup, showing the returned username and backend URL after successful session validation. It remains hidden for signed-out or invalid sessions. Added a localized authenticated-session indicator to the Firefox popup, showing the returned username and backend URL after successful session validation. It remains hidden for signed-out or invalid sessions.
### User
Address SA-003 by using a dedicated secret key supplied through a protected environment or secret file, and encrypt sensitive values before SQLite storage.
### Assistant outcome
Added Fernet encryption using the required `LINKLOG_DATA_ENCRYPTION_KEY` for newly stored SMTP passwords, Mastodon OAuth secrets and access tokens, and TOTP seeds. Protected API responses from returning Mastodon tokens, required the key in Docker, documented generation and rotation, and updated the security audit.
### User ### User
The popup still shows the sign-in block even though the authenticated session text is displayed; show the block only when signed out. The popup still shows the sign-in block even though the authenticated session text is displayed; show the block only when signed out.
@@ -954,5 +1008,11 @@ Made popup authentication states mutually exclusive using the DOM `hidden` prope
### User ### User
The authenticated session text and sign-in block are still shown together. The authenticated session text and sign-in block are still shown together.
### User
Use the VIBE directory to log interactions
### Assistant outcome
Appended this visible interaction to the project VIBE chat and prompt logs in the repo’s append-only format.
### Assistant outcome ### Assistant outcome
Made popup state transitions defensive by synchronizing both `hidden` attributes and CSS classes. Non-authentication tag-loading errors no longer switch the popup to signed-out state; only a rejected session does. Added `display: none !important` guards for both authentication blocks. Made popup state transitions defensive by synchronizing both `hidden` attributes and CSS classes. Non-authentication tag-loading errors no longer switch the popup to signed-out state; only a rejected session does. Added `display: none !important` guards for both authentication blocks.
+10
View File
@@ -148,6 +148,10 @@
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.
166. Display any errors that occur during posting.
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."
165. When pressing save link the plugin should display "Link saved to <url of linklog server>" and hide all other information. It should only refresh when the plugin is opened again
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
@@ -165,12 +169,18 @@
152. Make sure an email can only be selected when it has been validated. 152. Make sure an email can only be selected when it has been validated.
153. Remove the entire "New primary email address" block; keep only selecting an existing alternative as primary. 153. Remove the entire "New primary email address" block; keep only selecting an existing alternative as primary.
155. Remove the “New primary email address” functionality and keep only selecting an existing alternative as primary. 155. Remove the “New primary email address” functionality and keep only selecting an existing alternative as primary.
166. Address SA-003 by using a dedicated secret key supplied through a protected environment/secret file, encrypt sensitive values before SQLite storage
167. Address SA-004 by validating Mastodon instances as HTTPS public hostnames, rejecting unsafe DNS/IP ranges including mapped IPv6, and blocking redirects
168. Implement SA-005: login endpoint lacks rate limiting and lockout
167. Address SA-004 by validating Mastodon instances as HTTPS public hostnames, rejecting unsafe DNS/IP ranges including mapped IPv6, and blocking redirects.
158. When the user is logged in the webplugin should not display "Please sign in to use LinkLog." 158. When the user is logged in the webplugin should not display "Please sign in to use LinkLog."
156. When the user is signed in the plugin should not display "Please sign in to use LinkLog." and the link to the settings 156. When the user is signed in the plugin should not display "Please sign in to use LinkLog." and the link to the settings
159. The plugin still does not behave as expected. It still shows that the user should sign in. 159. The plugin still does not behave as expected. It still shows that the user should sign in.
160. In the popup show that the user has logged in. 160. In the popup show that the user has logged in.
166. Address SA-003 by using a dedicated secret key supplied through a protected environment/secret file, encrypt sensitive values before SQLite storage
161. The popup still shows the sign-in block even though the authenticated session text is displayed; show the block only when signed out. 161. The popup still shows the sign-in block even though the authenticated session text is displayed; show the block only when signed out.
162. The authenticated session text and sign-in block are still shown together. 162. The authenticated session text and sign-in block are still shown together.
163. Use the VIBE directory to log interactions.
## Future entries ## Future entries
Binary file not shown.
+4
View File
@@ -20,6 +20,7 @@ from backend.app.services.email_service import (
) )
from backend.app.services.email_verification import create_verification_token from backend.app.services.email_verification import create_verification_token
from backend.app.services.theme_service import THEMES, get_enabled_themes, save_enabled_themes from backend.app.services.theme_service import THEMES, get_enabled_themes, save_enabled_themes
from backend.app.services.secret_store import encrypt_secret
from backend.app.core.config import settings from backend.app.core.config import settings
router = APIRouter() router = APIRouter()
@@ -345,6 +346,9 @@ def update_plugin(
config = json.loads(current['config']) if current['config'] else {} config = json.loads(current['config']) if current['config'] else {}
if payload.config is not None: if payload.config is not None:
config.update(payload.config) config.update(payload.config)
for secret_name in ('access_token', 'client_secret', 'smtp_password'):
if config.get(secret_name):
config[secret_name] = encrypt_secret(config[secret_name])
conn.execute( conn.execute(
''' '''
+16 -5
View File
@@ -3,7 +3,7 @@
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from backend.app.api.dependencies import get_current_user from backend.app.api.dependencies import get_current_user
@@ -15,7 +15,9 @@ from backend.app.services.email_verification import verify_email
from backend.app.services.password_reset import create_reset_token, reset_password from backend.app.services.password_reset import create_reset_token, reset_password
from backend.app.services.token_service import issue_token, revoke_token, validate_token from backend.app.services.token_service import issue_token, revoke_token, validate_token
from backend.app.services.otp_service import verify_code from backend.app.services.otp_service import verify_code
from backend.app.services.secret_store import decrypt_secret
from backend.app.services.email_addresses import verify_user_email_address from backend.app.services.email_addresses import verify_user_email_address
from backend.app.services.login_throttle import check_login_allowed, clear_login_failures, record_login_failure
router = APIRouter() router = APIRouter()
@@ -34,10 +36,17 @@ class PasswordResetRequest(BaseModel):
@router.post('/login') @router.post('/login')
def login(payload: LoginRequest): def login(payload: LoginRequest, request: Request):
user = authenticate_user(payload.email.strip(), payload.password) email = payload.email.strip()
ip_address = request.client.host if request.client else 'unknown'
retry_after = check_login_allowed(ip_address, email)
if retry_after is not None:
raise HTTPException(status_code=429, detail='Too many failed login attempts. Try again later.', headers={'Retry-After': str(retry_after)})
user = authenticate_user(email, payload.password)
if user is None: if user is None:
reset_user = find_user(payload.email.strip()) record_login_failure(ip_address, email)
reset_user = find_user(email)
if reset_user and reset_user['email_verified'] and smtp_configured(): if reset_user and reset_user['email_verified'] and smtp_configured():
try: try:
token = create_reset_token(reset_user['id']) token = create_reset_token(reset_user['id'])
@@ -48,9 +57,11 @@ def login(payload: LoginRequest):
raise HTTPException(status_code=401, detail='Invalid username or password') raise HTTPException(status_code=401, detail='Invalid username or password')
if not user['email_verified']: if not user['email_verified']:
raise HTTPException(status_code=403, detail='Email address is not verified') raise HTTPException(status_code=403, detail='Email address is not verified')
if user['otp_enabled'] and not verify_code(user['otp_secret'], payload.otp): if user['otp_enabled'] and not verify_code(decrypt_secret(user['otp_secret']), payload.otp):
record_login_failure(ip_address, email)
raise HTTPException(status_code=401, detail='One-time password required or invalid') raise HTTPException(status_code=401, detail='One-time password required or invalid')
clear_login_failures(ip_address, email)
token_data = issue_token(user['id'], user['username']) token_data = issue_token(user['id'], user['username'])
return { return {
'access_token': token_data['access_token'], 'access_token': token_data['access_token'],
+32 -5
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,28 @@ 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 = 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: 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 plugin_errors = [
{'plugin': result.get('plugin', 'unknown'), 'reason': result.get('reason', 'Plugin failed')}
for result in plugin_results
if result.get('status') == 'failed'
]
return {**record, 'duplicate': duplicate, 'plugin_errors': plugin_errors}
@router.put('/links/{link_id}') @router.put('/links/{link_id}')
+10 -3
View File
@@ -15,6 +15,7 @@ from backend.app.services.otp_service import create_secret, provisioning_uri, ve
from backend.app.services.email_addresses import add_user_email_address, create_email_verification, list_user_email_addresses from backend.app.services.email_addresses import add_user_email_address, create_email_verification, list_user_email_addresses
from backend.app.services.email_service import send_verification_email, smtp_configured from backend.app.services.email_service import send_verification_email, smtp_configured
from backend.app.core.config import settings from backend.app.core.config import settings
from backend.app.services.secret_store import decrypt_secret, encrypt_secret
router = APIRouter() router = APIRouter()
@@ -116,7 +117,7 @@ def setup_otp(user: dict = Depends(get_current_user)):
raise HTTPException(status_code=409, detail='One-time password is already enabled') raise HTTPException(status_code=409, detail='One-time password is already enabled')
secret = create_secret() secret = create_secret()
with get_connection() as conn: with get_connection() as conn:
conn.execute('UPDATE users SET otp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (secret, user['id'])) conn.execute('UPDATE users SET otp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (encrypt_secret(secret), user['id']))
conn.commit() conn.commit()
return {'secret': secret, 'otpauth_url': provisioning_uri(secret, user['username'])} return {'secret': secret, 'otpauth_url': provisioning_uri(secret, user['username'])}
@@ -125,7 +126,7 @@ def setup_otp(user: dict = Depends(get_current_user)):
def update_otp(payload: OtpUpdate, user: dict = Depends(get_current_user)): def update_otp(payload: OtpUpdate, user: dict = Depends(get_current_user)):
if payload.action not in {'enable', 'disable'}: if payload.action not in {'enable', 'disable'}:
raise HTTPException(status_code=422, detail='OTP action must be enable or disable') raise HTTPException(status_code=422, detail='OTP action must be enable or disable')
if not verify_code(user['otp_secret'], payload.code): if not verify_code(decrypt_secret(user['otp_secret']), payload.code):
raise HTTPException(status_code=400, detail='Invalid one-time password') raise HTTPException(status_code=400, detail='Invalid one-time password')
with get_connection() as conn: with get_connection() as conn:
if payload.action == 'enable': if payload.action == 'enable':
@@ -329,6 +330,8 @@ def get_user_plugin_config(plugin_name: str, user: dict = Depends(get_current_us
return {} return {}
config = json.loads(row['config']) if row['config'] else {} config = json.loads(row['config']) if row['config'] else {}
if config.get('access_token'):
config.pop('access_token')
return config return config
@@ -346,6 +349,8 @@ def update_user_plugin_config(
current_config = json.loads(current['config']) if current and current['config'] else {} current_config = json.loads(current['config']) if current and current['config'] else {}
updates = payload.model_dump(exclude_none=True) updates = payload.model_dump(exclude_none=True)
if updates.get('access_token'):
updates['access_token'] = encrypt_secret(updates['access_token'])
merged = {**current_config, **updates} merged = {**current_config, **updates}
if current is None: if current is None:
@@ -368,4 +373,6 @@ def update_user_plugin_config(
conn.commit() conn.commit()
return merged public_config = dict(merged)
public_config.pop('access_token', None)
return public_config
+1
View File
@@ -24,6 +24,7 @@ class Settings:
version: str = os.getenv('LINKLOG_VERSION', '0.1.0') version: str = os.getenv('LINKLOG_VERSION', '0.1.0')
database_url: str = os.getenv('LINKLOG_DATABASE_URL', f'sqlite:///{DB_PATH}') database_url: str = os.getenv('LINKLOG_DATABASE_URL', f'sqlite:///{DB_PATH}')
secret_key: str = os.getenv('LINKLOG_SECRET_KEY', 'dev-secret-key-change-me') secret_key: str = os.getenv('LINKLOG_SECRET_KEY', 'dev-secret-key-change-me')
data_encryption_key: str = os.getenv('LINKLOG_DATA_ENCRYPTION_KEY', '')
token_expiry_days: int = int(os.getenv('LINKLOG_TOKEN_EXPIRY_DAYS', '30')) token_expiry_days: int = int(os.getenv('LINKLOG_TOKEN_EXPIRY_DAYS', '30'))
public_url: str = normalize_public_url(os.getenv('LINKLOG_PUBLIC_URL', 'http://localhost:8000')) public_url: str = normalize_public_url(os.getenv('LINKLOG_PUBLIC_URL', 'http://localhost:8000'))
smtp_host: str = os.getenv('LINKLOG_SMTP_HOST', '') smtp_host: str = os.getenv('LINKLOG_SMTP_HOST', '')
+3 -1
View File
@@ -7,6 +7,7 @@ import json
from backend.app.core.config import settings from backend.app.core.config import settings
from backend.app.database import get_connection from backend.app.database import get_connection
from backend.app.services.secret_store import decrypt_secret, encrypt_secret
def get_smtp_settings() -> dict: def get_smtp_settings() -> dict:
@@ -22,6 +23,7 @@ def get_smtp_settings() -> dict:
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('smtp',)).fetchone() row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('smtp',)).fetchone()
if row: if row:
values.update(json.loads(row['value'])) values.update(json.loads(row['value']))
values['smtp_password'] = decrypt_secret(values['smtp_password'])
return values return values
@@ -30,7 +32,7 @@ def save_smtp_settings(values: dict) -> None:
conn.execute( conn.execute(
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) '''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''', ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''',
('smtp', json.dumps(values)), ('smtp', json.dumps({**values, 'smtp_password': encrypt_secret(values['smtp_password'])})),
) )
conn.commit() conn.commit()
+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(
+64
View File
@@ -0,0 +1,64 @@
## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime, timedelta, timezone
from hashlib import sha256
import json
from backend.app.database import get_connection
MAX_FAILURES = 5
FAILURE_WINDOW = timedelta(minutes=15)
LOCKOUT_DURATION = timedelta(minutes=2)
def _setting_name(ip_address: str, email: str) -> str:
key = sha256(f'{ip_address}\0{email.casefold()}'.encode('utf-8')).hexdigest()
return f'login_rate:{key}'
def _read_rate(setting_name: str) -> dict:
with get_connection() as conn:
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', (setting_name,)).fetchone()
if not row:
return {}
try:
return json.loads(row['value'])
except (TypeError, json.JSONDecodeError):
return {}
def check_login_allowed(ip_address: str, email: str) -> int | None:
rate = _read_rate(_setting_name(ip_address, email))
now = datetime.now(timezone.utc)
locked_until = datetime.fromisoformat(rate['locked_until']) if rate.get('locked_until') else None
if locked_until and locked_until > now:
return int((locked_until - now).total_seconds()) + 1
return None
def record_login_failure(ip_address: str, email: str) -> None:
setting_name = _setting_name(ip_address, email)
now = datetime.now(timezone.utc)
rate = _read_rate(setting_name)
first_failure = datetime.fromisoformat(rate['first_failure']) if rate.get('first_failure') else now
if now - first_failure >= FAILURE_WINDOW:
rate = {}
first_failure = now
failures = int(rate.get('failures', 0)) + 1
updated = {'failures': failures, 'first_failure': first_failure.isoformat()}
if failures >= MAX_FAILURES:
updated['locked_until'] = (now + LOCKOUT_DURATION).isoformat()
with get_connection() as conn:
conn.execute(
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''',
(setting_name, json.dumps(updated)),
)
conn.commit()
def clear_login_failures(ip_address: str, email: str) -> None:
with get_connection() as conn:
conn.execute('DELETE FROM app_settings WHERE name = ?', (_setting_name(ip_address, email),))
conn.commit()
+11 -10
View File
@@ -7,18 +7,17 @@ import json
from secrets import token_urlsafe from secrets import token_urlsafe
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.error import HTTPError from urllib.error import HTTPError
from urllib.request import Request, urlopen from urllib.request import Request
from uuid import uuid4 from uuid import uuid4
from backend.app.core.config import settings from backend.app.core.config import settings
from backend.app.database import get_connection from backend.app.database import get_connection
from backend.app.services.secret_store import decrypt_secret, encrypt_secret
from backend.app.services.mastodon_security import open_no_redirect, validate_public_instance
def normalize_instance(instance: str) -> str: def normalize_instance(instance: str) -> str:
value = instance.strip().rstrip('/') return validate_public_instance(instance)
if not value:
raise ValueError('Mastodon instance is required')
return value if '://' in value else f'https://{value}'
def post_form(url: str, values: dict) -> dict: def post_form(url: str, values: dict) -> dict:
@@ -28,7 +27,7 @@ def post_form(url: str, values: dict) -> dict:
headers={'Content-Type': 'application/x-www-form-urlencoded'}, headers={'Content-Type': 'application/x-www-form-urlencoded'},
method='POST', method='POST',
) )
with urlopen(request, timeout=10) as response: with open_no_redirect(request, timeout=10) as response:
return json.loads(response.read().decode('utf-8')) return json.loads(response.read().decode('utf-8'))
@@ -39,6 +38,8 @@ def start_authorization(user_id: str, instance: str) -> str:
with get_connection() as conn: with get_connection() as conn:
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', (setting_name,)).fetchone() row = conn.execute('SELECT value FROM app_settings WHERE name = ?', (setting_name,)).fetchone()
app = json.loads(row['value']) if row else None app = json.loads(row['value']) if row else None
if app and app.get('client_secret'):
app['client_secret'] = decrypt_secret(app['client_secret'])
if not app: if not app:
app = post_form(f'{instance}/api/v1/apps', { app = post_form(f'{instance}/api/v1/apps', {
'client_name': settings.mastodon_client_name, 'client_name': settings.mastodon_client_name,
@@ -50,7 +51,7 @@ def start_authorization(user_id: str, instance: str) -> str:
conn.execute( conn.execute(
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) '''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''', ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''',
(setting_name, json.dumps(app)), (setting_name, json.dumps({**app, 'client_secret': encrypt_secret(app['client_secret'])})),
) )
conn.commit() conn.commit()
state = token_urlsafe(32) state = token_urlsafe(32)
@@ -62,7 +63,7 @@ def start_authorization(user_id: str, instance: str) -> str:
(id, user_id, state_hash, instance, client_id, client_secret, redirect_uri, expires_at) (id, user_id, state_hash, instance, client_id, client_secret, redirect_uri, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''', VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
(str(uuid4()), user_id, sha256(state.encode()).hexdigest(), instance, (str(uuid4()), user_id, sha256(state.encode()).hexdigest(), instance,
app['client_id'], app['client_secret'], redirect_uri, expires_at.isoformat()), app['client_id'], encrypt_secret(app['client_secret']), redirect_uri, expires_at.isoformat()),
) )
conn.commit() conn.commit()
return f'{instance}/oauth/authorize?' + urlencode({ return f'{instance}/oauth/authorize?' + urlencode({
@@ -90,7 +91,7 @@ def finish_authorization(code: str, state: str) -> str:
'grant_type': 'authorization_code', 'grant_type': 'authorization_code',
'code': code, 'code': code,
'client_id': record['client_id'], 'client_id': record['client_id'],
'client_secret': record['client_secret'], 'client_secret': decrypt_secret(record['client_secret']),
'redirect_uri': record['redirect_uri'], 'redirect_uri': record['redirect_uri'],
}) })
access_token = token.get('access_token') access_token = token.get('access_token')
@@ -102,7 +103,7 @@ def finish_authorization(code: str, state: str) -> str:
(record['user_id'], 'mastodon'), (record['user_id'], 'mastodon'),
).fetchone() ).fetchone()
config = json.loads(current['config']) if current and current['config'] else {} config = json.loads(current['config']) if current and current['config'] else {}
config.update({'instance': record['instance'], 'access_token': access_token}) config.update({'instance': record['instance'], 'access_token': encrypt_secret(access_token)})
if current: if current:
conn.execute( conn.execute(
'UPDATE user_plugin_config SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND plugin_name = ?', 'UPDATE user_plugin_config SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND plugin_name = ?',
+62
View File
@@ -0,0 +1,62 @@
## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
import ipaddress
import socket
from urllib.parse import urlsplit
from urllib.error import HTTPError
from urllib.request import HTTPRedirectHandler, Request, build_opener
class RejectRedirectHandler(HTTPRedirectHandler):
def redirect_request(self, request, file, code, msg, headers, newurl):
raise HTTPError(request.full_url, code, 'Redirects are not allowed', headers, None)
NO_REDIRECT_OPENER = build_opener(RejectRedirectHandler)
def _is_blocked_address(address: str) -> bool:
parsed = ipaddress.ip_address(address)
mapped = parsed.ipv4_mapped if isinstance(parsed, ipaddress.IPv6Address) else None
candidates = (parsed, mapped) if mapped else (parsed,)
return any(
candidate.is_loopback
or candidate.is_link_local
or candidate.is_private
or candidate.is_multicast
or candidate.is_unspecified
or candidate.is_reserved
for candidate in candidates
)
def validate_public_instance(instance: str) -> str:
value = instance.strip().rstrip('/')
if not value:
raise ValueError('Mastodon instance is required')
if '://' not in value:
value = f'https://{value}'
parsed = urlsplit(value)
if parsed.scheme.lower() != 'https' or not parsed.hostname:
raise ValueError('Mastodon instance must be an HTTPS public hostname')
if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ('', '/'):
raise ValueError('Mastodon instance must be a hostname-only HTTPS URL')
try:
port = parsed.port
except ValueError as error:
raise ValueError('Mastodon instance has an invalid port') from error
if port not in (None, 443):
raise ValueError('Mastodon instance must use HTTPS port 443')
hostname = parsed.hostname.rstrip('.').lower()
try:
addresses = {result[4][0] for result in socket.getaddrinfo(hostname, port or 443, type=socket.SOCK_STREAM)}
except socket.gaierror as error:
raise ValueError('Mastodon instance hostname could not be resolved') from error
if not addresses or any(_is_blocked_address(address) for address in addresses):
raise ValueError('Mastodon instance must resolve only to public IP addresses')
return f'https://{hostname}'
def open_no_redirect(request: Request, timeout: int = 10):
return NO_REDIRECT_OPENER.open(request, timeout=timeout)
+15 -9
View File
@@ -5,9 +5,11 @@ import json
import logging import logging
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import Request, urlopen from urllib.request import Request
from backend.app.plugins.base import BasePlugin from backend.app.plugins.base import BasePlugin
from backend.app.services.secret_store import decrypt_secret
from backend.app.services.mastodon_security import open_no_redirect, validate_public_instance
DEFAULT_POST_PREFIX = 'From my #LinkLog: ' DEFAULT_POST_PREFIX = 'From my #LinkLog: '
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -47,10 +49,12 @@ class MastodonPlugin(BasePlugin):
).fetchone() ).fetchone()
if row and row['config']: if row and row['config']:
config.update(json.loads(row['config'])) config.update(json.loads(row['config']))
config['access_token'] = decrypt_secret(config.get('access_token', ''))
instance = str(config.get('instance', '')).strip().rstrip('/') try:
if instance and '://' not in instance: instance = validate_public_instance(str(config.get('instance', '')))
instance = f'https://{instance}' except ValueError as error:
return {'status': 'failed', 'plugin': self.name, 'reason': str(error)}
access_token = str(config.get('access_token', '')).strip() access_token = str(config.get('access_token', '')).strip()
if not instance or not access_token: if not instance or not access_token:
logger.debug( logger.debug(
@@ -90,7 +94,7 @@ class MastodonPlugin(BasePlugin):
}, },
method='POST', method='POST',
) )
with urlopen(request, timeout=5) as response: with open_no_redirect(request, timeout=5) as response:
response_body = response.read().decode('utf-8') response_body = response.read().decode('utf-8')
logger.debug( logger.debug(
'Mastodon post response: endpoint=%s status=%s body_length=%d', 'Mastodon post response: endpoint=%s status=%s body_length=%d',
@@ -135,10 +139,12 @@ class MastodonPlugin(BasePlugin):
).fetchone() ).fetchone()
if row and row['config']: if row and row['config']:
config.update(json.loads(row['config'])) config.update(json.loads(row['config']))
config['access_token'] = decrypt_secret(config.get('access_token', ''))
instance = str(config.get('instance', '')).strip().rstrip('/') try:
if instance and '://' not in instance: instance = validate_public_instance(str(config.get('instance', '')))
instance = f'https://{instance}' except ValueError as error:
return {'status': 'failed', 'plugin': self.name, 'reason': str(error)}
access_token = str(config.get('access_token', '')).strip() access_token = str(config.get('access_token', '')).strip()
post_ids = event.get('mastodon_post_ids') or [] post_ids = event.get('mastodon_post_ids') or []
if not post_ids and event.get('mastodon_post_id'): if not post_ids and event.get('mastodon_post_id'):
@@ -153,7 +159,7 @@ class MastodonPlugin(BasePlugin):
headers={'Authorization': f'Bearer {access_token}', 'User-Agent': 'LinkLog/1.0'}, headers={'Authorization': f'Bearer {access_token}', 'User-Agent': 'LinkLog/1.0'},
method='DELETE', method='DELETE',
) )
with urlopen(request, timeout=5) as response: with open_no_redirect(request, timeout=5) as response:
response.read() response.read()
return {'status': 'deleted', 'plugin': self.name, 'count': len(post_ids)} return {'status': 'deleted', 'plugin': self.name, 'count': len(post_ids)}
except HTTPError as error: except HTTPError as error:
+32
View File
@@ -0,0 +1,32 @@
## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
from cryptography.fernet import Fernet, InvalidToken
from backend.app.core.config import settings
def _cipher() -> Fernet:
if not settings.data_encryption_key:
raise RuntimeError('LINKLOG_DATA_ENCRYPTION_KEY is required to access encrypted secrets')
try:
return Fernet(settings.data_encryption_key.encode('ascii'))
except (ValueError, UnicodeEncodeError) as error:
raise RuntimeError('LINKLOG_DATA_ENCRYPTION_KEY must be a valid Fernet key') from error
def encrypt_secret(value: str) -> str:
if not value:
return value
if value.startswith('enc:v1:'):
return value
return 'enc:v1:' + _cipher().encrypt(value.encode('utf-8')).decode('ascii')
def decrypt_secret(value: str) -> str:
if not value or not value.startswith('enc:v1:'):
return value
try:
return _cipher().decrypt(value[7:].encode('ascii')).decode('utf-8')
except (InvalidToken, UnicodeEncodeError) as error:
raise RuntimeError('Encrypted secret cannot be decrypted with LINKLOG_DATA_ENCRYPTION_KEY') from error
+1
View File
@@ -6,3 +6,4 @@ python-multipart==0.0.20
pytest==9.1.1 pytest==9.1.1
httpx==0.28.1 httpx==0.28.1
httpx2==2.12.0 httpx2==2.12.0
cryptography==46.0.3
+1
View File
@@ -10,6 +10,7 @@ import pytest
TEST_DATABASE_DIRECTORY = tempfile.TemporaryDirectory(prefix='linklog-tests-') TEST_DATABASE_DIRECTORY = tempfile.TemporaryDirectory(prefix='linklog-tests-')
TEST_DATABASE_PATH = os.path.join(TEST_DATABASE_DIRECTORY.name, 'linklog.db') TEST_DATABASE_PATH = os.path.join(TEST_DATABASE_DIRECTORY.name, 'linklog.db')
os.environ['LINKLOG_DATABASE_PATH'] = TEST_DATABASE_PATH os.environ['LINKLOG_DATABASE_PATH'] = TEST_DATABASE_PATH
os.environ['LINKLOG_DATA_ENCRYPTION_KEY'] = 'L5M4sQYVjD1N7pT2Xk8R0aBcDeFgHiJkLmNoPqRsTuV='
@pytest.fixture(scope='session', autouse=True) @pytest.fixture(scope='session', autouse=True)
+81 -13
View File
@@ -13,6 +13,7 @@ from fastapi.testclient import TestClient
from backend.app.main import app from backend.app.main import app
from backend.app.database import get_connection from backend.app.database import get_connection
from backend.app.services.email_service import get_smtp_settings from backend.app.services.email_service import get_smtp_settings
from backend.app.services.login_throttle import clear_login_failures
from backend.app.services.password_reset import create_reset_token from backend.app.services.password_reset import create_reset_token
from backend.app.services.token_service import issue_token from backend.app.services.token_service import issue_token
@@ -53,6 +54,20 @@ def test_login_returns_token():
assert client.get('/api/auth/me', params={'token': payload['access_token']}).status_code == 401 assert client.get('/api/auth/me', params={'token': payload['access_token']}).status_code == 401
def test_login_rate_limit_locks_out_after_five_failures_and_resets_on_success():
email = f'unknown-{uuid4().hex}@example.com'
for attempt in range(5):
response = client.post('/api/auth/login', json={'email': email, 'password': 'wrong-password'})
assert response.status_code == 401, attempt
locked = client.post('/api/auth/login', json={'email': email, 'password': 'wrong-password'})
assert locked.status_code == 429
assert int(locked.headers['Retry-After']) > 0
clear_login_failures('testclient', email)
valid = client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'})
assert valid.status_code == 200
def test_password_hashes_are_salted_and_legacy_hashes_upgrade_on_login(): def test_password_hashes_are_salted_and_legacy_hashes_upgrade_on_login():
from hashlib import sha256 from hashlib import sha256
from backend.app.database import hash_password from backend.app.database import hash_password
@@ -415,6 +430,57 @@ 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_link_save_reports_plugin_posting_errors():
with patch('backend.app.api.links.plugin_manager.dispatch', return_value=[
{'status': 'failed', 'plugin': 'mastodon', 'reason': 'HTTP 503: unavailable'},
]):
response = client.post('/api/links', headers=login_headers(), json={
'title': 'Plugin error report',
'url': 'https://example.com/plugin-error-report',
})
assert response.status_code == 201
assert response.json()['plugin_errors'] == [{'plugin': 'mastodon', 'reason': 'HTTP 503: unavailable'}]
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={
@@ -597,19 +663,20 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
try: try:
headers = login_headers() headers = login_headers()
base_url = f'http://127.0.0.1:{server.server_port}' base_url = f'http://127.0.0.1:{server.server_port}'
assert client.put('/api/user/plugins/mastodon', headers=headers, json={ with patch('backend.app.services.plugin_manager.validate_public_instance', return_value=base_url):
'instance': base_url, assert client.put('/api/user/plugins/mastodon', headers=headers, json={
'access_token': 'test-token', 'instance': base_url,
'post_prefix': 'From my #LinkLog: ', 'access_token': 'test-token',
}).status_code == 200 'post_prefix': 'From my #LinkLog: ',
assert client.put('/api/admin/plugins/mastodon', headers=headers, json={'enabled': True}).status_code == 200 }).status_code == 200
assert client.put('/api/admin/plugins/mastodon', headers=headers, json={'enabled': True}).status_code == 200
response = client.post('/api/links', headers=headers, json={ response = client.post('/api/links', headers=headers, json={
'title': 'A useful page', 'title': 'A useful page',
'url': 'https://example.com/useful', 'url': 'https://example.com/useful',
'comment': 'Worth sharing', 'comment': 'Worth sharing',
'tags': ['#python', '#web'], 'tags': ['#python', '#web'],
}) })
assert response.status_code == 201 assert response.status_code == 201
assert received['path'] == '/api/v1/statuses' assert received['path'] == '/api/v1/statuses'
@@ -632,7 +699,8 @@ def test_mastodon_post_without_title_omits_source_line():
plugin = MastodonPlugin() plugin = MastodonPlugin()
plugin.initialize({'instance': 'https://mastodon.example', 'access_token': 'test-token'}) plugin.initialize({'instance': 'https://mastodon.example', 'access_token': 'test-token'})
with patch('backend.app.services.plugin_manager.urlopen', return_value=response) as open_url: with patch('backend.app.services.plugin_manager.open_no_redirect', return_value=response) as open_url, \
patch('backend.app.services.plugin_manager.validate_public_instance', return_value='https://mastodon.example'):
result = plugin.handle_event({ result = plugin.handle_event({
'url': 'https://example.com/useful', 'url': 'https://example.com/useful',
'title': '', 'title': '',
+16 -1
View File
@@ -39,4 +39,19 @@ def test_send_test_email_uses_configured_recipient(monkeypatch):
message = smtp.send_message.call_args.args[0] message = smtp.send_message.call_args.args[0]
assert message['To'] == 'admin@example.com' assert message['To'] == 'admin@example.com'
assert message['Subject'] == 'LinkLog SMTP test' assert message['Subject'] == 'LinkLog SMTP test'
def test_smtp_password_is_encrypted_at_rest():
from backend.app.services.email_service import get_smtp_settings, save_smtp_settings
from backend.app.database import get_connection
values = {
'smtp_host': 'smtp.example.com', 'smtp_port': 587, 'smtp_username': 'mailer',
'smtp_password': 'secret', 'smtp_from': 'LinkLog <no-reply@example.com>', 'smtp_use_tls': True,
}
save_smtp_settings(values)
with get_connection() as conn:
stored = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('smtp',)).fetchone()['value']
assert 'secret' not in stored
assert get_smtp_settings()['smtp_password'] == 'secret'
+34
View File
@@ -0,0 +1,34 @@
## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
from unittest.mock import patch
from urllib.error import HTTPError
from urllib.request import Request
import pytest
from backend.app.services.mastodon_security import open_no_redirect, validate_public_instance
@pytest.mark.parametrize('instance', [
'http://mastodon.example',
'https://127.0.0.1',
'https://[::ffff:127.0.0.1]',
'https://user:password@mastodon.example',
])
def test_mastodon_instance_rejects_unsafe_urls(instance):
with pytest.raises(ValueError):
validate_public_instance(instance)
def test_mastodon_instance_rejects_private_dns_result():
with patch('backend.app.services.mastodon_security.socket.getaddrinfo', return_value=[(2, 1, 6, '', ('10.0.0.5', 443))]):
with pytest.raises(ValueError, match='public IP'):
validate_public_instance('https://mastodon.example')
def test_mastodon_outbound_redirects_are_rejected():
request = Request('https://mastodon.example/api/v1/statuses')
with patch('backend.app.services.mastodon_security.NO_REDIRECT_OPENER.open', side_effect=HTTPError(request.full_url, 302, 'Redirects are not allowed', {}, None)):
with pytest.raises(HTTPError, match='Redirects are not allowed'):
open_no_redirect(request)
+1
View File
@@ -16,6 +16,7 @@ services:
LINKLOG_APP_NAME: ${LINKLOG_APP_NAME:-LinkLog} LINKLOG_APP_NAME: ${LINKLOG_APP_NAME:-LinkLog}
LINKLOG_DATABASE_PATH: ${LINKLOG_DATABASE_PATH:-/app/backend/data/linklog.db} LINKLOG_DATABASE_PATH: ${LINKLOG_DATABASE_PATH:-/app/backend/data/linklog.db}
LINKLOG_SECRET_KEY: ${LINKLOG_SECRET_KEY:?Set LINKLOG_SECRET_KEY in .env} LINKLOG_SECRET_KEY: ${LINKLOG_SECRET_KEY:?Set LINKLOG_SECRET_KEY in .env}
LINKLOG_DATA_ENCRYPTION_KEY: ${LINKLOG_DATA_ENCRYPTION_KEY:?Set LINKLOG_DATA_ENCRYPTION_KEY in .env}
LINKLOG_PUBLIC_URL: ${LINKLOG_PUBLIC_URL:-localhost} LINKLOG_PUBLIC_URL: ${LINKLOG_PUBLIC_URL:-localhost}
LINKLOG_LOG_LEVEL: ${LINKLOG_LOG_LEVEL:-INFO} LINKLOG_LOG_LEVEL: ${LINKLOG_LOG_LEVEL:-INFO}
LINKLOG_TOKEN_EXPIRY_DAYS: ${LINKLOG_TOKEN_EXPIRY_DAYS:-30} LINKLOG_TOKEN_EXPIRY_DAYS: ${LINKLOG_TOKEN_EXPIRY_DAYS:-30}
+4 -1
View File
@@ -40,7 +40,10 @@
"configureAndLogIn": {"message": "Konfiguriere die Backend-URL und melde dich zuerst an."}, "configureAndLogIn": {"message": "Konfiguriere die Backend-URL und melde dich zuerst an."},
"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 auf $URL$ gespeichert.", "placeholders": {"url": {"content": "$1"}}},
"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."},
"publishingErrors": {"message": "Fehler bei der Veröffentlichung: $ERRORS$", "placeholders": {"errors": {"content": "$1"}}},
"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"},
+20 -1
View File
@@ -123,7 +123,26 @@
"message": "Submission failed" "message": "Submission failed"
}, },
"linkSaved": { "linkSaved": {
"message": "Link saved successfully" "message": "Link saved to $URL$.",
"placeholders": {
"url": {
"content": "$1"
}
}
},
"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."
},
"publishingErrors": {
"message": "Publishing errors: $ERRORS$",
"placeholders": {
"errors": {
"content": "$1"
}
}
}, },
"submissionFailedConnection": { "submissionFailedConnection": {
"message": "Submission failed. Check your backend connection." "message": "Submission failed. Check your backend connection."
+4 -1
View File
@@ -40,7 +40,10 @@
"configureAndLogIn": {"message": "Configura la URL del servidor e inicia sesión primero."}, "configureAndLogIn": {"message": "Configura la URL del servidor e inicia sesión primero."},
"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 en $URL$.", "placeholders": {"url": {"content": "$1"}}},
"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."},
"publishingErrors": {"message": "Errores de publicación: $ERRORS$", "placeholders": {"errors": {"content": "$1"}}},
"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"},
+4 -1
View File
@@ -40,7 +40,10 @@
"configureAndLogIn": {"message": "Configurez l’URL du serveur et connectez-vous d’abord."}, "configureAndLogIn": {"message": "Configurez l’URL du serveur et connectez-vous d’abord."},
"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é sur $URL$.", "placeholders": {"url": {"content": "$1"}}},
"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."},
"publishingErrors": {"message": "Erreurs de publication : $ERRORS$", "placeholders": {"errors": {"content": "$1"}}},
"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"},
+4 -1
View File
@@ -40,7 +40,10 @@
"configureAndLogIn": {"message": "Configureer de backend-URL en log eerst in."}, "configureAndLogIn": {"message": "Configureer de backend-URL en log eerst in."},
"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 op $URL$.", "placeholders": {"url": {"content": "$1"}}},
"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."},
"publishingErrors": {"message": "Publicatiefouten: $ERRORS$", "placeholders": {"errors": {"content": "$1"}}},
"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"},
+40 -2
View File
@@ -65,6 +65,19 @@ function showSignedInState(user, backendUrl) {
form.classList.remove('hidden'); form.classList.remove('hidden');
} }
function showSavedState(message) {
document.querySelector('header').classList.add('hidden');
document.querySelector('.extension-footer').classList.add('hidden');
authSession.hidden = true;
authSession.classList.add('hidden');
authWarning.hidden = true;
authWarning.classList.add('hidden');
form.classList.add('hidden');
statusEl.textContent = message;
statusEl.classList.remove('hidden', 'error');
statusEl.classList.add('success');
}
async function updateFeedLink() { async function updateFeedLink() {
const settings = await browser.storage.local.get(['backendUrl', 'username', 'accessToken']); const settings = await browser.storage.local.get(['backendUrl', 'username', 'accessToken']);
if (!settings.backendUrl || !settings.username || !settings.accessToken) return; if (!settings.backendUrl || !settings.username || !settings.accessToken) return;
@@ -142,6 +155,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 +221,14 @@ async function handleSubmit(event) {
throw new Error(t('submissionFailed')); throw new Error(t('submissionFailed'));
} }
setStatus(t('linkSaved')); const result = await response.json();
const saveMessage = result.duplicate ? t('linkAlreadyExists') : t('linkSaved', backendUrl);
if (result.plugin_errors?.length) {
const errors = result.plugin_errors.map((error) => `${error.plugin}: ${error.reason}`).join(' ');
showSavedState(`${saveMessage} ${t('publishingErrors', errors)}`);
return;
}
showSavedState(saveMessage);
} catch (error) { } catch (error) {
setStatus(t('submissionFailedConnection'), true); setStatus(t('submissionFailedConnection'), true);
} }
@@ -199,6 +237,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();