Add Linkki broadcast image
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8 context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; } name:ph… (push) Successful in 48s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18 POSTGIS_VERSION=3 VCHORD_VERSION=0.5.3 context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 55s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3 context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:linkki-tiedotus oci_labels:… (push) Successful in 39s

This commit is contained in:
ajp_anton
2026-08-19 20:51:26 +00:00
parent 7e6dea8b24
commit 8ec3cde4f2
56 changed files with 13441 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Web app package for Linkki."""
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
import base64
import getpass
import hashlib
import hmac
import json
import os
from pathlib import Path
from webapp.config import ConfigFileError
PBKDF2_ALGORITHM = "sha256"
PBKDF2_ITERATIONS = 600000
SALT_BYTES = 16
SUPPORTED_TRANSLATION_LANGUAGES = ["en", "fi", "sv"]
SUPPORTED_UI_LANGUAGES = ["auto", "fi", "sv", "en"]
PERMISSION_KEYS = [
"edit_content",
"edit_targets",
"save_announcements",
"open_telegram",
"post_now",
"schedule",
"unschedule",
"unlink_telegram",
"delete_telegram",
"delete_storage",
]
def load_users(users_file: Path) -> dict[str, dict]:
if not users_file.exists():
return {}
try:
payload = json.loads(users_file.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
raise ConfigFileError(users_file, f"Invalid JSON: {error.msg}") from error
users = payload.get("users", [])
result: dict[str, dict] = {}
changed = False
for user in users:
username = str(user.get("username", "")).strip()
password_hash = str(user.get("password_hash", "")).strip()
if username and password_hash:
permissions_payload, permissions_changed = normalize_permissions_payload(user.get("permissions", {}))
if permissions_changed:
user["permissions"] = permissions_payload
changed = True
language = normalize_ui_language(user.get("language", "en"))
if user.get("language") != language:
user["language"] = language
changed = True
result[username] = {
"password_hash": password_hash,
"permissions": load_permissions(permissions_payload),
"language": language,
}
if changed:
users_file.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
return result
def load_permissions(payload: object) -> dict[str, bool]:
source = payload if isinstance(payload, dict) else {}
permissions: dict[str, bool] = {}
for key in PERMISSION_KEYS:
permissions[key] = as_permission_bool(source.get(key, False))
return permissions
def normalize_permissions_payload(payload: object) -> tuple[dict[str, object], bool]:
source = dict(payload) if isinstance(payload, dict) else {}
changed = not isinstance(payload, dict)
normalized = {key: source[key] for key in source if key in PERMISSION_KEYS}
if len(normalized) != len(source):
changed = True
for key in PERMISSION_KEYS:
normalized_value = as_permission_bool(source.get(key, False))
if source.get(key) != normalized_value:
changed = True
normalized[key] = normalized_value
return normalized, changed
def as_permission_bool(value: object) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, int):
return value != 0
if isinstance(value, str):
lowered = value.strip().lower()
return lowered in {"1", "true", "yes", "on"}
return False
def normalize_ui_language(value: object) -> str:
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in SUPPORTED_UI_LANGUAGES:
return lowered
return "en"
def normalize_translation_language(value: object) -> str:
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in SUPPORTED_TRANSLATION_LANGUAGES:
return lowered
return "en"
def set_user_language(users_file: Path, username: str, language: str) -> None:
normalized = normalize_ui_language(language)
try:
payload = json.loads(users_file.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
raise ConfigFileError(users_file, f"Invalid JSON: {error.msg}") from error
users = payload.get("users", [])
updated = False
for user in users:
if str(user.get("username", "")).strip() == username:
user["language"] = normalized
updated = True
break
if not updated:
raise ValueError(f"Unknown user: {username}")
users_file.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
def hash_password(password: str) -> str:
salt = os.urandom(SALT_BYTES)
digest = hashlib.pbkdf2_hmac(PBKDF2_ALGORITHM, password.encode("utf-8"), salt, PBKDF2_ITERATIONS)
return "$".join(
[
"pbkdf2_sha256",
str(PBKDF2_ITERATIONS),
base64.urlsafe_b64encode(salt).decode("ascii"),
base64.urlsafe_b64encode(digest).decode("ascii"),
]
)
def verify_password(password: str, password_hash: str) -> bool:
algorithm, iterations_text, salt_text, digest_text = password_hash.split("$", 3)
if algorithm != "pbkdf2_sha256":
return False
iterations = int(iterations_text)
salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
expected_digest = base64.urlsafe_b64decode(digest_text.encode("ascii"))
actual_digest = hashlib.pbkdf2_hmac(PBKDF2_ALGORITHM, password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(actual_digest, expected_digest)
def hash_password_cli() -> None:
password = getpass.getpass("Password: ")
password_repeat = getpass.getpass("Repeat password: ")
if password != password_repeat:
raise ValueError("Passwords did not match.")
print(hash_password(password))
if __name__ == "__main__":
hash_password_cli()
+57
View File
@@ -0,0 +1,57 @@
import json
import os
from dataclasses import dataclass
from pathlib import Path
DEFAULT_TIMEZONE = "UTC"
DEFAULT_POLL_SECONDS = 60
class ConfigFileError(RuntimeError):
def __init__(self, path: Path, message: str):
super().__init__(f"{path}: {message}")
self.path = path
self.message = message
@dataclass(frozen=True)
class WebAppConfig:
data_dir: Path
posts_dir: Path
config_dir: Path
users_file: Path
timezone: str
session_secret: str
scheduler_poll_seconds: int
def load_webapp_config() -> WebAppConfig:
data_dir = Path(os.environ.get("LINKKI_DATA_DIR", "data")).resolve()
config_dir = data_dir / "config"
posts_dir = data_dir / "posts"
config_dir.mkdir(parents=True, exist_ok=True)
posts_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / "app_config.json"
config_data: dict[str, object] = {}
if config_path.exists():
try:
config_data = json.loads(config_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
raise ConfigFileError(config_path, f"Invalid JSON: {error.msg}") from error
timezone = str(config_data.get("timezone", DEFAULT_TIMEZONE))
session_secret = str(config_data.get("session_secret", "change-me"))
poll_seconds = int(config_data.get("scheduler_poll_seconds", DEFAULT_POLL_SECONDS))
users_filename = str(config_data.get("users_file", "users.json"))
return WebAppConfig(
data_dir=data_dir,
posts_dir=posts_dir,
config_dir=config_dir,
users_file=config_dir / users_filename,
timezone=timezone,
session_secret=session_secret,
scheduler_poll_seconds=poll_seconds,
)
+42
View File
@@ -0,0 +1,42 @@
import json
from pathlib import Path
from webapp.auth import SUPPORTED_UI_LANGUAGES, normalize_translation_language
from webapp.config import ConfigFileError
TRANSLATIONS_DIR = Path(__file__).resolve().parent / "translations"
TRANSLATION_CATALOG_FILE = TRANSLATIONS_DIR / "catalog.json"
def load_translations(language: str) -> dict:
normalized = normalize_translation_language(language)
catalog = load_translation_catalog()
merged: dict[str, object] = {}
for key, translations in catalog.items():
if not isinstance(translations, dict):
continue
english_value = translations.get("en")
localized_value = translations.get(normalized)
if localized_value not in (None, ""):
merged[key] = localized_value
elif english_value is not None:
merged[key] = english_value
return merged
def load_translation_catalog() -> dict:
if not TRANSLATION_CATALOG_FILE.exists():
return {}
try:
payload = json.loads(TRANSLATION_CATALOG_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
detail = f"Invalid JSON: {error.msg}"
if "Invalid control character" in error.msg:
detail += " JSON strings cannot contain raw line breaks; use \\n instead."
raise ConfigFileError(TRANSLATION_CATALOG_FILE, detail) from error
return payload if isinstance(payload, dict) else {}
def language_options() -> list[dict[str, str]]:
return [{"code": code, "label_key": f"ui_language.{code}"} for code in SUPPORTED_UI_LANGUAGES]
+876
View File
@@ -0,0 +1,876 @@
:root {
color-scheme: light dark;
--bg: Canvas;
--fg: CanvasText;
--panel: color-mix(in srgb, Canvas 92%, CanvasText 8%);
--border: color-mix(in srgb, CanvasText 20%, Canvas 80%);
--green: #2f8f4e;
--red: #b33c3c;
--yellow: #b58a00;
--blue: #4b7bd0;
}
body {
margin: 0;
font-family: sans-serif;
background: var(--bg);
color: var(--fg);
overflow: hidden;
}
.layout {
display: grid;
grid-template-columns: 290px 1fr;
height: 100vh;
overflow: hidden;
}
.sidebar,
.content {
padding: 1rem;
min-height: 0;
}
.sidebar {
border-right: 1px solid var(--border);
background: var(--panel);
overflow-y: auto;
}
.content {
min-width: 0;
overflow-y: auto;
}
.sidebar-actions,
.create-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.sidebar-actions {
align-items: center;
justify-content: space-between;
}
.sidebar-actions form {
margin: 0;
}
.sidebar-link {
text-decoration: none;
color: inherit;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border);
}
.sidebar-link.active {
outline: 2px solid var(--blue);
}
.language-form {
margin-bottom: 1rem;
}
.language-form label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.create-form input {
flex: 1 1 auto;
min-width: 0;
}
.create-form button {
flex: 0 0 auto;
}
.create-form input,
input,
textarea,
select,
button {
font: inherit;
}
.announcement-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.announcement-entry {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.4rem;
align-items: stretch;
}
.announcement-item {
display: block;
padding: 0.75rem;
border: 1px solid var(--border);
text-decoration: none;
color: inherit;
border-radius: 0.35rem;
background: color-mix(in srgb, var(--panel) 94%, CanvasText 6%);
}
.announcement-item span,
.announcement-item small {
display: block;
}
.announcement-title-row {
display: flex !important;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
}
.status-primary-marker {
flex: 0 0 auto;
}
.status-primary-marker-pinned {
width: 0.8rem;
height: 0.8rem;
display: inline-block;
background-color: currentColor;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M8 3a1 1 0 0 0-1 1v2.2l2 2V11l-4 3v2h6v6l1-1 1-5h6v-2l-4-3V8.2l2-2V4a1 1 0 0 0-1-1H8Z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M8 3a1 1 0 0 0-1 1v2.2l2 2V11l-4 3v2h6v6l1-1 1-5h6v-2l-4-3V8.2l2-2V4a1 1 0 0 0-1-1H8Z'/%3E%3C/svg%3E");
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-size: contain;
mask-size: contain;
}
.status-primary-marker-pinned.status-published {
color: var(--green);
}
.status-primary-marker-pinned.status-failed {
color: var(--red);
}
.status-primary-marker-pinned.status-scheduled {
color: var(--yellow);
}
.status-primary-marker-pinned.status-modified {
color: var(--blue);
}
.announcement-title {
flex: 1 1 auto;
min-width: 0;
}
.announcement-meta {
margin-top: 0.35rem;
opacity: 0.8;
}
.announcement-item.active {
outline: 2px solid var(--blue);
}
.announcement-item:not(.active) {
opacity: 0.78;
color: color-mix(in srgb, var(--fg) 82%, transparent 18%);
}
.announcement-item.disabled-nav {
opacity: 0.6;
pointer-events: none;
}
.announcement-order-controls {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.announcement-order-controls form {
margin: 0;
}
.announcement-order-controls button {
width: 2rem;
height: 2rem;
padding: 0;
}
.status-symbol {
font-size: 1rem;
font-weight: 700;
line-height: 1;
}
.status-symbol.status-published {
color: var(--green);
}
.status-symbol.status-failed {
color: var(--red);
}
.status-symbol.status-scheduled {
color: var(--yellow);
}
.status-symbol.status-modified {
color: var(--blue);
}
.status-symbol.status-symbol-incomplete {
color: var(--red);
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.editor-header h1 {
margin: 0 0 0.2rem;
}
.editor-header p {
margin: 0;
opacity: 0.8;
}
.editor-tabs {
margin-bottom: 1rem;
display: flex;
flex-direction: column;
}
.editor-tab-list {
display: flex;
gap: 0.2rem;
flex-wrap: wrap;
margin-bottom: -1px;
padding-left: 0.45rem;
position: relative;
z-index: 1;
}
.editor-tab-button {
position: relative;
padding: 0.5rem 0.9rem;
border: 1px solid color-mix(in srgb, var(--border) 92%, CanvasText 8%);
border-bottom: none;
border-radius: 0.6rem 0.6rem 0 0;
background: color-mix(in srgb, var(--panel) 88%, Canvas 12%);
color: color-mix(in srgb, var(--fg) 82%, transparent 18%);
transform: translateY(0.3rem);
box-shadow: inset 0 -0.2rem 0 color-mix(in srgb, CanvasText 6%, transparent);
cursor: pointer;
}
.editor-tab-button.active {
background: var(--panel);
color: var(--fg);
transform: translateY(0);
box-shadow: none;
border-color: var(--border);
z-index: 2;
}
.editor-tab-button.tab-missing {
color: var(--red);
}
.editor-tab-panel[hidden] {
display: none;
}
.editor-tab-panel {
border: 1px solid var(--border);
border-radius: 0 0.75rem 0.75rem 0.75rem;
background: var(--panel);
padding: 1rem;
}
.editor-tab-panel.tab-panel-disabled {
opacity: 0.72;
}
.meta-panel {
display: grid;
grid-template-columns: minmax(240px, 2fr) minmax(220px, 1.1fr) minmax(180px, 1fr) minmax(220px, 1.1fr);
gap: 0.75rem;
align-items: start;
}
.meta-title-field {
grid-column: 1;
grid-row: 1;
}
.meta-global-link-preview {
grid-column: 1;
grid-row: 2;
}
.warning-text {
grid-column: 1 / -1;
margin: 0 0 0.25rem;
color: var(--red);
font-weight: 600;
}
.meta-panel label,
.telegram-target-fields,
.meta-stack {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.telegram-target-fields {
grid-column: 2;
grid-row: 1 / span 2;
gap: 0.5rem;
}
.meta-stack {
grid-column: 3;
grid-row: 1 / span 2;
align-self: stretch;
justify-content: space-between;
}
.meta-schedule-field {
grid-column: 4;
grid-row: 1;
}
.meta-delete-service-field {
grid-column: 4;
grid-row: 2;
align-self: start;
}
.meta-panel label.meta-checkbox {
flex-direction: row;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
.meta-panel label.meta-checkbox input[type="checkbox"] {
align-self: center;
}
.top-actions,
.image-controls,
.image-paste-controls,
.format-toolbar {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.top-actions,
.image-controls,
.image-paste-controls {
margin-top: 0.5rem;
margin-bottom: 0.75rem;
}
.image-import-modal {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: color-mix(in srgb, CanvasText 24%, transparent);
z-index: 20;
}
.image-import-modal[hidden] {
display: none;
}
.image-import-dialog {
width: min(560px, 100%);
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--border);
background: var(--panel);
box-shadow: 0 18px 48px color-mix(in srgb, CanvasText 24%, transparent);
}
.image-import-header,
.image-import-actions,
.telegram-cell-editor,
.telegram-cell-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.image-import-header,
.image-import-actions {
justify-content: space-between;
}
.image-import-header h2,
.image-import-help {
margin: 0;
}
.image-import-dialog label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.top-actions {
align-items: flex-start;
}
.top-actions button,
.top-actions .button-link {
padding: 0.3rem 0.55rem;
line-height: 1.2;
min-height: 0;
white-space: pre-line;
}
.button-link {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
color: inherit;
text-decoration: none;
background: ButtonFace;
}
.permission-disabled {
background-image:
linear-gradient(
45deg,
transparent calc(50% - 1px),
color-mix(in srgb, var(--red) 72%, transparent) calc(50% - 1px),
color-mix(in srgb, var(--red) 72%, transparent) calc(50% + 1px),
transparent calc(50% + 1px)
),
linear-gradient(
-45deg,
transparent calc(50% - 1px),
color-mix(in srgb, var(--red) 72%, transparent) calc(50% - 1px),
color-mix(in srgb, var(--red) 72%, transparent) calc(50% + 1px),
transparent calc(50% + 1px)
);
background-repeat: no-repeat;
background-size: 100% 100%;
border-color: color-mix(in srgb, var(--red) 60%, var(--border) 40%);
}
textarea.permission-disabled,
input.permission-disabled,
select.permission-disabled,
button.permission-disabled,
.button-link.permission-disabled {
background-origin: border-box;
}
.upload-label {
align-self: center;
font-size: 0.95rem;
}
.images-panel > h2 {
margin: 0 0 1rem;
}
.status-box,
.images-panel,
.language-panel,
.instructions-panel,
.preview-panel,
.pinning-panel {
border: 1px solid var(--border);
padding: 1rem;
margin-bottom: 1rem;
}
.instruction-section + .instruction-section {
margin-top: 1rem;
}
.language-panel.disabled {
opacity: 0.85;
}
.language-panel.disabled .language-body {
display: none;
}
.image-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.image-card {
display: flex;
flex-direction: column;
width: 140px;
border: 1px solid var(--border);
padding: 0.5rem;
background: var(--panel);
}
.image-card img {
max-width: 100%;
max-height: 140px;
width: auto;
height: auto;
object-fit: contain;
display: block;
margin: 0 auto 0.5rem;
}
.move-controls {
display: flex;
gap: 0.5rem;
margin: auto 0 0.5rem;
}
.move-controls button {
flex: 1;
}
.image-card button {
width: 100%;
}
.status-box {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.status-box-content {
flex: 1 1 24rem;
min-width: 0;
white-space: pre-wrap;
}
.status-box > button {
margin-left: auto;
align-self: flex-end;
flex: 0 0 auto;
}
.telegram-binding-panel h2,
.telegram-binding-help {
margin: 0 0 0.75rem;
}
.telegram-binding-body {
overflow-x: auto;
}
.telegram-mapping-table {
display: inline-table;
width: max-content;
border-collapse: collapse;
table-layout: fixed;
margin-bottom: 0.75rem;
}
.telegram-mapping-row {
border-top: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
}
.telegram-mapping-table th,
.telegram-mapping-table td {
padding: 0.55rem 0.65rem;
text-align: left;
vertical-align: middle;
border-right: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
}
.telegram-mapping-table th:last-child,
.telegram-mapping-table td:last-child {
border-right: none;
}
.telegram-mapping-label {
white-space: nowrap;
width: 1%;
}
.telegram-mapping-table thead th {
font-weight: 700;
border-bottom: 1px solid var(--border);
}
.telegram-mapping-col-link {
width: 12.5rem;
}
.telegram-mapping-cell {
min-width: 0;
}
.telegram-cell-editor,
.telegram-cell-actions {
min-width: 0;
flex-wrap: nowrap;
}
.telegram-cell-editor input {
flex: 1 1 auto;
min-width: 0;
}
.telegram-cell-actions .button-link,
.telegram-cell-actions button,
.telegram-cell-editor button {
white-space: nowrap;
}
.telegram-delete-button {
margin-top: 0.25rem;
}
.pinning-controls {
margin-bottom: 1rem;
}
.unpin-scheduler {
display: flex;
gap: 0.75rem;
align-items: end;
flex-wrap: wrap;
}
.unpin-scheduler label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.preview-panel {
margin-bottom: 0;
background: color-mix(in srgb, var(--panel) 96%, CanvasText 4%);
}
.editor-preview-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
}
.preview-content a {
color: var(--blue);
}
.preview-content {
white-space: pre-wrap;
line-height: 1.45;
}
.preview-content pre {
padding: 0.75rem;
overflow-x: auto;
border: 1px solid var(--border);
background: color-mix(in srgb, Canvas 92%, CanvasText 8%);
border-radius: 0.35rem;
}
.preview-content code {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
background: color-mix(in srgb, Canvas 88%, CanvasText 12%);
border: 1px solid color-mix(in srgb, var(--border) 85%, CanvasText 15%);
border-radius: 0.25rem;
padding: 0.08rem 0.28rem;
}
.preview-content pre code {
background: transparent;
border: 0;
border-radius: 0;
padding: 0;
}
.preview-content tg-spoiler {
color: color-mix(in srgb, var(--fg) 70%, var(--bg) 30%);
background: color-mix(in srgb, var(--fg) 10%, var(--bg) 90%);
border: 1px dashed color-mix(in srgb, var(--border) 65%, var(--fg) 35%);
border-radius: 0.25rem;
padding: 0.08rem 0.28rem;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.preview-content blockquote {
margin: 0.75rem 0;
padding-left: 0.75rem;
border-left: 3px solid var(--border);
opacity: 0.9;
}
@media (max-width: 1200px) {
.meta-panel {
grid-template-columns: minmax(240px, 1fr) minmax(220px, 1fr);
}
.meta-title-field,
.meta-global-link-preview,
.telegram-target-fields,
.meta-stack,
.meta-schedule-field,
.meta-delete-service-field {
grid-column: auto;
grid-row: auto;
}
}
@media (max-width: 760px) {
.meta-panel {
grid-template-columns: 1fr;
}
.editor-preview-layout {
grid-template-columns: 1fr;
}
}
body.busy {
cursor: progress;
}
.language-toolbar {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.75rem;
}
.language-heading {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.language-title-toggle {
display: inline-flex;
align-items: center;
gap: 0.55rem;
width: fit-content;
cursor: pointer;
}
.language-title-toggle h2 {
margin: 0;
}
.language-subtoggle {
margin-left: 1.9rem;
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
white-space: nowrap;
}
.secondary-button {
padding: 0.35rem 0.7rem;
}
.editor-input-panel {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.format-toolbar button {
padding: 0.3rem 0.55rem;
}
textarea {
width: 100%;
min-height: 240px;
resize: none;
overflow-y: hidden;
}
.danger {
border-color: var(--red);
}
.login-page {
display: grid;
place-items: center;
min-height: 100vh;
}
.login-card {
width: 320px;
border: 1px solid var(--border);
padding: 1rem;
background: var(--panel);
}
.login-card form,
.login-card label {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.login-language-form {
margin-bottom: 1rem;
}
.error {
color: var(--red);
}
@media (max-width: 900px) {
body {
overflow: auto;
}
.layout {
grid-template-columns: 1fr;
height: auto;
overflow: visible;
}
.sidebar,
.content {
overflow: visible;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--border);
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because it is too large Load Diff
+184
View File
@@ -0,0 +1,184 @@
import json
from dataclasses import dataclass
from pathlib import Path
from linkki_poster.models import Config
from webapp.config import ConfigFileError
@dataclass(frozen=True)
class TelegramChatTarget:
chat_id: str
name: str
channel_username: str
discussion_group_id: str
is_default: bool
@dataclass(frozen=True)
class TelegramBotTarget:
bot_id: str
name: str
token: str
chats: list[TelegramChatTarget]
is_default: bool
@dataclass(frozen=True)
class TelegramTargetCatalog:
bots: list[TelegramBotTarget]
default_bot_id: str
def load_telegram_target_catalog(config_dir: Path) -> TelegramTargetCatalog:
settings_file = config_dir / "telegram_config.json"
if not settings_file.exists():
return TelegramTargetCatalog(bots=[], default_bot_id="")
try:
payload = json.loads(settings_file.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
raise ConfigFileError(settings_file, f"Invalid JSON: {error.msg}") from error
if not isinstance(payload, dict) or not isinstance(payload.get("bots"), list):
raise ConfigFileError(settings_file, "Expected a top-level 'bots' list.")
bots = _load_multi_bot_catalog(payload)
default_bot_id = ""
for bot in bots:
if bot.is_default:
default_bot_id = bot.bot_id
break
return TelegramTargetCatalog(bots=bots, default_bot_id=default_bot_id)
def resolve_target_config(catalog: TelegramTargetCatalog, bot_id: str, chat_id: str) -> Config:
bot = next((item for item in catalog.bots if item.bot_id == bot_id), None)
if bot is None:
raise ValueError("Choose a Telegram bot before posting or scheduling.")
chat = next((item for item in bot.chats if item.chat_id == chat_id), None)
if chat is None:
raise ValueError("Choose a Telegram chat before posting or scheduling.")
return Config(
bot_token=bot.token,
chat_id=chat.chat_id,
channel_username=chat.channel_username,
discussion_group_id=chat.discussion_group_id,
)
def resolve_poster_config(config_dir: Path, bot_id: str, chat_id: str) -> Config:
return resolve_target_config(load_telegram_target_catalog(config_dir), bot_id, chat_id)
def default_target_selection(catalog: TelegramTargetCatalog) -> tuple[str, str]:
if not catalog.default_bot_id:
return "", ""
bot = next((item for item in catalog.bots if item.bot_id == catalog.default_bot_id), None)
if bot is None:
return "", ""
for chat in bot.chats:
if chat.is_default:
return bot.bot_id, chat.chat_id
return bot.bot_id, ""
def serialise_catalog_for_client(catalog: TelegramTargetCatalog) -> dict:
return {
"default_bot_id": catalog.default_bot_id,
"bots": [
{
"id": bot.bot_id,
"name": bot.name,
"default": bot.is_default,
"default_chat_id": next((chat.chat_id for chat in bot.chats if chat.is_default), ""),
"chats": [
{
"chat_id": chat.chat_id,
"name": chat.name,
"has_discussion_group": bool(chat.discussion_group_id),
"channel_username": chat.channel_username,
"channel_internal_id": extract_internal_chat_id(chat.chat_id),
"discussion_group_internal_id": extract_internal_chat_id(chat.discussion_group_id),
"default": chat.is_default,
}
for chat in bot.chats
],
}
for bot in catalog.bots
],
}
def resolve_target_labels(catalog: TelegramTargetCatalog, bot_id: str, chat_id: str) -> tuple[str, str]:
bot_label = bot_id
chat_label = chat_id
for bot in catalog.bots:
if bot.bot_id != bot_id:
continue
bot_label = bot.name
for chat in bot.chats:
if chat.chat_id == chat_id:
chat_label = chat.name
break
break
return bot_label, chat_label
def extract_internal_chat_id(chat_id: str) -> str:
chat_id = str(chat_id).strip()
if chat_id.startswith("-100") and len(chat_id) > 4:
return chat_id[4:]
return chat_id.lstrip("-")
def _load_multi_bot_catalog(payload: dict) -> list[TelegramBotTarget]:
bots: list[TelegramBotTarget] = []
for bot_payload in payload.get("bots", []):
if not isinstance(bot_payload, dict):
continue
name = str(bot_payload.get("display_name", "")).strip()
token = str(bot_payload.get("token", "")).strip()
bot_id = str(bot_payload.get("id", "")).strip() or token.split(":", 1)[0].strip()
if not name or not token or not bot_id:
continue
chats = _load_chats(bot_payload.get("channels", []))
if not chats:
continue
bots.append(
TelegramBotTarget(
bot_id=bot_id,
name=name,
token=token,
chats=chats,
is_default=bool(bot_payload.get("default", False)),
)
)
return bots
def _load_chats(raw_chats: object) -> list[TelegramChatTarget]:
chats: list[TelegramChatTarget] = []
if not isinstance(raw_chats, list):
return chats
for chat_payload in raw_chats:
if not isinstance(chat_payload, dict):
continue
name = str(chat_payload.get("display_name", "")).strip()
chat_id = str(chat_payload.get("channel_id", "")).strip()
if not name or not chat_id:
continue
chats.append(
TelegramChatTarget(
chat_id=chat_id,
name=name,
channel_username=str(chat_payload.get("channel_username", "")).strip(),
discussion_group_id=str(chat_payload.get("discussion_group_id", "")).strip(),
is_default=bool(chat_payload.get("default", False)),
)
)
return chats
+121
View File
@@ -0,0 +1,121 @@
<!doctype html>
<html lang="{{ effective_ui_language }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ translations["app.title"] }}</title>
<link rel="icon" type="image/png" href="{{ static_asset_url('favicon.png') }}">
<link rel="stylesheet" href="{{ static_asset_url('app.css') }}">
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-actions">
<a class="sidebar-link {% if current_path == '/' %}active{% endif %}" href="/">{{ translations["nav.instructions"] }}</a>
<form method="post" action="/logout">
<button type="submit">{{ translations["nav.sign_out"] }}</button>
</form>
</div>
<form method="post" action="/language" class="language-form">
<input type="hidden" name="return_to" value="{{ current_path }}">
<label>
<span>{{ translations["nav.interface_language"] }}</span>
<select name="ui_language" onchange="this.form.submit()">
{% for option in language_options %}
<option value="{{ option.code }}" {% if option.code == ui_language %}selected{% endif %}>{{ translations[option.label_key] }}</option>
{% endfor %}
</select>
</label>
</form>
<form method="post" action="/announcements" class="create-form">
<input type="hidden" name="return_to" value="{{ current_path }}">
<input type="text" name="title" placeholder="{{ translations['create.placeholder'] }}" required class="{% if not permissions.save_announcements %}permission-disabled{% endif %}" {% if not permissions.save_announcements %}disabled title="{{ translations['create.permission_denied'] }}"{% endif %}>
<button type="submit" class="{% if not permissions.save_announcements %}permission-disabled{% endif %}" {% if not permissions.save_announcements %}disabled title="{{ translations['create.permission_denied'] }}"{% endif %}>{{ translations["create.button"] }}</button>
</form>
{% if create_error %}
<p class="error">{{ create_error }}</p>
{% endif %}
<nav class="announcement-list">
{% for item in announcements %}
<div class="announcement-entry">
<a class="announcement-item {% if broadcast and item.directory == broadcast.directory %}active{% endif %}" href="/announcements/{{ item.directory }}">
<span class="announcement-title-row">
{% if item.sidebar_has_pins %}
<span class="status-primary-marker status-{{ item.sidebar_pin_status }} status-primary-marker-pinned" aria-hidden="true"></span>
{% endif %}
{% if item.status_symbol %}
<span class="status-symbol status-{{ item.status }} {% if item.sidebar_links_incomplete %}status-symbol-incomplete{% endif %}">{{ item.status_symbol }}</span>
{% endif %}
<span class="announcement-title">{{ item.title }}</span>
</span>
<small>{{ item.target_summary }}</small>
{% for meta_line in item.sidebar_meta_lines %}
<small class="announcement-meta">{{ meta_line }}</small>
{% endfor %}
</a>
<div class="announcement-order-controls">
<form method="post" action="/announcements/{{ item.directory }}/move">
<input type="hidden" name="direction" value="up">
<input type="hidden" name="return_to" value="{{ current_path }}">
<button type="submit" class="{% if not permissions.save_announcements %}permission-disabled{% endif %}" {% if not item.can_move_up or not permissions.save_announcements %}disabled{% endif %}>&uarr;</button>
</form>
<form method="post" action="/announcements/{{ item.directory }}/move">
<input type="hidden" name="direction" value="down">
<input type="hidden" name="return_to" value="{{ current_path }}">
<button type="submit" class="{% if not permissions.save_announcements %}permission-disabled{% endif %}" {% if not item.can_move_down or not permissions.save_announcements %}disabled{% endif %}>&darr;</button>
</form>
</div>
</div>
{% endfor %}
</nav>
</aside>
<main class="content">
{% block content %}{% endblock %}
</main>
</div>
<script>
(function () {
const key = "linkki.sidebar.scrollTop";
const sidebar = document.querySelector(".sidebar");
if (!sidebar) return;
const restore = () => {
const raw = window.sessionStorage.getItem(key);
if (!raw) return;
const scrollTop = Number(raw);
if (!Number.isFinite(scrollTop)) return;
sidebar.scrollTop = scrollTop;
window.requestAnimationFrame(() => {
sidebar.scrollTop = scrollTop;
});
};
const persist = () => {
window.sessionStorage.setItem(key, String(sidebar.scrollTop));
};
restore();
sidebar.addEventListener("scroll", persist, { passive: true });
window.addEventListener("beforeunload", persist);
document.addEventListener("click", (event) => {
const link = event.target.closest("a");
if (link && link.closest(".sidebar")) {
persist();
}
});
document.addEventListener("submit", (event) => {
const form = event.target;
if (form instanceof HTMLFormElement && form.closest(".sidebar")) {
persist();
}
});
})();
</script>
</body>
</html>
@@ -0,0 +1,142 @@
{% extends "base.html" %}
{% block content %}
<section id="editor-root"
data-broadcast='{{ broadcast | tojson }}'
data-permissions='{{ permissions | tojson }}'
data-timezone='{{ timezone }}'
data-translations='{{ translations | tojson }}'>
<header class="editor-header">
<div>
<h1>{{ broadcast.metadata.title }}</h1>
<p>{{ broadcast.directory }}</p>
</div>
<div class="top-actions">
<button type="button" id="save-button">{{ translations["editor.save"] }}</button>
<button type="button" id="publish-button">{{ translations["editor.post_now"] }}</button>
<button type="button" id="schedule-button">{{ translations["editor.schedule"] }}</button>
<button type="button" id="delete-storage-button" class="danger">{{ translations["editor.delete_storage"] }}</button>
</div>
</header>
<section class="editor-tabs">
<div class="editor-tab-list" role="tablist" aria-label="{{ translations['editor.settings_tabs'] }}">
<button type="button" class="editor-tab-button active" id="editor-tab-main" data-tab="main" role="tab" aria-selected="true">{{ translations["editor.tab_main"] }}</button>
<button type="button" class="editor-tab-button" id="editor-tab-telegram" data-tab="telegram" role="tab" aria-selected="false">{{ translations["editor.tab_telegram"] }}</button>
<button type="button" class="editor-tab-button" id="editor-tab-pinning" data-tab="pinning" role="tab" aria-selected="false">{{ translations["editor.tab_pinning"] }}</button>
<button type="button" class="editor-tab-button" id="editor-tab-logs" data-tab="logs" role="tab" aria-selected="false">{{ translations["editor.tab_logs"] }}</button>
</div>
<section class="editor-tab-panel active" id="editor-tab-panel-main" data-tab-panel="main" role="tabpanel" aria-labelledby="editor-tab-main">
<section class="meta-panel">
<label class="meta-title-field">{{ translations["editor.title"] }}
<input type="text" id="title-input" value="{{ broadcast.metadata.title }}">
</label>
<label class="meta-checkbox meta-global-link-preview">
<input type="checkbox" id="disable-link-previews-global">
<span>{{ translations["editor.disable_link_previews_global"] }}</span>
</label>
<div class="telegram-target-fields">
<label>{{ translations["editor.telegram_bot"] }}
<select id="telegram-bot-select"></select>
</label>
<label>{{ translations["editor.telegram_chat"] }}
<select id="telegram-chat-select"></select>
</label>
</div>
<div class="meta-stack">
<label>{{ translations["editor.language_order"] }}
<select id="language-order-select">
<option value="fi,sv,en">{{ translations["editor.language_order.fi_sv_en"] }}</option>
<option value="fi,en,sv">{{ translations["editor.language_order.fi_en_sv"] }}</option>
<option value="sv,fi,en">{{ translations["editor.language_order.sv_fi_en"] }}</option>
<option value="sv,en,fi">{{ translations["editor.language_order.sv_en_fi"] }}</option>
<option value="en,fi,sv">{{ translations["editor.language_order.en_fi_sv"] }}</option>
<option value="en,sv,fi">{{ translations["editor.language_order.en_sv_fi"] }}</option>
</select>
</label>
</div>
<label class="meta-schedule-field">{{ translations["editor.schedule_label"] }}
<input type="datetime-local" id="schedule-input">
<small>{{ translations["editor.schedule_timezone"] }}: {{ timezone }}</small>
</label>
</section>
</section>
<section class="editor-tab-panel" id="editor-tab-panel-telegram" data-tab-panel="telegram" role="tabpanel" aria-labelledby="editor-tab-telegram" hidden>
<section id="telegram-binding-panel" class="telegram-binding-panel"></section>
<button type="button" id="delete-telegram-button" class="danger telegram-delete-button">{{ translations["editor.delete_telegram"] }}</button>
</section>
<section class="editor-tab-panel" id="editor-tab-panel-pinning" data-tab-panel="pinning" role="tabpanel" aria-labelledby="editor-tab-pinning" hidden>
<section class="pinning-panel">
<div class="meta-stack pinning-controls">
<label>{{ translations["editor.discussion_pinning"] }}
<select id="discussion-pinning-select">
<option value="none">{{ translations["editor.discussion_pinning.none"] }}</option>
<option value="first">{{ translations["editor.discussion_pinning.first"] }}</option>
<option value="last">{{ translations["editor.discussion_pinning.last"] }}</option>
<option value="all">{{ translations["editor.discussion_pinning.all"] }}</option>
</select>
</label>
<label class="meta-checkbox meta-delete-service-field">
<input type="checkbox" id="delete-pin-service-messages">
<span>{{ translations["editor.delete_pin_service_messages"] }}</span>
</label>
</div>
<div class="unpin-scheduler">
<label>{{ translations["editor.unpin_schedule_label"] }}
<input type="datetime-local" id="unpin-schedule-input">
</label>
<button type="button" id="unpin-schedule-button">{{ translations["editor.schedule_unpin"] }}</button>
</div>
</section>
</section>
<section class="editor-tab-panel" id="editor-tab-panel-logs" data-tab-panel="logs" role="tabpanel" aria-labelledby="editor-tab-logs" hidden>
<section class="status-box" id="status-box">
<div class="status-box-content" id="status-box-content"></div>
<button type="button" id="clear-status-button">{{ translations["editor.clear_status"] }}</button>
</section>
</section>
</section>
<section class="images-panel">
<h2>{{ translations["editor.global_images"] }}</h2>
<div class="image-controls">
<span class="upload-label">{{ translations["editor.add_global_images"] }}</span>
<input type="file" id="upload-global" accept="image/*" multiple>
</div>
<div class="image-paste-controls">
<button type="button" id="open-global-image-import-button" class="secondary-button">{{ translations["editor.open_image_import"] }}</button>
</div>
<div class="image-list" data-scope="global"></div>
</section>
<section id="languages-container"></section>
</section>
<div id="image-import-modal" class="image-import-modal" hidden>
<div class="image-import-dialog" role="dialog" aria-modal="true" aria-labelledby="image-import-title">
<div class="image-import-header">
<h2 id="image-import-title">{{ translations["editor.image_import_title"] }}</h2>
<button type="button" id="image-import-close-button" class="secondary-button">{{ translations["editor.close_image_import"] }}</button>
</div>
<p class="image-import-help">{{ translations["editor.image_import_help"] }}</p>
<label>{{ translations["editor.image_import_format"] }}
<select id="image-import-format-select">
<option value="auto">{{ translations["editor.image_import_format.auto"] }}</option>
<option value="png">{{ translations["editor.image_import_format.png"] }}</option>
<option value="jpeg">{{ translations["editor.image_import_format.jpeg"] }}</option>
</select>
</label>
<label>{{ translations["editor.image_import_url"] }}
<input type="url" id="image-import-url-input" placeholder="{{ translations['editor.image_import_url_placeholder'] }}">
</label>
<div class="image-import-actions">
<button type="button" id="image-import-submit-url-button">{{ translations["editor.import_image_url_submit"] }}</button>
<button type="button" id="image-import-cancel-button" class="secondary-button">{{ translations["editor.cancel_image_import"] }}</button>
</div>
</div>
</div>
<script src="{{ static_asset_url('app.js') }}"></script>
{% endblock %}
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block content %}
<section class="instructions-panel">
<h1>{{ translations["instructions.title"] }}</h1>
<section class="instruction-section">
<h2>{{ translations["instructions.quick_start_title"] }}</h2>
<ul>
{% for item in translations["instructions.quick_start_items"] %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</section>
<section class="instruction-section">
<h2>{{ translations["instructions.states_title"] }}</h2>
<ul>
{% for item in translations["instructions.states_items"] %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</section>
<section class="instruction-section">
<h2>{{ translations["instructions.formatting_title"] }}</h2>
<ul>
{% for item in translations["instructions.formatting_items"] %}
<li><strong>{{ item.label }}</strong>: <code>{{ item.example }}</code></li>
{% endfor %}
</ul>
</section>
<section class="instruction-section">
<h2>{{ translations["instructions.posted_title"] }}</h2>
<ul>
{% for item in translations["instructions.posted_items"] %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</section>
</section>
{% endblock %}
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="{{ effective_ui_language }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ translations["app.title"] }}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="login-page">
<main class="login-card">
<h1>{{ translations["login.title"] }}</h1>
<form method="post" action="/login-language" class="login-language-form">
<label>{{ translations["nav.interface_language"] }}
<select name="ui_language" onchange="this.form.submit()">
{% for option in login_language_options %}
<option value="{{ option.code }}" {% if option.code == login_ui_language %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
</form>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/login">
<label>{{ translations["login.username"] }} <input type="text" name="username" required autofocus></label>
<label>{{ translations["login.password"] }} <input type="password" name="password" required></label>
<button type="submit">{{ translations["login.sign_in"] }}</button>
</form>
</main>
</body>
</html>
File diff suppressed because it is too large Load Diff