HailXD commited on
Commit
feafa1f
·
1 Parent(s): 4367113

Process encrypted bucket jobs and deliver results from the Space

Browse files
Files changed (3) hide show
  1. app.py +19 -0
  2. bucket_jobs.py +211 -0
  3. requirements.txt +4 -0
app.py CHANGED
@@ -21,6 +21,7 @@ from ignored_preview import save_ignored_preview
21
  from clean import load_clean, clean_image
22
  from illustrious import load_illustrious, prepare_illustrious, infer_illustrious, save_illustrious
23
  from watermark_remover import WatermarkRemover
 
24
 
25
  INPUT_IMAGE_HEIGHT = 600
26
  TEXT_WORKER = [sys.executable, "-m", "text_detection"]
@@ -267,6 +268,10 @@ def remove_watermark(file_obj, strength, steps, model, ignore_eyes=False, ignore
267
  return make_terminal_html(cmd_html + f"\n\nDone!{preserved}{cleaned}", cursor=False), str(output_path), str(preview_path)
268
 
269
 
 
 
 
 
270
  def check_ai_metadata(file_obj):
271
  if file_obj is None:
272
  raise gr.Error("Please upload an image first.")
@@ -327,6 +332,19 @@ MODELS = load_models()
327
 
328
  with gr.Blocks(title="noai-watermark") as demo:
329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  gr.HTML("""
331
  <h1 style="text-align:center;">🛡️ noai-watermark</h1>
332
  <p style="text-align:center; color:#888;">
@@ -394,6 +412,7 @@ with gr.Blocks(title="noai-watermark") as demo:
394
  fn=remove_watermark,
395
  inputs=[upload_wm, strength, steps, model, ignore_eyes, ignore_hands, ignore_feet, clean_after, ignore_text],
396
  outputs=[wm_terminal, output_image, ignored_image],
 
397
  concurrency_limit=1,
398
  )
399
 
 
21
  from clean import load_clean, clean_image
22
  from illustrious import load_illustrious, prepare_illustrious, infer_illustrious, save_illustrious
23
  from watermark_remover import WatermarkRemover
24
+ from bucket_jobs import run_bucket_job
25
 
26
  INPUT_IMAGE_HEIGHT = 600
27
  TEXT_WORKER = [sys.executable, "-m", "text_detection"]
 
268
  return make_terminal_html(cmd_html + f"\n\nDone!{preserved}{cleaned}", cursor=False), str(output_path), str(preview_path)
269
 
270
 
271
+ def process_bucket_job(payload):
272
+ return run_bucket_job(payload, remove_watermark)
273
+
274
+
275
  def check_ai_metadata(file_obj):
276
  if file_obj is None:
277
  raise gr.Error("Please upload an image first.")
 
332
 
333
  with gr.Blocks(title="noai-watermark") as demo:
334
 
335
+ bucket_payload = gr.JSON(visible=False)
336
+ bucket_result = gr.JSON(visible=False)
337
+ bucket_submit = gr.Button(visible=False)
338
+ bucket_submit.click(
339
+ fn=process_bucket_job,
340
+ inputs=[bucket_payload],
341
+ outputs=[bucket_result],
342
+ api_name="process_bucket_job",
343
+ api_visibility="undocumented",
344
+ concurrency_id="watermark",
345
+ concurrency_limit=1,
346
+ )
347
+
348
  gr.HTML("""
349
  <h1 style="text-align:center;">🛡️ noai-watermark</h1>
350
  <p style="text-align:center; color:#888;">
 
412
  fn=remove_watermark,
413
  inputs=[upload_wm, strength, steps, model, ignore_eyes, ignore_hands, ignore_feet, clean_after, ignore_text],
414
  outputs=[wm_terminal, output_image, ignored_image],
415
+ concurrency_id="watermark",
416
  concurrency_limit=1,
417
  )
418
 
bucket_jobs.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+ import math
5
+ import re
6
+ import sqlite3
7
+ import time
8
+ from contextlib import closing
9
+ from html.parser import HTMLParser
10
+ from pathlib import Path
11
+ from secrets import token_bytes
12
+ from tempfile import TemporaryDirectory
13
+ from urllib.parse import urlencode
14
+
15
+ import httpx
16
+ import xxhash
17
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
18
+ from huggingface_hub import batch_bucket_files
19
+
20
+ API_URL = "https://hailxd.vercel.app"
21
+ DISCORD_API = "https://discord.com/api/v9"
22
+ DEV_CHANNEL = "1352311755682091111"
23
+ BOT_USER_AGENT = f"DiscordBot ({API_URL}, 1.0.0)"
24
+ JOB_DB = (Path("/data") if Path("/data").is_dir() else Path.home() / ".cache/no-watermark") / "bucket-jobs.sqlite3"
25
+ MAX_BYTES = 128 * 1024 * 1024
26
+ NONCE_BYTES = 12
27
+ TAG_BYTES = 16
28
+ RETRY_DELAYS = (1, 2, 4, 8, 16)
29
+ MODELS = {"CtrlRegen", "CtrlRegen Illustrious (SDXL)", "Default"}
30
+ IMAGE_TYPES = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp"}
31
+ CREATE_SQL = (
32
+ "CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, "
33
+ "output_hash TEXT NOT NULL DEFAULT '', discord_id TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '')"
34
+ )
35
+
36
+
37
+ class JobError(ValueError):
38
+ pass
39
+
40
+
41
+ class PlainText(HTMLParser):
42
+ def __init__(self):
43
+ super().__init__()
44
+ self.parts = []
45
+
46
+ def handle_data(self, data):
47
+ self.parts.append(data)
48
+
49
+
50
+ def _validate(payload):
51
+ if not isinstance(payload, dict):
52
+ raise JobError("Invalid job")
53
+ job = dict(payload)
54
+ for name in ("id", "input_hash", "input_url", "filename", "content_type", "bucket", "suffix", "hf_token", "discord_token", "channel"):
55
+ if not isinstance(job.get(name), str) or not job[name]:
56
+ raise JobError("Invalid job settings")
57
+ if not re.fullmatch(r"[a-f0-9-]{36}", job["id"]):
58
+ raise JobError("Invalid job ID")
59
+ if not re.fullmatch(r"[A-Za-z0-9_-]{8}", job["suffix"]):
60
+ raise JobError("Invalid encryption suffix")
61
+ if not re.fullmatch(r"[a-f0-9]{32}" + re.escape(job["suffix"]), job["input_hash"]):
62
+ raise JobError("Invalid encrypted image hash")
63
+ if not re.fullmatch(r"[A-Za-z0-9_-]+/[A-Za-z0-9_.-]+", job["bucket"]):
64
+ raise JobError("Invalid Hugging Face bucket")
65
+ expected_url = f"https://huggingface.co/buckets/{job['bucket']}/resolve/{job['input_hash']}"
66
+ if job["input_url"] != expected_url:
67
+ raise JobError("Invalid input bucket URL")
68
+ if job["channel"] != DEV_CHANNEL:
69
+ raise JobError("Delivery must use the Discord Dev channel")
70
+ if job["content_type"] not in IMAGE_TYPES or len(job["filename"]) > 240 or any(ord(char) < 32 for char in job["filename"]):
71
+ raise JobError("Invalid image metadata")
72
+ key = job.get("key")
73
+ if not isinstance(key, list) or len(key) != 16 or any(type(value) is not int or not 0 <= value <= 255 for value in key):
74
+ raise JobError("Invalid encryption key")
75
+ settings = job.get("options")
76
+ if not isinstance(settings, list) or len(settings) != 8:
77
+ raise JobError("Invalid image settings")
78
+ strength, steps, model, *flags = settings
79
+ if type(strength) not in (int, float) or not math.isfinite(strength) or not 0.01 <= strength <= 1:
80
+ raise JobError("Invalid strength")
81
+ if type(steps) is not int or not 10 <= steps <= 100 or not isinstance(model, str) or model not in MODELS:
82
+ raise JobError("Invalid model or steps")
83
+ if any(type(flag) is not bool for flag in flags):
84
+ raise JobError("Invalid image options")
85
+ return job
86
+
87
+
88
+ def _record(job):
89
+ fingerprint = hashlib.sha256(json.dumps(
90
+ {name: value for name, value in job.items() if name not in {"hf_token", "discord_token"}},
91
+ sort_keys=True, separators=(",", ":"),
92
+ ).encode()).hexdigest()
93
+ JOB_DB.parent.mkdir(parents=True, exist_ok=True)
94
+ with closing(sqlite3.connect(JOB_DB)) as db, db:
95
+ db.execute(CREATE_SQL)
96
+ db.execute("INSERT INTO jobs (id, fingerprint) VALUES (?, ?) ON CONFLICT(id) DO NOTHING", (job["id"], fingerprint))
97
+ row = db.execute("SELECT fingerprint, output_hash, discord_id, error FROM jobs WHERE id = ?", (job["id"],)).fetchone()
98
+ if row[0] != fingerprint:
99
+ raise JobError("Job ID already belongs to another image or settings")
100
+ return {"output_hash": row[1], "discord_id": row[2], "error": row[3]}
101
+
102
+
103
+ def _save(job_id, **values):
104
+ if not values or not set(values) <= {"output_hash", "discord_id", "error"}:
105
+ raise JobError("Invalid job state")
106
+ with closing(sqlite3.connect(JOB_DB)) as db, db:
107
+ db.execute("UPDATE jobs SET " + ", ".join(f"{name} = ?" for name in values) + " WHERE id = ?", (*values.values(), job_id))
108
+
109
+
110
+ def _retry(action):
111
+ for attempt in range(len(RETRY_DELAYS) + 1):
112
+ try:
113
+ return action()
114
+ except Exception:
115
+ if attempt == len(RETRY_DELAYS):
116
+ raise
117
+ time.sleep(RETRY_DELAYS[attempt])
118
+
119
+
120
+ def _download(job):
121
+ with httpx.stream("GET", job["input_url"], headers={"Authorization": f"Bearer {job['hf_token']}"}, follow_redirects=True, timeout=60) as response:
122
+ if not response.is_success:
123
+ raise JobError(f"Input download failed ({response.status_code})")
124
+ data = bytearray()
125
+ for chunk in response.iter_bytes():
126
+ data.extend(chunk)
127
+ if len(data) > MAX_BYTES + NONCE_BYTES + TAG_BYTES:
128
+ raise JobError("Input image exceeds 128 MB")
129
+ try:
130
+ image = AESGCM(bytes(job["key"])).decrypt(bytes(data[:NONCE_BYTES]), bytes(data[NONCE_BYTES:]), None)
131
+ except Exception as exc:
132
+ raise JobError("Unable to decrypt the input image") from exc
133
+ if xxhash.xxh3_128_hexdigest(image) + job["suffix"] != job["input_hash"]:
134
+ raise JobError("Input image hash mismatch")
135
+ return image
136
+
137
+
138
+ def _upload(job, content):
139
+ file_hash = xxhash.xxh3_128_hexdigest(content) + job["suffix"]
140
+ nonce = token_bytes(NONCE_BYTES)
141
+ encrypted = nonce + AESGCM(bytes(job["key"])).encrypt(nonce, content, None)
142
+ _retry(lambda: batch_bucket_files(job["bucket"], add=[(encrypted, file_hash)], token=job["hf_token"]))
143
+ return file_hash
144
+
145
+
146
+ def _notify(job, record):
147
+ if record["output_hash"]:
148
+ file_hash = record["output_hash"]
149
+ signature = hmac.new(bytes(job["key"]), f"no-watermark:{file_hash}".encode(), hashlib.sha256).hexdigest()
150
+ url = API_URL + "/no-watermark/?" + urlencode({"file": file_hash, "signature": signature})
151
+ content = f"No Watermark — {job['filename']}\n{url}"
152
+ else:
153
+ content = f"No Watermark failed — {job['filename']}\n{record['error']}"
154
+ payload = {
155
+ "content": content,
156
+ "allowed_mentions": {"parse": []},
157
+ "nonce": hashlib.blake2s(job["id"].encode(), digest_size=12).hexdigest(),
158
+ "enforce_nonce": True,
159
+ }
160
+
161
+ def send():
162
+ response = httpx.post(
163
+ f"{DISCORD_API}/channels/{job['channel']}/messages",
164
+ headers={"Authorization": f"Bot {job['discord_token']}", "User-Agent": BOT_USER_AGENT},
165
+ json=payload, timeout=30,
166
+ )
167
+ if response.status_code == 429:
168
+ time.sleep(min(float(response.json().get("retry_after", 1)), 60))
169
+ if not response.is_success:
170
+ raise JobError(f"Discord delivery failed ({response.status_code})")
171
+ return response.json()["id"]
172
+
173
+ return _retry(send)
174
+
175
+
176
+ def run_bucket_job(payload, remove_watermark):
177
+ try:
178
+ job = _validate(payload)
179
+ record = _record(job)
180
+ except Exception:
181
+ return {"status": "rejected", "error": "Invalid job or unavailable job storage"}
182
+ if record["discord_id"]:
183
+ return {"id": job["id"], "status": "failed" if record["error"] else "completed"}
184
+ if not record["output_hash"] and not record["error"]:
185
+ try:
186
+ content = _retry(lambda: _download(job))
187
+ with TemporaryDirectory(prefix="bucket-input-") as directory:
188
+ input_path = Path(directory) / ("image" + IMAGE_TYPES[job["content_type"]])
189
+ input_path.write_bytes(content)
190
+ terminal, output, _ = remove_watermark(str(input_path), *job["options"])
191
+ if not output:
192
+ parser = PlainText()
193
+ parser.feed(str(terminal))
194
+ raise JobError("".join(parser.parts).strip() or "Image processing failed")
195
+ output_path = Path(output)
196
+ if output_path.stat().st_size > MAX_BYTES:
197
+ raise JobError("Completed image exceeds 128 MB")
198
+ record["output_hash"] = _upload(job, output_path.read_bytes())
199
+ _save(job["id"], output_hash=record["output_hash"])
200
+ except Exception as exc:
201
+ error = str(exc) if isinstance(exc, JobError) else "Image processing or encrypted upload failed"
202
+ for secret in (job["hf_token"], job["discord_token"]):
203
+ error = error.replace(secret, "[redacted]")
204
+ record["error"] = error[:1000]
205
+ _save(job["id"], error=record["error"])
206
+ try:
207
+ discord_id = _notify(job, record)
208
+ _save(job["id"], discord_id=discord_id)
209
+ except Exception:
210
+ return {"id": job["id"], "status": "delivery_failed"}
211
+ return {"id": job["id"], "status": "failed" if record["error"] else "completed"}
requirements.txt CHANGED
@@ -8,3 +8,7 @@ ultralytics
8
  spandrel
9
  paddleocr>=3.0,<4
10
  paddlepaddle==3.2.2
 
 
 
 
 
8
  spandrel
9
  paddleocr>=3.0,<4
10
  paddlepaddle==3.2.2
11
+ cryptography
12
+ httpx
13
+ huggingface_hub>=1.6.0
14
+ xxhash