mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
Bold Media Group's fleet broke on the 1.9.3->1.9.6 upgrade. Their MDM does an
uninstall/reinstall (app data wiped), so the player registers with
{ pairing_code, fingerprint } and NO device_id and shows a pairing code — but the
dashboard reported "code does not exist". Deleting the device_fingerprints row fixed
it, which pinpointed the fingerprint-reclaim guard in server/ws/deviceSocket.js.
Root cause: the reclaim guard was
`stillAlive = !!liveConn || secondsSince < reclaimSettleSeconds; if (stillAlive) reject`.
On an in-place reinstall the old row heartbeat seconds ago, so `secondsSince < 300` is
ALWAYS true -> it emitted device:auth-error and returned BEFORE the pairing_code INSERT,
so the code the player displayed never existed server-side.
The settle window's real purpose was to REMATCH an existing fingerprint back to its
device row on reinstall — not to force a fresh re-pair. So the fix keys off claim status,
not the timer (server-only; no APK change — reviewed and confirmed unnecessary):
- Reject ONLY when the old row has a genuinely LIVE socket (liveConn) — the real anti-
hijack boundary. Unchanged.
- CLAIMED old row (user_id set) -> RECLAIM it regardless of the settle window: reuse the
row, rotate the token, emit device:registered{online} + device:paired. The panel returns
straight to paired (no operator re-pair, no orphaned duplicate row), preserving name /
claim / playlist / content. device:paired drives the app off the pairing screen, so the
fresh code it showed is irrelevant.
- UNCLAIMED old row -> fall through to the pairing_code path and PROVISION FRESH with the
shown code (reclaiming would leave a stale/null code -> "code does not exist"). #150
relinks the fingerprint to the new row.
`reclaimSettleSeconds` is now vestigial for this path. Trade-off: a fingerprint-only reclaim
of a CLAIMED-but-offline device is no longer delayed ~300s — not a new attack class (the old
code already granted it once the window elapsed); liveConn remains the hard boundary. Truly
closing that window without a re-pair needs client keystore attestation (a future APK).
Also fixes a latent crash this newly exercises: middleware/subscription.js getUserPlan()
dereferenced an undefined user in its else branch ("Cannot set properties of undefined
(setting 'trial_active')") when the user/plan JOIN missed. Under the claimed-reclaim path
that ran checkDeviceAccess->getUserPlan, the throw was swallowed by the reclaim try/catch and
silently dropped the device to provision-fresh. Guard: `if (!user) return null`.
Tests (server/test/fingerprint-reclaim.test.js):
- NEW: a CLAIMED reinstall reclaims the SAME row, emits device:paired, creates no duplicate,
keeps the fingerprint linked — regardless of the settle window (the Bold repro, fixed right).
- NEW: recent heartbeat + no live socket, UNCLAIMED -> provisions fresh with the shown code.
- NEW: a LIVE old socket still rejects and creates no new row (security preserved).
- Updated the #143 gone-device test to expect provision-fresh for an unclaimed row, and the
log-noise assertion to the "reclaim rejected" message.
465/465 server tests pass. Server-only: NOT deployed, no version bump, Android untouched.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
153 lines
4.8 KiB
JavaScript
153 lines
4.8 KiB
JavaScript
const { db } = require('../db/database');
|
|
const config = require('../config');
|
|
|
|
const TRIAL_DAYS = 14;
|
|
|
|
function getUserPlan(userId) {
|
|
const user = db.prepare(`
|
|
SELECT u.*, p.name as plan_name, p.display_name as plan_display_name,
|
|
p.max_devices, p.max_storage_mb, p.remote_control, p.remote_url,
|
|
p.priority_support, p.price_monthly, p.price_yearly
|
|
FROM users u
|
|
JOIN plans p ON u.plan_id = p.id
|
|
WHERE u.id = ?
|
|
`).get(userId);
|
|
|
|
// No user row (or no joinable plan) — return null so callers treat it as unrestricted
|
|
// (checkDeviceAccess: `if (!plan) return { allowed: true }`). Previously the else branch
|
|
// below dereferenced an undefined `user` ("Cannot set properties of undefined"), which — once
|
|
// a claimed device's reclaim runs checkDeviceAccess — was swallowed by the caller's try/catch
|
|
// and silently dropped the device to the provision-fresh path instead of reclaiming it.
|
|
if (!user) return null;
|
|
|
|
// Check if trial has expired
|
|
if (user.trial_started) {
|
|
const trialEnd = user.trial_started + (TRIAL_DAYS * 86400);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
user.trial_active = now < trialEnd;
|
|
user.trial_days_left = Math.max(0, Math.ceil((trialEnd - now) / 86400));
|
|
user.trial_end = trialEnd;
|
|
|
|
// Auto-downgrade if trial expired and no paid subscription
|
|
if (!user.trial_active && user.subscription_status !== 'active' && user.plan_name !== 'free') {
|
|
db.prepare("UPDATE users SET plan_id = 'free', trial_started = NULL WHERE id = ?").run(userId);
|
|
// Re-fetch with free plan
|
|
return getUserPlan(userId);
|
|
}
|
|
} else {
|
|
user.trial_active = false;
|
|
user.trial_days_left = 0;
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
function getUserDeviceCount(userId) {
|
|
return db.prepare('SELECT COUNT(*) as count FROM devices WHERE user_id = ?').get(userId).count;
|
|
}
|
|
|
|
function getUserStorageMB(userId) {
|
|
const result = db.prepare('SELECT COALESCE(SUM(file_size), 0) as total FROM content WHERE user_id = ?').get(userId);
|
|
return Math.ceil(result.total / (1024 * 1024));
|
|
}
|
|
|
|
// Check if user can add more devices
|
|
function checkDeviceLimit(req, res, next) {
|
|
const plan = getUserPlan(req.user.id);
|
|
if (!plan) return res.status(403).json({ error: 'No plan found' });
|
|
|
|
// -1 means unlimited
|
|
if (plan.max_devices === -1) return next();
|
|
|
|
const deviceCount = getUserDeviceCount(req.user.id);
|
|
if (deviceCount >= plan.max_devices) {
|
|
return res.status(403).json({
|
|
error: `Device limit reached (${plan.max_devices} on ${plan.plan_display_name} plan). Upgrade to add more.`,
|
|
code: 'DEVICE_LIMIT',
|
|
current: deviceCount,
|
|
limit: plan.max_devices,
|
|
plan: plan.plan_name
|
|
});
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Check if user can upload more content
|
|
function checkStorageLimit(req, res, next) {
|
|
const plan = getUserPlan(req.user.id);
|
|
if (!plan) return res.status(403).json({ error: 'No plan found' });
|
|
|
|
// -1 means unlimited
|
|
if (plan.max_storage_mb === -1) return next();
|
|
|
|
const usedMB = getUserStorageMB(req.user.id);
|
|
if (usedMB >= plan.max_storage_mb) {
|
|
return res.status(403).json({
|
|
error: `Storage limit reached (${plan.max_storage_mb}MB on ${plan.plan_display_name} plan). Upgrade for more.`,
|
|
code: 'STORAGE_LIMIT',
|
|
current_mb: usedMB,
|
|
limit_mb: plan.max_storage_mb,
|
|
plan: plan.plan_name
|
|
});
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Check if user has remote control access
|
|
function checkRemoteControl(req, res, next) {
|
|
const plan = getUserPlan(req.user.id);
|
|
if (!plan || !plan.remote_control) {
|
|
return res.status(403).json({
|
|
error: 'Remote control requires Starter plan or above.',
|
|
code: 'FEATURE_LOCKED',
|
|
plan: plan?.plan_name
|
|
});
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Check remote URL feature access
|
|
function checkRemoteUrl(req, res, next) {
|
|
const plan = getUserPlan(req.user.id);
|
|
if (!plan || !plan.remote_url) {
|
|
return res.status(403).json({
|
|
error: 'Remote URL content requires Pro plan or above.',
|
|
code: 'FEATURE_LOCKED',
|
|
plan: plan?.plan_name
|
|
});
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Check subscription is active (not expired)
|
|
function checkActiveSubscription(req, res, next) {
|
|
const plan = getUserPlan(req.user.id);
|
|
if (!plan) return res.status(403).json({ error: 'No plan found' });
|
|
|
|
// Free plan is always active
|
|
if (plan.plan_name === 'free') return next();
|
|
|
|
// Self-hosted mode doesn't check expiry
|
|
if (config.selfHosted) return next();
|
|
|
|
// Check if subscription has expired
|
|
if (plan.subscription_status !== 'active' && plan.subscription_ends && plan.subscription_ends < Math.floor(Date.now() / 1000)) {
|
|
return res.status(403).json({
|
|
error: 'Subscription expired. Please renew to continue.',
|
|
code: 'SUBSCRIPTION_EXPIRED'
|
|
});
|
|
}
|
|
next();
|
|
}
|
|
|
|
module.exports = {
|
|
getUserPlan,
|
|
getUserDeviceCount,
|
|
getUserStorageMB,
|
|
checkDeviceLimit,
|
|
checkStorageLimit,
|
|
checkRemoteControl,
|
|
checkRemoteUrl,
|
|
checkActiveSubscription
|
|
};
|