Bilder-Download: Mehrfach-Auswahl + Ordner-ZIP
- Selection-Mode: Bilder in Galerie per Klick auswaehlen - 1-3 Bilder: einzeln herunterladen, 4+: als ZIP - Ordner-Download: ganzen zugewiesenen Ordner als ZIP - Sicherheit: Zugriffspruefung + Path-Traversal-Schutz - archiver Package fuer Streaming-ZIP-Erstellung Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2
assets/css/custom.min.css
vendored
2
assets/css/custom.min.css
vendored
@@ -1 +1 @@
|
|||||||
.text-fg{color:#a0a0a1}#footer .presentify{margin:0 0 1.4em 0;display:grid;grid-template-columns:auto 1fr;column-gap:1rem;align-items:center}#footer .presentify .presentify-logo{display:flex;align-items:center;justify-content:center}#footer .presentify .presentify-logo a{display:flex;align-items:center;justify-content:center;border:0}#footer .presentify .presentify-logo img{width:72px;height:72px;display:block}#footer .presentify .presentify-text{margin:0}
|
.text-fg{color:#a0a0a1}#selection-toolbar{position:fixed;bottom:4em;left:0;width:100%;background:rgba(31,34,36,.95);padding:.75em 1.5em;display:flex;align-items:center;gap:1em;z-index:10003;transform:translateY(100%);transition:transform .3s ease;box-sizing:border-box}#selection-toolbar.visible{transform:translateY(0)}#selection-toolbar #sel-count{color:#fff;font-weight:400;margin-right:auto;white-space:nowrap}#selection-toolbar button{white-space:nowrap}body.selection-mode #main .thumb{cursor:pointer;position:relative}body.selection-mode #main .thumb::before{content:"";position:absolute;top:.6em;left:.6em;width:1.4em;height:1.4em;border:2px solid rgba(255,255,255,.7);border-radius:3px;background:rgba(0,0,0,.4);z-index:2;transition:all .2s ease;pointer-events:none}body.selection-mode #main .thumb.selected::before{background:#34a58e;border-color:#34a58e}body.selection-mode #main .thumb.selected .image{outline:3px solid #34a58e;outline-offset:-3px}@media screen and (max-width: 736px){#selection-toolbar{bottom:auto;top:4em;transform:translateY(-200%)}#selection-toolbar.visible{transform:translateY(0)}}#footer .presentify{margin:0 0 1.4em 0;display:grid;grid-template-columns:auto 1fr;column-gap:1rem;align-items:center}#footer .presentify .presentify-logo{display:flex;align-items:center;justify-content:center}#footer .presentify .presentify-logo a{display:flex;align-items:center;justify-content:center;border:0}#footer .presentify .presentify-logo img{width:72px;height:72px;display:block}#footer .presentify .presentify-text{margin:0}
|
||||||
2
assets/css/light.min.css
vendored
2
assets/css/light.min.css
vendored
File diff suppressed because one or more lines are too long
2
assets/css/main.min.css
vendored
2
assets/css/main.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -194,6 +194,7 @@
|
|||||||
|
|
||||||
initThumbs();
|
initThumbs();
|
||||||
initPoptrox();
|
initPoptrox();
|
||||||
|
initSelectionMode(images);
|
||||||
},
|
},
|
||||||
error: function() {
|
error: function() {
|
||||||
$main.html('<p style="text-align:center;color:#fff;padding:2em;">Keine Bilder gefunden. Lade Bilder in deinen Nextcloud-Ordner hoch.</p>');
|
$main.html('<p style="text-align:center;color:#fff;padding:2em;">Keine Bilder gefunden. Lade Bilder in deinen Nextcloud-Ordner hoch.</p>');
|
||||||
@@ -290,6 +291,177 @@
|
|||||||
return template;
|
return template;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== SELECTION MODE ====================
|
||||||
|
|
||||||
|
function initSelectionMode(allImages) {
|
||||||
|
var selectedImages = [];
|
||||||
|
var selectionActive = false;
|
||||||
|
|
||||||
|
// Toolbar erstellen (versteckt)
|
||||||
|
var $toolbar = $('<div id="selection-toolbar"></div>');
|
||||||
|
$toolbar.html(
|
||||||
|
'<span id="sel-count">0 ausgewaehlt</span>' +
|
||||||
|
'<button id="sel-download" class="button small" disabled>' +
|
||||||
|
'<i class="fa fa-download"></i> Herunterladen</button>' +
|
||||||
|
'<button id="sel-cancel" class="button small">' +
|
||||||
|
'<i class="fa fa-times"></i> Abbrechen</button>'
|
||||||
|
);
|
||||||
|
$('body').append($toolbar);
|
||||||
|
|
||||||
|
// Selection-Toggle Button im Header
|
||||||
|
var $selectToggle = $('<li><a href="#" id="select-mode-toggle" title="Bilder auswaehlen">' +
|
||||||
|
'<i class="fa fa-check-square"></i></a></li>');
|
||||||
|
$('#header nav > ul').prepend($selectToggle);
|
||||||
|
|
||||||
|
// Ordner-Download Buttons (nur wenn Ordner-Bilder vorhanden)
|
||||||
|
var folders = [];
|
||||||
|
allImages.forEach(function(img) {
|
||||||
|
if (img.folder && folders.indexOf(img.folder) === -1) {
|
||||||
|
folders.push(img.folder);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (folders.length > 0) {
|
||||||
|
folders.forEach(function(folder) {
|
||||||
|
var $folderBtn = $('<li><a href="#" class="folder-download-btn" ' +
|
||||||
|
'data-folder="' + folder + '" title="Ordner \'' + folder + '\' herunterladen">' +
|
||||||
|
'<i class="fa fa-folder-open"></i></a></li>');
|
||||||
|
$('#header nav > ul').prepend($folderBtn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection-Mode toggle
|
||||||
|
$(document).on('click', '#select-mode-toggle', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
selectionActive = !selectionActive;
|
||||||
|
$('body').toggleClass('selection-mode', selectionActive);
|
||||||
|
if (!selectionActive) clearSelection();
|
||||||
|
$toolbar.toggleClass('visible', selectionActive);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Klick auf Thumbnail im Selection-Mode
|
||||||
|
$(document).on('click', '.thumb', function(e) {
|
||||||
|
if (!selectionActive) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
var $thumb = $(this);
|
||||||
|
var $img = $thumb.find('img');
|
||||||
|
var imgName = $img.data('name');
|
||||||
|
var imgHref = $thumb.find('a.image').attr('href');
|
||||||
|
|
||||||
|
// Ordner aus href extrahieren
|
||||||
|
var folder = null;
|
||||||
|
var hrefPath = imgHref.replace('/images/fulls/', '');
|
||||||
|
var parts = hrefPath.split('/');
|
||||||
|
if (parts.length > 1) {
|
||||||
|
folder = decodeURIComponent(parts[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var idx = -1;
|
||||||
|
for (var i = 0; i < selectedImages.length; i++) {
|
||||||
|
if (selectedImages[i].name === imgName && selectedImages[i].folder === folder) {
|
||||||
|
idx = i; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx !== -1) {
|
||||||
|
selectedImages.splice(idx, 1);
|
||||||
|
$thumb.removeClass('selected');
|
||||||
|
} else {
|
||||||
|
selectedImages.push({ name: imgName, folder: folder });
|
||||||
|
$thumb.addClass('selected');
|
||||||
|
}
|
||||||
|
updateToolbar();
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateToolbar() {
|
||||||
|
var count = selectedImages.length;
|
||||||
|
$('#sel-count').text(count + ' ausgewaehlt');
|
||||||
|
$('#sel-download').prop('disabled', count === 0);
|
||||||
|
if (count > 3) {
|
||||||
|
$('#sel-download').html('<i class="fa fa-file-archive-o"></i> Als ZIP (' + count + ')');
|
||||||
|
} else if (count > 0) {
|
||||||
|
$('#sel-download').html('<i class="fa fa-download"></i> Herunterladen (' + count + ')');
|
||||||
|
} else {
|
||||||
|
$('#sel-download').html('<i class="fa fa-download"></i> Herunterladen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedImages = [];
|
||||||
|
$('.thumb').removeClass('selected');
|
||||||
|
updateToolbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download-Button
|
||||||
|
$(document).on('click', '#sel-download', function() {
|
||||||
|
if (selectedImages.length === 0) return;
|
||||||
|
$.ajax({
|
||||||
|
url: '/api/download/selected',
|
||||||
|
type: 'POST',
|
||||||
|
contentType: 'application/json',
|
||||||
|
data: JSON.stringify({ images: selectedImages }),
|
||||||
|
xhrFields: { responseType: 'blob' },
|
||||||
|
success: function(data, status, xhr) {
|
||||||
|
var ct = xhr.getResponseHeader('Content-Type');
|
||||||
|
if (ct && ct.indexOf('application/json') !== -1) {
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function() {
|
||||||
|
var resp = JSON.parse(this.result);
|
||||||
|
if (resp.mode === 'individual') {
|
||||||
|
resp.urls.forEach(function(url, i) {
|
||||||
|
setTimeout(function() {
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = '';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}, i * 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(data);
|
||||||
|
} else {
|
||||||
|
var url = window.URL.createObjectURL(data);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'mischkomposition-auswahl.zip';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
alert(xhr.responseJSON ? xhr.responseJSON.error : 'Download fehlgeschlagen');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Abbrechen-Button
|
||||||
|
$(document).on('click', '#sel-cancel', function() {
|
||||||
|
selectionActive = false;
|
||||||
|
$('body').removeClass('selection-mode');
|
||||||
|
$toolbar.removeClass('visible');
|
||||||
|
clearSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ordner-Download
|
||||||
|
$(document).on('click', '.folder-download-btn', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
var folder = $(this).data('folder');
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = '/api/download/folder/' + encodeURIComponent(folder);
|
||||||
|
a.download = folder + '.zip';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Galerie laden
|
// Galerie laden
|
||||||
loadGallery();
|
loadGallery();
|
||||||
|
|
||||||
|
|||||||
2
assets/js/main.min.js
vendored
2
assets/js/main.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -6,6 +6,85 @@
|
|||||||
color: _palette(fg);
|
color: _palette(fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== Selection Mode ==================== */
|
||||||
|
|
||||||
|
#selection-toolbar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: _size(header);
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(31, 34, 36, 0.95);
|
||||||
|
padding: 0.75em 1.5em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1em;
|
||||||
|
z-index: _misc(z-index-base) + 3;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#sel-count {
|
||||||
|
color: _palette(fg-bold);
|
||||||
|
font-weight: _font(weight-extrabold);
|
||||||
|
margin-right: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.selection-mode {
|
||||||
|
#main .thumb {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6em;
|
||||||
|
left: 0.6em;
|
||||||
|
width: 1.4em;
|
||||||
|
height: 1.4em;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.7);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 2;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
&::before {
|
||||||
|
background: _palette(accent1);
|
||||||
|
border-color: _palette(accent1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
outline: 3px solid _palette(accent1);
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 736px) {
|
||||||
|
#selection-toolbar {
|
||||||
|
bottom: auto;
|
||||||
|
top: _size(header);
|
||||||
|
transform: translateY(-200%);
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
#footer {
|
#footer {
|
||||||
.presentify {
|
.presentify {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const jwt = require('jsonwebtoken');
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const archiver = require('archiver');
|
||||||
const db = require('./database');
|
const db = require('./database');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -255,6 +256,121 @@ app.post('/api/admin/assign', requireAdmin, (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== DOWNLOAD API ====================
|
||||||
|
|
||||||
|
function isValidName(name) {
|
||||||
|
return name && !name.includes('/') && !name.includes('\\') && !name.includes('..');
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/api/download/selected', (req, res) => {
|
||||||
|
const { images } = req.body;
|
||||||
|
if (!Array.isArray(images) || images.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Keine Bilder ausgewaehlt' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Bilder validieren und Zugriff pruefen
|
||||||
|
let pending = images.length;
|
||||||
|
const verified = [];
|
||||||
|
let responded = false;
|
||||||
|
|
||||||
|
images.forEach((img, idx) => {
|
||||||
|
if (responded) return;
|
||||||
|
const name = img.name;
|
||||||
|
const folder = img.folder || null;
|
||||||
|
|
||||||
|
// Dateiname + Ordnername validieren
|
||||||
|
if (!isValidName(name) || !IMAGE_RE.test(name) || (folder && !isValidName(folder))) {
|
||||||
|
responded = true;
|
||||||
|
return res.status(400).json({ error: 'Ungueltiger Dateiname' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAccess = (ok) => {
|
||||||
|
if (responded) return;
|
||||||
|
if (!ok) {
|
||||||
|
responded = true;
|
||||||
|
return res.status(403).json({ error: 'Kein Zugriff auf: ' + name });
|
||||||
|
}
|
||||||
|
verified[idx] = img;
|
||||||
|
pending--;
|
||||||
|
if (pending === 0) sendFiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
// Root-Bild: fuer alle zugaenglich
|
||||||
|
const filePath = path.join(FULLS_DIR, name);
|
||||||
|
checkAccess(fs.existsSync(filePath));
|
||||||
|
} else {
|
||||||
|
// Ordner-Bild: nur wenn User den Ordner zugewiesen hat
|
||||||
|
if (!req.user) return checkAccess(false);
|
||||||
|
db.get('SELECT 1 FROM user_folders WHERE user_id = ? AND folder_name = ?',
|
||||||
|
[req.user.id, folder], (err, row) => {
|
||||||
|
if (err || !row) return checkAccess(false);
|
||||||
|
const filePath = path.join(FULLS_DIR, folder, name);
|
||||||
|
checkAccess(fs.existsSync(filePath));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendFiles() {
|
||||||
|
if (verified.length <= 3) {
|
||||||
|
// Einzelne Downloads: URLs zurueckgeben
|
||||||
|
const urls = verified.map(img => {
|
||||||
|
const prefix = img.folder ? encodeURIComponent(img.folder) + '/' : '';
|
||||||
|
return '/images/fulls/' + prefix + encodeURIComponent(img.name);
|
||||||
|
});
|
||||||
|
return res.json({ mode: 'individual', urls });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4+ Bilder: ZIP streamen
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="mischkomposition-auswahl.zip"');
|
||||||
|
const archive = archiver('zip', { zlib: { level: 5 } });
|
||||||
|
archive.on('error', () => {
|
||||||
|
if (!res.headersSent) res.status(500).json({ error: 'ZIP-Fehler' });
|
||||||
|
});
|
||||||
|
archive.pipe(res);
|
||||||
|
verified.forEach(img => {
|
||||||
|
const filePath = img.folder
|
||||||
|
? path.join(FULLS_DIR, img.folder, img.name)
|
||||||
|
: path.join(FULLS_DIR, img.name);
|
||||||
|
archive.file(filePath, { name: img.name });
|
||||||
|
});
|
||||||
|
archive.finalize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/download/folder/:folderName', (req, res) => {
|
||||||
|
const folderName = req.params.folderName;
|
||||||
|
if (!isValidName(folderName)) {
|
||||||
|
return res.status(400).json({ error: 'Ungueltiger Ordnername' });
|
||||||
|
}
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ error: 'Nicht eingeloggt' });
|
||||||
|
}
|
||||||
|
db.get('SELECT 1 FROM user_folders WHERE user_id = ? AND folder_name = ?',
|
||||||
|
[req.user.id, folderName], (err, row) => {
|
||||||
|
if (err || !row) {
|
||||||
|
return res.status(403).json({ error: 'Kein Zugriff auf diesen Ordner' });
|
||||||
|
}
|
||||||
|
const folderPath = path.join(FULLS_DIR, folderName);
|
||||||
|
const images = listImagesInDir(folderPath);
|
||||||
|
if (images.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Keine Bilder im Ordner' });
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="' + encodeURIComponent(folderName) + '.zip"');
|
||||||
|
const archive = archiver('zip', { zlib: { level: 5 } });
|
||||||
|
archive.on('error', () => {
|
||||||
|
if (!res.headersSent) res.status(500).json({ error: 'ZIP-Fehler' });
|
||||||
|
});
|
||||||
|
archive.pipe(res);
|
||||||
|
images.forEach(name => {
|
||||||
|
archive.file(path.join(folderPath, name), { name });
|
||||||
|
});
|
||||||
|
archive.finalize();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== STATIC FILES ====================
|
// ==================== STATIC FILES ====================
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, '../public')));
|
app.use(express.static(path.join(__dirname, '../public')));
|
||||||
|
|||||||
1190
package-lock.json
generated
1190
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/Kroonk/Photography#readme",
|
"homepage": "https://github.com/Kroonk/Photography#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user