GodfreyOwino commited on
Commit
e379e4f
·
0 Parent(s):

Initial commit: Complete MinIO File Storage API

Browse files
Files changed (13) hide show
  1. .gitignore +32 -0
  2. Dockerfile +38 -0
  3. README.md +203 -0
  4. app.py +7 -0
  5. app/__init__.py +1 -0
  6. app/api_key_auth.py +63 -0
  7. app/auth.py +55 -0
  8. app/database.py +92 -0
  9. app/main.py +692 -0
  10. app/minio_client.py +88 -0
  11. app/schemas.py +80 -0
  12. requirements.txt +14 -0
  13. 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