formseal-fetch 2.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.
- formseal_fetch-2.0.0/LICENSE +21 -0
- formseal_fetch-2.0.0/PKG-INFO +107 -0
- formseal_fetch-2.0.0/README.md +75 -0
- formseal_fetch-2.0.0/cli/commands/__init__.py +0 -0
- formseal_fetch-2.0.0/cli/commands/config.py +194 -0
- formseal_fetch-2.0.0/cli/commands/fetch.py +72 -0
- formseal_fetch-2.0.0/cli/commands/providers.py +39 -0
- formseal_fetch-2.0.0/cli/commands/setup.py +127 -0
- formseal_fetch-2.0.0/cli/fsf.py +124 -0
- formseal_fetch-2.0.0/cli/providers/__init__.py +0 -0
- formseal_fetch-2.0.0/cli/providers/cloudflare/account.py +69 -0
- formseal_fetch-2.0.0/cli/providers/cloudflare/storage/__init__.py +3 -0
- formseal_fetch-2.0.0/cli/providers/cloudflare/storage/kv.py +80 -0
- formseal_fetch-2.0.0/cli/security/tokens.py +166 -0
- formseal_fetch-2.0.0/cli/ui.py +89 -0
- formseal_fetch-2.0.0/formseal_fetch.egg-info/PKG-INFO +107 -0
- formseal_fetch-2.0.0/formseal_fetch.egg-info/SOURCES.txt +21 -0
- formseal_fetch-2.0.0/formseal_fetch.egg-info/dependency_links.txt +1 -0
- formseal_fetch-2.0.0/formseal_fetch.egg-info/entry_points.txt +2 -0
- formseal_fetch-2.0.0/formseal_fetch.egg-info/requires.txt +1 -0
- formseal_fetch-2.0.0/formseal_fetch.egg-info/top_level.txt +1 -0
- formseal_fetch-2.0.0/pyproject.toml +20 -0
- formseal_fetch-2.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 grayguava
|
|
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.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: formseal-fetch
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: CLI tool for fetching encrypted form submissions
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 grayguava
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Requires-Python: >=3.8
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: keyring>=25.0
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# formseal-fetch
|
|
34
|
+
|
|
35
|
+
<p align="center">
|
|
36
|
+
<img src="https://img.shields.io/badge/python-3.8+-3776ab?style=flat&labelColor=1e293b">
|
|
37
|
+
<img src="https://img.shields.io/badge/license-MIT-fc8181?style=flat&labelColor=1e293b">
|
|
38
|
+
<img src="https://img.shields.io/badge/formseal-ecosystem-10b981?style=flat&labelColor=1e293b">
|
|
39
|
+
</p>
|
|
40
|
+
|
|
41
|
+
Download encrypted form submissions from your storage backend for offline decryption.
|
|
42
|
+
|
|
43
|
+
## What it does
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
Browser (formseal-embed)
|
|
47
|
+
│
|
|
48
|
+
▼ (encrypted submissions)
|
|
49
|
+
Storage (Cloudflare KV / Supabase / ...)
|
|
50
|
+
│
|
|
51
|
+
▼ (fsf fetch)
|
|
52
|
+
Your PC ──► ciphertexts.jsonl
|
|
53
|
+
│
|
|
54
|
+
▼ (decrypt offline)
|
|
55
|
+
Plaintext form data
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install formseal-fetch
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Quick start
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
fsf connect provider:<name>
|
|
68
|
+
fsf fetch
|
|
69
|
+
fsf status
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Features
|
|
73
|
+
|
|
74
|
+
- **Secure storage** : Credentials stored in OS keychain (Windows Credential Manager / macOS Keychain / Linux Secret Service)
|
|
75
|
+
- **Deduplication** : Skips already-downloaded ciphertexts automatically
|
|
76
|
+
- **Offline-capable** ; Decrypt downloaded data anytime without network access
|
|
77
|
+
|
|
78
|
+
## Commands
|
|
79
|
+
|
|
80
|
+
| Command | Description |
|
|
81
|
+
|---------|-------------|
|
|
82
|
+
| `fsf connect` | Connect to a storage provider |
|
|
83
|
+
| `fsf fetch` | Download ciphertexts |
|
|
84
|
+
| `fsf status` | Show connection info |
|
|
85
|
+
| `fsf disconnect` | Clear all credentials |
|
|
86
|
+
|
|
87
|
+
Run `fsf --help` for all commands.
|
|
88
|
+
|
|
89
|
+
## Security
|
|
90
|
+
|
|
91
|
+
Your API tokens never leave your machine.formseal-fetch:
|
|
92
|
+
- Stores credentials in your OS keychain (encrypted at rest)
|
|
93
|
+
- Makes direct API calls to your storage backend only
|
|
94
|
+
- Sends no telemetry, has no analytics
|
|
95
|
+
|
|
96
|
+
## Documentation
|
|
97
|
+
|
|
98
|
+
Detailed guides: [docs/](./docs/)
|
|
99
|
+
|
|
100
|
+
- [Getting Started](./docs/getting-started.md)
|
|
101
|
+
- [Security](./docs/security.md)
|
|
102
|
+
- [Commands Reference](./docs/reference/commands.md)
|
|
103
|
+
- [Troubleshooting](./docs/troubleshooting.md)
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# formseal-fetch
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="https://img.shields.io/badge/python-3.8+-3776ab?style=flat&labelColor=1e293b">
|
|
5
|
+
<img src="https://img.shields.io/badge/license-MIT-fc8181?style=flat&labelColor=1e293b">
|
|
6
|
+
<img src="https://img.shields.io/badge/formseal-ecosystem-10b981?style=flat&labelColor=1e293b">
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
Download encrypted form submissions from your storage backend for offline decryption.
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
Browser (formseal-embed)
|
|
15
|
+
│
|
|
16
|
+
▼ (encrypted submissions)
|
|
17
|
+
Storage (Cloudflare KV / Supabase / ...)
|
|
18
|
+
│
|
|
19
|
+
▼ (fsf fetch)
|
|
20
|
+
Your PC ──► ciphertexts.jsonl
|
|
21
|
+
│
|
|
22
|
+
▼ (decrypt offline)
|
|
23
|
+
Plaintext form data
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install formseal-fetch
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
fsf connect provider:<name>
|
|
36
|
+
fsf fetch
|
|
37
|
+
fsf status
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Features
|
|
41
|
+
|
|
42
|
+
- **Secure storage** : Credentials stored in OS keychain (Windows Credential Manager / macOS Keychain / Linux Secret Service)
|
|
43
|
+
- **Deduplication** : Skips already-downloaded ciphertexts automatically
|
|
44
|
+
- **Offline-capable** ; Decrypt downloaded data anytime without network access
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
| Command | Description |
|
|
49
|
+
|---------|-------------|
|
|
50
|
+
| `fsf connect` | Connect to a storage provider |
|
|
51
|
+
| `fsf fetch` | Download ciphertexts |
|
|
52
|
+
| `fsf status` | Show connection info |
|
|
53
|
+
| `fsf disconnect` | Clear all credentials |
|
|
54
|
+
|
|
55
|
+
Run `fsf --help` for all commands.
|
|
56
|
+
|
|
57
|
+
## Security
|
|
58
|
+
|
|
59
|
+
Your API tokens never leave your machine.formseal-fetch:
|
|
60
|
+
- Stores credentials in your OS keychain (encrypted at rest)
|
|
61
|
+
- Makes direct API calls to your storage backend only
|
|
62
|
+
- Sends no telemetry, has no analytics
|
|
63
|
+
|
|
64
|
+
## Documentation
|
|
65
|
+
|
|
66
|
+
Detailed guides: [docs/](./docs/)
|
|
67
|
+
|
|
68
|
+
- [Getting Started](./docs/getting-started.md)
|
|
69
|
+
- [Security](./docs/security.md)
|
|
70
|
+
- [Commands Reference](./docs/reference/commands.md)
|
|
71
|
+
- [Troubleshooting](./docs/troubleshooting.md)
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
File without changes
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Config management
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cli.ui import br, fail, ok, info, warn, G, W, D, C, Y, R
|
|
8
|
+
from cli.security import tokens
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_version():
|
|
12
|
+
p = Path(__file__).parent.parent.parent / "version.txt"
|
|
13
|
+
if p.exists():
|
|
14
|
+
return p.read_text().strip()
|
|
15
|
+
return "dev"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
VERSION = _load_version()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
CONFIG_DIR = Path.home() / ".config" / "formseal-fetch"
|
|
22
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
VALID_PROVIDERS = {
|
|
26
|
+
"cloudflare": {},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
VALID_GLOBAL = {
|
|
30
|
+
"output_folder": "Output folder path",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_config():
|
|
35
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
if CONFIG_FILE.exists():
|
|
37
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def save_config(cfg):
|
|
42
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_token(provider: str):
|
|
47
|
+
return tokens.load_token(provider)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_namespace(provider: str):
|
|
51
|
+
return tokens.load_namespace(provider)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run_set(args):
|
|
55
|
+
if len(args) < 2:
|
|
56
|
+
fail("Usage: fsf set <key> <value>")
|
|
57
|
+
|
|
58
|
+
key = args[0]
|
|
59
|
+
value = " ".join(args[1:])
|
|
60
|
+
|
|
61
|
+
cfg = load_config()
|
|
62
|
+
|
|
63
|
+
if key == "provider":
|
|
64
|
+
if value not in VALID_PROVIDERS:
|
|
65
|
+
fail(f"Unknown provider: {value}\nValid: {', '.join(VALID_PROVIDERS.keys())}")
|
|
66
|
+
cfg["provider"] = value
|
|
67
|
+
save_config(cfg)
|
|
68
|
+
br()
|
|
69
|
+
ok(f"Provider set to {value}")
|
|
70
|
+
br()
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
if key in VALID_GLOBAL:
|
|
74
|
+
cfg[key] = value
|
|
75
|
+
save_config(cfg)
|
|
76
|
+
br()
|
|
77
|
+
ok(f"Set {key} = {value}")
|
|
78
|
+
br()
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
fail(f"Unknown key: {key}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_status():
|
|
85
|
+
cfg = load_config()
|
|
86
|
+
|
|
87
|
+
br()
|
|
88
|
+
print(f"{C} \u250c\u2500 {R}{W}formseal-fetch{R} {Y}v{VERSION}{R}")
|
|
89
|
+
print(G + " " + "\u2500" * 52 + R)
|
|
90
|
+
br()
|
|
91
|
+
|
|
92
|
+
print(f" {D}Configuration Status:{R}")
|
|
93
|
+
br()
|
|
94
|
+
|
|
95
|
+
provider = cfg.get("provider")
|
|
96
|
+
if not provider:
|
|
97
|
+
warn("No provider configured. Run: fsf connect provider:<name>")
|
|
98
|
+
br()
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
def row(label, value, color=W):
|
|
102
|
+
print(f" {D}{label:<26}{R}{color}{value}{R}")
|
|
103
|
+
|
|
104
|
+
row("Provider:", "Cloudflare")
|
|
105
|
+
|
|
106
|
+
if provider == "cloudflare":
|
|
107
|
+
namespace = get_namespace(provider)
|
|
108
|
+
token = get_token(provider)
|
|
109
|
+
|
|
110
|
+
row("KV Namespace ID:", namespace or "(not set)", W if namespace else D)
|
|
111
|
+
if namespace:
|
|
112
|
+
row("NS-ID Location:", tokens.namespace_location(provider), G)
|
|
113
|
+
|
|
114
|
+
if token:
|
|
115
|
+
try:
|
|
116
|
+
from cli.providers.cloudflare.account import get_account_id
|
|
117
|
+
account_id = get_account_id(token)
|
|
118
|
+
partial = account_id[:12] + "..." if len(account_id) > 12 else account_id
|
|
119
|
+
row("Account ID:", partial, G)
|
|
120
|
+
except Exception:
|
|
121
|
+
row("Account ID:", "(auth error)", R)
|
|
122
|
+
row("API Token:", "****")
|
|
123
|
+
row("Token Location:", tokens.token_location(provider), G)
|
|
124
|
+
else:
|
|
125
|
+
row("API Token:", "(not set)", D)
|
|
126
|
+
row("Token Location:", "Not set", D)
|
|
127
|
+
|
|
128
|
+
br()
|
|
129
|
+
row("Server Storage Type:", "Key-Value")
|
|
130
|
+
|
|
131
|
+
else:
|
|
132
|
+
fail(f"Unknown provider: {provider}")
|
|
133
|
+
|
|
134
|
+
output_folder = cfg.get("output_folder")
|
|
135
|
+
row("Output Folder:", output_folder or "(not set)", W if output_folder else D)
|
|
136
|
+
|
|
137
|
+
br()
|
|
138
|
+
|
|
139
|
+
br()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def run_config_show():
|
|
143
|
+
cfg = load_config()
|
|
144
|
+
|
|
145
|
+
br()
|
|
146
|
+
print(f"{C} \u250c\u2500 {R}{W}formseal-fetch{R} {G}config{R}")
|
|
147
|
+
print(G + " " + "\u2500" * 52 + R)
|
|
148
|
+
br()
|
|
149
|
+
|
|
150
|
+
provider = cfg.get("provider")
|
|
151
|
+
|
|
152
|
+
if not cfg:
|
|
153
|
+
info("No config. Run: fsf connect provider:<name>")
|
|
154
|
+
else:
|
|
155
|
+
for k, v in cfg.items():
|
|
156
|
+
print(f" {D}{k}:{R} {W}{v}{R}")
|
|
157
|
+
|
|
158
|
+
if provider:
|
|
159
|
+
token = get_token(provider)
|
|
160
|
+
print(f" {D}token:{R} {'****' if token else 'not set'}")
|
|
161
|
+
|
|
162
|
+
br()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def run_disconnect():
|
|
166
|
+
br()
|
|
167
|
+
print(f" {Y}This will delete all config and credentials.{R}")
|
|
168
|
+
print(f" Downloaded ciphertexts will NOT be affected.")
|
|
169
|
+
br()
|
|
170
|
+
sys.stdout.write(f" Continue? [y/N]: ")
|
|
171
|
+
sys.stdout.flush()
|
|
172
|
+
confirm = input().strip().lower()
|
|
173
|
+
|
|
174
|
+
if confirm != "y":
|
|
175
|
+
br()
|
|
176
|
+
info("Cancelled.")
|
|
177
|
+
br()
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
cfg = load_config()
|
|
181
|
+
provider = cfg.get("provider")
|
|
182
|
+
|
|
183
|
+
if CONFIG_FILE.exists():
|
|
184
|
+
CONFIG_FILE.unlink()
|
|
185
|
+
|
|
186
|
+
if provider:
|
|
187
|
+
tokens.clear_all(provider)
|
|
188
|
+
|
|
189
|
+
br()
|
|
190
|
+
ok("Disconnected. All config and credentials cleared.")
|
|
191
|
+
br()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
run_logout = run_disconnect
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Fetch ciphertexts
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from cli.ui import br, fail, ok, info, G, W, D, C, R
|
|
6
|
+
from cli.commands.config import load_config
|
|
7
|
+
from cli.security import tokens
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run(args):
|
|
11
|
+
parser = argparse.ArgumentParser(prog="fsf fetch")
|
|
12
|
+
parser.add_argument("--output", default=None)
|
|
13
|
+
parsed = parser.parse_args(args)
|
|
14
|
+
|
|
15
|
+
cfg = load_config()
|
|
16
|
+
provider = cfg.get("provider")
|
|
17
|
+
output_folder = cfg.get("output_folder", "data")
|
|
18
|
+
|
|
19
|
+
if not provider:
|
|
20
|
+
fail("No provider set. Run: fsf connect provider:<name>")
|
|
21
|
+
|
|
22
|
+
if parsed.output:
|
|
23
|
+
output_path = parsed.output
|
|
24
|
+
else:
|
|
25
|
+
output_path = f"{output_folder}/ciphertexts.jsonl"
|
|
26
|
+
|
|
27
|
+
token = tokens.load_token(provider)
|
|
28
|
+
if not token:
|
|
29
|
+
fail("No token. Run: fsf connect provider:<name> to set token")
|
|
30
|
+
|
|
31
|
+
if provider == "cloudflare":
|
|
32
|
+
namespace = tokens.load_namespace(provider)
|
|
33
|
+
if not namespace:
|
|
34
|
+
fail("No namespace. Run: fsf connect provider:<name> to set namespace")
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
from cli.providers.cloudflare.account import get_account_id
|
|
38
|
+
account_id = get_account_id(token)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
fail(f"Auth failed: {e}")
|
|
41
|
+
|
|
42
|
+
br()
|
|
43
|
+
provider_display = f"{provider.capitalize()} KV"
|
|
44
|
+
print(f"{C} \u250c\u2500 {R}{W}formseal-fetch{R} {G}{provider_display}{R}")
|
|
45
|
+
print(G + " " + "\u2500" * 52 + R)
|
|
46
|
+
br()
|
|
47
|
+
|
|
48
|
+
account_trunc = account_id[:8] + "***" if len(account_id) > 8 else account_id
|
|
49
|
+
namespace_trunc = namespace[:8] + "***" if len(namespace) > 8 else namespace
|
|
50
|
+
|
|
51
|
+
print(f"{D}>> Account:{R} {D}{account_trunc}{R}")
|
|
52
|
+
print(f"{D}>> Namespace:{R} {D}{namespace_trunc}{R}")
|
|
53
|
+
br()
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
from cli.providers.cloudflare.storage import fetch as storage_fetch
|
|
57
|
+
written, skipped = storage_fetch(
|
|
58
|
+
namespace=namespace,
|
|
59
|
+
account_id=account_id,
|
|
60
|
+
token=token,
|
|
61
|
+
output_path=output_path,
|
|
62
|
+
)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
fail(f"Fetch failed: {e}")
|
|
65
|
+
|
|
66
|
+
br()
|
|
67
|
+
ok(f"{written} new ciphertexts saved → {output_path}")
|
|
68
|
+
if skipped:
|
|
69
|
+
print(f" {D}({skipped} duplicates skipped){R}")
|
|
70
|
+
br()
|
|
71
|
+
else:
|
|
72
|
+
fail(f"Unknown provider: {provider}")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# List providers
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from cli.ui import br, C, G, Y, W, D, R
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _load_version():
|
|
9
|
+
p = Path(__file__).parent.parent.parent / "version.txt"
|
|
10
|
+
if p.exists():
|
|
11
|
+
return p.read_text().strip()
|
|
12
|
+
return "dev"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
VERSION = _load_version()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(args):
|
|
19
|
+
_list_providers()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _list_providers():
|
|
23
|
+
br()
|
|
24
|
+
print(f"{C} \u250c\u2500 {R}{W}formseal-fetch{R} {Y}v{VERSION}{R}")
|
|
25
|
+
print(G + " " + "\u2500" * 52 + R)
|
|
26
|
+
br()
|
|
27
|
+
|
|
28
|
+
print(f" {G}Available providers:{R}")
|
|
29
|
+
br()
|
|
30
|
+
print(f" {W}> Cloudflare{R} - Cloudflare KV")
|
|
31
|
+
br()
|
|
32
|
+
print(f" {W}> Supabase{R} - PostgreSQL DB (coming soon)")
|
|
33
|
+
br()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _help():
|
|
37
|
+
return [
|
|
38
|
+
("fsf providers", "list available providers"),
|
|
39
|
+
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Connect commands
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cli.ui import br, fail, ok, info, warn, G, W, D, C, Y, O, R
|
|
8
|
+
from cli.commands.config import load_config, save_config
|
|
9
|
+
from cli.security import tokens
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SETUP_SCHEMA = {
|
|
13
|
+
"cloudflare": {
|
|
14
|
+
"kv": {
|
|
15
|
+
"config_fields": [
|
|
16
|
+
{"key": "namespace", "prompt": "KV Namespace ID", "required": True},
|
|
17
|
+
],
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_args(args):
|
|
24
|
+
parsed = {}
|
|
25
|
+
for arg in args:
|
|
26
|
+
if ":" not in arg:
|
|
27
|
+
fail(f"Invalid format: {arg}\n Use flag:value (e.g., provider:<name>)")
|
|
28
|
+
key, value = arg.split(":", 1)
|
|
29
|
+
parsed[key] = value
|
|
30
|
+
return parsed
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run(args):
|
|
34
|
+
if not args:
|
|
35
|
+
fail("Usage: fsf connect provider:<name> [namespace:<id>] [output:<path>]")
|
|
36
|
+
|
|
37
|
+
parsed = _parse_args(args)
|
|
38
|
+
|
|
39
|
+
if "provider" not in parsed:
|
|
40
|
+
fail("provider is required.\n Usage: fsf connect provider:<name> [...]")
|
|
41
|
+
|
|
42
|
+
provider = parsed["provider"].lower()
|
|
43
|
+
if provider not in SETUP_SCHEMA:
|
|
44
|
+
fail(f"Unknown provider: {provider}\n Run fsf providers to see available.")
|
|
45
|
+
|
|
46
|
+
_setup_flow(provider, parsed)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _setup_flow(provider, parsed):
|
|
50
|
+
print()
|
|
51
|
+
print(f"{C} \u250c\u2500 {R}{W}formseal-fetch{R} {G}setup{R}")
|
|
52
|
+
print(G + " " + "\u2500" * 52 + R)
|
|
53
|
+
print()
|
|
54
|
+
|
|
55
|
+
cfg = load_config()
|
|
56
|
+
cfg["provider"] = provider
|
|
57
|
+
|
|
58
|
+
# Get config fields from schema
|
|
59
|
+
fields = SETUP_SCHEMA[provider]["kv"]["config_fields"]
|
|
60
|
+
|
|
61
|
+
# Ask for namespace if not provided
|
|
62
|
+
namespace = parsed.get("namespace")
|
|
63
|
+
if not namespace:
|
|
64
|
+
for field in fields:
|
|
65
|
+
prompt = field["prompt"]
|
|
66
|
+
try:
|
|
67
|
+
sys.stdout.write(f" {prompt}: ")
|
|
68
|
+
sys.stdout.flush()
|
|
69
|
+
namespace = input().strip()
|
|
70
|
+
except KeyboardInterrupt:
|
|
71
|
+
br()
|
|
72
|
+
info("Cancelled.")
|
|
73
|
+
br()
|
|
74
|
+
return
|
|
75
|
+
if field.get("required") and not namespace:
|
|
76
|
+
fail(f"{prompt} is required")
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
if namespace:
|
|
80
|
+
tokens.save_namespace(provider, namespace)
|
|
81
|
+
|
|
82
|
+
# Ask for token
|
|
83
|
+
if "token" in parsed:
|
|
84
|
+
token = parsed["token"]
|
|
85
|
+
else:
|
|
86
|
+
try:
|
|
87
|
+
sys.stdout.write(" Account API Token: ")
|
|
88
|
+
sys.stdout.flush()
|
|
89
|
+
token = sys.stdin.readline().strip()
|
|
90
|
+
except KeyboardInterrupt:
|
|
91
|
+
br()
|
|
92
|
+
info("Cancelled.")
|
|
93
|
+
br()
|
|
94
|
+
return
|
|
95
|
+
if not token:
|
|
96
|
+
fail("Account API Token is required")
|
|
97
|
+
|
|
98
|
+
token = "".join(c for c in token if c.isprintable()).strip()
|
|
99
|
+
if not token:
|
|
100
|
+
fail("Account API Token is required")
|
|
101
|
+
|
|
102
|
+
tokens.save_token(provider, token)
|
|
103
|
+
|
|
104
|
+
# Ask for output folder
|
|
105
|
+
if "output" in parsed:
|
|
106
|
+
output_folder = parsed["output"]
|
|
107
|
+
else:
|
|
108
|
+
try:
|
|
109
|
+
sys.stdout.write(f" Output Folder [{D}data{R}]: ")
|
|
110
|
+
sys.stdout.flush()
|
|
111
|
+
output_folder = input().strip()
|
|
112
|
+
if not output_folder:
|
|
113
|
+
output_folder = "data"
|
|
114
|
+
except KeyboardInterrupt:
|
|
115
|
+
br()
|
|
116
|
+
info("Cancelled.")
|
|
117
|
+
br()
|
|
118
|
+
return
|
|
119
|
+
cfg["output_folder"] = output_folder
|
|
120
|
+
print()
|
|
121
|
+
|
|
122
|
+
save_config(cfg)
|
|
123
|
+
|
|
124
|
+
print(f"{G} \u2713{R} Saved!")
|
|
125
|
+
print()
|
|
126
|
+
print(f" Run {W}fsf fetch{R} to download ciphertexts")
|
|
127
|
+
print()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Main entry point
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
7
|
+
|
|
8
|
+
from cli.ui import br, fail, info, warn, C, G, W, Y, R, D, GRAY
|
|
9
|
+
from cli.commands.config import run_status, run_set, run_disconnect
|
|
10
|
+
from cli.commands.setup import run as run_connect
|
|
11
|
+
from cli.commands.fetch import run as run_fetch
|
|
12
|
+
from cli.commands.providers import run as run_providers
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_version():
|
|
16
|
+
p = Path(__file__).parent.parent / "version.txt"
|
|
17
|
+
if p.exists():
|
|
18
|
+
return p.read_text().strip()
|
|
19
|
+
return "dev"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
VERSION = _load_version()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
COMMANDS = {
|
|
26
|
+
"connect": ("Connect to a provider", lambda a: run_connect(a)),
|
|
27
|
+
"fetch": ("Fetch ciphertexts", lambda a: run_fetch(a)),
|
|
28
|
+
"status": ("Show connection status", lambda a: run_status()),
|
|
29
|
+
"set": ("Set a config value", run_set),
|
|
30
|
+
"disconnect": ("Clear all credentials", lambda a: run_disconnect()),
|
|
31
|
+
"providers": ("List available providers", lambda a: run_providers(a)),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
HELP_GROUPS = {
|
|
36
|
+
"Connect": [
|
|
37
|
+
("fsf connect provider:<name>", "connect to a storage provider"),
|
|
38
|
+
("fsf disconnect", "clear configuration"),
|
|
39
|
+
],
|
|
40
|
+
"Fetch": [
|
|
41
|
+
("fsf fetch", "download ciphertexts"),
|
|
42
|
+
("fsf fetch --output <file>", "custom output path"),
|
|
43
|
+
],
|
|
44
|
+
"Config": [
|
|
45
|
+
("fsf status", "show configuration"),
|
|
46
|
+
("fsf set <key> <value>", "set config value"),
|
|
47
|
+
],
|
|
48
|
+
"Info": [
|
|
49
|
+
("fsf providers", "list available providers"),
|
|
50
|
+
("fsf --about", "show project info"),
|
|
51
|
+
],
|
|
52
|
+
"Docs": [
|
|
53
|
+
("https://github.com/formseal/formseal-fetch", None),
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _show_help():
|
|
59
|
+
br()
|
|
60
|
+
print(f"{C} \u250c\u2500 {R}{W}formseal-fetch{R} {Y}v{VERSION}{R}")
|
|
61
|
+
print(G + " " + "\u2500" * 52 + R)
|
|
62
|
+
br()
|
|
63
|
+
|
|
64
|
+
for group, cmds in HELP_GROUPS.items():
|
|
65
|
+
print(f" {GRAY}>> {group}{R}")
|
|
66
|
+
print(G + " " + "\u2500" * 52 + R)
|
|
67
|
+
for cmd, desc in cmds:
|
|
68
|
+
if desc:
|
|
69
|
+
print(f" {W}{cmd:<35}{R} {G}{desc}{R}")
|
|
70
|
+
else:
|
|
71
|
+
print(f" {C}{cmd}{R}")
|
|
72
|
+
br()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _show_about():
|
|
76
|
+
br()
|
|
77
|
+
print(f"{C} \u250c\u2500 {R}{W}formseal-fetch{R} {Y}v{VERSION}{R}")
|
|
78
|
+
print(G + " " + "\u2500" * 52 + R)
|
|
79
|
+
br()
|
|
80
|
+
print(f" {W}formseal-fetch{R} - CLI for fetching encrypted form submissions")
|
|
81
|
+
br()
|
|
82
|
+
print(f" {G}Repository:{R} https://github.com/formseal/formseal-fetch")
|
|
83
|
+
br()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main():
|
|
87
|
+
if len(sys.argv) < 2:
|
|
88
|
+
_show_help()
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
cmd = sys.argv[1].lower()
|
|
92
|
+
args = sys.argv[2:]
|
|
93
|
+
|
|
94
|
+
if cmd == "-h" or cmd == "--help":
|
|
95
|
+
_show_help()
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if cmd == "--about":
|
|
99
|
+
_show_about()
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
if cmd not in COMMANDS:
|
|
103
|
+
br()
|
|
104
|
+
fail(f"Unknown command: {cmd}\nRun 'fsf --help' for available commands")
|
|
105
|
+
|
|
106
|
+
_, handler = COMMANDS[cmd]
|
|
107
|
+
|
|
108
|
+
if handler is None:
|
|
109
|
+
br()
|
|
110
|
+
fail(f"Command not implemented: {cmd}")
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
handler(args)
|
|
114
|
+
except KeyboardInterrupt:
|
|
115
|
+
br()
|
|
116
|
+
info("Interrupted.")
|
|
117
|
+
br()
|
|
118
|
+
sys.exit(130)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
fail(str(e))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Cloudflare account authentication
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.request
|
|
5
|
+
import urllib.error
|
|
6
|
+
|
|
7
|
+
from cli.ui import fail
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthError(Exception):
|
|
11
|
+
"""Raised when Cloudflare authentication fails."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TokenError(Exception):
|
|
16
|
+
"""Raised when token is invalid or missing."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_account_id(token):
|
|
21
|
+
"""Fetch account_id from Cloudflare API."""
|
|
22
|
+
if not token:
|
|
23
|
+
raise TokenError("Token is empty")
|
|
24
|
+
|
|
25
|
+
token = token.strip()
|
|
26
|
+
if not token:
|
|
27
|
+
raise TokenError("Token is blank after stripping whitespace")
|
|
28
|
+
|
|
29
|
+
req = urllib.request.Request(
|
|
30
|
+
"https://api.cloudflare.com/client/v4/accounts",
|
|
31
|
+
headers={"Authorization": f"Bearer {token}"}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
36
|
+
data = json.loads(resp.read().decode())
|
|
37
|
+
except urllib.error.HTTPError as e:
|
|
38
|
+
body = e.read().decode("utf-8", errors="replace")
|
|
39
|
+
raise AuthError(f"HTTP {e.code}: {body}")
|
|
40
|
+
except json.JSONDecodeError as e:
|
|
41
|
+
raise AuthError(f"Invalid API response: {e}")
|
|
42
|
+
except urllib.error.URLError as e:
|
|
43
|
+
raise AuthError(f"Network error: {e.reason}")
|
|
44
|
+
|
|
45
|
+
if not data.get("success"):
|
|
46
|
+
errors = data.get("errors", [])
|
|
47
|
+
if errors:
|
|
48
|
+
msg = errors[0].get("message", "Unknown error")
|
|
49
|
+
if "token" in msg.lower():
|
|
50
|
+
raise TokenError(f"Invalid token: {msg}")
|
|
51
|
+
raise AuthError(msg)
|
|
52
|
+
raise AuthError("Unknown API error")
|
|
53
|
+
|
|
54
|
+
accounts = data.get("result", [])
|
|
55
|
+
if not accounts:
|
|
56
|
+
raise AuthError("No accounts found. Token needs 'Account Settings: Read' scope.")
|
|
57
|
+
|
|
58
|
+
return accounts[0]["id"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def validate_token(token):
|
|
62
|
+
"""Check if token is valid. Returns True/False, never raises."""
|
|
63
|
+
try:
|
|
64
|
+
get_account_id(token)
|
|
65
|
+
return True
|
|
66
|
+
except (TokenError, AuthError) as e:
|
|
67
|
+
return False
|
|
68
|
+
except Exception:
|
|
69
|
+
return False
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Cloudflare KV storage
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.request
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from cli.ui import fail, info
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get(url, token):
|
|
13
|
+
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
|
|
14
|
+
try:
|
|
15
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
16
|
+
return json.loads(resp.read().decode())
|
|
17
|
+
except urllib.error.HTTPError as e:
|
|
18
|
+
fail(f"HTTP {e.code}: {e.read().decode()}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_raw(url, token):
|
|
22
|
+
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
|
|
23
|
+
try:
|
|
24
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
25
|
+
return resp.read().decode("utf-8").strip()
|
|
26
|
+
except urllib.error.HTTPError as e:
|
|
27
|
+
fail(f"HTTP {e.code}: {e.read().decode()}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_seen(output_path) -> set:
|
|
31
|
+
p = Path(output_path)
|
|
32
|
+
if not p.exists():
|
|
33
|
+
return set()
|
|
34
|
+
return {line.strip() for line in p.read_text(encoding="utf-8").splitlines() if line.strip()}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fetch(namespace, account_id, token, output_path):
|
|
38
|
+
"""Fetch all values from a KV namespace."""
|
|
39
|
+
base = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/storage/kv/namespaces/{namespace}"
|
|
40
|
+
|
|
41
|
+
# List all keys (paginated)
|
|
42
|
+
all_keys = []
|
|
43
|
+
cursor = None
|
|
44
|
+
|
|
45
|
+
while True:
|
|
46
|
+
url = f"{base}/keys" + (f"?cursor={cursor}" if cursor else "")
|
|
47
|
+
data = _get(url, token)
|
|
48
|
+
if not data.get("success"):
|
|
49
|
+
fail(f"API error: {data.get('errors')}")
|
|
50
|
+
all_keys.extend(k["name"] for k in data.get("result", []))
|
|
51
|
+
cursor = data.get("result_info", {}).get("cursor")
|
|
52
|
+
if not cursor:
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
info(f"Found {len(all_keys)} entries")
|
|
56
|
+
|
|
57
|
+
if not all_keys:
|
|
58
|
+
info("No data to fetch.")
|
|
59
|
+
return 0, 0
|
|
60
|
+
|
|
61
|
+
# Deduplicate against existing
|
|
62
|
+
seen = _load_seen(output_path)
|
|
63
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
written = 0
|
|
66
|
+
skipped = 0
|
|
67
|
+
|
|
68
|
+
with open(output_path, "a", encoding="utf-8") as f:
|
|
69
|
+
for key in all_keys:
|
|
70
|
+
value = _get_raw(f"{base}/values/{urllib.parse.quote(key, safe='')}", token)
|
|
71
|
+
if not value:
|
|
72
|
+
continue
|
|
73
|
+
if value in seen:
|
|
74
|
+
skipped += 1
|
|
75
|
+
continue
|
|
76
|
+
f.write(value + "\n")
|
|
77
|
+
seen.add(value)
|
|
78
|
+
written += 1
|
|
79
|
+
|
|
80
|
+
return written, skipped
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Credential storage (keyring + JSON fallback)
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import base64
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import keyring
|
|
9
|
+
HAS_KEYRING = True
|
|
10
|
+
except ImportError:
|
|
11
|
+
HAS_KEYRING = False
|
|
12
|
+
|
|
13
|
+
SERVICE = "formseal-fetch"
|
|
14
|
+
|
|
15
|
+
CONFIG_DIR = Path.home() / ".config" / "formseal-fetch"
|
|
16
|
+
SECRETS_FILE = CONFIG_DIR / "secrets.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ensure_config_dir():
|
|
20
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_secrets() -> dict:
|
|
24
|
+
if not SECRETS_FILE.exists():
|
|
25
|
+
return {}
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(SECRETS_FILE.read_text())
|
|
28
|
+
except:
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _save_secrets(secrets: dict):
|
|
33
|
+
_ensure_config_dir()
|
|
34
|
+
SECRETS_FILE.write_text(json.dumps(secrets, indent=2))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save_token(provider: str, token: str) -> bool:
|
|
38
|
+
"""Save API token to OS keyring. Falls back to JSON if keyring fails."""
|
|
39
|
+
if HAS_KEYRING:
|
|
40
|
+
try:
|
|
41
|
+
keyring.set_password(SERVICE, f"{provider}:api-token", token)
|
|
42
|
+
return True
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
# Fallback to JSON file
|
|
47
|
+
secrets = _load_secrets()
|
|
48
|
+
secrets[f"{provider}:token"] = base64.b64encode(token.encode()).decode()
|
|
49
|
+
_save_secrets(secrets)
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_token(provider: str) -> str | None:
|
|
54
|
+
"""Load API token from OS keyring. Falls back to JSON if not in keyring."""
|
|
55
|
+
if HAS_KEYRING:
|
|
56
|
+
try:
|
|
57
|
+
token = keyring.get_password(SERVICE, f"{provider}:api-token")
|
|
58
|
+
if token:
|
|
59
|
+
return token
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
# Fallback to JSON file
|
|
64
|
+
secrets = _load_secrets()
|
|
65
|
+
encoded = secrets.get(f"{provider}:token")
|
|
66
|
+
if encoded:
|
|
67
|
+
return base64.b64decode(encoded.encode()).decode().strip()
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def delete_token(provider: str):
|
|
72
|
+
"""Delete API token from keyring. Falls back to JSON if keyring fails."""
|
|
73
|
+
if HAS_KEYRING:
|
|
74
|
+
try:
|
|
75
|
+
keyring.delete_password(SERVICE, f"{provider}:api-token")
|
|
76
|
+
return
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
secrets = _load_secrets()
|
|
81
|
+
secrets.pop(f"{provider}:token", None)
|
|
82
|
+
_save_secrets(secrets)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def save_namespace(provider: str, namespace: str) -> bool:
|
|
86
|
+
"""Save KV namespace ID to OS keyring. Falls back to JSON."""
|
|
87
|
+
if HAS_KEYRING:
|
|
88
|
+
try:
|
|
89
|
+
keyring.set_password(SERVICE, f"{provider}:kv-namespace", namespace)
|
|
90
|
+
return True
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
# Fallback to JSON file
|
|
95
|
+
secrets = _load_secrets()
|
|
96
|
+
secrets[f"{provider}:namespace"] = base64.b64encode(namespace.encode()).decode()
|
|
97
|
+
_save_secrets(secrets)
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_namespace(provider: str) -> str | None:
|
|
102
|
+
"""Load KV namespace ID from OS keyring. Falls back to JSON."""
|
|
103
|
+
if HAS_KEYRING:
|
|
104
|
+
try:
|
|
105
|
+
namespace = keyring.get_password(SERVICE, f"{provider}:kv-namespace")
|
|
106
|
+
if namespace:
|
|
107
|
+
return namespace
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
# Fallback to JSON file
|
|
112
|
+
secrets = _load_secrets()
|
|
113
|
+
encoded = secrets.get(f"{provider}:namespace")
|
|
114
|
+
if encoded:
|
|
115
|
+
return base64.b64decode(encoded.encode()).decode().strip()
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def delete_namespace(provider: str):
|
|
120
|
+
"""Delete namespace from keyring. Falls back to JSON if keyring fails."""
|
|
121
|
+
if HAS_KEYRING:
|
|
122
|
+
try:
|
|
123
|
+
keyring.delete_password(SERVICE, f"{provider}:kv-namespace")
|
|
124
|
+
return
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
secrets = _load_secrets()
|
|
129
|
+
secrets.pop(f"{provider}:namespace", None)
|
|
130
|
+
_save_secrets(secrets)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def token_location(provider: str) -> str:
|
|
134
|
+
"""Check where token is stored."""
|
|
135
|
+
if HAS_KEYRING:
|
|
136
|
+
try:
|
|
137
|
+
if keyring.get_password(SERVICE, f"{provider}:api-token"):
|
|
138
|
+
return "OS Keychain"
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
secrets = _load_secrets()
|
|
143
|
+
if f"{provider}:token" in secrets:
|
|
144
|
+
return "Config File"
|
|
145
|
+
return "Not set"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def namespace_location(provider: str) -> str:
|
|
149
|
+
"""Check where namespace is stored."""
|
|
150
|
+
if HAS_KEYRING:
|
|
151
|
+
try:
|
|
152
|
+
if keyring.get_password(SERVICE, f"{provider}:kv-namespace"):
|
|
153
|
+
return "OS Keychain"
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
secrets = _load_secrets()
|
|
158
|
+
if f"{provider}:namespace" in secrets:
|
|
159
|
+
return "Config File"
|
|
160
|
+
return "Not set"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def clear_all(provider: str):
|
|
164
|
+
"""Clear all secrets for a provider."""
|
|
165
|
+
delete_token(provider)
|
|
166
|
+
delete_namespace(provider)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Terminal output primitives
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
if os.name == "nt":
|
|
7
|
+
try:
|
|
8
|
+
os.system("chcp 65001 >nul")
|
|
9
|
+
except:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
14
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
15
|
+
except:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
RESET = "\x1b[0m"
|
|
19
|
+
BOLD = "\x1b[1m"
|
|
20
|
+
DIM = "\x1b[2m"
|
|
21
|
+
|
|
22
|
+
RED = "\x1b[31m"
|
|
23
|
+
GREEN = "\x1b[32m"
|
|
24
|
+
YELLOW = "\x1b[33m"
|
|
25
|
+
BLUE = "\x1b[34m"
|
|
26
|
+
MAGENTA= "\x1b[35m"
|
|
27
|
+
CYAN = "\x1b[36m"
|
|
28
|
+
WHITE = "\x1b[37m"
|
|
29
|
+
GRAY = "\x1b[90m"
|
|
30
|
+
|
|
31
|
+
O = "\x1b[38;5;208m"
|
|
32
|
+
S = "\x1b[38;5;112m"
|
|
33
|
+
G = "\x1b[38;5;244m"
|
|
34
|
+
C = "\x1b[38;5;117m"
|
|
35
|
+
Y = "\x1b[38;5;103m"
|
|
36
|
+
W = WHITE + BOLD
|
|
37
|
+
D = DIM
|
|
38
|
+
R = RESET
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def br():
|
|
42
|
+
print()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def rule():
|
|
46
|
+
print(G + " " + "\u2500" * 52 + R)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def badge(label, color):
|
|
50
|
+
return f"{color}{BOLD} {label} {R}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fail(msg):
|
|
54
|
+
br()
|
|
55
|
+
print(f"{badge('ERR', RED)} {msg}")
|
|
56
|
+
br()
|
|
57
|
+
raise SystemExit(1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def row(icon, label, value):
|
|
61
|
+
pad = 12
|
|
62
|
+
label = (label + " " * pad)[:pad]
|
|
63
|
+
print(f"{S}{icon}{R} {D}{label}{R} {W}{value}{R}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_line(command, desc):
|
|
67
|
+
pad = 34
|
|
68
|
+
command = (command + " " * pad)[:pad]
|
|
69
|
+
print(f" {W}{command}{R}{G}{desc}{R}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def code(msg):
|
|
73
|
+
print(f" {O}{msg}{R}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def link(msg):
|
|
77
|
+
print(f" {O}{msg}{R}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ok(msg):
|
|
81
|
+
print(f" {G}\u2713{R} {msg}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def info(msg):
|
|
85
|
+
print(f" {O}{msg}{R}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def warn(msg):
|
|
89
|
+
print(f" {Y}Warning:{R} {msg}")
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: formseal-fetch
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: CLI tool for fetching encrypted form submissions
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 grayguava
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Requires-Python: >=3.8
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: keyring>=25.0
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# formseal-fetch
|
|
34
|
+
|
|
35
|
+
<p align="center">
|
|
36
|
+
<img src="https://img.shields.io/badge/python-3.8+-3776ab?style=flat&labelColor=1e293b">
|
|
37
|
+
<img src="https://img.shields.io/badge/license-MIT-fc8181?style=flat&labelColor=1e293b">
|
|
38
|
+
<img src="https://img.shields.io/badge/formseal-ecosystem-10b981?style=flat&labelColor=1e293b">
|
|
39
|
+
</p>
|
|
40
|
+
|
|
41
|
+
Download encrypted form submissions from your storage backend for offline decryption.
|
|
42
|
+
|
|
43
|
+
## What it does
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
Browser (formseal-embed)
|
|
47
|
+
│
|
|
48
|
+
▼ (encrypted submissions)
|
|
49
|
+
Storage (Cloudflare KV / Supabase / ...)
|
|
50
|
+
│
|
|
51
|
+
▼ (fsf fetch)
|
|
52
|
+
Your PC ──► ciphertexts.jsonl
|
|
53
|
+
│
|
|
54
|
+
▼ (decrypt offline)
|
|
55
|
+
Plaintext form data
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install formseal-fetch
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Quick start
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
fsf connect provider:<name>
|
|
68
|
+
fsf fetch
|
|
69
|
+
fsf status
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Features
|
|
73
|
+
|
|
74
|
+
- **Secure storage** : Credentials stored in OS keychain (Windows Credential Manager / macOS Keychain / Linux Secret Service)
|
|
75
|
+
- **Deduplication** : Skips already-downloaded ciphertexts automatically
|
|
76
|
+
- **Offline-capable** ; Decrypt downloaded data anytime without network access
|
|
77
|
+
|
|
78
|
+
## Commands
|
|
79
|
+
|
|
80
|
+
| Command | Description |
|
|
81
|
+
|---------|-------------|
|
|
82
|
+
| `fsf connect` | Connect to a storage provider |
|
|
83
|
+
| `fsf fetch` | Download ciphertexts |
|
|
84
|
+
| `fsf status` | Show connection info |
|
|
85
|
+
| `fsf disconnect` | Clear all credentials |
|
|
86
|
+
|
|
87
|
+
Run `fsf --help` for all commands.
|
|
88
|
+
|
|
89
|
+
## Security
|
|
90
|
+
|
|
91
|
+
Your API tokens never leave your machine.formseal-fetch:
|
|
92
|
+
- Stores credentials in your OS keychain (encrypted at rest)
|
|
93
|
+
- Makes direct API calls to your storage backend only
|
|
94
|
+
- Sends no telemetry, has no analytics
|
|
95
|
+
|
|
96
|
+
## Documentation
|
|
97
|
+
|
|
98
|
+
Detailed guides: [docs/](./docs/)
|
|
99
|
+
|
|
100
|
+
- [Getting Started](./docs/getting-started.md)
|
|
101
|
+
- [Security](./docs/security.md)
|
|
102
|
+
- [Commands Reference](./docs/reference/commands.md)
|
|
103
|
+
- [Troubleshooting](./docs/troubleshooting.md)
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
cli/fsf.py
|
|
5
|
+
cli/ui.py
|
|
6
|
+
cli/commands/__init__.py
|
|
7
|
+
cli/commands/config.py
|
|
8
|
+
cli/commands/fetch.py
|
|
9
|
+
cli/commands/providers.py
|
|
10
|
+
cli/commands/setup.py
|
|
11
|
+
cli/providers/__init__.py
|
|
12
|
+
cli/providers/cloudflare/account.py
|
|
13
|
+
cli/providers/cloudflare/storage/__init__.py
|
|
14
|
+
cli/providers/cloudflare/storage/kv.py
|
|
15
|
+
cli/security/tokens.py
|
|
16
|
+
formseal_fetch.egg-info/PKG-INFO
|
|
17
|
+
formseal_fetch.egg-info/SOURCES.txt
|
|
18
|
+
formseal_fetch.egg-info/dependency_links.txt
|
|
19
|
+
formseal_fetch.egg-info/entry_points.txt
|
|
20
|
+
formseal_fetch.egg-info/requires.txt
|
|
21
|
+
formseal_fetch.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
keyring>=25.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cli
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=65.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "formseal-fetch"
|
|
7
|
+
version = "2.0.0"
|
|
8
|
+
description = "CLI tool for fetching encrypted form submissions"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {file = "LICENSE"}
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"keyring>=25.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
fsf = "cli.fsf:main"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools]
|
|
20
|
+
packages = ["cli", "cli.commands", "cli.providers", "cli.providers.cloudflare", "cli.providers.cloudflare.storage", "cli.security"]
|