Sync infra config: HA/Zabbix relocation, Immich storage move, new services

Accumulated uncommitted infra changes:
- Caddyfile: repoint HA/Zabbix to 192.168.1.4/.3, add ~20 new site routes
- Immich: move media to /mnt/smsg, enable CUDA ML, mem limits, rewrite backup.sh
- Add service stacks: agap-mcp, anki, family, freshrss, iperf3, kanboard,
  linkwarden, qbittorrent, radicale, syncthing, vikunja, windows
- openwebui: enable API keys; ollama: drop CPU fallback
- seafile/zabbix: extra_hosts entries; matrix: add user juris
- Remove pihole stack and stale wiki/migrate.py
- Ignore marketplace-mcp (standalone repo) and linkwarden runtime data

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
Alvis
2026-07-04 13:28:04 +00:00
parent 4363130163
commit b8efe4732d
51 changed files with 2813 additions and 374 deletions

6
family/Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM mediawiki:latest
RUN apt-get update && apt-get install -y ffmpeg unzip && rm -rf /var/lib/apt/lists/*
RUN curl -sL "https://extdist.wmflabs.org/dist/extensions/TimedMediaHandler-REL1_44-ef5edcf.tar.gz" \
| tar -xz -C /var/www/html/extensions/

BIN
family/IMG_0448.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

165
family/LocalSettings.php Normal file
View File

@@ -0,0 +1,165 @@
<?php
# This file was automatically generated by the MediaWiki 1.45.3
# installer. If you make manual changes, please keep track in case you
# need to recreate them later.
#
# See includes/MainConfigSchema.php for all configurable settings
# and their default values, but don't forget to make changes in _this_
# file, not there.
#
# Further documentation for configuration settings may be found at:
# https://www.mediawiki.org/wiki/Manual:Configuration_settings
# Protect against web entry
if ( !defined( 'MEDIAWIKI' ) ) {
exit;
}
## Uncomment this to disable output compression
# $wgDisableOutputCompression = true;
$wgSitename = "РодоВики";
## The URL base path to the directory containing the wiki;
## defaults for all runtime URL paths are based off of this.
## For more information on customizing the URLs
## (like /w/index.php/Page_title to /wiki/Page_title) please see:
## https://www.mediawiki.org/wiki/Manual:Short_URL
$wgScriptPath = "";
## The protocol and server name to use in fully-qualified URLs
$wgServer = "https://family.alogins.net";
## The URL path to static resources (images, scripts, etc.)
$wgResourceBasePath = $wgScriptPath;
## The URL paths to the logo. Make sure you change this from the default,
## or else you'll overwrite your logo when you upgrade!
$wgLogos = [
'1x' => "$wgResourceBasePath/images/logo.jpg",
'icon' => "$wgResourceBasePath/images/logo.jpg",
];
## UPO means: this is also a user preference option
$wgEnableEmail = true;
$wgEnableUserEmail = true; # UPO
$wgEmergencyContact = "";
$wgPasswordSender = "";
$wgEnotifUserTalk = false; # UPO
$wgEnotifWatchlist = false; # UPO
$wgEmailAuthentication = true;
## Database settings
$wgDBtype = "mysql";
$wgDBserver = "db";
$wgDBname = "mediawiki";
$wgDBuser = "mw_k7px2q";
$wgDBpassword = "Vt9#mLqR4wXn8bZ2";
# MySQL specific settings
$wgDBprefix = "fw";
$wgDBssl = false;
# MySQL table options to use during installation or update
$wgDBTableOptions = "ENGINE=InnoDB, DEFAULT CHARSET=binary";
# Shared database table
# This has no effect unless $wgSharedDB is also set.
$wgSharedTables[] = "actor";
## Shared memory settings
$wgMainCacheType = CACHE_ACCEL;
$wgMemCachedServers = [];
## To enable image uploads, make sure the 'images' directory
## is writable, then set this to true:
$wgEnableUploads = true;
$wgMaxUploadSize = 20 * 1024 * 1024; // 20MB
$wgFileExtensions = array_merge( $wgFileExtensions, [
'png', 'gif', 'jpg', 'jpeg', 'webp',
'mp4', 'webm', 'ogv',
'mp3', 'ogg', 'oga', 'wav', 'flac',
] );
$wgUseImageMagick = true;
$wgImageMagickConvertCommand = "/usr/bin/convert";
# InstantCommons allows wiki to use images from https://commons.wikimedia.org
$wgUseInstantCommons = false;
# Periodically send a pingback to https://www.mediawiki.org/ with basic data
# about this MediaWiki instance. The Wikimedia Foundation shares this data
# with MediaWiki developers to help guide future development efforts.
$wgPingback = true;
# Site language code, should be one of the list in ./includes/languages/data/Names.php
$wgLanguageCode = "ru";
# Time zone
$wgLocaltimezone = "UTC";
## Set $wgCacheDirectory to a writable directory on the web server
## to make your wiki go slightly faster. The directory should not
## be publicly accessible from the web.
#$wgCacheDirectory = "$IP/cache";
$wgSecretKey = "5156b70f5486793efade503a2301caf53b9fe241505868a90e33509814fb7d1f";
# Changing this will log out all existing sessions.
$wgAuthenticationTokenVersion = "1";
# Bust ResourceLoader CSS cache
$wgCacheEpoch = '20260403000001';
# Site upgrade key. Must be set to a string (default provided) to turn on the
# web installer while LocalSettings.php is in place
$wgUpgradeKey = "da11c2f2774f498f";
## For attaching licensing metadata to pages, and displaying an
## appropriate copyright notice / icon. GNU Free Documentation
## License and Creative Commons licenses are supported so far.
$wgRightsPage = ""; # Set to the title of a wiki page that describes your license/copyright
$wgRightsUrl = "";
$wgRightsText = "";
$wgRightsIcon = "";
# Path to the GNU diff3 utility. Used for conflict resolution.
$wgDiff3 = "/usr/bin/diff3";
## Default skin: you can change the default skin. Use the internal symbolic
## names, e.g. 'vector' or 'monobook':
$wgDefaultSkin = "vector-2022";
# Enabled skins.
# The following skins were automatically enabled:
wfLoadSkin( 'MinervaNeue' );
wfLoadSkin( 'MonoBook' );
wfLoadSkin( 'Timeless' );
wfLoadSkin( 'Vector' );
wfLoadExtension( 'Cite' );
wfLoadExtension( 'MultimediaViewer' );
wfLoadExtension( 'ParserFunctions' );
wfLoadExtension( 'VisualEditor' );
wfLoadExtension( 'TimedMediaHandler' );
$wgDefaultUserOptions['visualeditor-enable'] = 1;
$wgDefaultUserOptions['visualeditor-editor'] = 'visualeditor';
$wgVisualEditorParsoidAutoConfig = true;
# End of automatically generated settings.
# Add more configuration options below.
# Restrict all access to logged-in users only
$wgGroupPermissions['*']['read'] = false;
$wgGroupPermissions['*']['edit'] = false;
$wgGroupPermissions['*']['createaccount'] = false;
# Only admins can create accounts
$wgGroupPermissions['sysop']['createaccount'] = true;

Binary file not shown.

32
family/docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
services:
mediawiki:
build: .
image: mediawiki-tmh:latest
restart: unless-stopped
ports:
- "8099:80"
volumes:
- /mnt/ssd/dbs/wiki/mediawiki_images:/var/www/html/images
- ./LocalSettings.php:/var/www/html/LocalSettings.php # uncomment after initial setup
- ./IMG_0448.JPG:/var/www/html/images/logo.jpg
- ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini
environment:
MEDIAWIKI_DB_HOST: db
MEDIAWIKI_DB_NAME: mediawiki
MEDIAWIKI_DB_USER: mw_k7px2q
MEDIAWIKI_DB_PASSWORD: Vt9#mLqR4wXn8bZ2
depends_on:
- db
db:
image: mariadb:lts
restart: unless-stopped
environment:
MYSQL_DATABASE: mediawiki
MYSQL_USER: mw_k7px2q
MYSQL_PASSWORD: Vt9#mLqR4wXn8bZ2
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
volumes:
- /mnt/ssd/dbs/wiki/mediawiki_db:/var/lib/mysql

458
family/migrate.py Normal file
View File

@@ -0,0 +1,458 @@
#!/usr/bin/env python3
"""OtterWiki → MediaWiki migration script."""
import argparse
import re
import subprocess
from pathlib import Path
import requests
REPO = Path('/mnt/ssd/dbs/otter/app-data/repository')
API = 'http://localhost:8099/api.php'
_FN_DEF = re.compile(r'^\[(\^[^\]]+)\]:\s*(.*)')
# Cached pandoc availability (None = not yet checked)
_PANDOC_AVAILABLE: bool | None = None
def _cap(s: str) -> str:
"""Title-case: capitalize first letter of each word."""
return s.title() if s else s
def _pandoc_available() -> bool:
global _PANDOC_AVAILABLE
if _PANDOC_AVAILABLE is None:
try:
_PANDOC_AVAILABLE = subprocess.run(
['pandoc', '--version'], capture_output=True
).returncode == 0
except FileNotFoundError:
_PANDOC_AVAILABLE = False
return _PANDOC_AVAILABLE
# ---------------------------------------------------------------------------
# MediaWiki session
# ---------------------------------------------------------------------------
def mw_login(user: str, password: str):
s = requests.Session()
r = s.get(API, params={'action': 'query', 'meta': 'tokens', 'type': 'login', 'format': 'json'})
token = r.json()['query']['tokens']['logintoken']
s.post(API, data={'action': 'login', 'lgname': user, 'lgpassword': password,
'lgtoken': token, 'format': 'json'})
r = s.get(API, params={'action': 'query', 'meta': 'tokens', 'format': 'json'})
csrf = r.json()['query']['tokens']['csrftoken']
return s, csrf
# ---------------------------------------------------------------------------
# Title determination
# ---------------------------------------------------------------------------
def page_title(md_path: Path) -> str:
parts = md_path.relative_to(REPO).parts
if md_path.name == 'home.md' and len(parts) == 1:
return 'Заглавная страница'
return _cap(md_path.stem)
# ---------------------------------------------------------------------------
# Markdown → wikitext conversion
# ---------------------------------------------------------------------------
def convert_pandoc(text: str) -> str:
return subprocess.run(
['pandoc', '-f', 'markdown', '-t', 'mediawiki'],
input=text, capture_output=True, text=True
).stdout
def convert_python(text: str, skip_info: bool = False) -> str:
lines = text.split('\n')
# Collect footnote definitions in one pass
footnotes: dict[str, str] = {}
for line in lines:
m = _FN_DEF.match(line)
if m:
footnotes[m.group(1)] = m.group(2)
def replace_fn(m):
key = m.group(0)[1:-1] # strip outer [ ] to match footnotes dict keys
content = footnotes.get(key, m.group(0))
content = re.sub(r'\[([^\]^][^\]]*)\]\(([^)]+)\)', r'[\2 \1]', content)
return f'<ref>{content}</ref>'
out = []
for line in lines:
if _FN_DEF.match(line):
continue
m = re.match(r'^(#{1,6})\s+(.*)', line)
if m:
eq = '=' * len(m.group(1))
out.append(f'{eq} {m.group(2)} {eq}')
continue
if re.match(r'^---+$', line.strip()):
out.append('----')
continue
line = re.sub(r'^(\s*)-(\s)', r'\1*\2', line)
line = re.sub(r'\*\*\*(.+?)\*\*\*', r"'''''\1'''''", line)
line = re.sub(r'\*\*(.+?)\*\*', r"'''\1'''", line)
line = re.sub(r'\*(.+?)\*', r"''\1''", line)
line = re.sub(r'(?<!!)\[([^\]^!][^\]]*)\]\(([^)]+)\)', r'[\2 \1]', line)
line = re.sub(r'\[\^\S+?\]', replace_fn, line)
out.append(line)
result = convert_tables('\n'.join(out), skip_info=skip_info)
if footnotes:
result += '\n<references />'
return result
def _split_cells(line: str) -> list[str]:
"""Split a markdown table row on | but not inside [[ ]]."""
cells = []
depth = 0
current = []
i = 0
# Strip leading/trailing |
line = line.strip()
if line.startswith('|'):
line = line[1:]
if line.endswith('|'):
line = line[:-1]
while i < len(line):
if line[i:i+2] == '[[':
depth += 1
current.append('[[')
i += 2
elif line[i:i+2] == ']]':
depth -= 1
current.append(']]')
i += 2
elif line[i] == '|' and depth == 0:
cells.append(''.join(current).strip())
current = []
i += 1
else:
current.append(line[i])
i += 1
cells.append(''.join(current).strip())
return cells
def convert_tables(text: str, skip_info: bool = False) -> str:
lines = text.split('\n')
out = []
in_table = False
skip_table = False
for line in lines:
if re.match(r'^\|', line):
cells = _split_cells(line)
if all(re.match(r'^:?-+:?$', c) for c in cells if c):
# Separator row: start or continue table
if not in_table:
header_line = out.pop() if out else ''
hcells = _split_cells(header_line)
# Blank-header table (all-empty header cells) = OtterWiki info table
if skip_info and all(c == '' for c in hcells):
skip_table = True
in_table = True
else:
out += ['{| class="wikitable"', '|-', '! ' + ' !! '.join(hcells)]
in_table = True
skip_table = False
if not skip_table:
out.append('|-')
else:
if in_table:
if not skip_table:
out.append('|-')
out.append('| ' + ' || '.join(cells))
# else: skip info table row
else:
out.append(line)
else:
if in_table:
if not skip_table:
out.append('|}')
in_table = False
skip_table = False
out.append(line)
if in_table and not skip_table:
out.append('|}')
return '\n'.join(out)
_PERSON_FIELD_MAP = {
'родился': 'родился', 'родилась': 'родился',
'умер': 'умер', 'умерла': 'умер',
'отец': 'отец', 'мать': 'мать',
'супруг': 'супруг', 'супруга': 'супруг', 'муж': 'супруг', 'жена': 'супруг',
'дети': 'дети', 'ребёнок': 'дети',
'братья': 'братья', 'брат': 'братья', 'сестра': 'братья',
'сёстры': 'братья', 'сестры': 'братья',
'место рождения': 'место_рождения', 'место_рождения': 'место_рождения',
'прочее': 'прочее',
}
_PERSONA_PARAM_ORDER = ['родился', 'место_рождения', 'умер', 'отец', 'мать', 'супруг', 'дети', 'братья', 'прочее']
_PLACE_FIELD_MAP = {
'тип': 'тип',
'статус': 'статус',
'страна': 'страна',
'регион': 'регион', 'область': 'регион',
'район': 'район', 'расположение': 'район', 'самоуправление': 'район',
'река': 'река',
'основана': 'основана', 'основан': 'основана',
'население': 'население',
'адрес': 'адрес',
'период': 'период', 'годы': 'период',
'жильцы': 'жильцы', 'жильцы/семья': 'жильцы', 'семейное имя': 'прочее',
'латв. название': 'назв_латыш',
'белор. название': 'назв_белор',
'координаты': 'координаты',
'сайт': 'сайт',
'телефон': 'телефон', 'email': 'телефон',
'полное название': 'прочее', 'классы': 'прочее',
'штаб-квартира': 'прочее', 'сотрудников': 'прочее',
}
_PLACE_PARAM_ORDER = ['тип', 'статус', 'страна', 'регион', 'район', 'река', 'основана',
'население', 'адрес', 'период', 'жильцы', 'назв_латыш', 'назв_белор',
'координаты', 'сайт', 'телефон', 'прочее']
def _extract_infobox(text: str, title: str, name_param: str, template: str,
field_map: dict, param_order: list) -> tuple[str, str]:
"""Extract photo + blank-header info table; return ({{Template|...}}, cleaned_text)."""
lines = text.split('\n')
photo = None
fields: dict[str, str] = {}
remove: set[int] = set()
for i, line in enumerate(lines):
if re.match(r'^\|', line):
break # reached info table — stop looking for photo
m = re.match(r'^\s*\[!\[[^\]]*\]\(\./(?:[^/)]+/)?([^)?]+?)(?:\?[^)]*)?\)\]', line)
if m:
photo = m.group(1)
remove.add(i)
break
in_table = False
for i, line in enumerate(lines):
if re.match(r'^\|', line):
cells = _split_cells(line)
if all(re.match(r'^:?-+:?$', c) for c in cells if c):
if not in_table:
prev = i - 1
if prev >= 0 and re.match(r'^\|', lines[prev]):
hcells = _split_cells(lines[prev])
if all(c == '' for c in hcells):
in_table = True
remove.add(prev)
if in_table:
remove.add(i)
elif in_table:
remove.add(i)
if len(cells) >= 2:
key = re.sub(r'\*\*(.+?)\*\*', r'\1', cells[0]).strip().lower()
val = re.sub(r'\*\*(.+?)\*\*', r"'''\1'''", cells[1].strip())
param = field_map.get(key)
if param and param not in fields:
fields[param] = val
elif in_table:
in_table = False
if not photo and not fields:
return '', text
parts = ['{{' + template, f'| {name_param:<16} = {title}']
if photo:
parts.append(f'| фото = {photo}')
for param in param_order:
if param in fields:
parts.append(f'| {param:<16} = {fields[param]}')
infobox = '\n'.join(parts) + '\n}}'
cleaned = '\n'.join(line for i, line in enumerate(lines) if i not in remove)
return infobox, cleaned
def extract_person_infobox(text: str, title: str) -> tuple[str, str]:
return _extract_infobox(text, title, 'имя', 'Персона', _PERSON_FIELD_MAP, _PERSONA_PARAM_ORDER)
def extract_place_infobox(text: str, title: str) -> tuple[str, str]:
return _extract_infobox(text, title, 'название', 'Место', _PLACE_FIELD_MAP, _PLACE_PARAM_ORDER)
def strip_first_heading(text: str) -> str:
"""Remove the first H1 line — MW displays the page title itself."""
return re.sub(r'^#[^#][^\n]*\n?', '', text, count=1)
def convert(text: str, skip_info: bool = False, is_place: bool = False, title: str = '') -> str:
text = strip_first_heading(text)
infobox = ''
if skip_info:
infobox, text = extract_person_infobox(text, title)
elif is_place:
infobox, text = extract_place_infobox(text, title)
result = convert_pandoc(text) if _pandoc_available() else convert_python(text, skip_info=skip_info)
if infobox:
result = infobox + '\n' + result.lstrip('\n')
return result
# ---------------------------------------------------------------------------
# Post-processing
# ---------------------------------------------------------------------------
def fix_links(text: str) -> str:
pattern = (r'\[\[([^\]|]+)\|'
r'(?:Люди|Места|Воспоминания|люди|места|воспоминания|Место)'
r'/([^\]]+)\]\]')
def replace_link(m):
display = m.group(1).strip()
page = _cap(m.group(2).strip().lower())
if display.lower() == page.lower():
return f'[[{page}]]'
return f'[[{page}|{display}]]'
text = re.sub(pattern, replace_link, text)
# Also handle bare section paths: [[Section/PageName]] → [[PageName]]
text = re.sub(
r'\[\[(?:Люди|Места|Воспоминания|люди|места|воспоминания|Место)/([^\]|]+)\]\]',
lambda m: f'[[{m.group(1).strip().lower().title()}]]',
text
)
return text
def fix_images(text: str) -> str:
# Handle linked images: [![](./file.jpg?thumbnail=400)](./file.jpg)
# and plain images: ![alt](./file.jpg?thumbnail=400)
pattern = r'(?:\[)?!\[[^\]]*\]\(\./(?:[^/)]+/)?([^)?]+?)(\?[^)]*)?\)(?:\]\([^)]*\))?'
def replace_img(m):
filename = m.group(1)
size_m = re.search(r'thumbnail=(\d+)', m.group(2) or '')
return f'[[File:{filename}|{size_m.group(1)}px]]' if size_m else f'[[File:{filename}]]'
return re.sub(pattern, replace_img, text)
_CATEGORY_MAP = {'люди': 'Люди', 'места': 'Места', 'воспоминания': 'Воспоминания'}
def category_suffix(md_path: Path) -> str:
parts = md_path.relative_to(REPO).parts
if len(parts) == 1:
return '' if md_path.name == 'home.md' else '\n\n[[Category:Статьи]]'
cat = _CATEGORY_MAP.get(parts[0].lower())
return f'\n\n[[Category:{cat}]]' if cat else ''
# ---------------------------------------------------------------------------
# MW operations
# ---------------------------------------------------------------------------
def post_page(session, csrf: str, title: str, text: str, dry_run: bool) -> bool:
if dry_run:
print(f'[DRY] {title}')
return True
data = session.post(API, data={
'action': 'edit', 'title': title, 'text': text,
'token': csrf, 'format': 'json'
}).json()
if 'error' in data:
print(f'[ERR] {title}: {data["error"].get("info", data["error"])}')
return False
print(f'[OK] {title}')
return True
def upload_image(session, csrf: str, image_path: Path, dry_run: bool) -> bool:
basename = image_path.name
if dry_run:
print(f'[DRY] File:{basename}')
return True
with open(image_path, 'rb') as f:
data = session.post(API, data={
'action': 'upload', 'filename': basename,
'token': csrf, 'format': 'json', 'ignorewarnings': '1'
}, files={'file': f}).json()
if 'error' in data:
print(f'[ERR] File:{basename}: {data["error"].get("info", data["error"])}')
return False
if data.get('upload', {}).get('result') == 'Success':
print(f'[OK] File:{basename}')
else:
print(f'[SKIP] File:{basename} (already exists or no change)')
return True
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def collect_images() -> list[Path]:
images = []
for folder in ('люди', 'места'):
p = REPO / folder
if p.exists():
for ext in ('*.jpg', '*.jpeg', '*.JPG', '*.JPEG', '*.png', '*.PNG'):
images.extend(p.rglob(ext))
return images
def main():
parser = argparse.ArgumentParser(description='Migrate OtterWiki to MediaWiki')
parser.add_argument('--user', required=True)
parser.add_argument('--password', required=True)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
session = csrf = None
if not args.dry_run:
session, csrf = mw_login(args.user, args.password)
pages_ok = pages_err = images_ok = images_err = 0
for md_path in sorted(REPO.rglob('*.md')):
title = page_title(md_path)
raw = md_path.read_text(encoding='utf-8')
parts = md_path.relative_to(REPO).parts
is_people = len(parts) > 0 and parts[0].lower() == 'люди'
is_place = len(parts) > 0 and parts[0].lower() == 'места'
wikitext = convert(raw, skip_info=is_people, is_place=is_place, title=title)
wikitext = fix_links(wikitext)
wikitext = fix_images(wikitext)
wikitext += category_suffix(md_path)
if post_page(session, csrf, title, wikitext, args.dry_run):
pages_ok += 1
else:
pages_err += 1
for img in collect_images():
if upload_image(session, csrf, img, args.dry_run):
images_ok += 1
else:
images_err += 1
print(f'\nDone: {pages_ok} pages, {images_ok} images, {pages_err + images_err} errors')
if __name__ == '__main__':
main()

2
family/uploads.ini Normal file
View File

@@ -0,0 +1,2 @@
upload_max_filesize = 20M
post_max_size = 25M