refactor: unify storage config keys and refresh local dev setup

This commit is contained in:
qzl
2026-03-26 13:25:25 +08:00
parent b765b9e3e1
commit 5900993ee7
61 changed files with 1164 additions and 129 deletions
+4 -2
View File
@@ -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
######
+3
View File
@@ -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 -12
View File
@@ -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"
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 947 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

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."""
+13 -2
View File
@@ -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
+4
View File
@@ -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
+42 -28
View File
@@ -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."""
+3 -3
View File
@@ -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/"
+19 -2
View File
@@ -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,
+4
View File
@@ -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")
+90 -1
View File
@@ -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
+4 -2
View File
@@ -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
######
+15
View File
@@ -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`
发送验证码,不区分登录和注册场景。
+29
View File
@@ -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:
+3 -47
View File
@@ -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
+222
View File
@@ -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:
+26
View File
@@ -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
+171
View File
@@ -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;
+5
View File
@@ -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;