pressbridge 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pressbridge/__init__.py +3 -0
- pressbridge/__main__.py +166 -0
- pressbridge/audit.py +58 -0
- pressbridge/config.py +208 -0
- pressbridge/edition.py +1 -0
- pressbridge/files.py +313 -0
- pressbridge/sandbox.py +71 -0
- pressbridge/server.py +284 -0
- pressbridge/wpcli.py +214 -0
- pressbridge-1.1.0.dist-info/METADATA +110 -0
- pressbridge-1.1.0.dist-info/RECORD +15 -0
- pressbridge-1.1.0.dist-info/WHEEL +5 -0
- pressbridge-1.1.0.dist-info/entry_points.txt +2 -0
- pressbridge-1.1.0.dist-info/licenses/LICENSE +21 -0
- pressbridge-1.1.0.dist-info/top_level.txt +1 -0
pressbridge/__init__.py
ADDED
pressbridge/__main__.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Command line entry point.
|
|
2
|
+
|
|
3
|
+
pressbridge serve --config config.yaml # stdio (Claude Desktop / Claude Code, local)
|
|
4
|
+
pressbridge serve --config config.yaml --http # HTTPS-ready HTTP server (Pro)
|
|
5
|
+
pressbridge check --config config.yaml # validate config and WP-CLI access
|
|
6
|
+
pressbridge token # print a new random token
|
|
7
|
+
pressbridge watch run|daemon|status|report|test-alert # security watch (Agent)
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import os
|
|
13
|
+
import secrets
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .config import ConfigError, load_config
|
|
19
|
+
from .edition import EDITION
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _cmd_check(cfg) -> int:
|
|
23
|
+
from . import wpcli
|
|
24
|
+
|
|
25
|
+
ok = True
|
|
26
|
+
print(f"PressBridge {__version__} ({EDITION})")
|
|
27
|
+
print(f"Data dir: {cfg.data_dir}")
|
|
28
|
+
if not shutil.which(cfg.wp_bin):
|
|
29
|
+
print(f" ! WP-CLI '{cfg.wp_bin}' not found on PATH")
|
|
30
|
+
ok = False
|
|
31
|
+
for s in cfg.sites.values():
|
|
32
|
+
print(f"- {s.name}: root={s.root} mode={s.mode} wp={s.wp_path or 'none'} profile={s.wp_profile}")
|
|
33
|
+
if s.wp_path and shutil.which(cfg.wp_bin):
|
|
34
|
+
r = wpcli.run(cfg, s, ["core", "version"])
|
|
35
|
+
if r["exit_code"] == 0:
|
|
36
|
+
print(f" WordPress {r['stdout'].strip()}")
|
|
37
|
+
else:
|
|
38
|
+
ok = False
|
|
39
|
+
print(f" ! WP-CLI failed: {r['stderr'].strip()[:300]}")
|
|
40
|
+
print("OK" if ok else "Problems found")
|
|
41
|
+
return 0 if ok else 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cmd_watch(cfg, a) -> int:
|
|
45
|
+
if EDITION != "agent":
|
|
46
|
+
print("PressBridge Watch is part of PressBridge Agent (https://pressbridgesupport.github.io/pressbridge).", file=sys.stderr)
|
|
47
|
+
return 2
|
|
48
|
+
from .watch import parse_settings
|
|
49
|
+
from .watch import runner
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
settings = parse_settings(cfg.watch)
|
|
53
|
+
except ConfigError as e:
|
|
54
|
+
print(f"Config error: {e}", file=sys.stderr)
|
|
55
|
+
return 2
|
|
56
|
+
|
|
57
|
+
if a.watch_cmd == "status":
|
|
58
|
+
print(runner.status(cfg, settings))
|
|
59
|
+
return 0
|
|
60
|
+
if a.watch_cmd == "report":
|
|
61
|
+
p = runner.watch_dir(cfg) / "reports" / "latest.txt"
|
|
62
|
+
print(p.read_text() if p.exists() else "No report yet. Run: pressbridge watch run")
|
|
63
|
+
return 0
|
|
64
|
+
if a.watch_cmd == "test-alert":
|
|
65
|
+
errors = runner.test_alert(settings)
|
|
66
|
+
for e in errors:
|
|
67
|
+
print(f"! {e}", file=sys.stderr)
|
|
68
|
+
if not errors:
|
|
69
|
+
print("Test alert sent.")
|
|
70
|
+
return 1 if errors else 0
|
|
71
|
+
if a.watch_cmd == "daemon":
|
|
72
|
+
try:
|
|
73
|
+
runner.daemon(cfg, settings, run_now=a.now, log=lambda m: print(m, flush=True))
|
|
74
|
+
except KeyboardInterrupt:
|
|
75
|
+
pass
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
result = runner.run_once(cfg, settings, sites=a.site, notify=not a.no_notify,
|
|
80
|
+
use_claude=not a.no_claude, force_notify=a.notify_always,
|
|
81
|
+
save=not a.dry_run)
|
|
82
|
+
except (RuntimeError, ValueError) as e:
|
|
83
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
84
|
+
return 3
|
|
85
|
+
print(result.report_path.read_text() if result.report_path else "")
|
|
86
|
+
if result.notified:
|
|
87
|
+
print("Alert sent.")
|
|
88
|
+
for e in result.delivery_errors:
|
|
89
|
+
print(f"! Alert delivery failed: {e}", file=sys.stderr)
|
|
90
|
+
return result.exit_code
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main(argv: list[str] | None = None) -> int:
|
|
94
|
+
p = argparse.ArgumentParser(prog="pressbridge", description="Safe WordPress access for Claude")
|
|
95
|
+
p.add_argument("--version", action="version", version=f"pressbridge {__version__} ({EDITION})")
|
|
96
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
97
|
+
|
|
98
|
+
serve = sub.add_parser("serve", help="Run the MCP server")
|
|
99
|
+
serve.add_argument("--config", default=os.environ.get("PRESSBRIDGE_CONFIG", "/config/config.yaml"))
|
|
100
|
+
serve.add_argument("--http", action="store_true", help="Serve over HTTP instead of stdio (Pro)")
|
|
101
|
+
serve.add_argument("--port", type=int, default=int(os.environ.get("PRESSBRIDGE_PORT", "8765")))
|
|
102
|
+
serve.add_argument("--host", default=os.environ.get("PRESSBRIDGE_HOST", "0.0.0.0"),
|
|
103
|
+
help="Interface to bind in HTTP mode (use 127.0.0.1 behind a proxy on the host network)")
|
|
104
|
+
|
|
105
|
+
check = sub.add_parser("check", help="Validate the config and WP-CLI access")
|
|
106
|
+
check.add_argument("--config", default=os.environ.get("PRESSBRIDGE_CONFIG", "/config/config.yaml"))
|
|
107
|
+
|
|
108
|
+
sub.add_parser("token", help="Generate a random bearer token")
|
|
109
|
+
|
|
110
|
+
watch = sub.add_parser("watch", help="Security watch: nightly checks and alerts (Agent edition)")
|
|
111
|
+
watch.add_argument("--config", default=os.environ.get("PRESSBRIDGE_CONFIG", "/config/config.yaml"))
|
|
112
|
+
wsub = watch.add_subparsers(dest="watch_cmd", required=True)
|
|
113
|
+
w_run = wsub.add_parser("run", help="Check all sites once now")
|
|
114
|
+
w_run.add_argument("--site", action="append", help="Only this site (repeatable)")
|
|
115
|
+
w_run.add_argument("--no-notify", action="store_true", help="Don't send alerts")
|
|
116
|
+
w_run.add_argument("--no-claude", action="store_true", help="Skip Claude's review")
|
|
117
|
+
w_run.add_argument("--notify-always", action="store_true", help="Send the report even if all is clear")
|
|
118
|
+
w_run.add_argument("--dry-run", action="store_true", help="Don't save the new baseline (compare only)")
|
|
119
|
+
w_daemon = wsub.add_parser("daemon", help="Run on the schedule, forever (for Docker or systemd)")
|
|
120
|
+
w_daemon.add_argument("--now", action="store_true", help="Also run once at start")
|
|
121
|
+
wsub.add_parser("status", help="Show the last run, the next run and each site's baseline")
|
|
122
|
+
wsub.add_parser("test-alert", help="Send a test alert to every configured channel")
|
|
123
|
+
wsub.add_parser("report", help="Print the latest report")
|
|
124
|
+
|
|
125
|
+
a = p.parse_args(argv)
|
|
126
|
+
if a.cmd == "token":
|
|
127
|
+
print(secrets.token_hex(32))
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
cfg = load_config(a.config)
|
|
132
|
+
except ConfigError as e:
|
|
133
|
+
print(f"Config error: {e}", file=sys.stderr)
|
|
134
|
+
return 2
|
|
135
|
+
|
|
136
|
+
if a.cmd == "check":
|
|
137
|
+
return _cmd_check(cfg)
|
|
138
|
+
|
|
139
|
+
if a.cmd == "watch":
|
|
140
|
+
return _cmd_watch(cfg, a)
|
|
141
|
+
|
|
142
|
+
if a.http:
|
|
143
|
+
if EDITION == "free":
|
|
144
|
+
print("HTTP mode is a PressBridge Pro feature. Free runs over stdio.", file=sys.stderr)
|
|
145
|
+
return 2
|
|
146
|
+
import uvicorn
|
|
147
|
+
|
|
148
|
+
from .server import build_http_app
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
app = build_http_app(cfg)
|
|
152
|
+
except ConfigError as e:
|
|
153
|
+
print(f"Config error: {e}", file=sys.stderr)
|
|
154
|
+
return 2
|
|
155
|
+
uvicorn.run(app, host=a.host, port=a.port, log_level="info", proxy_headers=True,
|
|
156
|
+
forwarded_allow_ips="*")
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
from .server import build_server
|
|
160
|
+
|
|
161
|
+
build_server(cfg).run("stdio")
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
sys.exit(main())
|
pressbridge/audit.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Append-only JSON Lines audit log of every tool call."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_lock = threading.Lock()
|
|
10
|
+
_MAX_FIELD = 500
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _shorten(value):
|
|
14
|
+
if isinstance(value, str) and len(value) > _MAX_FIELD:
|
|
15
|
+
return value[:_MAX_FIELD] + f"... [{len(value)} chars]"
|
|
16
|
+
if isinstance(value, list):
|
|
17
|
+
return [_shorten(v) for v in value]
|
|
18
|
+
if isinstance(value, dict):
|
|
19
|
+
return {k: _shorten(v) for k, v in value.items()}
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def record(log_path: Path, *, tool: str, site: str | None, args: dict, ok: bool, error: str | None = None,
|
|
24
|
+
result_summary: dict | None = None) -> None:
|
|
25
|
+
entry = {
|
|
26
|
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
27
|
+
"tool": tool,
|
|
28
|
+
"site": site,
|
|
29
|
+
"args": _shorten(args),
|
|
30
|
+
"ok": ok,
|
|
31
|
+
}
|
|
32
|
+
if error:
|
|
33
|
+
entry["error"] = error[:_MAX_FIELD]
|
|
34
|
+
if result_summary:
|
|
35
|
+
entry["result"] = result_summary
|
|
36
|
+
line = json.dumps(entry, ensure_ascii=False)
|
|
37
|
+
with _lock:
|
|
38
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
with log_path.open("a", encoding="utf-8") as f:
|
|
40
|
+
f.write(line + "\n")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def tail(log_path: Path, limit: int = 50, site: str | None = None) -> list[dict]:
|
|
44
|
+
if not log_path.exists():
|
|
45
|
+
return []
|
|
46
|
+
lines = log_path.read_text(encoding="utf-8").splitlines()
|
|
47
|
+
out: list[dict] = []
|
|
48
|
+
for line in reversed(lines):
|
|
49
|
+
try:
|
|
50
|
+
item = json.loads(line)
|
|
51
|
+
except json.JSONDecodeError:
|
|
52
|
+
continue
|
|
53
|
+
if site and item.get("site") != site:
|
|
54
|
+
continue
|
|
55
|
+
out.append(item)
|
|
56
|
+
if len(out) >= limit:
|
|
57
|
+
break
|
|
58
|
+
return out
|
pressbridge/config.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Configuration loading for PressBridge.
|
|
2
|
+
|
|
3
|
+
A single YAML file describes the WordPress sites the server may touch,
|
|
4
|
+
what each one is allowed to do, and where audit logs and backups live.
|
|
5
|
+
Secrets (bearer tokens) are pulled from environment variables so the
|
|
6
|
+
config file itself can be committed safely.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from .edition import EDITION
|
|
18
|
+
|
|
19
|
+
DEFAULT_DENY = [
|
|
20
|
+
"wp-config*",
|
|
21
|
+
".env",
|
|
22
|
+
".env.*",
|
|
23
|
+
".htpasswd",
|
|
24
|
+
".git",
|
|
25
|
+
".git/*",
|
|
26
|
+
"*.sql",
|
|
27
|
+
"*.sql.gz",
|
|
28
|
+
"*.sqlite",
|
|
29
|
+
"*.sqlite3",
|
|
30
|
+
".ht.sqlite",
|
|
31
|
+
"*.wpress",
|
|
32
|
+
"backup*.zip",
|
|
33
|
+
"*.key",
|
|
34
|
+
"*.pem",
|
|
35
|
+
"id_rsa*",
|
|
36
|
+
"debug.log",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
VALID_MODES = ("read", "write")
|
|
40
|
+
VALID_PROFILES = ("readonly", "maintenance")
|
|
41
|
+
_ENV_REF = re.compile(r"\$\{([A-Z0-9_]+)\}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConfigError(ValueError):
|
|
45
|
+
"""Raised when the config file is missing or invalid."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Site:
|
|
50
|
+
name: str
|
|
51
|
+
root: Path
|
|
52
|
+
mode: str = "read"
|
|
53
|
+
wp_path: Path | None = None
|
|
54
|
+
wp_profile: str = "readonly"
|
|
55
|
+
wp_url: str | None = None
|
|
56
|
+
deny: list[str] = field(default_factory=lambda: list(DEFAULT_DENY))
|
|
57
|
+
max_read_bytes: int = 512_000
|
|
58
|
+
max_write_bytes: int = 2_000_000
|
|
59
|
+
watched: bool = True
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def writable(self) -> bool:
|
|
63
|
+
return self.mode == "write"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Config:
|
|
68
|
+
sites: dict[str, Site]
|
|
69
|
+
data_dir: Path
|
|
70
|
+
wp_bin: str = "wp"
|
|
71
|
+
wp_timeout: int = 120
|
|
72
|
+
tokens: list[str] = field(default_factory=list)
|
|
73
|
+
allowed_hosts: list[str] = field(default_factory=list)
|
|
74
|
+
wp_env: list[str] = field(default_factory=list)
|
|
75
|
+
watch: dict = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def audit_log(self) -> Path:
|
|
79
|
+
return self.data_dir / "audit.jsonl"
|
|
80
|
+
|
|
81
|
+
def backup_root(self, site: Site) -> Path:
|
|
82
|
+
return self.data_dir / "backups" / site.name
|
|
83
|
+
|
|
84
|
+
def trash_root(self, site: Site) -> Path:
|
|
85
|
+
return self.data_dir / "trash" / site.name
|
|
86
|
+
|
|
87
|
+
def site(self, name: str | None) -> Site:
|
|
88
|
+
if not name:
|
|
89
|
+
if len(self.sites) == 1:
|
|
90
|
+
return next(iter(self.sites.values()))
|
|
91
|
+
raise ConfigError(
|
|
92
|
+
"Several sites are configured; pass `site`. Known sites: "
|
|
93
|
+
+ ", ".join(sorted(self.sites))
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
return self.sites[name]
|
|
97
|
+
except KeyError:
|
|
98
|
+
raise ConfigError(
|
|
99
|
+
f"Unknown site '{name}'. Known sites: " + ", ".join(sorted(self.sites))
|
|
100
|
+
) from None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _expand_env(value):
|
|
104
|
+
if isinstance(value, str):
|
|
105
|
+
def repl(m: re.Match) -> str:
|
|
106
|
+
# Unset variables become "", and empty tokens/hosts are dropped later.
|
|
107
|
+
# HTTP mode refuses to start without a token, so this cannot open a hole.
|
|
108
|
+
return os.environ.get(m.group(1), "")
|
|
109
|
+
return _ENV_REF.sub(repl, value)
|
|
110
|
+
if isinstance(value, list):
|
|
111
|
+
return [_expand_env(v) for v in value]
|
|
112
|
+
if isinstance(value, dict):
|
|
113
|
+
return {k: _expand_env(v) for k, v in value.items()}
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parse_site(name: str, raw: dict) -> Site:
|
|
118
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,62}", name):
|
|
119
|
+
raise ConfigError(f"Site name '{name}' must be lowercase letters, digits, '-' or '_'")
|
|
120
|
+
if not isinstance(raw, dict) or "root" not in raw:
|
|
121
|
+
raise ConfigError(f"Site '{name}' needs a `root` path")
|
|
122
|
+
root = Path(raw["root"]).expanduser()
|
|
123
|
+
if not root.is_absolute():
|
|
124
|
+
raise ConfigError(f"Site '{name}': root must be an absolute path")
|
|
125
|
+
if not root.is_dir():
|
|
126
|
+
raise ConfigError(f"Site '{name}': root {root} does not exist or is not a directory")
|
|
127
|
+
|
|
128
|
+
mode = raw.get("mode", "read")
|
|
129
|
+
if mode not in VALID_MODES:
|
|
130
|
+
raise ConfigError(f"Site '{name}': mode must be one of {VALID_MODES}")
|
|
131
|
+
profile = raw.get("wp_profile", "readonly")
|
|
132
|
+
if profile not in VALID_PROFILES:
|
|
133
|
+
raise ConfigError(f"Site '{name}': wp_profile must be one of {VALID_PROFILES}")
|
|
134
|
+
if profile == "maintenance" and mode != "write":
|
|
135
|
+
raise ConfigError(f"Site '{name}': wp_profile 'maintenance' requires mode 'write'")
|
|
136
|
+
|
|
137
|
+
wp_path = raw.get("wp_path")
|
|
138
|
+
wp_path = Path(wp_path) if wp_path else None
|
|
139
|
+
if wp_path is None and (root / "wp-includes").is_dir():
|
|
140
|
+
wp_path = root
|
|
141
|
+
|
|
142
|
+
deny = list(DEFAULT_DENY)
|
|
143
|
+
if raw.get("replace_default_deny"):
|
|
144
|
+
deny = []
|
|
145
|
+
deny += list(raw.get("deny", []))
|
|
146
|
+
for pattern in raw.get("allow", []):
|
|
147
|
+
if pattern in deny:
|
|
148
|
+
deny.remove(pattern)
|
|
149
|
+
|
|
150
|
+
return Site(
|
|
151
|
+
name=name,
|
|
152
|
+
root=root.resolve(),
|
|
153
|
+
mode=mode,
|
|
154
|
+
wp_path=wp_path.resolve() if wp_path else None,
|
|
155
|
+
wp_profile=profile,
|
|
156
|
+
wp_url=raw.get("wp_url"),
|
|
157
|
+
deny=deny,
|
|
158
|
+
max_read_bytes=int(raw.get("max_read_bytes", 512_000)),
|
|
159
|
+
max_write_bytes=int(raw.get("max_write_bytes", 2_000_000)),
|
|
160
|
+
watched=bool(raw.get("watch", True)),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def load_config(path: str | Path) -> Config:
|
|
165
|
+
path = Path(path)
|
|
166
|
+
if not path.is_file():
|
|
167
|
+
raise ConfigError(f"Config file not found: {path}")
|
|
168
|
+
try:
|
|
169
|
+
raw = yaml.safe_load(path.read_text()) or {}
|
|
170
|
+
except yaml.YAMLError as e:
|
|
171
|
+
raise ConfigError(f"Config file is not valid YAML: {e}") from e
|
|
172
|
+
raw = _expand_env(raw)
|
|
173
|
+
|
|
174
|
+
sites_raw = raw.get("sites") or {}
|
|
175
|
+
if not sites_raw:
|
|
176
|
+
raise ConfigError("Config needs at least one entry under `sites`")
|
|
177
|
+
sites = {name: _parse_site(name, s) for name, s in sites_raw.items()}
|
|
178
|
+
|
|
179
|
+
if EDITION == "free":
|
|
180
|
+
if len(sites) > 1:
|
|
181
|
+
raise ConfigError(
|
|
182
|
+
"PressBridge Free supports one site. Remove the extra sites or upgrade to Pro."
|
|
183
|
+
)
|
|
184
|
+
for s in sites.values():
|
|
185
|
+
s.mode = "read"
|
|
186
|
+
s.wp_profile = "readonly"
|
|
187
|
+
|
|
188
|
+
data_dir = Path(raw.get("data_dir", "/data")).expanduser()
|
|
189
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
|
|
191
|
+
tokens = raw.get("tokens") or []
|
|
192
|
+
if isinstance(tokens, str):
|
|
193
|
+
tokens = [tokens]
|
|
194
|
+
tokens = [t for t in tokens if t]
|
|
195
|
+
for t in tokens:
|
|
196
|
+
if len(t) < 24:
|
|
197
|
+
raise ConfigError("Each token must be at least 24 characters (use `openssl rand -hex 32`)")
|
|
198
|
+
|
|
199
|
+
return Config(
|
|
200
|
+
sites=sites,
|
|
201
|
+
data_dir=data_dir.resolve(),
|
|
202
|
+
wp_bin=raw.get("wp_bin", "wp"),
|
|
203
|
+
wp_timeout=int(raw.get("wp_timeout", 120)),
|
|
204
|
+
tokens=tokens,
|
|
205
|
+
allowed_hosts=[h for h in (raw.get("allowed_hosts") or []) if h],
|
|
206
|
+
wp_env=[str(v) for v in (raw.get("wp_env") or [])],
|
|
207
|
+
watch=raw.get("watch") or {},
|
|
208
|
+
)
|
pressbridge/edition.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
EDITION = "free"
|