Initial Commit

This commit is contained in:
HPL-JesusCastro
2026-05-26 16:04:14 +08:00
commit 5d6f228224
62 changed files with 10152 additions and 0 deletions

119
server/routes/ads.js Normal file
View File

@@ -0,0 +1,119 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const multer = require('multer');
const { listAds, getAd, updateAd, updateAdFile, updateAdThumbnail, deleteAd } = require('../db');
const { captureScreenshot } = require('../utils/screenshot');
const UPLOADS_DIR = process.env.UPLOADS_DIR || path.join(__dirname, '..', '..', 'uploads');
const fileStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOADS_DIR),
filename: (_req, _file, cb) => cb(null, `${crypto.randomUUID()}.html`),
});
const uploadFile = multer({
storage: fileStorage,
fileFilter: (_req, file, cb) => {
if (file.mimetype === 'text/html' || file.originalname.endsWith('.html')) {
cb(null, true);
} else {
cb(new Error('Only .html files are allowed'));
}
},
limits: { fileSize: 50 * 1024 * 1024 },
});
const router = express.Router();
router.get('/', (req, res) => {
res.json(listAds(req.query.appId));
});
router.get('/:id', (req, res) => {
const ad = getAd(req.params.id);
if (!ad) return res.status(404).json({ error: 'Not found' });
res.json(ad);
});
router.patch('/:id', (req, res) => {
const existing = getAd(req.params.id);
if (!existing) return res.status(404).json({ error: 'Not found' });
const { title, appId, tags, developer, repo, branch, notes } = req.body;
const updated = updateAd(req.params.id, {
title: title ?? existing.title,
appId: appId ?? existing.appId ?? null,
tags: tags ?? existing.tags ?? [],
developer: developer ?? existing.developer ?? '',
repo: repo ?? existing.repo ?? '',
branch: branch ?? existing.branch ?? '',
notes: notes ?? existing.notes ?? '',
});
res.json(updated);
});
router.post('/:id/thumbnail', express.json({ limit: '10mb' }), (req, res) => {
const ad = getAd(req.params.id);
if (!ad) return res.status(404).json({ error: 'Not found' });
const { dataUrl } = req.body;
if (!dataUrl || !dataUrl.startsWith('data:image/')) {
return res.status(400).json({ error: 'Invalid dataUrl' });
}
const thumbFilename = `${req.params.id}-thumb.png`;
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
fs.writeFileSync(path.join(UPLOADS_DIR, thumbFilename), Buffer.from(base64Data, 'base64'));
const updated = updateAdThumbnail(req.params.id, thumbFilename);
res.json(updated);
});
router.post('/:id/screenshot', async (req, res) => {
const ad = getAd(req.params.id);
if (!ad) return res.status(404).json({ error: 'Not found' });
try {
const port = process.env.PORT || 3000;
const url = `http://127.0.0.1:${port}/uploads/${ad.filename}`;
const buffer = await captureScreenshot(url);
const thumbFilename = `${req.params.id}-thumb.png`;
fs.writeFileSync(path.join(UPLOADS_DIR, thumbFilename), buffer);
const updated = updateAdThumbnail(req.params.id, thumbFilename);
res.json(updated);
} catch (err) {
console.error('Screenshot failed:', err.message);
res.status(500).json({ error: 'Screenshot failed' });
}
});
router.post('/:id/file', uploadFile.single('file'), (req, res) => {
const ad = getAd(req.params.id);
if (!ad) return res.status(404).json({ error: 'Not found' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const oldPath = path.join(UPLOADS_DIR, ad.filename);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
const updated = updateAdFile(req.params.id, req.file.filename);
res.json(updated);
});
router.delete('/:id', (req, res) => {
const ad = deleteAd(req.params.id);
if (!ad) return res.status(404).json({ error: 'Not found' });
const filePath = path.join(UPLOADS_DIR, ad.filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
const thumbPath = path.join(UPLOADS_DIR, `${ad.id}-thumb.png`);
if (fs.existsSync(thumbPath)) fs.unlinkSync(thumbPath);
res.status(204).end();
});
module.exports = router;

115
server/routes/apps.js Normal file
View File

@@ -0,0 +1,115 @@
const express = require('express');
const { URL } = require('url');
const { listApps, getApp, insertApp, updateApp, deleteApp } = require('../db');
const router = express.Router();
// ── store lookup helper ───────────────────────────────────────────────────────
function metaContent(html, property) {
// handles both attribute orderings
const patterns = [
new RegExp(`<meta[^>]+property="${property}"[^>]+content="([^"]*)"`, 'i'),
new RegExp(`<meta[^>]+content="([^"]*)"[^>]+property="${property}"`, 'i'),
];
for (const re of patterns) {
const m = html.match(re);
if (m) return m[1];
}
return null;
}
async function fetchText(url) {
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9',
},
signal: AbortSignal.timeout(10000),
redirect: 'follow',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
async function lookupAppStore(storeUrl) {
const m = storeUrl.match(/\/id(\d+)/);
if (!m) throw new Error('Could not parse App Store app ID from URL');
const body = await fetchText(`https://itunes.apple.com/lookup?id=${m[1]}`);
const data = JSON.parse(body);
const r = data.results?.[0];
if (!r) throw new Error('App not found in iTunes catalogue');
return {
name: r.trackName,
icon_url: r.artworkUrl512 || r.artworkUrl100 || r.artworkUrl60 || '',
};
}
async function lookupPlayStore(storeUrl) {
const m = storeUrl.match(/[?&]id=([^&]+)/);
if (!m) throw new Error('Could not parse Play Store package ID from URL');
const body = await fetchText(`https://play.google.com/store/apps/details?id=${m[1]}&hl=en`);
const title = metaContent(body, 'og:title');
if (!title) throw new Error('Could not extract app name from Play Store page');
const name = title.replace(/\s*[-]\s*Apps on Google Play\s*$/i, '').trim();
const icon_url = metaContent(body, 'og:image') || '';
return { name, icon_url };
}
// ── routes ────────────────────────────────────────────────────────────────────
router.get('/lookup', async (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).json({ error: 'url query param is required' });
try {
new URL(url); // validate
} catch {
return res.status(400).json({ error: 'Invalid URL' });
}
try {
if (url.includes('apps.apple.com')) {
return res.json(await lookupAppStore(url));
}
if (url.includes('play.google.com')) {
return res.json(await lookupPlayStore(url));
}
return res.status(422).json({ error: 'URL must be an App Store or Play Store link' });
} catch (err) {
console.error('App lookup failed:', err.message);
res.status(502).json({ error: err.message || 'Lookup failed' });
}
});
router.get('/', (_req, res) => {
res.json(listApps());
});
router.post('/', (req, res) => {
const { name, ios_url, android_url, icon_url } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'name is required' });
const app = insertApp({ name: name.trim(), ios_url: ios_url || '', android_url: android_url || '', icon_url: icon_url || '' });
res.status(201).json(app);
});
router.patch('/:id', (req, res) => {
const existing = getApp(req.params.id);
if (!existing) return res.status(404).json({ error: 'Not found' });
const { name, ios_url, android_url, icon_url } = req.body;
const updated = updateApp(req.params.id, {
name: name ?? existing.name,
ios_url: ios_url ?? existing.ios_url,
android_url: android_url ?? existing.android_url,
icon_url: icon_url ?? existing.icon_url,
});
res.json(updated);
});
router.delete('/:id', (req, res) => {
const app = deleteApp(req.params.id);
if (!app) return res.status(404).json({ error: 'Not found' });
res.status(204).end();
});
module.exports = router;

25
server/routes/auth.js Normal file
View File

@@ -0,0 +1,25 @@
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
function makeToken(password) {
return crypto.createHmac('sha256', password).update('showcase-session').digest('hex');
}
router.post('/login', (req, res) => {
const { password } = req.body;
const expected = process.env.SHOWCASE_PASSWORD;
if (!expected) {
return res.status(500).json({ error: 'Server not configured' });
}
if (!password || password !== expected) {
return res.status(401).json({ error: 'Invalid password' });
}
res.json({ token: makeToken(expected) });
});
module.exports = { router, makeToken };

52
server/routes/upload.js Normal file
View File

@@ -0,0 +1,52 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { insertAd } = require('../db');
const { parseTitle } = require('../utils/parseTitle');
const UPLOADS_DIR = process.env.UPLOADS_DIR || path.join(__dirname, '..', '..', 'uploads');
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOADS_DIR),
filename: (_req, _file, cb) => cb(null, `${crypto.randomUUID()}.html`),
});
const upload = multer({
storage,
fileFilter: (_req, file, cb) => {
if (file.mimetype === 'text/html' || file.originalname.endsWith('.html')) {
cb(null, true);
} else {
cb(new Error('Only .html files are allowed'));
}
},
limits: { fileSize: 50 * 1024 * 1024 },
});
const router = express.Router();
router.post('/', upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const html = fs.readFileSync(req.file.path, 'utf8');
const extractedTitle = parseTitle(html);
const id = path.basename(req.file.filename, '.html');
const ad = insertAd({
id,
filename: req.file.filename,
title: req.body.title || extractedTitle || req.file.originalname.replace(/\.html$/i, ''),
appId: req.body.appId || null,
tags: req.body.tags ? JSON.parse(req.body.tags) : [],
developer: req.body.developer || '',
repo: req.body.repo || '',
branch: req.body.branch || '',
notes: req.body.notes || '',
});
res.status(201).json(ad);
});
module.exports = router;