urlwall 0.1.2__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.
- urlwall/__init__.py +179 -0
- urlwall/__main__.py +112 -0
- urlwall/app/Info-patch.plist +30 -0
- urlwall/app/url-wall.scpt +17 -0
- urlwall/app.py +78 -0
- urlwall/cli.py +28 -0
- urlwall/config.py +152 -0
- urlwall/html/bg.jpg +0 -0
- urlwall/html/template.html +211 -0
- urlwall/py.typed +0 -0
- urlwall-0.1.2.dist-info/METADATA +90 -0
- urlwall-0.1.2.dist-info/RECORD +16 -0
- urlwall-0.1.2.dist-info/WHEEL +5 -0
- urlwall-0.1.2.dist-info/entry_points.txt +2 -0
- urlwall-0.1.2.dist-info/licenses/LICENSE +201 -0
- urlwall-0.1.2.dist-info/top_level.txt +1 -0
urlwall/__init__.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
URL-Wall core logic.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from urlwall import config
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"getCanonicalHost",
|
|
17
|
+
"isUrlAllowed",
|
|
18
|
+
"isWebURL",
|
|
19
|
+
"niceHost",
|
|
20
|
+
"openURL",
|
|
21
|
+
"unwrap",
|
|
22
|
+
"writeWarningHTML",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def isWebURL(url):
|
|
27
|
+
"""Check whether url is most likely an actual web URL."""
|
|
28
|
+
up = urllib.parse.urlparse(url)
|
|
29
|
+
return up.scheme in ("http", "https", "ftp") and up.netloc
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def getCanonicalHost(url):
|
|
33
|
+
"""Extract canonical host from a URL (no www., no trailing dot, no port)."""
|
|
34
|
+
host = url
|
|
35
|
+
if "/" in host:
|
|
36
|
+
purl = urllib.parse.urlparse(host)
|
|
37
|
+
host = purl.netloc
|
|
38
|
+
# Strip port if present
|
|
39
|
+
if ":" in host:
|
|
40
|
+
host = host.rsplit(":", 1)[0]
|
|
41
|
+
host = host.lower().strip(".")
|
|
42
|
+
host = host.removeprefix("www.")
|
|
43
|
+
return host
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def niceHost(url):
|
|
47
|
+
"""Human-readable host name for display."""
|
|
48
|
+
up = urllib.parse.urlparse(url)
|
|
49
|
+
host = up.netloc.lower()
|
|
50
|
+
host = host.removeprefix("www.")
|
|
51
|
+
if host.endswith(".safelinks.protection.outlook.com"):
|
|
52
|
+
host = "safelinks.outlook.com"
|
|
53
|
+
return host
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def unwrap(url):
|
|
57
|
+
"""Unwrap redirect chains (Cisco web proxy, Outlook safelinks, etc.).
|
|
58
|
+
|
|
59
|
+
Returns a list of all URLs encountered during unwrapping.
|
|
60
|
+
"""
|
|
61
|
+
urls = []
|
|
62
|
+
while True:
|
|
63
|
+
urls.append(url)
|
|
64
|
+
up = urllib.parse.urlparse(url)
|
|
65
|
+
query = urllib.parse.parse_qs(up.query)
|
|
66
|
+
|
|
67
|
+
# Cisco web proxy: https://secure-web.cisco.com/...#...
|
|
68
|
+
if up.netloc == "secure-web.cisco.com":
|
|
69
|
+
m = re.match(r"^([^#]+)/(.+)$", url)
|
|
70
|
+
if m:
|
|
71
|
+
url1, url2 = m.groups()
|
|
72
|
+
url2 = urllib.parse.unquote(url2)
|
|
73
|
+
if isWebURL(url1) and isWebURL(url2):
|
|
74
|
+
url = url2
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
# Common redirect query params
|
|
78
|
+
for q in ("url", "target", "rd"):
|
|
79
|
+
if q in query and query[q] and isWebURL(query[q][0]):
|
|
80
|
+
url = query[q][0]
|
|
81
|
+
break
|
|
82
|
+
else:
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
return urls
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def isUrlAllowed(url, _seen=None, _depth=0):
|
|
89
|
+
"""Check if a URL is allowed, unwrapping redirect chains and query params."""
|
|
90
|
+
if _seen is None:
|
|
91
|
+
_seen = set()
|
|
92
|
+
if _depth > 20 or url in _seen:
|
|
93
|
+
# Prevent infinite recursion from circular redirects
|
|
94
|
+
return False
|
|
95
|
+
_seen.add(url)
|
|
96
|
+
|
|
97
|
+
# First unwrap query params to find nested URLs
|
|
98
|
+
up = urllib.parse.urlparse(url)
|
|
99
|
+
query = urllib.parse.parse_qs(up.query)
|
|
100
|
+
for key in ("q", "url"):
|
|
101
|
+
for val in query.get(key, []):
|
|
102
|
+
if val.startswith(("http://", "https://")):
|
|
103
|
+
return isUrlAllowed(val, _seen, _depth + 1)
|
|
104
|
+
|
|
105
|
+
# Then check the canonical host
|
|
106
|
+
host = getCanonicalHost(url)
|
|
107
|
+
return config.getConfig().isAllowed(host)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def writeWarningHTML(urls):
|
|
111
|
+
"""Generate a warning HTML page showing the URL chain.
|
|
112
|
+
|
|
113
|
+
Returns the path to a temp file containing the rendered page.
|
|
114
|
+
"""
|
|
115
|
+
assert urls
|
|
116
|
+
url_pairs = [(niceHost(url), url) for url in urls]
|
|
117
|
+
|
|
118
|
+
# Read template from package resources (works when installed via pip)
|
|
119
|
+
template_path = Path(__file__).parent / "html" / "template.html"
|
|
120
|
+
text = template_path.read_text(encoding="utf-8")
|
|
121
|
+
|
|
122
|
+
bg_path = Path(__file__).parent / "html" / "bg.jpg"
|
|
123
|
+
bgurl = "file://" + str(bg_path.resolve())
|
|
124
|
+
|
|
125
|
+
# The final URL for the "Go to" button
|
|
126
|
+
final_url = url_pairs[-1][1]
|
|
127
|
+
|
|
128
|
+
for k, v in {
|
|
129
|
+
"backgroundimage": bgurl,
|
|
130
|
+
"URLS": "", # Old template uses JS to build the chain
|
|
131
|
+
"URL": final_url,
|
|
132
|
+
}.items():
|
|
133
|
+
text = text.replace("{{" + k + "}}", v)
|
|
134
|
+
|
|
135
|
+
fd, fn = tempfile.mkstemp(prefix="warning-", suffix=".html")
|
|
136
|
+
os.write(fd, text.encode("utf-8"))
|
|
137
|
+
os.close(fd)
|
|
138
|
+
|
|
139
|
+
return fn
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def openURL(url):
|
|
143
|
+
"""Open a URL through the URL-Wall gate."""
|
|
144
|
+
try:
|
|
145
|
+
# Normalize: ensure https scheme
|
|
146
|
+
if not url.startswith("http"):
|
|
147
|
+
if not url.startswith("//"):
|
|
148
|
+
url = "//" + url
|
|
149
|
+
url = "https:" + url
|
|
150
|
+
|
|
151
|
+
# Log
|
|
152
|
+
log_fn = config.getConfig().logFile
|
|
153
|
+
with open(log_fn, "a") as log:
|
|
154
|
+
log.write(f"{url}\n")
|
|
155
|
+
|
|
156
|
+
# Unwrap redirect chains
|
|
157
|
+
chain = unwrap(url)
|
|
158
|
+
|
|
159
|
+
# Check if any URL in the chain is allowed
|
|
160
|
+
allowed = False
|
|
161
|
+
for chain_url in chain:
|
|
162
|
+
if isUrlAllowed(chain_url):
|
|
163
|
+
allowed = True
|
|
164
|
+
break
|
|
165
|
+
if not allowed:
|
|
166
|
+
# Show warning page with the full chain
|
|
167
|
+
tfn = writeWarningHTML(chain)
|
|
168
|
+
url = tfn
|
|
169
|
+
|
|
170
|
+
browser = config.getConfig().getBrowser()
|
|
171
|
+
subprocess.call(["/usr/bin/open", "-a", browser, url])
|
|
172
|
+
except OSError as e:
|
|
173
|
+
# Log error but still open to avoid losing the URL entirely
|
|
174
|
+
print(f"URL-Wall error: {e}", file=sys.stderr)
|
|
175
|
+
import traceback
|
|
176
|
+
|
|
177
|
+
traceback.print_exc(file=sys.stderr)
|
|
178
|
+
# Open in default browser (not user's preferred) as a safe fallback
|
|
179
|
+
subprocess.call(["/usr/bin/open", url])
|
urlwall/__main__.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
URL-Wall CLI entry point.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python -m urlwall [URL] # Open URL through the gate
|
|
6
|
+
python -m urlwall --allow-host-add URL # Add to allow list
|
|
7
|
+
python -m urlwall --allow-host-remove URL # Remove from allow list
|
|
8
|
+
python -m urlwall --also-subdomains # Combine with --allow-host-add
|
|
9
|
+
python -m urlwall --set-browser BROWSER # Set browser used after URL-Wall
|
|
10
|
+
python -m urlwall --use-as-default # Set URL-Wall as macOS default browser
|
|
11
|
+
python -m urlwall --install PATH # Install .app bundle
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
import urlwall
|
|
19
|
+
import urlwall.app
|
|
20
|
+
from urlwall import config
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main():
|
|
24
|
+
parser = argparse.ArgumentParser(prog="url-wall")
|
|
25
|
+
group = parser.add_mutually_exclusive_group()
|
|
26
|
+
|
|
27
|
+
group.add_argument(
|
|
28
|
+
"url",
|
|
29
|
+
metavar="URL",
|
|
30
|
+
nargs="?",
|
|
31
|
+
help="URL to open. If no URL and no flags, prints config.",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument("-v", "--verbose", action="store_true")
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--install", metavar="PATH", help="Install URL-Wall as a .app bundle at PATH"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Config updates
|
|
39
|
+
group.add_argument(
|
|
40
|
+
"--allow-host-add", "-a", metavar="URL", help="Add host to allow list"
|
|
41
|
+
)
|
|
42
|
+
group.add_argument(
|
|
43
|
+
"--allow-host-remove", metavar="URL", help="Remove host from allow list"
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--also-subdomains",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="Make --allow-host-add also apply for subdomains",
|
|
49
|
+
)
|
|
50
|
+
group.add_argument(
|
|
51
|
+
"--set-browser", metavar="BROWSER", help="Set browser to use after URL-Wall"
|
|
52
|
+
)
|
|
53
|
+
group.add_argument(
|
|
54
|
+
"--use-as-default",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="Set URL-Wall as the macOS default browser",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
args = parser.parse_args()
|
|
60
|
+
|
|
61
|
+
if args.install is not None:
|
|
62
|
+
app_path = os.path.expanduser(args.install)
|
|
63
|
+
if os.path.isdir(app_path):
|
|
64
|
+
print(
|
|
65
|
+
f"Error: {app_path} already exists. Remove it first.", file=sys.stderr
|
|
66
|
+
)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
urlwall.app.install(app_path)
|
|
69
|
+
print(f"URL-Wall installed at {app_path}")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
cfg = config.getConfig()
|
|
73
|
+
|
|
74
|
+
if args.url is not None:
|
|
75
|
+
urlwall.openURL(args.url)
|
|
76
|
+
elif args.allow_host_add is not None:
|
|
77
|
+
url = args.allow_host_add
|
|
78
|
+
if args.also_subdomains:
|
|
79
|
+
if host := cfg.addAllowedHostSubdomains(url):
|
|
80
|
+
print(f"Added {host!r} incl. subdomains to allowed hosts")
|
|
81
|
+
else:
|
|
82
|
+
if host := cfg.addAllowedHost(url):
|
|
83
|
+
print(f"Added {host!r} to allowed hosts")
|
|
84
|
+
elif args.allow_host_remove is not None:
|
|
85
|
+
url = args.allow_host_remove
|
|
86
|
+
if host := cfg.removeAllowedHost(url):
|
|
87
|
+
print(f"Removed {host!r} from allowed hosts")
|
|
88
|
+
if host := cfg.removeAllowedHostSubdomains(url):
|
|
89
|
+
print(f"Removed {host!r} incl. subdomains from allowed hosts")
|
|
90
|
+
elif args.set_browser is not None:
|
|
91
|
+
browser = args.set_browser
|
|
92
|
+
if br := cfg.setBrowser(browser):
|
|
93
|
+
print(f"Browser set to {br!r}")
|
|
94
|
+
else:
|
|
95
|
+
print(f"Browser already set to {browser!r}")
|
|
96
|
+
elif args.use_as_default:
|
|
97
|
+
if cfg.setAsDefaultBrowser():
|
|
98
|
+
print("URL-Wall registered as default browser handler.")
|
|
99
|
+
else:
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
else:
|
|
102
|
+
# Print current config summary
|
|
103
|
+
print(f"Browser: {cfg.getBrowser()}")
|
|
104
|
+
print(f"Allowed hosts: {', '.join(cfg.plist['hostsAllowed'])}")
|
|
105
|
+
if cfg.plist["hostWithSubdomainsAllowed"]:
|
|
106
|
+
print(
|
|
107
|
+
f"Allowed with subdomains: {', '.join(cfg.plist['hostWithSubdomainsAllowed'])}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleIdentifier</key>
|
|
6
|
+
<string>com.github.svalgaard.url-wall</string>
|
|
7
|
+
<key>CFBundleName</key>
|
|
8
|
+
<string>url-wall</string>
|
|
9
|
+
<key>CFBundleURLTypes</key>
|
|
10
|
+
<array>
|
|
11
|
+
<dict>
|
|
12
|
+
<key>CFBundleURLName</key>
|
|
13
|
+
<string>Web site URL</string>
|
|
14
|
+
<key>CFBundleURLSchemes</key>
|
|
15
|
+
<array>
|
|
16
|
+
<string>http</string>
|
|
17
|
+
<string>https</string>
|
|
18
|
+
</array>
|
|
19
|
+
</dict>
|
|
20
|
+
<dict>
|
|
21
|
+
<key>CFBundleURLName</key>
|
|
22
|
+
<string>FTP site URL</string>
|
|
23
|
+
<key>CFBundleURLSchemes</key>
|
|
24
|
+
<array>
|
|
25
|
+
<string>ftp</string>
|
|
26
|
+
</array>
|
|
27
|
+
</dict>
|
|
28
|
+
</array>
|
|
29
|
+
</dict>
|
|
30
|
+
</plist>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
--
|
|
2
|
+
-- AppleScript URL handler for URL-Wall
|
|
3
|
+
-- https://github.com/svalgaard/url-wall
|
|
4
|
+
--
|
|
5
|
+
-- https://developer.apple.com/library/archive/technotes/tn2065/
|
|
6
|
+
--
|
|
7
|
+
|
|
8
|
+
on open location theURL
|
|
9
|
+
set AppPath to POSIX path of (path to me as text)
|
|
10
|
+
set ScriptPath to AppPath & "/Contents/Resources/Assets/urlwall/cli.py"
|
|
11
|
+
try
|
|
12
|
+
do shell script "echo " & quoted form of theURL & " | python3 " & ScriptPath
|
|
13
|
+
on error
|
|
14
|
+
-- If Python fails, open directly in default browser
|
|
15
|
+
do shell script "open " & quoted form of theURL
|
|
16
|
+
end try
|
|
17
|
+
end open location
|
urlwall/app.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Build a URL-Wall .app bundle from the AppleScript handler.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
./url-wall install ~/Applications/URL-Wall.app
|
|
6
|
+
./url-wall install -f ~/Applications/URL-Wall.app # force overwrite
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import os
|
|
11
|
+
import plistlib
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
|
|
15
|
+
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
16
|
+
APP_DIR = os.path.join(os.path.dirname(__file__), "app")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ignore_bundle(src, names):
|
|
20
|
+
"""Ignore files/dirs that don't belong in the .app bundle."""
|
|
21
|
+
ignored = []
|
|
22
|
+
for pattern in (
|
|
23
|
+
"__pycache__",
|
|
24
|
+
"*.pyc",
|
|
25
|
+
".git",
|
|
26
|
+
".gitignore",
|
|
27
|
+
"tests",
|
|
28
|
+
"*.egg-info",
|
|
29
|
+
"dist",
|
|
30
|
+
"build",
|
|
31
|
+
".venv",
|
|
32
|
+
"venv",
|
|
33
|
+
"*.egg",
|
|
34
|
+
".github",
|
|
35
|
+
".gitkeep",
|
|
36
|
+
):
|
|
37
|
+
ignored.extend(fnmatch.filter(names, pattern))
|
|
38
|
+
return set(ignored)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def patchInfo(appPath):
|
|
42
|
+
"""Patch the .app's Info.plist with URL scheme handlers."""
|
|
43
|
+
infofn = os.path.join(appPath, "Contents/Info.plist")
|
|
44
|
+
patchfn = os.path.join(APP_DIR, "Info-patch.plist")
|
|
45
|
+
|
|
46
|
+
if not os.path.isfile(infofn):
|
|
47
|
+
raise FileNotFoundError(f"Info.plist not found inside {appPath}")
|
|
48
|
+
if not os.path.isfile(patchfn):
|
|
49
|
+
raise FileNotFoundError(f"Info-patch.plist not found at {patchfn}")
|
|
50
|
+
|
|
51
|
+
with open(patchfn, "rb") as f:
|
|
52
|
+
patch = plistlib.load(f)
|
|
53
|
+
with open(infofn, "rb") as f:
|
|
54
|
+
info = plistlib.load(f)
|
|
55
|
+
info.update(patch)
|
|
56
|
+
with open(infofn, "wb") as f:
|
|
57
|
+
plistlib.dump(info, f)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def install(appPath):
|
|
61
|
+
"""Build and install a URL-Wall .app bundle."""
|
|
62
|
+
assert appPath.endswith(".app")
|
|
63
|
+
|
|
64
|
+
scpt = os.path.join(APP_DIR, "url-wall.scpt")
|
|
65
|
+
if not os.path.isfile(scpt):
|
|
66
|
+
raise FileNotFoundError(f"AppleScript handler not found at {scpt}")
|
|
67
|
+
|
|
68
|
+
# Compile AppleScript into .app
|
|
69
|
+
subprocess.call(["/usr/bin/osacompile", "-o", appPath, scpt])
|
|
70
|
+
|
|
71
|
+
# Patch with URL scheme handlers
|
|
72
|
+
patchInfo(appPath)
|
|
73
|
+
|
|
74
|
+
# Copy project assets into the bundle (exclude build artifacts, vcs, tests)
|
|
75
|
+
assetsPath = os.path.join(appPath, "Contents/Resources/Assets")
|
|
76
|
+
if os.path.isdir(assetsPath):
|
|
77
|
+
shutil.rmtree(assetsPath)
|
|
78
|
+
shutil.copytree(ROOT, assetsPath, ignore=_ignore_bundle)
|
urlwall/cli.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
URL-Wall CLI wrapper.
|
|
3
|
+
|
|
4
|
+
This script is placed in the .app bundle and called by the AppleScript handler.
|
|
5
|
+
It ensures the urlwall package is importable from the bundle context.
|
|
6
|
+
|
|
7
|
+
When called with no arguments, reads the URL from stdin (piped by AppleScript).
|
|
8
|
+
When called with arguments, passes them through to __main__.py.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
# The .app bundle copies Assets/ at the same level as urlwall/
|
|
15
|
+
# So we need to add the Assets parent to sys.path
|
|
16
|
+
bundle_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
17
|
+
if bundle_root not in sys.path:
|
|
18
|
+
sys.path.insert(0, bundle_root)
|
|
19
|
+
|
|
20
|
+
from urlwall.__main__ import main
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
# If called with no args, read URL from stdin (piped by AppleScript)
|
|
24
|
+
if len(sys.argv) == 1 and not sys.stdin.isatty():
|
|
25
|
+
url = sys.stdin.read().strip()
|
|
26
|
+
if url:
|
|
27
|
+
sys.argv = ["urlwall", url]
|
|
28
|
+
main()
|
urlwall/config.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
URL-Wall configuration management.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import plistlib
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import urlwall
|
|
12
|
+
|
|
13
|
+
ME = "urlwall"
|
|
14
|
+
CONFIG_VERSION = "1.0"
|
|
15
|
+
CFG_ROOT = os.path.expanduser(f"~/.{ME}")
|
|
16
|
+
CONFIG_FN = os.path.join(CFG_ROOT, "setup.plist")
|
|
17
|
+
_config = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Config:
|
|
21
|
+
"""Manages the URL-Wall configuration stored as a plist."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, fn):
|
|
24
|
+
self.fn = fn
|
|
25
|
+
self.read()
|
|
26
|
+
|
|
27
|
+
# -- read/write setup --
|
|
28
|
+
|
|
29
|
+
def read(self):
|
|
30
|
+
if not os.path.isfile(self.fn):
|
|
31
|
+
self.setupDefault()
|
|
32
|
+
try:
|
|
33
|
+
with open(self.fn, "rb") as f:
|
|
34
|
+
self.plist = plistlib.load(f)
|
|
35
|
+
except (OSError, plistlib.InvalidFileException) as e:
|
|
36
|
+
print(f"Config read error: {e}", file=sys.stderr)
|
|
37
|
+
self.setupDefault()
|
|
38
|
+
|
|
39
|
+
def setupDefault(self):
|
|
40
|
+
self.plist = {
|
|
41
|
+
"configVersion": CONFIG_VERSION,
|
|
42
|
+
"defaultBrowser": "Safari.app",
|
|
43
|
+
"hostsAllowed": ["google.com"],
|
|
44
|
+
"hostWithSubdomainsAllowed": [],
|
|
45
|
+
}
|
|
46
|
+
self.write()
|
|
47
|
+
|
|
48
|
+
def write(self):
|
|
49
|
+
self.plist["hostsAllowed"].sort()
|
|
50
|
+
dn = os.path.dirname(self.fn)
|
|
51
|
+
if not os.path.isdir(dn):
|
|
52
|
+
os.makedirs(dn, exist_ok=True)
|
|
53
|
+
try:
|
|
54
|
+
with open(self.fn, "wb") as f:
|
|
55
|
+
plistlib.dump(self.plist, f)
|
|
56
|
+
except OSError as e:
|
|
57
|
+
print(f"Config write error: {e}", file=sys.stderr)
|
|
58
|
+
|
|
59
|
+
# -- browser --
|
|
60
|
+
|
|
61
|
+
def getBrowser(self):
|
|
62
|
+
return self.plist["defaultBrowser"]
|
|
63
|
+
|
|
64
|
+
def setBrowser(self, br):
|
|
65
|
+
curBrowser = self.getBrowser()
|
|
66
|
+
if br != curBrowser:
|
|
67
|
+
self.plist["defaultBrowser"] = br
|
|
68
|
+
self.write()
|
|
69
|
+
return br
|
|
70
|
+
|
|
71
|
+
def setAsDefaultBrowser(self):
|
|
72
|
+
"""Set URL-Wall as macOS default browser for http/https/ftp."""
|
|
73
|
+
bundle_id = "com.github.svalgaard.url-wall"
|
|
74
|
+
schemes = ("http", "https", "ftp")
|
|
75
|
+
entries = " ".join(
|
|
76
|
+
f'{{CFBundleIdentifier="{bundle_id}",LSHandlerRoleViewer="{s}",LSHandlerContentClassName=""}}'
|
|
77
|
+
for s in schemes
|
|
78
|
+
)
|
|
79
|
+
cmd = f"defaults write com.apple.LaunchServices LSHandlers -array-add {entries}"
|
|
80
|
+
try:
|
|
81
|
+
subprocess.run(["bash", "-c", cmd], check=True)
|
|
82
|
+
print("Run `killall Finder` to apply the change.")
|
|
83
|
+
return True
|
|
84
|
+
except subprocess.CalledProcessError as e:
|
|
85
|
+
print(f"Error setting default browser: {e}", file=sys.stderr)
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
# -- allowed hosts --
|
|
89
|
+
|
|
90
|
+
def addAllowedHost(self, host):
|
|
91
|
+
host = urlwall.getCanonicalHost(host)
|
|
92
|
+
if not host:
|
|
93
|
+
raise ValueError("Invalid host")
|
|
94
|
+
if host not in self.plist["hostsAllowed"]:
|
|
95
|
+
self.plist["hostsAllowed"].append(host)
|
|
96
|
+
self.write()
|
|
97
|
+
return host
|
|
98
|
+
|
|
99
|
+
def addAllowedHostSubdomains(self, host):
|
|
100
|
+
host = urlwall.getCanonicalHost(host)
|
|
101
|
+
if not host:
|
|
102
|
+
raise ValueError("Invalid host")
|
|
103
|
+
if host not in self.plist["hostWithSubdomainsAllowed"]:
|
|
104
|
+
self.plist["hostWithSubdomainsAllowed"].append(host)
|
|
105
|
+
self.write()
|
|
106
|
+
return host
|
|
107
|
+
|
|
108
|
+
def removeAllowedHost(self, host):
|
|
109
|
+
host = urlwall.getCanonicalHost(host)
|
|
110
|
+
if not host:
|
|
111
|
+
raise ValueError("Invalid host")
|
|
112
|
+
if host in self.plist["hostsAllowed"]:
|
|
113
|
+
self.plist["hostsAllowed"].remove(host)
|
|
114
|
+
self.write()
|
|
115
|
+
return host
|
|
116
|
+
|
|
117
|
+
def removeAllowedHostSubdomains(self, host):
|
|
118
|
+
host = urlwall.getCanonicalHost(host)
|
|
119
|
+
if not host:
|
|
120
|
+
raise ValueError("Invalid host")
|
|
121
|
+
if host in self.plist["hostWithSubdomainsAllowed"]:
|
|
122
|
+
self.plist["hostWithSubdomainsAllowed"].remove(host)
|
|
123
|
+
self.write()
|
|
124
|
+
return host
|
|
125
|
+
|
|
126
|
+
# -- allow check --
|
|
127
|
+
|
|
128
|
+
def isAllowed(self, url):
|
|
129
|
+
host = urlwall.getCanonicalHost(url)
|
|
130
|
+
if host in self.plist["hostsAllowed"]:
|
|
131
|
+
return True
|
|
132
|
+
if host in self.plist["hostWithSubdomainsAllowed"]:
|
|
133
|
+
return True
|
|
134
|
+
for p in self.plist["hostWithSubdomainsAllowed"]:
|
|
135
|
+
if host.endswith("." + p):
|
|
136
|
+
return True
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
# -- log file (hostname-specific) --
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def logFile(self):
|
|
143
|
+
hostname = socket.gethostname().split(".")[0]
|
|
144
|
+
return os.path.join(CFG_ROOT, f"url-wall-{hostname}.log")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def getConfig(configFilename=CONFIG_FN):
|
|
148
|
+
"""Return the singleton Config instance."""
|
|
149
|
+
global _config
|
|
150
|
+
if _config is None:
|
|
151
|
+
_config = Config(configFilename)
|
|
152
|
+
return _config
|
urlwall/html/bg.jpg
ADDED
|
Binary file
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
8
|
+
|
|
9
|
+
<title>Warning page</title>
|
|
10
|
+
<style>
|
|
11
|
+
* {
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
padding: 0;
|
|
17
|
+
margin: 0;
|
|
18
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
19
|
+
font-weight: 700;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#notfound {
|
|
23
|
+
position: relative;
|
|
24
|
+
height: 100vh;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#notfound .notfound-bg {
|
|
28
|
+
position: absolute;
|
|
29
|
+
width: 100%;
|
|
30
|
+
height: 100%;
|
|
31
|
+
background-image: url('{{backgroundimage}}');
|
|
32
|
+
background-size: cover;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#notfound .notfound-bg:after {
|
|
36
|
+
content: '';
|
|
37
|
+
position: absolute;
|
|
38
|
+
width: 100%;
|
|
39
|
+
height: 100%;
|
|
40
|
+
background-color: rgba(255, 0, 0, 0.3);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#notfound .notfound {
|
|
44
|
+
position: absolute;
|
|
45
|
+
left: 50%;
|
|
46
|
+
top: 50%;
|
|
47
|
+
transform: translate(-50%, -50%);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.notfound {
|
|
51
|
+
max-width: 700px;
|
|
52
|
+
width: 100%;
|
|
53
|
+
line-height: 1.4;
|
|
54
|
+
text-align: center;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.notfound .notfound-404 {
|
|
58
|
+
position: relative;
|
|
59
|
+
height: 120px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.notfound .notfound-404 h1 {
|
|
63
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
64
|
+
position: absolute;
|
|
65
|
+
left: 50%;
|
|
66
|
+
top: 50%;
|
|
67
|
+
transform: translate(-50%, -50%);
|
|
68
|
+
font-size: 80px;
|
|
69
|
+
font-weight: 900;
|
|
70
|
+
margin: 0px;
|
|
71
|
+
color: #fff;
|
|
72
|
+
text-transform: uppercase;
|
|
73
|
+
letter-spacing: 4px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.notfound h2 {
|
|
77
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
78
|
+
font-size: 20px;
|
|
79
|
+
font-weight: 700;
|
|
80
|
+
text-transform: uppercase;
|
|
81
|
+
color: #fff;
|
|
82
|
+
margin-top: 20px;
|
|
83
|
+
margin-bottom: 15px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.notfound p {
|
|
87
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
88
|
+
font-size: 16px;
|
|
89
|
+
font-weight: 400;
|
|
90
|
+
color: #fff;
|
|
91
|
+
margin-top: 20px;
|
|
92
|
+
margin-bottom: 15px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.notfound .home-btn,
|
|
96
|
+
.notfound .contact-btn {
|
|
97
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
98
|
+
display: inline-block;
|
|
99
|
+
font-weight: 700;
|
|
100
|
+
text-decoration: none;
|
|
101
|
+
background-color: transparent;
|
|
102
|
+
border: 2px solid transparent;
|
|
103
|
+
text-transform: uppercase;
|
|
104
|
+
padding: 10px 20px;
|
|
105
|
+
font-size: 16px;
|
|
106
|
+
border-radius: 40px;
|
|
107
|
+
margin: 5px;
|
|
108
|
+
transition: 0.2s all;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.notfound .home-btn:hover,
|
|
112
|
+
.notfound .contact-btn:hover {
|
|
113
|
+
opacity: 0.9;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.notfound .home-btn {
|
|
117
|
+
color: rgba(255, 0, 36, 0.7);
|
|
118
|
+
background: #fff;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.notfound .contact-btn {
|
|
122
|
+
border: 2px solid rgba(255, 255, 255, 0.9);
|
|
123
|
+
color: rgba(255, 255, 255, 0.9);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
span.url {
|
|
127
|
+
overflow: hidden;
|
|
128
|
+
white-space: nowrap;
|
|
129
|
+
text-overflow: ellipsis;
|
|
130
|
+
display: block;
|
|
131
|
+
max-width: 600px;
|
|
132
|
+
margin: 0 auto;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@media only screen and (max-width: 767px) {
|
|
136
|
+
.notfound .notfound-404 h1 {
|
|
137
|
+
font-size: 60px;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@media only screen and (max-width: 480px) {
|
|
142
|
+
.notfound .notfound-404 {
|
|
143
|
+
height: 100px;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.notfound .notfound-404 h1 {
|
|
147
|
+
font-size: 40px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.notfound h2 {
|
|
151
|
+
font-size: 16px;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.notfound .home-btn,
|
|
155
|
+
.notfound .contact-btn {
|
|
156
|
+
font-size: 14px;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
</style>
|
|
160
|
+
</head>
|
|
161
|
+
|
|
162
|
+
<body>
|
|
163
|
+
<div id="notfound">
|
|
164
|
+
<div class="notfound-bg"></div>
|
|
165
|
+
<div class="notfound">
|
|
166
|
+
<div class="notfound-404">
|
|
167
|
+
<h1>⚠️ Warning</h1>
|
|
168
|
+
</div>
|
|
169
|
+
<h2>Unknown domain in URL</h2>
|
|
170
|
+
<div id='content'>
|
|
171
|
+
<p>This page requires JavaScript to display the redirect chain.</p>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</body>
|
|
176
|
+
<script>
|
|
177
|
+
var div = document.getElementById('content');
|
|
178
|
+
div.innerHTML = '';
|
|
179
|
+
var url = '{{URL}}';
|
|
180
|
+
var host = '';
|
|
181
|
+
while (url) {
|
|
182
|
+
var urlo = new URL(url);
|
|
183
|
+
var host = urlo.hostname;
|
|
184
|
+
var urls = url.replace(host, '<b>' + host + '</b>');
|
|
185
|
+
|
|
186
|
+
next = urlo.searchParams.get('url') || urlo.searchParams.get('target') || urlo.searchParams.get('originalUrl');
|
|
187
|
+
|
|
188
|
+
if (!next && host === 'secure-web.cisco.com') {
|
|
189
|
+
next = decodeURIComponent(url.replace(/.*\//, ''));
|
|
190
|
+
}
|
|
191
|
+
if (!next && host.endsWith('.mcas.ms')) {
|
|
192
|
+
u = url;
|
|
193
|
+
u = u.replace(/\.mcas\.ms\//, '/');
|
|
194
|
+
u = u.replace(/\?McasTsid=[0-9]*/, '');
|
|
195
|
+
next = decodeURIComponent(u);
|
|
196
|
+
}
|
|
197
|
+
if (host.endsWith('.safelinks.protection.outlook.com')) {
|
|
198
|
+
host = 'safelinks.outlook.com';
|
|
199
|
+
}
|
|
200
|
+
if (host.startsWith('www.')) {
|
|
201
|
+
host = host.substring(4);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
cls = next ? 'contact-btn' : 'home-btn';
|
|
205
|
+
div.innerHTML += '<p>Requested URL<br><span class="url">' + urls + '</span></p>';
|
|
206
|
+
div.innerHTML += '<a href="' + url + '" class="' + cls + '">Go to ' + host + '</a>';
|
|
207
|
+
|
|
208
|
+
url = next;
|
|
209
|
+
}
|
|
210
|
+
document.title = 'Warning: ' + host;
|
|
211
|
+
</script>
|
urlwall/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: urlwall
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Interceptive browser for macOS — confirms unknown domains before opening
|
|
5
|
+
Author-email: Jens Svalgaard <github@svalgaard.net>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/svalgaard/urlwall
|
|
8
|
+
Project-URL: Repository, https://github.com/svalgaard/urlwall
|
|
9
|
+
Project-URL: Issues, https://github.com/svalgaard/urlwall/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Environment :: MacOS X
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest; extra == "test"
|
|
27
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
28
|
+
Provides-Extra: lint
|
|
29
|
+
Requires-Dist: ruff; extra == "lint"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# URL-Wall — Interceptive Browser for macOS
|
|
33
|
+
|
|
34
|
+
Add a confirmation step when you click an http, https, or ftp
|
|
35
|
+
URL *outside your browser* to ensure you reach the domain you expect.
|
|
36
|
+
|
|
37
|
+
Known domains go directly. Unknown domains show a warning page where you
|
|
38
|
+
can acknowledge and proceed.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Build the .app bundle
|
|
44
|
+
python -m urlwall --install ~/Applications/URL-Wall.app
|
|
45
|
+
|
|
46
|
+
# Set as macOS default browser
|
|
47
|
+
python -m urlwall --use-as-default
|
|
48
|
+
# Then run: killall Finder
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## CLI Usage
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Open a URL through the gate
|
|
55
|
+
python -m urlwall https://example.com
|
|
56
|
+
|
|
57
|
+
# Add a domain to the allow list
|
|
58
|
+
python -m urlwall --allow-host-add example.com
|
|
59
|
+
|
|
60
|
+
# Add with subdomain support
|
|
61
|
+
python -m urlwall --allow-host-add example.com --also-subdomains
|
|
62
|
+
|
|
63
|
+
# Remove from allow list
|
|
64
|
+
python -m urlwall --allow-host-remove example.com
|
|
65
|
+
|
|
66
|
+
# Set the browser used after URL-Wall
|
|
67
|
+
python -m urlwall --set-browser "Google Chrome.app"
|
|
68
|
+
|
|
69
|
+
# Set URL-Wall as macOS default browser
|
|
70
|
+
python -m urlwall --use-as-default
|
|
71
|
+
|
|
72
|
+
# View current config
|
|
73
|
+
python -m urlwall
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## How It Works
|
|
77
|
+
|
|
78
|
+
1. The `install` command compiles an AppleScript handler into a `.app` bundle
|
|
79
|
+
2. The `.app` is patched with URL scheme handlers (http, https, ftp)
|
|
80
|
+
3. When macOS routes a URL to URL-Wall, the AppleScript calls `python -m urlwall <url>`
|
|
81
|
+
4. The Python handler checks if the domain is allowed — if so, opens it directly
|
|
82
|
+
5. If not, shows a warning page with the full redirect chain and a "Proceed" button
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
Config is stored in `~/.urlwall/setup.plist`:
|
|
87
|
+
|
|
88
|
+
- `defaultBrowser` — app name (e.g., `Safari.app`)
|
|
89
|
+
- `hostsAllowed` — domains that open directly
|
|
90
|
+
- `hostWithSubdomainsAllowed` — domains + all subdomains
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
urlwall/__init__.py,sha256=K9NLvOeZhLkCcel-qg46ka29GBq-lM44ov3e9crbfp4,5066
|
|
2
|
+
urlwall/__main__.py,sha256=RVudeX1pMNnjHPwfyKG2Vz_KIvZD_Emj34cN8mqEZco,3739
|
|
3
|
+
urlwall/app.py,sha256=gfuU0LtYXshUQ4TQW-79CZHP9o1nO2T96M9x7cP-8sU,2228
|
|
4
|
+
urlwall/cli.py,sha256=e8SnnI91-7WQYb0e7Mw2_IJdY9ZNYupUnmCH9bb3rvg,900
|
|
5
|
+
urlwall/config.py,sha256=FVfcAqjgJjmhhMILMqUaXAOSB3-gjPFOGb5RRo4GUW0,4596
|
|
6
|
+
urlwall/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
urlwall/app/Info-patch.plist,sha256=GSFWWk1txxeKkB9T_1twR_E4dHTyfpnuNk6AWrBU0yE,730
|
|
8
|
+
urlwall/app/url-wall.scpt,sha256=y78SV6OIVDfHwgf5dnTkrx7bRowdiu2Cdw5lAF_tx4E,530
|
|
9
|
+
urlwall/html/bg.jpg,sha256=-9unC5KTAy_jd4KmFs8VVjYXNSb-S_ZjA0bZLg3NBQI,344663
|
|
10
|
+
urlwall/html/template.html,sha256=wDujCbeX3vBUGir9fbFiL1dopDV6ft-a_43xwrZt3w0,5067
|
|
11
|
+
urlwall-0.1.2.dist-info/licenses/LICENSE,sha256=rXNTfYv5HGthwWj0ydLs-ZAKc2SjPeubmLvPgNosGG4,11373
|
|
12
|
+
urlwall-0.1.2.dist-info/METADATA,sha256=-Qc6pX_nxnfDQnhEnTMemYhNE2jtZabOpaSg-4d3e30,2910
|
|
13
|
+
urlwall-0.1.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
urlwall-0.1.2.dist-info/entry_points.txt,sha256=S7JXhmWpm_QklIl1zxSxkcPdUSwmh-Fi07t32HxjU-A,51
|
|
15
|
+
urlwall-0.1.2.dist-info/top_level.txt,sha256=SKqDz7xbtrZ32DBs3WpUh7JMoqkfgfEaY3CpwuP2C_k,8
|
|
16
|
+
urlwall-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 Jens Svalgaard Kohrt <github@svalgaard.net>
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
urlwall
|