vibefs 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vibefs-0.1.0/.gitignore +13 -0
- vibefs-0.1.0/.python-version +1 -0
- vibefs-0.1.0/LICENSE +21 -0
- vibefs-0.1.0/PKG-INFO +149 -0
- vibefs-0.1.0/PLAN.md +113 -0
- vibefs-0.1.0/README.md +126 -0
- vibefs-0.1.0/pyproject.toml +36 -0
- vibefs-0.1.0/uv.lock +59 -0
- vibefs-0.1.0/vibefs.py +589 -0
vibefs-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
vibefs-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 reorx
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
vibefs-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vibefs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A simple, secure file preview service with time-limited URLs
|
|
5
|
+
Project-URL: Homepage, https://github.com/reorx/vibefs
|
|
6
|
+
Project-URL: Repository, https://github.com/reorx/vibefs
|
|
7
|
+
Author-email: reorx <novoreorx@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agent,cli,file-server,preview
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: bottle>=0.13.4
|
|
20
|
+
Requires-Dist: click>=8.3.1
|
|
21
|
+
Requires-Dist: pygments>=2.19.2
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# vibefs
|
|
25
|
+
|
|
26
|
+
A file preview server with time-limited access control, designed for AI agents to share local files with users via URLs.
|
|
27
|
+
|
|
28
|
+
Files are not accessible by default. Each file must be explicitly authorized with a TTL (default: 1 hour). The server starts automatically on the first `allow` call and shuts down when all authorizations expire.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
With [uv](https://docs.astral.sh/uv/):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Install globally
|
|
36
|
+
uv tool install vibefs
|
|
37
|
+
|
|
38
|
+
# Or run directly without installing
|
|
39
|
+
uvx vibefs --help
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
### Authorize a file
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
vibefs allow /path/to/file.py
|
|
48
|
+
# http://localhost:17173/f/a3b7c2d1/file.py
|
|
49
|
+
|
|
50
|
+
vibefs allow /path/to/file.py --ttl 300 # 5 minutes
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The daemon starts automatically if it's not already running.
|
|
54
|
+
|
|
55
|
+
### Manage authorizations
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
vibefs list # List active authorizations
|
|
59
|
+
vibefs revoke <token> # Revoke a specific authorization
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Server control
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
vibefs status # Check if daemon is running
|
|
66
|
+
vibefs stop # Stop the daemon
|
|
67
|
+
vibefs serve # Start server in foreground (for debugging)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Configuration
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
vibefs config set base_url https://files.example.com
|
|
74
|
+
vibefs config get base_url
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
When `base_url` is set, the `allow` command outputs URLs using it instead of `localhost:port`:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
vibefs allow /path/to/file.py
|
|
81
|
+
# https://files.example.com/f/a3b7c2d1/file.py
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## File rendering
|
|
85
|
+
|
|
86
|
+
- Code and text files (`.py`, `.js`, `.md`, `.json`, etc.) are rendered with syntax highlighting via Pygments.
|
|
87
|
+
- Other files are served with their original content type.
|
|
88
|
+
|
|
89
|
+
## Deploy
|
|
90
|
+
|
|
91
|
+
vibefs listens on `localhost:17173` by default. To make it accessible from the internet, use a tunneling service to map the local port to a public domain.
|
|
92
|
+
|
|
93
|
+
### Cloudflare Tunnel
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Install cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
|
|
97
|
+
|
|
98
|
+
# Quick tunnel (temporary public URL)
|
|
99
|
+
cloudflared tunnel --url http://localhost:17173
|
|
100
|
+
|
|
101
|
+
# Named tunnel (persistent domain)
|
|
102
|
+
cloudflared tunnel create vibefs
|
|
103
|
+
cloudflared tunnel route dns vibefs vibefs.example.com
|
|
104
|
+
cloudflared tunnel run --url http://localhost:17173 vibefs
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Other options
|
|
108
|
+
|
|
109
|
+
- **ngrok**: `ngrok http 17173`
|
|
110
|
+
- **Tailscale Funnel**: `tailscale funnel 17173`
|
|
111
|
+
- **frp**, **bore**, or any TCP tunneling tool
|
|
112
|
+
|
|
113
|
+
After setting up the tunnel, configure the base URL so generated links use your public domain:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
vibefs config set base_url https://vibefs.example.com
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Agent integration
|
|
120
|
+
|
|
121
|
+
To let an AI agent use vibefs, add instructions like the following to its system prompt or tool documentation:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
You have access to `vibefs`, a file preview tool. When you want to share a file
|
|
125
|
+
with the user, run:
|
|
126
|
+
|
|
127
|
+
vibefs allow /path/to/file [--ttl SECONDS]
|
|
128
|
+
|
|
129
|
+
This prints a URL. Send the URL to the user — they can open it in a browser to
|
|
130
|
+
view the file. The link expires after the TTL (default: 1 hour).
|
|
131
|
+
|
|
132
|
+
Use this when:
|
|
133
|
+
- Showing code, logs, or config files
|
|
134
|
+
- Sharing generated output
|
|
135
|
+
- Any time a file is easier to read in a browser than in chat
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## State
|
|
139
|
+
|
|
140
|
+
All runtime data is stored in `~/.vibefs/`:
|
|
141
|
+
|
|
142
|
+
- `vibefs.db` — authorization records (SQLite)
|
|
143
|
+
- `vibefs.pid` — daemon PID file
|
|
144
|
+
- `vibefs.log` — daemon log output
|
|
145
|
+
- `config.json` — configuration
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
vibefs-0.1.0/PLAN.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# vibefs — Vibe File Server
|
|
2
|
+
|
|
3
|
+
A simple, secure file preview service designed for AI agents to share files with users via time-limited URLs.
|
|
4
|
+
|
|
5
|
+
## Concept
|
|
6
|
+
|
|
7
|
+
Agents often need to show files to users. vibefs provides a lightweight web server where files must be explicitly authorized before they can be accessed, with automatic expiration.
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
- **Single-file core**: All logic lives in one Python file (`vibefs.py`) for maximum portability
|
|
12
|
+
- **CLI**: click-based, subcommands for serving and authorizing
|
|
13
|
+
- **Web**: Bottle (zero-dependency micro framework)
|
|
14
|
+
- **Storage**: SQLite (Python built-in), stores authorization records
|
|
15
|
+
|
|
16
|
+
## Commands
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
vibefs allow <path> [--ttl 3600] # Authorize a file, auto-start daemon if needed, output access URL
|
|
20
|
+
vibefs revoke <token> # Revoke access to a file
|
|
21
|
+
vibefs list # List currently authorized files
|
|
22
|
+
vibefs serve [--port 8080] [--host 0.0.0.0] # Manually start server in foreground (for debugging)
|
|
23
|
+
vibefs stop # Manually stop the daemon
|
|
24
|
+
vibefs status # Check if daemon is running
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## URL Format
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
http://localhost:8080/f/{short_hash}/{filename}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- `short_hash`: 6-8 char random token, not derived from path (no path leakage)
|
|
34
|
+
- `filename`: original filename for readability (e.g. `report.txt`)
|
|
35
|
+
- Full filesystem path is never exposed
|
|
36
|
+
|
|
37
|
+
## Flow
|
|
38
|
+
|
|
39
|
+
1. Agent runs `vibefs allow /home/user/projects/output/report.txt`
|
|
40
|
+
2. vibefs generates a short token, stores `{token, filepath, filename, created_at, expires_at}` in SQLite
|
|
41
|
+
3. Checks if daemon is running (via PID file):
|
|
42
|
+
- Not running → fork a background daemon process that starts the Bottle server
|
|
43
|
+
- Already running → skip
|
|
44
|
+
4. Outputs: `http://localhost:8080/f/a3b7c2/report.txt`
|
|
45
|
+
5. User clicks link → vibefs checks token in SQLite:
|
|
46
|
+
- Valid & not expired → read file, return content with appropriate content-type
|
|
47
|
+
- Expired → show "This file is no longer available" page
|
|
48
|
+
- Unknown token → 404
|
|
49
|
+
6. Default TTL: 1 hour, configurable via `--ttl` (seconds)
|
|
50
|
+
|
|
51
|
+
## Daemon Architecture
|
|
52
|
+
|
|
53
|
+
The server runs as an implicit, on-demand daemon — no manual `serve` required.
|
|
54
|
+
|
|
55
|
+
### Auto-start
|
|
56
|
+
- `allow` command checks PID file (`~/.vibefs/vibefs.pid`) to see if daemon is alive
|
|
57
|
+
- If not running, forks a background daemon process (double-fork or `subprocess` detach)
|
|
58
|
+
- Daemon writes its PID to the PID file on startup
|
|
59
|
+
|
|
60
|
+
### Auto-stop (self-cleanup)
|
|
61
|
+
- Daemon runs a background check every 60 seconds
|
|
62
|
+
- On each check: query SQLite for any non-expired authorizations
|
|
63
|
+
- If ALL authorizations have expired → daemon exits gracefully, removes PID file
|
|
64
|
+
- This ensures the server only runs when there are active files to serve
|
|
65
|
+
|
|
66
|
+
### State directory: `~/.vibefs/`
|
|
67
|
+
- `~/.vibefs/vibefs.db` — SQLite database (always use this path, not cwd)
|
|
68
|
+
- `~/.vibefs/vibefs.pid` — PID file for daemon liveness check
|
|
69
|
+
- `~/.vibefs/vibefs.log` — daemon log output (stdout/stderr redirect)
|
|
70
|
+
|
|
71
|
+
### PID file liveness check
|
|
72
|
+
- Read PID from file → `os.kill(pid, 0)` to check if process is alive
|
|
73
|
+
- If PID file exists but process is dead → stale PID file, clean up and restart
|
|
74
|
+
|
|
75
|
+
## File Format Support
|
|
76
|
+
|
|
77
|
+
- **Phase 1**: Plain text files (text/plain)
|
|
78
|
+
- **Future**: Markdown rendering, images, PDFs, syntax highlighting, etc.
|
|
79
|
+
|
|
80
|
+
## Dependencies
|
|
81
|
+
|
|
82
|
+
- `click` — CLI framework
|
|
83
|
+
- `bottle` — Web framework
|
|
84
|
+
- Everything else is Python stdlib (sqlite3, hashlib, os, etc.)
|
|
85
|
+
|
|
86
|
+
## Project Structure
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
vibefs/
|
|
90
|
+
├── PLAN.md # This file
|
|
91
|
+
├── vibefs.py # All core logic (single file)
|
|
92
|
+
├── pyproject.toml # Project metadata, dependencies & entry point
|
|
93
|
+
├── uv.lock # Lock file (auto-generated)
|
|
94
|
+
└── README.md # Usage documentation
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Packaging & Tooling
|
|
98
|
+
|
|
99
|
+
Single-file core, but proper Python packaging practices:
|
|
100
|
+
|
|
101
|
+
- **uv** as package manager (`uv init`, `uv add`, `uv run`)
|
|
102
|
+
- **pyproject.toml** for metadata, dependencies, and CLI entry point
|
|
103
|
+
- Entry point via `[project.scripts]`: `vibefs = "vibefs:cli"`
|
|
104
|
+
- Install with `uv pip install -e .` or run directly with `uv run vibefs`
|
|
105
|
+
- No `src/` layout — `vibefs.py` sits at project root, keeps it flat and simple
|
|
106
|
+
|
|
107
|
+
## Design Principles
|
|
108
|
+
|
|
109
|
+
- **Portable**: Single Python file, minimal dependencies
|
|
110
|
+
- **Proper packaging**: uv + pyproject.toml, installable as a real Python package
|
|
111
|
+
- **Secure by default**: Nothing is accessible until explicitly allowed
|
|
112
|
+
- **Ephemeral**: Authorizations expire automatically
|
|
113
|
+
- **Agent-friendly**: CLI output is clean and parseable
|
vibefs-0.1.0/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# vibefs
|
|
2
|
+
|
|
3
|
+
A file preview server with time-limited access control, designed for AI agents to share local files with users via URLs.
|
|
4
|
+
|
|
5
|
+
Files are not accessible by default. Each file must be explicitly authorized with a TTL (default: 1 hour). The server starts automatically on the first `allow` call and shuts down when all authorizations expire.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
With [uv](https://docs.astral.sh/uv/):
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Install globally
|
|
13
|
+
uv tool install vibefs
|
|
14
|
+
|
|
15
|
+
# Or run directly without installing
|
|
16
|
+
uvx vibefs --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
### Authorize a file
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
vibefs allow /path/to/file.py
|
|
25
|
+
# http://localhost:17173/f/a3b7c2d1/file.py
|
|
26
|
+
|
|
27
|
+
vibefs allow /path/to/file.py --ttl 300 # 5 minutes
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The daemon starts automatically if it's not already running.
|
|
31
|
+
|
|
32
|
+
### Manage authorizations
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
vibefs list # List active authorizations
|
|
36
|
+
vibefs revoke <token> # Revoke a specific authorization
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Server control
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
vibefs status # Check if daemon is running
|
|
43
|
+
vibefs stop # Stop the daemon
|
|
44
|
+
vibefs serve # Start server in foreground (for debugging)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Configuration
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
vibefs config set base_url https://files.example.com
|
|
51
|
+
vibefs config get base_url
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
When `base_url` is set, the `allow` command outputs URLs using it instead of `localhost:port`:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
vibefs allow /path/to/file.py
|
|
58
|
+
# https://files.example.com/f/a3b7c2d1/file.py
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## File rendering
|
|
62
|
+
|
|
63
|
+
- Code and text files (`.py`, `.js`, `.md`, `.json`, etc.) are rendered with syntax highlighting via Pygments.
|
|
64
|
+
- Other files are served with their original content type.
|
|
65
|
+
|
|
66
|
+
## Deploy
|
|
67
|
+
|
|
68
|
+
vibefs listens on `localhost:17173` by default. To make it accessible from the internet, use a tunneling service to map the local port to a public domain.
|
|
69
|
+
|
|
70
|
+
### Cloudflare Tunnel
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Install cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
|
|
74
|
+
|
|
75
|
+
# Quick tunnel (temporary public URL)
|
|
76
|
+
cloudflared tunnel --url http://localhost:17173
|
|
77
|
+
|
|
78
|
+
# Named tunnel (persistent domain)
|
|
79
|
+
cloudflared tunnel create vibefs
|
|
80
|
+
cloudflared tunnel route dns vibefs vibefs.example.com
|
|
81
|
+
cloudflared tunnel run --url http://localhost:17173 vibefs
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Other options
|
|
85
|
+
|
|
86
|
+
- **ngrok**: `ngrok http 17173`
|
|
87
|
+
- **Tailscale Funnel**: `tailscale funnel 17173`
|
|
88
|
+
- **frp**, **bore**, or any TCP tunneling tool
|
|
89
|
+
|
|
90
|
+
After setting up the tunnel, configure the base URL so generated links use your public domain:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
vibefs config set base_url https://vibefs.example.com
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Agent integration
|
|
97
|
+
|
|
98
|
+
To let an AI agent use vibefs, add instructions like the following to its system prompt or tool documentation:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
You have access to `vibefs`, a file preview tool. When you want to share a file
|
|
102
|
+
with the user, run:
|
|
103
|
+
|
|
104
|
+
vibefs allow /path/to/file [--ttl SECONDS]
|
|
105
|
+
|
|
106
|
+
This prints a URL. Send the URL to the user — they can open it in a browser to
|
|
107
|
+
view the file. The link expires after the TTL (default: 1 hour).
|
|
108
|
+
|
|
109
|
+
Use this when:
|
|
110
|
+
- Showing code, logs, or config files
|
|
111
|
+
- Sharing generated output
|
|
112
|
+
- Any time a file is easier to read in a browser than in chat
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## State
|
|
116
|
+
|
|
117
|
+
All runtime data is stored in `~/.vibefs/`:
|
|
118
|
+
|
|
119
|
+
- `vibefs.db` — authorization records (SQLite)
|
|
120
|
+
- `vibefs.pid` — daemon PID file
|
|
121
|
+
- `vibefs.log` — daemon log output
|
|
122
|
+
- `config.json` — configuration
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vibefs"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A simple, secure file preview service with time-limited URLs"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "reorx", email = "novoreorx@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
keywords = ["file-server", "preview", "cli", "agent"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
19
|
+
"Topic :: Utilities",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"bottle>=0.13.4",
|
|
23
|
+
"click>=8.3.1",
|
|
24
|
+
"pygments>=2.19.2",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/reorx/vibefs"
|
|
29
|
+
Repository = "https://github.com/reorx/vibefs"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
vibefs = "vibefs:cli"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
vibefs-0.1.0/uv.lock
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.14"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "bottle"
|
|
7
|
+
version = "0.13.4"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" }
|
|
10
|
+
wheels = [
|
|
11
|
+
{ url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "click"
|
|
16
|
+
version = "8.3.1"
|
|
17
|
+
source = { registry = "https://pypi.org/simple" }
|
|
18
|
+
dependencies = [
|
|
19
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
20
|
+
]
|
|
21
|
+
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
|
22
|
+
wheels = [
|
|
23
|
+
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[[package]]
|
|
27
|
+
name = "colorama"
|
|
28
|
+
version = "0.4.6"
|
|
29
|
+
source = { registry = "https://pypi.org/simple" }
|
|
30
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
31
|
+
wheels = [
|
|
32
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[[package]]
|
|
36
|
+
name = "pygments"
|
|
37
|
+
version = "2.19.2"
|
|
38
|
+
source = { registry = "https://pypi.org/simple" }
|
|
39
|
+
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
|
40
|
+
wheels = [
|
|
41
|
+
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[[package]]
|
|
45
|
+
name = "vibefs"
|
|
46
|
+
version = "0.1.0"
|
|
47
|
+
source = { editable = "." }
|
|
48
|
+
dependencies = [
|
|
49
|
+
{ name = "bottle" },
|
|
50
|
+
{ name = "click" },
|
|
51
|
+
{ name = "pygments" },
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[package.metadata]
|
|
55
|
+
requires-dist = [
|
|
56
|
+
{ name = "bottle", specifier = ">=0.13.4" },
|
|
57
|
+
{ name = "click", specifier = ">=8.3.1" },
|
|
58
|
+
{ name = "pygments", specifier = ">=2.19.2" },
|
|
59
|
+
]
|
vibefs-0.1.0/vibefs.py
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""vibefs — Vibe File Server
|
|
2
|
+
|
|
3
|
+
A simple, secure file preview service designed for AI agents to share files
|
|
4
|
+
with users via time-limited URLs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import atexit
|
|
8
|
+
import json
|
|
9
|
+
import mimetypes
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
import signal
|
|
13
|
+
import sqlite3
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
import bottle
|
|
20
|
+
import click
|
|
21
|
+
|
|
22
|
+
# --- Constants ---
|
|
23
|
+
|
|
24
|
+
DEFAULT_PORT = 17173
|
|
25
|
+
DEFAULT_HOST = '0.0.0.0'
|
|
26
|
+
DEFAULT_TTL = 3600 # 1 hour
|
|
27
|
+
TOKEN_LENGTH = 4 # bytes, produces 8 hex chars
|
|
28
|
+
CLEANUP_INTERVAL = 60 # seconds between auto-stop checks
|
|
29
|
+
|
|
30
|
+
# --- State Directory ---
|
|
31
|
+
|
|
32
|
+
STATE_DIR = os.path.expanduser('~/.vibefs')
|
|
33
|
+
DB_PATH = os.path.join(STATE_DIR, 'vibefs.db')
|
|
34
|
+
PID_PATH = os.path.join(STATE_DIR, 'vibefs.pid')
|
|
35
|
+
LOG_PATH = os.path.join(STATE_DIR, 'vibefs.log')
|
|
36
|
+
CONFIG_PATH = os.path.join(STATE_DIR, 'config.json')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ensure_state_dir():
|
|
40
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --- Config ---
|
|
44
|
+
|
|
45
|
+
def load_config():
|
|
46
|
+
if os.path.isfile(CONFIG_PATH):
|
|
47
|
+
with open(CONFIG_PATH) as f:
|
|
48
|
+
return json.load(f)
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def save_config(cfg):
|
|
53
|
+
ensure_state_dir()
|
|
54
|
+
with open(CONFIG_PATH, 'w') as f:
|
|
55
|
+
json.dump(cfg, f, indent=2)
|
|
56
|
+
f.write('\n')
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- Database ---
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_db_path():
|
|
63
|
+
return os.environ.get('VIBEFS_DB', DB_PATH)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_db():
|
|
67
|
+
db = sqlite3.connect(get_db_path())
|
|
68
|
+
db.row_factory = sqlite3.Row
|
|
69
|
+
db.execute("""
|
|
70
|
+
CREATE TABLE IF NOT EXISTS authorizations (
|
|
71
|
+
token TEXT PRIMARY KEY,
|
|
72
|
+
filepath TEXT NOT NULL,
|
|
73
|
+
filename TEXT NOT NULL,
|
|
74
|
+
created_at REAL NOT NULL,
|
|
75
|
+
expires_at REAL NOT NULL
|
|
76
|
+
)
|
|
77
|
+
""")
|
|
78
|
+
db.commit()
|
|
79
|
+
return db
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def add_authorization(filepath, ttl):
|
|
83
|
+
"""Add an authorization record and return (token, filename)."""
|
|
84
|
+
abs_path = os.path.abspath(filepath)
|
|
85
|
+
if not os.path.isfile(abs_path):
|
|
86
|
+
raise FileNotFoundError(f'File not found: {abs_path}')
|
|
87
|
+
|
|
88
|
+
token = secrets.token_hex(TOKEN_LENGTH)
|
|
89
|
+
filename = os.path.basename(abs_path)
|
|
90
|
+
now = time.time()
|
|
91
|
+
|
|
92
|
+
db = get_db()
|
|
93
|
+
db.execute(
|
|
94
|
+
'INSERT INTO authorizations (token, filepath, filename, created_at, expires_at) VALUES (?, ?, ?, ?, ?)',
|
|
95
|
+
(token, abs_path, filename, now, now + ttl),
|
|
96
|
+
)
|
|
97
|
+
db.commit()
|
|
98
|
+
db.close()
|
|
99
|
+
return token, filename
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def remove_authorization(token):
|
|
103
|
+
"""Remove an authorization record. Returns True if it existed."""
|
|
104
|
+
db = get_db()
|
|
105
|
+
cursor = db.execute('DELETE FROM authorizations WHERE token = ?', (token,))
|
|
106
|
+
db.commit()
|
|
107
|
+
deleted = cursor.rowcount > 0
|
|
108
|
+
db.close()
|
|
109
|
+
return deleted
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def list_authorizations():
|
|
113
|
+
"""Return all authorization records."""
|
|
114
|
+
db = get_db()
|
|
115
|
+
rows = db.execute(
|
|
116
|
+
'SELECT token, filepath, filename, created_at, expires_at FROM authorizations ORDER BY created_at DESC'
|
|
117
|
+
).fetchall()
|
|
118
|
+
db.close()
|
|
119
|
+
return rows
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def lookup_authorization(token):
|
|
123
|
+
"""Look up a token. Returns (row, status) where status is 'valid', 'expired', or 'not_found'."""
|
|
124
|
+
db = get_db()
|
|
125
|
+
row = db.execute(
|
|
126
|
+
'SELECT token, filepath, filename, created_at, expires_at FROM authorizations WHERE token = ?',
|
|
127
|
+
(token,),
|
|
128
|
+
).fetchone()
|
|
129
|
+
db.close()
|
|
130
|
+
|
|
131
|
+
if row is None:
|
|
132
|
+
return None, 'not_found'
|
|
133
|
+
if time.time() > row['expires_at']:
|
|
134
|
+
return row, 'expired'
|
|
135
|
+
return row, 'valid'
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def has_active_authorizations():
|
|
139
|
+
"""Check if there are any non-expired authorizations."""
|
|
140
|
+
db = get_db()
|
|
141
|
+
row = db.execute(
|
|
142
|
+
'SELECT COUNT(*) as cnt FROM authorizations WHERE expires_at > ?',
|
|
143
|
+
(time.time(),),
|
|
144
|
+
).fetchone()
|
|
145
|
+
db.close()
|
|
146
|
+
return row['cnt'] > 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --- PID File Management ---
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def read_pid():
|
|
153
|
+
"""Read PID from file. Returns int or None."""
|
|
154
|
+
if not os.path.exists(PID_PATH):
|
|
155
|
+
return None
|
|
156
|
+
with open(PID_PATH) as f:
|
|
157
|
+
content = f.read().strip()
|
|
158
|
+
if not content:
|
|
159
|
+
return None
|
|
160
|
+
return int(content)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def write_pid():
|
|
164
|
+
"""Write current process PID to file."""
|
|
165
|
+
ensure_state_dir()
|
|
166
|
+
with open(PID_PATH, 'w') as f:
|
|
167
|
+
f.write(str(os.getpid()))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def remove_pid():
|
|
171
|
+
"""Remove PID file if it exists."""
|
|
172
|
+
if os.path.exists(PID_PATH):
|
|
173
|
+
os.remove(PID_PATH)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def is_daemon_running():
|
|
177
|
+
"""Check if daemon is alive via PID file. Cleans stale PID files."""
|
|
178
|
+
pid = read_pid()
|
|
179
|
+
if pid is None:
|
|
180
|
+
return False
|
|
181
|
+
try:
|
|
182
|
+
os.kill(pid, 0)
|
|
183
|
+
return True
|
|
184
|
+
except ProcessLookupError:
|
|
185
|
+
# Process doesn't exist — stale PID file
|
|
186
|
+
remove_pid()
|
|
187
|
+
return False
|
|
188
|
+
except PermissionError:
|
|
189
|
+
# Process exists but we can't signal it (different user) — treat as running
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --- Daemon ---
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def start_daemon(port, host):
|
|
197
|
+
"""Fork a background daemon process running 'vibefs serve'."""
|
|
198
|
+
ensure_state_dir()
|
|
199
|
+
log_file = open(LOG_PATH, 'a')
|
|
200
|
+
proc = subprocess.Popen(
|
|
201
|
+
[sys.executable, '-m', 'vibefs', 'serve', '--port', str(port), '--host', host],
|
|
202
|
+
stdout=log_file,
|
|
203
|
+
stderr=log_file,
|
|
204
|
+
start_new_session=True,
|
|
205
|
+
)
|
|
206
|
+
log_file.close()
|
|
207
|
+
# Give it a moment to start and write PID
|
|
208
|
+
time.sleep(0.3)
|
|
209
|
+
if proc.poll() is not None:
|
|
210
|
+
click.echo('Warning: daemon process exited immediately, check ~/.vibefs/vibefs.log', err=True)
|
|
211
|
+
else:
|
|
212
|
+
click.echo(f'Daemon started (pid {proc.pid})', err=True)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def stop_daemon():
|
|
216
|
+
"""Send SIGTERM to the daemon. Returns True if signal was sent."""
|
|
217
|
+
pid = read_pid()
|
|
218
|
+
if pid is None:
|
|
219
|
+
return False
|
|
220
|
+
try:
|
|
221
|
+
os.kill(pid, signal.SIGTERM)
|
|
222
|
+
return True
|
|
223
|
+
except ProcessLookupError:
|
|
224
|
+
remove_pid()
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# --- Auto-stop Timer ---
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def start_cleanup_timer():
|
|
232
|
+
"""Start a background thread that exits the server when all authorizations expire."""
|
|
233
|
+
|
|
234
|
+
def check_loop():
|
|
235
|
+
while True:
|
|
236
|
+
time.sleep(CLEANUP_INTERVAL)
|
|
237
|
+
if not has_active_authorizations():
|
|
238
|
+
click.echo('All authorizations expired, shutting down.', err=True)
|
|
239
|
+
remove_pid()
|
|
240
|
+
os._exit(0)
|
|
241
|
+
|
|
242
|
+
t = threading.Thread(target=check_loop, daemon=True)
|
|
243
|
+
t.start()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# --- Renderers ---
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class BaseRenderer:
|
|
250
|
+
"""Default renderer: returns raw file content with guessed content-type."""
|
|
251
|
+
|
|
252
|
+
def render(self, filepath):
|
|
253
|
+
content_type, _ = mimetypes.guess_type(filepath)
|
|
254
|
+
if content_type is None:
|
|
255
|
+
content_type = 'application/octet-stream'
|
|
256
|
+
bottle.response.content_type = content_type
|
|
257
|
+
with open(filepath, 'rb') as f:
|
|
258
|
+
return f.read()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class CodeRenderer:
|
|
262
|
+
"""Renders code files with syntax highlighting via Pygments."""
|
|
263
|
+
|
|
264
|
+
def render(self, filepath):
|
|
265
|
+
from pygments import highlight
|
|
266
|
+
from pygments.formatters import HtmlFormatter
|
|
267
|
+
from pygments.lexers import get_lexer_for_filename, TextLexer
|
|
268
|
+
|
|
269
|
+
with open(filepath) as f:
|
|
270
|
+
code = f.read()
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
lexer = get_lexer_for_filename(filepath)
|
|
274
|
+
except Exception:
|
|
275
|
+
lexer = TextLexer()
|
|
276
|
+
|
|
277
|
+
formatter = HtmlFormatter(
|
|
278
|
+
style='monokai',
|
|
279
|
+
linenos=False,
|
|
280
|
+
cssclass='highlight',
|
|
281
|
+
)
|
|
282
|
+
highlighted = highlight(code, lexer, formatter)
|
|
283
|
+
css = formatter.get_style_defs('.highlight')
|
|
284
|
+
filename = os.path.basename(filepath)
|
|
285
|
+
stat = os.stat(filepath)
|
|
286
|
+
file_size = _format_size(stat.st_size)
|
|
287
|
+
file_mtime = time.strftime('%Y-%m-%d %H:%M', time.localtime(stat.st_mtime))
|
|
288
|
+
|
|
289
|
+
bottle.response.content_type = 'text/html; charset=utf-8'
|
|
290
|
+
return CODE_HTML_TEMPLATE.format(
|
|
291
|
+
filename=filename,
|
|
292
|
+
file_info=f'{file_size} · {file_mtime}',
|
|
293
|
+
pygments_css=css,
|
|
294
|
+
highlighted=highlighted,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _format_size(nbytes):
|
|
299
|
+
for unit in ('B', 'KB', 'MB', 'GB'):
|
|
300
|
+
if nbytes < 1024:
|
|
301
|
+
return f'{nbytes:.0f} {unit}' if unit == 'B' else f'{nbytes:.1f} {unit}'
|
|
302
|
+
nbytes /= 1024
|
|
303
|
+
return f'{nbytes:.1f} TB'
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
CODE_HTML_TEMPLATE = """<!DOCTYPE html>
|
|
307
|
+
<html lang="en">
|
|
308
|
+
<head>
|
|
309
|
+
<meta charset="utf-8">
|
|
310
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
311
|
+
<title>{filename}</title>
|
|
312
|
+
<style>
|
|
313
|
+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
314
|
+
body {{
|
|
315
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
316
|
+
background: #1e1e1e;
|
|
317
|
+
color: #d4d4d4;
|
|
318
|
+
min-height: 100vh;
|
|
319
|
+
}}
|
|
320
|
+
.file-header {{
|
|
321
|
+
background: #2d2d2d;
|
|
322
|
+
border-bottom: 1px solid #404040;
|
|
323
|
+
padding: 12px 16px;
|
|
324
|
+
font-size: 14px;
|
|
325
|
+
font-weight: 600;
|
|
326
|
+
color: #e0e0e0;
|
|
327
|
+
display: flex;
|
|
328
|
+
justify-content: space-between;
|
|
329
|
+
align-items: center;
|
|
330
|
+
}}
|
|
331
|
+
.file-info {{
|
|
332
|
+
font-size: 12px;
|
|
333
|
+
font-weight: 400;
|
|
334
|
+
color: #888;
|
|
335
|
+
}}
|
|
336
|
+
.file-content {{
|
|
337
|
+
overflow-x: auto;
|
|
338
|
+
}}
|
|
339
|
+
/* Pygments overrides */
|
|
340
|
+
{pygments_css}
|
|
341
|
+
.highlight {{
|
|
342
|
+
background: #1e1e1e;
|
|
343
|
+
padding: 0;
|
|
344
|
+
}}
|
|
345
|
+
.highlight pre {{
|
|
346
|
+
padding: 12px 8px;
|
|
347
|
+
margin: 0;
|
|
348
|
+
font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', monospace;
|
|
349
|
+
font-size: 15px;
|
|
350
|
+
line-height: 1.6;
|
|
351
|
+
white-space: pre-wrap;
|
|
352
|
+
word-wrap: break-word;
|
|
353
|
+
overflow-wrap: break-word;
|
|
354
|
+
}}
|
|
355
|
+
/* Mobile responsive */
|
|
356
|
+
@media (max-width: 768px) {{
|
|
357
|
+
.file-header {{
|
|
358
|
+
padding: 10px 12px;
|
|
359
|
+
font-size: 13px;
|
|
360
|
+
}}
|
|
361
|
+
.highlight pre {{
|
|
362
|
+
font-size: 14px;
|
|
363
|
+
line-height: 1.5;
|
|
364
|
+
padding: 8px 12px;
|
|
365
|
+
}}
|
|
366
|
+
}}
|
|
367
|
+
</style>
|
|
368
|
+
</head>
|
|
369
|
+
<body>
|
|
370
|
+
<div class="file-header"><span>{filename}</span><span class="file-info">{file_info}</span></div>
|
|
371
|
+
<div class="file-content">
|
|
372
|
+
{highlighted}
|
|
373
|
+
</div>
|
|
374
|
+
</body>
|
|
375
|
+
</html>"""
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# Renderer registry: extension -> renderer instance
|
|
379
|
+
_renderers = {}
|
|
380
|
+
_fallback_renderer = BaseRenderer()
|
|
381
|
+
|
|
382
|
+
CODE_EXTENSIONS = [
|
|
383
|
+
'.py', '.js', '.ts', '.jsx', '.tsx', '.go', '.rs', '.rb', '.java',
|
|
384
|
+
'.c', '.cpp', '.h', '.hpp', '.cs', '.swift', '.kt', '.scala',
|
|
385
|
+
'.sh', '.bash', '.zsh', '.fish',
|
|
386
|
+
'.html', '.css', '.scss', '.less',
|
|
387
|
+
'.json', '.yaml', '.yml', '.toml', '.ini', '.cfg',
|
|
388
|
+
'.xml', '.sql', '.graphql',
|
|
389
|
+
'.md', '.rst', '.txt',
|
|
390
|
+
'.lua', '.vim', '.el', '.clj', '.hs', '.ml', '.ex', '.exs',
|
|
391
|
+
'.r', '.R', '.jl', '.pl', '.pm', '.php',
|
|
392
|
+
'.dockerfile', '.makefile', '.cmake',
|
|
393
|
+
'.conf', '.env', '.gitignore',
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def init_renderers():
|
|
398
|
+
"""Register renderers for known extensions."""
|
|
399
|
+
code_renderer = CodeRenderer()
|
|
400
|
+
for ext in CODE_EXTENSIONS:
|
|
401
|
+
_renderers[ext] = code_renderer
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def get_renderer(filepath):
|
|
405
|
+
"""Get the appropriate renderer for a file, falling back to BaseRenderer."""
|
|
406
|
+
_, ext = os.path.splitext(filepath)
|
|
407
|
+
return _renderers.get(ext.lower(), _fallback_renderer)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
init_renderers()
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# --- Web Server (Bottle) ---
|
|
414
|
+
|
|
415
|
+
app = bottle.Bottle()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@app.route('/f/<token>/<filename>')
|
|
419
|
+
def serve_file(token, filename):
|
|
420
|
+
row, status = lookup_authorization(token)
|
|
421
|
+
|
|
422
|
+
if status == 'not_found':
|
|
423
|
+
bottle.abort(404, 'Not found')
|
|
424
|
+
|
|
425
|
+
if status == 'expired':
|
|
426
|
+
return bottle.template(EXPIRED_TEMPLATE, filename=row['filename'])
|
|
427
|
+
|
|
428
|
+
filepath = row['filepath']
|
|
429
|
+
if not os.path.isfile(filepath):
|
|
430
|
+
bottle.abort(404, 'File no longer exists on disk')
|
|
431
|
+
|
|
432
|
+
renderer = get_renderer(filepath)
|
|
433
|
+
return renderer.render(filepath)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
EXPIRED_TEMPLATE = """<!DOCTYPE html>
|
|
437
|
+
<html>
|
|
438
|
+
<head><title>File Expired</title>
|
|
439
|
+
<style>
|
|
440
|
+
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 80px auto; text-align: center; color: #333; }
|
|
441
|
+
h1 { font-size: 1.4em; }
|
|
442
|
+
p { color: #666; }
|
|
443
|
+
</style>
|
|
444
|
+
</head>
|
|
445
|
+
<body>
|
|
446
|
+
<h1>This file is no longer available</h1>
|
|
447
|
+
<p><strong>{{filename}}</strong> has expired and can no longer be accessed.</p>
|
|
448
|
+
</body>
|
|
449
|
+
</html>
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# --- CLI (Click) ---
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@click.group()
|
|
457
|
+
def cli():
|
|
458
|
+
"""vibefs — Vibe File Server
|
|
459
|
+
|
|
460
|
+
A simple, secure file preview service for sharing files via time-limited URLs.
|
|
461
|
+
"""
|
|
462
|
+
pass
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@cli.command()
|
|
466
|
+
@click.option('--port', default=DEFAULT_PORT, show_default=True, help='Port to listen on')
|
|
467
|
+
@click.option('--host', default=DEFAULT_HOST, show_default=True, help='Host to bind to')
|
|
468
|
+
@click.option('--foreground', is_flag=True, default=False, help='Run in foreground (no PID file cleanup timer)')
|
|
469
|
+
def serve(port, host, foreground):
|
|
470
|
+
"""Start the web server."""
|
|
471
|
+
ensure_state_dir()
|
|
472
|
+
write_pid()
|
|
473
|
+
atexit.register(remove_pid)
|
|
474
|
+
|
|
475
|
+
if not foreground:
|
|
476
|
+
start_cleanup_timer()
|
|
477
|
+
|
|
478
|
+
click.echo(f'vibefs serving on http://{host}:{port} (pid {os.getpid()})')
|
|
479
|
+
app.run(host=host, port=port, quiet=True)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@cli.command()
|
|
483
|
+
@click.argument('path')
|
|
484
|
+
@click.option('--ttl', default=DEFAULT_TTL, show_default=True, help='Time-to-live in seconds')
|
|
485
|
+
@click.option('--port', default=DEFAULT_PORT, show_default=True, help='Port for URL generation')
|
|
486
|
+
@click.option('--host', default='localhost', show_default=True, help='Host for URL generation')
|
|
487
|
+
def allow(path, ttl, port, host):
|
|
488
|
+
"""Authorize a file for access, auto-start daemon if needed, and print its URL."""
|
|
489
|
+
ensure_state_dir()
|
|
490
|
+
token, filename = add_authorization(path, ttl)
|
|
491
|
+
base_url = load_config().get('base_url')
|
|
492
|
+
if base_url:
|
|
493
|
+
url = f'{base_url.rstrip("/")}/f/{token}/{filename}'
|
|
494
|
+
else:
|
|
495
|
+
url = f'http://{host}:{port}/f/{token}/{filename}'
|
|
496
|
+
click.echo(url)
|
|
497
|
+
|
|
498
|
+
# Auto-start daemon if not running
|
|
499
|
+
if not is_daemon_running():
|
|
500
|
+
start_daemon(port, DEFAULT_HOST)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@cli.command()
|
|
504
|
+
@click.argument('token')
|
|
505
|
+
def revoke(token):
|
|
506
|
+
"""Revoke access to a file by its token."""
|
|
507
|
+
if remove_authorization(token):
|
|
508
|
+
click.echo(f'Revoked: {token}')
|
|
509
|
+
else:
|
|
510
|
+
click.echo(f'Token not found: {token}', err=True)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@cli.command('list')
|
|
514
|
+
def list_cmd():
|
|
515
|
+
"""List currently authorized files."""
|
|
516
|
+
rows = list_authorizations()
|
|
517
|
+
if not rows:
|
|
518
|
+
click.echo('No active authorizations.')
|
|
519
|
+
return
|
|
520
|
+
|
|
521
|
+
now = time.time()
|
|
522
|
+
for row in rows:
|
|
523
|
+
remaining = row['expires_at'] - now
|
|
524
|
+
if remaining > 0:
|
|
525
|
+
status = f'{int(remaining)}s remaining'
|
|
526
|
+
else:
|
|
527
|
+
status = 'expired'
|
|
528
|
+
click.echo(f' {row["token"]} {row["filepath"]} [{status}]')
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@cli.command()
|
|
532
|
+
def stop():
|
|
533
|
+
"""Stop the running daemon."""
|
|
534
|
+
if stop_daemon():
|
|
535
|
+
click.echo('Daemon stopped.')
|
|
536
|
+
else:
|
|
537
|
+
click.echo('Daemon is not running.', err=True)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
@cli.command()
|
|
541
|
+
def status():
|
|
542
|
+
"""Check if the daemon is running."""
|
|
543
|
+
pid = read_pid()
|
|
544
|
+
if is_daemon_running():
|
|
545
|
+
click.echo(f'Daemon is running (pid {pid}).')
|
|
546
|
+
else:
|
|
547
|
+
click.echo('Daemon is not running.')
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@cli.group()
|
|
551
|
+
def config():
|
|
552
|
+
"""Get or set configuration values."""
|
|
553
|
+
pass
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
VALID_CONFIG_KEYS = ['base_url']
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@config.command('set')
|
|
560
|
+
@click.argument('key')
|
|
561
|
+
@click.argument('value')
|
|
562
|
+
def config_set(key, value):
|
|
563
|
+
"""Set a config value (e.g. vibefs config set base_url https://files.example.com)."""
|
|
564
|
+
if key not in VALID_CONFIG_KEYS:
|
|
565
|
+
click.echo(f'Unknown config key: {key}. Valid keys: {", ".join(VALID_CONFIG_KEYS)}', err=True)
|
|
566
|
+
sys.exit(1)
|
|
567
|
+
cfg = load_config()
|
|
568
|
+
cfg[key] = value
|
|
569
|
+
save_config(cfg)
|
|
570
|
+
click.echo(f'{key} = {value}')
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@config.command('get')
|
|
574
|
+
@click.argument('key')
|
|
575
|
+
def config_get(key):
|
|
576
|
+
"""Get a config value (e.g. vibefs config get base_url)."""
|
|
577
|
+
if key not in VALID_CONFIG_KEYS:
|
|
578
|
+
click.echo(f'Unknown config key: {key}. Valid keys: {", ".join(VALID_CONFIG_KEYS)}', err=True)
|
|
579
|
+
sys.exit(1)
|
|
580
|
+
cfg = load_config()
|
|
581
|
+
value = cfg.get(key)
|
|
582
|
+
if value is None:
|
|
583
|
+
click.echo(f'{key}: (not set)')
|
|
584
|
+
else:
|
|
585
|
+
click.echo(f'{key} = {value}')
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
if __name__ == '__main__':
|
|
589
|
+
cli()
|