refactor: unify storage config keys and refresh local dev setup
@@ -68,9 +68,11 @@ SOCIAL_DATABASE__PASSWORD=change-me-strong-password
|
|||||||
# Agent Chat 附件存储配置(仅基础设施变量)
|
# Agent Chat 附件存储配置(仅基础设施变量)
|
||||||
############
|
############
|
||||||
SOCIAL_STORAGE__PROVIDER=supabase
|
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__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
|
SOCIAL_STORAGE__RETENTION_DAYS=30
|
||||||
|
|
||||||
######
|
######
|
||||||
|
|||||||
@@ -300,9 +300,12 @@ backend/logs/
|
|||||||
# Docker volumes (local data)
|
# Docker volumes (local data)
|
||||||
docker/supabase/volumes/db/data/
|
docker/supabase/volumes/db/data/
|
||||||
infra/docker/volumes/db/data/
|
infra/docker/volumes/db/data/
|
||||||
|
infra/docker/supabase/volumes/db/data/
|
||||||
|
infra/docker/supabase/volumes/storage/
|
||||||
|
|
||||||
# OpenCode local config
|
# OpenCode local config
|
||||||
# .opencode/ is now tracked - see .opencode/.gitignore for exclusions
|
# .opencode/ is now tracked - see .opencode/.gitignore for exclusions
|
||||||
|
.opencode/opencode.json.old
|
||||||
|
|
||||||
# Local git worktrees
|
# Local git worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|||||||
@@ -2,19 +2,9 @@
|
|||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"supabase": {
|
"supabase": {
|
||||||
"type": "local",
|
"type": "remote",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"command": [
|
"url": "http://localhost:8001/mcp"
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 947 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 767 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 778 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 20 KiB |
@@ -1,5 +1,7 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
import '../../../../core/theme/design_tokens.dart';
|
import '../../../../core/theme/design_tokens.dart';
|
||||||
import '../../../../core/di/injection.dart';
|
import '../../../../core/di/injection.dart';
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
import '../../../../shared/widgets/app_button.dart';
|
||||||
@@ -24,10 +26,13 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
|||||||
final _bioController = TextEditingController();
|
final _bioController = TextEditingController();
|
||||||
final _usersApi = sl<UsersApi>();
|
final _usersApi = sl<UsersApi>();
|
||||||
final _userCache = sl<SettingsUserCache>();
|
final _userCache = sl<SettingsUserCache>();
|
||||||
|
final _imagePicker = ImagePicker();
|
||||||
|
|
||||||
UserResponse? _user;
|
UserResponse? _user;
|
||||||
|
File? _selectedAvatar;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
|
bool _isUploadingAvatar = false;
|
||||||
bool _hasChanges = false;
|
bool _hasChanges = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -73,27 +78,74 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
|||||||
if (_user == null) return;
|
if (_user == null) return;
|
||||||
final usernameChanged = _usernameController.text != _user!.username;
|
final usernameChanged = _usernameController.text != _user!.username;
|
||||||
final bioChanged = _bioController.text != (_user!.bio ?? '');
|
final bioChanged = _bioController.text != (_user!.bio ?? '');
|
||||||
|
final avatarChanged = _selectedAvatar != null;
|
||||||
|
|
||||||
if ((usernameChanged || bioChanged) != _hasChanges) {
|
if ((usernameChanged || bioChanged || avatarChanged) != _hasChanges) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_hasChanges = usernameChanged || bioChanged;
|
_hasChanges = usernameChanged || bioChanged || avatarChanged;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<void> _saveProfile() async {
|
Future<void> _saveProfile() async {
|
||||||
if (!_hasChanges || _user == null) return;
|
if (!_hasChanges || _user == null) return;
|
||||||
|
|
||||||
final newUsername = _usernameController.text.trim();
|
final newUsername = _usernameController.text.trim();
|
||||||
final newBio = _bioController.text.trim();
|
final newBio = _bioController.text.trim();
|
||||||
|
final usernameChanged = newUsername != _user!.username;
|
||||||
|
final bioChanged = newBio != (_user!.bio ?? '');
|
||||||
|
|
||||||
if (newUsername.isEmpty) {
|
if (usernameChanged) {
|
||||||
Toast.show(context, '用户名不能为空', type: ToastType.warning);
|
if (newUsername.isEmpty) {
|
||||||
return;
|
Toast.show(context, '用户名不能为空', type: ToastType.warning);
|
||||||
}
|
return;
|
||||||
if (newUsername.length < 3 || newUsername.length > 30) {
|
}
|
||||||
Toast.show(context, '用户名需要3-30个字符', type: ToastType.warning);
|
if (newUsername.length < 3 || newUsername.length > 30) {
|
||||||
return;
|
Toast.show(context, '用户名需要3-30个字符', type: ToastType.warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -101,12 +153,18 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final request = UserUpdateRequest(
|
if (_selectedAvatar != null) {
|
||||||
username: newUsername,
|
await _uploadAvatar();
|
||||||
bio: newBio.isEmpty ? null : newBio,
|
}
|
||||||
);
|
|
||||||
final updatedUser = await _usersApi.updateMe(request);
|
if (usernameChanged || bioChanged) {
|
||||||
_userCache.set(updatedUser);
|
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) {
|
if (mounted) {
|
||||||
Toast.show(context, '保存成功', type: ToastType.success);
|
Toast.show(context, '保存成功', type: ToastType.success);
|
||||||
@@ -171,6 +229,8 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
_buildAvatarSection(),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
const Text(
|
const Text(
|
||||||
'用户名',
|
'用户名',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -191,6 +251,87 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<Color>(
|
||||||
|
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() {
|
Widget _buildBioSection() {
|
||||||
return AccountSectionCard(
|
return AccountSectionCard(
|
||||||
title: '个人简介',
|
title: '个人简介',
|
||||||
|
|||||||
@@ -188,11 +188,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
width: 64,
|
width: 64,
|
||||||
height: 64,
|
height: 64,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [AppColors.blue100, AppColors.blue50],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(32),
|
borderRadius: BorderRadius.circular(32),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -207,11 +202,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Icon(
|
clipBehavior: Clip.antiAlias,
|
||||||
Icons.person,
|
child: _buildAvatarImage(_user?.avatarUrl),
|
||||||
size: 28,
|
|
||||||
color: AppColors.blue600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.lg),
|
const SizedBox(width: AppSpacing.lg),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -307,6 +299,61 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<Color>(AppColors.blue600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String _buildFriendsSubtitle() {
|
String _buildFriendsSubtitle() {
|
||||||
if (_friendsCount == 0) {
|
if (_friendsCount == 0) {
|
||||||
return '暂无联系人';
|
return '暂无联系人';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:social_app/core/api/i_api_client.dart';
|
import 'package:social_app/core/api/i_api_client.dart';
|
||||||
import 'models/user_response.dart';
|
import 'models/user_response.dart';
|
||||||
|
|
||||||
@@ -38,6 +40,28 @@ class UsersApi {
|
|||||||
return UserResponse.fromJson(response.data);
|
return UserResponse.fromJson(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String> uploadAvatar(File file) async {
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'file': await MultipartFile.fromFile(
|
||||||
|
file.path,
|
||||||
|
filename: file.path.split('/').last,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
final response = await _client.post<Map<String, dynamic>>(
|
||||||
|
'$_prefix/me/avatar',
|
||||||
|
data: formData,
|
||||||
|
);
|
||||||
|
final payload = response.data;
|
||||||
|
if (payload is! Map<String, dynamic>) {
|
||||||
|
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<List<UserResponse>> searchUsers(String query) async {
|
Future<List<UserResponse>> searchUsers(String query) async {
|
||||||
final response = await _client.post(
|
final response = await _client.post(
|
||||||
'$_prefix/search',
|
'$_prefix/search',
|
||||||
|
|||||||
@@ -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."""
|
||||||
@@ -148,11 +148,22 @@ class SupabaseSettings(BaseModel):
|
|||||||
|
|
||||||
class StorageSettings(BaseModel):
|
class StorageSettings(BaseModel):
|
||||||
provider: Literal["supabase"] = "supabase"
|
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)
|
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)
|
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):
|
class AgentRuntimeSettings(BaseModel):
|
||||||
redis_stream_prefix: str = "agent:events"
|
redis_stream_prefix: str = "agent:events"
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ agents:
|
|||||||
temperature: 0.7
|
temperature: 0.7
|
||||||
max_tokens: null
|
max_tokens: null
|
||||||
timeout_seconds: 30
|
timeout_seconds: 30
|
||||||
context_messages: null
|
context_messages:
|
||||||
|
mode: number
|
||||||
|
count: 20
|
||||||
enabled_tools:
|
enabled_tools:
|
||||||
- calendar.read
|
- calendar.read
|
||||||
- calendar.write
|
- calendar.write
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ async def bootstrap() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def run_automation_scheduler_forever() -> None:
|
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:
|
if not config.automation_scheduler.enabled:
|
||||||
logger.info("Automation scheduler disabled by config")
|
logger.info("Automation scheduler disabled by config")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from supabase import create_client
|
|||||||
from storage3.exceptions import StorageApiError
|
from storage3.exceptions import StorageApiError
|
||||||
|
|
||||||
from core.config.settings import SupabaseSettings, config
|
from core.config.settings import SupabaseSettings, config
|
||||||
from core.config.settings import config as app_config
|
|
||||||
|
|
||||||
from .service_interface import BaseServiceProvider, register_service_instance
|
from .service_interface import BaseServiceProvider, register_service_instance
|
||||||
|
|
||||||
@@ -100,7 +99,6 @@ class SupabaseService(BaseServiceProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _ensure_storage_bucket(self) -> None:
|
async def _ensure_storage_bucket(self) -> None:
|
||||||
bucket_name = app_config.storage.bucket
|
|
||||||
storage = getattr(self._admin_client, "storage", None)
|
storage = getattr(self._admin_client, "storage", None)
|
||||||
if storage is None:
|
if storage is None:
|
||||||
self.logger.warning("Storage client unavailable, skipping bucket check")
|
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")
|
self.logger.warning("Storage get_bucket unavailable, skipping bucket check")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
buckets = [
|
||||||
|
(config.storage.attachment.bucket, False),
|
||||||
|
(config.storage.avatar.bucket, True),
|
||||||
|
]
|
||||||
|
|
||||||
def _check_and_create() -> None:
|
def _check_and_create() -> None:
|
||||||
try:
|
for bucket_name, is_public in buckets:
|
||||||
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
|
|
||||||
try:
|
try:
|
||||||
create_bucket(bucket_name, options={"public": False})
|
get_bucket(bucket_name)
|
||||||
self.logger.info("Storage bucket created", bucket=bucket_name)
|
self.logger.debug(
|
||||||
except Exception as exc: # noqa: BLE001
|
"Storage bucket already exists", bucket=bucket_name
|
||||||
msg = str(exc).lower()
|
)
|
||||||
if "already exists" in msg or "duplicate" in msg:
|
except Exception: # noqa: BLE001
|
||||||
self.logger.debug(
|
create_bucket = getattr(storage, "create_bucket", None)
|
||||||
"Storage bucket already exists (race)", bucket=bucket_name
|
if not callable(create_bucket):
|
||||||
|
self.logger.warning(
|
||||||
|
"Storage create_bucket unavailable, skipping bucket creation"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
self.logger.warning(
|
try:
|
||||||
"Failed to create storage bucket",
|
create_bucket(bucket_name, options={"public": is_public})
|
||||||
bucket=bucket_name,
|
self.logger.info(
|
||||||
error=str(exc),
|
"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)
|
await asyncio.to_thread(_check_and_create)
|
||||||
|
|
||||||
@@ -157,10 +168,13 @@ class SupabaseService(BaseServiceProvider):
|
|||||||
return from_bucket(bucket)
|
return from_bucket(bucket)
|
||||||
|
|
||||||
def _validate_bucket(self, bucket: str) -> None:
|
def _validate_bucket(self, bucket: str) -> None:
|
||||||
"""Validate that the bucket matches the configured bucket."""
|
"""Validate that the bucket matches one of configured storage buckets."""
|
||||||
expected = app_config.storage.bucket
|
allowed_buckets = {
|
||||||
if bucket != expected:
|
config.storage.attachment.bucket,
|
||||||
raise RuntimeError("Invalid 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:
|
def _ensure_bucket_client(self, bucket: str) -> Any:
|
||||||
"""Validate bucket and return authenticated bucket client."""
|
"""Validate bucket and return authenticated bucket client."""
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ class AgentService:
|
|||||||
f"agent-inputs/{current_user.id}/{thread_id}/uploads/"
|
f"agent-inputs/{current_user.id}/{thread_id}/uploads/"
|
||||||
f"{filename_hash}-{checksum}.{suffix}"
|
f"{filename_hash}-{checksum}.{suffix}"
|
||||||
)
|
)
|
||||||
bucket_name = config.storage.bucket
|
bucket_name = config.storage.attachment.bucket
|
||||||
try:
|
try:
|
||||||
stored_path = await self._attachment_storage.upload_bytes(
|
stored_path = await self._attachment_storage.upload_bytes(
|
||||||
bucket=bucket_name,
|
bucket=bucket_name,
|
||||||
@@ -377,7 +377,7 @@ class AgentService:
|
|||||||
status_code=503, detail="Attachment storage unavailable"
|
status_code=503, detail="Attachment storage unavailable"
|
||||||
)
|
)
|
||||||
normalized_bucket = bucket.strip()
|
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")
|
raise HTTPException(status_code=422, detail="Invalid attachment bucket")
|
||||||
|
|
||||||
normalized_path = path.strip()
|
normalized_path = path.strip()
|
||||||
@@ -540,7 +540,7 @@ class AgentService:
|
|||||||
status_code=422, detail="Invalid signed image url"
|
status_code=422, detail="Invalid signed image url"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
if bucket != config.storage.bucket:
|
if bucket != config.storage.attachment.bucket:
|
||||||
raise HTTPException(status_code=422, detail="INVALID_BINARY_URL_BUCKET")
|
raise HTTPException(status_code=422, detail="INVALID_BINARY_URL_BUCKET")
|
||||||
|
|
||||||
expected_prefix = f"agent-inputs/{current_user.id}/{thread_id}/uploads/"
|
expected_prefix = f"agent-inputs/{current_user.id}/{thread_id}/uploads/"
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ from __future__ import annotations
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, File, UploadFile, status
|
||||||
|
|
||||||
from schemas.shared.user import UserContext
|
from schemas.shared.user import UserContext
|
||||||
from v1.users.dependencies import get_user_service
|
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
|
from v1.users.service import UserService
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +29,23 @@ async def update_me(
|
|||||||
return await service.update_me(payload)
|
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])
|
@router.post("/search", response_model=list[UserContext])
|
||||||
async def search_users(
|
async def search_users(
|
||||||
payload: UserSearchRequest,
|
payload: UserSearchRequest,
|
||||||
|
|||||||
@@ -38,3 +38,7 @@ class UserUpdateRequest(BaseModel):
|
|||||||
if self.username is None and self.avatar_url is None and self.bio is None:
|
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")
|
raise ValueError("At least one field must be provided")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarUploadResponse(BaseModel):
|
||||||
|
url: str = Field(description="Public URL of the uploaded avatar")
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ from core.agentscope.caches.user_context_cache import (
|
|||||||
create_user_context_cache,
|
create_user_context_cache,
|
||||||
)
|
)
|
||||||
from core.auth.models import CurrentUser
|
from core.auth.models import CurrentUser
|
||||||
|
from core.config.settings import config
|
||||||
from core.db.base_service import BaseService
|
from core.db.base_service import BaseService
|
||||||
from core.logging import get_logger
|
from core.logging import get_logger
|
||||||
from schemas.shared.user import UserContext, parse_profile_settings
|
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.repository import UserRepository
|
||||||
from v1.users.schemas import UserSearchRequest, UserUpdateRequest
|
from v1.users.schemas import AvatarUploadResponse, UserSearchRequest, UserUpdateRequest
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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}$")
|
_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):
|
class AuthLookupGateway(Protocol):
|
||||||
async def search_user_ids_by_phone(
|
async def search_user_ids_by_phone(
|
||||||
self, query: str, limit: int = 20
|
self, query: str, limit: int = 20
|
||||||
@@ -164,6 +176,83 @@ class UserService(BaseService):
|
|||||||
settings=parse_profile_settings(user.settings),
|
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:
|
async def get_by_username(self, username: str) -> UserContext:
|
||||||
try:
|
try:
|
||||||
user = await self._repository.get_by_username(username)
|
user = await self._repository.get_by_username(username)
|
||||||
|
|||||||
@@ -159,3 +159,15 @@ def test_get_admin_client_lazily_initializes_clients(
|
|||||||
assert service.get_client() is anon_client
|
assert service.get_client() is anon_client
|
||||||
assert service.is_initialized is True
|
assert service.is_initialized is True
|
||||||
assert len(create_calls) == 2
|
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")
|
||||||
|
|||||||
@@ -11,17 +11,21 @@ def test_social_prefixed_storage_env_populates_settings(
|
|||||||
monkeypatch: MonkeyPatch,
|
monkeypatch: MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setenv("SOCIAL_STORAGE__PROVIDER", "supabase")
|
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__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")
|
monkeypatch.setenv("SOCIAL_STORAGE__RETENTION_DAYS", "45")
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
assert settings.storage.provider == "supabase"
|
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.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
|
assert settings.storage.retention_days == 45
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -67,9 +67,11 @@ SOCIAL_DATABASE__PASSWORD=change-me-strong-password
|
|||||||
# Agent Chat 附件存储配置(仅基础设施变量)
|
# Agent Chat 附件存储配置(仅基础设施变量)
|
||||||
############
|
############
|
||||||
SOCIAL_STORAGE__PROVIDER=supabase
|
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__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
|
SOCIAL_STORAGE__RETENTION_DAYS=30
|
||||||
|
|
||||||
######
|
######
|
||||||
|
|||||||
@@ -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`
|
## 1) POST `/otp/send`
|
||||||
|
|
||||||
发送验证码,不区分登录和注册场景。
|
发送验证码,不区分登录和注册场景。
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -1,49 +1,5 @@
|
|||||||
name: social-app-local
|
name: social-app-local
|
||||||
|
|
||||||
services:
|
include:
|
||||||
redis:
|
- ./supabase/docker-compose.yml
|
||||||
image: redis:7-alpine
|
- ./app/docker-compose.yml
|
||||||
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:
|
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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: []
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
\set pguser `echo "$POSTGRES_USER"`
|
||||||
|
|
||||||
|
CREATE DATABASE _supabase WITH OWNER :pguser;
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
grant usage on schema public to postgres;
|
||||||
|
grant create on schema public to postgres;
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||