Spaces:
Sleeping
Sleeping
Commit ·
e379e4f
0
Parent(s):
Initial commit: Complete MinIO File Storage API
Browse files- .gitignore +32 -0
- Dockerfile +38 -0
- README.md +203 -0
- app.py +7 -0
- app/__init__.py +1 -0
- app/api_key_auth.py +63 -0
- app/auth.py +55 -0
- app/database.py +92 -0
- app/main.py +692 -0
- app/minio_client.py +88 -0
- app/schemas.py +80 -0
- requirements.txt +14 -0
- start.sh +18 -0
.gitignore
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
env/
|
| 8 |
+
venv/
|
| 9 |
+
ENV/
|
| 10 |
+
.venv
|
| 11 |
+
|
| 12 |
+
# Environment
|
| 13 |
+
.env
|
| 14 |
+
.env.local
|
| 15 |
+
|
| 16 |
+
# IDEs
|
| 17 |
+
.vscode/
|
| 18 |
+
.idea/
|
| 19 |
+
*.swp
|
| 20 |
+
*.swo
|
| 21 |
+
|
| 22 |
+
# OS
|
| 23 |
+
.DS_Store
|
| 24 |
+
Thumbs.db
|
| 25 |
+
|
| 26 |
+
# MinIO data
|
| 27 |
+
/tmp/
|
| 28 |
+
*.log
|
| 29 |
+
|
| 30 |
+
# Database
|
| 31 |
+
*.db
|
| 32 |
+
*.sqlite3
|
Dockerfile
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system dependencies
|
| 6 |
+
RUN apt-get update && apt-get install -y \
|
| 7 |
+
gcc \
|
| 8 |
+
curl \
|
| 9 |
+
wget \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Install MinIO server
|
| 13 |
+
RUN wget https://dl.min.io/server/minio/release/linux-amd64/minio \
|
| 14 |
+
&& chmod +x minio \
|
| 15 |
+
&& mv minio /usr/local/bin/
|
| 16 |
+
|
| 17 |
+
# Copy requirements and install
|
| 18 |
+
COPY requirements.txt .
|
| 19 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# Copy application
|
| 22 |
+
COPY . .
|
| 23 |
+
|
| 24 |
+
# Create data directory
|
| 25 |
+
RUN mkdir -p /tmp/minio-data && chmod 777 /tmp/minio-data
|
| 26 |
+
|
| 27 |
+
# Make startup script executable
|
| 28 |
+
RUN chmod +x start.sh
|
| 29 |
+
|
| 30 |
+
# Expose ports
|
| 31 |
+
EXPOSE 7860 9000 9001
|
| 32 |
+
|
| 33 |
+
# Health check
|
| 34 |
+
HEALTHCHECK --interval=30s --timeout=30s --start-period=15s --retries=3 \
|
| 35 |
+
CMD curl -f http://localhost:7860/ || exit 1
|
| 36 |
+
|
| 37 |
+
# Start services
|
| 38 |
+
CMD ["./start.sh"]
|
README.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: MinIO Storage API
|
| 3 |
+
emoji: 🗃️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_file: app.py
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# MinIO File Storage Service
|
| 13 |
+
|
| 14 |
+
A complete file storage API with FastAPI backend, MinIO object storage, and PostgreSQL metadata management.
|
| 15 |
+
|
| 16 |
+
## 🚀 Features
|
| 17 |
+
|
| 18 |
+
- 🔐 **Dual Authentication** - JWT tokens and API keys
|
| 19 |
+
- 🎯 **Granular Permissions** - Fine-grained access control
|
| 20 |
+
- 🌐 **Public File Sharing** - Share files with public URLs
|
| 21 |
+
- 📁 **Project-based Storage** - Isolated storage per project
|
| 22 |
+
- 🔑 **API Key Management** - Generate keys with specific permissions
|
| 23 |
+
- 📤 **Direct Upload API** - Upload files with API keys
|
| 24 |
+
- 🖼️ **Inline File Viewing** - View images, PDFs in browser
|
| 25 |
+
- ⬇️ **Public Downloads** - Download files without authentication
|
| 26 |
+
|
| 27 |
+
## 🔒 API Key Permissions
|
| 28 |
+
|
| 29 |
+
| Permission | Description |
|
| 30 |
+
|-----------|-------------|
|
| 31 |
+
| `read` | View file metadata |
|
| 32 |
+
| `write` | Upload new files |
|
| 33 |
+
| `delete` | Delete files |
|
| 34 |
+
| `list` | List all files |
|
| 35 |
+
| `public` | Generate public URLs |
|
| 36 |
+
| `download` | Download files |
|
| 37 |
+
|
| 38 |
+
### Permission Presets
|
| 39 |
+
- **Read-only**: `read,list,download`
|
| 40 |
+
- **Upload only**: `write`
|
| 41 |
+
- **Full access**: `read,write,delete,list,public,download`
|
| 42 |
+
|
| 43 |
+
## 📖 Quick Start
|
| 44 |
+
|
| 45 |
+
### 1. Initialize Admin
|
| 46 |
+
```bash
|
| 47 |
+
POST /init-admin
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
### 2. Login
|
| 51 |
+
```bash
|
| 52 |
+
POST /auth/login
|
| 53 |
+
{
|
| 54 |
+
"username": "admin",
|
| 55 |
+
"password": "your_admin_password"
|
| 56 |
+
}
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### 3. Create Project
|
| 60 |
+
```bash
|
| 61 |
+
POST /projects
|
| 62 |
+
Authorization: Bearer <your_jwt_token>
|
| 63 |
+
{
|
| 64 |
+
"name": "My Project",
|
| 65 |
+
"description": "Project description"
|
| 66 |
+
}
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
### 4. Generate API Key
|
| 70 |
+
```bash
|
| 71 |
+
POST /projects/{project_id}/api-keys
|
| 72 |
+
Authorization: Bearer <your_jwt_token>
|
| 73 |
+
{
|
| 74 |
+
"name": "Upload Key",
|
| 75 |
+
"permissions": "write,public",
|
| 76 |
+
"description": "Key for uploading files"
|
| 77 |
+
}
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
### 5. Upload Files
|
| 81 |
+
```bash
|
| 82 |
+
curl -X POST "https://godfreyowino-minio-storage-api.hf.space/api/upload" \
|
| 83 |
+
-H "X-API-Key: sk_your_api_key" \
|
| 84 |
+
-F "file=@image.jpg"
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
## 📚 API Endpoints
|
| 88 |
+
|
| 89 |
+
### Authentication
|
| 90 |
+
- `POST /auth/register` - Register user
|
| 91 |
+
- `POST /auth/login` - Login user
|
| 92 |
+
- `GET /users/me` - Get user info
|
| 93 |
+
- `POST /init-admin` - Initialize admin
|
| 94 |
+
|
| 95 |
+
### Projects (JWT Required)
|
| 96 |
+
- `POST /projects` - Create project
|
| 97 |
+
- `GET /projects` - List projects
|
| 98 |
+
- `GET /projects/{id}` - Get project
|
| 99 |
+
|
| 100 |
+
### API Keys (JWT Required)
|
| 101 |
+
- `POST /projects/{id}/api-keys` - Create API key
|
| 102 |
+
- `GET /projects/{id}/api-keys` - List API keys
|
| 103 |
+
- `DELETE /projects/{id}/api-keys/{key_id}` - Revoke key
|
| 104 |
+
|
| 105 |
+
### Files (JWT Auth)
|
| 106 |
+
- `POST /projects/{id}/upload` - Upload file
|
| 107 |
+
- `GET /projects/{id}/files` - List files
|
| 108 |
+
- `GET /projects/{id}/files/{file_id}/download` - Download
|
| 109 |
+
- `DELETE /projects/{id}/files/{file_id}` - Delete
|
| 110 |
+
|
| 111 |
+
### Files (API Key Auth)
|
| 112 |
+
- `POST /api/upload` - Upload file
|
| 113 |
+
- `GET /api/files` - List files
|
| 114 |
+
- `GET /api/files/{file_id}` - Get file metadata
|
| 115 |
+
- `GET /api/files/{file_id}/download` - Download
|
| 116 |
+
- `GET /api/files/{file_id}/public-url` - Get public URL
|
| 117 |
+
- `DELETE /api/files/{file_id}` - Delete
|
| 118 |
+
|
| 119 |
+
### Public Access (No Auth)
|
| 120 |
+
- `GET /public/files/{file_id}` - View file inline
|
| 121 |
+
- `GET /public/download/{file_id}` - Download file
|
| 122 |
+
|
| 123 |
+
## 💻 Usage Examples
|
| 124 |
+
|
| 125 |
+
### Python
|
| 126 |
+
```python
|
| 127 |
+
import requests
|
| 128 |
+
|
| 129 |
+
API_KEY = "sk_your_api_key"
|
| 130 |
+
BASE_URL = "https://godfreyowino-minio-storage-api.hf.space"
|
| 131 |
+
|
| 132 |
+
# Upload file
|
| 133 |
+
with open("image.jpg", "rb") as f:
|
| 134 |
+
response = requests.post(
|
| 135 |
+
f"{BASE_URL}/api/upload",
|
| 136 |
+
headers={"X-API-Key": API_KEY},
|
| 137 |
+
files={"file": f}
|
| 138 |
+
)
|
| 139 |
+
result = response.json()
|
| 140 |
+
print(f"Public URL: {result['public_url']}")
|
| 141 |
+
|
| 142 |
+
# List files
|
| 143 |
+
response = requests.get(
|
| 144 |
+
f"{BASE_URL}/api/files",
|
| 145 |
+
headers={"X-API-Key": API_KEY}
|
| 146 |
+
)
|
| 147 |
+
files = response.json()
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
### JavaScript
|
| 151 |
+
```javascript
|
| 152 |
+
const API_KEY = "sk_your_api_key";
|
| 153 |
+
const BASE_URL = "https://godfreyowino-minio-storage-api.hf.space";
|
| 154 |
+
|
| 155 |
+
// Upload file
|
| 156 |
+
const uploadFile = async (file) => {
|
| 157 |
+
const formData = new FormData();
|
| 158 |
+
formData.append('file', file);
|
| 159 |
+
|
| 160 |
+
const response = await fetch(`${BASE_URL}/api/upload`, {
|
| 161 |
+
method: 'POST',
|
| 162 |
+
headers: { 'X-API-Key': API_KEY },
|
| 163 |
+
body: formData
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
const data = await response.json();
|
| 167 |
+
return data.public_url;
|
| 168 |
+
};
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
### HTML
|
| 172 |
+
```html
|
| 173 |
+
<!-- Display image -->
|
| 174 |
+
<img src="https://godfreyowino-minio-storage-api.hf.space/public/files/{file-id}"
|
| 175 |
+
alt="Uploaded image">
|
| 176 |
+
|
| 177 |
+
<!-- Download link -->
|
| 178 |
+
<a href="https://godfreyowino-minio-storage-api.hf.space/public/download/{file-id}">
|
| 179 |
+
Download File
|
| 180 |
+
</a>
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
## 🔧 Environment Variables
|
| 184 |
+
|
| 185 |
+
Required variables for HuggingFace Spaces:
|
| 186 |
+
```env
|
| 187 |
+
DATABASE_URL=your_postgresql_url
|
| 188 |
+
MINIO_ENDPOINT=localhost:9000
|
| 189 |
+
MINIO_ACCESS_KEY=minioadmin
|
| 190 |
+
MINIO_SECRET_KEY=minioadmin
|
| 191 |
+
MINIO_SECURE=false
|
| 192 |
+
SECRET_KEY=your_secret_key
|
| 193 |
+
ADMIN_USERNAME=admin
|
| 194 |
+
ADMIN_PASSWORD=your_admin_password
|
| 195 |
+
PUBLIC_URL=https://godfreyowino-minio-storage-api.hf.space
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
## 📖 Documentation
|
| 199 |
+
|
| 200 |
+
Interactive API docs: [/docs](https://godfreyowino-minio-storage-api.hf.space/docs)
|
| 201 |
+
|
| 202 |
+
## License
|
| 203 |
+
MIT License
|
app.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.main import app
|
| 2 |
+
import uvicorn
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
port = int(os.environ.get("PORT", 7860))
|
| 7 |
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# MinIO File Storage API Package
|
app/api_key_auth.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Header, HTTPException, status
|
| 2 |
+
from app.database import APIKey, Project
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
|
| 6 |
+
async def verify_api_key(
|
| 7 |
+
x_api_key: str = Header(..., description="API Key for authentication"),
|
| 8 |
+
db: Session = None
|
| 9 |
+
) -> tuple:
|
| 10 |
+
"""Verify API key and return project and permissions"""
|
| 11 |
+
if not x_api_key:
|
| 12 |
+
raise HTTPException(
|
| 13 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 14 |
+
detail="API Key is required"
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
if not x_api_key.startswith("sk_"):
|
| 18 |
+
raise HTTPException(
|
| 19 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 20 |
+
detail="Invalid API Key format"
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
api_key = db.query(APIKey).filter(
|
| 24 |
+
APIKey.key == x_api_key,
|
| 25 |
+
APIKey.is_active == True
|
| 26 |
+
).first()
|
| 27 |
+
|
| 28 |
+
if not api_key:
|
| 29 |
+
raise HTTPException(
|
| 30 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 31 |
+
detail="Invalid or inactive API Key"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Check expiration
|
| 35 |
+
if api_key.expires_at and api_key.expires_at < datetime.now(timezone.utc):
|
| 36 |
+
raise HTTPException(
|
| 37 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 38 |
+
detail="API Key has expired"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# Update last used
|
| 42 |
+
api_key.last_used_at = datetime.now(timezone.utc)
|
| 43 |
+
db.commit()
|
| 44 |
+
|
| 45 |
+
project = db.query(Project).filter(Project.id == api_key.project_id).first()
|
| 46 |
+
|
| 47 |
+
if not project or not project.is_active:
|
| 48 |
+
raise HTTPException(
|
| 49 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 50 |
+
detail="Project is inactive"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
permissions = [p.strip() for p in api_key.permissions.split(",")]
|
| 54 |
+
|
| 55 |
+
return project, permissions, api_key
|
| 56 |
+
|
| 57 |
+
def check_permission(permissions: list, required: str):
|
| 58 |
+
"""Check if required permission exists"""
|
| 59 |
+
if required not in permissions:
|
| 60 |
+
raise HTTPException(
|
| 61 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 62 |
+
detail=f"Permission '{required}' is required for this operation"
|
| 63 |
+
)
|
app/auth.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta, timezone
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from jose import jwt
|
| 4 |
+
from passlib.context import CryptContext
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
from app.database import User
|
| 7 |
+
import os
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
SECRET_KEY = os.getenv("SECRET_KEY", "fallback-secret-key")
|
| 13 |
+
ALGORITHM = os.getenv("ALGORITHM", "HS256")
|
| 14 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
| 15 |
+
|
| 16 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 17 |
+
|
| 18 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 19 |
+
"""Verify password against hash"""
|
| 20 |
+
if len(plain_password.encode('utf-8')) > 72:
|
| 21 |
+
plain_password = plain_password.encode('utf-8')[:72].decode('utf-8', errors='ignore')
|
| 22 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 23 |
+
|
| 24 |
+
def get_password_hash(password: str) -> str:
|
| 25 |
+
"""Generate password hash"""
|
| 26 |
+
if len(password.encode('utf-8')) > 72:
|
| 27 |
+
password = password.encode('utf-8')[:72].decode('utf-8', errors='ignore')
|
| 28 |
+
return pwd_context.hash(password)
|
| 29 |
+
|
| 30 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
| 31 |
+
"""Create JWT access token"""
|
| 32 |
+
to_encode = data.copy()
|
| 33 |
+
if expires_delta:
|
| 34 |
+
expire = datetime.now(timezone.utc) + expires_delta
|
| 35 |
+
else:
|
| 36 |
+
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
| 37 |
+
to_encode.update({"exp": expire})
|
| 38 |
+
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 39 |
+
return encoded_jwt
|
| 40 |
+
|
| 41 |
+
def verify_token(token: str):
|
| 42 |
+
"""Verify JWT token"""
|
| 43 |
+
try:
|
| 44 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 45 |
+
username: str = payload.get("sub")
|
| 46 |
+
return username
|
| 47 |
+
except jwt.JWTError:
|
| 48 |
+
return None
|
| 49 |
+
|
| 50 |
+
def authenticate_user(db: Session, username: str, password: str):
|
| 51 |
+
"""Authenticate user"""
|
| 52 |
+
user = db.query(User).filter(User.username == username).first()
|
| 53 |
+
if not user or not verify_password(password, user.hashed_password):
|
| 54 |
+
return False
|
| 55 |
+
return user
|
app/database.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine, Column, String, DateTime, Boolean, Text, ForeignKey
|
| 2 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 3 |
+
from sqlalchemy.orm import sessionmaker, Session, relationship
|
| 4 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
import uuid
|
| 7 |
+
import os
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
DATABASE_URL = os.getenv("DATABASE_URL")
|
| 13 |
+
|
| 14 |
+
engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_recycle=3600)
|
| 15 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 16 |
+
|
| 17 |
+
Base = declarative_base()
|
| 18 |
+
|
| 19 |
+
class User(Base):
|
| 20 |
+
"""User model for authentication"""
|
| 21 |
+
__tablename__ = "users"
|
| 22 |
+
|
| 23 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 24 |
+
username = Column(String, unique=True, index=True, nullable=False)
|
| 25 |
+
email = Column(String, unique=True, index=True, nullable=False)
|
| 26 |
+
hashed_password = Column(String, nullable=False)
|
| 27 |
+
is_active = Column(Boolean, default=True)
|
| 28 |
+
is_admin = Column(Boolean, default=False)
|
| 29 |
+
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
| 30 |
+
|
| 31 |
+
projects = relationship("Project", back_populates="owner", cascade="all, delete-orphan")
|
| 32 |
+
|
| 33 |
+
class Project(Base):
|
| 34 |
+
"""Project model - each project gets its own MinIO bucket"""
|
| 35 |
+
__tablename__ = "projects"
|
| 36 |
+
|
| 37 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 38 |
+
name = Column(String, nullable=False)
|
| 39 |
+
description = Column(Text)
|
| 40 |
+
bucket_name = Column(String, unique=True, nullable=False)
|
| 41 |
+
access_key = Column(String, unique=True, nullable=False)
|
| 42 |
+
secret_key = Column(String, nullable=False)
|
| 43 |
+
owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id"))
|
| 44 |
+
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
| 45 |
+
is_active = Column(Boolean, default=True)
|
| 46 |
+
|
| 47 |
+
owner = relationship("User", back_populates="projects")
|
| 48 |
+
files = relationship("FileRecord", back_populates="project", cascade="all, delete-orphan")
|
| 49 |
+
api_keys = relationship("APIKey", back_populates="project", cascade="all, delete-orphan")
|
| 50 |
+
|
| 51 |
+
class APIKey(Base):
|
| 52 |
+
"""API Key model for project authentication"""
|
| 53 |
+
__tablename__ = "api_keys"
|
| 54 |
+
|
| 55 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 56 |
+
name = Column(String, nullable=False)
|
| 57 |
+
description = Column(Text, nullable=True)
|
| 58 |
+
key = Column(String, unique=True, nullable=False, index=True)
|
| 59 |
+
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"))
|
| 60 |
+
permissions = Column(String, default="read")
|
| 61 |
+
is_active = Column(Boolean, default=True)
|
| 62 |
+
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
| 63 |
+
last_used_at = Column(DateTime(timezone=True), nullable=True)
|
| 64 |
+
expires_at = Column(DateTime(timezone=True), nullable=True)
|
| 65 |
+
|
| 66 |
+
project = relationship("Project", back_populates="api_keys")
|
| 67 |
+
|
| 68 |
+
class FileRecord(Base):
|
| 69 |
+
"""File metadata model"""
|
| 70 |
+
__tablename__ = "file_records"
|
| 71 |
+
|
| 72 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 73 |
+
filename = Column(String, nullable=False)
|
| 74 |
+
original_filename = Column(String, nullable=False)
|
| 75 |
+
file_size = Column(String)
|
| 76 |
+
content_type = Column(String)
|
| 77 |
+
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"))
|
| 78 |
+
uploaded_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
| 79 |
+
|
| 80 |
+
project = relationship("Project", back_populates="files")
|
| 81 |
+
|
| 82 |
+
def get_db():
|
| 83 |
+
"""Database session dependency"""
|
| 84 |
+
db = SessionLocal()
|
| 85 |
+
try:
|
| 86 |
+
yield db
|
| 87 |
+
finally:
|
| 88 |
+
db.close()
|
| 89 |
+
|
| 90 |
+
def create_tables():
|
| 91 |
+
"""Create all database tables"""
|
| 92 |
+
Base.metadata.create_all(bind=engine)
|
app/main.py
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException, Depends, UploadFile, File, status, Header
|
| 2 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 3 |
+
from fastapi.responses import StreamingResponse
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from typing import List
|
| 6 |
+
import uuid
|
| 7 |
+
import secrets
|
| 8 |
+
import string
|
| 9 |
+
import io
|
| 10 |
+
import os
|
| 11 |
+
from datetime import timedelta, datetime, timezone
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
|
| 14 |
+
from app.database import get_db, create_tables, User, Project, FileRecord, APIKey
|
| 15 |
+
from app.auth import (
|
| 16 |
+
authenticate_user, create_access_token, verify_token,
|
| 17 |
+
get_password_hash, ACCESS_TOKEN_EXPIRE_MINUTES
|
| 18 |
+
)
|
| 19 |
+
from app.minio_client import minio_client
|
| 20 |
+
from app.schemas import (
|
| 21 |
+
UserCreate, UserResponse, ProjectCreate, ProjectResponse,
|
| 22 |
+
FileResponse, LoginRequest, TokenResponse, FileUploadResponse,
|
| 23 |
+
APIKeyCreate, APIKeyResponse
|
| 24 |
+
)
|
| 25 |
+
from app.api_key_auth import verify_api_key, check_permission
|
| 26 |
+
|
| 27 |
+
load_dotenv()
|
| 28 |
+
|
| 29 |
+
# Create tables on startup
|
| 30 |
+
create_tables()
|
| 31 |
+
|
| 32 |
+
app = FastAPI(
|
| 33 |
+
title="MinIO File Storage Service",
|
| 34 |
+
description="Complete file storage solution with MinIO and project-based management",
|
| 35 |
+
version="1.0.0"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
security = HTTPBearer()
|
| 39 |
+
|
| 40 |
+
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)):
|
| 41 |
+
"""Get current authenticated user from JWT token"""
|
| 42 |
+
token = credentials.credentials
|
| 43 |
+
username = verify_token(token)
|
| 44 |
+
if username is None:
|
| 45 |
+
raise HTTPException(
|
| 46 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 47 |
+
detail="Could not validate credentials"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
user = db.query(User).filter(User.username == username).first()
|
| 51 |
+
if user is None:
|
| 52 |
+
raise HTTPException(
|
| 53 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 54 |
+
detail="User not found"
|
| 55 |
+
)
|
| 56 |
+
return user
|
| 57 |
+
|
| 58 |
+
def generate_random_string(length: int = 20) -> str:
|
| 59 |
+
"""Generate random string for API keys"""
|
| 60 |
+
return ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(length))
|
| 61 |
+
|
| 62 |
+
# ============================================================================
|
| 63 |
+
# Root & Health Endpoints
|
| 64 |
+
# ============================================================================
|
| 65 |
+
|
| 66 |
+
@app.get("/")
|
| 67 |
+
async def root():
|
| 68 |
+
"""Root endpoint"""
|
| 69 |
+
return {
|
| 70 |
+
"message": "MinIO File Storage Service API",
|
| 71 |
+
"docs": "/docs",
|
| 72 |
+
"status": "running",
|
| 73 |
+
"version": "1.0.0"
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
# ============================================================================
|
| 77 |
+
# Authentication Endpoints
|
| 78 |
+
# ============================================================================
|
| 79 |
+
|
| 80 |
+
@app.post("/auth/login", response_model=TokenResponse)
|
| 81 |
+
async def login(login_data: LoginRequest, db: Session = Depends(get_db)):
|
| 82 |
+
"""Login and get JWT token"""
|
| 83 |
+
user = authenticate_user(db, login_data.username, login_data.password)
|
| 84 |
+
if not user:
|
| 85 |
+
raise HTTPException(
|
| 86 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 87 |
+
detail="Incorrect username or password"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 91 |
+
access_token = create_access_token(
|
| 92 |
+
data={"sub": user.username}, expires_delta=access_token_expires
|
| 93 |
+
)
|
| 94 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
| 95 |
+
|
| 96 |
+
@app.post("/auth/register", response_model=UserResponse)
|
| 97 |
+
async def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
| 98 |
+
"""Register new user"""
|
| 99 |
+
if db.query(User).filter(User.username == user_data.username).first():
|
| 100 |
+
raise HTTPException(status_code=400, detail="Username already registered")
|
| 101 |
+
|
| 102 |
+
if db.query(User).filter(User.email == user_data.email).first():
|
| 103 |
+
raise HTTPException(status_code=400, detail="Email already registered")
|
| 104 |
+
|
| 105 |
+
hashed_password = get_password_hash(user_data.password)
|
| 106 |
+
db_user = User(
|
| 107 |
+
username=user_data.username,
|
| 108 |
+
email=user_data.email,
|
| 109 |
+
hashed_password=hashed_password
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
db.add(db_user)
|
| 113 |
+
db.commit()
|
| 114 |
+
db.refresh(db_user)
|
| 115 |
+
|
| 116 |
+
return db_user
|
| 117 |
+
|
| 118 |
+
@app.get("/users/me", response_model=UserResponse)
|
| 119 |
+
async def read_users_me(current_user: User = Depends(get_current_user)):
|
| 120 |
+
"""Get current user info"""
|
| 121 |
+
return current_user
|
| 122 |
+
|
| 123 |
+
@app.post("/init-admin")
|
| 124 |
+
async def init_admin(db: Session = Depends(get_db)):
|
| 125 |
+
"""Initialize admin user - only works if no admin exists"""
|
| 126 |
+
admin_username = os.getenv("ADMIN_USERNAME", "admin")
|
| 127 |
+
admin_password = os.getenv("ADMIN_PASSWORD", "admin123")
|
| 128 |
+
|
| 129 |
+
existing_admin = db.query(User).filter(User.is_admin == True).first()
|
| 130 |
+
if existing_admin:
|
| 131 |
+
raise HTTPException(status_code=400, detail="Admin already exists")
|
| 132 |
+
|
| 133 |
+
admin_user = User(
|
| 134 |
+
username=admin_username,
|
| 135 |
+
email="admin@example.com",
|
| 136 |
+
hashed_password=get_password_hash(admin_password),
|
| 137 |
+
is_admin=True
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
db.add(admin_user)
|
| 141 |
+
db.commit()
|
| 142 |
+
|
| 143 |
+
return {"message": f"Admin user created: {admin_username}"}
|
| 144 |
+
|
| 145 |
+
# ============================================================================
|
| 146 |
+
# Project Management Endpoints
|
| 147 |
+
# ============================================================================
|
| 148 |
+
|
| 149 |
+
@app.post("/projects", response_model=ProjectResponse)
|
| 150 |
+
async def create_project(
|
| 151 |
+
project_data: ProjectCreate,
|
| 152 |
+
current_user: User = Depends(get_current_user),
|
| 153 |
+
db: Session = Depends(get_db)
|
| 154 |
+
):
|
| 155 |
+
"""Create new project with MinIO bucket"""
|
| 156 |
+
bucket_name = f"project-{uuid.uuid4().hex[:8]}-{project_data.name.lower().replace(' ', '-')[:20]}"
|
| 157 |
+
access_key = f"proj_{generate_random_string(16)}"
|
| 158 |
+
secret_key = generate_random_string(32)
|
| 159 |
+
|
| 160 |
+
# Create bucket
|
| 161 |
+
if not minio_client.create_bucket(bucket_name):
|
| 162 |
+
raise HTTPException(status_code=500, detail="Failed to create storage bucket")
|
| 163 |
+
|
| 164 |
+
db_project = Project(
|
| 165 |
+
name=project_data.name,
|
| 166 |
+
description=project_data.description,
|
| 167 |
+
bucket_name=bucket_name,
|
| 168 |
+
access_key=access_key,
|
| 169 |
+
secret_key=secret_key,
|
| 170 |
+
owner_id=current_user.id
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
db.add(db_project)
|
| 174 |
+
db.commit()
|
| 175 |
+
db.refresh(db_project)
|
| 176 |
+
|
| 177 |
+
return db_project
|
| 178 |
+
|
| 179 |
+
@app.get("/projects", response_model=List[ProjectResponse])
|
| 180 |
+
async def list_projects(
|
| 181 |
+
current_user: User = Depends(get_current_user),
|
| 182 |
+
db: Session = Depends(get_db)
|
| 183 |
+
):
|
| 184 |
+
"""List all user projects"""
|
| 185 |
+
projects = db.query(Project).filter(Project.owner_id == current_user.id).all()
|
| 186 |
+
return projects
|
| 187 |
+
|
| 188 |
+
@app.get("/projects/{project_id}", response_model=ProjectResponse)
|
| 189 |
+
async def get_project(
|
| 190 |
+
project_id: uuid.UUID,
|
| 191 |
+
current_user: User = Depends(get_current_user),
|
| 192 |
+
db: Session = Depends(get_db)
|
| 193 |
+
):
|
| 194 |
+
"""Get project details"""
|
| 195 |
+
project = db.query(Project).filter(
|
| 196 |
+
Project.id == project_id,
|
| 197 |
+
Project.owner_id == current_user.id
|
| 198 |
+
).first()
|
| 199 |
+
|
| 200 |
+
if not project:
|
| 201 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 202 |
+
|
| 203 |
+
return project
|
| 204 |
+
|
| 205 |
+
# ============================================================================
|
| 206 |
+
# API Key Management Endpoints
|
| 207 |
+
# ============================================================================
|
| 208 |
+
|
| 209 |
+
@app.post("/projects/{project_id}/api-keys", response_model=APIKeyResponse)
|
| 210 |
+
async def create_api_key(
|
| 211 |
+
project_id: uuid.UUID,
|
| 212 |
+
api_key_data: APIKeyCreate,
|
| 213 |
+
current_user: User = Depends(get_current_user),
|
| 214 |
+
db: Session = Depends(get_db)
|
| 215 |
+
):
|
| 216 |
+
"""Create API key for project"""
|
| 217 |
+
project = db.query(Project).filter(
|
| 218 |
+
Project.id == project_id,
|
| 219 |
+
Project.owner_id == current_user.id
|
| 220 |
+
).first()
|
| 221 |
+
|
| 222 |
+
if not project:
|
| 223 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 224 |
+
|
| 225 |
+
# Generate API key
|
| 226 |
+
api_key = f"sk_{secrets.token_urlsafe(32)}"
|
| 227 |
+
|
| 228 |
+
db_api_key = APIKey(
|
| 229 |
+
name=api_key_data.name,
|
| 230 |
+
description=api_key_data.description,
|
| 231 |
+
key=api_key,
|
| 232 |
+
project_id=project.id,
|
| 233 |
+
permissions=api_key_data.permissions
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
db.add(db_api_key)
|
| 237 |
+
db.commit()
|
| 238 |
+
db.refresh(db_api_key)
|
| 239 |
+
|
| 240 |
+
return db_api_key
|
| 241 |
+
|
| 242 |
+
@app.get("/projects/{project_id}/api-keys", response_model=List[APIKeyResponse])
|
| 243 |
+
async def list_api_keys(
|
| 244 |
+
project_id: uuid.UUID,
|
| 245 |
+
current_user: User = Depends(get_current_user),
|
| 246 |
+
db: Session = Depends(get_db)
|
| 247 |
+
):
|
| 248 |
+
"""List all API keys for project"""
|
| 249 |
+
project = db.query(Project).filter(
|
| 250 |
+
Project.id == project_id,
|
| 251 |
+
Project.owner_id == current_user.id
|
| 252 |
+
).first()
|
| 253 |
+
|
| 254 |
+
if not project:
|
| 255 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 256 |
+
|
| 257 |
+
api_keys = db.query(APIKey).filter(APIKey.project_id == project.id).all()
|
| 258 |
+
|
| 259 |
+
# Mask keys for security
|
| 260 |
+
for key in api_keys:
|
| 261 |
+
if len(key.key) > 14:
|
| 262 |
+
key.key = key.key[:10] + "..." + key.key[-4:]
|
| 263 |
+
|
| 264 |
+
return api_keys
|
| 265 |
+
|
| 266 |
+
@app.delete("/projects/{project_id}/api-keys/{key_id}")
|
| 267 |
+
async def revoke_api_key(
|
| 268 |
+
project_id: uuid.UUID,
|
| 269 |
+
key_id: uuid.UUID,
|
| 270 |
+
current_user: User = Depends(get_current_user),
|
| 271 |
+
db: Session = Depends(get_db)
|
| 272 |
+
):
|
| 273 |
+
"""Revoke API key"""
|
| 274 |
+
project = db.query(Project).filter(
|
| 275 |
+
Project.id == project_id,
|
| 276 |
+
Project.owner_id == current_user.id
|
| 277 |
+
).first()
|
| 278 |
+
|
| 279 |
+
if not project:
|
| 280 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 281 |
+
|
| 282 |
+
api_key = db.query(APIKey).filter(
|
| 283 |
+
APIKey.id == key_id,
|
| 284 |
+
APIKey.project_id == project.id
|
| 285 |
+
).first()
|
| 286 |
+
|
| 287 |
+
if not api_key:
|
| 288 |
+
raise HTTPException(status_code=404, detail="API Key not found")
|
| 289 |
+
|
| 290 |
+
db.delete(api_key)
|
| 291 |
+
db.commit()
|
| 292 |
+
|
| 293 |
+
return {"message": "API Key revoked successfully"}
|
| 294 |
+
|
| 295 |
+
# ============================================================================
|
| 296 |
+
# File Management Endpoints (JWT Auth)
|
| 297 |
+
# ============================================================================
|
| 298 |
+
|
| 299 |
+
@app.post("/projects/{project_id}/upload", response_model=FileUploadResponse)
|
| 300 |
+
async def upload_file_jwt(
|
| 301 |
+
project_id: uuid.UUID,
|
| 302 |
+
file: UploadFile = File(...),
|
| 303 |
+
current_user: User = Depends(get_current_user),
|
| 304 |
+
db: Session = Depends(get_db)
|
| 305 |
+
):
|
| 306 |
+
"""Upload file to project (JWT auth)"""
|
| 307 |
+
project = db.query(Project).filter(
|
| 308 |
+
Project.id == project_id,
|
| 309 |
+
Project.owner_id == current_user.id
|
| 310 |
+
).first()
|
| 311 |
+
|
| 312 |
+
if not project:
|
| 313 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 314 |
+
|
| 315 |
+
file_data = await file.read()
|
| 316 |
+
file_extension = os.path.splitext(file.filename)[1] if file.filename else ""
|
| 317 |
+
unique_filename = f"{uuid.uuid4().hex}{file_extension}"
|
| 318 |
+
|
| 319 |
+
if not minio_client.upload_file(
|
| 320 |
+
project.bucket_name,
|
| 321 |
+
file_data,
|
| 322 |
+
unique_filename,
|
| 323 |
+
file.content_type or "application/octet-stream"
|
| 324 |
+
):
|
| 325 |
+
raise HTTPException(status_code=500, detail="Failed to upload file")
|
| 326 |
+
|
| 327 |
+
file_record = FileRecord(
|
| 328 |
+
filename=unique_filename,
|
| 329 |
+
original_filename=file.filename or "unknown",
|
| 330 |
+
file_size=str(len(file_data)),
|
| 331 |
+
content_type=file.content_type,
|
| 332 |
+
project_id=project.id
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
db.add(file_record)
|
| 336 |
+
db.commit()
|
| 337 |
+
db.refresh(file_record)
|
| 338 |
+
|
| 339 |
+
base_url = os.getenv("PUBLIC_URL", "http://localhost:7860")
|
| 340 |
+
public_url = f"{base_url}/public/files/{file_record.id}"
|
| 341 |
+
|
| 342 |
+
return FileUploadResponse(
|
| 343 |
+
message="File uploaded successfully",
|
| 344 |
+
filename=unique_filename,
|
| 345 |
+
file_id=file_record.id,
|
| 346 |
+
public_url=public_url
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
@app.get("/projects/{project_id}/files", response_model=List[FileResponse])
|
| 350 |
+
async def list_files_jwt(
|
| 351 |
+
project_id: uuid.UUID,
|
| 352 |
+
current_user: User = Depends(get_current_user),
|
| 353 |
+
db: Session = Depends(get_db)
|
| 354 |
+
):
|
| 355 |
+
"""List files in project (JWT auth)"""
|
| 356 |
+
project = db.query(Project).filter(
|
| 357 |
+
Project.id == project_id,
|
| 358 |
+
Project.owner_id == current_user.id
|
| 359 |
+
).first()
|
| 360 |
+
|
| 361 |
+
if not project:
|
| 362 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 363 |
+
|
| 364 |
+
files = db.query(FileRecord).filter(FileRecord.project_id == project.id).all()
|
| 365 |
+
return files
|
| 366 |
+
|
| 367 |
+
@app.get("/projects/{project_id}/files/{file_id}/download")
|
| 368 |
+
async def download_file_jwt(
|
| 369 |
+
project_id: uuid.UUID,
|
| 370 |
+
file_id: uuid.UUID,
|
| 371 |
+
current_user: User = Depends(get_current_user),
|
| 372 |
+
db: Session = Depends(get_db)
|
| 373 |
+
):
|
| 374 |
+
"""Download file (JWT auth)"""
|
| 375 |
+
project = db.query(Project).filter(
|
| 376 |
+
Project.id == project_id,
|
| 377 |
+
Project.owner_id == current_user.id
|
| 378 |
+
).first()
|
| 379 |
+
|
| 380 |
+
if not project:
|
| 381 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 382 |
+
|
| 383 |
+
file_record = db.query(FileRecord).filter(
|
| 384 |
+
FileRecord.id == file_id,
|
| 385 |
+
FileRecord.project_id == project.id
|
| 386 |
+
).first()
|
| 387 |
+
|
| 388 |
+
if not file_record:
|
| 389 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 390 |
+
|
| 391 |
+
file_data = minio_client.download_file(project.bucket_name, file_record.filename)
|
| 392 |
+
if file_data is None:
|
| 393 |
+
raise HTTPException(status_code=404, detail="File not found in storage")
|
| 394 |
+
|
| 395 |
+
return StreamingResponse(
|
| 396 |
+
io.BytesIO(file_data),
|
| 397 |
+
media_type=file_record.content_type or "application/octet-stream",
|
| 398 |
+
headers={"Content-Disposition": f"attachment; filename={file_record.original_filename}"}
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
@app.delete("/projects/{project_id}/files/{file_id}")
|
| 402 |
+
async def delete_file_jwt(
|
| 403 |
+
project_id: uuid.UUID,
|
| 404 |
+
file_id: uuid.UUID,
|
| 405 |
+
current_user: User = Depends(get_current_user),
|
| 406 |
+
db: Session = Depends(get_db)
|
| 407 |
+
):
|
| 408 |
+
"""Delete file (JWT auth)"""
|
| 409 |
+
project = db.query(Project).filter(
|
| 410 |
+
Project.id == project_id,
|
| 411 |
+
Project.owner_id == current_user.id
|
| 412 |
+
).first()
|
| 413 |
+
|
| 414 |
+
if not project:
|
| 415 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 416 |
+
|
| 417 |
+
file_record = db.query(FileRecord).filter(
|
| 418 |
+
FileRecord.id == file_id,
|
| 419 |
+
FileRecord.project_id == project.id
|
| 420 |
+
).first()
|
| 421 |
+
|
| 422 |
+
if not file_record:
|
| 423 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 424 |
+
|
| 425 |
+
if not minio_client.delete_file(project.bucket_name, file_record.filename):
|
| 426 |
+
raise HTTPException(status_code=500, detail="Failed to delete file from storage")
|
| 427 |
+
|
| 428 |
+
db.delete(file_record)
|
| 429 |
+
db.commit()
|
| 430 |
+
|
| 431 |
+
return {"message": "File deleted successfully"}
|
| 432 |
+
|
| 433 |
+
# ============================================================================
|
| 434 |
+
# Public API Endpoints (API Key Auth)
|
| 435 |
+
# ============================================================================
|
| 436 |
+
|
| 437 |
+
@app.post("/api/upload", response_model=FileUploadResponse)
|
| 438 |
+
async def api_upload_file(
|
| 439 |
+
file: UploadFile = File(...),
|
| 440 |
+
x_api_key: str = Header(...),
|
| 441 |
+
db: Session = Depends(get_db)
|
| 442 |
+
):
|
| 443 |
+
"""Upload file using API key (requires 'write' permission)"""
|
| 444 |
+
project, permissions, api_key = await verify_api_key(x_api_key, db)
|
| 445 |
+
check_permission(permissions, "write")
|
| 446 |
+
|
| 447 |
+
file_data = await file.read()
|
| 448 |
+
file_extension = os.path.splitext(file.filename)[1] if file.filename else ""
|
| 449 |
+
unique_filename = f"{uuid.uuid4().hex}{file_extension}"
|
| 450 |
+
|
| 451 |
+
if not minio_client.upload_file(
|
| 452 |
+
project.bucket_name,
|
| 453 |
+
file_data,
|
| 454 |
+
unique_filename,
|
| 455 |
+
file.content_type or "application/octet-stream"
|
| 456 |
+
):
|
| 457 |
+
raise HTTPException(status_code=500, detail="Failed to upload file")
|
| 458 |
+
|
| 459 |
+
file_record = FileRecord(
|
| 460 |
+
filename=unique_filename,
|
| 461 |
+
original_filename=file.filename or "unknown",
|
| 462 |
+
file_size=str(len(file_data)),
|
| 463 |
+
content_type=file.content_type,
|
| 464 |
+
project_id=project.id
|
| 465 |
+
)
|
| 466 |
+
|
| 467 |
+
db.add(file_record)
|
| 468 |
+
db.commit()
|
| 469 |
+
db.refresh(file_record)
|
| 470 |
+
|
| 471 |
+
base_url = os.getenv("PUBLIC_URL", "http://localhost:7860")
|
| 472 |
+
public_url = f"{base_url}/public/files/{file_record.id}"
|
| 473 |
+
|
| 474 |
+
return FileUploadResponse(
|
| 475 |
+
message="File uploaded successfully",
|
| 476 |
+
filename=unique_filename,
|
| 477 |
+
file_id=file_record.id,
|
| 478 |
+
public_url=public_url
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
@app.get("/api/files", response_model=List[FileResponse])
|
| 482 |
+
async def api_list_files(
|
| 483 |
+
x_api_key: str = Header(...),
|
| 484 |
+
db: Session = Depends(get_db)
|
| 485 |
+
):
|
| 486 |
+
"""List files using API key (requires 'list' or 'read' permission)"""
|
| 487 |
+
project, permissions, api_key = await verify_api_key(x_api_key, db)
|
| 488 |
+
|
| 489 |
+
if "list" not in permissions and "read" not in permissions:
|
| 490 |
+
raise HTTPException(
|
| 491 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 492 |
+
detail="Permission 'list' or 'read' is required"
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
files = db.query(FileRecord).filter(FileRecord.project_id == project.id).all()
|
| 496 |
+
return files
|
| 497 |
+
|
| 498 |
+
@app.get("/api/files/{file_id}", response_model=FileResponse)
|
| 499 |
+
async def api_get_file(
|
| 500 |
+
file_id: uuid.UUID,
|
| 501 |
+
x_api_key: str = Header(...),
|
| 502 |
+
db: Session = Depends(get_db)
|
| 503 |
+
):
|
| 504 |
+
"""Get file metadata using API key (requires 'read' permission)"""
|
| 505 |
+
project, permissions, api_key = await verify_api_key(x_api_key, db)
|
| 506 |
+
check_permission(permissions, "read")
|
| 507 |
+
|
| 508 |
+
file_record = db.query(FileRecord).filter(
|
| 509 |
+
FileRecord.id == file_id,
|
| 510 |
+
FileRecord.project_id == project.id
|
| 511 |
+
).first()
|
| 512 |
+
|
| 513 |
+
if not file_record:
|
| 514 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 515 |
+
|
| 516 |
+
return file_record
|
| 517 |
+
|
| 518 |
+
@app.get("/api/files/{file_id}/download")
|
| 519 |
+
async def api_download_file(
|
| 520 |
+
file_id: uuid.UUID,
|
| 521 |
+
x_api_key: str = Header(...),
|
| 522 |
+
db: Session = Depends(get_db)
|
| 523 |
+
):
|
| 524 |
+
"""Download file using API key (requires 'download' permission)"""
|
| 525 |
+
project, permissions, api_key = await verify_api_key(x_api_key, db)
|
| 526 |
+
check_permission(permissions, "download")
|
| 527 |
+
|
| 528 |
+
file_record = db.query(FileRecord).filter(
|
| 529 |
+
FileRecord.id == file_id,
|
| 530 |
+
FileRecord.project_id == project.id
|
| 531 |
+
).first()
|
| 532 |
+
|
| 533 |
+
if not file_record:
|
| 534 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 535 |
+
|
| 536 |
+
file_data = minio_client.download_file(project.bucket_name, file_record.filename)
|
| 537 |
+
|
| 538 |
+
if file_data is None:
|
| 539 |
+
raise HTTPException(status_code=404, detail="File not found in storage")
|
| 540 |
+
|
| 541 |
+
return StreamingResponse(
|
| 542 |
+
io.BytesIO(file_data),
|
| 543 |
+
media_type=file_record.content_type or "application/octet-stream",
|
| 544 |
+
headers={
|
| 545 |
+
"Content-Disposition": f"attachment; filename={file_record.original_filename}"
|
| 546 |
+
}
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
@app.get("/api/files/{file_id}/public-url")
|
| 550 |
+
async def get_public_url(
|
| 551 |
+
file_id: uuid.UUID,
|
| 552 |
+
x_api_key: str = Header(...),
|
| 553 |
+
db: Session = Depends(get_db)
|
| 554 |
+
):
|
| 555 |
+
"""Get public URL for file (requires 'public' permission)"""
|
| 556 |
+
project, permissions, api_key = await verify_api_key(x_api_key, db)
|
| 557 |
+
check_permission(permissions, "public")
|
| 558 |
+
|
| 559 |
+
file_record = db.query(FileRecord).filter(
|
| 560 |
+
FileRecord.id == file_id,
|
| 561 |
+
FileRecord.project_id == project.id
|
| 562 |
+
).first()
|
| 563 |
+
|
| 564 |
+
if not file_record:
|
| 565 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 566 |
+
|
| 567 |
+
base_url = os.getenv("PUBLIC_URL", "http://localhost:7860")
|
| 568 |
+
|
| 569 |
+
return {
|
| 570 |
+
"file_id": file_record.id,
|
| 571 |
+
"public_url": f"{base_url}/public/files/{file_record.id}",
|
| 572 |
+
"download_url": f"{base_url}/public/download/{file_record.id}",
|
| 573 |
+
"filename": file_record.original_filename
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
@app.delete("/api/files/{file_id}")
|
| 577 |
+
async def api_delete_file(
|
| 578 |
+
file_id: uuid.UUID,
|
| 579 |
+
x_api_key: str = Header(...),
|
| 580 |
+
db: Session = Depends(get_db)
|
| 581 |
+
):
|
| 582 |
+
"""Delete file using API key (requires 'delete' permission)"""
|
| 583 |
+
project, permissions, api_key = await verify_api_key(x_api_key, db)
|
| 584 |
+
check_permission(permissions, "delete")
|
| 585 |
+
|
| 586 |
+
file_record = db.query(FileRecord).filter(
|
| 587 |
+
FileRecord.id == file_id,
|
| 588 |
+
FileRecord.project_id == project.id
|
| 589 |
+
).first()
|
| 590 |
+
|
| 591 |
+
if not file_record:
|
| 592 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 593 |
+
|
| 594 |
+
if not minio_client.delete_file(project.bucket_name, file_record.filename):
|
| 595 |
+
raise HTTPException(status_code=500, detail="Failed to delete file")
|
| 596 |
+
|
| 597 |
+
db.delete(file_record)
|
| 598 |
+
db.commit()
|
| 599 |
+
|
| 600 |
+
return {"message": "File deleted successfully"}
|
| 601 |
+
|
| 602 |
+
# ============================================================================
|
| 603 |
+
# Public Access Endpoints (No Authentication)
|
| 604 |
+
# ============================================================================
|
| 605 |
+
|
| 606 |
+
@app.get("/public/files/{file_id}")
|
| 607 |
+
async def view_public_file(
|
| 608 |
+
file_id: uuid.UUID,
|
| 609 |
+
db: Session = Depends(get_db)
|
| 610 |
+
):
|
| 611 |
+
"""View file publicly (inline, no auth required)"""
|
| 612 |
+
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
| 613 |
+
|
| 614 |
+
if not file_record:
|
| 615 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 616 |
+
|
| 617 |
+
project = db.query(Project).filter(Project.id == file_record.project_id).first()
|
| 618 |
+
|
| 619 |
+
if not project or not project.is_active:
|
| 620 |
+
raise HTTPException(status_code=404, detail="File not available")
|
| 621 |
+
|
| 622 |
+
file_data = minio_client.download_file(project.bucket_name, file_record.filename)
|
| 623 |
+
|
| 624 |
+
if file_data is None:
|
| 625 |
+
raise HTTPException(status_code=404, detail="File not found in storage")
|
| 626 |
+
|
| 627 |
+
return StreamingResponse(
|
| 628 |
+
io.BytesIO(file_data),
|
| 629 |
+
media_type=file_record.content_type or "application/octet-stream",
|
| 630 |
+
headers={
|
| 631 |
+
"Content-Disposition": f"inline; filename={file_record.original_filename}",
|
| 632 |
+
"Cache-Control": "public, max-age=31536000",
|
| 633 |
+
"Access-Control-Allow-Origin": "*"
|
| 634 |
+
}
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
+
@app.get("/public/download/{file_id}")
|
| 638 |
+
async def download_public_file(
|
| 639 |
+
file_id: uuid.UUID,
|
| 640 |
+
db: Session = Depends(get_db)
|
| 641 |
+
):
|
| 642 |
+
"""Download file publicly (no auth required)"""
|
| 643 |
+
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
| 644 |
+
|
| 645 |
+
if not file_record:
|
| 646 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 647 |
+
|
| 648 |
+
project = db.query(Project).filter(Project.id == file_record.project_id).first()
|
| 649 |
+
|
| 650 |
+
if not project or not project.is_active:
|
| 651 |
+
raise HTTPException(status_code=404, detail="File not available")
|
| 652 |
+
|
| 653 |
+
file_data = minio_client.download_file(project.bucket_name, file_record.filename)
|
| 654 |
+
|
| 655 |
+
if file_data is None:
|
| 656 |
+
raise HTTPException(status_code=404, detail="File not found in storage")
|
| 657 |
+
|
| 658 |
+
return StreamingResponse(
|
| 659 |
+
io.BytesIO(file_data),
|
| 660 |
+
media_type=file_record.content_type or "application/octet-stream",
|
| 661 |
+
headers={
|
| 662 |
+
"Content-Disposition": f"attachment; filename={file_record.original_filename}",
|
| 663 |
+
"Access-Control-Allow-Origin": "*"
|
| 664 |
+
}
|
| 665 |
+
)
|
| 666 |
+
|
| 667 |
+
# ============================================================================
|
| 668 |
+
# Utility Endpoints
|
| 669 |
+
# ============================================================================
|
| 670 |
+
|
| 671 |
+
@app.get("/api-key-permissions")
|
| 672 |
+
async def get_available_permissions():
|
| 673 |
+
"""Get list of available API key permissions"""
|
| 674 |
+
return {
|
| 675 |
+
"permissions": {
|
| 676 |
+
"read": "View file metadata and information",
|
| 677 |
+
"write": "Upload new files",
|
| 678 |
+
"delete": "Delete existing files",
|
| 679 |
+
"list": "List all files in the project",
|
| 680 |
+
"public": "Generate public shareable URLs",
|
| 681 |
+
"download": "Download files"
|
| 682 |
+
},
|
| 683 |
+
"presets": {
|
| 684 |
+
"read_only": "read,list,download",
|
| 685 |
+
"write_only": "write",
|
| 686 |
+
"full_access": "read,write,delete,list,public,download"
|
| 687 |
+
}
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
if __name__ == "__main__":
|
| 691 |
+
import uvicorn
|
| 692 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
app/minio_client.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from minio import Minio
|
| 2 |
+
from minio.error import S3Error
|
| 3 |
+
import os
|
| 4 |
+
import io
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
class MinIOClient:
|
| 11 |
+
"""MinIO client wrapper"""
|
| 12 |
+
|
| 13 |
+
def __init__(self):
|
| 14 |
+
self.endpoint = os.getenv("MINIO_ENDPOINT", "localhost:9000")
|
| 15 |
+
self.access_key = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
|
| 16 |
+
self.secret_key = os.getenv("MINIO_SECRET_KEY", "minioadmin")
|
| 17 |
+
self.secure = os.getenv("MINIO_SECURE", "false").lower() == "true"
|
| 18 |
+
|
| 19 |
+
self.client = Minio(
|
| 20 |
+
self.endpoint,
|
| 21 |
+
access_key=self.access_key,
|
| 22 |
+
secret_key=self.secret_key,
|
| 23 |
+
secure=self.secure
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
def create_bucket(self, bucket_name: str) -> bool:
|
| 27 |
+
"""Create bucket in MinIO"""
|
| 28 |
+
try:
|
| 29 |
+
if not self.client.bucket_exists(bucket_name):
|
| 30 |
+
self.client.make_bucket(bucket_name)
|
| 31 |
+
print(f"✅ Created bucket: {bucket_name}")
|
| 32 |
+
return True
|
| 33 |
+
except S3Error as e:
|
| 34 |
+
print(f"❌ Error creating bucket {bucket_name}: {e}")
|
| 35 |
+
return False
|
| 36 |
+
|
| 37 |
+
def upload_file(self, bucket_name: str, file_data: bytes, filename: str, content_type: str) -> bool:
|
| 38 |
+
"""Upload file to MinIO"""
|
| 39 |
+
try:
|
| 40 |
+
# Ensure bucket exists
|
| 41 |
+
if not self.client.bucket_exists(bucket_name):
|
| 42 |
+
self.client.make_bucket(bucket_name)
|
| 43 |
+
|
| 44 |
+
self.client.put_object(
|
| 45 |
+
bucket_name,
|
| 46 |
+
filename,
|
| 47 |
+
io.BytesIO(file_data),
|
| 48 |
+
length=len(file_data),
|
| 49 |
+
content_type=content_type
|
| 50 |
+
)
|
| 51 |
+
print(f"✅ Uploaded file: {filename} to bucket: {bucket_name}")
|
| 52 |
+
return True
|
| 53 |
+
except S3Error as e:
|
| 54 |
+
print(f"❌ Error uploading file {filename}: {e}")
|
| 55 |
+
return False
|
| 56 |
+
|
| 57 |
+
def download_file(self, bucket_name: str, filename: str) -> Optional[bytes]:
|
| 58 |
+
"""Download file from MinIO"""
|
| 59 |
+
try:
|
| 60 |
+
response = self.client.get_object(bucket_name, filename)
|
| 61 |
+
data = response.read()
|
| 62 |
+
response.close()
|
| 63 |
+
response.release_conn()
|
| 64 |
+
return data
|
| 65 |
+
except S3Error as e:
|
| 66 |
+
print(f"❌ Error downloading file {filename}: {e}")
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
def delete_file(self, bucket_name: str, filename: str) -> bool:
|
| 70 |
+
"""Delete file from MinIO"""
|
| 71 |
+
try:
|
| 72 |
+
self.client.remove_object(bucket_name, filename)
|
| 73 |
+
return True
|
| 74 |
+
except S3Error as e:
|
| 75 |
+
print(f"❌ Error deleting file {filename}: {e}")
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
def list_files(self, bucket_name: str):
|
| 79 |
+
"""List all files in bucket"""
|
| 80 |
+
try:
|
| 81 |
+
objects = self.client.list_objects(bucket_name)
|
| 82 |
+
return [obj.object_name for obj in objects]
|
| 83 |
+
except S3Error as e:
|
| 84 |
+
print(f"❌ Error listing files in bucket {bucket_name}: {e}")
|
| 85 |
+
return []
|
| 86 |
+
|
| 87 |
+
# Global MinIO client instance
|
| 88 |
+
minio_client = MinIOClient()
|
app/schemas.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import uuid
|
| 5 |
+
|
| 6 |
+
class UserBase(BaseModel):
|
| 7 |
+
username: str
|
| 8 |
+
email: EmailStr
|
| 9 |
+
|
| 10 |
+
class UserCreate(UserBase):
|
| 11 |
+
password: str
|
| 12 |
+
|
| 13 |
+
class UserResponse(UserBase):
|
| 14 |
+
id: uuid.UUID
|
| 15 |
+
is_active: bool
|
| 16 |
+
is_admin: bool
|
| 17 |
+
created_at: datetime
|
| 18 |
+
|
| 19 |
+
class Config:
|
| 20 |
+
from_attributes = True
|
| 21 |
+
|
| 22 |
+
class ProjectCreate(BaseModel):
|
| 23 |
+
name: str
|
| 24 |
+
description: Optional[str] = None
|
| 25 |
+
|
| 26 |
+
class ProjectResponse(BaseModel):
|
| 27 |
+
id: uuid.UUID
|
| 28 |
+
name: str
|
| 29 |
+
description: Optional[str]
|
| 30 |
+
bucket_name: str
|
| 31 |
+
access_key: str
|
| 32 |
+
secret_key: str
|
| 33 |
+
created_at: datetime
|
| 34 |
+
is_active: bool
|
| 35 |
+
|
| 36 |
+
class Config:
|
| 37 |
+
from_attributes = True
|
| 38 |
+
|
| 39 |
+
class APIKeyCreate(BaseModel):
|
| 40 |
+
name: str
|
| 41 |
+
permissions: Optional[str] = "read"
|
| 42 |
+
description: Optional[str] = None
|
| 43 |
+
|
| 44 |
+
class APIKeyResponse(BaseModel):
|
| 45 |
+
id: uuid.UUID
|
| 46 |
+
name: str
|
| 47 |
+
key: str
|
| 48 |
+
permissions: str
|
| 49 |
+
description: Optional[str]
|
| 50 |
+
is_active: bool
|
| 51 |
+
created_at: datetime
|
| 52 |
+
last_used_at: Optional[datetime]
|
| 53 |
+
|
| 54 |
+
class Config:
|
| 55 |
+
from_attributes = True
|
| 56 |
+
|
| 57 |
+
class FileResponse(BaseModel):
|
| 58 |
+
id: uuid.UUID
|
| 59 |
+
filename: str
|
| 60 |
+
original_filename: str
|
| 61 |
+
file_size: Optional[str]
|
| 62 |
+
content_type: Optional[str]
|
| 63 |
+
uploaded_at: datetime
|
| 64 |
+
|
| 65 |
+
class Config:
|
| 66 |
+
from_attributes = True
|
| 67 |
+
|
| 68 |
+
class LoginRequest(BaseModel):
|
| 69 |
+
username: str
|
| 70 |
+
password: str
|
| 71 |
+
|
| 72 |
+
class TokenResponse(BaseModel):
|
| 73 |
+
access_token: str
|
| 74 |
+
token_type: str
|
| 75 |
+
|
| 76 |
+
class FileUploadResponse(BaseModel):
|
| 77 |
+
message: str
|
| 78 |
+
filename: str
|
| 79 |
+
file_id: uuid.UUID
|
| 80 |
+
public_url: Optional[str] = None
|
requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.104.1
|
| 2 |
+
uvicorn[standard]==0.24.0
|
| 3 |
+
python-multipart==0.0.6
|
| 4 |
+
python-jose[cryptography]==3.3.0
|
| 5 |
+
passlib[bcrypt]==1.7.4
|
| 6 |
+
bcrypt==4.0.1
|
| 7 |
+
sqlalchemy==2.0.23
|
| 8 |
+
psycopg2-binary==2.9.9
|
| 9 |
+
alembic==1.13.0
|
| 10 |
+
minio==7.2.0
|
| 11 |
+
python-dotenv==1.0.0
|
| 12 |
+
pytest==7.4.3
|
| 13 |
+
httpx==0.25.2
|
| 14 |
+
email-validator==2.1.0
|
start.sh
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -e
|
| 3 |
+
|
| 4 |
+
echo "===== Application Startup at $(date '+%Y-%m-%d %H:%M:%S') ====="
|
| 5 |
+
|
| 6 |
+
# Start MinIO in background
|
| 7 |
+
echo "🚀 Starting MinIO server..."
|
| 8 |
+
mkdir -p /tmp/minio-data
|
| 9 |
+
MINIO_ROOT_USER=${MINIO_ACCESS_KEY} MINIO_ROOT_PASSWORD=${MINIO_SECRET_KEY} minio server /tmp/minio-data --console-address ":9001" > /tmp/minio.log 2>&1 &
|
| 10 |
+
|
| 11 |
+
# Wait for MinIO to be ready
|
| 12 |
+
echo "⏳ Waiting for MinIO to be ready..."
|
| 13 |
+
sleep 8
|
| 14 |
+
echo "✅ MinIO is ready!"
|
| 15 |
+
|
| 16 |
+
# Start FastAPI application
|
| 17 |
+
echo "🚀 Starting FastAPI application..."
|
| 18 |
+
exec uvicorn app.main:app --host 0.0.0.0 --port 7860
|