Skip to content

Commit 9e86c15

Browse files
Gnathonicclaude
andcommitted
feat(mega): optional storage-upgrade referral link
- VITE_MEGA_REFERRAL_URL env var (verbatim link from MEGA's referral dashboard); unset = no link ever rendered, so forks/deploys never inherit a referral code. Guarded to https mega.nz/mega.io URLs. - Cloud page shows a disclosed upgrade link under the MEGA quota bar once usage reaches 80% (the bar's existing warning color). - uploadFile maps megajs EOVERQUOTA (-17) to a typed QUOTA_EXCEEDED error with an actionable message instead of the raw error text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3a78110 commit 9e86c15

7 files changed

Lines changed: 167 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ Create a `.env.local` file for cloud provider integration:
260260
VITE_GDRIVE_CLIENT_ID=your_client_id
261261
VITE_GDRIVE_API_KEY=your_api_key
262262
VITE_ONEDRIVE_CLIENT_ID=your_azure_app_client_id
263+
VITE_MEGA_REFERRAL_URL=https://mega.nz/aff=your_referral_code
263264
```
264265

265266
- `VITE_GDRIVE_*`: required only for Google Drive sync.
@@ -268,7 +269,12 @@ VITE_ONEDRIVE_CLIENT_ID=your_azure_app_client_id
268269
deploy origin as a **Single-page application** redirect URI. Scopes used:
269270
`Files.ReadWrite`, `offline_access`, `User.Read`. When unset, the OneDrive
270271
option is hidden from the cloud screen.
271-
- MEGA, WebDAV, and Local Folder require no env vars.
272+
- `VITE_MEGA_REFERRAL_URL`: optional. Paste a link from MEGA's referral
273+
dashboard verbatim (must be an https mega.nz/mega.io URL, or it is ignored).
274+
When set, users at ≥80% of their MEGA quota see a disclosed upgrade link on
275+
the cloud page; when unset, no link is rendered. Deliberately env-scoped so
276+
forks/deploys never inherit someone else's referral code.
277+
- MEGA, WebDAV, and Local Folder require no env vars to function.
272278

273279
## Testing
274280

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,9 @@ For OneDrive, register an Azure AD app ("common" authority) and add your deploy
196196
origin as a **Single-page application** redirect URI. When unset, the OneDrive
197197
option is hidden. MEGA, WebDAV, and Local Folder need no configuration.
198198

199+
Optionally, set `VITE_MEGA_REFERRAL_URL` to a MEGA referral link to show a
200+
disclosed storage-upgrade link to users nearing their MEGA quota.
201+
199202
## 💬 Community
200203

201204
Wanna chat with the devs? Share your hopes, dreams, and issues (with Mokuro Reader specifically)? Come join the [Mokuro Reader Discord](https://discord.gg/AU5pjjSQBw)!

src/lib/util/sync/providers/mega/mega-provider.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,3 +495,26 @@ describe('MegaProvider ghost-node handling', () => {
495495
expect(Object.keys(storage.files).sort()).toEqual(['ghost', 'root']);
496496
});
497497
});
498+
499+
describe('MegaProvider over-quota handling', () => {
500+
it('uploadFile maps EOVERQUOTA to a typed QUOTA_EXCEEDED error with a friendly message', async () => {
501+
const folder: any = {
502+
name: 'mokuro-reader',
503+
directory: true,
504+
upload: vi.fn((_opts: any, _buf: any, cb: (e: Error | null, f?: any) => void) => {
505+
queueMicrotask(() => cb(new Error('EOVERQUOTA (-17): Request over quota')));
506+
})
507+
};
508+
storageState.files = { root: folder };
509+
const provider = new MegaProvider();
510+
await provider.whenReady();
511+
await provider.login({ email: 'a@b.c', password: 'secret' });
512+
513+
await expect(
514+
provider.uploadFile('volume-data.json', new Uint8Array([1]))
515+
).rejects.toMatchObject({
516+
code: 'QUOTA_EXCEEDED',
517+
message: expect.stringContaining('storage is full')
518+
});
519+
});
520+
});

src/lib/util/sync/providers/mega/mega-provider.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ function isMegaNotFoundError(error: unknown): boolean {
100100
return message.includes('ENOENT') || message.includes('(-9)');
101101
}
102102

103+
/** megajs surfaces a full account as `EOVERQUOTA (-17)`. */
104+
function isMegaOverQuotaError(error: unknown): boolean {
105+
const message = error instanceof Error ? error.message : String(error);
106+
return message.includes('EOVERQUOTA') || message.includes('(-17)');
107+
}
108+
103109
/**
104110
* Smart retry wrapper for MEGA operations that may fail due to stale cache
105111
* When two devices sync back and forth, file IDs change but local cache is stale
@@ -800,6 +806,15 @@ export class MegaProvider implements SyncProvider {
800806

801807
return fileId;
802808
} catch (error) {
809+
// Typed QUOTA_EXCEEDED with a message users can act on, instead of
810+
// megajs's raw "EOVERQUOTA (-17): Request over quota".
811+
if (isMegaOverQuotaError(error)) {
812+
throw new ProviderError(
813+
'MEGA storage is full — free up space or upgrade your plan',
814+
'mega',
815+
'QUOTA_EXCEEDED'
816+
);
817+
}
803818
throw new ProviderError(
804819
`Failed to upload volume CBZ: ${error instanceof Error ? error.message : 'Unknown error'}`,
805820
'mega',
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
getMegaReferralUrl,
4+
shouldOfferMegaUpgrade,
5+
MEGA_UPGRADE_OFFER_THRESHOLD
6+
} from './referral';
7+
8+
describe('getMegaReferralUrl', () => {
9+
it('returns null when the env var is unset or empty', () => {
10+
expect(getMegaReferralUrl({})).toBeNull();
11+
expect(getMegaReferralUrl({ VITE_MEGA_REFERRAL_URL: '' })).toBeNull();
12+
expect(getMegaReferralUrl({ VITE_MEGA_REFERRAL_URL: undefined })).toBeNull();
13+
});
14+
15+
it('returns the URL for https MEGA domains', () => {
16+
expect(getMegaReferralUrl({ VITE_MEGA_REFERRAL_URL: 'https://mega.nz/aff=AbCd1234' })).toBe(
17+
'https://mega.nz/aff=AbCd1234'
18+
);
19+
expect(getMegaReferralUrl({ VITE_MEGA_REFERRAL_URL: 'https://mega.io/pro?aff=x' })).toBe(
20+
'https://mega.io/pro?aff=x'
21+
);
22+
});
23+
24+
it('rejects non-MEGA hosts and non-https URLs (misconfiguration guard)', () => {
25+
expect(getMegaReferralUrl({ VITE_MEGA_REFERRAL_URL: 'https://example.com/aff=x' })).toBeNull();
26+
expect(
27+
getMegaReferralUrl({ VITE_MEGA_REFERRAL_URL: 'https://mega.nz.evil.com/aff=x' })
28+
).toBeNull();
29+
expect(getMegaReferralUrl({ VITE_MEGA_REFERRAL_URL: 'http://mega.nz/aff=x' })).toBeNull();
30+
expect(getMegaReferralUrl({ VITE_MEGA_REFERRAL_URL: 'not a url' })).toBeNull();
31+
});
32+
});
33+
34+
describe('shouldOfferMegaUpgrade', () => {
35+
const url = 'https://mega.nz/aff=x';
36+
37+
it('is false without a referral URL, without quota data, or with an unknown total', () => {
38+
expect(shouldOfferMegaUpgrade({ used: 100, total: 100, available: 0 }, null)).toBe(false);
39+
expect(shouldOfferMegaUpgrade(null, url)).toBe(false);
40+
expect(shouldOfferMegaUpgrade({ used: 100, total: null, available: null }, url)).toBe(false);
41+
});
42+
43+
it('is true at or above the offer threshold and false below it', () => {
44+
const total = 1000;
45+
const atThreshold = Math.ceil(total * MEGA_UPGRADE_OFFER_THRESHOLD);
46+
expect(
47+
shouldOfferMegaUpgrade({ used: atThreshold, total, available: total - atThreshold }, url)
48+
).toBe(true);
49+
expect(shouldOfferMegaUpgrade({ used: total, total, available: 0 }, url)).toBe(true);
50+
expect(shouldOfferMegaUpgrade({ used: 500, total, available: 500 }, url)).toBe(false);
51+
});
52+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { StorageQuota } from '../../provider-interface';
2+
3+
/**
4+
* MEGA storage-upgrade referral offer.
5+
*
6+
* Configured via VITE_MEGA_REFERRAL_URL (paste the link generated by MEGA's
7+
* referral dashboard verbatim). When unset, no upgrade link is ever rendered —
8+
* same opt-in pattern as VITE_ONEDRIVE_CLIENT_ID, so forks and deploys never
9+
* inherit someone else's referral code by accident.
10+
*/
11+
12+
/** Offer the upgrade once usage reaches the quota bar's warning color. */
13+
export const MEGA_UPGRADE_OFFER_THRESHOLD = 0.8;
14+
15+
const MEGA_HOSTS = ['mega.nz', 'mega.io'];
16+
17+
/**
18+
* The configured referral URL, or null when unset or not an https MEGA link.
19+
* The host check is a misconfiguration guard: the app should never render an
20+
* "upgrade MEGA" link that leads anywhere but MEGA.
21+
*/
22+
export function getMegaReferralUrl(
23+
env: Record<string, string | undefined> = import.meta.env
24+
): string | null {
25+
const url = env?.VITE_MEGA_REFERRAL_URL;
26+
if (typeof url !== 'string' || url === '') return null;
27+
try {
28+
const parsed = new URL(url);
29+
if (parsed.protocol !== 'https:') return null;
30+
const host = parsed.hostname.toLowerCase();
31+
if (!MEGA_HOSTS.some((h) => host === h || host.endsWith(`.${h}`))) return null;
32+
} catch {
33+
return null;
34+
}
35+
return url;
36+
}
37+
38+
/** True when a referral link is configured and usage is at/above the threshold. */
39+
export function shouldOfferMegaUpgrade(
40+
quota: StorageQuota | null,
41+
referralUrl: string | null
42+
): boolean {
43+
if (!referralUrl || !quota || quota.total === null || quota.total <= 0) return false;
44+
return quota.used / quota.total >= MEGA_UPGRADE_OFFER_THRESHOLD;
45+
}

src/lib/views/CloudView.svelte

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,17 @@
2222
import { unifiedSyncService } from '$lib/util/sync/unified-sync-service';
2323
import { cacheManager } from '$lib/util/sync/cache-manager';
2424
import { isFilesystemProviderSupported } from '$lib/util/sync/providers/filesystem/feature-detect';
25+
import {
26+
getMegaReferralUrl,
27+
shouldOfferMegaUpgrade
28+
} from '$lib/util/sync/providers/mega/referral';
2529
import { PROVIDER_LABELS } from '$lib/util/sync/provider-display';
2630
2731
const CLOUD_ROOT_FOLDER = 'mokuro-reader';
2832
33+
// MEGA upgrade referral link (null unless VITE_MEGA_REFERRAL_URL is set).
34+
const megaReferralUrl = getMegaReferralUrl();
35+
2936
// Get store references for auto-subscription
3037
const providerStatusStore = providerManager.status;
3138
const cacheIsFetchingStore = cacheManager.isFetchingState;
@@ -1137,6 +1144,21 @@
11371144
(storageQuota.used / storageQuota.total) * 100
11381145
)}% used)
11391146
</div>
1147+
{#if currentProvider === 'mega' && shouldOfferMegaUpgrade(storageQuota, megaReferralUrl)}
1148+
<div class="pt-1 text-center">
1149+
<a
1150+
href={megaReferralUrl}
1151+
target="_blank"
1152+
rel="noopener noreferrer"
1153+
class="text-sm text-primary-500 hover:underline"
1154+
>
1155+
Running low on space? Upgrade your MEGA storage
1156+
</a>
1157+
<p class="text-xs text-gray-500">
1158+
Referral link — supports mokuro-reader at no extra cost to you
1159+
</p>
1160+
</div>
1161+
{/if}
11401162
</div>
11411163
{:else if storageQuota && storageQuota.used > 0}
11421164
<p class="text-sm text-gray-300">{formatBytes(storageQuota.used)} used</p>

0 commit comments

Comments
 (0)