This commit is contained in:
+2
-1
@@ -11,7 +11,7 @@ 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_TOKEN_EXPIRY_DAYS=30
|
LINKLOG_TOKEN_EXPIRY_DAYS=30
|
||||||
LINKLOG_PUBLIC_URL=http://localhost:8000
|
LINKLOG_PUBLIC_URL=localhost
|
||||||
LINKLOG_SMTP_HOST=
|
LINKLOG_SMTP_HOST=
|
||||||
LINKLOG_SMTP_PORT=587
|
LINKLOG_SMTP_PORT=587
|
||||||
LINKLOG_SMTP_USERNAME=
|
LINKLOG_SMTP_USERNAME=
|
||||||
@@ -22,6 +22,7 @@ LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS=24
|
|||||||
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS=1
|
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS=1
|
||||||
LINKLOG_MASTODON_CLIENT_NAME=LinkLog
|
LINKLOG_MASTODON_CLIENT_NAME=LinkLog
|
||||||
LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES=10
|
LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES=10
|
||||||
|
LINKLOG_LOG_LEVEL=INFO
|
||||||
# Optional comma-separated override. Leave empty to use the built-in list.
|
# Optional comma-separated override. Leave empty to use the built-in list.
|
||||||
LINKLOG_TRACKING_PARAMS=
|
LINKLOG_TRACKING_PARAMS=
|
||||||
# Keep the default path when using the named linklog_data volume.
|
# Keep the default path when using the named linklog_data volume.
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ The main configurable values are:
|
|||||||
| `LINKLOG_SECRET_KEY` | token signing/security secret | required in Docker |
|
| `LINKLOG_SECRET_KEY` | token signing/security secret | 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` | Base URL used in email verification links | `http://localhost:8000` |
|
| `LINKLOG_PUBLIC_URL` | Public hostname used by Traefik and expanded to a callback URL by the backend | `localhost` |
|
||||||
| `LINKLOG_SMTP_HOST` | SMTP server hostname; empty disables delivery in local development | empty |
|
| `LINKLOG_SMTP_HOST` | SMTP server hostname; empty disables delivery in local development | empty |
|
||||||
| `LINKLOG_SMTP_PORT` | SMTP server port | `587` |
|
| `LINKLOG_SMTP_PORT` | SMTP server port | `587` |
|
||||||
| `LINKLOG_SMTP_USERNAME` | SMTP login username | empty |
|
| `LINKLOG_SMTP_USERNAME` | SMTP login username | empty |
|
||||||
@@ -185,7 +185,6 @@ The main configurable values are:
|
|||||||
| `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` | Verification-link lifetime | `24` |
|
| `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` | Verification-link lifetime | `24` |
|
||||||
| `LINKLOG_PASSWORD_RESET_EXPIRY_HOURS` | Password-reset-link lifetime | `1` |
|
| `LINKLOG_PASSWORD_RESET_EXPIRY_HOURS` | Password-reset-link lifetime | `1` |
|
||||||
| `LINKLOG_TRACKING_PARAMS` | comma-separated tracking parameters (stripped from logged URLs) | built-in list |
|
| `LINKLOG_TRACKING_PARAMS` | comma-separated tracking parameters (stripped from logged URLs) | built-in list |
|
||||||
| `TRAEFIK_HOST` | hostname routed by Traefik | `localhost` |
|
|
||||||
| `APP_PORT` | direct host port for FastAPI | `8000` |
|
| `APP_PORT` | direct host port for FastAPI | `8000` |
|
||||||
|
|
||||||
New users created by an administrator are email-unverified and cannot sign in until they follow the verification link sent to their address. The link is valid for `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` hours and is handled by `/api/auth/verify-email`. Configure `LINKLOG_SMTP_HOST`, `LINKLOG_SMTP_FROM`, and the SMTP credentials for delivery; local development may leave the SMTP host empty, in which case accounts remain pending verification and no message is sent.
|
New users created by an administrator are email-unverified and cannot sign in until they follow the verification link sent to their address. The link is valid for `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` hours and is handled by `/api/auth/verify-email`. Configure `LINKLOG_SMTP_HOST`, `LINKLOG_SMTP_FROM`, and the SMTP credentials for delivery; local development may leave the SMTP host empty, in which case accounts remain pending verification and no message is sent.
|
||||||
@@ -231,7 +230,7 @@ After logging in, open <http://localhost:8000/profile>, enter the Mastodon insta
|
|||||||
- **Instance**: hostname or URL such as `mastodon.social` or `https://mastodon.social`
|
- **Instance**: hostname or URL such as `mastodon.social` or `https://mastodon.social`
|
||||||
- **Post prefix**: text placed immediately before the link; defaults to `From my #LinkLog: `
|
- **Post prefix**: text placed immediately before the link; defaults to `From my #LinkLog: `
|
||||||
|
|
||||||
`LINKLOG_PUBLIC_URL` must be the URL users can reach for the OAuth callback. For local development it can remain `http://localhost:8000`; for a deployment it must be the public LinkLog URL. Existing manually entered access tokens remain compatible with the plugin configuration API.
|
`LINKLOG_PUBLIC_URL` is the public hostname used by Traefik and the backend. Use `localhost` for local development or a hostname such as `linklog.kolkman.org` for a deployment. The backend adds `http://` for localhost and `https://` for other hostnames when constructing OAuth and email links. Existing manually entered access tokens remain compatible with the plugin configuration API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -511,3 +511,120 @@ Add the creation of an xpi bundle for the webextention to the Makefile
|
|||||||
|
|
||||||
### Assistant outcome
|
### Assistant outcome
|
||||||
Added a portable `xpi` Makefile target that regenerates logo assets and packages the Firefox extension into `LinkLog.xpi`, excluding metadata and minified artifacts. Added README instructions, ignored the generated XPI, and verified the archive contents and integrity with `unzip -tq`.
|
Added a portable `xpi` Makefile target that regenerates logo assets and packages the Firefox extension into `LinkLog.xpi`, excluding metadata and minified artifacts. Added README instructions, ignored the generated XPI, and verified the archive contents and integrity with `unzip -tq`.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Correct: A signed XPI will be generated manually and checked into the repository under XPI/signed
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Defined the release workflow around a manually checked-in signed XPI and added version/update-manifest validation, Docker image publishing, and Gitea release asset handling.
|
||||||
|
|
||||||
|
### User
|
||||||
|
The "data_collection_permissions" property is missing from the plugin
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Added Firefox `data_collection_permissions` requiring `websiteActivity`, with no optional categories, and enforced the declaration in release validation.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Validate the XPI during the make process of the unsigned xpi
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Added unsigned-XPI validation to the Makefile. The validator checks ZIP integrity, required files, macOS metadata, and exact manifest parity.
|
||||||
|
|
||||||
|
### User
|
||||||
|
pressing remove still does not remove a user.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Fixed admin user removal behavior, added server-side self-protection, improved browser error reporting, and updated the admin cache-busting version.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Trying to remove user alice: Could not remove alice: Internal Server Error
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Found the SQLite foreign-key failure caused by Alice-owned tokens, links, and plugin configuration. User deletion now removes dependent records transactionally before deleting the user. Full validation passed.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Create a Gitea Action for every update to main that creates a Docker image tagged development
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Added `.gitea/workflows/development.yml`, publishing `git.kolkman.org/olaf/link-log:development` on every push to `main`, with manual dispatch support.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Use an access token to publish to registry, not username and password
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Changed release and development workflows to use `REGISTRY_TOKEN` as the Docker registry credential while retaining `REGISTRY_USERNAME` as the login identity.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Add SMTP capabilities and email validation links
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Added SMTP delivery, email verification tokens, verification-link handling, and environment configuration for SMTP.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Do not configure default users at bootstrap. Present a configuration page when no administrator exists.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Removed default-user bootstrap, added a first-run setup page, persisted SMTP settings, created the first administrator through setup, and added an SMTP test-mail step.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Separate saving configuration from sending test mail, allow five sends with 20-second intervals, then hold for two minutes with a visual timer.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Split setup into Save configuration, Send test mail, and Complete setup actions. Added persistent server-side throttling, reload-safe countdown state, a five-send limit, and a two-minute cooldown.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Use `.env` defaults during initial configuration.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
The setup status endpoint now exposes effective environment SMTP defaults, and the setup form populates blank fields from them. Persisted setup values take precedence after saving.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Use OAuth to register with the Mastodon account and obtain access to post.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Added Mastodon OAuth app registration, authorization and callback endpoints, one-time OAuth state storage, and per-user access-token persistence.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Allow the user to enter the Mastodon server to authenticate to.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
The profile now requires an explicit Mastodon server, saves that server before OAuth, and authenticates against the selected instance.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Clicking authenticate with this server for social.secret-wg.org generates a 502 error.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Confirmed the Mastodon instance was returning HTTP 429 during app registration. OAuth app credentials are now cached per instance, and upstream rate-limit responses are passed through with retry information instead of being reported as generic 502 errors.
|
||||||
|
|
||||||
|
### User
|
||||||
|
When a password is mistyped, send a password reset link.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Added hashed, expiring, single-use password-reset tokens, SMTP reset mail, a reset-password page, generic login errors, and session revocation after reset.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Log the unlogged chat and prompt in the VIBE directory.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Appended the previously unlogged feature work and this request to `VIBE/PROMPTS.md` and `VIBE/CHAT_LOG.md`.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Continue to log prompts and chats in VIBE
|
||||||
|
|
||||||
|
### User
|
||||||
|
Use the VIBE directory in the current workspace.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Confirmed the current workspace is `/home/olaf/LinkLog_Dev` and appended both requests and this outcome to the VIBE logs located at `VIBE/PROMPTS.md` and `VIBE/CHAT_LOG.md`.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Posting to Mastodon seems to fail, try to fix, add debug logging.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Changed Mastodon status publishing to form-encoded API requests, added safe configurable logging for skipped, successful, and failed posts, included upstream HTTP response details without logging access tokens, and wired `LINKLOG_LOG_LEVEL` through the environment and Compose configuration. Updated the posting regression test. The focused test and complete Dockerized backend suite passed with 22 tests.
|
||||||
|
|
||||||
|
### User
|
||||||
|
`LINKLOG_PUBLIC_URL` and `TRAEFIK_HOST` are the same and can be merged. Use `LINKLOG_PUBLIC_URL` in Docker Compose.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Removed the duplicate `TRAEFIK_HOST` configuration, changed both Traefik router rules to use `LINKLOG_PUBLIC_URL`, standardized the value as a host name, and normalized scheme-less values to valid HTTP/HTTPS backend callback URLs. Compose validation, URL normalization, and the complete Dockerized backend suite passed with 22 tests.
|
||||||
|
|||||||
@@ -88,6 +88,33 @@
|
|||||||
84. Add the version to the name of the resulting xpi
|
84. Add the version to the name of the resulting xpi
|
||||||
85. Set the version number of the backend to 0.1.0
|
85. Set the version number of the backend to 0.1.0
|
||||||
84. Add the creation of an xpi bundle for the webextention to the Makefile
|
84. Add the creation of an xpi bundle for the webextention to the Makefile
|
||||||
|
85. Correct: A signed XPI will be generated manually and checked into the repository under XPI/signed
|
||||||
|
86. The "data_collection_permissions" property is missing from the plugin
|
||||||
|
87. Validate the XPI during the make process of the unsigned xpi
|
||||||
|
88. pressing remove still does not remove a user.
|
||||||
|
89. Trying to remove user alice: Could not remove alice: Internal Server Error
|
||||||
|
90. create a gitea action that is ran every time main is updated and that creates a docker image tagged development
|
||||||
|
91. Use an access token to publish to registry, not username and password
|
||||||
|
92. Add SMTP capabilities to the backend. Use it to validate the email addresses using a validation link in mail
|
||||||
|
93. Do not configure default users at bootstrap. Instead present a configuration page (only present if no administrator is configured). The configuration page asks for the admin users credetials and allows to configure the SMTP settings and sends a test mail after configuration
|
||||||
|
94. After saving the configuration - add link to home page together with "LinkLog is configured and the SMTP test mail was sent."
|
||||||
|
95. In the confiuration seperate the safe and send mail functionality. Allow the user to resend test mail 5 times with a 20 seconds interval and then hold back for 2 minutes - show a visual timer counting down.
|
||||||
|
96. when running the initial config use the defaults from the .env file when available
|
||||||
|
97. Use oauth to register with the mastodon account and obtain access to post
|
||||||
|
98. Allow user to enter the mastodon server to authenticate to
|
||||||
|
99. Clicking authenticate with this server (social.secret-wg.org) generates 502 error
|
||||||
|
100. Log the unlogged chat and promt in the VIBE directory
|
||||||
|
101. when password is mistyped send a password reset link
|
||||||
|
102. when running the initial config use the defaults from the .env file when available
|
||||||
|
103. After saving the configuration - add link to home page together with "LinkLog is configured and the SMTP test mail was sent."
|
||||||
|
104. In the confiuration seperate the safe and send mail functionality. Allow the user to resend test mail 5 times with a 20 seconds interval and then hold back for 2 minutes - show a visual timer counting down.
|
||||||
|
105. Use oauth to register with the mastodon account and obtain access to post
|
||||||
|
106. Allow user to enter the mastodon server to authenticate to
|
||||||
|
107. Log the unlogged chat and promt in the VIBE directory
|
||||||
|
108. Continue to log prompts and chats in VIBE
|
||||||
|
109. use VIVE that is in the current directory
|
||||||
|
110. Posting to mastodon seems to fail, try to fix, add debug logging
|
||||||
|
111. LINKLOG_PUBLIC_URL and TRAEFIK_HOST are the same and can be merged. (use LINKLOG_PUBLIC_URL), fix docker-compose to use said variable
|
||||||
|
|
||||||
## Future entries
|
## Future entries
|
||||||
|
|
||||||
|
|||||||
+682583
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@
|
|||||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException, status
|
from fastapi import APIRouter, Header, HTTPException, status
|
||||||
|
import logging
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from backend.app.services.link_service import create_link, list_public_links, list_tags, update_link
|
from backend.app.services.link_service import create_link, list_public_links, list_tags, update_link
|
||||||
@@ -9,6 +10,7 @@ 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
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class LinkCreate(BaseModel):
|
class LinkCreate(BaseModel):
|
||||||
@@ -44,7 +46,9 @@ def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header
|
|||||||
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
|
||||||
plugin_manager.dispatch({'type': 'link_created', **record})
|
plugin_results = plugin_manager.dispatch({'type': 'link_created', **record})
|
||||||
|
if any(result.get('status') == 'failed' for result in plugin_results):
|
||||||
|
logger.warning('One or more plugins failed for link_id=%s results=%s', record['id'], plugin_results)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
|||||||
DB_PATH = BASE_DIR / 'data' / 'linklog.db'
|
DB_PATH = BASE_DIR / 'data' / 'linklog.db'
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_public_url(value: str) -> str:
|
||||||
|
value = value.strip().rstrip('/')
|
||||||
|
if '://' in value:
|
||||||
|
return value
|
||||||
|
scheme = 'http' if value.startswith(('localhost', '127.0.0.1')) else 'https'
|
||||||
|
return f'{scheme}://{value}'
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Settings:
|
class Settings:
|
||||||
app_name: str = os.getenv('LINKLOG_APP_NAME', 'LinkLog')
|
app_name: str = os.getenv('LINKLOG_APP_NAME', 'LinkLog')
|
||||||
@@ -17,7 +25,7 @@ class Settings:
|
|||||||
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')
|
||||||
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 = os.getenv('LINKLOG_PUBLIC_URL', 'http://localhost:8000').rstrip('/')
|
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', '')
|
||||||
smtp_port: int = int(os.getenv('LINKLOG_SMTP_PORT', '587'))
|
smtp_port: int = int(os.getenv('LINKLOG_SMTP_PORT', '587'))
|
||||||
smtp_username: str = os.getenv('LINKLOG_SMTP_USERNAME', '')
|
smtp_username: str = os.getenv('LINKLOG_SMTP_USERNAME', '')
|
||||||
@@ -28,6 +36,7 @@ class Settings:
|
|||||||
password_reset_expiry_hours: int = int(os.getenv('LINKLOG_PASSWORD_RESET_EXPIRY_HOURS', '1'))
|
password_reset_expiry_hours: int = int(os.getenv('LINKLOG_PASSWORD_RESET_EXPIRY_HOURS', '1'))
|
||||||
mastodon_client_name: str = os.getenv('LINKLOG_MASTODON_CLIENT_NAME', 'LinkLog')
|
mastodon_client_name: str = os.getenv('LINKLOG_MASTODON_CLIENT_NAME', 'LinkLog')
|
||||||
mastodon_oauth_expiry_minutes: int = int(os.getenv('LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES', '10'))
|
mastodon_oauth_expiry_minutes: int = int(os.getenv('LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES', '10'))
|
||||||
|
log_level: str = os.getenv('LINKLOG_LOG_LEVEL', 'INFO').upper()
|
||||||
tracking_params: list[str] = None
|
tracking_params: list[str] = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
import logging
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -20,6 +21,8 @@ from backend.app.core.config import settings
|
|||||||
from backend.app.database import AVATARS_DIR
|
from backend.app.database import AVATARS_DIR
|
||||||
from backend.app.services.link_service import list_public_links
|
from backend.app.services.link_service import list_public_links
|
||||||
|
|
||||||
|
logging.basicConfig(level=getattr(logging, settings.log_level, logging.INFO))
|
||||||
|
|
||||||
app = FastAPI(title='LinkLog API', version=settings.version)
|
app = FastAPI(title='LinkLog API', version=settings.version)
|
||||||
app.mount('/static', StaticFiles(directory='frontend/static'), name='static')
|
app.mount('/static', StaticFiles(directory='frontend/static'), name='static')
|
||||||
app.mount('/media', StaticFiles(directory=AVATARS_DIR), name='media')
|
app.mount('/media', StaticFiles(directory=AVATARS_DIR), name='media')
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import urlencode
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from backend.app.plugins.base import BasePlugin
|
from backend.app.plugins.base import BasePlugin
|
||||||
|
|
||||||
DEFAULT_POST_PREFIX = 'From my #LinkLog: '
|
DEFAULT_POST_PREFIX = 'From my #LinkLog: '
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class DefaultFrontendPlugin(BasePlugin):
|
class DefaultFrontendPlugin(BasePlugin):
|
||||||
@@ -50,6 +53,10 @@ class MastodonPlugin(BasePlugin):
|
|||||||
instance = f'https://{instance}'
|
instance = f'https://{instance}'
|
||||||
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(
|
||||||
|
'Mastodon post skipped: instance_configured=%s token_configured=%s user_id=%s',
|
||||||
|
bool(instance), bool(access_token), user_id,
|
||||||
|
)
|
||||||
return {'status': 'skipped', 'plugin': self.name, 'reason': 'not_configured'}
|
return {'status': 'skipped', 'plugin': self.name, 'reason': 'not_configured'}
|
||||||
|
|
||||||
status_parts = [event.get('title') or event.get('url', '')]
|
status_parts = [event.get('title') or event.get('url', '')]
|
||||||
@@ -63,25 +70,51 @@ class MastodonPlugin(BasePlugin):
|
|||||||
if event.get('tags'):
|
if event.get('tags'):
|
||||||
status = f'{status} {" ".join(event["tags"])}'
|
status = f'{status} {" ".join(event["tags"])}'
|
||||||
status_parts.append(status)
|
status_parts.append(status)
|
||||||
|
post_body = '\n'.join(status_parts)
|
||||||
|
endpoint = f'{instance}/api/v1/statuses'
|
||||||
|
logger.debug(
|
||||||
|
'Posting link to Mastodon: endpoint=%s user_id=%s event_id=%s body_length=%d',
|
||||||
|
endpoint, user_id, event.get('id'), len(post_body),
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request = Request(
|
request = Request(
|
||||||
f'{instance}/api/v1/statuses',
|
endpoint,
|
||||||
data=json.dumps({'status': '\n'.join(status_parts)}).encode('utf-8'),
|
data=urlencode({'status': post_body}).encode('utf-8'),
|
||||||
headers={
|
headers={
|
||||||
'Authorization': f'Bearer {access_token}',
|
'Authorization': f'Bearer {access_token}',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'User-Agent': 'LinkLog/1.0',
|
||||||
},
|
},
|
||||||
method='POST',
|
method='POST',
|
||||||
)
|
)
|
||||||
with urlopen(request, timeout=5) as response:
|
with urlopen(request, timeout=5) as response:
|
||||||
response_data = json.loads(response.read().decode('utf-8'))
|
response_body = response.read().decode('utf-8')
|
||||||
|
logger.debug(
|
||||||
|
'Mastodon post response: endpoint=%s status=%s body_length=%d',
|
||||||
|
endpoint, response.status, len(response_body),
|
||||||
|
)
|
||||||
|
response_data = json.loads(response_body)
|
||||||
|
logger.info('Mastodon post succeeded: instance=%s user_id=%s post_id=%s', instance, user_id, response_data.get('id'))
|
||||||
return {
|
return {
|
||||||
'status': 'posted',
|
'status': 'posted',
|
||||||
'plugin': self.name,
|
'plugin': self.name,
|
||||||
'post_id': response_data.get('id'),
|
'post_id': response_data.get('id'),
|
||||||
}
|
}
|
||||||
except (HTTPError, URLError, TimeoutError, OSError, ValueError) as error:
|
except HTTPError as error:
|
||||||
|
response_body = error.read().decode('utf-8', errors='replace')
|
||||||
|
logger.warning(
|
||||||
|
'Mastodon post failed: endpoint=%s user_id=%s status=%s response=%s',
|
||||||
|
endpoint, user_id, error.code, response_body[:500],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'status': 'failed',
|
||||||
|
'plugin': self.name,
|
||||||
|
'reason': f'HTTP {error.code}: {response_body[:500]}',
|
||||||
|
}
|
||||||
|
except (URLError, TimeoutError, OSError, ValueError) as error:
|
||||||
|
logger.exception('Mastodon post failed: endpoint=%s user_id=%s error=%s', endpoint, user_id, error)
|
||||||
return {
|
return {
|
||||||
'status': 'failed',
|
'status': 'failed',
|
||||||
'plugin': self.name,
|
'plugin': self.name,
|
||||||
@@ -111,7 +144,9 @@ class PluginManager:
|
|||||||
results = []
|
results = []
|
||||||
for plugin in self.plugins:
|
for plugin in self.plugins:
|
||||||
if plugin.enabled:
|
if plugin.enabled:
|
||||||
results.append(plugin.handle_event(event))
|
result = plugin.handle_event(event)
|
||||||
|
results.append(result)
|
||||||
|
logger.debug('Plugin dispatch result: plugin=%s event_id=%s result=%s', plugin.name, event.get('id'), result)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import parse_qs
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -410,7 +411,8 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
|
|||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
received['path'] = self.path
|
received['path'] = self.path
|
||||||
received['authorization'] = self.headers['Authorization']
|
received['authorization'] = self.headers['Authorization']
|
||||||
received['body'] = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
|
received['content_type'] = self.headers['Content-Type']
|
||||||
|
received['body'] = parse_qs(self.rfile.read(int(self.headers['Content-Length'])).decode())
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header('Content-Type', 'application/json')
|
self.send_header('Content-Type', 'application/json')
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
@@ -442,9 +444,8 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
|
|||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
assert received['path'] == '/api/v1/statuses'
|
assert received['path'] == '/api/v1/statuses'
|
||||||
assert received['authorization'] == 'Bearer test-token'
|
assert received['authorization'] == 'Bearer test-token'
|
||||||
assert received['body'] == {
|
assert received['content_type'] == 'application/x-www-form-urlencoded'
|
||||||
'status': 'A useful page\nWorth sharing\nFrom my #LinkLog: https://example.com/useful #python #web',
|
assert received['body'] == {'status': ['A useful page\nWorth sharing\nFrom my #LinkLog: https://example.com/useful #python #web']}
|
||||||
}
|
|
||||||
finally:
|
finally:
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
thread.join()
|
thread.join()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ services:
|
|||||||
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_TOKEN_EXPIRY_DAYS: ${LINKLOG_TOKEN_EXPIRY_DAYS:-30}
|
LINKLOG_TOKEN_EXPIRY_DAYS: ${LINKLOG_TOKEN_EXPIRY_DAYS:-30}
|
||||||
LINKLOG_PUBLIC_URL: ${LINKLOG_PUBLIC_URL:-https://linklog.example.com}
|
LINKLOG_PUBLIC_URL: ${LINKLOG_PUBLIC_URL:-linklog.example.com}
|
||||||
LINKLOG_SMTP_HOST: ${LINKLOG_SMTP_HOST:-smtp.example.com}
|
LINKLOG_SMTP_HOST: ${LINKLOG_SMTP_HOST:-smtp.example.com}
|
||||||
LINKLOG_SMTP_PORT: ${LINKLOG_SMTP_PORT:-587}
|
LINKLOG_SMTP_PORT: ${LINKLOG_SMTP_PORT:-587}
|
||||||
LINKLOG_SMTP_USERNAME: ${LINKLOG_SMTP_USERNAME:?Set LINKLOG_SMTP_USERNAME in .env}
|
LINKLOG_SMTP_USERNAME: ${LINKLOG_SMTP_USERNAME:?Set LINKLOG_SMTP_USERNAME in .env}
|
||||||
@@ -25,6 +25,7 @@ services:
|
|||||||
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1}
|
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1}
|
||||||
LINKLOG_MASTODON_CLIENT_NAME: ${LINKLOG_MASTODON_CLIENT_NAME:-LinkLog}
|
LINKLOG_MASTODON_CLIENT_NAME: ${LINKLOG_MASTODON_CLIENT_NAME:-LinkLog}
|
||||||
LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES: ${LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES:-10}
|
LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES: ${LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES:-10}
|
||||||
|
LINKLOG_LOG_LEVEL: ${LINKLOG_LOG_LEVEL:-INFO}
|
||||||
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
|
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
|
||||||
restart: ${APP_RESTART_POLICY:-unless-stopped}
|
restart: ${APP_RESTART_POLICY:-unless-stopped}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -41,10 +42,10 @@ services:
|
|||||||
|
|
||||||
|
|
||||||
traefik.http.routers.linklog.entrypoints: web
|
traefik.http.routers.linklog.entrypoints: web
|
||||||
traefik.http.routers.linklog.rule: Host(`linklog.example.com`) # Make sure to change
|
traefik.http.routers.linklog.rule: Host(`${LINKLOG_PUBLIC_URL:-linklog.example.com}`) # Make sure to change
|
||||||
traefik.http.routers.linklog.middlewares: web-https-redirect,servicests # these middlewares must exist
|
traefik.http.routers.linklog.middlewares: web-https-redirect,servicests # these middlewares must exist
|
||||||
traefik.http.routers.linklog-secure.entrypoints: websecure
|
traefik.http.routers.linklog-secure.entrypoints: websecure
|
||||||
traefik.http.routers.linklog-secure.rule: Host(`linklog.example.com`) #Make sure to change
|
traefik.http.routers.linklog-secure.rule: Host(`${LINKLOG_PUBLIC_URL:-linklog.example.com}`) #Make sure to change
|
||||||
traefik.http.routers.linklog-secure.tls: true
|
traefik.http.routers.linklog-secure.tls: true
|
||||||
traefik.http.routers.linklog-secure.middlewares: servicests
|
traefik.http.routers.linklog-secure.middlewares: servicests
|
||||||
|
|
||||||
|
|||||||
+32
-16
@@ -10,24 +10,15 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-8000}:8000"
|
- "${APP_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- linklog_data:/app/backend/data
|
- ./linklog_data:/app/backend/data
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: ${APP_ENV:-production}
|
APP_ENV: ${APP_ENV:-production}
|
||||||
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_PUBLIC_URL: ${LINKLOG_PUBLIC_URL:-localhost}
|
||||||
|
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}
|
||||||
LINKLOG_PUBLIC_URL: ${LINKLOG_PUBLIC_URL:-http://localhost:8000}
|
|
||||||
LINKLOG_SMTP_HOST: ${LINKLOG_SMTP_HOST:-}
|
|
||||||
LINKLOG_SMTP_PORT: ${LINKLOG_SMTP_PORT:-587}
|
|
||||||
LINKLOG_SMTP_USERNAME: ${LINKLOG_SMTP_USERNAME:-}
|
|
||||||
LINKLOG_SMTP_PASSWORD: ${LINKLOG_SMTP_PASSWORD:-}
|
|
||||||
LINKLOG_SMTP_FROM: ${LINKLOG_SMTP_FROM:-LinkLog <no-reply@localhost>}
|
|
||||||
LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true}
|
|
||||||
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24}
|
|
||||||
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1}
|
|
||||||
LINKLOG_MASTODON_CLIENT_NAME: ${LINKLOG_MASTODON_CLIENT_NAME:-LinkLog}
|
|
||||||
LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES: ${LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES:-10}
|
|
||||||
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
|
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
|
||||||
restart: ${APP_RESTART_POLICY:-unless-stopped}
|
restart: ${APP_RESTART_POLICY:-unless-stopped}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -36,7 +27,32 @@ services:
|
|||||||
timeout: ${APP_HEALTHCHECK_TIMEOUT:-5s}
|
timeout: ${APP_HEALTHCHECK_TIMEOUT:-5s}
|
||||||
start_period: ${APP_HEALTHCHECK_START_PERIOD:-10s}
|
start_period: ${APP_HEALTHCHECK_START_PERIOD:-10s}
|
||||||
retries: ${APP_HEALTHCHECK_RETRIES:-3}
|
retries: ${APP_HEALTHCHECK_RETRIES:-3}
|
||||||
|
labels:
|
||||||
|
traefik.enable: true
|
||||||
volumes:
|
traefik.http.middlewares.web-https-redirect.redirectscheme.scheme: https
|
||||||
linklog_data:
|
traefik.http.services.linklog.loadbalancer.server.port: 8000
|
||||||
|
traefik.docker.network: git_traefik
|
||||||
|
|
||||||
|
|
||||||
|
traefik.http.routers.linklog.entrypoints: web
|
||||||
|
traefik.http.routers.linklog.rule: Host(`${LINKLOG_PUBLIC_URL:-localhost}`)
|
||||||
|
traefik.http.routers.linklog.middlewares: web-https-redirect,servicests
|
||||||
|
traefik.http.routers.linklog-secure.entrypoints: websecure
|
||||||
|
traefik.http.routers.linklog-secure.rule: Host(`${LINKLOG_PUBLIC_URL:-localhost}`)
|
||||||
|
traefik.http.routers.linklog-secure.tls: true
|
||||||
|
traefik.http.routers.linklog-secure.middlewares: servicests
|
||||||
|
|
||||||
|
|
||||||
|
traefik.http.routers.linklog-secure.tls.certresolver: myresolver
|
||||||
|
traefik.http.routers.linklog-secure.service: linklog
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- linklog_traefik
|
||||||
|
|
||||||
|
networks:
|
||||||
|
linklog_traefik:
|
||||||
|
external: true
|
||||||
|
name: linklog_traefik
|
||||||
|
|||||||
Reference in New Issue
Block a user