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.
@@ -0,0 +1,4 @@
1
+ """SharedServer package."""
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
6
+
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
+
@@ -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