// ============================ // 1. CORE SETUP // ============================ import express from "express"; import cors from "cors"; import path from "path"; import { fileURLToPath } from "url"; import { Storage } from "@google-cloud/storage"; import Joi from "joi"; import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); app.use(express.static("public")); // --- ENV PROVERA --- if (!process.env.JWT_SECRET) { throw new Error("KRITIČNA GREŠKA: JWT_SECRET nije postavljen u Environment Variables!"); } // --- KONSTANTE --- const BUCKET_NAME = process.env.BUCKET_NAME || "digitalna-galerija-uploads"; const JWT_SECRET = process.env.JWT_SECRET; const GLOBAL_LIMIT_FILE = "limits/free.json"; const storage = new Storage(); const bucket = storage.bucket(BUCKET_NAME); // --- PODACI FIRME (ZA PLAĆANJE) --- const FIRMA = { naziv: "Digital Creative World", racun: "265404031000090885" }; // =================================== // --- AŽURIRANA DEFINICIJA PAKETA --- // =================================== const MOJI_PAKETI = { 'PROBNI': { cena: 0, limit: 50, dana: 7 }, 'OSNOVNI': { cena: 5900, limit: 1000, dana: 30 }, 'STANDARD': { cena: 8900, limit: 3000, dana: 60 }, 'PREMIUM': { cena: 15220, limit: 'unlimited', dana: 365 } }; // ========================= // --- GENERISANJE IPS QR --- // ========================= function generatePackageQR(tipPaketa, eventName, orderId) { const izabran = MOJI_PAKETI[tipPaketa]; if (!izabran || izabran.cena === 0) return null; const qrData = { K: "PR", V: "01", C: "1", R: FIRMA.racun, N: FIRMA.naziv, I: `RSD${parseFloat(izabran.cena).toFixed(2).replace('.', ',')}`, P: "Uplatilac", SF: "289", S: `Aktivacija: ${eventName}`.substring(0, 35), // kraća forma RO: orderId.replace(/\D/g, "") }; return Object.entries(qrData).map(([k, v]) => `${k}:${v}`).join('|'); } // ========================= // --- HANDLE PACKAGE SELECTION --- // ========================= function handlePackageSelection(packageType, eventName, orderId) { const selected = MOJI_PAKETI[packageType]; if (selected.cena === 0) { return { status: "active", message: `Besplatan paket aktiviran (Limit: ${selected.limit} fajlova)` }; } else { const qrString = generatePackageQR(packageType, eventName, orderId); return { status: "pending_payment", qrCode: qrString, message: "Skenirajte kod za aktivaciju paketa" }; } } // ========================= // --- UPLOAD CHECK / PAYWALL --- // ========================= function canUpload(currentFileCount, packageType) { // Normalizujemo unos na velika slova radi sigurnosti const pType = (packageType || 'PROBNI').toUpperCase(); const paket = MOJI_PAKETI[pType]; if (!paket) { return { allowed: false, nextPackage: 'OSNOVNI', error: 'Nepoznat paket' }; } // Pretvaramo limit u Infinity ako je 'unlimited', inače u broj const limit = paket.limit === 'unlimited' ? Infinity : Number(paket.limit); const count = Number(currentFileCount || 0); if (count >= limit) { const nextPackageMap = { 'PROBNI': 'OSNOVNI', 'OSNOVNI': 'STANDARD', 'STANDARD': 'PREMIUM' }; return { allowed: false, nextPackage: nextPackageMap[pType] || 'PREMIUM', limitReached: limit }; } return { allowed: true }; } // ============================ // 2. HELPER FUNKCIJE // ============================ function userPath(email) { // Menja sve što nije slovo ili broj u donju crtu const safeEmail = (email || "").toLowerCase().replace(/[^a-z0-9]/g, "_"); return `users/${safeEmail}.json`; } async function hashPassword(pw) { return await bcrypt.hash(pw, 10); } async function checkPassword(plain, hash) { return await bcrypt.compare(plain, hash); } function makeToken(payload) { return jwt.sign(payload, JWT_SECRET, { expiresIn: "24h" }); } function verifyToken(token) { return jwt.verify(token, JWT_SECRET); } const makeEventId = (name, date) => { const n = (name || "event").toLowerCase().replace(/[^a-z0-9]/g, "").substring(0, 10); const d = (date || "now").replace(/[^0-9]/g, ""); return `ev_${n}_${d}_${Math.random().toString(36).substring(2, 7)}`; }; function getPrefix(userEmail) { if (!userEmail) throw new Error("userEmail nije definisan"); return `events/${userEmail}/`; } function getSecurePrefix(req) { const eventId = String(req.query.eventId || req.query.event || req.body?.eventId || "").trim(); if (!req.user || !req.user.email) return "uploads/"; const safeEmail = req.user.email.toLowerCase().replace(/[^a-z0-9]/g, "_"); return eventId ? `events/${safeEmail}/${eventId}/uploads/` : "uploads/"; } function getScopePrefix(req) { return getSecurePrefix(req); } function inScope(name, prefix) { name = String(name || ""); return name && name.startsWith(prefix); } app.use(cors({ origin: true, credentials: false })); app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ extended: true })); // ============================ // 4. JOI ŠEME // ============================ const createEventSchema = Joi.object({ name: Joi.string().trim().min(1).required(), org: Joi.string().trim().min(1).required(), date: Joi.string().trim().allow("").optional(), type: Joi.string().trim().allow("").optional(), msg: Joi.string().trim().allow("").optional(), plan: Joi.string().trim().default("free").optional() }); const authMiddleware = (req, res, next) => { try { const authHeader = req.headers.authorization; let token = (authHeader && authHeader.startsWith("Bearer ")) ? authHeader.split(" ")[1] : req.query.token; if (!token) { return res.status(401).json({ error: "missing_token" }); } // Koristi tvoju helper funkciju verifyToken req.user = verifyToken(token); next(); } catch (err) { console.error("AUTH ERROR:", err.message); return res.status(401).json({ error: "invalid_token" }); } }; const uploadMiddleware = async (req, res, next) => { try { const authHeader = req.headers.authorization; const token = authHeader?.startsWith("Bearer ") ? authHeader.split(" ")[1] : req.query.token; if (token) { req.user = verifyToken(token); req.user.eventId = String(req.query.eventId || req.query.event || req.body?.eventId || "").trim(); return next(); } const eventId = String(req.query.eventId || req.query.event || req.body?.eventId || "").trim(); if (!eventId) return res.status(401).json({ error: "missing_auth" }); if (!/^[a-zA-Z0-9_-]+$/.test(eventId)) return res.status(400).json({ error: "invalid_eventId" }); const eventPath = `events/${eventId}/event.json`; let ev; try { const [buf] = await bucket.file(eventPath).download(); ev = JSON.parse(buf.toString("utf8")); } catch { return res.status(403).json({ error: "invalid_event" }); } const createdAt = ev.createdAt || 0; const planKey = (ev.plan || "PROBNI").toUpperCase(); const danaPaketa = ev.danaPaketa || (MOJI_PAKETI[planKey]?.dana) || 30; const expiresAt = createdAt + (danaPaketa * 24 * 60 * 60 * 1000); if (Date.now() > expiresAt) return res.status(403).json({ error: "event_expired", message: "Galerija je istekla." }); req.user = { email: "guest", isGuest: true, eventId }; req.eventPlan = planKey; next(); } catch (err) { console.error("UPLOAD MIDDLEWARE ERROR:", err); return res.status(401).json({ error: "invalid_auth" }); } }; // ============================ // PROVERA STATUSA UPLATE // ============================ app.get('/landing/check-payment', async (req, res) => { try { const { eventId } = req.query; if (!eventId) return res.status(400).json({ error: 'Missing eventId' }); const eventPath = `events/${eventId}/event.json`; const file = bucket.file(eventPath); const [exists] = await file.exists(); if (!exists) return res.status(404).json({ error: 'Event not found' }); const [content] = await file.download(); const eventData = JSON.parse(content.toString()); // Vraćamo paid status iz event.json (isPaid: true/false) res.json({ paid: !!eventData.isPaid, status: eventData.isPaid ? 'active' : 'pending' }); } catch (err) { console.error("PAYMENT CHECK ERROR:", err); res.status(500).json({ error: "Server error" }); } }); app.post('/upload', authMiddleware, async (req, res) => { const { eventId, packageType } = req.body; const prefix = `events/${req.user.email}/${eventId}/uploads/`; const [files] = await bucket.getFiles({ prefix }); const realCount = files.length; const { allowed, nextPackage, limitReached } = canUpload(realCount, packageType); if (!allowed) { return res.status(403).json({ error: 'Dostignut limit fajlova', current: realCount, limit: limitReached, upgradeTo: nextPackage }); } res.json({ success: true }); }); /* ========================= ADMIN LOGIKA (BACKEND) ========================= */ const ADMIN_SECRET = (process.env.ADMIN_SECRET || "default_tajna").toString().trim(); function isAdmin(req) { if (!ADMIN_SECRET) return false; const key = (req.query.key || "").toString(); return key === ADMIN_SECRET; } const MAX_FILES = 10; const MAX_DELETE = 10; // Escape za klijentski JS function escapeJs(s) { if (!s) return ""; return String(s) .replace(/\\/g, "\\\\") .replace(/'/g, "\\'") .replace(/"/g, '\\"') .replace(/\n/g, "\\n") .replace(/\r/g, "\\r"); } // Escape za HTML (XSS zaštita) function escHtmlBackend(s) { if (!s) return ""; return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } app.get("/api/user/events", authMiddleware, async (req, res) => { try { const [, , apiResponse] = await bucket.getFiles({ prefix: "events/", delimiter: "/" }); const prefixes = apiResponse.prefixes || []; const results = []; for (const folder of prefixes) { const safeFolder = folder.endsWith("/") ? folder : folder + "/"; const file = bucket.file(`${safeFolder}event.json`); try { const [content] = await file.download(); const ev = JSON.parse(content.toString()); if (ev.owner === req.user.email) { results.push(ev); } } catch (e) { console.warn("Preskočen folder:", folder); } } results.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); res.json(results); } catch (e) { console.error("DASHBOARD API ERROR:", e); res.status(500).json({ error: "Greška na serveru" }); } }); /* ========================= HOME (HTML) ========================= */ app.get("/", async (req, res) => { try { // 1. PROVERA: Ako nema eventId-a (banka), šalji na landing const eventId = String(req.query.eventId || req.query.event || "").trim(); if (!eventId) { return res.redirect('/landing'); } // 2. ADMIN I PREFIKS (eventId je već definisan gore, ne piši 'const' ponovo) const adminMode = isAdmin(req); const prefix = `events/${eventId}/uploads/`; const [files] = await bucket.getFiles({ prefix }); const regularFiles = files.filter((f) => { const n = f.name || ""; if (!n || n.endsWith("/")) return false; if (n.endsWith("event.json")) return false; if (n.includes("naslovna.jpg")) return false; if (n.startsWith("uploads/meta/")) return false; return true; }); regularFiles.sort((a, b) => { const ta = new Date(a.metadata?.timeCreated || 0).getTime(); const tb = new Date(b.metadata?.timeCreated || 0).getTime(); return tb - ta; }); const MAX_RENDER = 90; const limitedFiles = regularFiles.slice(0, MAX_RENDER); // COVER (event-aware, sa fallback-om) — otpornije na putanje/fajl-ekstenzije let coverUrl; try { // 1) EVENT cover: prvo probaj iz event.json (coverObject), pa onda standardne putanje if (eventId) { let coverFromJson = ""; try { const eventJsonPath = `events/${eventId}/event.json`; const [exJson] = await bucket.file(eventJsonPath).exists(); if (exJson) { const [buf] = await bucket.file(eventJsonPath).download(); const obj = JSON.parse(buf.toString("utf8") || "{}"); coverFromJson = String(obj?.coverObject || "").trim(); // landing upisuje ovo } } catch (_e) { coverFromJson = ""; } const eventCoverCandidates = [ coverFromJson, // može biti "events//cover.jpg" ili nešto drugo `events/${eventId}/cover.jpg`, `events/${eventId}/cover.jpeg`, `events/${eventId}/cover.png`, `events/${eventId}/naslovna.jpg`, `events/${eventId}/uploads/naslovna.jpg`, ].filter(Boolean); for (const p of eventCoverCandidates) { const [ex] = await bucket.file(p).exists(); if (ex) { const [u] = await bucket.file(p).getSignedUrl({ action: "read", expires: Date.now() + 60 * 60 * 1000, }); coverUrl = u; break; } } } // 2) GLOBAL cover: u bucket-u ti je trenutno root "naslovna.jpg", ali nekad je bilo "uploads/naslovna.jpg" if (!coverUrl) { const globalCoverCandidates = [ "naslovna.jpg", "uploads/naslovna.jpg", "default-event/naslovna.jpg", ]; for (const p of globalCoverCandidates) { const [ex2] = await bucket.file(p).exists(); if (ex2) { const [u2] = await bucket.file(p).getSignedUrl({ action: "read", expires: Date.now() + 60 * 60 * 1000, }); coverUrl = u2; break; } } } // 3) final fallback if (!coverUrl) { coverUrl = "https://images.unsplash.com/photo-1546733080-163471032822?w=1200"; } } catch (e) { coverUrl = "https://images.unsplash.com/photo-1546733080-163471032822?w=1200"; } // EVENT.JSON (welcomeMessage) let welcomeMessage = ""; if (eventId) { try { const eventJsonPath = `events/${eventId}/event.json`; const [ex] = await bucket.file(eventJsonPath).exists(); if (ex) { const [buf] = await bucket.file(eventJsonPath).download(); const obj = JSON.parse(buf.toString("utf8")); // podrži i stare ključeve ako postoje welcomeMessage = String(obj?.welcomeMessage || obj?.msg || "").trim(); } } catch (e) { welcomeMessage = ""; } } // Ostavljamo prazno da ne bi bilo dupliranja na ekranu (onaj mali crni boks) const welcomeMessageHtml = ""; // Tvoja poruka postaje jedini i glavni naslov galerije const heroTitleHtml = `

${welcomeMessage || "DIGITALNA
GALERIJA"}

`; const fileData = await Promise.all( limitedFiles.map(async (file) => { const name = file.name || ""; const isVideo = /\.(mp4|webm|mov)$/i.test(name); const isImage = /\.(jpg|jpeg|png|gif|webp|heic|heif)$/i.test(name); if (!isVideo && !isImage) return null; const [url] = await file.getSignedUrl({ action: "read", expires: Date.now() + 60 * 60 * 1000, }); const md = file.metadata?.metadata || {}; const caption = (md.caption || "").toString(); const authorName = (md.authorName || "").toString(); const authorMessage = (md.authorMessage || "").toString(); const likes = parseInt(md.likes || "0", 10); const isHidden = md.isHidden === "1"; if (isHidden && !adminMode) return null; return { name, url, isVideo, safeName: escapeJs(name), encodedName: encodeURIComponent(name), caption, authorName, authorMessage, likes: Number.isFinite(likes) ? likes : 0, isHidden, }; }) ); const validFiles = fileData.filter(Boolean); const clientItems = validFiles.map((f) => ({ url: f.url, isVideo: !!f.isVideo, name: f.name, safeName: f.safeName, encodedName: f.encodedName, caption: f.caption || "", authorName: f.authorName || "", authorMessage: f.authorMessage || "", likes: f.likes || 0, isLiked: false, isHidden: f.isHidden || false, })); let galleryHtml = ""; validFiles.forEach((file, idx) => { galleryHtml += '\n
' + (adminMode ? '\n \n" + ' \n' : "") + '
' + (file.isVideo ? '' + '
' : '') + (file.caption && file.caption.trim() ? '
💬
' : "") + "
"; }); // TEK ONDA STATIC (Skloni ga ispod redirecta) app.use(express.static(path.join(__dirname, "public"))); const MAX_FILES = Number(process.env.MAX_FILES || 10); const MAX_DELETE = Number(process.env.MAX_DELETE || 10); const ADMIN_MODE = adminMode ? "true" : "false"; res.set( "Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate" ); console.log("[COVER]", { eventId, coverUrl }); // ... nastavlja se tvoj HTML res.send(...) deo res.send(` DIGITALNA GALERIJA
DIGITAL CREATIVE WORLD
${welcomeMessageHtml} ${heroTitleHtml}
0 slika i videa
${galleryHtml}
${ adminMode ? `
Selektovano: 0/${MAX_DELETE}
` : "" }

Hvala!

Uspomene će se uskoro pojaviti u albumu.

×
`); } catch (err) { console.error("Error in GET /:", err); res.status(500).send("Greška: " + (err.message || err)); } }); // 1. SIGN RUTA app.post("/sign", async (req, res) => { try { const files = req.body.files || []; // ✅ OVDE ide validacija if (!Array.isArray(files) || !files.length) return res.status(400).send("No files to sign"); const eventId = String(req.query.eventId || req.query.event || req.body?.eventId || "").trim(); const prefix = eventId ? `events/${eventId}/uploads/` : "uploads/"; const timestamp = Date.now(); const uploads = await Promise.all(files.map(async (f) => { const objName = `${prefix}${timestamp}-${f.name}`; const [url] = await bucket.file(objName).getSignedUrl({ version: "v4", action: "write", expires: Date.now() + 15 * 60 * 1000, contentType: f.type || "application/octet-stream" }); return { objName, url }; })); res.json({ uploads }); } catch (err) { console.error("Error in POST /sign:", err); res.status(500).send(err.message); } }); // 2. FINALIZE RUTA app.post("/finalize", uploadMiddleware, async (req, res) => { try { const eventId = String(req.query.eventId || req.query.event || req.body?.eventId || "").trim(); const prefix = eventId ? `events/${eventId}/uploads/` : "uploads/"; const { objects, authorName, authorMessage } = req.body; if (!Array.isArray(objects) || !objects.length) return res.status(400).send("Invalid request"); await Promise.all(objects.map(async (obj) => { const name = String(obj?.name || ""); if (!name || !name.startsWith(prefix)) throw new Error("Invalid file scope"); await bucket.file(name).setMetadata({ metadata: { caption: obj.caption || "", authorName: authorName || "", authorMessage: authorMessage || "", likes: "0", }, }); })); res.sendStatus(200); } catch (err) { console.error("Error in POST /finalize:", err); res.status(500).send(err.message); } }); // 3. ABORT RUTA app.post("/abort", uploadMiddleware, async (req, res) => { try { const eventId = String(req.query.eventId || req.query.event || req.body?.eventId || "").trim(); const prefix = eventId ? `events/${eventId}/uploads/` : "uploads/"; const { objects } = req.body; if (!Array.isArray(objects) || !objects.length) return res.status(400).send("Invalid request"); await Promise.all(objects.map((name) => { name = String(name || ""); if (!name || !name.startsWith(prefix)) return Promise.resolve(); return bucket.file(name).delete().catch(() => {}); })); res.sendStatus(200); } catch (err) { console.error("Error in POST /abort:", err); res.status(500).send(err.message); } }); app.post("/delete", async (req, res) => { if (!isAdmin(req)) return res.status(403).send("Admin only"); try { const prefix = getScopePrefix(req); const { objects } = req.body; if (!Array.isArray(objects) || !objects.length) return res.status(400).send("Invalid request"); await Promise.all(objects.map(enc => { const name = decodeURIComponent(String(enc || "")); if (!inScope(name, prefix)) return Promise.resolve(); return bucket.file(name).delete().catch(() => {}); })); res.sendStatus(200); } catch (err) { console.error("Error in POST /delete:", err); res.status(500).send(err.message); } }); app.post("/like", async (req, res) => { try { const prefix = getScopePrefix(req); const { name, delta } = req.body; if (!name || typeof delta !== "number") return res.status(400).send("Invalid request"); if (!inScope(name, prefix)) return res.status(403).send("Invalid file scope"); const file = bucket.file(name); const [exists] = await file.exists(); if (!exists) return res.status(404).send("File not found"); const [meta] = await file.getMetadata(); const md = meta.metadata || {}; const currentLikes = parseInt(md.likes || "0", 10); const newLikes = Math.max(0, currentLikes + delta); await file.setMetadata({ metadata: { ...md, likes: String(newLikes) } }); res.json({ likes: newLikes }); } catch (err) { console.error("Error in POST /like:", err); res.status(500).send("Like failed"); } }); app.post("/admin/hide", async (req, res) => { if (!isAdmin(req)) return res.status(403).send("Admin only"); try { const prefix = getScopePrefix(req); const { name, action } = req.body; if (!name || !action) return res.status(400).send("Invalid request"); if (!inScope(name, prefix)) return res.status(403).send("Invalid file scope"); const file = bucket.file(name); const [exists] = await file.exists(); if (!exists) return res.status(404).send("File not found"); const [meta] = await file.getMetadata(); const md = meta.metadata || {}; if (action === "hide") { await file.setMetadata({ metadata: { ...md, isHidden: "1" } }); } else if (action === "unhide") { const { isHidden, ...rest } = md; await file.setMetadata({ metadata: rest }); } else { return res.status(400).send("Invalid action"); } res.sendStatus(200); } catch (err) { console.error("Error in POST /admin/hide:", err); res.status(500).send(err.message); } }); app.post("/admin/delete", async (req, res) => { if (!isAdmin(req)) return res.status(403).send("Admin only"); try { const prefix = getScopePrefix(req); const { name } = req.body; if (!name) return res.status(400).send("Invalid request"); if (!inScope(name, prefix)) return res.status(403).send("Invalid file scope"); await bucket.file(name).delete().catch(() => {}); res.sendStatus(200); } catch (err) { console.error("Error in POST /admin/delete:", err); res.status(500).send(err.message); } }); // ========================= // LANDING (bez duplog express/app/PORT/listen) // ========================= app.get("/favicon.ico", (req, res) => res.status(204).end()); async function signedReadUrl(filePath, fallback = "") { try { if (!bucket) throw new Error("bucket is not initialized"); console.log("SIGNED READ:", bucket.name, JSON.stringify(filePath)); const [url] = await bucket.file(filePath).getSignedUrl({ version: "v4", action: "read", expires: Date.now() + 60 * 60 * 1000, responseType: "image/jpeg", responseDisposition: "inline", }); return url; } catch (e) { console.log("SIGNED READ ERR:", e?.message || e); return fallback; } } async function signedWriteUrl(filePath, contentType = "image/jpeg") { try { if (!bucket) throw new Error("bucket is not initialized"); console.log("SIGNED WRITE:", bucket.name, JSON.stringify(filePath), contentType); const [url] = await bucket.file(filePath).getSignedUrl({ version: "v4", action: "write", expires: Date.now() + 15 * 60 * 1000, contentType, }); return url; } catch (e) { console.log("SIGNED WRITE ERR:", e?.message || e); return ""; } } function randId(len = 12) { const abc = "abcdefghjkmnpqrstuvwxyz23456789"; let out = ""; for (let i = 0; i < len; i++) out += abc[Math.floor(Math.random() * abc.length)]; return out; } function escHtml(s) { return String(s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function landingPage({ pageTitle, active, bodyHtml }) { const title = escHtml(pageTitle || "Digital Creative World"); const act = active || "home"; return ` ${title}
${bodyHtml} ` } // HOME app.get("/landing", async (req, res) => { // SIGNED URL-ovi const heroUrl = await signedReadUrl("landing/landing-hero.jpg"); const phoneUrl = await signedReadUrl("landing/landing-phone.jpg"); const viewUrl = await signedReadUrl("landing/friends-viewing-event-photos.jpg"); const slideshowUrl = await signedReadUrl("landing/sajt-slajdsou.jpg"); const bodyHtml = `
Digitalna galerija za događaje

Počnite besplatno. Podešavanje za manje od 2 minuta.
Događaj – digitalna galerija

Kako da prikupite sve fotografije sa događaja

Tri koraka. Bez aplikacije. Sve u jednoj galeriji.

1
Kreiraj događaj
Unesi naziv i dobijaš link + QR kod.
2
Podeli gostima
Pošalji link ili QR pre / tokom događaja.
3
Gosti dodaju sadržaj
Fotke i video stižu direktno u galeriju.

Galerija uživo sa vašeg događaja

Gosti dodaju slike i video direktno sa telefona — bez aplikacije i registracije.

Upload
POGLEDAJ DEMO

Uživajte zajedno — i tokom događaja

Gosti gledaju fotografije uživo na ekranu, TV-u ili projektoru dok sadržaj stiže.

Slideshow

Vaš događaj. Jedna galerija. Sve uspomene.

Gosti dele fotografije i video zapise putem linka ili QR koda, a sav sadržaj se automatski prikuplja u privatnoj digitalnoj galeriji — bez aplikacija i registracije.

Digitalna galerija

Sve fotografije i video zapisi gostiju automatski se čuvaju na jednom mestu.

Pristup bez aplikacija

Gosti se priključuju jednim klikom ili skeniranjem QR koda.

Live prikaz sadržaja

Fotografije i video zapisi mogu se prikazivati uživo na ekranu ili projektoru.

Jednostavno preuzimanje

Kompletna galerija je spremna za preuzimanje u punom kvalitetu.

Prilagođen izgled

Vizuelno usklađivanje sa stilom događaja ili brendom.

Privatnost i kontrola

Sadržaj je vidljiv samo osobama kojima vi omogućite pristup.

Guests viewing gallery

Za koje događaje

Od malih privatnih okupljanja do velikih događaja — svi sadržaji gostiju na jednom mestu.

Venčanja

Svaki osmeh i pogled, skupljeni iz ugla onih koji su vam najbliži.

Privatne proslave

Spontani trenuci gostiju, objedinjeni u jednoj galeriji.

Rođendani

Uspomene koje ostaju i nakon što se svećice ugase.

Konferencije

Sadržaj učesnika prikupljen tokom samog događaja.

Korporativni događaji

Centralno mesto za timske i poslovne uspomene.

Svi ostali događaji

Za događaje van standardnih kategorija — sa istim premium iskustvom.

Paketi

Izaberi paket koji ti najviše odgovara. Sve radi preko linka ili QR koda.

Garancija povrata novca — ako na kraju ne iskoristite Digitalnu galeriju na svom događaju, vraćamo vam novac bez pitanja.

Besplatni
Probni
0
RSD
Probaj bez obaveze — za test i male događaje.
Do 50 fajlova (foto/video)
Galerija aktivna 7 dana
Link + QR kod za goste
Watermark na sadržaju
Bez preuzimanja cele galerije
Osnovni
Za manje događaje
5.900
RSD
Sve što ti treba da sakupiš uspomene na jednom mestu.
Do 1.000 fajlova
Galerija aktivna 30 dana
Preuzimanje cele galerije
Privatna / javna galerija
Prilagodljiva naslovna
Premium
Za velike i luksuzne evente
15.220
RSD
Neograničeno fajlova.
Neograničeno fajlova
Galerija aktivna 365 dana
VIP podrška
Bez logo oznaka
Prioritetni serveri
Bez instalacije. Radi na svakom telefonu. Gosti dodaju slike putem linka ili QR koda.

Najčešća pitanja

Ne. Otvore link ili skeniraju QR kod i odmah mogu da dodaju sadržaj.
Direktno sa telefona — bez naloga i bez registracije.
Da. Možeš preuzeti fotografije i video zapise u punom kvalitetu.
Imaš kontrolu: sadržaj možeš sakriti ili obrisati.
Da — dobijaš spontane uglove gostiju i sve na jednom mestu, uz fotografije fotografa.
Plaćanje je po događaju i zavisi od izabranog paketa. Nakon izbora paketa dobijate uputstva za plaćanje, a po potvrdi uplate galerija se aktivira i dobijate link i QR kod za goste.
`; res .status(200) .set("Content-Type", "text/html; charset=utf-8") .set("Content-Disposition", "inline") .send( landingPage({ pageTitle: "Digital Creative World – Landing", active: "home", bodyHtml, }) ); }); // ========================= // 1. REGISTRACIJA KORISNIKA (STRANICA) - FIKSIRANO SA NOVIM DIZAJNOM // ========================= app.get("/landing/user-register", (req, res) => { res.setHeader("Content-Type", "text/html; charset=utf-8"); res.send( landingPage({ pageTitle: "Otvori nalog – Digital Creative World", active: "", bodyHtml: `

Kreirajte nalog

Molimo popunite sva polja.
Nazad na početnu
` }) ); }); // ========================= // 2. PRIJAVA KORISNIKA (STRANICA) // ========================= app.get("/landing/login", (req, res) => { res.setHeader("Content-Type", "text/html; charset=utf-8"); res.send( landingPage({ pageTitle: "Prijava – Digital Creative World", active: "", bodyHtml: `
DIGITAL CREATIVE WORLD

Prijavite se na svoj nalog

Zaboravili ste lozinku?
ILI
` }) ); }); app.get("/landing/forgot-password", (req, res) => { res.setHeader("Content-Type", "text/html; charset=utf-8"); res.send( landingPage({ pageTitle: "Reset lozinke – Digital Creative World", active: "", bodyHtml: `
DIGITAL CREATIVE WORLD

Reset lozinke

Unesite email adresu i poslaćemo vam link za oporavak naloga.

` }) ); }); // ========================= // 3. DASHBOARD (STRANICA) - FIKSIRANO // ========================= app.get("/dashboard", async (req, res) => { res.setHeader("Content-Type", "text/html; charset=utf-8"); res.send( landingPage({ pageTitle: "Moj Dashboard – Digital Creative World", active: "dashboard", bodyHtml: `
` }) ); }); // ========================================== // 1. REGISTRACIJA (VRAĆENA NA /api/register) // ========================================== app.post("/api/register", async (req, res) => { try { const { email, password, name } = req.body; if (!email || !password) return res.status(400).json({ error: "Podaci nedostaju" }); // Generisanje imena fajla (npr. korisnik_gmail_com.json) const safeName = email.toLowerCase().replace(/[^a-z0-9]/g, "_") + ".json"; const filePath = `users/${safeName}`; const file = bucket.file(filePath); const [exists] = await file.exists(); if (exists) return res.status(400).json({ error: "Korisnik već postoji" }); const hashedPassword = await bcrypt.hash(password, 10); // Struktura koja prati tvoj Digital Creative World sistem const newUser = { email: email.toLowerCase(), password: hashedPassword, name: name || "", packageType: "PROBNI", isPaid: false, createdAt: Date.now(), myEvents: [] }; // Snimanje u Google Cloud Storage Bucket await file.save(JSON.stringify(newUser), { contentType: "application/json; charset=utf-8", resumable: false }); res.json({ success: true, message: "Uspešna registracija" }); } catch (e) { console.error("REG ERROR:", e); res.status(500).json({ error: "Greška prilikom kreiranja naloga" }); } }); // ========================================== // 2. PRIJAVA (VRAĆENA NA /api/login) // ========================================== app.post("/api/login", async (req, res) => { try { const { email, password } = req.body; if (!email || !password) return res.status(400).json({ error: "Email i lozinka obavezni" }); const safeName = email.toLowerCase().replace(/[^a-z0-9]/g, "_") + ".json"; const filePath = `users/${safeName}`; const file = bucket.file(filePath); const [exists] = await file.exists(); if (!exists) return res.status(401).json({ error: "Pogrešan email ili lozinka" }); const [content] = await file.download(); const user = JSON.parse(content.toString()); if (!user || !user.password) { return res.status(401).json({ error: "Nalog nije ispravno kreiran" }); } const match = await bcrypt.compare(password, user.password); if (!match) return res.status(401).json({ error: "Pogrešan email ili lozinka" }); // Provera uloge na osnovu tvog registrovanog email-a const userRole = (user.email === "digitalcreativeworld@gmail.com") ? "owner" : "eventAdmin"; const token = jwt.sign( { email: user.email, packageType: user.packageType || 'PROBNI', role: userRole }, JWT_SECRET, { expiresIn: "24h" } ); res.json({ token }); } catch (e) { console.error("LOGIN ERROR:", e); res.status(500).json({ error: "Interna greška na serveru" }); } }); // ========================= // LANDING – REGISTRACIJA // ========================= app.get("/landing/registracija", (req, res) => { const plan = (req.query.plan || "standard").toLowerCase(); // prefill (iz URL-a) const evName = escHtml(req.query.name || ""); const evDate = escHtml(req.query.date || ""); const evOrg = escHtml(req.query.org || ""); res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Content-Disposition", "inline"); res.send( landingPage({ pageTitle: "Registracija – Digital Creative World", active: "", bodyHtml: `
DIGITAL CREATIVE WORLD

Kreiraj događaj

Za manje od minut dobijaš link i QR kod za goste.

Izabrani paket: ${plan}

Nazad na početnu
`, }) ); }); // ======================= // LANDING CREATE (backend) - MINIMUM ZA NAS PROJEKAT // ======================= app.post("/landing/create", authMiddleware, async (req, res) => { try { const { error, value } = createEventSchema.validate(req.body); if (error) return res.status(400).json({ error: error.details[0].message }); const { name, date, org, plan, type, msg } = value; if (!name || !date || !org) return res.status(400).json({ error: "missing_required" }); const eventId = makeEventId(name, date); const ev = { eventId, owner: req.user.email, name, date, org, plan: (plan || "standard").toLowerCase(), type, msg, createdAt: Date.now(), coverObject: "", }; const eventPath = `events/${eventId}/event.json`; await bucket.file(eventPath).save(JSON.stringify(ev), { contentType: "application/json; charset=utf-8", resumable: false, }); console.log("LANDING CREATE OK:", eventId); // Svi idu na naslovnu sliku (Step 2) return res.json({ redirectUrl: `/landing/cover?eventId=${encodeURIComponent(eventId)}` }); } catch (e) { console.error("LANDING CREATE ERROR:", e?.stack || e); return res.status(500).json({ error: "server_error" }); } }); // ======================= // FINALIZACIJA NASLOVNE SLIKE (Step 2) - ISPRAVLJENO // ======================= app.post("/cover/finalize", express.json(), async (req, res) => { try { const eventId = String(req.body?.eventId || req.query.eventId || "").trim(); const objectName = String(req.body?.objectName || "").trim(); if (!eventId) return res.status(400).json({ error: "missing_eventId" }); if (!objectName) return res.status(400).json({ error: "missing_objectName" }); const eventPath = `events/${eventId}/event.json`; const [buf] = await bucket.file(eventPath).download(); const ev = JSON.parse(buf.toString("utf8")); ev.coverObject = objectName; ev.updatedAt = Date.now(); await bucket.file(eventPath).save(JSON.stringify(ev), { contentType: "application/json; charset=utf-8", resumable: false, }); console.log("COVER FINALIZE OK:", eventId); // Usmeravanje po planu koji je korisnik izabrao if (ev.plan === "free" || ev.plan === "probni") { // Besplatni idu na potvrdu događaja return res.json({ redirectUrl: `/e/${encodeURIComponent(eventId)}` }); } else { // Plaćeni idu na IPS QR naplatu za Digital Creative World return res.json({ redirectUrl: `/?eventId=${encodeURIComponent(eventId)}&showPayment=true&plan=${encodeURIComponent(ev.plan)}` }); } } catch (e) { console.error("COVER FINALIZE ERROR:", e); res.status(500).json({ error: "server_error" }); } }); app.get("/landing/skip-cover", authMiddleware, async (req, res) => { try { const eventId = String(req.query.eventId || "").trim(); if (!eventId) return res.status(400).send("Missing eventId"); const eventPath = `events/${eventId}/event.json`; const [buf] = await bucket.file(eventPath).download(); const ev = JSON.parse(buf.toString("utf8")); // Ako je paket besplatan, ide na potvrdu događaja if (ev.plan === "free" || ev.plan === "probni") { return res.redirect(`/e/${encodeURIComponent(eventId)}`); } else { // Plaćeni paketi moraju na IPS QR naplatu return res.redirect(`/?eventId=${encodeURIComponent(eventId)}&showPayment=true&plan=${encodeURIComponent(ev.plan)}`); } } catch (e) { console.error("SKIP COVER ERROR:", e); res.status(500).send("Greška na serveru."); } }); // RUTA ZA GENERISANJE PODATAKA ZA QR KOD app.get("/landing/payment-qr", async (req, res) => { try { const eventId = String(req.query.eventId || "").trim(); const plan = String(req.query.plan || "standard").trim(); if (!eventId) return res.status(400).json({ error: "Missing eventId" }); // Ovde definišeš iznose na osnovu plana let amount = "1500"; if (plan === "pro") amount = "3000"; if (plan === "premium") amount = "5000"; // IPS QR podaci (Primer za tvoju firmu Digital Creative World) // Napomena: Za pravi IPS kod koristi specifičan format (K:PR|V:01|C:1...) const paymentData = { qrCode: `K:PR|V:01|C:1|R:205000000012345678|N:Digital Creative World|I:RSD${amount},00|SF:289|S:Uplata za digitalnu galeriju ${eventId}`, amount: amount, orderId: `EV-${eventId}-${Date.now().toString().slice(-5)}` }; res.json(paymentData); } catch (err) { console.error("PAYMENT QR ERROR:", err); res.status(500).json({ error: "Greška pri generisanju podataka za plaćanje" }); } }); // KORAK 2: naslovna slika (cover) — upload jedne slike u events/{eventId}/cover.jpg app.get("/landing/cover", async (req, res) => { try { const eventId = String(req.query.eventId || "").trim(); if (!eventId) return res.status(400).type("html").send("Missing eventId"); const eventPath = `events/${eventId}/event.json`; const [exists] = await bucket.file(eventPath).exists(); if (!exists) return res.status(404).type("html").send("Događaj nije pronađen."); const [buf] = await bucket.file(eventPath).download(); const ev = JSON.parse(buf.toString("utf8") || "{}"); return res.type("html").send( landingPage({ pageTitle: `Naslovna – ${ev?.name ? ev.name : "Događaj"} – Digital Creative World`, active: "", noHero: true, bodyHtml: `

Korak 2/2

Dodaj naslovnu sliku događaja

Ova slika je naslovna (hero) za događaj. Možeš i da preskočiš.

${escHtml(ev?.name || "Događaj")}
Preskoči
`, }) ); } catch (e) { console.error("LANDING COVER PAGE ERROR:", e?.stack || e); return res.status(500).type("html").send("Greška na serveru."); } }); // cover SIGN (PUT url) app.post("/cover/sign", express.json(), async (req, res) => { try { const eventId = String(req.query.eventId || "").trim(); if (!eventId) return res.status(400).json({ error: "missing_eventId" }); const ct = String(req.body?.contentType || "image/jpeg").trim().toLowerCase(); if (!ct.startsWith("image/")) return res.status(400).json({ error: "only_images" }); // jedna naslovna po eventu (overwrite) const objectName = `events/${eventId}/cover.jpg`; const uploadUrl = await signedWriteUrl(objectName, ct); return res.json({ uploadUrl, objectName }); } catch (e) { console.error("COVER SIGN ERROR:", e?.stack || e); return res.status(500).json({ error: "server_error" }); } }); // event “entry” stranica: prikazuje osnovne info + vodi u galeriju sa eventId parametrom app.get("/e/:eventId", async (req, res) => { try { const eventId = String(req.params.eventId || "").trim(); const eventPath = `events/${eventId}/event.json`; const [exists] = await bucket.file(eventPath).exists(); if (!exists) { return res.status(404).type("html").send("Događaj nije pronađen."); } const [buf] = await bucket.file(eventPath).download(); const ev = JSON.parse(buf.toString("utf8")); return res.type("html").send( landingPage({ pageTitle: `${ev.name} – Digital Creative World`, active: "", bodyHtml: `

Događaj

${escHtml(ev.name)}

${escHtml(ev.date)} • ${escHtml(ev.org)}${ev.type ? " • " + escHtml(ev.type) : ""}

`, }) ); } catch (e) { console.error("EVENT PAGE ERROR:", e?.stack || e); return res.status(500).type("html").send("Greška na serveru."); } }); // SERVER const PORT = process.env.PORT || 8080; app.listen(PORT, "0.0.0.0", () => { console.log("Listening on", PORT); });