tempspace-cli 1.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of tempspace-cli might be problematic. Click here for more details.

@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: tempspace-cli
3
+ Version: 1.1.1
4
+ Summary: A command-line tool for uploading files to Tempspace.
5
+ Author-email: Tempspace <mcbplay1@gmail.com>
6
+ License: MIT License
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.7
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: requests==2.31.0
12
+ Requires-Dist: tqdm==4.66.1
13
+ Requires-Dist: requests-toolbelt==1.0.0
14
+ Requires-Dist: rich==13.7.1
15
+ Requires-Dist: qrcode==8.2
16
+
17
+ # 🚀 Tempspace
18
+
19
+ Tempspace is a terminal-style file sharing service that allows you to upload files and share them via a link, with features like password protection, one-time downloads, and automatic expiration.
20
+
21
+ ![Screenshot](https://i.imgur.com/your-screenshot.png)
22
+
23
+ ## ✨ Features
24
+
25
+ - **Multiple Upload Methods:** Use the web interface, cURL, or the official CLI tool.
26
+ - **Security:** Optional password protection, one-time downloads, and rate limiting.
27
+ - **Modern UI:** A terminal-inspired design with QR code generation and upload history.
28
+ - **File Management:** Support for large files, multiple expiry options, and automatic cleanup.
29
+
30
+ ## 🚀 Getting Started
31
+
32
+ ### Method 1: Using the Web Interface
33
+
34
+ The easiest way to use Tempspace is through the web interface.
35
+
36
+ 1. **Open** the service URL (e.g., `http://localhost:8000`) in your browser.
37
+ 2. **Drag and drop** a file or click to browse.
38
+ 3. **Configure** the expiry time and an optional password.
39
+ 4. **Upload** and share the generated link.
40
+
41
+ ### Method 2: Using cURL
42
+
43
+ You can use `cURL` to upload files directly from your terminal.
44
+
45
+ ```bash
46
+ # Basic upload
47
+ curl -F "file=@/path/to/file.txt" -F "hours=6" https://tempspace.fly.dev/upload
48
+
49
+ # With a password
50
+ curl -F "file=@/path/to/secret.txt" -F "password=secret123" https://tempspace.fly.dev/upload
51
+ ```
52
+
53
+ ### Method 3: Using the Official CLI Tool
54
+
55
+ For a more integrated terminal experience, you can install the official CLI tool from PyPI.
56
+
57
+ #### Installation
58
+ ```bash
59
+ pip install tempspace-cli
60
+ ```
61
+
62
+ #### Usage
63
+ ```bash
64
+ # Basic upload
65
+ tempspace /path/to/document.pdf -t 6h
66
+
67
+ # With a password
68
+ tempspace /path/to/secret.txt -p "mypassword"
69
+
70
+ # Point to a self-hosted server
71
+ tempspace /path/to/file.txt --url http://localhost:8000
72
+ ```
73
+
74
+ ## 🔧 Self-Hosting
75
+
76
+ You can run your own instance of Tempspace.
77
+
78
+ ### Installation
79
+ ```bash
80
+ # Clone the repository
81
+ git clone https://github.com/your-repo/tempspace.git
82
+ cd tempspace
83
+
84
+ # Install dependencies
85
+ pip install -r requirements.txt
86
+
87
+ # Configure
88
+ cp .env.example .env
89
+ nano .env # Change the admin password
90
+ ```
91
+
92
+ ### Running the Server
93
+ ```bash
94
+ python main.py
95
+ ```
96
+
97
+ The server will be available at `http://localhost:8000`.
98
+
99
+ ## 🛡️ Admin & Configuration
100
+
101
+ For advanced configuration, such as setting up a reverse proxy, managing admin endpoints, and more, please refer to the project's documentation or open an issue.
102
+
103
+ ## 🤝 Contributing
104
+
105
+ Contributions are welcome! Please fork the repository and submit a pull request.
106
+
107
+ ## 📝 License
108
+
109
+ This project is licensed under the MIT License.
@@ -0,0 +1,93 @@
1
+ # 🚀 Tempspace
2
+
3
+ Tempspace is a terminal-style file sharing service that allows you to upload files and share them via a link, with features like password protection, one-time downloads, and automatic expiration.
4
+
5
+ ![Screenshot](https://i.imgur.com/your-screenshot.png)
6
+
7
+ ## ✨ Features
8
+
9
+ - **Multiple Upload Methods:** Use the web interface, cURL, or the official CLI tool.
10
+ - **Security:** Optional password protection, one-time downloads, and rate limiting.
11
+ - **Modern UI:** A terminal-inspired design with QR code generation and upload history.
12
+ - **File Management:** Support for large files, multiple expiry options, and automatic cleanup.
13
+
14
+ ## 🚀 Getting Started
15
+
16
+ ### Method 1: Using the Web Interface
17
+
18
+ The easiest way to use Tempspace is through the web interface.
19
+
20
+ 1. **Open** the service URL (e.g., `http://localhost:8000`) in your browser.
21
+ 2. **Drag and drop** a file or click to browse.
22
+ 3. **Configure** the expiry time and an optional password.
23
+ 4. **Upload** and share the generated link.
24
+
25
+ ### Method 2: Using cURL
26
+
27
+ You can use `cURL` to upload files directly from your terminal.
28
+
29
+ ```bash
30
+ # Basic upload
31
+ curl -F "file=@/path/to/file.txt" -F "hours=6" https://tempspace.fly.dev/upload
32
+
33
+ # With a password
34
+ curl -F "file=@/path/to/secret.txt" -F "password=secret123" https://tempspace.fly.dev/upload
35
+ ```
36
+
37
+ ### Method 3: Using the Official CLI Tool
38
+
39
+ For a more integrated terminal experience, you can install the official CLI tool from PyPI.
40
+
41
+ #### Installation
42
+ ```bash
43
+ pip install tempspace-cli
44
+ ```
45
+
46
+ #### Usage
47
+ ```bash
48
+ # Basic upload
49
+ tempspace /path/to/document.pdf -t 6h
50
+
51
+ # With a password
52
+ tempspace /path/to/secret.txt -p "mypassword"
53
+
54
+ # Point to a self-hosted server
55
+ tempspace /path/to/file.txt --url http://localhost:8000
56
+ ```
57
+
58
+ ## 🔧 Self-Hosting
59
+
60
+ You can run your own instance of Tempspace.
61
+
62
+ ### Installation
63
+ ```bash
64
+ # Clone the repository
65
+ git clone https://github.com/your-repo/tempspace.git
66
+ cd tempspace
67
+
68
+ # Install dependencies
69
+ pip install -r requirements.txt
70
+
71
+ # Configure
72
+ cp .env.example .env
73
+ nano .env # Change the admin password
74
+ ```
75
+
76
+ ### Running the Server
77
+ ```bash
78
+ python main.py
79
+ ```
80
+
81
+ The server will be available at `http://localhost:8000`.
82
+
83
+ ## 🛡️ Admin & Configuration
84
+
85
+ For advanced configuration, such as setting up a reverse proxy, managing admin endpoints, and more, please refer to the project's documentation or open an issue.
86
+
87
+ ## 🤝 Contributing
88
+
89
+ Contributions are welcome! Please fork the repository and submit a pull request.
90
+
91
+ ## 📝 License
92
+
93
+ This project is licensed under the MIT License.
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,186 @@
1
+ import argparse
2
+ import requests
3
+ import os
4
+ import sys
5
+ import hashlib
6
+ import multiprocessing
7
+ from tqdm import tqdm
8
+ from requests_toolbelt.multipart.encoder import MultipartEncoder, MultipartEncoderMonitor
9
+ import math
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+ from rich import box
14
+ import qrcode
15
+ from rich.prompt import Prompt, Confirm
16
+
17
+ # Default configuration
18
+ DEFAULT_SERVER_URL = "https://tempspace.fly.dev/"
19
+ CHUNK_SIZE = 1024 * 1024 # 1MB
20
+
21
+ def parse_time(time_str: str) -> int:
22
+ """
23
+ Parse a time string (e.g., '7d', '24h', '360') into an integer number of hours.
24
+ Returns the number of hours as an integer, or None if parsing fails.
25
+ """
26
+ time_str = time_str.lower().strip()
27
+ if time_str.endswith('d'):
28
+ try:
29
+ days = int(time_str[:-1])
30
+ return days * 24
31
+ except ValueError:
32
+ return None
33
+ elif time_str.endswith('h'):
34
+ try:
35
+ return int(time_str[:-1])
36
+ except ValueError:
37
+ return None
38
+ else:
39
+ try:
40
+ return int(time_str)
41
+ except ValueError:
42
+ return None
43
+
44
+ def calculate_file_hash(filepath: str) -> str:
45
+ """Calculate the SHA256 hash of a file in chunks."""
46
+ sha256_hash = hashlib.sha256()
47
+ try:
48
+ with open(filepath, "rb") as f:
49
+ for byte_block in iter(lambda: f.read(CHUNK_SIZE), b""):
50
+ sha256_hash.update(byte_block)
51
+ return sha256_hash.hexdigest()
52
+ except IOError as e:
53
+ print(f"Error reading file for hashing: {e}", file=sys.stderr)
54
+ sys.exit(1)
55
+
56
+ def format_size(size_bytes: int) -> str:
57
+ """Converts a size in bytes to a human-readable format."""
58
+ if size_bytes == 0:
59
+ return "0B"
60
+ size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
61
+ i = int(math.floor(math.log(size_bytes, 1024)))
62
+ p = math.pow(1024, i)
63
+ s = round(size_bytes / p, 2)
64
+ return f"{s} {size_name[i]}"
65
+
66
+ def main():
67
+ """Main function to handle argument parsing and file upload."""
68
+ console = Console()
69
+
70
+ # --- Header ---
71
+ console.print(Panel("[bold cyan]Tempspace File Uploader[/bold cyan]", expand=False, border_style="blue"))
72
+
73
+
74
+ parser = argparse.ArgumentParser(
75
+ description="Upload a file to Tempspace.",
76
+ formatter_class=argparse.RawTextHelpFormatter
77
+ )
78
+
79
+ parser.add_argument("filepath", nargs='?', default=None, help="The path to the file you want to upload.")
80
+ parser.add_argument("-t", "--time", type=str, default='24', help="Set the file's expiration time. Examples: '24h', '7d', '360' (hours).\nDefault: '24' (24 hours).")
81
+ parser.add_argument("-p", "--password", type=str, help="Protect the file with a password.")
82
+ parser.add_argument("--one-time", action="store_true", help="The file will be deleted after the first download.")
83
+ parser.add_argument("--url", type=str, default=os.environ.get("TEMPSPACE_URL", DEFAULT_SERVER_URL), help=f"The URL of the Tempspace server.\nCan also be set with the TEMPSPACE_URL environment variable.\nDefault: {DEFAULT_SERVER_URL}")
84
+ parser.add_argument("--qr", action="store_true", help="Display a QR code of the download link.")
85
+ parser.add_argument("--it", action="store_true", help="Enable interactive mode.")
86
+
87
+ args = parser.parse_args()
88
+
89
+ # --- Interactive Mode ---
90
+ if args.it:
91
+ args.filepath = Prompt.ask("Enter the path to the file you want to upload")
92
+ args.time = Prompt.ask("Set the file's expiration time (e.g., '24h', '7d')", default='24')
93
+ args.password = Prompt.ask("Protect the file with a password?", default=None, password=True)
94
+ args.one_time = Confirm.ask("Delete the file after the first download?", default=False)
95
+ args.qr = Confirm.ask("Display a QR code of the download link?", default=False)
96
+
97
+
98
+ # --- Validate Inputs ---
99
+ if not args.filepath or not os.path.isfile(args.filepath):
100
+ console.print(Panel(f"[bold red]Error:[/] File not found at '{args.filepath}'", title="[bold red]Error[/bold red]", border_style="red"))
101
+ sys.exit(1)
102
+
103
+ hours = parse_time(args.time)
104
+ if hours is None:
105
+ console.print(Panel(f"[bold red]Error:[/] Invalid time format '{args.time}'. Use formats like '24h', '7d', or '360'.", title="[bold red]Error[/bold red]", border_style="red"))
106
+ sys.exit(1)
107
+
108
+ # --- Display File Details ---
109
+ table = Table(title="File Details", show_header=False, box=box.ROUNDED, border_style="cyan")
110
+ table.add_column("Field", style="bold")
111
+ table.add_column("Value")
112
+ table.add_row("File Name", os.path.basename(args.filepath))
113
+ table.add_row("File Size", format_size(os.path.getsize(args.filepath)))
114
+ table.add_row("Expiration", f"{hours} hours")
115
+ table.add_row("Password", "[green]Yes[/green]" if args.password else "[red]No[/red]")
116
+ table.add_row("One-Time Download", "[green]Yes[/green]" if args.one_time else "[red]No[/red]")
117
+ console.print(table)
118
+
119
+
120
+ # --- Hashing ---
121
+ console.print("[cyan]Calculating file hash...[/]")
122
+ client_hash = calculate_file_hash(args.filepath)
123
+ console.print(f" [cyan]- Hash:[/] {client_hash}")
124
+
125
+ # --- Prepare Upload ---
126
+ upload_url = f"{args.url.rstrip('/')}/upload"
127
+ filename = os.path.basename(args.filepath)
128
+ file_size = os.path.getsize(args.filepath)
129
+
130
+ fields = {
131
+ 'hours': str(hours),
132
+ 'one_time': str(args.one_time).lower(),
133
+ 'client_hash': client_hash,
134
+ 'file': (filename, open(args.filepath, 'rb'), 'application/octet-stream')
135
+ }
136
+ if args.password:
137
+ fields['password'] = args.password
138
+
139
+ encoder = MultipartEncoder(fields=fields)
140
+ response = None
141
+
142
+ try:
143
+ bar_format = "{desc}: {percentage:3.0f}%|{bar}| {n_fmt}/{total_fmt} [Speed: {rate_fmt}, ETA: {remaining}]"
144
+ with tqdm(total=encoder.len, unit='B', unit_scale=True, desc=f"Uploading {filename}", bar_format=bar_format) as pbar:
145
+ monitor = MultipartEncoderMonitor(encoder, lambda m: pbar.update(m.bytes_read - pbar.n))
146
+ response = requests.post(upload_url, data=monitor, headers={'Content-Type': monitor.content_type})
147
+
148
+ except FileNotFoundError:
149
+ console.print(Panel(f"[bold red]Error:[/] The file '{args.filepath}' was not found.", title="[bold red]Error[/bold red]", border_style="red"))
150
+ sys.exit(1)
151
+ except requests.exceptions.RequestException as e:
152
+ console.print(Panel(f"[bold red]An error occurred while connecting to the server:[/]\n{e}", title="[bold red]Error[/bold red]", border_style="red"))
153
+ sys.exit(1)
154
+ except Exception as e:
155
+ console.print(Panel(f"[bold red]An unexpected error occurred:[/] {e}", title="[bold red]Error[/bold red]", border_style="red"))
156
+ sys.exit(1)
157
+ finally:
158
+ # Ensure the file handle is closed
159
+ if 'file' in fields and not fields['file'][1].closed:
160
+ fields['file'][1].close()
161
+
162
+ # --- Handle Response ---
163
+ if response is not None:
164
+ if response.status_code == 200:
165
+ download_link = response.text.strip()
166
+ success_panel = Panel(f"[bold green]Upload successful![/bold green]\n\nDownload Link: {download_link}",
167
+ title="[bold cyan]Success[/bold cyan]", border_style="green")
168
+ console.print(success_panel)
169
+
170
+ if args.qr:
171
+ qr = qrcode.QRCode()
172
+ qr.add_data(download_link)
173
+ qr.make(fit=True)
174
+ qr.print_ascii()
175
+ else:
176
+ try:
177
+ error_details = response.json()
178
+ error_message = error_details.get('detail', 'No details provided.')
179
+ except requests.exceptions.JSONDecodeError:
180
+ error_message = response.text
181
+ console.print(Panel(f"[bold red]Error:[/] Upload failed with status code {response.status_code}\n[red]Server message:[/] {error_message}", title="[bold red]Error[/bold red]", border_style="red"))
182
+ sys.exit(1)
183
+
184
+ if __name__ == "__main__":
185
+ multiprocessing.freeze_support()
186
+ main()
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tool.setuptools]
6
+ packages = ["cli"]
7
+
8
+ [project]
9
+ name = "tempspace-cli"
10
+ version = "1.1.1"
11
+ authors = [
12
+ { name="Tempspace", email="mcbplay1@gmail.com" },
13
+ ]
14
+ description = "A command-line tool for uploading files to Tempspace."
15
+ readme = "README.md"
16
+ license = { text = "MIT License" }
17
+ requires-python = ">=3.7"
18
+ classifiers = [
19
+ "Programming Language :: Python :: 3",
20
+ "Operating System :: OS Independent",
21
+ ]
22
+ dependencies = [
23
+ "requests==2.31.0",
24
+ "tqdm==4.66.1",
25
+ "requests-toolbelt==1.0.0",
26
+ "rich==13.7.1",
27
+ "qrcode==8.2",
28
+ ]
29
+
30
+ [project.scripts]
31
+ tempspace = "cli.tempspace:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: tempspace-cli
3
+ Version: 1.1.1
4
+ Summary: A command-line tool for uploading files to Tempspace.
5
+ Author-email: Tempspace <mcbplay1@gmail.com>
6
+ License: MIT License
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.7
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: requests==2.31.0
12
+ Requires-Dist: tqdm==4.66.1
13
+ Requires-Dist: requests-toolbelt==1.0.0
14
+ Requires-Dist: rich==13.7.1
15
+ Requires-Dist: qrcode==8.2
16
+
17
+ # 🚀 Tempspace
18
+
19
+ Tempspace is a terminal-style file sharing service that allows you to upload files and share them via a link, with features like password protection, one-time downloads, and automatic expiration.
20
+
21
+ ![Screenshot](https://i.imgur.com/your-screenshot.png)
22
+
23
+ ## ✨ Features
24
+
25
+ - **Multiple Upload Methods:** Use the web interface, cURL, or the official CLI tool.
26
+ - **Security:** Optional password protection, one-time downloads, and rate limiting.
27
+ - **Modern UI:** A terminal-inspired design with QR code generation and upload history.
28
+ - **File Management:** Support for large files, multiple expiry options, and automatic cleanup.
29
+
30
+ ## 🚀 Getting Started
31
+
32
+ ### Method 1: Using the Web Interface
33
+
34
+ The easiest way to use Tempspace is through the web interface.
35
+
36
+ 1. **Open** the service URL (e.g., `http://localhost:8000`) in your browser.
37
+ 2. **Drag and drop** a file or click to browse.
38
+ 3. **Configure** the expiry time and an optional password.
39
+ 4. **Upload** and share the generated link.
40
+
41
+ ### Method 2: Using cURL
42
+
43
+ You can use `cURL` to upload files directly from your terminal.
44
+
45
+ ```bash
46
+ # Basic upload
47
+ curl -F "file=@/path/to/file.txt" -F "hours=6" https://tempspace.fly.dev/upload
48
+
49
+ # With a password
50
+ curl -F "file=@/path/to/secret.txt" -F "password=secret123" https://tempspace.fly.dev/upload
51
+ ```
52
+
53
+ ### Method 3: Using the Official CLI Tool
54
+
55
+ For a more integrated terminal experience, you can install the official CLI tool from PyPI.
56
+
57
+ #### Installation
58
+ ```bash
59
+ pip install tempspace-cli
60
+ ```
61
+
62
+ #### Usage
63
+ ```bash
64
+ # Basic upload
65
+ tempspace /path/to/document.pdf -t 6h
66
+
67
+ # With a password
68
+ tempspace /path/to/secret.txt -p "mypassword"
69
+
70
+ # Point to a self-hosted server
71
+ tempspace /path/to/file.txt --url http://localhost:8000
72
+ ```
73
+
74
+ ## 🔧 Self-Hosting
75
+
76
+ You can run your own instance of Tempspace.
77
+
78
+ ### Installation
79
+ ```bash
80
+ # Clone the repository
81
+ git clone https://github.com/your-repo/tempspace.git
82
+ cd tempspace
83
+
84
+ # Install dependencies
85
+ pip install -r requirements.txt
86
+
87
+ # Configure
88
+ cp .env.example .env
89
+ nano .env # Change the admin password
90
+ ```
91
+
92
+ ### Running the Server
93
+ ```bash
94
+ python main.py
95
+ ```
96
+
97
+ The server will be available at `http://localhost:8000`.
98
+
99
+ ## 🛡️ Admin & Configuration
100
+
101
+ For advanced configuration, such as setting up a reverse proxy, managing admin endpoints, and more, please refer to the project's documentation or open an issue.
102
+
103
+ ## 🤝 Contributing
104
+
105
+ Contributions are welcome! Please fork the repository and submit a pull request.
106
+
107
+ ## 📝 License
108
+
109
+ This project is licensed under the MIT License.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ cli/__init__.py
4
+ cli/tempspace.py
5
+ tempspace_cli.egg-info/PKG-INFO
6
+ tempspace_cli.egg-info/SOURCES.txt
7
+ tempspace_cli.egg-info/dependency_links.txt
8
+ tempspace_cli.egg-info/entry_points.txt
9
+ tempspace_cli.egg-info/requires.txt
10
+ tempspace_cli.egg-info/top_level.txt
11
+ tests/test_cli.py
12
+ tests/test_main.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tempspace = cli.tempspace:main
@@ -0,0 +1,5 @@
1
+ requests==2.31.0
2
+ tqdm==4.66.1
3
+ requests-toolbelt==1.0.0
4
+ rich==13.7.1
5
+ qrcode==8.2
@@ -0,0 +1,12 @@
1
+ import pytest
2
+ from cli.tempspace import parse_time
3
+
4
+ def test_parse_time():
5
+ assert parse_time("7d") == 168
6
+ assert parse_time("24h") == 24
7
+ assert parse_time("360") == 360
8
+ assert parse_time("1D") == 24
9
+ assert parse_time("2H") == 2
10
+ assert parse_time(" 7d ") == 168
11
+ assert parse_time("invalid") is None
12
+ assert parse_time("1w") is None
@@ -0,0 +1,405 @@
1
+ import pytest
2
+ from fastapi.testclient import TestClient
3
+ from main import app, UPLOAD_DIR
4
+ import os
5
+ import shutil
6
+ import asyncio
7
+ import json
8
+
9
+ # Create a client for testing
10
+ client = TestClient(app)
11
+
12
+ # Import RateLimiter to reset it
13
+ from main import RateLimiter, RATE_LIMIT_UPLOADS, RATE_LIMIT_DOWNLOADS, RATE_LIMIT_WINDOW
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def reset_rate_limiters(monkeypatch):
17
+ """Fixture to reset rate limiters before each test to prevent test interference."""
18
+ monkeypatch.setattr("main.upload_limiter", RateLimiter(RATE_LIMIT_UPLOADS, RATE_LIMIT_WINDOW))
19
+ monkeypatch.setattr("main.download_limiter", RateLimiter(RATE_LIMIT_DOWNLOADS, RATE_LIMIT_WINDOW))
20
+
21
+
22
+ @pytest.fixture(scope="module", autouse=True)
23
+ def setup_and_teardown():
24
+ """Fixture to set up a clean upload directory for tests and tear it down afterward."""
25
+ # Ensure a clean state before tests run
26
+ if UPLOAD_DIR.exists():
27
+ shutil.rmtree(UPLOAD_DIR)
28
+ UPLOAD_DIR.mkdir()
29
+
30
+ # Yield control to the tests
31
+ yield
32
+
33
+ # Teardown: clean up the uploads directory after all tests in the module are done
34
+ if UPLOAD_DIR.exists():
35
+ shutil.rmtree(UPLOAD_DIR)
36
+
37
+ def test_upload_file():
38
+ """Test basic file upload."""
39
+ response = client.post(
40
+ "/upload",
41
+ files={"file": ("test_file.txt", b"hello world", "text/plain")},
42
+ data={"hours": "1"},
43
+ )
44
+ assert response.status_code == 200
45
+ data = response.json()
46
+ assert data["success"] is True
47
+ assert data["filename"] == "test_file.txt"
48
+ assert "url" in data
49
+ assert "file_id" in data
50
+
51
+ def test_upload_with_password():
52
+ """Test file upload with a password."""
53
+ response = client.post(
54
+ "/upload",
55
+ files={"file": ("test_password.txt", b"secret content", "text/plain")},
56
+ data={"hours": "1", "password": "testpassword"},
57
+ )
58
+ assert response.status_code == 200
59
+ data = response.json()
60
+ assert data["success"] is True
61
+ assert data["password_protected"] is True
62
+
63
+ def test_download_file_no_password():
64
+ """Test downloading a file that doesn't require a password."""
65
+ # First, upload a file
66
+ upload_response = client.post(
67
+ "/upload",
68
+ files={"file": ("download_test.txt", b"download content", "text/plain")},
69
+ data={"hours": "1"},
70
+ )
71
+ upload_data = upload_response.json()
72
+ file_url = upload_data["url"]
73
+
74
+ # Now, try to download it
75
+ # The URL contains the full host, so we need to extract the path
76
+ path = file_url.split("/", 3)[-1]
77
+ download_response = client.get(path)
78
+
79
+ assert download_response.status_code == 200
80
+ assert download_response.content == b"download content"
81
+
82
+ def test_download_file_with_correct_password():
83
+ """Test downloading a password-protected file with the correct password."""
84
+ upload_response = client.post(
85
+ "/upload",
86
+ files={"file": ("protected_download.txt", b"protected", "text/plain")},
87
+ data={"hours": "1", "password": "supersecret"},
88
+ )
89
+ upload_data = upload_response.json()
90
+ path = upload_data["url"].split("/", 3)[-1]
91
+
92
+ download_response = client.get(f"{path}?password=supersecret")
93
+ assert download_response.status_code == 200
94
+ assert download_response.content == b"protected"
95
+
96
+ def test_download_file_with_incorrect_password():
97
+ """Test downloading a password-protected file with an incorrect password."""
98
+ upload_response = client.post(
99
+ "/upload",
100
+ files={"file": ("protected_fail.txt", b"protected fail", "text/plain")},
101
+ data={"hours": "1", "password": "supersecret"},
102
+ )
103
+ upload_data = upload_response.json()
104
+ path = upload_data["url"].split("/", 3)[-1]
105
+
106
+ download_response = client.get(f"{path}?password=wrongpassword")
107
+ assert download_response.status_code == 403 # Forbidden
108
+
109
+ def test_one_time_download():
110
+ """Test that a one-time download file is deleted after being accessed."""
111
+ upload_response = client.post(
112
+ "/upload",
113
+ files={"file": ("one_time.txt", b"one time content", "text/plain")},
114
+ data={"hours": "1", "one_time": "true"},
115
+ )
116
+ upload_data = upload_response.json()
117
+ path = upload_data["url"].split("/", 3)[-1]
118
+
119
+ # First download should succeed
120
+ first_download_response = client.get(path)
121
+ assert first_download_response.status_code == 200
122
+
123
+ # Let the async deletion task run
124
+ async def wait_for_deletion():
125
+ await asyncio.sleep(0.1)
126
+
127
+ asyncio.run(wait_for_deletion())
128
+
129
+ # Second download should fail
130
+ second_download_response = client.get(path)
131
+ assert second_download_response.status_code == 404 # Not Found
132
+
133
+ def test_delete_file():
134
+ """Test deleting a file using the client_id."""
135
+ # Upload a file with a specific client_id
136
+ client_id = "test-client-123"
137
+ upload_response = client.post(
138
+ "/upload",
139
+ files={"file": ("to_be_deleted.txt", b"delete me", "text/plain")},
140
+ data={"hours": "1", "client_id": client_id},
141
+ )
142
+ upload_data = upload_response.json()
143
+ file_id = upload_data["file_id"]
144
+
145
+ # Now, delete the file with the correct client_id
146
+ delete_response = client.request(
147
+ "DELETE",
148
+ f"/delete/{file_id}",
149
+ json={"client_id": client_id},
150
+ )
151
+ assert delete_response.status_code == 200
152
+ assert delete_response.json()["success"] is True
153
+
154
+ # Verify the file is gone
155
+ path = upload_data["url"].split("/", 3)[-1]
156
+ get_response = client.get(path)
157
+ assert get_response.status_code == 404
158
+
159
+ def test_delete_file_unauthorized():
160
+ """Test that deleting a file with the wrong client_id fails."""
161
+ # Upload a file with a specific client_id
162
+ owner_client_id = "owner-client"
163
+ upload_response = client.post(
164
+ "/upload",
165
+ files={"file": ("unauthorized_delete.txt", b"don't delete me", "text/plain")},
166
+ data={"hours": "1", "client_id": owner_client_id},
167
+ )
168
+ upload_data = upload_response.json()
169
+ file_id = upload_data["file_id"]
170
+
171
+ # Attempt to delete with a different client_id
172
+ attacker_client_id = "attacker-client"
173
+ delete_response = client.request(
174
+ "DELETE",
175
+ f"/delete/{file_id}",
176
+ json={"client_id": attacker_client_id},
177
+ )
178
+ assert delete_response.status_code == 403 # Forbidden
179
+
180
+ def test_duplicate_file_upload():
181
+ """Test that uploading a file with the same content hash returns the original file's URL."""
182
+ # First upload
183
+ response1 = client.post(
184
+ "/upload",
185
+ files={"file": ("duplicate_content.txt", b"this content is the same", "text/plain")},
186
+ data={"hours": "1"},
187
+ )
188
+ assert response1.status_code == 200
189
+ data1 = response1.json()
190
+ assert data1["success"] is True
191
+
192
+ # Second upload with the same content
193
+ response2 = client.post(
194
+ "/upload",
195
+ files={"file": ("another_name.txt", b"this content is the same", "text/plain")},
196
+ data={"hours": "1"},
197
+ )
198
+ assert response2.status_code == 200
199
+ data2 = response2.json()
200
+ assert data2["success"] is True
201
+
202
+ # Check that the file_id is the same for both uploads
203
+ assert data1["file_id"] == data2["file_id"]
204
+
205
+ import hashlib
206
+
207
+ def calculate_hash(content: bytes) -> str:
208
+ """Helper function to calculate SHA256 hash."""
209
+ return hashlib.sha256(content).hexdigest()
210
+
211
+ def test_upload_with_matching_hash():
212
+ """Test that a file upload with a matching client_hash is successful."""
213
+ file_content = b"content for hash test"
214
+ client_hash = calculate_hash(file_content)
215
+
216
+ response = client.post(
217
+ "/upload",
218
+ files={"file": ("hash_match.txt", file_content, "text/plain")},
219
+ data={"hours": "1", "client_hash": client_hash},
220
+ )
221
+ assert response.status_code == 200
222
+ data = response.json()
223
+ assert data["success"] is True
224
+ assert data["hash_verified"] is True
225
+
226
+ def test_upload_with_mismatching_hash():
227
+ """Test that a file upload with a mismatching client_hash is rejected."""
228
+ file_content = b"content for hash mismatch test"
229
+ # Provide a deliberately incorrect hash
230
+ incorrect_hash = "thisisnotthecorrecthash" * 2
231
+
232
+ response = client.post(
233
+ "/upload",
234
+ files={"file": ("hash_mismatch.txt", file_content, "text/plain")},
235
+ data={"hours": "1", "client_hash": incorrect_hash},
236
+ )
237
+ assert response.status_code == 400 # Bad Request
238
+ data = response.json()
239
+ assert "Hash verification failed" in data["detail"]
240
+
241
+ def test_upload_rate_limiting():
242
+ """Test that the upload rate limit is enforced."""
243
+ # The default rate limit is 10 uploads per hour. We'll exceed this.
244
+ for i in range(10):
245
+ response = client.post(
246
+ "/upload",
247
+ files={"file": (f"rate_limit_test_{i}.txt", b"rate limit content", "text/plain")},
248
+ data={"hours": "1"},
249
+ )
250
+ assert response.status_code == 200
251
+
252
+ # The 11th request should be rate-limited
253
+ response = client.post(
254
+ "/upload",
255
+ files={"file": ("rate_limit_exceeded.txt", b"this should fail", "text/plain")},
256
+ data={"hours": "1"},
257
+ )
258
+ assert response.status_code == 429
259
+
260
+ def test_download_rate_limiting():
261
+ """Test that the download rate limit is enforced."""
262
+ # First, upload a file to download
263
+ upload_response = client.post(
264
+ "/upload",
265
+ files={"file": ("download_rate_limit.txt", b"download me", "text/plain")},
266
+ data={"hours": "1"},
267
+ )
268
+ upload_data = upload_response.json()
269
+ path = upload_data["url"].split("/", 3)[-1]
270
+
271
+ # The default download limit is 100 per hour.
272
+ # We will hit the endpoint 100 times, expecting success.
273
+ for _ in range(100):
274
+ download_response = client.get(path)
275
+ assert download_response.status_code == 200
276
+
277
+ # The 101st request should be rate-limited
278
+ final_download_response = client.get(path)
279
+ assert final_download_response.status_code == 429
280
+
281
+ def test_debug_stats_unauthorized():
282
+ """Test that the /debug/stats endpoint requires authentication."""
283
+ response = client.get("/debug/stats")
284
+ assert response.status_code == 401 # Unauthorized
285
+
286
+ def test_debug_stats_authorized():
287
+ """Test that the /debug/stats endpoint returns data with correct authentication."""
288
+ # First, upload a file to have some stats to check
289
+ client.post(
290
+ "/upload",
291
+ files={"file": ("stats_test_file.txt", b"some data", "text/plain")},
292
+ data={"hours": "1"},
293
+ )
294
+
295
+ response = client.get("/debug/stats", auth=("admin", "testpassword"))
296
+ assert response.status_code == 200
297
+ # Check for some expected keys in the HTML response content
298
+ assert b"Total Files" in response.content
299
+ assert b"Total Size" in response.content
300
+ assert b"stats_test_file.txt" in response.content
301
+
302
+ def test_debug_wipe_unauthorized():
303
+ """Test that the /debug/wipe endpoint requires authentication."""
304
+ response = client.post("/debug/wipe")
305
+ assert response.status_code == 401 # Unauthorized
306
+
307
+ def test_debug_wipe_authorized():
308
+ """Test that the /debug/wipe endpoint successfully deletes all data."""
309
+ # First, upload a file to ensure there is data to wipe
310
+ upload_response = client.post(
311
+ "/upload",
312
+ files={"file": ("wipe_test_file.txt", b"wipe me", "text/plain")},
313
+ data={"hours": "1"},
314
+ )
315
+ assert upload_response.status_code == 200
316
+ upload_data = upload_response.json()
317
+ path = upload_data["url"].split("/", 3)[-1]
318
+
319
+ # Now, wipe the data
320
+ wipe_response = client.post("/debug/wipe", auth=("admin", "testpassword"))
321
+ assert wipe_response.status_code == 200
322
+ wipe_data = wipe_response.json()
323
+ assert wipe_data["success"] is True
324
+ assert wipe_data["wiped_files"] > 0
325
+
326
+ # Verify that the previously uploaded file is gone
327
+ get_response = client.get(path)
328
+ assert get_response.status_code == 404
329
+
330
+ # Import necessary modules for the expiration test
331
+ from datetime import datetime, timedelta, timezone
332
+ from main import cleanup_expired_files, shutdown_event
333
+
334
+ @pytest.mark.asyncio
335
+ async def test_file_expiration_and_cleanup(monkeypatch):
336
+ """Test that an expired file is automatically cleaned up by the background task."""
337
+ # Prevent the main app's cleanup task from running automatically
338
+ async def do_nothing():
339
+ pass
340
+ monkeypatch.setattr("main.cleanup_expired_files", do_nothing)
341
+
342
+ # Set a very short cleanup interval so we can trigger it manually
343
+ monkeypatch.setattr("main.CLEANUP_INTERVAL", 0.01)
344
+
345
+ # Upload a file with a 1-hour expiry
346
+ upload_response = client.post(
347
+ "/upload",
348
+ files={"file": ("cleanup_test.txt", b"I will be cleaned up", "text/plain")},
349
+ data={"hours": "1"},
350
+ )
351
+ assert upload_response.status_code == 200
352
+ upload_data = upload_response.json()
353
+ path = upload_data["url"].split("/", 3)[-1]
354
+ file_id = upload_data["file_id"]
355
+
356
+ # At this point, the file should exist in the metadata
357
+ from main import metadata
358
+ assert file_id in metadata
359
+
360
+ # A mock datetime class that is always 2 hours in the future
361
+ class MockDateTime(datetime):
362
+ @classmethod
363
+ def now(cls, tz=None):
364
+ return datetime.now(timezone.utc) + timedelta(hours=2)
365
+
366
+ # Now, patch the datetime object to simulate time passing
367
+ monkeypatch.setattr('main.datetime', MockDateTime)
368
+
369
+ # Manually run the cleanup task once
370
+ # Reset the shutdown event to allow the task to run
371
+ shutdown_event.clear()
372
+ cleanup_task = asyncio.create_task(cleanup_expired_files())
373
+ await asyncio.sleep(0.02) # Give it a moment to run
374
+ shutdown_event.set() # Stop the task
375
+ await cleanup_task # Wait for it to finish gracefully
376
+
377
+ # After cleanup, the file should be gone from metadata and disk
378
+ assert file_id not in metadata
379
+ download_response = client.get(path)
380
+ assert download_response.status_code == 404
381
+
382
+ def test_upload_with_invalid_expiry():
383
+ """Test that uploading with an invalid expiry time fails."""
384
+ response = client.post(
385
+ "/upload",
386
+ files={"file": ("invalid_expiry.txt", b"some content", "text/plain")},
387
+ data={"hours": "999"}, # 999 is not a valid expiry option
388
+ )
389
+ assert response.status_code == 400
390
+ assert "Invalid expiry time" in response.json()["detail"]
391
+
392
+ def test_upload_exceeding_max_size(monkeypatch):
393
+ """Test that uploading a file larger than MAX_FILE_SIZE fails."""
394
+ # Set a very small max file size for the test
395
+ monkeypatch.setattr("main.MAX_FILE_SIZE", 10) # 10 bytes
396
+
397
+ # Attempt to upload a file larger than the new max size
398
+ file_content = b"this file is definitely larger than ten bytes"
399
+ response = client.post(
400
+ "/upload",
401
+ files={"file": ("too_large.txt", file_content, "text/plain")},
402
+ data={"hours": "1"},
403
+ )
404
+ assert response.status_code == 413 # Payload Too Large
405
+ assert "File too large" in response.json()["detail"]