Implement file sharing functionality with public share links and associated API endpoints
This commit is contained in:
@@ -12,6 +12,7 @@ import 'pages/file_explorer.dart';
|
|||||||
import 'pages/document_viewer.dart';
|
import 'pages/document_viewer.dart';
|
||||||
import 'pages/editor_page.dart';
|
import 'pages/editor_page.dart';
|
||||||
import 'pages/join_page.dart';
|
import 'pages/join_page.dart';
|
||||||
|
import 'pages/public_file_viewer.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
import 'injection.dart';
|
import 'injection.dart';
|
||||||
|
|
||||||
@@ -42,6 +43,11 @@ final GoRouter _router = GoRouter(
|
|||||||
builder: (context, state) =>
|
builder: (context, state) =>
|
||||||
JoinPage(token: state.uri.queryParameters['token'] ?? ''),
|
JoinPage(token: state.uri.queryParameters['token'] ?? ''),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/share/:token',
|
||||||
|
builder: (context, state) =>
|
||||||
|
PublicFileViewer(token: state.pathParameters['token']!),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import 'document_viewer.dart';
|
|||||||
import 'video_viewer.dart';
|
import 'video_viewer.dart';
|
||||||
import '../injection.dart';
|
import '../injection.dart';
|
||||||
import '../services/file_service.dart';
|
import '../services/file_service.dart';
|
||||||
|
import '../widgets/share_file_dialog.dart';
|
||||||
|
|
||||||
typedef AudioFileSelectedCallback =
|
typedef AudioFileSelectedCallback =
|
||||||
void Function(String fileName, String fileUrl);
|
void Function(String fileName, String fileUrl);
|
||||||
@@ -815,10 +816,15 @@ class _FileExplorerState extends State<FileExplorer>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _sendFile(FileItem file) {
|
void _shareFile(FileItem file) {
|
||||||
ScaffoldMessenger.of(
|
showDialog(
|
||||||
context,
|
context: context,
|
||||||
).showSnackBar(SnackBar(content: Text('Send ${file.name}')));
|
builder: (BuildContext context) => ShareFileDialog(
|
||||||
|
orgId: widget.orgId,
|
||||||
|
fileId: file.id!,
|
||||||
|
fileName: file.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deleteFile(FileItem file) async {
|
Future<void> _deleteFile(FileItem file) async {
|
||||||
@@ -1304,10 +1310,10 @@ class _FileExplorerState extends State<FileExplorer>
|
|||||||
onPressed: () => _downloadFile(file),
|
onPressed: () => _downloadFile(file),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.send, color: AppTheme.secondaryText),
|
icon: const Icon(Icons.share, color: AppTheme.secondaryText),
|
||||||
splashColor: Colors.transparent,
|
splashColor: Colors.transparent,
|
||||||
highlightColor: Colors.transparent,
|
highlightColor: Colors.transparent,
|
||||||
onPressed: () => _sendFile(file),
|
onPressed: () => _shareFile(file),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete, color: AppTheme.secondaryText),
|
icon: const Icon(Icons.delete, color: AppTheme.secondaryText),
|
||||||
|
|||||||
168
b0esche_cloud/lib/pages/public_file_viewer.dart
Normal file
168
b0esche_cloud/lib/pages/public_file_viewer.dart
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'dart:js_interop';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
import '../services/api_client.dart';
|
||||||
|
import '../injection.dart';
|
||||||
|
|
||||||
|
class PublicFileViewer extends StatefulWidget {
|
||||||
|
final String token;
|
||||||
|
|
||||||
|
const PublicFileViewer({super.key, required this.token});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PublicFileViewer> createState() => _PublicFileViewerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PublicFileViewerState extends State<PublicFileViewer> {
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
Map<String, dynamic>? _fileData;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadFileData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFileData() async {
|
||||||
|
try {
|
||||||
|
final apiClient = getIt<ApiClient>();
|
||||||
|
final response = await apiClient.getRaw('/public/share/${widget.token}');
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_fileData = response;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'This link is invalid or has expired.';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadFile() {
|
||||||
|
if (_fileData != null && _fileData!['downloadUrl'] != null) {
|
||||||
|
// Use http package to download
|
||||||
|
http.get(Uri.parse(_fileData!['downloadUrl'])).then((response) {
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
// Trigger download in web
|
||||||
|
final uint8List = Uint8List.fromList(response.bodyBytes);
|
||||||
|
final jsUint8Array = JSUint8Array(
|
||||||
|
uint8List.buffer.toJS,
|
||||||
|
uint8List.offsetInBytes,
|
||||||
|
uint8List.length,
|
||||||
|
);
|
||||||
|
final jsArray = JSArray<JSAny>.withLength(1);
|
||||||
|
jsArray[0] = jsUint8Array;
|
||||||
|
final blob = web.Blob(jsArray);
|
||||||
|
final url = web.URL.createObjectURL(blob);
|
||||||
|
final anchor = web.HTMLAnchorElement()
|
||||||
|
..href = url
|
||||||
|
..download = _fileData!['fileName'] ?? 'download';
|
||||||
|
anchor.click();
|
||||||
|
web.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppTheme.primaryBackground,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: AppTheme.primaryBackground,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: AppTheme.primaryText),
|
||||||
|
onPressed: () => context.go('/'),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
_fileData?['fileName'] ?? 'Shared File',
|
||||||
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (_fileData != null)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.download, color: AppTheme.primaryText),
|
||||||
|
onPressed: _downloadFile,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Center(
|
||||||
|
child: _isLoading
|
||||||
|
? const CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.accentColor),
|
||||||
|
)
|
||||||
|
: _error != null
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.link_off, size: 64, color: Colors.red[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _fileData != null
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.insert_drive_file,
|
||||||
|
size: 64,
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_fileData!['fileName'] ?? 'Unknown file',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Size: ${(_fileData!['fileSize'] ?? 0) ~/ 1024} KB',
|
||||||
|
style: TextStyle(color: AppTheme.secondaryText),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _downloadFile,
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: const Text('Download File'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.accentColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,15 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> postRaw(String path, {dynamic data}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.post(path, data: data);
|
||||||
|
return response.data;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<T> patch<T>(
|
Future<T> patch<T>(
|
||||||
String path, {
|
String path, {
|
||||||
dynamic data,
|
dynamic data,
|
||||||
|
|||||||
239
b0esche_cloud/lib/widgets/share_file_dialog.dart
Normal file
239
b0esche_cloud/lib/widgets/share_file_dialog.dart
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../services/api_client.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
import '../injection.dart';
|
||||||
|
|
||||||
|
class ShareFileDialog extends StatefulWidget {
|
||||||
|
final String orgId;
|
||||||
|
final String fileId;
|
||||||
|
final String fileName;
|
||||||
|
|
||||||
|
const ShareFileDialog({
|
||||||
|
super.key,
|
||||||
|
required this.orgId,
|
||||||
|
required this.fileId,
|
||||||
|
required this.fileName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ShareFileDialog> createState() => _ShareFileDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShareFileDialogState extends State<ShareFileDialog> {
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _shareUrl;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadShareLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadShareLink() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final apiClient = getIt<ApiClient>();
|
||||||
|
final response = await apiClient.getRaw(
|
||||||
|
'/orgs/${widget.orgId}/files/${widget.fileId}/share',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response['exists'] == true) {
|
||||||
|
setState(() {
|
||||||
|
_shareUrl = response['url'];
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_shareUrl = null;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Failed to load share link';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createShareLink() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final apiClient = getIt<ApiClient>();
|
||||||
|
final response = await apiClient.postRaw(
|
||||||
|
'/orgs/${widget.orgId}/files/${widget.fileId}/share',
|
||||||
|
data: {},
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_shareUrl = response['url'];
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Failed to create share link';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _revokeShareLink() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final apiClient = getIt<ApiClient>();
|
||||||
|
await apiClient.delete(
|
||||||
|
'/orgs/${widget.orgId}/files/${widget.fileId}/share',
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_shareUrl = null;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Failed to revoke share link';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _copyToClipboard() {
|
||||||
|
if (_shareUrl != null) {
|
||||||
|
Clipboard.setData(ClipboardData(text: _shareUrl!));
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Link copied to clipboard')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 500),
|
||||||
|
child: Container(
|
||||||
|
decoration: AppTheme.glassDecoration,
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Share "${widget.fileName}"',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (_isLoading)
|
||||||
|
const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_error != null)
|
||||||
|
Text(_error!, style: TextStyle(color: Colors.red[400]))
|
||||||
|
else if (_shareUrl == null)
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'No share link exists for this file.',
|
||||||
|
style: TextStyle(color: AppTheme.secondaryText),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _createShareLink,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.accentColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: const Text('Create Share Link'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Share link created. Anyone with this link can view and download the file.',
|
||||||
|
style: TextStyle(color: AppTheme.secondaryText),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_shareUrl!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.copy,
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
onPressed: _copyToClipboard,
|
||||||
|
tooltip: 'Copy link',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _revokeShareLink,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.red[400],
|
||||||
|
),
|
||||||
|
child: const Text('Revoke Link'),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
go_cloud/api
BIN
go_cloud/api
Binary file not shown.
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"go.b0esche.cloud/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
@@ -1149,4 +1150,69 @@ func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateFileSize updates the size and last_modified timestamp of a file
|
// FileShareLink methods
|
||||||
|
|
||||||
|
// CreateFileShareLink creates a new share link for a file
|
||||||
|
func (db *DB) CreateFileShareLink(ctx context.Context, token string, fileID, orgID, createdByUserID uuid.UUID) (*models.FileShareLink, error) {
|
||||||
|
var link models.FileShareLink
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO file_share_links (token, file_id, org_id, created_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked
|
||||||
|
`, token, fileID, orgID, createdByUserID).Scan(
|
||||||
|
&link.ID, &link.Token, &link.FileID, &link.OrgID, &link.CreatedByUserID,
|
||||||
|
&link.CreatedAt, &link.UpdatedAt, &link.ExpiresAt, &link.IsRevoked)
|
||||||
|
return &link, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileShareLinkByFileID gets the active share link for a file
|
||||||
|
func (db *DB) GetFileShareLinkByFileID(ctx context.Context, fileID uuid.UUID) (*models.FileShareLink, error) {
|
||||||
|
var link models.FileShareLink
|
||||||
|
var expiresAtNull sql.NullTime
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked
|
||||||
|
FROM file_share_links
|
||||||
|
WHERE file_id = $1 AND is_revoked = FALSE AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, fileID).Scan(
|
||||||
|
&link.ID, &link.Token, &link.FileID, &link.OrgID, &link.CreatedByUserID,
|
||||||
|
&link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if expiresAtNull.Valid {
|
||||||
|
link.ExpiresAt = &expiresAtNull.Time
|
||||||
|
}
|
||||||
|
return &link, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileShareLinkByToken gets a share link by token
|
||||||
|
func (db *DB) GetFileShareLinkByToken(ctx context.Context, token string) (*models.FileShareLink, error) {
|
||||||
|
var link models.FileShareLink
|
||||||
|
var expiresAtNull sql.NullTime
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked
|
||||||
|
FROM file_share_links
|
||||||
|
WHERE token = $1 AND is_revoked = FALSE AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
`, token).Scan(
|
||||||
|
&link.ID, &link.Token, &link.FileID, &link.OrgID, &link.CreatedByUserID,
|
||||||
|
&link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if expiresAtNull.Valid {
|
||||||
|
link.ExpiresAt = &expiresAtNull.Time
|
||||||
|
}
|
||||||
|
return &link, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeFileShareLink revokes a share link
|
||||||
|
func (db *DB) RevokeFileShareLink(ctx context.Context, fileID uuid.UUID) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE file_share_links
|
||||||
|
SET is_revoked = TRUE, updated_at = NOW()
|
||||||
|
WHERE file_id = $1 AND is_revoked = FALSE
|
||||||
|
`, fileID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -262,6 +262,16 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
|
|||||||
r.Get("/meta", func(w http.ResponseWriter, req *http.Request) {
|
r.Get("/meta", func(w http.ResponseWriter, req *http.Request) {
|
||||||
fileMetaHandler(w, req)
|
fileMetaHandler(w, req)
|
||||||
})
|
})
|
||||||
|
// Share link management
|
||||||
|
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/share", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
getFileShareLinkHandler(w, req, db)
|
||||||
|
})
|
||||||
|
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/share", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
createFileShareLinkHandler(w, req, db)
|
||||||
|
})
|
||||||
|
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Delete("/share", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
revokeFileShareLinkHandler(w, req, db)
|
||||||
|
})
|
||||||
// WOPI session for org files
|
// WOPI session for org files
|
||||||
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Post("/wopi-session", func(w http.ResponseWriter, req *http.Request) {
|
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Post("/wopi-session", func(w http.ResponseWriter, req *http.Request) {
|
||||||
wopiSessionHandler(w, req, db, jwtManager, "https://of.b0esche.cloud")
|
wopiSessionHandler(w, req, db, jwtManager, "https://of.b0esche.cloud")
|
||||||
@@ -319,6 +329,16 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
|
|||||||
})
|
})
|
||||||
}) // Close protected routes
|
}) // Close protected routes
|
||||||
|
|
||||||
|
// Public routes (no auth required)
|
||||||
|
r.Route("/public", func(r chi.Router) {
|
||||||
|
r.Get("/share/{token}", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
publicFileShareHandler(w, req, db, jwtManager)
|
||||||
|
})
|
||||||
|
r.Get("/share/{token}/download", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
publicFileDownloadHandler(w, req, db, cfg, jwtManager)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2672,3 +2692,315 @@ func getMimeType(filename string) string {
|
|||||||
return "application/octet-stream"
|
return "application/octet-stream"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File share handlers
|
||||||
|
|
||||||
|
func getFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
||||||
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
||||||
|
fileId := chi.URLParam(r, "fileId")
|
||||||
|
|
||||||
|
fileUUID, err := uuid.Parse(fileId)
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists and belongs to org
|
||||||
|
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||||
|
if err != nil {
|
||||||
|
errors.LogError(r, err, "Failed to get file")
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if file.OrgID == nil || *file.OrgID != orgID {
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
link, err := db.GetFileShareLinkByFileID(r.Context(), fileUUID)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// No share link exists
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"exists": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errors.LogError(r, err, "Failed to get share link")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build full URL
|
||||||
|
scheme := "https"
|
||||||
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||||
|
scheme = proto
|
||||||
|
} else if r.TLS == nil {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
host := r.Host
|
||||||
|
if host == "" {
|
||||||
|
host = "go.b0esche.cloud"
|
||||||
|
}
|
||||||
|
fullURL := fmt.Sprintf("%s://%s/public/share/%s", scheme, host, link.Token)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"exists": true,
|
||||||
|
"url": fullURL,
|
||||||
|
"token": link.Token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func createFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
||||||
|
userIDStr, _ := middleware.GetUserID(r.Context())
|
||||||
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
||||||
|
fileId := chi.URLParam(r, "fileId")
|
||||||
|
|
||||||
|
fileUUID, err := uuid.Parse(fileId)
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists and belongs to org
|
||||||
|
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||||
|
if err != nil {
|
||||||
|
errors.LogError(r, err, "Failed to get file")
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if file.OrgID == nil || *file.OrgID != orgID {
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke existing link if any
|
||||||
|
db.RevokeFileShareLink(r.Context(), fileUUID) // Ignore error
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
token, err := storage.GenerateSecurePassword(32)
|
||||||
|
if err != nil {
|
||||||
|
errors.LogError(r, err, "Failed to generate token")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
link, err := db.CreateFileShareLink(r.Context(), token, fileUUID, orgID, userID)
|
||||||
|
if err != nil {
|
||||||
|
errors.LogError(r, err, "Failed to create share link")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build full URL
|
||||||
|
scheme := "https"
|
||||||
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||||
|
scheme = proto
|
||||||
|
} else if r.TLS == nil {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
host := r.Host
|
||||||
|
if host == "" {
|
||||||
|
host = "go.b0esche.cloud"
|
||||||
|
}
|
||||||
|
fullURL := fmt.Sprintf("%s://%s/public/share/%s", scheme, host, link.Token)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"url": fullURL,
|
||||||
|
"token": link.Token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
||||||
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
||||||
|
fileId := chi.URLParam(r, "fileId")
|
||||||
|
|
||||||
|
fileUUID, err := uuid.Parse(fileId)
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists and belongs to org
|
||||||
|
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||||
|
if err != nil {
|
||||||
|
errors.LogError(r, err, "Failed to get file")
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if file.OrgID == nil || *file.OrgID != orgID {
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.RevokeFileShareLink(r.Context(), fileUUID)
|
||||||
|
if err != nil {
|
||||||
|
errors.LogError(r, err, "Failed to revoke share link")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicFileShareHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
||||||
|
token := chi.URLParam(r, "token")
|
||||||
|
if token == "" {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
link, err := db.GetFileShareLinkByToken(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "Link not found or expired", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errors.LogError(r, err, "Failed to get share link")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file metadata
|
||||||
|
file, err := db.GetFileByID(r.Context(), link.FileID)
|
||||||
|
if err != nil {
|
||||||
|
errors.LogError(r, err, "Failed to get file")
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a short-lived token for download (1 hour)
|
||||||
|
viewerToken, err := jwtManager.GenerateWithDuration("", []string{link.OrgID.String()}, "", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
errors.LogError(r, err, "Failed to generate viewer token")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build download URL
|
||||||
|
scheme := "https"
|
||||||
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||||
|
scheme = proto
|
||||||
|
} else if r.TLS == nil {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
host := r.Host
|
||||||
|
if host == "" {
|
||||||
|
host = "go.b0esche.cloud"
|
||||||
|
}
|
||||||
|
downloadPath := fmt.Sprintf("%s://%s/public/share/%s/download?token=%s", scheme, host, token, url.QueryEscape(viewerToken))
|
||||||
|
|
||||||
|
// Determine file type
|
||||||
|
isPdf := strings.HasSuffix(strings.ToLower(file.Name), ".pdf")
|
||||||
|
mimeType := getMimeType(file.Name)
|
||||||
|
|
||||||
|
viewerSession := struct {
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
FileSize int64 `json:"fileSize"`
|
||||||
|
DownloadUrl string `json:"downloadUrl"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Capabilities struct {
|
||||||
|
CanEdit bool `json:"canEdit"`
|
||||||
|
CanAnnotate bool `json:"canAnnotate"`
|
||||||
|
IsPdf bool `json:"isPdf"`
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
} `json:"capabilities"`
|
||||||
|
}{
|
||||||
|
FileName: file.Name,
|
||||||
|
FileSize: file.Size,
|
||||||
|
DownloadUrl: downloadPath,
|
||||||
|
Token: viewerToken,
|
||||||
|
}
|
||||||
|
viewerSession.Capabilities.CanEdit = false
|
||||||
|
viewerSession.Capabilities.CanAnnotate = false
|
||||||
|
viewerSession.Capabilities.IsPdf = isPdf
|
||||||
|
viewerSession.Capabilities.MimeType = mimeType
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(viewerSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicFileDownloadHandler(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config, jwtManager *jwt.Manager) {
|
||||||
|
token := chi.URLParam(r, "token")
|
||||||
|
if token == "" {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewerToken := r.URL.Query().Get("token")
|
||||||
|
if viewerToken == "" {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Viewer token required", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify viewer token (contains org ID)
|
||||||
|
claims, err := jwtManager.Validate(viewerToken)
|
||||||
|
if err != nil {
|
||||||
|
errors.LogError(r, err, "Invalid viewer token")
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(claims.OrgIDs) == 0 {
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orgID, err := uuid.Parse(claims.OrgIDs[0])
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
link, err := db.GetFileShareLinkByToken(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "Link not found or expired", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errors.LogError(r, err, "Failed to get share link")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if link.OrgID != orgID {
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file metadata
|
||||||
|
file, err := db.GetFileByID(r.Context(), link.FileID)
|
||||||
|
if err != nil {
|
||||||
|
errors.LogError(r, err, "Failed to get file")
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get WebDAV client for org
|
||||||
|
client, err := getUserWebDAVClient(r.Context(), db, uuid.Nil, "https://of.b0esche.cloud", cfg.NextcloudUser, cfg.NextcloudPass)
|
||||||
|
if err != nil {
|
||||||
|
errors.LogError(r, err, "Failed to get WebDAV client")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream file
|
||||||
|
resp, err := client.Download(r.Context(), file.Path, "")
|
||||||
|
if err != nil {
|
||||||
|
errors.LogError(r, err, "Failed to download file")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Copy headers
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
w.Header()[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy body
|
||||||
|
io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
|
|||||||
20
go_cloud/internal/models/file_share_link.go
Normal file
20
go_cloud/internal/models/file_share_link.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileShareLink represents a public share link for a file
|
||||||
|
type FileShareLink struct {
|
||||||
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
|
Token string `json:"token" db:"token"`
|
||||||
|
FileID uuid.UUID `json:"file_id" db:"file_id"`
|
||||||
|
OrgID uuid.UUID `json:"org_id" db:"org_id"`
|
||||||
|
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"`
|
||||||
|
IsRevoked bool `json:"is_revoked" db:"is_revoked"`
|
||||||
|
}
|
||||||
17
go_cloud/migrations/0007_file_share_links.sql
Normal file
17
go_cloud/migrations/0007_file_share_links.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- Create file_share_links table
|
||||||
|
|
||||||
|
CREATE TABLE file_share_links (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||||
|
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
created_by_user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
is_revoked BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_file_share_links_token ON file_share_links(token);
|
||||||
|
CREATE INDEX idx_file_share_links_file_id ON file_share_links(file_id);
|
||||||
|
CREATE INDEX idx_file_share_links_org_id ON file_share_links(org_id);
|
||||||
10
go_cloud/migrations/0007_file_share_links_down.sql
Normal file
10
go_cloud/migrations/0007_file_share_links_down.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- Drop file_share_links table
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS file_share_links;
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
is_revoked BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_file_share_links_token ON file_share_links(token);
|
||||||
|
CREATE INDEX idx_file_share_links_file_id ON file_share_links(file_id);
|
||||||
|
CREATE INDEX idx_file_share_links_org_id ON file_share_links(org_id);
|
||||||
3
go_cloud/migrations/0007_file_share_links_down.sql.bak
Normal file
3
go_cloud/migrations/0007_file_share_links_down.sql.bak
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Drop file_share_links table
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS file_share_links;
|
||||||
Reference in New Issue
Block a user