refactor: unify storage config keys and refresh local dev setup
@@ -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
|
||||
|
||||
######
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
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: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<EditProfileScreen> {
|
||||
final _bioController = TextEditingController();
|
||||
final _usersApi = sl<UsersApi>();
|
||||
final _userCache = sl<SettingsUserCache>();
|
||||
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<EditProfileScreen> {
|
||||
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<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 {
|
||||
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<EditProfileScreen> {
|
||||
});
|
||||
|
||||
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<EditProfileScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildAvatarSection(),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
const Text(
|
||||
'用户名',
|
||||
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() {
|
||||
return AccountSectionCard(
|
||||
title: '个人简介',
|
||||
|
||||
@@ -188,11 +188,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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<SettingsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
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<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() {
|
||||
if (_friendsCount == 0) {
|
||||
return '暂无联系人';
|
||||
|
||||
@@ -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<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 {
|
||||
final response = await _client.post(
|
||||
'$_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):
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
######
|
||||
|
||||
@@ -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`
|
||||
|
||||
发送验证码,不区分登录和注册场景。
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -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;
|
||||