sharedserver 0.1.0__py3-none-any.whl
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.
- sharedserver/__init__.py +4 -0
- sharedserver/__main__.py +6 -0
- sharedserver/cli.py +76 -0
- sharedserver/clipboard.py +32 -0
- sharedserver/config.py +151 -0
- sharedserver/file_manager.py +131 -0
- sharedserver/models.py +41 -0
- sharedserver/security.py +51 -0
- sharedserver/server.py +142 -0
- sharedserver/static/app.css +226 -0
- sharedserver/static/app.js +358 -0
- sharedserver/templates/index.html +89 -0
- sharedserver/websocket.py +57 -0
- sharedserver-0.1.0.dist-info/METADATA +177 -0
- sharedserver-0.1.0.dist-info/RECORD +19 -0
- sharedserver-0.1.0.dist-info/WHEEL +5 -0
- sharedserver-0.1.0.dist-info/entry_points.txt +2 -0
- sharedserver-0.1.0.dist-info/licenses/LICENSE +22 -0
- sharedserver-0.1.0.dist-info/top_level.txt +1 -0
sharedserver/__init__.py
ADDED
sharedserver/__main__.py
ADDED
sharedserver/cli.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import copy
|
|
5
|
+
import socket
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Sequence
|
|
8
|
+
|
|
9
|
+
import uvicorn
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .config import load_config
|
|
13
|
+
from .server import create_app
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="sharedserver",
|
|
19
|
+
description="LAN file sharing and real-time text clipboard",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("port", nargs="?", type=int, help="port to listen on")
|
|
22
|
+
parser.add_argument("--host", help="host to bind (default: 0.0.0.0)")
|
|
23
|
+
parser.add_argument("-d", "--directory", type=Path, help="directory to share")
|
|
24
|
+
parser.add_argument("-c", "--config", type=Path, help="YAML configuration file")
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--max-upload-size",
|
|
27
|
+
type=int,
|
|
28
|
+
metavar="MB",
|
|
29
|
+
help="maximum upload size in MiB",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
32
|
+
return parser
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_lan_ip() -> str:
|
|
36
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
37
|
+
try:
|
|
38
|
+
sock.connect(("8.8.8.8", 80))
|
|
39
|
+
return str(sock.getsockname()[0])
|
|
40
|
+
except OSError:
|
|
41
|
+
try:
|
|
42
|
+
return socket.gethostbyname(socket.gethostname())
|
|
43
|
+
except OSError:
|
|
44
|
+
return "127.0.0.1"
|
|
45
|
+
finally:
|
|
46
|
+
sock.close()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
50
|
+
parser = build_parser()
|
|
51
|
+
args = parser.parse_args(argv)
|
|
52
|
+
try:
|
|
53
|
+
config = load_config(args.config).with_cli_overrides(
|
|
54
|
+
host=args.host,
|
|
55
|
+
port=args.port,
|
|
56
|
+
directory=args.directory.expanduser().resolve() if args.directory else None,
|
|
57
|
+
max_upload_size=(
|
|
58
|
+
args.max_upload_size * 1024 * 1024
|
|
59
|
+
if args.max_upload_size is not None
|
|
60
|
+
else None
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
app = create_app(config)
|
|
64
|
+
except (FileNotFoundError, NotADirectoryError, ValueError) as exc:
|
|
65
|
+
parser.error(str(exc))
|
|
66
|
+
|
|
67
|
+
port = config.server.port
|
|
68
|
+
print(
|
|
69
|
+
"SharedServer running:\n\n"
|
|
70
|
+
f"Local:\nhttp://127.0.0.1:{port}\n\n"
|
|
71
|
+
f"LAN:\nhttp://{get_lan_ip()}:{port}\n\n"
|
|
72
|
+
f"Sharing: {config.share.directory}\n"
|
|
73
|
+
)
|
|
74
|
+
log_config = copy.deepcopy(uvicorn.config.LOGGING_CONFIG)
|
|
75
|
+
log_config["loggers"]["uvicorn.error"]["level"] = "WARNING"
|
|
76
|
+
uvicorn.run(app, host=config.server.host, port=port, log_config=log_config)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from .config import ClipboardConfig
|
|
7
|
+
|
|
8
|
+
CopyKind = Literal["text", "image"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ClipboardPolicy:
|
|
12
|
+
def __init__(self, config: ClipboardConfig):
|
|
13
|
+
self.text_extensions = frozenset(config.text_extensions)
|
|
14
|
+
self.image_extensions = frozenset(config.image_extensions)
|
|
15
|
+
self.max_text_size = config.max_text_size
|
|
16
|
+
|
|
17
|
+
def copy_kind(self, path: Path) -> CopyKind | None:
|
|
18
|
+
suffix = path.suffix.lower()
|
|
19
|
+
if suffix in self.text_extensions:
|
|
20
|
+
return "text"
|
|
21
|
+
if suffix in self.image_extensions:
|
|
22
|
+
return "image"
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
def read_text(self, path: Path) -> str:
|
|
26
|
+
if self.copy_kind(path) != "text":
|
|
27
|
+
raise ValueError("file type is not allowed for text copying")
|
|
28
|
+
with path.open("rb") as source:
|
|
29
|
+
content = source.read(self.max_text_size + 1)
|
|
30
|
+
if len(content) > self.max_text_size:
|
|
31
|
+
raise ValueError("text file is too large to copy")
|
|
32
|
+
return content.decode("utf-8-sig", errors="replace")
|
sharedserver/config.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field, replace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
DEFAULT_TEXT_EXTENSIONS = (
|
|
10
|
+
".txt", ".md", ".json", ".xml", ".yaml", ".yml", ".csv", ".log",
|
|
11
|
+
".py", ".js", ".html", ".css", ".java", ".c", ".cpp",
|
|
12
|
+
)
|
|
13
|
+
DEFAULT_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ServerConfig:
|
|
18
|
+
host: str = "0.0.0.0"
|
|
19
|
+
port: int = 8000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ShareConfig:
|
|
24
|
+
directory: Path = field(default_factory=Path.cwd)
|
|
25
|
+
max_upload_size: int = 1024 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ClipboardConfig:
|
|
30
|
+
text_extensions: tuple[str, ...] = DEFAULT_TEXT_EXTENSIONS
|
|
31
|
+
image_extensions: tuple[str, ...] = DEFAULT_IMAGE_EXTENSIONS
|
|
32
|
+
max_text_size: int = 2 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class AppConfig:
|
|
37
|
+
server: ServerConfig = field(default_factory=ServerConfig)
|
|
38
|
+
share: ShareConfig = field(default_factory=ShareConfig)
|
|
39
|
+
clipboard: ClipboardConfig = field(default_factory=ClipboardConfig)
|
|
40
|
+
|
|
41
|
+
def with_cli_overrides(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
host: str | None = None,
|
|
45
|
+
port: int | None = None,
|
|
46
|
+
directory: Path | None = None,
|
|
47
|
+
max_upload_size: int | None = None,
|
|
48
|
+
) -> "AppConfig":
|
|
49
|
+
server = replace(
|
|
50
|
+
self.server,
|
|
51
|
+
host=host if host is not None else self.server.host,
|
|
52
|
+
port=port if port is not None else self.server.port,
|
|
53
|
+
)
|
|
54
|
+
share = replace(
|
|
55
|
+
self.share,
|
|
56
|
+
directory=directory if directory is not None else self.share.directory,
|
|
57
|
+
max_upload_size=(
|
|
58
|
+
max_upload_size
|
|
59
|
+
if max_upload_size is not None
|
|
60
|
+
else self.share.max_upload_size
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
result = replace(self, server=server, share=share)
|
|
64
|
+
_validate(result)
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extensions(value: Any, default: tuple[str, ...]) -> tuple[str, ...]:
|
|
69
|
+
if value is None:
|
|
70
|
+
return default
|
|
71
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
72
|
+
raise ValueError("clipboard extension lists must contain strings")
|
|
73
|
+
normalized = tuple(
|
|
74
|
+
dict.fromkeys(
|
|
75
|
+
(
|
|
76
|
+
item.strip().lower()
|
|
77
|
+
if item.strip().startswith(".")
|
|
78
|
+
else f".{item.strip().lower()}"
|
|
79
|
+
)
|
|
80
|
+
for item in value
|
|
81
|
+
if item.strip()
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
if any("/" in item or "\\" in item for item in normalized):
|
|
85
|
+
raise ValueError("clipboard extensions cannot contain path separators")
|
|
86
|
+
return normalized
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _section(data: dict[str, Any], name: str) -> dict[str, Any]:
|
|
90
|
+
value = data.get(name, {})
|
|
91
|
+
if not isinstance(value, dict):
|
|
92
|
+
raise ValueError(f"{name} must be a mapping")
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_config(path: Path | None = None) -> AppConfig:
|
|
97
|
+
config_path = path or Path.cwd() / "sharedserver.yaml"
|
|
98
|
+
if path is None and not config_path.exists():
|
|
99
|
+
result = AppConfig()
|
|
100
|
+
_validate(result)
|
|
101
|
+
return result
|
|
102
|
+
if not config_path.is_file():
|
|
103
|
+
raise FileNotFoundError(f"configuration file not found: {config_path}")
|
|
104
|
+
|
|
105
|
+
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
106
|
+
if not isinstance(raw, dict):
|
|
107
|
+
raise ValueError("configuration root must be a mapping")
|
|
108
|
+
server_data = _section(raw, "server")
|
|
109
|
+
share_data = _section(raw, "share")
|
|
110
|
+
clipboard_data = _section(raw, "clipboard")
|
|
111
|
+
|
|
112
|
+
directory_value = Path(str(share_data.get("directory", "."))).expanduser()
|
|
113
|
+
if not directory_value.is_absolute():
|
|
114
|
+
directory_value = config_path.resolve().parent / directory_value
|
|
115
|
+
|
|
116
|
+
result = AppConfig(
|
|
117
|
+
server=ServerConfig(
|
|
118
|
+
host=str(server_data.get("host", "0.0.0.0")),
|
|
119
|
+
port=int(server_data.get("port", 8000)),
|
|
120
|
+
),
|
|
121
|
+
share=ShareConfig(
|
|
122
|
+
directory=directory_value.resolve(),
|
|
123
|
+
max_upload_size=int(
|
|
124
|
+
share_data.get("max_upload_size", 1024 * 1024 * 1024)
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
clipboard=ClipboardConfig(
|
|
128
|
+
text_extensions=_extensions(
|
|
129
|
+
clipboard_data.get("text_extensions"), DEFAULT_TEXT_EXTENSIONS
|
|
130
|
+
),
|
|
131
|
+
image_extensions=_extensions(
|
|
132
|
+
clipboard_data.get("image_extensions"), DEFAULT_IMAGE_EXTENSIONS
|
|
133
|
+
),
|
|
134
|
+
max_text_size=int(
|
|
135
|
+
clipboard_data.get("max_text_size", 2 * 1024 * 1024)
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
_validate(result)
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _validate(config: AppConfig) -> None:
|
|
144
|
+
if not config.server.host:
|
|
145
|
+
raise ValueError("server.host cannot be empty")
|
|
146
|
+
if not 1 <= config.server.port <= 65535:
|
|
147
|
+
raise ValueError("server.port must be between 1 and 65535")
|
|
148
|
+
if config.share.max_upload_size <= 0:
|
|
149
|
+
raise ValueError("share.max_upload_size must be positive")
|
|
150
|
+
if config.clipboard.max_text_size <= 0:
|
|
151
|
+
raise ValueError("clipboard.max_text_size must be positive")
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import AsyncIterable
|
|
9
|
+
|
|
10
|
+
from .clipboard import ClipboardPolicy
|
|
11
|
+
from .models import DirectoryListing, FileEntry, UploadResult
|
|
12
|
+
from .security import SecurityError, resolve_within, sanitize_filename
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UploadTooLargeError(ValueError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FileManager:
|
|
20
|
+
def __init__(self, root: Path, max_upload_size: int, policy: ClipboardPolicy):
|
|
21
|
+
if not root.exists():
|
|
22
|
+
raise FileNotFoundError(f"shared directory does not exist: {root}")
|
|
23
|
+
if not root.is_dir():
|
|
24
|
+
raise NotADirectoryError(f"shared path is not a directory: {root}")
|
|
25
|
+
self.root = root.resolve(strict=True)
|
|
26
|
+
self.max_upload_size = max_upload_size
|
|
27
|
+
self.policy = policy
|
|
28
|
+
|
|
29
|
+
def path(self, relative: str, *, must_exist: bool = True) -> Path:
|
|
30
|
+
return resolve_within(self.root, relative, must_exist=must_exist)
|
|
31
|
+
|
|
32
|
+
def relative(self, path: Path) -> str:
|
|
33
|
+
return "" if path == self.root else path.relative_to(self.root).as_posix()
|
|
34
|
+
|
|
35
|
+
def list_directory(self, relative: str = "") -> DirectoryListing:
|
|
36
|
+
directory = self.path(relative)
|
|
37
|
+
if not directory.is_dir():
|
|
38
|
+
raise NotADirectoryError(relative)
|
|
39
|
+
|
|
40
|
+
entries: list[FileEntry] = []
|
|
41
|
+
for child in directory.iterdir():
|
|
42
|
+
if child.is_symlink() or child.name.startswith(".sharedserver-"):
|
|
43
|
+
continue
|
|
44
|
+
try:
|
|
45
|
+
stat = child.stat()
|
|
46
|
+
except OSError:
|
|
47
|
+
continue
|
|
48
|
+
is_directory = child.is_dir()
|
|
49
|
+
entries.append(
|
|
50
|
+
FileEntry(
|
|
51
|
+
name=child.name,
|
|
52
|
+
path=self.relative(child),
|
|
53
|
+
is_directory=is_directory,
|
|
54
|
+
size=None if is_directory else stat.st_size,
|
|
55
|
+
modified=datetime.fromtimestamp(
|
|
56
|
+
stat.st_mtime, tz=timezone.utc
|
|
57
|
+
).isoformat(),
|
|
58
|
+
copy_kind=None if is_directory else self.policy.copy_kind(child),
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
entries.sort(key=lambda entry: (not entry.is_directory, entry.name.casefold()))
|
|
62
|
+
current = self.relative(directory)
|
|
63
|
+
parent = None if directory == self.root else self.relative(directory.parent)
|
|
64
|
+
return DirectoryListing(path=current, parent=parent, entries=entries)
|
|
65
|
+
|
|
66
|
+
def create_directory(self, relative: str, raw_name: str) -> str:
|
|
67
|
+
parent = self.path(relative)
|
|
68
|
+
if not parent.is_dir():
|
|
69
|
+
raise NotADirectoryError(relative)
|
|
70
|
+
name = sanitize_filename(raw_name)
|
|
71
|
+
destination = resolve_within(parent, name, must_exist=False)
|
|
72
|
+
destination.mkdir()
|
|
73
|
+
return self.relative(destination)
|
|
74
|
+
|
|
75
|
+
def regular_file(self, relative: str) -> Path:
|
|
76
|
+
path = self.path(relative)
|
|
77
|
+
if not path.is_file() or path.is_symlink():
|
|
78
|
+
raise FileNotFoundError(relative)
|
|
79
|
+
return path
|
|
80
|
+
|
|
81
|
+
async def save_upload(
|
|
82
|
+
self,
|
|
83
|
+
relative: str,
|
|
84
|
+
raw_name: str,
|
|
85
|
+
chunks: AsyncIterable[bytes],
|
|
86
|
+
) -> UploadResult:
|
|
87
|
+
directory = self.path(relative)
|
|
88
|
+
if not directory.is_dir():
|
|
89
|
+
raise NotADirectoryError(relative)
|
|
90
|
+
name = sanitize_filename(raw_name)
|
|
91
|
+
destination = resolve_within(directory, name, must_exist=False)
|
|
92
|
+
if destination.exists() or destination.is_symlink():
|
|
93
|
+
raise FileExistsError(name)
|
|
94
|
+
|
|
95
|
+
size = 0
|
|
96
|
+
temp_path: Path | None = None
|
|
97
|
+
fallback_destination_created = False
|
|
98
|
+
try:
|
|
99
|
+
with tempfile.NamedTemporaryFile(
|
|
100
|
+
mode="wb", prefix=".sharedserver-", dir=directory, delete=False
|
|
101
|
+
) as temporary:
|
|
102
|
+
temp_path = Path(temporary.name)
|
|
103
|
+
async for chunk in chunks:
|
|
104
|
+
size += len(chunk)
|
|
105
|
+
if size > self.max_upload_size:
|
|
106
|
+
raise UploadTooLargeError(
|
|
107
|
+
f"upload exceeds {self.max_upload_size} bytes"
|
|
108
|
+
)
|
|
109
|
+
temporary.write(chunk)
|
|
110
|
+
temporary.flush()
|
|
111
|
+
os.fsync(temporary.fileno())
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
os.link(temp_path, destination)
|
|
115
|
+
except FileExistsError:
|
|
116
|
+
raise
|
|
117
|
+
except OSError:
|
|
118
|
+
# Some network and removable filesystems do not support hard links.
|
|
119
|
+
with temp_path.open("rb") as source, destination.open("xb") as target:
|
|
120
|
+
fallback_destination_created = True
|
|
121
|
+
shutil.copyfileobj(source, target)
|
|
122
|
+
return UploadResult(name=name, path=self.relative(destination), size=size)
|
|
123
|
+
except FileExistsError:
|
|
124
|
+
raise
|
|
125
|
+
except Exception:
|
|
126
|
+
if fallback_destination_created:
|
|
127
|
+
destination.unlink(missing_ok=True)
|
|
128
|
+
raise
|
|
129
|
+
finally:
|
|
130
|
+
if temp_path is not None:
|
|
131
|
+
temp_path.unlink(missing_ok=True)
|
sharedserver/models.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FileEntry(BaseModel):
|
|
9
|
+
name: str
|
|
10
|
+
path: str
|
|
11
|
+
is_directory: bool
|
|
12
|
+
size: int | None
|
|
13
|
+
modified: str
|
|
14
|
+
copy_kind: Literal["text", "image"] | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DirectoryListing(BaseModel):
|
|
18
|
+
path: str
|
|
19
|
+
parent: str | None
|
|
20
|
+
entries: list[FileEntry]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DirectoryCreate(BaseModel):
|
|
24
|
+
path: str = ""
|
|
25
|
+
name: str = Field(min_length=1, max_length=255)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ClipboardUpdate(BaseModel):
|
|
29
|
+
type: Literal["clipboard"]
|
|
30
|
+
content: str = Field(max_length=1_000_000)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TextContent(BaseModel):
|
|
34
|
+
content: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UploadResult(BaseModel):
|
|
38
|
+
name: str
|
|
39
|
+
path: str
|
|
40
|
+
size: int
|
|
41
|
+
|
sharedserver/security.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import unicodedata
|
|
5
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SecurityError(ValueError):
|
|
9
|
+
"""Raised when user input attempts to escape the shared directory."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
WINDOWS_RESERVED = {
|
|
13
|
+
"CON", "PRN", "AUX", "NUL",
|
|
14
|
+
*(f"COM{number}" for number in range(1, 10)),
|
|
15
|
+
*(f"LPT{number}" for number in range(1, 10)),
|
|
16
|
+
}
|
|
17
|
+
INVALID_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_within(root: Path, supplied: str = "", *, must_exist: bool = True) -> Path:
|
|
21
|
+
root = root.resolve(strict=True)
|
|
22
|
+
if "\x00" in supplied:
|
|
23
|
+
raise SecurityError("path contains a null byte")
|
|
24
|
+
|
|
25
|
+
normalized = supplied.replace("\\", "/")
|
|
26
|
+
posix_path = PurePosixPath(normalized)
|
|
27
|
+
windows_path = PureWindowsPath(supplied)
|
|
28
|
+
if posix_path.is_absolute() or windows_path.is_absolute() or windows_path.drive:
|
|
29
|
+
raise SecurityError("absolute paths are not allowed")
|
|
30
|
+
if ".." in posix_path.parts:
|
|
31
|
+
raise SecurityError("parent path segments are not allowed")
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
candidate = (root / Path(*posix_path.parts)).resolve(strict=must_exist)
|
|
35
|
+
candidate.relative_to(root)
|
|
36
|
+
except (OSError, ValueError) as exc:
|
|
37
|
+
raise SecurityError("path is outside the shared directory") from exc
|
|
38
|
+
return candidate
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sanitize_filename(raw_name: str) -> str:
|
|
42
|
+
name = unicodedata.normalize("NFC", raw_name.replace("\\", "/").split("/")[-1])
|
|
43
|
+
name = INVALID_FILENAME_CHARS.sub("_", name).strip().rstrip(". ")
|
|
44
|
+
if name in {"", ".", ".."}:
|
|
45
|
+
name = "upload"
|
|
46
|
+
if name.split(".", 1)[0].upper() in WINDOWS_RESERVED:
|
|
47
|
+
name = f"_{name}"
|
|
48
|
+
while len(name.encode("utf-8")) > 240:
|
|
49
|
+
name = name[:-1]
|
|
50
|
+
return name
|
|
51
|
+
|
sharedserver/server.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, HTTPException, Query, Request, WebSocket
|
|
7
|
+
from fastapi.responses import FileResponse, HTMLResponse
|
|
8
|
+
from fastapi.staticfiles import StaticFiles
|
|
9
|
+
|
|
10
|
+
from .clipboard import ClipboardPolicy
|
|
11
|
+
from .config import AppConfig
|
|
12
|
+
from .file_manager import FileManager, UploadTooLargeError
|
|
13
|
+
from .models import (
|
|
14
|
+
DirectoryCreate,
|
|
15
|
+
DirectoryListing,
|
|
16
|
+
TextContent,
|
|
17
|
+
UploadResult,
|
|
18
|
+
)
|
|
19
|
+
from .security import SecurityError
|
|
20
|
+
from .websocket import ClipboardHub
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _http_error(exc: Exception) -> HTTPException:
|
|
24
|
+
if isinstance(exc, SecurityError):
|
|
25
|
+
return HTTPException(status_code=403, detail=str(exc))
|
|
26
|
+
if isinstance(exc, (FileNotFoundError, NotADirectoryError)):
|
|
27
|
+
return HTTPException(status_code=404, detail="file or directory not found")
|
|
28
|
+
if isinstance(exc, FileExistsError):
|
|
29
|
+
return HTTPException(status_code=409, detail="a file with that name already exists")
|
|
30
|
+
if isinstance(exc, UploadTooLargeError):
|
|
31
|
+
return HTTPException(status_code=413, detail=str(exc))
|
|
32
|
+
return HTTPException(status_code=400, detail=str(exc))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _same_origin(websocket: WebSocket) -> bool:
|
|
36
|
+
origin = websocket.headers.get("origin")
|
|
37
|
+
host = websocket.headers.get("host")
|
|
38
|
+
return not origin or not host or urlsplit(origin).netloc.casefold() == host.casefold()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_app(config: AppConfig) -> FastAPI:
|
|
42
|
+
package_dir = Path(__file__).parent
|
|
43
|
+
policy = ClipboardPolicy(config.clipboard)
|
|
44
|
+
manager = FileManager(config.share.directory, config.share.max_upload_size, policy)
|
|
45
|
+
hub = ClipboardHub()
|
|
46
|
+
|
|
47
|
+
app = FastAPI(title="SharedServer", version="0.1.0", docs_url=None, redoc_url=None)
|
|
48
|
+
app.state.config = config
|
|
49
|
+
app.state.file_manager = manager
|
|
50
|
+
app.state.clipboard_hub = hub
|
|
51
|
+
app.mount("/static", StaticFiles(directory=package_dir / "static"), name="static")
|
|
52
|
+
|
|
53
|
+
@app.middleware("http")
|
|
54
|
+
async def security_headers(request: Request, call_next):
|
|
55
|
+
response = await call_next(request)
|
|
56
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
57
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
58
|
+
response.headers["Referrer-Policy"] = "no-referrer"
|
|
59
|
+
response.headers["Content-Security-Policy"] = (
|
|
60
|
+
"default-src 'self'; connect-src 'self' ws: wss:; "
|
|
61
|
+
"img-src 'self' blob: data:; object-src 'none'; frame-ancestors 'none'"
|
|
62
|
+
)
|
|
63
|
+
return response
|
|
64
|
+
|
|
65
|
+
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
|
66
|
+
async def index() -> HTMLResponse:
|
|
67
|
+
html = (package_dir / "templates" / "index.html").read_text(encoding="utf-8")
|
|
68
|
+
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
|
|
69
|
+
|
|
70
|
+
@app.get("/api/files", response_model=DirectoryListing)
|
|
71
|
+
async def list_files(path: str = Query(default="")) -> DirectoryListing:
|
|
72
|
+
try:
|
|
73
|
+
return manager.list_directory(path)
|
|
74
|
+
except (SecurityError, FileNotFoundError, NotADirectoryError, OSError) as exc:
|
|
75
|
+
raise _http_error(exc) from exc
|
|
76
|
+
|
|
77
|
+
@app.post("/api/directories", status_code=201)
|
|
78
|
+
async def create_directory(body: DirectoryCreate) -> dict[str, str]:
|
|
79
|
+
try:
|
|
80
|
+
return {"path": manager.create_directory(body.path, body.name)}
|
|
81
|
+
except (SecurityError, FileNotFoundError, NotADirectoryError, FileExistsError, OSError) as exc:
|
|
82
|
+
raise _http_error(exc) from exc
|
|
83
|
+
|
|
84
|
+
@app.put("/api/upload", response_model=UploadResult, status_code=201)
|
|
85
|
+
async def upload(
|
|
86
|
+
request: Request,
|
|
87
|
+
path: str = Query(default=""),
|
|
88
|
+
filename: str = Query(min_length=1, max_length=1024),
|
|
89
|
+
) -> UploadResult:
|
|
90
|
+
content_length = request.headers.get("content-length")
|
|
91
|
+
if content_length:
|
|
92
|
+
try:
|
|
93
|
+
if int(content_length) > config.share.max_upload_size:
|
|
94
|
+
raise HTTPException(status_code=413, detail="upload is too large")
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
raise HTTPException(status_code=400, detail="invalid Content-Length") from exc
|
|
97
|
+
try:
|
|
98
|
+
return await manager.save_upload(path, filename, request.stream())
|
|
99
|
+
except (
|
|
100
|
+
SecurityError,
|
|
101
|
+
FileNotFoundError,
|
|
102
|
+
NotADirectoryError,
|
|
103
|
+
FileExistsError,
|
|
104
|
+
UploadTooLargeError,
|
|
105
|
+
OSError,
|
|
106
|
+
) as exc:
|
|
107
|
+
raise _http_error(exc) from exc
|
|
108
|
+
|
|
109
|
+
@app.get("/api/download")
|
|
110
|
+
async def download(path: str = Query(min_length=1)) -> FileResponse:
|
|
111
|
+
try:
|
|
112
|
+
file = manager.regular_file(path)
|
|
113
|
+
return FileResponse(file, filename=file.name)
|
|
114
|
+
except (SecurityError, FileNotFoundError, OSError) as exc:
|
|
115
|
+
raise _http_error(exc) from exc
|
|
116
|
+
|
|
117
|
+
@app.get("/api/text", response_model=TextContent)
|
|
118
|
+
async def text_content(path: str = Query(min_length=1)) -> TextContent:
|
|
119
|
+
try:
|
|
120
|
+
file = manager.regular_file(path)
|
|
121
|
+
return TextContent(content=policy.read_text(file))
|
|
122
|
+
except (SecurityError, FileNotFoundError, ValueError, OSError) as exc:
|
|
123
|
+
raise _http_error(exc) from exc
|
|
124
|
+
|
|
125
|
+
@app.get("/api/image")
|
|
126
|
+
async def image_content(path: str = Query(min_length=1)) -> FileResponse:
|
|
127
|
+
try:
|
|
128
|
+
file = manager.regular_file(path)
|
|
129
|
+
if policy.copy_kind(file) != "image":
|
|
130
|
+
raise ValueError("file type is not allowed for image copying")
|
|
131
|
+
return FileResponse(file, headers={"Cache-Control": "no-store"})
|
|
132
|
+
except (SecurityError, FileNotFoundError, ValueError, OSError) as exc:
|
|
133
|
+
raise _http_error(exc) from exc
|
|
134
|
+
|
|
135
|
+
@app.websocket("/ws")
|
|
136
|
+
async def clipboard_socket(websocket: WebSocket) -> None:
|
|
137
|
+
if not _same_origin(websocket):
|
|
138
|
+
await websocket.close(code=1008, reason="origin not allowed")
|
|
139
|
+
return
|
|
140
|
+
await hub.handle(websocket)
|
|
141
|
+
|
|
142
|
+
return app
|