diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b492a6b80e..4036ac982a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -45,6 +45,7 @@ jobs: "PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false" "PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}" "PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY }}" + "PUBLIC_CONSOLE_FINGERPRINT_KEY=${{ secrets.PUBLIC_CONSOLE_FINGERPRINT_KEY }}" "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" "SENTRY_RELEASE=${{ github.event.release.tag_name }}" publish-cloud-stage: @@ -87,6 +88,7 @@ jobs: "PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false" "PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}" "PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}" + "PUBLIC_CONSOLE_FINGERPRINT_KEY=${{ secrets.PUBLIC_CONSOLE_FINGERPRINT_KEY_STAGE }}" publish-self-hosted: runs-on: ubuntu-latest steps: @@ -166,4 +168,5 @@ jobs: "PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false" "PUBLIC_CONSOLE_FEATURE_FLAGS=" "PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}" + "PUBLIC_CONSOLE_FINGERPRINT_KEY=${{ secrets.PUBLIC_CONSOLE_FINGERPRINT_KEY_STAGE }}" "PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}" diff --git a/Dockerfile b/Dockerfile index 788db3b627..691652e7cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,7 @@ ARG PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS ARG PUBLIC_APPWRITE_ENDPOINT ARG PUBLIC_GROWTH_ENDPOINT ARG PUBLIC_STRIPE_KEY +ARG PUBLIC_CONSOLE_FINGERPRINT_KEY ARG SENTRY_AUTH_TOKEN ARG SENTRY_RELEASE @@ -33,6 +34,7 @@ ENV PUBLIC_APPWRITE_MULTI_REGION=$PUBLIC_APPWRITE_MULTI_REGION ENV PUBLIC_CONSOLE_EMAIL_VERIFICATION=$PUBLIC_CONSOLE_EMAIL_VERIFICATION ENV PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=$PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS ENV PUBLIC_STRIPE_KEY=$PUBLIC_STRIPE_KEY +ENV PUBLIC_CONSOLE_FINGERPRINT_KEY=$PUBLIC_CONSOLE_FINGERPRINT_KEY ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN ENV SENTRY_RELEASE=$SENTRY_RELEASE ENV NODE_OPTIONS=--max_old_space_size=8192 diff --git a/bun.lock b/bun.lock index 8d4edefe9e..91d1e2db3e 100644 --- a/bun.lock +++ b/bun.lock @@ -6,13 +6,11 @@ "name": "@appwrite/console", "dependencies": { "@ai-sdk/svelte": "^1.1.24", - "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@c6f60aa", + "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@e64d5ed", "@appwrite.io/pink-icons": "0.25.0", "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@df765cc", - "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@df765cc", "@appwrite.io/pink-legacy": "^1.0.3", "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@df765cc", - "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@df765cc", "@faker-js/faker": "^9.9.0", "@plausible-analytics/tracker": "^0.4.4", "@popperjs/core": "^2.11.8", @@ -109,7 +107,7 @@ "@analytics/type-utils": ["@analytics/type-utils@0.6.4", "", {}, "sha512-Ou1gQxFakOWLcPnbFVsrPb8g1wLLUZYYJXDPjHkG07+5mustGs5yqACx42UAu4A6NszNN6Z5gGxhyH45zPWRxw=="], - "@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@c6f60aa", { "dependencies": { "bignumber.js": "9.0.0", "json-bigint": "1.0.0" } }], + "@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@e64d5ed", { "dependencies": { "bignumber.js": "9.0.0", "json-bigint": "1.0.0" } }], "@appwrite.io/pink-icons": ["@appwrite.io/pink-icons@0.25.0", "", {}, "sha512-0O3i2oEuh5mWvjO80i+X6rbzrWLJ1m5wmv2/M3a1p2PyBJsFxN8xQMTEmTn3Wl/D26SsM7SpzbdW6gmfgoVU9Q=="], diff --git a/package.json b/package.json index 9a6e6c9648..2520faac18 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ }, "dependencies": { "@ai-sdk/svelte": "^1.1.24", - "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@c6f60aa", + "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@e64d5ed", "@appwrite.io/pink-icons": "0.25.0", "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@df765cc", "@appwrite.io/pink-legacy": "^1.0.3", @@ -38,8 +38,6 @@ "dayjs": "^1.11.13", "deep-equal": "^2.2.3", "echarts": "^5.6.0", - "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@a4067bf": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@a4067bf", - "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@a4067bf": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@a4067bf", "ignore": "^6.0.2", "nanoid": "^5.1.5", "nanotar": "^0.1.1", diff --git a/src/lib/actions/analytics.ts b/src/lib/actions/analytics.ts index 305bf33d95..023e2fbeee 100644 --- a/src/lib/actions/analytics.ts +++ b/src/lib/actions/analytics.ts @@ -253,6 +253,7 @@ export enum Submit { ProjectUpdateLabels = 'submit_project_update_labels', ProjectService = 'submit_project_service', ProjectUpdateSMTP = 'submit_project_update_smtp', + ProjectResume = 'submit_project_resume', MemberCreate = 'submit_member_create', MemberDelete = 'submit_member_delete', MembershipUpdate = 'submit_membership_update', diff --git a/src/lib/helpers/fingerprint.ts b/src/lib/helpers/fingerprint.ts new file mode 100644 index 0000000000..9ecf76ed7a --- /dev/null +++ b/src/lib/helpers/fingerprint.ts @@ -0,0 +1,216 @@ +import { env } from '$env/dynamic/public'; + +const SECRET = env.PUBLIC_CONSOLE_FINGERPRINT_KEY ?? ''; +const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + +async function sha256(message: string): Promise { + const data = new TextEncoder().encode(message); + const hash = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +async function hmacSha256(message: string, secret: string): Promise { + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message)); + return Array.from(new Uint8Array(sig)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function getCanvasFingerprint(): string { + try { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return ''; + + canvas.width = 200; + canvas.height = 50; + + ctx.textBaseline = 'top'; + ctx.font = '14px Arial'; + ctx.fillStyle = '#f60'; + ctx.fillRect(125, 1, 62, 20); + ctx.fillStyle = '#069'; + ctx.fillText('Appwrite Console', 2, 15); + ctx.fillStyle = 'rgba(102, 204, 0, 0.7)'; + ctx.fillText('Appwrite Console', 4, 17); + + return canvas.toDataURL(); + } catch { + return ''; + } +} + +function getWebGLFingerprint(): string { + try { + const canvas = document.createElement('canvas'); + const gl = + canvas.getContext('webgl') || + (canvas.getContext('experimental-webgl') as WebGLRenderingContext | null); + if (!gl) return ''; + + const debugInfo = gl.getExtension('WEBGL_debug_renderer_info'); + if (!debugInfo) return 'webgl-no-debug'; + + const vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) || ''; + const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || ''; + + return `${vendor}~${renderer}`; + } catch { + return ''; + } +} + +async function getAudioFingerprint(): Promise { + try { + const OfflineCtx = + window.OfflineAudioContext || + (window as unknown as { webkitOfflineAudioContext: typeof OfflineAudioContext }) + .webkitOfflineAudioContext; + if (!OfflineCtx) return ''; + + const sampleRate = 44100; + const length = 4096; + const context = new OfflineCtx(1, length, sampleRate); + + const oscillator = context.createOscillator(); + oscillator.type = 'triangle'; + oscillator.frequency.value = 10000; + + const compressor = context.createDynamicsCompressor(); + compressor.threshold.value = -50; + compressor.knee.value = 40; + compressor.ratio.value = 12; + compressor.attack.value = 0; + compressor.release.value = 0.25; + + oscillator.connect(compressor); + compressor.connect(context.destination); + + oscillator.start(0); + + const buffer = await context.startRendering(); + const samples = buffer.getChannelData(0); + + let sum = 0; + for (let i = 0; i < samples.length; i++) { + sum += Math.abs(samples[i]); + } + + return sum.toString(); + } catch { + return ''; + } +} + +interface StaticSignals { + userAgent: string; + language: string; + languages: string[]; + platform: string; + hardwareConcurrency: number; + deviceMemory: number | undefined; + maxTouchPoints: number; + screenWidth: number; + screenHeight: number; + screenColorDepth: number; + screenPixelDepth: number; + devicePixelRatio: number; + timezoneOffset: number; + timezone: string; + canvas: string; + webgl: string; + audio: string; +} + +interface BrowserSignals extends StaticSignals { + timestamp: number; +} + +interface SignalsCache { + signals: StaticSignals; + collectedAt: number; +} + +let cache: SignalsCache | null = null; +let cachePromise: Promise | null = null; + +async function collectStaticSignals(): Promise { + const [canvasRaw, webgl, audio] = await Promise.all([ + Promise.resolve(getCanvasFingerprint()), + Promise.resolve(getWebGLFingerprint()), + getAudioFingerprint() + ]); + + const canvas = canvasRaw ? await sha256(canvasRaw) : ''; + + return { + userAgent: navigator.userAgent, + language: navigator.language, + languages: [...(navigator.languages || [])], + platform: navigator.platform, + hardwareConcurrency: navigator.hardwareConcurrency || 0, + deviceMemory: (navigator as Navigator & { deviceMemory?: number }).deviceMemory, + maxTouchPoints: navigator.maxTouchPoints || 0, + screenWidth: screen.width, + screenHeight: screen.height, + screenColorDepth: screen.colorDepth, + screenPixelDepth: screen.pixelDepth, + devicePixelRatio: window.devicePixelRatio || 1, + timezoneOffset: new Date().getTimezoneOffset(), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + canvas, + webgl, + audio + }; +} + +async function getCachedSignals(): Promise { + const now = Date.now(); + + if (cache && now - cache.collectedAt < CACHE_TTL_MS) { + return cache.signals; + } + + if (cachePromise) { + return cachePromise; + } + + cachePromise = collectStaticSignals(); + + try { + const signals = await cachePromise; + cache = { signals, collectedAt: now }; + return signals; + } finally { + cachePromise = null; + } +} + +export async function generateFingerprintToken(): Promise { + const staticSignals = await getCachedSignals(); + + const signals: BrowserSignals = { + ...staticSignals, + timestamp: Math.floor(Date.now() / 1000) + }; + + const payload = JSON.stringify(signals); + const encoded = btoa(payload); + + if (!SECRET) { + return encoded; + } + + const signature = await hmacSha256(encoded, SECRET); + + return `${encoded}.${signature}`; +} diff --git a/src/routes/(console)/organization-[organization]/+page.svelte b/src/routes/(console)/organization-[organization]/+page.svelte index 184a0ab878..b022f8f879 100644 --- a/src/routes/(console)/organization-[organization]/+page.svelte +++ b/src/routes/(console)/organization-[organization]/+page.svelte @@ -22,11 +22,12 @@ import { onMount, type ComponentType } from 'svelte'; import { canWriteProjects } from '$lib/stores/roles'; import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect'; - import { Alert, Badge, Icon, Layout, Tooltip, Typography } from '@appwrite.io/pink-svelte'; + import { Alert, Badge, Icon, Layout, Tag, Tooltip, Typography } from '@appwrite.io/pink-svelte'; import { IconAndroid, IconApple, IconCode, + IconExclamationCircle, IconFlutter, IconPlus, IconReact, @@ -210,6 +211,15 @@ {project.name} + + {#if project.status === 'paused'} + + + Paused + + {/if} + + {#each platforms.slice(0, 2) as platform} {@const icon = getIconForPlatform(platform.icon)} ? [Query.or([Query.search('search', search), Query.contains('labels', search)])] : []; const activeQueries = isCloud - ? [Query.or([Query.equal('status', 'active'), Query.isNull('status')])] + ? [Query.or([Query.equal('status', ['active', 'paused']), Query.isNull('status')])] : []; const activeProjects = await sdk.forConsole.projects.list({ diff --git a/src/routes/(console)/project-[region]-[project]/+layout.svelte b/src/routes/(console)/project-[region]-[project]/+layout.svelte index a2e8a1d585..58c8259123 100644 --- a/src/routes/(console)/project-[region]-[project]/+layout.svelte +++ b/src/routes/(console)/project-[region]-[project]/+layout.svelte @@ -25,6 +25,11 @@ canWriteSites } from '$lib/stores/roles'; import CsvImportBox from '$lib/components/csvImportBox.svelte'; + import { isCloud } from '$lib/system'; + import PausedProjectModal from './pausedProjectModal.svelte'; + import type { LayoutData } from './$types'; + + export let data: LayoutData; onMount(() => { return realtime.forProject(page.params.region, ['project', 'console'], (response) => { @@ -114,6 +119,10 @@ +{#if isCloud && data.project?.status === 'paused'} + +{/if} +
diff --git a/src/routes/(console)/project-[region]-[project]/+layout.ts b/src/routes/(console)/project-[region]-[project]/+layout.ts index 01700f3795..858f9625a3 100644 --- a/src/routes/(console)/project-[region]-[project]/+layout.ts +++ b/src/routes/(console)/project-[region]-[project]/+layout.ts @@ -11,6 +11,7 @@ import { loadAvailableRegions } from '$routes/(console)/regions'; import { type Models, Platform } from '@appwrite.io/console'; import { redirect } from '@sveltejs/kit'; import { resolve } from '$app/paths'; +import { generateFingerprintToken } from '$lib/helpers/fingerprint'; import { normalizeConsoleVariables } from '$lib/helpers/domains'; export const load: LayoutLoad = async ({ params, depends, parent }) => { @@ -18,7 +19,7 @@ export const load: LayoutLoad = async ({ params, depends, parent }) => { depends(Dependencies.PROJECT); const project = await sdk.forConsole.projects.get({ projectId: params.project }); - if (project.status !== 'active') { + if (project.status !== 'active' && project.status !== 'paused') { // project isn't active, redirect back to organizations page redirect( 303, @@ -102,6 +103,37 @@ export const load: LayoutLoad = async ({ params, depends, parent }) => { plansInfo.set(organization.billingPlanId, organizationPlan); } + // Track console access for cloud only (fire-and-forget, backend has 6-day cooldown) + // Don't call if project is paused - user must explicitly resume via createConsoleAccess + if (isCloud) { + const projectInactivityDays = organizationPlan?.projectInactivityDays ?? 0; + const consoleAccessedAt = (project as { consoleAccessedAt?: string }).consoleAccessedAt; + + let isPaused = false; + if (projectInactivityDays > 0 && consoleAccessedAt) { + const lastAccess = new Date(consoleAccessedAt); + const now = new Date(); + const diffDays = Math.floor( + (now.getTime() - lastAccess.getTime()) / (1000 * 60 * 60 * 24) + ); + isPaused = diffDays >= projectInactivityDays; + } + + if (!isPaused) { + generateFingerprintToken() + .then((fingerprint) => { + sdk.forConsole.client.headers['X-Appwrite-Console-Fingerprint'] = fingerprint; + return sdk.forConsole.projects.updateConsoleAccess({ + projectId: params.project + }); + }) + .catch(() => {}) + .finally(() => { + delete sdk.forConsole.client.headers['X-Appwrite-Console-Fingerprint']; + }); + } + } + return { project, organization, diff --git a/src/routes/(console)/project-[region]-[project]/pausedProjectModal.svelte b/src/routes/(console)/project-[region]-[project]/pausedProjectModal.svelte new file mode 100644 index 0000000000..d6e88e6660 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/pausedProjectModal.svelte @@ -0,0 +1,84 @@ + + + + + This project has been paused due to inactivity. + + Your data is safe and will remain intact. Resume the project to continue using it. + + + {#if error} + (error = null)}> + {error} + + {/if} + + + + + + + +