Skip to content

Commit 5c4c0c9

Browse files
committed
added ipfs upload feature
1 parent f9399b3 commit 5c4c0c9

5 files changed

Lines changed: 277 additions & 0 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import 'dart:convert';
2+
import 'package:flutter/foundation.dart';
3+
import 'package:http/http.dart' as http;
4+
import 'package:fula_files/core/services/secure_storage_service.dart';
5+
6+
class IpfsPublicService {
7+
IpfsPublicService._();
8+
static final instance = IpfsPublicService._();
9+
10+
static const String _defaultIpfsEndpoint = 'https://ipfs.cloud.fx.land';
11+
static const String _defaultIpfsServer = 'https://api.cloud.fx.land';
12+
static const String _defaultIpfsGateway = 'https://ipfs.cloud.fx.land/gateway/';
13+
14+
/// Upload a file publicly to IPFS and pin it.
15+
///
16+
/// 1. POST {ipfsEndpoint}/upload (multipart file) → returns CID
17+
/// 2. POST {ipfsServer}/api/pins (JSON {cid, name}) → pins for persistence
18+
/// 3. Build public gateway URL from CID
19+
Future<({String cid, String gatewayUrl})> pinFile(
20+
String localPath,
21+
String fileName,
22+
) async {
23+
final ipfsEndpoint = await SecureStorageService.instance
24+
.read(SecureStorageKeys.ipfsEndpointUrl) ??
25+
_defaultIpfsEndpoint;
26+
final ipfsServer = await SecureStorageService.instance
27+
.read(SecureStorageKeys.ipfsServerUrl) ??
28+
_defaultIpfsServer;
29+
final jwt =
30+
await SecureStorageService.instance.read(SecureStorageKeys.jwtToken);
31+
final ipfsGateway = await SecureStorageService.instance
32+
.read(SecureStorageKeys.ipfsGatewayUrl) ??
33+
_defaultIpfsGateway;
34+
35+
if (jwt == null || jwt.isEmpty) {
36+
throw Exception(
37+
'No API key configured. Please set your API key in Settings.');
38+
}
39+
40+
// Step 1: Upload file to IPFS endpoint to get CID
41+
final uploadUri = Uri.parse('$ipfsEndpoint/upload');
42+
debugPrint('IPFS upload → $uploadUri');
43+
final request = http.MultipartRequest('POST', uploadUri)
44+
..headers['Authorization'] = 'Bearer $jwt'
45+
..files.add(
46+
await http.MultipartFile.fromPath('file', localPath,
47+
filename: fileName));
48+
49+
final streamed = await request.send();
50+
final body = await streamed.stream.bytesToString();
51+
52+
if (streamed.statusCode != 200) {
53+
throw Exception(
54+
'Upload failed (${streamed.statusCode}): $body');
55+
}
56+
57+
final uploadJson = jsonDecode(body.trim()) as Map<String, dynamic>;
58+
final cid = uploadJson['cid'] as String?;
59+
if (cid == null || cid.isEmpty) {
60+
throw Exception('Upload succeeded but no CID returned');
61+
}
62+
debugPrint('IPFS upload OK cid=$cid');
63+
64+
// Step 2: Pin the CID via pinning service for persistence
65+
try {
66+
final pinUri = Uri.parse('$ipfsServer/api/pins');
67+
debugPrint('IPFS pin → $pinUri');
68+
final pinResponse = await http.post(
69+
pinUri,
70+
headers: {
71+
'Authorization': 'Bearer $jwt',
72+
'Content-Type': 'application/json',
73+
},
74+
body: jsonEncode({'cid': cid, 'name': fileName}),
75+
);
76+
debugPrint('Pin response (${pinResponse.statusCode}): ${pinResponse.body}');
77+
} catch (e) {
78+
// Pin failure is non-fatal — the file is already on IPFS
79+
debugPrint('Pin request failed (non-fatal): $e');
80+
}
81+
82+
// Step 3: Build gateway URL
83+
final base = ipfsGateway.endsWith('/') ? ipfsGateway : '$ipfsGateway/';
84+
final gatewayUrl = '$base$cid';
85+
86+
debugPrint('IPFS public share OK url=$gatewayUrl');
87+
return (cid: cid, gatewayUrl: gatewayUrl);
88+
}
89+
}

lib/core/services/secure_storage_service.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ class SecureStorageKeys {
8484
static const String aiEndpointUrl = 'ai_endpoint_url';
8585
static const String ipfsGatewayUrl = 'ipfs_gateway_url';
8686

87+
// IPFS upload endpoint (ipfs-server with /upload and /gateway)
88+
static const String ipfsEndpointUrl = 'ipfs_endpoint_url';
89+
8790
// NFT wallet keys
8891
static const String nftWalletPrivateKey = 'nft_wallet_private_key';
8992

lib/features/browser/screens/file_browser_screen.dart

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import 'package:fula_files/features/tags/widgets/tag_selector_dialog.dart';
4141
import 'package:fula_files/features/tags/widgets/tag_chip.dart';
4242
import 'package:open_filex/open_filex.dart';
4343
import 'package:share_plus/share_plus.dart';
44+
import 'package:url_launcher/url_launcher.dart';
45+
import 'package:fula_files/core/services/ipfs_public_service.dart';
4446
import 'package:fula_files/shared/utils/error_messages.dart';
4547
import 'package:fula_files/features/sharing/providers/collaboration_provider.dart';
4648
import 'package:fula_files/core/models/collaboration_group.dart';
@@ -2105,6 +2107,17 @@ class _FileBrowserScreenState extends ConsumerState<FileBrowserScreen> {
21052107
],
21062108
);
21072109
}),
2110+
// Share Publicly via IPFS (files only, no cloud sync required)
2111+
if (!file.isDirectory)
2112+
ListTile(
2113+
leading: const Icon(LucideIcons.globe, color: Colors.teal),
2114+
title: const Text('Share Publicly'),
2115+
subtitle: const Text('Pin to IPFS (unencrypted)'),
2116+
onTap: () {
2117+
Navigator.pop(ctx);
2118+
_sharePubliclyViaIpfs(file);
2119+
},
2120+
),
21082121
const Divider(height: 1),
21092122
// Archive actions - only for archive files
21102123
if (!file.isDirectory && ArchiveService.instance.isArchive(file.path))
@@ -2647,6 +2660,146 @@ class _FileBrowserScreenState extends ConsumerState<FileBrowserScreen> {
26472660
// ============================================================================
26482661

26492662
/// Show info message when share is disabled
2663+
Future<void> _sharePubliclyViaIpfs(LocalFile file) async {
2664+
final confirmed = await showDialog<bool>(
2665+
context: context,
2666+
builder: (ctx) => AlertDialog(
2667+
title: const Text('Share Publicly?'),
2668+
content: Column(
2669+
mainAxisSize: MainAxisSize.min,
2670+
crossAxisAlignment: CrossAxisAlignment.start,
2671+
children: [
2672+
const Text(
2673+
'This will upload the file to IPFS without any encryption. '
2674+
'Anyone with the link will be able to access it.',
2675+
),
2676+
const SizedBox(height: 16),
2677+
Text('File: ${file.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
2678+
Text('Size: ${file.sizeFormatted}'),
2679+
],
2680+
),
2681+
actions: [
2682+
TextButton(
2683+
onPressed: () => Navigator.pop(ctx, false),
2684+
child: const Text('Cancel'),
2685+
),
2686+
FilledButton(
2687+
style: FilledButton.styleFrom(backgroundColor: Colors.teal),
2688+
onPressed: () => Navigator.pop(ctx, true),
2689+
child: const Text('Share Publicly'),
2690+
),
2691+
],
2692+
),
2693+
);
2694+
2695+
if (confirmed != true || !mounted) return;
2696+
2697+
ScaffoldMessenger.of(context).showSnackBar(
2698+
SnackBar(
2699+
content: Row(
2700+
children: [
2701+
const SizedBox(
2702+
width: 20, height: 20,
2703+
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
2704+
),
2705+
const SizedBox(width: 16),
2706+
Expanded(child: Text('Uploading ${file.name} to IPFS...')),
2707+
],
2708+
),
2709+
duration: const Duration(seconds: 60),
2710+
),
2711+
);
2712+
2713+
try {
2714+
final result = await IpfsPublicService.instance.pinFile(file.path, file.name);
2715+
2716+
if (!mounted) return;
2717+
ScaffoldMessenger.of(context).clearSnackBars();
2718+
_showPublicIpfsLinkDialog(result.gatewayUrl, file.name);
2719+
} catch (e, st) {
2720+
debugPrint('Share publicly failed: $e\n$st');
2721+
if (!mounted) return;
2722+
ScaffoldMessenger.of(context).clearSnackBars();
2723+
// Show the actual exception message — our service throws clear messages
2724+
final msg = e is Exception
2725+
? e.toString().replaceFirst('Exception: ', '')
2726+
: ErrorMessages.forPublicShare(e);
2727+
ScaffoldMessenger.of(context).showSnackBar(
2728+
SnackBar(
2729+
content: Text(msg),
2730+
backgroundColor: Colors.red,
2731+
duration: const Duration(seconds: 8),
2732+
showCloseIcon: true,
2733+
closeIconColor: Colors.white,
2734+
),
2735+
);
2736+
}
2737+
}
2738+
2739+
void _showPublicIpfsLinkDialog(String gatewayUrl, String fileName) {
2740+
showDialog(
2741+
context: context,
2742+
builder: (ctx) => AlertDialog(
2743+
title: Row(
2744+
children: const [
2745+
Icon(LucideIcons.checkCircle, color: Colors.green, size: 24),
2746+
SizedBox(width: 8),
2747+
Text('Shared Publicly'),
2748+
],
2749+
),
2750+
content: Column(
2751+
mainAxisSize: MainAxisSize.min,
2752+
crossAxisAlignment: CrossAxisAlignment.start,
2753+
children: [
2754+
Text('$fileName is now publicly accessible via IPFS.'),
2755+
const SizedBox(height: 8),
2756+
const Text(
2757+
'Anyone with this link can access the file:',
2758+
style: TextStyle(fontSize: 12, color: Colors.grey),
2759+
),
2760+
const SizedBox(height: 12),
2761+
Container(
2762+
width: double.infinity,
2763+
padding: const EdgeInsets.all(12),
2764+
decoration: BoxDecoration(
2765+
color: Theme.of(ctx).colorScheme.surfaceContainerHighest,
2766+
borderRadius: BorderRadius.circular(8),
2767+
),
2768+
child: SelectableText(
2769+
gatewayUrl,
2770+
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
2771+
),
2772+
),
2773+
],
2774+
),
2775+
actions: [
2776+
TextButton(
2777+
onPressed: () => Navigator.pop(ctx),
2778+
child: const Text('Close'),
2779+
),
2780+
OutlinedButton.icon(
2781+
icon: const Icon(LucideIcons.externalLink, size: 16),
2782+
label: const Text('Open'),
2783+
onPressed: () {
2784+
launchUrl(Uri.parse(gatewayUrl), mode: LaunchMode.externalApplication);
2785+
},
2786+
),
2787+
FilledButton.icon(
2788+
icon: const Icon(LucideIcons.copy, size: 16),
2789+
label: const Text('Copy URL'),
2790+
onPressed: () {
2791+
Clipboard.setData(ClipboardData(text: gatewayUrl));
2792+
Navigator.pop(ctx);
2793+
ScaffoldMessenger.of(context).showSnackBar(
2794+
const SnackBar(content: Text('IPFS URL copied to clipboard')),
2795+
);
2796+
},
2797+
),
2798+
],
2799+
),
2800+
);
2801+
}
2802+
26502803
void _showShareDisabledInfo(bool isLoggedIn) {
26512804
if (!mounted) return;
26522805

lib/features/settings/screens/settings_screen.dart

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
4040
final _billingServerController = TextEditingController();
4141
final _aiEndpointController = TextEditingController();
4242
final _ipfsGatewayController = TextEditingController();
43+
final _ipfsEndpointController = TextEditingController();
4344
final _jwtTokenController = TextEditingController();
4445

4546
bool _isEditingApi = false;
@@ -65,13 +66,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
6566
static const String _defaultBillingServer = 'https://cloud.fx.land';
6667
static const String _defaultAiEndpoint = 'https://ai.cloud.fx.land';
6768
static const String _defaultIpfsGateway = 'https://ipfs.cloud.fx.land/gateway/';
69+
static const String _defaultIpfsEndpoint = 'https://ipfs.cloud.fx.land';
6870

6971
Future<void> _loadSettings() async {
7072
final apiGateway = await SecureStorageService.instance.read(SecureStorageKeys.apiGatewayUrl);
7173
final ipfsServer = await SecureStorageService.instance.read(SecureStorageKeys.ipfsServerUrl);
7274
final billingServer = await SecureStorageService.instance.read(SecureStorageKeys.billingServerUrl);
7375
final aiEndpoint = await SecureStorageService.instance.read(SecureStorageKeys.aiEndpointUrl);
7476
final ipfsGateway = await SecureStorageService.instance.read(SecureStorageKeys.ipfsGatewayUrl);
77+
final ipfsEndpoint = await SecureStorageService.instance.read(SecureStorageKeys.ipfsEndpointUrl);
7578
final jwtToken = await SecureStorageService.instance.read(SecureStorageKeys.jwtToken);
7679

7780
setState(() {
@@ -80,6 +83,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
8083
_billingServerController.text = billingServer ?? _defaultBillingServer;
8184
_aiEndpointController.text = aiEndpoint ?? _defaultAiEndpoint;
8285
_ipfsGatewayController.text = ipfsGateway ?? _defaultIpfsGateway;
86+
_ipfsEndpointController.text = ipfsEndpoint ?? _defaultIpfsEndpoint;
8387
_jwtTokenController.text = jwtToken ?? '';
8488
});
8589
}
@@ -92,6 +96,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
9296
_billingServerController.dispose();
9397
_aiEndpointController.dispose();
9498
_ipfsGatewayController.dispose();
99+
_ipfsEndpointController.dispose();
95100
_jwtTokenController.dispose();
96101
super.dispose();
97102
}
@@ -222,6 +227,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
222227
: _ipfsGatewayController.text,
223228
),
224229
),
230+
ListTile(
231+
leading: const Icon(LucideIcons.hardDrive),
232+
title: const Text('IPFS Endpoint'),
233+
subtitle: Text(
234+
_ipfsEndpointController.text.isEmpty
235+
? 'Not configured'
236+
: _ipfsEndpointController.text,
237+
),
238+
),
225239
ListTile(
226240
leading: const Icon(LucideIcons.key),
227241
title: const Text('API Key'),
@@ -282,6 +296,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
282296
),
283297
),
284298
const SizedBox(height: 12),
299+
TextField(
300+
controller: _ipfsEndpointController,
301+
decoration: const InputDecoration(
302+
labelText: 'IPFS Endpoint URL',
303+
hintText: 'https://ipfs.cloud.fx.land',
304+
prefixIcon: Icon(LucideIcons.hardDrive),
305+
),
306+
),
307+
const SizedBox(height: 12),
285308
TextField(
286309
controller: _jwtTokenController,
287310
decoration: InputDecoration(
@@ -713,6 +736,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
713736
SecureStorageKeys.ipfsGatewayUrl,
714737
_ipfsGatewayController.text,
715738
);
739+
await SecureStorageService.instance.write(
740+
SecureStorageKeys.ipfsEndpointUrl,
741+
_ipfsEndpointController.text,
742+
);
716743
await SecureStorageService.instance.write(
717744
SecureStorageKeys.jwtToken,
718745
_jwtTokenController.text,

lib/shared/utils/error_messages.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,11 @@ class ErrorMessages {
362362
return getUserFriendlyMessage(error, context: 'share');
363363
}
364364

365+
/// Get error message for public IPFS share operations
366+
static String forPublicShare(dynamic error) {
367+
return getUserFriendlyMessage(error, context: 'share publicly');
368+
}
369+
365370
/// Get error message for delete operations
366371
static String forDelete(dynamic error) {
367372
return getUserFriendlyMessage(error, context: 'delete');

0 commit comments

Comments
 (0)