feat(users): add account limits and API keys
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m43s

This commit is contained in:
2026-05-04 02:27:36 +03:00
parent dc379ea6a6
commit d7cbba1bf2
14 changed files with 1688 additions and 271 deletions

View File

@@ -1,7 +1,15 @@
function authHeaders() {
const headers = {};
const apiKeyEnabled = Boolean(el.apiKeyMode?.checked);
const apiKey = String(el.apiKeyInput?.value || "").trim();
if (apiKeyEnabled && apiKey) headers.Authorization = `Bearer ${apiKey}`;
return headers;
}
async function createBox() {
const response = await fetch("/box", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({
retention_key: el.expiry?.value || defaultRetention,
password: el.password?.value || "",
@@ -28,7 +36,7 @@ async function markFileStatus(item, status) {
try {
await fetch(`/box/${item.boxID}/files/${item.boxFile.id}/status`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ status }),
});
} catch (_) {
@@ -62,6 +70,8 @@ function uploadFile(item, onComplete) {
formData.append("file", item.file, item.displayName);
xhr.open("POST", item.boxFile.upload_path);
const headers = authHeaders();
if (headers.Authorization) xhr.setRequestHeader("Authorization", headers.Authorization);
xhr.upload.addEventListener("loadstart", () => {
item.loaded = 0;

View File

@@ -34,8 +34,10 @@ function setBoxOptionsLocked(locked) {
function updateDisabledReasons() {
if (el.startButton) {
let reason = "";
const policyMessage = apiKeyPolicyMessage();
if (!uploadsEnabled) reason = "Guest uploads are disabled.";
else if (uploadLocked) reason = "This upload already started. Press Clear to create another box.";
else if (policyMessage) reason = policyMessage;
else if (hasQuotaError()) reason = "Over maximum upload size. Remove highlighted files or clear some files.";
else if (!files.length) reason = "There are no files selected. Please select files to upload.";
el.startButton.disabled = false;
@@ -101,6 +103,13 @@ function syncMenuChecks() {
function syncApiKeyField() {
const enabled = Boolean(el.apiKeyMode?.checked) && !uploadLocked;
el.apiKeyRow?.classList.toggle("is-visible", Boolean(el.apiKeyMode?.checked));
if (!el.apiKeyMode?.checked) {
clearTimeout(apiKeyTimer);
apiKeyValidationRun += 1;
resetAccountLimits();
updateLimitHint();
renderFiles();
}
if (el.apiKeyInput) {
el.apiKeyInput.disabled = !enabled;
el.apiKeyInput.dataset.disabledReason = enabled ? "" : "Enable Use API key for larger quota before typing an API key.";
@@ -115,30 +124,83 @@ function validateApiKeyField() {
wrapper?.classList.remove("is-checking");
if (!el.apiKeyMode?.checked) {
apiKeyValidationRun += 1;
resetAccountLimits();
el.apiKeyState.textContent = "";
updateLimitHint();
renderFiles();
return;
}
const value = el.apiKeyInput.value.trim();
if (!value) {
apiKeyValidationRun += 1;
resetAccountLimits();
el.apiKeyState.textContent = "waiting";
updateLimitHint();
renderFiles();
saveSettings();
return;
}
if (!validApiKey(value)) {
apiKeyValidationRun += 1;
resetAccountLimits();
el.apiKeyInput.value = "";
el.apiKeyState.textContent = "invalid";
updateLimitHint();
renderFiles();
saveSettings();
showToast("Invalid API key removed. Paste a valid API key to save it.", "warning");
return;
}
const runID = apiKeyValidationRun + 1;
apiKeyValidationRun = runID;
el.apiKeyInput.disabled = true;
wrapper?.classList.add("is-checking");
el.apiKeyState.textContent = "checking";
apiKeyTimer = setTimeout(() => {
wrapper?.classList.remove("is-checking");
el.apiKeyInput.disabled = uploadLocked;
if (validApiKey(value)) {
el.apiKeyState.textContent = "saved locally";
apiKeyTimer = setTimeout(async () => {
try {
const response = await fetch("/auth/me", {
headers: { Authorization: `Bearer ${value}` },
});
let payload = {};
try {
payload = await response.json();
} catch (_) {}
if (runID !== apiKeyValidationRun) return;
wrapper?.classList.remove("is-checking");
el.apiKeyInput.disabled = uploadLocked;
if (!response.ok || !payload.user) {
resetAccountLimits();
el.apiKeyInput.value = "";
el.apiKeyState.textContent = "invalid";
updateLimitHint();
renderFiles();
saveSettings();
showToast(payload.error || "API key was not accepted.", "warning");
return;
}
applyAccountLimits(payload.user);
const policyMessage = apiKeyPolicyMessage();
const fileText = maxFileBytes ? formatBytes(maxFileBytes) : "unlimited";
const boxText = maxBoxBytes ? formatBytes(maxBoxBytes) : "unlimited";
el.apiKeyState.textContent = policyMessage ? "limited by policy" : "account limits applied";
updateLimitHint();
renderFiles();
saveSettings();
} else {
el.apiKeyInput.value = "";
el.apiKeyState.textContent = "invalid";
saveSettings();
showToast("Invalid API key removed. Paste a valid API key to save it.", "warning");
setStatus(`${payload.user.username || payload.user.email} limits: file ${fileText}, box ${boxText}`);
if (policyMessage) showToast(policyMessage, "warning");
} catch (_) {
if (runID !== apiKeyValidationRun) return;
wrapper?.classList.remove("is-checking");
el.apiKeyInput.disabled = uploadLocked;
resetAccountLimits();
updateLimitHint();
renderFiles();
el.apiKeyState.textContent = "check failed";
showToast("Could not check API key limits.", "warning");
}
}, 650);
}

View File

@@ -44,16 +44,20 @@ const el = {
const uploadsEnabled = el.form?.dataset.uploadsEnabled === "true";
const defaultRetention = el.form?.dataset.defaultRetention || "10s";
const maxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes);
const maxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes);
const baseMaxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes);
const baseMaxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes);
const oneTimeRetentionKey = "one-time";
let maxFileBytes = baseMaxFileBytes;
let maxBoxBytes = baseMaxBoxBytes;
let files = [];
let shareUrl = "";
let uploadLocked = false;
let statusTimer = null;
let pendingDuplicateFiles = [];
let apiKeyTimer = null;
let apiKeyValidationRun = 0;
let authenticatedUser = null;
let completedImpactKeys = new Set();
let overallImpactDone = false;
@@ -105,6 +109,33 @@ function hasQuotaError() {
return isOverBoxQuota() || oversizedFiles().length > 0;
}
function effectiveLimit(baseLimit, userLimit) {
return numberFromDataset(userLimit);
}
function resetAccountLimits() {
authenticatedUser = null;
maxFileBytes = baseMaxFileBytes;
maxBoxBytes = baseMaxBoxBytes;
}
function applyAccountLimits(user) {
authenticatedUser = user || null;
const limits = authenticatedUser?.limits || {};
maxFileBytes = effectiveLimit(baseMaxFileBytes, limits.max_file_size_bytes);
maxBoxBytes = effectiveLimit(baseMaxBoxBytes, limits.max_box_size_bytes);
}
function apiKeyPolicyMessage() {
if (!el.apiKeyMode?.checked || !authenticatedUser) return "";
const permissions = authenticatedUser.permissions || {};
if (authenticatedUser.status && authenticatedUser.status !== "active") return "The API key belongs to a disabled account.";
if (!permissions.can_use_api) return "This account is not allowed to use the API.";
if (!permissions.can_create_box) return "This account is not allowed to create boxes.";
if (!permissions.can_upload_file) return "This account is not allowed to upload files.";
return "";
}
function normalizedFileName(name) {
return String(name || "").trim().toLowerCase();
}