diff --git a/.github/BRANCH_PROTECTION.md b/.github/BRANCH_PROTECTION.md new file mode 100644 index 0000000..4555eb9 --- /dev/null +++ b/.github/BRANCH_PROTECTION.md @@ -0,0 +1,56 @@ +# Branch protection for `main` + +Merging to `main` requires passing CI and a pull request. Configuration lives in this repository so it can be reviewed and re-applied consistently. + +## What runs on every PR and push to `main` + +Workflow: [`.github/workflows/ci.yml`](workflows/ci.yml) + +The final job is always named **Required checks**. Branch rulesets require that status to be green before merge. + +## DCO (Developer Certificate of Origin) + +Install the [DCO GitHub App](https://github.com/apps/dco) on the Atlas-Commons organization. + +Every commit must include sign-off: + +```bash +git commit -s -m "Your message" +``` + +## Apply the ruleset (one-time) + +GitHub rulesets are configured on the repository, not via git push. + +```bash +chmod +x .github/scripts/apply-main-ruleset.sh +./.github/scripts/apply-main-ruleset.sh Atlas-Commons REPO_NAME +``` + +Or apply to every catalog repo from a machine with `gh` authenticated: + +```bash +./scripts/apply-all-catalog-rulesets.sh +``` + +### Private repositories (Bot, atlas-commons-website) + +Repository rulesets on **private** repos require GitHub Team or Pro. For those repos, configure branch protection manually under **Settings → Branches** until the org upgrades, or make the repo public. + +The apply script skips private repos automatically. + +### Important: check name must exist first + +GitHub only lets you select status checks that have run at least once. Open a PR against `main` (or push once) **before** applying the ruleset. + +## Apply rulesets to all catalog repos + +See [`atlas-commons-github-templates/scripts/apply-all-catalog-rulesets.sh`](https://github.com/Atlas-Commons/atlas-commons-github-templates) in the template pack, or run from any repo: + +```bash +for repo in Bot atlas-commons-website technitiumdns-api home-assistant-technitiumdns \ + StreamBooru Hassio-Addons Danbooru-Import-Scripts EmbyArrSync windowsRDP-SSH-tunnel-script; do + gh api --method POST "repos/Atlas-Commons/${repo}/rulesets" --input .github/rulesets/main.json 2>/dev/null || \ + gh api --method PUT "repos/Atlas-Commons/${repo}/rulesets/$(gh api repos/Atlas-Commons/${repo}/rulesets --jq '.[]|select(.name=="Protect main")|.id')" --input .github/rulesets/main.json +done +``` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..34abd08 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default reviewers for Atlas Commons repositories. +* @Amateur-God diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..21f488d --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,60 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to **stephen@atlastechsolutions.co.uk**. + +All complaints will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html), version 2.0. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..f00578d --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing to Atlas Commons + +Thank you for contributing to [Atlas Commons](https://github.com/Atlas-Commons) open-source projects. + +## Before you start + +1. Search [existing issues](https://github.com/Atlas-Commons) for duplicates. +2. For large changes, open an issue first to discuss approach. +3. Read our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Developer Certificate of Origin (DCO) + +**Every commit in a pull request must be signed off.** + +Use `-s` when committing: + +```bash +git commit -s -m "Describe your change" +``` + +This adds a `Signed-off-by:` line certifying you wrote the code or have the right to submit it under the project license. See [developercertificate.org](https://developercertificate.org/). + +The [DCO GitHub App](https://github.com/apps/dco) blocks merges when sign-off is missing. + +## Pull request process + +1. Fork the repository and create a branch from `main`. +2. Make focused changes with tests where applicable. +3. Ensure CI passes locally before opening the PR. +4. Open a pull request against `main` with a clear description. +5. Address review feedback; maintainers will merge when checks are green. + +## Local development + +See each repository's `README.md` for setup instructions. Most projects document install, test, and lint commands there. + +## Questions + +Open a [GitHub Discussion](https://github.com/orgs/Atlas-Commons/discussions) or issue in the relevant repository. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..608911c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,39 @@ +name: Bug report +description: Report something that is broken or incorrect +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug. Please search existing issues first. + - type: textarea + id: description + attributes: + label: What happened? + description: Describe the bug and what you expected instead. + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Minimal steps to reproduce the behavior. + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: input + id: version + attributes: + label: Version or commit + placeholder: e.g. v1.2.0 or main @ abc1234 + - type: textarea + id: environment + attributes: + label: Environment + description: OS, runtime versions, relevant configuration. + placeholder: e.g. Ubuntu 24.04, Python 3.12, Docker 27 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..029a1ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/Atlas-Commons/.github/blob/main/SECURITY.md + about: Report security issues privately — do not use public issues. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..c32b6cc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest an improvement or new capability +title: "[Feature]: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Describe the problem you want solved and how you imagine the feature working. + - type: textarea + id: problem + attributes: + label: Problem or use case + description: What problem does this solve? + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + description: How would you like this to work? + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Other approaches you thought about. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..9816e9c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Summary + + + +## Related issues + + + +## Checklist + +- [ ] I have read [CONTRIBUTING.md](.github/CONTRIBUTING.md) +- [ ] Every commit is signed off (`git commit -s`) for [DCO](https://developercertificate.org/) +- [ ] CI passes locally (or I explain why not applicable) +- [ ] Documentation updated if user-facing behavior changed +- [ ] Tests added or updated where applicable diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..5afb3c5 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,26 @@ +# Security Policy + +## Supported versions + +Security fixes are provided for the latest release on the default branch. Older releases may not receive patches. + +## Reporting a vulnerability + +**Please do not report security vulnerabilities via public GitHub issues.** + +Instead, use one of these channels: + +1. **GitHub Security Advisories** — [Open a private report](https://github.com/Atlas-Commons/.github/security/advisories/new) for the affected repository, or use **Report a vulnerability** on the repository Security tab. +2. **Email** — contact the maintainers at **stephen@atlastechsolutions.co.uk** with details and steps to reproduce. + +Include as much detail as possible: affected versions, impact, reproduction steps, and suggested mitigations if you have them. + +## Response timeline + +- **Acknowledgement** within 7 days +- **Fix or mitigation plan** within 60 days for confirmed issues +- Coordinated disclosure preferred; please allow time to release a fix before public disclosure + +## Bug bounty + +Atlas Commons does not operate a paid bug bounty program. We appreciate responsible disclosure and credit researchers in release notes when appropriate. diff --git a/.github/dco.yml b/.github/dco.yml new file mode 100644 index 0000000..c9db6be --- /dev/null +++ b/.github/dco.yml @@ -0,0 +1 @@ +allowSignOff: true diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json new file mode 100644 index 0000000..97f875f --- /dev/null +++ b/.github/rulesets/main.json @@ -0,0 +1,43 @@ +{ + "name": "Protect main", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/heads/main"], + "exclude": [] + } + }, + "bypass_actors": [], + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "allowed_merge_methods": ["merge", "squash", "rebase"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { + "context": "Required checks", + "integration_id": 15368 + } + ] + } + }, + { + "type": "non_fast_forward" + }, + { + "type": "deletion" + } + ] +} diff --git a/.github/scripts/apply-main-ruleset.sh b/.github/scripts/apply-main-ruleset.sh new file mode 100644 index 0000000..85068e8 --- /dev/null +++ b/.github/scripts/apply-main-ruleset.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Apply the main branch ruleset to a GitHub repository. +# +# Prerequisites: +# - GitHub CLI: https://cli.github.com/ +# - Authenticated: gh auth login +# - Admin access on the repository +# +# Usage: +# ./.github/scripts/apply-main-ruleset.sh +# ./.github/scripts/apply-main-ruleset.sh Atlas-Commons Bot + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +RULESET_FILE="${REPO_ROOT}/.github/rulesets/main.json" + +OWNER="${1:-}" +REPO="${2:-}" + +if [[ -z "${OWNER}" || -z "${REPO}" ]]; then + REMOTE="$(git -C "${REPO_ROOT}" remote get-url origin 2>/dev/null || true)" + if [[ "${REMOTE}" =~ github\.com[:/]([^/]+)/([^/.]+) ]]; then + OWNER="${BASH_REMATCH[1]}" + REPO="${BASH_REMATCH[2]}" + else + echo "Usage: $0 " >&2 + echo "Could not detect owner/repo from git remote." >&2 + exit 1 + fi +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "GitHub CLI (gh) is required. Install from https://cli.github.com/" >&2 + exit 1 +fi + +echo "Applying ruleset to ${OWNER}/${REPO} ..." + +visibility="$(gh repo view "${OWNER}/${REPO}" --json visibility -q '.visibility' 2>/dev/null || echo unknown)" +if [[ "${visibility}" == "PRIVATE" ]]; then + echo "Cannot apply repository rulesets to private repos without GitHub Team/Pro." >&2 + echo "Configure branch protection manually: https://github.com/${OWNER}/${REPO}/settings/branches" >&2 + exit 1 +fi + +EXISTING="$(gh api "repos/${OWNER}/${REPO}/rulesets" --jq '.[] | select(.name=="Protect main" or .name=="main") | .id' 2>/dev/null | head -1 || true)" + +if [[ -n "${EXISTING}" ]]; then + echo "Updating existing ruleset id=${EXISTING} ..." + gh api \ + --method PUT \ + "repos/${OWNER}/${REPO}/rulesets/${EXISTING}" \ + --input "${RULESET_FILE}" +else + echo "Creating new ruleset ..." + gh api \ + --method POST \ + "repos/${OWNER}/${REPO}/rulesets" \ + --input "${RULESET_FILE}" +fi + +echo "" +echo "Ruleset applied. Verify at:" +echo " https://github.com/${OWNER}/${REPO}/settings/rules" +echo "" +echo "Notes:" +echo " - Merges to main require the 'Required checks' CI job (workflow: CI)." +echo " - Install the DCO app: https://github.com/apps/dco" +echo " - Open one PR against main so CI runs before enforcing the ruleset." diff --git a/.github/scripts/verify-dco.sh b/.github/scripts/verify-dco.sh new file mode 100644 index 0000000..4b0bcfb --- /dev/null +++ b/.github/scripts/verify-dco.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Verify all commits in a PR include Signed-off-by (DCO). +set -euo pipefail + +if [[ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]]; then + echo "Not a pull request — skipping DCO check." + exit 0 +fi + +base="${GITHUB_BASE_REF:-main}" +git fetch origin "${base}" --depth=1 2>/dev/null || git fetch origin "${base}" + +missing=0 +while IFS= read -r sha; do + [[ -z "$sha" ]] && continue + if ! git log -1 --format=%B "$sha" | grep -qi '^Signed-off-by:'; then + echo "Missing Signed-off-by on commit ${sha:0:7}" + git log -1 --oneline "$sha" + missing=1 + fi +done < <(git rev-list "origin/${base}"..HEAD) + +if [[ "$missing" -ne 0 ]]; then + echo "" + echo "Add sign-off with: git commit -s --amend && git push --force-with-lease" + exit 1 +fi + +echo "All commits include DCO sign-off." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5478a39 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (npm smoke) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - run: npm ci + - run: npm run test:server + - run: npm run test:tags + + dco: + name: DCO sign-off + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: bash .github/scripts/verify-dco.sh + + required: + name: Required checks + runs-on: ubuntu-latest + needs: [test, dco] + if: always() + steps: + - run: | + if [[ "${{ needs.test.result }}" != "success" ]]; then exit 1; fi + if [[ "${{ github.event_name }}" == "pull_request" && "${{ needs.dco.result }}" != "success" ]]; then exit 1; fi + echo "All required CI checks passed." diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..679d043 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,18 @@ +name: Dependency review + +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/dependency-review-action@v4 + with: + comment-summary-in-pr: on-failure diff --git a/CHANGELOG.md b/CHANGELOG.md index e673831..6a4920d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,7 +76,7 @@ The format roughly follows Keep a Changelog, and dates are in YYYY-MM-DD. ### Fixed * Minor adjustments and fixes related to integrating the account system. -[v1.0.0]: [https://github.com/Amateur-God/StreamBooru/releases/tag/v1.0.0](https://github.com/Amateur-God/StreamBooru/releases/tag/v1.0.0) +[v1.0.0]: [https://github.com/Atlas-Commons/StreamBooru/releases/tag/v1.0.0](https://github.com/Atlas-Commons/StreamBooru/releases/tag/v1.0.0) ## [v0.4.0] — 2025-10-18 @@ -109,7 +109,7 @@ The format roughly follows Keep a Changelog, and dates are in YYYY-MM-DD. - “Favorite on site” (remote/booru‑side) is not available in the Android build. - Some sites may still rate‑limit or use anti‑bot protections; the native HTTP path helps with CORS but can’t bypass site‑level restrictions. -[v0.4.0]: [https://github.com/Amateur-God/StreamBooru/releases/tag/v0.4.0](https://github.com/Amateur-God/StreamBooru/releases/tag/v0.4.0) +[v0.4.0]: [https://github.com/Atlas-Commons/StreamBooru/releases/tag/v0.4.0](https://github.com/Atlas-Commons/StreamBooru/releases/tag/v0.4.0) ## [v0.3.1] — 2025-10-18 @@ -138,7 +138,7 @@ The format roughly follows Keep a Changelog, and dates are in YYYY-MM-DD. - Release workflow - Release notes are reliably extracted from CHANGELOG and passed via `body_path`; removed unsupported `allow_updates` input. Falls back to auto‑notes when no section matches. -[v0.3.1]: [https://github.com/Amateur-God/StreamBooru/releases/tag/v0.3.1](https://github.com/Amateur-God/StreamBooru/releases/tag/v0.3.1) +[v0.3.1]: [https://github.com/Atlas-Commons/StreamBooru/releases/tag/v0.3.1](https://github.com/Atlas-Commons/StreamBooru/releases/tag/v0.3.1) ## [v0.3.0] — 2025-10-18 @@ -196,7 +196,7 @@ The format roughly follows Keep a Changelog, and dates are in YYYY-MM-DD. - Lightbox video playback may not work on some Linux builds lacking proprietary codecs (H.264/AAC). Use “Open Media” or replace Electron’s `libffmpeg.so` with the distro’s `chromium-codecs-ffmpeg-extra` variant. - Danbooru video thumbnails may look softer (site only serves small static previews for videos). -[v0.3.0]: [https://github.com/Amateur-God/StreamBooru/releases/tag/v0.3.0](https://github.com/Amateur-God/StreamBooru/releases/tag/v0.3.0) +[v0.3.0]: [https://github.com/Atlas-Commons/StreamBooru/releases/tag/v0.3.0](https://github.com/Atlas-Commons/StreamBooru/releases/tag/v0.3.0) ## [v0.2.1] — 2025-10-18 @@ -273,4 +273,4 @@ The format roughly follows Keep a Changelog, and dates are in YYYY-MM-DD. - UI - Popular re‑sort path preserves scroll via nearest visible anchor element; append‑only path avoids any scroll changes. -[v0.2.1]: [https://github.com/Amateur-God/StreamBooru/releases/tag/v0.2.1](https://github.com/Amateur-God/StreamBooru/releases/tag/v0.2.1) \ No newline at end of file +[v0.2.1]: [https://github.com/Atlas-Commons/StreamBooru/releases/tag/v0.2.1](https://github.com/Atlas-Commons/StreamBooru/releases/tag/v0.2.1) \ No newline at end of file diff --git a/README.md b/README.md index 2825943..cde26df 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # 🌊 StreamBooru +[![CI](https://github.com/Atlas-Commons/StreamBooru/actions/workflows/ci.yml/badge.svg)](https://github.com/Atlas-Commons/StreamBooru/actions/workflows/ci.yml) + +Contributions welcome — see [CONTRIBUTING.md](.github/CONTRIBUTING.md). PRs require DCO sign-off (`git commit -s`). + Welcome to StreamBooru! Your slick, fast desktop gateway to the vast world of booru image boards. Dive into content from multiple sites all in one place. StreamBooru elegantly merges posts from various engines like Danbooru, Gelbooru, Moebooru (Yande.re/Konachan), e621/e926, and Derpibooru. Enjoy seamless browsing, cross-site searching, a handy lightbox viewer, bulk downloading, and your own local favorites collection. @@ -69,14 +73,14 @@ Choose the easiest method for your system: This command downloads the latest release and installs the best package for your Linux distribution (Deb → Flatpak → tar.gz), adding a launcher and the `streambooru` command. ```bash -curl -fsSL https://raw.githubusercontent.com/Amateur-God/StreamBooru/HEAD/scripts/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/Atlas-Commons/StreamBooru/HEAD/scripts/install.sh | bash ``` *(See the script or full docs for options like installing specific versions or forks.)* ### 2) Debian / Ubuntu (.deb) -1. Download the `.deb` file from the [Releases page](https://github.com/Amateur-God/StreamBooru/releases). +1. Download the `.deb` file from the [Releases page](https://github.com/Atlas-Commons/StreamBooru/releases). 2. Install it: ```bash @@ -85,7 +89,7 @@ curl -fsSL https://raw.githubusercontent.com/Amateur-God/StreamBooru/HEAD/script ### 3) Windows (.exe) -1. Download the `StreamBooru-Setup-.exe` from the [Releases page](https://github.com/Amateur-God/StreamBooru/releases). +1. Download the `StreamBooru-Setup-.exe` from the [Releases page](https://github.com/Atlas-Commons/StreamBooru/releases). 2. Run the installer (it's a simple One-Click setup). 3. Launch StreamBooru from your Start Menu. @@ -119,7 +123,7 @@ makepkg -si ### 6) Generic Linux (tar.gz) -1. Download `StreamBooru-*-linux-x64.tar.gz` from the [Releases page](https://github.com/Amateur-God/StreamBooru/releases). +1. Download `StreamBooru-*-linux-x64.tar.gz` from the [Releases page](https://github.com/Atlas-Commons/StreamBooru/releases). 2. Extract and run: ```bash tar xf StreamBooru-*-linux-x64.tar.gz diff --git a/aur/README.md b/aur/README.md index 5916914..bbca3d0 100644 --- a/aur/README.md +++ b/aur/README.md @@ -30,10 +30,10 @@ The same SSH key can push to both packages if your AUR account owns them. ```bash # Stable -./scripts/publish-aur.sh 1.0.2 Amateur-God StreamBooru streambooru-bin "'streambooru' 'streambooru-bin-beta'" +./scripts/publish-aur.sh 1.0.2 Atlas-Commons StreamBooru streambooru-bin "'streambooru' 'streambooru-bin-beta'" # Beta -./scripts/publish-aur.sh 1.1.0-beta.1 Amateur-God StreamBooru streambooru-bin-beta "'streambooru' 'streambooru-bin'" " (pre-release)" +./scripts/publish-aur.sh 1.1.0-beta.1 Atlas-Commons StreamBooru streambooru-bin-beta "'streambooru' 'streambooru-bin'" " (pre-release)" ``` Or use **Actions → Publish AUR (manual)** and pick stable or beta. diff --git a/docs/BETA_ANNOUNCEMENT_v1.1.0-beta.1.md b/docs/BETA_ANNOUNCEMENT_v1.1.0-beta.1.md index 8e2e14a..4db4240 100644 --- a/docs/BETA_ANNOUNCEMENT_v1.1.0-beta.1.md +++ b/docs/BETA_ANNOUNCEMENT_v1.1.0-beta.1.md @@ -39,7 +39,7 @@ No install required — browse directly on the sync server: ## Downloads -Grab builds from the [v1.1.0-beta.1 release](https://github.com/Amateur-God/StreamBooru/releases/tag/v1.1.0-beta.1): +Grab builds from the [v1.1.0-beta.1 release](https://github.com/Atlas-Commons/StreamBooru/releases/tag/v1.1.0-beta.1): | Platform | Artifact | |----------|----------| diff --git a/electron-builder.yml b/electron-builder.yml index a27e449..a54f643 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -19,7 +19,7 @@ linux: target: - deb - tar.gz - maintainer: "Amateur-god " + maintainer: "Atlas Commons " vendor: "StreamBooru Project" deb: diff --git a/electron/main.js b/electron/main.js index ce0fbbe..f40a7fc 100644 --- a/electron/main.js +++ b/electron/main.js @@ -27,6 +27,7 @@ const Moebooru = loadAdapter('moebooru'); const Gelbooru = loadAdapter('gelbooru'); const E621 = loadAdapter('e621'); const Derpibooru = loadAdapter('derpibooru'); +const { refererHeadersFor, BOORU_UA } = require('../server/src/refererFor'); let win; @@ -41,8 +42,8 @@ function setupHotlinkHeaders(sess) { else if (host === 'files.yande.re') referer = 'https://yande.re/'; else if (host === 'konachan.com') referer = 'https://konachan.com/'; else if (host === 'konachan.net') referer = 'https://konachan.net/'; - else if (host.endsWith('e621.net')) referer = 'https://e621.net/'; - else if (host.endsWith('e926.net')) referer = 'https://e926.net/'; + else if (host.endsWith('e621.net') || host.endsWith('e621.media')) referer = 'https://e621.net/'; + else if (host.endsWith('e926.net') || host.endsWith('e926.media')) referer = 'https://e926.net/'; else if (host.endsWith('derpicdn.net') || host.endsWith('derpibooru.org')) referer = 'https://derpibooru.org/'; if (referer) headers['Referer'] = referer; headers['User-Agent'] = headers['User-Agent'] || 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123 Safari/537.36 StreamBooru/Electron'; @@ -147,14 +148,15 @@ function removeLocalFavoriteKey(key) { /* http */ function applyDefaultHeaders(request, url, headers = {}) { + const refHdr = refererHeadersFor(url); const h = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'User-Agent': BOORU_UA, Accept: '*/*', 'Accept-Language': 'en-US,en;q=0.9', - Referer: url, + ...refHdr, ...headers }; - Object.entries(h).forEach(([k, v]) => request.setHeader(k, v)); + Object.entries(h).forEach(([k, v]) => { if (v != null && v !== '') request.setHeader(k, v); }); } function httpGetJson(url, headers = {}) { if (isDev) console.log('[GET]', url); @@ -490,7 +492,7 @@ ipcMain.handle('download:bulk', async (_evt, payload) => { const outPath = path.join(targetDir, filename); await new Promise((resolve, reject) => { const req = net.request({ url: it.url, method: 'GET' }); - req.setHeader('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'); + applyDefaultHeaders(req, it.url, {}); const file = fs.createWriteStream(outPath); req.on('response', (res) => { res.pipe(file); res.on('end', resolve); res.on('error', reject); }); req.on('error', reject); req.end(); @@ -514,7 +516,17 @@ ipcMain.handle('image:proxy', async (_evt, { url }) => { applyDefaultHeaders(req, url, {}); const chunks = []; let contentType = 'image/jpeg'; req.on('response', (res) => { - const ct = res.headers['content-type'] || res.headers['Content-Type']; if (ct) contentType = Array.isArray(ct) ? ct[0] : ct; + const status = res.statusCode || 0; + const ct = res.headers['content-type'] || res.headers['Content-Type']; + if (ct) contentType = Array.isArray(ct) ? ct[0] : ct; + if (status >= 400) { + resolve({ ok: false, error: `HTTP ${status}` }); + return; + } + if (String(contentType).toLowerCase().startsWith('video/')) { + resolve({ ok: false, error: 'not_an_image' }); + return; + } res.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); res.on('end', () => { const buf = Buffer.concat(chunks); resolve({ ok: true, dataUrl: `data:${contentType};base64,${buf.toString('base64')}` }); }); res.on('error', (e) => resolve({ ok: false, error: String(e) })); diff --git a/package.json b/package.json index 06ac9ad..7f977cc 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "StreamBooru – Electron app to browse multiple booru sites with New and Popular feeds, merged across sources.", "main": "electron/main.js", "author": { - "name": "Amateur-God", + "name": "Atlas-Commons", "email": "mrhowe95@gmail.com" }, "license": "GPLv3", diff --git a/renderer/components/lightbox.js b/renderer/components/lightbox.js index 3b8f4c2..a9da710 100644 --- a/renderer/components/lightbox.js +++ b/renderer/components/lightbox.js @@ -19,6 +19,9 @@ }; const isAndroid = () => !!(window.Platform && typeof window.Platform.isAndroid === 'function' && window.Platform.isAndroid()); + const isElectron = () => !!(window.Platform && typeof window.Platform.isElectron === 'function' && window.Platform.isElectron()); + const isWebBrowser = () => !isElectron() && !isAndroid(); + function isHotlinkHost(u) { try { const h = new URL(u).hostname; @@ -27,6 +30,7 @@ h === 'files.yande.re' || h === 'konachan.com' || h === 'konachan.net' || h.endsWith('e621.net') || h.endsWith('e926.net') || + h.endsWith('e621.media') || h.endsWith('e926.media') || h.endsWith('derpibooru.org') || h.endsWith('derpicdn.net') || h.endsWith('gelbooru.com') || h.endsWith('safebooru.org') || h.endsWith('rule34.xxx') || h.endsWith('realbooru.com') || h.endsWith('xbooru.com') || @@ -45,9 +49,59 @@ } catch (_) {} }; img.onerror = proxy; - if (isAndroid() && isHotlinkHost(url)) proxy(); + if ((isAndroid() || isWebBrowser()) && isHotlinkHost(url)) proxy(); }; + async function setVideoWithFallback(vid, url, sourceEl) { + if (!url) return; + const needsProxy = (isAndroid() || isWebBrowser()) && isHotlinkHost(url); + + const loadDirect = () => { + if (sourceEl) { + sourceEl.src = url; + const t = guessVideoType(url); + if (t) sourceEl.type = t; + } else { + vid.src = url; + } + try { vid.load(); } catch {} + }; + + const loadProxied = async () => { + try { + const blob = await window.api.fetchMediaBlob?.(url); + if (!blob) throw new Error('proxy unavailable'); + const objUrl = URL.createObjectURL(blob); + vid._blobUrl = objUrl; + if (sourceEl) { + sourceEl.src = objUrl; + sourceEl.type = blob.type || guessVideoType(url) || 'video/mp4'; + } else { + vid.src = objUrl; + } + try { vid.load(); } catch {} + const p = vid.play?.(); + if (p && typeof p.catch === 'function') p.catch(() => {}); + } catch (e) { + console.warn('video proxy fallback failed', e); + loadDirect(); + } + }; + + if (needsProxy) { + await loadProxied(); + } else { + loadDirect(); + } + + vid.addEventListener('error', () => { + if (!vid._proxyRetried && window.api?.fetchMediaBlob) { + vid._proxyRetried = true; + loadProxied(); + } + }, { once: true }); + } + const pathFromUrl = function (u) { try { const p = new URL(u).pathname; @@ -77,6 +131,86 @@ return tip; } + function createZoomController(viewport, mediaEl) { + const state = { scale: 1, tx: 0, ty: 0, dragging: false, lastX: 0, lastY: 0 }; + + const apply = () => { + mediaEl.style.transform = `translate(${state.tx}px, ${state.ty}px) scale(${state.scale})`; + viewport.classList.toggle('lb-zoomed', state.scale > 1.01); + }; + + const clampPan = () => { + const maxX = Math.max(0, (viewport.clientWidth * (state.scale - 1)) / 2); + const maxY = Math.max(0, (viewport.clientHeight * (state.scale - 1)) / 2); + state.tx = Math.max(-maxX, Math.min(maxX, state.tx)); + state.ty = Math.max(-maxY, Math.min(maxY, state.ty)); + }; + + const setScale = (next, originX, originY) => { + const prev = state.scale; + state.scale = Math.max(1, Math.min(4, next)); + if (state.scale === 1) { + state.tx = 0; + state.ty = 0; + } else if (originX != null && originY != null && prev !== state.scale) { + const rect = viewport.getBoundingClientRect(); + const cx = originX - rect.left - rect.width / 2; + const cy = originY - rect.top - rect.height / 2; + state.tx -= cx * (state.scale / prev - 1); + state.ty -= cy * (state.scale / prev - 1); + clampPan(); + } + apply(); + }; + + const reset = () => setScale(1); + + viewport.addEventListener('wheel', (e) => { + if (mediaEl.tagName !== 'IMG') return; + e.preventDefault(); + const delta = e.deltaY < 0 ? 0.15 : -0.15; + setScale(state.scale + delta, e.clientX, e.clientY); + }, { passive: false }); + + mediaEl.addEventListener('dblclick', (e) => { + if (mediaEl.tagName !== 'IMG') return; + e.preventDefault(); + if (state.scale > 1.01) reset(); + else setScale(2, e.clientX, e.clientY); + }); + + mediaEl.addEventListener('pointerdown', (e) => { + if (state.scale <= 1.01) return; + state.dragging = true; + state.lastX = e.clientX; + state.lastY = e.clientY; + mediaEl.setPointerCapture?.(e.pointerId); + }); + mediaEl.addEventListener('pointermove', (e) => { + if (!state.dragging) return; + state.tx += e.clientX - state.lastX; + state.ty += e.clientY - state.lastY; + state.lastX = e.clientX; + state.lastY = e.clientY; + clampPan(); + apply(); + }); + mediaEl.addEventListener('pointerup', () => { state.dragging = false; }); + mediaEl.addEventListener('pointercancel', () => { state.dragging = false; }); + + return { + zoomIn: () => setScale(state.scale + 0.25), + zoomOut: () => setScale(state.scale - 0.25), + reset, + handleKey(key) { + if (key === '+' || key === '=') { zoomIn(); return true; } + if (key === '-' || key === '_') { zoomOut(); return true; } + if (key === '0') { reset(); return true; } + return false; + } + }; + } + const hasRemote = (post) => typeof window.hasRemoteFavoriteSupport === 'function' && window.hasRemoteFavoriteSupport(post); const toggleRemote = (post) => window.toggleRemoteFavoriteRemote?.(post); @@ -84,7 +218,7 @@ const f = post.file_url || ''; const s = post.sample_url || ''; const p = post.preview_url || ''; - const hot = isAndroid() && (isHotlinkHost(f) || isHotlinkHost(s)); + const hot = (isAndroid() || isWebBrowser()) && (isHotlinkHost(f) || isHotlinkHost(s)); const order = hot ? [s, f, p] : [f, s, p]; for (const u of order) if (u) return u; return ''; @@ -95,6 +229,11 @@ if (!items || !items[index]) return; const post = items[index]; + if (lb._blobUrl) { + try { URL.revokeObjectURL(lb._blobUrl); } catch {} + lb._blobUrl = null; + } + lb.innerHTML = ''; const content = document.createElement('div'); content.className = 'content'; @@ -107,8 +246,12 @@ const full = pickFullUrl(post); const isVid = isVideoUrl(full); + const viewport = document.createElement('div'); + viewport.className = 'lb-viewport'; + let mediaEl; let tipEl = null; + let zoomCtl = null; if (isVid) { const mp4 = full.toLowerCase().endsWith('.mp4') || full.toLowerCase().endsWith('.m4v'); @@ -133,9 +276,6 @@ if (post.preview_url) vid.poster = post.preview_url; const source = document.createElement('source'); - source.src = full; - const t = guessVideoType(full); - if (t) source.type = t; vid.appendChild(source); const tryPlay = () => { @@ -147,13 +287,14 @@ if (!unsupported) { vid.addEventListener('canplay', tryPlay, { once: true }); vid.addEventListener('loadeddata', tryPlay, { once: true }); - vid.addEventListener('stalled', tryPlay); - vid.addEventListener('suspend', tryPlay); vid.addEventListener('click', () => { if (vid.paused) { tryPlay(); } else { vid.pause(); } }); + setVideoWithFallback(vid, full, source).then(() => { + if (vid._blobUrl) lb._blobUrl = vid._blobUrl; + }); } else { - tipEl = makeTip('This Electron/WebView cannot decode this video. Use “Open Media”.'); + tipEl = makeTip('This environment cannot decode this video. Use “Open Media”.'); } mediaEl = vid; @@ -163,8 +304,11 @@ setImageWithFallback(img, full); img.alt = post.tags?.join(' ') || ''; mediaEl = img; + zoomCtl = createZoomController(viewport, img); } + viewport.appendChild(mediaEl); + const toolbar = document.createElement('div'); toolbar.className = 'toolbar'; @@ -203,6 +347,21 @@ if (!res?.ok && !res?.cancelled) { alert('Download failed' + (res?.error ? `: ${res.error}` : '')); } }); + if (zoomCtl) { + const zoomInBtn = document.createElement('button'); + zoomInBtn.textContent = 'Zoom +'; + zoomInBtn.addEventListener('click', () => zoomCtl.zoomIn()); + const zoomOutBtn = document.createElement('button'); + zoomOutBtn.textContent = 'Zoom −'; + zoomOutBtn.addEventListener('click', () => zoomCtl.zoomOut()); + const zoomResetBtn = document.createElement('button'); + zoomResetBtn.textContent = 'Reset'; + zoomResetBtn.addEventListener('click', () => zoomCtl.reset()); + toolbar.appendChild(zoomInBtn); + toolbar.appendChild(zoomOutBtn); + toolbar.appendChild(zoomResetBtn); + } + let remoteBtn = null; if (hasRemote(post)) { remoteBtn = document.createElement('button'); @@ -235,13 +394,14 @@ toolbar.appendChild(localBtn); content.appendChild(closeBtn); - content.appendChild(mediaEl); + content.appendChild(viewport); if (tipEl) content.appendChild(tipEl); content.appendChild(toolbar); lb.appendChild(content); const keyHandler = (e) => { - if (e.key === 'Escape') { e.preventDefault(); hide(lb); } + if (e.key === 'Escape') { e.preventDefault(); hide(lb); return; } + if (zoomCtl?.handleKey(e.key)) { e.preventDefault(); return; } if (e.key === 'ArrowLeft') { e.preventDefault(); prevBtn.click(); } if (e.key === 'ArrowRight') { e.preventDefault(); nextBtn.click(); } }; @@ -249,7 +409,6 @@ lb._keyHandler = keyHandler; document.addEventListener('keydown', keyHandler, true); - // Overlay click to close with short guard against the same-tap synthetic click const guardUntil = Date.now() + 350; lb._openGuardUntil = guardUntil; lb.onclick = (e) => { @@ -261,6 +420,10 @@ const hide = function (lb) { document.removeEventListener('keydown', lb._keyHandler, true); lb._keyHandler = null; + if (lb._blobUrl) { + try { URL.revokeObjectURL(lb._blobUrl); } catch {} + lb._blobUrl = null; + } lb.classList.add('hidden'); lb.setAttribute('aria-hidden', 'true'); lb.innerHTML = ''; @@ -280,4 +443,4 @@ const idx = items.findIndex((p) => `${p?.site?.baseUrl || ''}#${p?.id}` === `${post?.site?.baseUrl || ''}#${post?.id}`); window.openLightboxAt(idx >= 0 ? idx : 0); }; -})(); \ No newline at end of file +})(); diff --git a/renderer/components/postCard.js b/renderer/components/postCard.js index 17a5b80..35e9c8e 100644 --- a/renderer/components/postCard.js +++ b/renderer/components/postCard.js @@ -1,29 +1,24 @@ (() => { const openExternal = (url) => { if (url) window.api.openExternal(url); }; - const isAndroid = () => !!(window.Platform && typeof window.Platform.isAndroid === 'function' && window.Platform.isAndroid()); + + const isElectron = () => !!(window.Platform?.isElectron?.()); + const isAndroid = () => !!(window.Platform?.isAndroid?.()); + const isWebBrowser = () => !isElectron() && !isAndroid(); const isVideoUrl = (u) => { try { const p = new URL(u, 'https://x/').pathname.toLowerCase(); return /\.(mp4|webm|mov|m4v)$/i.test(p); } catch { return /\.(mp4|webm|mov|m4v)$/i.test(String(u || '').toLowerCase()); } }; - // Prefer preview image for any video post to avoid putting video into - const pickThumb = (post) => { - const f = post.file_url || ''; - const s = post.sample_url || ''; - const p = post.preview_url || ''; - if ((isVideoUrl(f) || isVideoUrl(s)) && p) return p; - return s || f || p || ''; - }; - function isHotlinkHost(u) { try { - const h = new URL(u).hostname; + const h = new URL(u).hostname.toLowerCase(); return ( h.endsWith('donmai.us') || - h === 'files.yande.re' || + h === 'files.yande.re' || h.endsWith('yande.re') || h === 'konachan.com' || h === 'konachan.net' || h.endsWith('e621.net') || h.endsWith('e926.net') || + h.endsWith('e621.media') || h.endsWith('e926.media') || h.endsWith('derpibooru.org') || h.endsWith('derpicdn.net') || h.endsWith('gelbooru.com') || h.endsWith('safebooru.org') || h.endsWith('rule34.xxx') || h.endsWith('realbooru.com') || h.endsWith('xbooru.com') || @@ -32,6 +27,111 @@ } catch { return false; } } + const thumbCandidates = (post) => + [post.preview_url, post.sample_url, post.file_url] + .filter(Boolean) + .filter((u) => !isVideoUrl(u)); + + const isVideoPost = (post) => + !!post.is_video || isVideoUrl(post.file_url || '') || isVideoUrl(post.sample_url || ''); + + async function proxyIntoImg(imgEl, url) { + if (!window.api?.proxyImage || !url || isVideoUrl(url)) return false; + try { + const prox = await window.api.proxyImage(url); + if (prox?.ok && (prox.dataUrl || prox.url)) { + imgEl.src = prox.dataUrl || prox.url; + return imgEl.complete ? imgEl.naturalWidth > 0 : true; + } + } catch (e) { + console.warn('proxyImage failed (thumb)', e); + } + return false; + } + + /** Direct load first on Electron (webRequest injects Referer); proxy on error or web/Android hotlinks. */ + function attachThumbImage(imgEl, urls, onGiveUp) { + if (!urls.length) { + onGiveUp?.(); + return; + } + + let idx = 0; + + const tryDirect = (url) => new Promise((resolve) => { + const finish = (ok) => { + imgEl.onload = null; + imgEl.onerror = null; + resolve(ok); + }; + imgEl.onload = () => finish(imgEl.naturalWidth > 0); + imgEl.onerror = () => finish(false); + imgEl.removeAttribute('srcset'); + imgEl.src = url; + + if ((isWebBrowser() || isAndroid()) && isHotlinkHost(url)) { + imgEl.onload = null; + imgEl.onerror = null; + proxyIntoImg(imgEl, url).then(resolve); + } + }); + + const run = async () => { + while (idx < urls.length) { + const url = urls[idx++]; + if (await tryDirect(url)) return; + if (await proxyIntoImg(imgEl, url)) return; + } + onGiveUp?.(); + }; + + run(); + } + + function attachVideoThumb(thumbEl, url, onGiveUp) { + if (!url) { + onGiveUp?.(); + return; + } + + thumbEl.classList.add('thumb--video'); + const vid = document.createElement('video'); + vid.className = 'thumb-video'; + vid.muted = true; + vid.loop = true; + vid.playsInline = true; + vid.autoplay = true; + vid.preload = 'auto'; + vid.setAttribute('aria-label', 'Video preview'); + vid.draggable = false; + + const play = () => { try { vid.play()?.catch(() => {}); } catch {} }; + + const loadBlob = async () => { + try { + const blob = await window.api.fetchMediaBlob?.(url); + if (!blob) return false; + const objUrl = URL.createObjectURL(blob); + vid._blobUrl = objUrl; + vid.src = objUrl; + return true; + } catch { + return false; + } + }; + + vid.onloadeddata = play; + vid.onerror = async () => { + if (await loadBlob()) return; + if (vid._blobUrl) URL.revokeObjectURL(vid._blobUrl); + vid.remove(); + onGiveUp?.(); + }; + + vid.src = url; + thumbEl.insertBefore(vid, thumbEl.firstChild); + } + // tap (pointer-first with touch fallback) function onTap(el, handler, opts = {}) { const maxMove = opts.maxMove ?? 10; @@ -69,20 +169,6 @@ } } - async function tryProxyImage(imgEl, url, post) { - try { - if (!window.api?.proxyImage || !url || imgEl._proxiedOnce) return; - // Never try to proxy a video into - if (isVideoUrl(url)) return; - imgEl._proxiedOnce = true; - const prox = await window.api.proxyImage(url); - if (prox?.ok && (prox.url || prox.dataUrl)) { - imgEl.src = prox.url || prox.dataUrl; - imgEl.removeAttribute('srcset'); - } - } catch (e) { console.warn('proxyImage failed (thumb)', e); } - } - const buildActions = (post, idx) => { const wrap = document.createElement('div'); wrap.className = 'actions'; @@ -132,44 +218,57 @@ const thumb = document.createElement('div'); thumb.className = 'thumb'; - const img = document.createElement('img'); - img.loading = 'lazy'; - img.decoding = 'async'; - img.alt = String(post?.id ?? ''); - img.draggable = false; - img.style.touchAction = 'pan-y'; - img.style.userSelect = 'none'; - img.style.webkitUserDrag = 'none'; - - const thumbUrl = pickThumb(post); - img.src = thumbUrl; - - // Only include image URLs in srcset (skip video URLs) - const candidates = []; - if (post.sample_url && !isVideoUrl(post.sample_url)) candidates.push(`${post.sample_url} 1x`); - if (post.file_url && post.file_url !== post.sample_url && !isVideoUrl(post.file_url)) candidates.push(`${post.file_url} 2x`); - if (candidates.length) img.srcset = candidates.join(', '); - - img.addEventListener('error', () => tryProxyImage(img, thumbUrl, post)); - if (isAndroid() && thumbUrl) { - if (isHotlinkHost(thumbUrl)) { - tryProxyImage(img, thumbUrl, post); - } else { - let t = setTimeout(() => tryProxyImage(img, thumbUrl, post), 1500); - const clear = () => { if (t) { clearTimeout(t); t = null; } }; - img.addEventListener('load', clear, { once: true }); - img.addEventListener('error', clear, { once: true }); + const videoPost = isVideoPost(post); + const staticUrls = thumbCandidates(post); + const gridVideo = post.grid_video_url || ''; + + const showPlaceholder = () => { + thumb.classList.add('thumb--video'); + const placeholder = document.createElement('div'); + placeholder.className = 'thumb-placeholder'; + placeholder.setAttribute('aria-label', videoPost ? 'Video post' : 'No preview'); + placeholder.textContent = videoPost ? '▶' : '?'; + thumb.appendChild(placeholder); + }; + + const fallbackFromStatic = () => { + if (gridVideo) attachVideoThumb(thumb, gridVideo, showPlaceholder); + else showPlaceholder(); + }; + + if (staticUrls.length) { + const img = document.createElement('img'); + img.loading = index < 16 ? 'eager' : 'lazy'; + img.decoding = 'async'; + img.alt = videoPost ? 'Video preview' : String(post?.id ?? ''); + img.draggable = false; + img.style.touchAction = 'pan-y'; + img.style.userSelect = 'none'; + img.style.webkitUserDrag = 'none'; + thumb.appendChild(img); + attachThumbImage(img, staticUrls, () => { + img.remove(); + fallbackFromStatic(); + }); + + if (videoPost) { + const badge = document.createElement('span'); + badge.className = 'thumb-video-badge'; + badge.textContent = '▶'; + badge.setAttribute('aria-hidden', 'true'); + thumb.appendChild(badge); } + } else if (gridVideo) { + attachVideoThumb(thumb, gridVideo, showPlaceholder); + } else { + showPlaceholder(); } - // Full-coverage hitbox for reliable taps const hit = document.createElement('div'); hit.className = 'hitbox'; hit.setAttribute('role', 'button'); hit.setAttribute('tabindex', '-1'); onTap(hit, () => { if (window.openLightbox) window.openLightbox(post); }); - - thumb.appendChild(img); thumb.appendChild(hit); const meta = document.createElement('div'); @@ -194,4 +293,4 @@ return card; }; -})(); \ No newline at end of file +})(); diff --git a/renderer/js/bulk-download.js b/renderer/js/bulk-download.js deleted file mode 100644 index 99aea81..0000000 --- a/renderer/js/bulk-download.js +++ /dev/null @@ -1,75 +0,0 @@ -(function () { - // A getter your view can register to supply the current tab's posts - let getCurrentPosts = null; - - // window.registerResultsProvider(() => currentPostsArray); - window.registerResultsProvider = function (fn) { - if (typeof fn === 'function') getCurrentPosts = fn; - }; - - // Map a post object to a downloadable item - const toDownloadItem = function(post, i) { - // Prefer the original file if present, fallback to sample/preview - const url = post?.file_url || post?.sample_url || post?.preview_url || ''; - if (!url) return null; - - // Build a sensible filename - const baseName = (() => { - try { return decodeURIComponent(new URL(url).pathname.split('/').pop() || 'image'); } - catch { return 'image'; } - })(); - - const ext = (baseName.includes('.') ? baseName.split('.').pop() : 'jpg').slice(0, 10); - const safeSite = (post?.site?.name || post?.site?.type || 'site').replace(/[^\w.-]+/g, '_'); - const idPart = String(post?.id || i).replace(/[^\w.-]+/g, '_'); - const fileName = `${safeSite}-${idPart}.${ext}`.replace(/\.+\./g, '.'); - - return { - url, - siteName: post?.site?.name || post?.site?.baseUrl || 'unknown', - fileName - }; - }; - - const onDownloadAllClick = async function() { - try { - if (!getCurrentPosts) { - alert('No results are loaded yet.'); - return; - } - const posts = getCurrentPosts() || []; - if (!Array.isArray(posts) || posts.length === 0) { - alert('No results to download.'); - return; - } - - // Build items list - const items = posts - .map(toDownloadItem) - .filter(Boolean); - - if (items.length === 0) { - alert('No downloadable URLs found in the current results.'); - return; - } - - // Subfolders per site and limited concurrency - const res = await window.api.downloadBulk(items, { subfolderBySite: true, concurrency: 3 }); - if (res?.cancelled) return; - if (!res?.ok) { - alert(`Download failed: ${res?.error || 'unknown error'}`); - return; - } - - const failedCount = (res.failed || []).length; - const msg = `Saved ${res.saved} file(s)` + (failedCount ? `, ${failedCount} failed` : '') + (res.basePath ? `\nFolder: ${res.basePath}` : ''); - alert(msg); - } catch (e) { - console.error('Download all error:', e); - alert(`Download error: ${e?.message || e}`); - } - }; - - const btn = document.getElementById('btnDownloadAll'); - if (btn) btn.addEventListener('click', onDownloadAllClick); -})(); diff --git a/renderer/js/platform.js b/renderer/js/platform.js index a9b0719..7f4e3b0 100644 --- a/renderer/js/platform.js +++ b/renderer/js/platform.js @@ -230,7 +230,8 @@ if (h.endsWith('tbib.org')) return 'https://tbib.org'; if (h.endsWith('gelbooru.com')) return 'https://gelbooru.com'; if (h.endsWith('safebooru.org')) return 'https://safebooru.org'; - if (h.endsWith('e621.net') || h.endsWith('e926.net')) return 'https://e621.net'; + if (h.endsWith('e621.net') || h.endsWith('e621.media')) return 'https://e621.net'; + if (h.endsWith('e926.net') || h.endsWith('e926.media')) return 'https://e926.net'; if (h.endsWith('derpicdn.net') || h.endsWith('derpibooru.org')) return 'https://derpibooru.org'; return ''; } catch { return ''; } @@ -659,6 +660,120 @@ } } + function mediaproxyUrl(url, { download = false, filename = '' } = {}) { + const base = webProxyBase(); + if (!base) return ''; + const q = new URLSearchParams({ url }); + q.set('accept', 'image/*,video/*,application/octet-stream,*/*'); + if (download) { + q.set('download', '1'); + if (filename) q.set('filename', filename); + } + return `${base}/mediaproxy?${q.toString()}`; + } + + async function fetchMediaBlob(url) { + const needsProxy = isWebBrowser() || isAndroid(); + const proxy = needsProxy ? mediaproxyUrl(url) : ''; + const fetchUrl = proxy || url; + const Http = getHttp(); + if (isAndroid() && Http?.get) { + const res = await Http.get({ + url: fetchUrl, + responseType: 'arraybuffer', + headers: { Accept: 'image/*,video/*,application/octet-stream,*/*', 'User-Agent': UA }, + readTimeout: 120000, + connectTimeout: 30000 + }); + const status = res.status ?? 0; + if (status < 200 || status >= 300) throw new Error(`HTTP ${status}`); + const mime = guessMime(url); + return new Blob([res.data], { type: mime }); + } + const r = await fetch(fetchUrl, { headers: { Accept: 'image/*,video/*,application/octet-stream,*/*' } }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.blob(); + } + + async function triggerBlobDownload(blob, filename) { + const objUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objUrl; + a.download = String(filename || 'download').replace(/[<>:"/\\|?*\x00-\x1F]+/g, '_').slice(0, 200); + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(objUrl), 60_000); + } + + async function downloadMediaWeb({ url, fileName, siteName }) { + if (isElectron() && window.api?.downloadImage) { + return window.api.downloadImage({ url, siteName, fileName }); + } + const safeName = String(fileName || 'download').replace(/[<>:"/\\|?*\x00-\x1F]+/g, '_').slice(0, 200); + try { + const blob = await fetchMediaBlob(url); + if (C?.Plugins?.Filesystem?.writeFile) { + if (typeof C.Plugins.Filesystem.requestPermissions === 'function') { + try { + const perm = await C.Plugins.Filesystem.requestPermissions(); + const ok = perm.publicStorage === 'granted' || perm.publicStorage === 'limited'; + if (!ok) return { ok: false, error: 'Storage permission denied' }; + } catch {} + } + const buf = await blob.arrayBuffer(); + const bytes = new Uint8Array(buf); + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + await C.Plugins.Filesystem.writeFile({ + path: `Pictures/StreamBooru/${safeName}`, + data: b64(bin), + directory: 'EXTERNAL', + recursive: true + }); + return { ok: true }; + } + await triggerBlobDownload(blob, safeName); + return { ok: true }; + } catch (e) { + return { ok: false, error: String(e?.message || e) }; + } + } + + async function downloadBulkWeb(items, options = {}) { + if (isElectron() && window.api?.downloadBulk) { + return window.api.downloadBulk(items, options); + } + if (!Array.isArray(items) || items.length === 0) { + return { ok: false, error: 'No items to download' }; + } + if (isWebBrowser() && items.length > 1) { + const ok = window.confirm(`Download ${items.length} files? Your browser will save them one at a time.`); + if (!ok) return { ok: false, cancelled: true }; + } + const concurrency = Number(options.concurrency || 3); + let index = 0; + let saved = 0; + const failed = []; + const worker = async () => { + while (true) { + const i = index++; + if (i >= items.length) return; + const it = items[i]; + try { + const blob = await fetchMediaBlob(it.url); + await triggerBlobDownload(blob, it.fileName || `file_${i}`); + saved++; + } catch (e) { + failed.push({ i, error: String(e?.message || e) }); + } + } + }; + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker())); + return { ok: true, saved, failed, basePath: '(browser downloads)' }; + } + // Account helpers for remote sync const ACC_KEY = 'sb_account_v1'; function accLoad() { try { return JSON.parse(localStorage.getItem(ACC_KEY) || '{}'); } catch { return {}; } } @@ -958,21 +1073,11 @@ if (navigator.share) { const { title, text, url } = opts; await navigator.share({ title, text, url }); return true; } return false; }, saveImageFromUrl: async (url, filename = 'image.jpg') => { + const res = await downloadMediaWeb({ url, fileName: filename }); + if (res?.ok) return true; if (isElectron() && window.api?.saveImage) return window.api.saveImage(url, filename); - if (C?.Plugins?.Filesystem?.writeFile) { - try { - if (typeof C.Plugins.Filesystem.requestPermissions === 'function') { - try { const perm = await C.Plugins.Filesystem.requestPermissions(); const ok = perm.publicStorage === 'granted' || perm.publicStorage === 'limited'; if (!ok) return false; } catch {} - } - const resp = await fetch(url); const blob = await resp.blob(); const buf = await blob.arrayBuffer(); - const bytes = new Uint8Array(buf); let bin = ''; for (let i=0;i { + return false; + }, fetchMediaBlob, mediaproxyUrl, downloadMediaWeb, downloadBulkWeb, getVersion: async () => { if (isElectron() && window.api?.getVersion) return window.api.getVersion(); if (C?.Plugins?.App?.getInfo) { try { const info = await C.Plugins.App.getInfo(); return info?.version || 'android'; } catch {} } return 'web'; @@ -987,11 +1092,9 @@ (function ensureProxyHelpers() { window.api = window.api || {}; - if (typeof window.api.proxyImage !== 'function') { - window.api.proxyImage = proxyImage; - } else { - window.api.proxyImage = proxyImage; - } + window.api.proxyImage = proxyImage; + window.api.fetchMediaBlob = fetchMediaBlob; + window.api.mediaproxyUrl = mediaproxyUrl; window.apiHostNeedsProxy = window.apiHostNeedsProxy || hostNeedsProxy; })(); @@ -1001,8 +1104,8 @@ saveConfig: saveConfigWeb, fetchBooru: fetchBooruWeb, openExternal: window.Platform.openExternal, - downloadImage: async ({ url, fileName }) => window.Platform.saveImageFromUrl(url, fileName), - downloadBulk: undefined, + downloadImage: downloadMediaWeb, + downloadBulk: downloadBulkWeb, proxyImage, booruFavorite: async () => ({ ok: false, error: 'Not supported on Android build' }), authCheck: async () => ({ ok: true }), diff --git a/renderer/styles.css b/renderer/styles.css index 429ac9e..7a68753 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -103,6 +103,36 @@ body { -webkit-tap-highlight-color: transparent; } .card img { width: 100%; height: 100%; object-fit: cover; display: block; } +.card .thumb-video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + pointer-events: none; +} +.card .thumb-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5rem; + color: var(--muted); + background: linear-gradient(135deg, #12151c 0%, #0b0d12 100%); +} +.card .thumb-video-badge { + position: absolute; + left: 8px; + bottom: 8px; + z-index: 1; + padding: 2px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + color: #fff; + background: rgba(0, 0, 0, 0.65); + pointer-events: none; +} .card .meta { font-size: 12px; color: var(--muted); display: flex; align-items: center; justify-content: space-between; @@ -295,15 +325,39 @@ lightbox.hidden, .lightbox.hidden { display: none !important; } position: relative; max-width: 96vw; max-height: 92vh; display: flex; flex-direction: column; gap: 8px; align-items: center; } +.lightbox .lb-viewport { + overflow: hidden; + max-width: 96vw; + max-height: 86vh; + display: flex; + align-items: center; + justify-content: center; + background: #000; + border-radius: 6px; + touch-action: none; +} +.lightbox .lb-viewport.lb-zoomed { + cursor: grab; +} +.lightbox .lb-viewport.lb-zoomed:active { + cursor: grabbing; +} .lightbox .lb-media { display: block; max-width: 96vw; max-height: 86vh; - width: 100%; + width: auto; height: auto; object-fit: contain; background: #000; border-radius: 6px; + transform-origin: center center; + transition: transform 0.05s linear; + user-select: none; +} +.lightbox .lb-viewport video.lb-media { + width: 100%; + max-height: 86vh; } .lightbox .toolbar { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; diff --git a/scripts/install.sh b/scripts/install.sh index b4c3f4b..687a144 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -REPO="Amateur-God/StreamBooru" +REPO="Atlas-Commons/StreamBooru" PREFIX="/usr/local" DEST_DIR="/opt/streambooru" WRAPPER="${PREFIX}/bin/streambooru" diff --git a/scripts/test-server-smoke.js b/scripts/test-server-smoke.js index cd23bd5..0de9dd0 100644 --- a/scripts/test-server-smoke.js +++ b/scripts/test-server-smoke.js @@ -42,6 +42,15 @@ async function main() { if (r.status !== 400) throw new Error(`expected 400, got ${r.status}`); }); + await check('GET /mediaproxy rejects bad url', async () => { + const r = await fetchJson(`${base}/mediaproxy?url=https://evil.example/x.mp4`); + if (r.status === 404) { + console.log('skip: /mediaproxy not deployed on target server yet'); + return; + } + if (r.status !== 400) throw new Error(`expected 400, got ${r.status}`); + }); + await check('GET /api/me without token returns 401', async () => { const r = await fetchJson(`${base}/api/me`); if (r.status !== 401) throw new Error(`expected 401, got ${r.status}`); diff --git a/server/public/index.html b/server/public/index.html index b80574c..dc619dc 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -24,7 +24,7 @@

Open Web Browser

Download

Native apps for Linux, Windows, macOS, and Android with offline-friendly browsing.

- GitHub Releases + GitHub Releases
@@ -37,7 +37,7 @@

Sync API

diff --git a/server/src/index.js b/server/src/index.js index 6a8942d..30c26c3 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -12,6 +12,13 @@ const fs = require('fs'); const { query, pool } = require('./db'); const { enc, dec } = require('./crypto'); const { sanitizeSiteInput, sanitizeFavoriteKey, clampPost } = require('./sanitize'); +const { + isBooruHostAllowed, + isProxyAllowed, + refererFor, + refererHeadersFor, + BOORU_UA +} = require('./refererFor'); const app = express(); app.set('trust proxy', true); @@ -609,45 +616,9 @@ app.get('/api/stream', (req, res) => { send('hello', { ok: true, ts: Date.now() }); }); -/* ---------- Image proxy ---------- */ -const BOORU_UA = 'Mozilla/5.0 (Linux; Android 12; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119 Mobile Safari/537.36'; +/* ---------- Media proxy (images + video) ---------- */ -function isBooruHostAllowed(url) { - try { - const u = new URL(url); - const h = u.hostname.toLowerCase(); - const okProto = u.protocol === 'https:' || u.protocol === 'http:'; - const allow = - h.endsWith('donmai.us') || - h === 'files.yande.re' || h.endsWith('yande.re') || - h === 'konachan.com' || h === 'konachan.net' || - h.endsWith('e621.net') || h.endsWith('e926.net') || - h.endsWith('derpibooru.org') || h.endsWith('derpicdn.net') || - h.endsWith('gelbooru.com') || h.endsWith('safebooru.org') || - h.endsWith('rule34.xxx') || h.endsWith('realbooru.com') || h.endsWith('xbooru.com') || - h.endsWith('tbib.org') || h.endsWith('hypnohub.net'); - return okProto && allow; - } catch { return false; } -} - -function isProxyAllowed(url) { return isBooruHostAllowed(url); } -function refererFor(url) { - try { - const h = new URL(url).hostname.toLowerCase(); - if (h.endsWith('donmai.us')) return 'https://danbooru.donmai.us'; - if (h.endsWith('yande.re')) return 'https://yande.re'; - if (h.endsWith('konachan.com')) return 'https://konachan.com'; - if (h.endsWith('konachan.net')) return 'https://konachan.net'; - if (h.endsWith('hypnohub.net')) return 'https://hypnohub.net'; - if (h.endsWith('tbib.org')) return 'https://tbib.org'; - if (h.endsWith('gelbooru.com')) return 'https://gelbooru.com'; - if (h.endsWith('safebooru.org')) return 'https://safebooru.org'; - if (h.endsWith('e621.net') || h.endsWith('e926.net')) return 'https://e621.net'; - if (h.endsWith('derpicdn.net') || h.endsWith('derpibooru.org')) return 'https://derpibooru.org'; - return ''; - } catch { return ''; } -} -app.get('/imgproxy', async (req, res) => { +async function proxyMediaRequest(req, res, { download = false, filename = '' } = {}) { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Vary', 'Origin'); try { @@ -655,41 +626,15 @@ app.get('/imgproxy', async (req, res) => { if (!url || !isProxyAllowed(url)) return res.status(400).send('Bad url'); const refParam = String(req.query.ref || '').trim(); + const accept = String(req.query.accept || '').trim() || + 'image/avif,image/webp,image/apng,image/*,video/*,application/octet-stream,*/*;q=0.8'; const hdr = { - 'User-Agent': 'Mozilla/5.0 (Linux; Android 12; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119 Mobile Safari/537.36', - 'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8' + 'User-Agent': BOORU_UA, + Accept: accept, + ...refererHeadersFor(url, refParam) }; - let refFinal = ''; - if (refParam) { - try { - const u = new URL(refParam); - const h = u.hostname.toLowerCase(); - const allowRef = - h.endsWith('donmai.us') || - h.endsWith('yande.re') || - h.endsWith('konachan.com') || h.endsWith('konachan.net') || - h.endsWith('e621.net') || h.endsWith('e926.net') || - h.endsWith('derpibooru.org') || h.endsWith('derpicdn.net') || - h.endsWith('gelbooru.com') || h.endsWith('safebooru.org') || - h.endsWith('tbib.org') || h.endsWith('hypnohub.net') || - h.endsWith('rule34.xxx') || h.endsWith('realbooru.com') || h.endsWith('xbooru.com'); - if (allowRef) refFinal = u.toString(); - } catch {} - } - if (!refFinal) refFinal = refererFor(url); - - if (refFinal) { - try { - const o = new URL(refFinal); - hdr['Referer'] = refFinal; - hdr['Origin'] = `${o.protocol}//${o.host}`; - } catch { - hdr['Referer'] = refFinal; - } - } - const r = await fetch(url, { headers: hdr }); if (!r.ok) { res.status(r.status).end(`Upstream ${r.status}`); @@ -700,12 +645,25 @@ app.get('/imgproxy', async (req, res) => { res.setHeader('Content-Type', ct); res.setHeader('Cache-Control', 'public, max-age=86400'); + if (download) { + const safeName = String(filename || 'download').replace(/[<>:"/\\|?*\x00-\x1F]+/g, '_').slice(0, 200); + res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`); + } + if (r.body) Readable.fromWeb(r.body).pipe(res); else res.end(Buffer.from(await r.arrayBuffer())); } catch (e) { - console.error('imgproxy error', e); + console.error('mediaproxy error', e); res.status(500).end('proxy error'); } +} + +app.get('/imgproxy', (req, res) => proxyMediaRequest(req, res)); + +app.get('/mediaproxy', (req, res) => { + const download = String(req.query.download || '') === '1'; + const filename = String(req.query.filename || ''); + return proxyMediaRequest(req, res, { download, filename }); }); /* ---------- Booru API proxy (web browser CORS bypass) ---------- */ diff --git a/server/src/refererFor.js b/server/src/refererFor.js new file mode 100644 index 0000000..0dc173a --- /dev/null +++ b/server/src/refererFor.js @@ -0,0 +1,91 @@ +/** + * Shared Referer/Origin helpers for booru CDN hotlink requirements. + */ +function hostAllowed(hostname) { + const h = String(hostname || "").toLowerCase(); + return ( + h.endsWith("donmai.us") || + h === "files.yande.re" || h.endsWith("yande.re") || + h === "konachan.com" || h === "konachan.net" || + h.endsWith("e621.net") || h.endsWith("e926.net") || + h.endsWith("e621.media") || h.endsWith("e926.media") || + h.endsWith("derpibooru.org") || h.endsWith("derpicdn.net") || + h.endsWith("gelbooru.com") || h.endsWith("safebooru.org") || + h.endsWith("rule34.xxx") || h.endsWith("realbooru.com") || h.endsWith("xbooru.com") || + h.endsWith("tbib.org") || h.endsWith("hypnohub.net") + ); +} + +function isBooruHostAllowed(url) { + try { + const u = new URL(url); + const okProto = u.protocol === "https:" || u.protocol === "http:"; + return okProto && hostAllowed(u.hostname); + } catch { + return false; + } +} + +function isProxyAllowed(url) { + return isBooruHostAllowed(url); +} + +function refererFor(url) { + try { + const h = new URL(url).hostname.toLowerCase(); + if (h.endsWith("donmai.us")) return "https://danbooru.donmai.us"; + if (h.endsWith("yande.re") || h === "files.yande.re") return "https://yande.re"; + if (h.endsWith("konachan.com")) return "https://konachan.com"; + if (h.endsWith("konachan.net")) return "https://konachan.net"; + if (h.endsWith("hypnohub.net")) return "https://hypnohub.net"; + if (h.endsWith("tbib.org")) return "https://tbib.org"; + if (h.endsWith("gelbooru.com")) return "https://gelbooru.com"; + if (h.endsWith("safebooru.org")) return "https://safebooru.org"; + if (h.endsWith("rule34.xxx")) return "https://rule34.xxx"; + if (h.endsWith("realbooru.com")) return "https://realbooru.com"; + if (h.endsWith("xbooru.com")) return "https://xbooru.com"; + if (h.endsWith("e621.net") || h.endsWith("e621.media")) return "https://e621.net"; + if (h.endsWith("e926.net") || h.endsWith("e926.media")) return "https://e926.net"; + if (h.endsWith("derpicdn.net") || h.endsWith("derpibooru.org")) return "https://derpibooru.org"; + return ""; + } catch { + return ""; + } +} + +function refererHeadersFor(url, refOverride = "") { + let refFinal = ""; + if (refOverride) { + try { + const u = new URL(refOverride); + if (hostAllowed(u.hostname)) refFinal = u.toString(); + } catch { + /* ignore */ + } + } + if (!refFinal) refFinal = refererFor(url); + + const hdr = {}; + if (refFinal) { + try { + const o = new URL(refFinal); + hdr.Referer = refFinal; + hdr.Origin = `${o.protocol}//${o.host}`; + } catch { + hdr.Referer = refFinal; + } + } + return hdr; +} + +const BOORU_UA = + "Mozilla/5.0 (Linux; Android 12; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119 Mobile Safari/537.36"; + +module.exports = { + hostAllowed, + isBooruHostAllowed, + isProxyAllowed, + refererFor, + refererHeadersFor, + BOORU_UA +}; diff --git a/src/adapters/base.js b/src/adapters/base.js index dcd7ac3..700f1ba 100644 --- a/src/adapters/base.js +++ b/src/adapters/base.js @@ -19,6 +19,25 @@ function abs(baseUrl, url) { return url; } +function isVideoUrl(url) { + if (!url) return false; + try { + const path = new URL(url, 'https://x/').pathname.toLowerCase(); + return /\.(mp4|webm|mov|m4v)$/i.test(path); + } catch { + return /\.(mp4|webm|mov|m4v)$/i.test(String(url).toLowerCase()); + } +} + +/** First URL that is not a video file (safe for <img> thumbnails). */ +function pickStaticImageUrl(...urls) { + for (const u of urls) { + const s = String(u || '').trim(); + if (s && !isVideoUrl(s)) return s; + } + return ''; +} + function ratingToTag(rating) { switch ((rating || '').toLowerCase()) { case 'safe': return 'rating:safe'; @@ -44,24 +63,30 @@ function buildQueryTags(site, ...extras) { function normalizePost({ id, created_at, score, favorites, preview_url, sample_url, file_url, - width, height, tags, rating, source, post_url, site + width, height, tags, rating, source, post_url, site, + grid_video_url, is_video }) { + const staticPreview = pickStaticImageUrl(preview_url, sample_url, file_url); + const sample = sample_url || file_url || ''; + const file = file_url || sample_url || preview_url || ''; return { id: String(id), created_at: toIsoDate(created_at) || null, score: typeof score === 'number' ? score : score ? Number(score) || 0 : 0, favorites: typeof favorites === 'number' ? favorites : favorites ? Number(favorites) || 0 : 0, - preview_url: preview_url || sample_url || file_url || '', - sample_url: sample_url || file_url || '', - file_url: file_url || sample_url || preview_url || '', + preview_url: staticPreview || '', + sample_url: sample, + file_url: file, width: width ? Number(width) : null, height: height ? Number(height) : null, tags: Array.isArray(tags) ? tags : typeof tags === 'string' ? tags.split(/\s+/).filter(Boolean) : [], rating: rating || '', source: source || '', post_url, - site + site, + grid_video_url: grid_video_url || '', + is_video: !!is_video }; } -module.exports = { normalizePost, toIsoDate, abs, buildQueryTags, ratingToTag }; +module.exports = { normalizePost, toIsoDate, abs, buildQueryTags, ratingToTag, isVideoUrl, pickStaticImageUrl }; diff --git a/src/adapters/derpibooru.js b/src/adapters/derpibooru.js index 41fbe9e..4f4ec72 100644 --- a/src/adapters/derpibooru.js +++ b/src/adapters/derpibooru.js @@ -1,4 +1,4 @@ -const { normalizePost, abs } = require('./base'); +const { normalizePost, abs, pickStaticImageUrl, isVideoUrl } = require('./base'); // Derpibooru adapter (API: /api/v1/json/search/images) class DerpibooruAdapter { @@ -24,7 +24,11 @@ class DerpibooruAdapter { const rep = img?.representations || {}; const file = rep.full || img?.view_url || ''; const sample = rep.large || rep.medium || file; - const preview = rep.thumb || rep.small || sample; + const previewPath = pickStaticImageUrl(rep.small, rep.medium, rep.large, rep.thumb) || pickStaticImageUrl(sample); + const isVideo = /^video\//.test(String(img?.mime_type || '')) || isVideoUrl(file); + const gridVideo = isVideo + ? (rep.thumb_small || rep.thumb || rep.small || file) + : ''; const tagsArr = Array.isArray(img?.tags) ? img.tags @@ -35,7 +39,7 @@ class DerpibooruAdapter { created_at: img.created_at, score: img.score ?? ((img.upvotes || 0) - (img.downvotes || 0)), favorites: img.faves ?? img.favorites ?? 0, - preview_url: abs(base, preview || ''), + preview_url: abs(base, previewPath || ''), sample_url: abs(base, sample || ''), file_url: abs(base, file || ''), width: img.width || null, @@ -46,7 +50,9 @@ class DerpibooruAdapter { : (Array.isArray(tagsArr) && tagsArr.includes('questionable') ? 'questionable' : 'safe'), source: Array.isArray(img?.source_url) ? (img.source_url[0] || '') : (img.source_url || ''), post_url: `${base}/images/${img.id}`, - site: { name: site.name, type: site.type, baseUrl: site.baseUrl } + site: { name: site.name, type: site.type, baseUrl: site.baseUrl }, + grid_video_url: abs(base, gridVideo || ''), + is_video: isVideo }); } diff --git a/src/adapters/e621.js b/src/adapters/e621.js index 8e1615c..d897742 100644 --- a/src/adapters/e621.js +++ b/src/adapters/e621.js @@ -1,4 +1,4 @@ -const { normalizePost, abs } = require('./base'); +const { normalizePost, abs, isVideoUrl } = require('./base'); // e621/e926 adapter class E621Adapter { @@ -28,6 +28,8 @@ class E621Adapter { const file = p?.file || {}; const sample = p?.sample || {}; const preview = p?.preview || {}; + const fileUrl = file.url || sample.url || ''; + const isVideo = /^video\//.test(String(file.ext || '')) || isVideoUrl(fileUrl); const src = Array.isArray(p?.sources) && p.sources.length > 0 ? p.sources[0] : p?.source || ''; return normalizePost({ id: p.id, @@ -43,7 +45,9 @@ class E621Adapter { rating: p.rating || '', source: src, post_url: `${base}/posts/${p.id}`, - site: { name: site.name, type: site.type, baseUrl: site.baseUrl } + site: { name: site.name, type: site.type, baseUrl: site.baseUrl }, + grid_video_url: isVideo ? abs(base, file.url || sample.url || '') : '', + is_video: isVideo }); } diff --git a/src/shared/refererFor.js b/src/shared/refererFor.js new file mode 100644 index 0000000..3efb197 --- /dev/null +++ b/src/shared/refererFor.js @@ -0,0 +1,80 @@ +/** + * Shared Referer/Origin helpers for booru CDN hotlink requirements. + */ +function hostAllowed(hostname) { + const h = String(hostname || "").toLowerCase(); + return ( + h.endsWith("donmai.us") || + h === "files.yande.re" || h.endsWith("yande.re") || + h === "konachan.com" || h === "konachan.net" || + h.endsWith("e621.net") || h.endsWith("e926.net") || + h.endsWith("derpibooru.org") || h.endsWith("derpicdn.net") || + h.endsWith("gelbooru.com") || h.endsWith("safebooru.org") || + h.endsWith("rule34.xxx") || h.endsWith("realbooru.com") || h.endsWith("xbooru.com") || + h.endsWith("tbib.org") || h.endsWith("hypnohub.net") + ); +} + +export function isBooruHostAllowed(url) { + try { + const u = new URL(url); + const okProto = u.protocol === "https:" || u.protocol === "http:"; + return okProto && hostAllowed(u.hostname); + } catch { + return false; + } +} + +export function isProxyAllowed(url) { + return isBooruHostAllowed(url); +} + +export function refererFor(url) { + try { + const h = new URL(url).hostname.toLowerCase(); + if (h.endsWith("donmai.us")) return "https://danbooru.donmai.us"; + if (h.endsWith("yande.re") || h === "files.yande.re") return "https://yande.re"; + if (h.endsWith("konachan.com")) return "https://konachan.com"; + if (h.endsWith("konachan.net")) return "https://konachan.net"; + if (h.endsWith("hypnohub.net")) return "https://hypnohub.net"; + if (h.endsWith("tbib.org")) return "https://tbib.org"; + if (h.endsWith("gelbooru.com")) return "https://gelbooru.com"; + if (h.endsWith("safebooru.org")) return "https://safebooru.org"; + if (h.endsWith("rule34.xxx")) return "https://rule34.xxx"; + if (h.endsWith("realbooru.com")) return "https://realbooru.com"; + if (h.endsWith("xbooru.com")) return "https://xbooru.com"; + if (h.endsWith("e621.net") || h.endsWith("e926.net")) return "https://e621.net"; + if (h.endsWith("derpicdn.net") || h.endsWith("derpibooru.org")) return "https://derpibooru.org"; + return ""; + } catch { + return ""; + } +} + +export function refererHeadersFor(url, refOverride = "") { + let refFinal = ""; + if (refOverride) { + try { + const u = new URL(refOverride); + if (hostAllowed(u.hostname)) refFinal = u.toString(); + } catch { + /* ignore */ + } + } + if (!refFinal) refFinal = refererFor(url); + + const hdr = {}; + if (refFinal) { + try { + const o = new URL(refFinal); + hdr.Referer = refFinal; + hdr.Origin = `${o.protocol}//${o.host}`; + } catch { + hdr.Referer = refFinal; + } + } + return hdr; +} + +export const BOORU_UA = + "Mozilla/5.0 (Linux; Android 12; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119 Mobile Safari/537.36";