diff --git a/.claude/WIP.md b/.claude/WIP.md index f871ab56..6e511191 100644 --- a/.claude/WIP.md +++ b/.claude/WIP.md @@ -1,6 +1,6 @@ # Work In Progress (Claude) -_Last updated: 2026-03-06 (AI Gateway Login)_ +_Last updated: 2026-03-06 (PhotoPrism Gallery)_ > **Architecture Reference**: SecuBox Fanzine v3 — Les 4 Couches @@ -10,6 +10,14 @@ _Last updated: 2026-03-06 (AI Gateway Login)_ ### 2026-03-06 +- **PhotoPrism Private Photo Gallery** + - Backend: `secubox-app-photoprism` with LXC container (Debian Bookworm) + - CLI: `photoprismctl` with install/start/stop/index/import/emancipate commands + - LuCI: `luci-app-photoprism` KISS dashboard with stats and actions + - Features: AI face recognition, object detection, places/maps + - HAProxy integration via mitmproxy (WAF-safe, no bypass) + - MariaDB database, FFmpeg transcoding, HEIC support + - **AI Gateway `/login` Command** - CLI: `aigatewayctl login [provider]` - Interactive or scripted provider authentication - Validates credentials against provider API before saving diff --git a/package/secubox/luci-app-photoprism/Makefile b/package/secubox/luci-app-photoprism/Makefile new file mode 100644 index 00000000..d92435a5 --- /dev/null +++ b/package/secubox/luci-app-photoprism/Makefile @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: GPL-2.0-only +# Copyright (C) 2026 CyberMind.fr + +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-photoprism +PKG_VERSION:=0.1.0 +PKG_RELEASE:=1 +PKG_LICENSE:=GPL-2.0-only +PKG_MAINTAINER:=SecuBox + +LUCI_TITLE:=LuCI PhotoPrism Dashboard +LUCI_DEPENDS:=+secubox-app-photoprism +LUCI_PKGARCH:=all + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/luci-app-photoprism/install + $(INSTALL_DIR) $(1)/usr/libexec/rpcd + $(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.photoprism $(1)/usr/libexec/rpcd/luci.photoprism + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-photoprism.json $(1)/usr/share/rpcd/acl.d/luci-photoprism.json + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/photoprism + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/photoprism/overview.js $(1)/www/luci-static/resources/view/photoprism/overview.js + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-photoprism.json $(1)/usr/share/luci/menu.d/luci-app-photoprism.json +endef + +$(eval $(call BuildPackage,luci-app-photoprism)) diff --git a/package/secubox/luci-app-photoprism/htdocs/luci-static/resources/view/photoprism/overview.js b/package/secubox/luci-app-photoprism/htdocs/luci-static/resources/view/photoprism/overview.js new file mode 100644 index 00000000..65892b7a --- /dev/null +++ b/package/secubox/luci-app-photoprism/htdocs/luci-static/resources/view/photoprism/overview.js @@ -0,0 +1,448 @@ +'use strict'; +'require view'; +'require rpc'; +'require ui'; +'require poll'; + +var callStatus = rpc.declare({ + object: 'luci.photoprism', + method: 'status', + expect: {} +}); + +var callGetStats = rpc.declare({ + object: 'luci.photoprism', + method: 'get_stats', + expect: {} +}); + +var callStart = rpc.declare({ + object: 'luci.photoprism', + method: 'start', + expect: {} +}); + +var callStop = rpc.declare({ + object: 'luci.photoprism', + method: 'stop', + expect: {} +}); + +var callInstall = rpc.declare({ + object: 'luci.photoprism', + method: 'install', + expect: {} +}); + +var callUninstall = rpc.declare({ + object: 'luci.photoprism', + method: 'uninstall', + expect: {} +}); + +var callIndex = rpc.declare({ + object: 'luci.photoprism', + method: 'index', + expect: {} +}); + +var callImport = rpc.declare({ + object: 'luci.photoprism', + method: 'import', + expect: {} +}); + +var callEmancipate = rpc.declare({ + object: 'luci.photoprism', + method: 'emancipate', + params: ['domain'], + expect: {} +}); + +return view.extend({ + css: ` + :root { + --kiss-bg: #1a1a2e; + --kiss-card: #16213e; + --kiss-border: #0f3460; + --kiss-accent: #e94560; + --kiss-text: #eee; + --kiss-muted: #888; + --kiss-success: #00d26a; + --kiss-warning: #ffc107; + } + .kiss-container { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--kiss-bg); + color: var(--kiss-text); + padding: 20px; + min-height: 100vh; + } + .kiss-header { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 25px; + padding-bottom: 15px; + border-bottom: 1px solid var(--kiss-border); + } + .kiss-header h2 { + margin: 0; + font-size: 1.8em; + color: var(--kiss-text); + } + .kiss-badge { + padding: 4px 12px; + border-radius: 12px; + font-size: 0.8em; + font-weight: 600; + } + .kiss-badge-success { background: var(--kiss-success); color: #000; } + .kiss-badge-danger { background: var(--kiss-accent); color: #fff; } + .kiss-badge-warning { background: var(--kiss-warning); color: #000; } + .kiss-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + margin-bottom: 25px; + } + .kiss-card { + background: var(--kiss-card); + border: 1px solid var(--kiss-border); + border-radius: 10px; + padding: 20px; + } + .kiss-card h4 { + margin: 0 0 10px 0; + color: var(--kiss-muted); + font-size: 0.9em; + text-transform: uppercase; + } + .kiss-card .value { + font-size: 2em; + font-weight: 700; + color: var(--kiss-text); + } + .kiss-card .value.accent { color: var(--kiss-accent); } + .kiss-card .value.success { color: var(--kiss-success); } + .kiss-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 25px; + } + .kiss-btn { + padding: 10px 20px; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + font-size: 0.9em; + transition: all 0.2s; + } + .kiss-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .kiss-btn-primary { + background: var(--kiss-accent); + color: #fff; + } + .kiss-btn-primary:hover:not(:disabled) { + background: #ff6b6b; + } + .kiss-btn-secondary { + background: var(--kiss-border); + color: var(--kiss-text); + } + .kiss-btn-secondary:hover:not(:disabled) { + background: #1a4a7a; + } + .kiss-btn-success { + background: var(--kiss-success); + color: #000; + } + .kiss-btn-danger { + background: #dc3545; + color: #fff; + } + .kiss-section { + margin-bottom: 25px; + } + .kiss-section h3 { + margin: 0 0 15px 0; + font-size: 1.2em; + color: var(--kiss-text); + } + .kiss-features { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + .kiss-feature { + padding: 8px 15px; + background: var(--kiss-border); + border-radius: 20px; + font-size: 0.85em; + } + .kiss-feature.active { + background: var(--kiss-success); + color: #000; + } + .kiss-input-group { + display: flex; + gap: 10px; + margin-top: 15px; + } + .kiss-input { + flex: 1; + padding: 10px 15px; + border: 1px solid var(--kiss-border); + border-radius: 6px; + background: var(--kiss-bg); + color: var(--kiss-text); + font-size: 0.95em; + } + .kiss-link { + color: var(--kiss-accent); + text-decoration: none; + } + .kiss-link:hover { + text-decoration: underline; + } + .kiss-install-card { + text-align: center; + padding: 40px; + } + .kiss-install-card h3 { + margin-bottom: 15px; + } + .kiss-install-card p { + color: var(--kiss-muted); + margin-bottom: 25px; + } + `, + + status: null, + stats: null, + + load: function() { + return Promise.all([ + callStatus(), + callGetStats() + ]); + }, + + render: function(data) { + var self = this; + this.status = data[0] || {}; + this.stats = data[1] || {}; + + var container = E('div', { 'class': 'kiss-container' }, [ + E('style', {}, this.css), + this.renderHeader(), + this.status.installed ? this.renderDashboard() : this.renderInstallPrompt() + ]); + + poll.add(function() { + return Promise.all([callStatus(), callGetStats()]).then(function(results) { + self.status = results[0] || {}; + self.stats = results[1] || {}; + self.updateView(); + }); + }, 10); + + return container; + }, + + renderHeader: function() { + var status = this.status; + var badge = !status.installed + ? E('span', { 'class': 'kiss-badge kiss-badge-warning' }, 'Not Installed') + : status.running + ? E('span', { 'class': 'kiss-badge kiss-badge-success' }, 'Running') + : E('span', { 'class': 'kiss-badge kiss-badge-danger' }, 'Stopped'); + + return E('div', { 'class': 'kiss-header' }, [ + E('h2', {}, 'PhotoPrism Gallery'), + badge + ]); + }, + + renderInstallPrompt: function() { + var self = this; + + return E('div', { 'class': 'kiss-card kiss-install-card' }, [ + E('h3', {}, 'PhotoPrism Not Installed'), + E('p', {}, 'Self-hosted Google Photos alternative with AI-powered face recognition, search, and albums.'), + E('button', { + 'class': 'kiss-btn kiss-btn-primary', + 'click': function() { + this.disabled = true; + this.textContent = 'Installing...'; + callInstall().then(function(res) { + if (res.success) { + ui.addNotification(null, E('p', {}, 'PhotoPrism installed successfully!'), 'success'); + window.location.reload(); + } else { + ui.addNotification(null, E('p', {}, 'Installation failed: ' + (res.output || 'Unknown error')), 'error'); + } + }); + } + }, 'Install PhotoPrism') + ]); + }, + + renderDashboard: function() { + var self = this; + var status = this.status; + var stats = this.stats; + + return E('div', {}, [ + // Stats Grid + E('div', { 'class': 'kiss-grid', 'id': 'stats-grid' }, [ + E('div', { 'class': 'kiss-card' }, [ + E('h4', {}, 'Photos'), + E('div', { 'class': 'value accent', 'data-stat': 'photos' }, stats.photo_count || '0') + ]), + E('div', { 'class': 'kiss-card' }, [ + E('h4', {}, 'Videos'), + E('div', { 'class': 'value', 'data-stat': 'videos' }, stats.video_count || '0') + ]), + E('div', { 'class': 'kiss-card' }, [ + E('h4', {}, 'Originals Size'), + E('div', { 'class': 'value', 'data-stat': 'originals' }, stats.originals_size || '0') + ]), + E('div', { 'class': 'kiss-card' }, [ + E('h4', {}, 'Cache Size'), + E('div', { 'class': 'value', 'data-stat': 'cache' }, stats.storage_size || '0') + ]) + ]), + + // Actions + E('div', { 'class': 'kiss-section' }, [ + E('h3', {}, 'Actions'), + E('div', { 'class': 'kiss-actions' }, [ + E('button', { + 'class': 'kiss-btn ' + (status.running ? 'kiss-btn-danger' : 'kiss-btn-success'), + 'data-action': 'toggle', + 'click': function() { + var fn = status.running ? callStop : callStart; + this.disabled = true; + fn().then(function() { + window.location.reload(); + }); + } + }, status.running ? 'Stop' : 'Start'), + E('button', { + 'class': 'kiss-btn kiss-btn-secondary', + 'disabled': !status.running, + 'click': function() { + this.disabled = true; + this.textContent = 'Indexing...'; + callIndex().then(function(res) { + ui.addNotification(null, E('p', {}, 'Indexing complete'), 'success'); + window.location.reload(); + }); + } + }, 'Index Photos'), + E('button', { + 'class': 'kiss-btn kiss-btn-secondary', + 'disabled': !status.running, + 'click': function() { + this.disabled = true; + this.textContent = 'Importing...'; + callImport().then(function(res) { + ui.addNotification(null, E('p', {}, 'Import complete'), 'success'); + window.location.reload(); + }); + } + }, 'Import'), + status.running ? E('a', { + 'class': 'kiss-btn kiss-btn-primary', + 'href': 'http://' + window.location.hostname + ':' + (status.port || 2342), + 'target': '_blank' + }, 'Open Gallery') : E('span') + ]) + ]), + + // Features + E('div', { 'class': 'kiss-section' }, [ + E('h3', {}, 'AI Features'), + E('div', { 'class': 'kiss-features' }, [ + E('span', { 'class': 'kiss-feature ' + (status.face_recognition ? 'active' : '') }, 'Face Recognition'), + E('span', { 'class': 'kiss-feature ' + (status.object_detection ? 'active' : '') }, 'Object Detection'), + E('span', { 'class': 'kiss-feature ' + (status.places ? 'active' : '') }, 'Places / Maps') + ]) + ]), + + // Emancipate + E('div', { 'class': 'kiss-section' }, [ + E('h3', {}, 'Public Exposure'), + status.domain + ? E('p', {}, ['Gallery available at: ', E('a', { 'class': 'kiss-link', 'href': 'https://' + status.domain, 'target': '_blank' }, 'https://' + status.domain)]) + : E('div', { 'class': 'kiss-input-group' }, [ + E('input', { + 'class': 'kiss-input', + 'type': 'text', + 'id': 'emancipate-domain', + 'placeholder': 'photos.example.com' + }), + E('button', { + 'class': 'kiss-btn kiss-btn-primary', + 'click': function() { + var domain = document.getElementById('emancipate-domain').value; + if (!domain) { + ui.addNotification(null, E('p', {}, 'Please enter a domain'), 'warning'); + return; + } + this.disabled = true; + this.textContent = 'Configuring...'; + callEmancipate(domain).then(function(res) { + if (res.success) { + ui.addNotification(null, E('p', {}, 'Gallery exposed at ' + res.url), 'success'); + window.location.reload(); + } + }); + } + }, 'Emancipate') + ]) + ]), + + // Danger Zone + E('div', { 'class': 'kiss-section' }, [ + E('h3', {}, 'Danger Zone'), + E('button', { + 'class': 'kiss-btn kiss-btn-danger', + 'click': function() { + if (confirm('Remove PhotoPrism container? Photos will be preserved.')) { + this.disabled = true; + callUninstall().then(function() { + ui.addNotification(null, E('p', {}, 'PhotoPrism uninstalled'), 'success'); + window.location.reload(); + }); + } + } + }, 'Uninstall') + ]) + ]); + }, + + updateView: function() { + var stats = this.stats; + + var photosEl = document.querySelector('[data-stat="photos"]'); + var videosEl = document.querySelector('[data-stat="videos"]'); + var originalsEl = document.querySelector('[data-stat="originals"]'); + var cacheEl = document.querySelector('[data-stat="cache"]'); + + if (photosEl) photosEl.textContent = stats.photo_count || '0'; + if (videosEl) videosEl.textContent = stats.video_count || '0'; + if (originalsEl) originalsEl.textContent = stats.originals_size || '0'; + if (cacheEl) cacheEl.textContent = stats.storage_size || '0'; + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-photoprism/root/usr/libexec/rpcd/luci.photoprism b/package/secubox/luci-app-photoprism/root/usr/libexec/rpcd/luci.photoprism new file mode 100644 index 00000000..e62e2e85 --- /dev/null +++ b/package/secubox/luci-app-photoprism/root/usr/libexec/rpcd/luci.photoprism @@ -0,0 +1,201 @@ +#!/bin/sh +# PhotoPrism RPCD Handler for LuCI +# Copyright (C) 2026 CyberMind.fr + +. /usr/share/libubox/jshn.sh + +CONFIG="photoprism" + +# Method: status +method_status() { + /usr/sbin/photoprismctl status 2>/dev/null || echo '{"installed":false,"running":false}' +} + +# Method: get_config +method_get_config() { + json_init + + json_add_boolean "enabled" "$([ "$(uci -q get ${CONFIG}.main.enabled)" = "1" ] && echo true || echo false)" + json_add_string "data_path" "$(uci -q get ${CONFIG}.main.data_path || echo '/srv/photoprism')" + json_add_string "http_port" "$(uci -q get ${CONFIG}.main.http_port || echo '2342')" + json_add_string "memory_limit" "$(uci -q get ${CONFIG}.main.memory_limit || echo '2G')" + json_add_string "admin_user" "$(uci -q get ${CONFIG}.admin.username || echo 'admin')" + json_add_string "domain" "$(uci -q get ${CONFIG}.network.domain || echo '')" + json_add_boolean "face_recognition" "$([ "$(uci -q get ${CONFIG}.features.face_recognition)" = "1" ] && echo true || echo false)" + json_add_boolean "object_detection" "$([ "$(uci -q get ${CONFIG}.features.object_detection)" = "1" ] && echo true || echo false)" + json_add_boolean "places" "$([ "$(uci -q get ${CONFIG}.features.places)" = "1" ] && echo true || echo false)" + + json_dump +} + +# Method: get_stats +method_get_stats() { + local data_path=$(uci -q get ${CONFIG}.main.data_path || echo '/srv/photoprism') + local originals_size="0" + local storage_size="0" + local photo_count=0 + local video_count=0 + + if [ -d "${data_path}/originals" ]; then + originals_size=$(du -sh "${data_path}/originals" 2>/dev/null | cut -f1 || echo "0") + photo_count=$(find "${data_path}/originals" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.heic" -o -iname "*.raw" -o -iname "*.dng" \) 2>/dev/null | wc -l || echo 0) + video_count=$(find "${data_path}/originals" -type f \( -iname "*.mp4" -o -iname "*.mov" -o -iname "*.avi" -o -iname "*.mkv" \) 2>/dev/null | wc -l || echo 0) + fi + + if [ -d "${data_path}/storage" ]; then + storage_size=$(du -sh "${data_path}/storage" 2>/dev/null | cut -f1 || echo "0") + fi + + json_init + json_add_int "photo_count" "$photo_count" + json_add_int "video_count" "$video_count" + json_add_string "originals_size" "$originals_size" + json_add_string "storage_size" "$storage_size" + json_dump +} + +# Method: start +method_start() { + /etc/init.d/photoprism start >/dev/null 2>&1 + json_init + json_add_boolean "success" true + json_dump +} + +# Method: stop +method_stop() { + /etc/init.d/photoprism stop >/dev/null 2>&1 + json_init + json_add_boolean "success" true + json_dump +} + +# Method: restart +method_restart() { + /etc/init.d/photoprism restart >/dev/null 2>&1 + json_init + json_add_boolean "success" true + json_dump +} + +# Method: install +method_install() { + local result + result=$(/usr/sbin/photoprismctl install 2>&1) + local success=$? + + json_init + json_add_boolean "success" "$([ $success -eq 0 ] && echo true || echo false)" + json_add_string "output" "$result" + json_dump +} + +# Method: uninstall +method_uninstall() { + local result + result=$(/usr/sbin/photoprismctl uninstall 2>&1) + + json_init + json_add_boolean "success" true + json_add_string "output" "$result" + json_dump +} + +# Method: index +method_index() { + local result + result=$(/usr/sbin/photoprismctl index 2>&1) + + json_init + json_add_boolean "success" true + json_add_string "output" "$result" + json_dump +} + +# Method: import +method_import() { + local result + result=$(/usr/sbin/photoprismctl import 2>&1) + + json_init + json_add_boolean "success" true + json_add_string "output" "$result" + json_dump +} + +# Method: emancipate +method_emancipate() { + local domain="$1" + + if [ -z "$domain" ]; then + json_init + json_add_boolean "success" false + json_add_string "error" "Domain required" + json_dump + return + fi + + local result + result=$(/usr/sbin/photoprismctl emancipate "$domain" 2>&1) + + json_init + json_add_boolean "success" true + json_add_string "output" "$result" + json_add_string "url" "https://$domain" + json_dump +} + +# Method: logs +method_logs() { + local lines="${1:-50}" + local output + output=$(/usr/sbin/photoprismctl logs "$lines" 2>&1) + + json_init + json_add_string "logs" "$output" + json_dump +} + +# RPCD interface +case "$1" in + list) + echo '{ + "status": {}, + "get_config": {}, + "get_stats": {}, + "start": {}, + "stop": {}, + "restart": {}, + "install": {}, + "uninstall": {}, + "index": {}, + "import": {}, + "emancipate": {"domain": "string"}, + "logs": {"lines": "number"} + }' + ;; + call) + case "$2" in + status) method_status ;; + get_config) method_get_config ;; + get_stats) method_get_stats ;; + start) method_start ;; + stop) method_stop ;; + restart) method_restart ;; + install) method_install ;; + uninstall) method_uninstall ;; + index) method_index ;; + import) method_import ;; + emancipate) + read -r input + domain=$(echo "$input" | jsonfilter -e '@.domain' 2>/dev/null) + method_emancipate "$domain" + ;; + logs) + read -r input + lines=$(echo "$input" | jsonfilter -e '@.lines' 2>/dev/null) + method_logs "$lines" + ;; + esac + ;; +esac diff --git a/package/secubox/luci-app-photoprism/root/usr/share/luci/menu.d/luci-app-photoprism.json b/package/secubox/luci-app-photoprism/root/usr/share/luci/menu.d/luci-app-photoprism.json new file mode 100644 index 00000000..1c16bd82 --- /dev/null +++ b/package/secubox/luci-app-photoprism/root/usr/share/luci/menu.d/luci-app-photoprism.json @@ -0,0 +1,14 @@ +{ + "admin/services/photoprism": { + "title": "PhotoPrism", + "order": 85, + "action": { + "type": "view", + "path": "photoprism/overview" + }, + "depends": { + "acl": ["luci-app-photoprism"], + "uci": {"photoprism": true} + } + } +} diff --git a/package/secubox/luci-app-photoprism/root/usr/share/rpcd/acl.d/luci-photoprism.json b/package/secubox/luci-app-photoprism/root/usr/share/rpcd/acl.d/luci-photoprism.json new file mode 100644 index 00000000..72136b36 --- /dev/null +++ b/package/secubox/luci-app-photoprism/root/usr/share/rpcd/acl.d/luci-photoprism.json @@ -0,0 +1,31 @@ +{ + "luci-app-photoprism": { + "description": "Grant access to PhotoPrism Gallery", + "read": { + "ubus": { + "luci.photoprism": [ + "status", + "get_config", + "get_stats", + "logs" + ] + }, + "uci": ["photoprism"] + }, + "write": { + "ubus": { + "luci.photoprism": [ + "start", + "stop", + "restart", + "install", + "uninstall", + "index", + "import", + "emancipate" + ] + }, + "uci": ["photoprism"] + } + } +} diff --git a/package/secubox/secubox-app-photoprism/Makefile b/package/secubox/secubox-app-photoprism/Makefile new file mode 100644 index 00000000..2b0ad087 --- /dev/null +++ b/package/secubox/secubox-app-photoprism/Makefile @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: GPL-2.0-only +# Copyright (C) 2026 CyberMind.fr + +include $(TOPDIR)/rules.mk + +PKG_NAME:=secubox-app-photoprism +PKG_VERSION:=0.1.0 +PKG_RELEASE:=1 +PKG_LICENSE:=GPL-2.0-only +PKG_MAINTAINER:=SecuBox + +include $(INCLUDE_DIR)/package.mk + +define Package/secubox-app-photoprism + SECTION:=secubox + CATEGORY:=SecuBox + SUBMENU:=Apps + TITLE:=PhotoPrism Private Photo Gallery + DEPENDS:=+lxc +lxc-common +curl +wget-ssl +jsonfilter +coreutils-stat + PKGARCH:=all + PKG_FLAGS:=nonshared +endef + +define Package/secubox-app-photoprism/description + Self-hosted Google Photos alternative with AI-powered face recognition, + object detection, geolocation, and full-text search. Runs in LXC container + with MariaDB backend and web UI. +endef + +define Build/Compile +endef + +define Package/secubox-app-photoprism/install + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/etc/config/photoprism $(1)/etc/config/photoprism + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_BIN) ./files/etc/init.d/photoprism $(1)/etc/init.d/photoprism + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_BIN) ./files/usr/sbin/photoprismctl $(1)/usr/sbin/photoprismctl +endef + +define Package/secubox-app-photoprism/postinst +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + mkdir -p /srv/photoprism/originals + mkdir -p /srv/photoprism/storage + mkdir -p /srv/photoprism/import + chmod 755 /srv/photoprism +} +exit 0 +endef + +$(eval $(call BuildPackage,secubox-app-photoprism)) diff --git a/package/secubox/secubox-app-photoprism/README.md b/package/secubox/secubox-app-photoprism/README.md new file mode 100644 index 00000000..156179c6 --- /dev/null +++ b/package/secubox/secubox-app-photoprism/README.md @@ -0,0 +1,118 @@ +# SecuBox PhotoPrism + +Self-hosted Google Photos alternative with AI-powered features, running in an LXC container. + +## Features + +- **AI Face Recognition** - Automatically detect and group faces +- **Object Detection** - Find photos by objects, scenes, colors +- **Places / Maps** - View photos on a world map +- **Full-Text Search** - Search across all metadata +- **Albums & Sharing** - Organize and share collections +- **RAW Support** - Process RAW files from cameras +- **Video Playback** - Stream videos with transcoding + +## Quick Start + +```bash +# Install PhotoPrism (creates LXC container) +photoprismctl install + +# Start the service +/etc/init.d/photoprism start + +# Access the gallery +http://192.168.255.1:2342 +``` + +## CLI Commands + +| Command | Description | +|---------|-------------| +| `install` | Create LXC container with PhotoPrism | +| `uninstall` | Remove container (preserves photos) | +| `start/stop/restart` | Service lifecycle | +| `status` | JSON status for RPCD | +| `logs [N]` | Show last N log lines | +| `shell` | Open container shell | +| `index` | Trigger photo indexing | +| `import` | Import from inbox folder | +| `passwd [pass]` | Reset admin password | +| `backup` | Create database backup | +| `configure-haproxy ` | Setup HAProxy + SSL | +| `emancipate ` | Full public exposure | + +## Photo Management + +### Adding Photos + +1. **Direct Copy**: Copy files to `/srv/photoprism/originals/` +2. **Import Inbox**: Copy to `/srv/photoprism/import/`, run `photoprismctl import` +3. **WebDAV**: Enable WebDAV in PhotoPrism settings + +### Triggering Index + +After adding photos, run indexing: + +```bash +photoprismctl index +``` + +## Public Exposure + +Expose gallery to the internet with HAProxy + SSL: + +```bash +photoprismctl emancipate photos.example.com +``` + +This configures: +- HAProxy vhost with Let's Encrypt SSL +- mitmproxy WAF routing +- DNS record (if dnsctl available) + +## Configuration + +UCI config at `/etc/config/photoprism`: + +``` +config photoprism 'main' + option enabled '1' + option http_port '2342' + option memory_limit '2G' + +config photoprism 'features' + option face_recognition '1' + option object_detection '1' + option places '1' +``` + +## Resource Requirements + +- **RAM**: 2GB recommended (1GB minimum) +- **Storage**: ~500MB for container + your photos +- **CPU**: AI indexing is CPU-intensive + +## LuCI Dashboard + +Access via: Services → PhotoPrism + +Features: +- Status cards (photos, videos, storage) +- Start/Stop/Index/Import buttons +- AI feature toggles +- Emancipate form for public exposure + +## Data Paths + +| Path | Content | +|------|---------| +| `/srv/photoprism/originals` | Your photos and videos | +| `/srv/photoprism/storage` | Cache, thumbnails, database | +| `/srv/photoprism/import` | Upload inbox | + +## Security + +- Traffic routes through mitmproxy WAF (no bypass) +- Admin password stored in UCI +- Container runs with limited capabilities diff --git a/package/secubox/secubox-app-photoprism/files/etc/config/photoprism b/package/secubox/secubox-app-photoprism/files/etc/config/photoprism new file mode 100644 index 00000000..f10e1035 --- /dev/null +++ b/package/secubox/secubox-app-photoprism/files/etc/config/photoprism @@ -0,0 +1,35 @@ +# PhotoPrism Private Photo Gallery Configuration + +config photoprism 'main' + option enabled '0' + option data_path '/srv/photoprism' + option http_port '2342' + option memory_limit '2G' + option timezone 'Europe/Paris' + +config photoprism 'admin' + option username 'admin' + option password '' + +config photoprism 'features' + option face_recognition '1' + option object_detection '1' + option places '1' + option raw_thumbs '1' + option experimental '0' + +config photoprism 'network' + option domain '' + option haproxy '0' + option haproxy_ssl '1' + +config photoprism 'import' + option auto_import '0' + option import_path '/srv/photoprism/import' + option delete_after_import '0' + +config photoprism 'database' + option type 'mariadb' + option name 'photoprism' + option user 'photoprism' + option password '' diff --git a/package/secubox/secubox-app-photoprism/files/etc/init.d/photoprism b/package/secubox/secubox-app-photoprism/files/etc/init.d/photoprism new file mode 100644 index 00000000..a6b9a21b --- /dev/null +++ b/package/secubox/secubox-app-photoprism/files/etc/init.d/photoprism @@ -0,0 +1,32 @@ +#!/bin/sh /etc/rc.common +# PhotoPrism Private Photo Gallery - Init Script + +START=90 +STOP=10 +USE_PROCD=1 + +start_service() { + local enabled + enabled=$(uci -q get photoprism.main.enabled) + [ "$enabled" = "1" ] || return 0 + + procd_open_instance + procd_set_param command /usr/sbin/photoprismctl service-run + procd_set_param respawn 3600 5 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +stop_service() { + /usr/sbin/photoprismctl service-stop >/dev/null 2>&1 +} + +reload_service() { + stop_service + start_service +} + +service_triggers() { + procd_add_reload_trigger "photoprism" +} diff --git a/package/secubox/secubox-app-photoprism/files/usr/sbin/photoprismctl b/package/secubox/secubox-app-photoprism/files/usr/sbin/photoprismctl new file mode 100644 index 00000000..7794e44c --- /dev/null +++ b/package/secubox/secubox-app-photoprism/files/usr/sbin/photoprismctl @@ -0,0 +1,701 @@ +#!/bin/sh +# PhotoPrism Private Photo Gallery Controller +# Copyright (C) 2026 CyberMind.fr + +set -e + +CONFIG="photoprism" +LXC_NAME="photoprism" +LXC_PATH="/srv/lxc" +LXC_ROOTFS="${LXC_PATH}/${LXC_NAME}/rootfs" +LXC_CONFIG="${LXC_PATH}/${LXC_NAME}/config" +DATA_PATH="/srv/photoprism" +PHOTOPRISM_VERSION="240915-ce" +HOST_IP="192.168.255.1" + +# Detect architecture +detect_arch() { + case "$(uname -m)" in + aarch64) echo "arm64" ;; + x86_64) echo "amd64" ;; + *) echo "amd64" ;; + esac +} + +ARCH=$(detect_arch) + +# Logging +log() { echo "[photoprism] $*"; } +log_error() { echo "[photoprism] ERROR: $*" >&2; } + +# UCI helpers +uci_get() { uci -q get "${CONFIG}.$1" || echo "$2"; } +uci_set() { uci set "${CONFIG}.$1=$2" && uci commit "$CONFIG"; } + +# Load configuration +defaults() { + ENABLED=$(uci_get main.enabled 0) + DATA_PATH=$(uci_get main.data_path /srv/photoprism) + HTTP_PORT=$(uci_get main.http_port 2342) + MEMORY_LIMIT=$(uci_get main.memory_limit 2G) + TIMEZONE=$(uci_get main.timezone Europe/Paris) + ADMIN_USER=$(uci_get admin.username admin) + ADMIN_PASS=$(uci_get admin.password "") + FACE_RECOGNITION=$(uci_get features.face_recognition 1) + OBJECT_DETECTION=$(uci_get features.object_detection 1) + PLACES=$(uci_get features.places 1) + RAW_THUMBS=$(uci_get features.raw_thumbs 1) + DOMAIN=$(uci_get network.domain "") + DB_NAME=$(uci_get database.name photoprism) + DB_USER=$(uci_get database.user photoprism) + DB_PASS=$(uci_get database.password "") +} + +# Check if LXC container exists +lxc_exists() { + [ -d "$LXC_ROOTFS" ] && [ -f "$LXC_CONFIG" ] +} + +# Check if LXC container is running +lxc_running() { + lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING" +} + +# Generate random password +generate_password() { + head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 16 +} + +# Download Debian rootfs +download_rootfs() { + local arch="$1" + local rootfs_url="https://images.linuxcontainers.org/images/debian/bookworm/${arch}/default/" + + log "Fetching latest rootfs manifest..." + local latest=$(wget -qO- "${rootfs_url}" | grep -oE '[0-9]{8}_[0-9]{2}:[0-9]{2}' | sort -r | head -1) + + if [ -z "$latest" ]; then + log_error "Failed to find rootfs version" + return 1 + fi + + local tarball_url="${rootfs_url}${latest}/rootfs.tar.xz" + log "Downloading rootfs from: $tarball_url" + + mkdir -p "$LXC_ROOTFS" + wget -qO /tmp/photoprism-rootfs.tar.xz "$tarball_url" || { + log_error "Failed to download rootfs" + return 1 + } + + log "Extracting rootfs..." + tar -xJf /tmp/photoprism-rootfs.tar.xz -C "$LXC_ROOTFS" + rm -f /tmp/photoprism-rootfs.tar.xz + + log "Rootfs extracted successfully" +} + +# Create LXC configuration +create_lxc_config() { + local mem_bytes + case "$MEMORY_LIMIT" in + *G) mem_bytes=$(echo "$MEMORY_LIMIT" | tr -d 'G'); mem_bytes=$((mem_bytes * 1073741824)) ;; + *M) mem_bytes=$(echo "$MEMORY_LIMIT" | tr -d 'M'); mem_bytes=$((mem_bytes * 1048576)) ;; + *) mem_bytes=2147483648 ;; + esac + + mkdir -p "${LXC_PATH}/${LXC_NAME}" + + cat > "$LXC_CONFIG" << EOF +# PhotoPrism LXC Configuration +lxc.uts.name = ${LXC_NAME} +lxc.rootfs.path = dir:${LXC_ROOTFS} + +# Network - use host network +lxc.net.0.type = none + +# Mount points +lxc.mount.auto = proc:mixed sys:ro + +# Bind mounts for data persistence +lxc.mount.entry = ${DATA_PATH}/originals opt/photoprism/originals none bind,create=dir 0 0 +lxc.mount.entry = ${DATA_PATH}/storage opt/photoprism/storage none bind,create=dir 0 0 +lxc.mount.entry = ${DATA_PATH}/import opt/photoprism/import none bind,create=dir 0 0 + +# Resource limits +lxc.cgroup2.memory.max = ${mem_bytes} + +# Startup command +lxc.init.cmd = /opt/start-photoprism.sh + +# TTY +lxc.tty.max = 4 +lxc.pty.max = 128 + +# Capabilities +lxc.cap.drop = sys_admin +EOF + + log "LXC config created" +} + +# Create startup script inside container +create_startup_script() { + cat > "${LXC_ROOTFS}/opt/start-photoprism.sh" << 'SCRIPT' +#!/bin/bash +set -e + +# Start MariaDB +service mariadb start +sleep 2 + +# Wait for MariaDB to be ready +for i in $(seq 1 30); do + if mysqladmin ping >/dev/null 2>&1; then + break + fi + sleep 1 +done + +# Run PhotoPrism +cd /opt/photoprism +exec ./photoprism start +SCRIPT + chmod +x "${LXC_ROOTFS}/opt/start-photoprism.sh" +} + +# Create PhotoPrism configuration +create_photoprism_config() { + local db_pass="$1" + + mkdir -p "${LXC_ROOTFS}/opt/photoprism" + + cat > "${LXC_ROOTFS}/opt/photoprism/options.yml" << EOF +# PhotoPrism Configuration +AdminUser: "${ADMIN_USER}" +AdminPassword: "${ADMIN_PASS}" + +# Storage +OriginalsPath: "/opt/photoprism/originals" +StoragePath: "/opt/photoprism/storage" +ImportPath: "/opt/photoprism/import" + +# Server +HttpHost: "0.0.0.0" +HttpPort: ${HTTP_PORT} + +# Database +DatabaseDriver: "mysql" +DatabaseDsn: "${DB_USER}:${db_pass}@tcp(127.0.0.1:3306)/${DB_NAME}?charset=utf8mb4,utf8&parseTime=true" + +# Features +DisableFaces: $([ "$FACE_RECOGNITION" = "1" ] && echo "false" || echo "true") +DisableClassification: $([ "$OBJECT_DETECTION" = "1" ] && echo "false" || echo "true") +DisablePlaces: $([ "$PLACES" = "1" ] && echo "false" || echo "true") +DisableRaw: $([ "$RAW_THUMBS" = "1" ] && echo "false" || echo "true") + +# Quality +JpegQuality: 85 +ThumbSize: 2048 +ThumbSizeUncached: 7680 +EOF +} + +# Install packages inside container +install_packages() { + log "Installing packages in container..." + + # Configure apt + cat > "${LXC_ROOTFS}/etc/apt/sources.list" << EOF +deb http://deb.debian.org/debian bookworm main contrib non-free non-free-firmware +deb http://deb.debian.org/debian bookworm-updates main contrib non-free non-free-firmware +deb http://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware +EOF + + # Create install script + cat > "${LXC_ROOTFS}/tmp/install.sh" << 'INSTALL' +#!/bin/bash +set -e +export DEBIAN_FRONTEND=noninteractive + +apt-get update +apt-get install -y --no-install-recommends \ + mariadb-server \ + ffmpeg \ + exiftool \ + libheif-examples \ + ca-certificates \ + curl \ + wget \ + gnupg + +# Clean up +apt-get clean +rm -rf /var/lib/apt/lists/* +INSTALL + chmod +x "${LXC_ROOTFS}/tmp/install.sh" + + # Run install via chroot + chroot "$LXC_ROOTFS" /tmp/install.sh + rm -f "${LXC_ROOTFS}/tmp/install.sh" +} + +# Download and install PhotoPrism binary +install_photoprism_binary() { + log "Downloading PhotoPrism ${PHOTOPRISM_VERSION} for ${ARCH}..." + + local url="https://github.com/photoprism/photoprism/releases/download/${PHOTOPRISM_VERSION}/photoprism_${PHOTOPRISM_VERSION}_linux_${ARCH}.tar.gz" + + mkdir -p "${LXC_ROOTFS}/opt/photoprism" + + wget -qO /tmp/photoprism.tar.gz "$url" || { + log_error "Failed to download PhotoPrism" + return 1 + } + + tar -xzf /tmp/photoprism.tar.gz -C "${LXC_ROOTFS}/opt/photoprism" + rm -f /tmp/photoprism.tar.gz + + chmod +x "${LXC_ROOTFS}/opt/photoprism/photoprism" + log "PhotoPrism binary installed" +} + +# Setup MariaDB +setup_database() { + local db_pass="$1" + + cat > "${LXC_ROOTFS}/tmp/setup-db.sh" << DBSCRIPT +#!/bin/bash +service mariadb start +sleep 2 + +mysql -e "CREATE DATABASE IF NOT EXISTS ${DB_NAME};" +mysql -e "CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${db_pass}';" +mysql -e "GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';" +mysql -e "FLUSH PRIVILEGES;" + +service mariadb stop +DBSCRIPT + chmod +x "${LXC_ROOTFS}/tmp/setup-db.sh" + + chroot "$LXC_ROOTFS" /tmp/setup-db.sh + rm -f "${LXC_ROOTFS}/tmp/setup-db.sh" +} + +# Full installation +cmd_install() { + defaults + + if lxc_exists; then + log_error "PhotoPrism already installed. Use 'uninstall' first." + return 1 + fi + + log "Installing PhotoPrism..." + + # Create data directories + mkdir -p "${DATA_PATH}/originals" + mkdir -p "${DATA_PATH}/storage" + mkdir -p "${DATA_PATH}/import" + chmod -R 755 "$DATA_PATH" + + # Generate passwords if not set + if [ -z "$ADMIN_PASS" ]; then + ADMIN_PASS=$(generate_password) + uci_set admin.password "$ADMIN_PASS" + log "Generated admin password: $ADMIN_PASS" + fi + + local db_pass="$DB_PASS" + if [ -z "$db_pass" ]; then + db_pass=$(generate_password) + uci_set database.password "$db_pass" + fi + + # Download rootfs + download_rootfs "$ARCH" + + # Install packages + install_packages + + # Download PhotoPrism + install_photoprism_binary + + # Setup database + setup_database "$db_pass" + + # Create configs + create_lxc_config + create_photoprism_config "$db_pass" + create_startup_script + + # Enable service + uci_set main.enabled 1 + + log "PhotoPrism installed successfully!" + log "Admin user: $ADMIN_USER" + log "Admin password: $ADMIN_PASS" + log "Access URL: http://${HOST_IP}:${HTTP_PORT}" + log "" + log "Start with: /etc/init.d/photoprism start" +} + +# Uninstall +cmd_uninstall() { + defaults + + if lxc_running; then + log "Stopping container..." + lxc-stop -n "$LXC_NAME" -k 2>/dev/null || true + fi + + if lxc_exists; then + log "Removing container..." + rm -rf "${LXC_PATH}/${LXC_NAME}" + fi + + uci_set main.enabled 0 + + log "Container removed. Data preserved at: $DATA_PATH" + log "To remove all data: rm -rf $DATA_PATH" +} + +# Start container +cmd_start() { + defaults + + if ! lxc_exists; then + log_error "PhotoPrism not installed. Run 'install' first." + return 1 + fi + + if lxc_running; then + log "Already running" + return 0 + fi + + log "Starting PhotoPrism..." + lxc-start -n "$LXC_NAME" -d + + # Wait for service + local i=0 + while [ $i -lt 30 ]; do + if wget -qO /dev/null "http://127.0.0.1:${HTTP_PORT}/api/v1/status" 2>/dev/null; then + log "PhotoPrism started on port $HTTP_PORT" + return 0 + fi + sleep 1 + i=$((i + 1)) + done + + log "PhotoPrism started (API may still be initializing)" +} + +# Stop container +cmd_stop() { + if lxc_running; then + log "Stopping PhotoPrism..." + lxc-stop -n "$LXC_NAME" + log "Stopped" + else + log "Not running" + fi +} + +# Service run (called by init.d) +cmd_service_run() { + defaults + + if ! lxc_exists; then + log_error "PhotoPrism not installed" + return 1 + fi + + # Start container in foreground + exec lxc-start -n "$LXC_NAME" -F +} + +# Service stop (called by init.d) +cmd_service_stop() { + lxc-stop -n "$LXC_NAME" -k 2>/dev/null || true +} + +# Status output (JSON for RPCD) +cmd_status() { + defaults + + local running="false" + local installed="false" + local photos=0 + local videos=0 + local storage_used="0" + + lxc_exists && installed="true" + lxc_running && running="true" + + # Get stats from PhotoPrism API if running + if [ "$running" = "true" ]; then + local stats=$(wget -qO- "http://127.0.0.1:${HTTP_PORT}/api/v1/status" 2>/dev/null || echo "{}") + # API returns photo/video counts - parse if available + fi + + # Calculate storage + if [ -d "${DATA_PATH}/originals" ]; then + storage_used=$(du -sh "${DATA_PATH}/originals" 2>/dev/null | cut -f1 || echo "0") + fi + + cat << EOF +{ + "installed": $installed, + "running": $running, + "enabled": $([ "$ENABLED" = "1" ] && echo "true" || echo "false"), + "port": $HTTP_PORT, + "photos": $photos, + "videos": $videos, + "storage_used": "$storage_used", + "data_path": "$DATA_PATH", + "domain": "$DOMAIN", + "admin_user": "$ADMIN_USER", + "face_recognition": $([ "$FACE_RECOGNITION" = "1" ] && echo "true" || echo "false"), + "object_detection": $([ "$OBJECT_DETECTION" = "1" ] && echo "true" || echo "false"), + "places": $([ "$PLACES" = "1" ] && echo "true" || echo "false") +} +EOF +} + +# Logs +cmd_logs() { + local lines="${1:-50}" + + if ! lxc_running; then + log_error "Container not running" + return 1 + fi + + lxc-attach -n "$LXC_NAME" -- tail -n "$lines" /opt/photoprism/storage/photoprism.log 2>/dev/null || \ + lxc-attach -n "$LXC_NAME" -- journalctl -n "$lines" 2>/dev/null || \ + log "No logs available" +} + +# Shell access +cmd_shell() { + if ! lxc_running; then + log_error "Container not running" + return 1 + fi + + lxc-attach -n "$LXC_NAME" -- /bin/bash +} + +# Trigger indexing +cmd_index() { + defaults + + if ! lxc_running; then + log_error "Container not running" + return 1 + fi + + log "Starting photo indexing..." + lxc-attach -n "$LXC_NAME" -- /opt/photoprism/photoprism index + log "Indexing complete" +} + +# Import from inbox +cmd_import() { + defaults + + if ! lxc_running; then + log_error "Container not running" + return 1 + fi + + local delete_opt="" + [ "$(uci_get import.delete_after_import 0)" = "1" ] && delete_opt="--move" + + log "Importing photos from ${DATA_PATH}/import..." + lxc-attach -n "$LXC_NAME" -- /opt/photoprism/photoprism import $delete_opt + log "Import complete" +} + +# Reset admin password +cmd_passwd() { + local new_pass="${1:-$(generate_password)}" + defaults + + if ! lxc_running; then + log_error "Container not running" + return 1 + fi + + lxc-attach -n "$LXC_NAME" -- /opt/photoprism/photoprism passwd "$ADMIN_USER" "$new_pass" + uci_set admin.password "$new_pass" + + log "Password reset for $ADMIN_USER" + log "New password: $new_pass" +} + +# Backup +cmd_backup() { + defaults + local backup_dir="${DATA_PATH}/backups" + local timestamp=$(date +%Y%m%d-%H%M%S) + local backup_file="${backup_dir}/photoprism-${timestamp}.tar.gz" + + mkdir -p "$backup_dir" + + if lxc_running; then + log "Dumping database..." + lxc-attach -n "$LXC_NAME" -- mysqldump -u root "$DB_NAME" > "${backup_dir}/database-${timestamp}.sql" + fi + + log "Creating backup archive..." + tar -czf "$backup_file" \ + -C "$DATA_PATH" storage \ + -C "$backup_dir" "database-${timestamp}.sql" 2>/dev/null || true + + rm -f "${backup_dir}/database-${timestamp}.sql" + + log "Backup created: $backup_file" +} + +# Configure HAProxy +cmd_configure_haproxy() { + local domain="${1:-$DOMAIN}" + defaults + + [ -z "$domain" ] && { + log_error "Domain required: photoprismctl configure-haproxy " + return 1 + } + + log "Configuring HAProxy for $domain..." + + # Add backend + uci set haproxy.photoprism_web=backend + uci set haproxy.photoprism_web.server="photoprism ${HOST_IP}:${HTTP_PORT} weight 100 check" + + # Add vhost via mitmproxy (WAF-safe) + /usr/sbin/haproxyctl vhost add "$domain" --acme 2>/dev/null || { + # Manual vhost creation + local vhost_name=$(echo "$domain" | tr '.' '_') + uci set haproxy.${vhost_name}=vhost + uci set haproxy.${vhost_name}.domain="$domain" + uci set haproxy.${vhost_name}.backend='mitmproxy_inspector' + uci set haproxy.${vhost_name}.ssl='1' + uci set haproxy.${vhost_name}.acme='1' + } + + uci commit haproxy + + # Add mitmproxy route + local routes_file="/srv/mitmproxy-in/haproxy-routes.json" + if [ -f "$routes_file" ]; then + # Add route using sed (jsonfilter doesn't support writes) + local tmp_file="/tmp/routes_$$.json" + if grep -q "\"$domain\"" "$routes_file"; then + log "Route already exists" + else + # Insert before closing brace + sed -i "s/}$/,\"$domain\": [\"${HOST_IP}\", ${HTTP_PORT}]}/" "$routes_file" + log "Added mitmproxy route" + fi + fi + + # Regenerate and reload + /usr/sbin/haproxyctl generate 2>/dev/null || true + /usr/sbin/haproxyctl reload 2>/dev/null || true + /etc/init.d/mitmproxy restart 2>/dev/null || true + + uci_set network.domain "$domain" + uci_set network.haproxy 1 + + log "HAProxy configured for https://$domain" +} + +# Emancipate (full exposure) +cmd_emancipate() { + local domain="$1" + defaults + + [ -z "$domain" ] && { + log_error "Domain required: photoprismctl emancipate " + return 1 + } + + log "Emancipating PhotoPrism to $domain..." + + # Configure HAProxy + SSL + cmd_configure_haproxy "$domain" + + # Add DNS record if dnsctl available + if command -v dnsctl >/dev/null 2>&1; then + log "Adding DNS record..." + dnsctl add "$domain" A "$(uci -q get network.wan.ipaddr)" 2>/dev/null || true + fi + + log "PhotoPrism exposed at: https://$domain" +} + +# Usage +usage() { + cat << EOF +PhotoPrism Private Photo Gallery Controller + +Usage: photoprismctl [options] + +Installation: + install Install PhotoPrism in LXC container + uninstall Remove container (preserves photos) + +Service: + start Start PhotoPrism + stop Stop PhotoPrism + restart Restart PhotoPrism + status Show status (JSON) + logs [N] Show last N log lines (default: 50) + shell Open container shell + +Photo Management: + index Trigger photo indexing + import Import from inbox folder + +Administration: + passwd [pass] Reset admin password + backup Create backup + +Network: + configure-haproxy Configure HAProxy + SSL + emancipate Full exposure (HAProxy + DNS) + +Internal (called by init.d): + service-run Run in foreground + service-stop Stop service + +Configuration: /etc/config/photoprism +Photos: /srv/photoprism/originals +EOF +} + +# Main +case "$1" in + install) cmd_install ;; + uninstall) cmd_uninstall ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_stop; sleep 1; cmd_start ;; + status) cmd_status ;; + logs) shift; cmd_logs "$@" ;; + shell) cmd_shell ;; + index) cmd_index ;; + import) cmd_import ;; + passwd) shift; cmd_passwd "$@" ;; + backup) cmd_backup ;; + configure-haproxy) shift; cmd_configure_haproxy "$@" ;; + emancipate) shift; cmd_emancipate "$@" ;; + service-run) cmd_service_run ;; + service-stop) cmd_service_stop ;; + help|--help|-h) usage ;; + "") usage ;; + *) log_error "Unknown command: $1"; usage; exit 1 ;; +esac