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:
CyberMind-FR 2026-03-06 10:00:49 +01:00
parent 70056e02ed
commit 26519e7619
11 changed files with 1671 additions and 1 deletions

View File

@ -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

View 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))

View File

@ -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
});

View File

@ -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

View File

@ -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}
}
}
}

View File

@ -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"]
}
}
}

View 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))

View 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

View File

@ -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 ''

View File

@ -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"
}

View File

@ -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