feat: free-tier credit system + Syttende Mai access for Google users
- FreeTier.php: credit check/deduct/reset engine with hourly rate limit - bootstrap.php: dbnmDb() singleton, dbnToolsIsFreeTier(), credit gate helpers - index.php: store tier=free|approved in session from SSO JWT - All 7 API endpoints: credit gate (402/429) + X-Credits-Remaining header - layout.php: credit meta tag, JS balance var, Syttende Mai banner (05-17 only) - tools.js: credit badge in topbar, 402 modal, 429 toast, dbnUpdateCredits() - barnevernet.js + deep-research.js: wire 402/429 handling for NDJSON streams - tools.css: styles for credit badge, no-credits modal, rate-limit toast Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,9 @@ require_once __DIR__ . '/../includes/LegalTools.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
$ftUid = dbnToolsFreeTierCheck('ask');
|
||||
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'ask');
|
||||
if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
|
||||
$input = dbnToolsJsonInput(25000);
|
||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ require_once __DIR__ . '/../includes/BvjAnalyzerAgent.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
$ftUid = dbnToolsFreeTierCheck('barnevernet');
|
||||
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'barnevernet');
|
||||
|
||||
@ini_set('output_buffering', '0');
|
||||
@ini_set('zlib.output_compression', '0');
|
||||
@@ -16,6 +18,7 @@ ob_implicit_flush(true);
|
||||
header('Content-Type: application/x-ndjson; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Accel-Buffering: no');
|
||||
if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
|
||||
|
||||
$language = 'en';
|
||||
$startTime = microtime(true);
|
||||
|
||||
@@ -6,6 +6,8 @@ require_once __DIR__ . '/../includes/DeepResearchAgent.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
$ftUid = dbnToolsFreeTierCheck('deep-research');
|
||||
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'deep-research');
|
||||
|
||||
// Stream-friendly response — defeat output buffering so the user's browser
|
||||
// receives progress events while the agent runs (can take 60-180s for
|
||||
@@ -19,6 +21,7 @@ ob_implicit_flush(true);
|
||||
header('Content-Type: application/x-ndjson; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Accel-Buffering: no');
|
||||
if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
|
||||
|
||||
$language = 'en';
|
||||
$startTime = microtime(true);
|
||||
|
||||
@@ -5,6 +5,9 @@ require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
$ftUid = dbnToolsFreeTierCheck('extract');
|
||||
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'extract');
|
||||
if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
|
||||
|
||||
try {
|
||||
if (empty($_FILES['file']) || !is_array($_FILES['file'])) {
|
||||
|
||||
@@ -5,6 +5,9 @@ require_once __DIR__ . '/../includes/LegalTools.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
$ftUid = dbnToolsFreeTierCheck('redact');
|
||||
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'redact');
|
||||
if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
|
||||
$input = dbnToolsJsonInput(400000);
|
||||
|
||||
dbnToolsWithTelemetry('redact', '', function () use ($input): array {
|
||||
|
||||
@@ -5,6 +5,9 @@ require_once __DIR__ . '/../includes/LegalTools.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
$ftUid = dbnToolsFreeTierCheck('timeline');
|
||||
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'timeline');
|
||||
if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
|
||||
$input = dbnToolsJsonInput(400000);
|
||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ require_once __DIR__ . '/../includes/LegalTools.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
$ftUid = dbnToolsFreeTierCheck('transcribe');
|
||||
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'transcribe');
|
||||
if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
|
||||
|
||||
set_time_limit(0);
|
||||
ignore_user_abort(true);
|
||||
|
||||
@@ -4313,3 +4313,114 @@ body {
|
||||
.site-footer__inner { grid-template-columns: 1fr; }
|
||||
.site-footer__bottom { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* ── Syttende Mai banner ──────────────────────────────────────────────────── */
|
||||
.syttende-mai-banner {
|
||||
background: #ba0c2f;
|
||||
color: #fff;
|
||||
padding: 9px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
.syttende-mai-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.75);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.syttende-mai-close:hover { color: #fff; }
|
||||
|
||||
/* ── Free-tier credit badge ──────────────────────────────────────────────── */
|
||||
.credit-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
background: var(--soft-teal);
|
||||
color: var(--teal-dark);
|
||||
border: 1px solid rgba(15, 118, 110, 0.25);
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
.credit-badge.is-low {
|
||||
background: #fff7ed;
|
||||
color: #92400e;
|
||||
border-color: rgba(183, 121, 31, 0.3);
|
||||
}
|
||||
.credit-badge.is-empty {
|
||||
background: #fff0e8;
|
||||
color: #c2410c;
|
||||
border-color: rgba(194, 65, 12, 0.3);
|
||||
}
|
||||
|
||||
/* ── Credit-empty modal ───────────────────────────────────────────────────── */
|
||||
.credit-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(22, 19, 15, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 500;
|
||||
}
|
||||
.credit-modal {
|
||||
background: var(--panel);
|
||||
border-radius: 12px;
|
||||
padding: 32px 28px 24px;
|
||||
max-width: 380px;
|
||||
width: calc(100% - 40px);
|
||||
box-shadow: var(--shadow);
|
||||
text-align: center;
|
||||
}
|
||||
.credit-modal__icon { font-size: 2.5rem; margin-bottom: 12px; }
|
||||
.credit-modal h3 { margin: 0 0 8px; font-size: 1.15rem; color: var(--ink); }
|
||||
.credit-modal p { color: var(--muted); font-size: 0.9rem; margin: 0 0 20px; line-height: 1.55; }
|
||||
.credit-modal__actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
|
||||
.credit-modal__actions a {
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.credit-modal__cta { background: var(--teal); color: #fff; }
|
||||
.credit-modal__cta:hover { background: var(--teal-dark); }
|
||||
.credit-modal__dismiss { background: var(--line); color: var(--ink); }
|
||||
.credit-modal__dismiss:hover { background: #c8cdd8; }
|
||||
|
||||
/* ── Rate-limit toast ────────────────────────────────────────────────────── */
|
||||
.credit-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1b2330;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
z-index: 500;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||
animation: toastIn 0.2s ease;
|
||||
}
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
@@ -432,10 +432,19 @@
|
||||
}
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
setStatus(`Request failed (${response.status}).`, 'error');
|
||||
if (response.status === 402 || response.status === 429) {
|
||||
const d = await response.json().catch(() => ({}));
|
||||
if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d);
|
||||
} else {
|
||||
setStatus(`Request failed (${response.status}).`, 'error');
|
||||
}
|
||||
els.runButton.disabled = false;
|
||||
return;
|
||||
}
|
||||
const creditsRemaining = response.headers.get('X-Credits-Remaining');
|
||||
if (creditsRemaining !== null && typeof window.dbnUpdateCredits === 'function') {
|
||||
window.dbnUpdateCredits(parseInt(creditsRemaining, 10));
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
|
||||
@@ -364,10 +364,19 @@
|
||||
}
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
setStatus(`Request failed (${response.status}).`, 'error');
|
||||
if (response.status === 402 || response.status === 429) {
|
||||
const d = await response.json().catch(() => ({}));
|
||||
if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d);
|
||||
} else {
|
||||
setStatus(`Request failed (${response.status}).`, 'error');
|
||||
}
|
||||
els.runButton.disabled = false;
|
||||
return;
|
||||
}
|
||||
const creditsRemaining = response.headers.get('X-Credits-Remaining');
|
||||
if (creditsRemaining !== null && typeof window.dbnUpdateCredits === 'function') {
|
||||
window.dbnUpdateCredits(parseInt(creditsRemaining, 10));
|
||||
}
|
||||
|
||||
// Read the NDJSON stream
|
||||
const reader = response.body.getReader();
|
||||
|
||||
@@ -1202,6 +1202,12 @@ async function postJson(url, payload) {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.status === 402 || response.status === 429) {
|
||||
dbnFreeTierError(response.status, data);
|
||||
throw new Error(data.error?.message || (response.status === 429 ? 'rate_limit' : 'no_credits'));
|
||||
}
|
||||
const remaining = response.headers.get('X-Credits-Remaining');
|
||||
if (remaining !== null) { dbnUpdateCredits(parseInt(remaining, 10)); }
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message || `Request failed with HTTP ${response.status}.`);
|
||||
}
|
||||
@@ -1929,3 +1935,66 @@ function escapeHtml(value) {
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
// ── Free-tier credit badge ────────────────────────────────────────────────
|
||||
|
||||
let _freeTierBalance = (typeof window.DBN_FREE_TIER_BALANCE === 'number') ? window.DBN_FREE_TIER_BALANCE : -1;
|
||||
|
||||
function dbnUpdateCredits(balance) {
|
||||
if (typeof balance !== 'number' || balance < 0) return;
|
||||
_freeTierBalance = balance;
|
||||
const badge = document.getElementById('creditBadge');
|
||||
if (!badge) return;
|
||||
badge.textContent = '🪙 ' + balance + ' credit' + (balance !== 1 ? 's' : '');
|
||||
badge.classList.toggle('is-low', balance > 0 && balance <= 2);
|
||||
badge.classList.toggle('is-empty', balance === 0);
|
||||
}
|
||||
|
||||
// Exposed so barnevernet.js / deep-research.js can call it
|
||||
window.dbnUpdateCredits = dbnUpdateCredits;
|
||||
|
||||
function dbnFreeTierError(status, data) {
|
||||
if (status === 429) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'credit-toast';
|
||||
toast.textContent = 'Rate limit reached — you can make up to 10 requests per hour on the free tier.';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 5000);
|
||||
return;
|
||||
}
|
||||
// 402 — no credits
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'credit-modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="credit-modal" role="dialog" aria-modal="true" aria-labelledby="cmTitle">
|
||||
<div class="credit-modal__icon">🪙</div>
|
||||
<h3 id="cmTitle">Free credits used up</h3>
|
||||
<p>Your 10 free credits for this month have been used.<br>
|
||||
Credits reset on the 1st of next month — or upgrade to CaveauAI for unlimited access.</p>
|
||||
<div class="credit-modal__actions">
|
||||
<a href="https://dobetternorge.no/tools/" class="credit-modal__cta">Learn about CaveauAI</a>
|
||||
<button class="credit-modal__dismiss" onclick="this.closest('.credit-modal-overlay').remove()">Close</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
}
|
||||
|
||||
// Exposed so barnevernet.js / deep-research.js can call it
|
||||
window.dbnFreeTierError = dbnFreeTierError;
|
||||
|
||||
// Inject credit badge into topbar on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (_freeTierBalance < 0) return;
|
||||
const actions = document.querySelector('.topbar-actions');
|
||||
if (!actions) return;
|
||||
const badge = document.createElement('span');
|
||||
badge.id = 'creditBadge';
|
||||
badge.className = 'credit-badge';
|
||||
badge.title = 'Free monthly credits remaining';
|
||||
dbnUpdateCredits(_freeTierBalance); // set initial state before inserting
|
||||
badge.textContent = '🪙 ' + _freeTierBalance + ' credit' + (_freeTierBalance !== 1 ? 's' : '');
|
||||
badge.classList.toggle('is-low', _freeTierBalance > 0 && _freeTierBalance <= 2);
|
||||
badge.classList.toggle('is-empty', _freeTierBalance === 0);
|
||||
actions.insertBefore(badge, actions.firstChild);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Credit system for free-tier (Google-authenticated) users of tools.dobetternorge.no.
|
||||
*
|
||||
* Credits are stored in dobetternorge_maindb.user_tool_credits.
|
||||
* Usage is logged in user_tool_usage_log.
|
||||
*
|
||||
* CaveauAI client sessions (dbn_tools_user_id + client_id) bypass all checks.
|
||||
* Only SSO sessions with tier='free' are subject to limits.
|
||||
*/
|
||||
final class FreeTier
|
||||
{
|
||||
private const HOURLY_LIMIT = 10;
|
||||
|
||||
private const COSTS = [
|
||||
'corpus-search' => 0,
|
||||
'search' => 0,
|
||||
'ask' => 1,
|
||||
'extract' => 1,
|
||||
'timeline' => 2,
|
||||
'redact' => 2,
|
||||
'barnevernet' => 3,
|
||||
'advocate' => 3,
|
||||
'deep-research' => 5,
|
||||
'transcribe' => 2, // flat rate; actual duration unknown upfront
|
||||
];
|
||||
|
||||
/** Credit cost for a given tool slug. Returns 1 for unknown tools. */
|
||||
public static function cost(string $tool): int
|
||||
{
|
||||
return self::COSTS[$tool] ?? 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the user may proceed with a tool call.
|
||||
* Handles monthly reset automatically.
|
||||
*
|
||||
* Returns ['ok' => true, 'balance' => int]
|
||||
* or ['ok' => false, 'balance' => int, 'reason' => 'no_credits'|'rate_limit']
|
||||
*/
|
||||
public static function check(int $userId, string $tool): array
|
||||
{
|
||||
$db = dbnmDb();
|
||||
$cost = self::cost($tool);
|
||||
|
||||
// Auto-reset balance if a new month has begun since last reset
|
||||
$db->prepare(
|
||||
'UPDATE user_tool_credits
|
||||
SET balance = allowance, last_reset = CURDATE()
|
||||
WHERE user_id = ?
|
||||
AND (YEAR(last_reset) < YEAR(CURDATE()) OR MONTH(last_reset) < MONTH(CURDATE()))'
|
||||
)->execute([$userId]);
|
||||
|
||||
$row = $db->prepare(
|
||||
'SELECT balance FROM user_tool_credits WHERE user_id = ? LIMIT 1'
|
||||
);
|
||||
$row->execute([$userId]);
|
||||
$credits = $row->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($credits === false) {
|
||||
// No credits row — treat as 0 balance (shouldn't happen after ensureFreeTierCredits)
|
||||
return ['ok' => false, 'balance' => 0, 'reason' => 'no_credits'];
|
||||
}
|
||||
|
||||
$balance = (int)$credits['balance'];
|
||||
|
||||
// Free tools always pass
|
||||
if ($cost === 0) {
|
||||
return ['ok' => true, 'balance' => $balance];
|
||||
}
|
||||
|
||||
if ($balance < $cost) {
|
||||
return ['ok' => false, 'balance' => $balance, 'reason' => 'no_credits'];
|
||||
}
|
||||
|
||||
// Hourly rate limit check (counts any tool that costs > 0)
|
||||
$hourlyCount = $db->prepare(
|
||||
'SELECT COUNT(*) FROM user_tool_usage_log
|
||||
WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR) AND credits_used > 0'
|
||||
);
|
||||
$hourlyCount->execute([$userId]);
|
||||
if ((int)$hourlyCount->fetchColumn() >= self::HOURLY_LIMIT) {
|
||||
return ['ok' => false, 'balance' => $balance, 'reason' => 'rate_limit'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'balance' => $balance];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduct credits for a completed tool call and log the usage.
|
||||
* Safe to call even when cost is 0 (logs the call but deducts nothing).
|
||||
*/
|
||||
public static function deduct(int $userId, string $tool): int
|
||||
{
|
||||
$db = dbnmDb();
|
||||
$cost = self::cost($tool);
|
||||
|
||||
if ($cost > 0) {
|
||||
$db->prepare(
|
||||
'UPDATE user_tool_credits
|
||||
SET balance = GREATEST(0, balance - ?)
|
||||
WHERE user_id = ?'
|
||||
)->execute([$cost, $userId]);
|
||||
}
|
||||
|
||||
$db->prepare(
|
||||
'INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)'
|
||||
)->execute([$userId, $tool, $cost]);
|
||||
|
||||
$row = $db->prepare('SELECT balance FROM user_tool_credits WHERE user_id = ? LIMIT 1');
|
||||
$row->execute([$userId]);
|
||||
$r = $row->fetch(PDO::FETCH_ASSOC);
|
||||
return $r ? (int)$r['balance'] : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current balance for a user (after any pending monthly reset).
|
||||
*/
|
||||
public static function balance(int $userId): int
|
||||
{
|
||||
$result = self::check($userId, 'corpus-search'); // cost=0, triggers reset if needed
|
||||
return $result['balance'];
|
||||
}
|
||||
}
|
||||
@@ -385,6 +385,103 @@ function dbnToolsRagDb(): PDO
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PDO connection to dobetternorge_maindb (user accounts + credits).
|
||||
* Credentials come from DBNM_DB_* env vars.
|
||||
*/
|
||||
function dbnmDb(): PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
if ($pdo !== null) {
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
$host = dbnToolsEnv('DBNM_DB_HOST', 'localhost');
|
||||
$name = dbnToolsEnv('DBNM_DB_NAME', 'dobetternorge_maindb');
|
||||
$user = dbnToolsEnv('DBNM_DB_USER', 'root');
|
||||
$pass = dbnToolsEnv('DBNM_DB_PASS', '');
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
"mysql:host={$host};dbname={$name};charset=utf8mb4",
|
||||
$user,
|
||||
$pass,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]
|
||||
);
|
||||
} catch (PDOException $e) {
|
||||
throw new DbnToolsHttpException('Credit database is not reachable.', 503, 'credit_db_unavailable');
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the current session is a free-tier SSO user (Google login).
|
||||
* False for CaveauAI client sessions (always unlimited).
|
||||
*/
|
||||
function dbnToolsIsFreeTier(): bool
|
||||
{
|
||||
return !empty($_SESSION['dbn_tools_authenticated'])
|
||||
&& !empty($_SESSION['dbn_tools_sso_uid'])
|
||||
&& ($_SESSION['dbn_tools_tier'] ?? '') === 'free';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce free-tier credit gate before a tool call.
|
||||
* Exits with JSON 402/429 if the user is over limit or out of credits.
|
||||
* No-op for CaveauAI sessions.
|
||||
*
|
||||
* @return int The user_id for use in dbnToolsFreeTierDeduct()
|
||||
*/
|
||||
function dbnToolsFreeTierCheck(string $tool): int
|
||||
{
|
||||
if (!dbnToolsIsFreeTier()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/FreeTier.php';
|
||||
$uid = (int)$_SESSION['dbn_tools_sso_uid'];
|
||||
$result = FreeTier::check($uid, $tool);
|
||||
|
||||
if (!$result['ok']) {
|
||||
$isRateLimit = ($result['reason'] ?? '') === 'rate_limit';
|
||||
http_response_code($isRateLimit ? 429 : 402);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => ['code' => $result['reason'], 'message' => $isRateLimit
|
||||
? 'Rate limit reached — you can make up to 10 requests per hour on the free tier.'
|
||||
: 'No credits remaining. Your 10 free credits reset on the 1st of next month.',
|
||||
],
|
||||
'balance' => $result['balance'],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
return $uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduct credits after a successful tool call.
|
||||
* The $uid returned from dbnToolsFreeTierCheck() must be passed in.
|
||||
* No-op when uid === 0 (non-free-tier session).
|
||||
*
|
||||
* @return int Remaining balance (for response header)
|
||||
*/
|
||||
function dbnToolsFreeTierDeduct(int $uid, string $tool): int
|
||||
{
|
||||
if ($uid === 0) {
|
||||
return -1;
|
||||
}
|
||||
require_once __DIR__ . '/FreeTier.php';
|
||||
return FreeTier::deduct($uid, $tool);
|
||||
}
|
||||
|
||||
function dbnToolsClientSlug(): string
|
||||
{
|
||||
return dbnToolsEnv('DBN_CAVEAU_CLIENT_SLUG') ?: 'dobetter';
|
||||
|
||||
@@ -16,6 +16,13 @@ $toolTitle = $toolMeta['label'] ?? ($toolTitle ?? dbnToolsT('suite_title', $uiLa
|
||||
$toolKind = $toolMeta['sub'] ?? ($toolKind ?? '');
|
||||
$toolBadge = $toolMeta['badge'] ?? ($toolBadge ?? '');
|
||||
$langPath = strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?') ?: '/';
|
||||
|
||||
// Credit balance for free-tier users
|
||||
$layoutFreeTierBalance = -1;
|
||||
if (dbnToolsIsFreeTier()) {
|
||||
require_once __DIR__ . '/FreeTier.php';
|
||||
$layoutFreeTierBalance = FreeTier::balance((int)$_SESSION['dbn_tools_sso_uid']);
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?= htmlspecialchars($uiLang) ?>">
|
||||
@@ -23,13 +30,25 @@ $langPath = strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?') ?: '/';
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= htmlspecialchars($toolTitle) ?> - Do Better Norge</title>
|
||||
<?php if ($layoutFreeTierBalance >= 0): ?>
|
||||
<meta name="dbn-credits" content="<?= $layoutFreeTierBalance ?>">
|
||||
<?php endif; ?>
|
||||
<link rel="stylesheet" href="assets/css/tools.css">
|
||||
</head>
|
||||
<body data-authenticated="true" data-active-tool="<?= htmlspecialchars($toolName) ?>">
|
||||
<script>
|
||||
window.DBN_TOOLS_AUTHENTICATED = true;
|
||||
window.DBN_TOOLS_LANG = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
|
||||
<?php if ($layoutFreeTierBalance >= 0): ?>
|
||||
window.DBN_FREE_TIER_BALANCE = <?= $layoutFreeTierBalance ?>;
|
||||
<?php endif; ?>
|
||||
</script>
|
||||
<?php if (date('m-d') === '05-17'): ?>
|
||||
<div id="syttendeMaiBanner" class="syttende-mai-banner" role="banner">
|
||||
<span>🇳🇴 Gratulerer med dagen! Free access today — explore our AI legal tools for Norwegian family law.</span>
|
||||
<button onclick="this.parentElement.remove()" aria-label="Dismiss" class="syttende-mai-close">✕</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<main id="appShell" class="app-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
|
||||
@@ -34,6 +34,7 @@ if (isset($_GET['sso']) && !dbnToolsIsAuthenticated()) {
|
||||
$_SESSION['dbn_tools_user_id'] = (int)$tokenData['uid'];
|
||||
$_SESSION['dbn_tools_user_email'] = (string)$tokenData['email'];
|
||||
$_SESSION['dbn_tools_user_role'] = 'sso';
|
||||
$_SESSION['dbn_tools_tier'] = (string)($tokenData['tier'] ?? 'free');
|
||||
header('Location: ' . $returnPath);
|
||||
exit;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user