zer0-image-generator 0.6.0 → 0.8.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +106 -2
- data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
- data/lib/zer0_image_generator/abc.rb +75 -0
- data/lib/zer0_image_generator/all.rb +57 -0
- data/lib/zer0_image_generator/claude/client.rb +291 -0
- data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
- data/lib/zer0_image_generator/cli.rb +318 -0
- data/lib/zer0_image_generator/config.rb +192 -0
- data/lib/zer0_image_generator/constants.rb +177 -0
- data/lib/zer0_image_generator/content.rb +379 -0
- data/lib/zer0_image_generator/engine.rb +21 -0
- data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
- data/lib/zer0_image_generator/freesvg/client.rb +467 -0
- data/lib/zer0_image_generator/http.rb +264 -0
- data/lib/zer0_image_generator/library.rb +201 -0
- data/lib/zer0_image_generator/logging.rb +236 -0
- data/lib/zer0_image_generator/preview_generator.py +1072 -100
- data/lib/zer0_image_generator/prompt.rb +48 -0
- data/lib/zer0_image_generator/providers/base.rb +152 -0
- data/lib/zer0_image_generator/providers/gemini.rb +60 -0
- data/lib/zer0_image_generator/providers/local.rb +90 -0
- data/lib/zer0_image_generator/providers/openai.rb +102 -0
- data/lib/zer0_image_generator/providers/stability.rb +64 -0
- data/lib/zer0_image_generator/providers/xai.rb +81 -0
- data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
- data/lib/zer0_image_generator/providers.rb +22 -0
- data/lib/zer0_image_generator/runner.rb +573 -0
- data/lib/zer0_image_generator/settings.rb +386 -0
- data/lib/zer0_image_generator/stats.rb +61 -0
- data/lib/zer0_image_generator/support/py_random.rb +189 -0
- data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
- data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
- data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
- data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
- data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
- data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
- data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
- data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
- data/lib/zer0_image_generator/svg/lint.rb +400 -0
- data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
- data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
- data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
- data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
- data/lib/zer0_image_generator/version.rb +1 -1
- metadata +43 -2
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Zer0ImageGenerator
|
|
7
|
+
module Freesvg
|
|
8
|
+
# API base and the tier TTLs, lifted verbatim from tools/svg/freesvg.py so
|
|
9
|
+
# an on-disk cache written by either language stays valid for the other.
|
|
10
|
+
API = "https://freesvg.org/api/v1"
|
|
11
|
+
SEARCH_TTL = 7 * 86_400 # searches: 7 days
|
|
12
|
+
META_TTL = 30 * 86_400 # per-item metadata: 30 days
|
|
13
|
+
TIMEOUT = 30
|
|
14
|
+
|
|
15
|
+
# -- error hierarchy -------------------------------------------------------
|
|
16
|
+
#
|
|
17
|
+
# freesvg.py leans on Python's exception types: AuthError subclasses
|
|
18
|
+
# RuntimeError, api_get raises bare RuntimeError, and transport failure
|
|
19
|
+
# surfaces as OSError/URLError. `_cached` catches (AuthError, RuntimeError)
|
|
20
|
+
# and re-raises them, but catches OSError separately to serve stale cache.
|
|
21
|
+
# The Ruby split preserves that: Error (and its subclasses) propagate;
|
|
22
|
+
# TransportError is the offline signal Cache#fetch_cached catches. So
|
|
23
|
+
# TransportError deliberately does NOT descend from Error.
|
|
24
|
+
|
|
25
|
+
class Error < StandardError; end
|
|
26
|
+
|
|
27
|
+
# Missing/invalid credentials or a failed login. Python: AuthError(RuntimeError).
|
|
28
|
+
class AuthError < Error; end
|
|
29
|
+
|
|
30
|
+
# A request the server answered but rejected (non-200), or a response we
|
|
31
|
+
# could not turn into art. Python: the bare RuntimeErrors in api_get /
|
|
32
|
+
# _extract_svg / promote.
|
|
33
|
+
class RequestError < Error; end
|
|
34
|
+
|
|
35
|
+
# Transport-level failure (DNS, refused, timeout, TLS). The offline signal:
|
|
36
|
+
# Cache#fetch_cached catches it to fall back to a stale entry. Mirrors
|
|
37
|
+
# Python relying on urllib raising OSError/URLError. IOError keeps it clearly
|
|
38
|
+
# distinct from the Error tree above.
|
|
39
|
+
class TransportError < IOError; end
|
|
40
|
+
|
|
41
|
+
# The tiered local cache that makes repeat FreeSVG lookups cost zero API
|
|
42
|
+
# calls. On-disk layout (git-ignored; safe to delete — it rebuilds):
|
|
43
|
+
#
|
|
44
|
+
# token.json JWT + parsed expiry, chmod 600
|
|
45
|
+
# searches/<key>.json search responses, TTL 7 days
|
|
46
|
+
# meta/<id>.json per-item metadata, TTL 30 days
|
|
47
|
+
# files/<id>.svg downloaded art — immutable, cached forever
|
|
48
|
+
#
|
|
49
|
+
# Cache entries wrap their payload as {"fetched_at": <epoch>, "data": ...};
|
|
50
|
+
# freshness is now - fetched_at < ttl. A fresh entry never touches the
|
|
51
|
+
# network; a fetch failure with any cached copy present serves it stale.
|
|
52
|
+
#
|
|
53
|
+
# The clock is injected (a callable returning epoch seconds) so TTL
|
|
54
|
+
# boundaries are testable without sleeping — the seam replacing freesvg.py's
|
|
55
|
+
# monkeypatched `_now`.
|
|
56
|
+
class Cache
|
|
57
|
+
attr_reader :dir
|
|
58
|
+
|
|
59
|
+
def initialize(dir:, clock: -> { Time.now.to_f }, warn: ->(_msg) {})
|
|
60
|
+
@dir = dir
|
|
61
|
+
@clock = clock
|
|
62
|
+
@warn = warn
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def now
|
|
66
|
+
@clock.call
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Full path under the cache dir, with its parent directory created —
|
|
70
|
+
# freesvg.py's `_ensure`.
|
|
71
|
+
def ensure_path(*parts)
|
|
72
|
+
full = File.join(@dir, *parts)
|
|
73
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
74
|
+
full
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def path(*parts)
|
|
78
|
+
File.join(@dir, *parts)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Parsed JSON, or nil when absent/corrupt. freesvg.py's `_read_json`
|
|
82
|
+
# swallows exactly OSError/ValueError; the Ruby equivalents are
|
|
83
|
+
# SystemCallError/IOError (file trouble) and JSON::ParserError (bad JSON).
|
|
84
|
+
# Explicit, not a bare rescue: a damaged cache file must read as "absent"
|
|
85
|
+
# so the tier simply refetches.
|
|
86
|
+
def read_json(file)
|
|
87
|
+
JSON.parse(File.read(file))
|
|
88
|
+
rescue JSON::ParserError, SystemCallError, IOError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# freesvg.py's `_write_json`. Cache files are git-ignored working memory,
|
|
93
|
+
# so the exact byte layout is not a contract (unlike the manifest) — only
|
|
94
|
+
# the schema and the chmod-600 on the token are. pretty_generate keeps
|
|
95
|
+
# them human-diffable; a trailing newline matches the Python writer.
|
|
96
|
+
def write_json(file, data, private: false)
|
|
97
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
98
|
+
File.write(file, JSON.pretty_generate(data) + "\n")
|
|
99
|
+
# The JWT is a bearer credential: keep it owner-only on disk.
|
|
100
|
+
File.chmod(0o600, file) if private
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The freshness core (freesvg.py's `_cached`). Returns [data, from_cache?].
|
|
104
|
+
# fresh hit + no refresh -> serve cache, no network
|
|
105
|
+
# miss / stale / refresh -> run the fetch block
|
|
106
|
+
# fetch raised TransportError with a cached copy present -> serve stale + warn
|
|
107
|
+
# AuthError/RequestError from the block propagate untouched, exactly as
|
|
108
|
+
# Python re-raises (AuthError, RuntimeError) ahead of its OSError branch.
|
|
109
|
+
def fetch_cached(file, ttl, refresh)
|
|
110
|
+
entry = read_json(file)
|
|
111
|
+
fresh = present_hash?(entry) && (now - (entry["fetched_at"] || 0) < ttl)
|
|
112
|
+
return [entry["data"], true] if fresh && !refresh
|
|
113
|
+
|
|
114
|
+
begin
|
|
115
|
+
data = yield
|
|
116
|
+
rescue TransportError => exc
|
|
117
|
+
if present_hash?(entry)
|
|
118
|
+
stamped = Time.at(entry["fetched_at"]).strftime("%Y-%m-%d")
|
|
119
|
+
@warn.call("network unavailable (#{exc.message}); serving stale cache from #{stamped}")
|
|
120
|
+
return [entry["data"], true]
|
|
121
|
+
end
|
|
122
|
+
raise
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
write_json(file, { "fetched_at" => now, "data" => data })
|
|
126
|
+
[data, false]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# freesvg.py's `prune`: walk files/meta/searches, and while the cache is
|
|
130
|
+
# over budget delete oldest-first (by mtime, then size, then path — the
|
|
131
|
+
# tuple order Python's sorted() uses). Returns [removed_count, total_bytes].
|
|
132
|
+
def prune(max_mb: 50.0)
|
|
133
|
+
victims = []
|
|
134
|
+
%w[files meta searches].each do |sub|
|
|
135
|
+
base = File.join(@dir, sub)
|
|
136
|
+
next unless File.directory?(base)
|
|
137
|
+
|
|
138
|
+
Dir.children(base).each do |name|
|
|
139
|
+
full = File.join(base, name)
|
|
140
|
+
victims << [File.mtime(full).to_f, File.size(full), full]
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
total = victims.sum { |_mtime, size, _path| size }
|
|
145
|
+
removed = 0
|
|
146
|
+
budget = max_mb * 1024 * 1024
|
|
147
|
+
victims.sort_by { |mtime, size, path| [mtime, size, path] }.each do |_mtime, size, path|
|
|
148
|
+
break if total <= budget
|
|
149
|
+
|
|
150
|
+
File.delete(path)
|
|
151
|
+
total -= size
|
|
152
|
+
removed += 1
|
|
153
|
+
end
|
|
154
|
+
[removed, total]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
# Python truthiness for a parsed cache entry: a dict is truthy only when
|
|
160
|
+
# non-empty, so a stray `{}` cache file counts as "no usable entry" — both
|
|
161
|
+
# for the freshness check and the stale-serve fallback.
|
|
162
|
+
def present_hash?(entry)
|
|
163
|
+
entry.is_a?(Hash) && !entry.empty?
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "digest"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
|
|
9
|
+
require_relative "cache"
|
|
10
|
+
require_relative "../library"
|
|
11
|
+
require_relative "../svg/lint"
|
|
12
|
+
require_relative "../svg/sanitizer"
|
|
13
|
+
|
|
14
|
+
module Zer0ImageGenerator
|
|
15
|
+
module Freesvg
|
|
16
|
+
# Fields that, on an item, may hold a downloadable SVG URL. Same tuple and
|
|
17
|
+
# order as freesvg.py's URL_KEYS — the order is load-bearing, since
|
|
18
|
+
# _extract_svg returns the first URL field that yields real SVG bytes.
|
|
19
|
+
URL_KEYS = %w[svg url file path download_url svg_url image link].freeze
|
|
20
|
+
|
|
21
|
+
# Default HTTP transport. Returns [status, body_bytes] for any answered
|
|
22
|
+
# request (including 4xx/5xx — freesvg.py's `_http` folds HTTPError back
|
|
23
|
+
# into a (code, body) pair) and raises TransportError only when the request
|
|
24
|
+
# never completed, which is the signal Cache#fetch_cached treats as offline.
|
|
25
|
+
#
|
|
26
|
+
# Injectable: the Client takes any object with this #call shape, so tests
|
|
27
|
+
# (and a Rails app that wants its own connection pooling) can supply their
|
|
28
|
+
# own without a live network.
|
|
29
|
+
class HttpTransport
|
|
30
|
+
def call(method:, url:, body: nil, token: nil)
|
|
31
|
+
uri = URI.parse(url)
|
|
32
|
+
request = build_request(method, uri, body, token)
|
|
33
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
34
|
+
http.use_ssl = uri.scheme == "https"
|
|
35
|
+
http.open_timeout = TIMEOUT
|
|
36
|
+
http.read_timeout = TIMEOUT
|
|
37
|
+
response = http.request(request)
|
|
38
|
+
[response.code.to_i, response.body.to_s.b]
|
|
39
|
+
rescue SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, IOError => exc
|
|
40
|
+
# urllib surfaces these as OSError/URLError; re-badge as the offline
|
|
41
|
+
# signal so the cache can serve stale instead of hard-failing.
|
|
42
|
+
raise TransportError, exc.message
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def build_request(method, uri, body, token)
|
|
48
|
+
klass = method.to_s.upcase == "POST" ? Net::HTTP::Post : Net::HTTP::Get
|
|
49
|
+
request = klass.new(uri)
|
|
50
|
+
request["Accept"] = "application/json"
|
|
51
|
+
request["User-Agent"] = "zer0-image-generator/freesvg-cache"
|
|
52
|
+
if body
|
|
53
|
+
request["Content-Type"] = "application/json"
|
|
54
|
+
request.body = JSON.generate(body)
|
|
55
|
+
end
|
|
56
|
+
request["Authorization"] = "Bearer #{token}" if token
|
|
57
|
+
request
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# FreeSVG.org API client with the tiered local cache (Cache) and the example
|
|
62
|
+
# library (Library) behind it. Public methods return structured Ruby values
|
|
63
|
+
# — item hashes, cache-hit booleans, filesystem paths — never printed text,
|
|
64
|
+
# so a Rails UI can drive search / preview / promote directly.
|
|
65
|
+
#
|
|
66
|
+
# client.search("rocket") => [items, from_cache?]
|
|
67
|
+
# client.item_info("123") => [item, from_cache?]
|
|
68
|
+
# client.fetch("123") => cache path to the linted .svg
|
|
69
|
+
# client.promote("123") => repo-relative path of the graduated file
|
|
70
|
+
# client.prune(max_mb: 50) => [removed, total_bytes]
|
|
71
|
+
# client.status => Hash of cache + auth health
|
|
72
|
+
#
|
|
73
|
+
# Determinism/testability seams: `transport` (network) and `clock` (time)
|
|
74
|
+
# are injected, replacing the module globals freesvg.py monkeypatches in its
|
|
75
|
+
# tests. Credentials resolve from explicit args, then the environment, then
|
|
76
|
+
# the repo's git-ignored .env — matching freesvg.py's precedence.
|
|
77
|
+
class Client
|
|
78
|
+
attr_reader :cache, :library
|
|
79
|
+
|
|
80
|
+
def initialize(root: self.class.default_root,
|
|
81
|
+
cache_dir: nil, manifest_path: nil,
|
|
82
|
+
transport: HttpTransport.new,
|
|
83
|
+
clock: -> { Time.now.to_f },
|
|
84
|
+
warn: nil,
|
|
85
|
+
email: nil, password: nil)
|
|
86
|
+
@root = root
|
|
87
|
+
@cache_dir = cache_dir || ENV["FREESVG_CACHE_DIR"] ||
|
|
88
|
+
File.join(root, "tools", "svg", "library", "cache", "freesvg")
|
|
89
|
+
@manifest_path = manifest_path || ENV["FREESVG_MANIFEST"] ||
|
|
90
|
+
File.join(root, "tools", "svg", "library", "MANIFEST.json")
|
|
91
|
+
@transport = transport
|
|
92
|
+
@clock = clock
|
|
93
|
+
@warn = warn || method(:default_warn)
|
|
94
|
+
@email_override = email
|
|
95
|
+
@password_override = password
|
|
96
|
+
@cache = Cache.new(dir: @cache_dir, clock: @clock, warn: @warn)
|
|
97
|
+
@library = Library.new(@manifest_path)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# freesvg.py derives ROOT from the file location (tools/svg/..); the gem's
|
|
101
|
+
# copy lives at lib/zer0_image_generator/freesvg/client.rb, so the repo
|
|
102
|
+
# root is four levels up. Overridable via the constructor for tests.
|
|
103
|
+
def self.default_root
|
|
104
|
+
File.expand_path("../../..", __dir__)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# -- search / info -------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def search(query, page: 1, refresh: false)
|
|
110
|
+
# Cache key: sha256("<trimmed lowercased query>|<page>")[:16]. Identical
|
|
111
|
+
# bytes to freesvg.py so a warm cache dir is shared across languages.
|
|
112
|
+
key = Digest::SHA256.hexdigest("#{query.strip.downcase}|#{page}")[0, 16]
|
|
113
|
+
file = @cache.ensure_path("searches", "#{key}.json")
|
|
114
|
+
query_string = URI.encode_www_form("query" => query, "page" => page)
|
|
115
|
+
payload, from_cache = @cache.fetch_cached(file, SEARCH_TTL, refresh) do
|
|
116
|
+
api_get("/search?#{query_string}")
|
|
117
|
+
end
|
|
118
|
+
[items_of(payload), from_cache]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def item_info(svg_id, refresh: false)
|
|
122
|
+
file = @cache.ensure_path("meta", "#{svg_id}.json")
|
|
123
|
+
payload, from_cache = @cache.fetch_cached(file, META_TTL, refresh) do
|
|
124
|
+
api_get("/svg/#{svg_id}")
|
|
125
|
+
end
|
|
126
|
+
items = items_of(payload)
|
|
127
|
+
[items.empty? ? payload : items.first, from_cache]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# -- download ------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
# Download one item's SVG into the immutable files/ tier and lint it. A
|
|
133
|
+
# cached file is returned untouched (never refetched) unless refresh:.
|
|
134
|
+
# A lint failure is surfaced as a warning but the file is kept — promote
|
|
135
|
+
# is the gate that actually refuses dirty art.
|
|
136
|
+
def fetch(svg_id, refresh: false)
|
|
137
|
+
file = @cache.ensure_path("files", "#{svg_id}.svg")
|
|
138
|
+
return file if File.exist?(file) && !refresh
|
|
139
|
+
|
|
140
|
+
item, = item_info(svg_id, refresh: refresh)
|
|
141
|
+
body = extract_svg(item)
|
|
142
|
+
File.binwrite(file, body)
|
|
143
|
+
|
|
144
|
+
report = lint(file)
|
|
145
|
+
unless report.clean?
|
|
146
|
+
@warn.call("downloaded file has lint errors (kept in cache, blocked from " \
|
|
147
|
+
"promote):\n#{report.stderr_lines.join("\n")}")
|
|
148
|
+
end
|
|
149
|
+
file
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# -- promote -------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
# Graduate a cached keeper into the committed library with full
|
|
155
|
+
# provenance, appending its MANIFEST.json entry. Idempotent: a file
|
|
156
|
+
# already in the manifest is left unchanged.
|
|
157
|
+
#
|
|
158
|
+
# Two gates guard the library, both of which must pass:
|
|
159
|
+
# 1. Lint (svg-check parity) — a hard refusal on errors, as in Python.
|
|
160
|
+
# 2. Sanitizer — the security gate ADDED in this port (see the code
|
|
161
|
+
# comment and DEVIATIONS): external SVG is untrusted input, and the
|
|
162
|
+
# linter does not catch <script>/foreignObject/event-handlers/DOCTYPE.
|
|
163
|
+
def promote(svg_id, name: "", tags: nil)
|
|
164
|
+
local = fetch(svg_id)
|
|
165
|
+
|
|
166
|
+
report = lint(local)
|
|
167
|
+
unless report.clean?
|
|
168
|
+
raise RequestError,
|
|
169
|
+
"refusing to promote #{svg_id}: lint errors\n#{report.stderr_lines.join("\n")}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
reject_unsafe!(svg_id, local)
|
|
173
|
+
|
|
174
|
+
item, = item_info(svg_id)
|
|
175
|
+
title = (presence(item["title"]) || presence(item["name"]) || "freesvg #{svg_id}").to_s
|
|
176
|
+
slug = presence(name) || slugify(title)
|
|
177
|
+
dest_rel = "tools/svg/library/external/freesvg/freesvg-#{slug}.svg"
|
|
178
|
+
dest = File.join(@root, dest_rel)
|
|
179
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
180
|
+
# Keep the pristine file: only write the destination on first promote.
|
|
181
|
+
File.binwrite(dest, File.binread(local)) unless File.exist?(dest)
|
|
182
|
+
|
|
183
|
+
if @library.has_file?(dest_rel)
|
|
184
|
+
@warn.call("#{dest_rel} already in manifest; leaving entry unchanged")
|
|
185
|
+
return dest_rel
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
@library.add_entry(build_entry(dest_rel, dest, title, svg_id, tags, item["tags"], report.warnings))
|
|
189
|
+
dest_rel
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def prune(max_mb: 50.0)
|
|
193
|
+
@cache.prune(max_mb: max_mb)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Structured cache + auth health for a UI. freesvg.py's `status` command
|
|
197
|
+
# prints this; here it is returned so the caller decides how to render it.
|
|
198
|
+
def status
|
|
199
|
+
counts = {}
|
|
200
|
+
%w[files meta searches].each do |sub|
|
|
201
|
+
base = @cache.path(sub)
|
|
202
|
+
counts[sub] = File.directory?(base) ? Dir.children(base).length : 0
|
|
203
|
+
end
|
|
204
|
+
email, password = credentials
|
|
205
|
+
token = @cache.read_json(@cache.path("token.json"))
|
|
206
|
+
{
|
|
207
|
+
cache_dir: @cache.dir,
|
|
208
|
+
counts: counts,
|
|
209
|
+
credentials_set: !presence(email).nil? && !presence(password).nil?,
|
|
210
|
+
token: token_status(token)
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# -- credentials + token (exposed; a Rails app inspects auth state) ------
|
|
215
|
+
|
|
216
|
+
# [email, password], each resolved from override -> ENV -> .env, with
|
|
217
|
+
# Python's `or` semantics: an empty string is falsy and falls through to
|
|
218
|
+
# the next source. Either may be nil when unset.
|
|
219
|
+
def credentials
|
|
220
|
+
fallback = env_file_creds
|
|
221
|
+
email = presence(@email_override) || presence(ENV["FREESVG_EMAIL"]) || fallback["FREESVG_EMAIL"]
|
|
222
|
+
password = presence(@password_override) || presence(ENV["FREESVG_PASSWORD"]) || fallback["FREESVG_PASSWORD"]
|
|
223
|
+
[email, password]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Cached JWT, reused until it is within 60s of expiry. force: re-logs in.
|
|
227
|
+
def get_token(force: false)
|
|
228
|
+
file = @cache.ensure_path("token.json")
|
|
229
|
+
unless force
|
|
230
|
+
cached = @cache.read_json(file)
|
|
231
|
+
if cached && (cached["exp"] || 0) > @clock.call + 60
|
|
232
|
+
return cached["token"]
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
email, password = credentials
|
|
237
|
+
unless presence(email) && presence(password)
|
|
238
|
+
raise AuthError,
|
|
239
|
+
"FreeSVG credentials missing. Set FREESVG_EMAIL and FREESVG_PASSWORD " \
|
|
240
|
+
"in the environment or the repo's git-ignored .env " \
|
|
241
|
+
"(register at https://freesvg.org — the API is for registered users)."
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
status, body = http("POST", "#{API}/auth/login", body: { "email" => email, "password" => password })
|
|
245
|
+
raise AuthError, "login failed (HTTP #{status}): #{decode_snippet(body)}" if status != 200
|
|
246
|
+
|
|
247
|
+
token = presence(parse_json(body)["token"])
|
|
248
|
+
raise AuthError, "login response had no token" if token.nil?
|
|
249
|
+
|
|
250
|
+
@cache.write_json(file, { "token" => token, "exp" => jwt_exp(token) }, private: true)
|
|
251
|
+
token
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
private
|
|
255
|
+
|
|
256
|
+
# Authenticated GET returning parsed JSON; one forced-relogin retry on 401.
|
|
257
|
+
def api_get(path)
|
|
258
|
+
token = get_token
|
|
259
|
+
status, body = http("GET", "#{API}#{path}", token: token)
|
|
260
|
+
if status == 401
|
|
261
|
+
token = get_token(force: true)
|
|
262
|
+
status, body = http("GET", "#{API}#{path}", token: token)
|
|
263
|
+
end
|
|
264
|
+
raise RequestError, "GET #{path} failed (HTTP #{status}): #{decode_snippet(body)}" if status != 200
|
|
265
|
+
|
|
266
|
+
parse_json(body)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def http(method, url, body: nil, token: nil)
|
|
270
|
+
@transport.call(method: method, url: url, body: body, token: token)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Unwrap the list of items from the several response shapes FreeSVG has
|
|
274
|
+
# been seen to return. Mirrors freesvg.py's `_items_of`.
|
|
275
|
+
def items_of(payload)
|
|
276
|
+
return payload if payload.is_a?(Array)
|
|
277
|
+
|
|
278
|
+
if payload.is_a?(Hash)
|
|
279
|
+
%w[data svgs results items].each do |key|
|
|
280
|
+
return payload[key] if payload[key].is_a?(Array)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
[payload]
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Locate SVG bytes in an item: inline markup on any field first, then the
|
|
287
|
+
# first URL_KEYS field pointing at a downloadable .svg. The failure names
|
|
288
|
+
# the fields present so the schema can be adapted. Mirrors `_extract_svg`.
|
|
289
|
+
def extract_svg(item)
|
|
290
|
+
item.each_value do |value|
|
|
291
|
+
if value.is_a?(String) && value.lstrip.start_with?("<?xml", "<svg")
|
|
292
|
+
return value
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
URL_KEYS.each do |key|
|
|
297
|
+
value = item[key]
|
|
298
|
+
next unless value.is_a?(String) && value.start_with?("http") &&
|
|
299
|
+
(value.downcase.include?(".svg") || value.downcase.include?("download"))
|
|
300
|
+
|
|
301
|
+
status, body = http("GET", value, token: get_token)
|
|
302
|
+
return body if status == 200 && looks_like_svg?(body)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
raise RequestError,
|
|
306
|
+
"could not locate SVG content in item; fields present: " \
|
|
307
|
+
"#{item.keys.sort.join(', ')} — update URL_KEYS/_extract_svg in " \
|
|
308
|
+
"tools/svg/freesvg.py for this shape"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Byte sniff for a downloaded body (freesvg.py's `_looks_like_svg`).
|
|
312
|
+
# Precedence matches: (starts <?xml AND contains <svg) OR starts <svg.
|
|
313
|
+
def looks_like_svg?(body)
|
|
314
|
+
bytes = body.b
|
|
315
|
+
head = bytes[0, 2048].to_s.lstrip.downcase
|
|
316
|
+
(head.start_with?("<?xml") && bytes[0, 4096].to_s.downcase.include?("<svg")) ||
|
|
317
|
+
head.start_with?("<svg")
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# The lint gate, via the ported deterministic linter (svg/lint.rb, itself
|
|
321
|
+
# differential-tested against tools/svg/svg-check.py). freesvg.py shells
|
|
322
|
+
# out to svg-check.py with --max-kb 1024; this is the in-process, no-
|
|
323
|
+
# subprocess, no-network equivalent with identical findings and exit code.
|
|
324
|
+
def lint(file)
|
|
325
|
+
Zer0ImageGenerator::Svg::Lint.check(file, max_kb: 1024.0)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# SECURITY GATE — added in the Ruby port; freesvg.py has no equivalent.
|
|
329
|
+
# svg-check.py flags external <image>/network refs but NOT <script>,
|
|
330
|
+
# foreignObject, on* handlers, non-local url(), or DOCTYPE/ENTITY, so a
|
|
331
|
+
# weaponized third-party SVG can pass the lint gate. Before anything
|
|
332
|
+
# enters the committed library we additionally require it to survive the
|
|
333
|
+
# engine's own sanitizer with nothing stripped. The pristine file is left
|
|
334
|
+
# untouched (README: external files stay pristine) — a finding is a hard
|
|
335
|
+
# refusal, not a rewrite.
|
|
336
|
+
def reject_unsafe!(svg_id, file)
|
|
337
|
+
_clean, findings = Zer0ImageGenerator::Svg::Sanitizer.sanitize_svg(
|
|
338
|
+
File.binread(file).force_encoding("UTF-8")
|
|
339
|
+
)
|
|
340
|
+
return if findings.empty?
|
|
341
|
+
|
|
342
|
+
raise RequestError,
|
|
343
|
+
"refusing to promote #{svg_id}: sanitizer flagged unsafe content " \
|
|
344
|
+
"(#{findings.join('; ')}) — external SVG must survive sanitize_svg untouched"
|
|
345
|
+
rescue Zer0ImageGenerator::Svg::SvgError => exc
|
|
346
|
+
raise RequestError, "refusing to promote #{svg_id}: #{exc.message}"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Build the provenance-bearing manifest entry. Key order and values match
|
|
350
|
+
# freesvg.py's promote exactly (file, title, source_url, license,
|
|
351
|
+
# license_url, tags, lint, size_kb) so PyJson reproduces its bytes.
|
|
352
|
+
def build_entry(dest_rel, dest_abs, title, svg_id, tags, item_tags, warnings)
|
|
353
|
+
normalized = normalize_item_tags(item_tags)
|
|
354
|
+
chosen = [tags, normalized].find { |candidate| truthy?(candidate) } || []
|
|
355
|
+
entry_tags = chosen.first(6)
|
|
356
|
+
entry_tags = ["illustration"] if entry_tags.empty?
|
|
357
|
+
|
|
358
|
+
lint_status =
|
|
359
|
+
if warnings.empty?
|
|
360
|
+
"clean"
|
|
361
|
+
else
|
|
362
|
+
"warn: " + warnings.join("; ")[0, 160]
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
{
|
|
366
|
+
"file" => dest_rel,
|
|
367
|
+
"title" => title,
|
|
368
|
+
"source_url" => "#{API}/svg/#{svg_id}",
|
|
369
|
+
"license" => "CC0 / Public domain",
|
|
370
|
+
"license_url" => "https://freesvg.org",
|
|
371
|
+
"tags" => entry_tags.map { |t| t.to_s.downcase } + ["freesvg"],
|
|
372
|
+
"lint" => lint_status,
|
|
373
|
+
# Guaranteed byte-match to Python's round(bytes/1024, 1): "%.1f" and
|
|
374
|
+
# CPython round both use IEEE round-half-to-even, and ties are
|
|
375
|
+
# impossible here (bytes/1024 is dyadic, a half-tenth never is). Float
|
|
376
|
+
# of that string is the exact double repr renders as "N.N".
|
|
377
|
+
"size_kb" => Float(format("%.1f", File.size(dest_abs) / 1024.0))
|
|
378
|
+
}
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# item tags may arrive as a list or a comma-joined string; normalize to a
|
|
382
|
+
# list (or pass through nil). Mirrors the isinstance(str) branch in promote.
|
|
383
|
+
def normalize_item_tags(item_tags)
|
|
384
|
+
return item_tags unless item_tags.is_a?(String)
|
|
385
|
+
|
|
386
|
+
item_tags.split(",").map(&:strip).reject(&:empty?)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# slug: lowercase, non-alnum runs to "-", collapse dashes, trim dashes,
|
|
390
|
+
# default "untitled". Mirrors freesvg.py's `_slugify`.
|
|
391
|
+
def slugify(text)
|
|
392
|
+
slug = text.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/-+/, "-")
|
|
393
|
+
slug = slug.sub(/\A-+/, "").sub(/-+\z/, "")
|
|
394
|
+
slug.empty? ? "untitled" : slug
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Best-effort JWT expiry from the payload; a short conservative fallback
|
|
398
|
+
# when the token is unreadable. Mirrors freesvg.py's `_jwt_exp`, whose
|
|
399
|
+
# broad `except Exception` is faithfully reproduced here: the JWT is
|
|
400
|
+
# opaque third-party text, so any decode/parse failure just means "trust
|
|
401
|
+
# it for a few hours, no more".
|
|
402
|
+
def jwt_exp(token)
|
|
403
|
+
payload = token.split(".")[1].to_s.tr("-_", "+/")
|
|
404
|
+
payload += "=" * ((-payload.length) % 4)
|
|
405
|
+
# unpack1("m") is stdlib base64 decode — no dependency on the `base64`
|
|
406
|
+
# gem, which is no longer a default library. Mirrors Python's
|
|
407
|
+
# base64.urlsafe_b64decode after the -_/+/ translation + padding above.
|
|
408
|
+
Float(JSON.parse(payload.unpack1("m"))["exp"])
|
|
409
|
+
rescue StandardError
|
|
410
|
+
@clock.call + (6 * 3600)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def env_file_creds
|
|
414
|
+
creds = {}
|
|
415
|
+
path = File.join(@root, ".env")
|
|
416
|
+
return creds unless File.exist?(path)
|
|
417
|
+
|
|
418
|
+
File.foreach(path) do |line|
|
|
419
|
+
match = line.match(/\s*(?:export\s+)?(FREESVG_[A-Z_]+)\s*=\s*(.*)\s*$/)
|
|
420
|
+
next unless match
|
|
421
|
+
|
|
422
|
+
# Python strips only surrounding quotes here (str.strip("'\"")),
|
|
423
|
+
# deliberately not whitespace, so mirror that precisely.
|
|
424
|
+
creds[match[1]] = match[2].sub(/\A["']+/, "").sub(/["']+\z/, "")
|
|
425
|
+
end
|
|
426
|
+
creds
|
|
427
|
+
rescue SystemCallError
|
|
428
|
+
creds
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def token_status(token)
|
|
432
|
+
return { cached: false } unless token
|
|
433
|
+
|
|
434
|
+
left = (token["exp"] || 0) - @clock.call
|
|
435
|
+
left.positive? ? { cached: true, valid_hours: left / 3600.0 } : { cached: true, expired: true }
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def parse_json(body)
|
|
439
|
+
JSON.parse(body)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# First 200 bytes decoded as UTF-8 with replacement, for error messages.
|
|
443
|
+
# Mirrors body[:200].decode("utf-8", "replace").
|
|
444
|
+
def decode_snippet(body)
|
|
445
|
+
body.b[0, 200].to_s.force_encoding("UTF-8").scrub
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Python truthiness for `x or y`: nil, false, "", empty collections, and
|
|
449
|
+
# numeric zero are all falsy and fall through.
|
|
450
|
+
def truthy?(value)
|
|
451
|
+
return false if value.nil? || value == false
|
|
452
|
+
return false if value.respond_to?(:empty?) && value.empty?
|
|
453
|
+
return false if value.is_a?(Numeric) && value.zero?
|
|
454
|
+
|
|
455
|
+
true
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def presence(value)
|
|
459
|
+
value if truthy?(value)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def default_warn(message)
|
|
463
|
+
warn("freesvg: #{message}")
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|