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.
Files changed (38) hide show
  1. putplace_server/__init__.py +5 -0
  2. putplace_server/auth.py +279 -0
  3. putplace_server/cleanup_tasks.py +42 -0
  4. putplace_server/config.py +273 -0
  5. putplace_server/database.py +573 -0
  6. putplace_server/email_service.py +197 -0
  7. putplace_server/email_tokens.py +41 -0
  8. putplace_server/main.py +4195 -0
  9. putplace_server/models.py +222 -0
  10. putplace_server/ppserver.py +519 -0
  11. putplace_server/scripts/README_send_ses_email.md +388 -0
  12. putplace_server/scripts/__init__.py +1 -0
  13. putplace_server/scripts/atlas_cluster_control.py +379 -0
  14. putplace_server/scripts/create_api_key.py +119 -0
  15. putplace_server/scripts/deploy_digitalocean.py +986 -0
  16. putplace_server/scripts/deploy_digitalocean_old.py +487 -0
  17. putplace_server/scripts/pp_manage_users.py +1031 -0
  18. putplace_server/scripts/putplace_configure.py +1176 -0
  19. putplace_server/scripts/send_ses_email.py +324 -0
  20. putplace_server/scripts/setup_apprunner_fixed_ip.py +352 -0
  21. putplace_server/scripts/setup_aws_iam_users.py +446 -0
  22. putplace_server/scripts/toggle_registration.py +241 -0
  23. putplace_server/scripts/update_apprunner_vpc.py +196 -0
  24. putplace_server/scripts/update_deployment.py +175 -0
  25. putplace_server/static/README.md +67 -0
  26. putplace_server/static/css/.gitkeep +1 -0
  27. putplace_server/static/images/.gitkeep +5 -0
  28. putplace_server/static/images/LOGO_USAGE.md +152 -0
  29. putplace_server/static/images/favicon.svg +16 -0
  30. putplace_server/static/images/putplace-logo.svg +25 -0
  31. putplace_server/static/js/.gitkeep +1 -0
  32. putplace_server/storage.py +456 -0
  33. putplace_server/user_auth.py +52 -0
  34. putplace_server/version.py +3 -0
  35. putplace_server-0.8.2.dist-info/METADATA +182 -0
  36. putplace_server-0.8.2.dist-info/RECORD +38 -0
  37. putplace_server-0.8.2.dist-info/WHEEL +4 -0
  38. putplace_server-0.8.2.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,5 @@
1
+ """PutPlace Server - FastAPI server for file metadata storage."""
2
+
3
+ from .version import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -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