forked from maik/IPFS-portal
SIWE auth, API proxy fixes, dashboard, login, usage pages
- SIWE auth flow: challenge/login/logout/me API routes + JWT middleware
- Fixed [...path] catch-all: Kubo proxy with matchRoute() + mapToKuboAPI()
- Fixed session_id → nonce field mismatch in AuthProvider
- Fixed dashboard pins crash: Kubo returns {Keys: {...}}, not array
- Added middleware route guard (307 redirect to /login)
- Added login, register, profile, usage, upload pages
- Added dashboard components (StorageGauge, FreeTierProgress)
- Added SWR hooks + AuthGuard + Providers + Toast/ErrorBoundary
- Added payment integration (smart contract ABI, tiers, tokens)
- Fixed IPNS key listing: Name/Id → name/id mapping
- Added PWA support (manifest, service worker, icons)
- Added tests for helpers, limits, search-index, wallet
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatBytes, gatewayLink, truncateCid } from '../helpers';
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats 0 bytes', () => {
|
||||
expect(formatBytes(0)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('formats bytes without decimal', () => {
|
||||
expect(formatBytes(512)).toBe('512 B');
|
||||
});
|
||||
|
||||
it('formats KB with one decimal when < 10', () => {
|
||||
expect(formatBytes(1536)).toBe('1.5 KB');
|
||||
});
|
||||
|
||||
it('formats KB as integer when >= 10', () => {
|
||||
expect(formatBytes(15360)).toBe('15 KB');
|
||||
});
|
||||
|
||||
it('formats MB with one decimal when < 10', () => {
|
||||
expect(formatBytes(1048576)).toBe('1.0 MB');
|
||||
});
|
||||
|
||||
it('formats GB with one decimal when < 10', () => {
|
||||
expect(formatBytes(1073741824)).toBe('1.0 GB');
|
||||
});
|
||||
|
||||
it('formats TB with one decimal when < 10', () => {
|
||||
expect(formatBytes(1099511627776)).toBe('1.0 TB');
|
||||
});
|
||||
|
||||
it('formats large value as integer when >= 10', () => {
|
||||
// 10+ MB → integer, no decimal
|
||||
expect(formatBytes(11534336)).toBe('11 MB');
|
||||
});
|
||||
|
||||
it('handles negative values gracefully', () => {
|
||||
const result = formatBytes(-100);
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gatewayLink', () => {
|
||||
it('returns correct gateway URL for valid CID', () => {
|
||||
const cid = 'QmTest123';
|
||||
expect(gatewayLink(cid)).toBe('https://maos.dedyn.io/ipfs/QmTest123');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(gatewayLink('')).toBe('https://maos.dedyn.io/ipfs/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateCid', () => {
|
||||
it('returns full CID when shorter than chars', () => {
|
||||
expect(truncateCid('abc', 12)).toBe('abc');
|
||||
});
|
||||
|
||||
it('truncates long CID with ellipsis', () => {
|
||||
const cid = 'QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco';
|
||||
expect(truncateCid(cid, 12)).toBe('QmXoypizjW3W...');
|
||||
});
|
||||
|
||||
it('supports custom chars limit', () => {
|
||||
const cid = 'QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco';
|
||||
expect(truncateCid(cid, 6)).toBe('QmXoyp...');
|
||||
});
|
||||
|
||||
it('returns full CID when equal to chars', () => {
|
||||
expect(truncateCid('abcdef', 6)).toBe('abcdef');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(truncateCid('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { checkUploadAllowed, formatQuotaResult } from '../limits';
|
||||
|
||||
// Mock API calls — these functions hit live endpoints in production
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getRepoStats: vi.fn().mockRejectedValue(new Error('Not available')),
|
||||
listPins: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/payment', () => ({
|
||||
paymentService: {
|
||||
getUserUploads: vi.fn().mockRejectedValue(new Error('No contract')),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('checkUploadAllowed', () => {
|
||||
it('allows upload when usage is within limits', async () => {
|
||||
const result = await checkUploadAllowed();
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows upload with wallet when no contract data', async () => {
|
||||
const result = await checkUploadAllowed('0x1234567890123456789012345678901234567890');
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('applies custom quota override', async () => {
|
||||
// maxStorageMB=1 means 0 < 1 so still allowed when Kubo API fails
|
||||
const result = await checkUploadAllowed(undefined, { maxStorageMB: 1 });
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns correct default quota values', async () => {
|
||||
const result = await checkUploadAllowed();
|
||||
expect(result.quota.maxStorageMB).toBe(30_720);
|
||||
expect(result.quota.maxUploads).toBe(1_000);
|
||||
expect(result.quota.freeUploadsPerDay).toBe(50);
|
||||
expect(result.quota.maxPins).toBe(10_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatQuotaResult', () => {
|
||||
it('returns positive message when allowed', () => {
|
||||
const result = {
|
||||
allowed: true,
|
||||
quota: { maxStorageMB: 100, maxUploads: 10, freeUploadsPerDay: 5, maxPins: 100 },
|
||||
current: { storageUsedMB: 10, uploadCount: 2, pinCount: 3, todayUploadCount: 1 },
|
||||
};
|
||||
expect(formatQuotaResult(result as any)).toBe('Upload toegestaan');
|
||||
});
|
||||
|
||||
it('returns warning when blocked', () => {
|
||||
const result = {
|
||||
allowed: false,
|
||||
reason: 'Opslaglimiet bereikt',
|
||||
quota: { maxStorageMB: 100, maxUploads: 10, freeUploadsPerDay: 5, maxPins: 100 },
|
||||
current: { storageUsedMB: 100, uploadCount: 10, pinCount: 100, todayUploadCount: 5 },
|
||||
};
|
||||
expect(formatQuotaResult(result as any)).toBe('⚠️ Opslaglimiet bereikt');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isTextFile,
|
||||
extractText,
|
||||
addToIndex,
|
||||
removeFromIndex,
|
||||
clearIndex,
|
||||
getIndexStats,
|
||||
searchIndex,
|
||||
type IndexedEntry,
|
||||
} from '../search-index';
|
||||
|
||||
/* ── Note: search-index.ts gebruikt localStorage.
|
||||
* In vitest (node env) is localStorage niet beschikbaar.
|
||||
* De pure functies (isTextFile, extractText) testen we direct.
|
||||
* De storage-functies testen we alleen de logica.
|
||||
*/
|
||||
|
||||
describe('isTextFile', () => {
|
||||
it('recognises .txt', () => {
|
||||
expect(isTextFile('readme.txt')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises .md', () => {
|
||||
expect(isTextFile('CHANGELOG.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises .json', () => {
|
||||
expect(isTextFile('package.json')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises .html', () => {
|
||||
expect(isTextFile('index.html')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises .csv', () => {
|
||||
expect(isTextFile('data.csv')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises .log', () => {
|
||||
expect(isTextFile('error.log')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises .xml', () => {
|
||||
expect(isTextFile('config.xml')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises .yml', () => {
|
||||
expect(isTextFile('docker-compose.yml')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects .png', () => {
|
||||
expect(isTextFile('photo.png')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects .jpg', () => {
|
||||
expect(isTextFile('image.jpg')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects no-extension', () => {
|
||||
expect(isTextFile('Makefile')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractText', () => {
|
||||
it('preserves plain text', () => {
|
||||
expect(extractText('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('strips HTML tags', () => {
|
||||
expect(extractText('<p>Hello <b>world</b></p>')).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('collapses whitespace', () => {
|
||||
expect(extractText('hello world\n\n foo')).toBe('hello world foo');
|
||||
});
|
||||
|
||||
it('strips JSON braces', () => {
|
||||
expect(extractText('{"key": "value"}')).toBe('"key": "value"');
|
||||
});
|
||||
|
||||
it('limits to 2048 chars', () => {
|
||||
const long = 'a'.repeat(3000);
|
||||
expect(extractText(long).length).toBe(2048);
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(extractText('')).toBe('');
|
||||
});
|
||||
|
||||
it('handles complex HTML', () => {
|
||||
const html = `<!DOCTYPE html><html><head><title>Test</title></head>
|
||||
<body><h1>Hello</h1><p>This is a <a href="#">test</a> page.</p></body></html>`;
|
||||
expect(extractText(html)).toContain('Hello');
|
||||
expect(extractText(html)).toContain('This is a test page');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchIndex scoring (no localStorage)', () => {
|
||||
it('searchIndex returns empty for short query', () => {
|
||||
const results = searchIndex('x');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('searchIndex returns empty for empty query', () => {
|
||||
const results = searchIndex('');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('searchIndex returns empty when no index exists', () => {
|
||||
const results = searchIndex('hello');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage functions (no-op in node env)', () => {
|
||||
it('addToIndex does not throw', () => {
|
||||
const entry: IndexedEntry = {
|
||||
cid: 'QmTest',
|
||||
name: 'test.txt',
|
||||
type: 'file',
|
||||
text: 'hello world',
|
||||
size: 11,
|
||||
indexedAt: Date.now(),
|
||||
};
|
||||
expect(() => addToIndex(entry)).not.toThrow();
|
||||
});
|
||||
|
||||
it('removeFromIndex does not throw', () => {
|
||||
expect(() => removeFromIndex('QmTest')).not.toThrow();
|
||||
});
|
||||
|
||||
it('clearIndex does not throw', () => {
|
||||
expect(() => clearIndex()).not.toThrow();
|
||||
});
|
||||
|
||||
it('getIndexStats returns zeros', () => {
|
||||
// localStorage schrijft nergens heen in node env
|
||||
const stats = getIndexStats();
|
||||
expect(stats).toHaveProperty('entries');
|
||||
expect(stats).toHaveProperty('totalChars');
|
||||
expect(stats).toHaveProperty('lastIndexed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatWeiToETH, formatBytes, CHAIN_IDS } from '../wallet';
|
||||
|
||||
describe('formatWeiToETH', () => {
|
||||
it('formats 0 wei', () => {
|
||||
expect(formatWeiToETH(0n)).toBe('0.0000');
|
||||
});
|
||||
|
||||
it('formats 1 ETH', () => {
|
||||
expect(formatWeiToETH(10n ** 18n)).toBe('1.0000');
|
||||
});
|
||||
|
||||
it('formats 1.5 ETH', () => {
|
||||
expect(formatWeiToETH(15n * 10n ** 17n)).toBe('1.5000');
|
||||
});
|
||||
|
||||
it('formats small values', () => {
|
||||
expect(formatWeiToETH(10n ** 14n)).toBe('0.0001');
|
||||
});
|
||||
|
||||
it('accepts string input', () => {
|
||||
expect(formatWeiToETH('1000000000000000000')).toBe('1.0000');
|
||||
});
|
||||
|
||||
it('respects custom decimals', () => {
|
||||
expect(formatWeiToETH(10n ** 18n, 2)).toBe('1.00');
|
||||
});
|
||||
|
||||
it('handles zero decimals', () => {
|
||||
expect(formatWeiToETH(5n * 10n ** 17n, 0)).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBytes (wallet)', () => {
|
||||
it('formats 0 bytes', () => {
|
||||
expect(formatBytes(0n)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('formats bytes', () => {
|
||||
expect(formatBytes(500n)).toBe('500 B');
|
||||
});
|
||||
|
||||
it('formats KB', () => {
|
||||
expect(formatBytes(1500n)).toBe('1.5 KB');
|
||||
});
|
||||
|
||||
it('formats MB', () => {
|
||||
expect(formatBytes(3n * 1024n * 1024n)).toBe('3.0 MB');
|
||||
});
|
||||
|
||||
it('formats GB', () => {
|
||||
expect(formatBytes(2n * 1024n * 1024n * 1024n)).toBe('2.00 GB');
|
||||
});
|
||||
|
||||
it('accepts number input', () => {
|
||||
expect(formatBytes(1024)).toBe('1.0 KB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHAIN_IDS', () => {
|
||||
it('has expected chain constants', () => {
|
||||
expect(CHAIN_IDS.ZKSYNC_LOCAL).toBe(270);
|
||||
expect(CHAIN_IDS.ETHEREUM).toBe(1);
|
||||
expect(CHAIN_IDS.SEPOLIA).toBe(11155111);
|
||||
});
|
||||
});
|
||||
+6
-2
@@ -149,8 +149,12 @@ export interface IPNSKey {
|
||||
}
|
||||
|
||||
export async function listIPNSKeys(): Promise<IPNSKey[]> {
|
||||
const res = await apiFetch<{ Keys: IPNSKey[] }>('/ipns/keys');
|
||||
return res.Keys || [];
|
||||
const res = await apiFetch<{ Keys: { Name?: string; Id?: string }[] }>('/ipns/keys');
|
||||
if (!res.Keys) return [];
|
||||
return res.Keys.map((k: { Name?: string; Id?: string }) => ({
|
||||
name: k.Name ?? '',
|
||||
id: k.Id ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
export async function ipnsPublish(cid: string, key = 'self'): Promise<{ name: string; value: string }> {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/* ── IPFS Portal Server Auth ──
|
||||
*
|
||||
* JWT-gebaseerde sessies met wallet (SIWE) ondersteuning.
|
||||
* De JWT slaat het Python backend session_token op, zodat
|
||||
* de API proxy het kan doorsturen als X-Session-Token header.
|
||||
*/
|
||||
|
||||
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
|
||||
|
||||
/* ════════════════════════════ Config ════════════════════════════ */
|
||||
|
||||
export const SESSION_COOKIE = 'ipfs-portal-session';
|
||||
export const SESSION_MAX_AGE_SEC = 24 * 60 * 60; // 24 uur
|
||||
|
||||
function getSecret(): Uint8Array {
|
||||
const secret = process.env.JWT_SECRET || 'ipfs-portal-dev-secret-change-in-production';
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Types ════════════════════════════ */
|
||||
|
||||
export interface SessionPayload {
|
||||
address: string;
|
||||
role: 'admin' | 'user';
|
||||
sessionToken: string; // Python backend session token (UUID)
|
||||
}
|
||||
|
||||
export type SessionResult =
|
||||
| { authenticated: true; address: string; role: 'admin' | 'user'; sessionToken: string }
|
||||
| { authenticated: false };
|
||||
|
||||
/* ════════════════════════════ JWT Helpers ════════════════════════════ */
|
||||
|
||||
export async function createSessionJWT(payload: SessionPayload): Promise<string> {
|
||||
return new SignJWT({ ...payload } as unknown as JWTPayload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${SESSION_MAX_AGE_SEC}s`)
|
||||
.sign(getSecret());
|
||||
}
|
||||
|
||||
export async function verifySessionJWT(token: string): Promise<SessionPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getSecret(), {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
const { address, role, sessionToken } = payload as unknown as SessionPayload;
|
||||
if (!address || !role || !sessionToken) return null;
|
||||
return { address, role, sessionToken };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Cookie Helpers ════════════════════════════ */
|
||||
|
||||
export function cookieOptions(): {
|
||||
httpOnly: true;
|
||||
secure: boolean;
|
||||
sameSite: 'lax';
|
||||
maxAge: number;
|
||||
path: string;
|
||||
} {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: SESSION_MAX_AGE_SEC,
|
||||
path: '/',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import type { WalletProvider } from '@/lib/wallet';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export interface AuthUser {
|
||||
address: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
|
||||
export interface WalletOption {
|
||||
name: string;
|
||||
icon?: string;
|
||||
provider: WalletProvider;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
loading: boolean;
|
||||
loginWithWallet: (address: string, provider: WalletProvider) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
isAdmin: boolean;
|
||||
connectError: string | null;
|
||||
}
|
||||
|
||||
/* ── Context ── */
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
/* ── Provider ── */
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
|
||||
/* Check session on mount */
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.authenticated) {
|
||||
setUser({ address: data.address, role: data.role });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* server unreachable — stay logged out */
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const loginWithWallet = useCallback(async (address: string, provider: WalletProvider) => {
|
||||
setConnectError(null);
|
||||
|
||||
// 1. Get SIWE challenge message
|
||||
let challengeRes: Response;
|
||||
try {
|
||||
challengeRes = await fetch('/api/auth/challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address }),
|
||||
});
|
||||
} catch {
|
||||
setConnectError('Kan geen verbinding maken met de server');
|
||||
throw new Error('Network error');
|
||||
}
|
||||
|
||||
if (!challengeRes.ok) {
|
||||
const data = await challengeRes.json().catch(() => ({}));
|
||||
const msg = data.error || 'Challenge mislukt';
|
||||
setConnectError(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const { message, nonce } = await challengeRes.json();
|
||||
|
||||
// 2. Sign the message with the wallet
|
||||
let signature: string;
|
||||
try {
|
||||
signature = await provider.request({
|
||||
method: 'personal_sign',
|
||||
params: [message, address],
|
||||
});
|
||||
} catch {
|
||||
setConnectError('Signatuur geweigerd in wallet');
|
||||
throw new Error('User rejected signature');
|
||||
}
|
||||
|
||||
// 3. Send signed message to backend for verification
|
||||
let loginRes: Response;
|
||||
try {
|
||||
loginRes = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address, signature, nonce }),
|
||||
});
|
||||
} catch {
|
||||
setConnectError('Login request mislukt');
|
||||
throw new Error('Login network error');
|
||||
}
|
||||
|
||||
if (!loginRes.ok) {
|
||||
const data = await loginRes.json().catch(() => ({}));
|
||||
const msg = data.error || 'Verificatie mislukt';
|
||||
setConnectError(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const data = await loginRes.json();
|
||||
setUser({ address: data.address, role: data.role });
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, loginWithWallet, logout, isAdmin: user?.role === 'admin', connectError }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Hook ── */
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within <AuthProvider>');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* ════════════════════════════ Download ════════════════════════════
|
||||
* Utility om IPFS content te downloaden als bestand.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Download een bestand van IPFS via de explorer/cat proxy.
|
||||
* Maakt een Blob van de response en simuleert een klik op <a download>.
|
||||
*/
|
||||
export async function downloadFile(cid: string, filename?: string): Promise<void> {
|
||||
const res = await fetch(`/api/explorer/cat/${encodeURIComponent(cid)}`);
|
||||
if (!res.ok) throw new Error(`Download mislukt (${res.status})`);
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const ext = filename?.includes('.') ? '' : '.bin';
|
||||
const name = filename || `${cid.slice(0, 12)}${ext}`;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.rel = 'noopener noreferrer';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download via gateway (alternatief als proxy traag is).
|
||||
*/
|
||||
export function downloadViaGateway(cid: string, filename?: string) {
|
||||
const gateway = process.env.NEXT_PUBLIC_GATEWAY_URL || 'https://maos.dedyn.io';
|
||||
const a = document.createElement('a');
|
||||
a.href = `${gateway}/ipfs/${cid}`;
|
||||
a.download = filename || cid;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.click();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* ── Format bytes to human-readable size ── */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const val = bytes / Math.pow(1024, i);
|
||||
return val < 10 ? val.toFixed(1) + ' ' + units[i] : Math.round(val) + ' ' + units[i];
|
||||
}
|
||||
|
||||
/* ── Gateway link for CID ── */
|
||||
export function gatewayLink(cid: string): string {
|
||||
return `https://maos.dedyn.io/ipfs/${cid}`;
|
||||
}
|
||||
|
||||
/* ── Truncate CID for display ── */
|
||||
export function truncateCid(cid: string, chars: number = 12): string {
|
||||
if (cid.length <= chars) return cid;
|
||||
return `${cid.slice(0, chars)}...`;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/* ── Usage Limits ──
|
||||
*
|
||||
* Defines per-user/wallet quotas and checks current usage against them.
|
||||
* Uses Kubo API stats + contract data to determine if an upload is allowed.
|
||||
*/
|
||||
|
||||
import { getNodeInfo, getRepoStats, listPins } from './api';
|
||||
|
||||
/* ════════════════════════════ Types ════════════════════════════ */
|
||||
|
||||
export interface UsageQuota {
|
||||
maxStorageMB: number; // Max repo size in MB
|
||||
maxUploads: number; // Max total uploads
|
||||
freeUploadsPerDay: number; // Free tier: max uploads per day
|
||||
maxPins: number; // Max pinned CIDs
|
||||
}
|
||||
|
||||
export interface UsageCurrent {
|
||||
storageUsedMB: number;
|
||||
uploadCount: number;
|
||||
pinCount: number;
|
||||
todayUploadCount: number;
|
||||
}
|
||||
|
||||
export interface UsageCheckResult {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
quota: UsageQuota;
|
||||
current: UsageCurrent;
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Defaults ════════════════════════════ */
|
||||
|
||||
const DEFAULT_QUOTA: UsageQuota = {
|
||||
maxStorageMB: 30_720, // 30 GB
|
||||
maxUploads: 1_000,
|
||||
freeUploadsPerDay: 50,
|
||||
maxPins: 10_000,
|
||||
};
|
||||
|
||||
/* ════════════════════════════ Check ════════════════════════════ */
|
||||
|
||||
export async function checkUploadAllowed(
|
||||
wallet?: string,
|
||||
quotaOverride?: Partial<UsageQuota>,
|
||||
): Promise<UsageCheckResult> {
|
||||
const quota: UsageQuota = { ...DEFAULT_QUOTA, ...quotaOverride };
|
||||
const current: UsageCurrent = {
|
||||
storageUsedMB: 0,
|
||||
uploadCount: 0,
|
||||
pinCount: 0,
|
||||
todayUploadCount: 0,
|
||||
};
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
/* ── Gather current usage ── */
|
||||
|
||||
// 1. Storage from Kubo repo stat
|
||||
try {
|
||||
const repo = await getRepoStats();
|
||||
current.storageUsedMB = Math.round(repo.repoSize / (1024 * 1024));
|
||||
current.pinCount = repo.numObjects;
|
||||
} catch {
|
||||
// Kubo unreachable — allow through, log elsewhere
|
||||
}
|
||||
|
||||
// 2. Pin count
|
||||
try {
|
||||
const pins = await listPins();
|
||||
current.pinCount = pins.length;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 3. On-chain upload count (if wallet provided)
|
||||
if (wallet) {
|
||||
try {
|
||||
const { paymentService } = await import('@/lib/payment');
|
||||
const stats = await paymentService.getUserUploads(wallet as `0x${string}`);
|
||||
current.uploadCount = stats.uploadCount;
|
||||
|
||||
// Count today's uploads
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayTs = BigInt(Math.floor(today.getTime() / 1000));
|
||||
current.todayUploadCount = stats.uploads.filter(
|
||||
(u) => u.timestamp >= todayTs,
|
||||
).length;
|
||||
} catch {
|
||||
// Contract unreachable — assume wallet has no on-chain data
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Check limits ── */
|
||||
|
||||
if (current.storageUsedMB >= quota.maxStorageMB) {
|
||||
const usedGb = (current.storageUsedMB / 1024).toFixed(1);
|
||||
const maxGb = (quota.maxStorageMB / 1024).toFixed(1);
|
||||
errors.push(`Opslaglimiet bereikt (${usedGb}/${maxGb} GB)`);
|
||||
}
|
||||
|
||||
if (current.pinCount >= quota.maxPins) {
|
||||
errors.push(`Maximaal aantal pins bereikt (${quota.maxPins})`);
|
||||
}
|
||||
|
||||
if (current.uploadCount >= quota.maxUploads) {
|
||||
errors.push(`Maximaal aantal uploads bereikt (${quota.maxUploads})`);
|
||||
}
|
||||
|
||||
if (current.todayUploadCount >= quota.freeUploadsPerDay) {
|
||||
errors.push(`Dagelijkse gratis limiet bereikt (${quota.freeUploadsPerDay}/dag)`);
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: errors.length === 0,
|
||||
reason: errors.length > 0 ? errors.join('; ') : undefined,
|
||||
quota,
|
||||
current,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Format helper ── */
|
||||
|
||||
export function formatQuotaResult(result: UsageCheckResult): string {
|
||||
if (result.allowed) return 'Upload toegestaan';
|
||||
return `⚠️ ${result.reason}`;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export type NotificationType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number; // ms, default 4000
|
||||
}
|
||||
|
||||
interface NotificationContextValue {
|
||||
notifications: Notification[];
|
||||
notify: (n: Omit<Notification, 'id'>) => string;
|
||||
dismiss: (id: string) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
/* ── Context ── */
|
||||
|
||||
const NotificationContext = createContext<NotificationContextValue | null>(null);
|
||||
|
||||
/* ── Provider ── */
|
||||
|
||||
export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||
}, []);
|
||||
|
||||
const notify = useCallback(
|
||||
(n: Omit<Notification, 'id'>): string => {
|
||||
const id = `ntf-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const notification: Notification = { ...n, id, duration: n.duration ?? 4000 };
|
||||
|
||||
setNotifications((prev) => [...prev, notification]);
|
||||
|
||||
const dur = notification.duration ?? 5000;
|
||||
if (dur > 0) {
|
||||
setTimeout(() => dismiss(id), dur);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
[dismiss],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setNotifications([]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={{ notifications, notify, dismiss, clear }}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Hook ── */
|
||||
|
||||
export function useNotify(): NotificationContextValue {
|
||||
const ctx = useContext(NotificationContext);
|
||||
if (!ctx) throw new Error('useNotify must be used within <NotificationProvider>');
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
/* ── IPFS Portal Payment Service ── */
|
||||
|
||||
import {
|
||||
createWalletClient,
|
||||
createPublicClient,
|
||||
custom,
|
||||
http,
|
||||
encodeFunctionData,
|
||||
type Chain,
|
||||
type Address,
|
||||
type Hash,
|
||||
} from 'viem';
|
||||
import { getInjectedProvider, type WalletProvider, type WalletState } from './wallet';
|
||||
|
||||
/* ── Chain 270 (zkSync Local) ── */
|
||||
export const zkSyncLocal: Chain = {
|
||||
id: 270,
|
||||
name: 'zkSync Local',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: {
|
||||
default: { http: ['http://tom1687.no-ip.org:3050'] },
|
||||
},
|
||||
blockExplorers: {
|
||||
default: { name: 'Explorer', url: 'http://tom1687.no-ip.org:3050' },
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Contract ABI ── */
|
||||
export const IPFS_PORTAL_PAYMENT_ABI = [
|
||||
// ── State ──
|
||||
{ type: 'function', name: 'owner', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'maosTokenAddress', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'freeTierBytes', inputs: [], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'basePricePerMB', inputs: [], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'userTotalBytes', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'totalRevenue', inputs: [{ type: 'string' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'totalUploads', inputs: [], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'supportedTokens', inputs: [{ type: 'uint256' }], outputs: [{ type: 'string' }], stateMutability: 'view' },
|
||||
|
||||
// ── View ──
|
||||
{ type: 'function', name: 'getPrice', inputs: [{ type: 'uint256' }, { type: 'address' }], outputs: [
|
||||
{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'bool' }
|
||||
], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'getUserStats', inputs: [{ type: 'address' }], outputs: [
|
||||
{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' },
|
||||
{ type: 'tuple[]', components: [
|
||||
{ type: 'string' }, { type: 'uint256' }, { type: 'string' }, { type: 'uint256' }, { type: 'uint256' }
|
||||
]}
|
||||
], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'getMAOSDiscountTiers', inputs: [], outputs: [
|
||||
{ type: 'tuple[]', components: [{ type: 'uint256' }, { type: 'uint256' }] }
|
||||
], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'getSupportedTokens', inputs: [], outputs: [{ type: 'string[]' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'tokenConfigs', inputs: [{ type: 'string' }], outputs: [
|
||||
{ type: 'address' }, { type: 'uint8' }, { type: 'uint256' }, { type: 'bool' }
|
||||
], stateMutability: 'view' },
|
||||
|
||||
// ── Write ──
|
||||
{ type: 'function', name: 'payWithETH', inputs: [{ type: 'string' }, { type: 'uint256' }], outputs: [], stateMutability: 'payable' },
|
||||
{ type: 'function', name: 'payWithToken', inputs: [{ type: 'string' }, { type: 'uint256' }, { type: 'string' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
|
||||
// ── Admin Write ──
|
||||
{ type: 'function', name: 'setPricePerMB', inputs: [{ type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'setFreeTierBytes', inputs: [{ type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'setMAOSDiscountTier', inputs: [{ type: 'uint256' }, { type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'removeMAOSDiscountTier', inputs: [{ type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'configureToken', inputs: [{ type: 'string' }, { type: 'address' }, { type: 'uint8' }, { type: 'uint256' }, { type: 'bool' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'setMAOSTokenAddress', inputs: [{ type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'withdrawETH', inputs: [{ type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'withdrawToken', inputs: [{ type: 'string' }, { type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'transferOwnership', inputs: [{ type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
|
||||
// ── Events ──
|
||||
{ type: 'event', name: 'UploadPaidETH', inputs: [
|
||||
{ type: 'address', indexed: true }, { type: 'string', indexed: true },
|
||||
{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }
|
||||
]},
|
||||
{ type: 'event', name: 'UploadPaidToken', inputs: [
|
||||
{ type: 'address', indexed: true }, { type: 'string', indexed: true },
|
||||
{ type: 'uint256' }, { type: 'string', indexed: true }, { type: 'uint256' }, { type: 'uint256' }
|
||||
]},
|
||||
{ type: 'event', name: 'FreeUploadUsed', inputs: [
|
||||
{ type: 'address', indexed: true }, { type: 'string', indexed: true },
|
||||
{ type: 'uint256' }, { type: 'uint256' }
|
||||
]},
|
||||
] as const;
|
||||
|
||||
/* ── Default contract address (set after deploy) ── */
|
||||
export const DEFAULT_CONTRACT_ADDRESS = '0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe';
|
||||
|
||||
/* ── ERC20 ABI (minimal for approve + balanceOf) ── */
|
||||
const ERC20_ABI = [
|
||||
{ type: 'function', name: 'balanceOf', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'approve', inputs: [{ type: 'address' }, { type: 'uint256' }], outputs: [{ type: 'bool' }], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'allowance', inputs: [{ type: 'address' }, { type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'decimals', inputs: [], outputs: [{ type: 'uint8' }], stateMutability: 'view' },
|
||||
] as const;
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export interface PriceInfo {
|
||||
totalWei: bigint;
|
||||
discountBps: number;
|
||||
finalWei: bigint;
|
||||
isFree: boolean;
|
||||
}
|
||||
|
||||
export interface UserUploadStats {
|
||||
totalBytes: bigint;
|
||||
uploadCount: number;
|
||||
remainingFree: bigint;
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
symbol: string;
|
||||
address: Address;
|
||||
decimals: number;
|
||||
weiPerToken: bigint;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface MAOSDiscountTier {
|
||||
minBalance: bigint;
|
||||
discountBps: number;
|
||||
}
|
||||
|
||||
export interface UploadRecord {
|
||||
cid: string;
|
||||
bytesSize: bigint;
|
||||
tokenSymbol: string;
|
||||
amountPaid: bigint;
|
||||
timestamp: bigint;
|
||||
}
|
||||
|
||||
export interface UserFullStats {
|
||||
totalBytes: bigint;
|
||||
uploadCount: number;
|
||||
remainingFree: bigint;
|
||||
uploads: UploadRecord[];
|
||||
}
|
||||
|
||||
/* ── PaymentService ── */
|
||||
|
||||
export class PaymentService {
|
||||
private contractAddress: Address;
|
||||
|
||||
constructor(contractAddress?: string) {
|
||||
this.contractAddress = (contractAddress || DEFAULT_CONTRACT_ADDRESS) as Address;
|
||||
}
|
||||
|
||||
setContractAddress(address: string) {
|
||||
this.contractAddress = address as Address;
|
||||
}
|
||||
|
||||
/* ── Create public client (read-only) ── */
|
||||
private getPublicClient() {
|
||||
return createPublicClient({
|
||||
chain: zkSyncLocal,
|
||||
transport: http(),
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Create wallet client from provider ── */
|
||||
private getWalletClient(provider: WalletProvider) {
|
||||
return createWalletClient({
|
||||
chain: zkSyncLocal,
|
||||
transport: custom(provider),
|
||||
});
|
||||
}
|
||||
|
||||
/* ── READ: Get price for upload ── */
|
||||
async getPrice(bytesSize: number, userAddress: Address): Promise<PriceInfo> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getPrice',
|
||||
args: [BigInt(bytesSize), userAddress],
|
||||
});
|
||||
return {
|
||||
totalWei: result[0] as bigint,
|
||||
discountBps: Number(result[1]),
|
||||
finalWei: result[2] as bigint,
|
||||
isFree: result[3] as boolean,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── READ: Get user upload stats ── */
|
||||
async getUserStats(userAddress: Address): Promise<UserUploadStats> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getUserStats',
|
||||
args: [userAddress],
|
||||
});
|
||||
return {
|
||||
totalBytes: result[0] as bigint,
|
||||
uploadCount: Number(result[1]),
|
||||
remainingFree: result[2] as bigint,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── READ: Get full user stats including upload records ── */
|
||||
async getUserUploads(userAddress: Address): Promise<UserFullStats> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getUserStats',
|
||||
args: [userAddress],
|
||||
}) as unknown as readonly [bigint, bigint, bigint, readonly (readonly [string, bigint, string, bigint, bigint])[]];
|
||||
|
||||
const records = result[3] as unknown as readonly (readonly [string, bigint, string, bigint, bigint])[] ?? [];
|
||||
const uploads: UploadRecord[] = Array.from(records).map((rec) => ({
|
||||
cid: rec[0],
|
||||
bytesSize: rec[1],
|
||||
tokenSymbol: rec[2],
|
||||
amountPaid: rec[3],
|
||||
timestamp: rec[4],
|
||||
}));
|
||||
|
||||
return {
|
||||
totalBytes: result[0],
|
||||
uploadCount: Number(result[1]),
|
||||
remainingFree: result[2],
|
||||
uploads,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── READ: Get supported tokens ── */
|
||||
async getSupportedTokens(): Promise<TokenInfo[]> {
|
||||
const client = this.getPublicClient();
|
||||
const symbols = await client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getSupportedTokens',
|
||||
args: [],
|
||||
}) as string[];
|
||||
|
||||
const tokens: TokenInfo[] = [];
|
||||
for (const symbol of symbols) {
|
||||
const config = await client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'tokenConfigs',
|
||||
args: [symbol],
|
||||
});
|
||||
tokens.push({
|
||||
symbol,
|
||||
address: (config as any)[0] as Address,
|
||||
decimals: (config as any)[1] as number,
|
||||
weiPerToken: (config as any)[2] as bigint,
|
||||
enabled: (config as any)[3] as boolean,
|
||||
});
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/* ── READ: Get MAOS discount tiers ── */
|
||||
async getMAOSDiscountTiers(): Promise<MAOSDiscountTier[]> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getMAOSDiscountTiers',
|
||||
args: [],
|
||||
}) as unknown;
|
||||
|
||||
const tiers = result as readonly (readonly [bigint, bigint])[];
|
||||
return Array.from(tiers).map(([minBalance, discountBps]) => ({
|
||||
minBalance,
|
||||
discountBps: Number(discountBps),
|
||||
}));
|
||||
}
|
||||
|
||||
/* ── READ: Get free tier bytes ── */
|
||||
async getFreeTierBytes(): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'freeTierBytes',
|
||||
args: [],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── READ: Get base price per MB ── */
|
||||
async getBasePricePerMB(): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'basePricePerMB',
|
||||
args: [],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── WRITE: Pay with ETH ── */
|
||||
async payWithETH(
|
||||
provider: WalletProvider,
|
||||
cid: string,
|
||||
bytesSize: number,
|
||||
priceWei: bigint,
|
||||
): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
|
||||
const hash = await walletClient.sendTransaction({
|
||||
account: address,
|
||||
to: this.contractAddress,
|
||||
value: priceWei,
|
||||
data: this.encodePayWithETH(cid, bytesSize),
|
||||
});
|
||||
return hash;
|
||||
}
|
||||
|
||||
/* ── WRITE: Approve ERC20 + Pay with token ── */
|
||||
async approveAndPayWithToken(
|
||||
provider: WalletProvider,
|
||||
tokenAddress: Address,
|
||||
cid: string,
|
||||
bytesSize: number,
|
||||
tokenAmount: bigint,
|
||||
tokenSymbol: string,
|
||||
): Promise<{ approveHash?: Hash; payHash: Hash }> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
|
||||
// Check allowance
|
||||
const publicClient = this.getPublicClient();
|
||||
const allowance = await publicClient.readContract({
|
||||
address: tokenAddress,
|
||||
abi: ERC20_ABI,
|
||||
functionName: 'allowance',
|
||||
args: [address, this.contractAddress],
|
||||
}) as bigint;
|
||||
|
||||
let approveHash: Hash | undefined;
|
||||
if (allowance < tokenAmount) {
|
||||
const approveReq = await walletClient.writeContract({
|
||||
account: address,
|
||||
address: tokenAddress,
|
||||
abi: ERC20_ABI,
|
||||
functionName: 'approve',
|
||||
args: [this.contractAddress, tokenAmount],
|
||||
});
|
||||
approveHash = approveReq;
|
||||
}
|
||||
|
||||
// Pay
|
||||
const payHash = await walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'payWithToken',
|
||||
args: [cid, BigInt(bytesSize), tokenSymbol],
|
||||
});
|
||||
|
||||
return { approveHash, payHash };
|
||||
}
|
||||
|
||||
/* ── Encode payWithETH data ── */
|
||||
private encodePayWithETH(cid: string, bytesSize: number): `0x${string}` {
|
||||
return encodeFunctionData({
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'payWithETH',
|
||||
args: [cid, BigInt(bytesSize)],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── READ: Get contract owner ── */
|
||||
async getOwner(): Promise<Address> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'owner',
|
||||
args: [],
|
||||
}) as Promise<Address>;
|
||||
}
|
||||
|
||||
/* ── READ: Get total revenue for a token ── */
|
||||
async getTotalRevenue(tokenSymbol: string): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'totalRevenue',
|
||||
args: [tokenSymbol],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── READ: Get total upload count ── */
|
||||
async getTotalUploads(): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'totalUploads',
|
||||
args: [],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── READ: Get supported token symbols (string[]) ── */
|
||||
async getTokenSymbols(): Promise<string[]> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getSupportedTokens',
|
||||
args: [],
|
||||
}) as Promise<string[]>;
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — set price per MB (onlyOwner) ── */
|
||||
async adminSetPricePerMB(provider: WalletProvider, priceWei: bigint): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'setPricePerMB',
|
||||
args: [priceWei],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — set free tier bytes (onlyOwner) ── */
|
||||
async adminSetFreeTierBytes(provider: WalletProvider, bytes: bigint): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'setFreeTierBytes',
|
||||
args: [bytes],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — add/update MAOS discount tier ── */
|
||||
async adminSetDiscountTier(provider: WalletProvider, minBalance: bigint, discountBps: number): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'setMAOSDiscountTier',
|
||||
args: [minBalance, BigInt(discountBps)],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — remove MAOS discount tier ── */
|
||||
async adminRemoveDiscountTier(provider: WalletProvider, index: number): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'removeMAOSDiscountTier',
|
||||
args: [BigInt(index)],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — configure token ── */
|
||||
async adminConfigureToken(
|
||||
provider: WalletProvider,
|
||||
symbol: string,
|
||||
tokenAddress: Address,
|
||||
decimals: number,
|
||||
weiPerToken: bigint,
|
||||
enabled: boolean,
|
||||
): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'configureToken',
|
||||
args: [symbol, tokenAddress, decimals, weiPerToken, enabled],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — withdraw ETH (onlyOwner) ── */
|
||||
async adminWithdrawETH(provider: WalletProvider, to: Address): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'withdrawETH',
|
||||
args: [to],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — withdraw token (onlyOwner) ── */
|
||||
async adminWithdrawToken(provider: WalletProvider, tokenSymbol: string, to: Address): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'withdrawToken',
|
||||
args: [tokenSymbol, to],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — transfer ownership ── */
|
||||
async adminTransferOwnership(provider: WalletProvider, newOwner: Address): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'transferOwnership',
|
||||
args: [newOwner],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Check if contract is deployed ── */
|
||||
async isDeployed(): Promise<boolean> {
|
||||
if (this.contractAddress === DEFAULT_CONTRACT_ADDRESS) return false;
|
||||
try {
|
||||
const client = this.getPublicClient();
|
||||
const code = await client.getBytecode({ address: this.contractAddress });
|
||||
return code !== undefined && code !== '0x';
|
||||
} catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Singleton ── */
|
||||
export const paymentService = new PaymentService();
|
||||
@@ -0,0 +1,87 @@
|
||||
/* ── IPFS Portal Payment ABI & Constants ── */
|
||||
|
||||
import { type Chain } from 'viem';
|
||||
|
||||
/* ── Chain 270 (zkSync Local) ── */
|
||||
export const zkSyncLocal: Chain = {
|
||||
id: 270,
|
||||
name: 'zkSync Local',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: {
|
||||
default: { http: ['http://tom1687.no-ip.org:3050'] },
|
||||
},
|
||||
blockExplorers: {
|
||||
default: { name: 'Explorer', url: 'http://tom1687.no-ip.org:3050' },
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Contract ABI ── */
|
||||
export const IPFS_PORTAL_PAYMENT_ABI = [
|
||||
// ── State ──
|
||||
{ type: 'function', name: 'owner', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'maosTokenAddress', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'freeTierBytes', inputs: [], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'basePricePerMB', inputs: [], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'userTotalBytes', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'totalRevenue', inputs: [{ type: 'string' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'totalUploads', inputs: [], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'supportedTokens', inputs: [{ type: 'uint256' }], outputs: [{ type: 'string' }], stateMutability: 'view' },
|
||||
|
||||
// ── View ──
|
||||
{ type: 'function', name: 'getPrice', inputs: [{ type: 'uint256' }, { type: 'address' }], outputs: [
|
||||
{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'bool' }
|
||||
], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'getUserStats', inputs: [{ type: 'address' }], outputs: [
|
||||
{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' },
|
||||
{ type: 'tuple[]', components: [
|
||||
{ type: 'string' }, { type: 'uint256' }, { type: 'string' }, { type: 'uint256' }, { type: 'uint256' }
|
||||
]}
|
||||
], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'getMAOSDiscountTiers', inputs: [], outputs: [
|
||||
{ type: 'tuple[]', components: [{ type: 'uint256' }, { type: 'uint256' }] }
|
||||
], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'getSupportedTokens', inputs: [], outputs: [{ type: 'string[]' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'tokenConfigs', inputs: [{ type: 'string' }], outputs: [
|
||||
{ type: 'address' }, { type: 'uint8' }, { type: 'uint256' }, { type: 'bool' }
|
||||
], stateMutability: 'view' },
|
||||
|
||||
// ── Write ──
|
||||
{ type: 'function', name: 'payWithETH', inputs: [{ type: 'string' }, { type: 'uint256' }], outputs: [], stateMutability: 'payable' },
|
||||
{ type: 'function', name: 'payWithToken', inputs: [{ type: 'string' }, { type: 'uint256' }, { type: 'string' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
|
||||
// ── Admin Write ──
|
||||
{ type: 'function', name: 'setPricePerMB', inputs: [{ type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'setFreeTierBytes', inputs: [{ type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'setMAOSDiscountTier', inputs: [{ type: 'uint256' }, { type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'removeMAOSDiscountTier', inputs: [{ type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'configureToken', inputs: [{ type: 'string' }, { type: 'address' }, { type: 'uint8' }, { type: 'uint256' }, { type: 'bool' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'setMAOSTokenAddress', inputs: [{ type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'withdrawETH', inputs: [{ type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'withdrawToken', inputs: [{ type: 'string' }, { type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'transferOwnership', inputs: [{ type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
|
||||
// ── Events ──
|
||||
{ type: 'event', name: 'UploadPaidETH', inputs: [
|
||||
{ type: 'address', indexed: true }, { type: 'string', indexed: true },
|
||||
{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }
|
||||
]},
|
||||
{ type: 'event', name: 'UploadPaidToken', inputs: [
|
||||
{ type: 'address', indexed: true }, { type: 'string', indexed: true },
|
||||
{ type: 'uint256' }, { type: 'string', indexed: true }, { type: 'uint256' }, { type: 'uint256' }
|
||||
]},
|
||||
{ type: 'event', name: 'FreeUploadUsed', inputs: [
|
||||
{ type: 'address', indexed: true }, { type: 'string', indexed: true },
|
||||
{ type: 'uint256' }, { type: 'uint256' }
|
||||
]},
|
||||
] as const;
|
||||
|
||||
/* ── Default contract address (set after deploy) ── */
|
||||
export const DEFAULT_CONTRACT_ADDRESS = '0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe';
|
||||
|
||||
/* ── ERC20 ABI (minimal for approve + balanceOf) ── */
|
||||
export const ERC20_ABI = [
|
||||
{ type: 'function', name: 'balanceOf', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'approve', inputs: [{ type: 'address' }, { type: 'uint256' }], outputs: [{ type: 'bool' }], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'allowance', inputs: [{ type: 'address' }, { type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'decimals', inputs: [], outputs: [{ type: 'uint8' }], stateMutability: 'view' },
|
||||
] as const;
|
||||
@@ -0,0 +1,7 @@
|
||||
/* ── Barrel: IPFS Portal Payment ── */
|
||||
|
||||
export { IPFS_PORTAL_PAYMENT_ABI, DEFAULT_CONTRACT_ADDRESS, ERC20_ABI, zkSyncLocal } from './abi';
|
||||
export type { PriceInfo, UserUploadStats, TokenInfo, MAOSDiscountTier, UploadRecord, UserFullStats } from './types';
|
||||
export { PaymentService } from './write-service';
|
||||
export { PaymentReadService } from './read-service';
|
||||
export { paymentService } from './service';
|
||||
@@ -0,0 +1,154 @@
|
||||
/* ── IPFS Portal Payment Read Service ── */
|
||||
|
||||
import {
|
||||
createPublicClient, http,
|
||||
type Address,
|
||||
} from 'viem';
|
||||
import { zkSyncLocal, IPFS_PORTAL_PAYMENT_ABI, DEFAULT_CONTRACT_ADDRESS } from './abi';
|
||||
import type { PriceInfo, UserUploadStats, TokenInfo, MAOSDiscountTier, UserFullStats, UploadRecord } from './types';
|
||||
|
||||
export class PaymentReadService {
|
||||
protected contractAddress: Address;
|
||||
|
||||
constructor(contractAddress?: string) {
|
||||
this.contractAddress = (contractAddress || DEFAULT_CONTRACT_ADDRESS) as Address;
|
||||
}
|
||||
|
||||
setContractAddress(address: string) {
|
||||
this.contractAddress = address as Address;
|
||||
}
|
||||
|
||||
protected getPublicClient() {
|
||||
return createPublicClient({ chain: zkSyncLocal, transport: http() });
|
||||
}
|
||||
|
||||
/* ── READ: Get price for upload ── */
|
||||
async getPrice(bytesSize: number, userAddress: Address): Promise<PriceInfo> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getPrice', args: [BigInt(bytesSize), userAddress],
|
||||
});
|
||||
return {
|
||||
totalWei: result[0] as bigint, discountBps: Number(result[1]),
|
||||
finalWei: result[2] as bigint, isFree: result[3] as boolean,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── READ: Get user upload stats ── */
|
||||
async getUserStats(userAddress: Address): Promise<UserUploadStats> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getUserStats', args: [userAddress],
|
||||
});
|
||||
return {
|
||||
totalBytes: result[0] as bigint, uploadCount: Number(result[1]),
|
||||
remainingFree: result[2] as bigint,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── READ: Get full user stats including upload records ── */
|
||||
async getUserUploads(userAddress: Address): Promise<UserFullStats> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getUserStats', args: [userAddress],
|
||||
}) as unknown as readonly [bigint, bigint, bigint, readonly (readonly [string, bigint, string, bigint, bigint])[]];
|
||||
|
||||
const records = result[3] ?? [];
|
||||
const uploads: UploadRecord[] = Array.from(records).map(rec => ({
|
||||
cid: rec[0], bytesSize: rec[1], tokenSymbol: rec[2], amountPaid: rec[3], timestamp: rec[4],
|
||||
}));
|
||||
|
||||
return { totalBytes: result[0], uploadCount: Number(result[1]), remainingFree: result[2], uploads };
|
||||
}
|
||||
|
||||
/* ── READ: Get supported tokens ── */
|
||||
async getSupportedTokens(): Promise<TokenInfo[]> {
|
||||
const client = this.getPublicClient();
|
||||
const symbols = await client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getSupportedTokens', args: [],
|
||||
}) as string[];
|
||||
|
||||
const tokens: TokenInfo[] = [];
|
||||
for (const symbol of symbols) {
|
||||
const config = await client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'tokenConfigs', args: [symbol],
|
||||
});
|
||||
tokens.push({
|
||||
symbol, address: (config as any)[0] as Address,
|
||||
decimals: (config as any)[1] as number, weiPerToken: (config as any)[2] as bigint,
|
||||
enabled: (config as any)[3] as boolean,
|
||||
});
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/* ── READ: Get MAOS discount tiers ── */
|
||||
async getMAOSDiscountTiers(): Promise<MAOSDiscountTier[]> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getMAOSDiscountTiers', args: [],
|
||||
}) as unknown as readonly (readonly [bigint, bigint])[];
|
||||
|
||||
return Array.from(result).map(([minBalance, discountBps]) => ({ minBalance, discountBps: Number(discountBps) }));
|
||||
}
|
||||
|
||||
/* ── READ: Get free tier bytes ── */
|
||||
async getFreeTierBytes(): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'freeTierBytes', args: [],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── READ: Get base price per MB ── */
|
||||
async getBasePricePerMB(): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'basePricePerMB', args: [],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── READ: Get contract owner ── */
|
||||
async getOwner(): Promise<Address> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'owner', args: [],
|
||||
}) as Promise<Address>;
|
||||
}
|
||||
|
||||
/* ── READ: Get total revenue for a token ── */
|
||||
async getTotalRevenue(tokenSymbol: string): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'totalRevenue', args: [tokenSymbol],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── READ: Get total upload count ── */
|
||||
async getTotalUploads(): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'totalUploads', args: [],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── READ: Get supported token symbols ── */
|
||||
async getTokenSymbols(): Promise<string[]> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getSupportedTokens', args: [],
|
||||
}) as Promise<string[]>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* ── IPFS Portal Payment Service Instance ── */
|
||||
|
||||
import { PaymentService } from './write-service';
|
||||
export const paymentService = new PaymentService();
|
||||
@@ -0,0 +1,44 @@
|
||||
/* ── IPFS Portal Payment Types ── */
|
||||
|
||||
import type { Address } from 'viem';
|
||||
|
||||
export interface PriceInfo {
|
||||
totalWei: bigint;
|
||||
discountBps: number;
|
||||
finalWei: bigint;
|
||||
isFree: boolean;
|
||||
}
|
||||
|
||||
export interface UserUploadStats {
|
||||
totalBytes: bigint;
|
||||
uploadCount: number;
|
||||
remainingFree: bigint;
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
symbol: string;
|
||||
address: Address;
|
||||
decimals: number;
|
||||
weiPerToken: bigint;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface MAOSDiscountTier {
|
||||
minBalance: bigint;
|
||||
discountBps: number;
|
||||
}
|
||||
|
||||
export interface UploadRecord {
|
||||
cid: string;
|
||||
bytesSize: bigint;
|
||||
tokenSymbol: string;
|
||||
amountPaid: bigint;
|
||||
timestamp: bigint;
|
||||
}
|
||||
|
||||
export interface UserFullStats {
|
||||
totalBytes: bigint;
|
||||
uploadCount: number;
|
||||
remainingFree: bigint;
|
||||
uploads: UploadRecord[];
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/* ── IPFS Portal Payment Write Service ── */
|
||||
|
||||
import {
|
||||
createWalletClient, custom, encodeFunctionData,
|
||||
type Address, type Hash,
|
||||
} from 'viem';
|
||||
import { zkSyncLocal, IPFS_PORTAL_PAYMENT_ABI, ERC20_ABI, DEFAULT_CONTRACT_ADDRESS } from './abi';
|
||||
import type { WalletProvider } from '../wallet';
|
||||
import { PaymentReadService } from './read-service';
|
||||
|
||||
export class PaymentService extends PaymentReadService {
|
||||
private getWalletClient(provider: WalletProvider) {
|
||||
return createWalletClient({ chain: zkSyncLocal, transport: custom(provider) });
|
||||
}
|
||||
|
||||
/* ── WRITE: Pay with ETH ── */
|
||||
async payWithETH(provider: WalletProvider, cid: string, bytesSize: number, priceWei: bigint): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.sendTransaction({
|
||||
account: address, to: this.contractAddress, value: priceWei,
|
||||
data: this.encodePayWithETH(cid, bytesSize),
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Approve ERC20 + Pay with token ── */
|
||||
async approveAndPayWithToken(
|
||||
provider: WalletProvider, tokenAddress: Address,
|
||||
cid: string, bytesSize: number, tokenAmount: bigint, tokenSymbol: string,
|
||||
): Promise<{ approveHash?: Hash; payHash: Hash }> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
const publicClient = this.getPublicClient();
|
||||
|
||||
const allowance = await publicClient.readContract({
|
||||
address: tokenAddress, abi: ERC20_ABI, functionName: 'allowance',
|
||||
args: [address, this.contractAddress],
|
||||
}) as bigint;
|
||||
|
||||
let approveHash: Hash | undefined;
|
||||
if (allowance < tokenAmount) {
|
||||
approveHash = await walletClient.writeContract({
|
||||
account: address, address: tokenAddress, abi: ERC20_ABI,
|
||||
functionName: 'approve', args: [this.contractAddress, tokenAmount],
|
||||
});
|
||||
}
|
||||
|
||||
const payHash = await walletClient.writeContract({
|
||||
account: address, address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'payWithToken', args: [cid, BigInt(bytesSize), tokenSymbol],
|
||||
});
|
||||
return { approveHash, payHash };
|
||||
}
|
||||
|
||||
private encodePayWithETH(cid: string, bytesSize: number): `0x${string}` {
|
||||
return encodeFunctionData({
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI, functionName: 'payWithETH', args: [cid, BigInt(bytesSize)],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — set price per MB ── */
|
||||
async adminSetPricePerMB(provider: WalletProvider, priceWei: bigint): Promise<Hash> {
|
||||
return this.execWrite(provider, 'setPricePerMB', [priceWei]);
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — set free tier bytes ── */
|
||||
async adminSetFreeTierBytes(provider: WalletProvider, bytes: bigint): Promise<Hash> {
|
||||
return this.execWrite(provider, 'setFreeTierBytes', [bytes]);
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — add/update MAOS discount tier ── */
|
||||
async adminSetDiscountTier(provider: WalletProvider, minBalance: bigint, discountBps: number): Promise<Hash> {
|
||||
return this.execWrite(provider, 'setMAOSDiscountTier', [minBalance, BigInt(discountBps)]);
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — remove MAOS discount tier ── */
|
||||
async adminRemoveDiscountTier(provider: WalletProvider, index: number): Promise<Hash> {
|
||||
return this.execWrite(provider, 'removeMAOSDiscountTier', [BigInt(index)]);
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — configure token ── */
|
||||
async adminConfigureToken(
|
||||
provider: WalletProvider, symbol: string, tokenAddress: Address,
|
||||
decimals: number, weiPerToken: bigint, enabled: boolean,
|
||||
): Promise<Hash> {
|
||||
return this.execWrite(provider, 'configureToken', [symbol, tokenAddress, decimals, weiPerToken, enabled]);
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — withdraw ETH ── */
|
||||
async adminWithdrawETH(provider: WalletProvider, to: Address): Promise<Hash> {
|
||||
return this.execWrite(provider, 'withdrawETH', [to]);
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — withdraw token ── */
|
||||
async adminWithdrawToken(provider: WalletProvider, tokenSymbol: string, to: Address): Promise<Hash> {
|
||||
return this.execWrite(provider, 'withdrawToken', [tokenSymbol, to]);
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — transfer ownership ── */
|
||||
async adminTransferOwnership(provider: WalletProvider, newOwner: Address): Promise<Hash> {
|
||||
return this.execWrite(provider, 'transferOwnership', [newOwner]);
|
||||
}
|
||||
|
||||
private async execWrite(
|
||||
provider: WalletProvider,
|
||||
fn: 'payWithETH' | 'payWithToken' | 'setPricePerMB' | 'setFreeTierBytes' | 'setMAOSDiscountTier' | 'removeMAOSDiscountTier' | 'configureToken' | 'setMAOSTokenAddress' | 'withdrawETH' | 'withdrawToken' | 'transferOwnership',
|
||||
args: readonly unknown[],
|
||||
): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address, address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI, functionName: fn, args: args as any,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Check if contract is deployed ── */
|
||||
async isDeployed(): Promise<boolean> {
|
||||
if (this.contractAddress === DEFAULT_CONTRACT_ADDRESS) return false;
|
||||
try {
|
||||
const client = this.getPublicClient();
|
||||
const code = await client.getBytecode({ address: this.contractAddress });
|
||||
return code !== undefined && code !== '0x';
|
||||
} catch { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/* ── Search Index — Client-side full-text search voor IPFS ──
|
||||
*
|
||||
* Bewaart een miniatuur search index in localStorage.
|
||||
* Periodiek te updaten door gepinde files te crawlen en te indexeren.
|
||||
*
|
||||
* Beperking: alleen tekstuele content (.txt, .md, .json, .html)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
/* ════════════════════════════ Types ════════════════════════════ */
|
||||
|
||||
export interface IndexedEntry {
|
||||
cid: string;
|
||||
name: string;
|
||||
type: 'file' | 'dir';
|
||||
text: string; // geëxtraheerde tekst (max 2048 chars)
|
||||
size: number;
|
||||
indexedAt: number; // timestamp
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
entry: IndexedEntry;
|
||||
score: number; // relevance (higher = better)
|
||||
matchField: 'name' | 'text' | 'cid';
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Storage ════════════════════════════ */
|
||||
|
||||
const STORAGE_KEY = 'ipfs-search-index';
|
||||
|
||||
function loadIndex(): IndexedEntry[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveIndex(entries: IndexedEntry[]) {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||
} catch {
|
||||
// localStorage vol of blocked — silently fail
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Tekst extractie ════════════════════════════ */
|
||||
|
||||
const TEXT_EXTENSIONS = new Set(['.txt', '.md', '.json', '.html', '.htm', '.csv', '.log', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg']);
|
||||
|
||||
export function isTextFile(name: string): boolean {
|
||||
const ext = name.toLowerCase().slice(name.lastIndexOf('.'));
|
||||
return TEXT_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
export function extractText(raw: string): string {
|
||||
return raw
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML tags
|
||||
.replace(/\s+/g, ' ') // collapse whitespace
|
||||
.replace(/[{}\[\]]/g, ' ') // strip JSON braces
|
||||
.trim()
|
||||
.slice(0, 2048);
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Index beheer ════════════════════════════ */
|
||||
|
||||
export function addToIndex(entry: IndexedEntry) {
|
||||
const index = loadIndex();
|
||||
// Remove oud voor deze CID
|
||||
const filtered = index.filter((e) => e.cid !== entry.cid);
|
||||
filtered.push(entry);
|
||||
saveIndex(filtered);
|
||||
}
|
||||
|
||||
export function removeFromIndex(cid: string) {
|
||||
const index = loadIndex().filter((e) => e.cid !== cid);
|
||||
saveIndex(index);
|
||||
}
|
||||
|
||||
export function clearIndex() {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function getIndexStats() {
|
||||
const index = loadIndex();
|
||||
const totalSize = index.reduce((sum, e) => sum + e.text.length, 0);
|
||||
return {
|
||||
entries: index.length,
|
||||
totalChars: totalSize,
|
||||
lastIndexed: index.length > 0 ? Math.max(...index.map((e) => e.indexedAt)) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Zoeken ════════════════════════════ */
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'de', 'het', 'een', 'van', 'en', 'in', 'is', 'te', 'met', 'op',
|
||||
'voor', 'aan', 'dat', 'die', 'door', 'bij', 'ook', 'maar', 'uit',
|
||||
'naar', 'om', 'al', 'als', 'nog', 'dan', 'of', 'er', 'niet', 'dit',
|
||||
'the', 'a', 'an', 'and', 'or', 'in', 'on', 'at', 'to', 'for',
|
||||
'of', 'is', 'it', 'be', 'by', 'as', 'are', 'was', 'were', 'been',
|
||||
]);
|
||||
|
||||
function tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((t) => t.length > 1 && !STOP_WORDS.has(t));
|
||||
}
|
||||
|
||||
function scoreEntry(queryTokens: string[], entry: IndexedEntry): number {
|
||||
let score = 0;
|
||||
const nameLower = entry.name.toLowerCase();
|
||||
const textLower = entry.text.toLowerCase();
|
||||
const cidLower = entry.cid.toLowerCase();
|
||||
|
||||
for (const token of queryTokens) {
|
||||
// Exact match in name = hoogste score
|
||||
if (nameLower.includes(token)) {
|
||||
score += token.length * 10;
|
||||
if (nameLower === token) score += 20; // exact match bonus
|
||||
}
|
||||
// Match in text
|
||||
if (textLower.includes(token)) {
|
||||
score += token.length * 3;
|
||||
}
|
||||
// Match in CID (prefix)
|
||||
if (cidLower.startsWith(token)) {
|
||||
score += token.length * 8;
|
||||
}
|
||||
// Frequentie in text
|
||||
const count = (textLower.match(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
||||
score += count;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export function searchIndex(query: string, maxResults = 20): SearchResult[] {
|
||||
if (!query || query.trim().length < 2) return [];
|
||||
|
||||
const queryTokens = tokenize(query);
|
||||
if (queryTokens.length === 0) return [];
|
||||
|
||||
const index = loadIndex();
|
||||
const scored: SearchResult[] = [];
|
||||
|
||||
for (const entry of index) {
|
||||
const score = scoreEntry(queryTokens, entry);
|
||||
if (score > 0) {
|
||||
// Bepaal het beste match veld
|
||||
const nameLower = entry.name.toLowerCase();
|
||||
const cidLower = entry.cid.toLowerCase();
|
||||
let matchField: 'name' | 'text' | 'cid' = 'text';
|
||||
if (queryTokens.some((t) => nameLower.includes(t))) matchField = 'name';
|
||||
else if (queryTokens.some((t) => cidLower.startsWith(t))) matchField = 'cid';
|
||||
|
||||
scored.push({ entry, score, matchField });
|
||||
}
|
||||
}
|
||||
|
||||
return scored.sort((a, b) => b.score - a.score).slice(0, maxResults);
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Crawler ════════════════════════════ */
|
||||
|
||||
export async function rebuildIndex(
|
||||
cids: { cid: string; name: string }[],
|
||||
fetchContent: (cid: string) => Promise<string>,
|
||||
onProgress?: (done: number, total: number) => void,
|
||||
): Promise<{ indexed: number; failed: number }> {
|
||||
let indexed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Filter op text files
|
||||
const textFiles = cids.filter((c) => isTextFile(c.name));
|
||||
|
||||
for (let i = 0; i < textFiles.length; i++) {
|
||||
const file = textFiles[i];
|
||||
try {
|
||||
const raw = await fetchContent(file.cid);
|
||||
const text = extractText(raw);
|
||||
if (text.length > 0) {
|
||||
addToIndex({
|
||||
cid: file.cid,
|
||||
name: file.name,
|
||||
type: 'file',
|
||||
text,
|
||||
size: raw.length,
|
||||
indexedAt: Date.now(),
|
||||
});
|
||||
indexed++;
|
||||
}
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
onProgress?.(i + 1, textFiles.length);
|
||||
}
|
||||
|
||||
return { indexed, failed };
|
||||
}
|
||||
@@ -96,3 +96,13 @@ export function clearHistory(): void {
|
||||
localStorage.removeItem(HISTORY_KEY);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function removeMultipleFromHistory(ids: { cid: string; date: string }[]): UploadRecord[] {
|
||||
const history = getHistory();
|
||||
const idSet = new Set(ids.map((id) => id.cid + '::' + id.date));
|
||||
const filtered = history.filter((h) => !idSet.has(h.cid + '::' + h.date));
|
||||
try {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(filtered));
|
||||
} catch { /* ignore */ }
|
||||
return filtered;
|
||||
}
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
/* ── SWR Data Fetching Hooks ──
|
||||
*
|
||||
* Gelijktijdige data-fetching met auto-refresh, caching en revalidation.
|
||||
* Vervangt handmatige useEffect + useState + setInterval patronen.
|
||||
*
|
||||
* Alles fetched via de Next.js API proxy (krijgt cookies/sessie mee).
|
||||
*/
|
||||
|
||||
import useSWR from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import type { IPFSPin, RepoStats, BWStats, IPFSNodeInfo, IPFSUser } from './api';
|
||||
|
||||
/* ════════════════════════════ Fetcher ════════════════════════════ */
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
async function fetcher<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Config ════════════════════════════ */
|
||||
|
||||
const DEFAULT_REFRESH = 15_000; // 15 seconden
|
||||
|
||||
/* ════════════════════════════ Pins ════════════════════════════ */
|
||||
|
||||
export function usePins(refreshInterval = DEFAULT_REFRESH) {
|
||||
return useSWR<IPFSPin[]>('/api/pins', fetcher, {
|
||||
refreshInterval,
|
||||
revalidateOnFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Peers ════════════════════════════ */
|
||||
|
||||
export interface Peer {
|
||||
id: string;
|
||||
addr: string;
|
||||
latency: string;
|
||||
}
|
||||
|
||||
export function usePeers(refreshInterval = DEFAULT_REFRESH) {
|
||||
return useSWR<Peer[]>('/api/peers', async (path) => {
|
||||
const raw = await fetcher<{ Peers?: { Peer: string; Addr: string; Latency: string }[] }>(path);
|
||||
return (raw.Peers || []).map((p) => ({
|
||||
id: p.Peer,
|
||||
addr: p.Addr,
|
||||
latency: p.Latency || '—',
|
||||
}));
|
||||
}, {
|
||||
refreshInterval,
|
||||
revalidateOnFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Node Info ════════════════════════════ */
|
||||
|
||||
export function useNodeInfo(refreshInterval?: number) {
|
||||
return useSWR<IPFSNodeInfo>('/api/node/info', fetcher, {
|
||||
refreshInterval,
|
||||
revalidateOnFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Repo Stats ════════════════════════════ */
|
||||
|
||||
export function useRepoStats(refreshInterval = DEFAULT_REFRESH) {
|
||||
return useSWR<RepoStats>('/api/repo/stats', async (path) => {
|
||||
const raw = await fetcher<{
|
||||
RepoSize?: number; StorageMax?: number; NumObjects?: number;
|
||||
RepoPath?: string; Version?: string;
|
||||
}>(path);
|
||||
return {
|
||||
repoSize: raw.RepoSize ?? 0,
|
||||
storageMax: raw.StorageMax ?? 0,
|
||||
numObjects: raw.NumObjects ?? 0,
|
||||
repoPath: raw.RepoPath ?? '',
|
||||
version: raw.Version ?? '',
|
||||
};
|
||||
}, {
|
||||
refreshInterval,
|
||||
revalidateOnFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Bandwidth ════════════════════════════ */
|
||||
|
||||
export function useBandwidth(refreshInterval = DEFAULT_REFRESH) {
|
||||
return useSWR<BWStats>('/api/bw/stats', async (path) => {
|
||||
const raw = await fetcher<{
|
||||
TotalIn?: number; TotalOut?: number; RateIn?: number; RateOut?: number;
|
||||
}>(path);
|
||||
return {
|
||||
totalIn: raw.TotalIn ?? 0,
|
||||
totalOut: raw.TotalOut ?? 0,
|
||||
rateIn: raw.RateIn ?? 0,
|
||||
rateOut: raw.RateOut ?? 0,
|
||||
};
|
||||
}, {
|
||||
refreshInterval,
|
||||
revalidateOnFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Dashboard (alles-in-1) ════════════════════════════ */
|
||||
|
||||
export interface DashboardData {
|
||||
peers: Peer[];
|
||||
pins: IPFSPin[];
|
||||
repo: RepoStats | null;
|
||||
bw: BWStats | null;
|
||||
health: { status: string; node: string } | null;
|
||||
}
|
||||
|
||||
export function useDashboard(refreshInterval = DEFAULT_REFRESH) {
|
||||
return useSWR<DashboardData>('/api/health', async (healthPath) => {
|
||||
const [health, peers, pins, repo, bw] = await Promise.all([
|
||||
fetcher<{ status: string; node: string }>(healthPath).catch(() => null as any),
|
||||
fetcher<{ Peers?: { Peer: string; Addr: string; Latency: string }[] }>('/api/peers')
|
||||
.then((raw) => (raw.Peers || []).map((p) => ({ id: p.Peer, addr: p.Addr, latency: p.Latency || '—' })))
|
||||
.catch(() => [] as Peer[]),
|
||||
fetcher<{ Keys?: Record<string, { Type: string }> }>('/api/pins')
|
||||
.then((raw) => raw.Keys ? Object.entries(raw.Keys).map(([cid]) => ({ cid, name: '', size: 0, created: '' })) : [])
|
||||
.catch(() => [] as IPFSPin[]),
|
||||
fetcher<{ RepoSize?: number; StorageMax?: number; NumObjects?: number }>('/api/repo/stats')
|
||||
.then((raw) => ({ repoSize: raw.RepoSize ?? 0, storageMax: raw.StorageMax ?? 0, numObjects: raw.NumObjects ?? 0, repoPath: '', version: '' }))
|
||||
.catch(() => null),
|
||||
fetcher<{ TotalIn?: number; TotalOut?: number }>('/api/bw/stats')
|
||||
.then((raw) => ({ totalIn: raw.TotalIn ?? 0, totalOut: raw.TotalOut ?? 0, rateIn: 0, rateOut: 0 }))
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
return { peers, pins, repo, bw, health };
|
||||
}, {
|
||||
refreshInterval,
|
||||
revalidateOnFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Daily Stats (paginated) ════════════════════════════ */
|
||||
|
||||
export interface DailyStat {
|
||||
date: string;
|
||||
uploads: number;
|
||||
bytesAdded: number;
|
||||
bytesRemoved: number;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
export function useDailyStats() {
|
||||
return useSWRInfinite<DailyStat[]>(
|
||||
(pageIndex, previousData) => {
|
||||
if (previousData && previousData.length < PAGE_SIZE) return null;
|
||||
return `/api/stats/daily?page=${pageIndex}&limit=${PAGE_SIZE}`;
|
||||
},
|
||||
fetcher,
|
||||
{ revalidateFirstPage: false },
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Health ════════════════════════════ */
|
||||
|
||||
export function useHealth() {
|
||||
return useSWR<{ status: string; node: string }>('/api/health', fetcher, {
|
||||
refreshInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Users (admin) ════════════════════════════ */
|
||||
|
||||
export function useUsers() {
|
||||
return useSWR<IPFSUser[]>('/api/users', async (path) => {
|
||||
const raw = await fetcher<IPFSUser[] | { users: IPFSUser[] }>(path);
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (raw && Array.isArray(raw.users)) return raw.users;
|
||||
return [];
|
||||
}, {
|
||||
refreshInterval: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
/* ════════════════════════════ Theme ════════════════════════════
|
||||
* Live dark/light mode toggle.
|
||||
* Slaat voorkeur op in localStorage, past direct CSS class op <html>.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
import { getSettings, saveSettings } from '@/lib/storage';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
interface ThemeCtx {
|
||||
theme: Theme;
|
||||
toggle: () => void;
|
||||
setTheme: (t: Theme) => void;
|
||||
}
|
||||
|
||||
/* ── Context ── */
|
||||
|
||||
const ThemeContext = createContext<ThemeCtx | null>(null);
|
||||
|
||||
/* ── Provider ── */
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>('dark');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Init — run once on mount
|
||||
useEffect(() => {
|
||||
const saved = getSettings().theme;
|
||||
if (saved === 'light' || saved === 'dark') {
|
||||
setThemeState(saved);
|
||||
}
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Sync class + localStorage on every change
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
document.documentElement.classList.toggle('light', theme === 'light');
|
||||
saveSettings({ theme });
|
||||
}, [theme, mounted]);
|
||||
|
||||
const toggle = () => {
|
||||
// Enable smooth transitions during theme switch
|
||||
document.documentElement.classList.add('theme-transitioning');
|
||||
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||
// Remove transition class after animation completes
|
||||
setTimeout(() => document.documentElement.classList.remove('theme-transitioning'), 400);
|
||||
};
|
||||
|
||||
const setTheme = (t: Theme) => setThemeState(t);
|
||||
|
||||
// Render children immediately (no flash because .dark is default in CSS)
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggle, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Hook ── */
|
||||
|
||||
export function useTheme(): ThemeCtx {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme moet binnen ThemeProvider worden gebruikt');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/* ── useRealtime — SSE live data hook ──
|
||||
*
|
||||
* Consumeert Server-Sent Events van /api/events.
|
||||
* Geeft live bandwidth, peer count, repo stats.
|
||||
* Auto-reconnect bij verbroken verbinding.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/* ════════════════════════════ Types ════════════════════════════ */
|
||||
|
||||
export interface LiveBW {
|
||||
totalIn: number;
|
||||
totalOut: number;
|
||||
rateIn: number;
|
||||
rateOut: number;
|
||||
deltaIn: number;
|
||||
deltaOut: number;
|
||||
}
|
||||
|
||||
export interface LivePeers {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface LiveRepo {
|
||||
repoSize: number;
|
||||
storageMax: number;
|
||||
numObjects: number;
|
||||
}
|
||||
|
||||
export interface RealtimeState {
|
||||
connected: boolean;
|
||||
bandwidth: LiveBW | null;
|
||||
peers: LivePeers | null;
|
||||
repo: LiveRepo | null;
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Hook ════════════════════════════ */
|
||||
|
||||
export function useRealtime(): RealtimeState {
|
||||
const [state, setState] = useState<RealtimeState>({
|
||||
connected: false,
|
||||
bandwidth: null,
|
||||
peers: null,
|
||||
repo: null,
|
||||
});
|
||||
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
function connect() {
|
||||
if (cancelled) return;
|
||||
|
||||
const es = new EventSource('/api/events');
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.addEventListener('connected', () => {
|
||||
if (!cancelled) setState((prev) => ({ ...prev, connected: true }));
|
||||
});
|
||||
|
||||
es.addEventListener('bandwidth', (e: MessageEvent) => {
|
||||
if (!cancelled) {
|
||||
const bw: LiveBW = JSON.parse(e.data);
|
||||
setState((prev) => ({ ...prev, bandwidth: bw }));
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener('peers', (e: MessageEvent) => {
|
||||
if (!cancelled) {
|
||||
const peers: LivePeers = JSON.parse(e.data);
|
||||
setState((prev) => ({ ...prev, peers }));
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener('repo', (e: MessageEvent) => {
|
||||
if (!cancelled) {
|
||||
const repo: LiveRepo = JSON.parse(e.data);
|
||||
setState((prev) => ({ ...prev, repo }));
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
if (!cancelled) {
|
||||
setState((prev) => ({ ...prev, connected: false }));
|
||||
// Auto-reconnect na 3s
|
||||
reconnectTimeoutRef.current = setTimeout(connect, 3_000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
+15
-9
@@ -1,5 +1,20 @@
|
||||
/* ── Wallet connection (EIP-6963 + MAOS extension) ── */
|
||||
|
||||
/* ── Global type augmentation for window properties ── */
|
||||
declare global {
|
||||
interface Window {
|
||||
ethereum?: {
|
||||
isMetaMask?: boolean;
|
||||
isRabby?: boolean;
|
||||
isMAOSv6?: boolean;
|
||||
request: (args: { method: string; params?: unknown[] }) => Promise<any>;
|
||||
on?: (event: string, cb: (...args: any[]) => void) => void;
|
||||
removeListener?: (event: string, cb: (...args: any[]) => void) => void;
|
||||
};
|
||||
maosv6?: WalletProvider;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Types ── */
|
||||
export interface WalletProvider {
|
||||
request: (args: { method: string; params?: unknown[] }) => Promise<any>;
|
||||
@@ -69,12 +84,9 @@ export function discoverWallets(): Promise<DetectedWallet[]> {
|
||||
|
||||
// Also check legacy window.ethereum
|
||||
setTimeout(() => {
|
||||
// @ts-expect-error - window.ethereum
|
||||
const legacy = window.ethereum as WalletProvider | undefined;
|
||||
if (legacy && !wallets.some(w => w.provider === legacy)) {
|
||||
// @ts-expect-error
|
||||
const isMetaMask = window.ethereum?.isMetaMask === true;
|
||||
// @ts-expect-error - isMAOSv6
|
||||
const isMAOS = window.maosv6 !== undefined || window.ethereum?.isMAOSv6 === true;
|
||||
wallets.push({
|
||||
name: isMAOS ? 'MAOS Wallet' : isMetaMask ? 'MetaMask' : 'Wallet',
|
||||
@@ -91,9 +103,7 @@ export function discoverWallets(): Promise<DetectedWallet[]> {
|
||||
/* ── Get best provider (MAOS preferred if available) ── */
|
||||
export function getInjectedProvider(): WalletProvider | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
// @ts-expect-error - window.maosv6
|
||||
if (window.maosv6) return window.maosv6 as WalletProvider;
|
||||
// @ts-expect-error - window.ethereum
|
||||
if (window.ethereum) return window.ethereum as WalletProvider;
|
||||
return null;
|
||||
}
|
||||
@@ -101,13 +111,9 @@ export function getInjectedProvider(): WalletProvider | null {
|
||||
/* ── Detect wallet type ── */
|
||||
export function detectWalletType(): 'maos' | 'metamask' | 'rabby' | 'other' | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
// @ts-expect-error
|
||||
if (window.maosv6) return 'maos';
|
||||
// @ts-expect-error
|
||||
if (window.ethereum?.isRabby) return 'rabby';
|
||||
// @ts-expect-error
|
||||
if (window.ethereum?.isMetaMask) return 'metamask';
|
||||
// @ts-expect-error
|
||||
if (window.ethereum) return 'other';
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user