diff --git a/.github/workflows/backend-image.yml b/.github/workflows/backend-image.yml index 2a0d5ee..5676571 100644 --- a/.github/workflows/backend-image.yml +++ b/.github/workflows/backend-image.yml @@ -1,6 +1,8 @@ name: Backend image # Runs when backend/ or the build workflow changes. Use "Manual image publish" to force a build. +# Pull requests into develop only run the checks; the image build (~6 min, arm64 under QEMU) waits for the +# develop -> main pull request, which still builds it before anything is released. on: push: branches: [main] @@ -25,6 +27,8 @@ jobs: # The full backend suite against real Postgres, including end-to-end provider imports. The image # is only built and published if this passes. test: + # A main -> develop back-merge only brings release commits main already tested. + if: github.head_ref != 'main' runs-on: ubuntu-latest permissions: contents: read @@ -57,6 +61,9 @@ jobs: cache-dependency-path: backend/package-lock.json - run: npm ci --no-audit --no-fund - run: npx prisma generate + - run: npm run lint + # tsc: the typecheck the image build would otherwise be the only one to run. + - run: npm run build - run: npx prisma migrate deploy - run: npm test @@ -75,7 +82,9 @@ jobs: build: needs: [test, release] # Also when the release was skipped, as on pull requests; never after a failed one. - if: ${{ !cancelled() && needs.test.result == 'success' && needs.release.result != 'failure' }} + if: >- + ${{ !cancelled() && needs.test.result == 'success' && needs.release.result != 'failure' + && !(github.event_name == 'pull_request' && github.base_ref == 'develop') }} permissions: contents: read packages: write diff --git a/.github/workflows/bridge-release.yml b/.github/workflows/bridge-release.yml index 058c4dc..94e5968 100644 --- a/.github/workflows/bridge-release.yml +++ b/.github/workflows/bridge-release.yml @@ -21,6 +21,8 @@ concurrency: jobs: build-windows: + # A main -> develop back-merge only brings release commits main already tested. + if: github.head_ref != 'main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -44,6 +46,8 @@ jobs: if-no-files-found: error build-linux: + # A main -> develop back-merge only brings release commits main already tested. + if: github.head_ref != 'main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -67,6 +71,8 @@ jobs: if-no-files-found: error build-macos: + # A main -> develop back-merge only brings release commits main already tested. + if: github.head_ref != 'main' # macOS URL-scheme handling needs cgo and Cocoa, so it can't be cross-compiled. runs-on: macos-latest steps: diff --git a/.github/workflows/extension-release.yml b/.github/workflows/extension-release.yml index 1c59225..2b560b5 100644 --- a/.github/workflows/extension-release.yml +++ b/.github/workflows/extension-release.yml @@ -23,6 +23,8 @@ concurrency: jobs: build-chrome: + # A main -> develop back-merge only brings release commits main already tested. + if: github.head_ref != 'main' runs-on: ubuntu-latest defaults: run: @@ -53,6 +55,8 @@ jobs: if-no-files-found: error build-firefox: + # A main -> develop back-merge only brings release commits main already tested. + if: github.head_ref != 'main' runs-on: ubuntu-latest defaults: run: diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 8ab6af9..73479f3 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -11,6 +11,8 @@ permissions: jobs: check: + # A main -> develop back-merge only brings release commits main already tested. + if: github.head_ref != 'main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/frontend-image.yml b/.github/workflows/frontend-image.yml index 0ad326d..d2baf36 100644 --- a/.github/workflows/frontend-image.yml +++ b/.github/workflows/frontend-image.yml @@ -1,6 +1,8 @@ name: Frontend image # Runs when frontend/ or the build workflow changes. Use "Manual image publish" to force a build. +# Pull requests into develop only run the checks; the image build waits for the develop -> main pull request, +# which still builds it before anything is released. on: push: branches: [main] @@ -22,8 +24,30 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # Typecheck, lint, tests and a production build, natively. The image build skips the Dockerfile's test + # stage, so this is the only place the frontend tests run. + verify: + # A main -> develop back-merge only brings release commits main already tested. + if: github.head_ref != 'main' + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci --no-audit --no-fund + - run: npm run verify + # A merge into main may give the frontend a new version (release-project.yml); the image is built from it. release: + needs: verify if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: contents: write @@ -34,9 +58,11 @@ jobs: secrets: inherit build: - needs: release + needs: [verify, release] # Also when the release was skipped, as on pull requests; never after a failed one. - if: ${{ !cancelled() && needs.release.result != 'failure' }} + if: >- + ${{ !cancelled() && needs.verify.result == 'success' && needs.release.result != 'failure' + && !(github.event_name == 'pull_request' && github.base_ref == 'develop') }} permissions: contents: read packages: write diff --git a/.github/workflows/manual-image.yml b/.github/workflows/manual-image.yml index aec5996..987b538 100644 --- a/.github/workflows/manual-image.yml +++ b/.github/workflows/manual-image.yml @@ -13,6 +13,11 @@ on: - frontend - both default: both + version: + description: "Optional: an existing release (e.g. 1.0.1) to build from its git tag and tag X.Y.Z, X.Y and X" + required: false + type: string + default: "" jobs: backend: @@ -22,6 +27,8 @@ jobs: packages: write uses: ./.github/workflows/build-image.yml with: + ref: ${{ inputs.version && format('thingport-backend-v{0}', inputs.version) || '' }} + version: ${{ inputs.version }} project_dir: backend image_name: ghcr.io/${{ github.repository_owner }}/thingport-backend context: ./backend @@ -37,6 +44,8 @@ jobs: packages: write uses: ./.github/workflows/build-image.yml with: + ref: ${{ inputs.version && format('thingport-frontend-v{0}', inputs.version) || '' }} + version: ${{ inputs.version }} project_dir: frontend image_name: ghcr.io/${{ github.repository_owner }}/thingport-frontend context: ./frontend diff --git a/.github/workflows/release-project.yml b/.github/workflows/release-project.yml index aee4e81..a079a3b 100644 --- a/.github/workflows/release-project.yml +++ b/.github/workflows/release-project.yml @@ -74,7 +74,9 @@ jobs: break fi echo "main moved during the release; trying again." - elif [ "$attempt" = 3 ]; then + # Only a push that lost the race to the other project is worth retrying. Anything else may have + # failed after tagging, and a retry would then find nothing to release and pass without a version. + elif [ "$attempt" = 3 ] || [ "$(git ls-remote origin refs/heads/main | cut -f1)" = "$started" ]; then exit 1 fi sleep $((attempt * 10)) diff --git a/.github/workflows/web-pages.yml b/.github/workflows/web-pages.yml index 3e5c6db..397bfbc 100644 --- a/.github/workflows/web-pages.yml +++ b/.github/workflows/web-pages.yml @@ -52,6 +52,8 @@ env: jobs: build: + # A main -> develop back-merge only brings release commits main already tested. + if: github.head_ref != 'main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/backend/.releaserc.json b/backend/.releaserc.json index 8e06511..7e6d88e 100644 --- a/backend/.releaserc.json +++ b/backend/.releaserc.json @@ -21,7 +21,7 @@ [ "@semantic-release/exec", { - "publishCmd": "cat > \"${RUNNER_TEMP:-/tmp}/release-notes.md\" <<'SEMANTIC_RELEASE_NOTES'\n${nextRelease.notes}\nSEMANTIC_RELEASE_NOTES\nif [ -n \"$GITHUB_OUTPUT\" ]; then echo \"version=${nextRelease.version}\" >> \"$GITHUB_OUTPUT\"; echo \"tag=${nextRelease.gitTag}\" >> \"$GITHUB_OUTPUT\"; fi" + "publishCmd": "cat > \"$RUNNER_TEMP/release-notes.md\" <<'SEMANTIC_RELEASE_NOTES'\n${nextRelease.notes}\nSEMANTIC_RELEASE_NOTES\nif [ -n \"$GITHUB_OUTPUT\" ]; then echo \"version=${nextRelease.version}\" >> \"$GITHUB_OUTPUT\"; echo \"tag=${nextRelease.gitTag}\" >> \"$GITHUB_OUTPUT\"; fi" } ], [ diff --git a/backend/package-lock.json b/backend/package-lock.json index 8df625f..93406a2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "cors": "^2.8.6", "dotenv": "^18.0.8", "express": "^4.22.3", + "impit": "0.14.6", "jsonwebtoken": "^9.0.3", "jszip": "^3.10.2", "meshoptimizer": "^1.3.0", @@ -3148,6 +3149,153 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/impit": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/impit/-/impit-0.14.6.tgz", + "integrity": "sha512-1i5DxW0eRIUCtX9x+ZCSzD+FlRiEdrDOXazg0DYNMKUgnVwRWEwRhbA23Lpo2WOC674wZpwTQUubBi5z7m04Ng==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "impit-darwin-arm64": "0.14.6", + "impit-darwin-x64": "0.14.6", + "impit-linux-arm64-gnu": "0.14.6", + "impit-linux-arm64-musl": "0.14.6", + "impit-linux-x64-gnu": "0.14.6", + "impit-linux-x64-musl": "0.14.6", + "impit-win32-arm64-msvc": "0.14.6", + "impit-win32-x64-msvc": "0.14.6" + } + }, + "node_modules/impit-darwin-arm64": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/impit-darwin-arm64/-/impit-darwin-arm64-0.14.6.tgz", + "integrity": "sha512-QhEENTXA727SlgIIIfPg4Ij2FEgwX/3rKCBpR84JxZZfQVVNO/KB2cVvt9aupokY1NZIo6oBFYJXF5PKyRVKbQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-darwin-x64": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/impit-darwin-x64/-/impit-darwin-x64-0.14.6.tgz", + "integrity": "sha512-BYcBdG7+smXHrhwLGqtWWw1jTsQSWwJO7ZyfkVPMUGRcbPFJAXXKRUjdFWcp2AdMWyI5JufNF3IZzAJ6B++8mw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-arm64-gnu": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/impit-linux-arm64-gnu/-/impit-linux-arm64-gnu-0.14.6.tgz", + "integrity": "sha512-yoUxNCBDsQ72H7uTtaLp7hUQJxr1/OtIZ7KnaerFeJwPpuK2KCeAIXa/9ajoa0GZ6BErm9ZwkggOENhAXu0xxw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-arm64-musl": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/impit-linux-arm64-musl/-/impit-linux-arm64-musl-0.14.6.tgz", + "integrity": "sha512-2UL2ya3uRCdcZwkTL4Oe0RVyhvsONJHhbtD+0VZZkDKyCxyyjtZ6Rc1QQNKh2q/xKa3vNnzj/9qcu0mj/zCk8w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-x64-gnu": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/impit-linux-x64-gnu/-/impit-linux-x64-gnu-0.14.6.tgz", + "integrity": "sha512-6TJxs/iUQbxQuznb0gz5MQ15WIT4/IxHdwNG9II8kEKUEzVMLCun8IXiT5AGdYzwlmPYSIuxDjtY2ZALhC1FlA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-x64-musl": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/impit-linux-x64-musl/-/impit-linux-x64-musl-0.14.6.tgz", + "integrity": "sha512-AoneCmxaKKCx2wJZGLtFB6LrkTZUEViZ9XEsocpyMsm5IvV3mhj64AGIZI7zHlW7AFXq4MW0DPZkHiL/KxTB1A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-win32-arm64-msvc": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/impit-win32-arm64-msvc/-/impit-win32-arm64-msvc-0.14.6.tgz", + "integrity": "sha512-DRsyV3vNPeUTaRjtFiRqHb8FVFmLDR3NB47cvb+AKKuo+KUbRJdJR1zbDQMD2SOKhYRHf1PTxzamRIkl3yWqWQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-win32-x64-msvc": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/impit-win32-x64-msvc/-/impit-win32-x64-msvc-0.14.6.tgz", + "integrity": "sha512-0j7OHcgX8ui9ObSelx5v7OEEIfe00EgHcljq9RFR7oFzkD2ABS/6+fSyrXgu2LcC277vJ9oSfKjEacgGoCHOpQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", diff --git a/backend/package.json b/backend/package.json index f8e5d83..ee59409 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "cors": "^2.8.6", "dotenv": "^18.0.8", "express": "^4.22.3", + "impit": "0.14.6", "jsonwebtoken": "^9.0.3", "jszip": "^3.10.2", "meshoptimizer": "^1.3.0", diff --git a/backend/src/config.ts b/backend/src/config.ts index 0a553f1..f4fe24c 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -127,7 +127,6 @@ export const COLLECTION_SYNC_ENABLED = (process.env.COLLECTION_SYNC_ENABLED ?? " export const COLLECTION_SYNC_ITEM_DELAY_MS = envInt("COLLECTION_SYNC_ITEM_DELAY_MS", 60_000); // Gap between a single import's preview-image fetches (up to ~20 per model). export const IMPORT_PREVIEW_IMAGE_DELAY_MS = envInt("IMPORT_PREVIEW_IMAGE_DELAY_MS", 250); -export const IMPORT_USER_AGENT = "Thingport/1.0"; export const IMPORT_BROWSER_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"; diff --git a/backend/src/routes/imports.ts b/backend/src/routes/imports.ts index ebcc0db..43d00a9 100644 --- a/backend/src/routes/imports.ts +++ b/backend/src/routes/imports.ts @@ -291,7 +291,7 @@ router.post( if (!accessToken) { throw new HttpError( 503, - "Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Admin Settings.", + "Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Administration > Providers.", ); } @@ -329,7 +329,7 @@ router.post( if (!accessToken) { throw new HttpError( 503, - "Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Admin Settings.", + "Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Administration > Providers.", ); } diff --git a/backend/src/services/descriptionImageService.ts b/backend/src/services/descriptionImageService.ts index 4797e3f..e1b25fe 100644 --- a/backend/src/services/descriptionImageService.ts +++ b/backend/src/services/descriptionImageService.ts @@ -1,10 +1,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import sharp from "sharp"; -import { DESCRIPTION_IMAGES, IMPORT_TIMEOUT_SECONDS, IMPORT_USER_AGENT } from "../config"; +import { DESCRIPTION_IMAGES, IMPORT_BROWSER_USER_AGENT, IMPORT_TIMEOUT_SECONDS } from "../config"; import { prisma } from "../db"; import { mapWithConcurrency } from "../utils/concurrency"; import { validateRemoteUrl } from "../utils/urlUtils"; +import { browserFetch, needsBrowserFetch } from "../utils/browserFetch"; import { readCapped } from "../utils/readCapped"; const IMAGE_MAX_BYTES = 16 * 1024 * 1024; @@ -47,11 +48,10 @@ async function fetchImage(url: string, deadline: number): Promise let current = url; for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { await validateRemoteUrl(current); - const res = await fetch(current, { - headers: { "User-Agent": IMPORT_USER_AGENT, Accept: "image/*" }, - redirect: "manual", - signal: controller.signal, - }); + const headers = { "User-Agent": IMPORT_BROWSER_USER_AGENT, Accept: "image/*" }; + const res = needsBrowserFetch(current) + ? await browserFetch(current, { headers, redirect: "manual", signal: controller.signal }) + : await fetch(current, { headers, redirect: "manual", signal: controller.signal }); const location = res.headers.get("location"); if (res.status >= 300 && res.status < 400 && location) { await res.body?.cancel().catch(() => undefined); diff --git a/backend/src/services/fillGapsService.ts b/backend/src/services/fillGapsService.ts index 60e6ae2..f2773ec 100644 --- a/backend/src/services/fillGapsService.ts +++ b/backend/src/services/fillGapsService.ts @@ -54,7 +54,7 @@ export async function fetchSourceMeta(userId: string, print: Print, sourceUrl: s if (!accessToken) { throw new HttpError( 503, - "Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Admin Settings.", + "Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Administration > Providers.", ); } const resolved = await resolveThingiverseThing(externalId, accessToken); diff --git a/backend/src/services/importJobRunner.ts b/backend/src/services/importJobRunner.ts index fc8c051..1a4b21e 100644 --- a/backend/src/services/importJobRunner.ts +++ b/backend/src/services/importJobRunner.ts @@ -392,7 +392,7 @@ async function runThingiverseThingsImportJob( if (!accessToken) { throw new HttpError( 503, - "Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Admin Settings.", + "Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Administration > Providers.", ); } diff --git a/backend/src/services/importResolvers.ts b/backend/src/services/importResolvers.ts index f8bfd10..70b86ad 100644 --- a/backend/src/services/importResolvers.ts +++ b/backend/src/services/importResolvers.ts @@ -7,7 +7,6 @@ import { IMPORT_EXT_PRIORITY, IMPORT_HTML_MAX_BYTES, IMPORT_TIMEOUT_SECONDS, - IMPORT_USER_AGENT, } from "../config"; import { isJsonContentType } from "../utils/fileUtils"; import { htmlToMarkdown } from "./descriptionMarkdown"; @@ -171,7 +170,10 @@ async function fetchJsonFromUrl( referer?: string | null, headers?: Record, ): Promise { - const requestHeaders: Record = { "User-Agent": IMPORT_USER_AGENT, Accept: "application/json" }; + const requestHeaders: Record = { + "User-Agent": IMPORT_BROWSER_USER_AGENT, + Accept: "application/json", + }; if (referer) requestHeaders.Referer = referer; if (headers) Object.assign(requestHeaders, headers); const result = await fetchCappedBuffer(url, requestHeaders); diff --git a/backend/src/services/importService.ts b/backend/src/services/importService.ts index 9395bfd..ddf5d6b 100644 --- a/backend/src/services/importService.ts +++ b/backend/src/services/importService.ts @@ -5,12 +5,12 @@ import path from "node:path"; import crypto from "node:crypto"; import { IMPORT_ALLOWED_EXTS, + IMPORT_BROWSER_USER_AGENT, IMPORT_DOWNLOAD_RETRY_DELAY_MS, IMPORT_HTML_MAX_BYTES, IMPORT_MAX_BYTES, IMPORT_PREVIEW_IMAGE_DELAY_MS, IMPORT_TIMEOUT_SECONDS, - IMPORT_USER_AGENT, } from "../config"; import { HttpError, @@ -22,6 +22,7 @@ import { } from "../utils/fileUtils"; import { validateRemoteUrl } from "../utils/urlUtils"; import { readCapped } from "../utils/readCapped"; +import { browserFetch, needsBrowserFetch } from "../utils/browserFetch"; import { mapWithConcurrency, maybeSleep, sleep } from "../utils/concurrency"; import { CLOUDFLARE_BLOCKED_CODE, @@ -118,6 +119,7 @@ function parseCharset(contentType: string | null): string { } async function rawFetch(url: string, headers: Record): Promise { + if (needsBrowserFetch(url)) return browserFetch(url, { headers, timeoutMs: IMPORT_TIMEOUT_SECONDS * 1000 }); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), IMPORT_TIMEOUT_SECONDS * 1000); try { @@ -186,7 +188,7 @@ async function fetchMakerworldPageDesign( let res: Response; try { res = await fetchWithGuard(url, { - "User-Agent": IMPORT_USER_AGENT, + "User-Agent": IMPORT_BROWSER_USER_AGENT, Accept: "*/*", ...makerworldHtmlHeaders(url, cookie), }); @@ -223,7 +225,7 @@ export async function fetchMakerworldPageAuthor( let res: Response; try { res = await fetchWithGuard(url, { - "User-Agent": IMPORT_USER_AGENT, + "User-Agent": IMPORT_BROWSER_USER_AGENT, Accept: "*/*", ...makerworldHtmlHeaders(url, cookie), }); @@ -331,7 +333,7 @@ export async function openImportResponse( } } - const headers: Record = { "User-Agent": IMPORT_USER_AGENT, Accept: "*/*" }; + const headers: Record = { "User-Agent": IMPORT_BROWSER_USER_AGENT, Accept: "*/*" }; let makerworldCookie: string | null = null; if (host.endsWith("makerworld.com")) { makerworldCookie = resolveMakerworldCookie(body); @@ -491,7 +493,7 @@ const PREVIEW_IMAGE_MAX_COUNT = 20; async function fetchImageBytes(url: string): Promise { try { - const res = await rawFetch(url, { "User-Agent": IMPORT_USER_AGENT, Accept: "image/*" }); + const res = await rawFetch(url, { "User-Agent": IMPORT_BROWSER_USER_AGENT, Accept: "image/*" }); if (!res.ok) { await res.body?.cancel().catch(() => undefined); return null; @@ -751,7 +753,7 @@ function isDownloadBlocked(res: Response): boolean { * which is retried once and then reported so the caller can say why. */ export async function downloadPlainFileToTemp(url: string, suggestedName: string): Promise { try { - const headers = { "User-Agent": IMPORT_USER_AGENT, Accept: "*/*" }; + const headers = { "User-Agent": IMPORT_BROWSER_USER_AGENT, Accept: "*/*" }; let res = await rawFetch(url, headers); if (isDownloadBlocked(res)) { await res.body?.cancel().catch(() => undefined); @@ -794,7 +796,7 @@ async function importThingiverseThing( if (!accessToken) { throw new HttpError( 503, - "Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Admin Settings.", + "Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Administration > Providers.", ); } diff --git a/backend/src/services/thingiverseApi.ts b/backend/src/services/thingiverseApi.ts index a6b1dfb..7f8a4a4 100644 --- a/backend/src/services/thingiverseApi.ts +++ b/backend/src/services/thingiverseApi.ts @@ -1,4 +1,5 @@ -import { IMPORT_BROWSER_USER_AGENT, IMPORT_TIMEOUT_SECONDS } from "../config"; +import { IMPORT_TIMEOUT_SECONDS } from "../config"; +import { browserFetch } from "../utils/browserFetch"; import type { ImportedAuthorInfo, ImportedPageMetadata } from "./importResolvers"; import { cleanThingiverseMarkdown } from "./descriptionMarkdown"; import type { ProviderCollectionCard } from "./providerCollectionCard"; @@ -27,16 +28,16 @@ export class ThingiverseAuthError extends Error { constructor() { super( "The Thingiverse App Token configured for this instance was rejected. Ask an admin to " + - "update it in Admin Settings (a new one can be generated at thingiverse.com/apps/create).", + "update it in Administration > Providers (a new one can be generated at thingiverse.com/apps/create).", ); this.name = "ThingiverseAuthError"; } } const FLARESOLVERR_HINTS = { - missing: " Setting up FlareSolverr in Admin Settings > Connections gets past it.", + missing: " Setting up FlareSolverr in Administration > Connections gets past it.", failed: - " FlareSolverr couldn't get past it either: it may be down or unreachable. Check it in Admin Settings > Connections.", + " FlareSolverr couldn't get past it either: it may be down or unreachable. Check it in Administration > Connections.", }; /** Cloudflare's 429 challenge, distinct from a 404 or rejected token: the fix is to wait, or a working FlareSolverr. */ @@ -80,16 +81,7 @@ export function parseThingiverseLikesUrl(url: string): { username: string } | nu } async function rawApiFetch(url: string): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS); - try { - return await fetch(url, { - headers: { "User-Agent": IMPORT_BROWSER_USER_AGENT, Accept: "application/json" }, - signal: controller.signal, - }); - } finally { - clearTimeout(timeout); - } + return browserFetch(url, { headers: { Accept: "application/json" }, timeoutMs: API_TIMEOUT_MS }); } /** Once a challenge is seen, calls go straight through FlareSolverr for a while. JSON API calls diff --git a/backend/src/services/weatherService.ts b/backend/src/services/weatherService.ts index 89cbaf3..eae7285 100644 --- a/backend/src/services/weatherService.ts +++ b/backend/src/services/weatherService.ts @@ -1,5 +1,3 @@ -import { IMPORT_USER_AGENT } from "../config"; - // The dashboard's weather, through the server like every other outside service: one place to // cache, rate-limit and identify ourselves. Open-Meteo gives the forecast and place search, // OpenStreetMap's Nominatim names a detected location. @@ -23,7 +21,7 @@ const SEARCH_TTL_MS = 24 * 60 * 60 * 1000; const CACHE_LIMIT = 500; // Nominatim's usage policy: at most one request a second, with an identifying User-Agent. const NOMINATIM_INTERVAL_MS = 1100; -const USER_AGENT = `${IMPORT_USER_AGENT} (self-hosted; +https://github.com/TautvydasDerzinskas/Thingport)`; +const USER_AGENT = "Thingport/1.0 (self-hosted; +https://github.com/TautvydasDerzinskas/Thingport)"; let fetchImpl: typeof fetch = (...args) => fetch(...args); diff --git a/backend/src/utils/browserFetch.ts b/backend/src/utils/browserFetch.ts new file mode 100644 index 0000000..b7c6800 --- /dev/null +++ b/backend/src/utils/browserFetch.ts @@ -0,0 +1,49 @@ +import { Impit } from "impit"; + +// Thingiverse's Cloudflare challenges clients by their TLS handshake, whatever the headers or +// cookies, and Node's fetch is on its list. impit sends Chrome's handshake and headers. +const following = new Impit({ browser: "chrome", timeout: null }); +const manual = new Impit({ browser: "chrome", timeout: null, followRedirects: false }); + +const NULL_BODY_STATUSES = new Set([204, 205, 304]); + +/** Hosts that challenge Node's own fetch, so every request to them goes through browserFetch. */ +export function needsBrowserFetch(url: string): boolean { + try { + const host = new URL(url).hostname.toLowerCase(); + return host === "thingiverse.com" || host.endsWith(".thingiverse.com"); + } catch { + return false; + } +} + +/** fetch with Chrome's TLS fingerprint. `timeoutMs` covers waiting for the response, not reading + * the body. The caller's User-Agent is dropped: one that doesn't match the handshake is itself a + * bot signal. */ +export async function browserFetch( + url: string, + { + headers = {}, + timeoutMs, + signal, + redirect = "follow", + }: { headers?: Record; timeoutMs?: number; signal?: AbortSignal; redirect?: "follow" | "manual" }, +): Promise { + const sent = Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "user-agent")); + const controller = new AbortController(); + const onAbort = () => controller.abort(); + signal?.addEventListener("abort", onAbort); + const timeout = timeoutMs ? setTimeout(onAbort, timeoutMs) : undefined; + try { + const client = redirect === "follow" ? following : manual; + const res = await client.fetch(url, { headers: sent, signal: controller.signal }); + // A standard Response, so callers and helpers typed for fetch's take it as is. + const body = NULL_BODY_STATUSES.has(res.status) ? null : res.body; + const out = new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers }); + Object.defineProperty(out, "url", { value: res.url || url }); + return out; + } finally { + // The caller's signal stays wired: like fetch's, it also cuts off reading the body. + clearTimeout(timeout); + } +} diff --git a/backend/tests/setup.ts b/backend/tests/setup.ts index 544bb74..458514b 100644 --- a/backend/tests/setup.ts +++ b/backend/tests/setup.ts @@ -1,5 +1,6 @@ import type { Server } from "node:http"; import { Test } from "supertest"; +import { vi } from "vitest"; // supertest's request(app) listens on port 0 on every interface, then connects to 127.0.0.1 on the // port it got. On macOS a wildcard bind succeeds even when another program already holds that port @@ -45,3 +46,16 @@ proto.end = function (fn) { }); return this; }; + +// Tests mock global.fetch; send browserFetch through it too, read at call time so each test's mock applies. +vi.mock("../src/utils/browserFetch", async (importOriginal) => ({ + ...(await importOriginal()), + browserFetch: ( + url: string, + { + headers, + signal, + redirect, + }: { headers?: Record; signal?: AbortSignal; redirect?: RequestRedirect }, + ) => fetch(url, { headers, signal, redirect }), +})); diff --git a/docs/guides/consume-folder.md b/docs/guides/consume-folder.md index 37913c1..872e6f4 100644 --- a/docs/guides/consume-folder.md +++ b/docs/guides/consume-folder.md @@ -39,9 +39,9 @@ Open **Administration → Storage → Consume Folder**. If the volume is mounted - **Import into the library of** chooses whose library the models go to. It starts as the first admin account. On a shared server, switch it to the person whose library you're importing, then switch it back. - **Import mode** works like the choice you get when [uploading a folder](upload-folders.md): - - **Each model file is a separate model** (the default): every STL, 3MF, STEP or OBJ becomes a model, and images + - **Each file is a model** (the default): every STL, 3MF, STEP or OBJ becomes a model, and images become models too. - - **Each folder is one model**: a folder of model files becomes one model named after the folder, with its images as + - **Each folder is a model**: a folder of model files becomes one model named after the folder, with its images as photos and its other files attached. Click **Save consume settings**. You only need to do this again when you want to change something. diff --git a/docs/guides/existing-library.md b/docs/guides/existing-library.md index 9d59a15..a2c1ab6 100644 --- a/docs/guides/existing-library.md +++ b/docs/guides/existing-library.md @@ -13,7 +13,7 @@ all of them recreate your folders inside Thingport, so the structure you built s | [Consume folder](consume-folder.md) | Large libraries, NAS shares, or anything you add on a schedule | Copy files into a folder on the server | The browser uploads ask you how to import things as you go. The consume folder asks nothing: you choose its rules once -in the admin settings, and it imports whatever lands in it, without a browser tab open. +under **Administration → Storage → Consume Folder**, and it imports whatever lands in it, without a browser tab open. ## The one decision: what counts as a model? diff --git a/docs/guides/images/add-menu.png b/docs/guides/images/add-menu.png index b81f81a..9d6e56d 100644 Binary files a/docs/guides/images/add-menu.png and b/docs/guides/images/add-menu.png differ diff --git a/docs/guides/images/consume-settings.png b/docs/guides/images/consume-settings.png index a41cb97..01ac626 100644 Binary files a/docs/guides/images/consume-settings.png and b/docs/guides/images/consume-settings.png differ diff --git a/docs/install/unraid/README.md b/docs/install/unraid/README.md index 8382023..e106dc1 100644 --- a/docs/install/unraid/README.md +++ b/docs/install/unraid/README.md @@ -9,14 +9,12 @@ each other by container name -- the frontend's nginx config, for example, proxie straight to `http://backend:8000`. Unraid's Community Applications store only installs one container per template with no built-in way to wire multiple templates together on a shared network, so Thingport is **not searchable in the CA app store**. Instead, install it as a -Docker Compose stack, which Unraid supports natively (7.2+) or via a Community Applications -plugin. +Docker Compose stack. Unraid has no Compose support of its own, so this needs a plugin. -## 1. Get a Compose stack manager +## 1. Install the Compose Manager plugin -- **Unraid 7.2+**: the Docker tab has a built-in **Compose** section -- skip to step 2. -- **Older Unraid**: install **Compose Manager Plus** (or **Compose Manager**) from - Community Applications (Apps tab -> search "Compose Manager"). +Apps tab -> search **Compose Manager** -> install **Compose Manager** (or **Compose Manager +Plus**). It adds a **Compose** section to the Docker tab. ## 2. Create the stack diff --git a/docs/install/unraid/docker-compose.yml b/docs/install/unraid/docker-compose.yml index 8659c13..03e7b75 100644 --- a/docs/install/unraid/docker-compose.yml +++ b/docs/install/unraid/docker-compose.yml @@ -1,7 +1,7 @@ # Thingport for Unraid # -# Install with the "Compose Manager" (or "Compose Manager Plus") plugin from Community -# Applications, or with Unraid's built-in Docker > Compose tab on Unraid 7.2+: +# Unraid has no Compose support of its own: install the "Compose Manager" (or "Compose +# Manager Plus") plugin from Community Applications first, then: # 1. Docker tab -> Compose -> Add New Stack -> name it "thingport" # 2. Paste this file as the stack's docker-compose.yml # 3. Copy .env.example (next to this file) to the stack's .env and fill in the diff --git a/frontend/.releaserc.json b/frontend/.releaserc.json index dc4e6c6..2615e96 100644 --- a/frontend/.releaserc.json +++ b/frontend/.releaserc.json @@ -21,7 +21,7 @@ [ "@semantic-release/exec", { - "publishCmd": "cat > \"${RUNNER_TEMP:-/tmp}/release-notes.md\" <<'SEMANTIC_RELEASE_NOTES'\n${nextRelease.notes}\nSEMANTIC_RELEASE_NOTES\nif [ -n \"$GITHUB_OUTPUT\" ]; then echo \"version=${nextRelease.version}\" >> \"$GITHUB_OUTPUT\"; echo \"tag=${nextRelease.gitTag}\" >> \"$GITHUB_OUTPUT\"; fi" + "publishCmd": "cat > \"$RUNNER_TEMP/release-notes.md\" <<'SEMANTIC_RELEASE_NOTES'\n${nextRelease.notes}\nSEMANTIC_RELEASE_NOTES\nif [ -n \"$GITHUB_OUTPUT\" ]; then echo \"version=${nextRelease.version}\" >> \"$GITHUB_OUTPUT\"; echo \"tag=${nextRelease.gitTag}\" >> \"$GITHUB_OUTPUT\"; fi" } ], [ diff --git a/frontend/src/assets/screenshots/01_dashboard.png b/frontend/src/assets/screenshots/01_dashboard.png index 6961dd9..4901b92 100644 Binary files a/frontend/src/assets/screenshots/01_dashboard.png and b/frontend/src/assets/screenshots/01_dashboard.png differ diff --git a/frontend/src/assets/screenshots/02_models.png b/frontend/src/assets/screenshots/02_models.png index 0ff613f..ed789d4 100644 Binary files a/frontend/src/assets/screenshots/02_models.png and b/frontend/src/assets/screenshots/02_models.png differ diff --git a/frontend/src/assets/screenshots/03_model_details.png b/frontend/src/assets/screenshots/03_model_details.png index 3e03a8e..7dd4930 100644 Binary files a/frontend/src/assets/screenshots/03_model_details.png and b/frontend/src/assets/screenshots/03_model_details.png differ diff --git a/frontend/src/assets/screenshots/04_model_details_3d_preview.png b/frontend/src/assets/screenshots/04_model_details_3d_preview.png index 8346278..b1881ca 100644 Binary files a/frontend/src/assets/screenshots/04_model_details_3d_preview.png and b/frontend/src/assets/screenshots/04_model_details_3d_preview.png differ diff --git a/frontend/src/assets/screenshots/05_collections.png b/frontend/src/assets/screenshots/05_collections.png index 3fe2100..100e61b 100644 Binary files a/frontend/src/assets/screenshots/05_collections.png and b/frontend/src/assets/screenshots/05_collections.png differ diff --git a/frontend/src/assets/screenshots/06_tags.png b/frontend/src/assets/screenshots/06_tags.png index c502413..b07c7fa 100644 Binary files a/frontend/src/assets/screenshots/06_tags.png and b/frontend/src/assets/screenshots/06_tags.png differ diff --git a/frontend/src/assets/screenshots/08_my_models.png b/frontend/src/assets/screenshots/08_my_models.png index b68babf..9ea3459 100644 Binary files a/frontend/src/assets/screenshots/08_my_models.png and b/frontend/src/assets/screenshots/08_my_models.png differ diff --git a/frontend/src/assets/screenshots/09_dark_theme.png b/frontend/src/assets/screenshots/09_dark_theme.png index 9429e2d..e6bb016 100644 Binary files a/frontend/src/assets/screenshots/09_dark_theme.png and b/frontend/src/assets/screenshots/09_dark_theme.png differ diff --git a/frontend/src/assets/screenshots/10_profile.png b/frontend/src/assets/screenshots/10_profile.png index 1bb0a1b..915e128 100644 Binary files a/frontend/src/assets/screenshots/10_profile.png and b/frontend/src/assets/screenshots/10_profile.png differ diff --git a/frontend/src/assets/screenshots/11_configuration_providers.png b/frontend/src/assets/screenshots/11_configuration_providers.png index d1eef56..daaeb2d 100644 Binary files a/frontend/src/assets/screenshots/11_configuration_providers.png and b/frontend/src/assets/screenshots/11_configuration_providers.png differ diff --git a/frontend/src/assets/screenshots/12_collection_sync.png b/frontend/src/assets/screenshots/12_collection_sync.png index ae53b44..66c2c42 100644 Binary files a/frontend/src/assets/screenshots/12_collection_sync.png and b/frontend/src/assets/screenshots/12_collection_sync.png differ diff --git a/frontend/src/assets/screenshots/13_admin_overview.png b/frontend/src/assets/screenshots/13_admin_overview.png index 83a01e4..f948008 100644 Binary files a/frontend/src/assets/screenshots/13_admin_overview.png and b/frontend/src/assets/screenshots/13_admin_overview.png differ diff --git a/frontend/src/assets/screenshots/14_admin_providers.png b/frontend/src/assets/screenshots/14_admin_providers.png index f2d16c3..c8a4df1 100644 Binary files a/frontend/src/assets/screenshots/14_admin_providers.png and b/frontend/src/assets/screenshots/14_admin_providers.png differ diff --git a/frontend/src/assets/screenshots/15_admin_storage.png b/frontend/src/assets/screenshots/15_admin_storage.png index a8a8c6d..8d8d10c 100644 Binary files a/frontend/src/assets/screenshots/15_admin_storage.png and b/frontend/src/assets/screenshots/15_admin_storage.png differ diff --git a/frontend/src/assets/screenshots/16_admin_auth.png b/frontend/src/assets/screenshots/16_admin_auth.png index cb056ac..3487a81 100644 Binary files a/frontend/src/assets/screenshots/16_admin_auth.png and b/frontend/src/assets/screenshots/16_admin_auth.png differ diff --git a/frontend/src/assets/screenshots/17_admin_ai.png b/frontend/src/assets/screenshots/17_admin_ai.png index af3997f..d4dbfd0 100644 Binary files a/frontend/src/assets/screenshots/17_admin_ai.png and b/frontend/src/assets/screenshots/17_admin_ai.png differ diff --git a/frontend/src/assets/screenshots/18_admin_users.png b/frontend/src/assets/screenshots/18_admin_users.png index 4133e47..bdab68e 100644 Binary files a/frontend/src/assets/screenshots/18_admin_users.png and b/frontend/src/assets/screenshots/18_admin_users.png differ diff --git a/frontend/src/assets/screenshots/19_admin_logs.png b/frontend/src/assets/screenshots/19_admin_logs.png index f9d22c5..8925ea0 100644 Binary files a/frontend/src/assets/screenshots/19_admin_logs.png and b/frontend/src/assets/screenshots/19_admin_logs.png differ diff --git a/frontend/src/assets/screenshots/20_ai_add_model.png b/frontend/src/assets/screenshots/20_ai_add_model.png index 5cb2879..8cd67c4 100644 Binary files a/frontend/src/assets/screenshots/20_ai_add_model.png and b/frontend/src/assets/screenshots/20_ai_add_model.png differ diff --git a/frontend/src/assets/screenshots/21_ai_categorization_settings.png b/frontend/src/assets/screenshots/21_ai_categorization_settings.png index 6b68ffa..5c8f6aa 100644 Binary files a/frontend/src/assets/screenshots/21_ai_categorization_settings.png and b/frontend/src/assets/screenshots/21_ai_categorization_settings.png differ diff --git a/frontend/src/assets/screenshots/22_ai_suggestions.png b/frontend/src/assets/screenshots/22_ai_suggestions.png index d043166..0238fa5 100644 Binary files a/frontend/src/assets/screenshots/22_ai_suggestions.png and b/frontend/src/assets/screenshots/22_ai_suggestions.png differ diff --git a/frontend/src/assets/screenshots/24_oidc_provider_dialog.png b/frontend/src/assets/screenshots/24_oidc_provider_dialog.png index cb30949..1827bf0 100644 Binary files a/frontend/src/assets/screenshots/24_oidc_provider_dialog.png and b/frontend/src/assets/screenshots/24_oidc_provider_dialog.png differ diff --git a/frontend/src/assets/screenshots/25_sign_in_sso.png b/frontend/src/assets/screenshots/25_sign_in_sso.png index 21064b7..b3c2c65 100644 Binary files a/frontend/src/assets/screenshots/25_sign_in_sso.png and b/frontend/src/assets/screenshots/25_sign_in_sso.png differ diff --git a/frontend/src/assets/screenshots/26_profile_sign_in.png b/frontend/src/assets/screenshots/26_profile_sign_in.png index 67f6f59..12acf36 100644 Binary files a/frontend/src/assets/screenshots/26_profile_sign_in.png and b/frontend/src/assets/screenshots/26_profile_sign_in.png differ diff --git a/frontend/src/assets/screenshots/27_admin_import_queue.png b/frontend/src/assets/screenshots/27_admin_import_queue.png index de24eda..0dbd765 100644 Binary files a/frontend/src/assets/screenshots/27_admin_import_queue.png and b/frontend/src/assets/screenshots/27_admin_import_queue.png differ diff --git a/frontend/src/assets/screenshots/28_admin_connections.png b/frontend/src/assets/screenshots/28_admin_connections.png index b35bdeb..6c40294 100644 Binary files a/frontend/src/assets/screenshots/28_admin_connections.png and b/frontend/src/assets/screenshots/28_admin_connections.png differ diff --git a/frontend/src/assets/screenshots/29_admin_rendering.png b/frontend/src/assets/screenshots/29_admin_rendering.png index f9f7aeb..13a0fc4 100644 Binary files a/frontend/src/assets/screenshots/29_admin_rendering.png and b/frontend/src/assets/screenshots/29_admin_rendering.png differ diff --git a/frontend/src/assets/screenshots/30_admin_captcha.png b/frontend/src/assets/screenshots/30_admin_captcha.png index beaced2..ac2f546 100644 Binary files a/frontend/src/assets/screenshots/30_admin_captcha.png and b/frontend/src/assets/screenshots/30_admin_captcha.png differ diff --git a/frontend/src/assets/screenshots/31_admin_triggers.png b/frontend/src/assets/screenshots/31_admin_triggers.png index 10514bb..669f6be 100644 Binary files a/frontend/src/assets/screenshots/31_admin_triggers.png and b/frontend/src/assets/screenshots/31_admin_triggers.png differ diff --git a/frontend/src/assets/screenshots/32_system_health.png b/frontend/src/assets/screenshots/32_system_health.png index 14989de..2e21d3c 100644 Binary files a/frontend/src/assets/screenshots/32_system_health.png and b/frontend/src/assets/screenshots/32_system_health.png differ diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0d66dae..096c2e6 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -836,11 +836,11 @@ "linkAuthorsDone": "Linked {{linked}} model(s) to their author.", "linkAuthorsNotFound": "The author couldn't be found for {{count}} creator(s) — the model may have been removed from its site, or the site couldn't be reached.", "linkAuthorsProblems": { - "thingiverse_no_token": "Thingiverse models were skipped: add a Thingiverse App Token in Administration > Settings.", - "thingiverse_token_rejected": "Thingiverse models were skipped: Thingiverse rejected the App Token in Administration > Settings.", + "thingiverse_no_token": "Thingiverse models were skipped: add a Thingiverse App Token in Administration > Providers.", + "thingiverse_token_rejected": "Thingiverse models were skipped: Thingiverse rejected the App Token in Administration > Providers.", "thingiverse_rate_limited": "Thingiverse models were skipped: Thingiverse started blocking requests with a rate limit. It usually clears after a while — try again later.", "makerworld_captcha": "MakerWorld models were skipped: MakerWorld asked for a CAPTCHA, and blocks further requests for about 2 hours. Try again later.", - "makerworld_login_rejected": "MakerWorld models were skipped: MakerWorld rejected the saved MakerWorld login. Update it in Settings > Imports." + "makerworld_login_rejected": "MakerWorld models were skipped: MakerWorld rejected the saved MakerWorld login. Update it in Configuration > Providers." }, "refetchTitle": "Refetch model descriptions", "refetchDesc": "Replaces the description of every model a user imported from MakerWorld, Printables or Thingiverse with the current one from that site, with its formatting and images. Use it to bring models imported before formatted descriptions were supported up to date. Changes the user made to those descriptions are lost. Models whose source is gone keep their description. It runs in the background — MakerWorld requests are spaced out to avoid its CAPTCHA, so a large library can take a while.", diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index ab26923..b9203ef 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -844,11 +844,11 @@ "linkAuthorsDone": "Su autoriumi susieta modelių: {{linked}}.", "linkAuthorsNotFound": "Nepavyko rasti autoriaus kūrėjams: {{count}} – modelis galėjo būti pašalintas iš svetainės arba svetainė nepasiekiama.", "linkAuthorsProblems": { - "thingiverse_no_token": "Thingiverse modeliai praleisti: pridėkite Thingiverse programėlės raktą skiltyje Administravimas > Nustatymai.", - "thingiverse_token_rejected": "Thingiverse modeliai praleisti: Thingiverse atmetė programėlės raktą, nurodytą skiltyje Administravimas > Nustatymai.", + "thingiverse_no_token": "Thingiverse modeliai praleisti: pridėkite Thingiverse programėlės raktą skiltyje Administravimas > Tiekėjai.", + "thingiverse_token_rejected": "Thingiverse modeliai praleisti: Thingiverse atmetė programėlės raktą, nurodytą skiltyje Administravimas > Tiekėjai.", "thingiverse_rate_limited": "Thingiverse modeliai praleisti: Thingiverse pradėjo riboti užklausas. Paprastai tai praeina po kurio laiko — bandykite vėliau.", "makerworld_captcha": "MakerWorld modeliai praleisti: MakerWorld paprašė CAPTCHA ir maždaug 2 valandoms blokuoja tolesnes užklausas. Bandykite vėliau.", - "makerworld_login_rejected": "MakerWorld modeliai praleisti: MakerWorld atmetė išsaugotą MakerWorld prisijungimą. Atnaujinkite jį skiltyje Nustatymai > Importavimas." + "makerworld_login_rejected": "MakerWorld modeliai praleisti: MakerWorld atmetė išsaugotą MakerWorld prisijungimą. Atnaujinkite jį skiltyje Konfigūracija > Šaltiniai." }, "refetchTitle": "Iš naujo gauti modelių aprašymus", "refetchDesc": "Pakeičia kiekvieno naudotojo iš MakerWorld, Printables ar Thingiverse importuoto modelio aprašymą dabartiniu aprašymu iš tos svetainės, su formatavimu ir paveikslėliais. Naudokite tai modeliams, importuotiems prieš atsirandant formatuotų aprašymų palaikymui, atnaujinti. Naudotojo atlikti tų aprašymų pakeitimai bus prarasti. Modeliai, kurių šaltinio nebėra, išlaiko savo aprašymą. Vykdoma fone — MakerWorld užklausos atliekamos su pertraukomis, kad būtų išvengta CAPTCHA, todėl didelė biblioteka gali užtrukti.", @@ -2569,7 +2569,7 @@ "usernameFailed": "Nepavyko išsaugoti vartotojo vardo.", "openProviders": "Atidaryti tiekėjus", "notConnected": { - "makerworld": "Prijunkite MakerWorld per Konfigūracija > Tiekėjai, kad čia matytumėte savo kolekcijas. Pagal slapuką Thingport žino, kas jūs ten esate.", + "makerworld": "Prijunkite MakerWorld per Konfigūracija > Šaltiniai, kad čia matytumėte savo kolekcijas. Pagal slapuką Thingport žino, kas jūs ten esate.", "printables": "Printables šiuo metu nepasiekiamas.", "thingiverse": "Thingiverse šiame Thingport dar neprijungtas. Administratorius gali jį prijungti per Konfigūracija > Tiekėjai." }, diff --git a/regression/screenshots.mjs b/regression/screenshots.mjs index d779fe1..2bbed94 100644 --- a/regression/screenshots.mjs +++ b/regression/screenshots.mjs @@ -16,6 +16,8 @@ // // The account's theme and Categories/Folders choice are switched for the shots and restored afterwards. // +// The guide_* shots replace cut-outs in docs/guides/images; guide_consume_settings needs the consume folder mounted. +// // The administration, AI and single sign-on shots (13 onwards) need an admin account, at least one AI model with // both AI features on and some suggestions waiting, and an OIDC provider that's switched on and connected to the // account. Email addresses on the page are blurred. @@ -46,6 +48,7 @@ const collectionName = args.collection || process.env.SCREENSHOT_COLLECTION; const outDir = path.resolve( args.out || process.env.SCREENSHOT_OUT || path.join(here, "../frontend/src/assets/screenshots"), ); +const guideImagesDir = path.join(here, "../docs/guides/images"); const only = (args.only || process.env.SCREENSHOT_ONLY || "") .split(",") .map((s) => s.trim()) @@ -349,6 +352,56 @@ const SHOTS = [ await blurEmails(page); }, }, + // The guides' cut-outs, saved next to the guides rather than with the README shots. + { + name: "guide_consume_settings", + run: async (page) => { + // Shows "Watching /app/consume" only on an instance with the consume folder mounted. + await openAdmin(page, "/admin-storage"); + await blurEmails(page); + const save = page.getByRole("button", { name: "Save consume settings" }); + await save.scrollIntoViewIfNeeded(); + const clip = await page.evaluate(() => { + const heading = [...document.querySelectorAll("h1, h2, h3, h4, h5, h6")].find( + (el) => el.textContent?.trim() === "Consume Folder", + ); + const button = [...document.querySelectorAll("button")].find( + (el) => el.textContent?.trim() === "Save consume settings", + ); + const card = button?.closest(".MuiPaper-root") ?? button?.parentElement; + if (!heading || !card) return null; + const top = heading.getBoundingClientRect(); + const box = card.getBoundingClientRect(); + return { x: box.left - 16, y: top.top - 16, width: box.width + 32, height: box.bottom - top.top + 32 }; + }); + if (!clip) throw new Error("Consume Folder section not found"); + // Scrolled down, the floating back-to-top button lands in the cut-out. + await page.addStyleTag({ content: '[aria-label="Back to top"] { visibility: hidden !important; }' }); + await settle(page); + await page.screenshot({ path: path.join(guideImagesDir, "consume-settings.png"), clip }); + return { saved: true }; + }, + }, + { + name: "guide_add_menu", + run: async (page) => { + await page.goto("/"); + await settle(page, 0); + const add = page.getByRole("button", { name: "Add", exact: true }); + // Measured first: the open menu hides the rest of the page from role queries. + const button = await add.boundingBox(); + await add.click(); + const menu = page.getByRole("menu"); + await menu.waitFor(); + await page.waitForTimeout(400); + const list = await menu.boundingBox(); + const left = Math.min(button.x, list.x) - 16; + const right = Math.max(button.x + button.width, list.x + list.width) + 16; + const clip = { x: left, y: button.y - 16, width: right - left, height: list.y + list.height - button.y + 32 }; + await page.screenshot({ path: path.join(guideImagesDir, "add-menu.png"), clip }); + return { saved: true }; + }, + }, ]; function wanted(shot) {