fix: secure admin API endpoints with Bearer token auth

- main-site/server.js: add requireAuth middleware to POST /api/update-status
- gallery-backend/routes/photos.js: add requireAuth to upload, delete, and update routes
- admin/admin.js: send Authorization: Bearer header on all mutating requests (fetch + XHR upload); handle 401 on update-status and photo save
- docker-compose.yml: pass ADMIN_PASSWORD to gallery-backend; remove MongoDB public port mapping (27017:27017)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-05-08 08:30:58 -04:00
parent c40db43c04
commit 3330c47af2
4 changed files with 48 additions and 12 deletions

View File

@ -37,6 +37,7 @@ services:
environment:
MONGO_URI: mongodb://mongodb:27017/photogallery
WATERMARK_URL: http://watermarker:8000/watermark
ADMIN_PASSWORD: ${MAIN_ADMIN_PASSWORD}
volumes:
- ./main-site/photo-gallery-app/backend/uploads:/usr/src/app/uploads
depends_on:
@ -54,8 +55,6 @@ services:
mongodb:
image: mongo:latest
container_name: bpb-mongodb
ports:
- "27017:27017"
volumes:
- ./mongodb_data:/data/db
restart: always

View File

@ -344,13 +344,15 @@ document.addEventListener('DOMContentLoaded', () => {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getAdminPassword()}`,
},
body: JSON.stringify(updatedPhoto)
});
if (response.status === 401) { handleUnauthorized(); return; }
if (response.ok) {
closeEditModal();
fetchPhotos(); // Refresh the gallery
fetchPhotos();
fetchTagMeta();
} else {
alert('Failed to save changes.');
@ -364,7 +366,10 @@ document.addEventListener('DOMContentLoaded', () => {
async function deletePhoto(id) {
if (confirm('Are you sure you want to delete this photo?')) {
try {
await fetch(`${backendUrl}/photos/${id}`, { method: 'DELETE' });
await fetch(`${backendUrl}/photos/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${getAdminPassword()}` }
});
fetchPhotos();
} catch (error) {
console.error('Error deleting photo:', error);
@ -392,7 +397,10 @@ document.addEventListener('DOMContentLoaded', () => {
confirmBulkDeleteBtn.classList.add('is-loading');
try {
await Promise.all(ids.map(id => fetch(`${backendUrl}/photos/${id}`, { method: 'DELETE' })));
await Promise.all(ids.map(id => fetch(`${backendUrl}/photos/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${getAdminPassword()}` }
})));
clearSelection();
fetchPhotos();
closeBulkDeleteModal();
@ -449,7 +457,10 @@ document.addEventListener('DOMContentLoaded', () => {
};
await fetch(`${backendUrl}/photos/update/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getAdminPassword()}`,
},
body: JSON.stringify(payload)
});
}));
@ -613,6 +624,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
xhr.open('POST', `${backendUrl}/photos/upload`);
xhr.setRequestHeader('Authorization', `Bearer ${getAdminPassword()}`);
uploadButton.classList.add('is-loading');
uploadProgress.style.display = 'block';
@ -758,11 +770,17 @@ document.addEventListener('DOMContentLoaded', () => {
const response = await fetch('/api/update-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'Authorization': `Bearer ${getAdminPassword()}`,
},
body: JSON.stringify({ data })
});
if (response.status === 401) {
handleUnauthorized();
return;
}
const result = await response.json();
if (result.success) {

View File

@ -21,6 +21,16 @@ const {
} = require('../lib/tagConfig');
const WATERMARK_URL = process.env.WATERMARK_URL || 'http://watermarker:8000/watermark';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
function requireAuth(req, res, next) {
const auth = req.headers['authorization'] || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (!token || !ADMIN_PASSWORD || token !== ADMIN_PASSWORD) {
return res.status(401).json({ success: false, error: 'Unauthorized' });
}
next();
}
// We now use a visible diagonal watermark only. Invisible watermarking is disabled by default.
const DISABLE_WM = true;
@ -110,7 +120,7 @@ const parseIncomingTags = (tagsInput) => {
};
// POST new photo(s) with WebP conversion + duplicate hash checks
router.route('/upload').post(upload.array('photos'), async (req, res) => {
router.route('/upload').post(requireAuth, upload.array('photos'), async (req, res) => {
const files = (req.files && req.files.length) ? req.files : (req.file ? [req.file] : []);
if (!files.length) {
return res.status(400).json({ success: false, error: 'No file uploaded. Please select at least one file.' });
@ -343,7 +353,7 @@ router.route('/:id').get((req, res) => {
});
// DELETE a photo by ID
router.route('/:id').delete((req, res) => {
router.route('/:id').delete(requireAuth, (req, res) => {
console.log('DELETE request received for photo ID:', req.params.id);
console.log('Request headers:', req.headers);
console.log('Request IP:', req.ip);
@ -369,7 +379,7 @@ router.route('/:id').delete((req, res) => {
});
// UPDATE a photo by ID
router.route('/update/:id').post((req, res) => {
router.route('/update/:id').post(requireAuth, (req, res) => {
Photo.findById(req.params.id)
.then(photo => {
const incomingCaption = req.body.caption;

View File

@ -35,7 +35,16 @@ app.use(bodyParser.json());
// --- API Routes ---
const apiRouter = express.Router();
apiRouter.post('/update-status', (req, res) => {
function requireAuth(req, res, next) {
const auth = req.headers['authorization'] || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (!token || token !== ADMIN_PASSWORD) {
return res.status(401).json({ success: false, message: 'Unauthorized' });
}
next();
}
apiRouter.post('/update-status', requireAuth, (req, res) => {
console.log(`[${new Date().toISOString()}] Received request for /api/update-status`);
const { data } = req.body;