commit 1c827956c482980493e0ac92f2367e15e804049a Author: Olaf Date: Mon Aug 24 14:30:30 2026 +0200 Initial LinkLog implementation diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2b1a33c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.venv +.pytest_cache +__pycache__ +*.py[cod] +*.db +*.sqlite +*.sqlite3 +.DS_Store +VIBE diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..01df4c9 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ad7c12 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d0a9b5a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..871bafd --- /dev/null +++ b/README.md @@ -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: +- User feed: +- Profile settings: +- Admin page: +- Web login: +- Health check: +- OpenAPI documentation: + +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: +- Direct application port: +- Traefik 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 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. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md new file mode 100644 index 0000000..8225d6d --- /dev/null +++ b/REQUIREMENTS.md @@ -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 // 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. diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md new file mode 100644 index 0000000..e5c7258 --- /dev/null +++ b/VIBE/CHAT_LOG.md @@ -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 `/` 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. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md new file mode 100644 index 0000000..7552824 --- /dev/null +++ b/VIBE/PROMPTS.md @@ -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. diff --git a/VIBE/README.md b/VIBE/README.md new file mode 100644 index 0000000..eb628bb --- /dev/null +++ b/VIBE/README.md @@ -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. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..9cb47c3 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""LinkLog backend package.""" diff --git a/backend/app/admin_page.py b/backend/app/admin_page.py new file mode 100644 index 0000000..374bfe8 --- /dev/null +++ b/backend/app/admin_page.py @@ -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}) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..02e337d --- /dev/null +++ b/backend/app/api/admin.py @@ -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, + } diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..940133e --- /dev/null +++ b/backend/app/api/auth.py @@ -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']), + } diff --git a/backend/app/api/dependencies.py b/backend/app/api/dependencies.py new file mode 100644 index 0000000..a6aeb46 --- /dev/null +++ b/backend/app/api/dependencies.py @@ -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 diff --git a/backend/app/api/links.py b/backend/app/api/links.py new file mode 100644 index 0000000..66e5e82 --- /dev/null +++ b/backend/app/api/links.py @@ -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() diff --git a/backend/app/api/public.py b/backend/app/api/public.py new file mode 100644 index 0000000..53a60b4 --- /dev/null +++ b/backend/app/api/public.py @@ -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 + ] diff --git a/backend/app/api/user_config.py b/backend/app/api/user_config.py new file mode 100644 index 0000000..9b24b39 --- /dev/null +++ b/backend/app/api/user_config.py @@ -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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..8b502d0 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..4ca0b20 --- /dev/null +++ b/backend/app/core/security.py @@ -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)) diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..5a0b93e --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/frontend.py b/backend/app/frontend.py new file mode 100644 index 0000000..4da2068 --- /dev/null +++ b/backend/app/frontend.py @@ -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}) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..6dd79d2 --- /dev/null +++ b/backend/app/main.py @@ -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}, + ) diff --git a/backend/app/models/link.py b/backend/app/models/link.py new file mode 100644 index 0000000..8b1d86c --- /dev/null +++ b/backend/app/models/link.py @@ -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 diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..295accf --- /dev/null +++ b/backend/app/models/user.py @@ -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 diff --git a/backend/app/plugins/base.py b/backend/app/plugins/base.py new file mode 100644 index 0000000..fbd7426 --- /dev/null +++ b/backend/app/plugins/base.py @@ -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'} diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..177649f --- /dev/null +++ b/backend/app/services/auth_service.py @@ -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 diff --git a/backend/app/services/link_service.py b/backend/app/services/link_service.py new file mode 100644 index 0000000..8d936cc --- /dev/null +++ b/backend/app/services/link_service.py @@ -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] diff --git a/backend/app/services/plugin_manager.py b/backend/app/services/plugin_manager.py new file mode 100644 index 0000000..d479a09 --- /dev/null +++ b/backend/app/services/plugin_manager.py @@ -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() diff --git a/backend/app/services/token_service.py b/backend/app/services/token_service.py new file mode 100644 index 0000000..e542332 --- /dev/null +++ b/backend/app/services/token_service.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..53cdf82 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..63d519e --- /dev/null +++ b/backend/tests/test_api.py @@ -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 diff --git a/backend/tests/test_user_config.py b/backend/tests/test_user_config.py new file mode 100644 index 0000000..441db93 --- /dev/null +++ b/backend/tests/test_user_config.py @@ -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 'Loading...' in page_response.text + assert 'name="username"' not in page_response.text + assert '' 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: "' 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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e8c5e7c --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/static/admin.js b/frontend/static/admin.js new file mode 100644 index 0000000..d90d92c --- /dev/null +++ b/frontend/static/admin.js @@ -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. Sign in'; + 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 = ''; +}); +})(); diff --git a/frontend/static/feed.js b/frontend/static/feed.js new file mode 100644 index 0000000..8cfe2d7 --- /dev/null +++ b/frontend/static/feed.js @@ -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 = ''; + 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(); diff --git a/frontend/static/login.js b/frontend/static/login.js new file mode 100644 index 0000000..17ce09d --- /dev/null +++ b/frontend/static/login.js @@ -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'); +}); diff --git a/frontend/static/logout.js b/frontend/static/logout.js new file mode 100644 index 0000000..38208ea --- /dev/null +++ b/frontend/static/logout.js @@ -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'); +}); +})(); diff --git a/frontend/static/profile.js b/frontend/static/profile.js new file mode 100644 index 0000000..e4f8f5b --- /dev/null +++ b/frontend/static/profile.js @@ -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); +}); +})(); diff --git a/frontend/static/style.css b/frontend/static/style.css new file mode 100644 index 0000000..bba3d9e --- /dev/null +++ b/frontend/static/style.css @@ -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; + } +} diff --git a/frontend/templates/admin.html b/frontend/templates/admin.html new file mode 100644 index 0000000..107ee01 --- /dev/null +++ b/frontend/templates/admin.html @@ -0,0 +1,62 @@ + + + + + LinkLog Admin + + + + + + +
+ + +
+ + + + diff --git a/frontend/templates/feed.html b/frontend/templates/feed.html new file mode 100644 index 0000000..1423825 --- /dev/null +++ b/frontend/templates/feed.html @@ -0,0 +1,51 @@ + + + + + LinkLog Feed + + + + + + +
+ {% if profile %} + + {% endif %} +
+ + +
+ +
+
+ + + + diff --git a/frontend/templates/login.html b/frontend/templates/login.html new file mode 100644 index 0000000..597ec0d --- /dev/null +++ b/frontend/templates/login.html @@ -0,0 +1,34 @@ + + + + + Sign in - LinkLog + + + + + +
+ +
+ + + diff --git a/frontend/templates/user_profile.html b/frontend/templates/user_profile.html new file mode 100644 index 0000000..4c3a020 --- /dev/null +++ b/frontend/templates/user_profile.html @@ -0,0 +1,71 @@ + + + + + LinkLog Profile + + + + + + +
+

Sign in at /login to manage your settings.

+ + + +
+ + + + diff --git a/webextension/manifest.json b/webextension/manifest.json new file mode 100644 index 0000000..370e096 --- /dev/null +++ b/webextension/manifest.json @@ -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": [ + "" + ], + "action": { + "default_title": "LinkLog", + "default_popup": "popup.html" + }, + "options_ui": { + "page": "options.html", + "open_in_tab": true + } +} diff --git a/webextension/options.css b/webextension/options.css new file mode 100644 index 0000000..b9e1786 --- /dev/null +++ b/webextension/options.css @@ -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; +} diff --git a/webextension/options.html b/webextension/options.html new file mode 100644 index 0000000..c019e36 --- /dev/null +++ b/webextension/options.html @@ -0,0 +1,36 @@ + + + + + LinkLog Settings + + + +
+

LinkLog Settings

+ + + +
+ + + + + + + +
+
+ + + + diff --git a/webextension/options.js b/webextension/options.js new file mode 100644 index 0000000..7b73630 --- /dev/null +++ b/webextension/options.js @@ -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(); diff --git a/webextension/popup.css b/webextension/popup.css new file mode 100644 index 0000000..46d9d33 --- /dev/null +++ b/webextension/popup.css @@ -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; +} diff --git a/webextension/popup.html b/webextension/popup.html new file mode 100644 index 0000000..4a56cd8 --- /dev/null +++ b/webextension/popup.html @@ -0,0 +1,41 @@ + + + + + LinkLog + + + +
+
+

LinkLog

+
+ + + + +
+ + + + diff --git a/webextension/popup.js b/webextension/popup.js new file mode 100644 index 0000000..ddde677 --- /dev/null +++ b/webextension/popup.js @@ -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();