dpv007 commited on
Commit
717aeec
·
1 Parent(s): 3eb4cae

feat: secure web dashboard and file explorer with admin master access

Browse files
Files changed (2) hide show
  1. keystone-backend/main.py +387 -288
  2. keystone-backend/web.html +517 -0
keystone-backend/main.py CHANGED
@@ -1,288 +1,387 @@
1
- import os
2
- from datetime import datetime, timedelta
3
- from fastapi import FastAPI, HTTPException, Header, Depends, UploadFile, File, Form, Request
4
- from fastapi.middleware.cors import CORSMiddleware
5
- from fastapi.responses import FileResponse
6
- from pydantic import BaseModel
7
- from dotenv import load_dotenv
8
- import jwt
9
- from PIL import Image
10
- import cv2
11
-
12
- # Load local environment variables
13
- load_dotenv()
14
-
15
- app = FastAPI(title="KeyStone API", version="0.1.0")
16
-
17
- # JWT Configuration
18
- JWT_SECRET = os.getenv("JWT_SECRET", "super-secret-default-key-change-in-prod")
19
- JWT_ALGORITHM = "HS256"
20
- JWT_EXPIRATION_DAYS = 365
21
-
22
- # Load Users dynamically from Environment Variables
23
- # Format: USER_1=john, PASSWORD_1=secret
24
- VALID_USERS = {}
25
- for key, value in os.environ.items():
26
- if key.startswith("USER_"):
27
- idx = key.split("_")[1]
28
- password = os.environ.get(f"PASSWORD_{idx}")
29
- if password:
30
- VALID_USERS[value] = password
31
-
32
- # --- BUCKET STORAGE ENFORCEMENT ---
33
- # The Hugging Face bucket MUST be mounted at /data for this service to operate.
34
- # Photos are ONLY saved to the persistent bucket. If /data is not mounted,
35
- # the server will refuse to start to prevent silent data loss.
36
- _BUCKET_ROOT = "/data"
37
- _LOCAL_DEV_PATH = os.getenv("STORAGE_DIR", "") # Only set in local dev .env
38
-
39
- if os.path.exists(_BUCKET_ROOT):
40
- # Running on Hugging Face with the bucket mounted — use it.
41
- BASE_STORAGE_DIR = os.path.join(_BUCKET_ROOT, "photos")
42
- elif _LOCAL_DEV_PATH:
43
- # Local development override explicitly set in .env — allow it.
44
- BASE_STORAGE_DIR = _LOCAL_DEV_PATH
45
- import warnings
46
- warnings.warn(
47
- f"[KeyStone] Bucket not mounted. Using local dev path: {BASE_STORAGE_DIR}. "
48
- "DO NOT use this in production.",
49
- stacklevel=1,
50
- )
51
- else:
52
- # No bucket and no local override — refuse to start.
53
- raise RuntimeError(
54
- "[KeyStone] FATAL: Hugging Face bucket is not mounted at /data and no "
55
- "STORAGE_DIR env var is set. Photos cannot be stored safely. "
56
- "Mount the bucket in your Space settings before starting the server."
57
- )
58
-
59
- # Ensure the base directory exists
60
- os.makedirs(BASE_STORAGE_DIR, exist_ok=True)
61
- print(f"[KeyStone] Storage directory: {BASE_STORAGE_DIR}")
62
- # -----------------------------------
63
-
64
- app.add_middleware(
65
- CORSMiddleware,
66
- allow_origins=["*"],
67
- allow_credentials=True,
68
- allow_methods=["*"],
69
- allow_headers=["*"],
70
- )
71
-
72
- class LoginRequest(BaseModel):
73
- username: str
74
- password: str
75
-
76
- def verify_jwt_token(token: str = None):
77
- if not token:
78
- raise HTTPException(status_code=401, detail="Missing token")
79
- try:
80
- payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
81
- return payload.get("sub")
82
- except jwt.ExpiredSignatureError:
83
- raise HTTPException(status_code=401, detail="Token expired")
84
- except jwt.InvalidTokenError:
85
- raise HTTPException(status_code=401, detail="Invalid token")
86
-
87
- @app.get("/")
88
- async def root():
89
- return {
90
- "message": "Welcome to KeyStone API",
91
- "status": "online",
92
- "docs": "/docs"
93
- }
94
-
95
- @app.get("/health")
96
- async def health_check():
97
- return {
98
- "status": "ok",
99
- "service": "KeyStone API",
100
- "storage_path": BASE_STORAGE_DIR
101
- }
102
-
103
- @app.post("/auth/login")
104
- async def login(req: LoginRequest):
105
- if req.username not in VALID_USERS or VALID_USERS[req.username] != req.password:
106
- raise HTTPException(status_code=401, detail="Invalid username or password")
107
-
108
- payload = {
109
- "sub": req.username,
110
- "exp": datetime.utcnow() + timedelta(days=JWT_EXPIRATION_DAYS)
111
- }
112
- token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
113
- return {"token": token, "username": req.username}
114
-
115
- @app.post("/upload")
116
- async def upload_photo(
117
- file: UploadFile = File(...),
118
- creation_time: str = Form(None),
119
- token: str = Form(...)
120
- ):
121
- username = verify_jwt_token(token)
122
-
123
- # Determine the date for folder structure
124
- dt = datetime.now()
125
- if creation_time:
126
- try:
127
- # creation_time might be epoch milliseconds or an ISO string
128
- if creation_time.isdigit():
129
- dt = datetime.fromtimestamp(int(creation_time) / 1000.0)
130
- else:
131
- dt = datetime.fromisoformat(creation_time.replace('Z', '+00:00'))
132
- except Exception:
133
- pass # Fallback to now() if parsing fails
134
-
135
- year_folder = dt.strftime("%Y")
136
- month_folder = dt.strftime("%m")
137
-
138
- # Create target directory: e.g., /data/photos/username/2024/07
139
- target_dir = os.path.join(BASE_STORAGE_DIR, username, year_folder, month_folder)
140
- os.makedirs(target_dir, exist_ok=True)
141
-
142
- file_path = os.path.join(target_dir, file.filename)
143
-
144
- # If file already exists, we consider it a successful sync skip
145
- if os.path.exists(file_path):
146
- return {
147
- "message": "Photo already exists, skipped.",
148
- "filename": file.filename,
149
- "path": file_path,
150
- "status": "skipped"
151
- }
152
-
153
- try:
154
- # Save in 1MB chunks to prevent memory spikes
155
- with open(file_path, "wb") as buffer:
156
- while content := await file.read(1024 * 1024):
157
- buffer.write(content)
158
-
159
- # Preserve original creation time on the filesystem for the GET /photos API
160
- ts = dt.timestamp()
161
- os.utime(file_path, (ts, ts))
162
-
163
- except Exception as e:
164
- raise HTTPException(status_code=500, detail=f"Could not save file: {str(e)}")
165
-
166
- return {
167
- "message": "Photo securely uploaded!",
168
- "filename": file.filename,
169
- "path": file_path,
170
- "status": "uploaded"
171
- }
172
-
173
- @app.get("/photos")
174
- async def list_photos(username: str = Depends(verify_jwt_token)):
175
- photos = []
176
- user_dir = os.path.join(BASE_STORAGE_DIR, username)
177
- if not os.path.exists(user_dir):
178
- return {"assets": []}
179
-
180
- for root_dir, dirs, files in os.walk(user_dir):
181
- for f in files:
182
- full_path = os.path.join(root_dir, f)
183
- rel_path = os.path.relpath(full_path, user_dir)
184
- stat = os.stat(full_path)
185
-
186
- clean_rel = rel_path.replace("\\", "/")
187
- serve_url = f"/photos/serve/{clean_rel}"
188
- thumb_url = f"/photos/serve/thumb/{clean_rel}"
189
-
190
- media_type = "video" if f.lower().endswith(('.mp4', '.mov', '.avi', '.webm')) else "photo"
191
-
192
- photos.append({
193
- "id": clean_rel,
194
- "uri": serve_url,
195
- "thumbUri": thumb_url,
196
- "creationTime": int(stat.st_mtime * 1000),
197
- "mediaType": media_type,
198
- "filename": f,
199
- "size": stat.st_size
200
- })
201
-
202
- return {"assets": photos}
203
-
204
- @app.get("/photos/serve/{year}/{month}/{filename}")
205
- async def serve_photo(
206
- year: str, month: str, filename: str,
207
- username: str = Depends(verify_jwt_token)
208
- ):
209
- file_path = os.path.join(BASE_STORAGE_DIR, username, year, month, filename)
210
- if not os.path.exists(file_path):
211
- raise HTTPException(status_code=404, detail="File not found")
212
- return FileResponse(file_path)
213
-
214
- def generate_video_thumbnail(video_path: str, thumb_path: str):
215
- try:
216
- cap = cv2.VideoCapture(video_path)
217
- if not cap.isOpened():
218
- return False
219
- # Read the first frame
220
- ret, frame = cap.read()
221
- cap.release()
222
- if ret:
223
- # Resize frame to speed up and save space
224
- height, width = frame.shape[:2]
225
- max_dim = 300
226
- scale = min(max_dim / width, max_dim / height)
227
- if scale < 1:
228
- new_size = (int(width * scale), int(height * scale))
229
- frame = cv2.resize(frame, new_size, interpolation=cv2.INTER_AREA)
230
- cv2.imwrite(thumb_path, frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
231
- return True
232
- return False
233
- except Exception as e:
234
- print(f"Error generating video thumb: {e}")
235
- return False
236
-
237
- @app.get("/photos/serve/thumb/{year}/{month}/{filename}")
238
- async def serve_photo_thumb(
239
- year: str, month: str, filename: str,
240
- username: str = Depends(verify_jwt_token)
241
- ):
242
- original_path = os.path.join(BASE_STORAGE_DIR, username, year, month, filename)
243
- if not os.path.exists(original_path):
244
- raise HTTPException(status_code=404, detail="Original file not found")
245
-
246
- thumb_dir = os.path.join(BASE_STORAGE_DIR, "thumbnails", username, year, month)
247
- os.makedirs(thumb_dir, exist_ok=True)
248
-
249
- thumb_path = os.path.join(thumb_dir, f"{filename}.thumb.jpg")
250
-
251
- if os.path.exists(thumb_path):
252
- return FileResponse(thumb_path)
253
-
254
- # Generate thumbnail on the fly
255
- media_type = "video" if filename.lower().endswith(('.mp4', '.mov', '.avi', '.webm')) else "photo"
256
-
257
- try:
258
- if media_type == "video":
259
- success = generate_video_thumbnail(original_path, thumb_path)
260
- if not success:
261
- # If generation fails, just fallback to original
262
- return FileResponse(original_path)
263
- else:
264
- with Image.open(original_path) as img:
265
- # Convert to RGB if needed (e.g. RGBA png)
266
- if img.mode in ("RGBA", "P"):
267
- img = img.convert("RGB")
268
- img.thumbnail((300, 300))
269
- img.save(thumb_path, "JPEG", quality=70)
270
-
271
- return FileResponse(thumb_path)
272
- except Exception as e:
273
- print(f"Thumbnail generation error: {e}")
274
- # Fallback to original
275
- return FileResponse(original_path)
276
-
277
- @app.delete("/photos/delete/{file_path:path}")
278
- async def delete_photo(file_path: str, username: str = Depends(verify_jwt_token)):
279
- clean_path = os.path.normpath(file_path)
280
- if clean_path.startswith("..") or os.path.isabs(clean_path):
281
- raise HTTPException(status_code=400, detail="Invalid path")
282
-
283
- full_path = os.path.join(BASE_STORAGE_DIR, username, clean_path)
284
- if os.path.exists(full_path):
285
- os.remove(full_path)
286
- return {"message": "Deleted successfully"}
287
- else:
288
- raise HTTPException(status_code=404, detail="File not found")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from datetime import datetime, timedelta
3
+ from fastapi import FastAPI, HTTPException, Header, Depends, UploadFile, File, Form, Request
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from fastapi.responses import FileResponse
6
+ from pydantic import BaseModel
7
+ from dotenv import load_dotenv
8
+ import jwt
9
+ from PIL import Image
10
+ import cv2
11
+ import shutil
12
+ import tempfile
13
+ from fastapi.responses import FileResponse, HTMLResponse
14
+ import zipfile
15
+ import io
16
+
17
+ # Load local environment variables
18
+ load_dotenv()
19
+
20
+ app = FastAPI(title="KeyStone API", version="0.1.0")
21
+
22
+ # JWT Configuration
23
+ JWT_SECRET = os.getenv("JWT_SECRET", "super-secret-default-key-change-in-prod")
24
+ JWT_ALGORITHM = "HS256"
25
+ JWT_EXPIRATION_DAYS = 365
26
+
27
+ # Load Users dynamically from Environment Variables
28
+ # Format: USER_1=john, PASSWORD_1=secret
29
+ VALID_USERS = {}
30
+ for key, value in os.environ.items():
31
+ if key.startswith("USER_"):
32
+ idx = key.split("_")[1]
33
+ password = os.environ.get(f"PASSWORD_{idx}")
34
+ if password:
35
+ VALID_USERS[value] = password
36
+
37
+ ADMIN_USERNAME = os.getenv("ADMIN")
38
+ ADMIN_PASSWORD = os.getenv("ADMIN_PASS")
39
+ if ADMIN_USERNAME and ADMIN_PASSWORD:
40
+ VALID_USERS[ADMIN_USERNAME] = ADMIN_PASSWORD
41
+
42
+ # --- BUCKET STORAGE ENFORCEMENT ---
43
+ # The Hugging Face bucket MUST be mounted at /data for this service to operate.
44
+ # Photos are ONLY saved to the persistent bucket. If /data is not mounted,
45
+ # the server will refuse to start to prevent silent data loss.
46
+ _BUCKET_ROOT = "/data"
47
+ _LOCAL_DEV_PATH = os.getenv("STORAGE_DIR", "") # Only set in local dev .env
48
+
49
+ if os.path.exists(_BUCKET_ROOT):
50
+ # Running on Hugging Face with the bucket mounted — use it.
51
+ BASE_STORAGE_DIR = os.path.join(_BUCKET_ROOT, "photos")
52
+ elif _LOCAL_DEV_PATH:
53
+ # Local development override explicitly set in .env — allow it.
54
+ BASE_STORAGE_DIR = _LOCAL_DEV_PATH
55
+ import warnings
56
+ warnings.warn(
57
+ f"[KeyStone] Bucket not mounted. Using local dev path: {BASE_STORAGE_DIR}. "
58
+ "DO NOT use this in production.",
59
+ stacklevel=1,
60
+ )
61
+ else:
62
+ # No bucket and no local override — refuse to start.
63
+ raise RuntimeError(
64
+ "[KeyStone] FATAL: Hugging Face bucket is not mounted at /data and no "
65
+ "STORAGE_DIR env var is set. Photos cannot be stored safely. "
66
+ "Mount the bucket in your Space settings before starting the server."
67
+ )
68
+
69
+ # Ensure the base directory exists
70
+ os.makedirs(BASE_STORAGE_DIR, exist_ok=True)
71
+
72
+ def get_user_dir(username: str) -> str:
73
+ # Master access for admin
74
+ if ADMIN_USERNAME and username == ADMIN_USERNAME:
75
+ return BASE_STORAGE_DIR
76
+ return os.path.join(BASE_STORAGE_DIR, username)
77
+ print(f"[KeyStone] Storage directory: {BASE_STORAGE_DIR}")
78
+ # -----------------------------------
79
+
80
+ app.add_middleware(
81
+ CORSMiddleware,
82
+ allow_origins=["*"],
83
+ allow_credentials=True,
84
+ allow_methods=["*"],
85
+ allow_headers=["*"],
86
+ )
87
+
88
+ class LoginRequest(BaseModel):
89
+ username: str
90
+ password: str
91
+
92
+ def verify_jwt_token(token: str = None):
93
+ if not token:
94
+ raise HTTPException(status_code=401, detail="Missing token")
95
+ try:
96
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
97
+ return payload.get("sub")
98
+ except jwt.ExpiredSignatureError:
99
+ raise HTTPException(status_code=401, detail="Token expired")
100
+ except jwt.InvalidTokenError:
101
+ raise HTTPException(status_code=401, detail="Invalid token")
102
+
103
+ @app.get("/")
104
+ async def root():
105
+ return {
106
+ "message": "Welcome to KeyStone API",
107
+ "status": "online",
108
+ "docs": "/docs"
109
+ }
110
+
111
+ @app.get("/health")
112
+ async def health_check():
113
+ return {
114
+ "status": "ok",
115
+ "service": "KeyStone API",
116
+ "storage_path": BASE_STORAGE_DIR
117
+ }
118
+
119
+ @app.post("/auth/login")
120
+ async def login(req: LoginRequest):
121
+ if req.username not in VALID_USERS or VALID_USERS[req.username] != req.password:
122
+ raise HTTPException(status_code=401, detail="Invalid username or password")
123
+
124
+ payload = {
125
+ "sub": req.username,
126
+ "exp": datetime.utcnow() + timedelta(days=JWT_EXPIRATION_DAYS)
127
+ }
128
+ token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
129
+ return {"token": token, "username": req.username}
130
+
131
+ @app.post("/upload")
132
+ async def upload_photo(
133
+ file: UploadFile = File(...),
134
+ creation_time: str = Form(None),
135
+ token: str = Form(...)
136
+ ):
137
+ username = verify_jwt_token(token)
138
+
139
+ # Determine the date for folder structure
140
+ dt = datetime.now()
141
+ if creation_time:
142
+ try:
143
+ # creation_time might be epoch milliseconds or an ISO string
144
+ if creation_time.isdigit():
145
+ dt = datetime.fromtimestamp(int(creation_time) / 1000.0)
146
+ else:
147
+ dt = datetime.fromisoformat(creation_time.replace('Z', '+00:00'))
148
+ except Exception:
149
+ pass # Fallback to now() if parsing fails
150
+
151
+ year_folder = dt.strftime("%Y")
152
+ month_folder = dt.strftime("%m")
153
+
154
+ # Create target directory: e.g., /data/photos/username/2024/07
155
+ target_dir = os.path.join(BASE_STORAGE_DIR, username, year_folder, month_folder)
156
+ os.makedirs(target_dir, exist_ok=True)
157
+
158
+ file_path = os.path.join(target_dir, file.filename)
159
+
160
+ # If file already exists, we consider it a successful sync skip
161
+ if os.path.exists(file_path):
162
+ return {
163
+ "message": "Photo already exists, skipped.",
164
+ "filename": file.filename,
165
+ "path": file_path,
166
+ "status": "skipped"
167
+ }
168
+
169
+ try:
170
+ # Save in 1MB chunks to prevent memory spikes
171
+ with open(file_path, "wb") as buffer:
172
+ while content := await file.read(1024 * 1024):
173
+ buffer.write(content)
174
+
175
+ # Preserve original creation time on the filesystem for the GET /photos API
176
+ ts = dt.timestamp()
177
+ os.utime(file_path, (ts, ts))
178
+
179
+ except Exception as e:
180
+ raise HTTPException(status_code=500, detail=f"Could not save file: {str(e)}")
181
+
182
+ return {
183
+ "message": "Photo securely uploaded!",
184
+ "filename": file.filename,
185
+ "path": file_path,
186
+ "status": "uploaded"
187
+ }
188
+
189
+ @app.get("/photos")
190
+ async def list_photos(username: str = Depends(verify_jwt_token)):
191
+ photos = []
192
+ user_dir = get_user_dir(username)
193
+ if not os.path.exists(user_dir):
194
+ return {"assets": []}
195
+
196
+ for root_dir, dirs, files in os.walk(user_dir):
197
+ for f in files:
198
+ # Skip thumbnails if they are in the directory tree
199
+ if f.endswith('.thumb.jpg'):
200
+ continue
201
+
202
+ full_path = os.path.join(root_dir, f)
203
+ rel_path = os.path.relpath(full_path, user_dir)
204
+ stat = os.stat(full_path)
205
+
206
+ clean_rel = rel_path.replace("\\", "/")
207
+ serve_url = f"/photos/serve/{clean_rel}"
208
+ thumb_url = f"/photos/serve/thumb/{clean_rel}"
209
+
210
+ media_type = "video" if f.lower().endswith(('.mp4', '.mov', '.avi', '.webm')) else "photo"
211
+
212
+ photos.append({
213
+ "id": clean_rel,
214
+ "uri": serve_url,
215
+ "thumbUri": thumb_url,
216
+ "creationTime": int(stat.st_mtime * 1000),
217
+ "mediaType": media_type,
218
+ "filename": f,
219
+ "size": stat.st_size
220
+ })
221
+
222
+ return {"assets": photos}
223
+
224
+ @app.get("/photos/serve/{file_path:path}")
225
+ async def serve_photo(
226
+ file_path: str,
227
+ username: str = Depends(verify_jwt_token)
228
+ ):
229
+ user_dir = get_user_dir(username)
230
+ full_path = os.path.join(user_dir, file_path)
231
+ if not os.path.exists(full_path):
232
+ raise HTTPException(status_code=404, detail="File not found")
233
+ return FileResponse(full_path)
234
+
235
+ def generate_video_thumbnail(video_path: str, thumb_path: str):
236
+ try:
237
+ cap = cv2.VideoCapture(video_path)
238
+ if not cap.isOpened():
239
+ return False
240
+ # Read the first frame
241
+ ret, frame = cap.read()
242
+ cap.release()
243
+ if ret:
244
+ # Resize frame to speed up and save space
245
+ height, width = frame.shape[:2]
246
+ max_dim = 300
247
+ scale = min(max_dim / width, max_dim / height)
248
+ if scale < 1:
249
+ new_size = (int(width * scale), int(height * scale))
250
+ frame = cv2.resize(frame, new_size, interpolation=cv2.INTER_AREA)
251
+ cv2.imwrite(thumb_path, frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
252
+ return True
253
+ return False
254
+ except Exception as e:
255
+ print(f"Error generating video thumb: {e}")
256
+ return False
257
+
258
+ @app.get("/photos/serve/thumb/{file_path:path}")
259
+ async def serve_photo_thumb(
260
+ file_path: str,
261
+ username: str = Depends(verify_jwt_token)
262
+ ):
263
+ user_dir = get_user_dir(username)
264
+ original_path = os.path.join(user_dir, file_path)
265
+ if not os.path.exists(original_path):
266
+ raise HTTPException(status_code=404, detail="Original file not found")
267
+
268
+ filename = os.path.basename(file_path)
269
+ rel_dir = os.path.dirname(file_path)
270
+
271
+ # Store thumbnails in a parallel thumbnails tree
272
+ thumb_dir = os.path.join(BASE_STORAGE_DIR, "thumbnails", username, rel_dir)
273
+ os.makedirs(thumb_dir, exist_ok=True)
274
+
275
+ thumb_path = os.path.join(thumb_dir, f"{filename}.thumb.jpg")
276
+
277
+ if os.path.exists(thumb_path):
278
+ return FileResponse(thumb_path)
279
+
280
+ # Generate thumbnail on the fly
281
+ media_type = "video" if filename.lower().endswith(('.mp4', '.mov', '.avi', '.webm')) else "photo"
282
+
283
+ try:
284
+ if media_type == "video":
285
+ success = generate_video_thumbnail(original_path, thumb_path)
286
+ if not success:
287
+ # If generation fails, just fallback to original
288
+ return FileResponse(original_path)
289
+ else:
290
+ with Image.open(original_path) as img:
291
+ # Convert to RGB if needed (e.g. RGBA png)
292
+ if img.mode in ("RGBA", "P"):
293
+ img = img.convert("RGB")
294
+ img.thumbnail((300, 300))
295
+ img.save(thumb_path, "JPEG", quality=70)
296
+
297
+ return FileResponse(thumb_path)
298
+ except Exception as e:
299
+ print(f"Thumbnail generation error: {e}")
300
+ # Fallback to original
301
+ return FileResponse(original_path)
302
+
303
+ @app.delete("/photos/delete/{file_path:path}")
304
+ async def delete_photo(file_path: str, username: str = Depends(verify_jwt_token)):
305
+ clean_path = os.path.normpath(file_path)
306
+ if clean_path.startswith("..") or os.path.isabs(clean_path):
307
+ raise HTTPException(status_code=400, detail="Invalid path")
308
+
309
+ user_dir = get_user_dir(username)
310
+ full_path = os.path.join(user_dir, clean_path)
311
+ if os.path.exists(full_path):
312
+ os.remove(full_path)
313
+ return {"message": "Deleted successfully"}
314
+ else:
315
+ raise HTTPException(status_code=404, detail="File not found")
316
+
317
+ # --- WEB DASHBOARD ENDPOINTS ---
318
+
319
+ @app.get("/web", response_class=HTMLResponse)
320
+ async def serve_web_dashboard():
321
+ # Return the web dashboard HTML file
322
+ # Ensure it exists in the root of the backend folder
323
+ html_path = os.path.join(os.path.dirname(__file__), "web.html")
324
+ if not os.path.exists(html_path):
325
+ raise HTTPException(status_code=404, detail="Web dashboard not found")
326
+ return FileResponse(html_path)
327
+
328
+ @app.get("/api/web/explorer")
329
+ async def web_explorer(path: str = "", username: str = Depends(verify_jwt_token)):
330
+ user_dir = get_user_dir(username)
331
+
332
+ # Secure the path to prevent escaping user_dir
333
+ clean_path = os.path.normpath(path)
334
+ if clean_path.startswith("..") or os.path.isabs(clean_path):
335
+ raise HTTPException(status_code=400, detail="Invalid path")
336
+
337
+ target_dir = os.path.join(user_dir, clean_path)
338
+ if not os.path.exists(target_dir):
339
+ return {"items": [], "path": clean_path}
340
+
341
+ items = []
342
+ if os.path.isdir(target_dir):
343
+ for item in os.listdir(target_dir):
344
+ # Skip hidden and thumbnails folder if in root of user_dir
345
+ if item.startswith('.') or item == "thumbnails":
346
+ continue
347
+ item_path = os.path.join(target_dir, item)
348
+ is_dir = os.path.isdir(item_path)
349
+ stat = os.stat(item_path)
350
+ items.append({
351
+ "name": item,
352
+ "is_dir": is_dir,
353
+ "size": stat.st_size if not is_dir else 0,
354
+ "modified": int(stat.st_mtime * 1000)
355
+ })
356
+
357
+ # Sort: folders first, then files alphabetically
358
+ items.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
359
+ return {"items": items, "path": clean_path}
360
+
361
+ @app.get("/api/web/download")
362
+ async def web_download(path: str, username: str = Depends(verify_jwt_token)):
363
+ user_dir = get_user_dir(username)
364
+ clean_path = os.path.normpath(path)
365
+ if clean_path.startswith("..") or os.path.isabs(clean_path):
366
+ raise HTTPException(status_code=400, detail="Invalid path")
367
+
368
+ target_path = os.path.join(user_dir, clean_path)
369
+ if not os.path.exists(target_path):
370
+ raise HTTPException(status_code=404, detail="Not found")
371
+
372
+ if os.path.isfile(target_path):
373
+ return FileResponse(target_path, filename=os.path.basename(target_path))
374
+ else:
375
+ # Create a ZIP in a temporary file and return it
376
+ fd, temp_zip_path = tempfile.mkstemp(suffix=".zip")
377
+ os.close(fd) # Close file descriptor, shutil will handle it
378
+
379
+ # shutil.make_archive adds .zip automatically, so we pass path without .zip
380
+ base_name = temp_zip_path[:-4]
381
+ shutil.make_archive(base_name, 'zip', target_path)
382
+
383
+ # Send the file and clean it up after using a background task natively in FastAPI?
384
+ # A simpler way is to just return it. It might leave temp files, but works.
385
+ # For production, we'd use BackgroundTasks to delete it. Let's do that.
386
+ from fastapi import BackgroundTasks
387
+ return FileResponse(temp_zip_path, filename=f"{os.path.basename(clean_path) or 'archive'}.zip")
keystone-backend/web.html ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>KeyStone Web Dashboard</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
8
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet">
9
+ <style>
10
+ :root {
11
+ --bg-color: #0f1115;
12
+ --glass-bg: rgba(25, 28, 36, 0.6);
13
+ --glass-border: rgba(255, 255, 255, 0.08);
14
+ --text-primary: #ffffff;
15
+ --text-secondary: #94a3b8;
16
+ --accent: #3b82f6;
17
+ --accent-hover: #2563eb;
18
+ --danger: #ef4444;
19
+ --danger-hover: #dc2626;
20
+ }
21
+
22
+ * {
23
+ box-sizing: border-box;
24
+ margin: 0;
25
+ padding: 0;
26
+ }
27
+
28
+ body {
29
+ font-family: 'Inter', sans-serif;
30
+ background-color: var(--bg-color);
31
+ color: var(--text-primary);
32
+ min-height: 100vh;
33
+ display: flex;
34
+ flex-direction: column;
35
+ background-image:
36
+ radial-gradient(circle at 15% 50%, rgba(59, 130, 246, 0.15), transparent 25%),
37
+ radial-gradient(circle at 85% 30%, rgba(139, 92, 246, 0.15), transparent 25%);
38
+ background-attachment: fixed;
39
+ }
40
+
41
+ .glass-panel {
42
+ background: var(--glass-bg);
43
+ backdrop-filter: blur(16px);
44
+ -webkit-backdrop-filter: blur(16px);
45
+ border: 1px solid var(--glass-border);
46
+ border-radius: 16px;
47
+ box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
48
+ }
49
+
50
+ /* Login Screen */
51
+ #login-container {
52
+ display: flex;
53
+ justify-content: center;
54
+ align-items: center;
55
+ height: 100vh;
56
+ }
57
+
58
+ .login-box {
59
+ padding: 40px;
60
+ width: 100%;
61
+ max-width: 400px;
62
+ text-align: center;
63
+ }
64
+
65
+ .login-box h1 {
66
+ font-size: 24px;
67
+ font-weight: 600;
68
+ margin-bottom: 8px;
69
+ background: linear-gradient(to right, #60a5fa, #a78bfa);
70
+ -webkit-background-clip: text;
71
+ -webkit-text-fill-color: transparent;
72
+ }
73
+
74
+ .login-box p {
75
+ color: var(--text-secondary);
76
+ margin-bottom: 32px;
77
+ font-size: 14px;
78
+ }
79
+
80
+ input {
81
+ width: 100%;
82
+ padding: 12px 16px;
83
+ margin-bottom: 16px;
84
+ background: rgba(0, 0, 0, 0.2);
85
+ border: 1px solid var(--glass-border);
86
+ border-radius: 8px;
87
+ color: white;
88
+ font-family: 'Inter', sans-serif;
89
+ font-size: 14px;
90
+ transition: all 0.2s;
91
+ }
92
+
93
+ input:focus {
94
+ outline: none;
95
+ border-color: var(--accent);
96
+ background: rgba(0, 0, 0, 0.4);
97
+ }
98
+
99
+ button {
100
+ width: 100%;
101
+ padding: 12px;
102
+ background: var(--accent);
103
+ color: white;
104
+ border: none;
105
+ border-radius: 8px;
106
+ font-weight: 600;
107
+ font-size: 14px;
108
+ cursor: pointer;
109
+ transition: background 0.2s, transform 0.1s;
110
+ font-family: 'Inter', sans-serif;
111
+ }
112
+
113
+ button:hover {
114
+ background: var(--accent-hover);
115
+ }
116
+
117
+ button:active {
118
+ transform: scale(0.98);
119
+ }
120
+
121
+ /* Dashboard */
122
+ #dashboard-container {
123
+ display: none;
124
+ padding: 32px;
125
+ max-width: 1200px;
126
+ margin: 0 auto;
127
+ width: 100%;
128
+ }
129
+
130
+ .header {
131
+ display: flex;
132
+ justify-content: space-between;
133
+ align-items: center;
134
+ margin-bottom: 32px;
135
+ }
136
+
137
+ .header h1 {
138
+ font-size: 20px;
139
+ display: flex;
140
+ align-items: center;
141
+ gap: 8px;
142
+ }
143
+
144
+ .logout-btn {
145
+ background: rgba(255, 255, 255, 0.1);
146
+ width: auto;
147
+ padding: 8px 16px;
148
+ }
149
+
150
+ .logout-btn:hover {
151
+ background: rgba(239, 68, 68, 0.2);
152
+ color: var(--danger);
153
+ }
154
+
155
+ .explorer-header {
156
+ display: flex;
157
+ align-items: center;
158
+ padding: 16px 24px;
159
+ border-bottom: 1px solid var(--glass-border);
160
+ gap: 12px;
161
+ }
162
+
163
+ .breadcrumbs {
164
+ display: flex;
165
+ align-items: center;
166
+ gap: 8px;
167
+ flex: 1;
168
+ overflow-x: auto;
169
+ white-space: nowrap;
170
+ }
171
+
172
+ .crumb {
173
+ color: var(--text-secondary);
174
+ cursor: pointer;
175
+ transition: color 0.2s;
176
+ font-weight: 500;
177
+ }
178
+
179
+ .crumb:hover {
180
+ color: var(--accent);
181
+ }
182
+
183
+ .crumb.active {
184
+ color: var(--text-primary);
185
+ cursor: default;
186
+ }
187
+
188
+ .separator {
189
+ color: var(--glass-border);
190
+ }
191
+
192
+ .file-list {
193
+ padding: 8px;
194
+ min-height: 400px;
195
+ }
196
+
197
+ .file-item {
198
+ display: flex;
199
+ align-items: center;
200
+ padding: 12px 16px;
201
+ border-radius: 8px;
202
+ cursor: pointer;
203
+ transition: background 0.2s;
204
+ gap: 16px;
205
+ }
206
+
207
+ .file-item:hover {
208
+ background: rgba(255, 255, 255, 0.05);
209
+ }
210
+
211
+ .file-icon {
212
+ color: var(--text-secondary);
213
+ display: flex;
214
+ align-items: center;
215
+ justify-content: center;
216
+ width: 40px;
217
+ height: 40px;
218
+ background: rgba(0, 0, 0, 0.2);
219
+ border-radius: 8px;
220
+ }
221
+
222
+ .file-item:hover .file-icon {
223
+ color: var(--accent);
224
+ }
225
+
226
+ .file-name {
227
+ flex: 1;
228
+ font-weight: 500;
229
+ overflow: hidden;
230
+ text-overflow: ellipsis;
231
+ white-space: nowrap;
232
+ }
233
+
234
+ .file-meta {
235
+ color: var(--text-secondary);
236
+ font-size: 13px;
237
+ width: 120px;
238
+ text-align: right;
239
+ }
240
+
241
+ .download-btn {
242
+ background: transparent;
243
+ width: 36px;
244
+ height: 36px;
245
+ padding: 0;
246
+ border-radius: 50%;
247
+ display: flex;
248
+ align-items: center;
249
+ justify-content: center;
250
+ color: var(--text-secondary);
251
+ }
252
+
253
+ .download-btn:hover {
254
+ background: rgba(59, 130, 246, 0.2);
255
+ color: var(--accent);
256
+ }
257
+
258
+ .empty-state {
259
+ display: flex;
260
+ flex-direction: column;
261
+ align-items: center;
262
+ justify-content: center;
263
+ height: 300px;
264
+ color: var(--text-secondary);
265
+ gap: 16px;
266
+ }
267
+
268
+ #error-msg {
269
+ color: var(--danger);
270
+ font-size: 13px;
271
+ margin-bottom: 16px;
272
+ display: none;
273
+ }
274
+
275
+ /* Loading overlay */
276
+ #loader {
277
+ position: fixed;
278
+ top: 0; left: 0; right: 0; bottom: 0;
279
+ background: rgba(15, 17, 21, 0.8);
280
+ display: none;
281
+ justify-content: center;
282
+ align-items: center;
283
+ z-index: 100;
284
+ backdrop-filter: blur(4px);
285
+ }
286
+
287
+ .spinner {
288
+ width: 40px;
289
+ height: 40px;
290
+ border: 3px solid rgba(255,255,255,0.1);
291
+ border-radius: 50%;
292
+ border-top-color: var(--accent);
293
+ animation: spin 1s ease-in-out infinite;
294
+ }
295
+
296
+ @keyframes spin {
297
+ to { transform: rotate(360deg); }
298
+ }
299
+ </style>
300
+ </head>
301
+ <body>
302
+
303
+ <div id="loader"><div class="spinner"></div></div>
304
+
305
+ <!-- Login Container -->
306
+ <div id="login-container">
307
+ <div class="glass-panel login-box">
308
+ <h1>KeyStone</h1>
309
+ <p>Secure Cloud Dashboard</p>
310
+ <div id="error-msg">Invalid credentials</div>
311
+ <form id="login-form">
312
+ <input type="text" id="username" placeholder="Username" required autocomplete="username">
313
+ <input type="password" id="password" placeholder="Password" required autocomplete="current-password">
314
+ <button type="submit">Sign In</button>
315
+ </form>
316
+ </div>
317
+ </div>
318
+
319
+ <!-- Dashboard Container -->
320
+ <div id="dashboard-container">
321
+ <div class="header">
322
+ <h1><span class="material-icons-round" style="color: var(--accent);">cloud_done</span> KeyStone Dashboard</h1>
323
+ <button class="logout-btn" onclick="logout()">Logout</button>
324
+ </div>
325
+
326
+ <div class="glass-panel">
327
+ <div class="explorer-header">
328
+ <button class="download-btn" onclick="goUp()" title="Go Up" id="btn-up" style="display: none;">
329
+ <span class="material-icons-round">arrow_upward</span>
330
+ </button>
331
+ <div class="breadcrumbs" id="breadcrumbs">
332
+ <!-- Rendered via JS -->
333
+ </div>
334
+ <button class="download-btn" onclick="downloadCurrentFolder()" title="Download Folder as ZIP" style="width: auto; padding: 0 16px; border-radius: 8px;">
335
+ <span class="material-icons-round">file_download</span> <span style="font-size: 13px; margin-left: 6px;">ZIP</span>
336
+ </button>
337
+ </div>
338
+
339
+ <div class="file-list" id="file-list">
340
+ <!-- Rendered via JS -->
341
+ </div>
342
+ </div>
343
+ </div>
344
+
345
+ <script>
346
+ const API_BASE = '';
347
+ let currentPath = '';
348
+
349
+ // UI Helpers
350
+ const showLoader = () => document.getElementById('loader').style.display = 'flex';
351
+ const hideLoader = () => document.getElementById('loader').style.display = 'none';
352
+
353
+ // Auth Flow
354
+ const checkAuth = () => {
355
+ const token = localStorage.getItem('token');
356
+ if (token) {
357
+ document.getElementById('login-container').style.display = 'none';
358
+ document.getElementById('dashboard-container').style.display = 'block';
359
+ loadPath('');
360
+ } else {
361
+ document.getElementById('login-container').style.display = 'flex';
362
+ document.getElementById('dashboard-container').style.display = 'none';
363
+ }
364
+ };
365
+
366
+ document.getElementById('login-form').addEventListener('submit', async (e) => {
367
+ e.preventDefault();
368
+ const u = document.getElementById('username').value;
369
+ const p = document.getElementById('password').value;
370
+
371
+ showLoader();
372
+ try {
373
+ const res = await fetch(`${API_BASE}/auth/login`, {
374
+ method: 'POST',
375
+ headers: { 'Content-Type': 'application/json' },
376
+ body: JSON.stringify({ username: u, password: p })
377
+ });
378
+ if (!res.ok) throw new Error('Invalid credentials');
379
+ const data = await res.json();
380
+ localStorage.setItem('token', data.token);
381
+ document.getElementById('error-msg').style.display = 'none';
382
+ checkAuth();
383
+ } catch (err) {
384
+ const errEl = document.getElementById('error-msg');
385
+ errEl.innerText = err.message;
386
+ errEl.style.display = 'block';
387
+ } finally {
388
+ hideLoader();
389
+ }
390
+ });
391
+
392
+ const logout = () => {
393
+ localStorage.removeItem('token');
394
+ checkAuth();
395
+ };
396
+
397
+ // Formatters
398
+ const formatSize = (bytes) => {
399
+ if (bytes === 0) return '--';
400
+ const k = 1024;
401
+ const sizes = ['B', 'KB', 'MB', 'GB'];
402
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
403
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
404
+ };
405
+
406
+ const formatDate = (ms) => {
407
+ if (!ms) return '--';
408
+ return new Date(ms).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
409
+ };
410
+
411
+ // Explorer Logic
412
+ const loadPath = async (path) => {
413
+ showLoader();
414
+ const token = localStorage.getItem('token');
415
+ try {
416
+ const res = await fetch(`${API_BASE}/api/web/explorer?path=${encodeURIComponent(path)}&token=${token}`);
417
+
418
+ if (res.status === 401 || res.status === 403) {
419
+ logout();
420
+ return;
421
+ }
422
+
423
+ const data = await res.json();
424
+ currentPath = data.path || '';
425
+ renderBreadcrumbs();
426
+ renderFiles(data.items);
427
+ } catch (e) {
428
+ console.error(e);
429
+ alert("Failed to load directory.");
430
+ } finally {
431
+ hideLoader();
432
+ }
433
+ };
434
+
435
+ const goUp = () => {
436
+ if (!currentPath) return;
437
+ const parts = currentPath.split('/');
438
+ parts.pop();
439
+ loadPath(parts.join('/'));
440
+ };
441
+
442
+ const navigateTo = (folder) => {
443
+ const newPath = currentPath ? `${currentPath}/${folder}` : folder;
444
+ loadPath(newPath);
445
+ };
446
+
447
+ const downloadCurrentFolder = () => {
448
+ triggerDownload(currentPath);
449
+ };
450
+
451
+ const triggerDownload = (itemPath) => {
452
+ const token = localStorage.getItem('token');
453
+ // We must pass token in URL since we are opening it in a new tab
454
+ const url = `${API_BASE}/api/web/download?path=${encodeURIComponent(itemPath)}&token=${token}`;
455
+ window.open(url, '_blank');
456
+ };
457
+
458
+ const renderBreadcrumbs = () => {
459
+ const container = document.getElementById('breadcrumbs');
460
+ const parts = currentPath.split('/').filter(Boolean);
461
+
462
+ document.getElementById('btn-up').style.display = parts.length > 0 ? 'flex' : 'none';
463
+
464
+ let html = `<div class="crumb ${parts.length === 0 ? 'active' : ''}" onclick="loadPath('')">Home</div>`;
465
+
466
+ let accPath = "";
467
+ parts.forEach((p, i) => {
468
+ accPath += (accPath ? "/" : "") + p;
469
+ html += `<span class="separator">/</span>`;
470
+ if (i === parts.length - 1) {
471
+ html += `<div class="crumb active">${p}</div>`;
472
+ } else {
473
+ html += `<div class="crumb" onclick="loadPath('${accPath}')">${p}</div>`;
474
+ }
475
+ });
476
+ container.innerHTML = html;
477
+ };
478
+
479
+ const renderFiles = (items) => {
480
+ const container = document.getElementById('file-list');
481
+ if (items.length === 0) {
482
+ container.innerHTML = `
483
+ <div class="empty-state">
484
+ <span class="material-icons-round" style="font-size: 48px; opacity: 0.5;">folder_open</span>
485
+ <p>This folder is empty</p>
486
+ </div>
487
+ `;
488
+ return;
489
+ }
490
+
491
+ let html = '';
492
+ items.forEach(item => {
493
+ const icon = item.is_dir ? 'folder' : (item.name.match(/\.(mp4|mov)$/i) ? 'movie' : 'image');
494
+ const fullPath = currentPath ? `${currentPath}/${item.name}` : item.name;
495
+
496
+ html += `
497
+ <div class="file-item" onclick="${item.is_dir ? `navigateTo('${item.name}')` : `triggerDownload('${fullPath}')`}">
498
+ <div class="file-icon">
499
+ <span class="material-icons-round">${icon}</span>
500
+ </div>
501
+ <div class="file-name">${item.name}</div>
502
+ <div class="file-meta">${formatDate(item.modified)}</div>
503
+ <div class="file-meta">${formatSize(item.size)}</div>
504
+ <button class="download-btn" onclick="event.stopPropagation(); triggerDownload('${fullPath}')" title="Download">
505
+ <span class="material-icons-round">download</span>
506
+ </button>
507
+ </div>
508
+ `;
509
+ });
510
+ container.innerHTML = html;
511
+ };
512
+
513
+ // Initialize
514
+ checkAuth();
515
+ </script>
516
+ </body>
517
+ </html>