Merge pull request #63 from TautvydasDerzinskas/develop

Release 1.0.2
This commit is contained in:
Tautvydas Derzinskas authored and GitHub committed 2026-10-18 04:46:13 +02:00
commit 4dfa62cc9e
62 files changed
+377 -61

No files matched your search

+10 -1
View File
@@ -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
+6
View File
@@ -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:
+4
View File
@@ -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:
+2
View File
@@ -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
+28 -2
View File
@@ -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
+9
View File
@@ -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
+3 -1
View File
@@ -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))
+2
View File
@@ -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
+1 -1
View File
@@ -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"
}
],
[
+148
View File
@@ -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",
+1
View File
@@ -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",
-1
View File
@@ -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";
+2 -2
View File
@@ -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.",
);
}
@@ -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<Buffer | null>
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);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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.",
);
}
+4 -2
View File
@@ -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<string, string>,
): Promise<unknown | null> {
const requestHeaders: Record<string, string> = { "User-Agent": IMPORT_USER_AGENT, Accept: "application/json" };
const requestHeaders: Record<string, string> = {
"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);
+9 -7
View File
@@ -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<string, string>): Promise<Response> {
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<string, string> = { "User-Agent": IMPORT_USER_AGENT, Accept: "*/*" };
const headers: Record<string, string> = { "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<Buffer | null> {
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<PlainDownloadResult> {
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.",
);
}
+6 -14
View File
@@ -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<Response> {
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
+1 -3
View File
@@ -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);
+49
View File
@@ -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<string, string>; timeoutMs?: number; signal?: AbortSignal; redirect?: "follow" | "manual" },
): Promise<Response> {
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);
}
}
+14
View File
@@ -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<typeof import("../src/utils/browserFetch")>()),
browserFetch: (
url: string,
{
headers,
signal,
redirect,
}: { headers?: Record<string, string>; signal?: AbortSignal; redirect?: RequestRedirect },
) => fetch(url, { headers, signal, redirect }),
}));
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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?
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 111 KiB

+4 -6
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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"
}
],
[
Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 KiB

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 KiB

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 KiB

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 944 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 579 KiB

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 958 KiB

After

Width:  |  Height:  |  Size: 911 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 KiB

After

Width:  |  Height:  |  Size: 556 KiB

+3 -3
View File
@@ -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.",
+4 -4
View File
@@ -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."
},
+53
View File
@@ -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) {