diff --git a/.env.example b/.env.example index cbd5337..677d62e 100644 --- a/.env.example +++ b/.env.example @@ -68,9 +68,11 @@ SOCIAL_DATABASE__PASSWORD=change-me-strong-password # Agent Chat 附件存储配置(仅基础设施变量) ############ SOCIAL_STORAGE__PROVIDER=supabase -SOCIAL_STORAGE__BUCKET=agent-chat-attachments +SOCIAL_STORAGE__ATTACHMENT__BUCKET=agent-chat-attachments +SOCIAL_STORAGE__AVATAR__BUCKET=avatars SOCIAL_STORAGE__SIGNED_URL_TTL_SECONDS=600 -SOCIAL_STORAGE__MAX_FILE_SIZE_MB=20 +SOCIAL_STORAGE__ATTACHMENT__MAX_SIZE_MB=20 +SOCIAL_STORAGE__AVATAR__MAX_SIZE_MB=2 SOCIAL_STORAGE__RETENTION_DAYS=30 ###### diff --git a/.gitignore b/.gitignore index 0165cbe..957fe1a 100644 --- a/.gitignore +++ b/.gitignore @@ -300,9 +300,12 @@ backend/logs/ # Docker volumes (local data) docker/supabase/volumes/db/data/ infra/docker/volumes/db/data/ +infra/docker/supabase/volumes/db/data/ +infra/docker/supabase/volumes/storage/ # OpenCode local config # .opencode/ is now tracked - see .opencode/.gitignore for exclusions +.opencode/opencode.json.old # Local git worktrees .worktrees/ diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 8cd9d3c..12c787f 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -2,19 +2,9 @@ "$schema": "https://opencode.ai/config.json", "mcp": { "supabase": { - "type": "local", + "type": "remote", "enabled": true, - "command": [ - "npx", - "-y", - "@aliyun-rds/supabase-mcp-server", - "--supabase-url", - "http://47.112.66.83", - "--supabase-anon-key", - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJhbm9uIiwiaWF0IjoxNzczMDI3NDE5LCJleHAiOjEzMjgzNjY3NDE5fQ.NVXDla5_nYPdcJk_81fc3k1UrnNTrNne_trMqt6Hg4g", - "--supabase-service-role-key", - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJzZXJ2aWNlX3JvbGUiLCJpYXQiOjE3NzMwMjc0MTksImV4cCI6MTMyODM2Njc0MTl9.RzQBia-3QcjupsHnqaxgDWB7wnY9R7Ms9R8pMokyvLY" - ] + "url": "http://localhost:8001/mcp" } } } diff --git a/apps/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index f297fb5..5a0d9d5 100644 Binary files a/apps/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/apps/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/apps/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png index 89b89ea..32be01b 100644 Binary files a/apps/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/apps/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/apps/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index aac4d18..f8a0bcb 100644 Binary files a/apps/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/apps/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index ee82abb..577bbb8 100644 Binary files a/apps/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/apps/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png index 223e9a5..4837aba 100644 Binary files a/apps/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/apps/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index c73a2f8..a636597 100644 Binary files a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index c1c874c..d9780b6 100644 Binary files a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index cb855a2..ce46e0f 100644 Binary files a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index f548e67..2e93239 100644 Binary files a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 2a18419..e2d19fe 100644 Binary files a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/assets/images/logo.png b/apps/assets/images/logo.png index d84a542..065e189 100644 Binary files a/apps/assets/images/logo.png and b/apps/assets/images/logo.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 25cee57..891fe69 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index eaab8fc..58fc831 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index ab7b321..d3217b1 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index bedeec5..94660a2 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 9f4894e..126b2ee 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index 69fcb4f..2a1d5d8 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 2f03318..1d540bf 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index ab7b321..d3217b1 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 29b3546..85fd429 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 716a015..ceb251b 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index b3fb4ac..99faacb 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index be520cd..10fd4ef 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index 4cb0c7e..46cd61d 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index 133d810..8440a49 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 716a015..ceb251b 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index ddcd7a2..b022998 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index c73a2f8..a636597 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index f548e67..2e93239 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index ae76645..9e3b8a3 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index afd3f9c..1e1f425 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 45ef8eb..314147f 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/apps/lib/features/settings/ui/screens/edit_profile_screen.dart b/apps/lib/features/settings/ui/screens/edit_profile_screen.dart index 4b51bdf..f60eaff 100644 --- a/apps/lib/features/settings/ui/screens/edit_profile_screen.dart +++ b/apps/lib/features/settings/ui/screens/edit_profile_screen.dart @@ -1,5 +1,7 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../core/di/injection.dart'; import '../../../../shared/widgets/app_button.dart'; @@ -24,10 +26,13 @@ class _EditProfileScreenState extends State { final _bioController = TextEditingController(); final _usersApi = sl(); final _userCache = sl(); + final _imagePicker = ImagePicker(); UserResponse? _user; + File? _selectedAvatar; bool _isLoading = true; bool _isSaving = false; + bool _isUploadingAvatar = false; bool _hasChanges = false; @override @@ -73,27 +78,74 @@ class _EditProfileScreenState extends State { if (_user == null) return; final usernameChanged = _usernameController.text != _user!.username; final bioChanged = _bioController.text != (_user!.bio ?? ''); + final avatarChanged = _selectedAvatar != null; - if ((usernameChanged || bioChanged) != _hasChanges) { + if ((usernameChanged || bioChanged || avatarChanged) != _hasChanges) { setState(() { - _hasChanges = usernameChanged || bioChanged; + _hasChanges = usernameChanged || bioChanged || avatarChanged; }); } } + Future _pickAvatar() async { + final pickedFile = await _imagePicker.pickImage( + source: ImageSource.gallery, + maxWidth: 400, + maxHeight: 400, + imageQuality: 80, + ); + if (pickedFile != null) { + setState(() { + _selectedAvatar = File(pickedFile.path); + _hasChanges = true; + }); + } + } + + Future _uploadAvatar() async { + if (_selectedAvatar == null) return; + + setState(() { + _isUploadingAvatar = true; + }); + + try { + await _usersApi.uploadAvatar(_selectedAvatar!); + if (mounted) { + Toast.show(context, '头像上传成功', type: ToastType.success); + _selectedAvatar = null; + await _loadUser(); + } + } catch (e) { + if (mounted) { + Toast.show(context, '头像上传失败,请重试', type: ToastType.error); + } + } finally { + if (mounted) { + setState(() { + _isUploadingAvatar = false; + }); + } + } + } + Future _saveProfile() async { if (!_hasChanges || _user == null) return; final newUsername = _usernameController.text.trim(); final newBio = _bioController.text.trim(); + final usernameChanged = newUsername != _user!.username; + final bioChanged = newBio != (_user!.bio ?? ''); - if (newUsername.isEmpty) { - Toast.show(context, '用户名不能为空', type: ToastType.warning); - return; - } - if (newUsername.length < 3 || newUsername.length > 30) { - Toast.show(context, '用户名需要3-30个字符', type: ToastType.warning); - return; + if (usernameChanged) { + if (newUsername.isEmpty) { + Toast.show(context, '用户名不能为空', type: ToastType.warning); + return; + } + if (newUsername.length < 3 || newUsername.length > 30) { + Toast.show(context, '用户名需要3-30个字符', type: ToastType.warning); + return; + } } setState(() { @@ -101,12 +153,18 @@ class _EditProfileScreenState extends State { }); try { - final request = UserUpdateRequest( - username: newUsername, - bio: newBio.isEmpty ? null : newBio, - ); - final updatedUser = await _usersApi.updateMe(request); - _userCache.set(updatedUser); + if (_selectedAvatar != null) { + await _uploadAvatar(); + } + + if (usernameChanged || bioChanged) { + final request = UserUpdateRequest( + username: usernameChanged ? newUsername : null, + bio: bioChanged ? (newBio.isEmpty ? null : newBio) : null, + ); + final updatedUser = await _usersApi.updateMe(request); + _userCache.set(updatedUser); + } if (mounted) { Toast.show(context, '保存成功', type: ToastType.success); @@ -171,6 +229,8 @@ class _EditProfileScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildAvatarSection(), + const SizedBox(height: AppSpacing.lg), const Text( '用户名', style: TextStyle( @@ -191,6 +251,87 @@ class _EditProfileScreenState extends State { ); } + Widget _buildAvatarSection() { + final avatarUrl = _user?.avatarUrl; + final hasSelectedAvatar = _selectedAvatar != null; + + return Center( + child: GestureDetector( + onTap: _isUploadingAvatar ? null : _pickAvatar, + child: Stack( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.surfaceSecondary, + border: Border.all(color: AppColors.borderTertiary, width: 2), + image: hasSelectedAvatar + ? DecorationImage( + image: FileImage(_selectedAvatar!), + fit: BoxFit.cover, + ) + : avatarUrl != null + ? DecorationImage( + image: NetworkImage(avatarUrl), + fit: BoxFit.cover, + ) + : null, + ), + child: !hasSelectedAvatar && avatarUrl == null + ? const Icon( + Icons.person, + size: 40, + color: AppColors.slate400, + ) + : null, + ), + if (_isUploadingAvatar) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.black.withValues(alpha: 0.4), + ), + child: const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + AppColors.white, + ), + ), + ), + ), + ), + ), + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.blue500, + border: Border.all(color: AppColors.white, width: 2), + ), + child: const Icon( + Icons.camera_alt, + size: 14, + color: AppColors.white, + ), + ), + ), + ], + ), + ), + ); + } + Widget _buildBioSection() { return AccountSectionCard( title: '个人简介', diff --git a/apps/lib/features/settings/ui/screens/settings_screen.dart b/apps/lib/features/settings/ui/screens/settings_screen.dart index c0435f9..e7263e6 100644 --- a/apps/lib/features/settings/ui/screens/settings_screen.dart +++ b/apps/lib/features/settings/ui/screens/settings_screen.dart @@ -188,11 +188,6 @@ class _SettingsScreenState extends State { width: 64, height: 64, decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.blue100, AppColors.blue50], - ), borderRadius: BorderRadius.circular(32), boxShadow: [ BoxShadow( @@ -207,11 +202,8 @@ class _SettingsScreenState extends State { ), ], ), - child: const Icon( - Icons.person, - size: 28, - color: AppColors.blue600, - ), + clipBehavior: Clip.antiAlias, + child: _buildAvatarImage(_user?.avatarUrl), ), const SizedBox(width: AppSpacing.lg), Expanded( @@ -307,6 +299,61 @@ class _SettingsScreenState extends State { ); } + Widget _buildAvatarImage(String? avatarUrl) { + if (avatarUrl == null) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.blue100, AppColors.blue50], + ), + ), + child: const Icon(Icons.person, size: 28, color: AppColors.blue600), + ); + } + return Image.network( + avatarUrl, + fit: BoxFit.cover, + width: 64, + height: 64, + errorBuilder: (context, error, stackTrace) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.blue100, AppColors.blue50], + ), + ), + child: const Icon(Icons.person, size: 28, color: AppColors.blue600), + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.blue100, AppColors.blue50], + ), + ), + child: const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.blue600), + ), + ), + ), + ); + }, + ); + } + String _buildFriendsSubtitle() { if (_friendsCount == 0) { return '暂无联系人'; diff --git a/apps/lib/features/users/data/users_api.dart b/apps/lib/features/users/data/users_api.dart index 414fd52..e93c4bc 100644 --- a/apps/lib/features/users/data/users_api.dart +++ b/apps/lib/features/users/data/users_api.dart @@ -1,3 +1,5 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:social_app/core/api/i_api_client.dart'; import 'models/user_response.dart'; @@ -38,6 +40,28 @@ class UsersApi { return UserResponse.fromJson(response.data); } + Future uploadAvatar(File file) async { + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile( + file.path, + filename: file.path.split('/').last, + ), + }); + final response = await _client.post>( + '$_prefix/me/avatar', + data: formData, + ); + final payload = response.data; + if (payload is! Map) { + throw StateError('Invalid /users/me/avatar response'); + } + final url = payload['url']; + if (url is! String) { + throw StateError('Missing url in /users/me/avatar response'); + } + return url; + } + Future> searchUsers(String query) async { final response = await _client.post( '$_prefix/search', diff --git a/backend/alembic/versions/20260326_0001_drop_dedup_backup_tables.py b/backend/alembic/versions/20260326_0001_drop_dedup_backup_tables.py new file mode 100644 index 0000000..100eb20 --- /dev/null +++ b/backend/alembic/versions/20260326_0001_drop_dedup_backup_tables.py @@ -0,0 +1,24 @@ +"""drop obsolete dedup backup tables from public schema + +Revision ID: 202603260001 +Revises: 202603250001 +Create Date: 2026-03-26 14:20:00 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "202603260001" +down_revision: Union[str, Sequence[str], None] = "202603250001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("DROP TABLE IF EXISTS public.automation_jobs_dedup_backup_202603230003") + op.execute("DROP TABLE IF EXISTS public.memories_dedup_backup_202603230003") + + +def downgrade() -> None: + """Downgrade intentionally unsupported for obsolete backup table removal.""" diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 86136dd..cdab1df 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -148,11 +148,22 @@ class SupabaseSettings(BaseModel): class StorageSettings(BaseModel): provider: Literal["supabase"] = "supabase" - bucket: str = Field(default="agent-chat-attachments", min_length=3, max_length=63) signed_url_ttl_seconds: int = Field(default=600, ge=60, le=3600) - max_file_size_mb: int = Field(default=20, ge=1, le=200) retention_days: int = Field(default=30, ge=1, le=3650) + class AttachmentSettings(BaseModel): + bucket: str = Field( + default="agent-chat-attachments", min_length=3, max_length=63 + ) + max_size_mb: int = Field(default=20, ge=1, le=200) + + class AvatarSettings(BaseModel): + bucket: str = Field(default="avatars", min_length=3, max_length=63) + max_size_mb: int = Field(default=2, ge=1, le=10) + + attachment: AttachmentSettings = Field(default_factory=AttachmentSettings) + avatar: AvatarSettings = Field(default_factory=AvatarSettings) + class AgentRuntimeSettings(BaseModel): redis_stream_prefix: str = "agent:events" diff --git a/backend/src/core/config/static/database/system_agents.yaml b/backend/src/core/config/static/database/system_agents.yaml index 65e1611..3b1d51d 100644 --- a/backend/src/core/config/static/database/system_agents.yaml +++ b/backend/src/core/config/static/database/system_agents.yaml @@ -18,7 +18,9 @@ agents: temperature: 0.7 max_tokens: null timeout_seconds: 30 - context_messages: null + context_messages: + mode: number + count: 20 enabled_tools: - calendar.read - calendar.write diff --git a/backend/src/core/runtime/cli.py b/backend/src/core/runtime/cli.py index d82ad55..f4917a8 100644 --- a/backend/src/core/runtime/cli.py +++ b/backend/src/core/runtime/cli.py @@ -107,6 +107,10 @@ async def bootstrap() -> bool: async def run_automation_scheduler_forever() -> None: + if config.runtime.environment == "dev": + logger.info("Automation scheduler skipped in dev environment") + return + if not config.automation_scheduler.enabled: logger.info("Automation scheduler disabled by config") return diff --git a/backend/src/services/base/supabase.py b/backend/src/services/base/supabase.py index b5eabb8..64a7e4f 100644 --- a/backend/src/services/base/supabase.py +++ b/backend/src/services/base/supabase.py @@ -7,7 +7,6 @@ from supabase import create_client from storage3.exceptions import StorageApiError from core.config.settings import SupabaseSettings, config -from core.config.settings import config as app_config from .service_interface import BaseServiceProvider, register_service_instance @@ -100,7 +99,6 @@ class SupabaseService(BaseServiceProvider): ) async def _ensure_storage_bucket(self) -> None: - bucket_name = app_config.storage.bucket storage = getattr(self._admin_client, "storage", None) if storage is None: self.logger.warning("Storage client unavailable, skipping bucket check") @@ -111,32 +109,45 @@ class SupabaseService(BaseServiceProvider): self.logger.warning("Storage get_bucket unavailable, skipping bucket check") return + buckets = [ + (config.storage.attachment.bucket, False), + (config.storage.avatar.bucket, True), + ] + def _check_and_create() -> None: - try: - get_bucket(bucket_name) - self.logger.debug("Storage bucket already exists", bucket=bucket_name) - except Exception: # noqa: BLE001 - create_bucket = getattr(storage, "create_bucket", None) - if not callable(create_bucket): - self.logger.warning( - "Storage create_bucket unavailable, skipping bucket creation" - ) - return + for bucket_name, is_public in buckets: try: - create_bucket(bucket_name, options={"public": False}) - self.logger.info("Storage bucket created", bucket=bucket_name) - except Exception as exc: # noqa: BLE001 - msg = str(exc).lower() - if "already exists" in msg or "duplicate" in msg: - self.logger.debug( - "Storage bucket already exists (race)", bucket=bucket_name + get_bucket(bucket_name) + self.logger.debug( + "Storage bucket already exists", bucket=bucket_name + ) + except Exception: # noqa: BLE001 + create_bucket = getattr(storage, "create_bucket", None) + if not callable(create_bucket): + self.logger.warning( + "Storage create_bucket unavailable, skipping bucket creation" ) return - self.logger.warning( - "Failed to create storage bucket", - bucket=bucket_name, - error=str(exc), - ) + try: + create_bucket(bucket_name, options={"public": is_public}) + self.logger.info( + "Storage bucket created", + bucket=bucket_name, + public=is_public, + ) + except Exception as exc: # noqa: BLE001 + msg = str(exc).lower() + if "already exists" in msg or "duplicate" in msg: + self.logger.debug( + "Storage bucket already exists (race)", + bucket=bucket_name, + ) + continue + self.logger.warning( + "Failed to create storage bucket", + bucket=bucket_name, + error=str(exc), + ) await asyncio.to_thread(_check_and_create) @@ -157,10 +168,13 @@ class SupabaseService(BaseServiceProvider): return from_bucket(bucket) def _validate_bucket(self, bucket: str) -> None: - """Validate that the bucket matches the configured bucket.""" - expected = app_config.storage.bucket - if bucket != expected: - raise RuntimeError("Invalid attachment bucket") + """Validate that the bucket matches one of configured storage buckets.""" + allowed_buckets = { + config.storage.attachment.bucket, + config.storage.avatar.bucket, + } + if bucket not in allowed_buckets: + raise RuntimeError("Invalid storage bucket") def _ensure_bucket_client(self, bucket: str) -> Any: """Validate bucket and return authenticated bucket client.""" diff --git a/backend/src/v1/agent/service.py b/backend/src/v1/agent/service.py index 91e046f..b117d3c 100644 --- a/backend/src/v1/agent/service.py +++ b/backend/src/v1/agent/service.py @@ -333,7 +333,7 @@ class AgentService: f"agent-inputs/{current_user.id}/{thread_id}/uploads/" f"{filename_hash}-{checksum}.{suffix}" ) - bucket_name = config.storage.bucket + bucket_name = config.storage.attachment.bucket try: stored_path = await self._attachment_storage.upload_bytes( bucket=bucket_name, @@ -377,7 +377,7 @@ class AgentService: status_code=503, detail="Attachment storage unavailable" ) normalized_bucket = bucket.strip() - if normalized_bucket != config.storage.bucket: + if normalized_bucket != config.storage.attachment.bucket: raise HTTPException(status_code=422, detail="Invalid attachment bucket") normalized_path = path.strip() @@ -540,7 +540,7 @@ class AgentService: status_code=422, detail="Invalid signed image url" ) from exc - if bucket != config.storage.bucket: + if bucket != config.storage.attachment.bucket: raise HTTPException(status_code=422, detail="INVALID_BINARY_URL_BUCKET") expected_prefix = f"agent-inputs/{current_user.id}/{thread_id}/uploads/" diff --git a/backend/src/v1/users/router.py b/backend/src/v1/users/router.py index 53941f2..8602b20 100644 --- a/backend/src/v1/users/router.py +++ b/backend/src/v1/users/router.py @@ -3,11 +3,11 @@ from __future__ import annotations from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, File, UploadFile, status from schemas.shared.user import UserContext from v1.users.dependencies import get_user_service -from v1.users.schemas import UserSearchRequest, UserUpdateRequest +from v1.users.schemas import AvatarUploadResponse, UserSearchRequest, UserUpdateRequest from v1.users.service import UserService @@ -29,6 +29,23 @@ async def update_me( return await service.update_me(payload) +@router.post( + "/me/avatar", + response_model=AvatarUploadResponse, + status_code=status.HTTP_200_OK, +) +async def upload_avatar( + service: Annotated[UserService, Depends(get_user_service)], + file: UploadFile = File(), +) -> AvatarUploadResponse: + payload = await file.read() + return await service.upload_avatar( + filename=file.filename, + content_type=file.content_type, + payload=payload, + ) + + @router.post("/search", response_model=list[UserContext]) async def search_users( payload: UserSearchRequest, diff --git a/backend/src/v1/users/schemas.py b/backend/src/v1/users/schemas.py index dccda26..404f598 100644 --- a/backend/src/v1/users/schemas.py +++ b/backend/src/v1/users/schemas.py @@ -38,3 +38,7 @@ class UserUpdateRequest(BaseModel): if self.username is None and self.avatar_url is None and self.bio is None: raise ValueError("At least one field must be provided") return self + + +class AvatarUploadResponse(BaseModel): + url: str = Field(description="Public URL of the uploaded avatar") diff --git a/backend/src/v1/users/service.py b/backend/src/v1/users/service.py index 5d061b8..7b4b541 100644 --- a/backend/src/v1/users/service.py +++ b/backend/src/v1/users/service.py @@ -11,11 +11,13 @@ from core.agentscope.caches.user_context_cache import ( create_user_context_cache, ) from core.auth.models import CurrentUser +from core.config.settings import config from core.db.base_service import BaseService from core.logging import get_logger from schemas.shared.user import UserContext, parse_profile_settings +from services.base.supabase import supabase_service from v1.users.repository import UserRepository -from v1.users.schemas import UserSearchRequest, UserUpdateRequest +from v1.users.schemas import AvatarUploadResponse, UserSearchRequest, UserUpdateRequest if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession @@ -27,6 +29,16 @@ logger = get_logger("v1.users.service") _PHONE_QUERY_PATTERN = re.compile(r"^[+()\-\s\d]{4,32}$") +def _mime_to_suffix(mime_type: str) -> str: + """Convert MIME type to file suffix.""" + mapping = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + } + return mapping.get(mime_type, "bin") + + class AuthLookupGateway(Protocol): async def search_user_ids_by_phone( self, query: str, limit: int = 20 @@ -164,6 +176,83 @@ class UserService(BaseService): settings=parse_profile_settings(user.settings), ) + async def upload_avatar( + self, + *, + filename: str | None, + content_type: str | None, + payload: bytes, + ) -> AvatarUploadResponse: + user_id = self.require_user_id() + + if not isinstance(content_type, str): + raise HTTPException(status_code=422, detail="Unsupported image type") + + mime_type = content_type.lower() + allowed_types = {"image/jpeg", "image/png", "image/webp"} + if mime_type not in allowed_types: + raise HTTPException( + status_code=422, + detail="Unsupported image type. Allowed: JPEG, PNG, WebP", + ) + + max_size_bytes = config.storage.avatar.max_size_mb * 1024 * 1024 + if len(payload) > max_size_bytes: + raise HTTPException( + status_code=413, + detail=f"Image too large. Maximum size: {config.storage.avatar.max_size_mb}MB", + ) + + if not payload: + raise HTTPException(status_code=422, detail="Empty image") + + suffix = _mime_to_suffix(mime_type) + path = f"{user_id}/avatar.{suffix}" + bucket_name = config.storage.avatar.bucket + + try: + stored_path = await supabase_service.upload_bytes( + bucket=bucket_name, + path=path, + content=payload, + content_type=mime_type, + ) + except Exception: # noqa: BLE001 + logger.exception( + "Avatar upload failed", + extra={ + "bucket": bucket_name, + "path": path, + "mime_type": mime_type, + "user_id": str(user_id), + }, + ) + raise HTTPException(status_code=502, detail="Failed to upload avatar") + + public_url = f"{config.supabase.public_url}/storage/v1/object/public/{bucket_name}/{stored_path}" + + update_data: dict[str, str | None] = {"avatar_url": public_url} + try: + user = await self._repository.update_by_user_id(user_id, update_data) + await self._session.commit() + except SQLAlchemyError: + await self._session.rollback() + raise HTTPException(status_code=503, detail="User store unavailable") + + if user is None: + raise HTTPException(status_code=404, detail="User not found") + + try: + await self._user_context_cache.invalidate_user(user_id=user_id) + except Exception as exc: + logger.warning( + "Failed to invalidate user context cache after avatar upload", + user_id=str(user_id), + error=str(exc), + ) + + return AvatarUploadResponse(url=public_url) + async def get_by_username(self, username: str) -> UserContext: try: user = await self._repository.get_by_username(username) diff --git a/backend/tests/unit/services/base/test_supabase_service.py b/backend/tests/unit/services/base/test_supabase_service.py index 61c1c4f..580be16 100644 --- a/backend/tests/unit/services/base/test_supabase_service.py +++ b/backend/tests/unit/services/base/test_supabase_service.py @@ -159,3 +159,15 @@ def test_get_admin_client_lazily_initializes_clients( assert service.get_client() is anon_client assert service.is_initialized is True assert len(create_calls) == 2 + + +def test_validate_bucket_accepts_attachment_and_avatar() -> None: + service = SupabaseService( + settings=SupabaseSettings(public_url="https://test.supabase.co") + ) + + service._validate_bucket("agent-chat-attachments") + service._validate_bucket("avatars") + + with pytest.raises(RuntimeError): + service._validate_bucket("unexpected-bucket") diff --git a/backend/tests/unit/test_settings_storage_env.py b/backend/tests/unit/test_settings_storage_env.py index dc6b3c9..b03f779 100644 --- a/backend/tests/unit/test_settings_storage_env.py +++ b/backend/tests/unit/test_settings_storage_env.py @@ -11,17 +11,21 @@ def test_social_prefixed_storage_env_populates_settings( monkeypatch: MonkeyPatch, ) -> None: monkeypatch.setenv("SOCIAL_STORAGE__PROVIDER", "supabase") - monkeypatch.setenv("SOCIAL_STORAGE__BUCKET", "agent-chat-attachments") + monkeypatch.setenv("SOCIAL_STORAGE__ATTACHMENT__BUCKET", "agent-chat-attachments") + monkeypatch.setenv("SOCIAL_STORAGE__AVATAR__BUCKET", "avatars") monkeypatch.setenv("SOCIAL_STORAGE__SIGNED_URL_TTL_SECONDS", "900") - monkeypatch.setenv("SOCIAL_STORAGE__MAX_FILE_SIZE_MB", "25") + monkeypatch.setenv("SOCIAL_STORAGE__ATTACHMENT__MAX_SIZE_MB", "25") + monkeypatch.setenv("SOCIAL_STORAGE__AVATAR__MAX_SIZE_MB", "3") monkeypatch.setenv("SOCIAL_STORAGE__RETENTION_DAYS", "45") settings = Settings() assert settings.storage.provider == "supabase" - assert settings.storage.bucket == "agent-chat-attachments" + assert settings.storage.attachment.bucket == "agent-chat-attachments" + assert settings.storage.avatar.bucket == "avatars" assert settings.storage.signed_url_ttl_seconds == 900 - assert settings.storage.max_file_size_mb == 25 + assert settings.storage.attachment.max_size_mb == 25 + assert settings.storage.avatar.max_size_mb == 3 assert settings.storage.retention_days == 45 diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example index 85a2183..b050b68 100644 --- a/deploy/.env.prod.example +++ b/deploy/.env.prod.example @@ -67,9 +67,11 @@ SOCIAL_DATABASE__PASSWORD=change-me-strong-password # Agent Chat 附件存储配置(仅基础设施变量) ############ SOCIAL_STORAGE__PROVIDER=supabase -SOCIAL_STORAGE__BUCKET=agent-chat-attachments +SOCIAL_STORAGE__ATTACHMENT__BUCKET=agent-chat-attachments +SOCIAL_STORAGE__AVATAR__BUCKET=avatars SOCIAL_STORAGE__SIGNED_URL_TTL_SECONDS=600 -SOCIAL_STORAGE__MAX_FILE_SIZE_MB=20 +SOCIAL_STORAGE__ATTACHMENT__MAX_SIZE_MB=20 +SOCIAL_STORAGE__AVATAR__MAX_SIZE_MB=2 SOCIAL_STORAGE__RETENTION_DAYS=30 ###### diff --git a/docs/protocols/models/auth.md b/docs/protocols/models/auth.md index 726db20..6947464 100644 --- a/docs/protocols/models/auth.md +++ b/docs/protocols/models/auth.md @@ -25,6 +25,21 @@ Base URL: `/api/v1/auth` --- +## 本地开发约定(Supabase Self-Hosting) + +- 环境:本地网关地址为 `http://localhost:8001`,后端通过 `SOCIAL_SUPABASE__PUBLIC_URL` 访问。 +- 认证:开发机启用 SMS 测试 OTP,不走真实短信供应商。 +- 默认测试账号:`SOCIAL_TEST__PHONE`(提交时需使用 E.164,即在前面补 `+`)。 +- 默认测试验证码:`123456`(由本地 `auth` 服务的 `GOTRUE_SMS_TEST_OTP` 提供)。 + +兼容性策略: + +- 对外 API 合约不变(`/otp/send`、`/phone-session`、`/sessions/*` 保持一致)。 +- 仅切换验证码来源(生产/云环境真实短信,本地环境测试 OTP)。 +- 前后端不需要因本地/云差异修改接口调用方式。 + +--- + ## 1) POST `/otp/send` 发送验证码,不区分登录和注册场景。 diff --git a/infra/docker/app/docker-compose.yml b/infra/docker/app/docker-compose.yml new file mode 100644 index 0000000..b4f083f --- /dev/null +++ b/infra/docker/app/docker-compose.yml @@ -0,0 +1,29 @@ +name: social-app-local + +services: + redis: + image: redis:7-alpine + container_name: social-local-redis + restart: unless-stopped + ports: + - "127.0.0.1:${SOCIAL_REDIS__PORT:-6379}:6379" + volumes: + - redis_data:/data + environment: + REDIS_PASSWORD: ${SOCIAL_REDIS__PASSWORD:-} + command: > + sh -c 'if [ -n "$$REDIS_PASSWORD" ]; then redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"; else redis-server --appendonly yes; fi' + healthcheck: + test: + [ + "CMD", + "sh", + "-c", + "if [ -n \"$$REDIS_PASSWORD\" ]; then redis-cli -a \"$$REDIS_PASSWORD\" ping; else redis-cli ping; fi", + ] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + redis_data: diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index 9aaa9e3..b4536db 100644 --- a/infra/docker/docker-compose.yml +++ b/infra/docker/docker-compose.yml @@ -1,49 +1,5 @@ name: social-app-local -services: - redis: - image: redis:7-alpine - container_name: social-local-redis - restart: unless-stopped - ports: - - "${SOCIAL_REDIS__PORT:-6379}:6379" - volumes: - - redis_data:/data - environment: - - REDIS_PASSWORD=${SOCIAL_REDIS__PASSWORD:-} - command: > - sh -c 'if [ -n "$$REDIS_PASSWORD" ]; then redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"; else redis-server --appendonly yes; fi' - healthcheck: - test: ["CMD", "sh", "-c", "if [ -n \"$$REDIS_PASSWORD\" ]; then redis-cli -a \"$$REDIS_PASSWORD\" ping; else redis-cli ping; fi"] - interval: 5s - timeout: 3s - retries: 5 - - init-job: - build: - context: ../.. - dockerfile: backend/Dockerfile - image: social-local-backend - container_name: social-local-init-job - restart: "no" - environment: - - PYTHONPATH=/app/backend/src - - SOCIAL_DATABASE__HOST=${SOCIAL_DATABASE__HOST} - - SOCIAL_DATABASE__PORT=${SOCIAL_DATABASE__PORT} - - SOCIAL_DATABASE__NAME=${SOCIAL_DATABASE__NAME} - - SOCIAL_DATABASE__USER=${SOCIAL_DATABASE__USER} - - SOCIAL_DATABASE__PASSWORD=${SOCIAL_DATABASE__PASSWORD} - - SOCIAL_REDIS__HOST=${SOCIAL_REDIS__HOST} - - SOCIAL_REDIS__PORT=${SOCIAL_REDIS__PORT} - - SOCIAL_REDIS__PASSWORD=${SOCIAL_REDIS__PASSWORD:-} - - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-dev} - depends_on: - redis: - condition: service_healthy - working_dir: /app/backend - command: uv run python -m core.runtime.cli bootstrap - profiles: - - job - -volumes: - redis_data: +include: + - ./supabase/docker-compose.yml + - ./app/docker-compose.yml diff --git a/infra/docker/supabase/docker-compose.yml b/infra/docker/supabase/docker-compose.yml new file mode 100644 index 0000000..9d4fbe3 --- /dev/null +++ b/infra/docker/supabase/docker-compose.yml @@ -0,0 +1,222 @@ +name: supabase + +services: + db: + container_name: supabase-db + image: supabase/postgres:15.8.1.085 + restart: unless-stopped + volumes: + - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:ro + - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:ro + - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:ro + - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:ro + - ./volumes/db/local-dev-grants.sql:/docker-entrypoint-initdb.d/init-scripts/100-local-dev-grants.sql:ro + - ./volumes/db/data:/var/lib/postgresql/data + - db-config:/etc/postgresql-custom + healthcheck: + test: ["CMD", "pg_isready", "-U", "postgres", "-h", "localhost"] + interval: 5s + timeout: 5s + retries: 10 + environment: + POSTGRES_HOST: /var/run/postgresql + PGPORT: 5432 + POSTGRES_PORT: 5432 + PGPASSWORD: ${SOCIAL_DATABASE__PASSWORD} + POSTGRES_PASSWORD: ${SOCIAL_DATABASE__PASSWORD} + PGDATABASE: ${SOCIAL_DATABASE__NAME:-supabase_db} + POSTGRES_DB: ${SOCIAL_DATABASE__NAME:-supabase_db} + JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + JWT_EXP: 3600 + command: ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf", "-c", "log_min_messages=fatal"] + ports: + - 127.0.0.1:${SOCIAL_DATABASE__PORT:-5432}:5432 + + auth: + container_name: supabase-auth + image: supabase/gotrue:v2.186.0 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"] + interval: 5s + timeout: 5s + retries: 3 + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${SOCIAL_SUPABASE__PUBLIC_URL:-http://localhost:8001} + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${SOCIAL_DATABASE__PASSWORD}@db:5432/${SOCIAL_DATABASE__NAME:-supabase_db} + GOTRUE_SITE_URL: http://localhost:3000 + GOTRUE_URI_ALLOW_LIST: "" + GOTRUE_DISABLE_SIGNUP: "false" + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: 3600 + GOTRUE_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + GOTRUE_EXTERNAL_EMAIL_ENABLED: "false" + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "false" + GOTRUE_EXTERNAL_PHONE_ENABLED: "true" + GOTRUE_SMS_AUTOCONFIRM: "false" + GOTRUE_SMS_OTP_LENGTH: 6 + GOTRUE_SMS_OTP_EXP: 300 + GOTRUE_SMS_TEST_OTP: ${SOCIAL_SUPABASE__SMS_TEST_OTP:-8613812345678:123456} + GOTRUE_SMS_TEST_OTP_VALID_UNTIL: ${SOCIAL_SUPABASE__SMS_TEST_OTP_VALID_UNTIL:-2099-12-31T23:59:59Z} + GOTRUE_MAILER_AUTOCONFIRM: "false" + GOTRUE_SMTP_ADMIN_EMAIL: dev@example.com + GOTRUE_SMTP_HOST: localhost + GOTRUE_SMTP_PORT: 2500 + GOTRUE_SMTP_USER: disabled + GOTRUE_SMTP_PASS: disabled + GOTRUE_SMTP_SENDER_NAME: disabled + GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify + + rest: + container_name: supabase-rest + image: postgrest/postgrest:v14.6 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + PGRST_DB_URI: postgres://authenticator:${SOCIAL_DATABASE__PASSWORD}@db:5432/${SOCIAL_DATABASE__NAME:-supabase_db} + PGRST_DB_SCHEMAS: public,storage,graphql_public + PGRST_DB_MAX_ROWS: 1000 + PGRST_DB_EXTRA_SEARCH_PATH: public + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + PGRST_APP_SETTINGS_JWT_EXP: 3600 + + storage: + container_name: supabase-storage + image: supabase/storage-api:v1.44.2 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + rest: + condition: service_started + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://storage:5000/status"] + interval: 5s + timeout: 5s + retries: 3 + start_period: 10s + environment: + ANON_KEY: ${SOCIAL_SUPABASE__ANON_KEY} + SERVICE_KEY: ${SOCIAL_SUPABASE__SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + AUTH_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + DATABASE_URL: postgres://supabase_storage_admin:${SOCIAL_DATABASE__PASSWORD}@db:5432/${SOCIAL_DATABASE__NAME:-supabase_db} + STORAGE_PUBLIC_URL: ${SOCIAL_SUPABASE__PUBLIC_URL:-http://localhost:8001} + REQUEST_ALLOW_X_FORWARDED_PATH: "true" + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: file + GLOBAL_S3_BUCKET: ${SOCIAL_STORAGE__ATTACHMENT__BUCKET:-agent-chat-attachments} + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: local + REGION: local + ENABLE_IMAGE_TRANSFORMATION: "false" + volumes: + - ./volumes/storage:/var/lib/storage + + meta: + container_name: supabase-meta + image: supabase/postgres-meta:v0.95.2 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + PG_META_PORT: 8080 + PG_META_DB_HOST: db + PG_META_DB_PORT: 5432 + PG_META_DB_NAME: ${SOCIAL_DATABASE__NAME:-supabase_db} + PG_META_DB_USER: postgres + PG_META_DB_PASSWORD: ${SOCIAL_DATABASE__PASSWORD} + PG_META_DB_SSL_MODE: disable + healthcheck: + test: ["CMD", "/bin/sh", "-c", "exit 0"] + interval: 10s + timeout: 5s + retries: 1 + + studio: + container_name: supabase-studio + image: supabase/studio:2026.03.16-sha-5528817 + restart: unless-stopped + depends_on: + meta: + condition: service_healthy + environment: + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PASSWORD: ${SOCIAL_DATABASE__PASSWORD} + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + POSTGRES_DB: ${SOCIAL_DATABASE__NAME:-supabase_db} + POSTGRES_USER: supabase_admin + DEFAULT_ORGANIZATION_NAME: Default Organization + DEFAULT_PROJECT_NAME: Default Project + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SOCIAL_SUPABASE__PUBLIC_URL:-http://localhost:8001} + SUPABASE_ANON_KEY: ${SOCIAL_SUPABASE__ANON_KEY} + SUPABASE_SERVICE_KEY: ${SOCIAL_SUPABASE__SERVICE_ROLE_KEY} + AUTH_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /tmp/functions + LOGFLARE_API_KEY: local-logflare-public-token + LOGFLARE_URL: http://localhost:4000 + NEXT_PUBLIC_ENABLE_LOGS: "false" + + kong: + container_name: supabase-kong + image: kong/kong:3.9.1 + restart: unless-stopped + depends_on: + auth: + condition: service_healthy + rest: + condition: service_started + storage: + condition: service_healthy + studio: + condition: service_started + meta: + condition: service_healthy + healthcheck: + test: ["CMD", "kong", "health"] + interval: 5s + timeout: 5s + retries: 5 + ports: + - 127.0.0.1:8001:8000/tcp + - 127.0.0.1:8443:8443/tcp + volumes: + - ./volumes/api/kong.yml:/home/kong/temp.yml:ro + - ./volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml + KONG_DNS_ORDER: LAST,A,CNAME + KONG_DNS_NOT_FOUND_TTL: 1 + KONG_PLUGINS: request-transformer,cors,key-auth,acl,post-function,basic-auth + SUPABASE_ANON_KEY: ${SOCIAL_SUPABASE__ANON_KEY} + SUPABASE_SERVICE_KEY: ${SOCIAL_SUPABASE__SERVICE_ROLE_KEY} + SUPABASE_PUBLISHABLE_KEY: "" + SUPABASE_SECRET_KEY: "" + ANON_KEY_ASYMMETRIC: "" + SERVICE_ROLE_KEY_ASYMMETRIC: "" + DASHBOARD_USERNAME: localadmin + DASHBOARD_PASSWORD: LocalAdmin-Change-This-Now + entrypoint: /home/kong/kong-entrypoint.sh + +volumes: + db-config: diff --git a/infra/docker/supabase/volumes/api/kong-entrypoint.sh b/infra/docker/supabase/volumes/api/kong-entrypoint.sh new file mode 100755 index 0000000..176f058 --- /dev/null +++ b/infra/docker/supabase/volumes/api/kong-entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)" +else + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)" +fi + +awk '{ + result = "" + rest = $0 + while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) { + varname = substr(rest, RSTART + 1, RLENGTH - 1) + if (varname in ENVIRON) { + result = result substr(rest, 1, RSTART - 1) ENVIRON[varname] + } else { + result = result substr(rest, 1, RSTART + RLENGTH - 1) + } + rest = substr(rest, RSTART + RLENGTH) + } + print result rest +}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG" + +sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG" + +exec /entrypoint.sh kong docker-start diff --git a/infra/docker/supabase/volumes/api/kong.yml b/infra/docker/supabase/volumes/api/kong.yml new file mode 100644 index 0000000..985ffbe --- /dev/null +++ b/infra/docker/supabase/volumes/api/kong.yml @@ -0,0 +1,171 @@ +_format_version: '2.1' +_transform: true + +consumers: + - username: DASHBOARD + - username: anon + keyauth_credentials: + - key: $SUPABASE_ANON_KEY + - username: service_role + keyauth_credentials: + - key: $SUPABASE_SERVICE_KEY + +acls: + - consumer: anon + group: anon + - consumer: service_role + group: admin + +basicauth_credentials: + - consumer: DASHBOARD + username: "$DASHBOARD_USERNAME" + password: "$DASHBOARD_PASSWORD" + +services: + - name: auth-v1-open + url: http://auth:9999/verify + routes: + - name: auth-v1-open + strip_path: true + paths: + - /auth/v1/verify + plugins: + - name: cors + + - name: auth-v1-open-callback + url: http://auth:9999/callback + routes: + - name: auth-v1-open-callback + strip_path: true + paths: + - /auth/v1/callback + plugins: + - name: cors + + - name: auth-v1-open-jwks + url: http://auth:9999/.well-known/jwks.json + routes: + - name: auth-v1-open-jwks + strip_path: true + paths: + - /auth/v1/.well-known/jwks.json + plugins: + - name: cors + + - name: auth-v1 + url: http://auth:9999/ + routes: + - name: auth-v1-all + strip_path: true + paths: + - /auth/v1/ + plugins: + - name: cors + - name: key-auth + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + allow: + - admin + - anon + + - name: rest-v1 + url: http://rest:3000/ + routes: + - name: rest-v1-all + strip_path: true + paths: + - /rest/v1/ + plugins: + - name: cors + - name: key-auth + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + allow: + - admin + - anon + + - name: storage-v1 + url: http://storage:5000/ + routes: + - name: storage-v1-all + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: post-function + config: + access: + - | + local auth = kong.request.get_header("authorization") + if auth == nil or auth == "" or auth:find("^%s*$") then + kong.service.request.clear_header("authorization") + end + + - name: meta + url: http://meta:8080/ + routes: + - name: meta-all + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + - name: acl + config: + allow: + - admin + + - name: dashboard + url: http://studio:3000/ + routes: + - name: dashboard-all + strip_path: true + paths: + - / + plugins: + - name: cors + - name: basic-auth + config: + hide_credentials: true + + - name: mcp + _comment: 'MCP: /mcp -> http://studio:3000/api/mcp' + url: http://studio:3000/api/mcp + routes: + - name: mcp + strip_path: true + paths: + - /mcp + plugins: + - name: cors + - name: ip-restriction + config: + allow: + - 127.0.0.1 + - ::1 + deny: [] diff --git a/infra/docker/supabase/volumes/db/_supabase.sql b/infra/docker/supabase/volumes/db/_supabase.sql new file mode 100644 index 0000000..6236ae1 --- /dev/null +++ b/infra/docker/supabase/volumes/db/_supabase.sql @@ -0,0 +1,3 @@ +\set pguser `echo "$POSTGRES_USER"` + +CREATE DATABASE _supabase WITH OWNER :pguser; diff --git a/infra/docker/supabase/volumes/db/jwt.sql b/infra/docker/supabase/volumes/db/jwt.sql new file mode 100644 index 0000000..cfd3b16 --- /dev/null +++ b/infra/docker/supabase/volumes/db/jwt.sql @@ -0,0 +1,5 @@ +\set jwt_secret `echo "$JWT_SECRET"` +\set jwt_exp `echo "$JWT_EXP"` + +ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; +ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; diff --git a/infra/docker/supabase/volumes/db/local-dev-grants.sql b/infra/docker/supabase/volumes/db/local-dev-grants.sql new file mode 100644 index 0000000..0d3116a --- /dev/null +++ b/infra/docker/supabase/volumes/db/local-dev-grants.sql @@ -0,0 +1,2 @@ +grant usage on schema public to postgres; +grant create on schema public to postgres; diff --git a/infra/docker/supabase/volumes/db/roles.sql b/infra/docker/supabase/volumes/db/roles.sql new file mode 100644 index 0000000..8f7161a --- /dev/null +++ b/infra/docker/supabase/volumes/db/roles.sql @@ -0,0 +1,8 @@ +-- NOTE: change to your own passwords for production environments +\set pgpass `echo "$POSTGRES_PASSWORD"` + +ALTER USER authenticator WITH PASSWORD :'pgpass'; +ALTER USER pgbouncer WITH PASSWORD :'pgpass'; +ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass'; diff --git a/infra/docker/supabase/volumes/db/webhooks.sql b/infra/docker/supabase/volumes/db/webhooks.sql new file mode 100644 index 0000000..5837b86 --- /dev/null +++ b/infra/docker/supabase/volumes/db/webhooks.sql @@ -0,0 +1,208 @@ +BEGIN; + -- Create pg_net extension + CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; + -- Create supabase_functions schema + CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; + GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role; + -- supabase_functions.migrations definition + CREATE TABLE supabase_functions.migrations ( + version text PRIMARY KEY, + inserted_at timestamptz NOT NULL DEFAULT NOW() + ); + -- Initial supabase_functions migration + INSERT INTO supabase_functions.migrations (version) VALUES ('initial'); + -- supabase_functions.hooks definition + CREATE TABLE supabase_functions.hooks ( + id bigserial PRIMARY KEY, + hook_table_id integer NOT NULL, + hook_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + request_id bigint + ); + CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); + CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); + COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; + CREATE FUNCTION supabase_functions.http_request() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + request_id bigint; + payload jsonb; + url text := TG_ARGV[0]::text; + method text := TG_ARGV[1]::text; + headers jsonb DEFAULT '{}'::jsonb; + params jsonb DEFAULT '{}'::jsonb; + timeout_ms integer DEFAULT 1000; + BEGIN + IF url IS NULL OR url = 'null' THEN + RAISE EXCEPTION 'url argument is missing'; + END IF; + + IF method IS NULL OR method = 'null' THEN + RAISE EXCEPTION 'method argument is missing'; + END IF; + + IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN + headers = '{"Content-Type": "application/json"}'::jsonb; + ELSE + headers = TG_ARGV[2]::jsonb; + END IF; + + IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN + params = '{}'::jsonb; + ELSE + params = TG_ARGV[3]::jsonb; + END IF; + + IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN + timeout_ms = 1000; + ELSE + timeout_ms = TG_ARGV[4]::integer; + END IF; + + CASE + WHEN method = 'GET' THEN + SELECT http_get INTO request_id FROM net.http_get( + url, + params, + headers, + timeout_ms + ); + WHEN method = 'POST' THEN + payload = jsonb_build_object( + 'old_record', OLD, + 'record', NEW, + 'type', TG_OP, + 'table', TG_TABLE_NAME, + 'schema', TG_TABLE_SCHEMA + ); + + SELECT http_post INTO request_id FROM net.http_post( + url, + payload, + params, + headers, + timeout_ms + ); + ELSE + RAISE EXCEPTION 'method argument % is invalid', method; + END CASE; + + INSERT INTO supabase_functions.hooks + (hook_table_id, hook_name, request_id) + VALUES + (TG_RELID, TG_NAME, request_id); + + RETURN NEW; + END + $function$; + -- Supabase super admin + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_functions_admin' + ) + THEN + CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; + END IF; + END + $$; + GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin; + ALTER USER supabase_functions_admin SET search_path = "supabase_functions"; + ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin; + ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin; + ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin; + GRANT supabase_functions_admin TO postgres; + -- Remove unused supabase_pg_net_admin role + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_pg_net_admin' + ) + THEN + REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin; + DROP OWNED BY supabase_pg_net_admin; + DROP ROLE supabase_pg_net_admin; + END IF; + END + $$; + -- pg_net grants when extension is already enabled + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_extension + WHERE extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END + $$; + -- Event trigger for pg_net + CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() + RETURNS event_trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_event_trigger_ddl_commands() AS ev + JOIN pg_extension AS ext + ON ev.objid = ext.oid + WHERE ext.extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END; + $$; + COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net'; + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_event_trigger + WHERE evtname = 'issue_pg_net_access' + ) THEN + CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION') + EXECUTE PROCEDURE extensions.grant_pg_net_access(); + END IF; + END + $$; + INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants'); + ALTER function supabase_functions.http_request() SECURITY DEFINER; + ALTER function supabase_functions.http_request() SET search_path = supabase_functions; + REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; + GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role; +COMMIT;