Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions .github/workflows/release-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,15 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute image tags
id: tags
env:
REF_NAME: ${{ github.ref_name }}
run: |
TAG="${{ github.ref_name }}"
echo "tags=ghcr.io/pythoughts-labs/watermark-remover:${TAG},ghcr.io/pythoughts-labs/watermark-remover:latest" >> "$GITHUB_OUTPUT"
if [[ ! "$REF_NAME" =~ ^v[0-9][0-9A-Za-z.+-]*$ ]]; then
echo "invalid release ref: $REF_NAME" >&2
exit 1
fi
TAG="$REF_NAME"
echo "tags=ghcr.io/pymodel/watermark-remover:${TAG},ghcr.io/pymodel/watermark-remover:latest" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
Expand Down Expand Up @@ -64,9 +70,15 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute image tags
id: tags
env:
REF_NAME: ${{ github.ref_name }}
run: |
TAG="${{ github.ref_name }}"
echo "tags=ghcr.io/pythoughts-labs/watermark-remover:${{ matrix.tag }}-${TAG},ghcr.io/pythoughts-labs/watermark-remover:${{ matrix.tag }}-latest" >> "$GITHUB_OUTPUT"
if [[ ! "$REF_NAME" =~ ^v[0-9][0-9A-Za-z.+-]*$ ]]; then
echo "invalid release ref: $REF_NAME" >&2
exit 1
fi
TAG="$REF_NAME"
echo "tags=ghcr.io/pymodel/watermark-remover:${{ matrix.tag }}-${TAG},ghcr.io/pymodel/watermark-remover:${{ matrix.tag }}-latest" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/release-pypi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: release-pypi

on:
push:
tags:
- "v*"
workflow_dispatch:

permissions:
contents: read

jobs:
publish:
name: Build and publish to PyPI
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: read
# Trusted Publishing: mints a short-lived OIDC token, so no API token
# is stored anywhere. Bound on PyPI to PyModel/watermark-remover.
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Comment thread
elkaix marked this conversation as resolved.
with:
# No git operations after checkout; don't leave GITHUB_TOKEN in .git/config.
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Build sdist and wheel
run: |
python -m pip install --upgrade build
python -m build
- name: Verify the tag matches the packaged version
env:
TAG: ${{ github.ref_name }}
run: test -f "dist/watermark_remover-${TAG#v}.tar.gz"
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ CLAUDE.md

# Python build artifacts
build/
dist/
*.egg-info/

# Secrets / local env
Expand Down
2 changes: 1 addition & 1 deletion CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement through GitHub
by opening a private security advisory or contacting the repository maintainers
via [GitHub](https://github.com/Pythoughts-labs/watermark-remover) or by email at
via [GitHub](https://github.com/PyModel/watermark-remover) or by email at
<melkholy@techmatrix.com>.

All complaints will be reviewed and investigated promptly and fairly.
Expand Down
71 changes: 45 additions & 26 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ GitHub Release (when releases exist). Older tags are not maintained.

Please report vulnerabilities privately using one of:

1. **[GitHub Security Advisories](https://github.com/Pythoughts-labs/watermark-remover/security/advisories/new)**
1. **[GitHub Security Advisories](https://github.com/PyModel/watermark-remover/security/advisories/new)**
(preferred) — "Report a vulnerability" on the repository Security tab
2. Email the maintainer at <melkholy@techmatrix.com>

Expand Down
6 changes: 3 additions & 3 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ services:
build:
context: .
dockerfile: Dockerfile
image: ghcr.io/pythoughts-labs/watermark-remover:latest
image: ghcr.io/pymodel/watermark-remover:latest
ports:
- "127.0.0.1:8765:8765"
environment:
Expand All @@ -46,7 +46,7 @@ services:
build:
context: .
dockerfile: Dockerfile.markllm
image: ghcr.io/pythoughts-labs/watermark-remover:markllm-latest
image: ghcr.io/pymodel/watermark-remover:markllm-latest
# One-shot CLI: `up` just confirms the image; run real jobs with
# `docker compose run --rm wr-markllm detect /data/wm.txt --scheme kgw`.
command: ["--help"]
Expand All @@ -66,7 +66,7 @@ services:
build:
context: .
dockerfile: Dockerfile.markdiffusion
image: ghcr.io/pythoughts-labs/watermark-remover:markdiffusion-latest
image: ghcr.io/pymodel/watermark-remover:markdiffusion-latest
command: ["--help"]
read_only: true
tmpfs:
Expand Down
38 changes: 35 additions & 3 deletions demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from __future__ import annotations

import json
import re
import shutil
import sys
import tempfile
from pathlib import Path
Expand All @@ -29,17 +31,47 @@
"""Gradio handler. Returns (report markdown, cleaned file path, rewrite prompt)."""
if file_obj is None:
return "Upload a file first.", None, ""
src = Path(file_obj.name if hasattr(file_obj, "name") else str(file_obj))
requested_src = Path(file_obj.name if hasattr(file_obj, "name") else str(file_obj))
workdir = Path(tempfile.mkdtemp(prefix="wmr-"))
dest = workdir / f"{src.stem}.cleaned{src.suffix}"

try:
if requested_src.is_symlink():

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
raise ValueError("uploaded path is a symlink")
src = requested_src.resolve(strict=True)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
if not src.is_file():

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
raise ValueError("uploaded path is not a regular file")
configured_temporary_root = Path(tempfile.gettempdir()).absolute()
temporary_root = configured_temporary_root.resolve(strict=True)
if not src.is_relative_to(temporary_root):
raise ValueError("uploaded path is outside the system temporary directory")

requested_absolute = requested_src.absolute()
for candidate_root in (configured_temporary_root, temporary_root):
try:
relative_source = requested_absolute.relative_to(candidate_root)
break
except ValueError:
continue
else:
raise ValueError("uploaded path reaches the temporary directory through a symlink")
current = candidate_root
for component in relative_source.parts:
current /= component
if current.is_symlink():
raise ValueError(f"uploaded path contains a symlink: {current}")

safe_stem = re.sub(r"[^A-Za-z0-9._-]", "_", src.stem) or "upload"
safe_suffix = re.sub(r"[^A-Za-z0-9._-]", "_", src.suffix)
dest = workdir / f"{safe_stem}.cleaned{safe_suffix}"
kind = classify_asset(src)
result = clean_asset(
src, dest, CleanPlan(forced_kind=kind, strip_all_metadata=not keep_non_ai)
).to_dict()
except Exception as e:
return f"**Error cleaning {src.name}:** `{e}`", None, ""
# Success must keep workdir alive (Gradio serves dest from it); on
# failure nothing references it, so don't leak a directory per upload.
shutil.rmtree(workdir, ignore_errors=True)
return f"**Error cleaning {requested_src.name}:** `{e}`", None, ""
Comment thread
elkaix marked this conversation as resolved.

prompt = ""
if layer_b and kind == "text":
Expand Down
2 changes: 1 addition & 1 deletion docs/windows-autostart.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Replace `<path-to-clone>` below with wherever you want the repo to live (e.g.
path consistently in every step below.

```powershell
git clone https://github.com/Pythoughts-labs/watermark-remover.git <path-to-clone>
git clone https://github.com/PyModel/watermark-remover.git <path-to-clone>
```

## 2. Create a silent launcher script
Expand Down
45 changes: 32 additions & 13 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
[build-system]
requires = ["setuptools>=68.0", "wheel"]
requires = ["setuptools>=77.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "watermark-remover"
version = "0.4.0"
description = "Pure-Python AI watermark remover for text, images, and documents"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
license = "MIT"
license-files = ["LICENSE"]
authors = [{name = "Mohamed Elkholy"}]
keywords = [
"watermark",
"watermark-removal",
"ai-detection",
"c2pa",
"provenance",
"unicode",
"steganography",
"exif",
"metadata",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
Expand All @@ -23,6 +35,12 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]

[project.urls]
Homepage = "https://github.com/PyModel/watermark-remover"
Repository = "https://github.com/PyModel/watermark-remover"
Issues = "https://github.com/PyModel/watermark-remover/issues"
Changelog = "https://github.com/PyModel/watermark-remover/releases"

[project.optional-dependencies]
visible = [
"numpy",
Expand All @@ -47,22 +65,23 @@ all = [
]

[project.scripts]
wm = "skills.remove_ai_marks.scripts.clean_file:main"
wm-serve = "skills.remove_ai_marks.scripts.server:main"
wm-audit-dir = "skills.remove_ai_marks.scripts.audit_dir:main"
wm-audit-site = "skills.remove_ai_marks.scripts.audit_website:main"
wm = "watermark_remover.scripts.clean_file:main"
wm-serve = "watermark_remover.scripts.server:main"
wm-audit-dir = "watermark_remover.scripts.audit_dir:main"
wm-audit-site = "watermark_remover.scripts.audit_website:main"

[tool.setuptools]
# The on-disk directory uses a hyphen (skills/remove-ai-marks) while the
# importable package uses an underscore; map them explicitly.
package-dir = {"skills.remove_ai_marks" = "skills/remove-ai-marks"}
packages = ["skills.remove_ai_marks", "skills.remove_ai_marks.scripts"]
# The on-disk directory keeps its Claude-skill layout (skills/remove-ai-marks);
# the installed package is a single top-level `watermark_remover` root. Mapping
# them here avoids claiming a generic `skills` namespace in site-packages.
package-dir = {"watermark_remover" = "skills/remove-ai-marks"}
packages = ["watermark_remover", "watermark_remover.scripts"]

[tool.setuptools.package-data]
# Ship the skill documentation and bootstrap assets so a wheel install is
# self-sufficient for the CLI surfaces that read them at runtime.
"skills.remove_ai_marks" = ["SKILL.md", "references/*.md"]
"skills.remove_ai_marks.scripts" = ["*.txt", "setup_*.sh", "setup_*.ps1"]
"watermark_remover" = ["SKILL.md", "references/*.md"]
"watermark_remover.scripts" = ["*.txt", "setup_*.sh", "setup_*.ps1"]

[tool.ruff]
line-length = 100
Expand Down
95 changes: 84 additions & 11 deletions skills/clean-user-facing-text/scripts/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

import json
import os
import stat
import sys
import tempfile
from contextlib import suppress
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -126,28 +128,99 @@
def safe_write_bytes(path: str | Path, data: bytes) -> None:
destination = Path(path)
destination.parent.mkdir(parents=True, exist_ok=True)
parent = destination.parent.resolve(strict=True)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
destination = parent / destination.name
if destination.is_symlink():
raise OSError(f"refusing to write through symlink: {destination}")

fd, temporary = tempfile.mkstemp(
prefix=f".{destination.name}.",
suffix=".tmp",
dir=str(destination.parent),
)
parent_fd: int | None = None
if os.name != "nt":
flags = os.O_RDONLY
if hasattr(os, "O_DIRECTORY"):
flags |= os.O_DIRECTORY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
parent_fd = os.open(parent, flags)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

parent_identity = os.fstat(parent_fd) if parent_fd is not None else os.lstat(parent)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
fd = -1
temporary: Path | None = None

def validate_paths(staged_identity: os.stat_result) -> None:
current_parent = os.lstat(parent)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
if stat.S_ISLNK(current_parent.st_mode) or not os.path.samestat(
parent_identity, current_parent
):
raise OSError(f"destination directory changed while writing: {parent}")

if parent_fd is not None:
staged = os.stat(temporary.name, dir_fd=parent_fd, follow_symlinks=False)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
try:
current_destination = os.stat(
destination.name, dir_fd=parent_fd, follow_symlinks=False

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
)
except FileNotFoundError:
current_destination = None
else:
staged = os.lstat(temporary)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
try:
current_destination = os.lstat(destination)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
except FileNotFoundError:
current_destination = None

if not os.path.samestat(staged_identity, staged):
raise OSError(f"temporary path changed while writing: {temporary}")
if current_destination is None:
return
if stat.S_ISLNK(current_destination.st_mode):
raise OSError(f"refusing to write through symlink: {destination}")
if os.path.samestat(staged_identity, current_destination):
raise OSError(f"destination aliases temporary file: {destination}")

try:
fd, temporary_name = tempfile.mkstemp(
prefix=f".{destination.name}.",

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
suffix=".tmp",
dir=str(parent),

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
)
temporary = Path(temporary_name)
staged_identity = os.fstat(fd)
validate_paths(staged_identity)
if hasattr(os, "fchmod"):
os.fchmod(fd, _default_file_mode())
with os.fdopen(fd, "wb") as handle:
handle = os.fdopen(fd, "wb")
fd = -1
with handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, destination)
validate_paths(staged_identity)
if parent_fd is not None:
os.replace(
temporary.name,

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
destination.name,

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
src_dir_fd=parent_fd,
dst_dir_fd=parent_fd,
)
else:
os.replace(temporary, destination)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
temporary = None
except BaseException:
try:
os.unlink(temporary)
except OSError:
pass
if fd >= 0:
with suppress(OSError):
os.close(fd)
if temporary is not None:
try:
if parent_fd is not None:
os.unlink(temporary.name, dir_fd=parent_fd)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
else:
os.unlink(temporary)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
except OSError:
Comment thread
elkaix marked this conversation as resolved.
pass
raise
finally:
if parent_fd is not None:
os.close(parent_fd)


def write_text_output(text: str, path: str | None) -> None:
Expand Down
Loading