Initial commit: PicUpper webcam upload service
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
uploads/
|
||||||
|
users.json
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
265
README.md
Normal file
265
README.md
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
# PicUpper - Webcam Picture Upload Service
|
||||||
|
|
||||||
|
A Node.js service that receives pictures from webcams and stores them in a structured way for creating time-lapse movies.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Multi-camera support**: Handle uploads from multiple webcams simultaneously
|
||||||
|
- **API key authentication**: Secure your upload endpoint
|
||||||
|
- **Time-based storage**: Images organized by camera/year/month/day for easy time-lapse creation
|
||||||
|
- **High-frequency uploads**: Handles uploads up to every second
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/picUpper
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Management
|
||||||
|
|
||||||
|
PicUpper supports multiple users, each with their own API key. Manage users with the CLI tool:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add a new user (returns the API key - save it!)
|
||||||
|
node manage-users.js add webcam1 "Front door camera"
|
||||||
|
node manage-users.js add backyard "Backyard camera"
|
||||||
|
|
||||||
|
# List all users
|
||||||
|
node manage-users.js list
|
||||||
|
|
||||||
|
# Regenerate API key for a user
|
||||||
|
node manage-users.js regenerate webcam1
|
||||||
|
|
||||||
|
# Disable/enable a user
|
||||||
|
node manage-users.js disable webcam1
|
||||||
|
node manage-users.js enable webcam1
|
||||||
|
|
||||||
|
# Remove a user
|
||||||
|
node manage-users.js remove webcam1
|
||||||
|
```
|
||||||
|
|
||||||
|
Users are stored in `users.json`. Each user has an individual API key that can be used for authentication.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `PICUPPER_PORT` | Yes | Port the service listens on |
|
||||||
|
| `PICUPPER_UPLOAD_DIR` | No | Custom upload directory (default: `./uploads`) |
|
||||||
|
| `PICUPPER_USERS_FILE` | No | Custom users file path (default: `./users.json`) |
|
||||||
|
|
||||||
|
## Running the Service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First, create at least one user
|
||||||
|
node manage-users.js add mywebcam "My webcam"
|
||||||
|
|
||||||
|
# Start the service
|
||||||
|
export PICUPPER_PORT=3080
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
|
||||||
|
No authentication required. Returns service status.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"timestamp": "2025-12-18T12:00:00.000Z",
|
||||||
|
"uptime": 1234.56
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upload Picture
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /upload
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload a picture from a webcam.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
- `X-API-Key`: Your API key
|
||||||
|
|
||||||
|
**Form Data:**
|
||||||
|
- `image`: The image file (JPEG, PNG, WebP, or GIF)
|
||||||
|
- `cameraId`: Identifier for the camera (optional, default: "default")
|
||||||
|
|
||||||
|
**Example with curl:**
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "X-API-Key: your-secret-key" \
|
||||||
|
-F "image=@snapshot.jpg" \
|
||||||
|
-F "cameraId=front-door" \
|
||||||
|
http://localhost:3080/upload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"cameraId": "front-door",
|
||||||
|
"filename": "front-door_2025-12-18T12-00-00-000Z.jpg",
|
||||||
|
"path": "/path/to/uploads/front-door/2025/12/18/front-door_2025-12-18T12-00-00-000Z.jpg",
|
||||||
|
"size": 45678,
|
||||||
|
"timestamp": "2025-12-18T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Cameras
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /cameras
|
||||||
|
```
|
||||||
|
|
||||||
|
List all cameras that have uploaded images.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
- `X-API-Key`: Your API key
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: your-secret-key" http://localhost:3080/cameras
|
||||||
|
```
|
||||||
|
|
||||||
|
### Camera Statistics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /stats/:cameraId
|
||||||
|
```
|
||||||
|
|
||||||
|
Get upload statistics for a specific camera.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: your-secret-key" http://localhost:3080/stats/front-door
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cameraId": "front-door",
|
||||||
|
"totalImages": 1440,
|
||||||
|
"totalSizeBytes": 72000000,
|
||||||
|
"totalSizeMB": "68.66",
|
||||||
|
"oldestImage": "2025-12-01T00:00:00.000Z",
|
||||||
|
"newestImage": "2025-12-18T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage Structure
|
||||||
|
|
||||||
|
Images are stored in a hierarchical directory structure optimized for time-lapse processing:
|
||||||
|
|
||||||
|
```
|
||||||
|
uploads/
|
||||||
|
└── {cameraId}/
|
||||||
|
└── {YYYY}/
|
||||||
|
└── {MM}/
|
||||||
|
└── {DD}/
|
||||||
|
└── {cameraId}_{timestamp}.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
This structure allows files to be concatenated in chronological order for movie creation.
|
||||||
|
|
||||||
|
## Nginx Configuration
|
||||||
|
|
||||||
|
**Current deployment:** `https://dev.seedheads.de/picUploadApi/`
|
||||||
|
|
||||||
|
To expose the API under `/picUploadApi/` on your website, add this to your nginx configuration:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /picUploadApi/ {
|
||||||
|
# Strip the /picUploadApi prefix when proxying
|
||||||
|
rewrite ^/picUploadApi/(.*)$ /$1 break;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:3080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Important for file uploads
|
||||||
|
client_max_body_size 5M;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After adding, reload nginx:
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you can access the API at `https://yourwebsite.com/picUploadApi/upload`.
|
||||||
|
|
||||||
|
## Creating Time-Lapse Movies
|
||||||
|
|
||||||
|
Once you have collected images, you can create a time-lapse movie using ffmpeg:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to a specific day's images
|
||||||
|
cd uploads/front-door/2025/12/18
|
||||||
|
|
||||||
|
# Create a video at 30fps (each frame shows for 1/30th of a second)
|
||||||
|
ffmpeg -pattern_type glob -i '*.jpg' -c:v libx264 -pix_fmt yuv420p -r 30 timelapse.mp4
|
||||||
|
|
||||||
|
# Or create a slower video at 10fps
|
||||||
|
ffmpeg -pattern_type glob -i '*.jpg' -c:v libx264 -pix_fmt yuv420p -r 10 timelapse-slow.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
For multi-day time-lapses:
|
||||||
|
```bash
|
||||||
|
# Create a file list
|
||||||
|
find uploads/front-door/2025/12 -name '*.jpg' | sort > filelist.txt
|
||||||
|
|
||||||
|
# Convert to ffmpeg format
|
||||||
|
sed "s/^/file '/; s/$/'/" filelist.txt > ffmpeg-list.txt
|
||||||
|
|
||||||
|
# Create video from list
|
||||||
|
ffmpeg -f concat -safe 0 -i ffmpeg-list.txt -c:v libx264 -pix_fmt yuv420p -r 30 december-timelapse.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
## Webcam Upload Script Example
|
||||||
|
|
||||||
|
Here's a simple bash script to capture and upload from a webcam:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# capture-and-upload.sh
|
||||||
|
|
||||||
|
API_URL="http://localhost:3080/upload"
|
||||||
|
API_KEY="your-secret-key"
|
||||||
|
CAMERA_ID="webcam1"
|
||||||
|
TEMP_FILE="/tmp/webcam_snapshot.jpg"
|
||||||
|
|
||||||
|
# Capture from webcam (requires fswebcam)
|
||||||
|
fswebcam -r 1920x1080 --no-banner "$TEMP_FILE"
|
||||||
|
|
||||||
|
# Upload to PicUpper
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "X-API-Key: $API_KEY" \
|
||||||
|
-F "image=@$TEMP_FILE" \
|
||||||
|
-F "cameraId=$CAMERA_ID" \
|
||||||
|
"$API_URL"
|
||||||
|
|
||||||
|
rm -f "$TEMP_FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it every minute with cron:
|
||||||
|
```bash
|
||||||
|
* * * * * /path/to/capture-and-upload.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
12
ecosystem.config.cjs
Normal file
12
ecosystem.config.cjs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'picupper',
|
||||||
|
script: 'server.js',
|
||||||
|
env: {
|
||||||
|
PICUPPER_PORT: 3080
|
||||||
|
},
|
||||||
|
watch: false,
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true
|
||||||
|
}]
|
||||||
|
};
|
||||||
150
manage-users.js
Normal file
150
manage-users.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import {
|
||||||
|
addUser,
|
||||||
|
removeUser,
|
||||||
|
listUsers,
|
||||||
|
regenerateApiKey,
|
||||||
|
setUserEnabled
|
||||||
|
} from './users.js';
|
||||||
|
|
||||||
|
const command = process.argv[2];
|
||||||
|
const args = process.argv.slice(3);
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.log(`
|
||||||
|
PicUpper User Management CLI
|
||||||
|
|
||||||
|
Usage: node manage-users.js <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
add <username> [description] Add a new user and generate API key
|
||||||
|
remove <username> Remove a user
|
||||||
|
list List all users
|
||||||
|
regenerate <username> Regenerate API key for a user
|
||||||
|
enable <username> Enable a user
|
||||||
|
disable <username> Disable a user
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
node manage-users.js add webcam1 "Front door camera"
|
||||||
|
node manage-users.js add backyard "Backyard camera"
|
||||||
|
node manage-users.js list
|
||||||
|
node manage-users.js regenerate webcam1
|
||||||
|
node manage-users.js disable webcam1
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (command) {
|
||||||
|
case 'add': {
|
||||||
|
const username = args[0];
|
||||||
|
const description = args.slice(1).join(' ') || '';
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
console.error('Error: Username is required');
|
||||||
|
printUsage();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = addUser(username, description);
|
||||||
|
console.log(`\nUser '${username}' created successfully!`);
|
||||||
|
console.log(`\nAPI Key (save this, it won't be shown again):`);
|
||||||
|
console.log(`\n ${apiKey}\n`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'remove': {
|
||||||
|
const username = args[0];
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
console.error('Error: Username is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeUser(username);
|
||||||
|
console.log(`User '${username}' removed successfully.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'list': {
|
||||||
|
const users = listUsers();
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log('No users found. Use "add" command to create one.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nRegistered Users:');
|
||||||
|
console.log('─'.repeat(80));
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const status = user.enabled ? '✓ enabled' : '✗ disabled';
|
||||||
|
console.log(` ${user.username}`);
|
||||||
|
console.log(` Key: ${user.keyPreview}`);
|
||||||
|
console.log(` Status: ${status}`);
|
||||||
|
if (user.description) {
|
||||||
|
console.log(` Description: ${user.description}`);
|
||||||
|
}
|
||||||
|
console.log(` Created: ${user.createdAt}`);
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'regenerate': {
|
||||||
|
const username = args[0];
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
console.error('Error: Username is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newApiKey = regenerateApiKey(username);
|
||||||
|
console.log(`\nAPI key regenerated for '${username}'!`);
|
||||||
|
console.log(`\nNew API Key (save this, it won't be shown again):`);
|
||||||
|
console.log(`\n ${newApiKey}\n`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'enable': {
|
||||||
|
const username = args[0];
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
console.error('Error: Username is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserEnabled(username, true);
|
||||||
|
console.log(`User '${username}' enabled.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'disable': {
|
||||||
|
const username = args[0];
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
console.error('Error: Username is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserEnabled(username, false);
|
||||||
|
console.log(`User '${username}' disabled.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'help':
|
||||||
|
case '--help':
|
||||||
|
case '-h':
|
||||||
|
case undefined:
|
||||||
|
printUsage();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error(`Unknown command: ${command}`);
|
||||||
|
printUsage();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
1013
package-lock.json
generated
Normal file
1013
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "pic-upper",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Picture upload service for webcam time-lapse capture",
|
||||||
|
"type": "module",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"webcam",
|
||||||
|
"timelapse",
|
||||||
|
"upload",
|
||||||
|
"api"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
220
server.js
Normal file
220
server.js
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { validateApiKey, loadUsers } from './users.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Configuration from environment variables
|
||||||
|
const PORT = process.env.PICUPPER_PORT;
|
||||||
|
const UPLOAD_DIR = process.env.PICUPPER_UPLOAD_DIR || path.join(__dirname, 'uploads');
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB - typical webcam snapshot size
|
||||||
|
|
||||||
|
if (!PORT) {
|
||||||
|
console.error('ERROR: PICUPPER_PORT environment variable is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for users
|
||||||
|
const users = loadUsers();
|
||||||
|
if (Object.keys(users).length === 0) {
|
||||||
|
console.warn('WARNING: No users configured. Use "node manage-users.js add <username>" to create users.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure upload directory exists
|
||||||
|
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||||
|
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// API Key authentication middleware
|
||||||
|
function authenticate(req, res, next) {
|
||||||
|
const providedKey = req.headers['x-api-key'] || req.query.apiKey;
|
||||||
|
|
||||||
|
if (!providedKey) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized: Missing API key' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = validateApiKey(providedKey);
|
||||||
|
if (!username) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized: Invalid API key' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach username to request for logging/tracking
|
||||||
|
req.authenticatedUser = username;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure multer storage
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
const cameraId = req.body.cameraId || req.query.cameraId || 'default';
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Create directory structure: uploads/{cameraId}/{YYYY}/{MM}/{DD}/
|
||||||
|
const year = now.getFullYear().toString();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
const destPath = path.join(UPLOAD_DIR, cameraId, year, month, day);
|
||||||
|
|
||||||
|
fs.mkdirSync(destPath, { recursive: true });
|
||||||
|
cb(null, destPath);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const cameraId = req.body.cameraId || req.query.cameraId || 'default';
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Filename format: {cameraId}_{timestamp}.{ext}
|
||||||
|
const timestamp = now.toISOString().replace(/[:.]/g, '-');
|
||||||
|
const ext = path.extname(file.originalname) || '.jpg';
|
||||||
|
|
||||||
|
cb(null, `${cameraId}_${timestamp}${ext}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// File filter - only accept images
|
||||||
|
function fileFilter(req, file, cb) {
|
||||||
|
const allowedMimes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||||
|
|
||||||
|
if (allowedMimes.includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error(`Invalid file type: ${file.mimetype}. Allowed: ${allowedMimes.join(', ')}`), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter,
|
||||||
|
limits: {
|
||||||
|
fileSize: MAX_FILE_SIZE
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check endpoint (no auth required)
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload endpoint
|
||||||
|
app.post('/upload', authenticate, upload.single('image'), (req, res) => {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No image file provided. Use form field "image".' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cameraId = req.body.cameraId || req.query.cameraId || 'default';
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
cameraId,
|
||||||
|
filename: req.file.filename,
|
||||||
|
path: req.file.path,
|
||||||
|
size: req.file.size,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// List cameras (directories in upload folder)
|
||||||
|
app.get('/cameras', authenticate, (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||||
|
return res.json({ cameras: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cameras = fs.readdirSync(UPLOAD_DIR, { withFileTypes: true })
|
||||||
|
.filter(dirent => dirent.isDirectory())
|
||||||
|
.map(dirent => dirent.name);
|
||||||
|
|
||||||
|
res.json({ cameras });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to list cameras', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get statistics for a camera
|
||||||
|
app.get('/stats/:cameraId', authenticate, (req, res) => {
|
||||||
|
const { cameraId } = req.params;
|
||||||
|
const cameraPath = path.join(UPLOAD_DIR, cameraId);
|
||||||
|
|
||||||
|
if (!fs.existsSync(cameraPath)) {
|
||||||
|
return res.status(404).json({ error: `Camera '${cameraId}' not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let totalImages = 0;
|
||||||
|
let totalSize = 0;
|
||||||
|
let oldestImage = null;
|
||||||
|
let newestImage = null;
|
||||||
|
|
||||||
|
// Recursively count files
|
||||||
|
function countFiles(dir) {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
countFiles(fullPath);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
totalImages++;
|
||||||
|
const stats = fs.statSync(fullPath);
|
||||||
|
totalSize += stats.size;
|
||||||
|
|
||||||
|
if (!oldestImage || stats.mtime < oldestImage) {
|
||||||
|
oldestImage = stats.mtime;
|
||||||
|
}
|
||||||
|
if (!newestImage || stats.mtime > newestImage) {
|
||||||
|
newestImage = stats.mtime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
countFiles(cameraPath);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
cameraId,
|
||||||
|
totalImages,
|
||||||
|
totalSizeBytes: totalSize,
|
||||||
|
totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2),
|
||||||
|
oldestImage: oldestImage ? oldestImage.toISOString() : null,
|
||||||
|
newestImage: newestImage ? newestImage.toISOString() : null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to get camera stats', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use((error, req, res, next) => {
|
||||||
|
if (error instanceof multer.MulterError) {
|
||||||
|
if (error.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
return res.status(413).json({
|
||||||
|
error: `File too large. Maximum size: ${MAX_FILE_SIZE / (1024 * 1024)}MB`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`PicUpper service running on port ${PORT}`);
|
||||||
|
console.log(`Upload directory: ${UPLOAD_DIR}`);
|
||||||
|
console.log(`Max file size: ${MAX_FILE_SIZE / (1024 * 1024)}MB`);
|
||||||
|
});
|
||||||
139
users.js
Normal file
139
users.js
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const USERS_FILE = process.env.PICUPPER_USERS_FILE || path.join(__dirname, 'users.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load users from JSON file
|
||||||
|
*/
|
||||||
|
export function loadUsers() {
|
||||||
|
if (!fs.existsSync(USERS_FILE)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(USERS_FILE, 'utf-8');
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading users file:', error.message);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save users to JSON file
|
||||||
|
*/
|
||||||
|
export function saveUsers(users) {
|
||||||
|
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a secure API key
|
||||||
|
*/
|
||||||
|
export function generateApiKey() {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new user with generated API key
|
||||||
|
*/
|
||||||
|
export function addUser(username, description = '') {
|
||||||
|
const users = loadUsers();
|
||||||
|
|
||||||
|
if (users[username]) {
|
||||||
|
throw new Error(`User '${username}' already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = generateApiKey();
|
||||||
|
|
||||||
|
users[username] = {
|
||||||
|
apiKey,
|
||||||
|
description,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
enabled: true
|
||||||
|
};
|
||||||
|
|
||||||
|
saveUsers(users);
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a user
|
||||||
|
*/
|
||||||
|
export function removeUser(username) {
|
||||||
|
const users = loadUsers();
|
||||||
|
|
||||||
|
if (!users[username]) {
|
||||||
|
throw new Error(`User '${username}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete users[username];
|
||||||
|
saveUsers(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate API key for a user
|
||||||
|
*/
|
||||||
|
export function regenerateApiKey(username) {
|
||||||
|
const users = loadUsers();
|
||||||
|
|
||||||
|
if (!users[username]) {
|
||||||
|
throw new Error(`User '${username}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newApiKey = generateApiKey();
|
||||||
|
users[username].apiKey = newApiKey;
|
||||||
|
users[username].keyRegeneratedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
saveUsers(users);
|
||||||
|
return newApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable a user
|
||||||
|
*/
|
||||||
|
export function setUserEnabled(username, enabled) {
|
||||||
|
const users = loadUsers();
|
||||||
|
|
||||||
|
if (!users[username]) {
|
||||||
|
throw new Error(`User '${username}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
users[username].enabled = enabled;
|
||||||
|
saveUsers(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all users (without exposing full API keys)
|
||||||
|
*/
|
||||||
|
export function listUsers() {
|
||||||
|
const users = loadUsers();
|
||||||
|
|
||||||
|
return Object.entries(users).map(([username, data]) => ({
|
||||||
|
username,
|
||||||
|
description: data.description,
|
||||||
|
createdAt: data.createdAt,
|
||||||
|
enabled: data.enabled,
|
||||||
|
keyPreview: data.apiKey.substring(0, 8) + '...'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an API key and return the username if valid
|
||||||
|
*/
|
||||||
|
export function validateApiKey(apiKey) {
|
||||||
|
const users = loadUsers();
|
||||||
|
|
||||||
|
for (const [username, data] of Object.entries(users)) {
|
||||||
|
if (data.apiKey === apiKey && data.enabled) {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user