feat(photoprism): Add private photo gallery with AI features
New packages: - secubox-app-photoprism: LXC-based PhotoPrism deployment - Debian Bookworm container with MariaDB, FFmpeg - AI face recognition, object detection, places/maps - photoprismctl CLI: install/start/stop/index/import/emancipate - HAProxy integration via mitmproxy (WAF-safe) - luci-app-photoprism: KISS-themed dashboard - Stats cards (photos, videos, storage) - Service controls and AI feature display - Emancipate form for public exposure - RPCD backend with 12 methods docs: Update WIP.md with PhotoPrism feature Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
70056e02ed
commit
26519e7619
@ -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
|
||||
|
||||
29
package/secubox/luci-app-photoprism/Makefile
Normal file
29
package/secubox/luci-app-photoprism/Makefile
Normal file
@ -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 <contact@secubox.in>
|
||||
|
||||
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))
|
||||
@ -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
|
||||
});
|
||||
@ -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
|
||||
@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
53
package/secubox/secubox-app-photoprism/Makefile
Normal file
53
package/secubox/secubox-app-photoprism/Makefile
Normal file
@ -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 <contact@secubox.in>
|
||||
|
||||
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))
|
||||
118
package/secubox/secubox-app-photoprism/README.md
Normal file
118
package/secubox/secubox-app-photoprism/README.md
Normal file
@ -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 <domain>` | Setup HAProxy + SSL |
|
||||
| `emancipate <domain>` | 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
|
||||
@ -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 ''
|
||||
@ -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"
|
||||
}
|
||||
@ -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 <domain>"
|
||||
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 <domain>"
|
||||
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 <command> [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 <domain> Configure HAProxy + SSL
|
||||
emancipate <domain> 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
|
||||
Loading…
Reference in New Issue
Block a user