1
0
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:
maikrolf
2026-06-28 18:31:05 +02:00
parent 1ddc89479c
commit c9432a16ba
84 changed files with 6679 additions and 1426 deletions
+78
View File
@@ -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('');
});
});
+62
View File
@@ -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');
});
});
+144
View File
@@ -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');
});
});
+66
View File
@@ -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
View File
@@ -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 }> {
+71
View File
@@ -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: '/',
};
}
+133
View File
@@ -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;
}
+44
View File
@@ -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();
}
+19
View File
@@ -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)}...`;
}
+128
View File
@@ -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}`;
}
+71
View File
@@ -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;
}
-539
View File
@@ -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();
+87
View File
@@ -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;
+7
View File
@@ -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';
+154
View File
@@ -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[]>;
}
}
+4
View File
@@ -0,0 +1,4 @@
/* ── IPFS Portal Payment Service Instance ── */
import { PaymentService } from './write-service';
export const paymentService = new PaymentService();
+44
View File
@@ -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[];
}
+126
View File
@@ -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; }
}
}
+207
View File
@@ -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 };
}
+10
View File
@@ -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
View File
@@ -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,
});
}
+72
View File
@@ -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;
}
+114
View File
@@ -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
View File
@@ -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;
}