Compare commits
19 Commits
df6b094964
...
dev
| Author | SHA256 | Date | |
|---|---|---|---|
| 01517ca4c7 | |||
| 118cdc0dbf | |||
| 370f32ac06 | |||
| 58554db579 | |||
| 02fb5a029d | |||
| f23d7d4790 | |||
| 756103e25f | |||
| 53f72f2c37 | |||
| 821e20fd76 | |||
| c3fd4d9f57 | |||
| 0ce0b2b842 | |||
| 187ac702bf | |||
| 04bf1d358f | |||
| 26b9785ba3 | |||
| 0a5637f12a | |||
| 0352fffabe | |||
| 5227d38315 | |||
| 7a632e7a77 | |||
| 717940d5c6 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,4 +5,5 @@ src/img/**
|
|||||||
**.log
|
**.log
|
||||||
src/pages/infophp.php
|
src/pages/infophp.php
|
||||||
src/pages/gethash.php
|
src/pages/gethash.php
|
||||||
|
vars.php
|
||||||
|
**.github
|
||||||
|
|||||||
221
install
Normal file
221
install
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# --- Setup ---
|
||||||
|
BASEDIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
SRCDIR="$BASEDIR/.src"
|
||||||
|
SCRIPTDIR="$SRCDIR/scripts"
|
||||||
|
BACKUPDIR="/tmp/shutterlinksort-backup"
|
||||||
|
|
||||||
|
# --- Functions ---
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: sudo $0 [OPTIONS] [WEBROOT]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--help Show this help message
|
||||||
|
--dry-run Preview actions without changing any files
|
||||||
|
--rollback Restore the last backup
|
||||||
|
--uninstall Remove installed files and web content
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
WEBROOT Required: destination for web content
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
Install normally (default web root):
|
||||||
|
sudo $0
|
||||||
|
|
||||||
|
Install to custom web root:
|
||||||
|
sudo $0 /srv/http/shutterlinksort
|
||||||
|
|
||||||
|
Dry-run install (preview actions):
|
||||||
|
./install.sh --dry-run /srv/http/shutterlinksort
|
||||||
|
|
||||||
|
Rollback from last backup:
|
||||||
|
sudo $0 --rollback
|
||||||
|
|
||||||
|
Uninstall installed files:
|
||||||
|
sudo $0 --uninstall
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_if_exists() {
|
||||||
|
local target="$1"
|
||||||
|
if [ -e "$target" ]; then
|
||||||
|
local target_dir
|
||||||
|
target_dir="$(dirname "$target")"
|
||||||
|
if [ "$DRYRUN" = true ]; then
|
||||||
|
echo " [DRY-RUN] Would back up existing $(basename "$target") to $BACKUPDIR/$target_dir"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
mkdir -p "$BACKUPDIR/$target_dir"
|
||||||
|
cp -a "$target" "$BACKUPDIR/$target_dir/"
|
||||||
|
echo " • Backed up existing $(basename "$target")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback() {
|
||||||
|
if [ ! -d "$BACKUPDIR" ]; then
|
||||||
|
echo "No backup found at $BACKUPDIR. Nothing to restore."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
read -pr "Are you sure you want to rollback all files from backup? [y/N]: " confirm
|
||||||
|
if [[ "$confirm" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "=== Rolling back from backup ==="
|
||||||
|
if [ "$DRYRUN" = true ]; then
|
||||||
|
echo " [DRY-RUN] Would restore backup from $BACKUPDIR to /"
|
||||||
|
else
|
||||||
|
cp -a "$BACKUPDIR/"* /
|
||||||
|
echo "Rollback complete ✅"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Rollback cancelled."
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
uninstall() {
|
||||||
|
read -pr "Are you sure you want to uninstall all shutterlinksort files and web content? [y/N]: " confirm
|
||||||
|
if [[ "$confirm" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "=== Uninstalling shutterlinksort ==="
|
||||||
|
for srcfile in "$SCRIPTDIR"/shutterlinksort-*; do
|
||||||
|
filename="$(basename "$srcfile")"
|
||||||
|
progname="${filename%%-*}"
|
||||||
|
pathpart="${filename#*-}"
|
||||||
|
targetpath="${pathpart//-//}"
|
||||||
|
target="/$targetpath/$progname"
|
||||||
|
if [ "$DRYRUN" = true ] && [ -e "$target" ]; then
|
||||||
|
echo " [DRY-RUN] Would remove $target"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ -e "$target" ]; then
|
||||||
|
rm -f "$target"
|
||||||
|
echo " • Removed $target"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -d "$WEBROOT" ]; then
|
||||||
|
if [ "$DRYRUN" = true ]; then
|
||||||
|
echo " [DRY-RUN] Would remove web content at $WEBROOT"
|
||||||
|
else
|
||||||
|
rm -rf "$WEBROOT"
|
||||||
|
echo " • Removed web content at $WEBROOT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "Uninstall complete ✅"
|
||||||
|
else
|
||||||
|
echo "Uninstall cancelled."
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Parse arguments ---
|
||||||
|
DRYRUN=false
|
||||||
|
ACTION="install"
|
||||||
|
|
||||||
|
# Show usage if no arguments were provided
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--help)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRYRUN=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--rollback|--uninstall)
|
||||||
|
ACTION="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
WEBROOT="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Main ---
|
||||||
|
case "$ACTION" in
|
||||||
|
--rollback)
|
||||||
|
rollback
|
||||||
|
;;
|
||||||
|
--uninstall)
|
||||||
|
uninstall
|
||||||
|
;;
|
||||||
|
install)
|
||||||
|
echo "=== Installing shutterlinksort components ==="
|
||||||
|
echo "Using web root: $WEBROOT"
|
||||||
|
echo "Backup directory: $BACKUPDIR"
|
||||||
|
if [ "$DRYRUN" = true ]; then
|
||||||
|
echo "[DRY-RUN mode: no files will be changed]"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Clear previous backup
|
||||||
|
if [ "$DRYRUN" = true ]; then
|
||||||
|
echo "[DRY-RUN] Would remove existing $BACKUPDIR"
|
||||||
|
else
|
||||||
|
rm -rf "$BACKUPDIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DRYRUN" = true ]; then
|
||||||
|
echo "[DRY-RUN] Would create $BACKUPDIR"
|
||||||
|
else
|
||||||
|
mkdir -p "$BACKUPDIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install scripts
|
||||||
|
for srcfile in "$SCRIPTDIR"/shutterlinksort-*; do
|
||||||
|
[ -e "$srcfile" ] || continue
|
||||||
|
filename="$(basename "$srcfile")"
|
||||||
|
progname="${filename%%-*}"
|
||||||
|
pathpart="${filename#*-}"
|
||||||
|
targetpath="${pathpart//-//}"
|
||||||
|
target="/$targetpath/$progname"
|
||||||
|
|
||||||
|
echo "→ Installing $progname → $target"
|
||||||
|
mkdir -p "/$targetpath" 2>/dev/null || true
|
||||||
|
backup_if_exists "$target"
|
||||||
|
if [ "$DRYRUN" = true ]; then
|
||||||
|
echo " [DRY-RUN] Would install $srcfile → $target"
|
||||||
|
else
|
||||||
|
install -m 755 "$srcfile" "$target"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Install web content
|
||||||
|
echo
|
||||||
|
echo "=== Installing web content ==="
|
||||||
|
if [ "$DRYRUN" = true ]; then
|
||||||
|
echo " [DRY-RUN] Would create $WEBROOT"
|
||||||
|
else
|
||||||
|
mkdir -p "$WEBROOT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "$WEBROOT" ]; then
|
||||||
|
if [ "$DRYRUN" = true ]; then
|
||||||
|
echo " [DRY-RUN] Would back up existing web content to $BACKUPDIR/$(basename "$WEBROOT")"
|
||||||
|
else
|
||||||
|
cp -a "$WEBROOT" "$BACKUPDIR/"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DRYRUN" = true ]; then
|
||||||
|
echo " [DRY-RUN] Would copy $SRCDIR/web/ → $WEBROOT"
|
||||||
|
else
|
||||||
|
cp -r "$SRCDIR/web/"* "$WEBROOT/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Web content installed to: $WEBROOT"
|
||||||
|
echo "All backups stored in: $BACKUPDIR"
|
||||||
|
echo "Installation complete ✅"
|
||||||
|
echo "To rollback, run: sudo $0 --rollback"
|
||||||
|
echo "To uninstall, run: sudo $0 --uninstall"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
67
scripts/shutterlinksort-etc-init.d
Normal file
67
scripts/shutterlinksort-etc-init.d
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/bash /etc/rc.common
|
||||||
|
# shutterlinksort.init
|
||||||
|
# Copyright (C) 2025 reclusejay (James Blackmore)
|
||||||
|
# OpenWrt init script for shutterlinksort
|
||||||
|
|
||||||
|
# shellcheck disable=SC2034 # START/STOP used in rc.common
|
||||||
|
# START/STOP handled by /etc/rc.common
|
||||||
|
START=96
|
||||||
|
STOP=10
|
||||||
|
|
||||||
|
SCRIPT_NAME="shutterlinksort"
|
||||||
|
LOCK_FILE="/var/lock/$SCRIPT_NAME"
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if [ -f "$LOCK_FILE" ]; then
|
||||||
|
echo "$SCRIPT_NAME is already running (pid=$(cat "$LOCK_FILE"))"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting $SCRIPT_NAME..."
|
||||||
|
"$SCRIPT_NAME" >/dev/null 2>&1 &
|
||||||
|
pid=$!
|
||||||
|
|
||||||
|
# Verify the process started successfully
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo "$pid" > "$LOCK_FILE"
|
||||||
|
echo "$SCRIPT_NAME started with pid=$pid"
|
||||||
|
else
|
||||||
|
echo "Failed to start $SCRIPT_NAME"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if [ ! -f "$LOCK_FILE" ]; then
|
||||||
|
echo "$SCRIPT_NAME not running"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
pid=$(cat "$LOCK_FILE")
|
||||||
|
echo "Stopping $SCRIPT_NAME (pid=$pid)..."
|
||||||
|
|
||||||
|
if kill "$pid" 2>/dev/null; then
|
||||||
|
sleep 1
|
||||||
|
rm -f "$LOCK_FILE"
|
||||||
|
echo "$SCRIPT_NAME stopped"
|
||||||
|
else
|
||||||
|
echo "Failed to stop $SCRIPT_NAME (process may not exist)"
|
||||||
|
rm -f "$LOCK_FILE"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
restart() {
|
||||||
|
echo "Restarting $SCRIPT_NAME..."
|
||||||
|
stop
|
||||||
|
sleep 2
|
||||||
|
start
|
||||||
|
}
|
||||||
|
|
||||||
|
boot() {
|
||||||
|
start
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
stop
|
||||||
|
}
|
||||||
296
scripts/shutterlinksort-usr-bin
Normal file
296
scripts/shutterlinksort-usr-bin
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ======================================================================
|
||||||
|
# shutterlinksort
|
||||||
|
# ----------------
|
||||||
|
# Camera image downloader and organizer for PTP/IP-connected devices.
|
||||||
|
#
|
||||||
|
# Connects to a camera over network (using gphoto2), downloads new
|
||||||
|
# photos, extracts EXIF metadata (date, shutter count), and sorts
|
||||||
|
# images into folders by date.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# shutterlinksort
|
||||||
|
# Dependencies:
|
||||||
|
# gphoto2, exiftool, bash
|
||||||
|
#
|
||||||
|
# Author: James Blackmore (reclusejay)
|
||||||
|
# Version: 1.0
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
# --- Safety options ---
|
||||||
|
# -e : Exit immediately if a command exits with non-zero status.
|
||||||
|
# -u : Treat unset variables as an error and exit.
|
||||||
|
# -o pipefail : Fail a pipeline if *any* command in it fails.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Cleanup handler — runs on interrupt/termination
|
||||||
|
# ============================================================
|
||||||
|
cleanup() {
|
||||||
|
# Disable any special globbing options we enabled
|
||||||
|
shopt -u nullglob nocaseglob || true
|
||||||
|
|
||||||
|
# Delete any 0-byte (incomplete) downloads if present
|
||||||
|
find "$cam_dl_dir" -type f -size 0 -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
log "Caught interrupt, exiting"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
# Trap cleanup on Ctrl+C, kill, or quit signals
|
||||||
|
trap cleanup SIGINT SIGTERM SIGQUIT
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Function: php_get
|
||||||
|
# Pulls a config value from the PHP $CONFIG array
|
||||||
|
# ============================================================
|
||||||
|
php_get() {
|
||||||
|
if ! command -v php >/dev/null 2>&1; then
|
||||||
|
# fallback to php-cli
|
||||||
|
if command -v php-cli >/dev/null 2>&1; then
|
||||||
|
php-cli -r "include('../src/includes/config.php'); print \$CONFIG['$1'];"
|
||||||
|
else
|
||||||
|
echo "Error: Neither 'php' nor 'php-cli' is available."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
php -r "include('../src/includes/config.php'); print \$CONFIG['$1'];"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Load all settings from PHP config
|
||||||
|
# ============================================================
|
||||||
|
cam_uuid=$(php_get "camera_uuid")
|
||||||
|
cam_dl_dir=$(php_get "upload_dir")
|
||||||
|
cam_sort_dir=$(php_get "sorted_img_dir")
|
||||||
|
cam_index_file=$(php_get "index_file")
|
||||||
|
cam_xml_url=$(php_get "camera_xml_url")
|
||||||
|
cam_ext_format=$(php_get "camera_ext_formats")
|
||||||
|
cam_mac=$(php_get "camera_mac")
|
||||||
|
g2_port=$(php_get "gphoto2_config_port")
|
||||||
|
g2_model=$(php_get "gphoto2_config_model")
|
||||||
|
g2_guid=$(php_get "gphoto2_config_guid")
|
||||||
|
g2_conf=$(php_get "gphoto2_config_file")
|
||||||
|
|
||||||
|
# Create the index file if missing
|
||||||
|
[ -f "$cam_index_file" ] || echo 0 > "$cam_index_file"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Global variables
|
||||||
|
# ============================================================
|
||||||
|
debug=true
|
||||||
|
ver="v.25.10.1"
|
||||||
|
STATUS="INITIALIZING"
|
||||||
|
CAM_IP=""
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Function: log
|
||||||
|
# Provides timestamped console and syslog logging
|
||||||
|
# ============================================================
|
||||||
|
log() {
|
||||||
|
local msg lev full_msg
|
||||||
|
if $debug; then
|
||||||
|
msg="${1:-NO MESSAGE!}"
|
||||||
|
lev="${2:-info}"
|
||||||
|
|
||||||
|
full_msg="[debug $lev] Version: $ver - $(date +[%Y/%m/%d-%H:%M:%S]) - $msg"
|
||||||
|
|
||||||
|
# Always print to stdout for debugging
|
||||||
|
echo "$full_msg"
|
||||||
|
|
||||||
|
# Try syslog first; fallback to a file log if logger fails
|
||||||
|
logger -t camera-download-sort "$full_msg" 2>/dev/null || \
|
||||||
|
echo "$full_msg" >> /var/log/camera-download-sort.log
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Function: find_ip_by_mac
|
||||||
|
# Locates the IP address of a device by its MAC address
|
||||||
|
# ============================================================
|
||||||
|
find_ip_by_mac() {
|
||||||
|
local mac ip file
|
||||||
|
|
||||||
|
# Normalize MAC to lowercase for comparison
|
||||||
|
mac=$(echo "$1" | tr '[:upper:]' '[:lower:]')
|
||||||
|
[ -z "$mac" ] && log "No MAC provided" && return 1
|
||||||
|
|
||||||
|
# 1️⃣ Primary source: /proc/net/arp (most reliable)
|
||||||
|
ip=$(awk -v mac="$mac" 'NR>1 && tolower($4)==mac {print $1; exit}' /proc/net/arp)
|
||||||
|
|
||||||
|
# 2️⃣ Fallback: BusyBox arp command output
|
||||||
|
if [ -z "$ip" ] && command -v arp >/dev/null 2>&1; then
|
||||||
|
ip=$(arp | awk -v mac="$mac" 'NR>1 && tolower($4)==mac {print $1; exit}')
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3️⃣ Fallback: dnsmasq lease files
|
||||||
|
if [ -z "$ip" ]; then
|
||||||
|
for file in /tmp/dhcp.leases /var/lib/misc/dnsmasq.leases; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
ip=$(awk -v mac="$mac" 'tolower($2)==mac {print $3; exit}' "$file")
|
||||||
|
[ -n "$ip" ] && break
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If found, set global CAM_IP variable
|
||||||
|
if [ -n "$ip" ]; then
|
||||||
|
CAM_IP="$ip"
|
||||||
|
log "Camera found at $CAM_IP"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Not found
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Function: is_camera_connected
|
||||||
|
# Checks whether the camera’s MAC address is visible
|
||||||
|
# ============================================================
|
||||||
|
is_camera_connected() {
|
||||||
|
if find_ip_by_mac "$cam_mac"; then
|
||||||
|
if [ "$STATUS" = "WAITING" ]; then
|
||||||
|
# Already in waiting state — skip
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
STATUS="CONNECTED"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
STATUS="DISCONNECTED"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Function: download_images
|
||||||
|
# Uses gphoto2 (PTP/IP) to pull new images from camera
|
||||||
|
# ============================================================
|
||||||
|
download_images() {
|
||||||
|
local wd="$PWD"
|
||||||
|
STATUS="WAITING"
|
||||||
|
cd "$cam_dl_dir" || return 1
|
||||||
|
|
||||||
|
# Create gphoto2 config file if it doesn’t exist
|
||||||
|
if [ ! -f "$g2_conf" ]; then
|
||||||
|
{
|
||||||
|
echo "$g2_port$CAM_IP"
|
||||||
|
echo "$g2_model"
|
||||||
|
echo "$g2_guid$cam_uuid"
|
||||||
|
} > "$g2_conf"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for gphoto2 availability
|
||||||
|
if command -v gphoto2 >/dev/null 2>&1; then
|
||||||
|
# Download all new files, skipping ones that already exist
|
||||||
|
if gphoto2 --port "ptpip:$CAM_IP" -P --skip-existing; then
|
||||||
|
log "Downloaded new images from $CAM_IP"
|
||||||
|
cd "$wd" || true
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "gphoto2 download failed" error
|
||||||
|
else
|
||||||
|
log "gphoto2 not found — skipping download" warn
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$wd" || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Function: sort_images
|
||||||
|
# Sorts downloaded images into folders by EXIF date
|
||||||
|
# ============================================================
|
||||||
|
sort_images() {
|
||||||
|
local ext img datetime shuttercount yyyy mm dd timepart hhmmss ext_lower \
|
||||||
|
output_dir new_filename new_file uid index
|
||||||
|
|
||||||
|
STATUS="SORTING"
|
||||||
|
log "$STATUS"
|
||||||
|
|
||||||
|
# Ensure exiftool exists
|
||||||
|
command -v exiftool >/dev/null 2>&1 || {
|
||||||
|
log "exiftool missing — cannot sort" error
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get current image index number
|
||||||
|
index=$(cat "$cam_index_file")
|
||||||
|
|
||||||
|
# Loop over each configured image extension (e.g. JPG ARW)
|
||||||
|
for ext in $cam_ext_format; do
|
||||||
|
# Enable flexible globbing (case-insensitive, no literal fail)
|
||||||
|
shopt -s nullglob nocaseglob
|
||||||
|
|
||||||
|
# Iterate through each matching file
|
||||||
|
for img in "$cam_dl_dir"/*."$ext"; do
|
||||||
|
|
||||||
|
# Pull EXIF date and shutter count
|
||||||
|
readarray -t exif_data < <(exiftool -s -s -s -DateTimeOriginal -ShutterCount "$img")
|
||||||
|
datetime="${exif_data[0]:-0000:00:00 00:00:00}"
|
||||||
|
shuttercount="${exif_data[1]:-}"
|
||||||
|
|
||||||
|
log "Processing $img (index $index)"
|
||||||
|
|
||||||
|
# Generate a unique ID if no shuttercount found
|
||||||
|
if [ -z "$shuttercount" ]; then
|
||||||
|
uid="rn${RANDOM}idx${index}"
|
||||||
|
log "No shuttercount in $img, uid=$uid"
|
||||||
|
else
|
||||||
|
uid="sc${shuttercount}idx${index}"
|
||||||
|
log "Shuttercount=$shuttercount uid=$uid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse EXIF date into components
|
||||||
|
yyyy=$(echo "$datetime" | cut -d':' -f1)
|
||||||
|
mm=$(echo "$datetime" | cut -d':' -f2)
|
||||||
|
dd=$(echo "$datetime" | cut -d':' -f3 | cut -d' ' -f1)
|
||||||
|
timepart=$(echo "$datetime" | cut -d' ' -f2)
|
||||||
|
hhmmss=$(echo "$timepart" | tr -d ':')
|
||||||
|
|
||||||
|
# Normalize extension to lowercase
|
||||||
|
ext_lower=${ext,,}
|
||||||
|
|
||||||
|
# Build destination folder structure
|
||||||
|
output_dir="$cam_sort_dir/$ext_lower/$yyyy/$mm/$dd"
|
||||||
|
mkdir -p "$output_dir" || { log "mkdir failed: $output_dir" error; return 1; }
|
||||||
|
|
||||||
|
# Construct new filename
|
||||||
|
new_filename="${hhmmss}_${uid}.${ext_lower}"
|
||||||
|
new_file="$output_dir/$new_filename"
|
||||||
|
|
||||||
|
log "Moving $img -> $new_file"
|
||||||
|
|
||||||
|
# Move and increment index
|
||||||
|
if mv "$img" "$new_file"; then
|
||||||
|
index=$((index + 1))
|
||||||
|
else
|
||||||
|
log "Failed to move $img" error
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Update index file after processing each extension type
|
||||||
|
printf '%s\n' "$index" > "$cam_index_file"
|
||||||
|
done
|
||||||
|
|
||||||
|
STATUS="DONE"
|
||||||
|
log "$STATUS"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Main loop — runs forever
|
||||||
|
# ============================================================
|
||||||
|
log "Camera script started"
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
# If camera detected and not already downloading/sorting
|
||||||
|
if is_camera_connected && [ "$STATUS" != "WAITING" ] && [ "$STATUS" != "SORTING" ]; then
|
||||||
|
download_images || log "Download step failed" error
|
||||||
|
# If camera disconnected, begin sorting
|
||||||
|
elif [ "$STATUS" = "DISCONNECTED" ]; then
|
||||||
|
sort_images || log "Sort step failed" error
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Avoid hammering system; check every 2 seconds
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
108
src/css/options.css
Normal file
108
src/css/options.css
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/* css/options.css - matches SPA theme */
|
||||||
|
|
||||||
|
/* Container for the options page */
|
||||||
|
.options-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center; /* center horizontally */
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px 18px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 60vh;
|
||||||
|
font-family: Inter, "Segoe UI", Roboto, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards / sections inside options page */
|
||||||
|
.option-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: 0 2px 6px var(--shadow);
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings inside cards */
|
||||||
|
.option-card h1,
|
||||||
|
.option-card h2,
|
||||||
|
.option-card h3,
|
||||||
|
.option-card h4 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons inside cards */
|
||||||
|
.option-card button {
|
||||||
|
margin: 0.25rem;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.option-card button:hover {
|
||||||
|
background: var(--accentHover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs (password, text) and textarea (config/logs) */
|
||||||
|
.option-card input[type="password"],
|
||||||
|
.option-card input[type="text"],
|
||||||
|
.option-card textarea {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
margin: 0.25rem 0.5rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config editor & logs box */
|
||||||
|
#configEditor,
|
||||||
|
#logsBox {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
height: 200px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flash animation for updates */
|
||||||
|
.updated {
|
||||||
|
background-color: rgba(0, 200, 0, 0.2);
|
||||||
|
transition: background 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive tweaks */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.option-card input[type="password"],
|
||||||
|
.option-card input[type="text"],
|
||||||
|
.option-card textarea {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/css/shell.css
Normal file
109
src/css/shell.css
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/* css/shell.css — Themed terminal styling (dark/light aware) */
|
||||||
|
|
||||||
|
.shell-page {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 12px var(--shadow);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "JetBrains Mono", monospace;
|
||||||
|
transition: background 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-page h2 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-output {
|
||||||
|
background: #171717;
|
||||||
|
color: #01b501;
|
||||||
|
padding: 1rem;
|
||||||
|
height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
box-shadow: inset 0 0 6px rgba(0,0,0,0.4);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-mode .shell-output {
|
||||||
|
background: #f9f9f9;
|
||||||
|
color: #004400;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
box-shadow: inset 0 0 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: inset 0 0 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-form .prompt {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-mode .shell-input {
|
||||||
|
border: 1px solid rgba(0,0,0,0.1);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-run {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-run:hover {
|
||||||
|
background: var(--accentHover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-run:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.line {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.line.cmd { color: var(--accent); }
|
||||||
|
pre.line.err { color: #f33; }
|
||||||
|
pre.line.out { color: #01b501; }
|
||||||
|
body.light-mode pre.line.out { color: #007700; }
|
||||||
@@ -16,6 +16,13 @@ $cache_file = $CONFIG['cache_file'];
|
|||||||
// Serve cached gallery JSON if fresh
|
// Serve cached gallery JSON if fresh
|
||||||
if (file_exists($cache_file)) {
|
if (file_exists($cache_file)) {
|
||||||
$data = json_decode(file_get_contents($cache_file), true);
|
$data = json_decode(file_get_contents($cache_file), true);
|
||||||
|
$count = count_all_images($CONFIG);
|
||||||
|
if ($count !== $data['countall']) {
|
||||||
|
unlink($cache_file);
|
||||||
|
$data = buildGalleryCache($CONFIG);
|
||||||
|
if (!is_dir(dirname($cache_file))) @mkdir(dirname($cache_file), 0775, true);
|
||||||
|
file_put_contents($cache_file, json_encode($data, JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$data = buildGalleryCache($CONFIG);
|
$data = buildGalleryCache($CONFIG);
|
||||||
if (!is_dir(dirname($cache_file))) @mkdir(dirname($cache_file), 0775, true);
|
if (!is_dir(dirname($cache_file))) @mkdir(dirname($cache_file), 0775, true);
|
||||||
@@ -37,6 +44,53 @@ echo json_encode([
|
|||||||
|
|
||||||
// --- FUNCTIONS ---
|
// --- FUNCTIONS ---
|
||||||
|
|
||||||
|
function count_all_images($CONFIG) {
|
||||||
|
$dir = $CONFIG['sorted_img_dir'];
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$iterator = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->isFile()) {
|
||||||
|
$ext = strtolower($file->getExtension());
|
||||||
|
if ($ext === 'arw' || $ext === 'raw' ||
|
||||||
|
$ext === 'jpg' || $ext === 'png' ) {
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function count_raw_files($CONFIG) {
|
||||||
|
$dir = $CONFIG['sorted_img_dir'].'/arw';
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$iterator = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->isFile()) {
|
||||||
|
$ext = strtolower($file->getExtension());
|
||||||
|
if ($ext === 'arw' || $ext === 'raw') {
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
function buildGalleryCache($CONFIG) {
|
function buildGalleryCache($CONFIG) {
|
||||||
$dir = $CONFIG['gallery_dir'];
|
$dir = $CONFIG['gallery_dir'];
|
||||||
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir));
|
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir));
|
||||||
@@ -74,11 +128,13 @@ function buildGalleryCache($CONFIG) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'generated' => date('c'),
|
'generated' => date('c'),
|
||||||
'count' => count($images),
|
'countjpg' => count($images),
|
||||||
|
'countraw' => count_raw_files($CONFIG),
|
||||||
|
'countall' => count_all_images($CONFIG),
|
||||||
'images' => $images
|
'images' => $images
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
20
src/includes/api/admin.php
Normal file
20
src/includes/api/admin.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
if (empty($_SESSION['is_admin'])) exit(json_encode(['error'=>'Not authorized']));
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
$old = $_POST['oldPass'] ?? '';
|
||||||
|
$new = $_POST['newPass'] ?? '';
|
||||||
|
|
||||||
|
$stored_hash = file_get_contents(__DIR__.'/../../admin.pass'); // store hashed password
|
||||||
|
|
||||||
|
if($action === 'change_pass'){
|
||||||
|
if(!password_verify($old, $stored_hash)) exit(json_encode(['error'=>'Current password incorrect']));
|
||||||
|
$hash = password_hash($new, PASSWORD_DEFAULT);
|
||||||
|
file_put_contents(__DIR__.'/../../admin.pass', $hash);
|
||||||
|
echo json_encode(['message'=>'Password changed successfully']);
|
||||||
|
}else{
|
||||||
|
echo json_encode(['error'=>'Unknown action']);
|
||||||
|
}
|
||||||
16
src/includes/api/cache.php
Normal file
16
src/includes/api/cache.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
if (empty($_SESSION['is_admin'])) exit(json_encode(['error'=>'Not authorized']));
|
||||||
|
|
||||||
|
$cacheDir = __DIR__.'/../../cache';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if(!is_dir($cacheDir)){
|
||||||
|
echo json_encode(['error'=>'Cache folder not found']); exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = glob($cacheDir.'/*');
|
||||||
|
foreach($files as $f){ if(is_file($f)) unlink($f); }
|
||||||
|
|
||||||
|
echo json_encode(['message'=>'Cache cleared']);
|
||||||
100
src/includes/api/config.php
Normal file
100
src/includes/api/config.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
// includes/api/config.php
|
||||||
|
session_start();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if (empty($_SESSION['is_admin'])) {
|
||||||
|
echo json_encode(['error' => 'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$configFile = __DIR__ . '/../config.php';
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
|
||||||
|
function makeDiff($old, $new) {
|
||||||
|
$oldLines = explode("\n", $old);
|
||||||
|
$newLines = explode("\n", $new);
|
||||||
|
$diff = [];
|
||||||
|
|
||||||
|
$max = max(count($oldLines), count($newLines));
|
||||||
|
for ($i = 0; $i < $max; $i++) {
|
||||||
|
$o = $oldLines[$i] ?? '';
|
||||||
|
$n = $newLines[$i] ?? '';
|
||||||
|
if ($o !== $n) {
|
||||||
|
$diff[] = sprintf("Line %d:\n- %s\n+ %s", $i + 1, $o, $n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return implode("\n\n", $diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syntaxCheck($content) {
|
||||||
|
$tmpFile = tempnam(sys_get_temp_dir(), 'cfgcheck_');
|
||||||
|
file_put_contents($tmpFile, "<?php\n" . $content);
|
||||||
|
exec("php -l " . escapeshellarg($tmpFile), $output, $ret);
|
||||||
|
//unlink($tmpFile);
|
||||||
|
if ($ret !== 0) return implode("\n", $output);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'load':
|
||||||
|
if (!file_exists($configFile)) {
|
||||||
|
echo json_encode(['error' => 'Config file not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$content = file_get_contents($configFile);
|
||||||
|
echo json_encode(['content' => $content]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'preview-save':
|
||||||
|
$newData = $_POST['data'] ?? '';
|
||||||
|
if (!file_exists($configFile)) {
|
||||||
|
echo json_encode(['error' => 'Config file not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$oldData = file_get_contents($configFile);
|
||||||
|
$diff = makeDiff($oldData, $newData);
|
||||||
|
|
||||||
|
// Syntax check
|
||||||
|
$syntaxErr = syntaxCheck($newData);
|
||||||
|
if ($syntaxErr) {
|
||||||
|
echo json_encode(['error' => "Syntax error detected:\n$syntaxErr"]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($diff) === '') {
|
||||||
|
echo json_encode(['message' => 'No changes detected']);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['diff' => $diff]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'save':
|
||||||
|
$newData = $_POST['data'] ?? '';
|
||||||
|
if (trim($newData) === '') {
|
||||||
|
echo json_encode(['error' => 'Config data cannot be empty']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Syntax check before saving
|
||||||
|
$syntaxErr = syntaxCheck($newData);
|
||||||
|
if ($syntaxErr) {
|
||||||
|
echo json_encode(['error' => "Syntax error detected:\n$syntaxErr"]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup original
|
||||||
|
copy($configFile, $configFile . '.bak_' . date('Ymd_His'));
|
||||||
|
|
||||||
|
$result = file_put_contents($configFile, $newData);
|
||||||
|
if ($result === false) {
|
||||||
|
echo json_encode(['error' => 'Failed to write config file']);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['message' => 'Configuration saved successfully']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
echo json_encode(['error' => 'Invalid action']);
|
||||||
|
}
|
||||||
|
?>
|
||||||
14
src/includes/api/logs.php
Normal file
14
src/includes/api/logs.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
if (empty($_SESSION['is_admin'])) exit(json_encode(['error'=>'Not authorized']));
|
||||||
|
|
||||||
|
$logFile = __DIR__.'/../../logs/system.log';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if(!file_exists($logFile)){
|
||||||
|
echo json_encode(['error'=>'Log file not found']); exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = file_get_contents($logFile);
|
||||||
|
echo json_encode(['content'=>$content]);
|
||||||
55
src/includes/api/system.php
Normal file
55
src/includes/api/system.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
if (empty($_SESSION['is_admin'])) exit(json_encode(['error'=>'Not authorized']));
|
||||||
|
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
switch($action){
|
||||||
|
case 'system-info':
|
||||||
|
$uptime = shell_exec('uptime -p');
|
||||||
|
$load = sys_getloadavg()[0] ?? 'N/A';
|
||||||
|
$mem = round(memory_get_usage()/1024/1024,2) . ' MB';
|
||||||
|
echo json_encode([
|
||||||
|
'uptime'=>$uptime,
|
||||||
|
'load'=>$load,
|
||||||
|
'memory'=>$mem
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'wifi-status':
|
||||||
|
$status = trim(shell_exec('uci get wireless.@wifi-iface[0].disabled')) === '1' ? 'disabled' : 'enabled';
|
||||||
|
echo json_encode(['message'=>$status]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'wifi-toggle':
|
||||||
|
shell_exec('uci set wireless.@wifi-iface[0].disabled=$( [ $(uci get wireless.@wifi-iface[0].disabled) -eq 1 ] && echo 0 || echo 1 ) && wifi');
|
||||||
|
echo json_encode(['message'=>'Wi-Fi toggled']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'service-status':
|
||||||
|
$service = escapeshellarg($_POST['service'] ?? '');
|
||||||
|
if(!$service) { echo json_encode(['error'=>'No service specified']); exit; }
|
||||||
|
$status = shell_exec("service $service status");
|
||||||
|
echo json_encode(['message'=> $status]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'service-start':
|
||||||
|
case 'service-stop':
|
||||||
|
case 'service-restart':
|
||||||
|
$service = escapeshellarg($_POST['service'] ?? '');
|
||||||
|
if(!$service) { echo json_encode(['error'=>'No service specified']); exit; }
|
||||||
|
$cmd = str_replace('service-', '', $action);
|
||||||
|
shell_exec("service $service $cmd");
|
||||||
|
echo json_encode(['message'=>"Service $service $cmd executed"]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'system-reboot':
|
||||||
|
shell_exec('reboot');
|
||||||
|
echo json_encode(['message'=>'Rebooting device']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
echo json_encode(['error'=>'Unknown action']);
|
||||||
|
}
|
||||||
@@ -1,39 +1,83 @@
|
|||||||
<?php
|
<?php
|
||||||
// includes/config.php
|
// includes/config.php
|
||||||
// Global configuration for the site. Edit values as needed.
|
|
||||||
|
// === Site Setting ===
|
||||||
$ROOT_DIR='/home/reclusejay/repos/camera-gallery/src';
|
$ROOT_DIR='/home/reclusejay/repos/camera-gallery/src';
|
||||||
$CONFIG = [
|
$CONFIG = [
|
||||||
// === General Site Info ===
|
|
||||||
'site_name' => 'My Photo Gallery',
|
|
||||||
'nav_order' => ['home', 'gallery', 'upload'],
|
|
||||||
'nav_admin' => ['shell', 'gethash'],
|
|
||||||
'nav_hidden' => ['admin','infophp'],
|
|
||||||
'default_theme' => 'dark', // 'dark' or 'light'
|
|
||||||
'timezone' => 'UTC',
|
|
||||||
|
|
||||||
// === Paths ===
|
// === General Site Info ===
|
||||||
'base_dir' => $ROOT_DIR,
|
'site_name' => 'The Photo Gallery',
|
||||||
'gallery_dir' => $ROOT_DIR.'/img/sorted/jpg',
|
'default_theme' => 'dark', // 'dark' or 'light'
|
||||||
'thumb_dir' => $ROOT_DIR.'/cache/thumbs',
|
'nav_order' => ['home', 'gallery', 'upload'],
|
||||||
'cache_dir' => $ROOT_DIR.'/cache',
|
'nav_admin' => ['shell', 'options'],
|
||||||
'log_dir' => $ROOT_DIR.'/logs',
|
'nav_hidden' => ['admin','infophp','gethash'],
|
||||||
'upload_dir' => $ROOT_DIR.'/img/uploads',
|
'timezone' => 'UTC',
|
||||||
'index_file' => $ROOT_DIR.'/img/.index',
|
|
||||||
|
|
||||||
// === Gallery & Caching ===
|
// === Gallery & Caching ===
|
||||||
'max_per_page' => 100,
|
'max_per_page' => 100,
|
||||||
'thumb_max_size' => 300,
|
'thumb_max_size' => 300,
|
||||||
'thumb_quality' => 80,
|
'thumb_quality' => 80,
|
||||||
'cache_file' => $ROOT_DIR.'/cache/gallery.json',
|
|
||||||
'remove_nodes' => ['MAKERNOTE', 'INTEROP', 'THUMBNAIL'],
|
|
||||||
'remove_tags' => ['SectionsFound','UserComment','UserCommentEncoding','Thumbnail.FileType','Thumbnail.MimeType','ComponentsConfiguration','FileSource','SceneType'],
|
|
||||||
'exif_dir' => $ROOT_DIR.'/cache/exif',
|
|
||||||
|
|
||||||
// === Uploads ===
|
// === Uploads ===
|
||||||
'upload_password' => '$2y$12$Fb7u3H.428PoPCy/pxhqpu.poqjmDbiyzJtRJs/CEcCEPPMOYBLCm', // bcrypt hash
|
|
||||||
'max_parallel_uploads' => 2, // max simultaneous uploads
|
'max_parallel_uploads' => 2, // max simultaneous uploads
|
||||||
|
|
||||||
|
// === Shell ===
|
||||||
|
'blacklist_commands' => ['rm', 'shutdown', 'reboot', 'passwd', 'dd', ':(){'],
|
||||||
|
'default_dir' => '/home',
|
||||||
|
|
||||||
|
// === ShutterLinkSort bash file config ===
|
||||||
|
'camera_mac' => '00:00:00:00:00:00',
|
||||||
|
'camera_uuid' => 'e4:ec:89:a3:0c:b5:4d:1d:cb:e3:a8:e1:ff:68:48:ed',
|
||||||
|
'camera_xml_url' => 'http://ip:1900/DeviceDescription.xml',
|
||||||
|
'camera_ext_formats' => 'ARW JPG',
|
||||||
|
'gphoto2_config_file' => '/root/.gphoto/settings',
|
||||||
|
'gphoto2_config_port' => 'gphoto2=port=ptpip:',
|
||||||
|
'gphoto2_config_model' => 'gphoto2=model=PTP/IP Camera',
|
||||||
|
'gphoto2_config_guid' => 'ptp2_ip=guid=',
|
||||||
|
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
// === JavaScript Variables ===
|
||||||
|
$CONFIG_JS = [
|
||||||
|
// === Index System Wide ===
|
||||||
|
'index_admin_status_update_interval' => 50000,
|
||||||
|
|
||||||
|
// === Home Page ===
|
||||||
|
'home_chart_color_threshold_medium' => '70',
|
||||||
|
'home_chart_color_threshold_high' => '80',
|
||||||
|
|
||||||
|
// === Shell Page ===
|
||||||
|
'shell_print_howto_to_logconsole' => TRUE,
|
||||||
|
|
||||||
|
// === Options Page ===
|
||||||
|
'options_status_interval' => 100000,
|
||||||
|
'options_status_services' => ['uhttpd', 'dnsmasq', 'dropbear'],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
// === ! DO NOT CHANGE ! ===
|
||||||
|
$CONFIG += [
|
||||||
|
// === Paths ===
|
||||||
|
'base_dir' => $ROOT_DIR, // Location of root directory for the web server usually (Define at the top $ROOT_DIR)
|
||||||
|
'sorted_img_dir' => $ROOT_DIR.'/img/sorted',
|
||||||
|
'gallery_dir' => $ROOT_DIR.'/img/sorted/jpg', // Location of the images
|
||||||
|
'upload_dir' => $ROOT_DIR.'/img/uploads', // Location for uploaded images
|
||||||
|
'index_file' => $ROOT_DIR.'/img/.index', // Location of the index file for image naming
|
||||||
|
'cache_dir' => $ROOT_DIR.'/cache', // Location of the cache directory
|
||||||
|
'thumb_dir' => $ROOT_DIR.'/cache/thumbs', // Location for generated thumbnail
|
||||||
|
'exif_dir' => $ROOT_DIR.'/cache/exif', // Location of exif file, info extracted from images
|
||||||
|
'cache_file' => $ROOT_DIR.'/cache/gallery.json', // Location of the gallery cache file
|
||||||
|
'log_dir' => $ROOT_DIR.'/logs', // Location of the log files
|
||||||
|
// Admin Hashed Password
|
||||||
|
'upload_password' => '$2y$12$Fb7u3H.428PoPCy/pxhqpu.poqjmDbiyzJtRJs/CEcCEPPMOYBLCm', // The bcrypt hash for admin password ! DO NOT USE PLAIN TEXT PASSWORD !
|
||||||
|
// Exclude empty or useless exif information
|
||||||
|
'remove_nodes' => ['MAKERNOTE', 'INTEROP', 'THUMBNAIL'],
|
||||||
|
'remove_tags' => ['SectionsFound','UserComment','UserCommentEncoding','Thumbnail.FileType','Thumbnail.MimeType','ComponentsConfiguration','FileSource','SceneType'],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Apply timezone globally
|
// Apply timezone globally
|
||||||
date_default_timezone_set($CONFIG['timezone']);
|
date_default_timezone_set($CONFIG['timezone']);
|
||||||
|
|
||||||
|
?>
|
||||||
46
src/includes/shell_exec.php
Normal file
46
src/includes/shell_exec.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
// includes/shell_exec.php — Executes shell commands securely (admin only)
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
session_start();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if (empty($_SESSION['is_admin'])) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'Not authorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd = trim($_POST['command'] ?? '');
|
||||||
|
if ($cmd === '') {
|
||||||
|
echo json_encode(['output' => '']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restrict dangerous commands for safety
|
||||||
|
$blacklist = $CONFIG['blacklist_commands'];
|
||||||
|
foreach ($blacklist as $bad) {
|
||||||
|
if (stripos($cmd, $bad) !== false) {
|
||||||
|
echo json_encode(['output' => "⚠️ Command '$bad' not allowed"]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute safely (captures both stdout and stderr)
|
||||||
|
$descriptor = [
|
||||||
|
1 => ['pipe', 'w'],
|
||||||
|
2 => ['pipe', 'w']
|
||||||
|
];
|
||||||
|
$process = proc_open($cmd, $descriptor, $pipes, $CONFIG['default_dir']);
|
||||||
|
if (is_resource($process)) {
|
||||||
|
$output = stream_get_contents($pipes[1]);
|
||||||
|
$error = stream_get_contents($pipes[2]);
|
||||||
|
fclose($pipes[1]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
$status = proc_close($process);
|
||||||
|
echo json_encode([
|
||||||
|
'output' => trim($output . "\n" . $error),
|
||||||
|
'status' => $status
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['output' => 'Failed to execute command']);
|
||||||
|
}
|
||||||
@@ -45,14 +45,17 @@ $disk0_free = disk_free_space('/home');
|
|||||||
$disk0_used = $disk0_total - $disk0_free;
|
$disk0_used = $disk0_total - $disk0_free;
|
||||||
$disk0_usage = round(($disk0_used / $disk0_total) * 100, 1);
|
$disk0_usage = round(($disk0_used / $disk0_total) * 100, 1);
|
||||||
|
|
||||||
// --- Gallery image count ---
|
// --- Gallery image countjpg ---
|
||||||
// --- Image count from cache/gallery.json ---
|
// --- Image countjpg from cache/gallery.json ---
|
||||||
$jsonFile = __DIR__ . '/../cache/gallery.json';
|
$jsonFile = __DIR__ . '/../cache/gallery.json';
|
||||||
if (file_exists($jsonFile)) {
|
if (file_exists($jsonFile)) {
|
||||||
$gallery = json_decode(file_get_contents($jsonFile), true);
|
$gallery = json_decode(file_get_contents($jsonFile), true);
|
||||||
$count = $gallery['count'] ?? 0;
|
$countjpg = $gallery['countjpg'] ?? 0;
|
||||||
|
$countraw = $gallery['countraw'] ?? 0;
|
||||||
} else {
|
} else {
|
||||||
$count = 0;
|
// generate the file
|
||||||
|
$countjpg = 0;
|
||||||
|
$countraw = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -70,5 +73,6 @@ echo json_encode([
|
|||||||
'disk0' => $disk0_usage,
|
'disk0' => $disk0_usage,
|
||||||
'disk0_total' => $disk0_total,
|
'disk0_total' => $disk0_total,
|
||||||
'disk0_used' => $disk0_used,
|
'disk0_used' => $disk0_used,
|
||||||
'images' => $count,
|
'imagesjpg' => $countjpg,
|
||||||
|
'imagesraw' => $countraw,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
// index.php
|
||||||
|
// Start session and include config
|
||||||
session_start();
|
session_start();
|
||||||
// index.php - SPA
|
|
||||||
require_once __DIR__ . '/includes/config.php';
|
require_once __DIR__ . '/includes/config.php';
|
||||||
|
|
||||||
// Replicate what admin_auth.php does for the "check" action
|
// Replicate what admin_auth.php does for the "check" action
|
||||||
@@ -11,12 +12,13 @@ $pages_dir = __DIR__ . '/pages';
|
|||||||
$nav_order = $CONFIG['nav_order'] ?? [];
|
$nav_order = $CONFIG['nav_order'] ?? [];
|
||||||
$nav_admin = $CONFIG['nav_admin'] ?? [];
|
$nav_admin = $CONFIG['nav_admin'] ?? [];
|
||||||
$nav_hidden = $CONFIG['nav_hidden'] ?? [];
|
$nav_hidden = $CONFIG['nav_hidden'] ?? [];
|
||||||
$nav_items = [];
|
|
||||||
$nav_bar = [];
|
$nav_bar = [];
|
||||||
|
$nav_bar_admin = [];
|
||||||
|
|
||||||
if (is_dir($pages_dir)) {
|
if (is_dir($pages_dir)) {
|
||||||
foreach (glob($pages_dir . '/*.php') as $file) {
|
foreach (glob($pages_dir . '/*.php') as $file) {
|
||||||
$base = strtolower(basename($file, '.php'));
|
$base = strtolower(basename($file, '.php'));
|
||||||
if (in_array($base, ['_template', 'error'])) continue; // skip special files
|
if (in_array($base, ['_template', 'error', 'log'])) continue; // skip special files
|
||||||
if (in_array($base, array_map('strtolower', $nav_hidden))) continue;
|
if (in_array($base, array_map('strtolower', $nav_hidden))) continue;
|
||||||
$label = ucfirst($base);
|
$label = ucfirst($base);
|
||||||
|
|
||||||
@@ -29,24 +31,7 @@ if (is_dir($pages_dir)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Categorize pages
|
// Order nav_bar according to $nav_order
|
||||||
foreach ($nav_items as $base => $label) {
|
|
||||||
$lcBase = lcfirst($base);
|
|
||||||
|
|
||||||
if (in_array($lcBase, array_map('strtolower',$nav_hidden))) {
|
|
||||||
continue; // skip hidden
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($lcBase, $nav_admin)) {
|
|
||||||
$nav_bar_admin[] = $label;
|
|
||||||
} elseif (in_array($lcBase, $nav_order)) {
|
|
||||||
$nav_bar[] = $label; // ordered pages
|
|
||||||
} else {
|
|
||||||
$nav_bar[] = $label; // unordered normal pages
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: order nav_bar according to $nav_order
|
|
||||||
$ordered = [];
|
$ordered = [];
|
||||||
foreach ($nav_order as $o) {
|
foreach ($nav_order as $o) {
|
||||||
foreach ($nav_bar as $key => $label) {
|
foreach ($nav_bar as $key => $label) {
|
||||||
@@ -57,7 +42,7 @@ foreach ($nav_order as $o) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$nav_bar = array_merge($ordered, $nav_bar);
|
$nav_bar = array_merge($ordered, $nav_bar);
|
||||||
unset($nav_items,$nav_order,$nav_admin,$nav_hidden)
|
unset($nav_order,$nav_admin,$nav_hidden);
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -67,6 +52,9 @@ unset($nav_items,$nav_order,$nav_admin,$nav_hidden)
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title><?= htmlspecialchars($CONFIG['site_name']) ?></title>
|
<title><?= htmlspecialchars($CONFIG['site_name']) ?></title>
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<script>
|
||||||
|
const CONFIG = <?= json_encode($CONFIG_JS, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
@@ -110,10 +98,7 @@ unset($nav_items,$nav_order,$nav_admin,$nav_hidden)
|
|||||||
|
|
||||||
<!-- GLightbox -->
|
<!-- GLightbox -->
|
||||||
<link rel="stylesheet" href="dist/css/glightbox.min.css">
|
<link rel="stylesheet" href="dist/css/glightbox.min.css">
|
||||||
<script src="dist/js/glightbox.min.js"></script>
|
<script src="dist/js/glightbox.min.js" defer></script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script src="js/app.js" defer></script>
|
<script src="js/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Admin check failed', err);
|
console.warn('Admin check failed', err);
|
||||||
|
lockBtn.textContent = '🔒';
|
||||||
|
lockBtn.classList.add('locked');
|
||||||
|
lockBtn.title = 'Admin Login';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +68,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await checkAdmin();
|
await checkAdmin();
|
||||||
setInterval(checkAdmin, 30000);
|
setInterval(checkAdmin, CONFIG.index_admin_status_update_interval);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// --- Page loader ---
|
// --- Page loader ---
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
document.addEventListener('page-loaded', e => {
|
document.addEventListener('page-loaded', e => {
|
||||||
if (e.detail.page !== "home") return;
|
if (e.detail.page !== "home") return;
|
||||||
|
|
||||||
const stats = ['Cpu','Ram','Zram','Swap','Disk','Disk0','Images'];
|
const stats = ['Cpu','Ram','Zram','Swap','Disk','Disk0','Imagesjpg','Imagesraw'];
|
||||||
const elements = {};
|
const elements = {};
|
||||||
const bars = {};
|
const bars = {};
|
||||||
const prev = {};
|
const prev = {};
|
||||||
@@ -32,7 +32,7 @@ document.addEventListener('page-loaded', e => {
|
|||||||
const numVal = Number(val) || 0;
|
const numVal = Number(val) || 0;
|
||||||
|
|
||||||
// Animate number
|
// Animate number
|
||||||
if (key === 'Images') {
|
if (key === 'Imagesjpg' || key === 'Imagesraw') {
|
||||||
// Raw number
|
// Raw number
|
||||||
animateValue(elements[key], prev[key], numVal, 400, false);
|
animateValue(elements[key], prev[key], numVal, 400, false);
|
||||||
} else {
|
} else {
|
||||||
@@ -55,14 +55,14 @@ document.addEventListener('page-loaded', e => {
|
|||||||
|
|
||||||
// Progress bar
|
// Progress bar
|
||||||
if (bars[key]) {
|
if (bars[key]) {
|
||||||
let pct = key === 'Images' ? Math.min(numVal / 100 * 100, 100) : numVal;
|
let pct = key === 'Imagesjpg' || key === 'Imagesraw' ? Math.min(numVal / 100 * 100, 100) : numVal;
|
||||||
bars[key].style.width = pct + '%';
|
bars[key].style.width = pct + '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color thresholds
|
// Color thresholds
|
||||||
if (numVal >= 85) {
|
if (numVal >= CONFIG.home_chart_color_threshold_high) {
|
||||||
elements[key].className = 'high';
|
elements[key].className = 'high';
|
||||||
} else if (numVal >= 75) {
|
} else if (numVal >= CONFIG.home_chart_color_threshold_medium) {
|
||||||
elements[key].className = 'medium';
|
elements[key].className = 'medium';
|
||||||
} else {
|
} else {
|
||||||
elements[key].className = 'low';
|
elements[key].className = 'low';
|
||||||
|
|||||||
183
src/js/options.js
Normal file
183
src/js/options.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
document.addEventListener('page-loaded', e => {
|
||||||
|
if (e.detail.page !== 'options') return;
|
||||||
|
|
||||||
|
// ---- STATUS WIDGET ----
|
||||||
|
const wifi = document.getElementById('statusWifi');
|
||||||
|
const uptime = document.getElementById('statusUptime');
|
||||||
|
const load = document.getElementById('statusLoad');
|
||||||
|
const mem = document.getElementById('statusMem');
|
||||||
|
const services = document.getElementById('statusServices');
|
||||||
|
const btnRefresh = document.getElementById('btnRefreshStatus');
|
||||||
|
|
||||||
|
async function updateStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('includes/api/system.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({ action: 'system-info' })
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (j.error) throw new Error(j.error);
|
||||||
|
|
||||||
|
wifi.textContent = j.wifi || '–';
|
||||||
|
uptime.textContent = j.uptime || '–';
|
||||||
|
load.textContent = j.load || '–';
|
||||||
|
mem.textContent = j.memory || '–';
|
||||||
|
|
||||||
|
const svcList = CONFIG.options_status_services;
|
||||||
|
const results = [];
|
||||||
|
for (const svc of svcList) {
|
||||||
|
const r = await fetch('includes/api/system.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({ action: 'service-status', service: svc })
|
||||||
|
});
|
||||||
|
const text = await r.text();
|
||||||
|
results.push(`${svc}: ${text.includes('running') ? '🟢' : '🔴'}`);
|
||||||
|
}
|
||||||
|
services.textContent = results.join(' ');
|
||||||
|
} catch (err) {
|
||||||
|
wifi.textContent = uptime.textContent = load.textContent = mem.textContent = 'Error';
|
||||||
|
services.textContent = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btnRefresh?.addEventListener('click', updateStatus);
|
||||||
|
updateStatus();
|
||||||
|
setInterval(updateStatus, CONFIG.options_status_interval);
|
||||||
|
|
||||||
|
// ---- CONFIG EDITOR ----
|
||||||
|
const editor = document.getElementById('configEditor');
|
||||||
|
const msg = document.getElementById('configMessage');
|
||||||
|
const btnLoadConfig = document.getElementById('btnLoadConfig');
|
||||||
|
const btnSaveConfig = document.getElementById('btnSaveConfig');
|
||||||
|
|
||||||
|
if (!btnSaveConfig.dataset.bound) {
|
||||||
|
btnSaveConfig.dataset.bound = 'true';
|
||||||
|
|
||||||
|
btnLoadConfig?.addEventListener('click', async () => {
|
||||||
|
msg.textContent = 'Loading...';
|
||||||
|
try {
|
||||||
|
const res = await fetch('includes/api/config.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({ action: 'load' })
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (j.error) msg.textContent = j.error;
|
||||||
|
else {
|
||||||
|
editor.value = j.content || '';
|
||||||
|
msg.textContent = 'Config loaded.';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
msg.textContent = 'Error loading config: ' + err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnSaveConfig?.addEventListener('click', async () => {
|
||||||
|
msg.textContent = 'Checking changes...';
|
||||||
|
try {
|
||||||
|
const data = editor.value;
|
||||||
|
|
||||||
|
// Preview changes & syntax check
|
||||||
|
const previewRes = await fetch('includes/api/config.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({ action: 'preview-save', data })
|
||||||
|
});
|
||||||
|
const preview = await previewRes.json();
|
||||||
|
|
||||||
|
if (preview.error) {
|
||||||
|
msg.textContent = preview.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.message) {
|
||||||
|
msg.textContent = preview.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmMsg = `⚙️ Changes detected (${preview.diff.split('\n\n').length} lines):\n\n` +
|
||||||
|
preview.diff.slice(0, 1000) +
|
||||||
|
(preview.diff.length > 1000 ? '\n\n...preview truncated...' : '') +
|
||||||
|
`\n\nSave these changes?`;
|
||||||
|
|
||||||
|
if (!confirm(confirmMsg)) {
|
||||||
|
msg.textContent = 'Save canceled.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.textContent = 'Saving...';
|
||||||
|
const resSave = await fetch('includes/api/config.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({ action: 'save', data })
|
||||||
|
});
|
||||||
|
const jSave = await resSave.json();
|
||||||
|
msg.textContent = jSave.message || jSave.error || 'Saved successfully.';
|
||||||
|
if (jSave.message && jSave.message.includes('successfully')) {
|
||||||
|
editor.value = '';
|
||||||
|
msg.textContent = '';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
msg.textContent = 'Error: ' + err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CACHE ----
|
||||||
|
const cacheBtn = document.getElementById('btnClearCache');
|
||||||
|
cacheBtn?.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Clear cache folder?')) return;
|
||||||
|
const res = await fetch('includes/api/cache.php', { method: 'POST' });
|
||||||
|
const j = await res.json();
|
||||||
|
alert(j.message || j.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- LOGS ----
|
||||||
|
const logsBtn = document.getElementById('btnViewLogs');
|
||||||
|
const logsBox = document.getElementById('logsBox');
|
||||||
|
logsBtn?.addEventListener('click', async () => {
|
||||||
|
const res = await fetch('includes/api/logs.php');
|
||||||
|
const j = await res.json();
|
||||||
|
logsBox.textContent = j.content || j.error;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- WIFI / LAN ----
|
||||||
|
const wifiBtn = document.getElementById('btnWifiToggle');
|
||||||
|
const lanBtn = document.getElementById('btnLanRestart');
|
||||||
|
|
||||||
|
wifiBtn?.addEventListener('click', async () => {
|
||||||
|
await fetch('includes/api/system.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({ action: 'wifi-toggle' })
|
||||||
|
});
|
||||||
|
updateStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
lanBtn?.addEventListener('click', async () => {
|
||||||
|
await fetch('includes/api/system.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({ action: 'service-restart', service: 'network' })
|
||||||
|
});
|
||||||
|
updateStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- PASSWORD CHANGE ----
|
||||||
|
const oldPass = document.getElementById('oldPass');
|
||||||
|
const newPass = document.getElementById('newPass');
|
||||||
|
const confirmPass = document.getElementById('confirmPass');
|
||||||
|
const passBtn = document.getElementById('btnChangePass');
|
||||||
|
|
||||||
|
passBtn?.addEventListener('click', async () => {
|
||||||
|
if (newPass.value !== confirmPass.value) {
|
||||||
|
alert('New passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch('includes/api/admin.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({
|
||||||
|
action: 'change_pass',
|
||||||
|
oldPass: oldPass.value,
|
||||||
|
newPass: newPass.value
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
alert(j.message || j.error);
|
||||||
|
});
|
||||||
|
});
|
||||||
91
src/js/shell.js
Normal file
91
src/js/shell.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// js/shell.js — SPA-safe interactive shell with history + Ctrl-L clear
|
||||||
|
(() => {
|
||||||
|
if (window.shellInitialized) return;
|
||||||
|
window.shellInitialized = true;
|
||||||
|
|
||||||
|
document.addEventListener('page-loaded', (e) => {
|
||||||
|
if (e.detail.page !== 'shell') return;
|
||||||
|
|
||||||
|
console.log('[shell.js] Initializing shell...');
|
||||||
|
|
||||||
|
const form = document.getElementById('shellForm');
|
||||||
|
const input = document.getElementById('shellInput');
|
||||||
|
const output = document.getElementById('shellOutput');
|
||||||
|
const runBtn = document.getElementById('runBtn');
|
||||||
|
if (!form || !input || !output || !runBtn) return;
|
||||||
|
|
||||||
|
// ----- History -----
|
||||||
|
const history = [];
|
||||||
|
let histIndex = -1;
|
||||||
|
|
||||||
|
function appendLine(text, type = 'out') {
|
||||||
|
const line = document.createElement('pre');
|
||||||
|
line.className = `line ${type}`;
|
||||||
|
line.textContent = text;
|
||||||
|
output.appendChild(line);
|
||||||
|
output.scrollTop = output.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const cmd = input.value.trim();
|
||||||
|
if (!cmd) return;
|
||||||
|
|
||||||
|
history.push(cmd);
|
||||||
|
histIndex = history.length;
|
||||||
|
appendLine(`$ ${cmd}`, 'cmd');
|
||||||
|
input.value = '';
|
||||||
|
runBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('includes/shell_exec.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ command: cmd })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) appendLine(data.error, 'err');
|
||||||
|
if (data.output) appendLine(data.output, 'out');
|
||||||
|
} catch (err) {
|
||||||
|
appendLine(`Error: ${err.message}`, 'err');
|
||||||
|
} finally {
|
||||||
|
runBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----- Keyboard handlers -----
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
if (histIndex > 0) {
|
||||||
|
histIndex--;
|
||||||
|
input.value = history[histIndex];
|
||||||
|
setTimeout(() => input.setSelectionRange(input.value.length, input.value.length), 0);
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
if (histIndex < history.length - 1) {
|
||||||
|
histIndex++;
|
||||||
|
input.value = history[histIndex];
|
||||||
|
} else {
|
||||||
|
histIndex = history.length;
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key.toLowerCase() === 'l' && e.ctrlKey) {
|
||||||
|
// Ctrl+L clear screen
|
||||||
|
output.innerHTML = '';
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (CONFIG.shell_print_howto_to_logconsole){
|
||||||
|
console.log('[shell.js] Shell initialized.');
|
||||||
|
console.log('💡 Usage');
|
||||||
|
console.log('Shortcut Action');
|
||||||
|
console.log('↑ / ↓ Browse previous commands');
|
||||||
|
console.log('Ctrl + L Clear the screen');
|
||||||
|
console.log('Enter Execute command');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -38,14 +38,14 @@
|
|||||||
<div class="progress-bar"><div id="barDisk0"></div></div>
|
<div class="progress-bar"><div id="barDisk0"></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>Images 🖼️</h3>
|
<h3>Images JPG 🖼️</h3>
|
||||||
<p id="statImages">–</p>
|
<p id="statImagesjpg">–</p>
|
||||||
<div class="progress-bar"><div id="barImages"></div></div>
|
<div class="progress-bar"><div id="barImagesjpg"></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>Blank Card ✏️</h3>
|
<h3>Images RAW 🖼️</h3>
|
||||||
<p id="statImages">–</p>
|
<p id="statImagesraw">–</p>
|
||||||
<div class="progress-bar"><div id="barnone"></div></div>
|
<div class="progress-bar"><div id="barImagesraw"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
80
src/pages/options.php
Normal file
80
src/pages/options.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once __DIR__ . '/../includes/config.php';
|
||||||
|
if (empty($_SESSION['is_admin'])) {
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Options Panel</title>
|
||||||
|
<link rel="stylesheet" href="css/options.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="options-container">
|
||||||
|
|
||||||
|
<h1>System Options</h1>
|
||||||
|
|
||||||
|
<section class="option-card status-widget" id="systemStatus">
|
||||||
|
<h2>System Status</h2>
|
||||||
|
<div class="status-grid">
|
||||||
|
<div><strong>Wi-Fi:</strong> <span id="statusWifi">Loading…</span></div>
|
||||||
|
<div><strong>Uptime:</strong> <span id="statusUptime">–</span></div>
|
||||||
|
<div><strong>Load:</strong> <span id="statusLoad">–</span></div>
|
||||||
|
<div><strong>Memory:</strong> <span id="statusMem">–</span></div>
|
||||||
|
<div><strong>Services:</strong> <span id="statusServices">–</span></div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<button id="btnRefreshStatus" class="btn small">🔄 Refresh</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="option-card">
|
||||||
|
<h2>Wi-Fi & Network</h2>
|
||||||
|
<button id="btnWifiToggle">Toggle Wi-Fi</button>
|
||||||
|
<button id="btnLanRestart">Restart LAN</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="option-card">
|
||||||
|
<h2>Services</h2>
|
||||||
|
<input type="text" id="serviceName" placeholder="Service name (e.g., uhttpd)">
|
||||||
|
<button id="btnServiceStart">Start</button>
|
||||||
|
<button id="btnServiceStop">Stop</button>
|
||||||
|
<button id="btnServiceRestart">Restart</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="option-card">
|
||||||
|
<h2>Configuration File</h2>
|
||||||
|
<textarea id="configEditor" placeholder="Config contents here..."></textarea>
|
||||||
|
<button id="btnLoadConfig">Load</button>
|
||||||
|
<button id="btnSaveConfig">Save</button>
|
||||||
|
<div id="configMessage"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="option-card">
|
||||||
|
<h2>Cache</h2>
|
||||||
|
<button id="btnClearCache">Clear Cache</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="option-card">
|
||||||
|
<h2>Logs</h2>
|
||||||
|
<button id="btnViewLogs">View Logs</button>
|
||||||
|
<pre id="logsBox">(none)</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="option-card">
|
||||||
|
<h2>Admin Password</h2>
|
||||||
|
<input type="password" id="oldPass" placeholder="Old password">
|
||||||
|
<input type="password" id="newPass" placeholder="New password">
|
||||||
|
<input type="password" id="confirmPass" placeholder="Confirm new password">
|
||||||
|
<button id="btnChangePass">Change Password</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/options.js" defer></script>
|
||||||
|
<link rel="stylesheet" href="css/options.css">
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
// pages/shell.php — Admin shell console
|
||||||
|
require_once __DIR__ . '/../includes/config.php';
|
||||||
|
session_start();
|
||||||
|
if (empty($_SESSION['is_admin'])) {
|
||||||
|
echo "<section class='center'><p class='error'>Access denied. Admin login required.</p></section>";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<section class="shell-page">
|
||||||
|
<h2>Interactive Shell</h2>
|
||||||
|
|
||||||
|
<div id="shellOutput" class="shell-output"></div>
|
||||||
|
|
||||||
|
<form id="shellForm" class="shell-form">
|
||||||
|
<span class="prompt">$</span>
|
||||||
|
<input id="shellInput" class="shell-input" type="text" placeholder="Enter command..." autocomplete="off" />
|
||||||
|
<button id="runBtn" class="btn-run" type="submit">Run</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="css/shell.css">
|
||||||
|
<script src="js/shell.js" defer></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user