feat: Implement image fetching and storage system
Some checks failed
Deploy to BeePC / deploy (push) Has been cancelled
Some checks failed
Deploy to BeePC / deploy (push) Has been cancelled
- Add image-fetcher module for downloading and saving images from various sources. - Create storage module for managing image files, including downloading, verifying integrity, and cleaning up orphaned files. - Develop gallery HTML page for displaying images with sorting and filtering options. - Set up RESTful API routes for image management, including fetching, adding tags, and deleting images. - Introduce setup script for initializing the database and configuring image sources. - Implement a batch process for verifying image integrity and cleaning up old images. - Add setup batch script for easy installation and configuration of the image storage system.
This commit is contained in:
@@ -3,4 +3,8 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
*.log
|
*.log
|
||||||
README.md
|
README.md
|
||||||
|
IMAGE_STORAGE_GUIDE.md
|
||||||
.gitignore
|
.gitignore
|
||||||
|
data/
|
||||||
|
setup.bat
|
||||||
|
scripts/deploy.*
|
||||||
|
|||||||
412
DOCKER_DEPLOYMENT.md
Normal file
412
DOCKER_DEPLOYMENT.md
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
# 🐳 Docker Deployment Guide for Image Storage
|
||||||
|
|
||||||
|
This guide covers deployment of the HomeBase image storage system in Docker on your home lab.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose installed on your home lab server (beepc)
|
||||||
|
- SSH access configured (required for deploy script)
|
||||||
|
- PowerShell 5+ or PowerShell Core for running deploy scripts
|
||||||
|
- Network access from your dev machine to the home lab
|
||||||
|
|
||||||
|
## Quick Deployment
|
||||||
|
|
||||||
|
### 1. Deploy to Home Lab
|
||||||
|
|
||||||
|
From your local machine, run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\deploy.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
This script will:
|
||||||
|
- Copy all project files to your home lab server (spencer@beepc)
|
||||||
|
- Build the Docker image
|
||||||
|
- Create and start the container with persistent storage
|
||||||
|
- Set up systemd service for auto-start
|
||||||
|
|
||||||
|
### 2. After Deployment
|
||||||
|
|
||||||
|
The container will automatically:
|
||||||
|
- Create the SQLite database
|
||||||
|
- Create image storage directories
|
||||||
|
- Start the image fetcher (if configured)
|
||||||
|
- Be available at `http://homebase.sketchferret.com`
|
||||||
|
|
||||||
|
## Docker Setup Details
|
||||||
|
|
||||||
|
### Persistent Storage
|
||||||
|
|
||||||
|
Images are stored in a Docker volume called `homebase-data`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- homebase-data:/app/data
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures:
|
||||||
|
- Database persists across container restarts
|
||||||
|
- Image files are not lost on container recreation
|
||||||
|
- Data survives container updates
|
||||||
|
|
||||||
|
### Viewing Container Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into home lab
|
||||||
|
ssh spencer@beepc
|
||||||
|
|
||||||
|
# View container logs
|
||||||
|
docker compose -f ~/homebase/docker-compose.yml logs -f
|
||||||
|
|
||||||
|
# Or use systemd
|
||||||
|
journalctl -u homebase -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop container
|
||||||
|
docker compose -f ~/homebase/docker-compose.yml down
|
||||||
|
|
||||||
|
# Rebuild and restart
|
||||||
|
docker compose -f ~/homebase/docker-compose.yml up -d --build
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
docker compose -f ~/homebase/docker-compose.yml ps
|
||||||
|
|
||||||
|
# View stored images
|
||||||
|
docker exec homebase ls -lh /app/data/images/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration in Docker
|
||||||
|
|
||||||
|
### Image Sources (image-sources.json)
|
||||||
|
|
||||||
|
After deployment, edit the configuration on the remote server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh spencer@beepc
|
||||||
|
nano ~/homebase/image-sources.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/homebase
|
||||||
|
docker compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Edit `docker-compose.yml` to customize:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3001
|
||||||
|
- DOMAIN=homebase.sketchferret.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Managing Data
|
||||||
|
|
||||||
|
### Backup Database
|
||||||
|
|
||||||
|
The SQLite database is stored in the Docker volume at `/app/data/homebase.db`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into server
|
||||||
|
ssh spencer@beepc
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
docker run --rm -v homebase-data:/data \
|
||||||
|
-v ~/backups:/backup \
|
||||||
|
alpine cp /data/homebase.db /backup/homebase.db.$(date +%Y%m%d)
|
||||||
|
|
||||||
|
# Backup images
|
||||||
|
docker run --rm -v homebase-data:/data \
|
||||||
|
-v ~/backups:/backup \
|
||||||
|
alpine tar czf /backup/images.$(date +%Y%m%d).tar.gz /data/images/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v homebase-data:/data \
|
||||||
|
-v ~/backups:/backup \
|
||||||
|
alpine cp /backup/homebase.db.20260211 /data/homebase.db
|
||||||
|
|
||||||
|
# Restart container
|
||||||
|
docker restart homebase
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clean Up Old Images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into server
|
||||||
|
ssh spencer@beepc
|
||||||
|
|
||||||
|
# Connect to running container
|
||||||
|
docker exec -it homebase node -e "
|
||||||
|
const db = require('./lib/database');
|
||||||
|
db.initializeDatabase().then(async () => {
|
||||||
|
const deleted = await db.cleanupOldImages(30);
|
||||||
|
console.log('Deleted', deleted, 'old images');
|
||||||
|
process.exit(0);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Container won't start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh spencer@beepc
|
||||||
|
cd ~/homebase
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker compose logs app
|
||||||
|
|
||||||
|
# Rebuild
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Check again
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission errors
|
||||||
|
|
||||||
|
The container runs as the `node` user. Check logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app ls -la /app/data/
|
||||||
|
```
|
||||||
|
|
||||||
|
All files should be owned by `node`.
|
||||||
|
|
||||||
|
### Can't access via domain
|
||||||
|
|
||||||
|
Check that the service is running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh spencer@beepc
|
||||||
|
|
||||||
|
# Check systemd
|
||||||
|
systemctl status homebase
|
||||||
|
|
||||||
|
# Check Docker
|
||||||
|
docker ps | grep homebase
|
||||||
|
|
||||||
|
# Test port
|
||||||
|
curl -i http://localhost:3001/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database is locked
|
||||||
|
|
||||||
|
This usually means two processes are accessing the database simultaneously:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if multiple containers are running
|
||||||
|
docker ps -a | grep homebase
|
||||||
|
|
||||||
|
# Stop all containers
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Verify only one is running
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating the Deployment
|
||||||
|
|
||||||
|
### Update Code
|
||||||
|
|
||||||
|
After making changes locally:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd c:\Users\srene\Sources\HomeBase
|
||||||
|
git commit -am "Add new features"
|
||||||
|
git push
|
||||||
|
|
||||||
|
# Deploy to home lab
|
||||||
|
.\scripts\deploy.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Dependencies
|
||||||
|
|
||||||
|
If you update `package.json`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# From your local machine
|
||||||
|
.\scripts\deploy.ps1
|
||||||
|
|
||||||
|
# The deploy script will:
|
||||||
|
# 1. Copy new package.json
|
||||||
|
# 2. Rebuild the Docker image (npm install runs here)
|
||||||
|
# 3. Restart the container
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Updates
|
||||||
|
|
||||||
|
SSH into the server and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/homebase
|
||||||
|
|
||||||
|
# Pull latest code
|
||||||
|
git pull # If using git
|
||||||
|
|
||||||
|
# Or manually copy files via SCP
|
||||||
|
# Then rebuild:
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Systemd Service
|
||||||
|
|
||||||
|
The deployment script sets up a systemd service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check service
|
||||||
|
systemctl status homebase
|
||||||
|
|
||||||
|
# Start service
|
||||||
|
sudo systemctl start homebase
|
||||||
|
|
||||||
|
# Stop service
|
||||||
|
sudo systemctl stop homebase
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
journalctl -u homebase -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Service file location: `/etc/systemd/system/homebase.service`
|
||||||
|
|
||||||
|
## Volume Management
|
||||||
|
|
||||||
|
### List volumes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker volume ls | grep homebase
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inspect volume
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker volume inspect homebase-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows the mount point on the host server.
|
||||||
|
|
||||||
|
### Manual mount (for recovery)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it --rm -v homebase-data:/data alpine sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then you can inspect files at `/data/`
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### Monitor Storage Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://homebase.sketchferret.com/api/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cleanup Recommendations
|
||||||
|
|
||||||
|
- **Weekly**: Run cleanup for images older than 7 days
|
||||||
|
- **Monthly**: Run verification to check corruption
|
||||||
|
- **Quarterly**: Full database optimization
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into server
|
||||||
|
ssh spencer@beepc
|
||||||
|
docker exec homebase node -e "
|
||||||
|
const db = require('./lib/database');
|
||||||
|
const storage = require('./lib/storage');
|
||||||
|
|
||||||
|
db.initializeDatabase().then(async () => {
|
||||||
|
console.log('Running cleanup...');
|
||||||
|
const deleted = await db.cleanupOldImages(7);
|
||||||
|
const orphaned = await storage.cleanupOrphanedFiles(db.db);
|
||||||
|
console.log('Deleted:', deleted, 'old images');
|
||||||
|
console.log('Removed:', orphaned.cleaned, 'orphaned files');
|
||||||
|
process.exit(0);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Network & Firewall
|
||||||
|
|
||||||
|
### Open Ports
|
||||||
|
|
||||||
|
Ensure port 3001 is accessible on your home lab network:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if port is listening
|
||||||
|
ssh spencer@beepc
|
||||||
|
netstat -tlnp | grep 3001
|
||||||
|
|
||||||
|
# Or use Docker
|
||||||
|
docker port homebase
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domain Configuration
|
||||||
|
|
||||||
|
The app is configured to run on `homebase.sketchferret.com`. Ensure:
|
||||||
|
- DNS resolves to your home lab IP
|
||||||
|
- Firewall allows traffic on port 3001
|
||||||
|
- Your home lab router has port forwarding (if external access needed)
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Enable detailed logging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into server
|
||||||
|
ssh spencer@beepc
|
||||||
|
|
||||||
|
# View container logs with timestamps
|
||||||
|
docker compose logs -f --timestamps app
|
||||||
|
|
||||||
|
# Or view the last 100 lines
|
||||||
|
docker compose logs --tail=100 app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access container shell
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it homebase sh
|
||||||
|
cd /app
|
||||||
|
node setup.js config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database inspection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Access SQLite directly
|
||||||
|
docker exec -it homebase sqlite3 /app/data/homebase.db
|
||||||
|
|
||||||
|
# In sqlite prompt:
|
||||||
|
sqlite> SELECT COUNT(*) FROM images;
|
||||||
|
sqlite> SELECT * FROM tags;
|
||||||
|
sqlite> .quit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Regular Backups**: Back up the database volume weekly
|
||||||
|
2. **Log Monitoring**: Check logs regularly for errors
|
||||||
|
3. **Storage Limits**: Monitor storage and clean up old images
|
||||||
|
4. **Configuration**: Keep `image-sources.json` updated with working URLs
|
||||||
|
5. **Testing**: Test image fetching after configuration changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
For API usage, see [IMAGE_STORAGE_GUIDE.md](IMAGE_STORAGE_GUIDE.md)
|
||||||
@@ -8,8 +8,13 @@ RUN npm install --omit=dev
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Ensure proper permissions for node user
|
# Create data directory for persistent storage
|
||||||
RUN chown -R node:node /app
|
RUN mkdir -p /app/data/images && \
|
||||||
|
chown -R node:node /app && \
|
||||||
|
chmod -R 755 /app/data
|
||||||
|
|
||||||
|
# Install avahi for mDNS support (.local hostname resolution)
|
||||||
|
RUN apk add --no-cache avahi avahi-tools libc6-compat
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|
||||||
|
|||||||
435
IMAGE_STORAGE_GUIDE.md
Normal file
435
IMAGE_STORAGE_GUIDE.md
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
# 📸 Image Storage System Guide
|
||||||
|
|
||||||
|
This guide covers the long-term image storage solution integrated into HomeBase. It provides reliable, organized storage for images pulled from web services with built-in corruption detection and ML-friendly tagging.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Automatic Image Fetching**: Pull images from web services every 2-3 minutes
|
||||||
|
- **Corruption Detection**: SHA256 checksums verify data integrity
|
||||||
|
- **Tag-Based Organization**: Tag images for machine learning model training
|
||||||
|
- **Efficient Storage**: File-based storage with SQLite metadata database
|
||||||
|
- **RESTful API**: Complete API for image management and queries
|
||||||
|
- **Scalable**: Designed to handle thousands of images
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Image Fetcher Service │
|
||||||
|
│ (Runs every 2-3 minutes, pull from web service) │
|
||||||
|
└────────┬────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
├─────┬──────────────┬──────────────┐
|
||||||
|
│ │ │ │
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
Download Hash Store File Insert Metadata
|
||||||
|
Image (SHA256) (data/images) (SQLite)
|
||||||
|
│
|
||||||
|
└──────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────▼──────────┐
|
||||||
|
│ REST API Endpoints │
|
||||||
|
│ - List Images │
|
||||||
|
│ - Add Tags │
|
||||||
|
│ - Search/Filter │
|
||||||
|
│ - Verify Integrity │
|
||||||
|
└────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Initialize the System
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node setup.js init
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Create the SQLite database
|
||||||
|
- Set up data directory structure
|
||||||
|
- Load configuration
|
||||||
|
- Test the system
|
||||||
|
|
||||||
|
### 3. Configure Image Sources
|
||||||
|
|
||||||
|
Edit `image-sources.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": "Webcam Feed",
|
||||||
|
"url": "https://your-service.com/image.jpg",
|
||||||
|
"tags": ["webcam", "monitoring"],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fetchInterval": 0.033
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fetch Intervals** (edit `fetchInterval` to customize):
|
||||||
|
- `0.0167` = 1 second
|
||||||
|
- `0.033` = 2 seconds (recommended for fast updates)
|
||||||
|
- `0.05` = 3 seconds
|
||||||
|
- `2.5` = 2.5 minutes (original default)
|
||||||
|
|
||||||
|
### 4. Start the Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The system will automatically start fetching images at the configured interval.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### image-sources.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": "Example Source",
|
||||||
|
"url": "https://example.com/image.jpg",
|
||||||
|
"tags": ["tag1", "tag2"],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fetchInterval": 0.033
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `fetchInterval`: **Minutes** between fetch cycles. Use decimals for sub-minute intervals:
|
||||||
|
- `0.0167` = 1 second
|
||||||
|
- `0.033` = 2 seconds
|
||||||
|
- `0.05` = 3 seconds
|
||||||
|
- `0.167` = 10 seconds
|
||||||
|
- `1` = 1 minute
|
||||||
|
- `2.5` = 2.5 minutes (original default)
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### List Images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/images?page=1&pageSize=50&sort=fetched_at&order=DESC
|
||||||
|
GET /api/images?tag=webcam # Filter by tag
|
||||||
|
GET /api/images?sourceUrl=https://... # Filter by source
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"filename": "image_1234567890_abc123.jpg",
|
||||||
|
"source_url": "https://...",
|
||||||
|
"filesize": 102400,
|
||||||
|
"file_hash": "sha256hash",
|
||||||
|
"fetched_at": "2023-01-01T12:00:00Z",
|
||||||
|
"tags": ["webcam", "monitoring"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 50,
|
||||||
|
"total": 240,
|
||||||
|
"pages": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Image Details
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/images/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Download Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/images/{id}/download
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch New Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/images
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"source_url": "https://example.com/image.jpg",
|
||||||
|
"tags": ["tag1", "tag2"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add Tags to Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/images/{id}/tags
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"tags": ["newtag1", "newtag2"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List All Tags
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/tags
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"tags": ["webcam", "monitoring", "test", "example"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage Statistics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"stats": {
|
||||||
|
"imageCount": 240,
|
||||||
|
"totalSize": 24576000,
|
||||||
|
"totalSizeGB": "0.02",
|
||||||
|
"fileCount": 240
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify Image Integrity
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/verify
|
||||||
|
```
|
||||||
|
|
||||||
|
This checks all images for corruption using their stored checksums.
|
||||||
|
|
||||||
|
### Cleanup Old Images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/cleanup
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"daysOld": 30
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DELETE /api/images/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Corruption Detection
|
||||||
|
|
||||||
|
The system uses SHA256 checksums to detect corruption:
|
||||||
|
|
||||||
|
1. **Storage**: When an image is saved, its SHA256 hash is calculated and stored
|
||||||
|
2. **Verification**: The `/api/verify` endpoint re-hashes all files and compares with stored hashes
|
||||||
|
3. **Marking**: Corrupted images are marked in the database and excluded from queries
|
||||||
|
4. **Recovery**: Corrupted files can be re-fetched using the source URL
|
||||||
|
|
||||||
|
### Manual Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3001/api/verify
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tagging for ML Training
|
||||||
|
|
||||||
|
Tags are essential for organizing training datasets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add images with training tags
|
||||||
|
POST /api/images
|
||||||
|
{
|
||||||
|
"source_url": "https://...",
|
||||||
|
"tags": ["dataset_v1", "labeled", "weather-sunny"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Query all images with specific tag
|
||||||
|
GET /api/images?tag=weather-sunny
|
||||||
|
|
||||||
|
# Get tag statistics
|
||||||
|
GET /api/tags
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Images Table
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | INTEGER | Primary key |
|
||||||
|
| filename | TEXT | Unique filename |
|
||||||
|
| source_url | TEXT | Original image URL |
|
||||||
|
| file_path | TEXT | Local file path |
|
||||||
|
| filesize | INTEGER | File size in bytes |
|
||||||
|
| file_hash | TEXT | SHA256 hash |
|
||||||
|
| mime_type | TEXT | Content type |
|
||||||
|
| fetched_at | DATETIME | When image was fetched |
|
||||||
|
| is_corrupted | BOOLEAN | Corruption flag |
|
||||||
|
|
||||||
|
### Tags Table
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | INTEGER | Primary key |
|
||||||
|
| image_id | INTEGER | Foreign key to images |
|
||||||
|
| tag | TEXT | Tag text |
|
||||||
|
| created_at | DATETIME | When tag was added |
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
homebase/
|
||||||
|
├── server.js # Main server
|
||||||
|
├── package.json # Dependencies
|
||||||
|
├── setup.js # Setup script
|
||||||
|
├── image-sources.json # Configuration
|
||||||
|
├── lib/
|
||||||
|
│ ├── database.js # SQLite operations
|
||||||
|
│ ├── storage.js # File storage operations
|
||||||
|
│ └── image-fetcher.js # Image fetching service
|
||||||
|
├── routes/
|
||||||
|
│ └── images.js # API routes
|
||||||
|
└── data/ # Created at runtime
|
||||||
|
├── homebase.db # SQLite database
|
||||||
|
└── images/ # Stored image files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Manual Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Initialize system
|
||||||
|
node setup.js init
|
||||||
|
|
||||||
|
# Test fetch
|
||||||
|
node setup.js test https://example.com/image.jpg tag1,tag2
|
||||||
|
|
||||||
|
# View configuration
|
||||||
|
node setup.js config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regular Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Daily: Verify integrity
|
||||||
|
curl -X POST http://localhost:3001/api/verify
|
||||||
|
|
||||||
|
# Weekly: Cleanup old images (30+ days)
|
||||||
|
curl -X POST http://localhost:3001/api/cleanup -H "Content-Type: application/json" -d '{"daysOld": 30}'
|
||||||
|
|
||||||
|
# Monitor storage
|
||||||
|
curl http://localhost:3001/api/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
1. **Pagination**: Use `pageSize=50` or less for large datasets
|
||||||
|
2. **Tagging**: Use consistent tag names for easier filtering
|
||||||
|
3. **Cleanup**: Run cleanup weekly to manage storage
|
||||||
|
4. **Verification**: Run verification monthly to detect issues early
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### No images fetching?
|
||||||
|
|
||||||
|
1. Check `image-sources.json` - ensure `enabled: true`
|
||||||
|
2. Verify URL is accessible
|
||||||
|
3. Check server logs for errors
|
||||||
|
4. Test fetch: `node setup.js test https://url.jpg`
|
||||||
|
|
||||||
|
### High storage usage?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check statistics
|
||||||
|
curl http://localhost:3001/api/stats
|
||||||
|
|
||||||
|
# Cleanup images older than 7 days
|
||||||
|
curl -X POST http://localhost:3001/api/cleanup \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"daysOld": 7}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Corrupted files detected?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify all images
|
||||||
|
curl -X POST http://localhost:3001/api/verify
|
||||||
|
|
||||||
|
# Get list of corrupted images
|
||||||
|
curl 'http://localhost:3001/api/images?corrupted=true'
|
||||||
|
|
||||||
|
# Re-fetch if source still available
|
||||||
|
POST /api/images with same source_url
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Export Dataset for ML Training
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get all images with specific tag
|
||||||
|
curl 'http://localhost:3001/api/images?tag=weather-sunny&pageSize=999' \
|
||||||
|
-o dataset.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitor Fetch Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3001/api/fetcher/status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add tags to multiple images (via API loop)
|
||||||
|
for image_id in 1 2 3 4 5; do
|
||||||
|
curl -X POST http://localhost:3001/api/images/$image_id/tags \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tags": ["batch-import"]}'
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Database Access**: SQLite database is file-based; protect with filesystem permissions
|
||||||
|
2. **Image Storage**: Protect `data/images` directory from unauthorized access
|
||||||
|
3. **API Security**: Consider adding authentication for production use
|
||||||
|
4. **File Validation**: System validates MIME types and file sizes
|
||||||
|
|
||||||
|
## Performance Metrics
|
||||||
|
|
||||||
|
- **Fetch Time**: ~1-5 seconds per image (network dependent)
|
||||||
|
- **Database Queries**: <100ms for typical queries
|
||||||
|
- **Verification**: ~50ms per image
|
||||||
|
- **Storage**: ~1KB overhead per image in database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
For more information, check the API documentation or run `node setup.js help`.
|
||||||
168
README.md
168
README.md
@@ -1,13 +1,18 @@
|
|||||||
# HomeBase
|
# HomeBase
|
||||||
|
|
||||||
Simple self-hosted Node.js Docker application with automated SSH deployment.
|
Self-hosted Node.js Docker application with automated SSH deployment and **long-term image storage** with corruption detection and tagging for ML training.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🚀 Simple Express.js server
|
- 🚀 Express.js server
|
||||||
- 🐳 Docker containerized
|
- 🐳 Docker containerized with persistent volumes
|
||||||
- 🔄 Automated deployment via SSH
|
- 🔄 Automated deployment via SSH
|
||||||
- ✅ Health check endpoint
|
- 📸 **Long-term image storage** with SQLite database
|
||||||
|
- 🏷️ **Tag-based organization** for machine learning datasets
|
||||||
|
- 🛡️ **Corruption detection** using SHA256 checksums
|
||||||
|
- 🔍 **RESTful API** for image management
|
||||||
|
- 🧪 **Automatic fetching** every 2-3 minutes from web services
|
||||||
|
- ✅ Health check endpoints
|
||||||
- 🔐 SSH key-based authentication
|
- 🔐 SSH key-based authentication
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
@@ -30,53 +35,70 @@ On the remote server (beepc):
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
|
# Initialize the image storage system
|
||||||
|
node setup.js init
|
||||||
|
|
||||||
|
# Configure image sources (edit image-sources.json)
|
||||||
|
# Set "enabled": true for sources you want to fetch from
|
||||||
|
|
||||||
# Run locally
|
# Run locally
|
||||||
npm start
|
npm start
|
||||||
|
|
||||||
# Visit http://localhost:3001
|
# Visit http://localhost:3001
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Image Storage Features
|
||||||
|
|
||||||
|
Once running, manage images via the REST API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all images
|
||||||
|
curl http://localhost:3001/api/images
|
||||||
|
|
||||||
|
# Add an image manually
|
||||||
|
curl -X POST http://localhost:3001/api/images \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"source_url":"https://example.com/image.jpg","tags":["test"]}'
|
||||||
|
|
||||||
|
# Get storage statistics
|
||||||
|
curl http://localhost:3001/api/stats
|
||||||
|
|
||||||
|
# Verify all images for corruption
|
||||||
|
curl -X POST http://localhost:3001/api/verify
|
||||||
|
```
|
||||||
|
|
||||||
|
📖 **Full documentation:**
|
||||||
|
- [📸 Image Storage Guide](IMAGE_STORAGE_GUIDE.md) - Complete API reference, configuration, and usage
|
||||||
|
- [🐳 Docker Deployment Guide](DOCKER_DEPLOYMENT.md) - Deployment to home lab with persistent storage
|
||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
|
|
||||||
### Option 1: Manual Deployment (Windows)
|
### Quick Deployment to Home Lab (PowerShell)
|
||||||
|
|
||||||
```bash
|
From your local Windows machine:
|
||||||
# Run the deployment script from project root
|
|
||||||
.\scripts\deploy.bat
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: Manual Deployment (Linux/Mac)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Make script executable
|
|
||||||
chmod +x scripts/deploy.sh
|
|
||||||
|
|
||||||
# Run deployment
|
|
||||||
bash scripts/deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 3: Manual Deployment (PowerShell)
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Run the deployment script from project root
|
# Deploy to home lab (beepc)
|
||||||
.\scripts\deploy.ps1
|
.\scripts\deploy.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 4: Automated Deployment (GitHub Actions)
|
This will:
|
||||||
|
1. Copy all project files to your home lab server
|
||||||
|
2. Build the Docker image
|
||||||
|
3. Create and start the container with persistent storage
|
||||||
|
4. Set up systemd service for auto-start
|
||||||
|
|
||||||
1. Add your SSH private key to GitHub Secrets:
|
The app will be available at `http://homebase.sketchferret.com`
|
||||||
- Go to your repository Settings > Secrets and variables > Actions
|
|
||||||
- Add a new secret named `SSH_PRIVATE_KEY`
|
|
||||||
- Paste your SSH private key content
|
|
||||||
|
|
||||||
2. Push to main branch:
|
### Persistent Data Storage
|
||||||
```bash
|
|
||||||
git add .
|
|
||||||
git commit -m "Initial commit"
|
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
3. The app will automatically deploy to spencer@beepc
|
Images and database are stored in a Docker volume (`homebase-data`) that persists across container restarts and updates.
|
||||||
|
|
||||||
|
For detailed deployment instructions, see [DOCKER_DEPLOYMENT.md](DOCKER_DEPLOYMENT.md)
|
||||||
|
|
||||||
|
### Option 2: Manual Deployment
|
||||||
|
|
||||||
|
Manual deployment steps are documented in [DOCKER_DEPLOYMENT.md](DOCKER_DEPLOYMENT.md#manual-updates)
|
||||||
|
|
||||||
## SSH Setup
|
## SSH Setup
|
||||||
|
|
||||||
@@ -114,9 +136,29 @@ sudo apt-get install docker-compose-plugin
|
|||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
- `GET /` - Main endpoint, returns app status
|
### Status & Health
|
||||||
|
- `GET /api/status` - App status and version
|
||||||
- `GET /health` - Health check endpoint
|
- `GET /health` - Health check endpoint
|
||||||
|
|
||||||
|
### Image Management
|
||||||
|
- `GET /api/images` - List all images (with pagination & filtering)
|
||||||
|
- `GET /api/images/{id}` - Get image details
|
||||||
|
- `GET /api/images/{id}/download` - Download image file
|
||||||
|
- `POST /api/images` - Fetch and save new image
|
||||||
|
- `DELETE /api/images/{id}` - Delete image
|
||||||
|
|
||||||
|
### Tagging
|
||||||
|
- `POST /api/images/{id}/tags` - Add tags to image
|
||||||
|
- `GET /api/tags` - List all available tags
|
||||||
|
|
||||||
|
### Administration
|
||||||
|
- `GET /api/stats` - Storage statistics
|
||||||
|
- `POST /api/verify` - Verify all images for corruption
|
||||||
|
- `POST /api/cleanup` - Clean up old images
|
||||||
|
- `GET /api/fetcher/status` - Image fetcher status
|
||||||
|
|
||||||
|
See [IMAGE_STORAGE_GUIDE.md](IMAGE_STORAGE_GUIDE.md) for detailed API documentation.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -125,16 +167,30 @@ HomeBase/
|
|||||||
│ ├── deploy.ps1 # PowerShell deployment script
|
│ ├── deploy.ps1 # PowerShell deployment script
|
||||||
│ ├── deploy.sh # Bash deployment script
|
│ ├── deploy.sh # Bash deployment script
|
||||||
│ └── deploy.bat # Windows batch deployment script
|
│ └── deploy.bat # Windows batch deployment script
|
||||||
|
├── lib/
|
||||||
|
│ ├── database.js # SQLite operations & schema
|
||||||
|
│ ├── storage.js # File storage & checksums
|
||||||
|
│ └── image-fetcher.js # Image fetching service
|
||||||
|
├── routes/
|
||||||
|
│ └── images.js # Image API endpoints
|
||||||
|
├── public/
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── app.js
|
||||||
|
│ └── styles.css
|
||||||
├── .github/
|
├── .github/
|
||||||
│ └── workflows/
|
│ └── workflows/
|
||||||
│ └── deploy.yml # GitHub Actions workflow
|
│ └── deploy.yml # GitHub Actions workflow
|
||||||
├── .dockerignore # Docker ignore file
|
├── .dockerignore # Docker ignore file
|
||||||
├── .gitignore # Git ignore file
|
├── .gitignore # Git ignore file
|
||||||
├── Dockerfile # Docker configuration
|
├── Dockerfile # Docker configuration
|
||||||
├── docker-compose.yml # Docker Compose configuration
|
├── docker-compose.yml # Docker Compose with volumes
|
||||||
├── homebase.service # Systemd service file
|
├── homebase.service # Systemd service file
|
||||||
├── package.json # Node.js dependencies
|
├── package.json # Node.js dependencies
|
||||||
├── server.js # Express server
|
├── server.js # Express server
|
||||||
|
├── setup.js # Setup & CLI tool
|
||||||
|
├── image-sources.json # Image source configuration
|
||||||
|
├── IMAGE_STORAGE_GUIDE.md # Complete image storage docs
|
||||||
|
├── DOCKER_DEPLOYMENT.md # Docker deployment guide
|
||||||
└── README.md # This file
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -212,16 +268,44 @@ ssh spencer@beepc "sudo systemctl restart homebase"
|
|||||||
ssh spencer@beepc "sudo systemctl disable homebase"
|
ssh spencer@beepc "sudo systemctl disable homebase"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Variables
|
## Configuration
|
||||||
|
|
||||||
You can customize settings by modifying [docker-compose.yml](docker-compose.yml):
|
### Image Sources
|
||||||
|
|
||||||
|
Edit `image-sources.json` to configure which image URLs to fetch from:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": "Webcam Feed",
|
||||||
|
"url": "https://your-service.com/image.jpg",
|
||||||
|
"tags": ["webcam", "monitoring"],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fetchInterval": 0.033
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fetch Intervals** - Edit `fetchInterval` (in minutes):
|
||||||
|
- `0.0167` = 1 second
|
||||||
|
- `0.033` = 2 seconds (default, recommended for steady updates)
|
||||||
|
- `0.05` = 3 seconds
|
||||||
|
- `1` = 1 minute
|
||||||
|
- `2.5` = 2.5 minutes (original default)
|
||||||
|
|
||||||
|
The fetcher will automatically start pulling images from enabled sources at this interval.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Customize settings in [docker-compose.yml](docker-compose.yml):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
- PORT=3001 # Internal port (reverse proxy maps to standard HTTP)
|
- PORT=3001
|
||||||
- DOMAIN=homebase.sketchferret.com # Your domain
|
- DOMAIN=homebase.sketchferret.com
|
||||||
ports:
|
- NODE_ENV=production
|
||||||
- "3001:3001" # Port mapping for reverse proxy backend
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|||||||
@@ -3,9 +3,19 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: homebase
|
container_name: homebase
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
# Use host network to access both local network and Tailscale
|
||||||
- "3001:3001"
|
# Also inherits host's DNS for .local mDNS hostname resolution
|
||||||
|
network_mode: host
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
- DOMAIN=homebase.sketchferret.com
|
- DOMAIN=homebase.sketchferret.com
|
||||||
|
volumes:
|
||||||
|
# Persist database and image files
|
||||||
|
- homebase-data:/app/data
|
||||||
|
# Mount host's resolv.conf for mDNS support
|
||||||
|
- /etc/resolv.conf:/etc/resolv.conf:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
homebase-data:
|
||||||
|
driver: local
|
||||||
|
|||||||
19
image-sources.json
Normal file
19
image-sources.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": "Bedroom Exterior",
|
||||||
|
"url": "http://192.168.254.66:8080/",
|
||||||
|
"tags": ["lake", "road"],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fetchInterval": 0.033,
|
||||||
|
"comments": {
|
||||||
|
"sources": "Array of image sources to fetch from",
|
||||||
|
"name": "Human-readable name for the source",
|
||||||
|
"url": "Full URL to the image",
|
||||||
|
"tags": "Array of tags to apply to fetched images (useful for ML training)",
|
||||||
|
"enabled": "Set to true to include this source in fetching",
|
||||||
|
"fetchInterval": "Minutes between fetch cycles. Examples: 0.033 = 2 seconds, 0.05 = 3 seconds, 0.083 = 5 seconds, 1 = 1 minute, 2.5 = 2.5 minutes (default)"
|
||||||
|
}
|
||||||
|
}
|
||||||
342
lib/database.js
Normal file
342
lib/database.js
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const DB_PATH = path.join(__dirname, '..', 'data', 'homebase.db');
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
const dataDir = path.dirname(DB_PATH);
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(DB_PATH, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Database connection error:', err);
|
||||||
|
} else {
|
||||||
|
console.log('Connected to SQLite database at', DB_PATH);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable foreign keys
|
||||||
|
db.run('PRAGMA foreign_keys = ON');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize database schema
|
||||||
|
*/
|
||||||
|
async function initializeDatabase() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.serialize(() => {
|
||||||
|
// Images table
|
||||||
|
db.run(
|
||||||
|
`CREATE TABLE IF NOT EXISTS images (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
filename TEXT NOT NULL UNIQUE,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
filesize INTEGER,
|
||||||
|
file_hash TEXT NOT NULL,
|
||||||
|
mime_type TEXT,
|
||||||
|
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_corrupted BOOLEAN DEFAULT 0
|
||||||
|
)`,
|
||||||
|
(err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tags table
|
||||||
|
db.run(
|
||||||
|
`CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
image_id INTEGER NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(image_id, tag)
|
||||||
|
)`,
|
||||||
|
(err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create indexes for better query performance
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_images_source_url ON images(source_url)');
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_images_fetched_at ON images(fetched_at)');
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_images_file_hash ON images(file_hash)');
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_tags_image_id ON tags(image_id)');
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag)', (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
console.log('Database initialized successfully');
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a new image record
|
||||||
|
*/
|
||||||
|
function insertImage(imageData) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { filename, source_url, file_path, filesize, file_hash, mime_type } = imageData;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO images (filename, source_url, file_path, filesize, file_hash, mime_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[filename, source_url, file_path, filesize, file_hash, mime_type],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ id: this.lastID });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add tags to an image
|
||||||
|
*/
|
||||||
|
function addTags(imageId, tags) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!Array.isArray(tags) || tags.length === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
const stmt = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO tags (image_id, tag) VALUES (?, ?)'
|
||||||
|
);
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
tags.forEach((tag) => {
|
||||||
|
stmt.run([imageId, tag], (err) => {
|
||||||
|
if (err && !hasError) {
|
||||||
|
hasError = true;
|
||||||
|
stmt.finalize();
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
completed++;
|
||||||
|
if (completed === tags.length) {
|
||||||
|
stmt.finalize((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark image as corrupted
|
||||||
|
*/
|
||||||
|
function markAsCorrupted(imageId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
'UPDATE images SET is_corrupted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[imageId],
|
||||||
|
(err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get image by ID
|
||||||
|
*/
|
||||||
|
function getImage(imageId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT * FROM images WHERE id = ?',
|
||||||
|
[imageId],
|
||||||
|
(err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all images with optional filtering
|
||||||
|
*/
|
||||||
|
function getImages(options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { tag, sourceUrl, limit = 100, offset = 0, sortBy = 'fetched_at', order = 'DESC' } = options;
|
||||||
|
|
||||||
|
let query = 'SELECT DISTINCT i.* FROM images i';
|
||||||
|
let params = [];
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
query += ' INNER JOIN tags t ON i.id = t.image_id';
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' WHERE i.is_corrupted = 0';
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
query += ' AND t.tag = ?';
|
||||||
|
params.push(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceUrl) {
|
||||||
|
query += ' AND i.source_url = ?';
|
||||||
|
params.push(sourceUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY i.${sortBy} ${order} LIMIT ? OFFSET ?`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
db.all(query, params, (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tags for an image
|
||||||
|
*/
|
||||||
|
function getImageTags(imageId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(
|
||||||
|
'SELECT tag FROM tags WHERE image_id = ? ORDER BY tag',
|
||||||
|
[imageId],
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve((rows || []).map(r => r.tag));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all unique tags
|
||||||
|
*/
|
||||||
|
function getAllTags() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(
|
||||||
|
'SELECT DISTINCT tag FROM tags ORDER BY tag',
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve((rows || []).map(r => r.tag));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get image count
|
||||||
|
*/
|
||||||
|
function getImageCount(tag = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let query = 'SELECT COUNT(*) as count FROM images WHERE is_corrupted = 0';
|
||||||
|
let params = [];
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
query = `SELECT COUNT(DISTINCT i.id) as count FROM images i
|
||||||
|
INNER JOIN tags t ON i.id = t.image_id
|
||||||
|
WHERE i.is_corrupted = 0 AND t.tag = ?`;
|
||||||
|
params.push(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get(query, params, (err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row?.count || 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete image (soft delete - keeps file but marks for cleanup)
|
||||||
|
*/
|
||||||
|
function deleteImage(imageId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
'DELETE FROM images WHERE id = ?',
|
||||||
|
[imageId],
|
||||||
|
(err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up old images (older than specified days)
|
||||||
|
*/
|
||||||
|
function cleanupOldImages(daysOld = 30) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
`DELETE FROM images WHERE fetched_at < datetime('now', '-' || ? || ' days')`,
|
||||||
|
[daysOld],
|
||||||
|
function(err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(this.changes);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get images by hash (detect duplicates)
|
||||||
|
*/
|
||||||
|
function getImagesByHash(hash) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(
|
||||||
|
'SELECT * FROM images WHERE file_hash = ? AND is_corrupted = 0',
|
||||||
|
[hash],
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close database connection
|
||||||
|
*/
|
||||||
|
function closeDatabase() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else {
|
||||||
|
console.log('Database connection closed');
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
db,
|
||||||
|
initializeDatabase,
|
||||||
|
insertImage,
|
||||||
|
addTags,
|
||||||
|
markAsCorrupted,
|
||||||
|
getImage,
|
||||||
|
getImages,
|
||||||
|
getImageTags,
|
||||||
|
getAllTags,
|
||||||
|
getImageCount,
|
||||||
|
deleteImage,
|
||||||
|
cleanupOldImages,
|
||||||
|
getImagesByHash,
|
||||||
|
closeDatabase
|
||||||
|
};
|
||||||
162
lib/image-fetcher.js
Normal file
162
lib/image-fetcher.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
const storage = require('./storage');
|
||||||
|
const database = require('./database');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
let fetchInterval = null;
|
||||||
|
let isRunning = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single image from URL
|
||||||
|
*/
|
||||||
|
async function fetchImage(sourceUrl, tags = []) {
|
||||||
|
try {
|
||||||
|
console.log(`[Image Fetcher] Fetching from: ${sourceUrl}`);
|
||||||
|
|
||||||
|
// Download and save image
|
||||||
|
const downloadResult = await storage.downloadAndSaveImage(sourceUrl);
|
||||||
|
|
||||||
|
if (!downloadResult.success) {
|
||||||
|
console.error(`[Image Fetcher] Failed to download: ${downloadResult.error}`);
|
||||||
|
return { success: false, error: downloadResult.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert into database
|
||||||
|
const imageRecord = await database.insertImage({
|
||||||
|
filename: downloadResult.filename,
|
||||||
|
source_url: sourceUrl,
|
||||||
|
file_path: downloadResult.file_path,
|
||||||
|
filesize: downloadResult.filesize,
|
||||||
|
file_hash: downloadResult.file_hash,
|
||||||
|
mime_type: downloadResult.mime_type
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add tags if provided
|
||||||
|
if (tags && tags.length > 0) {
|
||||||
|
await database.addTags(imageRecord.id, tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Image Fetcher] Saved image: ${downloadResult.filename} (ID: ${imageRecord.id})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
imageId: imageRecord.id,
|
||||||
|
filename: downloadResult.filename,
|
||||||
|
filesize: downloadResult.filesize
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Image Fetcher] Error:', err.message);
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify all images in database for corruption
|
||||||
|
*/
|
||||||
|
async function verifyAllImages() {
|
||||||
|
try {
|
||||||
|
console.log('[Image Fetcher] Starting verification of all images...');
|
||||||
|
|
||||||
|
const images = await database.getImages({ limit: 10000 });
|
||||||
|
let corruptCount = 0;
|
||||||
|
|
||||||
|
for (const image of images) {
|
||||||
|
const verification = await storage.verifyImageIntegrity(image.file_path, image.file_hash);
|
||||||
|
|
||||||
|
if (!verification.valid) {
|
||||||
|
console.warn(`[Image Fetcher] Corrupted image detected: ${image.filename} - ${verification.reason}`);
|
||||||
|
await database.markAsCorrupted(image.id);
|
||||||
|
corruptCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (corruptCount > 0) {
|
||||||
|
console.log(`[Image Fetcher] Found and marked ${corruptCount} corrupted images`);
|
||||||
|
} else {
|
||||||
|
console.log('[Image Fetcher] Verification complete - all images intact');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { checked: images.length, corrupted: corruptCount };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Image Fetcher] Verification error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch from multiple sources
|
||||||
|
*/
|
||||||
|
async function fetchFromMultipleSources(sources) {
|
||||||
|
console.log(`[Image Fetcher] Fetching from ${sources.length} source(s)...`);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const source of sources) {
|
||||||
|
const sourceUrl = typeof source === 'string' ? source : source.url;
|
||||||
|
const tags = source.tags || [];
|
||||||
|
|
||||||
|
const result = await fetchImage(sourceUrl, tags);
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start periodic image fetching
|
||||||
|
*/
|
||||||
|
function startFetcher(sources, intervalMinutes = 2.5) {
|
||||||
|
if (isRunning) {
|
||||||
|
console.log('[Image Fetcher] Fetcher already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
const intervalMs = intervalMinutes * 60 * 1000;
|
||||||
|
|
||||||
|
console.log(`[Image Fetcher] Starting fetcher with ${intervalMinutes} minute interval`);
|
||||||
|
console.log(`[Image Fetcher] Will fetch from ${sources.length} source(s)`);
|
||||||
|
|
||||||
|
// Do initial fetch immediately
|
||||||
|
fetchFromMultipleSources(sources);
|
||||||
|
|
||||||
|
// Then set up interval
|
||||||
|
fetchInterval = setInterval(() => {
|
||||||
|
fetchFromMultipleSources(sources);
|
||||||
|
}, intervalMs);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'running',
|
||||||
|
interval: intervalMinutes,
|
||||||
|
sources: sources.length,
|
||||||
|
nextFetch: new Date(Date.now() + intervalMs)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the fetcher
|
||||||
|
*/
|
||||||
|
function stopFetcher() {
|
||||||
|
if (fetchInterval) {
|
||||||
|
clearInterval(fetchInterval);
|
||||||
|
fetchInterval = null;
|
||||||
|
isRunning = false;
|
||||||
|
console.log('[Image Fetcher] Fetcher stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fetcher status
|
||||||
|
*/
|
||||||
|
function getStatus() {
|
||||||
|
return {
|
||||||
|
running: isRunning,
|
||||||
|
nextFetch: fetchInterval ? 'scheduled' : 'not running'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fetchImage,
|
||||||
|
fetchFromMultipleSources,
|
||||||
|
startFetcher,
|
||||||
|
stopFetcher,
|
||||||
|
verifyAllImages,
|
||||||
|
getStatus
|
||||||
|
};
|
||||||
244
lib/storage.js
Normal file
244
lib/storage.js
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const IMAGES_DIR = path.join(__dirname, '..', 'data', 'images');
|
||||||
|
|
||||||
|
// Ensure images directory exists
|
||||||
|
if (!fs.existsSync(IMAGES_DIR)) {
|
||||||
|
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate SHA256 hash of a file
|
||||||
|
*/
|
||||||
|
function calculateFileHash(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
const stream = fs.createReadStream(filePath);
|
||||||
|
|
||||||
|
stream.on('error', reject);
|
||||||
|
stream.on('data', chunk => hash.update(chunk));
|
||||||
|
stream.on('end', () => resolve(hash.digest('hex')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique filename
|
||||||
|
*/
|
||||||
|
function generateFilename(sourceUrl, timestamp = Date.now()) {
|
||||||
|
const hash = crypto.createHash('md5').update(sourceUrl + timestamp).digest('hex');
|
||||||
|
const ext = getFileExtension(sourceUrl);
|
||||||
|
return `image_${timestamp}_${hash}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file extension from URL
|
||||||
|
*/
|
||||||
|
function getFileExtension(url) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const pathname = urlObj.pathname;
|
||||||
|
const ext = path.extname(pathname);
|
||||||
|
|
||||||
|
// If no extension, try to get from content-type or use default
|
||||||
|
if (!ext || ext.length > 5) {
|
||||||
|
return '.jpg';
|
||||||
|
}
|
||||||
|
return ext;
|
||||||
|
} catch (err) {
|
||||||
|
return '.jpg';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download an image and save it to storage
|
||||||
|
*/
|
||||||
|
async function downloadAndSaveImage(sourceUrl, options = {}) {
|
||||||
|
const { timeout = 30000, maxSize = 50 * 1024 * 1024 } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Download image
|
||||||
|
const response = await axios({
|
||||||
|
url: sourceUrl,
|
||||||
|
method: 'GET',
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout,
|
||||||
|
maxContentLength: maxSize,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = generateFilename(sourceUrl);
|
||||||
|
const filePath = path.join(IMAGES_DIR, filename);
|
||||||
|
const fileBuffer = Buffer.from(response.data);
|
||||||
|
const filesize = fileBuffer.length;
|
||||||
|
const file_hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||||
|
const mime_type = response.headers['content-type'] || 'image/jpeg';
|
||||||
|
|
||||||
|
// Save file
|
||||||
|
fs.writeFileSync(filePath, fileBuffer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
file_path: filePath,
|
||||||
|
relative_path: path.relative(path.join(__dirname, '..'), filePath),
|
||||||
|
filesize,
|
||||||
|
file_hash,
|
||||||
|
mime_type,
|
||||||
|
source_url: sourceUrl,
|
||||||
|
success: true
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to download image from ${sourceUrl}:`, err.message);
|
||||||
|
return {
|
||||||
|
source_url: sourceUrl,
|
||||||
|
success: false,
|
||||||
|
error: err.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify image integrity using stored hash
|
||||||
|
*/
|
||||||
|
async function verifyImageIntegrity(filePath, expectedHash) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return { valid: false, reason: 'File does not exist' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualHash = await calculateFileHash(filePath);
|
||||||
|
|
||||||
|
if (actualHash !== expectedHash) {
|
||||||
|
return { valid: false, reason: 'Hash mismatch', expectedHash, actualHash };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { valid: false, reason: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete image file
|
||||||
|
*/
|
||||||
|
function deleteImageFile(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get image file as buffer
|
||||||
|
*/
|
||||||
|
function getImageBuffer(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fs.readFile(filePath, (err, data) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage statistics
|
||||||
|
*/
|
||||||
|
function getStorageStats() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
let totalSize = 0;
|
||||||
|
let fileCount = 0;
|
||||||
|
|
||||||
|
if (!fs.existsSync(IMAGES_DIR)) {
|
||||||
|
resolve({ totalSize: 0, fileCount: 0, dataDir: IMAGES_DIR });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(IMAGES_DIR);
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const filePath = path.join(IMAGES_DIR, file);
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
if (stats.isFile()) {
|
||||||
|
totalSize += stats.size;
|
||||||
|
fileCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
totalSize,
|
||||||
|
totalSizeGB: (totalSize / (1024 ** 3)).toFixed(2),
|
||||||
|
fileCount,
|
||||||
|
dataDir: IMAGES_DIR
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up orphaned files (files in storage but not in database)
|
||||||
|
*/
|
||||||
|
async function cleanupOrphanedFiles(db) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(IMAGES_DIR)) {
|
||||||
|
return { cleaned: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(IMAGES_DIR);
|
||||||
|
let cleaned = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(IMAGES_DIR, file);
|
||||||
|
|
||||||
|
// Check if file exists in database
|
||||||
|
const existsInDb = await new Promise((resolve) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT id FROM images WHERE filename = ?',
|
||||||
|
[file],
|
||||||
|
(err, row) => resolve(!err && !!row)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existsInDb) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
cleaned++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to delete orphaned file ${file}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cleaned };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Cleanup error:', err);
|
||||||
|
return { cleaned: 0, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
downloadAndSaveImage,
|
||||||
|
verifyImageIntegrity,
|
||||||
|
deleteImageFile,
|
||||||
|
getImageBuffer,
|
||||||
|
getStorageStats,
|
||||||
|
calculateFileHash,
|
||||||
|
generateFilename,
|
||||||
|
getFileExtension,
|
||||||
|
cleanupOrphanedFiles,
|
||||||
|
IMAGES_DIR
|
||||||
|
};
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2"
|
"express": "^4.18.2",
|
||||||
|
"sqlite3": "^5.1.6",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"crypto": "^1.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
587
public/gallery.html
Normal file
587
public/gallery.html
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>HomeBase Image Gallery</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2.5em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 1.1em;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls select,
|
||||||
|
.controls input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls select {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
display: flex;
|
||||||
|
gap: 30px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.8em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:nth-child(odd) .timeline-content {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: auto;
|
||||||
|
width: calc(50% - 20px);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:nth-child(even) .timeline-content {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 0;
|
||||||
|
width: calc(50% - 20px);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-marker {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: white;
|
||||||
|
border: 3px solid #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-timestamp {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 0.95em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-tag {
|
||||||
|
display: inline-block;
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #555;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-tag.active {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-size {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.timeline::before {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-marker {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:nth-child(odd) .timeline-content,
|
||||||
|
.timeline-item:nth-child(even) .timeline-content {
|
||||||
|
width: calc(100% - 50px);
|
||||||
|
margin-left: 50px;
|
||||||
|
margin-right: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
animation: fadeIn 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
position: relative;
|
||||||
|
margin: auto;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-info {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: absolute;
|
||||||
|
right: 20px;
|
||||||
|
top: 20px;
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1001;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>📸 Image Timeline</h1>
|
||||||
|
<p>Browse your captured images chronologically</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<label>
|
||||||
|
Sort by:
|
||||||
|
<select id="sortSelect">
|
||||||
|
<option value="newest">Newest First</option>
|
||||||
|
<option value="oldest">Oldest First</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Filter by tag:
|
||||||
|
<select id="tagSelect">
|
||||||
|
<option value="">All Tags</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Refresh:
|
||||||
|
<input type="checkbox" id="autoRefresh" unchecked>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Total Images</span>
|
||||||
|
<span class="stat-value" id="totalCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Total Storage</span>
|
||||||
|
<span class="stat-value" id="totalSize">0 GB</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Last Updated</span>
|
||||||
|
<span class="stat-value" id="lastUpdated">--:--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loading" class="loading">Loading images...</div>
|
||||||
|
<div id="error" class="error" style="display:none;"></div>
|
||||||
|
<div class="timeline" id="timeline" style="display:none;"></div>
|
||||||
|
<div class="empty" id="empty" style="display:none;">No images found. Start fetching to see images here!</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for full image view -->
|
||||||
|
<div id="imageModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<img id="modalImage" src="" alt="">
|
||||||
|
<div class="modal-info" id="modalInfo"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_BASE = '';
|
||||||
|
let allImages = [];
|
||||||
|
let allTags = [];
|
||||||
|
let refreshInterval = null;
|
||||||
|
|
||||||
|
const timeline = document.getElementById('timeline');
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
const error = document.getElementById('error');
|
||||||
|
const empty = document.getElementById('empty');
|
||||||
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
|
const tagSelect = document.getElementById('tagSelect');
|
||||||
|
const autoRefresh = document.getElementById('autoRefresh');
|
||||||
|
const modal = document.getElementById('imageModal');
|
||||||
|
const closeBtn = document.querySelector('.close');
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImages() {
|
||||||
|
try {
|
||||||
|
loading.style.display = 'block';
|
||||||
|
error.style.display = 'none';
|
||||||
|
timeline.style.display = 'none';
|
||||||
|
empty.style.display = 'none';
|
||||||
|
|
||||||
|
// Fetch images
|
||||||
|
const response = await fetch(`${API_BASE}/api/images?pageSize=999`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch images');
|
||||||
|
const data = await response.json();
|
||||||
|
allImages = data.images || [];
|
||||||
|
|
||||||
|
// Fetch tags
|
||||||
|
const tagsResponse = await fetch(`${API_BASE}/api/tags`);
|
||||||
|
const tagsData = await tagsResponse.json();
|
||||||
|
allTags = tagsData.tags || [];
|
||||||
|
|
||||||
|
// Fetch stats
|
||||||
|
const statsResponse = await fetch(`${API_BASE}/api/stats`);
|
||||||
|
const statsData = statsResponse.ok ? await statsResponse.json() : null;
|
||||||
|
if (statsData) {
|
||||||
|
document.getElementById('totalCount').textContent = statsData.stats.imageCount || allImages.length;
|
||||||
|
document.getElementById('totalSize').textContent = statsData.stats.totalSizeGB + ' GB' || '0 GB';
|
||||||
|
document.getElementById('lastUpdated').textContent = new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tag filter
|
||||||
|
updateTagFilter();
|
||||||
|
|
||||||
|
// Render images
|
||||||
|
renderTimeline();
|
||||||
|
|
||||||
|
loading.style.display = 'none';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading images:', err);
|
||||||
|
error.style.display = 'block';
|
||||||
|
error.textContent = 'Error loading images: ' + err.message;
|
||||||
|
loading.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTagFilter() {
|
||||||
|
const currentValue = tagSelect.value;
|
||||||
|
tagSelect.innerHTML = '<option value="">All Tags</option>';
|
||||||
|
allTags.forEach(tag => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = tag;
|
||||||
|
option.textContent = tag;
|
||||||
|
tagSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
tagSelect.value = currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeline() {
|
||||||
|
// Filter images
|
||||||
|
const selectedTag = tagSelect.value;
|
||||||
|
let filtered = allImages;
|
||||||
|
if (selectedTag) {
|
||||||
|
filtered = allImages.filter(img =>
|
||||||
|
img.tags && img.tags.includes(selectedTag)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort images
|
||||||
|
const sorted = [...filtered];
|
||||||
|
if (sortSelect.value === 'oldest') {
|
||||||
|
sorted.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear timeline
|
||||||
|
timeline.innerHTML = '';
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
empty.style.display = 'block';
|
||||||
|
timeline.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add items
|
||||||
|
sorted.forEach(image => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'timeline-item';
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'timeline-content';
|
||||||
|
content.onclick = () => showModal(image);
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = `${API_BASE}/api/images/${image.id}/download`;
|
||||||
|
img.onerror = () => img.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100" height="100"%3E%3Crect fill="%23ccc" width="100" height="100"/%3E%3Ctext x="50" y="50" text-anchor="middle" dy=".3em" fill="%23999" font-size="14"%3EImage Error%3C/text%3E%3C/svg%3E';
|
||||||
|
|
||||||
|
const timestamp = document.createElement('div');
|
||||||
|
timestamp.className = 'timeline-timestamp';
|
||||||
|
timestamp.textContent = formatDate(image.fetched_at);
|
||||||
|
|
||||||
|
const tagsDiv = document.createElement('div');
|
||||||
|
tagsDiv.className = 'timeline-tags';
|
||||||
|
if (image.tags && image.tags.length > 0) {
|
||||||
|
image.tags.forEach(tag => {
|
||||||
|
const tagEl = document.createElement('span');
|
||||||
|
tagEl.className = 'timeline-tag';
|
||||||
|
if (selectedTag === tag) tagEl.classList.add('active');
|
||||||
|
tagEl.textContent = tag;
|
||||||
|
tagEl.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
tagSelect.value = tag;
|
||||||
|
renderTimeline();
|
||||||
|
};
|
||||||
|
tagsDiv.appendChild(tagEl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = document.createElement('div');
|
||||||
|
size.className = 'timeline-size';
|
||||||
|
size.textContent = formatSize(image.filesize);
|
||||||
|
|
||||||
|
content.appendChild(img);
|
||||||
|
content.appendChild(timestamp);
|
||||||
|
content.appendChild(tagsDiv);
|
||||||
|
content.appendChild(size);
|
||||||
|
|
||||||
|
const marker = document.createElement('div');
|
||||||
|
marker.className = 'timeline-marker';
|
||||||
|
|
||||||
|
item.appendChild(marker);
|
||||||
|
item.appendChild(content);
|
||||||
|
timeline.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
timeline.style.display = 'block';
|
||||||
|
empty.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModal(image) {
|
||||||
|
document.getElementById('modalImage').src = `${API_BASE}/api/images/${image.id}/download`;
|
||||||
|
const info = document.getElementById('modalInfo');
|
||||||
|
info.innerHTML = `
|
||||||
|
<div style="margin-bottom: 12px;">
|
||||||
|
<strong style="color: #667eea;">${formatDate(image.fetched_at)}</strong>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 8px;">
|
||||||
|
<strong>Size:</strong> ${formatSize(image.filesize)}
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 8px;">
|
||||||
|
<strong>Hash:</strong> <code style="background: #f5f5f5; padding: 4px 8px; border-radius: 4px; font-size: 0.85em;">${image.file_hash.substring(0, 16)}...</code>
|
||||||
|
</div>
|
||||||
|
${image.tags && image.tags.length > 0 ? `
|
||||||
|
<div>
|
||||||
|
<strong>Tags:</strong> ${image.tags.map(tag => `<span class="timeline-tag">${tag}</span>`).join(' ')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
modal.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeBtn.onclick = () => {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.onclick = (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sortSelect.addEventListener('change', renderTimeline);
|
||||||
|
tagSelect.addEventListener('change', renderTimeline);
|
||||||
|
|
||||||
|
autoRefresh.addEventListener('change', (e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
loadImages();
|
||||||
|
refreshInterval = setInterval(loadImages, 3000);
|
||||||
|
} else {
|
||||||
|
if (refreshInterval) clearInterval(refreshInterval);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadImages();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -36,6 +36,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="features-section">
|
||||||
|
<h2>Features</h2>
|
||||||
|
<div class="features-grid">
|
||||||
|
<a href="/gallery.html" class="feature-card">
|
||||||
|
<div class="feature-icon">📸</div>
|
||||||
|
<h3>Image Gallery</h3>
|
||||||
|
<p>Browse captured images on an interactive timeline</p>
|
||||||
|
</a>
|
||||||
|
<a href="/api/stats" class="feature-card">
|
||||||
|
<div class="feature-icon">📊</div>
|
||||||
|
<h3>Statistics</h3>
|
||||||
|
<p>View storage usage and image metrics</p>
|
||||||
|
</a>
|
||||||
|
<a href="/api/status" class="feature-card">
|
||||||
|
<div class="feature-icon">✨</div>
|
||||||
|
<h3>API Status</h3>
|
||||||
|
<p>Check system status and features</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
|
|||||||
@@ -133,6 +133,51 @@ section h2 {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 16px rgba(74, 158, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card h3 {
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding-top: 2rem;
|
padding-top: 2rem;
|
||||||
@@ -159,6 +204,10 @@ footer {
|
|||||||
.info-grid {
|
.info-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
|
|||||||
235
routes/images.js
Normal file
235
routes/images.js
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const database = require('../lib/database');
|
||||||
|
const storage = require('../lib/storage');
|
||||||
|
const imageFetcher = require('../lib/image-fetcher');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/images - Get all images with optional filtering
|
||||||
|
*/
|
||||||
|
router.get('/images', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { tag, sourceUrl, page = 1, pageSize = 50, sort = 'fetched_at', order = 'DESC' } = req.query;
|
||||||
|
|
||||||
|
const limit = parseInt(pageSize);
|
||||||
|
const offset = (parseInt(page) - 1) * limit;
|
||||||
|
|
||||||
|
const images = await database.getImages({
|
||||||
|
tag,
|
||||||
|
sourceUrl,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
sortBy: sort,
|
||||||
|
order
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get tags for each image
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
images[i].tags = await database.getImageTags(images[i].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = await database.getImageCount(tag);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
images,
|
||||||
|
pagination: {
|
||||||
|
page: parseInt(page),
|
||||||
|
pageSize: limit,
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / limit)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/images/:id - Get a specific image
|
||||||
|
*/
|
||||||
|
router.get('/images/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const image = await database.getImage(req.params.id);
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Image not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
image.tags = await database.getImageTags(image.id);
|
||||||
|
|
||||||
|
res.json({ success: true, image });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/images/:id/download - Download image file
|
||||||
|
*/
|
||||||
|
router.get('/images/:id/download', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const image = await database.getImage(req.params.id);
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Image not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await storage.getImageBuffer(image.file_path);
|
||||||
|
res.contentType(image.mime_type);
|
||||||
|
res.send(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/images - Fetch and save a new image
|
||||||
|
*/
|
||||||
|
router.post('/images', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { source_url, tags = [] } = req.body;
|
||||||
|
|
||||||
|
if (!source_url) {
|
||||||
|
return res.status(400).json({ success: false, error: 'source_url is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await imageFetcher.fetchImage(source_url, tags);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await database.getImage(result.imageId);
|
||||||
|
image.tags = await database.getImageTags(image.id);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, image });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/images/:id/tags - Add tags to an image
|
||||||
|
*/
|
||||||
|
router.post('/images/:id/tags', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { tags = [] } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(tags) || tags.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, error: 'tags must be a non-empty array' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await database.getImage(req.params.id);
|
||||||
|
if (!image) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Image not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await database.addTags(image.id, tags);
|
||||||
|
|
||||||
|
image.tags = await database.getImageTags(image.id);
|
||||||
|
|
||||||
|
res.json({ success: true, image });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/images/:id - Delete an image
|
||||||
|
*/
|
||||||
|
router.delete('/images/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const image = await database.getImage(req.params.id);
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Image not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
await storage.deleteImageFile(image.file_path);
|
||||||
|
|
||||||
|
// Delete database record
|
||||||
|
await database.deleteImage(image.id);
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Image deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tags - Get all available tags
|
||||||
|
*/
|
||||||
|
router.get('/tags', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tags = await database.getAllTags();
|
||||||
|
res.json({ success: true, tags });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/stats - Get storage and image statistics
|
||||||
|
*/
|
||||||
|
router.get('/stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const storageStats = await storage.getStorageStats();
|
||||||
|
const imageCount = await database.getImageCount();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
stats: {
|
||||||
|
imageCount,
|
||||||
|
...storageStats
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/verify - Verify all images for corruption
|
||||||
|
*/
|
||||||
|
router.post('/verify', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await imageFetcher.verifyAllImages();
|
||||||
|
res.json({ success: true, result });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/cleanup - Clean up old images
|
||||||
|
*/
|
||||||
|
router.post('/cleanup', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { daysOld = 30 } = req.body;
|
||||||
|
|
||||||
|
const deleted = await database.cleanupOldImages(daysOld);
|
||||||
|
const orphaned = await storage.cleanupOrphanedFiles(database.db);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
cleanup: {
|
||||||
|
deletedFromDb: deleted,
|
||||||
|
orphanedFilesRemoved: orphaned.cleaned
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/fetcher/status - Get fetcher status
|
||||||
|
*/
|
||||||
|
router.get('/fetcher/status', (req, res) => {
|
||||||
|
const status = imageFetcher.getStatus();
|
||||||
|
res.json({ success: true, status });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -25,6 +25,8 @@ $files = @(
|
|||||||
"docker-compose.yml",
|
"docker-compose.yml",
|
||||||
"package.json",
|
"package.json",
|
||||||
"server.js",
|
"server.js",
|
||||||
|
"setup.js",
|
||||||
|
"image-sources.json",
|
||||||
".dockerignore",
|
".dockerignore",
|
||||||
"homebase.service"
|
"homebase.service"
|
||||||
)
|
)
|
||||||
@@ -38,12 +40,14 @@ foreach ($file in $files) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Copy public folder recursively
|
# Copy directories recursively
|
||||||
Write-Host " Copying public folder..." -ForegroundColor Gray
|
foreach ($dir in @("public", "lib", "routes", "scripts")) {
|
||||||
scp -r "public" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/"
|
Write-Host " Copying $dir folder..." -ForegroundColor Gray
|
||||||
if ($LASTEXITCODE -ne 0) {
|
scp -r $dir "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/"
|
||||||
Write-Host "[!!] Failed to copy public folder" -ForegroundColor Red
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "[!!] Failed to copy $dir folder" -ForegroundColor Red
|
||||||
exit 1
|
exit 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fix permissions on the remote server
|
# Fix permissions on the remote server
|
||||||
|
|||||||
87
server.js
87
server.js
@@ -1,30 +1,103 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const database = require('./lib/database');
|
||||||
|
const imageFetcher = require('./lib/image-fetcher');
|
||||||
|
const imagesRouter = require('./routes/images');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const DOMAIN = process.env.DOMAIN || 'homebase.sketchferret.com';
|
const DOMAIN = process.env.DOMAIN || 'homebase.sketchferret.com';
|
||||||
|
|
||||||
// Serve static files from public folder
|
// Middleware
|
||||||
|
app.use(express.json());
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
// API endpoint
|
// Initialize database
|
||||||
|
database.initializeDatabase().catch(err => {
|
||||||
|
console.error('Failed to initialize database:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Routes
|
||||||
|
app.use('/api', imagesRouter);
|
||||||
|
|
||||||
|
// Status endpoint
|
||||||
app.get('/api/status', (req, res) => {
|
app.get('/api/status', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
message: 'HomeBase is running!',
|
message: 'HomeBase is running!',
|
||||||
domain: DOMAIN,
|
domain: DOMAIN,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
version: '1.0.0'
|
version: '1.0.0',
|
||||||
|
features: {
|
||||||
|
imageStorage: true,
|
||||||
|
tagging: true,
|
||||||
|
corruptionDetection: true
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Health check
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'healthy' });
|
res.json({ status: 'healthy' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
// Start server
|
||||||
console.log(`Server running on port ${PORT}`);
|
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Domain: ${DOMAIN}`);
|
console.log(`\n🏠 HomeBase Server Started`);
|
||||||
console.log(`Visit: http://${DOMAIN}`);
|
console.log(` Port: ${PORT}`);
|
||||||
|
console.log(` Domain: ${DOMAIN}`);
|
||||||
|
console.log(` URL: http://${DOMAIN}\n`);
|
||||||
|
|
||||||
|
// Load configuration from image-sources.json
|
||||||
|
const configPath = path.join(__dirname, 'image-sources.json');
|
||||||
|
let config = { sources: [], fetchInterval: 2.5 };
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
const fileContent = fs.readFileSync(configPath, 'utf8');
|
||||||
|
config = JSON.parse(fileContent);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('⚠️ Could not read image-sources.json:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageSources = config.sources || [];
|
||||||
|
const fetchInterval = config.fetchInterval || 2.5;
|
||||||
|
|
||||||
|
// Start image fetcher if sources are configured
|
||||||
|
if (imageSources.length > 0) {
|
||||||
|
const enabledSources = imageSources.filter(s => s.enabled);
|
||||||
|
if (enabledSources.length > 0) {
|
||||||
|
imageFetcher.startFetcher(enabledSources, fetchInterval);
|
||||||
|
console.log(`📸 Image fetcher: Enabled (${fetchInterval} minute interval)\n`);
|
||||||
|
} else {
|
||||||
|
console.log('📸 Image fetcher: Disabled (no enabled sources in config)');
|
||||||
|
console.log(' Edit image-sources.json and set "enabled": true to enable\n');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('📸 Image fetcher: No sources configured');
|
||||||
|
console.log(' Edit image-sources.json to add image sources\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('\n📴 Shutting down gracefully...');
|
||||||
|
imageFetcher.stopFetcher();
|
||||||
|
server.close(async () => {
|
||||||
|
await database.closeDatabase();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('\n📴 Shutting down gracefully...');
|
||||||
|
imageFetcher.stopFetcher();
|
||||||
|
server.close(async () => {
|
||||||
|
await database.closeDatabase();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
45
setup.bat
Normal file
45
setup.bat
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
@echo off
|
||||||
|
REM HomeBase Image Storage - Setup Script
|
||||||
|
REM Run this script to initialize the image storage system
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo HomeBase Image Storage Setup
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Check if Node.js is installed
|
||||||
|
where node >nul 2>nul
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo ERROR: Node.js is not installed or not in PATH
|
||||||
|
echo Please install Node.js from https://nodejs.org
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [1/3] Installing dependencies...
|
||||||
|
call npm install
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo ERROR: Failed to install dependencies
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [2/3] Initializing database...
|
||||||
|
call node setup.js init
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo ERROR: Failed to initialize database
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [3/3] Setup complete!
|
||||||
|
echo.
|
||||||
|
echo Next steps:
|
||||||
|
echo 1. Edit image-sources.json to add your image sources
|
||||||
|
echo 2. Set "enabled": true for sources you want to use
|
||||||
|
echo 3. Run: npm start
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
199
setup.js
Normal file
199
setup.js
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HomeBase Image Storage Setup and Configuration Script
|
||||||
|
* This script helps you configure image sources and test the storage system
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const database = require('./lib/database');
|
||||||
|
const storage = require('./lib/storage');
|
||||||
|
const imageFetcher = require('./lib/image-fetcher');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const CONFIG_FILE = path.join(__dirname, 'image-sources.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create default configuration file
|
||||||
|
*/
|
||||||
|
async function createDefaultConfig() {
|
||||||
|
const defaultConfig = {
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
name: "Example Source",
|
||||||
|
url: "https://picsum.photos/800/600?random=1",
|
||||||
|
tags: ["example", "test"],
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
fetchInterval: 0.033
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(defaultConfig, null, 2));
|
||||||
|
console.log(`✅ Created default configuration at: ${CONFIG_FILE}`);
|
||||||
|
console.log(' Edit this file to add your image sources');
|
||||||
|
console.log(' fetchInterval: 0.033 = 2 seconds. Adjust as needed (0.0167 = 1s, 2.5 = 2.5min)');
|
||||||
|
return defaultConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load configuration
|
||||||
|
*/
|
||||||
|
function loadConfig() {
|
||||||
|
if (!fs.existsSync(CONFIG_FILE)) {
|
||||||
|
return createDefaultConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error reading configuration:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get enabled sources from configuration
|
||||||
|
*/
|
||||||
|
function getEnabledSources(config) {
|
||||||
|
return config.sources.filter(source => source.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize storage directories and database
|
||||||
|
*/
|
||||||
|
async function initialize() {
|
||||||
|
console.log('\n🚀 Initializing HomeBase Image Storage System...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize database
|
||||||
|
console.log('📦 Setting up database...');
|
||||||
|
await database.initializeDatabase();
|
||||||
|
console.log(' ✅ Database initialized');
|
||||||
|
|
||||||
|
// Check storage directory
|
||||||
|
console.log('\n💾 Checking storage...');
|
||||||
|
const stats = await storage.getStorageStats();
|
||||||
|
console.log(` ✅ Storage directory: ${stats.dataDir}`);
|
||||||
|
console.log(` 📊 Current usage: ${stats.totalSizeGB} GB (${stats.fileCount} files)`);
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
console.log('\n⚙️ Loading configuration...');
|
||||||
|
const config = loadConfig();
|
||||||
|
const enabledSources = getEnabledSources(config);
|
||||||
|
|
||||||
|
console.log(` ✅ Configuration loaded`);
|
||||||
|
console.log(` 📍 Image sources configured: ${config.sources.length}`);
|
||||||
|
console.log(` ✨ Enabled sources: ${enabledSources.length}`);
|
||||||
|
|
||||||
|
// Test image fetch if enabled sources exist
|
||||||
|
if (enabledSources.length > 0) {
|
||||||
|
console.log('\n🧪 Testing image fetch...');
|
||||||
|
const testSource = enabledSources[0];
|
||||||
|
console.log(` Fetching from: ${testSource.url}`);
|
||||||
|
|
||||||
|
const result = await imageFetcher.fetchImage(testSource.url, testSource.tags);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log(` ✅ Successfully fetched and stored image`);
|
||||||
|
console.log(` 📄 Filename: ${result.filename}`);
|
||||||
|
console.log(` 💾 Size: ${(result.filesize / 1024).toFixed(2)} KB`);
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠️ Failed to fetch: ${result.error}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('\n⚠️ No enabled image sources found');
|
||||||
|
console.log(' Edit image-sources.json and set "enabled": true to enable sources');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✨ Initialization complete!\n');
|
||||||
|
console.log('📖 API Endpoints:');
|
||||||
|
console.log(' GET /api/images - List all images');
|
||||||
|
console.log(' POST /api/images - Fetch and save new image');
|
||||||
|
console.log(' GET /api/images/{id} - Get image details');
|
||||||
|
console.log(' POST /api/images/{id}/tags - Add tags to image');
|
||||||
|
console.log(' GET /api/tags - List all tags');
|
||||||
|
console.log(' GET /api/stats - Storage statistics');
|
||||||
|
console.log(' POST /api/verify - Verify image integrity');
|
||||||
|
console.log(' POST /api/cleanup - Clean up old images');
|
||||||
|
console.log('\n');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Initialization failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await database.closeDatabase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test image fetch
|
||||||
|
*/
|
||||||
|
async function testFetch(url, tags = []) {
|
||||||
|
console.log('\n🧪 Testing Image Fetch\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await database.initializeDatabase();
|
||||||
|
|
||||||
|
console.log(`Fetching from: ${url}`);
|
||||||
|
console.log(`Tags: ${tags.join(', ') || 'none'}\n`);
|
||||||
|
|
||||||
|
const result = await imageFetcher.fetchImage(url, tags);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log('✅ Success!');
|
||||||
|
console.log(` Image ID: ${result.imageId}`);
|
||||||
|
console.log(` Filename: ${result.filename}`);
|
||||||
|
console.log(` Size: ${(result.filesize / 1024).toFixed(2)} KB`);
|
||||||
|
|
||||||
|
const image = await database.getImage(result.imageId);
|
||||||
|
console.log(` Hash: ${image.file_hash}`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ Failed: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error:', err.message);
|
||||||
|
} finally {
|
||||||
|
await database.closeDatabase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show usage information
|
||||||
|
*/
|
||||||
|
function showUsage() {
|
||||||
|
console.log('\nHomeBase Image Storage CLI\n');
|
||||||
|
console.log('Usage:');
|
||||||
|
console.log(' node setup.js init - Initialize system');
|
||||||
|
console.log(' node setup.js test <url> [tags] - Test fetch from URL');
|
||||||
|
console.log(' node setup.js config - Show configuration');
|
||||||
|
console.log(' node setup.js help - Show this help\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main
|
||||||
|
const command = process.argv[2];
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'init':
|
||||||
|
initialize();
|
||||||
|
break;
|
||||||
|
case 'test':
|
||||||
|
const url = process.argv[3];
|
||||||
|
const tags = process.argv.slice(4).join(' ').split(',').map(t => t.trim()).filter(t => t);
|
||||||
|
if (!url) {
|
||||||
|
console.error('❌ URL required: node setup.js test <url> [tags]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
testFetch(url, tags);
|
||||||
|
break;
|
||||||
|
case 'config':
|
||||||
|
const config = loadConfig();
|
||||||
|
console.log('\nCurrent Configuration:\n');
|
||||||
|
console.log(JSON.stringify(config, null, 2));
|
||||||
|
break;
|
||||||
|
case 'help':
|
||||||
|
default:
|
||||||
|
showUsage();
|
||||||
|
break;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user