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:
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
|
||||
};
|
||||
Reference in New Issue
Block a user