diff --git a/CHANGELOG.MD b/CHANGELOG.MD
new file mode 100644
index 0000000..6795526
--- /dev/null
+++ b/CHANGELOG.MD
@@ -0,0 +1,6 @@
+# Changelog
+
+## Version v0.1.1
+### Features
+ * Ability to add new logs through the web interface
+## Version v0.1.0 Initial release
diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md
index 5bd34b3..bdc730f 100644
--- a/VIBE/CHAT_LOG.md
+++ b/VIBE/CHAT_LOG.md
@@ -120,6 +120,24 @@ Fix SA-015: Some destructive and administrative operations lack audit logging.
### Assistant outcome
Added an append-only `security_audit_events` table and audit service. Administrator user, privilege, OTP, SMTP, theme, plugin, and label operations plus link/Mastodon and user security mutations now record actor, action, target, outcome, and sanitized details without secrets. Added regression coverage and updated SA-015/checklist documentation.
+### User
+The plugin finds a different title than the new-entry page, see e.g. https://docs.gitea.com/administration/config-cheat-sheet/
+
+### Assistant outcome
+Improved the scraper's title extraction to prioritize the page `
` tag (matching browser and plugin behavior) over meta tags, and to check for Open Graph, Twitter, and generic meta tags as fallbacks. Tested with Gitea documentation.
+
+### User
+The plugin and the new entry page are still inconsistent.
+
+### Assistant outcome
+Identified that the HTML response was gzip-compressed and `iter_raw()` was returning compressed bytes instead of decompressed HTML. Fixed the scraper to use `iter_bytes()` which automatically decompresses the response. Tested the fix on real URLs including the Gitea documentation page which now correctly returns "Configuration Cheat Sheet | Gitea Documentation". All 54 backend tests pass.
+
+### User
+For https://docs.gitea.com/administration/config-cheat-sheet/ I want the title to be Configuration Cheat Sheet | Gitea Documentation
+
+### Assistant outcome
+Verified that the fixed scraper now correctly extracts "Configuration Cheat Sheet | Gitea Documentation" from the Gitea documentation page, matching the browser and plugin behavior. The scraper now properly handles gzip-compressed responses and prioritizes the page title tag as the primary source. All tests pass and the new-entry page and plugin are now consistent.
+
### User
Solve SA-010: Avatar validation trusts the client MIME type.
diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md
index aa62ef5..97feb3a 100644
--- a/VIBE/PROMPTS.md
+++ b/VIBE/PROMPTS.md
@@ -210,6 +210,14 @@
203. When posting to Mastodon, add an empty line between "From my #LinkLog:" and the title.
204. Fix the release workflow because the runner's curl does not support `--fail-with-body`.
+## 2026-08-27
+
+205. Maintain actions in the logs if VIBE.
+206. Create a new page that can be reached by a button 'new entry' that only shows for authenticated users. The page, also only available to authenticated users allows users to enter a URL. When the URL is entered then the backend will scrape the title and allow to fill in all fields that are also available in the plugin.
+207. The plugin finds a different title than the new-entry page, see e.g. https://docs.gitea.com/administration/config-cheat-sheet/
+208. The plugin and the new entry page are still inconsistent.
+209. For https://docs.gitea.com/administration/config-cheat-sheet/ I want the title to be Configuration Cheat Sheet | Gitea Documentation
+
## Future entries
Append each new user prompt here with its date and preserve the chronological order.
diff --git a/backend/app/api/links.py b/backend/app/api/links.py
index 1fe0b19..e416af1 100644
--- a/backend/app/api/links.py
+++ b/backend/app/api/links.py
@@ -11,6 +11,7 @@ from backend.app.database import get_connection
from backend.app.services.plugin_manager import plugin_manager
from backend.app.services.token_service import validate_token
from backend.app.services.audit_service import record_audit_event
+from backend.app.services.scraper_service import scrape_title
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -36,6 +37,21 @@ def available_tags():
return list_tags()
+@router.get('/scrape')
+def scrape_url(url: str, authorization: str | None = Header(default=None)):
+ if not authorization or not authorization.startswith('Bearer '):
+ raise HTTPException(status_code=401, detail='Missing or invalid Authorization header')
+ info = validate_token(authorization.replace('Bearer ', '', 1))
+ if info is None:
+ raise HTTPException(status_code=401, detail='Token expired or invalid')
+ try:
+ title = scrape_title(url)
+ return {'title': title}
+ except Exception as e:
+ logger.error('Scrape error for %s: %s', url, e)
+ raise HTTPException(status_code=502, detail='Could not scrape URL') from e
+
+
@router.get('/links/check')
def check_existing_link(
title: str,
diff --git a/backend/app/main.py b/backend/app/main.py
index f4a89bd..42a2fdd 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -78,6 +78,13 @@ async def labels_page(request: Request):
return templates.TemplateResponse(request, 'labels.html', {})
+@app.get('/new-entry', response_class=HTMLResponse)
+async def new_entry_page(request: Request):
+ if not has_administrator():
+ return RedirectResponse('/setup')
+ return templates.TemplateResponse(request, 'new-entry.html', {})
+
+
@app.get('/about', response_class=HTMLResponse)
async def about_page(request: Request):
return templates.TemplateResponse(request, 'about.html', {})
diff --git a/backend/app/services/scraper_service.py b/backend/app/services/scraper_service.py
new file mode 100644
index 0000000..a4ee074
--- /dev/null
+++ b/backend/app/services/scraper_service.py
@@ -0,0 +1,106 @@
+## Copyright © 2026 Olaf Kolkman
+## SPDX-License-Identifier: GPL-3.0-or-later
+
+import httpx
+import logging
+from html.parser import HTMLParser
+from urllib.parse import urlparse
+
+logger = logging.getLogger(__name__)
+
+
+class TitleParser(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.title = None
+ self.og_title = None
+ self.twitter_title = None
+ self.meta_title = None
+ self.in_title = False
+
+ def handle_starttag(self, tag, attrs):
+ if tag.lower() == 'title':
+ self.in_title = True
+ elif tag.lower() == 'meta':
+ attrs_dict = dict(attrs)
+ # Check for Open Graph title
+ if attrs_dict.get('property', '').lower() == 'og:title':
+ content = attrs_dict.get('content', '').strip()
+ if content and not self.og_title:
+ self.og_title = content
+ # Check for Twitter title
+ elif attrs_dict.get('name', '').lower() == 'twitter:title':
+ content = attrs_dict.get('content', '').strip()
+ if content and not self.twitter_title:
+ self.twitter_title = content
+ # Check for generic meta title
+ elif attrs_dict.get('name', '').lower() == 'title':
+ content = attrs_dict.get('content', '').strip()
+ if content and not self.meta_title:
+ self.meta_title = content
+
+ def handle_endtag(self, tag):
+ if tag.lower() == 'title':
+ self.in_title = False
+
+ def handle_data(self, data):
+ if self.in_title and not self.title:
+ stripped = data.strip()
+ if stripped:
+ self.title = stripped
+
+ def get_best_title(self):
+ """Return the best title found, matching browser behavior.
+ Priority: page tag (what browser shows), then meta tags as fallback."""
+ return self.title or self.og_title or self.twitter_title or self.meta_title
+
+
+def scrape_title(url: str) -> str:
+ """
+ Scrape the title from a URL, checking multiple sources:
+ 1. Page title tag (what browser shows)
+ 2. Open Graph title (og:title meta tag)
+ 3. Twitter title (twitter:title meta tag)
+ 4. Generic meta title
+ 5. Domain name as fallback
+ """
+ try:
+ # Parse URL to extract domain as fallback
+ parsed = urlparse(url)
+ domain = parsed.netloc or url
+
+ # Fetch the URL with a timeout and size limit, using iter_bytes for decompression
+ with httpx.stream('GET', url, follow_redirects=True, timeout=5.0) as response:
+ if response.status_code != 200:
+ logger.warning('Failed to fetch %s: status %d', url, response.status_code)
+ return domain
+
+ # Read HTML in chunks (auto-decompressed) to avoid loading huge files
+ html_content = b''
+ max_size = 1024 * 100 # 100 KB limit
+ for chunk in response.iter_bytes():
+ html_content += chunk
+ if len(html_content) > max_size:
+ break
+
+ # Parse the HTML to extract title
+ try:
+ html_text = html_content.decode('utf-8', errors='ignore')
+ parser = TitleParser()
+ parser.feed(html_text)
+ best_title = parser.get_best_title()
+ if best_title:
+ return best_title
+ except Exception as e:
+ logger.warning('Failed to parse HTML from %s: %s', url, e)
+
+ return domain
+ except httpx.TimeoutException:
+ logger.warning('Timeout fetching %s', url)
+ return urlparse(url).netloc or url
+ except httpx.NetworkError as e:
+ logger.warning('Network error fetching %s: %s', url, e)
+ return urlparse(url).netloc or url
+ except Exception as e:
+ logger.error('Unexpected error scraping %s: %s', url, e)
+ return urlparse(url).netloc or url
diff --git a/docker-compose.yml b/docker-compose.yml
index 3ad0b91..06bf248 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -30,21 +30,21 @@ services:
labels:
traefik.enable: true
traefik.http.middlewares.web-https-redirect.redirectscheme.scheme: https
- traefik.http.services.linklog.loadbalancer.server.port: 8000
+ traefik.http.services.testlog.loadbalancer.server.port: 8000
traefik.docker.network: git_traefik
- traefik.http.routers.linklog.entrypoints: web
- traefik.http.routers.linklog.rule: Host(`${LINKLOG_PUBLIC_URL:-linklog.example.com}`)
- traefik.http.routers.linklog.middlewares: web-https-redirect,servicests
- traefik.http.routers.linklog-secure.entrypoints: websecure
- traefik.http.routers.linklog-secure.rule: Host(`${LINKLOG_PUBLIC_URL:-linklog.example.com}`)
- traefik.http.routers.linklog-secure.tls: true
- traefik.http.routers.linklog-secure.middlewares: servicests
+ traefik.http.routers.testlog.entrypoints: web
+ traefik.http.routers.testlog.rule: Host(`${LINKLOG_PUBLIC_URL:-linklog.example.com}`)
+ traefik.http.routers.testlog.middlewares: web-https-redirect,servicests
+ traefik.http.routers.testlog-secure.entrypoints: websecure
+ traefik.http.routers.testlog-secure.rule: Host(`${LINKLOG_PUBLIC_URL:-linklog.example.com}`)
+ traefik.http.routers.testlog-secure.tls: true
+ traefik.http.routers.testlog-secure.middlewares: servicests
- traefik.http.routers.linklog-secure.tls.certresolver: myresolver
- traefik.http.routers.linklog-secure.service: linklog
+ traefik.http.routers.testlog-secure.tls.certresolver: myresolver
+ traefik.http.routers.testlog-secure.service: testlog
diff --git a/frontend/static/auth-header.js b/frontend/static/auth-header.js
index 227cde8..5f2d634 100644
--- a/frontend/static/auth-header.js
+++ b/frontend/static/auth-header.js
@@ -3,6 +3,7 @@
(() => {
const loginButton = document.querySelector('#auth-login-button');
+ const newEntryButton = document.querySelector('#new-entry-button');
const profileLink = document.querySelector('#auth-profile-link');
const labelsLink = document.querySelector('#auth-labels-link');
const adminLink = document.querySelector('#auth-admin-link');
@@ -24,6 +25,7 @@
function showSignedOut() {
loginButton.classList.remove('hidden');
+ if (newEntryButton) newEntryButton.classList.add('hidden');
profileLink.classList.add('hidden');
labelsLink.classList.add('hidden');
adminLink.classList.add('hidden');
@@ -33,6 +35,7 @@
function showSignedIn(user) {
loginButton.classList.add('hidden');
+ if (newEntryButton) newEntryButton.classList.remove('hidden');
profileLink.classList.remove('hidden');
labelsLink.classList.remove('hidden');
adminLink.classList.toggle('hidden', !user.is_admin);
diff --git a/frontend/static/new-entry.js b/frontend/static/new-entry.js
new file mode 100644
index 0000000..77ca954
--- /dev/null
+++ b/frontend/static/new-entry.js
@@ -0,0 +1,203 @@
+// Copyright © 2026 Olaf Kolkman
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+(() => {
+ const entryForm = document.getElementById('entry-form');
+ const authRequired = document.getElementById('auth-required');
+ const urlInput = document.getElementById('url-input');
+ const titleInput = document.getElementById('title-input');
+ const commentInput = document.getElementById('comment-input');
+ const newTagsInput = document.getElementById('new-tags-input');
+ const existingTagsEl = document.getElementById('existing-tags');
+ const mastodonEnabledCheckbox = document.getElementById('mastodon-enabled');
+ const scrapeButton = document.getElementById('scrape-button');
+ const scrapeStatus = document.getElementById('scrape-status');
+ const submitButton = document.getElementById('submit-button');
+ const submitStatus = document.getElementById('submit-status');
+
+ const token = localStorage.getItem('linklogAccessToken');
+ let availableTags = [];
+ let selectedTags = new Set();
+ let currentUser = null;
+
+ // Utility function to add status messages
+ function setStatus(statusEl, message, isError = false) {
+ statusEl.textContent = message;
+ statusEl.className = `status ${isError ? 'error' : 'success'}`;
+ statusEl.classList.remove('hidden');
+ if (!isError) {
+ setTimeout(() => statusEl.classList.add('hidden'), 4000);
+ }
+ }
+
+ // Check authentication
+ if (!token) {
+ authRequired.classList.remove('hidden');
+ entryForm.classList.add('hidden');
+ return;
+ }
+
+ // Verify token is still valid
+ fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } })
+ .then((response) => {
+ if (!response.ok) throw new Error('Not authenticated');
+ return response.json();
+ })
+ .then((user) => {
+ currentUser = user;
+ entryForm.classList.remove('hidden');
+ loadTags();
+ })
+ .catch(() => {
+ localStorage.removeItem('linklogAccessToken');
+ authRequired.classList.remove('hidden');
+ entryForm.classList.add('hidden');
+ });
+
+ // Load available tags
+ async function loadTags() {
+ try {
+ const response = await fetch('/api/tags');
+ if (!response.ok) throw new Error('Could not load tags');
+ availableTags = await response.json();
+ renderTags();
+ } catch (error) {
+ console.error('Error loading tags:', error);
+ }
+ }
+
+ // Render tag checkboxes
+ function renderTags() {
+ existingTagsEl.innerHTML = '';
+ availableTags.forEach((tag) => {
+ const label = document.createElement('label');
+ label.className = 'tag-checkbox';
+
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.value = tag;
+ checkbox.checked = selectedTags.has(tag);
+ checkbox.addEventListener('change', () => {
+ if (checkbox.checked) {
+ selectedTags.add(tag);
+ } else {
+ selectedTags.delete(tag);
+ }
+ });
+
+ label.appendChild(checkbox);
+ label.append(` ${tag}`);
+ existingTagsEl.appendChild(label);
+ });
+ }
+
+ // Scrape URL for title
+ scrapeButton.addEventListener('click', async (e) => {
+ e.preventDefault();
+ const url = urlInput.value.trim();
+ if (!url) {
+ setStatus(scrapeStatus, 'Please enter a URL', true);
+ return;
+ }
+
+ scrapeButton.disabled = true;
+ setStatus(scrapeStatus, 'Scraping...', false);
+
+ try {
+ const response = await fetch(`/api/scrape?url=${encodeURIComponent(url)}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ localStorage.removeItem('linklogAccessToken');
+ location.reload();
+ return;
+ }
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+ if (data.title) {
+ titleInput.value = data.title;
+ setStatus(scrapeStatus, 'Title loaded!', false);
+ } else {
+ setStatus(scrapeStatus, 'No title found', true);
+ }
+ } catch (error) {
+ console.error('Scrape error:', error);
+ setStatus(scrapeStatus, `Error: ${error.message}`, true);
+ } finally {
+ scrapeButton.disabled = false;
+ }
+ });
+
+ // Handle form submission
+ entryForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const url = urlInput.value.trim();
+ const title = titleInput.value.trim();
+ const comment = commentInput.value.trim();
+ const newTags = newTagsInput.value.trim();
+
+ if (!url || !title) {
+ setStatus(submitStatus, 'URL and title are required', true);
+ return;
+ }
+
+ // Combine selected tags and new tags
+ const tags = Array.from(selectedTags);
+ if (newTags) {
+ const newTagsList = newTags
+ .split(',')
+ .map((t) => t.trim())
+ .filter((t) => t);
+ tags.push(...newTagsList);
+ }
+
+ submitButton.disabled = true;
+ setStatus(submitStatus, 'Saving...', false);
+
+ try {
+ const response = await fetch('/api/links', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ title,
+ url,
+ comment,
+ tags,
+ }),
+ });
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ localStorage.removeItem('linklogAccessToken');
+ location.reload();
+ return;
+ }
+ const error = await response.json().catch(() => ({}));
+ throw new Error(error.detail || `HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+ setStatus(submitStatus, `Link saved to LinkLog${data.duplicate ? ' (updated)' : ''}!`, false);
+
+ // Redirect to user page after 1 second
+ if (currentUser) {
+ setTimeout(() => {
+ window.location.href = `/${encodeURIComponent(currentUser.username)}/`;
+ }, 1000);
+ }
+ } catch (error) {
+ console.error('Submit error:', error);
+ setStatus(submitStatus, `Error: ${error.message}`, true);
+ } finally {
+ submitButton.disabled = false;
+ }
+ });
+})();
diff --git a/frontend/static/style.css b/frontend/static/style.css
index 1fb58fa..19d9481 100644
--- a/frontend/static/style.css
+++ b/frontend/static/style.css
@@ -248,6 +248,31 @@ body::selection {
font-size: 1.1em;
}
+.new-entry-button {
+ padding: 10px 13px;
+ border: 1px solid var(--mauve);
+ border-radius: 7px;
+ background: var(--mauve);
+ color: var(--crust);
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.new-entry-button:hover {
+ background: var(--lavender);
+ border-color: var(--lavender);
+}
+
+.new-entry-button a {
+ color: inherit;
+ text-decoration: none;
+}
+
+.new-entry-button.hidden {
+ display: none;
+}
+
.auth-menu {
position: absolute;
z-index: 30;
@@ -768,6 +793,221 @@ button:disabled {
margin-top: 14px;
}
+/* Entry form styling */
+.entry-form {
+ max-width: 600px;
+ margin: 0 auto;
+ display: grid;
+ gap: 24px;
+}
+
+.entry-form.hidden,
+#auth-required.hidden {
+ display: none;
+}
+
+#auth-required {
+ max-width: 600px;
+ margin: 40px auto;
+ padding: 16px;
+ background: var(--surface-0);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ border-left: 4px solid var(--red);
+}
+
+#auth-required p {
+ margin: 0;
+ color: var(--text);
+}
+
+#auth-required a {
+ color: var(--lavender);
+ text-decoration: underline;
+}
+
+#auth-required a:hover {
+ color: var(--mauve);
+}
+
+.form-section {
+ display: grid;
+ gap: 8px;
+}
+
+.form-section label {
+ display: grid;
+ gap: 6px;
+}
+
+.form-section > legend {
+ font-weight: 600;
+ color: var(--text);
+ font-size: 0.95rem;
+ margin: 0;
+ padding: 0;
+}
+
+.form-section input[type='url'],
+.form-section input[type='text'],
+.form-section textarea {
+ padding: 10px 12px;
+ background: var(--mantle);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text);
+ font-family: inherit;
+ font-size: 0.95rem;
+ line-height: 1.4;
+}
+
+.form-section textarea {
+ resize: vertical;
+ min-height: 100px;
+}
+
+.form-section input[type='url']:focus,
+.form-section input[type='text']:focus,
+.form-section textarea:focus {
+ outline: none;
+ border-color: var(--lavender);
+ box-shadow: 0 0 0 3px rgba(180, 190, 254, 0.1);
+}
+
+.tag-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 10px;
+ padding: 12px;
+ background: var(--mantle);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+.tag-checkbox {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--subtext);
+ font-size: 0.9rem;
+ cursor: pointer;
+ user-select: none;
+}
+
+.tag-checkbox input {
+ cursor: pointer;
+}
+
+.form-section fieldset {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px;
+ margin: 0;
+}
+
+.form-section fieldset legend {
+ margin: 0;
+ padding: 0 6px;
+}
+
+.form-section fieldset label {
+ gap: 6px;
+}
+
+.form-section fieldset input[type='text'] {
+ width: 100%;
+}
+
+.form-section fieldset input[type='checkbox'] {
+ width: auto;
+ margin-right: 6px;
+ cursor: pointer;
+}
+
+.form-actions {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 12px;
+}
+
+.form-actions button,
+.form-actions a {
+ padding: 10px 16px;
+ border-radius: 8px;
+ font-weight: 600;
+ text-align: center;
+ text-decoration: none;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.form-actions button[type='submit'],
+#scrape-button:not(:disabled) {
+ background: var(--mauve);
+ border: 1px solid var(--mauve);
+ color: var(--crust);
+}
+
+.form-actions button[type='submit']:hover:not(:disabled) {
+ background: var(--lavender);
+ border-color: var(--lavender);
+}
+
+.form-actions button[type='submit']:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+#scrape-button {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ color: var(--text);
+}
+
+#scrape-button:not(:disabled):hover {
+ background: var(--surface-2);
+ border-color: var(--text);
+}
+
+#scrape-button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.form-actions a {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ color: var(--text);
+}
+
+.form-actions a:hover {
+ background: var(--surface-2);
+ border-color: var(--text);
+}
+
+.status {
+ padding: 12px;
+ border-radius: 8px;
+ font-size: 0.9rem;
+ line-height: 1.4;
+}
+
+.status.success {
+ background: rgba(131, 165, 152, 0.2);
+ border: 1px solid var(--teal);
+ color: var(--teal);
+}
+
+.status.error {
+ background: rgba(243, 139, 168, 0.2);
+ border: 1px solid var(--red);
+ color: var(--red);
+}
+
+.status.hidden {
+ display: none;
+}
+
@media (max-width: 600px) {
.container {
padding: 0 14px;
@@ -815,6 +1055,11 @@ button:disabled {
font-size: 0.9rem;
}
+ .new-entry-button {
+ padding: 8px 11px;
+ font-size: 0.9rem;
+ }
+
.logout-button {
font-size: 0.9rem;
}
diff --git a/frontend/templates/about.html b/frontend/templates/about.html
index f8b6dd3..8afdcaf 100644
--- a/frontend/templates/about.html
+++ b/frontend/templates/about.html
@@ -18,6 +18,7 @@
A quiet place for the links worth keeping.