putplace-server 0.8.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- putplace_server/__init__.py +5 -0
- putplace_server/auth.py +279 -0
- putplace_server/cleanup_tasks.py +42 -0
- putplace_server/config.py +273 -0
- putplace_server/database.py +573 -0
- putplace_server/email_service.py +197 -0
- putplace_server/email_tokens.py +41 -0
- putplace_server/main.py +4195 -0
- putplace_server/models.py +222 -0
- putplace_server/ppserver.py +519 -0
- putplace_server/scripts/README_send_ses_email.md +388 -0
- putplace_server/scripts/__init__.py +1 -0
- putplace_server/scripts/atlas_cluster_control.py +379 -0
- putplace_server/scripts/create_api_key.py +119 -0
- putplace_server/scripts/deploy_digitalocean.py +986 -0
- putplace_server/scripts/deploy_digitalocean_old.py +487 -0
- putplace_server/scripts/pp_manage_users.py +1031 -0
- putplace_server/scripts/putplace_configure.py +1176 -0
- putplace_server/scripts/send_ses_email.py +324 -0
- putplace_server/scripts/setup_apprunner_fixed_ip.py +352 -0
- putplace_server/scripts/setup_aws_iam_users.py +446 -0
- putplace_server/scripts/toggle_registration.py +241 -0
- putplace_server/scripts/update_apprunner_vpc.py +196 -0
- putplace_server/scripts/update_deployment.py +175 -0
- putplace_server/static/README.md +67 -0
- putplace_server/static/css/.gitkeep +1 -0
- putplace_server/static/images/.gitkeep +5 -0
- putplace_server/static/images/LOGO_USAGE.md +152 -0
- putplace_server/static/images/favicon.svg +16 -0
- putplace_server/static/images/putplace-logo.svg +25 -0
- putplace_server/static/js/.gitkeep +1 -0
- putplace_server/storage.py +456 -0
- putplace_server/user_auth.py +52 -0
- putplace_server/version.py +3 -0
- putplace_server-0.8.2.dist-info/METADATA +182 -0
- putplace_server-0.8.2.dist-info/RECORD +38 -0
- putplace_server-0.8.2.dist-info/WHEEL +4 -0
- putplace_server-0.8.2.dist-info/entry_points.txt +4 -0
putplace_server/auth.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Authentication and authorization for PutPlace API."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import secrets
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Optional, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, HTTPException, Security, status
|
|
9
|
+
from fastapi.security import APIKeyHeader
|
|
10
|
+
from pymongo.asynchronous.collection import AsyncCollection
|
|
11
|
+
|
|
12
|
+
from . import database
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .database import MongoDB
|
|
16
|
+
|
|
17
|
+
# API key header name
|
|
18
|
+
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_auth_db() -> "MongoDB":
|
|
22
|
+
"""Get database instance for authentication.
|
|
23
|
+
|
|
24
|
+
This function is used as a dependency in FastAPI routes.
|
|
25
|
+
Returns the global database.mongodb instance.
|
|
26
|
+
"""
|
|
27
|
+
return database.mongodb
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_api_key() -> str:
|
|
31
|
+
"""Generate a new API key.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
A cryptographically secure random API key (hex string, 64 characters)
|
|
35
|
+
"""
|
|
36
|
+
return secrets.token_hex(32) # 32 bytes = 64 hex characters
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def hash_api_key(api_key: str) -> str:
|
|
40
|
+
"""Hash an API key for secure storage.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
api_key: The API key to hash
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
SHA256 hash of the API key
|
|
47
|
+
"""
|
|
48
|
+
return hashlib.sha256(api_key.encode()).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class APIKeyAuth:
|
|
52
|
+
"""API Key authentication manager."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, db: database.MongoDB):
|
|
55
|
+
"""Initialize API key authentication.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
db: MongoDB database instance
|
|
59
|
+
"""
|
|
60
|
+
self.db = db
|
|
61
|
+
|
|
62
|
+
async def get_api_keys_collection(self) -> AsyncCollection:
|
|
63
|
+
"""Get the API keys collection.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
MongoDB collection for API keys
|
|
67
|
+
"""
|
|
68
|
+
if self.db.client is None:
|
|
69
|
+
raise RuntimeError("Database not connected")
|
|
70
|
+
|
|
71
|
+
db = self.db.client[self.db.collection.database.name]
|
|
72
|
+
return db["api_keys"]
|
|
73
|
+
|
|
74
|
+
async def create_api_key(
|
|
75
|
+
self,
|
|
76
|
+
name: str,
|
|
77
|
+
user_id: Optional[str] = None,
|
|
78
|
+
description: Optional[str] = None,
|
|
79
|
+
) -> tuple[str, dict]:
|
|
80
|
+
"""Create a new API key.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
name: Name/identifier for this API key
|
|
84
|
+
user_id: Optional user ID who owns this key
|
|
85
|
+
description: Optional description
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Tuple of (api_key, key_metadata)
|
|
89
|
+
The api_key is returned only once and should be given to the user.
|
|
90
|
+
The key_metadata contains the stored information (without the actual key).
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
RuntimeError: If database not connected
|
|
94
|
+
"""
|
|
95
|
+
collection = await self.get_api_keys_collection()
|
|
96
|
+
|
|
97
|
+
# Generate new API key
|
|
98
|
+
api_key = generate_api_key()
|
|
99
|
+
key_hash = hash_api_key(api_key)
|
|
100
|
+
|
|
101
|
+
# Create metadata document
|
|
102
|
+
key_doc = {
|
|
103
|
+
"key_hash": key_hash,
|
|
104
|
+
"name": name,
|
|
105
|
+
"description": description,
|
|
106
|
+
"user_id": user_id, # Associate with user
|
|
107
|
+
"created_at": datetime.utcnow(),
|
|
108
|
+
"last_used_at": None,
|
|
109
|
+
"is_active": True,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Insert into database
|
|
113
|
+
result = await collection.insert_one(key_doc.copy())
|
|
114
|
+
|
|
115
|
+
# Return the plain API key (only time we show it) and metadata
|
|
116
|
+
key_doc["_id"] = str(result.inserted_id)
|
|
117
|
+
key_doc.pop("key_hash") # Don't include hash in response
|
|
118
|
+
|
|
119
|
+
return api_key, key_doc
|
|
120
|
+
|
|
121
|
+
async def verify_api_key(self, api_key: str) -> Optional[dict]:
|
|
122
|
+
"""Verify an API key and return its metadata.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
api_key: The API key to verify
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Key metadata if valid and active, None otherwise
|
|
129
|
+
"""
|
|
130
|
+
collection = await self.get_api_keys_collection()
|
|
131
|
+
|
|
132
|
+
# Hash the provided key
|
|
133
|
+
key_hash = hash_api_key(api_key)
|
|
134
|
+
|
|
135
|
+
# Look up in database
|
|
136
|
+
key_doc = await collection.find_one({
|
|
137
|
+
"key_hash": key_hash,
|
|
138
|
+
"is_active": True,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
if key_doc:
|
|
142
|
+
# Update last used timestamp
|
|
143
|
+
await collection.update_one(
|
|
144
|
+
{"_id": key_doc["_id"]},
|
|
145
|
+
{"$set": {"last_used_at": datetime.utcnow()}}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Return metadata (without hash)
|
|
149
|
+
key_doc.pop("key_hash", None)
|
|
150
|
+
key_doc["_id"] = str(key_doc["_id"])
|
|
151
|
+
return key_doc
|
|
152
|
+
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
async def revoke_api_key(self, key_id: str) -> bool:
|
|
156
|
+
"""Revoke (deactivate) an API key.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
key_id: MongoDB ObjectId of the key to revoke
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
True if key was revoked, False if not found
|
|
163
|
+
"""
|
|
164
|
+
from bson import ObjectId
|
|
165
|
+
|
|
166
|
+
collection = await self.get_api_keys_collection()
|
|
167
|
+
|
|
168
|
+
result = await collection.update_one(
|
|
169
|
+
{"_id": ObjectId(key_id)},
|
|
170
|
+
{"$set": {"is_active": False}}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return result.modified_count > 0
|
|
174
|
+
|
|
175
|
+
async def list_api_keys(self, user_id: Optional[str] = None) -> list[dict]:
|
|
176
|
+
"""List all API keys (without showing actual keys).
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
user_id: Optional user ID to filter keys by owner
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
List of API key metadata
|
|
183
|
+
"""
|
|
184
|
+
collection = await self.get_api_keys_collection()
|
|
185
|
+
|
|
186
|
+
# Build query filter
|
|
187
|
+
query = {}
|
|
188
|
+
if user_id:
|
|
189
|
+
query["user_id"] = user_id
|
|
190
|
+
|
|
191
|
+
cursor = collection.find(query, {"key_hash": 0})
|
|
192
|
+
keys = []
|
|
193
|
+
|
|
194
|
+
async for key_doc in cursor:
|
|
195
|
+
key_doc["_id"] = str(key_doc["_id"])
|
|
196
|
+
keys.append(key_doc)
|
|
197
|
+
|
|
198
|
+
return keys
|
|
199
|
+
|
|
200
|
+
async def delete_api_key(self, key_id: str) -> bool:
|
|
201
|
+
"""Permanently delete an API key.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
key_id: MongoDB ObjectId of the key to delete
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
True if key was deleted, False if not found
|
|
208
|
+
"""
|
|
209
|
+
from bson import ObjectId
|
|
210
|
+
|
|
211
|
+
collection = await self.get_api_keys_collection()
|
|
212
|
+
|
|
213
|
+
result = await collection.delete_one({"_id": ObjectId(key_id)})
|
|
214
|
+
|
|
215
|
+
return result.deleted_count > 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# Dependency for protected endpoints
|
|
219
|
+
async def get_current_api_key(
|
|
220
|
+
api_key: str = Security(API_KEY_HEADER),
|
|
221
|
+
db: "MongoDB" = Depends(get_auth_db),
|
|
222
|
+
) -> dict:
|
|
223
|
+
"""FastAPI dependency to validate API key.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
api_key: API key from request header
|
|
227
|
+
db: Database instance (injected)
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
API key metadata if valid
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
HTTPException: If API key is missing or invalid
|
|
234
|
+
"""
|
|
235
|
+
if not api_key:
|
|
236
|
+
raise HTTPException(
|
|
237
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
238
|
+
detail="API key required. Include X-API-Key header.",
|
|
239
|
+
headers={"WWW-Authenticate": "ApiKey"},
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Get API key authenticator
|
|
243
|
+
auth = APIKeyAuth(db)
|
|
244
|
+
|
|
245
|
+
# Verify the key
|
|
246
|
+
key_metadata = await auth.verify_api_key(api_key)
|
|
247
|
+
|
|
248
|
+
if not key_metadata:
|
|
249
|
+
raise HTTPException(
|
|
250
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
251
|
+
detail="Invalid or inactive API key",
|
|
252
|
+
headers={"WWW-Authenticate": "ApiKey"},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return key_metadata
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# Optional dependency - allows unauthenticated access
|
|
259
|
+
async def get_optional_api_key(
|
|
260
|
+
api_key: str = Security(API_KEY_HEADER),
|
|
261
|
+
db: "MongoDB" = Depends(get_auth_db),
|
|
262
|
+
) -> Optional[dict]:
|
|
263
|
+
"""FastAPI dependency for optional API key authentication.
|
|
264
|
+
|
|
265
|
+
Returns API key metadata if provided and valid, None otherwise.
|
|
266
|
+
Does not raise an error if no key is provided.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
api_key: API key from request header
|
|
270
|
+
db: Database instance (injected)
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
API key metadata if valid, None if not provided or invalid
|
|
274
|
+
"""
|
|
275
|
+
if not api_key:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
auth = APIKeyAuth(db)
|
|
279
|
+
return await auth.verify_api_key(api_key)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Background cleanup tasks for expired pending users."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from .database import mongodb
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def cleanup_expired_pending_users_task():
|
|
13
|
+
"""
|
|
14
|
+
Periodically clean up expired pending users.
|
|
15
|
+
|
|
16
|
+
Runs every hour and deletes pending users whose confirmation has expired.
|
|
17
|
+
"""
|
|
18
|
+
while True:
|
|
19
|
+
try:
|
|
20
|
+
# Wait 1 hour between cleanup runs
|
|
21
|
+
await asyncio.sleep(3600) # 3600 seconds = 1 hour
|
|
22
|
+
|
|
23
|
+
logger.info("Running cleanup task for expired pending users...")
|
|
24
|
+
|
|
25
|
+
# Delete expired pending users
|
|
26
|
+
deleted_count = await mongodb.cleanup_expired_pending_users()
|
|
27
|
+
|
|
28
|
+
if deleted_count > 0:
|
|
29
|
+
logger.info(f"Cleaned up {deleted_count} expired pending user(s)")
|
|
30
|
+
else:
|
|
31
|
+
logger.debug("No expired pending users to clean up")
|
|
32
|
+
|
|
33
|
+
except Exception as e:
|
|
34
|
+
logger.error(f"Error in cleanup task: {e}")
|
|
35
|
+
# Continue running even if there's an error
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def start_cleanup_task():
|
|
40
|
+
"""Start the cleanup background task."""
|
|
41
|
+
asyncio.create_task(cleanup_expired_pending_users_task())
|
|
42
|
+
logger.info("Started background cleanup task for expired pending users")
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Application configuration.
|
|
2
|
+
|
|
3
|
+
Configuration priority (highest to lowest):
|
|
4
|
+
1. Environment variables
|
|
5
|
+
2. ppserver.toml file
|
|
6
|
+
3. Default values
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
15
|
+
|
|
16
|
+
from .version import __version__
|
|
17
|
+
|
|
18
|
+
# Import tomli for Python < 3.11, tomllib for Python >= 3.11
|
|
19
|
+
if sys.version_info >= (3, 11):
|
|
20
|
+
import tomllib
|
|
21
|
+
else:
|
|
22
|
+
try:
|
|
23
|
+
import tomli as tomllib
|
|
24
|
+
except ImportError:
|
|
25
|
+
tomllib = None # type: ignore
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_config_file() -> Optional[Path]:
|
|
29
|
+
"""Find ppserver.toml file in standard locations.
|
|
30
|
+
|
|
31
|
+
Search order:
|
|
32
|
+
1. PUTPLACE_CONFIG environment variable (if set)
|
|
33
|
+
2. ./ppserver.toml (current directory)
|
|
34
|
+
3. ~/.config/putplace/ppserver.toml (user config)
|
|
35
|
+
4. /etc/putplace/ppserver.toml (system config)
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Path to config file if found, None otherwise
|
|
39
|
+
"""
|
|
40
|
+
# Check environment variable first
|
|
41
|
+
env_config = os.environ.get("PUTPLACE_CONFIG")
|
|
42
|
+
if env_config:
|
|
43
|
+
env_path = Path(env_config)
|
|
44
|
+
if env_path.exists() and env_path.is_file():
|
|
45
|
+
return env_path
|
|
46
|
+
# If PUTPLACE_CONFIG is set but file doesn't exist, log warning but continue searching
|
|
47
|
+
import logging
|
|
48
|
+
logging.warning(f"PUTPLACE_CONFIG set to {env_config} but file not found, searching standard locations")
|
|
49
|
+
|
|
50
|
+
search_paths = [
|
|
51
|
+
Path.cwd() / "ppserver.toml",
|
|
52
|
+
Path.home() / ".config" / "putplace" / "ppserver.toml",
|
|
53
|
+
Path("/etc/putplace/ppserver.toml"),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
for path in search_paths:
|
|
57
|
+
if path.exists() and path.is_file():
|
|
58
|
+
return path
|
|
59
|
+
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_toml_config() -> dict[str, Any]:
|
|
64
|
+
"""Load configuration from TOML file.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Dictionary with configuration values, empty dict if no config file found
|
|
68
|
+
"""
|
|
69
|
+
if tomllib is None:
|
|
70
|
+
return {}
|
|
71
|
+
|
|
72
|
+
config_file = find_config_file()
|
|
73
|
+
if config_file is None:
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
with open(config_file, "rb") as f:
|
|
78
|
+
toml_data = tomllib.load(f)
|
|
79
|
+
|
|
80
|
+
# Flatten nested TOML structure to match Settings field names
|
|
81
|
+
config = {}
|
|
82
|
+
|
|
83
|
+
# Database settings
|
|
84
|
+
if "database" in toml_data:
|
|
85
|
+
db = toml_data["database"]
|
|
86
|
+
if "mongodb_url" in db:
|
|
87
|
+
config["mongodb_url"] = db["mongodb_url"]
|
|
88
|
+
if "mongodb_database" in db:
|
|
89
|
+
config["mongodb_database"] = db["mongodb_database"]
|
|
90
|
+
if "mongodb_collection" in db:
|
|
91
|
+
config["mongodb_collection"] = db["mongodb_collection"]
|
|
92
|
+
|
|
93
|
+
# API settings
|
|
94
|
+
if "api" in toml_data:
|
|
95
|
+
api = toml_data["api"]
|
|
96
|
+
if "title" in api:
|
|
97
|
+
config["api_title"] = api["title"]
|
|
98
|
+
if "description" in api:
|
|
99
|
+
config["api_description"] = api["description"]
|
|
100
|
+
|
|
101
|
+
# Storage settings
|
|
102
|
+
if "storage" in toml_data:
|
|
103
|
+
storage = toml_data["storage"]
|
|
104
|
+
if "backend" in storage:
|
|
105
|
+
config["storage_backend"] = storage["backend"]
|
|
106
|
+
if "path" in storage:
|
|
107
|
+
config["storage_path"] = storage["path"]
|
|
108
|
+
if "s3_bucket_name" in storage:
|
|
109
|
+
config["s3_bucket_name"] = storage["s3_bucket_name"]
|
|
110
|
+
if "s3_region_name" in storage:
|
|
111
|
+
config["s3_region_name"] = storage["s3_region_name"]
|
|
112
|
+
if "s3_prefix" in storage:
|
|
113
|
+
config["s3_prefix"] = storage["s3_prefix"]
|
|
114
|
+
|
|
115
|
+
# AWS settings
|
|
116
|
+
if "aws" in toml_data:
|
|
117
|
+
aws = toml_data["aws"]
|
|
118
|
+
if "profile" in aws:
|
|
119
|
+
config["aws_profile"] = aws["profile"]
|
|
120
|
+
if "access_key_id" in aws:
|
|
121
|
+
config["aws_access_key_id"] = aws["access_key_id"]
|
|
122
|
+
if "secret_access_key" in aws:
|
|
123
|
+
config["aws_secret_access_key"] = aws["secret_access_key"]
|
|
124
|
+
|
|
125
|
+
# OAuth settings
|
|
126
|
+
if "oauth" in toml_data:
|
|
127
|
+
oauth = toml_data["oauth"]
|
|
128
|
+
if "google_client_id" in oauth:
|
|
129
|
+
config["google_client_id"] = oauth["google_client_id"]
|
|
130
|
+
if "google_client_secret" in oauth:
|
|
131
|
+
config["google_client_secret"] = oauth["google_client_secret"]
|
|
132
|
+
|
|
133
|
+
# Email settings
|
|
134
|
+
if "email" in toml_data:
|
|
135
|
+
email = toml_data["email"]
|
|
136
|
+
if "sender_email" in email:
|
|
137
|
+
config["sender_email"] = email["sender_email"]
|
|
138
|
+
if "base_url" in email:
|
|
139
|
+
config["base_url"] = email["base_url"]
|
|
140
|
+
if "aws_region" in email:
|
|
141
|
+
config["email_aws_region"] = email["aws_region"]
|
|
142
|
+
|
|
143
|
+
# Server settings
|
|
144
|
+
if "server" in toml_data:
|
|
145
|
+
server = toml_data["server"]
|
|
146
|
+
if "registration_enabled" in server:
|
|
147
|
+
config["registration_enabled"] = server["registration_enabled"]
|
|
148
|
+
|
|
149
|
+
return config
|
|
150
|
+
|
|
151
|
+
except Exception as e:
|
|
152
|
+
# If there's an error reading TOML, just return empty config
|
|
153
|
+
# Environment variables and defaults will still work
|
|
154
|
+
import logging
|
|
155
|
+
logging.warning(f"Failed to load TOML config from {config_file}: {e}")
|
|
156
|
+
return {}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Settings(BaseSettings):
|
|
160
|
+
"""Application settings.
|
|
161
|
+
|
|
162
|
+
Configuration is loaded in this priority order (highest to lowest):
|
|
163
|
+
1. Environment variables (e.g., MONGODB_URL, STORAGE_BACKEND)
|
|
164
|
+
2. ppserver.toml file (search order below)
|
|
165
|
+
3. Default values defined below
|
|
166
|
+
|
|
167
|
+
Config file search order (PUTPLACE_CONFIG overrides):
|
|
168
|
+
- PUTPLACE_CONFIG environment variable (if set, takes highest priority)
|
|
169
|
+
- ./ppserver.toml (current directory)
|
|
170
|
+
- ~/.config/putplace/ppserver.toml (user config)
|
|
171
|
+
- /etc/putplace/ppserver.toml (system config)
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
mongodb_url: str
|
|
175
|
+
mongodb_database: str
|
|
176
|
+
mongodb_collection: str
|
|
177
|
+
|
|
178
|
+
# API settings
|
|
179
|
+
api_title: str
|
|
180
|
+
api_version: str = __version__
|
|
181
|
+
api_description: str
|
|
182
|
+
|
|
183
|
+
# Storage settings
|
|
184
|
+
storage_backend: str
|
|
185
|
+
storage_path: str
|
|
186
|
+
|
|
187
|
+
# S3 storage settings (only used if storage_backend="s3")
|
|
188
|
+
s3_bucket_name: Optional[str] = None
|
|
189
|
+
s3_region_name: str = "us-east-1"
|
|
190
|
+
s3_prefix: str = "files/"
|
|
191
|
+
|
|
192
|
+
# AWS credentials (OPTIONAL - see SECURITY.md for best practices)
|
|
193
|
+
aws_profile: Optional[str] = None
|
|
194
|
+
aws_access_key_id: Optional[str] = None
|
|
195
|
+
aws_secret_access_key: Optional[str] = None
|
|
196
|
+
|
|
197
|
+
# OAuth settings
|
|
198
|
+
google_client_id: Optional[str] = None
|
|
199
|
+
google_client_secret: Optional[str] = None
|
|
200
|
+
|
|
201
|
+
# Email settings for SES
|
|
202
|
+
sender_email: str = "noreply@putplace.org"
|
|
203
|
+
base_url: str = "http://localhost:8000"
|
|
204
|
+
email_aws_region: str = "eu-west-1"
|
|
205
|
+
|
|
206
|
+
# Registration control
|
|
207
|
+
registration_enabled: bool = True # Set to False to disable new user registration
|
|
208
|
+
|
|
209
|
+
model_config = SettingsConfigDict(
|
|
210
|
+
case_sensitive=False,
|
|
211
|
+
extra="ignore", # Ignore extra environment variables (e.g., PUTPLACE_API_KEY for client)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def __init__(self, **kwargs):
|
|
215
|
+
"""Initialize settings with priority: env vars > TOML > defaults."""
|
|
216
|
+
# Load TOML config
|
|
217
|
+
toml_config = load_toml_config()
|
|
218
|
+
|
|
219
|
+
# Helper to get value with priority: explicit kwarg > env var > TOML > default
|
|
220
|
+
def get_value(key: str, default: Any = None) -> Any:
|
|
221
|
+
# 1. Check if explicitly passed as kwarg
|
|
222
|
+
if key in kwargs:
|
|
223
|
+
return kwargs[key]
|
|
224
|
+
# 2. Check environment variable (uppercase)
|
|
225
|
+
env_val = os.getenv(key.upper())
|
|
226
|
+
if env_val is not None:
|
|
227
|
+
return env_val
|
|
228
|
+
# 3. Check TOML config
|
|
229
|
+
if key in toml_config:
|
|
230
|
+
return toml_config[key]
|
|
231
|
+
# 4. Use default
|
|
232
|
+
return default
|
|
233
|
+
|
|
234
|
+
# Build values dict with proper priority
|
|
235
|
+
values = {
|
|
236
|
+
"mongodb_url": get_value("mongodb_url", "mongodb://localhost:27017"),
|
|
237
|
+
"mongodb_database": get_value("mongodb_database", "putplace"),
|
|
238
|
+
"mongodb_collection": get_value("mongodb_collection", "file_metadata"),
|
|
239
|
+
"api_title": get_value("api_title", "PutPlace API"),
|
|
240
|
+
"api_description": get_value("api_description", "File metadata storage API"),
|
|
241
|
+
"storage_backend": get_value("storage_backend", "local"),
|
|
242
|
+
"storage_path": get_value("storage_path", "/var/putplace/files"),
|
|
243
|
+
"s3_bucket_name": get_value("s3_bucket_name"),
|
|
244
|
+
"s3_region_name": get_value("s3_region_name", "us-east-1"),
|
|
245
|
+
"s3_prefix": get_value("s3_prefix", "files/"),
|
|
246
|
+
"aws_profile": get_value("aws_profile"),
|
|
247
|
+
"aws_access_key_id": get_value("aws_access_key_id"),
|
|
248
|
+
"aws_secret_access_key": get_value("aws_secret_access_key"),
|
|
249
|
+
"google_client_id": get_value("google_client_id"),
|
|
250
|
+
"google_client_secret": get_value("google_client_secret"),
|
|
251
|
+
"sender_email": get_value("sender_email", "noreply@putplace.org"),
|
|
252
|
+
"base_url": get_value("base_url", "http://localhost:8000"),
|
|
253
|
+
"email_aws_region": get_value("email_aws_region", "eu-west-1"),
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
# Merge with any remaining kwargs
|
|
257
|
+
values.update({k: v for k, v in kwargs.items() if k not in values})
|
|
258
|
+
|
|
259
|
+
super().__init__(**values)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# Create settings instance
|
|
263
|
+
# Pydantic Settings priority (highest to lowest):
|
|
264
|
+
# 1. Constructor arguments (not used here)
|
|
265
|
+
# 2. Environment variables
|
|
266
|
+
# 3. Defaults in class definition
|
|
267
|
+
# Since we're not passing any constructor args, env vars will naturally override defaults
|
|
268
|
+
settings = Settings()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_settings() -> Settings:
|
|
272
|
+
"""Get application settings."""
|
|
273
|
+
return settings
|