Initial LinkLog implementation

This commit is contained in:
Olaf
2026-08-24 14:30:30 +02:00
commit 1c827956c4
50 changed files with 3436 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.gitignore
.venv
.pytest_cache
__pycache__
*.py[cod]
*.db
*.sqlite
*.sqlite3
.DS_Store
VIBE
+23
View File
@@ -0,0 +1,23 @@
# LinkLog application
APP_ENV=production
APP_CONTAINER_NAME=linklog-app
APP_PORT=8000
APP_RESTART_POLICY=unless-stopped
LINKLOG_APP_NAME=LinkLog
LINKLOG_SECRET_KEY=replace-with-a-long-random-secret
LINKLOG_TOKEN_EXPIRY_DAYS=30
# Optional comma-separated override. Leave empty to use the built-in list.
LINKLOG_TRACKING_PARAMS=
# Keep the default path when using the named linklog_data volume.
LINKLOG_DATABASE_PATH=/app/backend/data/linklog.db
# Traefik
TRAEFIK_IMAGE=traefik:v3.1
TRAEFIK_CONTAINER_NAME=linklog-traefik
TRAEFIK_HOST=localhost
TRAEFIK_HTTP_PORT=80
TRAEFIK_HTTP_INTERNAL_PORT=80
TRAEFIK_DASHBOARD_PORT=8080
TRAEFIK_API_INSECURE=true
TRAEFIK_RESTART_POLICY=unless-stopped
DOCKER_SOCKET_PATH=/var/run/docker.sock
+16
View File
@@ -0,0 +1,16 @@
__pycache__/
*.py[cod]
*$py.class
*.pyd
*.so
*-min.*
.venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.env
.DS_Store
*.db
*.sqlite
*.sqlite3
*.log
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY backend/requirements.txt ./backend/requirements.txt
RUN pip install --no-cache-dir -r backend/requirements.txt
COPY backend ./backend
COPY frontend ./frontend
EXPOSE 8000
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+216
View File
@@ -0,0 +1,216 @@
# LinkLog
LinkLog is a Firefox extension and Python web service for saving links with a title, comment, timestamp, and tracking parameters removed. The service stores links in SQLite and can publish them through plugins, including Mastodon.
## Project Layout
```text
backend/ FastAPI application, services, database, and tests
frontend/ Jinja templates and browser-side assets
webextension/ Firefox Manifest V3 extension
Dockerfile Backend container image
docker-compose.yml App plus Traefik for local proxying
REQUIREMENTS.md Product requirements
VIBE/ Conversation and prompt logs
```
## Requirements
For local development:
- macOS, Linux, or Windows with Python 3.11+
- Firefox for installing the extension
- Docker Desktop and Docker Compose v2 for the container workflow
The backend currently uses FastAPI, uvicorn, SQLite, and Pydantic. `httpx2` is included for the Starlette-compatible test client.
Jinja2 is included for server-rendered HTML templates.
## Local Installation
From the repository root:
```sh
python3.11 -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r backend/requirements.txt
```
On Windows PowerShell, activate the environment with:
```powershell
.venv\Scripts\Activate.ps1
```
The SQLite database is created automatically at `backend/data/linklog.db` when the application starts or when the auth router is imported. The starter accounts are:
| Username | Password | Role |
| --- | --- | --- |
| `alice` | `secret123` | administrator |
| `bob` | `secret123` | standard user |
These credentials are for development only. Change the authentication and seeding design before deploying publicly.
## Run The Backend
Activate the virtual environment, then run uvicorn from the repository root:
```sh
. .venv/bin/activate
PYTHONPATH=. uvicorn backend.app.main:app --reload --host 127.0.0.1 --port 8000
```
Open these URLs:
- Public feed: <http://localhost:8000/>
- User feed: <http://localhost:8000/alice>
- Profile settings: <http://localhost:8000/profile>
- Admin page: <http://localhost:8000/admin>
- Web login: <http://localhost:8000/login>
- Health check: <http://localhost:8000/health>
- OpenAPI documentation: <http://localhost:8000/docs>
The browser extension defaults to `http://localhost:8000`.
Appending a username to the root URL, such as `/alice`, opens that user's public feed and profile information.
Configuration APIs require a bearer token returned by the login endpoint. User configuration uses the identity in that token. Plugin administration additionally requires an administrator account; the development `alice` account is seeded as an administrator, while `bob` is a standard user.
On the profile page, the authenticated username is displayed as read-only. The profile form prepopulates the default avatar URL when it has not been customized. Bio and email fields remain empty until the user provides values. Mastodon settings default to the `mastodon.social` instance and the `From my #LinkLog: "` post prefix.
## Run Tests
```sh
. .venv/bin/activate
PYTHONPATH=. pytest backend/tests -q
```
To ensure warnings are clean as well:
```sh
PYTHONPATH=. PYTHONWARNINGS=error pytest backend/tests -q
```
## Install The Firefox Extension For Development
The extension is an unpacked Firefox extension. No build step is required.
1. Start the backend locally.
2. Open Firefox and visit `about:debugging#/runtime/this-firefox`.
3. Select **Load Temporary Add-on**.
4. Choose `webextension/manifest.json`.
5. Open the LinkLog extension options and enter:
- Backend URL: `http://localhost:8000`
- Username: `alice`
- Password: `secret123`
6. Save the settings and login.
7. Open a webpage, select the LinkLog toolbar button, review the title and URL, add a comment, and submit it.
Temporary extensions are removed when Firefox restarts. Reload the extension from `about:debugging` after changing its files.
## Docker And Traefik
Create the Docker environment file before starting the stack:
```sh
cp .env.example .env
```
Edit `.env` and replace `LINKLOG_SECRET_KEY` with a long random value. Docker Compose automatically reads `.env` from the repository root. The committed `.env.example` contains safe defaults and placeholders; the real `.env` is ignored by Git.
The main configurable values are:
| Variable | Purpose | Default |
| --- | --- | --- |
| `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_TOKEN_EXPIRY_DAYS` | access-token lifetime | `30` |
| `LINKLOG_TRACKING_PARAMS` | comma-separated tracking parameters | built-in list |
| `TRAEFIK_HOST` | hostname routed by Traefik | `localhost` |
| `TRAEFIK_HTTP_PORT` | host port for the application proxy | `80` |
| `TRAEFIK_DASHBOARD_PORT` | host port for the dashboard | `8080` |
| `TRAEFIK_API_INSECURE` | enable the local dashboard API | `true` |
| `APP_PORT` | direct host port for FastAPI | `8000` |
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.
Build and start the application and local Traefik proxy:
```sh
docker compose up --build
```
The services are available at:
- LinkLog through Traefik: <http://localhost/>
- Direct application port: <http://localhost:8000/>
- Traefik dashboard: <http://localhost:8080/dashboard/>
The Compose configuration routes the hostname `localhost` through Traefik. The SQLite database is stored in the named Docker volume `linklog_data`, mounted at `/app/backend/data`.
Stop the stack without deleting its database:
```sh
docker compose down
```
Stop the stack and delete the named database volume:
```sh
docker compose down -v
```
For a real deployment, replace the `localhost` router rule, configure TLS, protect the Traefik dashboard, avoid exposing the direct application port, and provide production secrets and authentication. The included Compose file is a local/prototype deployment scaffold, not a production security configuration.
## Mastodon Configuration
After logging in, open <http://localhost:8000/profile> and save the Mastodon settings:
- **Instance**: hostname or URL such as `mastodon.social` or `https://mastodon.social`
- **Access token**: a Mastodon API token with permission to create statuses
- **Post prefix**: text placed immediately before the link; defaults to `From my #LinkLog: "`
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
Login:
```sh
curl -X POST http://localhost:8000/api/auth/login \\
-H 'Content-Type: application/json' \\
-d '{"username":"alice","password":"secret123"}'
```
Submit a link using the returned access token:
```sh
curl -X POST http://localhost:8000/api/links \\
-H 'Content-Type: application/json' \\
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \\
-d '{"title":"Example","url":"https://example.com","comment":"Worth reading"}'
```
The public feed is available at:
```text
GET http://localhost:8000/api/public/feed
```
Admin plugin requests must include the administrator's token:
```sh
curl http://localhost:8000/api/admin/plugins \\
-H 'Authorization: Bearer YOUR_ADMIN_ACCESS_TOKEN'
```
Administrators can manage user privileges from the admin page. The `Administrator` checkbox sends `PUT /api/admin/users/{user_id}` with `{"is_admin": true}` or `{"is_admin": false}`. The API rejects any change that would leave the system without an administrator.
## Troubleshooting
- **`ModuleNotFoundError`**: activate `.venv` and rerun `python -m pip install -r backend/requirements.txt`.
- **Jinja2 import error in Docker**: rebuild the image with `docker compose up --build`; the runtime dependency is declared in `backend/requirements.txt`.
- **Extension cannot login**: confirm the backend is running, use the exact backend origin without a trailing API path, and check the browser console for blocked requests.
- **Port 8000 is occupied**: run uvicorn with another port and update the extension backend URL, for example `--port 8001`.
- **Port 80 or 8080 is occupied**: change the host-side ports in `docker-compose.yml`.
- **Stale development data**: stop the backend and remove `backend/data/linklog.db`, or run `docker compose down -v` for the container volume.
+33
View File
@@ -0,0 +1,33 @@
# LinkLog
I want to build a firefox plugin and a server backend.
## Plugin
The plugin, when activated will, record the current page title and URL, with tracking information stripped, display them to the users, so it can be edited. The plugin also allows the user to enter a comment in a separate field. It will send the title, the URL, the comment and a timestamp to the backend server using a configurable URL. At configuration time the plugin will obtain an authentication token by logging in on the backend.
## Server
The plugin is supposed to talk to a backend stand-alone web service.
The web service stores its information in a SQLite database and has a plugin based architecture for publishing its information.
### Server Frontend
The server frontend is made accessible in such a way that it can run behind a reverse proxy. A Dockerfile that utilizes Traefik is provided for setup.
The server front end has an admin interface in which users and plugins can be configured.
An additional Mastodon plugin will post the URLs to a Mastodon instance.
The default plugin is a frontend that publishes public information: the log of the links that users have submitted. Each link will be displayed as a page title (when available, otherwise the plain URL), preceded by a user's avatar and followed by the comment.
Links can be filtered per user, for example by putting /<user>/ in the URL path. For filtered pages, the user's avatar and name are displayed at the top and their profile information at the bottom.
When users visit the page they will be able to select and sort on users, persistently stored as a cookie.
### Frontend User
The server has a user-configuration interface where users can change their credentials, profile, and avatar.
When the Mastodon plugin is active, the user-configuration page will allow configuration of credentials and the addition of a hashtag with the posts.
+207
View File
@@ -0,0 +1,207 @@
# Chat Log
## 2026-08-24
### User
I want to build a firefox plugin as specified in the REQUIREMENTS.md file.
### Assistant outcome
Analyzed the requirements and established a Python-first architecture for a Firefox extension, FastAPI backend, SQLite persistence, plugin-based publishing, frontend pages, and Docker/Traefik deployment.
### User
I want this written in python where possible.
### Assistant outcome
Selected Python 3.11, FastAPI, uvicorn, SQLite, and a Python service layer as the primary implementation stack. Browser extension code remains JavaScript because Firefox extension runtime code executes in the browser.
### User
Start implementation.
### Assistant outcome
Created the backend scaffold, database schema, authentication and token lifecycle, link creation and retrieval, public feed API, plugin abstractions, and regression tests.
### User
Make the frontend page and the Docker setup.
### Assistant outcome
Added the public feed, admin and profile templates, static styling and feed behavior, Firefox extension scaffolding, Dockerfile, and Docker Compose with Traefik.
### User
Continue / go / continue.
### Assistant outcome
Added and registered the profile API and profile page, then validated the backend routes with pytest.
### User
Continue.
### Assistant outcome
Added user plugin configuration endpoints, admin plugin enable/disable endpoints, persisted Mastodon plugin state, and runtime plugin refresh behavior. The API suite passed.
### User
continue
### Assistant outcome
Connected the profile and admin pages to live APIs. Profile and Mastodon forms now load and save settings, and the admin page renders plugin rows with enable/disable actions. The backend suite passed and both browser scripts passed syntax checks.
### User
implement next step
### Assistant outcome
Identified the next backend step as real Mastodon posting from the link submission path, with per-user settings and failure isolation. Implementation was about to begin.
### User
Create a folder called VIBE and log our chat and promts. Previous and future
### Assistant outcome
Created this `VIBE` folder and its append-only chat and prompt logs. Future visible exchanges should be appended here in chronological order.
### User
continue implementation
### Assistant outcome
Implemented real Mastodon publishing for newly submitted links. The link endpoint now dispatches a `link_created` event after saving, the Mastodon plugin loads per-user instance/token/hashtag settings, posts JSON to `/api/v1/statuses`, and isolates remote failures so link creation remains successful. Added an end-to-end local HTTP server test. Full backend validation passed with 8 tests.
### User
Fix the Starlette/httpx deprecation by using an alternative
### Assistant outcome
Added Starlette's preferred `httpx2` package to `backend/requirements.txt` and installed it in the active virtual environment. Fixed local test-server teardown so strict warning validation is clean. The full backend suite passes with `PYTHONWARNINGS=error`: 8 passed, 0 warnings.
### User
Clean up and create a README that includes full install instructions
### Assistant outcome
Removed the obsolete Compose `version` key, added `.dockerignore`, and created a full root README covering prerequisites, local Python installation, backend startup, tests, Firefox temporary extension installation, Docker/Traefik, Mastodon setup, API examples, and troubleshooting. Compose validation passed and the strict backend suite passed with 8 tests.
### User
Review Docker setup and configuration
### Assistant outcome
Reviewed the current Dockerfile, Compose file, ignore rules, application settings, and admin routes. The image build and Compose rendering passed. Identified production risks: hard-coded secret configuration, unauthenticated admin APIs, exposed direct app and insecure Traefik dashboard ports, unrestricted Docker socket access, and missing health/restart hardening.
### User
Make sure all configurateion can be set using a .env file with docker
### Assistant outcome
Made application settings environment-driven, wired Docker and Traefik settings through Compose interpolation, added `.env.example`, ignored the real `.env`, and documented the variables and setup flow in `README.md`. Compose validation, Python compilation, and the strict backend suite passed with 8 tests.
### User
Execute all TODOs
### Assistant outcome
No literal TODO or FIXME markers were present. Implemented the remaining public-feed requirement by joining links to real user profiles, adding `/api/public/feed/{username}`, adding `/<username>` HTML pages with profile context, and making the browser feed use the filtered API endpoint. Full strict backend validation passed with 8 tests, and the updated feed script passed syntax validation.
### User
Add authenticated admin/user configuration
### Assistant outcome
Added shared bearer-token dependencies, protected user configuration endpoints, restricted plugin administration to administrator accounts, and changed user configuration to use the authenticated user's ID instead of hard-coded `user-1`. The seeded `alice` account is an administrator for development and `bob` remains a standard user. README authentication guidance was added, and the strict backend suite passed with 8 tests.
### User
Make sure the link-log page is responsive
### Assistant outcome
Added responsive CSS for the feed, profile, and admin views: fluid controls, viewport-safe sizing, mobile card spacing, stacked toolbar fields, and stacked plugin controls. Also hardened Mastodon host-only configuration so it cannot break link creation. Browser script syntax checks passed and the strict backend suite passed with 8 tests.
### User
next
### Assistant outcome
Added a web login page at `/login`, token storage in browser local storage, and authenticated requests from the profile and admin pages. Added login route coverage and preserved administrator checks. Full strict backend validation passed with 8 tests, and all browser scripts passed syntax validation.
### User
Make the layout modern based on catpuccino mocha theme
### Assistant outcome
Replaced the shared blue-and-white stylesheet with a modern Catppuccin Mocha design using Mocha surface colors, mauve/lavender accents, DM Sans and Space Grotesk typography, improved card hierarchy, focus states, buttons, and responsive mobile layouts. Main HTML/static routes returned 200, all browser scripts passed syntax checks, and the strict backend suite passed with 8 tests.
### User
docker compose up fails with Jinja2 errors
### Assistant outcome
Reproduced the Docker failure and confirmed `jinja2` was missing from `backend/requirements.txt`, even though local development had it installed. Added `jinja2==3.1.6`, rebuilt the image, verified the app container started and served `/health` and `/`, and stopped the temporary Compose stack. Traefik provider logs separately showed a Docker Desktop daemon connectivity issue causing its localhost route to return 404.
### User
put the avatar and the username on the top right hand side, and wrap all other text around it.
### Assistant outcome
Updated the shared feed card styling so the avatar and username float in the upper-right corner, with titles, comments, profile text, and metadata wrapping around the identity block. Added a clearfix to keep card boundaries stable on desktop and mobile. All main routes returned 200 and the strict backend suite passed with 8 tests.
### User
add a login button on the top right of the page
### Assistant outcome
Added a top-right `Sign in` link to the public LinkLog header, styled for the Catppuccin Mocha theme with a compact mobile layout. Added rendered-page coverage and verified the strict backend suite, feed script, and stylesheet response.
### User
add a link to the administrator page to the user configuration for users that have admin privilige
### Assistant outcome
Added a hidden `Admin` link to the profile header that is revealed by `profile.js` only when the authenticated `/api/user/me` response reports `is_admin`. Standard users do not see the link. Profile route and script validation passed, followed by the full strict backend suite with 8 passing tests.
### User
add the ability to add and remove users to the admin interface
### Assistant outcome
Added administrator-only user management endpoints for listing, creating, and deleting users. The admin page now has a live user list, add-user form, administrator checkbox, and confirmed remove controls. Password hashes are never returned; self-deletion and last-administrator deletion are blocked. Focused tests passed, followed by the full strict backend suite with 9 passing tests.
### User
Only show the add user interface when logged in as admin and only show 'Administrator sign-in required' when an administrator is not signed in. Also add a checkbox to users in the admin to assign admin rights.
### Assistant outcome
Made the admin controls and user-management form hidden until protected admin API requests succeed. The administrator sign-in notice is shown only when the user is not authenticated as an administrator. Kept the explicit Administrator checkbox in the add-user form, which sends the privilege as a boolean. Initial HTML state and admin CRUD tests passed, followed by the strict backend suite with 9 passing tests.
### User
/admin shows 'sign out' while the users has been signed out.
### Assistant outcome
Hid the sign-out button in the initial admin and profile HTML. The admin script reveals it only after protected admin requests succeed, while the profile script reveals it only after the authenticated profile request succeeds. Added rendered-state assertions and verified the strict backend suite with 9 passing tests.
### User
Show a login button when nobody is logged in for /admin
### Assistant outcome
Added a hidden `Sign in` link to the admin header that points to `/login`. The admin script reveals it when administrator authentication fails, while showing `Sign out` only after admin authentication succeeds. Added rendered-state coverage and verified the strict backend suite with 9 passing tests.
### User
The signin and signout button are not still not visible
### Assistant outcome
Changed the admin `Sign in` button to be visible in the initial HTML instead of waiting for an asynchronous failed API request. The script hides it after successful administrator authentication and reveals `Sign out` at the same time. Verified the signed-out rendered state, browser scripts, and strict backend suite with 9 passing tests.
### User
It seems that the session is not maintained. I want Login buttons when a user has logged in and logout buttons when a user has logged out. If an authenticated user tries to load the admin page without authorization it should say that the user has no authorization
### Assistant outcome
Updated the admin page to validate the stored browser token through `/api/auth/me` and distinguish signed-out users, authenticated non-admin users, and administrators. Signed-out users see `Sign in`; authenticated users see `Sign out`; non-admin users see an explicit authorization error and no admin controls. The auth endpoint now returns `is_admin`, and the strict backend suite passed with 9 tests.
### User
When a logged in user gets to the profile page their username should be printed - it is inmutable. Available defaults should prepopulte the fields.
### Assistant outcome
Changed the profile username from an editable input to a read-only display and removed username updates from the user profile API. Added defaults for bio, avatar URL, Mastodon instance, and post prefix in the rendered profile form and browser loader. Added regression coverage; the strict backend suite passed with 9 tests.
### User
The username doesn't load. The firefox console reports: Uncaught SyntaxError: redeclaration of const logoutButton
### Assistant outcome
Fixed the global lexical declaration collision between `logout.js` and the page scripts. Renamed the profile and admin references to `profileLogoutButton` and `adminLogoutButton`, allowing `profile.js` to parse and execute its username loader. The combined logout/profile script check passed, the focused profile test passed, and the strict backend suite passed with 9 tests.
### User
Check the whole authentication flow as it seems broken.
### Assistant outcome
Traced login, token storage, profile/admin session checks, logout revocation, and extension authentication. Found that the live Docker container was serving an older `profile.js` containing the stale `const logoutButton` declaration, while the workspace source was already corrected. Wrapped authentication scripts in closures, added cache-busted script URLs, removed the stale minified profile artifact, rebuilt Docker, and verified the live profile asset and health endpoint. Full strict backend validation passed with 9 tests.
### User
In the admin interface allow to toggle administrative rights for users. But always enforce there to be at least one user with admin rights
### Assistant outcome
Added `PUT /api/admin/users/{user_id}` for administrator privilege changes and added a checkbox to every admin user row. Promotion and demotion are supported, while the API rejects demoting the last administrator. Focused tests and the full strict backend suite passed with 10 tests.
### User
remove default for bio and email in the profile page
### Assistant outcome
Removed the default bio text from the profile template and browser loader. Email remains API-driven and empty until stored for the user. The avatar, Mastodon instance, and post-prefix defaults remain unchanged. Focused profile validation passed, followed by the strict backend suite with 10 passing tests.
+43
View File
@@ -0,0 +1,43 @@
# Prompt Log
## 2026-08-24
1. I want to build a firefox plugin as specified in the REQUIREMENTS.md file.
2. I want this written in python where possible.
3. Start implementation.
4. Make the frontend page and the Docker setup.
5. Continue.
6. go.
7. continue.
8. Continue.
9. Create a folder called VIBE and log our chat and promts. Previous and future.
10. implement next step.
11. continue implementation
12. Fix the Starlette/httpx deprecation by using an alternative
13. Clean up and create a README that includes full install instructions
14. Review Docker setup and configuration
15. Make sure all configurateion can be set using a .env file with docker
16. Execute all TODOs
17. Add authenticated admin/user configuration
18. Make sure the link-log page is responsive
19. next
20. Make the layout modern based on catpuccino mocha theme
21. docker compose up fails with Jinja2 errors
22. put the avatar and the username on the top right hand side, and wrap all other text around it.
23. add a login button on the top right of the page
24. add a link to the administrator page to the user configuration for users that have admin privilige
25. add the ability to add and remove users to the admin interface
26. Only show the add user interface when logged in as admin and only show 'Administrator sign-in required' when an administrator is not signed in. Also add a checkbox to users in the admin to assign admin rights.
27. /admin shows 'sign out' while the users has been signed out.
28. Show a login button when nobody is logged in for /admin
29. The signin and signout button are not still not visible
30. It seems that the session is not maintained. I want Login buttons when a user has logged in and logout buttons when a user has logged out. If an authenticated user tries to load the admin page without authorization it should say that the user has no authorization
31. When a logged in user gets to the profile page their username should be printed - it is inmutable. Available defaults should prepopulte the fields.
32. The username doesn't load. The firefox console reports: Uncaught SyntaxError: redeclaration of const logoutButton
33. Check the whole authentication flow as it seems broken.
34. In the admin interface allow to toggle administrative rights for users. But always enforce there to be at least one user with admin rights
35. remove default for bio and email in the profile page
## Future entries
Append each new user prompt here with its date and preserve the chronological order.
+10
View File
@@ -0,0 +1,10 @@
# VIBE
This folder contains an append-only record of the visible Link Log development conversation.
- `CHAT_LOG.md` records user requests and assistant responses or outcomes in chronological order.
- `PROMPTS.md` records user prompts separately for quick reference.
Only the user-visible conversation is recorded. System, developer, environment, and private tool instructions are intentionally excluded.
For future turns, append one dated entry to both logs after the exchange is complete. Keep entries concise and preserve the order of the conversation.
+1
View File
@@ -0,0 +1 @@
"""LinkLog backend package."""
+14
View File
@@ -0,0 +1,14 @@
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from starlette.requests import Request
from backend.app.main import app
templates = Jinja2Templates(directory='frontend/templates')
@app.get('/admin', response_class=HTMLResponse)
async def admin_dashboard(request: Request):
return templates.TemplateResponse('admin.html', {'request': request})
+202
View File
@@ -0,0 +1,202 @@
import json
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from backend.app.api.dependencies import require_admin
from backend.app.database import get_connection, hash_password
router = APIRouter()
class AdminPluginUpdate(BaseModel):
enabled: bool | None = None
config: dict | None = None
class AdminUserCreate(BaseModel):
username: str
email: str
password: str
is_admin: bool = False
class AdminUserUpdate(BaseModel):
is_admin: bool
def public_user(row):
return {
'id': row['id'],
'username': row['username'],
'email': row['email'],
'is_admin': bool(row['is_admin']),
'avatar_url': row['avatar_url'],
'bio': row['bio'],
'created_at': row['created_at'],
}
@router.get('/users')
def list_users(_: dict = Depends(require_admin)):
with get_connection() as conn:
rows = conn.execute(
'SELECT id, username, email, is_admin, avatar_url, bio, created_at FROM users ORDER BY username'
).fetchall()
return [public_user(row) for row in rows]
@router.post('/users', status_code=201)
def create_user(payload: AdminUserCreate, _: dict = Depends(require_admin)):
username = payload.username.strip()
email = payload.email.strip()
if not username or not email or len(payload.password) < 8:
raise HTTPException(status_code=422, detail='Username, email, and a password of at least 8 characters are required')
with get_connection() as conn:
try:
cursor = conn.execute(
'''
INSERT INTO users (id, username, email, password_hash, is_admin)
VALUES (?, ?, ?, ?, ?)
''',
(str(uuid4()), username, email, hash_password(payload.password), int(payload.is_admin)),
)
conn.commit()
except Exception as error:
if 'UNIQUE constraint failed' in str(error):
raise HTTPException(status_code=409, detail='Username or email already exists') from error
raise
row = conn.execute(
'SELECT id, username, email, is_admin, avatar_url, bio, created_at FROM users WHERE rowid = last_insert_rowid()'
).fetchone()
return public_user(row)
@router.put('/users/{user_id}')
def update_user_privileges(
user_id: str,
payload: AdminUserUpdate,
_: dict = Depends(require_admin),
):
with get_connection() as conn:
target = conn.execute(
'SELECT id, is_admin FROM users WHERE id = ?',
(user_id,),
).fetchone()
if target is None:
raise HTTPException(status_code=404, detail='User not found')
if target['is_admin'] and not payload.is_admin:
admin_count = conn.execute(
'SELECT COUNT(*) AS count FROM users WHERE is_admin = 1'
).fetchone()['count']
if admin_count <= 1:
raise HTTPException(status_code=400, detail='At least one administrator is required')
conn.execute(
'UPDATE users SET is_admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(int(payload.is_admin), user_id),
)
conn.commit()
row = conn.execute(
'SELECT id, username, email, is_admin, avatar_url, bio, created_at FROM users WHERE id = ?',
(user_id,),
).fetchone()
return public_user(row)
@router.delete('/users/{user_id}')
def delete_user(user_id: str, current_user: dict = Depends(require_admin)):
if user_id == current_user['id']:
raise HTTPException(status_code=400, detail='You cannot delete your own account')
with get_connection() as conn:
target = conn.execute('SELECT id, is_admin FROM users WHERE id = ?', (user_id,)).fetchone()
if target is None:
raise HTTPException(status_code=404, detail='User not found')
if target['is_admin']:
admins = conn.execute('SELECT COUNT(*) AS count FROM users WHERE is_admin = 1').fetchone()['count']
if admins <= 1:
raise HTTPException(status_code=400, detail='Cannot delete the last administrator')
conn.execute('DELETE FROM users WHERE id = ?', (user_id,))
conn.commit()
return {'status': 'deleted', 'id': user_id}
@router.get('/plugins')
def list_plugins(_: dict = Depends(require_admin)):
with get_connection() as conn:
rows = conn.execute(
'SELECT id, name, version, enabled, config FROM plugins ORDER BY name'
).fetchall()
return [
{
'id': row['id'],
'name': row['name'],
'version': row['version'],
'enabled': bool(row['enabled']),
'config': row['config'],
}
for row in rows
]
@router.get('/plugins/{plugin_name}')
def get_plugin(plugin_name: str, _: dict = Depends(require_admin)):
with get_connection() as conn:
row = conn.execute(
'SELECT id, name, version, enabled, config FROM plugins WHERE name = ?',
(plugin_name,),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail='Plugin not found')
return {
'id': row['id'],
'name': row['name'],
'version': row['version'],
'enabled': bool(row['enabled']),
'config': json.loads(row['config']) if row['config'] else {},
}
@router.put('/plugins/{plugin_name}')
def update_plugin(
plugin_name: str,
payload: AdminPluginUpdate,
_: dict = Depends(require_admin),
):
with get_connection() as conn:
current = conn.execute(
'SELECT id, name, version, enabled, config FROM plugins WHERE name = ?',
(plugin_name,),
).fetchone()
if current is None:
raise HTTPException(status_code=404, detail='Plugin not found')
enabled = payload.enabled if payload.enabled is not None else bool(current['enabled'])
config = json.loads(current['config']) if current['config'] else {}
if payload.config is not None:
config.update(payload.config)
conn.execute(
'''
UPDATE plugins
SET enabled = ?, config = ?, updated_at = CURRENT_TIMESTAMP
WHERE name = ?
''',
(1 if enabled else 0, json.dumps(config, ensure_ascii=False), plugin_name),
)
conn.commit()
return {
'name': plugin_name,
'enabled': enabled,
'config': config,
}
+61
View File
@@ -0,0 +1,61 @@
from uuid import uuid4
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from backend.app.database import get_connection, init_db
from backend.app.services.auth_service import authenticate_user
from backend.app.services.token_service import issue_token, revoke_token, validate_token
router = APIRouter()
init_db()
class LoginRequest(BaseModel):
username: str
password: str
@router.post('/login')
def login(payload: LoginRequest):
user = authenticate_user(payload.username, payload.password)
if user is None:
raise HTTPException(status_code=401, detail='Invalid username or password')
token_data = issue_token(user['id'], user['username'])
return {
'access_token': token_data['access_token'],
'token_type': 'bearer',
'expires_at': token_data['expires_at'],
'refresh_token': token_data['refresh_token'],
'user': {'id': user['id'], 'username': user['username'], 'email': user['email']}
}
@router.post('/logout')
def logout(payload: dict):
token = payload.get('token')
if not token:
raise HTTPException(status_code=400, detail='Token is required')
revoked = revoke_token(token)
if not revoked:
raise HTTPException(status_code=404, detail='Token not found or already revoked')
return {'status': 'logged_out'}
@router.get('/me')
def current_user(token: str):
info = validate_token(token)
if info is None:
raise HTTPException(status_code=401, detail='Token expired or invalid')
with get_connection() as conn:
user = conn.execute('SELECT * FROM users WHERE id = ?', (info['user_id'],)).fetchone()
if user is None:
raise HTTPException(status_code=404, detail='User not found')
return {
'id': user['id'],
'username': user['username'],
'email': user['email'],
'is_admin': bool(user['is_admin']),
}
+41
View File
@@ -0,0 +1,41 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend.app.database import get_connection
from backend.app.services.token_service import validate_token
bearer_scheme = HTTPBearer(auto_error=False)
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
):
if credentials is None or credentials.scheme.lower() != 'bearer':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Authentication required',
headers={'WWW-Authenticate': 'Bearer'},
)
token_data = validate_token(credentials.credentials)
if token_data is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Token expired or invalid',
headers={'WWW-Authenticate': 'Bearer'},
)
with get_connection() as conn:
user = conn.execute(
'SELECT * FROM users WHERE id = ?',
(token_data['user_id'],),
).fetchone()
if user is None:
raise HTTPException(status_code=404, detail='User not found')
return dict(user)
def require_admin(user: dict = Depends(get_current_user)):
if not user['is_admin']:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Administrator access required')
return user
+34
View File
@@ -0,0 +1,34 @@
from fastapi import APIRouter, Header, HTTPException, status
from pydantic import BaseModel
from backend.app.services.link_service import create_link, list_public_links
from backend.app.services.plugin_manager import plugin_manager
from backend.app.services.token_service import validate_token
router = APIRouter()
class LinkCreate(BaseModel):
title: str
url: str
comment: str = ''
timestamp: str | None = None
@router.post('/links', status_code=status.HTTP_201_CREATED)
def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header(default=None)):
if not authorization or not authorization.startswith('Bearer '):
raise HTTPException(status_code=401, detail='Missing or invalid Authorization header')
token = authorization.replace('Bearer ', '', 1)
info = validate_token(token)
if info is None:
raise HTTPException(status_code=401, detail='Token expired or invalid')
record = create_link(info['user_id'], payload.title, payload.url, payload.comment, payload.timestamp)
plugin_manager.dispatch({'type': 'link_created', **record})
return record
@router.get('/links')
def list_links():
return list_public_links()
+26
View File
@@ -0,0 +1,26 @@
from fastapi import APIRouter
from backend.app.services.link_service import list_public_links
router = APIRouter()
@router.get('/feed')
@router.get('/feed/{username}')
def public_feed(username: str | None = None):
items = list_public_links(username)
return [
{
'id': item['id'],
'title': item['title'],
'url': item['url'],
'comment': item['comment'],
'user': {
'username': item['username'],
'avatar_url': item['avatar_url'],
'bio': item['bio'],
},
'created_at': item['created_at'],
}
for item in items
]
+116
View File
@@ -0,0 +1,116 @@
import json
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from backend.app.api.dependencies import get_current_user
from backend.app.database import get_connection
router = APIRouter()
class UserConfigUpdate(BaseModel):
email: str | None = None
bio: str | None = None
avatar_url: str | None = None
class UserPluginConfigUpdate(BaseModel):
instance: str | None = None
access_token: str | None = None
post_prefix: str | None = None
hashtag: str | None = None
@router.get('/me')
def get_current_user_profile(user: dict = Depends(get_current_user)):
with get_connection() as conn:
row = conn.execute(
'SELECT * FROM users WHERE id = ?',
(user['id'],),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail='User not found')
return dict(row)
@router.put('/me')
def update_current_user_profile(
payload: UserConfigUpdate,
user: dict = Depends(get_current_user),
):
with get_connection() as conn:
current = conn.execute('SELECT * FROM users WHERE id = ?', (user['id'],)).fetchone()
if current is None:
raise HTTPException(status_code=404, detail='User not found')
email = payload.email or current['email']
bio = payload.bio if payload.bio is not None else current['bio']
avatar_url = payload.avatar_url if payload.avatar_url is not None else current['avatar_url']
conn.execute(
'''
UPDATE users
SET email = ?, bio = ?, avatar_url = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''',
(email, bio, avatar_url, user['id']),
)
conn.commit()
return {'status': 'updated'}
@router.get('/plugins/{plugin_name}')
def get_user_plugin_config(plugin_name: str, user: dict = Depends(get_current_user)):
with get_connection() as conn:
row = conn.execute(
'SELECT * FROM user_plugin_config WHERE user_id = ? AND plugin_name = ?',
(user['id'], plugin_name),
).fetchone()
if row is None:
return {}
config = json.loads(row['config']) if row['config'] else {}
return config
@router.put('/plugins/{plugin_name}')
def update_user_plugin_config(
plugin_name: str,
payload: UserPluginConfigUpdate,
user: dict = Depends(get_current_user),
):
with get_connection() as conn:
current = conn.execute(
'SELECT * FROM user_plugin_config WHERE user_id = ? AND plugin_name = ?',
(user['id'], plugin_name),
).fetchone()
current_config = json.loads(current['config']) if current and current['config'] else {}
updates = payload.model_dump(exclude_none=True)
merged = {**current_config, **updates}
if current is None:
conn.execute(
'''
INSERT INTO user_plugin_config (id, user_id, plugin_name, config, created_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
''',
(str(uuid4()), user['id'], plugin_name, json.dumps(merged, ensure_ascii=False)),
)
else:
conn.execute(
'''
UPDATE user_plugin_config
SET config = ?, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND plugin_name = ?
''',
(json.dumps(merged, ensure_ascii=False), user['id'], plugin_name),
)
conn.commit()
return merged
+35
View File
@@ -0,0 +1,35 @@
from dataclasses import dataclass
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
DB_PATH = BASE_DIR / 'data' / 'linklog.db'
@dataclass
class Settings:
app_name: str = os.getenv('LINKLOG_APP_NAME', 'LinkLog')
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')
token_expiry_days: int = int(os.getenv('LINKLOG_TOKEN_EXPIRY_DAYS', '30'))
tracking_params: list[str] = None
def __post_init__(self):
if self.tracking_params is None:
default_tracking_params = [
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
'utm_id', 'utm_name', 'gclid', 'fbclid', 'dclid', 'msclkid', 'mc_cid',
'mc_eid', 'igshid', 'ga_source', 'ga_medium', 'ga_campaign', 'ga_place',
'campaignid', 'hsa_cr', 'hsa_cam', 'hsa_grp', 'hsa_ad', 'hsa_src',
'hsa_acc', 'hsa_net', 'hsa_mt', 'hsa_kw', 'hsa_ver', 'hsa_tgt',
]
configured_params = os.getenv('LINKLOG_TRACKING_PARAMS')
self.tracking_params = (
[item.strip() for item in configured_params.split(',') if item.strip()]
if configured_params
else default_tracking_params
)
settings = Settings()
+25
View File
@@ -0,0 +1,25 @@
from datetime import datetime, timedelta, timezone
from hashlib import sha256
from backend.app.core.config import settings
def hash_token(token: str) -> str:
return sha256(token.encode('utf-8')).hexdigest()
def token_expires_at() -> datetime:
return datetime.now(timezone.utc) + timedelta(days=settings.token_expiry_days)
def clean_url(url: str) -> str:
from urllib.parse import parse_qsl, urlsplit, urlunsplit
parts = urlsplit(url)
if not parts.query:
return url
params = parse_qsl(parts.query, keep_blank_values=True)
filtered = [(k, v) for k, v in params if k not in settings.tracking_params]
new_query = '&'.join(f'{k}={v}' if v != '' else k for k, v in filtered)
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
+115
View File
@@ -0,0 +1,115 @@
import sqlite3
import os
from hashlib import sha256
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
DB_PATH = Path(os.getenv('LINKLOG_DATABASE_PATH', BASE_DIR / 'data' / 'linklog.db'))
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
def hash_password(password: str) -> str:
return sha256(password.encode('utf-8')).hexdigest()
SCHEMA = '''
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
avatar_url TEXT,
bio TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_type TEXT NOT NULL DEFAULT 'access',
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
url TEXT NOT NULL,
comment TEXT,
timestamp TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_public INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
version TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
config TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_plugin_config (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
plugin_name TEXT NOT NULL,
config TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, plugin_name),
FOREIGN KEY(user_id) REFERENCES users(id)
);
'''
def get_connection() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA foreign_keys = ON')
return conn
def init_db() -> None:
with get_connection() as conn:
conn.executescript(SCHEMA)
alice_hash = hash_password('secret123')
bob_hash = hash_password('secret123')
conn.execute(
'''
INSERT OR IGNORE INTO users (id, username, email, password_hash, is_admin)
VALUES (?, ?, ?, ?, 1)
''',
('user-1', 'alice', 'alice@example.com', alice_hash)
)
conn.execute("UPDATE users SET is_admin = 1 WHERE id = 'user-1'")
conn.execute(
'''
INSERT OR IGNORE INTO users (id, username, email, password_hash, is_admin)
VALUES (?, ?, ?, ?, 0)
''',
('user-2', 'bob', 'bob@example.com', bob_hash)
)
conn.execute(
'''
INSERT OR IGNORE INTO plugins (id, name, version, enabled, config)
VALUES (?, ?, ?, 1, ?)
''',
('plugin-1', 'default_frontend', '1.0.0', '{"route": "/"}')
)
conn.execute(
'''
INSERT OR IGNORE INTO plugins (id, name, version, enabled, config)
VALUES (?, ?, ?, 1, ?)
''',
('plugin-2', 'mastodon', '1.0.0', '{"enabled": true, "instance": "mastodon.social"}')
)
conn.commit()
+18
View File
@@ -0,0 +1,18 @@
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.requests import Request
from backend.app.main import app
from backend.app.services.link_service import list_public_links
app.mount('/static', StaticFiles(directory='frontend/static'), name='static')
templates = Jinja2Templates(directory='frontend/templates')
@app.get('/', response_class=HTMLResponse)
async def public_root(request: Request):
feed = list_public_links()
return templates.TemplateResponse('feed.html', {'request': request, 'feed': feed})
+65
View File
@@ -0,0 +1,65 @@
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.requests import Request
from backend.app.api.admin import router as admin_router
from backend.app.api.auth import router as auth_router
from backend.app.api.links import router as links_router
from backend.app.api.public import router as public_router
from backend.app.api.user_config import router as user_config_router
from backend.app.services.link_service import list_public_links
app = FastAPI(title='LinkLog API')
app.mount('/static', StaticFiles(directory='frontend/static'), name='static')
app.include_router(auth_router, prefix='/api/auth')
app.include_router(links_router, prefix='/api')
app.include_router(public_router, prefix='/api/public')
app.include_router(admin_router, prefix='/api/admin')
app.include_router(user_config_router, prefix='/api/user')
templates = Jinja2Templates(directory='frontend/templates')
@app.get('/', response_class=HTMLResponse)
async def public_root(request: Request):
feed = list_public_links()
return templates.TemplateResponse(request, 'feed.html', {'feed': feed})
@app.get('/admin', response_class=HTMLResponse)
async def admin_dashboard(request: Request):
return templates.TemplateResponse(request, 'admin.html', {})
@app.get('/profile', response_class=HTMLResponse)
async def user_profile_page(request: Request):
return templates.TemplateResponse(request, 'user_profile.html', {})
@app.get('/login', response_class=HTMLResponse)
async def login_page(request: Request):
return templates.TemplateResponse(request, 'login.html', {})
@app.get('/health')
def health_check():
return {'status': 'ok'}
@app.get('/{username}', response_class=HTMLResponse)
async def public_user_feed(request: Request, username: str):
feed = list_public_links(username)
profile = None
if feed:
profile = {
'username': feed[0]['username'],
'avatar_url': feed[0]['avatar_url'],
'bio': feed[0]['bio'],
}
return templates.TemplateResponse(
request,
'feed.html',
{'feed': feed, 'profile': profile, 'user_filter': username},
)
+17
View File
@@ -0,0 +1,17 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
@dataclass
class LinkRecord:
id: str
user_id: str
title: str
url: str
comment: str
created_at: datetime
timestamp: Optional[datetime] = None
is_public: bool = True
updated_at: Optional[datetime] = None
deleted_at: Optional[datetime] = None
+16
View File
@@ -0,0 +1,16 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class UserRecord:
id: str
username: str
password_hash: str
email: Optional[str] = None
avatar_url: Optional[str] = None
bio: Optional[str] = None
is_admin: bool = False
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
+16
View File
@@ -0,0 +1,16 @@
class BasePlugin:
name = 'base'
version = '1.0.0'
enabled = True
def initialize(self, config=None):
return True
def validate_config(self, config):
return True, 'ok'
def handle_event(self, event):
raise NotImplementedError
def health_check(self):
return {'name': self.name, 'status': 'ok'}
+18
View File
@@ -0,0 +1,18 @@
from datetime import datetime, timezone
from hashlib import sha256
from backend.app.database import get_connection
def hash_password(password: str) -> str:
return sha256(password.encode('utf-8')).hexdigest()
def authenticate_user(username: str, password: str):
password_hash = hash_password(password)
with get_connection() as conn:
row = conn.execute(
'SELECT * FROM users WHERE username = ? AND password_hash = ?',
(username, password_hash),
).fetchone()
return dict(row) if row else None
+57
View File
@@ -0,0 +1,57 @@
from datetime import datetime, timezone
from uuid import uuid4
from backend.app.core.security import clean_url
from backend.app.database import get_connection
def create_link(user_id: str, title: str, url: str, comment: str, timestamp: str | None):
cleaned_url = clean_url(url)
created_at = datetime.now(timezone.utc).isoformat()
record = {
'id': str(uuid4()),
'user_id': user_id,
'title': title,
'url': cleaned_url,
'comment': comment,
'timestamp': timestamp or created_at,
'created_at': created_at,
'updated_at': created_at,
'is_public': 1,
}
with get_connection() as conn:
conn.execute(
'''
INSERT INTO links (id, user_id, title, url, comment, timestamp, created_at, updated_at, is_public)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(
record['id'],
record['user_id'],
record['title'],
record['url'],
record['comment'],
record['timestamp'],
record['created_at'],
record['updated_at'],
record['is_public'],
),
)
conn.commit()
return record
def list_public_links(username: str | None = None):
with get_connection() as conn:
rows = conn.execute(
'''
SELECT links.*, users.username, users.avatar_url, users.bio
FROM links
JOIN users ON users.id = links.user_id
WHERE links.is_public = 1
AND (? IS NULL OR users.username = ?)
ORDER BY created_at DESC
''',
(username, username),
).fetchall()
return [dict(row) for row in rows]
+112
View File
@@ -0,0 +1,112 @@
import json
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from backend.app.plugins.base import BasePlugin
DEFAULT_POST_PREFIX = 'From my #LinkLog: "'
class DefaultFrontendPlugin(BasePlugin):
name = 'default_frontend'
version = '1.0.0'
def handle_event(self, event):
return {'status': 'accepted', 'plugin': self.name, 'event': event.get('type')}
class MastodonPlugin(BasePlugin):
name = 'mastodon'
version = '1.0.0'
enabled = False
config = {}
def initialize(self, config=None):
self.config = config or {}
return True
def handle_event(self, event):
config = dict(self.config)
user_id = event.get('user_id')
if user_id:
from backend.app.database import get_connection
with get_connection() as conn:
row = conn.execute(
'''
SELECT config FROM user_plugin_config
WHERE user_id = ? AND plugin_name = ?
''',
(user_id, self.name),
).fetchone()
if row and row['config']:
config.update(json.loads(row['config']))
instance = str(config.get('instance', '')).strip().rstrip('/')
if instance and '://' not in instance:
instance = f'https://{instance}'
access_token = str(config.get('access_token', '')).strip()
if not instance or not access_token:
return {'status': 'skipped', 'plugin': self.name, 'reason': 'not_configured'}
status_parts = [event.get('title') or event.get('url', '')]
if event.get('comment'):
status_parts.append(event['comment'])
post_prefix = config.get('post_prefix')
if post_prefix is None and config.get('hashtag'):
post_prefix = f'#{str(config["hashtag"]).strip().lstrip("#")} '
post_prefix = str(post_prefix if post_prefix is not None else DEFAULT_POST_PREFIX)
status_parts.append(f'{post_prefix}{event.get("url", "")}'.strip())
try:
request = Request(
f'{instance}/api/v1/statuses',
data=json.dumps({'status': '\n'.join(status_parts)}).encode('utf-8'),
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
},
method='POST',
)
with urlopen(request, timeout=5) as response:
response_data = json.loads(response.read().decode('utf-8'))
return {
'status': 'posted',
'plugin': self.name,
'post_id': response_data.get('id'),
}
except (HTTPError, URLError, TimeoutError, OSError, ValueError) as error:
return {
'status': 'failed',
'plugin': self.name,
'reason': str(error),
}
class PluginManager:
def __init__(self):
self.plugins = [DefaultFrontendPlugin(), MastodonPlugin()]
def refresh_from_db(self):
from backend.app.database import get_connection
with get_connection() as conn:
rows = conn.execute('SELECT name, enabled, config FROM plugins').fetchall()
enabled_names = {row['name'] for row in rows if row['enabled']}
for plugin in self.plugins:
plugin.enabled = plugin.name in enabled_names
row = next((item for item in rows if item['name'] == plugin.name), None)
plugin.initialize(json.loads(row['config']) if row and row['config'] else {})
return enabled_names
def dispatch(self, event):
self.refresh_from_db()
results = []
for plugin in self.plugins:
if plugin.enabled:
results.append(plugin.handle_event(event))
return results
plugin_manager = PluginManager()
+62
View File
@@ -0,0 +1,62 @@
from datetime import datetime, timezone
from hashlib import sha256
from uuid import uuid4
from backend.app.core.config import settings
from backend.app.database import get_connection
def hash_token(token: str) -> str:
return sha256(token.encode('utf-8')).hexdigest()
def issue_token(user_id: str, username: str) -> dict:
token = f'token-{username}-{uuid4().hex}'
expires_at = datetime.now(timezone.utc).replace(microsecond=0)
expires_at = expires_at.replace(day=expires_at.day + 30 if False else expires_at.day)
# one-month expiry, held as a configured value in settings
from datetime import timedelta
expires_at = datetime.now(timezone.utc) + timedelta(days=settings.token_expiry_days)
with get_connection() as conn:
conn.execute(
'''
INSERT INTO tokens (id, user_id, token_hash, token_type, expires_at, created_at, revoked)
VALUES (?, ?, ?, 'access', ?, CURRENT_TIMESTAMP, 0)
''',
(str(uuid4()), user_id, hash_token(token), expires_at.isoformat())
)
conn.commit()
return {
'access_token': token,
'token_type': 'bearer',
'expires_at': expires_at.isoformat(),
'refresh_token': f'refresh-{uuid4().hex}',
}
def validate_token(token: str) -> dict | None:
token_hash = hash_token(token)
with get_connection() as conn:
row = conn.execute(
'''
SELECT * FROM tokens
WHERE token_hash = ? AND revoked = 0 AND expires_at > ?
''',
(token_hash, datetime.now(timezone.utc).isoformat()),
).fetchone()
if row is None:
return None
return dict(row)
def revoke_token(token: str) -> bool:
token_hash = hash_token(token)
with get_connection() as conn:
cursor = conn.execute(
'UPDATE tokens SET revoked = 1 WHERE token_hash = ?',
(token_hash,),
)
conn.commit()
return cursor.rowcount > 0
+7
View File
@@ -0,0 +1,7 @@
fastapi==0.141.1
uvicorn==0.52.4
pydantic==2.13.4
jinja2==3.1.6
pytest==9.1.1
httpx==0.28.1
httpx2==2.12.0
+207
View File
@@ -0,0 +1,207 @@
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from fastapi.testclient import TestClient
from backend.app.main import app
client = TestClient(app)
def login_headers(username='alice'):
token = client.post('/api/auth/login', json={
'username': username,
'password': 'secret123',
}).json()['access_token']
return {'Authorization': f'Bearer {token}'}
def test_login_returns_token():
response = client.post('/api/auth/login', json={
'username': 'alice',
'password': 'secret123',
})
assert response.status_code == 200
payload = response.json()
assert 'access_token' in payload
assert payload['token_type'] == 'bearer'
admin_session = client.get('/api/auth/me', params={'token': payload['access_token']})
assert admin_session.status_code == 200
assert admin_session.json()['is_admin'] is True
user_token = client.post('/api/auth/login', json={
'username': 'bob',
'password': 'secret123',
}).json()['access_token']
user_session = client.get('/api/auth/me', params={'token': user_token})
assert user_session.status_code == 200
assert user_session.json()['is_admin'] is False
def test_configuration_requires_authentication_and_admin_role():
assert client.get('/api/user/me').status_code == 401
assert client.get('/api/admin/plugins').status_code == 401
assert client.get('/api/admin/users').status_code == 401
assert client.get('/api/admin/plugins', headers=login_headers('bob')).status_code == 403
assert client.get('/api/admin/users', headers=login_headers('bob')).status_code == 403
def test_admin_can_add_list_and_remove_users():
headers = login_headers()
create_response = client.post('/api/admin/users', headers=headers, json={
'username': 'charlie',
'email': 'charlie@example.com',
'password': 'charlie-secret',
})
assert create_response.status_code == 201
user = create_response.json()
assert user['username'] == 'charlie'
assert 'password_hash' not in user
users = client.get('/api/admin/users', headers=headers).json()
assert any(item['id'] == user['id'] for item in users)
assert client.delete(f"/api/admin/users/{user['id']}", headers=headers).status_code == 200
assert all(item['id'] != user['id'] for item in client.get('/api/admin/users', headers=headers).json())
assert client.delete('/api/admin/users/user-1', headers=headers).status_code == 400
def test_admin_can_toggle_privileges_without_removing_last_admin():
headers = login_headers()
bob = next(user for user in client.get('/api/admin/users', headers=headers).json() if user['username'] == 'bob')
promote = client.put(f"/api/admin/users/{bob['id']}", headers=headers, json={'is_admin': True})
assert promote.status_code == 200
assert promote.json()['is_admin'] is True
demote = client.put(f"/api/admin/users/{bob['id']}", headers=headers, json={'is_admin': False})
assert demote.status_code == 200
assert demote.json()['is_admin'] is False
last_admin = client.put('/api/admin/users/user-1', headers=headers, json={'is_admin': False})
assert last_admin.status_code == 400
def test_submit_link_stores_cleaned_url_and_public_feed():
response = client.post('/api/links', headers=login_headers(), json={
'title': 'Example page',
'url': 'https://example.com/path?utm_source=ad&utm_medium=email&keep=yes',
'comment': 'Interesting read',
'timestamp': '2026-08-24T12:00:00Z',
})
assert response.status_code == 201
data = response.json()
assert data['url'] == 'https://example.com/path?keep=yes'
feed = client.get('/api/public/feed').json()
matching = next(item for item in feed if item['id'] == data['id'])
assert matching['comment'] == 'Interesting read'
assert matching['user']['username'] == 'alice'
filtered_response = client.get('/api/public/feed/alice')
assert filtered_response.status_code == 200
assert all(item['user']['username'] == 'alice' for item in filtered_response.json())
assert client.get('/api/public/feed/does-not-exist').json() == []
def test_logout_revokes_token_and_admin_can_list_plugins():
headers = login_headers()
token = headers['Authorization'].removeprefix('Bearer ')
assert client.post('/api/auth/logout', json={'token': token}).status_code == 200
revoked_response = client.post('/api/links', headers=headers, json={
'title': 'Should fail',
'url': 'https://example.com/path',
'comment': 'nope',
})
assert revoked_response.status_code == 401
plugins_response = client.get('/api/admin/plugins', headers=login_headers())
assert plugins_response.status_code == 200
assert any(plugin['name'] == 'default_frontend' for plugin in plugins_response.json())
def test_public_and_admin_pages_render_html():
root_page = client.get('/')
assert 'LinkLog' in root_page.text
assert 'class="login-button" href="/login"' in root_page.text
assert client.get('/alice').status_code == 200
assert 'alice' in client.get('/alice').text
assert client.get('/login').status_code == 200
assert 'Sign in' in client.get('/login').text
assert client.get('/admin').status_code == 200
admin_page = client.get('/admin').text
assert 'Admin' in admin_page
assert 'id="admin-controls" class="hidden"' in admin_page
assert 'id="admin-auth-notice" class="auth-notice hidden"' in admin_page
assert 'id="admin-login-button" class="login-button" href="/login"' in admin_page
assert 'id="logout-button" class="logout-button hidden"' in admin_page
def test_link_submission_posts_to_enabled_mastodon_plugin():
received = {}
class MastodonHandler(BaseHTTPRequestHandler):
def do_POST(self):
received['path'] = self.path
received['authorization'] = self.headers['Authorization']
received['body'] = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(b'{"id":"status-1"}')
def log_message(self, format, *args):
return
server = ThreadingHTTPServer(('127.0.0.1', 0), MastodonHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
headers = login_headers()
base_url = f'http://127.0.0.1:{server.server_port}'
assert client.put('/api/user/plugins/mastodon', headers=headers, json={
'instance': base_url,
'access_token': 'test-token',
'post_prefix': 'From my #LinkLog: "',
}).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={
'title': 'A useful page',
'url': 'https://example.com/useful',
'comment': 'Worth sharing',
})
assert response.status_code == 201
assert received['path'] == '/api/v1/statuses'
assert received['authorization'] == 'Bearer test-token'
assert received['body'] == {
'status': 'A useful page\nWorth sharing\nFrom my #LinkLog: "https://example.com/useful',
}
finally:
server.shutdown()
thread.join()
server.server_close()
def test_plugin_config_can_be_saved_for_mastodon():
headers = login_headers()
assert client.put('/api/user/plugins/mastodon', headers=headers, json={
'instance': 'mastodon.social',
'access_token': 'demo-token',
'post_prefix': 'From my #LinkLog: "',
}).status_code == 200
payload = client.get('/api/user/plugins/mastodon', headers=headers).json()
assert payload['instance'] == 'mastodon.social'
assert payload['post_prefix'] == 'From my #LinkLog: "'
admin_update = client.put('/api/admin/plugins/mastodon', headers=headers, json={
'enabled': True,
'config': {'instance': 'mastodon.social'},
})
assert admin_update.status_code == 200
+39
View File
@@ -0,0 +1,39 @@
from fastapi.testclient import TestClient
from backend.app.main import app
client = TestClient(app)
def test_user_config_api_and_profile_page():
login = client.post('/api/auth/login', json={
'username': 'alice',
'password': 'secret123',
}).json()
headers = {'Authorization': f"Bearer {login['access_token']}"}
profile_response = client.get('/api/user/me', headers=headers)
assert profile_response.status_code == 200
payload = profile_response.json()
assert payload['username'] == 'alice'
update_response = client.put('/api/user/me', json={
'username': 'should-not-change',
'bio': 'Updated bio',
'avatar_url': 'https://example.com/new-avatar.png'
}, headers=headers)
assert update_response.status_code == 200
page_response = client.get('/profile')
assert page_response.status_code == 200
assert 'Profile' in page_response.text
assert '<output id="username" class="readonly-value">Loading...</output>' in page_response.text
assert 'name="username"' not in page_response.text
assert '<textarea id="bio" name="bio" rows="4"></textarea>' in page_response.text
assert 'https://example.com/avatar.png' in page_response.text
assert 'value="mastodon.social"' in page_response.text
assert 'From my #LinkLog: &quot;' in page_response.text
assert 'id="admin-link"' in page_response.text
assert 'class="login-button hidden"' in page_response.text
assert 'id="logout-button" class="logout-button hidden"' in page_response.text
+40
View File
@@ -0,0 +1,40 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: ${APP_CONTAINER_NAME:-linklog-app}
ports:
- "${APP_PORT:-8000}:8000"
volumes:
- linklog_data:/app/backend/data
environment:
APP_ENV: ${APP_ENV:-production}
LINKLOG_APP_NAME: ${LINKLOG_APP_NAME:-LinkLog}
LINKLOG_DATABASE_PATH: ${LINKLOG_DATABASE_PATH:-/app/backend/data/linklog.db}
LINKLOG_SECRET_KEY: ${LINKLOG_SECRET_KEY:?Set LINKLOG_SECRET_KEY in .env}
LINKLOG_TOKEN_EXPIRY_DAYS: ${LINKLOG_TOKEN_EXPIRY_DAYS:-30}
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
restart: ${APP_RESTART_POLICY:-unless-stopped}
labels:
- "traefik.enable=true"
- "traefik.http.routers.linklog.rule=Host(`${TRAEFIK_HOST:-localhost}`)"
- "traefik.http.routers.linklog.entrypoints=web"
- "traefik.http.services.linklog.loadbalancer.server.port=8000"
traefik:
image: ${TRAEFIK_IMAGE:-traefik:v3.1}
container_name: ${TRAEFIK_CONTAINER_NAME:-linklog-traefik}
command:
- --providers.docker=true
- --entrypoints.web.address=:${TRAEFIK_HTTP_INTERNAL_PORT:-80}
- --api.insecure=${TRAEFIK_API_INSECURE:-true}
ports:
- "${TRAEFIK_HTTP_PORT:-80}:${TRAEFIK_HTTP_INTERNAL_PORT:-80}"
- "${TRAEFIK_DASHBOARD_PORT:-8080}:8080"
restart: ${TRAEFIK_RESTART_POLICY:-unless-stopped}
volumes:
- "${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock:ro"
volumes:
linklog_data:
+186
View File
@@ -0,0 +1,186 @@
(() => {
const pluginList = document.querySelector('#plugin-list');
const userList = document.querySelector('#user-list');
const userForm = document.querySelector('#user-form');
const adminControls = document.querySelector('#admin-controls');
const adminAuthNotice = document.querySelector('#admin-auth-notice');
const adminLoginButton = document.querySelector('#admin-login-button');
const adminLogoutButton = document.querySelector('#logout-button');
const accessToken = localStorage.getItem('linklogAccessToken');
function authHeaders(includeJson = false) {
return {
...(includeJson ? {'Content-Type': 'application/json'} : {}),
...(accessToken ? {Authorization: `Bearer ${accessToken}`} : {}),
};
}
function renderPlugins(plugins) {
pluginList.replaceChildren(...plugins.map((plugin) => {
const row = document.createElement('div');
row.className = 'plugin-row';
const label = document.createElement('span');
label.textContent = `${plugin.name} (${plugin.version})`;
const button = document.createElement('button');
button.type = 'button';
button.textContent = plugin.enabled ? 'Disable' : 'Enable';
button.addEventListener('click', () => updatePlugin(plugin, button));
row.append(label, button);
return row;
}));
}
function renderUsers(users) {
userList.replaceChildren(...users.map((user) => {
const row = document.createElement('div');
row.className = 'plugin-row';
const label = document.createElement('span');
label.textContent = `${user.username} (${user.email})${user.is_admin ? ' - admin' : ''}`;
const privilegeLabel = document.createElement('label');
privilegeLabel.className = 'user-admin-toggle';
const privilegeCheckbox = document.createElement('input');
privilegeCheckbox.type = 'checkbox';
privilegeCheckbox.checked = user.is_admin;
privilegeCheckbox.setAttribute('aria-label', `Administrator rights for ${user.username}`);
privilegeCheckbox.addEventListener('change', () => updateUserPrivilege(user, privilegeCheckbox));
privilegeLabel.append(privilegeCheckbox, document.createTextNode(' Administrator'));
const button = document.createElement('button');
button.type = 'button';
button.className = 'danger-button';
button.textContent = 'Remove';
button.addEventListener('click', () => removeUser(user, button));
row.append(label, privilegeLabel, button);
return row;
}));
}
async function loadUsers() {
const response = await fetch('/api/admin/users', {headers: authHeaders()});
if (!response.ok) throw new Error('Could not load users');
renderUsers(await response.json());
}
function showAdminState(isAdmin) {
adminControls.classList.toggle('hidden', !isAdmin);
adminAuthNotice.classList.toggle('hidden', isAdmin);
adminLoginButton.classList.toggle('hidden', isAdmin);
adminLogoutButton.classList.toggle('hidden', !accessToken);
}
function showSignedOutState() {
showAdminState(false);
adminAuthNotice.innerHTML = 'Administrator sign-in required. <a href="/login">Sign in</a>';
adminLoginButton.classList.remove('hidden');
adminLogoutButton.classList.add('hidden');
}
function showUnauthorizedState() {
showAdminState(false);
adminAuthNotice.textContent = 'You are signed in, but you are not authorized to access this page.';
adminLoginButton.classList.add('hidden');
adminLogoutButton.classList.remove('hidden');
}
async function loadAdminState() {
if (!accessToken) {
showSignedOutState();
return;
}
const sessionResponse = await fetch(`/api/auth/me?token=${encodeURIComponent(accessToken)}`);
if (!sessionResponse.ok) {
localStorage.removeItem('linklogAccessToken');
showSignedOutState();
return;
}
const user = await sessionResponse.json();
if (!user.is_admin) {
showUnauthorizedState();
return;
}
await Promise.all([loadUsers(), loadPlugins()]);
showAdminState(true);
}
async function loadPlugins() {
const response = await fetch('/api/admin/plugins', {headers: authHeaders()});
if (!response.ok) throw new Error('Could not load plugins');
renderPlugins(await response.json());
}
async function updatePlugin(plugin, button) {
button.disabled = true;
const response = await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.name)}`, {
method: 'PUT',
headers: authHeaders(true),
body: JSON.stringify({enabled: !plugin.enabled}),
});
if (response.ok) {
await loadPlugins();
} else {
button.disabled = false;
}
}
async function removeUser(user, button) {
if (!window.confirm(`Remove ${user.username}?`)) return;
button.disabled = true;
const response = await fetch(`/api/admin/users/${encodeURIComponent(user.id)}`, {
method: 'DELETE',
headers: authHeaders(),
});
if (response.ok) {
await loadUsers();
} else {
button.disabled = false;
}
}
async function updateUserPrivilege(user, checkbox) {
checkbox.disabled = true;
const response = await fetch(`/api/admin/users/${encodeURIComponent(user.id)}`, {
method: 'PUT',
headers: authHeaders(true),
body: JSON.stringify({is_admin: checkbox.checked}),
});
if (response.ok) {
await loadUsers();
} else {
checkbox.checked = user.is_admin;
checkbox.disabled = false;
}
}
userForm.addEventListener('submit', async (event) => {
event.preventDefault();
const values = Object.fromEntries(new FormData(userForm));
values.is_admin = userForm.elements.is_admin.checked;
const response = await fetch('/api/admin/users', {
method: 'POST',
headers: authHeaders(true),
body: JSON.stringify(values),
});
const status = document.querySelector('#user-status');
status.textContent = response.ok ? 'User added.' : 'Could not add user.';
status.style.color = response.ok ? '#94e2d5' : '#f38ba8';
if (response.ok) {
userForm.reset();
await loadUsers();
}
});
loadAdminState().catch((error) => {
showAdminState(false);
adminAuthNotice.textContent = accessToken
? `Could not verify administrator access: ${error.message}`
: 'Administrator sign-in required.';
adminAuthNotice.classList.remove('hidden');
userList.textContent = '';
pluginList.textContent = '';
});
})();
+119
View File
@@ -0,0 +1,119 @@
const feedEl = document.getElementById('feed');
const sortSelect = document.getElementById('sort-select');
const userFilter = document.getElementById('user-filter');
const cookieName = 'linklog-feed-preferences';
function readPreferences() {
const cookie = document.cookie
.split('; ')
.find((row) => row.startsWith(`${cookieName}=`));
if (!cookie) {
return { sort: 'newest', user: '' };
}
try {
return JSON.parse(decodeURIComponent(cookie.split('=')[1]));
} catch (error) {
return { sort: 'newest', user: '' };
}
}
function writePreferences(pref) {
const value = encodeURIComponent(JSON.stringify(pref));
document.cookie = `${cookieName}=${value}; path=/; max-age=31536000`;
}
function renderFeed(items) {
feedEl.innerHTML = '';
if (!items.length) {
feedEl.innerHTML = '<div class="link-item"><p>No links yet.</p></div>';
return;
}
items.forEach((item) => {
const article = document.createElement('article');
article.className = 'link-item';
const header = document.createElement('div');
header.className = 'link-header';
const avatar = document.createElement('div');
avatar.className = 'avatar';
avatar.textContent = (item.user?.username || 'U').slice(0, 1).toUpperCase();
const userName = document.createElement('div');
userName.className = 'user-name';
userName.textContent = item.user?.username || 'unknown';
header.appendChild(avatar);
header.appendChild(userName);
const title = document.createElement('h2');
const link = document.createElement('a');
link.href = item.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = item.title || item.url;
title.appendChild(link);
const comment = document.createElement('div');
comment.className = 'comment';
comment.textContent = item.comment || 'No comment provided';
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = item.created_at || 'updated recently';
article.appendChild(header);
article.appendChild(title);
article.appendChild(comment);
article.appendChild(meta);
feedEl.appendChild(article);
});
}
async function loadFeed() {
const routeUser = document.body.dataset.userFilter;
const endpoint = routeUser
? `/api/public/feed/${encodeURIComponent(routeUser)}`
: '/api/public/feed';
const response = await fetch(endpoint);
const data = await response.json();
let items = data || [];
const pref = readPreferences();
if (pref.user && !routeUser) {
items = items.filter((item) => (item.user?.username || '').toLowerCase() === pref.user.toLowerCase());
}
if (pref.sort === 'oldest') {
items = [...items].reverse();
}
renderFeed(items);
}
function syncPreferences() {
const pref = readPreferences();
sortSelect.value = pref.sort;
userFilter.value = pref.user;
sortSelect.addEventListener('change', (event) => {
const next = { ...readPreferences(), sort: event.target.value };
writePreferences(next);
loadFeed();
});
userFilter.addEventListener('input', (event) => {
const next = { ...readPreferences(), user: event.target.value.trim() };
writePreferences(next);
loadFeed();
});
}
syncPreferences();
loadFeed();
+24
View File
@@ -0,0 +1,24 @@
const form = document.querySelector('#login-form');
const status = document.querySelector('#login-status');
form.addEventListener('submit', async (event) => {
event.preventDefault();
status.textContent = 'Signing in...';
status.style.color = '#334155';
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
if (!response.ok) {
status.textContent = 'Sign-in failed.';
status.style.color = '#b91c1c';
return;
}
const data = await response.json();
localStorage.setItem('linklogAccessToken', data.access_token);
window.location.assign('/profile');
});
+20
View File
@@ -0,0 +1,20 @@
(() => {
const logoutButton = document.querySelector('#logout-button');
logoutButton.addEventListener('click', async () => {
const token = localStorage.getItem('linklogAccessToken');
logoutButton.disabled = true;
logoutButton.textContent = 'Signing out...';
if (token) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token}),
}).catch(() => undefined);
}
localStorage.removeItem('linklogAccessToken');
window.location.assign('/login');
});
})();
+71
View File
@@ -0,0 +1,71 @@
(() => {
const profileForm = document.querySelector('#profile-form');
const mastodonForm = document.querySelector('#mastodon-form');
const profileLogoutButton = document.querySelector('#logout-button');
const accessToken = localStorage.getItem('linklogAccessToken');
const defaultAvatarUrl = 'https://example.com/avatar.png';
const defaultMastodonInstance = 'mastodon.social';
const defaultPostPrefix = 'From my #LinkLog: "';
function authHeaders(includeJson = false) {
return {
...(includeJson ? {'Content-Type': 'application/json'} : {}),
...(accessToken ? {Authorization: `Bearer ${accessToken}`} : {}),
};
}
function setStatus(selector, message, isError = false) {
const status = document.querySelector(selector);
status.textContent = message;
status.style.color = isError ? '#b91c1c' : '#166534';
}
async function loadProfile() {
const response = await fetch('/api/user/me', {headers: authHeaders()});
if (!response.ok) throw new Error('Could not load profile');
const profile = await response.json();
document.querySelector('#username').textContent = profile.username || '';
document.querySelector('#email').value = profile.email || '';
document.querySelector('#bio').value = profile.bio || '';
document.querySelector('#avatar-url').value = profile.avatar_url || defaultAvatarUrl;
if (profile.is_admin) {
document.querySelector('#admin-link').classList.remove('hidden');
}
profileLogoutButton.classList.remove('hidden');
}
async function loadMastodonConfig() {
const response = await fetch('/api/user/plugins/mastodon', {headers: authHeaders()});
if (!response.ok) throw new Error('Could not load Mastodon settings');
const config = await response.json();
document.querySelector('#mastodon-instance').value = config.instance || defaultMastodonInstance;
document.querySelector('#mastodon-access-token').value = config.access_token || '';
document.querySelector('#mastodon-post-prefix').value = config.post_prefix || defaultPostPrefix;
}
profileForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(profileForm);
const response = await fetch('/api/user/me', {
method: 'PUT',
headers: authHeaders(true),
body: JSON.stringify(Object.fromEntries(formData)),
});
setStatus('#profile-status', response.ok ? 'Profile saved.' : 'Could not save profile.', !response.ok);
});
mastodonForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(mastodonForm);
const response = await fetch('/api/user/plugins/mastodon', {
method: 'PUT',
headers: authHeaders(true),
body: JSON.stringify(Object.fromEntries(formData)),
});
setStatus('#mastodon-status', response.ok ? 'Mastodon settings saved.' : 'Could not save Mastodon settings.', !response.ok);
});
Promise.all([loadProfile(), loadMastodonConfig()]).catch((error) => {
setStatus('#profile-status', accessToken ? error.message : 'Please sign in first.', true);
});
})();
+464
View File
@@ -0,0 +1,464 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap');
:root {
--base: #1e1e2e;
--mantle: #181825;
--crust: #11111b;
--surface-0: #313244;
--surface-1: #45475a;
--surface-2: #585b70;
--text: #cdd6f4;
--subtext: #a6adc8;
--muted: #7f849c;
--mauve: #cba6f7;
--lavender: #b4befe;
--blue: #89b4fa;
--teal: #94e2d5;
--peach: #fab387;
--red: #f38ba8;
--border: rgba(205, 214, 244, 0.12);
--shadow: 0 18px 50px rgba(17, 17, 27, 0.28);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background:
linear-gradient(145deg, rgba(203, 166, 247, 0.06), transparent 36%),
var(--base);
color: var(--text);
font-family: 'DM Sans', 'Avenir Next', sans-serif;
line-height: 1.5;
}
body::selection {
background: var(--mauve);
color: var(--crust);
}
.container {
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 0 24px;
}
.site-header {
position: relative;
overflow: hidden;
padding: 56px 0 48px;
background:
linear-gradient(115deg, rgba(203, 166, 247, 0.18), transparent 45%),
var(--mantle);
border-bottom: 1px solid var(--border);
}
.site-header::after {
position: absolute;
right: 12%;
bottom: -52px;
width: 180px;
height: 100px;
border-top: 1px solid rgba(180, 190, 254, 0.28);
border-radius: 50%;
content: '';
transform: rotate(-12deg);
}
.site-header h1,
.link-item h2 {
font-family: 'Space Grotesk', 'Avenir Next', sans-serif;
letter-spacing: 0;
}
.site-header h1 {
margin: 0;
color: var(--text);
font-size: clamp(2.2rem, 5vw, 4rem);
line-height: 1;
}
.site-header p {
max-width: 34rem;
margin: 14px 0 0;
color: var(--subtext);
font-size: 1.05rem;
}
.header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.header-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.login-button {
flex: 0 0 auto;
margin-top: 2px;
padding: 10px 16px;
border: 1px solid var(--surface-2);
border-radius: 8px;
background: var(--surface-0);
color: var(--text);
font-weight: 700;
text-decoration: none;
transition: background 160ms ease, border-color 160ms ease;
}
.login-button:hover {
border-color: var(--lavender);
background: var(--surface-1);
color: var(--text);
}
.logout-button {
min-width: 0;
padding: 10px 13px;
border-color: rgba(243, 139, 168, 0.45);
background: transparent;
color: var(--red);
}
.logout-button:hover {
border-color: var(--red);
background: rgba(243, 139, 168, 0.12);
color: var(--red);
}
main.container {
padding-top: 28px;
padding-bottom: 64px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin: 0 0 24px;
padding: 16px;
background: rgba(24, 24, 37, 0.72);
border: 1px solid var(--border);
border-radius: 12px;
}
.toolbar label,
.settings-panel label {
display: grid;
gap: 7px;
color: var(--subtext);
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.checkbox-label {
display: flex !important;
align-items: center;
gap: 9px !important;
text-transform: none !important;
}
.checkbox-label input {
width: auto;
min-width: 0;
}
.toolbar label {
flex: 1 1 220px;
}
select,
input,
textarea,
button {
max-width: 100%;
min-width: 180px;
padding: 11px 13px;
border: 1px solid var(--surface-1);
border-radius: 8px;
background: var(--surface-0);
color: var(--text);
font: inherit;
}
select:focus,
input:focus,
textarea:focus,
button:focus-visible {
outline: 2px solid var(--lavender);
outline-offset: 2px;
border-color: var(--lavender);
}
::placeholder {
color: var(--muted);
}
.settings-panel form {
display: grid;
gap: 16px;
max-width: 560px;
}
.settings-panel textarea {
min-height: 110px;
resize: vertical;
}
.readonly-value {
display: block;
padding: 11px 13px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--mantle);
color: var(--lavender);
font-weight: 600;
}
button {
width: fit-content;
border-color: var(--mauve);
background: var(--mauve);
color: var(--crust);
cursor: pointer;
font-weight: 700;
transition: filter 160ms ease, transform 160ms ease;
}
button:hover {
filter: brightness(1.08);
transform: translateY(-1px);
}
button:disabled {
cursor: wait;
opacity: 0.6;
transform: none;
}
.status {
min-height: 1.25em;
margin: 0;
color: var(--teal);
}
.hidden {
display: none;
}
.auth-notice {
margin: 0 0 20px;
color: var(--subtext);
}
.auth-notice a,
.link-item a {
color: var(--lavender);
}
.auth-notice a:hover,
.link-item a:hover {
color: var(--mauve);
}
.plugin-list,
.feed {
display: grid;
gap: 14px;
}
.plugin-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
.plugin-row:last-child {
border-bottom: 0;
}
.plugin-row button {
min-width: 0;
padding: 8px 12px;
background: var(--surface-1);
border-color: var(--surface-2);
color: var(--text);
}
.user-admin-toggle {
display: flex;
align-items: center;
gap: 7px;
color: var(--subtext);
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
}
.user-admin-toggle input {
width: auto;
min-width: 0;
}
.danger-button {
border-color: rgba(243, 139, 168, 0.45) !important;
background: transparent !important;
color: var(--red) !important;
}
.feed {
padding-bottom: 40px;
}
.link-item {
padding: 22px;
background: rgba(49, 50, 68, 0.84);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow);
}
.link-item h2 {
margin: 0 0 8px;
color: var(--text);
font-size: 1.2rem;
line-height: 1.25;
}
.link-header {
display: inline-flex;
align-items: center;
float: right;
gap: 12px;
margin: 0 0 12px 18px;
}
.avatar {
display: grid;
width: 38px;
height: 38px;
flex: 0 0 38px;
place-items: center;
border: 2px solid rgba(203, 166, 247, 0.5);
border-radius: 50%;
background: var(--surface-1);
color: var(--mauve);
font-weight: 700;
}
.user-name {
color: var(--lavender);
font-weight: 700;
}
.link-item a {
text-decoration: none;
overflow-wrap: anywhere;
}
.link-item::after {
display: table;
clear: both;
content: '';
}
.comment,
.profile-summary p {
margin: 12px 0 0;
color: var(--subtext);
line-height: 1.65;
}
.meta {
margin-top: 14px;
color: var(--muted);
font-size: 0.82rem;
}
@media (max-width: 600px) {
.container {
padding: 0 14px;
}
.site-header {
padding: 38px 0 34px;
}
.site-header h1 {
font-size: 2.35rem;
}
.header-row {
align-items: center;
gap: 12px;
}
.header-actions {
align-items: stretch;
flex-direction: column;
}
.login-button {
padding: 8px 11px;
font-size: 0.9rem;
}
.logout-button {
padding: 8px 11px;
font-size: 0.9rem;
}
main.container {
padding-top: 18px;
}
.toolbar {
gap: 12px;
padding: 14px;
}
.toolbar label,
select,
input,
textarea,
.settings-panel button {
width: 100%;
min-width: 0;
}
.link-item {
padding: 16px;
border-radius: 10px;
}
.link-header {
margin-left: 14px;
}
.plugin-row {
align-items: stretch;
flex-direction: column;
gap: 9px;
}
.plugin-row button {
align-self: flex-start;
}
}
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LinkLog Admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header class="site-header">
<div class="container">
<div class="header-row">
<div>
<h1>LinkLog Admin</h1>
<p>Manage users and plugin configuration</p>
</div>
<div class="header-actions">
<a id="admin-login-button" class="login-button" href="/login">Sign in</a>
<button id="logout-button" class="logout-button hidden" type="button">Sign out</button>
</div>
</div>
</div>
</header>
<main class="container">
<p id="admin-auth-notice" class="auth-notice hidden"></p>
<div id="admin-controls" class="hidden">
<section class="link-item settings-panel">
<h2>Users</h2>
<form id="user-form">
<label>
Username
<input name="username" type="text" required />
</label>
<label>
Email
<input name="email" type="email" required />
</label>
<label>
Password
<input name="password" type="password" minlength="8" required />
</label>
<label class="checkbox-label">
<input name="is_admin" type="checkbox" />
Administrator
</label>
<button type="submit">Add user</button>
<p id="user-status" class="status" role="status"></p>
</form>
<div id="user-list" class="plugin-list" aria-live="polite">Loading users...</div>
</section>
<section class="link-item settings-panel">
<h2>Plugins</h2>
<div id="plugin-list" class="plugin-list" aria-live="polite">Loading plugins...</div>
</section>
</div>
</main>
<script src="/static/logout.js?v=2"></script>
<script src="/static/admin.js?v=2"></script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LinkLog Feed</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body data-user-filter="{{ user_filter or '' }}">
<header class="site-header">
<div class="container">
<div class="header-row">
<div>
<h1>LinkLog</h1>
<p>Public link feed</p>
</div>
<a class="login-button" href="/login">Sign in</a>
</div>
</div>
</header>
<main class="container">
{% if profile %}
<section class="link-item profile-summary">
<div class="link-header">
<div class="avatar">{{ profile.username[:1].upper() }}</div>
<div class="user-name">{{ profile.username }}</div>
</div>
<p>{{ profile.bio or 'No profile information provided.' }}</p>
</section>
{% endif %}
<section class="toolbar">
<label>
Sort
<select id="sort-select">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
</label>
<label>
User filter
<input id="user-filter" type="text" placeholder="alice" />
</label>
</section>
<section id="feed" class="feed" aria-live="polite"></section>
</main>
<script src="/static/feed.js"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Sign in - LinkLog</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header class="site-header">
<div class="container">
<h1>Sign in</h1>
<p>Access your LinkLog settings</p>
</div>
</header>
<main class="container">
<section class="link-item settings-panel">
<form id="login-form">
<label>
Username
<input id="username" name="username" type="text" autocomplete="username" required />
</label>
<label>
Password
<input id="password" name="password" type="password" autocomplete="current-password" required />
</label>
<button type="submit">Sign in</button>
<p id="login-status" class="status" role="status"></p>
</form>
</section>
</main>
<script src="/static/login.js"></script>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LinkLog Profile</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header class="site-header">
<div class="container">
<div class="header-row">
<h1>Profile</h1>
<div class="header-actions">
<a id="admin-link" class="login-button hidden" href="/admin">Admin</a>
<button id="logout-button" class="logout-button hidden" type="button">Sign out</button>
</div>
</div>
</div>
</header>
<main class="container">
<p class="auth-notice">Sign in at <a href="/login">/login</a> to manage your settings.</p>
<section class="link-item settings-panel">
<h2>Profile Settings</h2>
<form id="profile-form">
<label>
Username
<output id="username" class="readonly-value">Loading...</output>
</label>
<label>
Email
<input id="email" name="email" type="email" required />
</label>
<label>
Bio
<textarea id="bio" name="bio" rows="4"></textarea>
</label>
<label>
Avatar URL
<input id="avatar-url" name="avatar_url" type="url" value="https://example.com/avatar.png" />
</label>
<button type="submit">Save profile</button>
<p id="profile-status" class="status" role="status"></p>
</form>
</section>
<section class="link-item settings-panel">
<h2>Mastodon</h2>
<form id="mastodon-form">
<label>
Instance
<input id="mastodon-instance" name="instance" type="text" value="mastodon.social" placeholder="mastodon.social" />
</label>
<label>
Access token
<input id="mastodon-access-token" name="access_token" type="password" autocomplete="off" />
</label>
<label>
Post prefix
<input id="mastodon-post-prefix" name="post_prefix" type="text" value="From my #LinkLog: &quot;" />
</label>
<button type="submit">Save Mastodon settings</button>
<p id="mastodon-status" class="status" role="status"></p>
</form>
</section>
</main>
<script src="/static/logout.js?v=2"></script>
<script src="/static/profile.js?v=2"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"manifest_version": 3,
"name": "LinkLog",
"version": "0.1.0",
"description": "Capture and submit page links to LinkLog.",
"permissions": [
"activeTab",
"storage",
"tabs"
],
"host_permissions": [
"<all_urls>"
],
"action": {
"default_title": "LinkLog",
"default_popup": "popup.html"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
}
}
+71
View File
@@ -0,0 +1,71 @@
body {
margin: 0;
font-family: sans-serif;
background: #f8fafc;
color: #0f172a;
}
.options-shell {
max-width: 440px;
margin: 40px auto;
padding: 24px;
background: white;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
h1 {
margin-top: 0;
}
label {
display: block;
margin-bottom: 16px;
font-weight: 600;
}
input,
button {
width: 100%;
box-sizing: border-box;
font: inherit;
}
input {
margin-top: 6px;
padding: 8px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
}
button {
margin-top: 10px;
border: none;
border-radius: 8px;
padding: 10px 12px;
background: #2563eb;
color: white;
font-weight: 600;
cursor: pointer;
}
.status {
margin-bottom: 12px;
padding: 8px 10px;
border-radius: 8px;
font-size: 0.86rem;
}
.status.success {
background: #dcfce7;
color: #166534;
}
.status.error {
background: #fee2e2;
color: #991b1b;
}
.hidden {
display: none;
}
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LinkLog Settings</title>
<link rel="stylesheet" href="options.css" />
</head>
<body>
<main class="options-shell">
<h1>LinkLog Settings</h1>
<div id="status" class="status hidden" aria-live="polite"></div>
<form id="settings-form">
<label>
Backend URL
<input id="backend-url" type="url" placeholder="https://example.com" />
</label>
<label>
Username
<input id="username" type="text" placeholder="alice" />
</label>
<label>
Password
<input id="password" type="password" placeholder="********" />
</label>
<button type="submit">Save and log in</button>
</form>
</main>
<script src="options.js"></script>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
const DEFAULT_BACKEND = 'http://localhost:8000';
const statusEl = document.getElementById('status');
const form = document.getElementById('settings-form');
const backendUrlInput = document.getElementById('backend-url');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
function setStatus(message, isError = false) {
statusEl.textContent = message;
statusEl.classList.remove('hidden');
statusEl.classList.toggle('error', isError);
statusEl.classList.toggle('success', !isError);
}
async function loadSettings() {
const settings = await browser.storage.local.get(['backendUrl', 'username']);
backendUrlInput.value = settings.backendUrl || DEFAULT_BACKEND;
usernameInput.value = settings.username || '';
}
async function saveSettingsAndLogin(event) {
event.preventDefault();
const backendUrl = backendUrlInput.value.trim();
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!backendUrl || !username || !password) {
setStatus('Please fill in all fields', true);
return;
}
try {
const response = await fetch(`${backendUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
await browser.storage.local.set({
backendUrl,
username,
accessToken: data.access_token,
tokenType: data.token_type,
tokenExpiresAt: data.expires_at,
refreshToken: data.refresh_token,
});
setStatus('Logged in successfully');
} catch (error) {
setStatus('Unable to log in. Check backend URL and credentials.', true);
}
}
form.addEventListener('submit', saveSettingsAndLogin);
loadSettings();
+88
View File
@@ -0,0 +1,88 @@
body {
width: 360px;
margin: 0;
font-family: sans-serif;
background: #f5f7fb;
color: #1f2937;
}
.popup-shell {
padding: 16px;
}
header h1 {
margin: 0 0 12px;
font-size: 1.2rem;
}
label {
display: block;
margin-bottom: 12px;
font-size: 0.85rem;
font-weight: 600;
}
input,
textarea,
button {
width: 100%;
box-sizing: border-box;
font: inherit;
}
input,
textarea {
margin-top: 6px;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 8px 10px;
background: white;
}
textarea {
resize: vertical;
}
.actions {
display: flex;
gap: 8px;
}
button {
border: none;
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
font-weight: 600;
}
#submit-link {
background: #2563eb;
color: white;
}
.secondary {
background: #e2e8f0;
color: #0f172a;
}
.status {
margin-bottom: 10px;
border-radius: 8px;
padding: 8px 10px;
font-size: 0.8rem;
}
.status.success {
background: #dcfce7;
color: #166534;
}
.status.error {
background: #fee2e2;
color: #991b1b;
}
.hidden {
display: none;
}
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LinkLog</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<main class="popup-shell">
<header>
<h1>LinkLog</h1>
</header>
<div id="status" class="status hidden" aria-live="polite"></div>
<form id="link-form">
<label>
Title
<input id="title" name="title" type="text" />
</label>
<label>
URL
<input id="url" name="url" type="url" />
</label>
<label>
Comment
<textarea id="comment" name="comment" rows="3" placeholder="Your comment"></textarea>
</label>
<div class="actions">
<button type="submit" id="submit-link">Save link</button>
<button type="button" id="open-settings" class="secondary">Settings</button>
</div>
</form>
</main>
<script src="popup.js"></script>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
const statusEl = document.getElementById('status');
const form = document.getElementById('link-form');
const titleInput = document.getElementById('title');
const urlInput = document.getElementById('url');
const commentInput = document.getElementById('comment');
const openSettingsButton = document.getElementById('open-settings');
function setStatus(message, isError = false) {
statusEl.textContent = message;
statusEl.classList.remove('hidden');
statusEl.classList.toggle('error', isError);
statusEl.classList.toggle('success', !isError);
}
async function getSettings() {
const result = await browser.storage.local.get([
'backendUrl',
'accessToken',
'tokenExpiresAt',
]);
return result;
}
function removeKnownTrackingParams(urlString) {
try {
const url = new URL(urlString);
const known = new Set([
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
'utm_id', 'utm_name', 'gclid', 'fbclid', 'dclid', 'msclkid'
]);
for (const key of known) {
url.searchParams.delete(key);
}
return url.toString();
} catch (error) {
return urlString;
}
}
async function populateCurrentTab() {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (!tab) return;
titleInput.value = tab.title || '';
urlInput.value = tab.url || '';
}
async function handleSubmit(event) {
event.preventDefault();
setStatus('Submitting...', false);
const settings = await getSettings();
const token = settings.accessToken;
const backendUrl = settings.backendUrl;
if (!token || !backendUrl) {
setStatus('Please configure the backend URL and log in first.', true);
browser.runtime.openOptionsPage();
return;
}
try {
const response = await fetch(`${backendUrl}/api/links`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
title: titleInput.value,
url: removeKnownTrackingParams(urlInput.value),
comment: commentInput.value,
timestamp: new Date().toISOString(),
})
});
if (response.status === 401) {
setStatus('Session expired. Re-authenticate in settings.', true);
browser.runtime.openOptionsPage();
return;
}
if (!response.ok) {
throw new Error('Submission failed');
}
setStatus('Link saved successfully');
} catch (error) {
setStatus('Submission failed. Check your backend connection.', true);
}
}
openSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage());
form.addEventListener('submit', handleSubmit);
populateCurrentTab();