tempo-cli-100 1.0.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.
- tempo_cli_100-1.0.0/PKG-INFO +91 -0
- tempo_cli_100-1.0.0/README.md +82 -0
- tempo_cli_100-1.0.0/pyproject.toml +18 -0
- tempo_cli_100-1.0.0/setup.cfg +4 -0
- tempo_cli_100-1.0.0/tempo_cli/__init__.py +3 -0
- tempo_cli_100-1.0.0/tempo_cli/__main__.py +6 -0
- tempo_cli_100-1.0.0/tempo_cli/cli.py +178 -0
- tempo_cli_100-1.0.0/tempo_cli/client.py +115 -0
- tempo_cli_100-1.0.0/tempo_cli/scanner.py +70 -0
- tempo_cli_100-1.0.0/tempo_cli/server.py +330 -0
- tempo_cli_100-1.0.0/tempo_cli/templates.py +224 -0
- tempo_cli_100-1.0.0/tempo_cli/utils.py +85 -0
- tempo_cli_100-1.0.0/tempo_cli_100.egg-info/PKG-INFO +91 -0
- tempo_cli_100-1.0.0/tempo_cli_100.egg-info/SOURCES.txt +15 -0
- tempo_cli_100-1.0.0/tempo_cli_100.egg-info/dependency_links.txt +1 -0
- tempo_cli_100-1.0.0/tempo_cli_100.egg-info/entry_points.txt +2 -0
- tempo_cli_100-1.0.0/tempo_cli_100.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tempo-cli-100
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A fast, minimal LAN file-sharing CLI and web server.
|
|
5
|
+
Author: Tempo contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# Tempo
|
|
11
|
+
|
|
12
|
+
Tempo เป็น CLI สำหรับแชร์ไฟล์ในวง LAN ที่เร็วและมีฟีเจอร์มากกว่า
|
|
13
|
+
`python -m http.server` ธรรมดา:
|
|
14
|
+
|
|
15
|
+
- **Threaded server** (`ThreadingHTTPServer`) รองรับหลาย client พร้อมกัน
|
|
16
|
+
- **HTTP Range requests** — เล่นวิดีโอ/เสียงแบบ stream/scrub ได้ ไม่ต้องโหลดทั้งไฟล์
|
|
17
|
+
- **โหมดสิทธิ์ (permission)**: `download` / `upload` / `full`
|
|
18
|
+
- **Upload ผ่านเว็บ** ด้วย multipart/form-data (ไม่พึ่ง dependency ภายนอก)
|
|
19
|
+
- **Direct download/upload** จาก command line โดยไม่ต้องเปิดเบราว์เซอร์
|
|
20
|
+
- **LAN scanner** หา Tempo server อื่นในวงแลน ด้วย thread pool
|
|
21
|
+
- **หน้าเว็บมินิมอล** สไตล์ file manager ยุค Windows แรกๆ
|
|
22
|
+
|
|
23
|
+
เขียนด้วย Python standard library ล้วน ไม่มี dependency ภายนอก
|
|
24
|
+
|
|
25
|
+
## ติดตั้ง
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install .
|
|
29
|
+
# หรือระหว่างพัฒนา:
|
|
30
|
+
pip install -e .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
หลังติดตั้งจะได้คำสั่ง `tempo` ในเครื่อง หรือจะรันตรงโดยไม่ติดตั้งก็ได้:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python3 -m tempo_cli [options] [host]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Syntax
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
tempo [OPTION]... [HOST]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| ตัวเลือก | รูปแบบ | ความหมาย |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `-h`, `--help` | | แสดงข้อความช่วยเหลือแล้วออก |
|
|
48
|
+
| `-v`, `--version` | | แสดงเวอร์ชันแล้วออก |
|
|
49
|
+
| `-p`, `--port` | `PORT` | พอร์ต 0-65535 (default: 8000) |
|
|
50
|
+
| `-pm`, `--permission` | `download\|upload\|full` | โหมดสิทธิ์ (default: full) |
|
|
51
|
+
| `-d`, `--download` | `[HOST]:[PORT]/[FILEPATH]` | โหลดไฟล์จาก Tempo server ทันที |
|
|
52
|
+
| `-u`, `--upload` | `FILEPATH:[HOST]:[PORT]` | อัปโหลดไฟล์ไปยัง Tempo server ทันที |
|
|
53
|
+
| `-s`, `--scan` | `[TIMEOUT] [PORT] [IP]` | สแกนหา Tempo server ในวงแลน (`IP=auto` เพื่อสแกนทั้ง subnet) |
|
|
54
|
+
|
|
55
|
+
รองรับทั้งรูปแบบ `--option value` และ `--option=value` ตาม GNU Coding Standards
|
|
56
|
+
|
|
57
|
+
## ตัวอย่างการใช้งาน
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# แชร์โฟลเดอร์ปัจจุบัน พอร์ต 8000 ทุก interface
|
|
61
|
+
tempo
|
|
62
|
+
|
|
63
|
+
# ระบุพอร์ตและ host ที่จะ bind
|
|
64
|
+
tempo -p 9000 0.0.0.0
|
|
65
|
+
|
|
66
|
+
# โหมดอ่านอย่างเดียว (ไม่มีปุ่ม upload)
|
|
67
|
+
tempo --permission=download
|
|
68
|
+
|
|
69
|
+
# โหมด upload อย่างเดียว (มีแค่หน้า upload)
|
|
70
|
+
tempo -pm upload
|
|
71
|
+
|
|
72
|
+
# โหลดไฟล์ทันทีจาก server ที่รู้จักอยู่แล้ว
|
|
73
|
+
tempo -d 192.168.1.5:8000/movie.mp4
|
|
74
|
+
|
|
75
|
+
# อัปโหลดไฟล์ทันที ไปยัง server ที่อนุญาต upload
|
|
76
|
+
tempo -u ./photo.png:192.168.1.5:8000
|
|
77
|
+
|
|
78
|
+
# สแกนทั้ง subnet หา Tempo server อื่น (timeout เริ่มต้น 1s, port 8000)
|
|
79
|
+
tempo -s
|
|
80
|
+
|
|
81
|
+
# สแกนแบบระบุ timeout/port/target เอง
|
|
82
|
+
tempo -s 0.5 8000 auto
|
|
83
|
+
tempo -s 1.0 8000 192.168.1.20
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## หมายเหตุด้านความปลอดภัย
|
|
87
|
+
|
|
88
|
+
Tempo ไม่มีระบบ authentication ใดๆ — ใครก็ตามที่เข้าถึงวงแลนเดียวกัน
|
|
89
|
+
และรู้ IP:PORT จะสามารถเข้าถึง (หรืออัปโหลด/ดาวน์โหลด ตามโหมดสิทธิ์) ได้ทันที
|
|
90
|
+
เหมาะสำหรับใช้งานในเครือข่ายที่เชื่อถือได้เท่านั้น (บ้าน/ออฟฟิศ/ทีมเดียวกัน)
|
|
91
|
+
ไม่แนะนำให้เปิดออก public internet
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Tempo
|
|
2
|
+
|
|
3
|
+
Tempo เป็น CLI สำหรับแชร์ไฟล์ในวง LAN ที่เร็วและมีฟีเจอร์มากกว่า
|
|
4
|
+
`python -m http.server` ธรรมดา:
|
|
5
|
+
|
|
6
|
+
- **Threaded server** (`ThreadingHTTPServer`) รองรับหลาย client พร้อมกัน
|
|
7
|
+
- **HTTP Range requests** — เล่นวิดีโอ/เสียงแบบ stream/scrub ได้ ไม่ต้องโหลดทั้งไฟล์
|
|
8
|
+
- **โหมดสิทธิ์ (permission)**: `download` / `upload` / `full`
|
|
9
|
+
- **Upload ผ่านเว็บ** ด้วย multipart/form-data (ไม่พึ่ง dependency ภายนอก)
|
|
10
|
+
- **Direct download/upload** จาก command line โดยไม่ต้องเปิดเบราว์เซอร์
|
|
11
|
+
- **LAN scanner** หา Tempo server อื่นในวงแลน ด้วย thread pool
|
|
12
|
+
- **หน้าเว็บมินิมอล** สไตล์ file manager ยุค Windows แรกๆ
|
|
13
|
+
|
|
14
|
+
เขียนด้วย Python standard library ล้วน ไม่มี dependency ภายนอก
|
|
15
|
+
|
|
16
|
+
## ติดตั้ง
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install .
|
|
20
|
+
# หรือระหว่างพัฒนา:
|
|
21
|
+
pip install -e .
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
หลังติดตั้งจะได้คำสั่ง `tempo` ในเครื่อง หรือจะรันตรงโดยไม่ติดตั้งก็ได้:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
python3 -m tempo_cli [options] [host]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Syntax
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
tempo [OPTION]... [HOST]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| ตัวเลือก | รูปแบบ | ความหมาย |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `-h`, `--help` | | แสดงข้อความช่วยเหลือแล้วออก |
|
|
39
|
+
| `-v`, `--version` | | แสดงเวอร์ชันแล้วออก |
|
|
40
|
+
| `-p`, `--port` | `PORT` | พอร์ต 0-65535 (default: 8000) |
|
|
41
|
+
| `-pm`, `--permission` | `download\|upload\|full` | โหมดสิทธิ์ (default: full) |
|
|
42
|
+
| `-d`, `--download` | `[HOST]:[PORT]/[FILEPATH]` | โหลดไฟล์จาก Tempo server ทันที |
|
|
43
|
+
| `-u`, `--upload` | `FILEPATH:[HOST]:[PORT]` | อัปโหลดไฟล์ไปยัง Tempo server ทันที |
|
|
44
|
+
| `-s`, `--scan` | `[TIMEOUT] [PORT] [IP]` | สแกนหา Tempo server ในวงแลน (`IP=auto` เพื่อสแกนทั้ง subnet) |
|
|
45
|
+
|
|
46
|
+
รองรับทั้งรูปแบบ `--option value` และ `--option=value` ตาม GNU Coding Standards
|
|
47
|
+
|
|
48
|
+
## ตัวอย่างการใช้งาน
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# แชร์โฟลเดอร์ปัจจุบัน พอร์ต 8000 ทุก interface
|
|
52
|
+
tempo
|
|
53
|
+
|
|
54
|
+
# ระบุพอร์ตและ host ที่จะ bind
|
|
55
|
+
tempo -p 9000 0.0.0.0
|
|
56
|
+
|
|
57
|
+
# โหมดอ่านอย่างเดียว (ไม่มีปุ่ม upload)
|
|
58
|
+
tempo --permission=download
|
|
59
|
+
|
|
60
|
+
# โหมด upload อย่างเดียว (มีแค่หน้า upload)
|
|
61
|
+
tempo -pm upload
|
|
62
|
+
|
|
63
|
+
# โหลดไฟล์ทันทีจาก server ที่รู้จักอยู่แล้ว
|
|
64
|
+
tempo -d 192.168.1.5:8000/movie.mp4
|
|
65
|
+
|
|
66
|
+
# อัปโหลดไฟล์ทันที ไปยัง server ที่อนุญาต upload
|
|
67
|
+
tempo -u ./photo.png:192.168.1.5:8000
|
|
68
|
+
|
|
69
|
+
# สแกนทั้ง subnet หา Tempo server อื่น (timeout เริ่มต้น 1s, port 8000)
|
|
70
|
+
tempo -s
|
|
71
|
+
|
|
72
|
+
# สแกนแบบระบุ timeout/port/target เอง
|
|
73
|
+
tempo -s 0.5 8000 auto
|
|
74
|
+
tempo -s 1.0 8000 192.168.1.20
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## หมายเหตุด้านความปลอดภัย
|
|
78
|
+
|
|
79
|
+
Tempo ไม่มีระบบ authentication ใดๆ — ใครก็ตามที่เข้าถึงวงแลนเดียวกัน
|
|
80
|
+
และรู้ IP:PORT จะสามารถเข้าถึง (หรืออัปโหลด/ดาวน์โหลด ตามโหมดสิทธิ์) ได้ทันที
|
|
81
|
+
เหมาะสำหรับใช้งานในเครือข่ายที่เชื่อถือได้เท่านั้น (บ้าน/ออฟฟิศ/ทีมเดียวกัน)
|
|
82
|
+
ไม่แนะนำให้เปิดออก public internet
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tempo-cli-100"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "A fast, minimal LAN file-sharing CLI and web server."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Tempo contributors" }]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
tempo = "tempo_cli.cli:main"
|
|
16
|
+
|
|
17
|
+
[tool.setuptools]
|
|
18
|
+
packages = ["tempo_cli"]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""tempo(1) -- fast, minimal LAN file sharing.
|
|
2
|
+
|
|
3
|
+
Argument handling follows the POSIX Utility Syntax Guidelines (short
|
|
4
|
+
options bundle-able, '--' ends option parsing, '-' alone is meaningful)
|
|
5
|
+
and the GNU Coding Standards (every short option has a long equivalent,
|
|
6
|
+
'--help' and '--version' behave as specified, '--opt=value' is accepted).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from . import client
|
|
14
|
+
from . import scanner
|
|
15
|
+
from . import server
|
|
16
|
+
from .utils import valid_port
|
|
17
|
+
|
|
18
|
+
PROG = "tempo"
|
|
19
|
+
|
|
20
|
+
USAGE = f"{PROG} [OPTION]... [HOST]"
|
|
21
|
+
|
|
22
|
+
DESCRIPTION = (
|
|
23
|
+
"Tempo shares files over the local network: a lightweight, threaded "
|
|
24
|
+
"replacement for `python -m http.server` with a minimal browsable UI, "
|
|
25
|
+
"video/audio/text preview, uploads, and LAN discovery."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
EPILOG = """\
|
|
29
|
+
Examples:
|
|
30
|
+
tempo serve the current directory on all interfaces, port 8000
|
|
31
|
+
tempo -p 9000 0.0.0.0 serve on port 9000, bound to 0.0.0.0
|
|
32
|
+
tempo --permission=download read-only file browser (no upload button)
|
|
33
|
+
tempo -pm upload upload-only mode (no browsing/download)
|
|
34
|
+
tempo -d 192.168.1.5:8000/movie.mp4 download a file directly, no browser needed
|
|
35
|
+
tempo -u ./photo.png:192.168.1.5:8000 upload a file directly, no browser needed
|
|
36
|
+
tempo -s scan the local /24 subnet for Tempo servers
|
|
37
|
+
tempo -s 0.5 8000 auto same, with an explicit timeout/port/target
|
|
38
|
+
|
|
39
|
+
Report bugs to your local network administrator, or the project issue tracker.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_parser():
|
|
44
|
+
parser = argparse.ArgumentParser(
|
|
45
|
+
prog=PROG,
|
|
46
|
+
usage=USAGE,
|
|
47
|
+
description=DESCRIPTION,
|
|
48
|
+
epilog=EPILOG,
|
|
49
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
50
|
+
add_help=False, # we add -h/--help manually to control ordering/wording
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"host",
|
|
55
|
+
nargs="?",
|
|
56
|
+
default="0.0.0.0",
|
|
57
|
+
metavar="HOST",
|
|
58
|
+
help="address to bind the server to (default: 0.0.0.0, i.e. all interfaces)",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"-h", "--help",
|
|
63
|
+
action="help",
|
|
64
|
+
default=argparse.SUPPRESS,
|
|
65
|
+
help="display this help text and exit",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"-v", "--version",
|
|
69
|
+
action="version",
|
|
70
|
+
version=f"{PROG} {__version__}",
|
|
71
|
+
help="output version information and exit",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"-p", "--port",
|
|
75
|
+
metavar="PORT",
|
|
76
|
+
default=8000,
|
|
77
|
+
type=int,
|
|
78
|
+
help="TCP port to listen on, 0-65535 (default: 8000)",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"-pm", "--permission",
|
|
82
|
+
metavar="MODE",
|
|
83
|
+
choices=("download", "upload", "full"),
|
|
84
|
+
default="full",
|
|
85
|
+
help="access mode: 'download' (browse/read/download only), "
|
|
86
|
+
"'upload' (upload only), or 'full' (both; default)",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"-d", "--download",
|
|
90
|
+
metavar="[HOST]:[PORT]/[FILEPATH]",
|
|
91
|
+
help="fetch FILEPATH from a running Tempo server directly, no browser needed",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"-u", "--upload",
|
|
95
|
+
metavar="FILEPATH:[HOST]:[PORT]",
|
|
96
|
+
help="push FILEPATH to a running Tempo server directly, "
|
|
97
|
+
"if that server's permission mode allows uploads",
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"-s", "--scan",
|
|
101
|
+
nargs="*",
|
|
102
|
+
metavar="[TIMEOUT] [PORT] [IP]",
|
|
103
|
+
help="scan for Tempo servers on the LAN; IP may be a single address "
|
|
104
|
+
"or 'auto' to sweep the local /24 subnet (default: 1.0 8000 auto)",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return parser
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _dispatch_scan(scan_args):
|
|
111
|
+
timeout, port, ip = scanner.DEFAULT_TIMEOUT, scanner.DEFAULT_PORT, "auto"
|
|
112
|
+
if len(scan_args) >= 1 and scan_args[0]:
|
|
113
|
+
try:
|
|
114
|
+
timeout = float(scan_args[0])
|
|
115
|
+
except ValueError:
|
|
116
|
+
print(f"tempo: invalid timeout: {scan_args[0]!r}", file=sys.stderr)
|
|
117
|
+
return 1
|
|
118
|
+
if len(scan_args) >= 2 and scan_args[1]:
|
|
119
|
+
try:
|
|
120
|
+
port = valid_port(scan_args[1])
|
|
121
|
+
except ValueError as exc:
|
|
122
|
+
print(f"tempo: {exc}", file=sys.stderr)
|
|
123
|
+
return 1
|
|
124
|
+
if len(scan_args) >= 3 and scan_args[2]:
|
|
125
|
+
ip = scan_args[2]
|
|
126
|
+
if len(scan_args) > 3:
|
|
127
|
+
print("tempo: --scan takes at most 3 arguments: [timeout] [port] [ip]", file=sys.stderr)
|
|
128
|
+
return 1
|
|
129
|
+
|
|
130
|
+
scanner.scan(ip, port=port, timeout=timeout)
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _dispatch_download(spec):
|
|
135
|
+
try:
|
|
136
|
+
host, port, filepath = client.parse_download_spec(spec)
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
print(f"tempo: {exc}", file=sys.stderr)
|
|
139
|
+
return 1
|
|
140
|
+
ok = client.download_file(host, port, filepath)
|
|
141
|
+
return 0 if ok else 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _dispatch_upload(spec):
|
|
145
|
+
try:
|
|
146
|
+
filepath, host, port = client.parse_upload_spec(spec)
|
|
147
|
+
except ValueError as exc:
|
|
148
|
+
print(f"tempo: {exc}", file=sys.stderr)
|
|
149
|
+
return 1
|
|
150
|
+
ok = client.upload_file(filepath, host, port)
|
|
151
|
+
return 0 if ok else 1
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main(argv=None):
|
|
155
|
+
parser = build_parser()
|
|
156
|
+
args = parser.parse_args(argv)
|
|
157
|
+
|
|
158
|
+
if not (0 <= args.port <= 65535):
|
|
159
|
+
parser.error(f"argument -p/--port: port out of range (0-65535): {args.port}")
|
|
160
|
+
|
|
161
|
+
# Mutually-exclusive "action" modes: scan / download / upload / serve.
|
|
162
|
+
if args.scan is not None:
|
|
163
|
+
return _dispatch_scan(args.scan)
|
|
164
|
+
if args.download:
|
|
165
|
+
return _dispatch_download(args.download)
|
|
166
|
+
if args.upload:
|
|
167
|
+
return _dispatch_upload(args.upload)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
server.run_server(args.host, args.port, args.permission)
|
|
171
|
+
except OSError as exc:
|
|
172
|
+
print(f"tempo: cannot bind to {args.host}:{args.port}: {exc}", file=sys.stderr)
|
|
173
|
+
return 1
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
sys.exit(main())
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Client-side shortcuts: fetch a file from a Tempo server, or push one to it,
|
|
2
|
+
without opening a browser.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import http.client
|
|
6
|
+
import mimetypes
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
DOWNLOAD_RE = re.compile(r"^(?P<host>[^:/]+):(?P<port>\d+)/(?P<filepath>.+)$")
|
|
14
|
+
UPLOAD_RE = re.compile(r"^(?P<filepath>.+):(?P<host>[^:]+):(?P<port>\d+)$")
|
|
15
|
+
|
|
16
|
+
CHUNK_SIZE = 256 * 1024
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_download_spec(spec):
|
|
20
|
+
match = DOWNLOAD_RE.match(spec)
|
|
21
|
+
if not match:
|
|
22
|
+
raise ValueError(
|
|
23
|
+
"invalid --download format, expected [host]:[port]/[filepath]"
|
|
24
|
+
)
|
|
25
|
+
return match.group("host"), int(match.group("port")), match.group("filepath")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_upload_spec(spec):
|
|
29
|
+
match = UPLOAD_RE.match(spec)
|
|
30
|
+
if not match:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
"invalid --upload format, expected filepath:[host]:[port]"
|
|
33
|
+
)
|
|
34
|
+
return match.group("filepath"), match.group("host"), int(match.group("port"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _progress(done, total, label):
|
|
38
|
+
if total:
|
|
39
|
+
pct = done * 100 // total
|
|
40
|
+
bar = "#" * (pct // 4)
|
|
41
|
+
sys.stdout.write(f"\r{label} [{bar:<25}] {pct:3d}%")
|
|
42
|
+
else:
|
|
43
|
+
sys.stdout.write(f"\r{label} {done} bytes")
|
|
44
|
+
sys.stdout.flush()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def download_file(host, port, filepath, dest_dir="."):
|
|
48
|
+
quoted_path = "/" + urllib.parse.quote(filepath.lstrip("/")) + "?dl=1"
|
|
49
|
+
conn = http.client.HTTPConnection(host, port, timeout=15)
|
|
50
|
+
try:
|
|
51
|
+
conn.request("GET", quoted_path)
|
|
52
|
+
resp = conn.getresponse()
|
|
53
|
+
if resp.status not in (200,):
|
|
54
|
+
print(f"Download failed: HTTP {resp.status} {resp.reason}")
|
|
55
|
+
return False
|
|
56
|
+
total = int(resp.getheader("Content-Length", 0))
|
|
57
|
+
name = os.path.basename(filepath) or "download.bin"
|
|
58
|
+
dest = os.path.join(dest_dir, name)
|
|
59
|
+
done = 0
|
|
60
|
+
with open(dest, "wb") as fh:
|
|
61
|
+
while True:
|
|
62
|
+
chunk = resp.read(CHUNK_SIZE)
|
|
63
|
+
if not chunk:
|
|
64
|
+
break
|
|
65
|
+
fh.write(chunk)
|
|
66
|
+
done += len(chunk)
|
|
67
|
+
_progress(done, total, "Downloading")
|
|
68
|
+
print(f"\nSaved to {os.path.abspath(dest)}")
|
|
69
|
+
return True
|
|
70
|
+
except (OSError, http.client.HTTPException) as exc:
|
|
71
|
+
print(f"Download failed: {exc}")
|
|
72
|
+
return False
|
|
73
|
+
finally:
|
|
74
|
+
conn.close()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def upload_file(filepath, host, port):
|
|
78
|
+
if not os.path.isfile(filepath):
|
|
79
|
+
print(f"Upload failed: no such file: {filepath}")
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
boundary = uuid.uuid4().hex
|
|
83
|
+
name = os.path.basename(filepath)
|
|
84
|
+
mime_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
85
|
+
|
|
86
|
+
with open(filepath, "rb") as fh:
|
|
87
|
+
file_data = fh.read()
|
|
88
|
+
|
|
89
|
+
pre = (
|
|
90
|
+
f"--{boundary}\r\n"
|
|
91
|
+
f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n'
|
|
92
|
+
f"Content-Type: {mime_type}\r\n\r\n"
|
|
93
|
+
).encode()
|
|
94
|
+
post = f"\r\n--{boundary}--\r\n".encode()
|
|
95
|
+
body = pre + file_data + post
|
|
96
|
+
|
|
97
|
+
conn = http.client.HTTPConnection(host, port, timeout=30)
|
|
98
|
+
try:
|
|
99
|
+
headers = {
|
|
100
|
+
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
101
|
+
"Content-Length": str(len(body)),
|
|
102
|
+
}
|
|
103
|
+
conn.request("POST", "/upload", body=body, headers=headers)
|
|
104
|
+
resp = conn.getresponse()
|
|
105
|
+
resp.read()
|
|
106
|
+
if resp.status == 200:
|
|
107
|
+
print(f"Uploaded '{name}' to {host}:{port} successfully.")
|
|
108
|
+
return True
|
|
109
|
+
print(f"Upload failed: HTTP {resp.status} {resp.reason}")
|
|
110
|
+
return False
|
|
111
|
+
except (OSError, http.client.HTTPException) as exc:
|
|
112
|
+
print(f"Upload failed: {exc}")
|
|
113
|
+
return False
|
|
114
|
+
finally:
|
|
115
|
+
conn.close()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Scan the LAN for other running Tempo servers.
|
|
2
|
+
|
|
3
|
+
Two-stage check per host: (1) can we open a TCP connection to the port
|
|
4
|
+
within the timeout, and (2) does an HTTP GET / to that host answer with the
|
|
5
|
+
'X-Tempo-Server' marker header that only Tempo sets. Both stages run under a
|
|
6
|
+
shared timeout and are dispatched across a thread pool for speed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import concurrent.futures
|
|
10
|
+
import http.client
|
|
11
|
+
import socket
|
|
12
|
+
|
|
13
|
+
from .utils import get_local_ip, guess_subnet_hosts
|
|
14
|
+
|
|
15
|
+
DEFAULT_TIMEOUT = 1.0
|
|
16
|
+
DEFAULT_PORT = 8000
|
|
17
|
+
DEFAULT_MAX_WORKERS = 100
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def probe_host(ip, port, timeout):
|
|
21
|
+
"""Return True if a Tempo server is confirmed running at ip:port."""
|
|
22
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
23
|
+
sock.settimeout(timeout)
|
|
24
|
+
try:
|
|
25
|
+
if sock.connect_ex((ip, port)) != 0:
|
|
26
|
+
return False
|
|
27
|
+
except OSError:
|
|
28
|
+
return False
|
|
29
|
+
finally:
|
|
30
|
+
sock.close()
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
conn = http.client.HTTPConnection(ip, port, timeout=timeout)
|
|
34
|
+
conn.request("GET", "/")
|
|
35
|
+
resp = conn.getresponse()
|
|
36
|
+
resp.read()
|
|
37
|
+
conn.close()
|
|
38
|
+
return resp.getheader("X-Tempo-Server") == "1"
|
|
39
|
+
except (OSError, http.client.HTTPException):
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def scan(ip_arg, port=DEFAULT_PORT, timeout=DEFAULT_TIMEOUT, max_workers=DEFAULT_MAX_WORKERS):
|
|
44
|
+
"""Scan one host or the whole /24 subnet ('auto') for Tempo servers."""
|
|
45
|
+
if ip_arg in (None, "auto"):
|
|
46
|
+
local_ip = get_local_ip()
|
|
47
|
+
targets = guess_subnet_hosts(local_ip)
|
|
48
|
+
print(f"Scanning subnet around {local_ip}/24 ({len(targets)} hosts), "
|
|
49
|
+
f"port {port}, timeout {timeout}s ...")
|
|
50
|
+
else:
|
|
51
|
+
targets = [ip_arg]
|
|
52
|
+
print(f"Scanning {ip_arg}, port {port}, timeout {timeout}s ...")
|
|
53
|
+
|
|
54
|
+
found = []
|
|
55
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
56
|
+
future_map = {pool.submit(probe_host, ip, port, timeout): ip for ip in targets}
|
|
57
|
+
for future in concurrent.futures.as_completed(future_map):
|
|
58
|
+
ip = future_map[future]
|
|
59
|
+
try:
|
|
60
|
+
if future.result():
|
|
61
|
+
found.append(ip)
|
|
62
|
+
print(f" \u2713 Tempo server found: http://{ip}:{port}/")
|
|
63
|
+
except Exception:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if not found:
|
|
67
|
+
print("No Tempo servers found.")
|
|
68
|
+
else:
|
|
69
|
+
print(f"\nDone. {len(found)} Tempo server(s) found.")
|
|
70
|
+
return found
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Threaded HTTP server for Tempo.
|
|
2
|
+
|
|
3
|
+
Deliberately dependency-free (stdlib only) but faster and more capable than
|
|
4
|
+
``python -m http.server``: it serves multiple clients concurrently
|
|
5
|
+
(ThreadingHTTPServer), supports HTTP Range requests so videos can be
|
|
6
|
+
scrubbed/streamed instead of downloaded whole, and supports permission-gated
|
|
7
|
+
uploads via a hand-rolled multipart/form-data parser.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import datetime
|
|
11
|
+
import mimetypes
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import socketserver
|
|
15
|
+
import sys
|
|
16
|
+
import urllib.parse
|
|
17
|
+
from http import HTTPStatus
|
|
18
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
19
|
+
|
|
20
|
+
from . import templates
|
|
21
|
+
from .utils import file_kind
|
|
22
|
+
|
|
23
|
+
CHUNK_SIZE = 256 * 1024
|
|
24
|
+
RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _iso(ts):
|
|
28
|
+
return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TempoRequestHandler(BaseHTTPRequestHandler):
|
|
32
|
+
server_version = "Tempo/1.0"
|
|
33
|
+
protocol_version = "HTTP/1.1"
|
|
34
|
+
|
|
35
|
+
# These are set on the class by make_handler_class() below.
|
|
36
|
+
root_dir = "."
|
|
37
|
+
permission = "full"
|
|
38
|
+
|
|
39
|
+
# -- helpers ---------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def can_download(self):
|
|
43
|
+
return self.permission in ("download", "full")
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def can_upload(self):
|
|
47
|
+
return self.permission in ("upload", "full")
|
|
48
|
+
|
|
49
|
+
def _safe_path(self, url_path):
|
|
50
|
+
"""Resolve a URL path to a filesystem path confined to root_dir."""
|
|
51
|
+
path = urllib.parse.unquote(url_path.split("?", 1)[0])
|
|
52
|
+
path = path.lstrip("/")
|
|
53
|
+
full = os.path.normpath(os.path.join(self.root_dir, path))
|
|
54
|
+
root_abs = os.path.abspath(self.root_dir)
|
|
55
|
+
full_abs = os.path.abspath(full)
|
|
56
|
+
if not (full_abs == root_abs or full_abs.startswith(root_abs + os.sep)):
|
|
57
|
+
return None
|
|
58
|
+
return full_abs
|
|
59
|
+
|
|
60
|
+
def _send_html(self, html_text, status=HTTPStatus.OK):
|
|
61
|
+
data = html_text.encode("utf-8")
|
|
62
|
+
self.send_response(status)
|
|
63
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
64
|
+
self.send_header("Content-Length", str(len(data)))
|
|
65
|
+
self.send_header("X-Tempo-Server", "1")
|
|
66
|
+
self.end_headers()
|
|
67
|
+
self.wfile.write(data)
|
|
68
|
+
|
|
69
|
+
def _send_forbidden(self, reason="Not permitted."):
|
|
70
|
+
self._send_html(templates.render_forbidden(reason), status=HTTPStatus.FORBIDDEN)
|
|
71
|
+
|
|
72
|
+
def _send_not_found(self):
|
|
73
|
+
self._send_html(
|
|
74
|
+
templates._page("Tempo :: 404", '<div class="notice">404 Not Found</div>'),
|
|
75
|
+
status=HTTPStatus.NOT_FOUND,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def log_message(self, fmt, *args):
|
|
79
|
+
sys.stderr.write("[tempo] %s - %s\n" % (self.address_string(), fmt % args))
|
|
80
|
+
|
|
81
|
+
# -- GET ---------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def do_GET(self):
|
|
84
|
+
self.send_header # no-op reference to silence linters on some setups
|
|
85
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
86
|
+
url_path = parsed.path
|
|
87
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
88
|
+
|
|
89
|
+
if url_path == "/upload":
|
|
90
|
+
if not self.can_upload:
|
|
91
|
+
self._send_forbidden("Upload is disabled on this server.")
|
|
92
|
+
return
|
|
93
|
+
self._send_html(templates.render_upload_page(url_path))
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
if not self.can_download:
|
|
97
|
+
self._send_forbidden("Browsing/download is disabled on this server.")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
fs_path = self._safe_path(url_path)
|
|
101
|
+
if fs_path is None or not os.path.exists(fs_path):
|
|
102
|
+
self._send_not_found()
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
if os.path.isdir(fs_path):
|
|
106
|
+
self._list_directory(url_path, fs_path)
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
# It's a file.
|
|
110
|
+
ext = os.path.splitext(fs_path)[1]
|
|
111
|
+
kind = file_kind(ext)
|
|
112
|
+
name = os.path.basename(fs_path)
|
|
113
|
+
|
|
114
|
+
if "view" in query and kind:
|
|
115
|
+
if kind == "text":
|
|
116
|
+
try:
|
|
117
|
+
with open(fs_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
118
|
+
text = fh.read(2_000_000) # cap huge files
|
|
119
|
+
except OSError:
|
|
120
|
+
self._send_not_found()
|
|
121
|
+
return
|
|
122
|
+
self._send_html(templates.render_text_viewer(url_path, name, text))
|
|
123
|
+
else:
|
|
124
|
+
self._send_html(templates.render_viewer(url_path, name, kind))
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
force_download = "dl" in query
|
|
128
|
+
self._serve_file(fs_path, name, force_download)
|
|
129
|
+
|
|
130
|
+
def _list_directory(self, url_path, fs_path):
|
|
131
|
+
try:
|
|
132
|
+
names = sorted(os.listdir(fs_path), key=str.lower)
|
|
133
|
+
except OSError:
|
|
134
|
+
self._send_forbidden("Cannot read this directory.")
|
|
135
|
+
return
|
|
136
|
+
entries = []
|
|
137
|
+
for name in names:
|
|
138
|
+
full = os.path.join(fs_path, name)
|
|
139
|
+
try:
|
|
140
|
+
stat_res = os.stat(full)
|
|
141
|
+
except OSError:
|
|
142
|
+
continue
|
|
143
|
+
entries.append(
|
|
144
|
+
{
|
|
145
|
+
"name": name,
|
|
146
|
+
"is_dir": os.path.isdir(full),
|
|
147
|
+
"size": stat_res.st_size,
|
|
148
|
+
"mtime": _iso(stat_res.st_mtime),
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
entries.sort(key=lambda item: (not item["is_dir"], item["name"].lower()))
|
|
152
|
+
self._send_html(
|
|
153
|
+
templates.render_directory(url_path, entries, self.can_download, self.can_upload)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _serve_file(self, fs_path, name, force_download):
|
|
157
|
+
try:
|
|
158
|
+
file_size = os.path.getsize(fs_path)
|
|
159
|
+
except OSError:
|
|
160
|
+
self._send_not_found()
|
|
161
|
+
return
|
|
162
|
+
mime_type = mimetypes.guess_type(fs_path)[0] or "application/octet-stream"
|
|
163
|
+
|
|
164
|
+
range_header = self.headers.get("Range")
|
|
165
|
+
start, end = 0, file_size - 1
|
|
166
|
+
status = HTTPStatus.OK
|
|
167
|
+
if range_header:
|
|
168
|
+
match = RANGE_RE.match(range_header)
|
|
169
|
+
if match:
|
|
170
|
+
if match.group(1):
|
|
171
|
+
start = int(match.group(1))
|
|
172
|
+
if match.group(2):
|
|
173
|
+
end = int(match.group(2))
|
|
174
|
+
else:
|
|
175
|
+
end = file_size - 1
|
|
176
|
+
status = HTTPStatus.PARTIAL_CONTENT
|
|
177
|
+
|
|
178
|
+
if start >= file_size or end >= file_size or start > end:
|
|
179
|
+
self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
|
|
180
|
+
self.send_header("Content-Range", f"bytes */{file_size}")
|
|
181
|
+
self.send_header("X-Tempo-Server", "1")
|
|
182
|
+
self.end_headers()
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
length = end - start + 1
|
|
186
|
+
self.send_response(status)
|
|
187
|
+
self.send_header("Content-Type", mime_type)
|
|
188
|
+
self.send_header("Content-Length", str(length))
|
|
189
|
+
self.send_header("Accept-Ranges", "bytes")
|
|
190
|
+
self.send_header("X-Tempo-Server", "1")
|
|
191
|
+
if force_download:
|
|
192
|
+
self.send_header(
|
|
193
|
+
"Content-Disposition", f'attachment; filename="{name}"'
|
|
194
|
+
)
|
|
195
|
+
if status == HTTPStatus.PARTIAL_CONTENT:
|
|
196
|
+
self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
|
|
197
|
+
self.end_headers()
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
with open(fs_path, "rb") as fh:
|
|
201
|
+
fh.seek(start)
|
|
202
|
+
remaining = length
|
|
203
|
+
while remaining > 0:
|
|
204
|
+
chunk = fh.read(min(CHUNK_SIZE, remaining))
|
|
205
|
+
if not chunk:
|
|
206
|
+
break
|
|
207
|
+
self.wfile.write(chunk)
|
|
208
|
+
remaining -= len(chunk)
|
|
209
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
# -- POST (upload) -------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
def do_POST(self):
|
|
215
|
+
if self.path.rstrip("/") != "/upload" and not self.path.startswith("/upload?"):
|
|
216
|
+
self._send_not_found()
|
|
217
|
+
return
|
|
218
|
+
if not self.can_upload:
|
|
219
|
+
self._send_forbidden("Upload is disabled on this server.")
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
content_type = self.headers.get("Content-Type", "")
|
|
223
|
+
if "multipart/form-data" not in content_type:
|
|
224
|
+
self._send_html(
|
|
225
|
+
templates.render_upload_page("/upload", "Invalid request (expected multipart/form-data)."),
|
|
226
|
+
status=HTTPStatus.BAD_REQUEST,
|
|
227
|
+
)
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
match = re.search(r'boundary="?([^";]+)"?', content_type)
|
|
231
|
+
if not match:
|
|
232
|
+
self._send_html(
|
|
233
|
+
templates.render_upload_page("/upload", "Missing multipart boundary."),
|
|
234
|
+
status=HTTPStatus.BAD_REQUEST,
|
|
235
|
+
)
|
|
236
|
+
return
|
|
237
|
+
boundary = match.group(1).encode()
|
|
238
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
239
|
+
body = self.rfile.read(content_length)
|
|
240
|
+
|
|
241
|
+
filename, file_data = self._parse_multipart(body, boundary)
|
|
242
|
+
if not filename or file_data is None:
|
|
243
|
+
self._send_html(
|
|
244
|
+
templates.render_upload_page("/upload", "No file received."),
|
|
245
|
+
status=HTTPStatus.BAD_REQUEST,
|
|
246
|
+
)
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
safe_name = os.path.basename(filename)
|
|
250
|
+
dest = os.path.join(self.root_dir, safe_name)
|
|
251
|
+
dest = self._deduplicate_path(dest)
|
|
252
|
+
try:
|
|
253
|
+
with open(dest, "wb") as fh:
|
|
254
|
+
fh.write(file_data)
|
|
255
|
+
except OSError as exc:
|
|
256
|
+
self._send_html(
|
|
257
|
+
templates.render_upload_page("/upload", f"Failed to save file: {exc}"),
|
|
258
|
+
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
259
|
+
)
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
self._send_html(
|
|
263
|
+
templates.render_upload_page(
|
|
264
|
+
"/upload", f"\u2713 Uploaded '{os.path.basename(dest)}' successfully."
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _deduplicate_path(path):
|
|
270
|
+
if not os.path.exists(path):
|
|
271
|
+
return path
|
|
272
|
+
base, ext = os.path.splitext(path)
|
|
273
|
+
counter = 1
|
|
274
|
+
while True:
|
|
275
|
+
candidate = f"{base} ({counter}){ext}"
|
|
276
|
+
if not os.path.exists(candidate):
|
|
277
|
+
return candidate
|
|
278
|
+
counter += 1
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _parse_multipart(body, boundary):
|
|
282
|
+
"""Minimal multipart/form-data parser: extracts the first file part."""
|
|
283
|
+
delimiter = b"--" + boundary
|
|
284
|
+
parts = body.split(delimiter)
|
|
285
|
+
for part in parts:
|
|
286
|
+
part = part.strip(b"\r\n")
|
|
287
|
+
if not part or part == b"--":
|
|
288
|
+
continue
|
|
289
|
+
if b"\r\n\r\n" not in part:
|
|
290
|
+
continue
|
|
291
|
+
header_blob, _, content = part.partition(b"\r\n\r\n")
|
|
292
|
+
headers = header_blob.decode(errors="replace")
|
|
293
|
+
if "filename=" not in headers:
|
|
294
|
+
continue
|
|
295
|
+
fname_match = re.search(r'filename="([^"]*)"', headers)
|
|
296
|
+
if not fname_match or not fname_match.group(1):
|
|
297
|
+
continue
|
|
298
|
+
filename = fname_match.group(1)
|
|
299
|
+
# Strip trailing CRLF that precedes the next boundary marker.
|
|
300
|
+
if content.endswith(b"\r\n"):
|
|
301
|
+
content = content[:-2]
|
|
302
|
+
return filename, content
|
|
303
|
+
return None, None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def make_handler_class(root_dir, permission):
|
|
307
|
+
return type(
|
|
308
|
+
"BoundTempoRequestHandler",
|
|
309
|
+
(TempoRequestHandler,),
|
|
310
|
+
{"root_dir": os.path.abspath(root_dir), "permission": permission},
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class TempoServer(ThreadingHTTPServer):
|
|
315
|
+
daemon_threads = True
|
|
316
|
+
allow_reuse_address = True
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def run_server(host, port, permission, root_dir="."):
|
|
320
|
+
handler_cls = make_handler_class(root_dir, permission)
|
|
321
|
+
httpd = TempoServer((host, port), handler_cls)
|
|
322
|
+
bound_host, bound_port = httpd.server_address
|
|
323
|
+
print(f"Tempo serving '{os.path.abspath(root_dir)}' ({permission}) "
|
|
324
|
+
f"at http://{bound_host}:{bound_port}/ (Ctrl+C to stop)")
|
|
325
|
+
try:
|
|
326
|
+
httpd.serve_forever()
|
|
327
|
+
except KeyboardInterrupt:
|
|
328
|
+
print("\nShutting down.")
|
|
329
|
+
finally:
|
|
330
|
+
httpd.server_close()
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Minimal, low-fidelity HTML templates.
|
|
2
|
+
|
|
3
|
+
Styled loosely after early-Windows file managers: a grey chrome, a sunken
|
|
4
|
+
"panel" for the listing, a plain sans-serif font, and just enough CSS to
|
|
5
|
+
keep things readable -- nothing decorative.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import html
|
|
9
|
+
import urllib.parse
|
|
10
|
+
|
|
11
|
+
from .utils import file_icon, file_kind, human_size
|
|
12
|
+
|
|
13
|
+
PAGE_CSS = """
|
|
14
|
+
* { box-sizing: border-box; }
|
|
15
|
+
body {
|
|
16
|
+
margin: 0;
|
|
17
|
+
font-family: Tahoma, "Segoe UI", Verdana, sans-serif;
|
|
18
|
+
font-size: 13px;
|
|
19
|
+
background: #c3c7cb;
|
|
20
|
+
color: #000;
|
|
21
|
+
}
|
|
22
|
+
.titlebar {
|
|
23
|
+
background: linear-gradient(180deg, #4a6ea9 0%, #2f4d7a 100%);
|
|
24
|
+
color: #fff;
|
|
25
|
+
padding: 6px 10px;
|
|
26
|
+
font-weight: bold;
|
|
27
|
+
font-size: 14px;
|
|
28
|
+
border-bottom: 2px solid #1b2f4d;
|
|
29
|
+
}
|
|
30
|
+
.toolbar {
|
|
31
|
+
background: #d4d7dc;
|
|
32
|
+
border-bottom: 1px solid #8f959e;
|
|
33
|
+
padding: 6px 10px;
|
|
34
|
+
display: flex;
|
|
35
|
+
gap: 8px;
|
|
36
|
+
align-items: center;
|
|
37
|
+
flex-wrap: wrap;
|
|
38
|
+
}
|
|
39
|
+
.toolbar a, .toolbar span.path {
|
|
40
|
+
color: #000;
|
|
41
|
+
text-decoration: none;
|
|
42
|
+
padding: 3px 8px;
|
|
43
|
+
border: 1px solid #8f959e;
|
|
44
|
+
background: #eceef1;
|
|
45
|
+
}
|
|
46
|
+
.toolbar a:hover { background: #dfe6f0; }
|
|
47
|
+
.path {
|
|
48
|
+
font-family: "Courier New", monospace;
|
|
49
|
+
background: #fff !important;
|
|
50
|
+
flex: 1;
|
|
51
|
+
min-width: 200px;
|
|
52
|
+
}
|
|
53
|
+
.panel {
|
|
54
|
+
margin: 10px;
|
|
55
|
+
background: #fff;
|
|
56
|
+
border: 2px inset #8f959e;
|
|
57
|
+
}
|
|
58
|
+
table { width: 100%; border-collapse: collapse; }
|
|
59
|
+
th {
|
|
60
|
+
text-align: left;
|
|
61
|
+
background: #d4d7dc;
|
|
62
|
+
border-bottom: 1px solid #8f959e;
|
|
63
|
+
padding: 4px 8px;
|
|
64
|
+
font-weight: bold;
|
|
65
|
+
}
|
|
66
|
+
td {
|
|
67
|
+
padding: 3px 8px;
|
|
68
|
+
border-bottom: 1px solid #e6e8eb;
|
|
69
|
+
white-space: nowrap;
|
|
70
|
+
}
|
|
71
|
+
tr:hover td { background: #dfe6f0; }
|
|
72
|
+
td.name { width: 60%; }
|
|
73
|
+
td.actions a {
|
|
74
|
+
margin-right: 8px;
|
|
75
|
+
color: #1a3f7a;
|
|
76
|
+
}
|
|
77
|
+
.col-size, .col-modified { color: #444; font-family: "Courier New", monospace; }
|
|
78
|
+
.statusbar {
|
|
79
|
+
background: #d4d7dc;
|
|
80
|
+
border-top: 1px solid #8f959e;
|
|
81
|
+
padding: 4px 10px;
|
|
82
|
+
color: #333;
|
|
83
|
+
}
|
|
84
|
+
.upload-box {
|
|
85
|
+
margin: 10px;
|
|
86
|
+
padding: 14px;
|
|
87
|
+
background: #eceef1;
|
|
88
|
+
border: 2px inset #8f959e;
|
|
89
|
+
}
|
|
90
|
+
.upload-box input[type=file] { margin-bottom: 8px; }
|
|
91
|
+
.upload-box input[type=submit] {
|
|
92
|
+
padding: 4px 14px;
|
|
93
|
+
border: 1px solid #8f959e;
|
|
94
|
+
background: #d4d7dc;
|
|
95
|
+
cursor: pointer;
|
|
96
|
+
}
|
|
97
|
+
.viewer { margin: 10px; }
|
|
98
|
+
.viewer video, .viewer audio, .viewer img { max-width: 100%; }
|
|
99
|
+
.viewer pre {
|
|
100
|
+
background: #fff;
|
|
101
|
+
border: 2px inset #8f959e;
|
|
102
|
+
padding: 10px;
|
|
103
|
+
overflow: auto;
|
|
104
|
+
max-height: 70vh;
|
|
105
|
+
font-family: "Courier New", monospace;
|
|
106
|
+
white-space: pre-wrap;
|
|
107
|
+
word-break: break-word;
|
|
108
|
+
}
|
|
109
|
+
.notice { padding: 10px; margin: 10px; background: #fff8d6; border: 1px solid #cbb658; }
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _page(title, body):
|
|
114
|
+
return f"""<!DOCTYPE html>
|
|
115
|
+
<html lang="th">
|
|
116
|
+
<head>
|
|
117
|
+
<meta charset="utf-8">
|
|
118
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
119
|
+
<title>{html.escape(title)}</title>
|
|
120
|
+
<style>{PAGE_CSS}</style>
|
|
121
|
+
</head>
|
|
122
|
+
<body>
|
|
123
|
+
<div class="titlebar">Tempo File Server</div>
|
|
124
|
+
{body}
|
|
125
|
+
</body>
|
|
126
|
+
</html>"""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _toolbar(url_path, can_download, can_upload):
|
|
130
|
+
parts = [p for p in url_path.split("/") if p]
|
|
131
|
+
up_link = ""
|
|
132
|
+
if parts:
|
|
133
|
+
parent = "/" + "/".join(parts[:-1])
|
|
134
|
+
up_link = f'<a href="{html.escape(parent)}">\u2b06 Up</a>'
|
|
135
|
+
links = []
|
|
136
|
+
if can_download:
|
|
137
|
+
links.append(up_link)
|
|
138
|
+
if can_upload:
|
|
139
|
+
links.append('<a href="/upload">\u2191 Upload</a>')
|
|
140
|
+
links = [item for item in links if item]
|
|
141
|
+
return (
|
|
142
|
+
'<div class="toolbar">'
|
|
143
|
+
+ "".join(links)
|
|
144
|
+
+ f'<span class="path">{html.escape("/" + "/".join(parts))}</span>'
|
|
145
|
+
+ "</div>"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def render_directory(url_path, entries, can_download, can_upload):
|
|
150
|
+
"""entries: list of dict(name, is_dir, size, mtime)"""
|
|
151
|
+
rows = []
|
|
152
|
+
for entry in entries:
|
|
153
|
+
name = entry["name"]
|
|
154
|
+
href = urllib.parse.quote(name) + ("/" if entry["is_dir"] else "")
|
|
155
|
+
ext = "" if entry["is_dir"] else "." + name.rsplit(".", 1)[-1] if "." in name else ""
|
|
156
|
+
icon = file_icon(ext, entry["is_dir"])
|
|
157
|
+
size = "" if entry["is_dir"] else human_size(entry["size"])
|
|
158
|
+
modified = entry["mtime"]
|
|
159
|
+
actions = ""
|
|
160
|
+
if not entry["is_dir"] and can_download:
|
|
161
|
+
kind = file_kind(ext)
|
|
162
|
+
if kind:
|
|
163
|
+
actions += f'<a href="{href}?view=1">View</a>'
|
|
164
|
+
actions += f'<a href="{href}?dl=1">Download</a>'
|
|
165
|
+
display_name = f'<a href="{href}">{icon} {html.escape(name)}</a>' if (entry["is_dir"] or can_download) else f'{icon} {html.escape(name)}'
|
|
166
|
+
rows.append(
|
|
167
|
+
f"<tr><td class='name'>{display_name}</td>"
|
|
168
|
+
f"<td class='col-size'>{size}</td>"
|
|
169
|
+
f"<td class='col-modified'>{html.escape(modified)}</td>"
|
|
170
|
+
f"<td class='actions'>{actions}</td></tr>"
|
|
171
|
+
)
|
|
172
|
+
if not rows:
|
|
173
|
+
rows.append('<tr><td colspan="4" style="text-align:center;color:#888;padding:20px;">(\u0e42\u0e1f\u0e25\u0e14\u0e40\u0e21\u0e2d\u0e23\u0e4c\u0e19\u0e35\u0e49\u0e27\u0e48\u0e32\u0e07\u0e40\u0e1b\u0e25\u0e48\u0e32)</td></tr>')
|
|
174
|
+
|
|
175
|
+
body = _toolbar(url_path, can_download, can_upload)
|
|
176
|
+
body += (
|
|
177
|
+
'<div class="panel"><table>'
|
|
178
|
+
"<tr><th>\u0e0a\u0e37\u0e48\u0e2d (Name)</th><th>\u0e02\u0e19\u0e32\u0e14 (Size)</th>"
|
|
179
|
+
"<th>\u0e41\u0e01\u0e49\u0e44\u0e02\u0e25\u0e48\u0e32\u0e2a\u0e38\u0e14 (Modified)</th><th>\u0e01\u0e32\u0e23\u0e01\u0e23\u0e30\u0e17\u0e33 (Action)</th></tr>"
|
|
180
|
+
+ "".join(rows)
|
|
181
|
+
+ "</table></div>"
|
|
182
|
+
f'<div class="statusbar">{len(entries)} item(s) — Tempo</div>'
|
|
183
|
+
)
|
|
184
|
+
return _page(f"Tempo :: {url_path}", body)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def render_upload_page(url_path, message=None):
|
|
188
|
+
notice = f'<div class="notice">{html.escape(message)}</div>' if message else ""
|
|
189
|
+
body = _toolbar(url_path, can_download=True, can_upload=True)
|
|
190
|
+
body += notice
|
|
191
|
+
body += f"""
|
|
192
|
+
<div class="upload-box">
|
|
193
|
+
<form method="post" action="/upload" enctype="multipart/form-data">
|
|
194
|
+
<div><input type="file" name="file" required></div>
|
|
195
|
+
<div><input type="submit" value="Upload"></div>
|
|
196
|
+
</form>
|
|
197
|
+
</div>
|
|
198
|
+
"""
|
|
199
|
+
return _page("Tempo :: Upload", body)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def render_forbidden(reason="Access is not permitted on this server."):
|
|
203
|
+
body = f'<div class="notice">403 Forbidden — {html.escape(reason)}</div>'
|
|
204
|
+
return _page("Tempo :: 403 Forbidden", body)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def render_viewer(url_path, name, kind):
|
|
208
|
+
href = urllib.parse.quote(name)
|
|
209
|
+
body = f'<div class="toolbar"><span class="path">{html.escape(url_path)}</span></div>'
|
|
210
|
+
body += '<div class="viewer">'
|
|
211
|
+
if kind == "video":
|
|
212
|
+
body += f'<video controls autoplay src="{href}"></video>'
|
|
213
|
+
elif kind == "audio":
|
|
214
|
+
body += f'<audio controls autoplay src="{href}"></audio>'
|
|
215
|
+
elif kind == "image":
|
|
216
|
+
body += f'<img src="{href}" alt="{html.escape(name)}">'
|
|
217
|
+
body += "</div>"
|
|
218
|
+
return _page(f"Tempo :: {name}", body)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def render_text_viewer(url_path, name, text):
|
|
222
|
+
body = f'<div class="toolbar"><span class="path">{html.escape(url_path)}</span></div>'
|
|
223
|
+
body += f'<div class="viewer"><pre>{html.escape(text)}</pre></div>'
|
|
224
|
+
return _page(f"Tempo :: {name}", body)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Small shared helpers: local IP discovery, subnet enumeration, formatting."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import socket
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
VIEWABLE_TEXT_EXT = {
|
|
8
|
+
".txt", ".md", ".log", ".cfg", ".ini", ".json", ".yaml", ".yml",
|
|
9
|
+
".py", ".c", ".h", ".cpp", ".js", ".ts", ".css", ".html", ".sh",
|
|
10
|
+
".csv", ".conf",
|
|
11
|
+
}
|
|
12
|
+
VIEWABLE_VIDEO_EXT = {".mp4", ".webm", ".ogv", ".mov", ".mkv"}
|
|
13
|
+
VIEWABLE_AUDIO_EXT = {".mp3", ".wav", ".ogg", ".flac", ".m4a"}
|
|
14
|
+
VIEWABLE_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def human_size(num_bytes):
|
|
18
|
+
"""Return a human readable file size, e.g. '12.3 MB'."""
|
|
19
|
+
step = 1024.0
|
|
20
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
21
|
+
if num_bytes < step:
|
|
22
|
+
return f"{num_bytes:.0f} {unit}" if unit == "B" else f"{num_bytes:.1f} {unit}"
|
|
23
|
+
num_bytes /= step
|
|
24
|
+
return f"{num_bytes:.1f} PB"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def file_kind(ext):
|
|
28
|
+
"""Classify a file extension into a viewer kind, or None if not viewable inline."""
|
|
29
|
+
ext = ext.lower()
|
|
30
|
+
if ext in VIEWABLE_TEXT_EXT:
|
|
31
|
+
return "text"
|
|
32
|
+
if ext in VIEWABLE_VIDEO_EXT:
|
|
33
|
+
return "video"
|
|
34
|
+
if ext in VIEWABLE_AUDIO_EXT:
|
|
35
|
+
return "audio"
|
|
36
|
+
if ext in VIEWABLE_IMAGE_EXT:
|
|
37
|
+
return "image"
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def file_icon(ext, is_dir=False):
|
|
42
|
+
"""Return a small unicode glyph used as a retro-style file icon."""
|
|
43
|
+
if is_dir:
|
|
44
|
+
return "\U0001F4C1" # folder
|
|
45
|
+
kind = file_kind(ext)
|
|
46
|
+
return {
|
|
47
|
+
"text": "\U0001F4C4",
|
|
48
|
+
"video": "\U0001F3AC",
|
|
49
|
+
"audio": "\U0001F3B5",
|
|
50
|
+
"image": "\U0001F5BC",
|
|
51
|
+
}.get(kind, "\U0001F4E6")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_local_ip():
|
|
55
|
+
"""Best-effort discovery of this machine's LAN IP address.
|
|
56
|
+
|
|
57
|
+
Uses a UDP 'connect' (no packets actually sent) to force the OS to pick
|
|
58
|
+
the outbound interface, then reads back the local address it chose.
|
|
59
|
+
"""
|
|
60
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
61
|
+
try:
|
|
62
|
+
sock.connect(("8.8.8.8", 80))
|
|
63
|
+
return sock.getsockname()[0]
|
|
64
|
+
except OSError:
|
|
65
|
+
return "127.0.0.1"
|
|
66
|
+
finally:
|
|
67
|
+
sock.close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def guess_subnet_hosts(local_ip=None):
|
|
71
|
+
"""Guess every host address on the local /24 subnet, excluding our own IP."""
|
|
72
|
+
local_ip = local_ip or get_local_ip()
|
|
73
|
+
network = ipaddress.ip_network(f"{local_ip}/24", strict=False)
|
|
74
|
+
return [str(host) for host in network.hosts() if str(host) != local_ip]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def valid_port(value):
|
|
78
|
+
"""argparse type= helper: validate a TCP port number in [0, 65535]."""
|
|
79
|
+
try:
|
|
80
|
+
port = int(value)
|
|
81
|
+
except ValueError as exc:
|
|
82
|
+
raise ValueError(f"invalid port: {value!r}") from exc
|
|
83
|
+
if not (0 <= port <= 65535):
|
|
84
|
+
raise ValueError(f"port out of range (0-65535): {port}")
|
|
85
|
+
return port
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tempo-cli-100
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A fast, minimal LAN file-sharing CLI and web server.
|
|
5
|
+
Author: Tempo contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# Tempo
|
|
11
|
+
|
|
12
|
+
Tempo เป็น CLI สำหรับแชร์ไฟล์ในวง LAN ที่เร็วและมีฟีเจอร์มากกว่า
|
|
13
|
+
`python -m http.server` ธรรมดา:
|
|
14
|
+
|
|
15
|
+
- **Threaded server** (`ThreadingHTTPServer`) รองรับหลาย client พร้อมกัน
|
|
16
|
+
- **HTTP Range requests** — เล่นวิดีโอ/เสียงแบบ stream/scrub ได้ ไม่ต้องโหลดทั้งไฟล์
|
|
17
|
+
- **โหมดสิทธิ์ (permission)**: `download` / `upload` / `full`
|
|
18
|
+
- **Upload ผ่านเว็บ** ด้วย multipart/form-data (ไม่พึ่ง dependency ภายนอก)
|
|
19
|
+
- **Direct download/upload** จาก command line โดยไม่ต้องเปิดเบราว์เซอร์
|
|
20
|
+
- **LAN scanner** หา Tempo server อื่นในวงแลน ด้วย thread pool
|
|
21
|
+
- **หน้าเว็บมินิมอล** สไตล์ file manager ยุค Windows แรกๆ
|
|
22
|
+
|
|
23
|
+
เขียนด้วย Python standard library ล้วน ไม่มี dependency ภายนอก
|
|
24
|
+
|
|
25
|
+
## ติดตั้ง
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install .
|
|
29
|
+
# หรือระหว่างพัฒนา:
|
|
30
|
+
pip install -e .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
หลังติดตั้งจะได้คำสั่ง `tempo` ในเครื่อง หรือจะรันตรงโดยไม่ติดตั้งก็ได้:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python3 -m tempo_cli [options] [host]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Syntax
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
tempo [OPTION]... [HOST]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| ตัวเลือก | รูปแบบ | ความหมาย |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `-h`, `--help` | | แสดงข้อความช่วยเหลือแล้วออก |
|
|
48
|
+
| `-v`, `--version` | | แสดงเวอร์ชันแล้วออก |
|
|
49
|
+
| `-p`, `--port` | `PORT` | พอร์ต 0-65535 (default: 8000) |
|
|
50
|
+
| `-pm`, `--permission` | `download\|upload\|full` | โหมดสิทธิ์ (default: full) |
|
|
51
|
+
| `-d`, `--download` | `[HOST]:[PORT]/[FILEPATH]` | โหลดไฟล์จาก Tempo server ทันที |
|
|
52
|
+
| `-u`, `--upload` | `FILEPATH:[HOST]:[PORT]` | อัปโหลดไฟล์ไปยัง Tempo server ทันที |
|
|
53
|
+
| `-s`, `--scan` | `[TIMEOUT] [PORT] [IP]` | สแกนหา Tempo server ในวงแลน (`IP=auto` เพื่อสแกนทั้ง subnet) |
|
|
54
|
+
|
|
55
|
+
รองรับทั้งรูปแบบ `--option value` และ `--option=value` ตาม GNU Coding Standards
|
|
56
|
+
|
|
57
|
+
## ตัวอย่างการใช้งาน
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# แชร์โฟลเดอร์ปัจจุบัน พอร์ต 8000 ทุก interface
|
|
61
|
+
tempo
|
|
62
|
+
|
|
63
|
+
# ระบุพอร์ตและ host ที่จะ bind
|
|
64
|
+
tempo -p 9000 0.0.0.0
|
|
65
|
+
|
|
66
|
+
# โหมดอ่านอย่างเดียว (ไม่มีปุ่ม upload)
|
|
67
|
+
tempo --permission=download
|
|
68
|
+
|
|
69
|
+
# โหมด upload อย่างเดียว (มีแค่หน้า upload)
|
|
70
|
+
tempo -pm upload
|
|
71
|
+
|
|
72
|
+
# โหลดไฟล์ทันทีจาก server ที่รู้จักอยู่แล้ว
|
|
73
|
+
tempo -d 192.168.1.5:8000/movie.mp4
|
|
74
|
+
|
|
75
|
+
# อัปโหลดไฟล์ทันที ไปยัง server ที่อนุญาต upload
|
|
76
|
+
tempo -u ./photo.png:192.168.1.5:8000
|
|
77
|
+
|
|
78
|
+
# สแกนทั้ง subnet หา Tempo server อื่น (timeout เริ่มต้น 1s, port 8000)
|
|
79
|
+
tempo -s
|
|
80
|
+
|
|
81
|
+
# สแกนแบบระบุ timeout/port/target เอง
|
|
82
|
+
tempo -s 0.5 8000 auto
|
|
83
|
+
tempo -s 1.0 8000 192.168.1.20
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## หมายเหตุด้านความปลอดภัย
|
|
87
|
+
|
|
88
|
+
Tempo ไม่มีระบบ authentication ใดๆ — ใครก็ตามที่เข้าถึงวงแลนเดียวกัน
|
|
89
|
+
และรู้ IP:PORT จะสามารถเข้าถึง (หรืออัปโหลด/ดาวน์โหลด ตามโหมดสิทธิ์) ได้ทันที
|
|
90
|
+
เหมาะสำหรับใช้งานในเครือข่ายที่เชื่อถือได้เท่านั้น (บ้าน/ออฟฟิศ/ทีมเดียวกัน)
|
|
91
|
+
ไม่แนะนำให้เปิดออก public internet
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tempo_cli/__init__.py
|
|
4
|
+
tempo_cli/__main__.py
|
|
5
|
+
tempo_cli/cli.py
|
|
6
|
+
tempo_cli/client.py
|
|
7
|
+
tempo_cli/scanner.py
|
|
8
|
+
tempo_cli/server.py
|
|
9
|
+
tempo_cli/templates.py
|
|
10
|
+
tempo_cli/utils.py
|
|
11
|
+
tempo_cli_100.egg-info/PKG-INFO
|
|
12
|
+
tempo_cli_100.egg-info/SOURCES.txt
|
|
13
|
+
tempo_cli_100.egg-info/dependency_links.txt
|
|
14
|
+
tempo_cli_100.egg-info/entry_points.txt
|
|
15
|
+
tempo_cli_100.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tempo_cli
|