fileclip 0.1.0__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.
- fileclip-0.1.0/LICENSE +21 -0
- fileclip-0.1.0/PKG-INFO +90 -0
- fileclip-0.1.0/README.md +65 -0
- fileclip-0.1.0/pyproject.toml +48 -0
- fileclip-0.1.0/src/fileclip/__init__.py +8 -0
- fileclip-0.1.0/src/fileclip/__main__.py +6 -0
- fileclip-0.1.0/src/fileclip/cli.py +228 -0
- fileclip-0.1.0/src/fileclip/server.py +88 -0
- fileclip-0.1.0/src/fileclip/static/app.css +438 -0
- fileclip-0.1.0/src/fileclip/static/app.js +943 -0
- fileclip-0.1.0/src/fileclip/static/index.html +57 -0
fileclip-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Osama Masoud
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
fileclip-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fileclip
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local clipboard-envelope file transfer helper
|
|
5
|
+
Keywords: clipboard,file-transfer,fastapi,local-web-app
|
|
6
|
+
Author: Osama Masoud
|
|
7
|
+
Author-email: Osama Masoud <osama.masoud@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Dist: fastapi>=0.138.0
|
|
18
|
+
Requires-Dist: typer>=0.26.7
|
|
19
|
+
Requires-Dist: uvicorn>=0.49.0
|
|
20
|
+
Requires-Python: >=3.14
|
|
21
|
+
Project-URL: Homepage, https://github.com/omasoud/fileclip
|
|
22
|
+
Project-URL: Repository, https://github.com/omasoud/fileclip
|
|
23
|
+
Project-URL: Issues, https://github.com/omasoud/fileclip/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# FileClip
|
|
27
|
+
|
|
28
|
+
FileClip is a small local web app for moving one file through text clipboard synchronization. It packages a dropped file into a self-contained `FILECLIP/1` text envelope, copies that envelope to the local clipboard, and can later hydrate a compatible envelope back into a downloadable file.
|
|
29
|
+
|
|
30
|
+
FileClip does not sync clipboards, upload files, or store file contents on disk. It assumes another system already syncs clipboard text between the environments you care about.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
For CLI use:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv tool install fileclip
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or with pipx:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pipx install fileclip
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Run
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
fileclip
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
By default the server binds to `127.0.0.1`, chooses an available port, and opens the browser.
|
|
53
|
+
|
|
54
|
+
Useful options:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
fileclip --port 8080
|
|
58
|
+
fileclip --no-open
|
|
59
|
+
fileclip --passphrase-prompt
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Plain Mode
|
|
63
|
+
|
|
64
|
+
Without a passphrase, FileClip creates plain base64 clipboard envelopes. This is convenient for trusted local clipboard-sync paths, but the file bytes are visible to anything that can read the clipboard envelope.
|
|
65
|
+
|
|
66
|
+
## Passphrase Mode
|
|
67
|
+
|
|
68
|
+
With `--passphrase` or `--passphrase-prompt`, FileClip encrypts file bytes in the browser with PBKDF2/SHA-256 and AES-GCM before building the clipboard envelope.
|
|
69
|
+
|
|
70
|
+
`--passphrase-prompt` avoids putting the passphrase in shell history or the process list. The browser still receives the passphrase through local launch configuration so it can encrypt and decrypt files. Passphrase mode is a practical guardrail, not a high-assurance security boundary.
|
|
71
|
+
|
|
72
|
+
Encrypted mode protects payload bytes but does not hide envelope metadata such as filename, MIME type, file size, or SHA-256 hash.
|
|
73
|
+
|
|
74
|
+
## Limits
|
|
75
|
+
|
|
76
|
+
FileClip has no artificial file-size limit, but browsers, clipboards, clipboard managers, and external clipboard-sync channels do. Large payloads may fail to copy, paste, or sync.
|
|
77
|
+
|
|
78
|
+
Only schema `1` envelopes are supported. FileClip intentionally rejects unknown schema versions and mode mismatches.
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
uv sync
|
|
84
|
+
uv run pytest
|
|
85
|
+
uv run fileclip --no-open
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
FileClip is distributed under the MIT License.
|
fileclip-0.1.0/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# FileClip
|
|
2
|
+
|
|
3
|
+
FileClip is a small local web app for moving one file through text clipboard synchronization. It packages a dropped file into a self-contained `FILECLIP/1` text envelope, copies that envelope to the local clipboard, and can later hydrate a compatible envelope back into a downloadable file.
|
|
4
|
+
|
|
5
|
+
FileClip does not sync clipboards, upload files, or store file contents on disk. It assumes another system already syncs clipboard text between the environments you care about.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
For CLI use:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv tool install fileclip
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or with pipx:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pipx install fileclip
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Run
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
fileclip
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
By default the server binds to `127.0.0.1`, chooses an available port, and opens the browser.
|
|
28
|
+
|
|
29
|
+
Useful options:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
fileclip --port 8080
|
|
33
|
+
fileclip --no-open
|
|
34
|
+
fileclip --passphrase-prompt
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Plain Mode
|
|
38
|
+
|
|
39
|
+
Without a passphrase, FileClip creates plain base64 clipboard envelopes. This is convenient for trusted local clipboard-sync paths, but the file bytes are visible to anything that can read the clipboard envelope.
|
|
40
|
+
|
|
41
|
+
## Passphrase Mode
|
|
42
|
+
|
|
43
|
+
With `--passphrase` or `--passphrase-prompt`, FileClip encrypts file bytes in the browser with PBKDF2/SHA-256 and AES-GCM before building the clipboard envelope.
|
|
44
|
+
|
|
45
|
+
`--passphrase-prompt` avoids putting the passphrase in shell history or the process list. The browser still receives the passphrase through local launch configuration so it can encrypt and decrypt files. Passphrase mode is a practical guardrail, not a high-assurance security boundary.
|
|
46
|
+
|
|
47
|
+
Encrypted mode protects payload bytes but does not hide envelope metadata such as filename, MIME type, file size, or SHA-256 hash.
|
|
48
|
+
|
|
49
|
+
## Limits
|
|
50
|
+
|
|
51
|
+
FileClip has no artificial file-size limit, but browsers, clipboards, clipboard managers, and external clipboard-sync channels do. Large payloads may fail to copy, paste, or sync.
|
|
52
|
+
|
|
53
|
+
Only schema `1` envelopes are supported. FileClip intentionally rejects unknown schema versions and mode mismatches.
|
|
54
|
+
|
|
55
|
+
## Development
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv sync
|
|
59
|
+
uv run pytest
|
|
60
|
+
uv run fileclip --no-open
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
FileClip is distributed under the MIT License.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fileclip"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Local clipboard-envelope file transfer helper"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Osama Masoud", email = "osama.masoud@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
keywords = ["clipboard", "file-transfer", "fastapi", "local-web-app"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Environment :: Web Environment",
|
|
15
|
+
"Framework :: FastAPI",
|
|
16
|
+
"Intended Audience :: End Users/Desktop",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Topic :: Utilities",
|
|
20
|
+
]
|
|
21
|
+
requires-python = ">=3.14"
|
|
22
|
+
dependencies = [
|
|
23
|
+
"fastapi>=0.138.0",
|
|
24
|
+
"typer>=0.26.7",
|
|
25
|
+
"uvicorn>=0.49.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
fileclip = "fileclip.cli:main"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/omasoud/fileclip"
|
|
33
|
+
Repository = "https://github.com/omasoud/fileclip"
|
|
34
|
+
Issues = "https://github.com/omasoud/fileclip/issues"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["uv_build>=0.9.18,<0.12.0"]
|
|
38
|
+
build-backend = "uv_build"
|
|
39
|
+
|
|
40
|
+
[tool.uv]
|
|
41
|
+
exclude-newer = "2 days" # Exclude packages released in the last 2 days as a protection against undiscovered supply chain vulnerabilities.
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"httpx2>=2.4.0",
|
|
46
|
+
"packaging>=26.2",
|
|
47
|
+
"pytest>=9.1.1",
|
|
48
|
+
]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Command-line interface for FileClip."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import socket
|
|
7
|
+
import webbrowser
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
import uvicorn
|
|
14
|
+
|
|
15
|
+
from fileclip.server import LaunchConfig, create_app
|
|
16
|
+
|
|
17
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
18
|
+
DEFAULT_PORT = 0
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
add_completion=False,
|
|
22
|
+
help="Run the local FileClip clipboard-envelope web app.",
|
|
23
|
+
no_args_is_help=False,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ServerLaunch:
|
|
29
|
+
"""Resolved server launch details."""
|
|
30
|
+
|
|
31
|
+
host: str
|
|
32
|
+
requested_port: int
|
|
33
|
+
port: int
|
|
34
|
+
url: str
|
|
35
|
+
sockets: list[socket.socket] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_loopback_host(host: str) -> bool:
|
|
39
|
+
"""Return whether host is an accepted loopback bind address."""
|
|
40
|
+
|
|
41
|
+
normalized = host.strip().lower()
|
|
42
|
+
if normalized == "localhost":
|
|
43
|
+
return True
|
|
44
|
+
try:
|
|
45
|
+
return ipaddress.ip_address(normalized).is_loopback
|
|
46
|
+
except ValueError:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_host_for_url(host: str) -> str:
|
|
51
|
+
"""Format a host for use in an HTTP URL."""
|
|
52
|
+
|
|
53
|
+
if ":" in host and not host.startswith("["):
|
|
54
|
+
return f"[{host}]"
|
|
55
|
+
return host
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_app_url(host: str, port: int) -> str:
|
|
59
|
+
"""Build the browser URL for a FileClip server."""
|
|
60
|
+
|
|
61
|
+
return f"http://{format_host_for_url(host)}:{port}/"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _bind_socket(host: str) -> socket.socket:
|
|
65
|
+
"""Bind and listen on an available port for the requested host."""
|
|
66
|
+
|
|
67
|
+
last_error: OSError | None = None
|
|
68
|
+
for family, socktype, proto, _canonname, sockaddr in socket.getaddrinfo(
|
|
69
|
+
host,
|
|
70
|
+
0,
|
|
71
|
+
type=socket.SOCK_STREAM,
|
|
72
|
+
):
|
|
73
|
+
sock = socket.socket(family, socktype, proto)
|
|
74
|
+
try:
|
|
75
|
+
sock.bind(sockaddr)
|
|
76
|
+
sock.listen()
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
last_error = exc
|
|
79
|
+
sock.close()
|
|
80
|
+
continue
|
|
81
|
+
return sock
|
|
82
|
+
if last_error is not None:
|
|
83
|
+
raise last_error
|
|
84
|
+
raise OSError(f"Could not resolve a bind address for {host!r}.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def close_sockets(sockets: list[socket.socket] | None) -> None:
|
|
88
|
+
"""Close sockets that were pre-bound for uvicorn."""
|
|
89
|
+
|
|
90
|
+
if sockets is None:
|
|
91
|
+
return
|
|
92
|
+
for sock in sockets:
|
|
93
|
+
try:
|
|
94
|
+
sock.close()
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def prepare_server_launch(host: str, port: int) -> ServerLaunch:
|
|
100
|
+
"""Validate bind options and resolve the actual browser URL."""
|
|
101
|
+
|
|
102
|
+
if not is_loopback_host(host):
|
|
103
|
+
raise typer.BadParameter(
|
|
104
|
+
"Loopback-only host required.",
|
|
105
|
+
param_hint="--host",
|
|
106
|
+
)
|
|
107
|
+
if port < 0 or port > 65535:
|
|
108
|
+
raise typer.BadParameter(
|
|
109
|
+
"Port must be between 0 and 65535.",
|
|
110
|
+
param_hint="--port",
|
|
111
|
+
)
|
|
112
|
+
if port == 0:
|
|
113
|
+
sock = _bind_socket(host)
|
|
114
|
+
actual_port = int(sock.getsockname()[1])
|
|
115
|
+
return ServerLaunch(
|
|
116
|
+
host=host,
|
|
117
|
+
requested_port=port,
|
|
118
|
+
port=actual_port,
|
|
119
|
+
url=build_app_url(host, actual_port),
|
|
120
|
+
sockets=[sock],
|
|
121
|
+
)
|
|
122
|
+
return ServerLaunch(
|
|
123
|
+
host=host,
|
|
124
|
+
requested_port=port,
|
|
125
|
+
port=port,
|
|
126
|
+
url=build_app_url(host, port),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _prompt_passphrase() -> str:
|
|
131
|
+
"""Prompt for a passphrase without echoing it to the terminal."""
|
|
132
|
+
|
|
133
|
+
return str(typer.prompt("Passphrase", hide_input=True))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def resolve_passphrase(
|
|
137
|
+
passphrase: str | None,
|
|
138
|
+
passphrase_prompt: bool,
|
|
139
|
+
prompt_func: Callable[[], str] = _prompt_passphrase,
|
|
140
|
+
) -> str | None:
|
|
141
|
+
"""Resolve passphrase options into the configured passphrase."""
|
|
142
|
+
|
|
143
|
+
if passphrase is not None and passphrase_prompt:
|
|
144
|
+
raise typer.BadParameter(
|
|
145
|
+
"Use either --passphrase or --passphrase-prompt, not both.",
|
|
146
|
+
param_hint="--passphrase",
|
|
147
|
+
)
|
|
148
|
+
resolved = prompt_func() if passphrase_prompt else passphrase
|
|
149
|
+
if resolved == "":
|
|
150
|
+
raise typer.BadParameter(
|
|
151
|
+
"Passphrase cannot be empty.",
|
|
152
|
+
param_hint="--passphrase",
|
|
153
|
+
)
|
|
154
|
+
return resolved
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def run_local_server(
|
|
158
|
+
*,
|
|
159
|
+
host: str,
|
|
160
|
+
port: int,
|
|
161
|
+
open_browser: bool,
|
|
162
|
+
passphrase: str | None,
|
|
163
|
+
passphrase_prompt: bool,
|
|
164
|
+
browser_open: Callable[[str], bool] = webbrowser.open,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Run the local FileClip server."""
|
|
167
|
+
|
|
168
|
+
resolved_passphrase = resolve_passphrase(passphrase, passphrase_prompt)
|
|
169
|
+
launch = prepare_server_launch(host, port)
|
|
170
|
+
asgi_app = create_app(LaunchConfig(passphrase=resolved_passphrase))
|
|
171
|
+
uvicorn_config = uvicorn.Config(
|
|
172
|
+
asgi_app,
|
|
173
|
+
host=launch.host,
|
|
174
|
+
port=launch.port,
|
|
175
|
+
access_log=False,
|
|
176
|
+
log_level="info",
|
|
177
|
+
)
|
|
178
|
+
server = uvicorn.Server(uvicorn_config)
|
|
179
|
+
try:
|
|
180
|
+
typer.echo(f"Serving FileClip at {launch.url}")
|
|
181
|
+
if open_browser:
|
|
182
|
+
browser_open(launch.url)
|
|
183
|
+
server.run(sockets=launch.sockets)
|
|
184
|
+
finally:
|
|
185
|
+
close_sockets(launch.sockets)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command()
|
|
189
|
+
def root(
|
|
190
|
+
host: Annotated[
|
|
191
|
+
str,
|
|
192
|
+
typer.Option("--host", help="Loopback host to bind."),
|
|
193
|
+
] = DEFAULT_HOST,
|
|
194
|
+
port: Annotated[
|
|
195
|
+
int,
|
|
196
|
+
typer.Option("--port", min=0, max=65535, help="Port to bind; 0 chooses one."),
|
|
197
|
+
] = DEFAULT_PORT,
|
|
198
|
+
open_browser: Annotated[
|
|
199
|
+
bool,
|
|
200
|
+
typer.Option("--open/--no-open", help="Open the browser after launch."),
|
|
201
|
+
] = True,
|
|
202
|
+
passphrase: Annotated[
|
|
203
|
+
str | None,
|
|
204
|
+
typer.Option("--passphrase", help="Enable encrypted mode with this passphrase."),
|
|
205
|
+
] = None,
|
|
206
|
+
passphrase_prompt: Annotated[
|
|
207
|
+
bool,
|
|
208
|
+
typer.Option(
|
|
209
|
+
"--passphrase-prompt",
|
|
210
|
+
help="Prompt for a passphrase without echoing it.",
|
|
211
|
+
),
|
|
212
|
+
] = False,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Start the local FileClip browser app."""
|
|
215
|
+
|
|
216
|
+
run_local_server(
|
|
217
|
+
host=host,
|
|
218
|
+
port=port,
|
|
219
|
+
open_browser=open_browser,
|
|
220
|
+
passphrase=passphrase,
|
|
221
|
+
passphrase_prompt=passphrase_prompt,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main() -> None:
|
|
226
|
+
"""Run the Typer application."""
|
|
227
|
+
|
|
228
|
+
app()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""FastAPI application factory for FileClip."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
11
|
+
from fastapi.staticfiles import StaticFiles
|
|
12
|
+
|
|
13
|
+
from fileclip import __version__
|
|
14
|
+
|
|
15
|
+
APP_NAME = "fileclip"
|
|
16
|
+
SCHEMA_VERSION = 1
|
|
17
|
+
PLAIN_MODE = "plain"
|
|
18
|
+
ENCRYPTED_MODE = "encrypted"
|
|
19
|
+
CONFIG_CACHE_CONTROL = "no-store"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class LaunchConfig:
|
|
24
|
+
"""Runtime configuration exposed to the browser app."""
|
|
25
|
+
|
|
26
|
+
passphrase: str | None = None
|
|
27
|
+
app_name: str = APP_NAME
|
|
28
|
+
schema: int = SCHEMA_VERSION
|
|
29
|
+
version: str = __version__
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def mode(self) -> str:
|
|
33
|
+
"""Return the public launch mode."""
|
|
34
|
+
|
|
35
|
+
if self.passphrase is None:
|
|
36
|
+
return PLAIN_MODE
|
|
37
|
+
return ENCRYPTED_MODE
|
|
38
|
+
|
|
39
|
+
def as_public_config(self) -> dict[str, Any]:
|
|
40
|
+
"""Return the JSON-safe browser launch configuration."""
|
|
41
|
+
|
|
42
|
+
config: dict[str, Any] = {
|
|
43
|
+
"app": self.app_name,
|
|
44
|
+
"version": self.version,
|
|
45
|
+
"schema": self.schema,
|
|
46
|
+
"mode": self.mode,
|
|
47
|
+
}
|
|
48
|
+
if self.passphrase is not None:
|
|
49
|
+
config["passphrase"] = self.passphrase
|
|
50
|
+
return config
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def static_dir() -> Path:
|
|
54
|
+
"""Return the packaged static asset directory."""
|
|
55
|
+
|
|
56
|
+
return Path(__file__).with_name("static")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_app(config: LaunchConfig | None = None) -> FastAPI:
|
|
60
|
+
"""Create the local FileClip ASGI application."""
|
|
61
|
+
|
|
62
|
+
launch_config = config or LaunchConfig()
|
|
63
|
+
assets = static_dir()
|
|
64
|
+
app = FastAPI(
|
|
65
|
+
title="FileClip",
|
|
66
|
+
docs_url=None,
|
|
67
|
+
redoc_url=None,
|
|
68
|
+
openapi_url=None,
|
|
69
|
+
)
|
|
70
|
+
app.state.launch_config = launch_config
|
|
71
|
+
|
|
72
|
+
@app.get("/", include_in_schema=False)
|
|
73
|
+
def index() -> FileResponse:
|
|
74
|
+
"""Serve the browser app shell."""
|
|
75
|
+
|
|
76
|
+
return FileResponse(assets / "index.html", media_type="text/html")
|
|
77
|
+
|
|
78
|
+
@app.get("/config.json", include_in_schema=False)
|
|
79
|
+
def config_json() -> JSONResponse:
|
|
80
|
+
"""Serve launch configuration without allowing browser caching."""
|
|
81
|
+
|
|
82
|
+
return JSONResponse(
|
|
83
|
+
launch_config.as_public_config(),
|
|
84
|
+
headers={"Cache-Control": CONFIG_CACHE_CONTROL},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
app.mount("/static", StaticFiles(directory=assets), name="static")
|
|
88
|
+
return app
|