putplace 0.4.1__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.

Potentially problematic release.


This version of putplace might be problematic. Click here for more details.

putplace/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """putplace - A Python project."""
2
+
3
+ from .version import __version__
4
+
5
+ __all__ = ["__version__"]
putplace/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)
putplace/config.py ADDED
@@ -0,0 +1,167 @@
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 sys
10
+ from pathlib import Path
11
+ from typing import Any, Optional
12
+
13
+ from pydantic_settings import BaseSettings, SettingsConfigDict
14
+
15
+ from .version import __version__
16
+
17
+ # Import tomli for Python < 3.11, tomllib for Python >= 3.11
18
+ if sys.version_info >= (3, 11):
19
+ import tomllib
20
+ else:
21
+ try:
22
+ import tomli as tomllib
23
+ except ImportError:
24
+ tomllib = None # type: ignore
25
+
26
+
27
+ def find_config_file() -> Optional[Path]:
28
+ """Find ppserver.toml file in standard locations.
29
+
30
+ Search order:
31
+ 1. ./ppserver.toml (current directory)
32
+ 2. ~/.config/putplace/ppserver.toml (user config)
33
+ 3. /etc/putplace/ppserver.toml (system config)
34
+
35
+ Returns:
36
+ Path to config file if found, None otherwise
37
+ """
38
+ search_paths = [
39
+ Path.cwd() / "ppserver.toml",
40
+ Path.home() / ".config" / "putplace" / "ppserver.toml",
41
+ Path("/etc/putplace/ppserver.toml"),
42
+ ]
43
+
44
+ for path in search_paths:
45
+ if path.exists() and path.is_file():
46
+ return path
47
+
48
+ return None
49
+
50
+
51
+ def load_toml_config() -> dict[str, Any]:
52
+ """Load configuration from TOML file.
53
+
54
+ Returns:
55
+ Dictionary with configuration values, empty dict if no config file found
56
+ """
57
+ if tomllib is None:
58
+ return {}
59
+
60
+ config_file = find_config_file()
61
+ if config_file is None:
62
+ return {}
63
+
64
+ try:
65
+ with open(config_file, "rb") as f:
66
+ toml_data = tomllib.load(f)
67
+
68
+ # Flatten nested TOML structure to match Settings field names
69
+ config = {}
70
+
71
+ # Database settings
72
+ if "database" in toml_data:
73
+ db = toml_data["database"]
74
+ if "mongodb_url" in db:
75
+ config["mongodb_url"] = db["mongodb_url"]
76
+ if "mongodb_database" in db:
77
+ config["mongodb_database"] = db["mongodb_database"]
78
+ if "mongodb_collection" in db:
79
+ config["mongodb_collection"] = db["mongodb_collection"]
80
+
81
+ # API settings
82
+ if "api" in toml_data:
83
+ api = toml_data["api"]
84
+ if "title" in api:
85
+ config["api_title"] = api["title"]
86
+ if "description" in api:
87
+ config["api_description"] = api["description"]
88
+
89
+ # Storage settings
90
+ if "storage" in toml_data:
91
+ storage = toml_data["storage"]
92
+ if "backend" in storage:
93
+ config["storage_backend"] = storage["backend"]
94
+ if "path" in storage:
95
+ config["storage_path"] = storage["path"]
96
+ if "s3_bucket_name" in storage:
97
+ config["s3_bucket_name"] = storage["s3_bucket_name"]
98
+ if "s3_region_name" in storage:
99
+ config["s3_region_name"] = storage["s3_region_name"]
100
+ if "s3_prefix" in storage:
101
+ config["s3_prefix"] = storage["s3_prefix"]
102
+
103
+ # AWS settings
104
+ if "aws" in toml_data:
105
+ aws = toml_data["aws"]
106
+ if "profile" in aws:
107
+ config["aws_profile"] = aws["profile"]
108
+ if "access_key_id" in aws:
109
+ config["aws_access_key_id"] = aws["access_key_id"]
110
+ if "secret_access_key" in aws:
111
+ config["aws_secret_access_key"] = aws["secret_access_key"]
112
+
113
+ return config
114
+
115
+ except Exception as e:
116
+ # If there's an error reading TOML, just return empty config
117
+ # Environment variables and defaults will still work
118
+ import logging
119
+ logging.warning(f"Failed to load TOML config from {config_file}: {e}")
120
+ return {}
121
+
122
+
123
+ class Settings(BaseSettings):
124
+ """Application settings.
125
+
126
+ Configuration is loaded in this priority order (highest to lowest):
127
+ 1. Environment variables (e.g., MONGODB_URL, STORAGE_BACKEND)
128
+ 2. ppserver.toml file (./ppserver.toml, ~/.config/putplace/ppserver.toml, /etc/putplace/ppserver.toml)
129
+ 3. Default values defined below
130
+ """
131
+
132
+ mongodb_url: str = "mongodb://localhost:27017"
133
+ mongodb_database: str = "putplace"
134
+ mongodb_collection: str = "file_metadata"
135
+
136
+ # API settings
137
+ api_title: str = "PutPlace API"
138
+ api_version: str = __version__
139
+ api_description: str = "File metadata storage API"
140
+
141
+ # Storage settings
142
+ storage_backend: str = "local" # "local" or "s3"
143
+ storage_path: str = "/var/putplace/files" # For local storage
144
+
145
+ # S3 storage settings (only used if storage_backend="s3")
146
+ s3_bucket_name: Optional[str] = None
147
+ s3_region_name: str = "us-east-1"
148
+ s3_prefix: str = "files/"
149
+
150
+ # AWS credentials (OPTIONAL - see SECURITY.md for best practices)
151
+ # If not specified, boto3 will use standard credential chain:
152
+ # 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
153
+ # 2. AWS credentials file (~/.aws/credentials)
154
+ # 3. IAM role (if running on EC2/ECS/Lambda) - RECOMMENDED
155
+ aws_profile: Optional[str] = None # Use specific profile from ~/.aws/credentials
156
+ aws_access_key_id: Optional[str] = None # NOT RECOMMENDED: use IAM roles or profiles instead
157
+ aws_secret_access_key: Optional[str] = None # NOT RECOMMENDED: use IAM roles or profiles instead
158
+
159
+ model_config = SettingsConfigDict(
160
+ case_sensitive=False,
161
+ extra="ignore", # Ignore extra environment variables (e.g., PUTPLACE_API_KEY for client)
162
+ )
163
+
164
+
165
+ # Load TOML config first, then create Settings (env vars will override)
166
+ _toml_config = load_toml_config()
167
+ settings = Settings(**_toml_config)