Starchik1 commited on
Commit
9bb2d26
·
verified ·
1 Parent(s): f269387

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +301 -48
app.py CHANGED
@@ -9,7 +9,7 @@ from datetime import datetime, timedelta
9
  from pathlib import Path
10
  from contextlib import asynccontextmanager
11
 
12
- from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Form
13
  from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse
14
  import aiofiles
15
  from apscheduler.schedulers.background import BackgroundScheduler
@@ -112,7 +112,62 @@ async def lifespan(app: FastAPI):
112
 
113
  app = FastAPI(title="Free File Sharing", lifespan=lifespan)
114
 
115
- # ------------------- Web Interface -------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  @app.get("/", response_class=HTMLResponse)
117
  async def index():
118
  return HTMLResponse("""
@@ -488,7 +543,249 @@ function showSuccess(msg) { showMessage(msg, false); }
488
  </html>
489
  """)
490
 
491
- # ------------------- API Endpoints -------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
  @app.post("/upload/init")
493
  async def init_upload(data: dict):
494
  file_count = data.get("file_count", 1)
@@ -586,48 +883,4 @@ async def delete_by_token(delete_token: str):
586
  if not upload:
587
  raise HTTPException(status_code=404, detail="Invalid token or files already deleted")
588
  await delete_upload(upload['id'])
589
- return {"status": "deleted"}
590
-
591
- @app.get("/{short_code}")
592
- async def download(short_code: str, request: Request):
593
- conn = get_conn()
594
- upload = conn.execute(
595
- "SELECT id, is_complete, is_deleted FROM uploads WHERE short_code=?",
596
- (short_code,)
597
- ).fetchone()
598
- if not upload or upload['is_deleted']:
599
- conn.close()
600
- raise HTTPException(status_code=404, detail="Link not found or files deleted")
601
- if not upload['is_complete']:
602
- conn.close()
603
- return HTMLResponse("<h3>Files are still uploading, please try later</h3>", status_code=202)
604
-
605
- files = conn.execute("SELECT original_name, stored_name FROM files WHERE upload_id=?", (upload['id'],)).fetchall()
606
- conn.close()
607
- upload_dir = UPLOAD_DIR / upload['id']
608
- if not upload_dir.exists():
609
- raise HTTPException(status_code=404, detail="Files not found on disk")
610
-
611
- if len(files) == 1:
612
- file_path = upload_dir / files[0]['original_name']
613
- if not file_path.exists():
614
- raise HTTPException(status_code=404, detail="File missing")
615
- return FileResponse(file_path, filename=files[0]['original_name'])
616
- else:
617
- zip_filename = f"files_{short_code}.zip"
618
- async def zip_stream():
619
- zip_buffer = io.BytesIO()
620
- with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
621
- for f in files:
622
- file_path = upload_dir / f['original_name']
623
- if file_path.exists():
624
- zf.write(file_path, f['original_name'])
625
- zip_buffer.seek(0)
626
- while True:
627
- data = zip_buffer.read(8192)
628
- if not data:
629
- break
630
- yield data
631
- return StreamingResponse(zip_stream(), media_type="application/zip", headers={
632
- "Content-Disposition": f'attachment; filename="{zip_filename}"'
633
- })
 
9
  from pathlib import Path
10
  from contextlib import asynccontextmanager
11
 
12
+ from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Form, Query
13
  from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse
14
  import aiofiles
15
  from apscheduler.schedulers.background import BackgroundScheduler
 
112
 
113
  app = FastAPI(title="Free File Sharing", lifespan=lifespan)
114
 
115
+ # ------------------- Beautiful error page -------------------
116
+ def error_html(message: str, status_code: int = 404) -> str:
117
+ return f"""
118
+ <!DOCTYPE html>
119
+ <html>
120
+ <head>
121
+ <title>Error {status_code}</title>
122
+ <meta charset="UTF-8">
123
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
124
+ <style>
125
+ body {{
126
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
127
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
128
+ min-height: 100vh;
129
+ display: flex;
130
+ justify-content: center;
131
+ align-items: center;
132
+ margin: 0;
133
+ padding: 20px;
134
+ }}
135
+ .error-card {{
136
+ background: white;
137
+ border-radius: 20px;
138
+ padding: 40px;
139
+ text-align: center;
140
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
141
+ max-width: 400px;
142
+ }}
143
+ h1 {{
144
+ font-size: 5em;
145
+ color: #ff5252;
146
+ margin: 0;
147
+ }}
148
+ p {{
149
+ color: #555;
150
+ font-size: 1.1em;
151
+ margin: 20px 0;
152
+ }}
153
+ a {{
154
+ color: #667eea;
155
+ text-decoration: none;
156
+ font-weight: bold;
157
+ }}
158
+ </style>
159
+ </head>
160
+ <body>
161
+ <div class="error-card">
162
+ <h1>{status_code}</h1>
163
+ <p>{message}</p>
164
+ <a href="/">← Back to main page</a>
165
+ </div>
166
+ </body>
167
+ </html>
168
+ """
169
+
170
+ # ------------------- Main upload page -------------------
171
  @app.get("/", response_class=HTMLResponse)
172
  async def index():
173
  return HTMLResponse("""
 
543
  </html>
544
  """)
545
 
546
+ # ------------------- Download page (like Mega) -------------------
547
+ def format_size(size_bytes):
548
+ if size_bytes < 1024:
549
+ return f"{size_bytes} B"
550
+ elif size_bytes < 1024*1024:
551
+ return f"{size_bytes/1024:.1f} KB"
552
+ elif size_bytes < 1024*1024*1024:
553
+ return f"{size_bytes/(1024*1024):.1f} MB"
554
+ else:
555
+ return f"{size_bytes/(1024*1024*1024):.2f} GB"
556
+
557
+ @app.get("/{short_code}")
558
+ async def download_page(short_code: str, request: Request, direct: bool = Query(False)):
559
+ conn = get_conn()
560
+ upload = conn.execute(
561
+ "SELECT id, is_complete, is_deleted, created_at, expires_at FROM uploads WHERE short_code=?",
562
+ (short_code,)
563
+ ).fetchone()
564
+ if not upload or upload['is_deleted']:
565
+ conn.close()
566
+ return HTMLResponse(status_code=404, content=error_html("Link not found or files have been deleted."))
567
+ if not upload['is_complete']:
568
+ conn.close()
569
+ return HTMLResponse("<h3>Files are still uploading, please try later</h3>", status_code=202)
570
+
571
+ files = conn.execute("SELECT original_name, size FROM files WHERE upload_id=?", (upload['id'],)).fetchall()
572
+ conn.close()
573
+
574
+ # Если запрошена прямая загрузка (параметр ?direct или путь /dl/...)
575
+ if direct:
576
+ return await serve_direct_download(upload['id'], short_code, files)
577
+
578
+ # Иначе показываем красивую страницу
579
+ total_size = sum(f['size'] for f in files)
580
+ file_names = [f['original_name'] for f in files]
581
+ display_name = file_names[0] if len(files) == 1 else f"{len(files)} files"
582
+ created = upload['created_at']
583
+ expires = upload['expires_at']
584
+
585
+ html = f"""
586
+ <!DOCTYPE html>
587
+ <html lang="en">
588
+ <head>
589
+ <meta charset="UTF-8">
590
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
591
+ <title>Download - {display_name}</title>
592
+ <script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
593
+ <style>
594
+ body {{
595
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
596
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
597
+ min-height: 100vh;
598
+ display: flex;
599
+ justify-content: center;
600
+ align-items: center;
601
+ padding: 20px;
602
+ margin: 0;
603
+ }}
604
+ .download-card {{
605
+ background: white;
606
+ border-radius: 20px;
607
+ padding: 30px;
608
+ max-width: 500px;
609
+ width: 100%;
610
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
611
+ text-align: center;
612
+ }}
613
+ .file-icon {{
614
+ font-size: 4em;
615
+ margin-bottom: 15px;
616
+ }}
617
+ h2 {{
618
+ color: #333;
619
+ word-break: break-all;
620
+ margin-bottom: 15px;
621
+ }}
622
+ .info {{
623
+ color: #555;
624
+ font-size: 0.95em;
625
+ margin: 10px 0;
626
+ }}
627
+ .btn-download {{
628
+ display: inline-block;
629
+ padding: 15px 40px;
630
+ background: linear-gradient(135deg, #667eea, #764ba2);
631
+ color: white;
632
+ border: none;
633
+ border-radius: 30px;
634
+ font-weight: bold;
635
+ font-size: 1.1em;
636
+ cursor: pointer;
637
+ text-decoration: none;
638
+ transition: transform 0.2s, box-shadow 0.2s;
639
+ box-shadow: 0 4px 10px rgba(102,126,234,0.4);
640
+ margin: 15px 0;
641
+ }}
642
+ .btn-download:hover {{
643
+ transform: translateY(-2px);
644
+ box-shadow: 0 6px 15px rgba(102,126,234,0.6);
645
+ }}
646
+ .qr-section {{
647
+ margin-top: 20px;
648
+ position: relative;
649
+ }}
650
+ .qr-container {{
651
+ display: inline-block;
652
+ cursor: pointer;
653
+ position: relative;
654
+ }}
655
+ .qr-hint {{
656
+ position: absolute;
657
+ bottom: 100%;
658
+ left: 50%;
659
+ transform: translateX(-50%);
660
+ background: #333;
661
+ color: white;
662
+ padding: 5px 12px;
663
+ border-radius: 20px;
664
+ font-size: 0.8em;
665
+ white-space: nowrap;
666
+ opacity: 0;
667
+ transition: opacity 0.3s;
668
+ pointer-events: none;
669
+ }}
670
+ .qr-container:hover .qr-hint {{
671
+ opacity: 1;
672
+ }}
673
+ .direct-link {{
674
+ margin-top: 10px;
675
+ font-size: 0.9em;
676
+ color: #666;
677
+ }}
678
+ .direct-link a {{
679
+ color: #667eea;
680
+ text-decoration: none;
681
+ }}
682
+ .back-link {{
683
+ margin-top: 20px;
684
+ display: block;
685
+ color: #667eea;
686
+ text-decoration: none;
687
+ }}
688
+ </style>
689
+ </head>
690
+ <body>
691
+ <div class="download-card">
692
+ <div class="file-icon">📁</div>
693
+ <h2>{display_name}</h2>
694
+ <div class="info">Size: {format_size(total_size)}</div>
695
+ <div class="info">Uploaded: {created}</div>
696
+ <div class="info">Expires: {expires}</div>
697
+ <a href="/dl/{short_code}" class="btn-download" id="downloadBtn">⬇ Download ({format_size(total_size)})</a>
698
+ <div class="qr-section">
699
+ <div class="qr-container" id="qrContainer">
700
+ <div id="qrcode"></div>
701
+ <div class="qr-hint">Click to switch to direct link</div>
702
+ </div>
703
+ </div>
704
+ <div class="direct-link">
705
+ Direct: <a href="/dl/{short_code}" id="directLink">/dl/{short_code}</a>
706
+ </div>
707
+ <a href="/" class="back-link">← Upload more files</a>
708
+ </div>
709
+ <script>
710
+ const pageUrl = window.location.href.split('?')[0];
711
+ const directUrl = pageUrl.replace(/\/[^\/]+$/, '/dl/{short_code}');
712
+ let showingDirect = false;
713
+ const qrDiv = document.getElementById('qrcode');
714
+ const qrContainer = document.getElementById('qrContainer');
715
+ const directLink = document.getElementById('directLink');
716
+
717
+ function makeQR(url) {{
718
+ qrDiv.innerHTML = '';
719
+ new QRCode(qrDiv, {{
720
+ text: url,
721
+ width: 128,
722
+ height: 128,
723
+ }});
724
+ }}
725
+
726
+ makeQR(pageUrl);
727
+
728
+ qrContainer.addEventListener('click', () => {{
729
+ showingDirect = !showingDirect;
730
+ if (showingDirect) {{
731
+ makeQR(directUrl);
732
+ directLink.textContent = directUrl;
733
+ }} else {{
734
+ makeQR(pageUrl);
735
+ directLink.textContent = '/dl/{short_code}';
736
+ }}
737
+ }});
738
+ </script>
739
+ </body>
740
+ </html>
741
+ """
742
+ return HTMLResponse(html)
743
+
744
+ @app.get("/dl/{short_code}")
745
+ async def direct_download(short_code: str, request: Request):
746
+ conn = get_conn()
747
+ upload = conn.execute(
748
+ "SELECT id, is_complete, is_deleted FROM uploads WHERE short_code=?",
749
+ (short_code,)
750
+ ).fetchone()
751
+ if not upload or upload['is_deleted'] or not upload['is_complete']:
752
+ conn.close()
753
+ return HTMLResponse(status_code=404, content=error_html("File not found."))
754
+
755
+ files = conn.execute("SELECT original_name, size FROM files WHERE upload_id=?", (upload['id'],)).fetchall()
756
+ conn.close()
757
+ return await serve_direct_download(upload['id'], short_code, files)
758
+
759
+ async def serve_direct_download(upload_id: str, short_code: str, files):
760
+ upload_dir = UPLOAD_DIR / upload_id
761
+ if not upload_dir.exists():
762
+ return HTMLResponse(status_code=404, content=error_html("Files missing."))
763
+
764
+ if len(files) == 1:
765
+ file_path = upload_dir / files[0]['original_name']
766
+ if not file_path.exists():
767
+ return HTMLResponse(status_code=404, content=error_html("File missing."))
768
+ return FileResponse(file_path, filename=files[0]['original_name'])
769
+ else:
770
+ zip_filename = f"files_{short_code}.zip"
771
+ async def zip_stream():
772
+ zip_buffer = io.BytesIO()
773
+ with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
774
+ for f in files:
775
+ file_path = upload_dir / f['original_name']
776
+ if file_path.exists():
777
+ zf.write(file_path, f['original_name'])
778
+ zip_buffer.seek(0)
779
+ while True:
780
+ data = zip_buffer.read(8192)
781
+ if not data:
782
+ break
783
+ yield data
784
+ return StreamingResponse(zip_stream(), media_type="application/zip", headers={
785
+ "Content-Disposition": f'attachment; filename="{zip_filename}"'
786
+ })
787
+
788
+ # ------------------- Upload endpoints (unchanged) -------------------
789
  @app.post("/upload/init")
790
  async def init_upload(data: dict):
791
  file_count = data.get("file_count", 1)
 
883
  if not upload:
884
  raise HTTPException(status_code=404, detail="Invalid token or files already deleted")
885
  await delete_upload(upload['id'])
886
+ return {"status": "deleted"}