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/
|
||||
*.log
|
||||
README.md
|
||||
IMAGE_STORAGE_GUIDE.md
|
||||
.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 . .
|
||||
|
||||
# Ensure proper permissions for node user
|
||||
RUN chown -R node:node /app
|
||||
# Create data directory for persistent storage
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- 🚀 Simple Express.js server
|
||||
- 🐳 Docker containerized
|
||||
- 🚀 Express.js server
|
||||
- 🐳 Docker containerized with persistent volumes
|
||||
- 🔄 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
|
||||
|
||||
## Prerequisites
|
||||
@@ -30,53 +35,70 @@ On the remote server (beepc):
|
||||
# Install dependencies
|
||||
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
|
||||
npm start
|
||||
|
||||
# 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
|
||||
|
||||
### Option 1: Manual Deployment (Windows)
|
||||
### Quick Deployment to Home Lab (PowerShell)
|
||||
|
||||
```bash
|
||||
# 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)
|
||||
From your local Windows machine:
|
||||
|
||||
```powershell
|
||||
# Run the deployment script from project root
|
||||
# Deploy to home lab (beepc)
|
||||
.\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:
|
||||
- Go to your repository Settings > Secrets and variables > Actions
|
||||
- Add a new secret named `SSH_PRIVATE_KEY`
|
||||
- Paste your SSH private key content
|
||||
The app will be available at `http://homebase.sketchferret.com`
|
||||
|
||||
2. Push to main branch:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Initial commit"
|
||||
git push origin main
|
||||
```
|
||||
### Persistent Data Storage
|
||||
|
||||
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
|
||||
|
||||
@@ -114,9 +136,29 @@ sudo apt-get install docker-compose-plugin
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /` - Main endpoint, returns app status
|
||||
### Status & Health
|
||||
- `GET /api/status` - App status and version
|
||||
- `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
|
||||
|
||||
```
|
||||
@@ -125,16 +167,30 @@ HomeBase/
|
||||
│ ├── deploy.ps1 # PowerShell deployment script
|
||||
│ ├── deploy.sh # Bash 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/
|
||||
│ └── workflows/
|
||||
│ └── deploy.yml # GitHub Actions workflow
|
||||
├── .dockerignore # Docker ignore file
|
||||
├── .gitignore # Git ignore file
|
||||
├── Dockerfile # Docker configuration
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
├── docker-compose.yml # Docker Compose with volumes
|
||||
├── homebase.service # Systemd service file
|
||||
├── package.json # Node.js dependencies
|
||||
├── 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
|
||||
```
|
||||
|
||||
@@ -212,16 +268,44 @@ ssh spencer@beepc "sudo systemctl restart 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
|
||||
environment:
|
||||
- PORT=3001 # Internal port (reverse proxy maps to standard HTTP)
|
||||
- DOMAIN=homebase.sketchferret.com # Your domain
|
||||
ports:
|
||||
- "3001:3001" # Port mapping for reverse proxy backend
|
||||
- PORT=3001
|
||||
- DOMAIN=homebase.sketchferret.com
|
||||
- NODE_ENV=production
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
@@ -3,9 +3,19 @@ services:
|
||||
build: .
|
||||
container_name: homebase
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3001"
|
||||
# Use host network to access both local network and Tailscale
|
||||
# Also inherits host's DNS for .local mDNS hostname resolution
|
||||
network_mode: host
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
- 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": "",
|
||||
"license": "MIT",
|
||||
"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>
|
||||
</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>
|
||||
|
||||
<footer>
|
||||
|
||||
@@ -133,6 +133,51 @@ section h2 {
|
||||
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 {
|
||||
text-align: center;
|
||||
padding-top: 2rem;
|
||||
@@ -159,6 +204,10 @@ footer {
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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",
|
||||
"package.json",
|
||||
"server.js",
|
||||
"setup.js",
|
||||
"image-sources.json",
|
||||
".dockerignore",
|
||||
"homebase.service"
|
||||
)
|
||||
@@ -38,13 +40,15 @@ foreach ($file in $files) {
|
||||
}
|
||||
}
|
||||
|
||||
# Copy public folder recursively
|
||||
Write-Host " Copying public folder..." -ForegroundColor Gray
|
||||
scp -r "public" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/"
|
||||
# Copy directories recursively
|
||||
foreach ($dir in @("public", "lib", "routes", "scripts")) {
|
||||
Write-Host " Copying $dir folder..." -ForegroundColor Gray
|
||||
scp -r $dir "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "[!!] Failed to copy public folder" -ForegroundColor Red
|
||||
Write-Host "[!!] Failed to copy $dir folder" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Fix permissions on the remote server
|
||||
Write-Host " Fixing permissions..." -ForegroundColor Gray
|
||||
|
||||
85
server.js
85
server.js
@@ -1,30 +1,103 @@
|
||||
const express = require('express');
|
||||
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 PORT = process.env.PORT || 3001;
|
||||
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')));
|
||||
|
||||
// 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) => {
|
||||
res.json({
|
||||
message: 'HomeBase is running!',
|
||||
domain: DOMAIN,
|
||||
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) => {
|
||||
res.json({ status: 'healthy' });
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
// Start server
|
||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`\n🏠 HomeBase Server Started`);
|
||||
console.log(` Port: ${PORT}`);
|
||||
console.log(` Domain: ${DOMAIN}`);
|
||||
console.log(`Visit: http://${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