Rich solo composer: WYSIWYG editor, sanitized HTML bodies, media uploads, CTA labels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -34,7 +34,7 @@ const CANNED = [
|
||||
{ re: /(referral link|invite link|share link|refer)/i,
|
||||
a: 'You get your share link the moment you sign in, free members included. One tip: switch on payouts (one free wallet step in Members) before your people start buying, because the contract locks each buyer to their sponsor at their first purchase.' },
|
||||
{ re: /(solo ad|inbox ad|inbox)/i,
|
||||
a: 'Solo ads are full-message ads delivered straight into member inboxes on-site. Compose one under Campaigns (pick "Solo ad"): subject, up to 1000 characters, your link. You pay 5 credits per guaranteed delivery, 10 deliveries minimum. On the reading side, your Inbox section collects solos from other members — give one a real read (10 seconds on the open message) and claim 2 credits, up to 5 rewarded reads a day. You never receive your own solo.' },
|
||||
a: 'Solo ads are full-message ads delivered straight into member inboxes on-site. Compose one under Campaigns (pick "Solo ad"): subject line, a rich-text message with a real editor (bold, headings, lists, links), an attached image or video if you want one, and a call-to-action button with your own label. You pay 5 credits per guaranteed delivery, 10 deliveries minimum. On the reading side, your Inbox section collects solos from other members — give one a real read (10 seconds on the open message) and claim 2 credits, up to 5 rewarded reads a day. You never receive your own solo.' },
|
||||
{ re: /((view|watch|see).{0,12}ads?|earn.{0,12}credits?|daily (set|ads|views))/i,
|
||||
a: 'In the Earn credits section of Members, each ad in the daily set opens full screen in its own tab, showing the advertiser\'s real site. A countdown runs while you watch (it pauses if you leave the tab), then you pass a quick click-the-icon check and the view counts. Finish the set, claim your daily credits, and spend them on your own banner or text campaigns. You never see your own ads, and viewer rewards are credits, never cash.' },
|
||||
{ re: /(credit|impression|cpm|what do i get|what am i buying)/i,
|
||||
@@ -54,7 +54,7 @@ FACTS:
|
||||
- Ad packages: Micro $5/500 credits, Activation $20/2,000, Builder $50/5,500, Growth $100/12,000, Leader $250/32,500. Dollar-priced, settled in POL (Polygon) at the live Chainlink rate. 1 credit = 1 cent of ad delivery.
|
||||
- Live formats: display banners (per impression), text ads (per impression), login ads (per day). Coming: inbox ads, featured rotation with disclosed rotation size, verified-visit packs.
|
||||
- Members EARN credits by attention: in the Earn credits section of Members, each ad in the daily set opens FULL SCREEN in its own tab, showing the advertiser's real website. A countdown runs while you watch (it pauses if you leave the tab), then a quick human check (click the named icon) must be passed before the view counts. Finish the daily set, claim a small daily credit batch. Earned credits spend on banner and text campaigns; attention earns advertising, referrals earn money, and viewer rewards are never cash. Advertisers get real, verified visits to their site.
|
||||
- Onsite SOLO ADS are live: a solo ad is a full message (subject + up to 1000 characters + your link) delivered into members' on-site Inbox (Members > Inbox). Cost 5 credits per guaranteed delivery, minimum 10 deliveries (50 credits). Each member receives a given solo at most once, and never the sender's own. Readers earn 2 credits per real read (10-second dwell on the open message, up to 5 rewarded reads/day) — claimed right from the message. Compose one in Campaigns > Solo ad.
|
||||
- Onsite SOLO ADS are live: a solo ad is a full message (subject + up to 2000 characters of formatted text + your link) delivered into members' on-site Inbox (Members > Inbox). The composer in Campaigns > Solo ad has a rich-text editor (bold, headings, lists, links), lets you ATTACH one image (PNG/JPG/WebP/GIF, up to 3MB) or one video (MP4/WebM, up to 25MB), and adds a call-to-action button with a custom label that opens the target URL. Cost 5 credits per guaranteed delivery, minimum 10 deliveries (50 credits). Each member receives a given solo at most once, and never the sender's own. Readers earn 2 credits per real read (10-second dwell on the open message, up to 5 rewarded reads/day) — claimed right from the message. Compose one in Campaigns > Solo ad.
|
||||
- Campaign target URLs are checked the moment they are submitted: the page must be reachable and must ALLOW framing (no X-Frame-Options deny/sameorigin, no blocking CSP frame-ancestors), because surf views show the real site full screen. Frame-blocking or dead URLs are rejected with the exact reason; the fix is a landing page that allows framing. Login-ad targets skip the frame check (they are click-through only).
|
||||
- Every purchase is split by an immutable smart contract in the same transaction: 50% direct sponsor, 20% level 2, 10% level 3, 20% platform. No withdrawals exist; money lands in members' own wallets instantly.
|
||||
- Qualification: level 1 open to all; 2 buyers of $20+ unlock level 2; 5 unlock level 3. Unqualified shares pass up the sponsor line, checking up to 25 positions, else the platform receives them. Qualification cannot be bought and never expires.
|
||||
|
||||
@@ -98,8 +98,9 @@ async function bootstrap() {
|
||||
UNIQUE KEY uq_solo (campaign_id, email),
|
||||
INDEX (email), INDEX (email, rewarded, rewarded_day)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
// solo message bodies run long; widen the shared campaigns.body column
|
||||
await alterSafe('ALTER TABLE campaigns MODIFY body VARCHAR(1200) NULL');
|
||||
// solo message bodies are sanitized rich text; TEXT gives them room
|
||||
await alterSafe('ALTER TABLE campaigns MODIFY body TEXT NULL');
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN cta_label VARCHAR(40) NULL');
|
||||
await q(`CREATE TABLE IF NOT EXISTS burns (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
member_id INT NOT NULL,
|
||||
|
||||
+53
-2
@@ -330,6 +330,46 @@
|
||||
+ '. Readers earn ' + (lastRates.soloReadCredits || 2) + ' credits for a real read, so your message gets opened.';
|
||||
}
|
||||
$('cBudget').addEventListener('input', soloHint);
|
||||
// rich solo editor: small toolbar over contenteditable (CSP allows no external editor);
|
||||
// the server whitelist-sanitizes whatever HTML arrives, this is just authoring comfort
|
||||
let soloMedia = null; // { url, type } from /api/my/upload
|
||||
document.querySelectorAll('.ed-bar [data-cmd]').forEach(btn =>
|
||||
btn.addEventListener('click', () => { $('cSoloEd').focus(); document.execCommand(btn.dataset.cmd, false, null); }));
|
||||
document.querySelectorAll('.ed-bar [data-block]').forEach(btn =>
|
||||
btn.addEventListener('click', () => { $('cSoloEd').focus(); document.execCommand('formatBlock', false, btn.dataset.block); }));
|
||||
$('edLinkBtn').addEventListener('click', () => {
|
||||
const url = prompt('Link URL (https://…)');
|
||||
if (!url) return;
|
||||
$('cSoloEd').focus();
|
||||
document.execCommand('createLink', false, url);
|
||||
});
|
||||
$('edAttachBtn').addEventListener('click', () => $('cSoloFile').click());
|
||||
$('cSoloFile').addEventListener('change', async () => {
|
||||
const f = $('cSoloFile').files[0];
|
||||
if (!f) return;
|
||||
$('edMediaInfo').textContent = 'Uploading ' + f.name + '…';
|
||||
try {
|
||||
const r = await (await fetch('/api/my/upload', { method: 'POST',
|
||||
headers: { 'Content-Type': f.type }, body: f })).json();
|
||||
if (r.error) { $('edMediaInfo').textContent = r.error; $('cSoloFile').value = ''; return; }
|
||||
soloMedia = r;
|
||||
$('edMediaInfo').textContent = f.name + ' attached';
|
||||
$('edMediaRemove').hidden = false;
|
||||
const pv = $('edMediaPrev');
|
||||
pv.hidden = false;
|
||||
pv.innerHTML = r.type === 'video'
|
||||
? '<video src="' + r.url + '" controls style="max-width:320px;border-radius:10px"></video>'
|
||||
: '<img src="' + r.url + '" alt="attachment preview" style="max-width:320px;border-radius:10px">';
|
||||
} catch (e) { $('edMediaInfo').textContent = 'Upload failed. Try again.'; }
|
||||
$('cSoloFile').value = '';
|
||||
});
|
||||
$('edMediaRemove').addEventListener('click', () => {
|
||||
soloMedia = null;
|
||||
$('edMediaInfo').textContent = '';
|
||||
$('edMediaRemove').hidden = true;
|
||||
$('edMediaPrev').hidden = true;
|
||||
$('edMediaPrev').innerHTML = '';
|
||||
});
|
||||
$('cType').addEventListener('change', () => {
|
||||
const t = $('cType').value;
|
||||
$('cImageRow').hidden = t === 'text' || t === 'solo';
|
||||
@@ -344,10 +384,14 @@
|
||||
await api('/api/my/campaigns', { type: $('cType').value, name: $('cName').value,
|
||||
targetUrl: $('cTarget').value, imageUrl: $('cImage').value,
|
||||
title: $('cTitle').value,
|
||||
body: $('cType').value === 'solo' ? $('cSoloBody').value : $('cBody').value,
|
||||
body: $('cType').value === 'solo' ? $('cSoloEd').innerHTML : $('cBody').value,
|
||||
mediaUrl: $('cType').value === 'solo' && soloMedia ? soloMedia.url : '',
|
||||
ctaLabel: $('cCtaLabel').value,
|
||||
budget: Number($('cBudget').value) });
|
||||
IAP.status('Campaign is live. It starts serving right away.', 'ok');
|
||||
$('cName').value = ''; $('cBudget').value = '';
|
||||
$('cSoloEd').innerHTML = ''; $('cCtaLabel').value = '';
|
||||
if ($('edMediaRemove') && !$('edMediaRemove').hidden) $('edMediaRemove').click();
|
||||
await loadCampaigns();
|
||||
}));
|
||||
// defers the busy() lookup to click time (busy is declared below)
|
||||
@@ -395,8 +439,15 @@
|
||||
$('inboxReadCard').hidden = false;
|
||||
$('ibSubject').textContent = r.subject || '(no subject)';
|
||||
$('ibMeta').textContent = 'from ' + (r.fromName || 'a member') + ' · ' + new Date(r.delivered).toLocaleString();
|
||||
$('ibBody').textContent = r.body || '';
|
||||
$('ibBody').innerHTML = r.body || ''; // whitelist-sanitized on the server at submit
|
||||
const mv = $('ibMedia');
|
||||
mv.hidden = !r.mediaUrl;
|
||||
mv.innerHTML = !r.mediaUrl ? ''
|
||||
: r.mediaType === 'video'
|
||||
? '<video src="' + r.mediaUrl + '" controls style="max-width:100%;border-radius:12px"></video>'
|
||||
: '<img src="' + r.mediaUrl + '" alt="attachment" style="max-width:100%;border-radius:12px">';
|
||||
$('ibVisit').href = r.url;
|
||||
$('ibVisit').textContent = r.ctaLabel || 'Learn more';
|
||||
const btn = $('ibClaimBtn');
|
||||
clearInterval(ibTimer);
|
||||
if (r.rewarded) {
|
||||
|
||||
@@ -295,6 +295,18 @@ textarea{resize:vertical;font:inherit}
|
||||
.donut-legend{display:flex;flex-direction:column;gap:8px;font-size:13px}
|
||||
.donut-legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:8px}
|
||||
.donut-center{font-family:var(--disp);font-weight:800}
|
||||
/* ── solo composer: toolbar + contenteditable editor ── */
|
||||
.ed-bar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
|
||||
.ed-bar button{background:var(--panel);color:var(--ink);border:1px solid var(--line-strong);border-radius:8px;
|
||||
padding:5px 11px;font-size:12.5px;cursor:pointer}
|
||||
.ed-bar button:hover,.ed-bar button:focus-visible{border-color:var(--mint);outline:none}
|
||||
.ed-body{background:rgba(4,8,7,.65);border:1px solid var(--line-strong);border-radius:11px;
|
||||
min-height:180px;padding:12px 14px;outline:none;overflow-wrap:anywhere}
|
||||
.ed-body:focus{border-color:var(--mint)}
|
||||
.ed-body:empty::before{content:attr(data-ph);color:var(--muted)}
|
||||
.ed-body a,.ib-rich a{color:var(--mint)}
|
||||
.ed-body h3,.ib-rich h3,.ed-body h4,.ib-rich h4{margin:.5em 0 .3em}
|
||||
.ed-media{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0 0}
|
||||
/* ── solo-ads inbox ── */
|
||||
.bo-menu .pill{margin-left:auto;background:var(--amber);color:#1a1206;font-size:11px;font-weight:800;
|
||||
border-radius:999px;padding:1px 8px;line-height:1.5}
|
||||
|
||||
+31
-7
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Member area | InstantAdPay</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905n">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905o">
|
||||
</head>
|
||||
<body class="bo-body">
|
||||
|
||||
@@ -222,7 +222,30 @@
|
||||
<p id="cImageRow"><input id="cImage" placeholder="Image URL (banner/login ads)" style="width:100%"></p>
|
||||
<p id="cTitleRow" hidden><input id="cTitle" placeholder="Headline (max 60)" style="width:100%"></p>
|
||||
<p id="cBodyRow" hidden><input id="cBody" placeholder="Ad text (max 140)" style="width:100%"></p>
|
||||
<p id="cSoloRow" hidden><textarea id="cSoloBody" placeholder="Your message (40–1000 characters). Write it like an email worth reading." rows="7" style="width:100%"></textarea></p>
|
||||
<div id="cSoloRow" hidden>
|
||||
<div class="ed-bar" aria-label="Formatting">
|
||||
<button type="button" data-cmd="bold" title="Bold"><b>B</b></button>
|
||||
<button type="button" data-cmd="italic" title="Italic"><i>I</i></button>
|
||||
<button type="button" data-cmd="underline" title="Underline"><u>U</u></button>
|
||||
<button type="button" data-block="h3" title="Heading">Heading</button>
|
||||
<button type="button" data-block="p" title="Normal text">Normal</button>
|
||||
<button type="button" data-cmd="insertUnorderedList" title="Bullet list">• List</button>
|
||||
<button type="button" data-cmd="insertOrderedList" title="Numbered list">1. List</button>
|
||||
<button type="button" id="edLinkBtn" title="Insert link">Link</button>
|
||||
<button type="button" data-cmd="removeFormat" title="Clear formatting">Clear</button>
|
||||
</div>
|
||||
<div id="cSoloEd" class="ed-body" contenteditable="true"
|
||||
data-ph="Write it like an email worth reading. Bold the promise, list the proof, link the receipts."></div>
|
||||
<p style="margin:10px 0 0"><input id="cCtaLabel" maxlength="30" style="width:100%"
|
||||
placeholder="Call-to-action button label (default: Learn more) — the button opens your target URL"></p>
|
||||
<p class="ed-media">
|
||||
<input type="file" id="cSoloFile" accept="image/png,image/jpeg,image/webp,image/gif,video/mp4,video/webm" hidden>
|
||||
<button type="button" class="btn small sec" id="edAttachBtn">Attach image or video</button>
|
||||
<span id="edMediaInfo" class="small muted"></span>
|
||||
<button type="button" class="btn small sec" id="edMediaRemove" hidden>Remove</button>
|
||||
</p>
|
||||
<div id="edMediaPrev" hidden style="margin-top:8px"></div>
|
||||
</div>
|
||||
<p class="small muted" id="cSoloHint" hidden></p>
|
||||
<button class="btn" id="createCampBtn">Launch campaign</button>
|
||||
</div>
|
||||
@@ -239,7 +262,8 @@
|
||||
<p><a href="#" id="ibBack">← Back to inbox</a></p>
|
||||
<h3 id="ibSubject"></h3>
|
||||
<p class="small muted" id="ibMeta"></p>
|
||||
<div id="ibBody" class="promo-block" style="white-space:pre-wrap"></div>
|
||||
<div id="ibMedia" hidden style="margin:12px 0"></div>
|
||||
<div id="ibBody" class="promo-block ib-rich"></div>
|
||||
<p style="margin-top:14px">
|
||||
<a class="btn" id="ibVisit" target="_blank" rel="noopener nofollow">Visit the advertiser</a>
|
||||
<button class="btn sec" id="ibClaimBtn" type="button" hidden>…</button></p>
|
||||
@@ -355,9 +379,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/common.js?v=20260905n"></script>
|
||||
<script src="/assets/wallet.js?v=20260905n"></script>
|
||||
<script src="/assets/my.js?v=20260905n"></script>
|
||||
<script src="/assets/chat.js?v=20260905n"></script>
|
||||
<script src="/assets/common.js?v=20260905o"></script>
|
||||
<script src="/assets/wallet.js?v=20260905o"></script>
|
||||
<script src="/assets/my.js?v=20260905o"></script>
|
||||
<script src="/assets/chat.js?v=20260905o"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Viewing ad — InstantAdPay</title>
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905n">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905o">
|
||||
<style>
|
||||
html,body{height:100%;margin:0;overflow:hidden}
|
||||
.vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)}
|
||||
@@ -43,6 +43,6 @@
|
||||
</div>
|
||||
<iframe class="vframe" id="vFrame" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" referrerpolicy="no-referrer" title="Advertiser site"></iframe>
|
||||
</div>
|
||||
<script src="/assets/view.js?v=20260905n"></script>
|
||||
<script src="/assets/view.js?v=20260905o"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -28,6 +28,9 @@ const SITE_FILE = path.join(DATA_DIR, 'site.json');
|
||||
|
||||
const db = require('./db');
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); // solo-ad media lives on the volume
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
const uploadCounts = new Map(); // email:day -> uploads today
|
||||
const chatHits = new Map();
|
||||
function chatLimited(ip) {
|
||||
const now = Date.now(), rec = chatHits.get(ip);
|
||||
@@ -116,7 +119,8 @@ function siteConfig() {
|
||||
// ---- helpers ----
|
||||
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.webp': 'image/webp',
|
||||
'.ico': 'image/x-icon', '.json': 'application/json', '.mp4': 'video/mp4', '.woff2': 'font/woff2' };
|
||||
'.ico': 'image/x-icon', '.json': 'application/json', '.mp4': 'video/mp4', '.woff2': 'font/woff2',
|
||||
'.gif': 'image/gif', '.webm': 'video/webm' };
|
||||
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data: https://fonts.gstatic.com; form-action 'self'; frame-src https: http:";
|
||||
function baseHeaders(extra) {
|
||||
return Object.assign({ 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff',
|
||||
@@ -136,6 +140,19 @@ function sendFile(res, file) {
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
function readRaw(req, maxBytes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let n = 0;
|
||||
req.on('data', c => {
|
||||
n += c.length;
|
||||
if (n > maxBytes) { req.destroy(); reject(new Error('too big')); return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let d = ''; let n = 0;
|
||||
@@ -527,6 +544,33 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
return json(res, r.error ? 404 : 200, r);
|
||||
}
|
||||
// media upload for solo ads: raw body, size-capped, magic-byte verified
|
||||
if (p === '/api/my/upload' && req.method === 'POST') {
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
||||
const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif',
|
||||
'video/mp4': 'mp4', 'video/webm': 'webm' };
|
||||
if (!EXT[ct]) return json(res, 400, { error: 'Use a PNG, JPG, WebP, GIF, MP4 or WebM file.' });
|
||||
const isVideo = ct.startsWith('video/');
|
||||
const key = s.email + ':' + new Date().toISOString().slice(0, 10);
|
||||
if ((uploadCounts.get(key) || 0) >= 10) return json(res, 400, { error: 'Upload limit for today reached (10 files).' });
|
||||
let buf;
|
||||
try { buf = await readRaw(req, isVideo ? 25 * 1024 * 1024 : 3 * 1024 * 1024); }
|
||||
catch (e) { return json(res, 400, { error: 'File too large. Images up to 3MB, video up to 25MB.' }); }
|
||||
const magicOk = buf.length > 16 && (
|
||||
(ct === 'image/png' && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) ||
|
||||
(ct === 'image/jpeg' && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) ||
|
||||
(ct === 'image/webp' && buf.slice(0, 4).toString() === 'RIFF' && buf.slice(8, 12).toString() === 'WEBP') ||
|
||||
(ct === 'image/gif' && buf.slice(0, 4).toString() === 'GIF8') ||
|
||||
(ct === 'video/mp4' && buf.slice(4, 8).toString() === 'ftyp') ||
|
||||
(ct === 'video/webm' && buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3));
|
||||
if (!magicOk) return json(res, 400, { error: 'That file does not look like a real ' + EXT[ct].toUpperCase() + '.' });
|
||||
const name = crypto.randomBytes(12).toString('hex') + '.' + EXT[ct];
|
||||
fs.writeFileSync(path.join(UPLOADS_DIR, name), buf);
|
||||
uploadCounts.set(key, (uploadCounts.get(key) || 0) + 1);
|
||||
return json(res, 200, { url: '/uploads/' + name, type: isVideo ? 'video' : 'image' });
|
||||
}
|
||||
m = /^\/api\/my\/inbox\/(\d+)\/claim$/.exec(p);
|
||||
if (m && req.method === 'POST') {
|
||||
const s = await auth.fromRequest(req);
|
||||
@@ -631,6 +675,8 @@ const server = http.createServer(async (req, res) => {
|
||||
if (p === '/contract') return sendFile(res, path.join(PUBLIC_DIR, 'contract.html'));
|
||||
if (p === '/my') return sendFile(res, path.join(PUBLIC_DIR, 'my.html'));
|
||||
if (/^\/view\/[a-f0-9]{32}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'view.html'));
|
||||
m = /^\/uploads\/([a-z0-9]{24}\.(?:png|jpg|webp|gif|mp4|webm))$/.exec(p);
|
||||
if (m) return sendFile(res, path.join(UPLOADS_DIR, m[1]));
|
||||
const safe = path.normalize(p).replace(/^([.\\/])+/, '');
|
||||
const file = path.join(PUBLIC_DIR, safe);
|
||||
if (file.startsWith(PUBLIC_DIR) && fs.existsSync(file) && fs.statSync(file).isFile()) return sendFile(res, file);
|
||||
|
||||
Reference in New Issue
Block a user