diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..b1cefe49b --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,72 @@ +{ + "permissions": { + "allow": [ + "Bash(rg:--max-depth:*)", + "Bash(grep:*)", + "Bash(find:. -type f:*)", + "Bash(ls:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(wc:*)", + "Bash(file:*)", + "Bash(stat:*)", + "Bash(readlink:*)", + "Bash(tree:-L 2:*)", + "Bash(diff:*)", + "Bash(which:*)", + "Bash(du:*)", + "Bash(df:*)", + "Bash(env)", + "Bash(pwd)", + "Bash(git status:*)", + "Bash(git log:*)", + "Bash(git diff:*)", + "Bash(git show:*)", + "Bash(git branch --list:*)", + "Bash(git ls-files:*)", + "Bash(git remote -v)", + "Bash(git submodule status:*)", + "Bash(git merge-base:*)", + "Bash(git fetch:*)", + "Bash(gh pr view:*)", + "Bash(gh pr list:*)", + "Bash(gh pr diff:*)", + "Bash(gh issue view:*)", + "Bash(gh issue list:*)", + "Bash(gh repo view:*)", + "Bash(gh auth status:*)", + "Bash(make)", + "Bash(make build:*)", + "Bash(make test:*)", + "Bash(make test_c:*)", + "Bash(make test_py:*)", + "Bash(make check_dependencies:*)", + "Bash(make submodules:*)", + "Bash(make cppcheck:*)", + "Bash(npm ls:*)", + "Bash(npm view:*)", + "Bash(node --version)", + "Bash(curl -s http://127.0.0.1:80*)", + "Bash(curl -s -i http://127.0.0.1:80*)", + "Bash(curl -s http://127.0.0.1:8443*)", + "Bash(curl -s http://127.0.0.1:18080*)", + "Bash(curl -s -i http://127.0.0.1:18080*)", + "Bash(docker ps:*)", + "Bash(docker logs:*)" + ], + "deny": [ + "Bash(git push:*)", + "Bash(rm:*)", + "Bash(ssh:*)", + "Bash(scp:*)", + "Bash(sftp:*)", + "Bash(ssh-keygen:*)", + "Bash(ssh-copy-id:*)", + "Bash(rsync:*-e ssh*)", + "Bash(rsync:*--rsh=ssh*)", + "Bash(gh pr comment:*)", + "Bash(gh issue comment:*)", + "Bash(gh pr review:*)" + ] + } +} \ No newline at end of file diff --git a/.github/workflows/build_commit_web.yml b/.github/workflows/build_commit_web.yml index afe3adb35..e8a0a82cc 100644 --- a/.github/workflows/build_commit_web.yml +++ b/.github/workflows/build_commit_web.yml @@ -91,12 +91,12 @@ jobs: - name: Commit and push if changed run: | if [[ -n $(git status --porcelain) ]]; then - git add . - git commit -m "Update frontend (make web)" - git pull --rebase - git push + git add . + git commit -m "Update frontend (make web)" + git pull --no-recurse-submodules --no-tags --rebase + git push --no-recurse-submodules else - echo "No changes to commit and push." + echo "No changes to commit and push." fi - name: Get Last Commit Hash diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml index 5924f64cc..a309b74d0 100644 --- a/.github/workflows/build_release.yml +++ b/.github/workflows/build_release.yml @@ -9,7 +9,49 @@ on: pull_request: branches: [ "master", "develop" ] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type == 'branch' || github.event_name == 'pull_request' }} + jobs: + # runs next to the builds; if a test fails, the whole run is cancelled + tests: + runs-on: ubuntu-latest + permissions: + contents: read + actions: write # only for cancelling this run + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends gcc protobuf-c-compiler build-essential openssl faketime python3 python3-pytest curl ffmpeg + + # the tests start a server and check the web UI it serves + - name: Build web UI + run: | + make web + mkdir -p data/www/web + cp -r contrib/data/www/web/. data/www/web/ + + # generating the test certificates takes minutes, see tests/py/with_server.sh + - name: Cache test template + uses: actions/cache@v6 + with: + path: bin/test-template + key: test-template-${{ hashFiles('src/cert.c', 'include/cert.h') }} + + - name: Run all tests + run: make test + + - name: Cancel the run on failed tests + if: failure() + continue-on-error: true # PRs from forks only get a read-only token + env: + GH_TOKEN: ${{ github.token }} + run: gh run cancel ${{ github.run_id }} --repo "$GITHUB_REPOSITORY" + build-windows-amd64: runs-on: windows-latest outputs: @@ -49,7 +91,7 @@ jobs: appendix: ${{ steps.set_data2.outputs.appendix }} steps: - name: Install build dependencies - run: sudo apt-get install -y --no-install-recommends gcc protobuf-c-compiler build-essential + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends gcc protobuf-c-compiler build-essential - name: Get current datetime id: get-datetime run: echo "CURRENT_DATE=$(date +'%Y-%m-%d')" >> $GITHUB_ENV diff --git a/.github/workflows/publish_docker_matrix_all.yml b/.github/workflows/publish_docker_matrix_all.yml index 6527b341f..6edd0f056 100644 --- a/.github/workflows/publish_docker_matrix_all.yml +++ b/.github/workflows/publish_docker_matrix_all.yml @@ -14,11 +14,18 @@ on: tags: - tc_nightly* - tc_v*.*.* + # release candidates: images only (rc, rc0.7.1, ...), no release, never latest; cut from develop + - rc_v*.*.* pull_request: branches: - master - develop +concurrency: + # release candidates share one group: rc / rc0.x.y are moving tags, runs must not overlap + group: ${{ github.workflow }}-${{ startsWith(github.ref_name, 'rc_v') && 'release-candidates' || github.ref }} + cancel-in-progress: ${{ github.ref_type == 'branch' || github.event_name == 'pull_request' }} + jobs: test_commit_hash: runs-on: ubuntu-latest @@ -28,6 +35,17 @@ jobs: submodules: recursive ref: ${{ inputs.tcw_commit_hash }} + # a release candidate is cut from develop: refuse an rc_ tag whose commit is not reachable from it + - name: Check that the release candidate tag is on develop + if: ${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'rc_v') }} + run: | + git fetch --no-tags --unshallow origin develop || git fetch --no-tags origin develop + commit="$(git rev-parse HEAD)" + if ! git merge-base --is-ancestor "$commit" FETCH_HEAD; then + echo "::error::$GITHUB_REF_NAME points at $commit, which is not on develop. Release candidates are cut from develop." + exit 1 + fi + - name: Verify commit hash if: ${{ inputs.tcw_commit_hash != '' }} run: | @@ -48,7 +66,8 @@ jobs: platforms: '["linux/amd64", "linux/arm/v7", "linux/arm64/v8", "linux/ppc64le", "linux/s390x"]' primary_image: true error_platforms: '["linux/s390x"]' - noasan_fallback_platforms: '["linux/arm/v7", "linux/arm64/v8", "linux/ppc64le"]' + # arm64 builds with AddressSanitizer (native runner, see base workflow). + noasan_fallback_platforms: '["linux/arm/v7", "linux/ppc64le"]' tcw_commit_hash: ${{ inputs.tcw_commit_hash }} debian: needs: test_commit_hash @@ -59,7 +78,8 @@ jobs: platforms: '["linux/amd64", "linux/arm/v7", "linux/arm64/v8", "linux/i386", "linux/ppc64le", "linux/s390x"]' primary_image: false error_platforms: '["linux/s390x"]' - noasan_fallback_platforms: '["linux/arm/v7", "linux/arm64/v8", "linux/ppc64le"]' + # arm64 builds with AddressSanitizer (native runner, see base workflow). + noasan_fallback_platforms: '["linux/arm/v7", "linux/ppc64le"]' tcw_commit_hash: ${{ inputs.tcw_commit_hash }} alpine: needs: test_commit_hash @@ -79,9 +99,10 @@ jobs: with: system: "ubuntuAsanTest" dockerfile: "DockerfileUbuntu" - platforms: '["linux/arm/v7", "linux/arm64/v8", "linux/ppc64le"]' + # arm64 is promoted to the main build; these remain ASAN-broken under QEMU. + platforms: '["linux/arm/v7", "linux/ppc64le"]' primary_image: false - error_platforms: '["linux/arm/v7", "linux/arm64/v8", "linux/ppc64le"]' + error_platforms: '["linux/arm/v7", "linux/ppc64le"]' noasan_fallback_platforms: "[]" skip_empty_merge: true tcw_commit_hash: ${{ inputs.tcw_commit_hash }} @@ -91,9 +112,10 @@ jobs: with: system: "debianAsanTest" dockerfile: "DockerfileDebian" - platforms: '["linux/arm/v7", "linux/arm64/v8", "linux/ppc64le"]' + # arm64 is promoted to the main build; these remain ASAN-broken under QEMU. + platforms: '["linux/arm/v7", "linux/ppc64le"]' primary_image: false - error_platforms: '["linux/arm/v7", "linux/arm64/v8", "linux/ppc64le"]' + error_platforms: '["linux/arm/v7", "linux/ppc64le"]' noasan_fallback_platforms: "[]" skip_empty_merge: true tcw_commit_hash: ${{ inputs.tcw_commit_hash }} \ No newline at end of file diff --git a/.github/workflows/publish_docker_matrix_base.yml b/.github/workflows/publish_docker_matrix_base.yml index 04001309a..612e6e40c 100644 --- a/.github/workflows/publish_docker_matrix_base.yml +++ b/.github/workflows/publish_docker_matrix_base.yml @@ -49,7 +49,10 @@ jobs: fail-fast: false matrix: platform: ${{ fromJson(inputs.platforms) }} - runs-on: ubuntu-latest + # aarch64 is built/tested natively so AddressSanitizer works (QEMU-user + # emulation cannot satisfy libasan's fixed 64-bit allocator mapping). + # All other platforms stay on the amd64 runner and cross-build via QEMU. + runs-on: ${{ matrix.platform == 'linux/arm64/v8' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} steps: - uses: actions/checkout@v7 with: @@ -70,6 +73,20 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Release candidate version + id: rc_version + if: ${{ startsWith(github.ref, 'refs/tags/rc_v') }} + run: | + # rc_v0.7.1, rc_v0.7.1-1 or rc_v0.7.1-rc0: base = 0.7.1, full = 0.7.1-1 / 0.7.1-rc0 (empty for the base tag) + ver="${GITHUB_REF_NAME#rc_v}" + if ! [[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::$GITHUB_REF_NAME is not rc_vX.Y.Z or rc_vX.Y.Z-" + exit 1 + fi + base="${ver%%-*}" + echo "base=$base" >> "$GITHUB_OUTPUT" + if [ "$ver" != "$base" ]; then echo "full=$ver" >> "$GITHUB_OUTPUT"; fi + - name: Docker meta matrix id: matrix_meta uses: docker/metadata-action@v6 @@ -86,15 +103,37 @@ jobs: type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/tc_v') }} type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/tc_nightly') }} type=raw,value=nightly-develop,enable=${{ github.ref == 'refs/heads/develop' }} + type=raw,value=rc,enable=${{ startsWith(github.ref, 'refs/tags/rc_v') }} + type=raw,value=rc${{ steps.rc_version.outputs.base }},enable=${{ startsWith(github.ref, 'refs/tags/rc_v') }} + type=raw,value=rc${{ steps.rc_version.outputs.full }},enable=${{ startsWith(github.ref, 'refs/tags/rc_v') && steps.rc_version.outputs.full != '' }} type=ref,event=tag type=match,pattern=tc_v(\d+),group=1 type=match,pattern=tc_v(\d+.\d+),group=1 type=match,pattern=tc_v(\d+.\d+.\d+),group=1 type=match,pattern=tc_v(\d+.\d+.\d+-\S+),group=1 flavor: | + latest=false prefix=arch_,onlatest=true suffix=_${{ matrix.platform }}_${{ inputs.system }},onlatest=${{ inputs.primary_image }} + - name: Release candidates must not move latest or the plain version tags + if: ${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'rc_v') }} + env: + TAGS: ${{ steps.matrix_meta.outputs.tags }} + run: | + # do not rely on metadata-action internals (e.g. automatic latest): look at what it generated + echo "$TAGS" + bad=0 + while read -r tag; do + [ -n "$tag" ] || continue + name="${tag##*:}"; name="${name#arch_}"; name="${name%%_*}" + if [ "$name" = latest ] || [[ "$name" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then + echo "::error::release candidate $GITHUB_REF_NAME would get the image tag '$tag'" + bad=1 + fi + done <<< "$TAGS" + exit $bad + - name: Modify tags id: modify_tags if: ${{ inputs.primary_image }} @@ -141,10 +180,27 @@ jobs: platform=$(echo "${{ matrix.platform }}" | tr '/' '_') echo "platform=$platform" echo "platform=$platform" >> $GITHUB_OUTPUT + - name: Lower ASLR entropy for AddressSanitizer (aarch64) + if: ${{ matrix.platform == 'linux/arm64/v8' }} + run: | + # Native arm64 runners use a recent kernel with vm.mmap_rnd_bits=32, which can + # collide with libasan's fixed 64-bit allocator base and abort at startup with + # "CHECK failed: sanitizer_allocator_primary64.h". 28 still gives strong ASLR. + # Load-bearing when the base image's libasan predates LLVM 17 (GCC <=13); a + # no-op for GCC 14+, which moved the allocator base to tolerate 32-bit ASLR. + # vm.* sysctls are not namespaced, so this host-level change reaches the + # container started below. CI-only; does not touch shipped images. Non-fatal. + sudo sysctl -w vm.mmap_rnd_bits=28 || echo "warning: could not set vm.mmap_rnd_bits" - name: Run docker image test id: docker-test run: | - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + # QEMU binfmt is only needed to run foreign-arch containers on the amd64 + # runner. The native arm64 runner runs arm64 containers directly, and the + # multiarch/qemu-user-static helper is not arm64-compatible there + # (it fails with "Exec format error"), so skip it for arm64. + if [ "${{ matrix.platform }}" != "linux/arm64/v8" ]; then + docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + fi if [ "${{ github.event_name }}" = "pull_request" ]; then docker run -e DOCKER_TEST=1 --platform ${{ matrix.platform }} --name teddyCloud-test teddycloud-test:local else @@ -231,6 +287,20 @@ jobs: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Release candidate version + id: rc_version + if: ${{ startsWith(github.ref, 'refs/tags/rc_v') }} + run: | + # rc_v0.7.1, rc_v0.7.1-1 or rc_v0.7.1-rc0: base = 0.7.1, full = 0.7.1-1 / 0.7.1-rc0 (empty for the base tag) + ver="${GITHUB_REF_NAME#rc_v}" + if ! [[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::$GITHUB_REF_NAME is not rc_vX.Y.Z or rc_vX.Y.Z-" + exit 1 + fi + base="${ver%%-*}" + echo "base=$base" >> "$GITHUB_OUTPUT" + if [ "$ver" != "$base" ]; then echo "full=$ver" >> "$GITHUB_OUTPUT"; fi + - name: Docker meta if: ${{ steps.check_digests.outputs.has_digests == 'true' }} id: teddycloud_meta # you'll use this in the next step @@ -250,14 +320,35 @@ jobs: type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/tc_v') }} type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/tc_nightly') }} type=raw,value=nightly-develop,enable=${{ github.ref == 'refs/heads/develop' }} + type=raw,value=rc,enable=${{ startsWith(github.ref, 'refs/tags/rc_v') }} + type=raw,value=rc${{ steps.rc_version.outputs.base }},enable=${{ startsWith(github.ref, 'refs/tags/rc_v') }} + type=raw,value=rc${{ steps.rc_version.outputs.full }},enable=${{ startsWith(github.ref, 'refs/tags/rc_v') && steps.rc_version.outputs.full != '' }} type=ref,event=tag type=match,pattern=tc_v(\d+),group=1 type=match,pattern=tc_v(\d+.\d+),group=1 type=match,pattern=tc_v(\d+.\d+.\d+),group=1 type=match,pattern=tc_v(\d+.\d+.\d+-\S+),group=1 flavor: | + latest=false suffix=_${{ inputs.system }},onlatest=${{ !inputs.primary_image }} + - name: Release candidates must not move latest or the plain version tags + if: ${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'rc_v') }} + env: + TAGS: ${{ steps.teddycloud_meta.outputs.tags }} + run: | + # do not rely on metadata-action internals (e.g. automatic latest): look at what it generated + echo "$TAGS" + bad=0 + while read -r tag; do + [ -n "$tag" ] || continue + name="${tag##*:}"; name="${name#arch_}"; name="${name%%_*}" + if [ "$name" = latest ] || [[ "$name" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then + echo "::error::release candidate $GITHUB_REF_NAME would get the image tag '$tag'" + bad=1 + fi + done <<< "$TAGS" + exit $bad - name: Create manifest list and push (primary) if: ${{ inputs.primary_image && steps.check_digests.outputs.has_digests == 'true' }} working-directory: /tmp/digests/${{ github.sha }} diff --git a/.gitignore b/.gitignore index 49ab42b26..aaddba713 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ fake-server-tb2/* /dev-sandbox/run/ /dev-sandbox/.teddycloud-native.pid *.pcap + +# scratch notes, not committed +tmp/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f2aa3308..9008f45a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,19 @@ reproducible option and is the recommended path on macOS. Linux debug builds com with **AddressSanitizer + UBSan** enabled by default — please verify your change is clean under them. +## Testing + +```bash +make test # C unit tests + Python integration tests against a throwaway server +make test_c # just the C unit tests (tests/c/) +``` + +New pure logic (no running server needed) gets a C test under `tests/c/`; new +HTTP-level behavior gets a Python test under `tests/py/`, using +`tests/py/with_server.sh` to start/stop the throwaway server instead of +duplicating that in the Makefile. See [docs/architecture.md](docs/architecture.md#testing) +for how these fit together. + ## Vendored / third-party code TeddyCloud vendors the Oryx Embedded **CycloneTCP / CycloneSSL / CycloneCrypto** stack @@ -56,8 +69,47 @@ under `cyclone/` (git submodules) plus a few other libraries (cJSON, opus, ogg, **Don't edit the vendored submodule sources directly.** Where TeddyCloud needs to change vendored behaviour, it keeps a *patched copy* under `src/cyclone/…` and the `Makefile` compiles that instead of the submodule original (the `-Isrc/cyclone/…` include path takes -precedence). If you must change a vendored file, add or extend the copy there rather than -modifying the submodule. +precedence). If you must change a vendored file: copy it unmodified from the submodule into +the matching path under `src/cyclone/…`, then make your change on that copy. Don't run the +auto-formatter on it — keep the copy identical to upstream except for the actual patch, so +the diff against upstream stays reviewable. + +## Releases and release candidates + +A release is a tag `tc_vX.Y.Z`: it builds the zips, a draft GitHub release and the Docker images +`X.Y.Z`, `X.Y`, `X` and `latest`. + +A **release candidate** has its own tag prefix `rc_` and so never touches the release machinery. Tag a +commit that is on `develop`: + + git fetch origin + git tag rc_v0.7.1 origin/develop # the first candidate of 0.7.1 + git push origin rc_v0.7.1 + # further candidates of the same version: rc_v0.7.1-1, rc_v0.7.1-2 (or rc_v0.7.1-rc1, ...) + +It publishes **Docker images only** (no GitHub release): `rc` (the newest candidate of anything), +`rc0.7.1` (the newest candidate of 0.7.1) and, for a suffixed tag, the exact `rc0.7.1-1` (or +`rc0.7.1-rc1`) plus the git tag name. `latest`, `0`, `0.7` and `0.7.1` never move, a check in the +workflow fails the run before pushing if they would. The binary reports `v0.7.1-rc` / `v0.7.1-rc1`. +An `rc_` tag whose commit is not reachable from `develop` is refused. Candidate runs are queued one +at a time (the `rc` tags move), so don't re-run an old candidate. Nothing is merged to `master`. + +### Promoting a good candidate to a release + +Release exactly the commit that was tested, so the release is the candidate. Releases so far are a merge of a +`develop` commit into `master` (commit message `vX.Y.Z`) and a `tc_vX.Y.Z` tag on that `develop` commit: + + git fetch origin --tags + git checkout master && git pull --ff-only origin master + git merge --no-ff rc_v0.7.1 -m "v0.7.1" # the tested commit, not the current develop tip + git push origin master + git tag tc_v0.7.1 rc_v0.7.1^{commit} # same commit as the candidate (use rc_v0.7.1-N if that was the good one) + git push origin tc_v0.7.1 + +The `tc_v...` tag builds the zips and a **draft** GitHub release (publish it by hand) and the Docker images +`0.7.1`, `0.7`, `0` and `latest`. The `rc` / `rc0.7.1` image tags are left alone. If the candidate was not good, +fix it on `develop` and cut `rc_v0.7.1-1`. Run the "Make web and commit" workflow *before* tagging a candidate if +the frontend changed, so the candidate already contains the frontend that will be released. ## Code style diff --git a/Makefile b/Makefile index b86f64364..394c151ba 100644 --- a/Makefile +++ b/Makefile @@ -15,9 +15,12 @@ EXECUTABLE = $(BIN_DIR)/teddycloud$(EXEC_EXT) LINK_LO_FILE = $(EXECUTABLE).lo PLATFORM ?= linux OPTI_LEVEL ?= -O2 -TEST_API_HTTP_PORT ?= 18080 -TEST_API_HTTPS_PORT ?= 18443 -TEST_API_HTTPS_API_PORT ?= 18444 +TEST_API_HTTP_PORT ?= 0 +TEST_API_HTTPS_PORT ?= 0 +TEST_API_HTTPS_API_PORT ?= 0 +TEST_AUTH_HTTP_PORT ?= 0 +TEST_AUTH_HTTPS_PORT ?= 0 +TEST_AUTH_HTTPS_API_PORT ?= 0 ifeq ($(OS),Windows_NT) SHELL_ENV ?= cmd @@ -86,27 +89,19 @@ endif endif endif -ifeq ($(build_os_id),"ubuntu") -ifeq ($(build_arch),"aarch64") -# Workaround AddressSanitizer: CHECK failed: sanitizer_allocator_primary64.h:131 "((kSpaceBeg)) == ((address_range.Init(TotalSpaceSize, PrimaryAllocatorName, kSpaceBeg)))" (0x500000000000, 0xfffffffffffffff4) (tid=8) -# LLM: Ubuntu's Linux kernel version 6.5.0-25 increased the number of random bits used for ASLR from 28 to 32 on 64-bit systems7. -# The AddressSanitizer library hasn't been updated to accommodate this change in the ASLR configuration7. -# This mismatch causes a CHECK failure in the sanitizer_allocator_primary64.h file, specifically at line 131. -# But this doesn't work! -CFLAGS_VERSION+=-DSANITIZER_CAN_USE_ALLOCATOR64=0 -endif -endif - -ifeq ($(build_os_id),"debian") -ifeq ($(build_arch),"aarch64") -# Workaround AddressSanitizer: CHECK failed: sanitizer_allocator_primary64.h:131 "((kSpaceBeg)) == ((address_range.Init(TotalSpaceSize, PrimaryAllocatorName, kSpaceBeg)))" (0x500000000000, 0xfffffffffffffff4) (tid=8) -# LLM: Ubuntu's Linux kernel version 6.5.0-25 increased the number of random bits used for ASLR from 28 to 32 on 64-bit systems7. -# The AddressSanitizer library hasn't been updated to accommodate this change in the ASLR configuration7. -# This mismatch causes a CHECK failure in the sanitizer_allocator_primary64.h file, specifically at line 131. -# But this doesn't work! -CFLAGS_VERSION+=-DSANITIZER_CAN_USE_ALLOCATOR64=0 -endif -endif +# NOTE (issue #311): AddressSanitizer on aarch64 aborts at startup with +# CHECK failed: sanitizer_allocator_primary64.h:131 "((kSpaceBeg)) == (...)" (0x500000000000, 0xfffffffffffffff4) +# when libasan cannot mmap its fixed 64-bit allocator region. TWO triggers cause it: +# (1) QEMU-user emulation / small-VA kernels, whose constrained address space cannot +# hold the mapping -- this is why the CI cross-built-under-qemu failed; and +# (2) high-entropy ASLR (vm.mmap_rnd_bits=32 on recent kernels) colliding with the +# allocator base -- this also happens on NATIVE arm64 hardware, probabilistically. +# Passing -DSANITIZER_CAN_USE_ALLOCATOR64=0 in CFLAGS here has NO effect: that define +# only matters when libasan itself is compiled, not the application. +# Fixes: build/test aarch64 on NATIVE arm64 runners (kills trigger 1) and lower ASLR to +# vm.mmap_rnd_bits=28 in CI (kills trigger 2) -- see publish_docker_matrix_base.yml. +# libasan from LLVM 17+ (GCC 14+) moved its allocator base 0x600000000000 -> 0x500000000000 +# to tolerate 32-bit ASLR, so a current base image needs no extra workaround; GCC <=13 does. build_version:=vX.X.X build_gitTagPrefix:=$(firstword $(subst _, ,$(build_gitTag))) @@ -114,6 +109,11 @@ ifeq ($(build_gitTagPrefix),tc) build_version:=$(subst ${build_gitTagPrefix}_,,${build_gitTag}) CFLAGS_VERSION+=-DBUILD_VERSION=\"${build_version}\" endif +# release candidates: rc_v0.7.1 -> v0.7.1-rc, rc_v0.7.1-1 -> v0.7.1-rc1, rc_v0.7.1-rc0 stays as it is +ifeq ($(build_gitTagPrefix),rc) + build_version:=$(shell echo '$(build_gitTag)' | sed -E 's/^rc_//; s/^(v[0-9.]+)$$/\1-rc/; s/^(v[0-9.]+)-([0-9]+)$$/\1-rc\2/') + CFLAGS_VERSION+=-DBUILD_VERSION=\"${build_version}\" +endif WEB_GIT_DIRTY ?= 1 WEB_GIT_BUILD_TIME ?= unknown @@ -810,44 +810,42 @@ dev-sandbox-restart: dev-sandbox-down $(MAKE) dev-sandbox-up; \ fi -.PHONY: test_api_custom_json -test_api_custom_json: - $(QUIET)$(ECHO) "[ ${CYAN}TEST${NC} ] Run custom JSON API tests against running server" - $(QUIET)TEDDYCLOUD_BASE_URL=http://127.0.0.1:80 python3 tests/test_tonies_custom_json_api.py - -.PHONY: test_api_custom_json_with_server -test_api_custom_json_with_server: build - $(QUIET)$(ECHO) "[ ${CYAN}TEST${NC} ] Start server, run custom JSON API tests, stop server" - $(QUIET)python3 -c 'import socket,sys; ports=[$(TEST_API_HTTP_PORT),$(TEST_API_HTTPS_PORT),$(TEST_API_HTTPS_API_PORT)]; used=[]; [used.append(p) for p in ports if (lambda s: (s.settimeout(0.2), s.connect_ex(("127.0.0.1", p)), s.close()))(socket.socket(socket.AF_INET, socket.SOCK_STREAM))[1] == 0]; sys.exit(1 if used else 0)' || { \ - $(ECHO) "[ ${RED}ERR${NC} ] One or more test ports are already in use: $(TEST_API_HTTP_PORT), $(TEST_API_HTTPS_PORT), $(TEST_API_HTTPS_API_PORT)"; \ - $(ECHO) "[ ${RED}ERR${NC} ] Please free them manually first. Example:"; \ - $(ECHO) " ps -ef | awk '/teddycloud/ && !/awk/ {print}'"; \ - $(ECHO) " kill "; \ - exit 1; \ - } - $(QUIET)tmp_log=/tmp/teddycloud_test_api_custom_json.log; \ - srv_pid_file=/tmp/teddycloud_test_api_custom_json.pid; \ - ./bin/teddycloud --config-set "core.server.http_port=$(TEST_API_HTTP_PORT),core.server.https_web_port=$(TEST_API_HTTPS_PORT),core.server.https_api_port=$(TEST_API_HTTPS_API_PORT)" > "$$tmp_log" 2>&1 & \ - srv_pid=$$!; \ - echo $$srv_pid > "$$srv_pid_file"; \ - trap 'kill $$srv_pid >/dev/null 2>&1 || true; rm -f "$$srv_pid_file"' EXIT INT TERM; \ - ready=0; \ - for i in $$(seq 1 60); do \ - if ! kill -0 $$srv_pid >/dev/null 2>&1; then \ - $(ECHO) "[ ${RED}ERR${NC} ] Test server exited during startup. Log:"; \ - sed -n '1,140p' "$$tmp_log"; \ - exit 1; \ - fi; \ - http_code=$$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$(TEST_API_HTTP_PORT)/web/" || true); \ - if [ "$$http_code" = "200" ]; then \ - ready=1; \ - break; \ - fi; \ - sleep 0.2; \ - done; \ - if [ "$$ready" != "1" ]; then \ - $(ECHO) "[ ${RED}ERR${NC} ] Test server did not become ready on port $(TEST_API_HTTP_PORT). Log:"; \ - sed -n '1,140p' "$$tmp_log"; \ - exit 1; \ - fi; \ - TEDDYCLOUD_BASE_URL=http://127.0.0.1:$(TEST_API_HTTP_PORT) python3 tests/test_tonies_custom_json_api.py +TEST_C_SRC := $(wildcard tests/c/*.c) + +.PHONY: test_c +test_c: + $(QUIET)$(ECHO) "[ ${CYAN}TEST${NC} ] Build and run C unit tests" + $(QUIET)mkdir -p $(BIN_DIR) + $(QUIET)$(CC) -I include -I src/cyclone/common -I src/cyclone/cyclone_tcp -I cyclone/common -I cyclone/cyclone_tcp -I cyclone/cyclone_ssl -I cyclone/cyclone_crypto -D GPL_LICENSE_TERMS_ACCEPTED -ffunction-sections -Wl,--gc-sections -o $(BIN_DIR)/test_c $(TEST_C_SRC) $(SRC_DIR)/os_ext.c $(SRC_DIR)/str_ext.c $(SRC_DIR)/fs_ext.c $(SRC_DIR)/esp32_port.c $(SRC_DIR)/platform/platform_linux.c $(SRC_DIR)/cyclone/common/os_port_posix.c $(SRC_DIR)/box_cert.c $(SRC_DIR)/zip_writer.c + $(QUIET)$(BIN_DIR)/test_c + +.PHONY: test +test: test_c test_py test_auth_pool_reuse_with_server + +PYTHON ?= python3 +# every test_*.py is picked up automatically; test_hardening.py is split per class, so a crash only fails its own test +# every entry gets a fresh sandboxed server (see tests/py/with_server.sh) +HARDENING_TESTS := $(shell sed -n 's/^class \([A-Za-z0-9]*\)(\(Base\|Ota\)):.*/\1/p' tests/py/test_hardening.py | grep -vx Ota) +PY_TESTS := $(filter-out tests/py/test_hardening.py tests/py/test_auth_pool_reuse.py,$(sort $(wildcard tests/py/test_*.py))) \ + $(addprefix tests/py/test_hardening.py::,$(HARDENING_TESTS)) + +# make test_py TESTS=cors only entries containing "cors" +# make test_py PYTEST_ARGS="-k name" extra pytest arguments +# ASan fills only the first 4 KiB of a new allocation with garbage by default; +# filling all of it makes use of uninitialised memory fail reliably (Tap495). +.PHONY: test_py +test_py: build + $(QUIET)$(ECHO) "[ ${CYAN}TEST${NC} ] Run pytest, a fresh sandboxed server per entry" + $(QUIET)$(PYTHON) -m pytest --version >/dev/null 2>&1 || { echo "pytest is missing: pip install pytest (or apt install python3-pytest)"; exit 1; } + $(QUIET)rc=0; for t in $(PY_TESTS); do \ + [ -z "$(TESTS)" ] || echo "$$t" | grep -q -- "$(TESTS)" || continue; \ + ASAN_OPTIONS="$${ASAN_OPTIONS:+$$ASAN_OPTIONS:}max_malloc_fill_size=1073741824" \ + tests/py/with_server.sh $(TEST_API_HTTP_PORT) $(TEST_API_HTTPS_PORT) $(TEST_API_HTTPS_API_PORT) 60 -- \ + $(PYTHON) -m pytest -q $(PYTEST_ARGS) $$t || rc=1; \ + done; exit $$rc + +.PHONY: test_auth_pool_reuse_with_server +test_auth_pool_reuse_with_server: build + $(QUIET)$(ECHO) "[ ${CYAN}TEST${NC} ] Start server, run pooled-connection auth regression test, stop server" + $(QUIET)tests/py/with_server.sh $(TEST_AUTH_HTTP_PORT) $(TEST_AUTH_HTTPS_PORT) $(TEST_AUTH_HTTPS_API_PORT) 60 -- \ + tests/py/run_auth_pool_reuse.sh diff --git a/README.md b/README.md index 0e073be5f..376e48186 100644 --- a/README.md +++ b/README.md @@ -22,15 +22,45 @@ Currently implemented are: * Decode RTNL logs * MQTT client * Home Assistant integration (MQTT) +* Optional web UI login (disabled by default; enable in the web interface) * [Web frontend](https://github.com/toniebox-reverse-engineering/teddycloud_web) (full stack developers welcome) ## Planned * teddyBench integration * Toniebox 2 support +## Web UI login + +Login protection is **off by default**. When enabled, the web interface requires a username and password; Toniebox cloud APIs on the box port are unchanged. + +- Create users and turn login on in the web UI (Settings). +- Users are stored as salted SHA-256 hashes in `web_users.json` under the config directory. +- After five failed logins from the same IP, further attempts are blocked for 10 minutes. +- Set `TEDDYCLOUD_WEB_AUTH_DISABLE=1` to bypass login (for recovery). +- Removing `web_users.json` (no users left) turns login protection off again. +- **Still not recommended for public exposure:** the login only protects the web UI. The direct APIs are still unprotected, so do not host TeddyCloud publicly unless you know how to secure a self-hosted service (for example behind a VPN or an authenticating reverse proxy). TeddyCloud does not treat LAN and reverse-proxy traffic differently (a proxy often looks like a local client). + +A Traefik example that separates the web UI (HTTP) from the Toniebox port (TLS passthrough) is in [`docker/docker-compose.traefik.example.yaml`](docker/docker-compose.traefik.example.yaml). + ## Where to start? If you want to get started, please follow our [guide on our website](https://toniebox-reverse-engineering.github.io/docs/tools/teddycloud/). +## Docker images and release candidates +Docker images are published to `ghcr.io/toniebox-reverse-engineering/teddycloud`: + +| Tag | What it is | +|-----|------------| +| `latest`, `0.7.0`, `0.7`, `0` | the newest release (git tag `tc_vX.Y.Z`) | +| `nightly-develop` | the current `develop` branch | +| `rc`, `rc0.7.1`, `rc_v0.7.1` | release candidates, see below | + +Before a release there can be **release candidates** (`rc`) for testing. They are cut from `develop` with a git tag +`rc_vX.Y.Z` (more candidates of the same version: `rc_vX.Y.Z-1`, `rc_vX.Y.Z-2`, ...). `rc` always points to the newest +candidate and `rc0.7.1` to the newest candidate of version 0.7.1; the exact tag (e.g. `rc0.7.1-1`) stays available. +A candidate is published as Docker image only (no GitHub release), never moves `latest` or the version tags, and the +version shown by TeddyCloud is `v0.7.1-rc`, `v0.7.1-rc1`, ... Please test them and report problems before the release. +How to cut one is described in [CONTRIBUTING.md](CONTRIBUTING.md#releases-and-release-candidates). + ## Development and bulding Please use the [develop](tree/develop) for your development and pull requests. Stable builds are available from the master branch. Don't forget to clone the submodules with --recurse-submodules. To catch sanitizer in you IDE set a breakpoint on `__asan::ReportGenericError`. @@ -62,7 +92,7 @@ TeddyCloud consists of two repositories: the backend (teddycloud) and the web fr | Workflow | Trigger | Description | |----------|---------|-------------| | [Make web and commit](https://github.com/toniebox-reverse-engineering/teddycloud/actions/workflows/build_commit_web.yml) | Manual (`workflow_dispatch`) | Builds the web from the teddycloud_web submodule, commits files to `contrib/data/www/`, pushes, and triggers the Docker build. | -| [Docker Image Publish Matrix](https://github.com/toniebox-reverse-engineering/teddycloud/actions/workflows/publish_docker_matrix_all.yml) | Push to `master`/`develop` or tags | Builds Docker images automatically. | +| [Docker Image Publish Matrix](https://github.com/toniebox-reverse-engineering/teddycloud/actions/workflows/publish_docker_matrix_all.yml) | Push to `master`/`develop` or tags (`tc_v...` releases, `rc_v...` release candidates) | Builds Docker images automatically. | **After merging:** Once both PRs are merged, a maintainer runs the "Make web and commit" workflow. It builds the frontend from the current teddycloud_web state (same branch), commits the built files, and updates the submodule pointer if needed. The subsequent push triggers the Docker build automatically. diff --git a/contrib/data/www/legacy.html b/contrib/data/www/legacy.html deleted file mode 100644 index 252c6409c..000000000 --- a/contrib/data/www/legacy.html +++ /dev/null @@ -1,2529 +0,0 @@ - - - - - TeddyCloud administration interface - - - - - - - - - - -
- - - - - \ No newline at end of file diff --git a/contrib/data/www/library/babel.js b/contrib/data/www/library/babel.js deleted file mode 100644 index c86b8bdd9..000000000 --- a/contrib/data/www/library/babel.js +++ /dev/null @@ -1,61686 +0,0 @@ -(function webpackUniversalModuleDefinition(root, factory) { - if(typeof exports === 'object' && typeof module === 'object') - module.exports = factory(); - else if(typeof define === 'function' && define.amd) - define([], factory); - else if(typeof exports === 'object') - exports["Babel"] = factory(); - else - root["Babel"] = factory(); -})(this, function() { -return /******/ (function(modules) { // webpackBootstrap -/******/ // The module cache -/******/ var installedModules = {}; - -/******/ // The require function -/******/ function __webpack_require__(moduleId) { - -/******/ // Check if module is in cache -/******/ if(installedModules[moduleId]) -/******/ return installedModules[moduleId].exports; - -/******/ // Create a new module (and put it into the cache) -/******/ var module = installedModules[moduleId] = { -/******/ exports: {}, -/******/ id: moduleId, -/******/ loaded: false -/******/ }; - -/******/ // Execute the module function -/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); - -/******/ // Flag the module as loaded -/******/ module.loaded = true; - -/******/ // Return the exports of the module -/******/ return module.exports; -/******/ } - - -/******/ // expose the modules object (__webpack_modules__) -/******/ __webpack_require__.m = modules; - -/******/ // expose the module cache -/******/ __webpack_require__.c = installedModules; - -/******/ // __webpack_public_path__ -/******/ __webpack_require__.p = ""; - -/******/ // Load entry module and return exports -/******/ return __webpack_require__(0); -/******/ }) -/************************************************************************/ -/******/ ((function(modules) { - // Check all modules for deduplicated modules - for(var i in modules) { - if(Object.prototype.hasOwnProperty.call(modules, i)) { - switch(typeof modules[i]) { - case "function": break; - case "object": - // Module can be created from a template - modules[i] = (function(_m) { - var args = _m.slice(1), fn = modules[_m[0]]; - return function (a,b,c) { - fn.apply(this, [a,b,c].concat(args)); - }; - }(modules[i])); - break; - default: - // Module is a copy of another module - modules[i] = modules[modules[i]]; - break; - } - } - } - return modules; -}([ -/* 0 */ -/***/ (function(module, exports, __webpack_require__) { - - 'use strict'; - - Object.defineProperty(exports, "__esModule", { - value: true - }); - exports.version = exports.buildExternalHelpers = exports.availablePresets = exports.availablePlugins = undefined; - - var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; - - var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; - - exports.transform = transform; - exports.transformFromAst = transformFromAst; - exports.registerPlugin = registerPlugin; - exports.registerPlugins = registerPlugins; - exports.registerPreset = registerPreset; - exports.registerPresets = registerPresets; - exports.transformScriptTags = transformScriptTags; - exports.disableScriptTags = disableScriptTags; - - var _babelCore = __webpack_require__(290); - - var Babel = _interopRequireWildcard(_babelCore); - - var _transformScriptTags = __webpack_require__(629); - - function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } - - var isArray = Array.isArray || function (arg) { - return Object.prototype.toString.call(arg) === '[object Array]'; - }; - - /** - * Loads the given name (or [name, options] pair) from the given table object - * holding the available presets or plugins. - * - * Returns undefined if the preset or plugin is not available; passes through - * name unmodified if it (or the first element of the pair) is not a string. - */ - function loadBuiltin(builtinTable, name) { - if (isArray(name) && typeof name[0] === 'string') { - if (builtinTable.hasOwnProperty(name[0])) { - return [builtinTable[name[0]]].concat(name.slice(1)); - } - return; - } else if (typeof name === 'string') { - return builtinTable[name]; - } - // Could be an actual preset/plugin module - return name; - } - - /** - * Parses plugin names and presets from the specified options. - */ - function processOptions(options) { - // Parse preset names - var presets = (options.presets || []).map(function (presetName) { - var preset = loadBuiltin(availablePresets, presetName); - - if (preset) { - // workaround for babel issue - // at some point, babel copies the preset, losing the non-enumerable - // buildPreset key; convert it into an enumerable key. - if (isArray(preset) && _typeof(preset[0]) === 'object' && preset[0].hasOwnProperty('buildPreset')) { - preset[0] = _extends({}, preset[0], { buildPreset: preset[0].buildPreset }); - } - } else { - throw new Error('Invalid preset specified in Babel options: "' + presetName + '"'); - } - return preset; - }); - - // Parse plugin names - var plugins = (options.plugins || []).map(function (pluginName) { - var plugin = loadBuiltin(availablePlugins, pluginName); - - if (!plugin) { - throw new Error('Invalid plugin specified in Babel options: "' + pluginName + '"'); - } - return plugin; - }); - - return _extends({ - babelrc: false - }, options, { - presets: presets, - plugins: plugins - }); - } - - function transform(code, options) { - return Babel.transform(code, processOptions(options)); - } - - function transformFromAst(ast, code, options) { - return Babel.transformFromAst(ast, code, processOptions(options)); - } - var availablePlugins = exports.availablePlugins = {}; - var availablePresets = exports.availablePresets = {}; - var buildExternalHelpers = exports.buildExternalHelpers = Babel.buildExternalHelpers; - /** - * Registers a named plugin for use with Babel. - */ - function registerPlugin(name, plugin) { - if (availablePlugins.hasOwnProperty(name)) { - console.warn('A plugin named "' + name + '" is already registered, it will be overridden'); - } - availablePlugins[name] = plugin; - } - /** - * Registers multiple plugins for use with Babel. `newPlugins` should be an object where the key - * is the name of the plugin, and the value is the plugin itself. - */ - function registerPlugins(newPlugins) { - Object.keys(newPlugins).forEach(function (name) { - return registerPlugin(name, newPlugins[name]); - }); - } - - /** - * Registers a named preset for use with Babel. - */ - function registerPreset(name, preset) { - if (availablePresets.hasOwnProperty(name)) { - console.warn('A preset named "' + name + '" is already registered, it will be overridden'); - } - availablePresets[name] = preset; - } - /** - * Registers multiple presets for use with Babel. `newPresets` should be an object where the key - * is the name of the preset, and the value is the preset itself. - */ - function registerPresets(newPresets) { - Object.keys(newPresets).forEach(function (name) { - return registerPreset(name, newPresets[name]); - }); - } - - // All the plugins we should bundle - registerPlugins({ - 'check-es2015-constants': __webpack_require__(66), - 'external-helpers': __webpack_require__(322), - 'inline-replace-variables': __webpack_require__(323), - 'syntax-async-functions': __webpack_require__(67), - 'syntax-async-generators': __webpack_require__(195), - 'syntax-class-constructor-call': __webpack_require__(196), - 'syntax-class-properties': __webpack_require__(197), - 'syntax-decorators': __webpack_require__(125), - 'syntax-do-expressions': __webpack_require__(198), - 'syntax-exponentiation-operator': __webpack_require__(199), - 'syntax-export-extensions': __webpack_require__(200), - 'syntax-flow': __webpack_require__(126), - 'syntax-function-bind': __webpack_require__(201), - 'syntax-function-sent': __webpack_require__(325), - 'syntax-jsx': __webpack_require__(127), - 'syntax-object-rest-spread': __webpack_require__(202), - 'syntax-trailing-function-commas': __webpack_require__(128), - 'transform-async-functions': __webpack_require__(326), - 'transform-async-to-generator': __webpack_require__(129), - 'transform-async-to-module-method': __webpack_require__(328), - 'transform-class-constructor-call': __webpack_require__(203), - 'transform-class-properties': __webpack_require__(204), - 'transform-decorators': __webpack_require__(205), - 'transform-decorators-legacy': __webpack_require__(329).default, // <- No clue. Nope. - 'transform-do-expressions': __webpack_require__(206), - 'transform-es2015-arrow-functions': __webpack_require__(68), - 'transform-es2015-block-scoped-functions': __webpack_require__(69), - 'transform-es2015-block-scoping': __webpack_require__(70), - 'transform-es2015-classes': __webpack_require__(71), - 'transform-es2015-computed-properties': __webpack_require__(72), - 'transform-es2015-destructuring': __webpack_require__(73), - 'transform-es2015-duplicate-keys': __webpack_require__(130), - 'transform-es2015-for-of': __webpack_require__(74), - 'transform-es2015-function-name': __webpack_require__(75), - 'transform-es2015-instanceof': __webpack_require__(332), - 'transform-es2015-literals': __webpack_require__(76), - 'transform-es2015-modules-amd': __webpack_require__(131), - 'transform-es2015-modules-commonjs': __webpack_require__(77), - 'transform-es2015-modules-systemjs': __webpack_require__(208), - 'transform-es2015-modules-umd': __webpack_require__(209), - 'transform-es2015-object-super': __webpack_require__(78), - 'transform-es2015-parameters': __webpack_require__(79), - 'transform-es2015-shorthand-properties': __webpack_require__(80), - 'transform-es2015-spread': __webpack_require__(81), - 'transform-es2015-sticky-regex': __webpack_require__(82), - 'transform-es2015-template-literals': __webpack_require__(83), - 'transform-es2015-typeof-symbol': __webpack_require__(84), - 'transform-es2015-unicode-regex': __webpack_require__(85), - 'transform-es3-member-expression-literals': __webpack_require__(336), - 'transform-es3-property-literals': __webpack_require__(337), - 'transform-es5-property-mutators': __webpack_require__(338), - 'transform-eval': __webpack_require__(339), - 'transform-exponentiation-operator': __webpack_require__(132), - 'transform-export-extensions': __webpack_require__(210), - 'transform-flow-comments': __webpack_require__(340), - 'transform-flow-strip-types': __webpack_require__(211), - 'transform-function-bind': __webpack_require__(212), - 'transform-jscript': __webpack_require__(341), - 'transform-object-assign': __webpack_require__(342), - 'transform-object-rest-spread': __webpack_require__(213), - 'transform-object-set-prototype-of-to-assign': __webpack_require__(343), - 'transform-proto-to-assign': __webpack_require__(344), - 'transform-react-constant-elements': __webpack_require__(345), - 'transform-react-display-name': __webpack_require__(214), - 'transform-react-inline-elements': __webpack_require__(346), - 'transform-react-jsx': __webpack_require__(215), - 'transform-react-jsx-compat': __webpack_require__(347), - 'transform-react-jsx-self': __webpack_require__(349), - 'transform-react-jsx-source': __webpack_require__(350), - 'transform-regenerator': __webpack_require__(86), - 'transform-runtime': __webpack_require__(353), - 'transform-strict-mode': __webpack_require__(216), - 'undeclared-variables-check': __webpack_require__(354) - }); - - // All the presets we should bundle - registerPresets({ - es2015: __webpack_require__(217), - es2016: __webpack_require__(218), - es2017: __webpack_require__(219), - latest: __webpack_require__(356), - react: __webpack_require__(357), - 'stage-0': __webpack_require__(358), - 'stage-1': __webpack_require__(220), - 'stage-2': __webpack_require__(221), - 'stage-3': __webpack_require__(222), - - // ES2015 preset with es2015-modules-commonjs removed - // Plugin list copied from babel-preset-es2015/index.js - 'es2015-no-commonjs': { - plugins: [__webpack_require__(83), __webpack_require__(76), __webpack_require__(75), __webpack_require__(68), __webpack_require__(69), __webpack_require__(71), __webpack_require__(78), __webpack_require__(80), __webpack_require__(72), __webpack_require__(74), __webpack_require__(82), __webpack_require__(85), __webpack_require__(66), __webpack_require__(81), __webpack_require__(79), __webpack_require__(73), __webpack_require__(70), __webpack_require__(84), [__webpack_require__(86), { async: false, asyncGenerators: false }]] - }, - - // ES2015 preset with plugins set to loose mode. - // Based off https://github.com/bkonkle/babel-preset-es2015-loose/blob/master/index.js - 'es2015-loose': { - plugins: [[__webpack_require__(83), { loose: true }], __webpack_require__(76), __webpack_require__(75), __webpack_require__(68), __webpack_require__(69), [__webpack_require__(71), { loose: true }], __webpack_require__(78), __webpack_require__(80), __webpack_require__(130), [__webpack_require__(72), { loose: true }], [__webpack_require__(74), { loose: true }], __webpack_require__(82), __webpack_require__(85), __webpack_require__(66), [__webpack_require__(81), { loose: true }], __webpack_require__(79), [__webpack_require__(73), { loose: true }], __webpack_require__(70), __webpack_require__(84), [__webpack_require__(77), { loose: true }], [__webpack_require__(86), { async: false, asyncGenerators: false }]] - } - }); - - var version = exports.version = ("6.26.0"); - - // Listen for load event if we're in a browser and then kick off finding and - // running of scripts with "text/babel" type. - if (typeof window !== 'undefined' && window && window.addEventListener) { - window.addEventListener('DOMContentLoaded', function () { - return transformScriptTags(); - }, false); - } - - /** - * Transform - + - - - + + + - - - - + + + + @@ -28,7 +40,7 @@ - - + + diff --git a/contrib/data/www/web/manifest.json b/contrib/data/www/web/manifest.json index bab087e1b..5759956e7 100644 --- a/contrib/data/www/web/manifest.json +++ b/contrib/data/www/web/manifest.json @@ -3,21 +3,21 @@ "name": "TeddyCloud Admin", "icons": [ { - "src": "/web/web-app-manifest-light-192.png", + "src": "web-app-manifest-light-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable", "media": "(prefers-color-scheme: light)" }, { - "src": "/web/web-app-manifest-light-512.png", + "src": "web-app-manifest-light-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable", "media": "(prefers-color-scheme: light)" }, { - "src": "/web/apple-touch-icon.png", + "src": "apple-touch-icon.png", "sizes": "180x180", "type": "image/png" } diff --git a/contrib/data/www/web/translations/de.json b/contrib/data/www/web/translations/de.json index a165d52e4..5241aad5d 100644 --- a/contrib/data/www/web/translations/de.json +++ b/contrib/data/www/web/translations/de.json @@ -10,6 +10,23 @@ "errorNoOggOpusSupportByApple": "Dein Browser unterstützt im Moment keine OGG/Opus (taf) Dateien. Nutzt du ein Apple Gerät, dann hilf uns, indem Sie ein Ticket bei Apple zur Behebung des Problems einreichen!", "unknownSource": "Unbekannt" }, + "auth": { + "loginFailed": "Ungültiger Benutzername oder Passwort", + "loginHint": "Bei TeddyCloud anmelden", + "loginRateLimited": "Zu viele Anmeldeversuche. Versuche es in ein paar Minuten erneut.", + "loginSubmit": "Anmelden", + "loginTitle": "Web-Login", + "logout": "Abmelden", + "newPassword": "Neues Passwort", + "password": "Passwort", + "passwordMin": "Mindestens 4 Zeichen", + "passwordRequired": "Passwort ist erforderlich", + "retry": "Erneut versuchen", + "statusLoadFailed": "Authentifizierungsstatus konnte nicht geladen werden", + "statusLoadFailedDetails": "Der Authentifizierungsstatus konnte nicht geladen werden. Prüfe die Verbindung zu TeddyCloud und versuche es erneut.", + "username": "Benutzername", + "usernameRequired": "Benutzername ist erforderlich" + }, "common": { "cancel": "Abbrechen" }, @@ -125,6 +142,7 @@ "fields": { "author": "Autor des Plugins", "description": "Kurze Zusammenfassung des Plugins", + "hideInNav": "Flag, das angibt, ob das Plugin in der Navigation ausgeblendet werden soll (true/false)", "icon": "Das für das Plugin verwendete Icon. Muss eine gültige Ant Design Icon-Komponente von https://ant.design/components/icon sein, z. B. TrophyOutlined, TagsOutlined", "pluginHomepage": "Homepage des Plugins", "pluginName": "Der Titel, der in Menüs angezeigt wird (erforderlich)", @@ -140,11 +158,16 @@ }, "helpButton": "Hilfe", "helpTextCreatePlugin": "Ein Text darüber, wie man ein Plugin erstellt", + "hidden": "Versteckt", "installedPlugins": "Installierte Plugins", "intro": "Hier findest du deine hinzugefügten TeddyCloud Plugins und kannst neue Plugins von dir oder der TeddyCloud Community hinzufügen!", "navigationTitle": "TC Plugins", "ok": "OK", "open": "Plugin öffnen", + "pagination": { + "pageSelector": "Plugins pro Seite", + "showAll": "Alle anzeigen" + }, "plugin": "Plugin", "title": "TeddyCloud Plugins", "upload": { @@ -255,7 +278,7 @@ "translations": { "allTranslationStrings": "Alle Übersetzungsstrings", "extraKeys": "Zusätzliche Schlüssel", - "hint": "Wenn du eine schlechte Übersetzung siehst, komm bitte über Telegram auf uns zu. Französisch und Spanisch werden derzeit vollständig von ChatGPT übersetzt.", + "hint": "Wenn dir eine fehlerhafte Übersetzung auffällt, melde dich bitte bei uns auf Telegram. Nur Englisch, Deutsch, Französisch und Spanisch werden aktiv gepflegt, wobei Französisch und Spanisch derzeit vollständig von KI übersetzt wurden. Alle weiteren Sprachen werden nicht aktiv gepflegt – fehlende Übersetzungen können gerne beigesteuert werden. Du kannst auch weitere Sprachen hinzufügen, indem du eine bestehende Sprachdatei übersetzt, sie als languagecode.json im Ordner public/translations speicherst und anschließend den Server neu startest.", "key": "Schlüssel", "language": "Sprache", "missing": "FEHLT", @@ -315,6 +338,15 @@ ], "fileBrowser": { "actions": "Aktionen", + "assignToTonie": { + "action": "Tonie zuweisen", + "failedDetails": "Zuweisung an \"{{series}}\" fehlgeschlagen: ", + "failedMessage": "Zuweisung fehlgeschlagen!", + "searchPlaceholder": "Tonies durchsuchen...", + "successDetails": "Datei erfolgreich \"{{series}}\" zugewiesen!", + "successMessage": "Tonie zugewiesen!", + "title": "Tonie zuweisen" + }, "attention": "Bitte beachte!", "cancel": "Cancel", "confirmDeleteDialog": "Möchtest du die Datei {{fileToDelete}} wirklich löschen?", @@ -345,6 +377,7 @@ "deleteMultiple": "{{selectedRowCount}} markierte Zeilen löschen", "downloadFile": "Download file", "downloadFileAsOgg": "Download TAF als *.ogg", + "downloadTracks": "TAF-Tracks als *.ogg herunterladen", "encodeFiles": { "addFiles": "Dateien hinzufügen", "cancel": "Abbrechen", @@ -364,22 +397,40 @@ "filter": "Filter im aktuellen Ordner", "help": { "actionItems": { + "assignToTonie": { + "text": "Weist die ausgewählte Datei einem Tonie/Tag als Inhalt zu.", + "title": "Tonie zuweisen" + }, "deleteFileFolder": { "text": "Löscht die Datei oder den Ordner (nur wenn der Ordner leer ist). Hinweis: Wenn diese Datei einem Tonie/Tag zugewiesen ist, wird der Verweis nicht aktualisiert.", "title": "Datei/Ordner löschen" }, + "downloadFile": { + "text": "Lädt die ausgewählte Datei herunter. Bei TAF-Dateien mit mehreren Tracks öffnet sich eine Track-Auswahl, um die Tracks als einzelne *.ogg-Dateien (ZIP) oder als eine *.ogg-Datei herunterzuladen; TAF-Dateien mit nur einem Track werden als eine *.ogg-Datei heruntergeladen.", + "title": "Datei herunterladen" + }, "duplicateTAPFile": { "text": "Erstellt eine Kopie der ausgewählten TAP-Datei.", "title": "TAP-Datei duplizieren" }, "editTAFMetadata": { - "text": "Öffnet den Metadaten-Editor, um Details der TAF-Datei zu ändern. Änderungen werden in tonies.custom.json gespeichert. (WIP)", + "text": "Öffnet den Custom-Modell-Editor für die TAF-Datei. Ein bereits verknüpftes Custom-Modell (gleiche Audio-ID und gleicher Hash) wird bearbeitet, andernfalls wird ein neues für die Datei angelegt. Änderungen werden in tonies.custom.json gespeichert.", "title": "TAF-Metadaten bearbeiten" }, "editTAPPlaylist": { "text": "Öffnet den Tonie-Audio-Playlist-Editor, um TAP-Dateien zu verwalten.", "title": "TAP-Playlist bearbeiten" }, + "markAsListened": { + "listened": { + "text": "Die Datei ist derzeit als gehört markiert. Klicke, um sie als nicht gehört zu markieren.", + "title": "Als nicht gehört markieren" + }, + "unlistened": { + "text": "Die Datei ist derzeit als nicht gehört markiert. Klicke, um sie als gehört zu markieren.", + "title": "Als gehört markieren" + } + }, "migrateToAudioIdFolder": { "text": "Verschiebt die TAF-Datei in den Ordner 'by/audioId' der Library und aktualisiert die Quelle in der JSON-Datei.", "title": "TAF-Datei nach AudioId in die Library verschieben" @@ -446,6 +497,7 @@ }, "hidden": "Versteckt", "image": "Bild", + "markAsListened": "Als gehört markieren", "maxSelectedRows": "Du kannst nur maximal {{maxSelectedRows}} Dateien auswählen", "maxSelectedRowsWarning": "Zu viele Dateien ausgewählt!", "messages": { @@ -455,6 +507,8 @@ "deleteSuccessfulDetails": "Datei \"{{file}}\" erfolgreich gelöscht!", "deleting": "Löschen...", "deletingDetails": "Lösche Datei \"{{file}}\"...", + "downloadFailed": "Download fehlgeschlagen", + "downloadFailedDetails": "Download der Datei \"{{file}}\" fehlgeschlagen", "errorFetchingDirContent": "Laden des Verzeichnisinhalts fehlgeschlagen", "errorFetchingDirContentDetails": "Laden des Verzeichnisinhalts von \"{{path}}\" fehlgeschlagen: ", "migrationFailed": "Migration fehlgeschlagen", @@ -477,7 +531,9 @@ "renamingFailed": "Umbenennen fehlgeschlagen", "renamingFailedDetails": "Umbenennen der Datei \"{{fileSource}}\" in \"{{fileTarget}}\" fehlgeschlagen: ", "renamingSuccessful": "Datei erfolgreich umbenannt!", - "renamingSuccessfulDetails": "Datei \"{{fileSource}}\" erfolgreich in \"{{fileTarget}}\" umbenannt!" + "renamingSuccessfulDetails": "Datei \"{{fileSource}}\" erfolgreich in \"{{fileTarget}}\" umbenannt!", + "toggleListenedFailed": "Gehört-Status konnte nicht geändert werden", + "toggleListenedFailedDetails": "Gehört-Status für Datei \"{{file}}\" konnte nicht geändert werden: " }, "migrateContentToLib": "TAF-Datei in Library (by/audioId/) migrieren", "migrateContentToLibRoot": "TAF-Datei in Library (root) migrieren", @@ -507,7 +563,7 @@ "series": "Serie", "size": "Größe", "tafMeta": { - "edit": "(WIP - not implemented yet) Metadaten editieren" + "edit": "Metadaten editieren (Custom-Modell)" }, "tap": { "edit": "TeddyAudio Playlist editieren", @@ -519,6 +575,7 @@ } }, "title": "Dateibrowser", + "unmarkAsListened": "Als nicht gehört markieren", "unusedTafsModal": { "actions": "Aktionen", "fullPath": "Vollständiger Pfad", @@ -642,6 +699,14 @@ "01pluginFeature": "Erweiter die TeddyCloud-Funktionalität durch benutzerdefinierte Plugins" }, "title": "TeddyCloud-Plugins" + }, + "15webLogin": { + "items": { + "01optionalLogin": "optionaler Login für die Weboberfläche mit mehreren Benutzern (standardmäßig aus)", + "02rateLimit": "Cookie-Sitzung mit Sperre nach mehreren fehlgeschlagenen Anmeldungen", + "03userLimit": "bis zu 32 Benutzer" + }, + "title": "Web-UI-Login" } }, "futureFeatures": { @@ -699,11 +764,7 @@ "invalidCharactersDetected": "Deine Eingabe enthält ungültige Zeichen, wie {{invalidChar}}" }, "language": { - "change": "Sprache ändern", - "english": "EN", - "french": "FR", - "german": "DE", - "spanish": "ES" + "change": "Sprache ändern" }, "languageUtil": { "af-af": "Afrikaans (Südafrika)", @@ -1022,7 +1083,6 @@ "hint": "Dies sind die Default-Einstellungen. Einige der Einstellungen können von deinen Toniebox spezifischen Einstellungen überschrieben werden. Um diese zu ändern, gehe zu ", "information": "Information", "keepScrolling": "Weiter scrollen, um weitere Optionen zu sehen.", - "legacyGui": "Alte Admin-GUI", "levelLabel": "Settings level", "navigationTitle": "Einstellungen", "notifications": { @@ -1075,12 +1135,35 @@ "toniesJsonUpdateSuccessful": "Tonies(.custom).json erfolgreich neu geladen!", "tryToRestartTC": "Versuche, TeddyCloud neu zu starten", "warning": "Achtung! Settings level", - "warningHint": "Falls du Einstellungen vermisst, erhöhe das 'Settings level'." + "warningHint": "Falls du Einstellungen vermisst, erhöhe das 'Settings level'.", + "webAuth": { + "addUser": "Benutzer hinzufügen", + "changePassword": "Passwort ändern", + "changePasswordFor": "Passwort für {{username}} ändern", + "createFirstUser": "Lege zuerst einen Benutzer an, bevor du den Login aktivierst.", + "deleteContent": "Benutzer {{username}} löschen?", + "deleteLastContent": "Letzten Benutzer {{username}} löschen? Der Login-Schutz wird dabei ausgeschaltet.", + "deleteLastHint": "Du kannst den Login-Schutz wieder einschalten, sobald ein Benutzer existiert.", + "deleteLastTitle": "Letzten Benutzer löschen", + "deleteTitle": "Benutzer löschen", + "deleteUser": "Löschen", + "enabled": "Login für die Weboberfläche verlangen", + "envOverride": "Der Login-Schutz ist aktuell durch TEDDYCLOUD_WEB_AUTH_DISABLE deaktiviert.", + "intro": "Alle Benutzer haben vollen Zugriff auf die Weboberfläche. Die Box-APIs sind nicht betroffen.", + "loadFailed": "Benutzer konnten nicht geladen werden", + "navigationTitle": "Web-Login", + "passwordChanged": "Passwort aktualisiert", + "passwordChangedDetails": "Passwort für {{username}} wurde aktualisiert.", + "saveFailed": "Änderungen konnten nicht gespeichert werden", + "title": "Web-Login", + "users": "Benutzer" + } }, "teddycloud": { "newVersionAvailable": "Es ist eine neue Version verfügbar!", "newVersionDetailsAvailable": "Du verwendest nicht die neueste TeddyCloud-Version. Bitte aktualisiere, um alle Vorteile der neuesten Veröffentlichung zu erhalten!", "newVersionDetailsAvailableDevelop": "Du verwendest nicht die neueste TeddyCloud-Develop-Version. Bitte aktualisiere, um alle Vorteile der neuesten Develop-Version zu erhalten!", + "newVersionDetailsAvailableReleaseCandidate": "Du verwendest nicht die neueste TeddyCloud-Release-Kandidaten-Version. Bitte aktualisiere, um alle Vorteile der neuesten Release-Kandidaten-Version zu erhalten!", "newVersionShort": "Update verfügbar", "openNewVersionInGithub": "Neueste Version auf GitHub öffnen" }, @@ -1099,6 +1182,19 @@ "status": "Status", "troubleShooting": "Fehlerbehebung" }, + "boxCertificate": { + "nextConnection": "Der Status wird bei der nächsten Verbindung der Box aktualisiert.", + "pin": "Gepinntes Zertifikat (SHA-256)", + "presented": "Vorgezeigtes Zertifikat (SHA-256)", + "status": "Status", + "statusText": { + "mismatch": "Anderes Zertifikat als das gepinnte!", + "none": "Noch nicht geprüft", + "pinned": "Gepinntes Zertifikat", + "trusted": "Zertifikat von TeddyCloud ausgestellt" + }, + "title": "Zertifikatsprüfung" + }, "boxFlashingCommon": { "adguard": { "adGuardRequirement": "AdGuard: Stelle sicher, dass AdGuard Home auf einem Gerät in deinem Netzwerk installiert und konfiguriert ist.", @@ -1273,6 +1369,7 @@ "setupTeddyCloudText": "Herzlichen Glückwunsch! Da du diese Seite erreicht hast, hast du TeddyCloud erfolgreich eingerichtet!", "title": "Füge deine Toniebox zu TeddyCloud hinzu", "UARTHint": { + "ackUARTHint": "Ich habe die richtige Spannung (3,3 V) eingestellt und den neuesten erforderlichen Treiber installiert.", "description": "Bevor du fortfährst, überprüfe bitte Folgendes:", "imageAlt1": "Jumper-Beispiel 1", "imageAlt2": "Jumper-Beispiel 2", @@ -1308,6 +1405,7 @@ }, "applyingPatches": "Patches anwenden", "applyingPatchesSection": { + "altPortHint": "Wenn du einen Port-Patch erstellt hast, lege ihn ebenfalls nach /revvox/boot/patch/ und ergänze \"altPort.custom\" in der Liste der Patches.", "examplengCFGJsonCollapse": { "collapseTitle": "Beispiel \"ngCfg.json\" mit dem Patch \"altUrl.custom.305\" unter Verwendung von \"ofw2\"" }, @@ -1372,7 +1470,10 @@ "connectToTonieboxProceed": "Jetzt bist du bereit, weiterzumachen!", "connectToTonieboxText": "Die grüne LED auf der Hauptplatine der Toniebox sollte dauerhaft leuchten (ohne zu blinken). Falls dies nicht der Fall ist, sind deine Verbindungen möglicherweise locker oder falsch. Trenne den Programmer, überprüfe alle Verbindungen sorgfältig und versuche es erneut. Wenn du mit dünnen Drähten arbeitest, könntest du Heißkleber verwenden, um sie zu fixieren und Bewegungen zu verhindern. Stelle außerdem sicher, dass die Toniebox über das Ladegerät mit Strom versorgt wird.", "connectToTonieboxTextUart": "Stelle sicher, dass der UART auf 3,3V konfiguriert ist, bevor du den UART-Programmer über USB mit deinem Computer verbindest.", - "createPatch": "Erstelle altUrl.custom.305.patch", + "createHostNamePatch": "Erstelle altUrl.custom.305.patch", + "createPortPatch": "Erstelle altPort.custom.patch", + "customPortPatch": "Optional: Port-Patch erstellen (altPort.custom.json)", + "customPortPatchHint": "Nur nötig, wenn die Box TeddyCloud über einen anderen Port als 443 erreichen soll, z. B. über einen Proxy, der nach Port verteilt. Der Patch funktioniert mit den Firmware-Versionen 3.1.0 BF2, 3.3.0 und 3.4.0. hackiebox-ng bringt bereits altPort.json für Port 10443 mit; ein eigener Patch ist nur für einen anderen Port nötig. CC3235-Boxen und die Toniebox 2 lassen sich nicht patchen: Sie verbinden sich immer mit Port 443.", "customUrlPatch": "Erstelle einen benutzerdefinierten URL-Patch (altUrl.custom.305.json)", "customUrlPatchHint": "Bitte erstelle diesen Patch nur, wenn die vordefinierten Patches altUrl.tc.fritz.box.json und altUrl.305.json mit deiner aktuellen TeddyCloud-Installation nicht funktionieren. Wenn sie deinen Anforderungen entsprechen, kannst du mit 'Weiter' fortfahren.", "dedicatedUart": "Dedizierter UART", @@ -1395,6 +1496,7 @@ "hint": "Es gibt derzeit keine direkte Unterstützung, um deine CC3200 Toniebox mit TeddyCloud zu flashen, aber hier findest du eine Schritt-für-Schritt-Anleitung:", "hint2": "Bitte folge dann dem CC3200-Pfad:", "hintPatchHost": "Gib den Hostnamen deiner TeddyCloud-Instanz ein und klicke dann auf 'Erstelle altUrl.custom.305.patch'. Der Patch wird danach automatisch heruntergeladen.", + "hintPatchPort": "Gib den Port ein, mit dem sich die Box verbinden soll, und klicke dann auf 'Erstelle altPort.custom.patch'. Der Patch wird danach automatisch heruntergeladen.", "hostname": "TeddyCloud-Hostname:", "hostnameTooLong": "Der Hostname darf nicht länger als 12 Zeichen sein.", "installBootloader": "Installiere den HackieboxNG Custom Bootloader", @@ -1461,6 +1563,8 @@ "next": "Weiter", "patches": "Patches", "patchesMoreInformationLink": "Weitere Informationen zu benutzerdefinierten Patches", + "port": "TeddyCloud-Port:", + "portInvalid": "Der Port muss zwischen 1 und 65535 liegen.", "predefinedUrlPatches": "Vordefinierte URL-Patches", "predefinedUrlPatchesIntro": "Für CC3200-Boxen wird empfohlen, einen altUrl-Patch zu verwenden. HackieboxNG bringt zwei vordefinierte altUrl-Patches bereits mit:", "previous": "Zurück", @@ -1474,6 +1578,7 @@ "certificatesDumpedCAreplacementFlashed": "Zertifikate ausgelesen und CA-Ersatz geflasht", "CH341AProgrammer": { "CH341AProgrammer": "CH341A-Programmiererverbindung einrichten", + "CH341AProgrammerClampConnection": "CH341A-Programmer mit SOP8-Klemme, verbunden mit dem IS25LP032D-Flashspeicher", "CH341Sop8remove": "Hier schneiden, bis es passt.", "flashMemoryDumpText1": "Verbinde die Klemme mit dem IS25LP032D-Flash-Speicher. Achte darauf, dass das rote Kabel an der Klemme mit dem Punkt auf dem Chip, der Pin 1 markiert, übereinstimmt.", "flashMemoryDumpText2": "Falls die Klemme nicht hält, kann es sein, dass sie mit einem benachbarten schwarzen Block kollidiert. Schneide gegebenenfalls mehr Plastik ab. Verbinde den CH341A noch nicht. Überprüfe die Kabelpositionen nochmals.", @@ -1542,6 +1647,7 @@ "readingFlashText2": "Es wird empfohlen, mehrere Lesevorgänge durchzuführen und die Ergebnisse zu vergleichen.", "writingFlash": "Flashen mit Raspberry Pi Pico" }, + "portHint": "CC3235-Boxen verbinden sich immer mit Port 443: Für sie gibt es keinen Port-Patch, TeddyCloud muss also auf Port 443 erreichbar sein (ein Proxy kann sie nur nach IP verteilen).", "previous": "Zurück", "serprogFirmwareLink": "Installationsanweisungen hier finden", "title": "CC3235 Box Flashing", @@ -1551,8 +1657,8 @@ "confirmDeleteModal": "Bestätige Löschvorgang", "connectESP32Modal": { "beware": "Achtung!", - "connectESP32Text1.1": "Bitte verbinde den Jumper J100 (Boot) und setze die Box zurück, um sie in den erforderlichen UART-Modus zu versetzen.", - "connectESP32Text1.2": "Verbinde dein 3,3 V UART mit J103:", + "connectESP32Text1_1": "Bitte verbinde den Jumper J100 (Boot) und setze die Box zurück, um sie in den erforderlichen UART-Modus zu versetzen.", + "connectESP32Text1_2": "Verbinde dein 3,3 V UART mit J103:", "connectESP32Text2": "Wenn du dir nicht sicher bist, in welchem Modus der ESP32 startet, überprüfe Folgendes.", "downloadMode": "Download-Modus", "downloadModeText": "LED ist aus, kein Ton, Serieller Output:", @@ -1596,7 +1702,7 @@ "attention": "Achtung", "baudRate": "Baudrate: ", "baudRateInfo": "Wenn du Probleme beim Lesen oder Schreiben des Flash-Speichers hast, versuche die Baudrate zu ändern. Der Standardwert ist 921600.", - "browserNotSupported": "Die integrierte ESP32 Box Flashing Funktionalität kann nur mit Browsern genutzt werden, die die Web Serial API unterstützen. Dein genutzter Browser unterstützt diese Funktion leider nicht. Bitte verwende einen Browser, der die Web Serial API unterstützt, wie z.B. Chrome oder Edge.", + "browserNotSupported": "Die integrierte ESP32 Box Flashing Funktionalität kann nur mit Browsern genutzt werden, die die Web Serial API unterstützen. Dein genutzter Browser unterstützt diese Funktion leider nicht. Bitte verwende einen Browser, der die Web Serial API unterstützt, wie z.B. Chrome oder Edge oder Firefox (> v151).", "esp32flasher": { "ackBackUpWithOtherTool": "Ich habe ein Backup mit einem anderen Tool erstellt oder mir ist bewusst, dass ich ohne zusätzliches Backup auf eigenes Risiko fortfahre.", "ackLatestFirmware": "Ich bestätige, dass die Box mit der originalen Cloud verbunden war, die aktuelle Firmware erhalten hat und ein Tonie erfolgreich abgespielt wurde.", @@ -1657,8 +1763,9 @@ "hintESP32FirmwareFlashed": "Das Patchen ist abgeschlossen. Trenne die Toniebox vom UART, entferne den Kurzschluss von J100 und starte die Box neu. Sie sollte sich jetzt mit TeddyCloud verbinden können!", "hintESP32FirmwareReseted": "Das Zurücksetzen sollte abgeschlossen sein. Bitte trenne die Toniebox vom UART, entferne den Kurzschluss von J100 und starte die Box neu. Sie sollte nicht mehr mit deinem Teddycloud verbunden sein. Führe einen Frische-Check durch, um zu testen, ob alles einwandfrei funktioniert.", "hintFlashESP32RevvoxFlasher": "Klicke auf „ESP32 flashen“, wähle falls nötig den COM-Port aus und warte, bis der Schritt abgeschlossen ist!", - "hintPatchFlash": "Um das ausgewählte / gelesene Flash mit dem dem CA-Zertifikat deiner TeddyCloud zu versehen und ggf. weitere Anpassungen wie Hostname der TeddyCloud und/oder der WiFi-Zugangsdaten vorzunehmen, klicke nach Eingabe auf \"Patch\" oder gehe ohne Patching direkt zum nächsten Schritt. ", + "hintPatchFlash": "Um das ausgewählte / gelesene Flash mit dem dem CA-Zertifikat deiner TeddyCloud zu versehen und ggf. weitere Anpassungen wie Hostname und Port der TeddyCloud und/oder der WiFi-Zugangsdaten vorzunehmen, klicke nach Eingabe auf \"Patch\" oder gehe ohne Patching direkt zum nächsten Schritt. ", "hintPatchHost": "Gib den gewünschten Hostnamen deiner TeddyCloud-Instanz ein.", + "hintPatchPort": "Port, mit dem sich die Box verbindet. Lass 443 stehen, außer die Box soll TeddyCloud über einen anderen Port erreichen, z. B. über einen Proxy, der nach Port verteilt. CC3235-Boxen und die Toniebox 2 lassen sich nicht patchen: Sie verbinden sich immer mit Port 443.", "hintPatchWifi": "Gib optional die WiFi Zugangsdaten für deine Toniebox ein. Bitte nur setzen, wenn es wirklich nötig ist - normalerweise, wenn deine Toniebox kurz vor dem Patchen mit deinem WLAN verbunden war, ist dies nicht erforderlich.", "hintReadESP32ImportFlash": "Trenne jetzt die Stromversorgung deiner Toniebox. Stelle sicher, dass dein UART im 3,3-V-Modus arbeitet. Verbinde dein UART mit dem PC und der Toniebox. Überbrücke die J100-Lötpunkte. Schließe danach die Toniebox wieder an die Stromversorgung an. Klicke auf „Flash auslesen“ oder „Datei laden“ und wähle dann den COM-Port aus. Warte anschließend, bis der Schritt abgeschlossen ist!", "hintSaveLog": "Bitte speichere die Protokollausgabe in eine Datei für weitere Analyse bei Problemen.", @@ -1681,6 +1788,8 @@ "patchingFlashImage": "Flash-Image wird gepatcht...", "patchingSuccessful": "Patchen erfolgreich, bereit zum Flashen von {{size}} MiB", "payloadReceived": "Payload empfangen", + "port": "TeddyCloud-Port:", + "portInvalid": "Der Port muss zwischen 1 und 32767 liegen.", "portOpenFailedInUse": "Port konnte nicht geöffnet werden. Bereits in Gebrauch?", "preparingFlash": "Vorbereitung von {{size}} MiB Flash", "previous": "Zurück", @@ -1712,7 +1821,7 @@ "writingFinished": "Schreiben abgeschlossen", "writingFlash": "Schreibe {{size}} MiB Flash..." }, - "hint": "Dieser Abschnitt ist nur verfügbar, wenn HTTPS verwendet wird und du einen Browser nutzt, der die Web Serial API unterstützt, wie z.B. Chrome oder Edge. Wenn du deine ESP32 Toniebox flashen willst, dann klicke bitte auf \"Weiter zu HTTPS\", du wirst dann automatisch auf die HTTPS Version weitergeleitet. Es kann sein, dass dein Browser meldet, dass die Verbindung nicht sicher ist. Dies ist ok, bestätige es und öffne die Seite dennoch. Eventuell musst du dafür weitere Details bei der Meldung anzeigen.", + "hint": "Dieser Abschnitt ist nur verfügbar, wenn HTTPS verwendet wird und du einen Browser nutzt, der die Web Serial API unterstützt, wie z.B. Chrome oder Edge oder Firefox (> v151). Wenn du deine ESP32 Toniebox flashen willst, dann klicke bitte auf \"Weiter zu HTTPS\", du wirst dann automatisch auf die HTTPS Version weitergeleitet. Es kann sein, dass dein Browser meldet, dass die Verbindung nicht sicher ist. Dies ist ok, bestätige es und öffne die Seite dennoch. Eventuell musst du dafür weitere Details bei der Meldung anzeigen.", "hintLegacyApproach": "Wenn du lieber keinen Browser verwenden möchtest, der WebSerial unterstützt, kannst du immer noch die Legacy-Methode verwenden (kein GUI, nur Eingabeaufforderung).", "httpsSettings": "HTTPS Settings", "legacy": { @@ -1725,6 +1834,7 @@ "flashCAreplacementText1": "Ersetze das originale CA in deinem Flash-Dump mit esptool.", "flashCAreplacementText2": "Baue deine Toniebox erneut zusammen. Wenn du bereits den TeddyCloud-Hostnamen festgelegt hast, kannst du den DNS-Schritt für den ESP32 überspringen. Andernfalls fahre mit dem DNS-Schritt für den ESP32 fort.", "flashCAreplacementText3": "Deine Toniebox sollte jetzt in der Lage sein, eine Verbindung zu deiner TeddyCloud herzustellen. Mache einen Freshnesscheck und klicke auf \"Verfügbare Boxen anzeigen\".", + "flashESPtoolScreen": "ESPtool-Bildschirm", "followLegacyApproach": "Oder verwende die Legacy-Methode (kein GUI, nur Eingabeaufforderung).", "hint": "Es gibt eine direkte Unterstützung zum Flashen deiner ESP32 Toniebox mit TeddyCloud!", "installESPTool": "Aktuelles ESPtool installieren", @@ -1740,6 +1850,7 @@ "linkWikiGeneral": "Patching deiner Toniebox zur Nutzung mit TeddyCloud", "linkWikiSpecific": "ESP32 spezifische Einrichtung", "navigationTitle": "ESP32", + "processing": "Verarbeite...", "redirect": "Weiter zu HTTPS", "title": "ESP32 Box Flashing" }, @@ -1828,6 +1939,22 @@ }, "alternativeSource": "Der Toniefigur {{originalTonie}} ist der Inhalt {{assignedContent}} zugewiesen!", "alternativeSourceUnknown": "Der Toniefigur {{originalTonie}} ist alternativer Inhalt zugewiesen!", + "assignNextEpisode": { + "action": "Nächste Folge \"{{file}}\" zuweisen", + "failedDetails": "\"{{episode}}\" konnte nicht zugewiesen werden: ", + "failedMessage": "Zuweisen der nächsten Folge fehlgeschlagen!", + "noneLeft": "Keine ungehörten Folgen mehr in diesem Ordner.", + "successDetails": "\"{{episode}}\" wurde als nächste Folge zugewiesen!", + "successMessage": "Nächste Folge zugewiesen!" + }, + "assignPrevEpisode": { + "action": "Vorherige Folge \"{{file}}\" zuweisen", + "failedDetails": "\"{{episode}}\" konnte nicht zugewiesen werden: ", + "failedMessage": "Zuweisen der vorherigen Folge fehlgeschlagen!", + "noneLeft": "Keine vorherigen Folgen mehr in diesem Ordner.", + "successDetails": "\"{{episode}}\" wurde als vorherige Folge zugewiesen!", + "successMessage": "Vorherige Folge zugewiesen!" + }, "cancel": "Abbrechen", "closeAudioPlayer": "Player schließen", "closeAudioPlayerPopover": "Schließen stoppt die Wiedergabe!", @@ -1866,10 +1993,18 @@ }, "audio": { "libraryLabel": "Custom-Audio ({{library}})", + "locateInLibrary": "In Bibliothek suchen", + "notFoundInLibrary": "Nicht in der Bibliothek gefunden", "notInIndex": "Nicht im Custom-Audio-Index: {{audioId}}/{{hash}}", "placeholder": "Custom-Audio aus {{library}} wählen", "selectFromLibrary": "Audio aus {{library}} auswählen" }, + "audioSource": { + "linkedDescription": "Änderungen gelten für alle Dateien und Tags, die dieses Modell verwenden.", + "linkedTitle": "{{file}} ist mit diesem Custom-Modell verknüpft", + "newDescription": "Beim Speichern wird ein neues Custom-Modell angelegt und über Audio-ID und Hash mit der Datei verknüpft.", + "newTitle": "Noch kein Custom-Modell für {{file}}" + }, "batch": { "description": "Änderungen gelten für alle ausgewählten Modelle.", "title": "Batch-Bearbeitung aktiv" @@ -1911,6 +2046,11 @@ "noChangesBody": "Es gibt aktuell nichts zu speichern.", "noChangesTitle": "Keine Änderungen erkannt", "optionalColumns": "Zusatzspalten", + "overridesOriginal": { + "description": "Gespeicherte Änderungen ersetzen die Original-Informationen überall, wo dieses Audio oder Modell verwendet wird. Wird das Custom-Modell gelöscht, erscheint wieder das Original.", + "tag": "Überschreibt Original {{model}}", + "title": "Überschreibt Original-Tonie {{model}} ({{name}})" + }, "pagination": { "modelsPerPage": "Modelle/Seite" }, @@ -1949,6 +2089,13 @@ }, "tableMenu": "Tabellen-Menü", "title": "Modell-Editor", + "tracks": { + "fewerNamesDescription": "Die Datei hat {{tracks}} Tracks. Namen behalten ihre Position, der Info-Dialog bietet das Springen zu einem Track aber nur an, wenn alle Tracks bis zum letzten aufgeführt sind.", + "fewerNamesTitle": "Die letzten Tracks der Datei haben keinen Namen", + "moreNamesDescription": "Die zusätzlichen Namen werden gespeichert und aufgelistet, haben aber keine Position in der Datei. Der Info-Dialog kann dann nicht zu einzelnen Tracks springen.", + "moreNamesTitle": "Mehr Namen als Tracks in der Datei ({{tracks}})", + "notInFile": "Zusätzliche Namen ohne Track in der Datei:" + }, "unsaved": "{{count}} ungespeicherte Änderung(en)", "validation": { "title": "Bitte zuerst diese Probleme beheben" @@ -1979,6 +2126,8 @@ "modelInfoHeading": "Modell-Info", "modelInfoLanguage": "Sprache", "modelInfoModel": "Modell", + "modelInfoNo": "Nein", + "modelInfoRelease": "Release", "modelInfoSeries": "Serie", "modelInfoTitle": "Titel", "modelReadOnlyHint": "Original-Tonie: Modell kann nicht geändert werden. Du kannst nur eigenes Audio zuweisen.", @@ -1986,6 +2135,7 @@ "placeholderSearchForARadioStream": "Suche nach einem Radiostream", "save": "Speichern", "setAudioFromModel": "Audio aus Modell setzen", + "setAudioFromModelNoMapping": "Dem ausgewählten Modell ist kein Audio zugeordnet.", "setAudioFromModelUnavailableInLibrary": "Audio aus dem Modell ist noch nicht in der Bibliothek verfügbar.", "setModelFromAudio": "Modell aus Audio setzen", "source": "Quelle", @@ -1999,6 +2149,7 @@ "encoding": "Kodierung", "errorFileProcessing": "Fehler bei der Verarbeitung der Audiodatei: ", "filename": "Dateiname", + "loading": "Laden...", "loadingWasmEncoder": "WASM-Encoder wird geladen...", "maxFiles": "Du kannst nur maximal {{maxFiles}} Dateien hinzufügen.", "navigationTitle": "Audio Encoder", @@ -2040,9 +2191,14 @@ }, "editIcon": { "createNewModel": { - "description": "Öffnet ein Fenster, um alle nötigen Details für ein neues Modell einzugeben. Diese Funktion ist noch in Entwicklung.", + "description": "Öffnet den Custom-Model-Editor, um die Details eines neuen Modells für deinen Tonie/Tag einzugeben.", "text": "Erstelle ein neues benutzerdefiniertes Modell für deinen Tonie/Tag.", - "title": "Neues Modell erstellen (WIP)" + "title": "Neues Modell erstellen" + }, + "editModel": { + "description": "Bearbeitet das ausgewählte eigene Modell im Custom-Model-Editor. Die Audio-Einträge zeigen sofort Audio-ID und Hash an; mit dem Such-Symbol eines Eintrags findest du die Datei in deiner Bibliothek.", + "text": "Bearbeitet das dem Tonie/Tag zugewiesene eigene Modell.", + "title": "Eigenes Modell bearbeiten" }, "model": { "description": "Suche ein Modell und weise es zu. Das Bild wird in der Übersicht angezeigt.", @@ -2054,6 +2210,16 @@ "text": "Suche nach Online-Radio-Streams (falls die API von radio-browser.info verfügbar ist).", "title": "Radio-Stream-Suche" }, + "setAudioFromModel": { + "description": "Erscheint, wenn ein Modell ausgewählt ist, das nicht zum aktuellen Audio passt. Die Bibliothek wird erst beim Klick auf die Schaltfläche durchsucht, was einen Moment dauern kann. Wird keine passende Datei gefunden, erscheint ein Hinweis unter dem Quellenfeld.", + "text": "Weist die Audiodatei zu, die zum ausgewählten Modell gehört.", + "title": "Audio aus Modell setzen" + }, + "setModelFromAudio": { + "description": "Erscheint, wenn das zugewiesene Audio zu einem bekannten Modell gehört. Es ist keine Bibliothekssuche nötig, das Modell wird aus den Audio-Informationen übernommen.", + "text": "Weist das Modell zu, das zum aktuellen Audio gehört.", + "title": "Modell aus Audio setzen" + }, "source": { "description": "Du kannst Inhalte aus deiner Library (z.B. TAF- oder TAP-Dateien) zuweisen oder eine Stream-URL eingeben.", "text": "Zeigt die aktuell zugewiesene Inhaltsquelle an.", @@ -2064,9 +2230,9 @@ }, "infoIcon": { "download": { - "description": "Wenn verfügbar, kann die verlinkte TAF-Datei als einzelne .ogg-Datei heruntergeladen werden.", - "text": "Ermöglicht das Herunterladen der TAF-Datei als .ogg-Audiodatei.", - "title": "TAF als .ogg herunterladen" + "description": "Wenn verfügbar, können Tracks als einzelne *.ogg-Dateien heruntergeladen werden (oder als eine *.ogg-Datei).", + "text": "Öffnet die Track-Auswahl zum Herunterladen der TAF als *.ogg.", + "title": "TAF als *.ogg herunterladen" }, "exists": { "description": "Der Status 'Existiert' wird auf 'ja' gesetzt, wenn die zugewiesene Quelldatei existiert.", @@ -2219,6 +2385,7 @@ "exportToJson": "Als JSON exportieren", "hideSelectedTags": "Tags ausblenden", "select": "Auswählen", + "selectAll": "Alle auswählen", "selectButtonTooltip": "Auswahlmodus aktivieren", "selected": "Tags/Tonies ausgewählt", "setLive": "Live aktivieren", @@ -2231,6 +2398,17 @@ "navigationTitle": "System Sounds", "title": "System Sounds" }, + "tafDownload": { + "asOneFile": "Als eine *.ogg-Datei herunterladen", + "downloadCount": "{{count}} Tracks als *.ogg herunterladen", + "downloadOne": "1 Track als *.ogg herunterladen", + "duration": "Zeit", + "modalTitle": "Tracks als *.ogg herunterladen", + "selectAll": "Alle Tracks auswählen", + "selectedCount": "{{selected}} von {{total}} ausgewählt", + "track": "Track", + "unnamedTrack": "Track {{number}}" + }, "tafHeaderInvalid": "Ungültige TAF", "tafHeaderOf": "TAF-Header von ", "tafHeaderToniesJsonHint": "Du darfst die Daten hier nicht bearbeiten, wenn du sie an das tonies-json übermittelst. Dazu gehört auch die Anzahl der Tracks, selbst wenn sie sich von der Anzahl im Feld track-desc unterscheidet!", @@ -2309,11 +2487,15 @@ "customImageTitle": "Gib einen Titel ein, der gedruckt werden soll", "customImageUpload": "Temporäres Bild hinzufügen (ohne Library)", "diameter": "Innendurchmesser", + "downloadPdf": "PDF herunterladen", "empty": "Leer - Bitte wähle zuerst Tonies oder Tags aus oder füge ein eigenes Bild hinzu!", "episodes": "Episoden", "fontFamily": "Schriftart", "fontFamilyPlaceholder": "Schriftart wählen", + "height": "Höhe", + "imageBottom": "Bildabstand unten", "imageBottomLeft": "Bildabstand unten/links", + "imageLeft": "Bildabstand links", "imagePosition": "Bildausrichtung", "imageScale": "Bildskalierung", "intro": "Durchsuche alle Tonies und Tags - sowohl offizielle als auch deine eigenen - um dein druckbares Traveltonies Blatt zu erstellen.", @@ -2339,6 +2521,7 @@ "paperSize": "Papierformat", "placeholder": "Gib einen Suchbegriff ein!", "preview": "Vorschau Label", + "printMarginNotification": "CSS-Druckränder werden nur in Chrome zuverlässig unterstützt. Für einen korrekten Labeldruck verwende bitte Chrome.", "printMode": "Druckmodus", "printModeImageAndText": "Bild + Text", "printModeImageAndTextTooltip": "Beide Seiten deiner Traveltonie drucken: Bild auf der einen Seite, Titel und Episodeninfos auf der anderen", @@ -2368,7 +2551,8 @@ "square": "Eckig", "textFontSize": "Textgröße", "title": "TeddyStudio", - "trackTitles": "Titel (jeweils eine Zeile)" + "trackTitles": "Titel (jeweils eine Zeile)", + "width": "Breite" }, "title": "Tonies", "tonies": { diff --git a/contrib/data/www/web/translations/en.json b/contrib/data/www/web/translations/en.json index e99261b98..2878a2503 100644 --- a/contrib/data/www/web/translations/en.json +++ b/contrib/data/www/web/translations/en.json @@ -10,6 +10,23 @@ "errorNoOggOpusSupportByApple": "Your browser does not support OGG/Opus (taf) files at the moment. If you are using an Apple device, help us out by submitting a ticket to Apple for a fix!", "unknownSource": "Unkown" }, + "auth": { + "loginFailed": "Invalid username or password", + "loginHint": "Sign in to TeddyCloud", + "loginRateLimited": "Too many login attempts. Try again in a few minutes.", + "loginSubmit": "Log in", + "loginTitle": "Web login", + "logout": "Log out", + "newPassword": "New password", + "password": "Password", + "passwordMin": "At least 4 characters", + "passwordRequired": "Password is required", + "retry": "Retry", + "statusLoadFailed": "Could not load authentication status", + "statusLoadFailedDetails": "The authentication status could not be loaded. Check the connection to TeddyCloud and try again.", + "username": "Username", + "usernameRequired": "Username is required" + }, "common": { "cancel": "Cancel" }, @@ -125,6 +142,7 @@ "fields": { "author": "Author of the plugin", "description": "Short summary of the plugin", + "hideInNav": "Flag indicating whether the plugin should be hidden in navigation (true/false)", "icon": "The icon used for the plugin. Must be a valid Ant Design icon component from https://ant.design/components/icon, e.g. TrophyOutlined, TagsOutlined", "pluginHomepage": "Homepage of the plugin", "pluginName": "The title shown in menus (mandatory)", @@ -140,11 +158,16 @@ }, "helpButton": "Help", "helpTextCreatePlugin": "Some Text about how to create a plugin", + "hidden": "Hidden", "installedPlugins": "Installed plugins", "intro": "Here you find your added TeddyCloud plugins and you can add new plugins provided by you or the TeddyCloud community!", "navigationTitle": "TC Plugins", "ok": "OK", "open": "Open Plugin", + "pagination": { + "pageSelector": "Plugins per Page", + "showAll": "Show All" + }, "plugin": "Plugin", "title": "TeddyCloud Plugins", "upload": { @@ -255,7 +278,7 @@ "translations": { "allTranslationStrings": "All Translation strings", "extraKeys": "Extra keys", - "hint": "If you see any bad translation, please come back to us on Telegram. French and Spanish are currently completely translated by ChatGPT.", + "hint": "If you see any bad translation, please contact us on Telegram. Only English, German, French, and Spanish are actively maintained, with French and Spanish currently translated entirely by AI. All other additional languages are not actively maintained, so feel free to contribute missing translations. You can also add more languages by translating an existing language file, saving it as languagecode.json in the public/translations folder, and restarting the server.", "key": "Key", "language": "Language", "missing": "MISSING", @@ -315,6 +338,15 @@ ], "fileBrowser": { "actions": "Actions", + "assignToTonie": { + "action": "Assign to Tonie", + "failedDetails": "Could not assign \"{{series}}\" as content: ", + "failedMessage": "Assigning to Tonie failed!", + "searchPlaceholder": "Search Tonies...", + "successDetails": "File assigned to \"{{series}}\" successfully!", + "successMessage": "Tonie assigned!", + "title": "Assign to Tonie" + }, "attention": "Please note!", "cancel": "Cancel", "confirmDeleteDialog": "Are you sure you want to delete {{fileToDelete}}?", @@ -345,6 +377,7 @@ "deleteMultiple": "Delete {{selectedRowCount}} selected rows", "downloadFile": "Download File", "downloadFileAsOgg": "Download TAF as *.ogg", + "downloadTracks": "Download TAF tracks as *.ogg", "encodeFiles": { "addFiles": "Add files", "cancel": "Cancel", @@ -364,22 +397,40 @@ "filter": "Filter within current folder", "help": { "actionItems": { + "assignToTonie": { + "text": "Assigns the selected file as content to a Tonie/Tag.", + "title": "Assign to Tonie" + }, "deleteFileFolder": { "text": "Deletes the file or folder (only if the folder is empty). Note: If this file is assigned to a Tonie/Tag, the reference will not be updated.", "title": "Delete File/Folder" }, + "downloadFile": { + "text": "Downloads the selected file. For TAF files with several tracks, a track selection opens to download the tracks as separate *.ogg files (ZIP) or as one *.ogg file; single-track TAF files are downloaded as one *.ogg file.", + "title": "Download File" + }, "duplicateTAPFile": { "text": "Creates a duplicate of the selected TAP file.", "title": "Duplicate TAP File" }, "editTAFMetadata": { - "text": "Opens the metadata editor to modify TAF file details. Changes are stored in tonies.custom.json. (WIP)", + "text": "Opens the custom model editor for the TAF file. An already linked custom model (same audio ID and hash) is edited, otherwise a new one is created for the file. Changes are stored in tonies.custom.json.", "title": "Edit TAF Metadata" }, "editTAPPlaylist": { "text": "Opens the TeddyAudio Playlist Editor to manage TAP files.", "title": "Edit TAP Playlist" }, + "markAsListened": { + "listened": { + "text": "The file is currently marked as listened. Click to mark it as not listened.", + "title": "Mark as Not Listened" + }, + "unlistened": { + "text": "The file is currently marked as not listened. Click to mark it as listened.", + "title": "Mark as Listened" + } + }, "migrateToAudioIdFolder": { "text": "Moves the TAF file into the library's 'by/audioId' folder and updates the source in the JSON file.", "title": "Migrate TAF File to Library by AudioId" @@ -446,6 +497,7 @@ }, "hidden": "Hidden", "image": "Image", + "markAsListened": "Mark as listened", "maxSelectedRows": "You can only select up to {{maxSelectedRows}} files", "maxSelectedRowsWarning": "Too many files selected!", "messages": { @@ -455,6 +507,8 @@ "deleteSuccessfulDetails": "Deleted file \"{{file}}\" successful!", "deleting": "Deleting...", "deletingDetails": "Deleting file \"{{file}}\"...", + "downloadFailed": "Download failed", + "downloadFailedDetails": "Download of file \"{{file}}\" failed", "errorFetchingDirContent": "Loading dir content failed", "errorFetchingDirContentDetails": "Failed to load dir content of \"{{path}}\": ", "migrationFailed": "Migration failed", @@ -477,7 +531,9 @@ "renamingFailed": "Renaming failed", "renamingFailedDetails": "Renaming of file \"{{fileSource}}\" to \"{{fileTarget}}\" failed: ", "renamingSuccessful": "Renamed file successfully!", - "renamingSuccessfulDetails": "Renamed file \"{{fileSource}}\" to \"{{fileTarget}}\" successfully!" + "renamingSuccessfulDetails": "Renamed file \"{{fileSource}}\" to \"{{fileTarget}}\" successfully!", + "toggleListenedFailed": "Could not change listened flag", + "toggleListenedFailedDetails": "Could not change listened flag for file \"{{file}}\": " }, "migrateContentToLib": "Migrate TAF to library (by/audioId/)", "migrateContentToLibRoot": "Migrate TAF to library (root)", @@ -507,7 +563,7 @@ "series": "Series", "size": "Size", "tafMeta": { - "edit": "(WIP - not implemented yet) Edit metadata" + "edit": "Edit metadata (custom model)" }, "tap": { "edit": "Edit TeddyAudio Playlist", @@ -519,6 +575,7 @@ } }, "title": "Filebrowser", + "unmarkAsListened": "Mark as not listened", "unusedTafsModal": { "actions": "Actions", "fullPath": "Full path", @@ -642,6 +699,14 @@ "01pluginFeature": "extend TeddyCloud functionality by custom plugins" }, "title": "TeddyCloud Plugins" + }, + "15webLogin": { + "items": { + "01optionalLogin": "optional login for the web UI with multiple users (off by default)", + "02rateLimit": "cookie session with rate limiting after repeated failed logins", + "03userLimit": "up to 32 users" + }, + "title": "Web UI login" } }, "futureFeatures": { @@ -699,11 +764,7 @@ "invalidCharactersDetected": "Your input contains invalid characters, such as {{invalidChar}}" }, "language": { - "change": "Change language", - "english": "EN", - "french": "FR", - "german": "DE", - "spanish": "ES" + "change": "Change language" }, "languageUtil": { "af-af": "Afrikaans (South Africa)", @@ -997,7 +1058,7 @@ "close": "Close", "discard": "Discard changes", "errorFetchingFieldValue": "Error fetching field value", - "errorFetchingFieldValueDetails": "Error fetching field value for \"{{setting}}\"{{overlay}}:", + "errorFetchingFieldValueDetails": "Error fetching field value for \"{{setting}}\" {{overlay}}:", "errorFetchingSetting": "Error fetching setting!", "errorFetchingSettingDetails": "Error fetching setting \"{{setting}}\": ", "errorSettingSettingsLevel": "Error setting Settings level", @@ -1022,7 +1083,6 @@ "hint": "This are the default settings. Some of the settings might be overwritten by each of your Tonieboxes. To change these settings go to ", "information": "Information", "keepScrolling": "Keep scrolling to see more options.", - "legacyGui": "Legacy Admin GUI", "levelLabel": "Settings level", "navigationTitle": "Settings", "notifications": { @@ -1075,12 +1135,35 @@ "toniesJsonUpdateSuccessful": "Tonies(.custom).json reloaded successfully!", "tryToRestartTC": "Try to restart TeddyCloud", "warning": "Attention! Settings level", - "warningHint": "If you are missing settings, increase the 'Settings level'." + "warningHint": "If you are missing settings, increase the 'Settings level'.", + "webAuth": { + "addUser": "Add user", + "changePassword": "Change password", + "changePasswordFor": "Change password for {{username}}", + "createFirstUser": "Create a user before enabling login protection.", + "deleteContent": "Delete user {{username}}?", + "deleteLastContent": "Delete last user {{username}}? Login protection will be turned off.", + "deleteLastHint": "You can turn login protection back on after creating a user.", + "deleteLastTitle": "Delete last user", + "deleteTitle": "Delete user", + "deleteUser": "Delete", + "enabled": "Require login for the web interface", + "envOverride": "Login protection is currently disabled by TEDDYCLOUD_WEB_AUTH_DISABLE.", + "intro": "All users have full access to the web interface. Box APIs are not affected.", + "loadFailed": "Could not load users", + "navigationTitle": "Web login", + "passwordChanged": "Password updated", + "passwordChangedDetails": "Password for {{username}} was updated.", + "saveFailed": "Could not save changes", + "title": "Web login", + "users": "Users" + } }, "teddycloud": { "newVersionAvailable": "There is a new version available!", "newVersionDetailsAvailable": "You are not using the latest TeddyCloud version. Please update to get all benefits of the latest release!", "newVersionDetailsAvailableDevelop": "You are not using the latest TeddyCloud develop version. Please update to get all benefits of the latest develop version!", + "newVersionDetailsAvailableReleaseCandidate": "You are not using the latest TeddyCloud release candidate version. Please update to take full advantage of the latest release candidate!", "newVersionShort": "Update available", "openNewVersionInGithub": "Open the latest version on GitHub" }, @@ -1099,6 +1182,19 @@ "status": "Status", "troubleShooting": "Troubleshooting" }, + "boxCertificate": { + "nextConnection": "The status is updated with the next connection of the box.", + "pin": "Pinned certificate (SHA-256)", + "presented": "Presented certificate (SHA-256)", + "status": "Status", + "statusText": { + "mismatch": "Other certificate than the pinned one!", + "none": "Not checked yet", + "pinned": "Pinned certificate", + "trusted": "Certificate issued by TeddyCloud" + }, + "title": "Certificate check" + }, "boxFlashingCommon": { "adguard": { "adGuardRequirement": "AdGuard: Make sure AdGuard Home is installed and configured on a device in your network.", @@ -1273,6 +1369,7 @@ "setupTeddyCloudText": "Congratulations! By reaching this page, you've successfully set up TeddyCloud!", "title": "Add your Toniebox to TeddyCloud", "UARTHint": { + "ackUARTHint": "I have set the correct voltage (3.3 V) and installed the latest required driver.", "description": "Before proceeding, please double-check the following:", "imageAlt1": "Jumper Example 1", "imageAlt2": "Jumper Example 2", @@ -1308,6 +1405,7 @@ }, "applyingPatches": "Applying patches", "applyingPatchesSection": { + "altPortHint": "If you created a port patch, put it into /revvox/boot/patch/ as well and add \"altPort.custom\" to the patches list.", "examplengCFGJsonCollapse": { "collapseTitle": "Example \"ngCfg.json\" with \"altUrl.custom.305\"-patch using \"ofw2\"" }, @@ -1372,7 +1470,10 @@ "connectToTonieboxProceed": "You are now ready to proceed!", "connectToTonieboxText": "The green LED on the Toniebox mainboard should stay steadily lit (no flashing or blinking). If it does not, your connections might be loose or incorrect. Disconnect the programmer, double-check all connections, and try again. If you are working with thin wires, consider applying hot glue to secure them in place and prevent any movement. Additionally, ensure that the Toniebox is powered using its charger.", "connectToTonieboxTextUart": "Ensure that the UART is configured to 3.3V before connecting the UART programmer to your computer via USB.", - "createPatch": "Create altUrl.custom.305.patch", + "createHostNamePatch": "Create altUrl.custom.305.patch", + "createPortPatch": "Create altPort.custom.patch", + "customPortPatch": "Optional: Create a port patch (altPort.custom.json)", + "customPortPatchHint": "Only needed if the box should reach TeddyCloud on another port than 443, e.g. through a proxy that routes by port. The patch works with the firmware versions 3.1.0 BF2, 3.3.0 and 3.4.0. hackiebox-ng already ships altPort.json for port 10443; create your own patch only for another port. CC3235 boxes and the Toniebox 2 cannot be patched: they always connect to port 443.", "customUrlPatch": "Create a custom URL Patch (altUrl.custom.305.json)", "customUrlPatchHint": "Please create this patch only if the predefined altUrl.tc.fritz.box.json and altUrl.305.json patches are not functioning with your current TeddyCloud setup. If they do meet your needs you can proceed by clicking 'Next'.", "dedicatedUart": "Dedicated UART", @@ -1395,6 +1496,7 @@ "hint": "There is currently no direct support of flashing your CC3200 Toniebox with TeddyCloud, but you will find a step by step guidance here:", "hint2": "Please follow then the CC3200 path:", "hintPatchHost": "Enter the hostname of your TeddyCloud instance and then click 'Create altUrl.custom.305.patch'. The patch will automatically download afterwards.", + "hintPatchPort": "Enter the port the box should connect to and click 'Create altPort.custom.patch'. The patch will automatically download afterwards.", "hostname": "TeddyCloud Hostname:", "hostnameTooLong": "The hostname must not exceed 12 characters.", "installBootloader": "Install the HackieboxNG custom bootloader", @@ -1461,6 +1563,8 @@ "next": "Next", "patches": "Patches", "patchesMoreInformationLink": "More information on custom patches", + "port": "TeddyCloud port:", + "portInvalid": "The port must be between 1 and 65535.", "predefinedUrlPatches": "Predefined URL Patches", "predefinedUrlPatchesIntro": "For CC3200 boxes, it's recommended to use an altUrl patch. HackieboxNG includes two predefined altUrl patches ready to use:", "previous": "Back", @@ -1474,6 +1578,7 @@ "certificatesDumpedCAreplacementFlashed": "Certificates dumped and CA replacement flashed", "CH341AProgrammer": { "CH341AProgrammer": "Set up CH341A programmer connection", + "CH341AProgrammerClampConnection": "CH341A Programmer with SOP8 Clamp connected to the IS25LP032D Flash memory", "CH341Sop8remove": "Trim here until it fits properly.", "flashMemoryDumpText1": "Connect the clamp to the IS25LP032D Flash memory. Ensure the red cable on the clamp aligns with the dot on the chip marking pin 1.", "flashMemoryDumpText2": "If the clamp does not hold, it may be colliding with a nearby black block. Trim more plastic if needed. Do not connect the CH341A yet. Double-check cable positions.", @@ -1542,6 +1647,7 @@ "readingFlashText2": "It's recommended to perform multiple reads and compare results.", "writingFlash": "Writing Flash with Raspberry Pi Pico" }, + "portHint": "CC3235 boxes always connect to port 443: there is no port patch for them, so TeddyCloud has to be reachable on port 443 (a proxy can only route them by IP).", "previous": "Back", "serprogFirmwareLink": "Find installation instructions here", "title": "CC3235 Box Flashing", @@ -1551,8 +1657,8 @@ "confirmDeleteModal": "Confirm delete", "connectESP32Modal": { "beware": "Beware!", - "connectESP32Text1.1": "Please connect the jumper J100 (Boot) and reset the box to put it into the required UARTmode.", - "connectESP32Text1.2": "Connect your 3.3V UART to J103:", + "connectESP32Text1_1": "Please connect the jumper J100 (Boot) and reset the box to put it into the required UARTmode.", + "connectESP32Text1_2": "Connect your 3.3V UART to J103:", "connectESP32Text2": "If you`re unsure in which mode the ESP32 is starting, check the following.", "downloadMode": "Download mode", "downloadModeText": "LED is off, no sound, Serial output:", @@ -1596,7 +1702,7 @@ "attention": "Attention", "baudRate": "Baudrate: ", "baudRateInfo": "If you encounter issues reading or writing to the flash, try changing the baud rate. The default is 921600.", - "browserNotSupported": "The integrated ESP32 Box flashing functionality can only be used with browsers that support the Web Serial API. Unfortunately, your current browser does not support this feature. Please use a browser that supports the Web Serial API, such as Chrome or Edge.", + "browserNotSupported": "The integrated ESP32 Box flashing functionality can only be used with browsers that support the Web Serial API. Unfortunately, your current browser does not support this feature. Please use a browser that supports the Web Serial API, such as Chrome or Edge or Firefox (> v151).", "esp32flasher": { "ackBackUpWithOtherTool": "I have created a backup using another tool, or I understand and accept the risk of proceeding without one.", "ackLatestFirmware": "I confirm that the box was connected to the original cloud, received the latest firmware, and successfully played a Tonie.", @@ -1657,8 +1763,9 @@ "hintESP32FirmwareFlashed": "The patching should be done. Please disconnect the Toniebox from the UART, remove the short circuit from J100 and restart the box. It should now be able to connect to TeddyCloud!", "hintESP32FirmwareReseted": "The reset should be done. Please disconnect the Toniebox from the UART, remove the short circuit from J100 and restart the box. It should be no longer connected to your Teddycloud. Do a freshnesscheck to test if it works fine.", "hintFlashESP32RevvoxFlasher": "Click on \"Flash ESP32\", if necessary choose the COM-Port and wait till the step is done!", - "hintPatchFlash": "To apply the selected/read flash with your TeddyCloud CA certificate and possibly make other adjustments like the TeddyCloud hostname and/or WiFi credentials, click 'Patch' after entering the information or proceed to the next step without patching.", + "hintPatchFlash": "To apply the selected/read flash with your TeddyCloud CA certificate and possibly make other adjustments like the TeddyCloud hostname, port and/or WiFi credentials, click 'Patch' after entering the information or proceed to the next step without patching.", "hintPatchHost": "Enter the desired hostname for your TeddyCloud instance.", + "hintPatchPort": "Port the box connects to. Keep 443 unless the box should reach TeddyCloud on another port, e.g. through a proxy that routes by port. CC3235 boxes and the Toniebox 2 cannot be patched: they always connect to port 443.", "hintPatchWifi": "Optionally enter the WiFi credentials for your Toniebox. Please only set this if you really need to - usually, if your Toniebox was connected to your WiFi right before patching, you don't need to.", "hintReadESP32ImportFlash": "Now disconnect the powersource of your Toniebox. Make sure that the UART operates in 3.3V mode. Connect your UART with your PC and Toniebox. Short cut the J100 pinholes. Then connect your Toniebox to power. Click on \"Read Flash\" or \"Load File\", then choose the COM-Port. Finally wait till the step is done!", "hintSaveLog": "Please save the log output to a file for further analysis in case of problems.", @@ -1681,6 +1788,8 @@ "patchingFlashImage": "Patching flash image...", "patchingSuccessful": "Patching successful, ready to flash {{size}} MiB.", "payloadReceived": "Payload received", + "port": "TeddyCloud port:", + "portInvalid": "The port must be between 1 and 32767.", "portOpenFailedInUse": "Failed to open this port. Already in use?", "preparingFlash": "Preparing {{size}} MiB flash", "previous": "Previous", @@ -1712,7 +1821,7 @@ "writingFinished": "Writing finished", "writingFlash": "Writing {{size}} MiB flash..." }, - "hint": "This section is only available when using HTTPS and a browser that supports the Web Serial API, such as Chrome or Edge. If you want to flash your ESP32 Toniebox, please click on \"Proceed to HTTPS\", and you will be automatically redirected to the HTTPS version. Your browser might warn that the connection is not secure. This is okay; confirm it and proceed to open the page anyway. You may need to display more details in the warning.", + "hint": "This section is only available when using HTTPS and a browser that supports the Web Serial API, such as Chrome or Edge or Firefox (> v151). If you want to flash your ESP32 Toniebox, please click on \"Proceed to HTTPS\", and you will be automatically redirected to the HTTPS version. Your browser might warn that the connection is not secure. This is okay; confirm it and proceed to open the page anyway. You may need to display more details in the warning.", "hintLegacyApproach": "If you prefer not to use a browser that supports WebSerial, you can still use the legacy method (no GUI, command line only).", "httpsSettings": "HTTPS Settings", "legacy": { @@ -1725,6 +1834,7 @@ "flashCAreplacementText1": "Replace the original CA within your flash dump with esptool.", "flashCAreplacementText2": "Reassamble your Toniebox again. If you already set the TeddyCloud hostname, you can skip the DNS step for the ESP32, if not continue with DNS step for the ESP32.", "flashCAreplacementText3": "Your Toniebox should now be able to connect to your TeddyCloud. Do a freshnesscheck and click on \"Check available Boxes\".", + "flashESPtoolScreen": "ESPtool Screen", "followLegacyApproach": "Or use the legacy method (no GUI, command line only).", "hint": "There is a direct support of flashing your ESP32 Toniebox with TeddyCloud available!", "installESPTool": "Install recent ESPtool", @@ -1740,6 +1850,7 @@ "linkWikiGeneral": "Patching your Toniebox for using with TeddyCloud", "linkWikiSpecific": "ESP32 specific setup", "navigationTitle": "ESP32", + "processing": "Processing...", "redirect": "Proceed to HTTPS", "title": "ESP32 Box Flashing" }, @@ -1828,6 +1939,22 @@ }, "alternativeSource": "This Tonie {{originalTonie}} is assigned the content {{assignedContent}}!", "alternativeSourceUnknown": "This Tonie {{originalTonie}} is assigned alternative content!", + "assignNextEpisode": { + "action": "Assign next episode \"{{file}}\"", + "failedDetails": "Could not assign \"{{episode}}\": ", + "failedMessage": "Assigning next episode failed!", + "noneLeft": "No unlistened episodes left in this folder.", + "successDetails": "Assigned \"{{episode}}\" as next episode!", + "successMessage": "Next episode assigned!" + }, + "assignPrevEpisode": { + "action": "Assign previous episode \"{{file}}\"", + "failedDetails": "Could not assign \"{{episode}}\": ", + "failedMessage": "Assigning previous episode failed!", + "noneLeft": "No previous episodes left in this folder.", + "successDetails": "Assigned \"{{episode}}\" as previous episode!", + "successMessage": "Previous episode assigned!" + }, "cancel": "Cancel", "closeAudioPlayer": "Close player", "closeAudioPlayerPopover": "Close stops playback!", @@ -1866,10 +1993,18 @@ }, "audio": { "libraryLabel": "Custom audio ({{library}})", + "locateInLibrary": "Locate in library", + "notFoundInLibrary": "Not found in library", "notInIndex": "Not in custom audio index: {{audioId}}/{{hash}}", "placeholder": "Select custom audio from {{library}}", "selectFromLibrary": "Select audio from {{library}}" }, + "audioSource": { + "linkedDescription": "Changes apply to all files and tags that use this model.", + "linkedTitle": "{{file}} is linked to this custom model", + "newDescription": "Saving creates a new custom model and links it to the file by audio ID and hash.", + "newTitle": "No custom model for {{file}} yet" + }, "batch": { "description": "Changes apply to all selected models.", "title": "Batch editing active" @@ -1911,6 +2046,11 @@ "noChangesBody": "There is nothing to save at the moment.", "noChangesTitle": "No changes detected", "optionalColumns": "Optional columns", + "overridesOriginal": { + "description": "Saved changes replace the original information wherever this audio or model is used. Delete the custom model to show the original again.", + "tag": "Overrides original {{model}}", + "title": "Overrides original Tonie {{model}} ({{name}})" + }, "pagination": { "modelsPerPage": "models/page" }, @@ -1949,6 +2089,13 @@ }, "tableMenu": "Table menu", "title": "Model editor", + "tracks": { + "fewerNamesDescription": "The file has {{tracks}} tracks. Names keep their position, but the info dialog only offers jumping to a track when every track up to the last one is listed.", + "fewerNamesTitle": "The last tracks of the file have no name", + "moreNamesDescription": "The additional names are saved and listed, but have no position in the file. The info dialog then cannot jump to single tracks.", + "moreNamesTitle": "More names than tracks in the file ({{tracks}})", + "notInFile": "Additional names without a track in the file:" + }, "unsaved": "{{count}} unsaved change(s)", "validation": { "title": "Please fix these issues first" @@ -1979,6 +2126,8 @@ "modelInfoHeading": "Model Info", "modelInfoLanguage": "Language", "modelInfoModel": "Model", + "modelInfoNo": "No", + "modelInfoRelease": "Release", "modelInfoSeries": "Series", "modelInfoTitle": "Title", "modelReadOnlyHint": "Original tonie: Model cannot be changed. You can only assign custom audio.", @@ -1986,6 +2135,7 @@ "placeholderSearchForARadioStream": "Search for a radio stream", "save": "Save", "setAudioFromModel": "Set audio from model", + "setAudioFromModelNoMapping": "The selected model has no audio assigned.", "setAudioFromModelUnavailableInLibrary": "Model audio is not yet available in the library.", "setModelFromAudio": "Set model from audio", "source": "Source", @@ -1999,6 +2149,7 @@ "encoding": "Encoding", "errorFileProcessing": "Failed to process the audio file: ", "filename": "Filename", + "loading": "Loading...", "loadingWasmEncoder": "Loading WASM encoder...", "maxFiles": "You can only add up to {{maxFiles}} files.", "navigationTitle": "Audio Encoder", @@ -2040,9 +2191,14 @@ }, "editIcon": { "createNewModel": { - "description": "Opens a modal to enter the required details for a new model. This feature is still under development and may not be functional yet.", + "description": "Opens the Custom Model Editor to enter the details of a new model for your Tonie/Tag.", "text": "Create a new custom model for your Tonie/Tag.", - "title": "Create New Model (WIP)" + "title": "Create New Model" + }, + "editModel": { + "description": "Edit the selected custom model in the Custom Model Editor. The audio entries show their audio ID and hash right away; use the locate icon of an entry to find its file in your library.", + "text": "Edit the custom model assigned to the Tonie/Tag.", + "title": "Edit Custom Model" }, "model": { "description": "Search for a model and assign it to the Tonie/Tag. The image will be shown in the overview.", @@ -2054,6 +2210,16 @@ "text": "Search online radio streams (available if the radio-browser.info API is accessible).", "title": "Radio Stream Search" }, + "setAudioFromModel": { + "description": "Appears when a model is selected that does not match the current audio. Your library is searched only when you click the button, which can take a moment. If no matching file is found, a hint is shown below the source field.", + "text": "Assign the audio file that belongs to the selected model.", + "title": "Set Audio from Model" + }, + "setModelFromAudio": { + "description": "Appears when the assigned audio belongs to a known model. No library search is needed, the model is taken from the audio information.", + "text": "Assign the model that belongs to the current audio.", + "title": "Set Model from Audio" + }, "source": { "description": "You can assign content from your library (e.g., TAF or TAP files) or enter a stream URL.", "text": "Displays the currently assigned content source.", @@ -2064,9 +2230,9 @@ }, "infoIcon": { "download": { - "description": "If available, the linked TAF file can be downloaded as a single .ogg file.", - "text": "Enables downloading the TAF file as an .ogg audio file.", - "title": "Download TAF as .ogg" + "description": "If available, tracks can be downloaded as separate *.ogg files (or as one *.ogg file).", + "text": "Opens track selection to download the TAF as *.ogg.", + "title": "Download TAF as *.ogg" }, "exists": { "description": "The 'exists' status is set to 'yes' if the assigned source file is present.", @@ -2219,6 +2385,7 @@ "exportToJson": "Export to JSON", "hideSelectedTags": "Hide tags", "select": "Select", + "selectAll": "Select all", "selectButtonTooltip": "Enable selection mode", "selected": "Tags/Tonies selected", "setLive": "Enable live", @@ -2231,6 +2398,17 @@ "navigationTitle": "System Sounds", "title": "System Sounds" }, + "tafDownload": { + "asOneFile": "Download as one *.ogg file", + "downloadCount": "Download {{count}} tracks as *.ogg", + "downloadOne": "Download 1 track as *.ogg", + "duration": "Time", + "modalTitle": "Download tracks as *.ogg", + "selectAll": "Select all tracks", + "selectedCount": "{{selected}} of {{total}} selected", + "track": "Track", + "unnamedTrack": "Track {{number}}" + }, "tafHeaderInvalid": "Invalid TAF", "tafHeaderOf": "TAF-Header of ", "tafHeaderToniesJsonHint": "You must not edit the data here when submitting it to the tonies-json. This includes the track count, even if it is different from the track count in the track-desc field!", @@ -2309,11 +2487,15 @@ "customImageTitle": "Enter a title to be printed", "customImageUpload": "Add temporary image (without Library)", "diameter": "Inner diameter", + "downloadPdf": "Download PDF", "empty": "Empty - Please select Tonies or Tags or add custom image first!", "episodes": "Episodes", "fontFamily": "Label font", "fontFamilyPlaceholder": "Choose a font", + "height": "Height", + "imageBottom": "Image margin bottom", "imageBottomLeft": "Image margin bottom/left", + "imageLeft": "Image margin left", "imagePosition": "Image position", "imageScale": "Image scale", "intro": "Search all Tonies and tags - both official and your custom ones - to create your printable Traveltonies sheet.", @@ -2339,6 +2521,7 @@ "paperSize": "Paper size", "placeholder": "Enter a search term!", "preview": "Preview Label", + "printMarginNotification": "CSS print margins are only reliably supported in Chrome. Please use Chrome to ensure correct label printing.", "printMode": "Print mode", "printModeImageAndText": "Image + Text", "printModeImageAndTextTooltip": "Print both sides of your Traveltonie with the tags image for one side and the title and episode information for the other", @@ -2368,7 +2551,8 @@ "square": "Square", "textFontSize": "Text size", "title": "TeddyStudio", - "trackTitles": "Track titles (one per line)" + "trackTitles": "Track titles (one per line)", + "width": "Width" }, "title": "Tonies", "tonies": { diff --git a/contrib/data/www/web/translations/es.json b/contrib/data/www/web/translations/es.json index e4fa8fa98..b64fd85f3 100644 --- a/contrib/data/www/web/translations/es.json +++ b/contrib/data/www/web/translations/es.json @@ -10,6 +10,23 @@ "errorNoOggOpusSupportByApple": "Tu navegador no soporta archivos OGG/Opus (taf) en este momento. Si estás utilizando un dispositivo Apple, ¡ayúdanos enviando un ticket a Apple para solicitar una solución!", "unknownSource": "Desconocido" }, + "auth": { + "loginFailed": "Usuario o contraseña no válidos", + "loginHint": "Inicia sesión en TeddyCloud", + "loginRateLimited": "Demasiados intentos de inicio de sesión. Inténtalo de nuevo en unos minutos.", + "loginSubmit": "Iniciar sesión", + "loginTitle": "Inicio de sesión web", + "logout": "Cerrar sesión", + "newPassword": "Nueva contraseña", + "password": "Contraseña", + "passwordMin": "Al menos 4 caracteres", + "passwordRequired": "La contraseña es obligatoria", + "retry": "Reintentar", + "statusLoadFailed": "No se pudo cargar el estado de autenticación", + "statusLoadFailedDetails": "No se pudo cargar el estado de autenticación. Comprueba la conexión con TeddyCloud e inténtalo de nuevo.", + "username": "Usuario", + "usernameRequired": "El usuario es obligatorio" + }, "common": { "cancel": "Cancelar" }, @@ -125,6 +142,7 @@ "fields": { "author": "Autor del plugin", "description": "Resumen breve del plugin", + "hideInNav": "Indicador que define si el plugin debe ocultarse en la navegación (true/false)", "icon": "El icono utilizado para el plugin. Debe ser un componente de icono válido de Ant Design de https://ant.design/components/icon, p. ej. TrophyOutlined, TagsOutlined", "pluginHomepage": "Página principal del plugin", "pluginName": "El título que aparece en los menús (obligatorio)", @@ -140,11 +158,16 @@ }, "helpButton": "Ayuda", "helpTextCreatePlugin": "Texto sobre cómo crear un plugin", + "hidden": "Oculto", "installedPlugins": "Plugins instalados", "intro": "Aquí puedes ver tus plugins añadidos a TeddyCloud y también agregar nuevos plugins creados por ti o por la comunidad TeddyCloud.", "navigationTitle": "Plugins TC", "ok": "OK", "open": "Abrir plugin", + "pagination": { + "pageSelector": "Plugins por página", + "showAll": "Mostrar todos" + }, "plugin": "Plugin", "title": "Plugins de TeddyCloud", "upload": { @@ -255,7 +278,7 @@ "translations": { "allTranslationStrings": "Todas las cadenas de traducción", "extraKeys": "Claves adicionales", - "hint": "Si ves alguna mala traducción, por favor vuelve a contactarnos por Telegram. El francés y el español están actualmente completamente traducidos por ChatGPT.", + "hint": "Si encuentras alguna traducción incorrecta, ponte en contacto con nosotros en Telegram. Solo el inglés, el alemán, el francés y el español se mantienen activamente; actualmente, el francés y el español están traducidos completamente mediante IA. Los demás idiomas adicionales no se mantienen activamente, así que no dudes en contribuir con las traducciones que falten. También puedes añadir más idiomas traduciendo un archivo de idioma existente, guardándolo como languagecode.json en la carpeta public/translations y reiniciando el servidor.", "key": "Clave", "language": "Idioma", "missing": "FALTANTE", @@ -315,6 +338,15 @@ ], "fileBrowser": { "actions": "Acciones", + "assignToTonie": { + "action": "Asignar a Tonie", + "failedDetails": "No se pudo asignar a \"{{series}}\": ", + "failedMessage": "¡Error al asignar!", + "searchPlaceholder": "Buscar Tonies...", + "successDetails": "¡Archivo asignado a \"{{series}}\" correctamente!", + "successMessage": "¡Tonie asignado!", + "title": "Asignar a Tonie" + }, "attention": "¡Atención!", "cancel": "Cancelar", "confirmDeleteDialog": "¿Estás seguro de que quieres eliminar {{fileToDelete}}?", @@ -345,6 +377,7 @@ "deleteMultiple": "{{selectedRowCount}} filas seleccionadas para eliminar", "downloadFile": "Descargar archivo", "downloadFileAsOgg": "Descargar TAF como *.ogg", + "downloadTracks": "Descargar pistas TAF como *.ogg", "encodeFiles": { "addFiles": "Agregar archivos", "cancel": "Cancelar", @@ -364,22 +397,40 @@ "filter": "Filtrar dentro de la carpeta actual", "help": { "actionItems": { + "assignToTonie": { + "text": "Asigna el archivo seleccionado como contenido a un Tonie/Tag.", + "title": "Asignar a Tonie" + }, "deleteFileFolder": { "text": "Elimina el archivo o carpeta (solo si la carpeta está vacía). Nota: Si este archivo está asignado a un Tonie/Tag, la referencia no se actualizará.", "title": "Eliminar archivo/carpeta" }, + "downloadFile": { + "text": "Descarga el archivo seleccionado. En archivos TAF con varias pistas se abre una selección de pistas para descargarlas como archivos *.ogg separados (ZIP) o como un solo archivo *.ogg; los archivos TAF de una sola pista se descargan como un único archivo *.ogg.", + "title": "Descargar archivo" + }, "duplicateTAPFile": { "text": "Crea una copia del archivo TAP seleccionado.", "title": "Duplicar archivo TAP" }, "editTAFMetadata": { - "text": "Abre el editor de metadatos para modificar los detalles del archivo TAF. Los cambios se guardan en tonies.custom.json. (WIP)", + "text": "Abre el editor de modelos personalizados para el archivo TAF. Si ya hay un modelo personalizado vinculado (mismo ID de audio y hash), se edita; de lo contrario, se crea uno nuevo para el archivo. Los cambios se guardan en tonies.custom.json.", "title": "Editar metadatos de TAF" }, "editTAPPlaylist": { "text": "Abre el editor de TeddyAudio Playlist de Tonie para administrar archivos TAP.", "title": "Editar lista de reproducción TAP" }, + "markAsListened": { + "listened": { + "text": "El archivo está marcado actualmente como escuchado. Haz clic para marcarlo como no escuchado.", + "title": "Marcar como no escuchado" + }, + "unlistened": { + "text": "El archivo está marcado actualmente como no escuchado. Haz clic para marcarlo como escuchado.", + "title": "Marcar como escuchado" + } + }, "migrateToAudioIdFolder": { "text": "Mueve el archivo TAF a la carpeta 'by/audioId' de la biblioteca y actualiza la fuente en el archivo JSON.", "title": "Migrar archivo TAF a la biblioteca por AudioId" @@ -446,6 +497,7 @@ }, "hidden": "Oculto", "image": "Imagen", + "markAsListened": "Marcar como escuchado", "maxSelectedRows": "Solo puedes seleccionar hasta {{maxSelectedRows}} archivos", "maxSelectedRowsWarning": "¡Demasiados archivos seleccionados!", "messages": { @@ -455,6 +507,8 @@ "deleteSuccessfulDetails": "Archivo \"{{file}}\" eliminado con éxito!", "deleting": "Eliminando...", "deletingDetails": "Eliminando archivo \"{{file}}\"...", + "downloadFailed": "Error en la descarga", + "downloadFailedDetails": "Error al descargar el archivo \"{{file}}\"", "errorFetchingDirContent": "Error al cargar el contenido del directorio", "errorFetchingDirContentDetails": "Error al cargar el contenido del directorio \"{{path}}\": ", "migrationFailed": "Migración fallida", @@ -477,7 +531,9 @@ "renamingFailed": "Error al renombrar", "renamingFailedDetails": "Error al renombrar el archivo \"{{fileSource}}\" a \"{{fileTarget}}\": ", "renamingSuccessful": "¡Archivo renombrado con éxito!", - "renamingSuccessfulDetails": "Archivo \"{{fileSource}}\" renombrado a \"{{fileTarget}}\" con éxito!" + "renamingSuccessfulDetails": "Archivo \"{{fileSource}}\" renombrado a \"{{fileTarget}}\" con éxito!", + "toggleListenedFailed": "No se pudo cambiar el estado de escuchado", + "toggleListenedFailedDetails": "No se pudo cambiar el estado de escuchado del archivo \"{{file}}\": " }, "migrateContentToLib": "Migrar TAF a la biblioteca (por/audioId/)", "migrateContentToLibRoot": "Migrar TAF a la biblioteca (raíz)", @@ -507,7 +563,7 @@ "series": "Serie", "size": "Tamaño", "tafMeta": { - "edit": "(WIP - aún no implementado) Editar metadatos" + "edit": "Editar metadatos (modelo personalizado)" }, "tap": { "edit": "Editar lista de reproducción de Tonie", @@ -519,6 +575,7 @@ } }, "title": "Explorador de archivos", + "unmarkAsListened": "Marcar como no escuchado", "unusedTafsModal": { "actions": "Acciones", "fullPath": "Ruta completa", @@ -642,6 +699,14 @@ "01pluginFeature": "Extiende la funcionalidad de TeddyCloud mediante complementos personalizados" }, "title": "Complementos de TeddyCloud" + }, + "15webLogin": { + "items": { + "01optionalLogin": "inicio de sesión opcional para la interfaz web con varios usuarios (desactivado por defecto)", + "02rateLimit": "sesión con cookie y bloqueo tras varios intentos fallidos", + "03userLimit": "hasta 32 usuarios" + }, + "title": "Inicio de sesión de la interfaz web" } }, "futureFeatures": { @@ -699,11 +764,7 @@ "invalidCharactersDetected": "Tu entrada contiene caracteres no válidos, como {{invalidChar}}" }, "language": { - "change": "Cambiar idioma", - "english": "EN", - "french": "FR", - "german": "DE", - "spanish": "ES" + "change": "Cambiar idioma" }, "languageUtil": { "af-af": "Afrikáans (Sudáfrica)", @@ -1022,7 +1083,6 @@ "hint": "Esta es la configuración predeterminada. Algunas de las configuraciones pueden ser reemplazadas por cada una de tus Tonieboxes. Para cambiar estas configuraciones ve a ", "information": "Información", "keepScrolling": "Sigue desplazándote para ver más opciones.", - "legacyGui": "Interfaz de administración heredada", "levelLabel": "Nivel de configuración", "navigationTitle": "Configuración", "notifications": { @@ -1075,12 +1135,35 @@ "toniesJsonUpdateSuccessful": "¡Tonies(.custom).json recargado con éxito!", "tryToRestartTC": "Intentando reiniciar TeddyCloud", "warning": "¡Atención! Nivel de configuración", - "warningHint": "Actualmente, los campos de texto deben guardarse explícitamente hasta que se implemente una mejor solución. Para hacerlo, haz clic en el símbolo de guardar al final del campo. Si faltan configuraciones, aumenta el 'Nivel de configuración'." + "warningHint": "Actualmente, los campos de texto deben guardarse explícitamente hasta que se implemente una mejor solución. Para hacerlo, haz clic en el símbolo de guardar al final del campo. Si faltan configuraciones, aumenta el 'Nivel de configuración'.", + "webAuth": { + "addUser": "Añadir usuario", + "changePassword": "Cambiar contraseña", + "changePasswordFor": "Cambiar la contraseña de {{username}}", + "createFirstUser": "Crea un usuario antes de activar el inicio de sesión.", + "deleteContent": "¿Eliminar el usuario {{username}}?", + "deleteLastContent": "¿Eliminar el último usuario {{username}}? La protección de inicio de sesión se desactivará.", + "deleteLastHint": "Puedes volver a activar la protección después de crear un usuario.", + "deleteLastTitle": "Eliminar el último usuario", + "deleteTitle": "Eliminar usuario", + "deleteUser": "Eliminar", + "enabled": "Exigir inicio de sesión en la interfaz web", + "envOverride": "La protección de inicio de sesión está desactivada por TEDDYCLOUD_WEB_AUTH_DISABLE.", + "intro": "Todos los usuarios tienen acceso completo a la interfaz web. Las APIs de las cajas no se ven afectadas.", + "loadFailed": "No se pudieron cargar los usuarios", + "navigationTitle": "Inicio de sesión web", + "passwordChanged": "Contraseña actualizada", + "passwordChangedDetails": "La contraseña de {{username}} se ha actualizado.", + "saveFailed": "No se pudieron guardar los cambios", + "title": "Inicio de sesión web", + "users": "Usuarios" + } }, "teddycloud": { "newVersionAvailable": "¡Hay una nueva versión disponible!", "newVersionDetailsAvailable": "No estás usando la versión más reciente de TeddyCloud. Actualiza para obtener todos los beneficios de la última versión.", "newVersionDetailsAvailableDevelop": "No estás usando la versión más reciente de la versión develop de TeddyCloud. Actualiza para obtener todos los beneficios de la última versión develop.", + "newVersionDetailsAvailableReleaseCandidate": "No estás utilizando la última versión candidata de TeddyCloud. ¡Actualiza para aprovechar todas las ventajas de la última versión candidata!", "newVersionShort": "Actualización disponible", "openNewVersionInGithub": "Abrir la última versión en GitHub" }, @@ -1099,6 +1182,19 @@ "status": "Estado", "troubleShooting": "Resolución de problemas" }, + "boxCertificate": { + "nextConnection": "El estado se actualiza con la próxima conexión de la Toniebox.", + "pin": "Certificado fijado (SHA-256)", + "presented": "Certificado presentado (SHA-256)", + "status": "Estado", + "statusText": { + "mismatch": "¡Otro certificado distinto del fijado!", + "none": "Aún no comprobado", + "pinned": "Certificado fijado", + "trusted": "Certificado emitido por TeddyCloud" + }, + "title": "Comprobación del certificado" + }, "boxFlashingCommon": { "adguard": { "adGuardRequirement": "AdGuard: Asegúrate de que AdGuard Home esté instalado y configurado en un dispositivo de tu red.", @@ -1273,6 +1369,7 @@ "setupTeddyCloudText": "¡Felicidades! Al llegar a esta página, ¡has configurado TeddyCloud con éxito!", "title": "Añade tu Toniebox a TeddyCloud", "UARTHint": { + "ackUARTHint": "He configurado el voltaje correcto (3,3 V) e instalado el controlador más reciente necesario.", "description": "Antes de continuar, por favor revisa lo siguiente:", "imageAlt1": "Ejemplo de jumper 1", "imageAlt2": "Ejemplo de jumper 2", @@ -1308,6 +1405,7 @@ }, "applyingPatches": "Aplicando parches", "applyingPatchesSection": { + "altPortHint": "Si has creado un parche de puerto, colócalo también en /revvox/boot/patch/ y añade \"altPort.custom\" a la lista de parches.", "examplengCFGJsonCollapse": { "collapseTitle": "Ejemplo de \"ngCfg.json\" con el parche \"altUrl.custom.305\" usando \"ofw2\"" }, @@ -1372,7 +1470,10 @@ "connectToTonieboxProceed": "¡Ahora estás listo para continuar!", "connectToTonieboxText": "El LED verde en la placa principal de la Toniebox debe permanecer encendido de forma continua (sin parpadear). Si no lo hace, es posible que las conexiones estén flojas o incorrectas. Desconecta el programador, verifica todas las conexiones y vuelve a intentarlo. Si estás trabajando con cables finos, considera usar pegamento caliente para asegurarlos y evitar que se muevan. Además, asegúrate de que la Toniebox esté alimentada mediante su cargador.", "connectToTonieboxTextUart": "Asegúrate de que el UART esté configurado a 3.3V antes de conectar el programador UART a tu computadora mediante USB.", - "createPatch": "Crear altUrl.custom.305.patch", + "createHostNamePatch": "Crear altUrl.custom.305.patch", + "createPortPatch": "Crear altPort.custom.patch", + "customPortPatch": "Opcional: Crear un parche de puerto (altPort.custom.json)", + "customPortPatchHint": "Solo es necesario si la Toniebox debe conectarse a TeddyCloud a través de un puerto distinto de 443, por ejemplo mediante un proxy que enruta por puerto. El parche funciona con las versiones de firmware 3.1.0 BF2, 3.3.0 y 3.4.0. hackiebox-ng ya incluye altPort.json para el puerto 10443; crea tu propio parche solo para otro puerto. Las Toniebox CC3235 y la Toniebox 2 no se pueden parchear: siempre se conectan al puerto 443.", "customUrlPatch": "Crear un parche de URL personalizado (altUrl.custom.305.json)", "customUrlPatchHint": "Por favor, crea este parche solo si los parches predefinidos altUrl.tc.fritz.box.json y altUrl.305.json no funcionan con tu configuración actual de TeddyCloud. Si satisfacen tus necesidades, puedes continuar haciendo clic en 'Siguiente'.", "dedicatedUart": "UART dedicado", @@ -1395,6 +1496,7 @@ "hint": "Actualmente no hay soporte directo para flashear tu Toniebox CC3200 con TeddyCloud, pero encontrarás una guía paso a paso aquí:", "hint2": "Sigue luego el camino para CC3200:", "hintPatchHost": "Ingresa el nombre de host de tu instancia de TeddyCloud y luego haz clic en 'Crear altUrl.custom.305.patch'. El parche se descargará automáticamente después.", + "hintPatchPort": "Introduce el puerto al que debe conectarse la Toniebox y haz clic en 'Crear altPort.custom.patch'. El parche se descargará automáticamente.", "hostname": "Nombre de host de TeddyCloud:", "hostnameTooLong": "El nombre de host no debe exceder los 12 caracteres.", "installBootloader": "Instalar el Custom Bootloader HackieboxNG", @@ -1461,6 +1563,8 @@ "next": "Siguiente", "patches": "Parches", "patchesMoreInformationLink": "Más información sobre parches personalizados", + "port": "Puerto de TeddyCloud:", + "portInvalid": "El puerto debe estar entre 1 y 65535.", "predefinedUrlPatches": "Parches de URL predefinidos", "predefinedUrlPatchesIntro": "Para cajas CC3200, se recomienda usar un parche altUrl. HackieboxNG ya incluye dos parches altUrl predefinidos:", "previous": "Atrás", @@ -1474,6 +1578,7 @@ "certificatesDumpedCAreplacementFlashed": "Certificados extraídos y CA de reemplazo flasheado", "CH341AProgrammer": { "CH341AProgrammer": "Configurar conexión del programador CH341A", + "CH341AProgrammerClampConnection": "Programador CH341A con pinza SOP8 conectado a la memoria flash IS25LP032D", "CH341Sop8remove": "Recorta aquí hasta que encaje correctamente.", "flashMemoryDumpText1": "Conecta la pinza a la memoria flash IS25LP032D. Asegúrate de que el cable rojo en la pinza se alinee con el punto en el chip que marca el pin 1.", "flashMemoryDumpText2": "Si la pinza no se sostiene, puede estar chocando con un bloque negro cercano. Recorta más plástico si es necesario. No conectes aún el CH341A. Revisa nuevamente las posiciones de los cables.", @@ -1542,6 +1647,7 @@ "readingFlashText2": "Se recomienda realizar varias lecturas y comparar los resultados.", "writingFlash": "Escribir en la memoria flash con Raspberry Pi Pico" }, + "portHint": "Las cajas CC3235 siempre se conectan al puerto 443: no hay parche de puerto para ellas, así que TeddyCloud debe estar accesible en el puerto 443 (un proxy solo puede enrutarlas por IP).", "previous": "Atrás", "serprogFirmwareLink": "Encuentra las instrucciones de instalación aquí", "title": "Flasheo de la caja CC3235", @@ -1551,8 +1657,8 @@ "confirmDeleteModal": "Confirmar eliminación", "connectESP32Modal": { "beware": "¡Cuidado!", - "connectESP32Text1.1": "Por favor, conecta el jumper J100 (Boot) y reinicia la caja para ponerla en el modo UART requerido.", - "connectESP32Text1.2": "Conecta tu UART de 3.3V a J103:", + "connectESP32Text1_1": "Por favor, conecta el jumper J100 (Boot) y reinicia la caja para ponerla en el modo UART requerido.", + "connectESP32Text1_2": "Conecta tu UART de 3.3V a J103:", "connectESP32Text2": "Si no estás seguro de en qué modo está iniciando el ESP32, verifica lo siguiente.", "downloadMode": "Modo de descarga", "downloadModeText": "LED apagado, sin sonido, Salida serial:", @@ -1596,7 +1702,7 @@ "attention": "Atención", "baudRate": "Velocidad de transmisión: ", "baudRateInfo": "Si tienes problemas para leer o escribir en la memoria flash, prueba a cambiar la velocidad de transmisión. El valor predeterminado es 921600.", - "browserNotSupported": "La funcionalidad de flasheo integrado de la caja ESP32 solo se puede utilizar con navegadores que admiten la API Web Serial. Desafortunadamente, tu navegador actual no admite esta función. Por favor, utiliza un navegador que soporte la API Web Serial, como Chrome o Edge.", + "browserNotSupported": "La funcionalidad de flasheo integrado de la caja ESP32 solo se puede utilizar con navegadores que admiten la API Web Serial. Desafortunadamente, tu navegador actual no admite esta función. Por favor, utiliza un navegador que soporte la API Web Serial, como Chrome o Edge o Firefox (> v151).", "esp32flasher": { "ackBackUpWithOtherTool": "He creado una copia de seguridad con otra herramienta o soy consciente de que continúo sin una copia de seguridad adicional bajo mi propia responsabilidad.", "ackLatestFirmware": "Confirmo que la caja se conectó a la nube original, recibió el firmware más reciente y pudo reproducir un Tonie correctamente.", @@ -1657,8 +1763,9 @@ "hintESP32FirmwareFlashed": "El parcheo debería estar hecho. Desconecta la Toniebox del UART, quita el cortocircuito de J100 y reinicia la caja. ¡Ahora debería poder conectarse a TeddyCloud!", "hintESP32FirmwareReseted": "El restablecimiento debería estar completado. Por favor, desconecta la Toniebox del UART, retira el cortocircuito de J100 y reinicia la caja. Ya no debería estar conectada a tu Teddycloud. Realiza una comprobación de frescura para verificar que todo funciona bien.", "hintFlashESP32RevvoxFlasher": "Haz clic en \"Flashear ESP32\", elige el puerto COM si es necesario y espera a que el paso termine.", - "hintPatchFlash": "Para aplicar el flash seleccionado/leído con tu certificado CA de TeddyCloud y posiblemente hacer otros ajustes como el nombre de host de TeddyCloud y/o credenciales WiFi, haz clic en 'Parchear' después de ingresar la información o continúa con el siguiente paso sin parchear.", + "hintPatchFlash": "Para aplicar el flash seleccionado/leído con tu certificado CA de TeddyCloud y posiblemente hacer otros ajustes como el nombre de host y el puerto de TeddyCloud y/o credenciales WiFi, haz clic en 'Parchear' después de ingresar la información o continúa con el siguiente paso sin parchear.", "hintPatchHost": "Introduce el nombre de host deseado para tu instancia de TeddyCloud.", + "hintPatchPort": "Puerto al que se conecta la Toniebox. Deja 443 salvo que la Toniebox deba conectarse a TeddyCloud a través de otro puerto, por ejemplo mediante un proxy que enruta por puerto. Las Toniebox CC3235 y la Toniebox 2 no se pueden parchear: siempre se conectan al puerto 443.", "hintPatchWifi": "Opcionalmente ingresa las credenciales WiFi para tu Toniebox. Por favor, solo configúralo si realmente es necesario; normalmente, si tu Toniebox estaba conectada a tu WiFi justo antes de aplicar el parche, no necesitas hacerlo.", "hintReadESP32ImportFlash": "Ahora desconecta la fuente de alimentación de tu Toniebox. Asegúrate de que tu UART funcione en modo de 3,3 V. Conecta tu UART al PC y a la Toniebox. Puentea los puntos de soldadura J100. Después conecta de nuevo la Toniebox a la alimentación. Haz clic en \"Leer flash\" o \"Cargar archivo\" y elige el puerto COM. Finalmente espera a que el paso termine.", "hintSaveLog": "Por favor, guarda la salida del registro en un archivo para un análisis adicional en caso de problemas.", @@ -1681,6 +1788,8 @@ "patchingFlashImage": "Parcheando imagen de flash...", "patchingSuccessful": "Parcheo exitoso, listo para flashear {{size}} MiB.", "payloadReceived": "Carga útil recibida", + "port": "Puerto de TeddyCloud:", + "portInvalid": "El puerto debe estar entre 1 y 32767.", "portOpenFailedInUse": "Error al abrir este puerto. ¿Ya está en uso?", "preparingFlash": "Preparando flash de {{size}} MiB", "previous": "Anterior", @@ -1712,7 +1821,7 @@ "writingFinished": "Escritura finalizada", "writingFlash": "Escribiendo flash de {{size}} MiB..." }, - "hint": "Esta sección solo está disponible cuando se utiliza HTTPS y un navegador que admite la API Web Serial, como Chrome o Edge. Si deseas flashear tu Toniebox ESP32, haz clic en \"Proceder a HTTPS\" y serás redirigido automáticamente a la versión HTTPS. Tu navegador puede advertir que la conexión no es segura. Esto está bien; confírmalo y procede a abrir la página de todos modos. Es posible que debas mostrar más detalles en la advertencia.", + "hint": "Esta sección solo está disponible cuando se utiliza HTTPS y un navegador que admite la API Web Serial, como Chrome o Edge o Firefox (> v151). Si deseas flashear tu Toniebox ESP32, haz clic en \"Proceder a HTTPS\" y serás redirigido automáticamente a la versión HTTPS. Tu navegador puede advertir que la conexión no es segura. Esto está bien; confírmalo y procede a abrir la página de todos modos. Es posible que debas mostrar más detalles en la advertencia.", "hintLegacyApproach": "Si prefieres no usar un navegador que soporte WebSerial, aún puedes usar el método legado (sin GUI, solo línea de comandos).", "httpsSettings": "Configuración de HTTPS", "legacy": { @@ -1725,6 +1834,7 @@ "flashCAreplacementText1": "Reemplaza la CA original dentro de tu volcado flash con esptool.", "flashCAreplacementText2": "Vuelve a ensamblar tu Toniebox. Si ya configuraste el nombre de host de TeddyCloud, puedes omitir el paso de DNS para el ESP32, si no, continúa con el paso de DNS para el ESP32.", "flashCAreplacementText3": "Tu Toniebox ahora debería poder conectarse a tu TeddyCloud. Haz una verificación de frescura y haz clic en \"Verificar cajas disponibles\".", + "flashESPtoolScreen": "Pantalla de ESPtool", "followLegacyApproach": "O utiliza el método legado (sin GUI, solo línea de comandos).", "hint": "¡Hay soporte directo para flashear tu Toniebox ESP32 con TeddyCloud disponible!", "installESPTool": "Instalar la herramienta ESP reciente", @@ -1740,6 +1850,7 @@ "linkWikiGeneral": "Parchear tu Toniebox para usar con TeddyCloud", "linkWikiSpecific": "Configuración específica para ESP32", "navigationTitle": "ESP32", + "processing": "Procesando...", "redirect": "Proceder a HTTPS", "title": "Flasheo de la caja ESP32" }, @@ -1828,6 +1939,22 @@ }, "alternativeSource": "Este Tonie {{originalTonie}} tiene asignado el contenido {{assignedContent}}!", "alternativeSourceUnknown": "¡Este Tonie {{originalTonie}} tiene asignado contenido alternativo!", + "assignNextEpisode": { + "action": "Asignar el siguiente episodio \"{{file}}\"", + "failedDetails": "No se pudo asignar \"{{episode}}\": ", + "failedMessage": "¡Error al asignar el siguiente episodio!", + "noneLeft": "No quedan episodios sin escuchar en esta carpeta.", + "successDetails": "¡\"{{episode}}\" se asignó como siguiente episodio!", + "successMessage": "¡Siguiente episodio asignado!" + }, + "assignPrevEpisode": { + "action": "Asignar el episodio anterior \"{{file}}\"", + "failedDetails": "No se pudo asignar \"{{episode}}\": ", + "failedMessage": "¡Error al asignar el episodio anterior!", + "noneLeft": "No hay episodios anteriores disponibles en esta carpeta.", + "successDetails": "¡\"{{episode}}\" se asignó como episodio anterior!", + "successMessage": "¡Episodio anterior asignado!" + }, "cancel": "Cancelar", "closeAudioPlayer": "Cerrar reproductor", "closeAudioPlayerPopover": "¡Cerrar detiene la reproducción!", @@ -1866,10 +1993,18 @@ }, "audio": { "libraryLabel": "Audio personalizado ({{library}})", + "locateInLibrary": "Buscar en la biblioteca", + "notFoundInLibrary": "No encontrado en la biblioteca", "notInIndex": "No en el índice de audio personalizado: {{audioId}}/{{hash}}", "placeholder": "Seleccionar audio personalizado de {{library}}", "selectFromLibrary": "Seleccionar audio de {{library}}" }, + "audioSource": { + "linkedDescription": "Los cambios se aplican a todos los archivos y tags que usan este modelo.", + "linkedTitle": "{{file}} está vinculado a este modelo personalizado", + "newDescription": "Al guardar se crea un nuevo modelo personalizado y se vincula al archivo mediante el ID de audio y el hash.", + "newTitle": "Aún no hay un modelo personalizado para {{file}}" + }, "batch": { "description": "Los cambios se aplican a todos los modelos seleccionados.", "title": "Edición por lotes activa" @@ -1911,6 +2046,11 @@ "noChangesBody": "No hay nada que guardar en este momento.", "noChangesTitle": "No se detectaron cambios", "optionalColumns": "Columnas opcionales", + "overridesOriginal": { + "description": "Los cambios guardados reemplazan la información original en todos los lugares donde se usa este audio o modelo. Elimina el modelo personalizado para volver a mostrar el original.", + "tag": "Reemplaza el original {{model}}", + "title": "Reemplaza el Tonie original {{model}} ({{name}})" + }, "pagination": { "modelsPerPage": "modelos/página" }, @@ -1949,6 +2089,13 @@ }, "tableMenu": "Menú de tabla", "title": "Editor de modelos", + "tracks": { + "fewerNamesDescription": "El archivo tiene {{tracks}} pistas. Los nombres conservan su posición, pero el diálogo de información solo permite saltar a una pista si están listadas todas las pistas hasta la última.", + "fewerNamesTitle": "Las últimas pistas del archivo no tienen nombre", + "moreNamesDescription": "Los nombres adicionales se guardan y se muestran, pero no tienen posición en el archivo. El diálogo de información no puede entonces saltar a pistas individuales.", + "moreNamesTitle": "Más nombres que pistas en el archivo ({{tracks}})", + "notInFile": "Nombres adicionales sin pista en el archivo:" + }, "unsaved": "{{count}} cambio(s) sin guardar", "validation": { "title": "Por favor corrige estos problemas primero" @@ -1979,6 +2126,8 @@ "modelInfoHeading": "Info del modelo", "modelInfoLanguage": "Idioma", "modelInfoModel": "Modelo", + "modelInfoNo": "No", + "modelInfoRelease": "Lanzamiento", "modelInfoSeries": "Serie", "modelInfoTitle": "Título", "modelReadOnlyHint": "Tonie original: el modelo no se puede cambiar. Solo puedes asignar audio personalizado.", @@ -1986,6 +2135,7 @@ "placeholderSearchForARadioStream": "Buscar una transmisión de radio", "save": "Guardar", "setAudioFromModel": "Establecer audio del modelo", + "setAudioFromModelNoMapping": "El modelo seleccionado no tiene audio asignado.", "setAudioFromModelUnavailableInLibrary": "El audio del modelo aún no está disponible en la biblioteca.", "setModelFromAudio": "Establecer modelo desde el audio", "source": "Fuente", @@ -1999,6 +2149,7 @@ "encoding": "Codificación", "errorFileProcessing": "No se pudo procesar el archivo de audio: ", "filename": "Nombre del archivo", + "loading": "Cargando...", "loadingWasmEncoder": "Cargando el codificador WASM...", "maxFiles": "Solo puedes agregar hasta {{maxFiles}} archivos.", "navigationTitle": "Codificador de audio", @@ -2040,9 +2191,14 @@ }, "editIcon": { "createNewModel": { - "description": "Abre una ventana para introducir los detalles del nuevo modelo. Esta función aún está en desarrollo.", + "description": "Abre el editor de modelos personalizados para introducir los detalles de un nuevo modelo para tu Tonie/Tag.", "text": "Crea un modelo personalizado para tu Tonie/Tag.", - "title": "Crear nuevo modelo (en desarrollo)" + "title": "Crear nuevo modelo" + }, + "editModel": { + "description": "Edita el modelo personalizado seleccionado en el editor de modelos personalizados. Las entradas de audio muestran enseguida su ID de audio y hash; con el icono de localizar de una entrada se busca su archivo en tu biblioteca.", + "text": "Edita el modelo personalizado asignado al Tonie/Tag.", + "title": "Editar modelo personalizado" }, "model": { "description": "Busca y asigna un modelo. La imagen se mostrará en la vista general.", @@ -2054,6 +2210,16 @@ "text": "Busca emisoras de radio online (disponible si la API de radio-browser.info está activa).", "title": "Búsqueda de emisoras" }, + "setAudioFromModel": { + "description": "Aparece cuando se selecciona un modelo que no coincide con el audio actual. La biblioteca solo se busca al hacer clic en el botón, lo que puede tardar un momento. Si no se encuentra ningún archivo, se muestra una indicación bajo el campo de origen.", + "text": "Asigna el archivo de audio que corresponde al modelo seleccionado.", + "title": "Establecer audio desde el modelo" + }, + "setModelFromAudio": { + "description": "Aparece cuando el audio asignado pertenece a un modelo conocido. No hace falta buscar en la biblioteca; el modelo se toma de la información del audio.", + "text": "Asigna el modelo que corresponde al audio actual.", + "title": "Establecer modelo desde el audio" + }, "source": { "description": "Puedes asignar contenido desde tu biblioteca (por ejemplo, archivos TAF o TAP) o introducir una URL de streaming.", "text": "Muestra la fuente de contenido actualmente asignada.", @@ -2064,9 +2230,9 @@ }, "infoIcon": { "download": { - "description": "Si está disponible, puedes descargar el archivo TAF vinculado como un único archivo .ogg.", - "text": "Permite descargar el archivo TAF como un archivo de audio .ogg.", - "title": "Descargar TAF como .ogg" + "description": "Si está disponible, las pistas se pueden descargar como archivos *.ogg separados (o como un solo archivo *.ogg).", + "text": "Abre la selección de pistas para descargar el TAF como *.ogg.", + "title": "Descargar TAF como *.ogg" }, "exists": { "description": "El estado 'existe' se muestra como 'sí' si el archivo fuente asignado está presente.", @@ -2219,6 +2385,7 @@ "exportToJson": "Exportar a JSON", "hideSelectedTags": "Ocultar tags", "select": "Seleccionar", + "selectAll": "Seleccionar todo", "selectButtonTooltip": "Activar modo de selección", "selected": "Tags/Tonies seleccionados", "setLive": "Activar live", @@ -2231,6 +2398,17 @@ "navigationTitle": "Sonidos del sistema", "title": "Sonidos del sistema" }, + "tafDownload": { + "asOneFile": "Descargar como un solo archivo *.ogg", + "downloadCount": "Descargar {{count}} pistas como *.ogg", + "downloadOne": "Descargar 1 pista como *.ogg", + "duration": "Tiempo", + "modalTitle": "Descargar pistas como *.ogg", + "selectAll": "Seleccionar todas las pistas", + "selectedCount": "{{selected}} de {{total}} seleccionadas", + "track": "Pista", + "unnamedTrack": "Pista {{number}}" + }, "tafHeaderInvalid": "TAF no válido", "tafHeaderOf": "TAF-Header de ", "tafHeaderToniesJsonHint": "No debes editar los datos aquí cuando los envíes al tonies-json. ¡Esto incluye el número de pistas, incluso si es diferente del número de pistas en el campo track-desc!", @@ -2309,11 +2487,15 @@ "customImageTitle": "Introduce un título para imprimir", "customImageUpload": "Añadir imagen temporal (sin Biblioteca)", "diameter": "Diámetro interior", + "downloadPdf": "Descargar PDF", "empty": "Vacío - ¡Primero selecciona Tonies o Tags o añade una imagen propia!", "episodes": "Episodios", "fontFamily": "Fuente de etiqueta", "fontFamilyPlaceholder": "Elegir una fuente", + "height": "Altura", + "imageBottom": "Margen inferior de la imagen", "imageBottomLeft": "Margen inferior/izquierdo de la imagen", + "imageLeft": "Margen izquierdo de la imagen", "imagePosition": "Posición de la imagen", "imageScale": "Escala de imagen", "intro": "Busca todos los Tonies y etiquetas - tanto los oficiales como los personalizados - para crear tu hoja imprimible de Traveltonies (moneda).", @@ -2339,6 +2521,7 @@ "paperSize": "Tamaño del papel", "placeholder": "¡Introduce un término de búsqueda!", "preview": "Vista previa etiqueta", + "printMarginNotification": "Los márgenes de impresión CSS solo son compatibles de forma fiable con Chrome. Utiliza Chrome para garantizar una impresión correcta de las etiquetas.", "printMode": "Modo", "printModeImageAndText": "Imagen + Texto", "printModeImageAndTextTooltip": "Imprime ambos lados de tu moneda Traveltonie: imagen por un lado, título e información de episodio por el otro", @@ -2368,7 +2551,8 @@ "square": "Cuadrada", "textFontSize": "Tamaño de texto", "title": "TeddyStudio", - "trackTitles": "Títulos (uno por línea)" + "trackTitles": "Títulos (uno por línea)", + "width": "Ancho" }, "title": "Tonies", "tonies": { diff --git a/contrib/data/www/web/translations/fr.json b/contrib/data/www/web/translations/fr.json index 69cd06d6a..03798f1d0 100644 --- a/contrib/data/www/web/translations/fr.json +++ b/contrib/data/www/web/translations/fr.json @@ -10,6 +10,23 @@ "errorNoOggOpusSupportByApple": "Votre navigateur ne prend pas en charge les fichiers OGG/Opus (taf) pour le moment. Si vous utilisez un appareil Apple, aidez-nous en soumettant un ticket à Apple pour demander une solution !", "unknownSource": "Inconnu" }, + "auth": { + "loginFailed": "Nom d'utilisateur ou mot de passe invalide", + "loginHint": "Connectez-vous à TeddyCloud", + "loginRateLimited": "Trop de tentatives de connexion. Réessaie dans quelques minutes.", + "loginSubmit": "Se connecter", + "loginTitle": "Connexion web", + "logout": "Se déconnecter", + "newPassword": "Nouveau mot de passe", + "password": "Mot de passe", + "passwordMin": "Au moins 4 caractères", + "passwordRequired": "Le mot de passe est requis", + "retry": "Réessayer", + "statusLoadFailed": "Impossible de charger l’état d’authentification", + "statusLoadFailedDetails": "L’état d’authentification n’a pas pu être chargé. Vérifiez la connexion à TeddyCloud et réessayez.", + "username": "Nom d'utilisateur", + "usernameRequired": "Le nom d'utilisateur est requis" + }, "common": { "cancel": "Annuler" }, @@ -125,6 +142,7 @@ "fields": { "author": "Auteur du plugin", "description": "Résumé court du plugin", + "hideInNav": "Indicateur précisant si le plugin doit être masqué dans la navigation (true/false)", "icon": "L'icône utilisée pour le plugin. Doit être un composant d'icône Ant Design valide depuis https://ant.design/components/icon, par ex. TrophyOutlined, TagsOutlined", "pluginHomepage": "Page d'accueil du plugin", "pluginName": "Le titre affiché dans les menus (obligatoire)", @@ -140,11 +158,16 @@ }, "helpButton": "Aide", "helpTextCreatePlugin": "Un texte sur la création d'un plugin", + "hidden": "Masqué", "installedPlugins": "Plugins installés", "intro": "Vous trouverez ici vos plugins TeddyCloud ajoutés et pouvez ajouter de nouveaux plugins fournis par vous-même ou la communauté TeddyCloud !", "navigationTitle": "Plugins TC", "ok": "OK", "open": "Ouvrir le plugin", + "pagination": { + "pageSelector": "Plugins par page", + "showAll": "Afficher tout" + }, "plugin": "Plugin", "title": "Plugins TeddyCloud", "upload": { @@ -255,7 +278,7 @@ "translations": { "allTranslationStrings": "Toutes les chaînes de traduction", "extraKeys": "Clés supplémentaires", - "hint": "Si tu vois une mauvaise traduction, reviens vers nous sur Telegram. Le français et l'espagnol sont actuellement complètement traduits par ChatGPT.", + "hint": "Si vous remarquez une mauvaise traduction, veuillez nous contacter sur Telegram. Seuls l'anglais, l'allemand, le français et l'espagnol sont activement maintenus, le français et l'espagnol étant actuellement entièrement traduits par IA. Toutes les autres langues supplémentaires ne sont pas activement maintenues ; n'hésitez donc pas à contribuer aux traductions manquantes. Vous pouvez également ajouter d'autres langues en traduisant un fichier de langue existant, en l'enregistrant sous languagecode.json dans le dossier public/translations, puis en redémarrant le serveur.", "key": "Clé", "language": "Langue", "missing": "MANQUANT", @@ -315,6 +338,15 @@ ], "fileBrowser": { "actions": "Actions", + "assignToTonie": { + "action": "Assigner à une Tonie", + "failedDetails": "Impossible d'assigner à \"{{series}}\" : ", + "failedMessage": "Échec de l'assignation !", + "searchPlaceholder": "Rechercher des Tonies...", + "successDetails": "Fichier assigné à \"{{series}}\" avec succès !", + "successMessage": "Tonie assignée !", + "title": "Assigner à une Tonie" + }, "attention": "Veuillez noter !", "cancel": "Annuler", "confirmDeleteDialog": "Êtes-vous sûr de vouloir supprimer {{fileToDelete}}?", @@ -334,10 +366,10 @@ "placeholder": "Entrez le nom du dossier" }, "customJsonSnippetModal": { - "allFiles": "Affiche l’extrait de tous les fichiers TAF avec des données d’en-tête valides.", + "allFiles": "Affiche l'extrait de tous les fichiers TAF avec des données d'en-tête valides.", "noValidHeaders": "Aucun en-tête TAF valide trouvé.", "ok": "OK", - "selectedFiles": "Affiche uniquement l’extrait des fichiers TAF sélectionnés.", + "selectedFiles": "Affiche uniquement l'extrait des fichiers TAF sélectionnés.", "title": "Extrait tonies.custom.json" }, "date": "Date", @@ -345,6 +377,7 @@ "deleteMultiple": "{{selectedRowCount}} lignes sélectionnées à supprimer", "downloadFile": "Télécharger le fichier", "downloadFileAsOgg": "Télécharger TAF en *.ogg", + "downloadTracks": "Télécharger les pistes TAF en *.ogg", "encodeFiles": { "addFiles": "Ajouter des fichiers", "cancel": "Annuler", @@ -364,22 +397,40 @@ "filter": "Filtrer dans le dossier actuel", "help": { "actionItems": { + "assignToTonie": { + "text": "Assigne le fichier sélectionné comme contenu à un Tonie/Tag.", + "title": "Assigner à un Tonie" + }, "deleteFileFolder": { "text": "Supprime le fichier ou le dossier (seulement si le dossier est vide). Remarque : si ce fichier est attribué à un Tonie/Tag, la référence ne sera pas mise à jour.", "title": "Supprimer le fichier/dossier" }, + "downloadFile": { + "text": "Télécharge le fichier sélectionné. Pour les fichiers TAF contenant plusieurs pistes, une sélection des pistes s'ouvre pour les télécharger en fichiers *.ogg séparés (ZIP) ou en un seul fichier *.ogg ; les fichiers TAF d'une seule piste sont téléchargés en un seul fichier *.ogg.", + "title": "Télécharger le fichier" + }, "duplicateTAPFile": { "text": "Crée une copie du fichier TAP sélectionné.", "title": "Dupliquer le fichier TAP" }, "editTAFMetadata": { - "text": "Ouvre l'éditeur de métadonnées pour modifier les détails du fichier TAF. Les modifications sont enregistrées dans tonies.custom.json. (WIP)", + "text": "Ouvre l'éditeur de modèles personnalisés pour le fichier TAF. Un modèle personnalisé déjà lié (même ID audio et même hash) est modifié, sinon un nouveau modèle est créé pour le fichier. Les modifications sont enregistrées dans tonies.custom.json.", "title": "Modifier les métadonnées TAF" }, "editTAPPlaylist": { "text": "Ouvre l'éditeur de TeddyAudio Playlist pour gérer les fichiers TAP.", "title": "Modifier la liste de lecture TAP" }, + "markAsListened": { + "listened": { + "text": "Le fichier est actuellement marqué comme écouté. Cliquez pour le marquer comme non écouté.", + "title": "Marquer comme non écouté" + }, + "unlistened": { + "text": "Le fichier est actuellement marqué comme non écouté. Cliquez pour le marquer comme écouté.", + "title": "Marquer comme écouté" + } + }, "migrateToAudioIdFolder": { "text": "Déplace le fichier TAF dans le dossier 'by/audioId' de la bibliothèque et met à jour la source dans le fichier JSON.", "title": "Migrer le fichier TAF dans la bibliothèque par AudioId" @@ -446,6 +497,7 @@ }, "hidden": "Caché", "image": "Image", + "markAsListened": "Marquer comme écouté", "maxSelectedRows": "Vous ne pouvez sélectionner que jusqu'à {{maxSelectedRows}} fichiers", "maxSelectedRowsWarning": "Trop de fichiers sélectionnés !", "messages": { @@ -455,6 +507,8 @@ "deleteSuccessfulDetails": "Fichier \"{{file}}\" supprimé avec succès!", "deleting": "Suppression...", "deletingDetails": "Suppression du fichier \"{{file}}\"...", + "downloadFailed": "Échec du téléchargement", + "downloadFailedDetails": "Échec du téléchargement du fichier \"{{file}}\"", "errorFetchingDirContent": "Échec du chargement du contenu du répertoire", "errorFetchingDirContentDetails": "Échec du chargement du contenu du répertoire \"{{path}}\" : ", "migrationFailed": "Échec de la migration", @@ -477,7 +531,9 @@ "renamingFailed": "Échec du renommage", "renamingFailedDetails": "Échec du renommage du fichier \"{{fileSource}}\" en \"{{fileTarget}}\" : ", "renamingSuccessful": "Fichier renommé avec succès!", - "renamingSuccessfulDetails": "Fichier \"{{fileSource}}\" renommé en \"{{fileTarget}}\" avec succès!" + "renamingSuccessfulDetails": "Fichier \"{{fileSource}}\" renommé en \"{{fileTarget}}\" avec succès!", + "toggleListenedFailed": "Impossible de modifier le statut écouté", + "toggleListenedFailedDetails": "Impossible de modifier le statut écouté du fichier \"{{file}}\" : " }, "migrateContentToLib": "Migrer TAF vers la bibliothèque (par/audioId/)", "migrateContentToLibRoot": "Migrer TAF vers la bibliothèque (racine)", @@ -507,7 +563,7 @@ "series": "Série", "size": "Taille", "tafMeta": { - "edit": "(WIP - non implémenté) Éditer les métadonnées" + "edit": "Éditer les métadonnées (modèle personnalisé)" }, "tap": { "edit": "Modifier la playlist audio Tonie", @@ -519,6 +575,7 @@ } }, "title": "Navigateur de fichiers", + "unmarkAsListened": "Marquer comme non écouté", "unusedTafsModal": { "actions": "Actions", "fullPath": "Chemin complet", @@ -642,6 +699,14 @@ "01pluginFeature": "Étendez les fonctionnalités de TeddyCloud avec des plugins personnalisés" }, "title": "Plugins TeddyCloud" + }, + "15webLogin": { + "items": { + "01optionalLogin": "connexion optionnelle à l'interface web avec plusieurs utilisateurs (désactivée par défaut)", + "02rateLimit": "session par cookie avec blocage après plusieurs échecs de connexion", + "03userLimit": "jusqu'à 32 utilisateurs" + }, + "title": "Connexion à l'interface web" } }, "futureFeatures": { @@ -699,11 +764,7 @@ "invalidCharactersDetected": "Ta saisie contient des caractères invalides, tels que {{invalidChar}}" }, "language": { - "change": "Changer de langue", - "english": "EN", - "french": "FR", - "german": "DE", - "spanish": "ES" + "change": "Changer de langue" }, "languageUtil": { "af-af": "Afrikaans (Afrique du Sud)", @@ -1022,7 +1083,6 @@ "hint": "Ce sont les paramètres par défaut. Certains paramètres peuvent être remplacés par chacun de vos Tonieboxes. Pour modifier ces paramètres, allez à ", "information": "Informations", "keepScrolling": "Continuez à faire défiler pour voir plus d'options.", - "legacyGui": "Ancienne GUI Admin", "levelLabel": "Niveau des paramètres", "navigationTitle": "Paramètres", "notifications": { @@ -1075,12 +1135,35 @@ "toniesJsonUpdateSuccessful": "Tonies(.custom).json rechargé avec succès!", "tryToRestartTC": "Tentative de redémarrer TeddyCloud", "warning": "Attention ! Niveau des paramètres", - "warningHint": "Si des paramètres vous manquent, augmentez le 'Niveau des paramètres'." + "warningHint": "Si des paramètres vous manquent, augmentez le 'Niveau des paramètres'.", + "webAuth": { + "addUser": "Ajouter un utilisateur", + "changePassword": "Changer le mot de passe", + "changePasswordFor": "Changer le mot de passe de {{username}}", + "createFirstUser": "Créez un utilisateur avant d'activer la connexion.", + "deleteContent": "Supprimer l'utilisateur {{username}} ?", + "deleteLastContent": "Supprimer le dernier utilisateur {{username}} ? La protection par connexion sera désactivée.", + "deleteLastHint": "Vous pourrez réactiver la protection après avoir créé un utilisateur.", + "deleteLastTitle": "Supprimer le dernier utilisateur", + "deleteTitle": "Supprimer l'utilisateur", + "deleteUser": "Supprimer", + "enabled": "Exiger une connexion pour l'interface web", + "envOverride": "La protection par connexion est actuellement désactivée par TEDDYCLOUD_WEB_AUTH_DISABLE.", + "intro": "Tous les utilisateurs ont un accès complet à l'interface web. Les API des boîtes ne sont pas concernées.", + "loadFailed": "Impossible de charger les utilisateurs", + "navigationTitle": "Connexion web", + "passwordChanged": "Mot de passe mis à jour", + "passwordChangedDetails": "Le mot de passe de {{username}} a été mis à jour.", + "saveFailed": "Impossible d'enregistrer les modifications", + "title": "Connexion web", + "users": "Utilisateurs" + } }, "teddycloud": { "newVersionAvailable": "Une nouvelle version est disponible !", "newVersionDetailsAvailable": "Tu n'utilises pas la dernière version de TeddyCloud. Mets à jour pour profiter de tous les avantages de la dernière version !", "newVersionDetailsAvailableDevelop": "Tu n'utilises pas la dernière version develop de TeddyCloud. Mets à jour pour profiter de tous les avantages de la dernière version develop !", + "newVersionDetailsAvailableReleaseCandidate": "Vous n'utilisez pas la dernière version candidate de TeddyCloud. Veuillez effectuer la mise à jour pour profiter pleinement des avantages de cette dernière version candidate !", "newVersionShort": "Mise à jour disponible", "openNewVersionInGithub": "Ouvrir la dernière version sur GitHub" }, @@ -1099,6 +1182,19 @@ "status": "Statut", "troubleShooting": "Dépannage" }, + "boxCertificate": { + "nextConnection": "Le statut est mis à jour à la prochaine connexion de la Toniebox.", + "pin": "Certificat épinglé (SHA-256)", + "presented": "Certificat présenté (SHA-256)", + "status": "Statut", + "statusText": { + "mismatch": "Autre certificat que celui épinglé !", + "none": "Pas encore vérifié", + "pinned": "Certificat épinglé", + "trusted": "Certificat émis par TeddyCloud" + }, + "title": "Vérification du certificat" + }, "boxFlashingCommon": { "adguard": { "adGuardRequirement": "AdGuard : Assure-toi qu'AdGuard Home est installé et configuré sur un appareil de ton réseau.", @@ -1273,6 +1369,7 @@ "setupTeddyCloudText": "Félicitations ! En atteignant cette page, vous avez réussi à configurer TeddyCloud !", "title": "Ajoutez votre Toniebox à TeddyCloud", "UARTHint": { + "ackUARTHint": "J'ai réglé la tension correcte (3,3 V) et installé le dernier pilote requis.", "description": "Avant de continuer, vérifie bien les points suivants :", "imageAlt1": "Exemple de cavalier 1", "imageAlt2": "Exemple de cavalier 2", @@ -1308,6 +1405,7 @@ }, "applyingPatches": "Application des patches", "applyingPatchesSection": { + "altPortHint": "Si tu as créé un patch de port, place-le aussi dans /revvox/boot/patch/ et ajoute \"altPort.custom\" à la liste des patches.", "examplengCFGJsonCollapse": { "collapseTitle": "Exemple de \"ngCfg.json\" avec le patch \"altUrl.custom.305\" en utilisant \"ofw2\"" }, @@ -1372,7 +1470,10 @@ "connectToTonieboxProceed": "Tu es maintenant prêt à continuer !", "connectToTonieboxText": "La LED verte de la carte principale de la Toniebox doit rester allumée en continu (sans clignoter). Si ce n'est pas le cas, tes connexions pourraient être mal fixées ou incorrectes. Déconnecte le programmeur, vérifie toutes les connexions et essaie à nouveau. Si tu travailles avec des fils fins, pense à utiliser de la colle chaude pour les fixer et éviter qu'ils ne bougent. De plus, assure-toi que la Toniebox est alimentée via son chargeur.", "connectToTonieboxTextUart": "Assure-toi que l'UART est configuré à 3,3V avant de connecter le programmeur UART à ton ordinateur via USB.", - "createPatch": "Créer altUrl.custom.305.patch", + "createHostNamePatch": "Créer altUrl.custom.305.patch", + "createPortPatch": "Créer altPort.custom.patch", + "customPortPatch": "Optionnel : Créer un patch de port (altPort.custom.json)", + "customPortPatchHint": "Nécessaire uniquement si la Toniebox doit joindre TeddyCloud sur un autre port que 443, par exemple via un proxy qui route selon le port. Le patch fonctionne avec les versions de firmware 3.1.0 BF2, 3.3.0 et 3.4.0. hackiebox-ng fournit déjà altPort.json pour le port 10443 ; crée ton propre patch uniquement pour un autre port. Les Toniebox CC3235 et la Toniebox 2 ne peuvent pas être patchées : elles se connectent toujours au port 443.", "customUrlPatch": "Créer un patch URL personnalisé (altUrl.custom.305.json)", "customUrlPatchHint": "Veuillez créer ce patch uniquement si les patches prédéfinis altUrl.tc.fritz.box.json et altUrl.305.json ne fonctionnent pas avec votre configuration actuelle de TeddyCloud. S'ils répondent à vos besoins, vous pouvez continuer en cliquant sur 'Suivant'.", "dedicatedUart": "UART dédié", @@ -1395,6 +1496,7 @@ "hint": "Il n'y a actuellement pas de support direct pour flasher votre CC3200 Toniebox avec TeddyCloud, mais vous trouverez ici un guide étape par étape :", "hint2": "Veuillez suivre ensuite le chemin CC3200 :", "hintPatchHost": "Entrez le nom d'hôte de votre instance TeddyCloud, puis cliquez sur 'Créer altUrl.custom.305.patch'. Le patch sera téléchargé automatiquement par la suite.", + "hintPatchPort": "Saisis le port auquel la Toniebox doit se connecter, puis clique sur 'Créer altPort.custom.patch'. Le patch sera ensuite téléchargé automatiquement.", "hostname": "Nom d'hôte TeddyCloud :", "hostnameTooLong": "Le nom d'hôte ne doit pas dépasser 12 caractères.", "installBootloader": "Installer le Custom Bootloader HackieboxNG", @@ -1461,6 +1563,8 @@ "next": "Suivant", "patches": "Patches", "patchesMoreInformationLink": "Plus d'informations sur les patches personnalisés", + "port": "Port TeddyCloud :", + "portInvalid": "Le port doit être compris entre 1 et 65535.", "predefinedUrlPatches": "Correctifs d'URL prédéfinis", "predefinedUrlPatchesIntro": "Pour les boîtiers CC3200, il est recommandé d'utiliser un correctif altUrl. HackieboxNG inclut déjà deux correctifs altUrl prêts à l'emploi :", "previous": "Retour", @@ -1474,6 +1578,7 @@ "certificatesDumpedCAreplacementFlashed": "Certificats extraits et remplacement de CA flashé", "CH341AProgrammer": { "CH341AProgrammer": "Configurer la connexion du programmeur CH341A", + "CH341AProgrammerClampConnection": "Programmateur CH341A avec pince SOP8 connecté à la mémoire flash IS25LP032D", "CH341Sop8remove": "Raccourcis ici jusqu'à ce qu'elle s'ajuste correctement.", "flashMemoryDumpText1": "Connecte la pince à la mémoire flash IS25LP032D. Assure-toi que le câble rouge de la pince est aligné avec le point sur la puce marquant la broche 1.", "flashMemoryDumpText2": "Si la pince ne tient pas, elle peut entrer en collision avec un bloc noir à proximité. Raccourcis plus de plastique si nécessaire. Ne connecte pas encore le CH341A. Vérifie à nouveau les positions des câbles.", @@ -1542,6 +1647,7 @@ "readingFlashText2": "Il est recommandé d'effectuer plusieurs lectures et de comparer les résultats.", "writingFlash": "Écrire la mémoire flash avec le Raspberry Pi Pico" }, + "portHint": "Les box CC3235 se connectent toujours au port 443 : il n'existe pas de patch de port pour elles, TeddyCloud doit donc être accessible sur le port 443 (un proxy ne peut les router que par IP).", "previous": "Retour", "serprogFirmwareLink": "Trouver les instructions d'installation ici", "title": "CC3235 Flashage de la boîte", @@ -1551,8 +1657,8 @@ "confirmDeleteModal": "Confirmer la suppression", "connectESP32Modal": { "beware": "Attention !", - "connectESP32Text1.1": "Veuillez connecter le jumper J100 (Boot) et réinitialiser la boîte pour la mettre en mode UART requis.", - "connectESP32Text1.2": "Connectez votre UART de 3,3V à J103:", + "connectESP32Text1_1": "Veuillez connecter le jumper J100 (Boot) et réinitialiser la boîte pour la mettre en mode UART requis.", + "connectESP32Text1_2": "Connectez votre UART de 3,3V à J103:", "connectESP32Text2": "Si vous n'êtes pas sûr du mode dans lequel démarre l'ESP32, vérifiez ce qui suit.", "downloadMode": "Mode de téléchargement", "downloadModeText": "LED éteinte, pas de son, Sortie série :", @@ -1596,7 +1702,7 @@ "attention": "Attention", "baudRate": "Taux de baud : ", "baudRateInfo": "Si tu rencontres des problèmes pour lire ou écrire dans la mémoire flash, essaie de changer le taux de baud. La valeur par défaut est 921600.", - "browserNotSupported": "La fonctionnalité de flashage intégré de la boîte ESP32 ne peut être utilisée qu'avec des navigateurs prenant en charge l'API Web Serial. Malheureusement, votre navigateur actuel ne prend pas en charge cette fonctionnalité. Veuillez utiliser un navigateur qui prend en charge l'API Web Serial, comme Chrome ou Edge.", + "browserNotSupported": "La fonctionnalité de flashage intégré de la boîte ESP32 ne peut être utilisée qu'avec des navigateurs prenant en charge l'API Web Serial. Malheureusement, votre navigateur actuel ne prend pas en charge cette fonctionnalité. Veuillez utiliser un navigateur qui prend en charge l'API Web Serial, comme Chrome ou Edge ou Firefox (> v151).", "esp32flasher": { "ackBackUpWithOtherTool": "He creado una copia de seguridad con otra herramienta o soy consciente de que continúo sin una copia de seguridad adicional bajo mi propia responsabilidad.", "ackLatestFirmware": "Confirmo que la caja se conectó a la nube original, recibió el firmware más reciente y pudo reproducir un Tonie correctamente.", @@ -1657,8 +1763,9 @@ "hintESP32FirmwareFlashed": "Le patchage est terminé. Déconnecte la Toniebox de l'UART, retire le court-circuit du J100 et redémarre la box. Elle devrait maintenant pouvoir se connecter à TeddyCloud !", "hintESP32FirmwareReseted": "La réinitialisation devrait être terminée. Veuillez déconnecter la Toniebox du UART, retirer le court-circuit de J100 et redémarrer la box. Elle ne devrait plus être connectée à ton Teddycloud. Fais une vérification de fraîcheur pour tester si tout fonctionne correctement.", "hintFlashESP32RevvoxFlasher": "Clique sur « Flasher l'ESP32 », choisis le port COM si nécessaire et attends que l'étape soit terminée.", - "hintPatchFlash": "Pour appliquer le flash sélectionné/lu avec le certificat CA de votre TeddyCloud et éventuellement effectuer d'autres ajustements tels que le nom d'hôte de TeddyCloud et/ou les identifiants WiFi, cliquez sur 'Patch' après avoir entré les informations ou passez à l'étape suivante sans patch.", + "hintPatchFlash": "Pour appliquer le flash sélectionné/lu avec le certificat CA de votre TeddyCloud et éventuellement effectuer d'autres ajustements tels que le nom d'hôte et le port de TeddyCloud et/ou les identifiants WiFi, cliquez sur 'Patch' après avoir entré les informations ou passez à l'étape suivante sans patch.", "hintPatchHost": "Entrez le nom d'hôte souhaité pour votre instance TeddyCloud.", + "hintPatchPort": "Port auquel la Toniebox se connecte. Garde 443, sauf si la Toniebox doit joindre TeddyCloud sur un autre port, par exemple via un proxy qui route selon le port. Les Toniebox CC3235 et la Toniebox 2 ne peuvent pas être patchées : elles se connectent toujours au port 443.", "hintPatchWifi": "Entrez optionnellement les identifiants WiFi pour votre Toniebox. Merci de ne le faire que si c'est vraiment nécessaire - normalement, si votre Toniebox était connectée à votre WiFi juste avant la mise à jour, ce n'est pas nécessaire.", "hintReadESP32ImportFlash": "Débranche maintenant l'alimentation de ta Toniebox. Assure-toi que ton UART fonctionne en mode 3,3 V. Connecte ton UART au PC et à la Toniebox. Court-circuite les pastilles J100. Rebranche ensuite ta Toniebox à l'alimentation. Clique sur « Lire le flash » ou « Charger le fichier », puis choisis le port COM. Enfin, attends que l'étape soit terminée.", "hintSaveLog": "Veuillez enregistrer la sortie du journal dans un fichier pour une analyse ultérieure en cas de problème.", @@ -1681,6 +1788,8 @@ "patchingFlashImage": "Patch de l'image du flash...", "patchingSuccessful": "Patch réussi, prêt à flasher {{size}} MiB", "payloadReceived": "Charge utile reçue", + "port": "Port TeddyCloud :", + "portInvalid": "Le port doit être compris entre 1 et 32767.", "portOpenFailedInUse": "Échec de l'ouverture de ce port. Déjà en cours d'utilisation ?", "preparingFlash": "Préparation du flash de {{size}} MiB", "previous": "Précédent", @@ -1712,7 +1821,7 @@ "writingFinished": "Écriture terminée", "writingFlash": "Écriture de {{size}} MiB de mémoire flash..." }, - "hint": "Cette section est uniquement disponible si vous utilisez HTTPS et un navigateur compatible avec l'API Web Serial, comme Chrome ou Edge. Si vous souhaitez flasher votre ESP32 Toniebox, cliquez sur \"Continuer vers HTTPS\", vous serez alors automatiquement redirigé vers la version HTTPS. Il est possible que votre navigateur indique que la connexion n'est pas sécurisée. C'est normal, confirmez-le et ouvrez la page quand même. Vous devrez peut-être afficher plus de détails dans l'avertissement.", + "hint": "Cette section est uniquement disponible si vous utilisez HTTPS et un navigateur compatible avec l'API Web Serial, comme Chrome ou Edge ou Firefox (> v151). Si vous souhaitez flasher votre ESP32 Toniebox, cliquez sur \"Continuer vers HTTPS\", vous serez alors automatiquement redirigé vers la version HTTPS. Il est possible que votre navigateur indique que la connexion n'est pas sécurisée. C'est normal, confirmez-le et ouvrez la page quand même. Vous devrez peut-être afficher plus de détails dans l'avertissement.", "hintLegacyApproach": "Si vous préférez ne pas utiliser un navigateur prenant en charge WebSerial, vous pouvez toujours utiliser l'approche héritée via votre ligne de commande.", "httpsSettings": "Paramètres HTTPS", "legacy": { @@ -1725,6 +1834,7 @@ "flashCAreplacementText1": "Remplacez le CA d'origine dans votre dump flash avec esptool.", "flashCAreplacementText2": "Remontez votre Toniebox. Si vous avez déjà défini le nom d'hôte de TeddyCloud, vous pouvez ignorer l'étape DNS pour l'ESP32, sinon continuez avec l'étape DNS pour l'ESP32.", "flashCAreplacementText3": "Votre Toniebox devrait maintenant pouvoir se connecter à votre TeddyCloud. Effectuez un contrôle de fraîcheur et cliquez sur \"Vérifier les boîtes disponibles\".", + "flashESPtoolScreen": "Écran ESPtool", "followLegacyApproach": "Ou utilisez la méthode ancienne (pas de GUI, uniquement en ligne de commande).", "hint": "Il existe un support direct pour flasher votre Toniebox ESP32 avec TeddyCloud !", "installESPTool": "Installer l'outil ESP récent", @@ -1740,6 +1850,7 @@ "linkWikiGeneral": "Patch de votre Toniebox pour l'utiliser avec TeddyCloud", "linkWikiSpecific": "Configuration spécifique ESP32", "navigationTitle": "ESP32", + "processing": "Traitement en cours...", "redirect": "Continuer vers HTTPS", "title": "ESP32 Flashage de la boîte" }, @@ -1828,6 +1939,22 @@ }, "alternativeSource": "Cette Tonie {{originalTonie}} est assignée à un contenu {{assignedContent}} !", "alternativeSourceUnknown": "Cette Tonie {{originalTonie}} est assignée à un contenu alternatif !", + "assignNextEpisode": { + "action": "Assigner l'épisode suivante \"{{file}}\"", + "failedDetails": "Impossible assigner \"{{episode}}\" : ", + "failedMessage": "Échec de assignation de l'épisode suivant !", + "noneLeft": "Aucun épisode non écouté restant dans ce dossier.", + "successDetails": "\"{{episode}}\" a été assigné comme épisode suivant !", + "successMessage": "Épisode suivant assigné !" + }, + "assignPrevEpisode": { + "action": "Assigner l'épisode précédent \"{{file}}\"", + "failedDetails": "Impossible assigner \"{{episode}}\" : ", + "failedMessage": "Échec de l'attribution de l'épisode précédent !", + "noneLeft": "Aucun épisode précédent restant dans ce dossier.", + "successDetails": "\"{{episode}}\" a été assigné comme épisode précédent !", + "successMessage": "Épisode précédent assigné !" + }, "cancel": "Annuler", "closeAudioPlayer": "Fermer le lecteur audio", "closeAudioPlayerPopover": "Fermer arrête la lecture!", @@ -1866,10 +1993,18 @@ }, "audio": { "libraryLabel": "Audio personnalisé ({{library}})", + "locateInLibrary": "Rechercher dans la bibliothèque", + "notFoundInLibrary": "Introuvable dans la bibliothèque", "notInIndex": "Pas dans l'index audio personnalisé : {{audioId}}/{{hash}}", "placeholder": "Sélectionner l'audio personnalisé dans {{library}}", "selectFromLibrary": "Sélectionner l'audio dans {{library}}" }, + "audioSource": { + "linkedDescription": "Les modifications s'appliquent à tous les fichiers et tags qui utilisent ce modèle.", + "linkedTitle": "{{file}} est lié à ce modèle personnalisé", + "newDescription": "L'enregistrement crée un nouveau modèle personnalisé et le lie au fichier via l'ID audio et le hash.", + "newTitle": "Pas encore de modèle personnalisé pour {{file}}" + }, "batch": { "description": "Les modifications s'appliquent à tous les modèles sélectionnés.", "title": "Édition par lot active" @@ -1911,6 +2046,11 @@ "noChangesBody": "Il n'y a rien à enregistrer pour le moment.", "noChangesTitle": "Aucune modification détectée", "optionalColumns": "Colonnes optionnelles", + "overridesOriginal": { + "description": "Les modifications enregistrées remplacent les informations d'origine partout où cet audio ou ce modèle est utilisé. Supprimez le modèle personnalisé pour réafficher l'original.", + "tag": "Remplace l'original {{model}}", + "title": "Remplace le Tonie d'origine {{model}} ({{name}})" + }, "pagination": { "modelsPerPage": "modèles/page" }, @@ -1949,6 +2089,13 @@ }, "tableMenu": "Menu du tableau", "title": "Éditeur de modèles", + "tracks": { + "fewerNamesDescription": "Le fichier contient {{tracks}} pistes. Les noms conservent leur position, mais la fenêtre d'information ne propose le saut vers une piste que si toutes les pistes jusqu'à la dernière sont listées.", + "fewerNamesTitle": "Les dernières pistes du fichier n'ont pas de nom", + "moreNamesDescription": "Les noms supplémentaires sont enregistrés et listés, mais n'ont pas de position dans le fichier. La fenêtre d'information ne peut alors pas sauter vers une piste précise.", + "moreNamesTitle": "Plus de noms que de pistes dans le fichier ({{tracks}})", + "notInFile": "Noms supplémentaires sans piste dans le fichier :" + }, "unsaved": "{{count}} modification(s) non enregistrée(s)", "validation": { "title": "Veuillez d'abord corriger ces problèmes" @@ -1979,6 +2126,8 @@ "modelInfoHeading": "Infos du modèle", "modelInfoLanguage": "Langue", "modelInfoModel": "Modèle", + "modelInfoNo": "Non", + "modelInfoRelease": "Sortie", "modelInfoSeries": "Série", "modelInfoTitle": "Titre", "modelReadOnlyHint": "Tonie original : le modèle ne peut pas être modifié. Vous pouvez uniquement assigner un audio personnalisé.", @@ -1986,6 +2135,7 @@ "placeholderSearchForARadioStream": "Rechercher un flux radio", "save": "Enregistrer", "setAudioFromModel": "Définir l'audio du modèle", + "setAudioFromModelNoMapping": "Aucun audio n'est associé au modèle sélectionné.", "setAudioFromModelUnavailableInLibrary": "L'audio du modèle n'est pas encore disponible dans la bibliothèque.", "setModelFromAudio": "Définir le modèle depuis l'audio", "source": "Source", @@ -1999,6 +2149,7 @@ "encoding": "Encodage", "errorFileProcessing": "Échec du traitement du fichier audio : ", "filename": "Nom du fichier", + "loading": "Chargement...", "loadingWasmEncoder": "Chargement de l'encodeur WASM...", "maxFiles": "Vous ne pouvez ajouter que jusqu'à {{maxFiles}} fichiers.", "navigationTitle": "Encodeur audio", @@ -2040,9 +2191,14 @@ }, "editIcon": { "createNewModel": { - "description": "Ouvre une fenêtre pour saisir les détails du nouveau modèle. Cette fonction est encore en développement.", + "description": "Ouvre l'éditeur de modèles personnalisés pour saisir les détails d'un nouveau modèle pour votre Tonie/Tag.", "text": "Crée un modèle personnalisé pour ton Tonie/Tag.", - "title": "Créer un nouveau modèle (en cours)" + "title": "Créer un nouveau modèle" + }, + "editModel": { + "description": "Modifie le modèle personnalisé sélectionné dans l'éditeur de modèles personnalisés. Les entrées audio affichent immédiatement leur ID audio et leur hash ; l'icône de localisation d'une entrée permet de retrouver son fichier dans votre bibliothèque.", + "text": "Modifie le modèle personnalisé attribué au Tonie/Tag.", + "title": "Modifier le modèle personnalisé" }, "model": { "description": "Recherche et assigne un modèle. L'image sera affichée dans l'aperçu.", @@ -2054,6 +2210,16 @@ "text": "Recherche des stations radio en ligne (si l'API de radio-browser.info est disponible).", "title": "Recherche de radio" }, + "setAudioFromModel": { + "description": "Apparaît lorsqu'un modèle est sélectionné et ne correspond pas à l'audio actuel. La bibliothèque n'est parcourue qu'au clic sur le bouton, ce qui peut prendre un moment. Si aucun fichier correspondant n'est trouvé, une indication s'affiche sous le champ source.", + "text": "Attribue le fichier audio correspondant au modèle sélectionné.", + "title": "Définir l'audio depuis le modèle" + }, + "setModelFromAudio": { + "description": "Apparaît lorsque l'audio attribué appartient à un modèle connu. Aucune recherche dans la bibliothèque n'est nécessaire, le modèle est repris des informations de l'audio.", + "text": "Attribue le modèle correspondant à l'audio actuel.", + "title": "Définir le modèle depuis l'audio" + }, "source": { "description": "Tu peux assigner du contenu depuis ta bibliothèque (ex. fichiers TAF ou TAP) ou saisir une URL de stream.", "text": "Affiche la source de contenu actuellement assignée.", @@ -2064,9 +2230,9 @@ }, "infoIcon": { "download": { - "description": "Si disponible, tu peux télécharger le fichier TAF lié sous forme d'un fichier .ogg unique.", - "text": "Permet de télécharger le fichier TAF au format audio .ogg.", - "title": "Télécharger le TAF en .ogg" + "description": "Si disponible, les pistes peuvent être téléchargées en fichiers *.ogg séparés (ou en un seul fichier *.ogg).", + "text": "Ouvre la sélection des pistes pour télécharger le TAF en *.ogg.", + "title": "Télécharger le TAF en *.ogg" }, "exists": { "description": "Le statut 'existe' est défini sur 'oui' si le fichier source assigné est présent.", @@ -2219,6 +2385,7 @@ "exportToJson": "Exporter en JSON", "hideSelectedTags": "Masquer les tags", "select": "Sélectionner", + "selectAll": "Tout sélectionner", "selectButtonTooltip": "Activer le mode de sélection", "selected": "Tags/Tonies sélectionnés", "setLive": "Activer live", @@ -2231,6 +2398,17 @@ "navigationTitle": "Sons du système", "title": "Sons du système" }, + "tafDownload": { + "asOneFile": "Télécharger en un seul fichier *.ogg", + "downloadCount": "Télécharger {{count}} pistes en *.ogg", + "downloadOne": "Télécharger 1 piste en *.ogg", + "duration": "Durée", + "modalTitle": "Télécharger les pistes en *.ogg", + "selectAll": "Sélectionner toutes les pistes", + "selectedCount": "{{selected}} sur {{total}} sélectionnées", + "track": "Piste", + "unnamedTrack": "Piste {{number}}" + }, "tafHeaderInvalid": "TAF invalide", "tafHeaderOf": "En-tête TAF de ", "tafHeaderToniesJsonHint": "Tu ne dois pas modifier les données ici lorsque tu les envoies au tonies-json. Cela inclut le nombre de pistes, même s'il est différent du nombre de pistes dans le champ track-desc !", @@ -2309,11 +2487,15 @@ "customImageTitle": "Saisis un titre à imprimer", "customImageUpload": "Ajouter image temporaire (sans Bibliothèque)", "diameter": "Diamètre intérieur", + "downloadPdf": "Télécharger le PDF", "empty": "Vide - Sélectionne d'abord des Tonies ou Tags ou ajoute une image personnalisée !", "episodes": "Épisodes", "fontFamily": "Police de l'étiquette", "fontFamilyPlaceholder": "Choisir une police", + "height": "Hauteur", + "imageBottom": "Marge de l'image en bas", "imageBottomLeft": "Marge de l'image en bas / à gauche", + "imageLeft": "Marge de l'image à gauche", "imagePosition": "Position de l'image", "imageScale": "Échelle de l'image", "intro": "Recherche tous les Tonies et Tags, officiels et tes propres, pour créer ta feuille imprimable de Traveltonies (pièce).", @@ -2339,6 +2521,7 @@ "paperSize": "Format du papier", "placeholder": "Saisis un terme de recherche !", "preview": "Aperçu étiquette", + "printMarginNotification": "Les marges d'impression CSS ne sont prises en charge de manière fiable que dans Chrome. Utilise Chrome pour garantir une impression correcte des étiquettes.", "printMode": "Mode", "printModeImageAndText": "Image + Texte", "printModeImageAndTextTooltip": "Imprime les deux côtés de ta pièce Traveltonie : image d'un côté, titre et infos de l'épisode de l'autre", @@ -2368,7 +2551,8 @@ "square": "Carrée", "textFontSize": "Taille du texte", "title": "TeddyStudio", - "trackTitles": "Titres (un par ligne)" + "trackTitles": "Titres (un par ligne)", + "width": "Largeur" }, "title": "Tonies", "tonies": { diff --git a/contrib/data/www/web/translations/it.json b/contrib/data/www/web/translations/it.json new file mode 100644 index 000000000..37869dfba --- /dev/null +++ b/contrib/data/www/web/translations/it.json @@ -0,0 +1,2679 @@ +{ + "404": { + "description": "Ops - Pagina non trovata", + "navigationTitle": "404 - Pagina non trovata", + "title": "404" + }, + "audio": { + "errorConfirm": "Chiudi", + "errorNoOggOpusSupport": "Errore - OGG/Opus (taf) non supportato.", + "errorNoOggOpusSupportByApple": "Al momento il tuo browser non supporta i file OGG/Opus (taf). Se utilizzi un dispositivo Apple, aiutaci segnalando il problema ad Apple!", + "unknownSource": "Sconosciuto" + }, + "auth": { + "loginFailed": "Nome utente o password non validi", + "loginHint": "Accedi a TeddyCloud", + "loginRateLimited": "Troppi tentativi di accesso. Riprova tra qualche minuto.", + "loginSubmit": "Accedi", + "loginTitle": "Accesso Web", + "logout": "Esci", + "newPassword": "Nuova password", + "password": "Password", + "passwordMin": "Almeno 4 caratteri", + "passwordRequired": "La password è obbligatoria", + "retry": "Riprova", + "statusLoadFailed": "Impossibile caricare lo stato di autenticazione", + "statusLoadFailedDetails": "Impossibile caricare lo stato di autenticazione. Controlla la connessione a TeddyCloud e riprova.", + "username": "Nome utente", + "usernameRequired": "Il nome utente è obbligatorio" + }, + "common": { + "cancel": "Annulla" + }, + "community": { + "attribution": { + "navigationTitle": "Riconoscimenti", + "title": "Riconoscimenti" + }, + "changelog": { + "allCommits": "Tutti i commit inclusi sono disponibili qui (repository teddy cloud)", + "changes": "Modifiche", + "discussion": "Discussione", + "githubRelease": "Release GitHub", + "navigationTitle": "Registro modifiche", + "title": "Registro modifiche", + "version": "Versione" + }, + "community": { + "discourse": "La vivace base di conoscenza del progetto, un luogo in cui porre domande le cui risposte possono essere utili anche ad altri.", + "getInvolved": "Partecipa", + "getInvolvedText1": "Entra a far parte della nostra comunità globale di collaboratori scrivendo codice, commentando i problemi o partecipando alle discussioni nel nostro gruppo Telegram.", + "getInvolvedText2": "I migliori punti di contatto sono i seguenti", + "github": "Il luogo in cui sono conservati tutti i sorgenti di questo e dei progetti correlati. Qui puoi creare fork dei repository, contribuire con funzionalità nuove o migliorate, aprire issue o semplicemente lasciare un feedback sulle issue esistenti.", + "teddyCloudWiki": "La Wiki contiene, tra le altre cose, informazioni su come installare TeddyCloud e su come risolvere eventuali problemi.", + "telegram": "Il primo livello di supporto se hai bisogno di aiuto o se sei interessato a partecipare allo sviluppo di TeddyCloud." + }, + "contribution": { + "communityValues": "Valori della comunità", + "communityValues1": "Usa un linguaggio accogliente e inclusivo.", + "communityValues2": "Rispetta punti di vista ed esperienze differenti.", + "communityValues3": "Accetta con serenità le critiche costruttive.", + "communityValues4": "Promuovi ciò che è meglio per la comunità.", + "communityValues5": "Mostra empatia verso gli altri membri della comunità.", + "communityValues6": "Le decisioni vengono prese in base al merito tecnico e al consenso. La comunità Teddycloud aspira a trattare tutti allo stesso modo e a valorizzare ogni contributo.", + "communityValuesExamples": "Esempi di comportamenti che contribuiscono a creare un ambiente positivo includono:", + "communityValuesIntro": "Nell'interesse di favorire un ambiente aperto e accogliente, collaboratori e manutentori si impegnano a rendere la partecipazione al nostro progetto e alla nostra comunità un'esperienza priva di molestie per tutti, indipendentemente da età, corporatura, disabilità, etnia, identità ed espressione di genere, livello di esperienza, nazionalità, aspetto personale, razza, religione, identità o orientamento sessuale.", + "contributionIntro": "Teddycloud può crescere solo grazie ai contributi di questa comunità. Grazie per il tuo entusiasmo e per il tuo lavoro: apprezziamo tutto ciò che fai!", + "makeFirstContribution": "Dai il tuo primo contributo", + "makeFirstContribution1": "Scrivi codice.", + "makeFirstContribution2": "Migliora la documentazione.", + "makeFirstContribution3": "Rispondi alle domande nel nostro gruppo Telegram.", + "makeFirstContribution4": "Indaga su bug e issue su GitHub.", + "makeFirstContribution5": "Esamina e commenta le pull request di altri sviluppatori.", + "makeFirstContribution6": "Segnala un problema.", + "makeFirstContribution7": "Metti un \"pollice in su\" alle issue che ritieni rilevanti.", + "makeFirstContributionIntro": "Ci sono molti modi per contribuire a Teddycloud! Puoi contribuire al codice e migliorare la documentazione di Teddycloud. I contributi più comuni riguardano codice, documentazione e supporto alla comunità.", + "navigationTitle": "Contributi", + "title": "Contributi", + "toniesJson": { + "navigationTitle": "tonies.json", + "text": "Puoi sostenerci inviandoci i dati mancanti dei Tonie elencati qui sotto. In futuro sarà possibile farlo direttamente in TeddyCloud; al momento puoi utilizzare TeddyBench. Grazie in anticipo!", + "title": "Contribuisci a tonies.json" + } + }, + "contributors": { + "bigRoundOfApplause": "Un grande applauso per loro!", + "contributorsIntro": "Questo progetto si basa sull'impegno instancabile dei collaboratori che mettono a disposizione il proprio tempo libero e le proprie competenze. Direttamente collegati a TeddyCloud ci sono i collaboratori dei due progetti teddycloud e teddycloud_web:", + "navigationTitle": "Collaboratori", + "others": "Puoi trovare altri collaboratori dell'intero ecosistema negli altri repository GitHub all'indirizzo", + "supportTeam": "Sostieni il team", + "supportTeamLink": "Sponsorizza TeddyCloud su GitHub", + "supportTeamText": "Se ti piace TeddyCloud, valuta di sostenerci con una sponsorizzazione o una donazione!", + "teddyCloud": "La parte più essenziale, ma per lo più invisibile, del tuo server TeddyCloud.", + "teddyCloudWeb": "Ovvero il frontend che stai utilizzando e visualizzando in questo momento.", + "title": "Collaboratori" + }, + "faq": { + "intro": "Qui puoi trovare le risposte ad alcune domande frequenti. Se la tua domanda non trova risposta qui, visita il forum o utilizza il gruppo Telegram per ricevere assistenza.", + "navigationTitle": "FAQ", + "title": "FAQ" + }, + "forum": { + "navigationTitle": "Forum" + }, + "navigationTitle": "Comunità", + "plugins": { + "addButton": "Aggiungi", + "addButtonTooltip": "Fai clic qui per aggiungere nuovi plugin o aggiornare quelli esistenti", + "available": "Plugin disponibili", + "by": "di", + "cancel": "Annulla", + "delete": "Elimina", + "deletion": { + "confirmDeleteDialog": "Vuoi davvero eliminare il plugin \"{{pluginId}}\"?", + "confirmDeleteModal": "Conferma eliminazione", + "delete": "Elimina", + "errorDeletingPlugin": "Eliminazione del plugin non riuscita", + "errorDeletingPluginDetails": "L'eliminazione del plugin \"{{filename}}\" non è riuscita: ", + "successDeletingPlugin": "Plugin eliminato correttamente", + "successDeletingPluginDetails": "Il plugin \"{{filename}}\" è stato eliminato correttamente" + }, + "downloadPluginTemplate": "Scarica PluginTemplate", + "empty": "Nessun plugin disponibile", + "error": { + "notification": { + "message": "JSON di descrizione mancante - elemento \"pluginName\" nel plugin \"{{pluginId}}\", ignorato.", + "missingPluginIndexHtml": "index.html mancante nel plugin \"{{pluginId}}\"!", + "title": "Errore durante il caricamento del plugin" + }, + "pluginNotFound": "index.html del plugin non trovato - controlla il plugin!", + "title": "Errore durante il caricamento del plugin" + }, + "errorLoadingPlugin": "Impossibile caricare i metadati del plugin {{pluginname}}", + "filter": { + "unknown": "Sezione non impostata" + }, + "help": { + "descriptionExampleTitle": "plugin.json per l'esempio precedente:", + "descriptionIntro": "Ogni plugin deve includere un file plugin.json nella propria directory principale. Questo file descrive il plugin e consente di visualizzarlo correttamente nell'interfaccia.", + "descriptionTitle": "plugin.json — Metadati richiesti", + "exampleFolderTitle": "Cartella di esempio:", + "exampleTitle": "Esempio:", + "fields": { + "author": "Autore del plugin", + "description": "Breve descrizione del plugin", + "hideInNav": "Flag che indica se il plugin deve essere nascosto nella navigazione (true/false)", + "icon": "L'icona utilizzata per il plugin. Deve essere un componente icona Ant Design valido da https://ant.design/components/icon, ad es. TrophyOutlined, TagsOutlined", + "pluginHomepage": "Homepage del plugin", + "pluginName": "Il titolo mostrato nei menu (obbligatorio)", + "standalone": "Flag booleano che indica se il plugin deve essere eseguito in modalità autonoma (senza intestazione, barra laterale e piè di pagina). Importante: non racchiudere true o false tra virgolette.", + "teddyCloudSection": "Sezione desiderata in cui aggiungere il plugin alla navigazione (valori validi: home, tonies, tonieboxes, settings, community)", + "version": "Versione del plugin" + }, + "fieldsTitle": "Campi:", + "folderStructureTitle": "Struttura della cartella del plugin:", + "intro": "Per rendere il plugin rilevabile e visibile nella navigazione della Comunità, deve rispettare questa struttura e includere file index.html e plugin.json validi. Un file preview.png opzionale verrà mostrato come immagine nella scheda del plugin.", + "popoverTitle": "Guida plugin", + "title": "Struttura della cartella del plugin e requisiti" + }, + "helpButton": "Aiuto", + "helpTextCreatePlugin": "Testo su come creare un plugin", + "hidden": "Nascosto", + "installedPlugins": "Plugin installati", + "intro": "Qui trovi i plugin TeddyCloud che hai aggiunto e puoi aggiungere nuovi plugin forniti da te o dalla comunità TeddyCloud!", + "navigationTitle": "Plugin TC", + "ok": "OK", + "open": "Apri plugin", + "pagination": { + "pageSelector": "Plugins per pagina", + "showAll": "Mostra tutti" + }, + "plugin": "Plugin", + "title": "Plugin TeddyCloud", + "upload": { + "add": "Aggiungi plugin", + "errorUploadingPlugin": "Caricamento del nuovo plugin non riuscito", + "errorUploadingPluginDetails": "Il caricamento del nuovo plugin \"{{filename}}\" non è riuscito: ", + "selectPluginZip": "Seleziona il file ZIP del plugin", + "successUploadingPlugin": "Nuovo plugin caricato correttamente", + "successUploadingPluginDetails": "Il nuovo plugin \"{{filename}}\" è stato caricato correttamente", + "uploadModal": "Carica un file ZIP del plugin", + "uploadModalHint": "Carica un plugin nuovo o esistente. Se il plugin ZIP caricato esiste già in TeddyCloud, verrà sovrascritto.", + "warningUploadingPlugin": "Seleziona un file", + "warningUploadingPluginDetails": "Seleziona un file ZIP del plugin prima di fare clic su \"Aggiungi plugin\"" + }, + "visitHomepage": "Visita la homepage dei plugin" + }, + "supportRequestGuide": { + "actions": { + "label": "Azioni eseguite" + }, + "actual": { + "label": "Risultato effettivo" + }, + "additionalInfo": { + "label": "Informazioni aggiuntive (note, allega screenshot o registrazione)" + }, + "closing": "Fornire questo livello di dettaglio aiuterà la comunità a risolvere il problema in modo più efficace. Grazie!", + "createSupportRequest": "Crea richiesta", + "createSupportRequestFormTitle": "Crea richiesta di supporto", + "description": { + "label": "Descrizione del problema" + }, + "docker": { + "label": "Versione Docker" + }, + "englishHint": "Scrivi la richiesta di supporto in inglese per assicurarti che tutta la comunità possa aiutarti.", + "example": { + "description": "Di seguito trovi un esempio di richiesta di supporto ben strutturata per aiutarti a iniziare:", + "details": "Oggetto: Errore durante il caricamento di un file in TeddyCloud (Docker)

Descrizione:
Ho riscontrato un errore durante il tentativo di caricare un file in TeddyCloud.

1. Azioni eseguite:
- Ho effettuato l'accesso correttamente.
- Sono andato alla pagina di caricamento e ho selezionato un file (testfile.pdf).
- Ho fatto clic sul pulsante di caricamento.

2. Risultato previsto:
- Il file dovrebbe essere caricato correttamente e comparire nell'elenco dei file.

3. Risultato effettivo:
- È apparso un messaggio di errore: 'Caricamento non riuscito: connessione persa.'

4. Dettagli dell'ambiente:
- SO: Ubuntu 20.04
- Versione Docker: 24.0.2
- Versione TeddyCloud: 0.6.3

5. Log:
In allegato un estratto del log (teddycloud_logs.txt) relativo al momento in cui si è verificato l'errore.", + "title": "Esempio di richiesta di supporto" + }, + "expected": { + "label": "Risultato previsto" + }, + "formattedRequest": "Richiesta formattata", + "generate": "Genera", + "hintSendingSupportRequest": "Copia il contenuto seguente nel gruppo Telegram o in una nuova discussione sul forum. Non dimenticare di allegare i log raccolti e, se disponibili, ulteriori screenshot o registrazioni dello schermo.", + "intro": "Quando richiedi assistenza, fornire informazioni dettagliate e chiare aiuta la comunità a diagnosticare e risolvere il problema più rapidamente. Segui questi passaggi per assicurarti che la richiesta contenga tutti i dettagli necessari:", + "logs": { + "label": "Estratto di log rilevante" + }, + "navigationTitle": "Guida alla richiesta di supporto", + "os": { + "label": "Sistema operativo e versione" + }, + "requiredField": "Questo campo è obbligatorio.", + "step1": { + "content": "Inizia con un breve riepilogo del problema. Indica eventuali messaggi di errore specifici ricevuti (se possibile, copia e incolla il messaggio esatto).", + "title": "Passaggio 1: Descrivi chiaramente il problema" + }, + "step2": { + "intro": "Descrivi in dettaglio cosa hai fatto prima che si verificasse l'errore. Includi:", + "list": [ + "Azioni eseguite (ad es. 'Ho effettuato l'accesso e ho provato a caricare un file').", + "Risultato previsto (ad es. 'Il file dovrebbe essere caricato correttamente').", + "Risultato effettivo (ad es. 'Ho ricevuto un messaggio di errore: caricamento non riuscito')." + ], + "title": "Passaggio 2: Fornisci una descrizione passo passo" + }, + "step3": { + "list": [ + "Sistema operativo e versione (ad es. Windows 10, Ubuntu 20.04).", + "Se utilizzi Docker e quale versione.", + "Qualsiasi altro dettaglio di configurazione rilevante." + ], + "title": "Passaggio 3: Includi le informazioni tecniche" + }, + "step4": { + "intro": "Fornisci i log per aiutare a diagnosticare il problema. Ecco come:", + "list": [ + "Includi l'output dei log relativo al momento in cui si è verificato il problema.", + "Assicurati di rimuovere le informazioni sensibili prima di condividerli." + ], + "listFirstEntry": "Esegui questo comando per raccogliere i log:", + "title": "Passaggio 4: Allega i log rilevanti" + }, + "step5": { + "content": "Se possibile, includi screenshot o una breve registrazione dello schermo per mostrare visivamente il problema.", + "title": "Passaggio 5: Screenshot o video (facoltativo)" + }, + "step6": { + "content": "Facci sapere se hai già provato passaggi di risoluzione dei problemi, come riavviare il servizio o controllare i log.", + "title": "Passaggio 6: Passaggi già eseguiti per risolvere il problema" + }, + "stepsTaken": { + "label": "Passaggi già eseguiti per risolvere il problema" + }, + "subject": { + "label": "Oggetto", + "placeholder": "ad es. 'Errore durante il caricamento di un file in TeddyCloud (Docker)'" + }, + "title": "Come creare una buona richiesta di supporto", + "version": { + "label": "Versione TeddyCloud" + } + }, + "title": "Comunità", + "translations": { + "allTranslationStrings": "Tutte le stringhe di traduzione", + "extraKeys": "Chiavi extra", + "hint": "Se noti una traduzione errata, contattaci su Telegram. Solo l'inglese, il tedesco, il francese e lo spagnolo vengono mantenuti attivamente; attualmente il francese e lo spagnolo sono tradotti interamente tramite IA. Tutte le altre lingue aggiuntive non vengono mantenute attivamente, quindi sentiti libero di contribuire con le traduzioni mancanti. Puoi anche aggiungere altre lingue traducendo un file di lingua esistente, salvandolo come languagecode.json nella cartella public/translations e riavviando il server.", + "key": "Chiave", + "language": "Lingua", + "missing": "MANCANTE", + "missingExtraKeysHeadline": "Chiavi di traduzione mancanti / extra", + "missingKeys": "Chiavi mancanti", + "navigationTitle": "Traduzioni", + "noDiscrepancies": "È tutto a posto.", + "noExtraKeys": "Nessuna chiave extra.", + "noMissingKeys": "Nessuna chiave mancante.", + "title": "Traduzioni" + } + }, + "confirmDialog": { + "contentHintTitle": "Tieni presente" + }, + "faq": [ + { + "answer": "Per forzare un aggiornamento, esegui un controllo di aggiornamento (freshness check) sul tuo Toniebox. Tieni premuto uno degli orecchi per circa 3 secondi finché non senti un suono. Attendi che un secondo suono confermi il completamento dell'aggiornamento.", + "question": "Come posso forzare l'aggiornamento di un Tonie dopo aver assegnato nuovi contenuti in TeddyCloud?" + }, + { + "answer": "Usa lo strumento Audioencoder e aggiungi fino a 99 file audio. A ogni file verrà assegnata una traccia separata nel file TAF creato.", + "question": "Come posso creare un file TAF multitraccia?" + }, + { + "answer": "Puoi aggiungere fino a 16 Toniebox a una singola istanza TeddyCloud.", + "question": "Quanti Toniebox posso aggiungere a un'istanza TeddyCloud?" + }, + { + "answer": "Posiziona il tag su un Toniebox connesso. Verrà aggiunto automaticamente ai tuoi Tonie in TeddyCloud. Se hai molti tag sconosciuti, puoi filtrare per gli ultimi tag riprodotti oppure aprire la pagina di gestione del Toniebox e fare clic sull'ultimo tag riprodotto.", + "question": "Come posso aggiungere un nuovo tag a TeddyCloud?" + }, + { + "answer": "Il modo più semplice per rimuovere un tag è nasconderlo. Apri la finestra delle informazioni del tag che vuoi rimuovere e fai clic sul pulsante 'Nascondi'. Il tag rimarrà nascosto finché non verrà nuovamente posizionato su un Toniebox. In alternativa, se hai accesso alle schede SD di tutti i Toniebox collegati, puoi eliminare fisicamente il tag rimuovendo la cartella dei contenuti corrispondente su ogni box e quindi rimuovendolo dalla vista dei contenuti di TeddyCloud.", + "question": "Come posso rimuovere un tag dal mio TeddyCloud?" + }, + { + "answer": "Configura una directory dei contenuti separata per ogni Toniebox nelle impostazioni di gestione dei Toniebox. In seguito, nella panoramica dei Tonie comparirà un menu a discesa in alto a destra che consente di scegliere tra le diverse directory dei contenuti. Non è necessario assegnare una directory univoca a ogni box: i box con la stessa directory verranno raggruppati nella panoramica dei Tonie.", + "question": "Come posso gestire contenuti diversi per un tag su Toniebox differenti?" + }, + { + "answer": "Esegui un freshness check su quel Toniebox. In questo modo verranno riaggiunti a TeddyCloud tutti i tag che sono stati posizionati almeno una volta sul box (e che non sono stati eliminati). I Tonie originali devono essere posizionati almeno una volta sul box per ottenere l'autenticazione cloud e scaricare in TeddyCloud i contenuti associati.", + "question": "Perché la panoramica dei Tonie è vuota dopo aver aggiunto una nuova directory dei contenuti a un box?" + }, + { + "answer": "Assicurati che il flag 'nocloud' sia impostato su true e verifica che nessuno abbia posizionato di nascosto un altro tag sul box.", + "question": "Perché il mio tag con contenuti personalizzati riparte sempre dall'inizio invece che dall'ultima posizione riprodotta?" + }, + { + "answer": "Assicurati che siano abilitate le seguenti impostazioni: 'Cache Content' e 'Dump rUID/auth'. Consigliamo inoltre di abilitare 'Cache to library'.", + "question": "Perché i contenuti dei miei tag non vengono memorizzati nella cache di TeddyCloud?" + }, + { + "answer": "Assicurati di utilizzare il tipo corretto di tag. In generale sono compatibili solo i tag NSP SLIX-L originali. Troverai ulteriori informazioni nel forum. Se possiedi la versione CC3200 del Toniebox, puoi abilitare anche altri tag RFID; consulta la wiki per maggiori dettagli. Assicurati inoltre che ci sia una certa distanza tra il tag e il Toniebox (almeno 2-3 mm), perché un tag RFID appoggiato direttamente potrebbe non funzionare correttamente.", + "question": "Perché il mio tag RFID non funziona?" + } + ], + "fileBrowser": { + "actions": "Azioni", + "assignToTonie": { + "action": "Assegna a Tonie", + "failedDetails": "Impossibile assegnare \"{{series}}\" come contenuto: ", + "failedMessage": "Assegnazione a Tonie non riuscita!", + "searchPlaceholder": "Cerca Tonie...", + "successDetails": "File assegnato correttamente a \"{{series}}\"!", + "successMessage": "Tonie assegnato!", + "title": "Assegna a Tonie" + }, + "attention": "Attenzione!", + "cancel": "Annulla", + "confirmDeleteDialog": "Sei sicuro di voler eliminare {{fileToDelete}}?", + "confirmDeleteModal": "Conferma eliminazione", + "confirmMultipleDeleteDialog": "Sei sicuro di voler eliminare le righe selezionate?", + "createDirectory": { + "cancel": "Annulla", + "create": "Crea", + "createDirectory": "Crea nuova directory", + "directoryCreated": "La nuova directory è stata creata!", + "directoryCreatedDetails": "La nuova directory \"{{directory}}\" è stata creata!", + "directoryCreateFailed": "Creazione della directory non riuscita!", + "directoryCreateFailedDetails": "Creazione della directory \"{{directory}}\" non riuscita: ", + "inDirectory": "nella directory ", + "modalTitle": "Crea nuova directory", + "parentPath": "Directory principale:", + "placeholder": "Inserisci il nome della cartella" + }, + "customJsonSnippetModal": { + "allFiles": "Visualizzazione dello snippet per tutti i file TAF con dati di intestazione validi.", + "noValidHeaders": "Nessuna intestazione TAF valida trovata.", + "ok": "OK", + "selectedFiles": "Visualizzazione dello snippet solo per i file TAF selezionati.", + "title": "Snippet tonies.custom.json" + }, + "date": "Data", + "delete": "Elimina", + "deleteMultiple": "Elimina {{selectedRowCount}} righe selezionate", + "downloadFile": "Scarica file", + "downloadFileAsOgg": "Scarica TAF come *.ogg", + "downloadTracks": "Scarica tracce TAF come *.ogg", + "encodeFiles": { + "addFiles": "Aggiungi file", + "cancel": "Annulla", + "encode": "Codifica", + "encodeFiles": "Codifica {{selectedRowCount}} file selezionati in un file taf. Verranno considerati solo i seguenti tipi di file: ", + "encoding": "Codifica", + "encodingFailed": "Codifica non riuscita!", + "encodingFailedDetails": "Codifica dei file \"{{file}}\" non riuscita: ", + "encodingInProgress": "Codifica dei file in corso...", + "encodingSuccessful": "File codificati correttamente!", + "encodingSuccessfulDetails": "File codificati correttamente in \"{{file}}\"", + "modalTitle": "Codifica file audio", + "noSupportedFiletypesChoosen": "Nessun tipo di file supportato selezionato. Sono supportati i seguenti tipi di file: " + }, + "episode": "Episodio", + "fileTypesWarning": "Seleziona solo tipi di file supportati!", + "filter": "Filtra nella cartella corrente", + "help": { + "actionItems": { + "assignToTonie": { + "text": "Assegna il file selezionato come contenuto a un Tonie/Tag.", + "title": "Assegna a Tonie" + }, + "deleteFileFolder": { + "text": "Elimina il file o la cartella (solo se la cartella è vuota). Nota: se questo file è assegnato a un Tonie/Tag, il riferimento non verrà aggiornato.", + "title": "Elimina file/cartella" + }, + "downloadFile": { + "text": "Scarica il file selezionato. Per i file TAF con più tracce si apre una selezione delle tracce per scaricarle come file *.ogg separati (ZIP) o come un unico file *.ogg; i file TAF con una sola traccia vengono scaricati come un unico file *.ogg.", + "title": "Scarica file" + }, + "duplicateTAPFile": { + "text": "Crea un duplicato del file TAP selezionato.", + "title": "Duplica file TAP" + }, + "editTAFMetadata": { + "text": "Apre l'editor dei modelli personalizzati per il file TAF. Un modello personalizzato già collegato (stesso ID audio e hash) viene modificato, altrimenti ne viene creato uno nuovo per il file. Le modifiche vengono salvate in tonies.custom.json.", + "title": "Modifica metadati TAF" + }, + "editTAPPlaylist": { + "text": "Apre l'editor di playlist TeddyAudio per gestire i file TAP.", + "title": "Modifica playlist TAP" + }, + "markAsListened": { + "listened": { + "text": "Il file è attualmente segnato come ascoltato. Fai clic per segnarlo come non ascoltato.", + "title": "Segna come non ascoltato" + }, + "unlistened": { + "text": "Il file è attualmente segnato come non ascoltato. Fai clic per segnarlo come ascoltato.", + "title": "Segna come ascoltato" + } + }, + "migrateToAudioIdFolder": { + "text": "Sposta il file TAF nella cartella 'by/audioId' della libreria e aggiorna la sorgente nel file JSON.", + "title": "Migra file TAF nella libreria per AudioId" + }, + "migrateToLibraryRoot": { + "text": "Sposta il file TAF nella cartella principale della libreria e aggiorna la sorgente nel file JSON corrispondente.", + "title": "Migra file TAF nella radice della libreria" + }, + "moveFile": { + "text": "Sposta il file in una nuova posizione. Nota: se questo file è assegnato a un Tonie/Tag, il riferimento non verrà aggiornato.", + "title": "Sposta file" + }, + "playAudioFile": { + "text": "Apre il lettore audio e avvia la riproduzione del file.", + "title": "Riproduci file audio" + }, + "renameFile": { + "text": "Rinomina il file. Nota: se questo file è assegnato a un Tonie/Tag, il riferimento non verrà aggiornato automaticamente.", + "title": "Rinomina file" + } + }, + "actionItemsMulti": { + "deleteSelectedFiles": { + "text": "Elimina i file o le cartelle selezionati (solo se vuote). Nota: i riferimenti ai file eliminati nei Tonie/Tag non verranno aggiornati.", + "title": "Elimina file/cartelle selezionati" + }, + "encodeFilesToTAF": { + "text": "Aggiunge i file supportati a una coda di codifica. Puoi selezionare più file per codificarli in un file TAF, che potrà poi essere assegnato a un Tonie/Tag.", + "title": "Codifica file in TAF" + }, + "moveSelectedFiles": { + "text": "Sposta i file selezionati in una nuova posizione. Nota: i riferimenti a questi file nei Tonie/Tag non verranno aggiornati. Le cartelle selezionate verranno ignorate.", + "title": "Sposta file selezionati" + } + }, + "closeButton": "Chiudi", + "instructions": { + "fileActions": { + "title": "Azioni sui file" + }, + "multiSelection": { + "text": "Per eseguire azioni su più file, seleziona le relative caselle di controllo. Nell'intestazione compariranno nuove icone di azione che consentono di spostare, eliminare o codificare più file:", + "title": "Azioni su selezione multipla" + }, + "openDirectory": { + "text": "Fai doppio clic su una directory per aprirla. Le directory hanno un'icona di cartella davanti e mostrano come dimensione.", + "title": "Apri directory" + }, + "openTAFViewer": { + "text": "Fai doppio clic su un file TAF per visualizzarne l'intestazione. Il visualizzatore delle intestazioni TAF mostra dettagli come AudioId, hash SHA1 e dimensione del file. AudioId e hash SHA1 sono necessari per le voci in tonies.custom.json.", + "title": "Apri visualizzatore intestazione TAF" + }, + "openTAPJSONViewer": { + "text": "Fai doppio clic su un file TAP o JSON per aprire il visualizzatore JSON.", + "title": "Apri visualizzatore TAP/JSON" + }, + "viewTonieInfoModal": { + "text": "Fai clic sull'immagine di un Tonie/Tag nella riga per aprire la finestra TonieInfo. Vengono mostrate informazioni dettagliate, tra cui un'immagine più grande e l'elenco delle tracce (se disponibile).", + "title": "Visualizza finestra TonieInfo" + } + }, + "showHelp": "Aiuto", + "title": "Aiuto" + }, + "hidden": "Nascosto", + "image": "Immagine", + "markAsListened": "Segna come ascoltato", + "maxSelectedRows": "Puoi selezionare al massimo {{maxSelectedRows}} file", + "maxSelectedRowsWarning": "Sono stati selezionati troppi file!", + "messages": { + "deleteFailed": "Eliminazione non riuscita", + "deleteFailedDetails": "Eliminazione del file \"{{file}}\" non riuscita", + "deleteSuccessful": "Eliminazione riuscita!", + "deleteSuccessfulDetails": "File \"{{file}}\" eliminato correttamente!", + "deleting": "Eliminazione...", + "deletingDetails": "Eliminazione del file \"{{file}}\"...", + "downloadFailed": "Download non riuscito", + "downloadFailedDetails": "Download del file \"{{file}}\" non riuscito", + "errorFetchingDirContent": "Caricamento del contenuto della directory non riuscito", + "errorFetchingDirContentDetails": "Impossibile caricare il contenuto della directory \"{{path}}\": ", + "migrationFailed": "Migrazione non riuscita", + "migrationFailedDetails": "Migrazione di {{ruid}} non riuscita: ", + "migrationOngoing": "Migrazione...", + "migrationOngoingDetails": "Migrazione di {{ruid}} in corso...", + "migrationSuccessful": "Migrazione riuscita!", + "migrationSuccessfulDetails": "Migrazione di {{ruid}} completata correttamente!", + "moving": "Spostamento...", + "movingDetails": "Spostamento del file \"{{file}}\"...", + "movingFailed": "Spostamento non riuscito", + "movingFailedDetails": "Spostamento del file \"{{fileSource}}\" in \"{{fileTarget}}\" non riuscito: ", + "movingSuccessful": "File spostato correttamente!", + "movingSuccessfulDetails": "File \"{{fileSource}}\" spostato correttamente in \"{{fileTarget}}\"!", + "noRowsSelected": "Nessuna riga selezionata", + "noRowsSelectedForDeletion": "Nessuna riga selezionata per l'eliminazione", + "noRowsSelectedForMoving": "Nessuna riga selezionata per lo spostamento", + "renaming": "Ridenominazione...", + "renamingDetails": "Ridenominazione del file \"{{file}}\"...", + "renamingFailed": "Ridenominazione non riuscita", + "renamingFailedDetails": "Ridenominazione del file \"{{fileSource}}\" in \"{{fileTarget}}\" non riuscita: ", + "renamingSuccessful": "File rinominato correttamente!", + "renamingSuccessfulDetails": "File \"{{fileSource}}\" rinominato correttamente in \"{{fileTarget}}\"!", + "toggleListenedFailed": "Impossibile modificare lo stato di ascolto", + "toggleListenedFailedDetails": "Impossibile modificare lo stato di ascolto per il file \"{{file}}\": " + }, + "migrateContentToLib": "Migra TAF nella libreria (by/audioId/)", + "migrateContentToLibRoot": "Migra TAF nella libreria (radice)", + "model": "Modello", + "move": "Sposta", + "moveFile": { + "attention": "Lo spostamento dei file non aggiorna l'associazione con i Tonie/Tag. Dovrai modificare manualmente tutti i Tonie/Tag che utilizzano come sorgente i file spostati!", + "cancel": "Annulla", + "destinationPlaceholder": "Seleziona la cartella di destinazione", + "modalTitle": "Sposta file", + "modalTitleMultiple": "Sposta file selezionati", + "move": "Sposta", + "moveTo": "in" + }, + "moveMultiple": "Sposta {{selectedRowCount}} file selezionati (solo file, le cartelle vengono ignorate!)", + "name": "Nome", + "playFile": "Riproduci", + "rename": "Rinomina", + "renameFile": { + "attention": "La ridenominazione dei file non aggiorna l'associazione con i Tonie/Tag. Dovrai modificare manualmente tutti i Tonie/Tag che utilizzano come sorgente il file rinominato!", + "cancel": "Annulla", + "modalTitle": "Rinomina file", + "rename": "Rinomina" + }, + "root": "Radice", + "selectAllowedFileTypesOnly": "Puoi selezionare solo i seguenti tipi di file: {{fileTypes}}", + "series": "Serie", + "size": "Dimensione", + "tafMeta": { + "edit": "Modifica metadati (modello personalizzato)" + }, + "tap": { + "edit": "Modifica playlist TeddyAudio", + "messages": { + "savedSuccessfully": "TAP salvato correttamente!", + "savedSuccessfullyDetails": "TAP \"{{file}}\" salvato correttamente in \"{{path}}\"!", + "savingFailed": "Salvataggio TAP non riuscito!", + "savingFailedDetails": "Salvataggio del TAP \"{{file}}\" in \"{{path}}\" non riuscito:" + } + }, + "title": "Gestione file", + "unmarkAsListened": "Segna come non ascoltato", + "unusedTafsModal": { + "actions": "Azioni", + "fullPath": "Percorso completo", + "name": "Nome file TAF", + "noUnusedTafs": "Nessun file TAF inutilizzato trovato!", + "ok": "OK", + "title": "File TAF inutilizzati" + }, + "upload": { + "cancel": "Annulla", + "hideUploadFilesDragNDrop": "Nascondi caricamento file", + "modalTitle": "Carica file", + "showUploadFilesDragNDrop": "Caricamento file", + "upload": "Carica", + "uploadedFile": "File caricato!", + "uploadedFileFailed": "Caricamento del file non riuscito!", + "uploadFailed": "Non è stato possibile caricare alcuni file. Riprova.", + "uploadFailedForFile": "Caricamento di \"{{file}}\" non riuscito.", + "uploadHint": "Fai clic o trascina i file nell'area di caricamento / nell'elenco dei file per caricarli", + "uploading": "Caricamento...", + "uploadInProgress": "Caricamento di \"{{file}}\"...", + "uploadSuccessful": "File caricati correttamente.", + "uploadSuccessfulDetails": "Tutti i file sono stati caricati correttamente.", + "uploadSuccessfulForFile": "File \"{{file}}\" caricato correttamente.", + "uploadText": "Fai clic o trascina i file in quest'area per caricarli", + "uploadTimeout": "Timeout del caricamento dopo {{ms}} ms" + } + }, + "footer": { + "sponsor": "Diventa sponsor!", + "sponsorText": "Ti piace TeddyCloud?" + }, + "home": { + "build": "Build", + "error": "Errore - La GUI non si trova nello stato previsto.", + "errorConfirm": "Chiudi", + "errorWebVersionMismatch": "Il backend ha una versione diversa dal frontend. In un'installazione Docker, assicurati che non sia stato creato alcun volume in data/www.", + "features": { + "currentlyImplementedFeatures": "Funzionalità attualmente implementate", + "description": "TeddyCloud è un server alternativo per il tuo Toniebox che consente di ospitare localmente i servizi cloud. In questo modo hai il controllo sui dati inviati al cloud del produttore originale e puoi ospitare i tuoi file audio delle figurine, ad esempio sul tuo NAS o su qualsiasi altro server.", + "features": { + "01contentProvision": { + "items": { + "01provideAudioContent": "fornire contenuti audio tramite rete", + "02passtroughAudio": "inoltrare i contenuti audio originali dei Tonie", + "03provideCustomAudio": "fornire contenuti audio personalizzati", + "04simulateLiveContent": "simulare contenuti live", + "05convertAudio": "convertire al volo i flussi audio tramite ffmpeg per web radio e streaming", + "06definePlaylists": "definire playlist TeddyAudio (PoC nel WebFrontend)", + "07cacheAudio": "memorizzare nella cache i contenuti audio originali dei Tonie", + "08filterTags": "filtrare i tag personalizzati per impedirne l'eliminazione" + }, + "title": "Fornitura di contenuti per il tuo Toniebox" + }, + "02webUI": "WebUI", + "03manageTonies": { + "items": { + "01assignContent": "assegnare contenuti e altre proprietà a ogni tag", + "02searchStreams": "cercare e assegnare flussi di web radio a ogni tag", + "03defineMetaData": "definire i metadati dei tag personalizzati (editor modelli personalizzati nel WebFrontend)", + "04filterTonies": "filtrare Tonie/Tag", + "05hideTonies": "nascondere Tonie/Tag non disponibili", + "06showLastPlayed": "mostrare gli ultimi Tonie/Tag riprodotti" + }, + "title": "Gestisci tutti i tuoi Tonie/Tag" + }, + "04manageLibrary": "Gestisci la libreria dei contenuti", + "05managePlaylists": "Gestisci la libreria delle playlist TeddyAudio (PoC nel WebFrontend)", + "06convertAudioFile": "Converti qualsiasi file audio in un file audio Tonie", + "07manageTonieboxes": { + "items": { + "01singleBoxManagement": "gestire un singolo box, gestirli tutti insieme o raggrupparli come preferisci", + "02cloudAccess": "disabilitare/abilitare l'accesso al cloud e altre proprietà correlate", + "03maxVolume": "configurare il volume massimo per altoparlante e cuffie", + "04configureLED": "configurare il LED", + "05configureSlapping": "configurare lo slapping", + "06customizeSounds": "personalizzare via rete i suoni originali del box (ad es. il jingle)" + }, + "title": "Gestisci fino a 16 Toniebox" + }, + "08esp32Flashing": { + "items": { + "01certificates": "estrarre/iniettare certificati in un dump del firmware ESP32", + "02newServerHost": "impostare un nuovo host del server", + "03wifiCredentials": "impostare le credenziali Wi-Fi", + "04extractCertificates": "estrarre i certificati e salvarli in TeddyCloud", + "05guidelineLegacyMethod": "guida al flashing con il metodo legacy (senza GUI, solo riga di comando)" + }, + "title": "Flashing del box ESP32" + }, + "09CC3200Flashing": { + "items": { + "01Guideline": "guida al flashing", + "02AltUrlCustomPatch": "generazione tramite modulo della patch AltUrl.custom.305" + }, + "title": "Flashing del box CC3200" + }, + "10CC3235Flashing": { + "items": { + "01Guideline": "guida dettagliata al processo di flashing" + }, + "title": "Flashing del box CC3235" + }, + "11decodeLogs": "Decodifica log RTNL", + "12mqttClient": { + "items": { + "01homeAssistantIntegration": "integrazione con Home Assistant (MQTT)" + }, + "title": "Client MQTT" + }, + "13TeddyStudio": { + "items": { + "01Description": "Crea e stampa le tue etichette per i tag personalizzati.", + "02Options": "Personalizza formato, dimensione, spaziatura delle etichette e molte altre opzioni.", + "03Paperpresets": "Usa preset di carta per etichette esistenti oppure condividine di nuovi nel forum o su Telegram." + }, + "title": "TeddyStudio" + }, + "14plugins": { + "items": { + "01pluginFeature": "estendere le funzionalità di TeddyCloud tramite plugin personalizzati" + }, + "title": "Plugin TeddyCloud" + }, + "15webLogin": { + "items": { + "01optionalLogin": "accesso opzionale per l'interfaccia Web con più utenti (disattivato per impostazione predefinita)", + "02rateLimit": "sessione tramite cookie con limitazione dei tentativi dopo ripetuti accessi non riusciti", + "03userLimit": "fino a 32 utenti" + }, + "title": "Accesso all'interfaccia Web" + } + }, + "futureFeatures": { + "01firmwareUpdate": "Aggiornamento firmware tramite TeddyCloud", + "03reportUnknownTonies": "Segnalazione di Tonie sconosciuti", + "04completeTAPIntegration": "Completamento dell'integrazione TAP nel WebFrontend", + "05moreStats": "Più statistiche sui Tonie/Tag riprodotti", + "06securityImprovements": "Miglioramenti della sicurezza, come il controllo degli accessi (Toniebox e utenti)" + }, + "navigationTitle": "Funzionalità", + "title": "Funzionalità TeddyCloud", + "yetToCome": "In arrivo" + }, + "forumIntroPart1": "Scopri maggiori informazioni, entra in contatto con persone con interessi simili e resta aggiornato sulle ultime discussioni nel nostro forum: ", + "forumIntroPart2": ". Approfondisci gli argomenti, condividi le tue esperienze e connettiti con la nostra comunità. Partecipa alla conversazione!", + "helpfulLinks": "Link utili", + "intro": "TeddyCloud è un server alternativo per il tuo Toniebox che consente di ospitare localmente i servizi cloud. In questo modo hai il controllo sui dati inviati al cloud del produttore originale e puoi ospitare i tuoi file audio delle figurine.", + "navigationTitle": "Home", + "sponsor": { + "navigationTitle": "Sponsor" + }, + "stats": { + "claimed": "Assegnato", + "claimedTitle": "Stato di assegnazione", + "cloud_blocked": "Richieste cloud bloccate", + "cloud_failed": "Richieste cloud non riuscite", + "cloud_requests": "Richieste cloud eseguite", + "cloudDisabled": "Cloud disabilitato", + "cloudEnabled": "Cloud abilitato", + "cloudTitle": "Stato cloud", + "connections": "Connessioni effettuate a questo server", + "languageTitle": "Lingue", + "live": "Live", + "liveTitle": "Stato live", + "navigationTitle": "Statistiche", + "notLive": "Non live", + "reverse_requests": "Chiamate reverse proxy effettuate dai client", + "serverStats": "Statistiche del server", + "title": "Statistiche", + "toniesStats": "Statistiche Tonie", + "totalTonieBoxes": "Toniebox totali", + "totalTonies": "Tonie totali", + "unclaimed": "Non assegnato" + }, + "title": "Benvenuto in TeddyCloud!", + "toAllYourTonieboxes": "A tutti i tuoi Toniebox", + "toAllYourTonies": "A tutti i tuoi Tonie", + "tonieMeeting": { + "navigationTitle": "Tonie Meeting" + }, + "yourTonieboxes": "I tuoi Toniebox", + "yourTonies": "I tuoi Tonie" + }, + "inputValidator": { + "invalidCharactersDetected": "Il testo inserito contiene caratteri non validi, ad esempio {{invalidChar}}" + }, + "language": { + "change": "Cambia lingua" + }, + "languageUtil": { + "af-af": "Afrikaans (Afghanistan)", + "am-et": "Amarico (Etiopia)", + "ar-ae": "Arabo (Emirati Arabi Uniti)", + "ar-bh": "Arabo (Bahrein)", + "ar-dz": "Arabo (Algeria)", + "ar-eg": "Arabo (Egitto)", + "ar-eh": "Arabo (Sahara Occidentale)", + "ar-iq": "Arabo (Iraq)", + "ar-jo": "Arabo (Giordania)", + "ar-km": "Arabo (Comore)", + "ar-kw": "Arabo (Kuwait)", + "ar-lb": "Arabo (Libano)", + "ar-ly": "Arabo (Libia)", + "ar-ma": "Arabo (Marocco)", + "ar-mr": "Arabo (Mauritania)", + "ar-om": "Arabo (Oman)", + "ar-ps": "Arabo (Territori Palestinesi)", + "ar-qa": "Arabo (Qatar)", + "ar-sa": "Arabo (Arabia Saudita)", + "ar-sd": "Arabo (Sudan)", + "ar-sy": "Arabo (Siria)", + "ar-tn": "Arabo (Tunisia)", + "ar-ye": "Arabo (Yemen)", + "az-az": "Azerbaigiano (Azerbaigian)", + "be-by": "Bielorusso (Bielorussia)", + "bg-bg": "Bulgaro (Bulgaria)", + "bm-ml": "Bambara (Mali)", + "bn-bd": "Bengalese (Bangladesh)", + "bs-ba": "Bosniaco (Bosnia ed Erzegovina)", + "ca-ad": "Catalano (Andorra)", + "cs-cz": "Ceco (Cechia)", + "da-dk": "Danese (Danimarca)", + "de-at": "Tedesco (Austria)", + "de-ch": "Tedesco (Svizzera)", + "de-de": "Tedesco (Germania)", + "de-li": "Tedesco (Liechtenstein)", + "dv-mv": "Divehi (Maldive)", + "dz-bt": "Dzongkha (Bhutan)", + "el-cy": "Greco (Cipro)", + "el-gr": "Greco (Grecia)", + "en-ag": "Inglese (Antigua e Barbuda)", + "en-ai": "Inglese (Anguilla)", + "en-as": "Inglese (Samoa Americane)", + "en-au": "Inglese (Australia)", + "en-bb": "Inglese (Barbados)", + "en-bi": "Inglese (Burundi)", + "en-bm": "Inglese (Bermuda)", + "en-bs": "Inglese (Bahamas)", + "en-bw": "Inglese (Botswana)", + "en-bz": "Inglese (Belize)", + "en-ca": "Inglese (Canada)", + "en-cc": "Inglese (Isole Cocos (Keeling))", + "en-ck": "Inglese (Isole Cook)", + "en-cx": "Inglese (Isola Christmas)", + "en-dm": "Inglese (Dominica)", + "en-fj": "Inglese (Figi)", + "en-fk": "Inglese (Isole Falkland)", + "en-fm": "Inglese (Micronesia)", + "en-gb": "Inglese (Regno Unito)", + "en-gd": "Inglese (Grenada)", + "en-gg": "Inglese (Guernsey)", + "en-gh": "Inglese (Ghana)", + "en-gi": "Inglese (Gibilterra)", + "en-gm": "Inglese (Gambia)", + "en-gs": "Inglese (Georgia del Sud e Sandwich Australi)", + "en-gu": "Inglese (Guam)", + "en-gy": "Inglese (Guyana)", + "en-hm": "Inglese (Isole Heard e McDonald)", + "en-ie": "Inglese (Irlanda)", + "en-im": "Inglese (Isola di Man)", + "en-io": "Inglese (Territorio Britannico dell'Oceano Indiano)", + "en-je": "Inglese (Jersey)", + "en-jm": "Inglese (Giamaica)", + "en-ki": "Inglese (Kiribati)", + "en-kn": "Inglese (Saint Kitts e Nevis)", + "en-ky": "Inglese (Isole Cayman)", + "en-lc": "Inglese (Saint Lucia)", + "en-lr": "Inglese (Liberia)", + "en-ls": "Inglese (Lesotho)", + "en-mh": "Inglese (Isole Marshall)", + "en-mp": "Inglese (Isole Marianne Settentrionali)", + "en-ms": "Inglese (Montserrat)", + "en-mu": "Inglese (Mauritius)", + "en-mw": "Inglese (Malawi)", + "en-na": "Inglese (Namibia)", + "en-nf": "Inglese (Isola Norfolk)", + "en-ng": "Inglese (Nigeria)", + "en-nr": "Inglese (Nauru)", + "en-nu": "Inglese (Niue)", + "en-nz": "Inglese (Nuova Zelanda)", + "en-pg": "Inglese (Papua Nuova Guinea)", + "en-pn": "Inglese (Isole Pitcairn)", + "en-pw": "Inglese (Palau)", + "en-sb": "Inglese (Isole Salomone)", + "en-sc": "Inglese (Seychelles)", + "en-sg": "Inglese (Singapore)", + "en-sh": "Inglese (Sant'Elena)", + "en-sl": "Inglese (Sierra Leone)", + "en-ss": "Inglese (Sud Sudan)", + "en-sz": "Inglese (Eswatini)", + "en-ta": "Inglese (Tristan da Cunha)", + "en-tc": "Inglese (Isole Turks e Caicos)", + "en-tk": "Inglese (Tokelau)", + "en-to": "Inglese (Tonga)", + "en-tt": "Inglese (Trinidad e Tobago)", + "en-tv": "Inglese (Tuvalu)", + "en-ug": "Inglese (Uganda)", + "en-um": "Inglese (Isole Minori Esterne degli Stati Uniti)", + "en-us": "Inglese (Stati Uniti)", + "en-vc": "Inglese (Saint Vincent e Grenadine)", + "en-vg": "Inglese (Isole Vergini Britanniche)", + "en-vi": "Inglese (Isole Vergini Americane)", + "en-vu": "Inglese (Vanuatu)", + "en-ws": "Inglese (Samoa)", + "en-za": "Inglese (Sudafrica)", + "en-zm": "Inglese (Zambia)", + "en-zw": "Inglese (Zimbabwe)", + "es-ar": "Spagnolo (Argentina)", + "es-bo": "Spagnolo (Bolivia)", + "es-cl": "Spagnolo (Cile)", + "es-co": "Spagnolo (Colombia)", + "es-cr": "Spagnolo (Costa Rica)", + "es-cu": "Spagnolo (Cuba)", + "es-do": "Spagnolo (Repubblica Dominicana)", + "es-ec": "Spagnolo (Ecuador)", + "es-es": "Spagnolo (Spagna)", + "es-gq": "Spagnolo (Guinea Equatoriale)", + "es-gt": "Spagnolo (Guatemala)", + "es-hn": "Spagnolo (Honduras)", + "es-ic": "Spagnolo (Isole Canarie)", + "es-mx": "Spagnolo (Messico)", + "es-ni": "Spagnolo (Nicaragua)", + "es-pa": "Spagnolo (Panama)", + "es-pe": "Spagnolo (Perù)", + "es-pr": "Spagnolo (Portorico)", + "es-py": "Spagnolo (Paraguay)", + "es-sv": "Spagnolo (El Salvador)", + "es-uy": "Spagnolo (Uruguay)", + "es-ve": "Spagnolo (Venezuela)", + "et-ee": "Estone (Estonia)", + "fa-ir": "Persiano (Iran)", + "fi-fi": "Finlandese (Finlandia)", + "fo-fo": "Faroese (Isole Fær Øer)", + "fr-bf": "Francese (Burkina Faso)", + "fr-bj": "Francese (Benin)", + "fr-bl": "Francese (Saint-Barthélemy)", + "fr-cd": "Francese (Congo - Kinshasa)", + "fr-cf": "Francese (Repubblica Centrafricana)", + "fr-cg": "Francese (Congo-Brazzaville)", + "fr-ci": "Francese (Costa d'Avorio)", + "fr-cm": "Francese (Camerun)", + "fr-dj": "Francese (Gibuti)", + "fr-fr": "Francese (Francia)", + "fr-ga": "Francese (Gabon)", + "fr-gf": "Francese (Guyana Francese)", + "fr-gn": "Francese (Guinea)", + "fr-gp": "Francese (Guadalupa)", + "fr-ht": "Francese (Haiti)", + "fr-lu": "Francese (Lussemburgo)", + "fr-mc": "Francese (Monaco)", + "fr-mf": "Francese (Saint Martin)", + "fr-mq": "Francese (Martinica)", + "fr-nc": "Francese (Nuova Caledonia)", + "fr-ne": "Francese (Niger)", + "fr-pf": "Francese (Polinesia Francese)", + "fr-pm": "Francese (Saint-Pierre e Miquelon)", + "fr-re": "Francese (Riunione)", + "fr-sn": "Francese (Senegal)", + "fr-td": "Francese (Ciad)", + "fr-tf": "Francese (Terre Australi Francesi)", + "fr-tg": "Francese (Togo)", + "fr-wf": "Francese (Wallis e Futuna)", + "fr-yt": "Francese (Mayotte)", + "he-il": "Ebraico (Israele)", + "hi-in": "Hindi (India)", + "hr-hr": "Croato (Croazia)", + "hu-hu": "Ungherese (Ungheria)", + "hy-am": "Armeno (Armenia)", + "id-id": "Indonesiano (Indonesia)", + "is-is": "Islandese (Islanda)", + "it-it": "Italiano (Italia)", + "it-sm": "Italiano (San Marino)", + "it-va": "Italiano (Città del Vaticano)", + "ja-jp": "Giapponese (Giappone)", + "ka-ge": "Georgiano (Georgia)", + "kk-kz": "Kazako (Kazakistan)", + "kl-gl": "Groenlandese (Groenlandia)", + "km-kh": "Khmer (Cambogia)", + "ko-kp": "Coreano (Corea del Nord)", + "ko-kr": "Coreano (Corea del Sud)", + "ky-kg": "Kirghiso (Kirghizistan)", + "lo-la": "Lao (Laos)", + "lt-lt": "Lituano (Lituania)", + "lv-lv": "Lettone (Lettonia)", + "mg-mg": "Malgascio (Madagascar)", + "mk-mk": "Macedone (Macedonia del Nord)", + "mn-mn": "Mongolo (Mongolia)", + "ms-bn": "Malese (Brunei)", + "ms-my": "Malese (Malaysia)", + "mt-mt": "Maltese (Malta)", + "my-mm": "Birmano (Myanmar (Birmania))", + "ne-np": "Nepalese (Nepal)", + "nl-aw": "Olandese (Aruba)", + "nl-be": "Olandese (Belgio)", + "nl-bq": "Olandese (Caraibi Olandesi)", + "nl-cw": "Olandese (Curaçao)", + "nl-nl": "Olandese (Paesi Bassi)", + "nl-sr": "Olandese (Suriname)", + "nl-sx": "Olandese (Sint Maarten)", + "no-bv": "Norvegese (Isola Bouvet)", + "no-no": "Norvegese (Norvegia)", + "no-sj": "Norvegese (Svalbard e Jan Mayen)", + "other": "Altro", + "pl-pl": "Polacco (Polonia)", + "pt-ao": "Portoghese (Angola)", + "pt-br": "Portoghese (Brasile)", + "pt-cv": "Portoghese (Capo Verde)", + "pt-gw": "Portoghese (Guinea-Bissau)", + "pt-mz": "Portoghese (Mozambico)", + "pt-pt": "Portoghese (Portogallo)", + "pt-st": "Portoghese (São Tomé e Príncipe)", + "ro-md": "Rumeno (Moldavia)", + "ro-ro": "Rumeno (Romania)", + "ru-ru": "Russo (Russia)", + "rw-kg": "Kinyarwanda (Kirghizistan)", + "si-lk": "Singalese (Sri Lanka)", + "sk-sk": "Slovacco (Slovacchia)", + "sl-si": "Sloveno (Slovenia)", + "so-so": "Somalo (Somalia)", + "sq-al": "Albanese (Albania)", + "sq-xk": "Albanese (Kosovo)", + "sr-me": "Serbo (Montenegro)", + "sr-rs": "Serbo (Serbia)", + "sv-ax": "Svedese (Isole Åland)", + "sv-se": "Svedese (Svezia)", + "sw-ke": "Swahili (Kenya)", + "sw-tz": "Swahili (Tanzania)", + "tet-tl": "Tetum (Timor Est)", + "tg-tj": "Tagico (Tagikistan)", + "th-th": "Thailandese (Thailandia)", + "ti-er": "Tigrino (Eritrea)", + "tk-tm": "Turcomanno (Turkmenistan)", + "tl-ph": "Tagalog (Filippine)", + "tr-tr": "Turco (Turchia)", + "uk-ua": "Ucraino (Ucraina)", + "undefined": "Altro/non definito", + "unknownLanguageCode": "Codice lingua sconosciuto: ", + "ur-pk": "Urdu (Pakistan)", + "uz-uz": "Uzbeco (Uzbekistan)", + "vi-vn": "Vietnamita (Vietnam)", + "xx-aq": "Lingue varie (Antartide)", + "xx-eu": "Lingue varie (Unione europea)", + "zh-cn": "Cinese (Cina)", + "zh-hk": "Cinese (RAS di Hong Kong)", + "zh-mo": "Cinese (RAS di Macao)", + "zh-tw": "Cinese (Taiwan)" + }, + "radioStreamSearch": { + "failedToFetchSearchResults": "Impossibile recuperare i risultati della ricerca delle stazioni radio", + "failedToFetchSearchResultsDetails": "Impossibile recuperare i risultati della ricerca delle stazioni radio: ", + "noResults": "Nessun risultato trovato.", + "searchLabel": "[Facoltativo] Cerca una stazione radio" + }, + "security": { + "alert": "Avviso", + "incident_detected": "Rilevato un incidente di sicurezza! Controlla i log!" + }, + "server": { + "boxineDisabled": "Boxine/Toniecloud è attualmente disabilitato. Abilita l'impostazione 'Cloud enabled' per riconnetterti a Boxine/Toniecloud.", + "boxineStatusOffline": "Non connesso a Boxine/Toniecloud", + "boxineStatusOnline": "Connesso a Boxine/Toniecloud", + "teddycloudStatusOffline": "Server Teddycloud offline", + "teddycloudStatusOnline": "Server Teddycloud in esecuzione correttamente" + }, + "settings": { + "certificates": { + "hint": "Questi sono i certificati utilizzati per l'uso generale di TeddyCloud. Per i tuoi Toniebox possono essere utilizzati certificati specifici. Per modificarli, vai a ", + "information": "Informazioni", + "invalidFileType": "Tipo di file non valido! Sono consentiti solo file .der!", + "navigationTitle": "Certificati", + "title": "Certificati", + "uploadFailed": "Caricamento del certificato non riuscito!", + "uploadFailedDetails": "Caricamento del certificato {{filename}} non riuscito", + "uploadHint": "Trascina qui i certificati client (*.der) dal tuo box", + "uploadSuccessful": "Certificato caricato correttamente.", + "uploadSuccessfulDetails": "Certificato {{filename}} caricato correttamente.", + "uploadText": "Fai clic o trascina i certificati client in quest'area per caricarli" + }, + "close": "Chiudi", + "discard": "Annulla modifiche", + "errorFetchingFieldValue": "Errore durante il recupero del valore del campo", + "errorFetchingFieldValueDetails": "Errore durante il recupero del valore del campo per \"{{setting}}\"{{overlay}}:", + "errorFetchingSetting": "Errore durante il recupero dell'impostazione!", + "errorFetchingSettingDetails": "Errore durante il recupero dell'impostazione \"{{setting}}\": ", + "errorSettingSettingsLevel": "Errore durante l'impostazione del livello delle impostazioni", + "errorSettingSettingsLevelDetails": "Impossibile impostare il nuovo livello delle impostazioni:", + "errorWhileSavingConfig": "Salvataggio della configurazione su file non riuscito", + "errorWhileSavingConfigDetails": "Errore durante il salvataggio della configurazione su file: ", + "general": { + "navigationTitle": "Generale", + "title": "Impostazioni generali" + }, + "guiSettings": { + "exportSettings": "Esporta", + "importSettings": "Importa", + "jsonLoaded": "Memoria locale caricata", + "jsonLoadedDetails": "Il JSON della memoria locale è stato caricato correttamente!", + "jsonLoadFailed": "Caricamento della memoria locale non riuscito", + "jsonLoadFailedDetails": "Si è verificato un errore durante il caricamento del JSON della memoria locale: ", + "navigationTitle": "Impostazioni GUI", + "noLocalSettings": "Nessun valore salvato localmente trovato", + "title": "Impostazioni GUI e valori salvati localmente" + }, + "hint": "Queste sono le impostazioni predefinite. Alcune impostazioni possono essere sovrascritte da ciascuno dei tuoi Toniebox. Per modificare queste impostazioni, vai a ", + "information": "Informazioni", + "keepScrolling": "Continua a scorrere per vedere altre opzioni.", + "levelLabel": "Livello impostazioni", + "navigationTitle": "Impostazioni", + "notifications": { + "clickToConfirm": "Fai clic per confermare", + "colContext": "Contesto", + "colDate": "Data", + "colDetails": "Dettagli/Descrizione", + "colStatus": "Stato", + "colTitle": "Titolo", + "colType": "Tipo", + "confirmed": "confermato", + "confirmSelectedNotifications": "Conferma le notifiche selezionate", + "endDate": "Data di fine", + "error": "Errore", + "filterBy": "Filtra per", + "info": "Info", + "navigationTitle": "Notifiche", + "pageSelector": "notifiche/pagina", + "removeAll": "Rimuovi tutte", + "removeSelectedNotifications": "Elimina le notifiche selezionate", + "searchIn": "Cerca in", + "startDate": "Data di inizio", + "success": "Operazione riuscita", + "title": "Notifiche", + "unconfirmed": "non confermato", + "warning": "Avviso" + }, + "overlayDisabled": "Sovrascrittura disabilitata!", + "overlayed": "sovrascritto", + "overlayEnabled": "Sovrascrittura abilitata!", + "resetToTCDetails": "L'impostazione \"{{setting}}\"{{overlay}} è stata ripristinata correttamente al valore TeddyCloud.", + "restartComplete": "Riavvio del server TeddyCloud riuscito!", + "restartFailed": "Riavvio del server TeddyCloud non riuscito!", + "restartInProgress": "TeddyCloud si sta riavviando...", + "restartServer": "Riavvia server", + "restartTC": "Riavvio di TeddyCloud", + "rtnl": { + "clear": "Cancella log RTNL", + "navigationTitle": "Log RTNL", + "title": "Log RTNL" + }, + "save": "Salva", + "saved": "Impostazione salvata!", + "saveDetails": "L'impostazione \"{{setting}}\"{{overlay}} è stata salvata correttamente.", + "title": "Impostazioni", + "tonieboxes": "toniebox", + "toniesJsonUpdate": "Ricarica Tonies.json", + "toniesJsonUpdateFailed": "Impossibile ricaricare Tonies(.custom).json!", + "toniesJsonUpdateInProgress": "Caricamento di Tonies(.custom).json...", + "toniesJsonUpdateSuccessful": "Tonies(.custom).json ricaricato correttamente!", + "tryToRestartTC": "Prova a riavviare TeddyCloud", + "warning": "Attenzione! Livello impostazioni", + "warningHint": "Se mancano delle impostazioni, aumenta il 'Livello impostazioni'.", + "webAuth": { + "addUser": "Aggiungi utente", + "changePassword": "Cambia password", + "changePasswordFor": "Cambia password per {{username}}", + "createFirstUser": "Crea un utente prima di abilitare la protezione tramite accesso.", + "deleteContent": "Eliminare l'utente {{username}}?", + "deleteLastContent": "Eliminare l'ultimo utente {{username}}? La protezione tramite accesso verrà disattivata.", + "deleteLastHint": "Puoi riattivare la protezione tramite accesso dopo aver creato un utente.", + "deleteLastTitle": "Elimina ultimo utente", + "deleteTitle": "Elimina utente", + "deleteUser": "Elimina", + "enabled": "Richiedi l'accesso per l'interfaccia Web", + "envOverride": "La protezione tramite accesso è attualmente disattivata da TEDDYCLOUD_WEB_AUTH_DISABLE.", + "intro": "Tutti gli utenti hanno accesso completo all'interfaccia Web. Le API del Toniebox non sono interessate.", + "loadFailed": "Impossibile caricare gli utenti", + "navigationTitle": "Accesso Web", + "passwordChanged": "Password aggiornata", + "passwordChangedDetails": "La password per {{username}} è stata aggiornata.", + "saveFailed": "Impossibile salvare le modifiche", + "title": "Accesso Web", + "users": "Utenti" + } + }, + "teddycloud": { + "newVersionAvailable": "È disponibile una nuova versione!", + "newVersionDetailsAvailable": "Non stai utilizzando l'ultima versione di TeddyCloud. Aggiorna per usufruire di tutti i vantaggi dell'ultima release!", + "newVersionDetailsAvailableDevelop": "Non stai utilizzando l'ultima versione di sviluppo di TeddyCloud. Aggiorna per usufruire di tutti i vantaggi dell'ultima versione di sviluppo!", + "newVersionDetailsAvailableReleaseCandidate": "Non stai utilizzando l'ultima versione candidata al rilascio di TeddyCloud. Aggiorna per sfruttare tutti i vantaggi dell'ultima versione candidata!", + "newVersionShort": "Aggiornamento disponibile", + "openNewVersionInGithub": "Apri l'ultima versione su GitHub" + }, + "tonieboxes": { + "accessApiDisabled": "Accesso alle API TeddyCloud disabilitato.", + "accessApiEnabled": "Accesso alle API TeddyCloud abilitato.", + "availableBoxModal": { + "availableBoxes": "Toniebox disponibili (solo Toniebox {{boxVersion}})", + "boxVersion": "Versione", + "cancel": "Annulla", + "cc3200Hint": ", che sia avviato lo slot firmware corretto e che siano impostate tutte le patch necessarie", + "commonName": "Indirizzo MAC del Toniebox", + "newBoxAvailable": "Controlla se il nuovo box compare nella tabella seguente. Se non è presente, qualcosa potrebbe essere andato storto. Assicurati di aver abilitato l'impostazione \"Add new boxes\", che il box sia visibile nella tua rete Wi-Fi, che la CA sostitutiva sia stata flashata{{cc3200Hint}} e che venga utilizzato l'indirizzo IP/URL corretto per TeddyCloud. Se il box continua a non comparire, controlla qui:", + "ok": "OK", + "recheck": "Ricontrolla", + "status": "Stato", + "troubleShooting": "Risoluzione dei problemi" + }, + "boxCertificate": { + "nextConnection": "Lo stato viene aggiornato alla prossima connessione della Toniebox.", + "pin": "Certificato fissato (SHA-256)", + "presented": "Certificato presentato (SHA-256)", + "status": "Stato", + "statusText": { + "mismatch": "Certificato diverso da quello fissato!", + "none": "Non ancora verificato", + "pinned": "Certificato fissato", + "trusted": "Certificato emesso da TeddyCloud" + }, + "title": "Verifica del certificato" + }, + "boxFlashingCommon": { + "adguard": { + "adGuardRequirement": "AdGuard: assicurati che AdGuard Home sia installato e configurato su un dispositivo della tua rete.", + "clientExplanation": "Limita questa regola alle richieste provenienti specificamente dall'indirizzo IP YYY.YYY.YYY.YYY (IP del Toniebox).", + "dnsRewriteExplanation": "Specifica che la risposta DNS deve essere un record A valido (NOERROR) che punta a XXX.XXX.XXX.XXX (IP di TeddyCloud).", + "explanationTitle": "Spiegazione di ciascun componente:", + "ipReservationInstructions": "Prenotazioni degli indirizzi IP: assicurati che sia l'istanza TeddyCloud sia il Toniebox abbiano indirizzi IP statici nella rete, assegnandoli manualmente o usando prenotazioni DHCP nelle impostazioni del router.", + "loggingMonitoringInstructions": "Registrazione e monitoraggio: in AdGuard Home abilita la registrazione delle query per monitorare e verificare che le richieste del Toniebox vengano risolte correttamente.", + "moreInformation": "Ulteriori informazioni su AdGuard:", + "prerequisitesTitle": "Prerequisiti", + "prodDomainExplanation": "Corrisponde a qualsiasi sottodominio di prod.de.tbs.toys.", + "setupInstructionsTitle": "Per configurare AdGuard come server DNS con regole di riscrittura DNS personalizzate per la tua istanza TeddyCloud e il Toniebox, segui questi passaggi:", + "step1Instructions": "Accedi alla dashboard di AdGuard Home. Vai a Settings > General Settings. Nelle impostazioni DNS, abilita AdGuard come resolver DNS principale impostandolo come server DNS della rete/router o dei singoli dispositivi.", + "step1Title": "1. Configura AdGuard Home come server DNS", + "step2Instructions": "Nella dashboard di AdGuard Home, vai a Filters > Custom Filtering Rules. Aggiungi le seguenti regole personalizzate per riscrivere le query DNS di domini specifici verso l'IP del tuo TeddyCloud, solo quando la richiesta proviene dal Toniebox.", + "step2Title": "2. Aggiungi regole di riscrittura DNS personalizzate", + "step3Instructions": "Riavvia il servizio AdGuard Home per applicare le modifiche. Verifica le riscritture DNS per confermare che le richieste del Toniebox verso prod.de.tbs.toys e rtnl.bxcl.de vengano risolte sull'IP di TeddyCloud. Gli altri dispositivi della rete dovrebbero risolvere questi domini tramite il normale DNS (salvo diversa configurazione).", + "step3Title": "3. Verifica e testa la configurazione", + "step4Title": "4. Sicurezza e gestione aggiuntive (facoltative)", + "stepsTitle": "Passaggi", + "teddyCloudIp": "IP TeddyCloud: ottieni l'indirizzo IP statico della tua istanza TeddyCloud (sostituisci XXX.XXX.XXX.XXX).", + "tonieboxIp": "IP Toniebox: individua l'indirizzo IP statico del tuo Toniebox (sostituisci YYY.YYY.YYY.YYY).", + "usingAdGuard": "Uso di AdGuard" + }, + "alternativeDNSSolutions": "Altre soluzioni non invasive", + "certificates": "Certificati", + "certificatesIntro": "Al primo avvio TeddyCloud genererà la CA e i certificati con data iniziale 2015-11-03. Verranno salvati in /certs/server/. Viene inoltre generata la CA sostitutiva per il Toniebox in certs/server/ca.der.", + "connectTBToWifi": { + "preparationText": "Collega il Toniebox alla rete Wi-Fi e aggiorna il firmware. Molti box vengono forniti con un firmware di produzione che deve essere aggiornato; altrimenti il box potrebbe non funzionare correttamente. Non è necessario collegare il box all'app/account mytonies.", + "step1": "**Accendi** il Toniebox e stringi entrambe le orecchie per 5 secondi finché il LED diventa blu e senti un suono.", + "step2": "Attendi circa 20 secondi finché non senti il secondo suono.", + "step3": "Sul tuo dispositivo apri le impostazioni Wi-Fi e connettiti alla rete `Toniebox-####`.", + "step4": "Ignora l'avviso 'Nessun accesso a Internet' se compare.", + "step5": "Apri un browser web e vai a `192.168.1.1`.", + "step6": "Seleziona la tua **lingua** e fai clic su _Ok_ per continuare.", + "step7": "Scegli la tua **rete Wi-Fi domestica a 2,4 GHz**, inserisci la password e fai clic su `Connect Toniebox`.", + "step8": "Attendi che il LED diventi verde fisso. Quando è verde, posiziona un Tonie sul Toniebox per eseguire un test.", + "title": "Connetti il Toniebox al Wi-Fi (senza assistente di configurazione e account tonies)" + }, + "dns": "DNS", + "dnsBeware": "Attenzione!", + "dnsBewareText": "Questa operazione interromperà la connessione di tutti i Toniebox nella tua rete che non sono stati modificati con la tua CA sostitutiva!", + "dnsHint": "Adattamenti DNS per TeddyCloud", + "dnsText1": "Imposta le voci DNS per prod.de.tbs.toys e rtnl.bxcl.de sull'indirizzo IP del server TeddyCloud.", + "dumpCertificates": "Estrai certificati per TeddyCloud", + "dumpCertificatesIntro1": "Ti serviranno flash:/cert/ca.der (CA Boxine), flash:/cert/client.der (certificato client) e flash:/cert/private.der (chiave privata client).", + "dumpCertificatesIntro2": "Conserva una copia di backup dei certificati, in particolare client.der e private.der. Senza di essi non potrai più connetterti al cloud!", + "installCC3200Tool": { + "gitText1": "Installa ", + "gitText2": ", aggiungilo al PATH se vuoi", + "gitTextLink": "git", + "intro": "Questo strumento funziona con Python >=3.6 e una versione recente di pySerial. Sono quindi necessari alcuni passaggi preparatori per installarlo:", + "link": "Repository GitHub dello strumento", + "moreInformation": "Ulteriori informazioni sullo strumento sono disponibili qui:", + "pipText1": "(Facoltativo) installa ", + "pipText2": ", se non è già incluso nell'installazione di Python", + "pipTextLink": "pip", + "pythonText1": "Installa Python tramite ", + "pythonText2": ", selezionando l'opzione per aggiungerlo al PATH", + "pythonTextLink": "programma di installazione grafico", + "text": "Ora puoi installare cc3200tool. A seconda che tu abbia aggiunto git al PATH, usa la riga di comando oppure avvia git cmd. Quindi inserisci", + "textAlternatively": "Se non funziona, prova quanto segue: ", + "title": "Installa lo strumento CC3200" + }, + "openWrt": { + "dnsText2": "In alternativa puoi impostare come gateway dei Toniebox l'IP di TeddyCloud. Con OpenWRT funziona così:", + "usingOpenWrt": "Uso di OpenWRT" + }, + "preparations": "Preparativi" + }, + "boxSetup": { + "boxSetupIntro1": "Questa pagina offre una panoramica completa dei passaggi necessari per aggiungere il tuo Toniebox a TeddyCloud. Se riscontri problemi, visita il", + "boxSetupIntro2": "oppure unisciti al", + "boxSetupIntro3": "per ricevere supporto dalla comunità.", + "boxSetupIntroForum": "forum", + "boxSetupIntroTelegram": "gruppo Telegram", + "boxVersion": { + "cc3200con1": "È la versione più vecchia disponibile", + "cc3200pro1": "Flashing tramite riga di comando + adattatore UART", + "cc3200pro2": "Supporta bootloader personalizzato (HackieboxNG) e firmware (Hackiebox)", + "cc3200pro3": "Consente di passare facilmente tra diverse versioni del firmware (fino a 9 slot, incluso il firmware originale) quando è installato il bootloader personalizzato", + "cc3200pro4": "Le patch consentono di modificare IP/hostname di TeddyCloud quando è installato il bootloader personalizzato", + "cc3200pro5": "Sono disponibili patch aggiuntive, incluso il supporto per tag NFC diversi da SLIX-L quando si utilizza il bootloader personalizzato", + "cc3235con1": "Il flashing è complesso e richiede accesso diretto alla memoria", + "cc3235con2": "Il firmware è firmato e crittografato", + "cc3235con3": "La sostituzione della CA può essere poco affidabile e richiedere diversi tentativi", + "cons": "Svantaggi", + "countryOfManufacture": "Paese di produzione:", + "countryOfManufactureText": "Controlla il fondo del box per vedere se è stato prodotto in Cina o in Ungheria. I Toniebox prodotti in Cina (da 01/2024 in poi) sono probabilmente basati su ESP32.", + "emptyCons": "Nessuno svantaggio significativo", + "emptyPros": "Nessun vantaggio significativo", + "esp32con1": "Il firmware personalizzato è in fase di sviluppo, ma al momento il lavoro è sospeso e nello stato attuale non è funzionante", + "esp32con2": "Le modifiche all'IP/hostname di TeddyCloud o il ritorno al firmware originale richiedono un nuovo flashing", + "esp32pro1": "Versione più recente disponibile", + "esp32pro2": "Metodo di flashing più semplice tramite browser + adattatore UART", + "esp32pro3": "ESP32 è supportato da una vasta comunità di sviluppatori", + "howToIdentify": "Come identificare la versione del tuo Toniebox 1 senza aprire la confezione", + "howToIdentifyIntro": "Poiché non esiste un'etichetta che specifichi la versione esatta, per identificare la versione del Toniebox servono alcuni piccoli indizi:", + "howToIdentifyOutro": "Per maggiori dettagli, consulta ", + "intro": "Attualmente sono note quattro versioni del Toniebox 1, che si differenziano principalmente per l'MCU (microcontrollore) utilizzato, e una versione del Toniebox 2:", + "macAddressCheck": "Controllo indirizzo MAC:", + "macAddressCheckText": "Il modo più affidabile per determinare la versione è controllare l'indirizzo MAC stampato, che può trovarsi sulla confezione o direttamente sul Toniebox. Usa macvendors.com per cercare l'indirizzo MAC e individuare il produttore dell'MCU, dato che può aiutarti a identificare la versione del Toniebox.", + "navigationTitle": "Versioni", + "pros": "Vantaggi", + "title": "Informazioni sulle diverse versioni del Toniebox", + "uses": "Utilizza", + "whatVersion": "Quale versione di Toniebox 1 dovrei scegliere?", + "whatVersionConclusion": "Non consigliamo la versione TB1 V3 CC3235 a causa delle difficoltà di flashing e del supporto limitato. Attualmente la versione TB1 V1/V2 (TI CC3200) offre il maggior numero di funzionalità, incluso il supporto per bootloader e firmware personalizzati. Tuttavia, pensando al futuro, la nostra principale raccomandazione è la versione TB1 V4 basata su ESP32.", + "whatVersionConclusionHeader": "Conclusione", + "whatVersionIntro": "Se non hai ancora scelto un Toniebox 1, qui trovi i vantaggi e gli svantaggi di ogni versione per aiutarti a decidere. In definitiva, la scelta dipende dalle tue esigenze e preferenze specifiche. In ogni caso, tutte le versioni sono compatibili con TeddyCloud." + }, + "flashBox": "Flasha Toniebox", + "flashBoxText": "Per aggiungere il Toniebox a TeddyCloud sono necessarie alcune modifiche. A seconda della versione del Toniebox, sono disponibili tre guide specifiche:", + "identifyTonieboxVersion": "Identifica la versione del tuo Toniebox", + "identifyTonieboxVersionText": "Il primo passo per aggiungere il Toniebox a TeddyCloud è identificarne la versione. La versione determina il metodo specifico necessario per modificare il Toniebox e renderlo compatibile con TeddyCloud.", + "identifyVersion": { + "boxMacTooLong": "L'indirizzo MAC inserito non è valido (troppo lungo)", + "boxVersion": "Questo significa che il tuo box è un", + "cc3200text": "Questa versione riporta la scritta CC3200R1 sul chip principale, come mostrato nelle immagini seguenti.", + "cc3235text": "Anche in questo caso la versione è indicata sul chip principale, come mostrato nelle immagini seguenti: CC3235.", + "errorMac": "Impossibile identificare il produttore con l'indirizzo MAC fornito", + "esp32text": "Il PCB e il chip principale della versione ESP32 appaiono come nelle immagini seguenti.", + "generalInstruction1": "Se non riesci a recuperare l'indirizzo MAC, puoi identificare la versione specifica del Toniebox", + "generalInstruction2": "aprendolo e controllando il chip principale sul PCB. Fai clic sulla scheda di ciascuna versione per ulteriori dettagli.", + "generalInstructionLinkText": "aprendolo", + "identify": "Identifica", + "identifyUsingMac": "Identifica la versione tramite l'indirizzo MAC del tuo Toniebox. Trovi l'indirizzo MAC sul fondo del Toniebox.", + "mac": "Inserisci l'indirizzo MAC:", + "macvendors": "Il controllo utilizza l'API fornita da", + "macvendorsLink": "macvendors.com", + "navigationTitle": "Identifica versione", + "or": "oppure", + "proceedToFlash1": "Vai alla pagina di flashing per la versione selezionata:", + "proceedToFlashLinkText": "Flashing del box", + "tb2": "Riguardo al Toniebox 2", + "tb2Details": "È nota una sola versione dell'ultimo Toniebox 2, quindi l'identificazione è semplice. Se il tuo box è un Toniebox 2, puoi andare direttamente alle istruzioni di flashing per Toniebox 2.", + "tb2text": "Questa versione è unica e riporta TB-MAIN_2.0.P sul PCB, come mostrato nell'immagine seguente.", + "tiInstruction": "Per individuare la versione specifica devi aprire il Toniebox e identificare il chip principale sul PCB.", + "title": "Identifica la versione del tuo Toniebox", + "vendor": "Il produttore del chip principale del tuo Toniebox è", + "version": "versione" + }, + "navigationTitle": "Aggiungi Toniebox", + "newbieGuides": "Nel forum Revvox sono disponibili diverse guide per principianti. Potrebbero esserti utili:", + "openBoxGuide": { + "alternativeGuidelineVideo": "In alternativa puoi guardare la seguente guida su YouTube:", + "finally": "Ora puoi accedere al PCB per identificare la versione del Toniebox e modificarlo!", + "guideSourceLicense": "licenza Creative Commons BY-NC-SA 3.0", + "guideSourcePart1": "I passaggi 1-4 di questa guida sono tratti da", + "guideSourcePart2": "e il passaggio 5 è tratto da", + "guideSourcePart3": "Entrambe le guide sono state scritte originariamente da", + "guideSourcePart4": "che è anche l'autore di tutte le immagini utilizzate. Questa guida è concessa in licenza secondo la ", + "link1": "https://www.ifixit.com/Guide/Toniebox+Opening+Procedure/124139", + "link2": "https://www.ifixit.com/Teardown/Toniebox+Teardown/106148", + "navigationTitle": "Apri Toniebox", + "step1": "Passaggio 1", + "step1Text1": "Posiziona il Toniebox su una superficie piana (ad es. una scrivania o un banco da lavoro).", + "step1Text2": "Applica una leggera pressione e ruota il Toniebox in senso antiorario per sbloccare le due clip sotto la piastra inferiore.", + "step1Text3": "Sentirai un \"clic\" quando le due clip si sbloccano.", + "step2": "Passaggio 2", + "step2Text": "Rimuovi la piastra inferiore.", + "step3": "Passaggio 3", + "step3Text": "Rimuovi la vite Phillips #1 sul fondo del Toniebox.", + "step4": "Passaggio 4", + "step4Text": "Solleva la parte interna fuori dall'involucro in schiuma.", + "step5": "Passaggio 5", + "step5Text1": "Il pacco batteria e la scheda madre sono fissati con sole due normali viti Phillips.", + "step5Text2": "Rimuovi le viti e potrai scollegare il pacco batteria dal PCB.", + "title": "Come aprire il Toniebox e accedere al PCB" + }, + "setupTeddyCloud": "Configura TeddyCloud", + "setupTeddyCloudText": "Congratulazioni! Se sei arrivato a questa pagina, hai configurato TeddyCloud correttamente!", + "title": "Aggiungi il tuo Toniebox a TeddyCloud", + "UARTHint": { + "ackUARTHint": "Ho impostato la tensione corretta (3,3 V) e installato il driver più recente richiesto.", + "description": "Prima di procedere, verifica attentamente quanto segue:", + "imageAlt1": "Esempio ponticello 1", + "imageAlt2": "Esempio ponticello 2", + "imageAlt3": "Esempio ponticello 3", + "noUSBCVersion": "Recentemente sono stati segnalati diversi problemi con la versione USB-C dell'UART, inclusi errori di connessione. Per evitare questi problemi consigliamo vivamente di utilizzare la più affidabile versione micro-USB.", + "title": "Avviso importante: collegamento UART e impostazioni di tensione", + "uartDriver": "Installazione driver UART", + "updateDriver": "Assicurati che sul dispositivo sia installato il driver UART più recente. Controlla in Gestione dispositivi. Se non è installato alcun driver, scarica e installa quello appropriato. Il flashing potrebbe non funzionare in modo affidabile con driver generici o obsoleti.", + "voltageCompatibility": "Compatibilità della tensione", + "voltageCompatibilityText1": "Assicurati di utilizzare un adattatore UART che supporti 3,3 V.", + "voltageCompatibilityText2": "Se il tuo adattatore UART supporta sia 3,3 V sia 5 V, devi impostarlo su 3,3 V. Nella maggior parte dei casi sulla scheda è presente un ponticello per selezionare la tensione corretta. Qui puoi vedere alcuni esempi di tali ponticelli (la tua scheda potrebbe essere diversa):", + "warning": "Importante: assicurati assolutamente che la tensione sia impostata su 3,3 V (spesso indicata come 3v3). L'uso dell'impostazione a 5 V può danneggiare permanentemente il Toniebox." + }, + "uploadCertificateButton": "Carica certificati", + "uploadCertificateIntro": "Dovresti caricare i certificati estratti in TeddyCloud se questo è il tuo primo Toniebox o se vuoi designare questo Toniebox come quello utilizzato per scaricare contenuti dal cloud Tonies originale. Inoltre devi aggiungere i certificati al Toniebox all'interno di TeddyCloud. Per farlo, vai alla sezione Toniebox, fai clic sull'icona Certificato e carica nuovamente i certificati.", + "uploadCertificates": "Carica certificati (ca.der, client.der e private.der)", + "uploadCertificatesToBox": "Se il Toniebox è visualizzato, vai alla sezione Toniebox e carica sul box appena aggiunto i certificati estratti nel passaggio precedente. Usa l'icona del certificato nel piè di pagina della scheda del box per completare il caricamento: ", + "useIt": "Usalo!", + "useItText": "Ora puoi assegnare contenuti personalizzati ai tuoi Tonie o tag personalizzati, rilassarti e goderti le infinite possibilità del tuo Toniebox sbloccato!" + }, + "boxWithoutAPIAccess": "Toniebox senza accesso alle API TeddyCloud!", + "boxWithoutAPIAccessGoToTonieboxes": "Se vuoi modificarlo, abilita l'accesso API per il ", + "boxWithoutAPIAccessText": "I seguenti Toniebox non hanno l'accesso API abilitato:", + "cancel": "Annulla", + "cc3200BoxFlashing": { + "altUrlFritzBoxPatch": { + "CollapseTitle": "Patch altUrl.tc.fritz.box (altUrl.tc.fritz.box.json, preset predefinito in ngCfg.json per lo slot firmware ofw2)", + "text": "Se utilizzi un Fritz!Box, puoi applicare la patch altUrl.tc.fritz.box. Imposta semplicemente il nome del server TeddyCloud su \"tc\" nelle impostazioni del Fritz!Box (Rete domestica -> Rete -> Connessioni di rete -> Modifica)." + }, + "altUrlPatch": { + "CollapseTitle": "Patch altURL (altUrl.305.json)", + "text": "La patch altUrl.305 sostituisce gli URL cloud predefiniti con URL personalizzati. Per utilizzarla, configura il DNS in modo che sia prod.revvox sia rtnl.revvox puntino all'indirizzo IP del server TeddyCloud." + }, + "applyingPatches": "Applicazione delle patch", + "applyingPatchesSection": { + "altPortHint": "Se hai creato una patch della porta, mettila anche in /revvox/boot/patch/ e aggiungi \"altPort.custom\" all'elenco delle patch.", + "examplengCFGJsonCollapse": { + "collapseTitle": "Esempio di \"ngCfg.json\" con patch \"altUrl.custom.305\" usando \"ofw2\"" + }, + "finish": "Se hai modificato ngCfg.json come indicato, rimonta il Toniebox e accendilo. Esegui un freshness check, attendi un momento e fai clic su 'Mostra box disponibili' per visualizzare i Toniebox disponibili nel tuo TeddyCloud.", + "introduction": "La configurazione del bootloader personalizzato avviene nel file ngCfg.json presente sulla scheda SD del Toniebox nella directory sd://revvox/boot (nel passaggio 2 dovresti aver già copiato tutti i file del bootloader Hackiebox NG sulla scheda SD del Toniebox).", + "section2_part1": "Se vuoi utilizzare il Toniebox con Teddycloud, consigliamo di usare il secondo slot firmware. Imposta quindi nella sezione di configurazione generale l'elemento \"activeImg\" su \"ofw2\".", + "section2_part2": "Se hai creato la patch \"altUrl.custom.305\", copia il file della patch sulla scheda SD del Toniebox in sd://revvox/boot/patch. Successivamente puoi aggiungere la patch nella sezione di configurazione del secondo slot firmware (\"ofw2\"). Tieni presente che viene applicata una sola patch altUrl. Rimuovi \"altUrl.tc.fritz.box\" e/o \"altUrl.305\" se sono presenti nella configurazione.", + "technicalDetailsCollapse": { + "activeImgDesc": "Imposta lo slot firmware da selezionare all'avvio", + "bootFlashImgDesc": "Legge il firmware da un file nella flash", + "checkHashDesc": "Controlla l'hash del firmware", + "collapseTitle": "Dettagli tecnici del file di configurazione", + "default": "Predefinito", + "description": "Descrizione", + "firmwareSection": "Sezione firmware", + "firmwareSectionIntro": "Sono disponibili nove slot firmware denominati ofw1, ofw2, ofw3, cfw1, cfw2, cfw3, add1, add2 e add3.", + "flashImgDesc": "Percorso del file nella flash", + "generalSection": "Sezione generale", + "hashFileDesc": "Controlla l'hash dal file ng-XXX?.sha (true) oppure dagli ultimi 64 byte del firmware stesso (ofw)", + "key": "Chiave", + "logColorDesc": "Abilita log colorati", + "logLevelDesc": "Imposta il livello di log 0:Trace - 5:Fatal", + "minBatteryLevelDesc": "Tensione di spegnimento per proteggere la batteria. Dividi per 2785 per ottenere la tensione (standard 3,18 V)", + "ofwFixDesc": "Aggiunge byte magic al firmware per consentire l'avvio diretto di ofw", + "ofwFixFlashDesc": "Byte magic letti dal bootloader ofw nella flash", + "ofwFixValueDesc": "Byte magic da inserire nell'immagine OFW durante l'avvio (possono essere estratti dai dati OFW BL data[-8:-4])", + "ofwSimBLDesc": "Legge l'immagine da avviare da flash:/sys/mcubootinfo.bin e carica l'immagine da flash:/sys/mcuimgN.bin invece di leggerla dalla SD (come il bootloader ofw)", + "patchDirectory": "directory delle patch", + "patchesDesc": "Elenco delle patch da caricare, vedi:", + "patchWiki": "wiki delle patch", + "section1_link": "sd:/revvox/boot/ngCfg.json", + "section1_part1": "La configurazione del bootloader è salvata in", + "section1_part2": ". Tutte le sezioni o le chiavi che iniziano con un trattino basso \"_\" sono commenti e verranno ignorate.", + "section1_part3": "Il file di configurazione è composto da una sezione generale e da sezioni dedicate ai 9 slot firmware disponibili.", + "serialLogDesc": "Abilita il log su UART (TX) a 921600 baud. Funziona solo con build di debug!", + "title": "Configurazione", + "values": "Valori", + "waitForBootDesc": "Attende la pressione di un orecchio prima dell'avvio del firmware con una sequenza lampeggiante (blu, verde, ciano, nero)", + "waitForPressDesc": "Attende la pressione di un orecchio all'avvio con una sequenza lampeggiante (blu, verde, ciano, nero)", + "waitTimeoutInSDesc": "Timeout in secondi per waitForPress se non viene premuto alcun orecchio (ibernazione)", + "watchdogDesc": "Mantiene il watchdog abilitato durante l'avvio del firmware (se l'avvio fallisce, il box si riavvia)" + } + }, + "applyingPatchesWithNgCfgJson": "Applica patch usando ngCfg.json", + "applyingPatchesWithNgCfgJsonLink": "Trova qui le istruzioni", + "bootloader": "Bootloader", + "bootloaderInstalled": "Bootloader installato correttamente", + "certificates": { + "alreadyAvailable": "I certificati ca.der, client.der e private.der dovrebbero essere già disponibili in:", + "extractAgain": "In caso contrario, assicurati di aver eseguito correttamente il passaggio 2. Puoi anche estrarre nuovamente i file dal box usando cc3200tool (il PC dovrebbe essere ancora collegato al Toniebox tramite UART/ESP32-C3 e porta di debug)", + "uploadCertificates": "Carica questi certificati in TeddyCloud usando il pulsante seguente." + }, + "certificatesDumpedCAreplacementFlashed": "Certificati estratti e CA sostitutiva flashata", + "checkBoxes": "Mostra box disponibili", + "connectToToniebox": "Collega il Toniebox per applicare le patch", + "connectToTonieboxConnectDebugPortText1": "Puoi utilizzare un connettore Tag dedicato come ", + "connectToTonieboxConnectDebugPortText2": " oppure utilizzare fili sottili per il collegamento.", + "connectToTonieboxConnectTableExplanation": "*Il pin SOP2 del Toniebox deve essere ponticellato con il VCC del Toniebox.", + "connectToTonieboxConnectTableIntro": "Ora collega la porta di debug del Toniebox alla UART come descritto nella tabella seguente:", + "connectToTonieboxIntro": "Devi collegare la UART/ESP32-C3 alla porta di debug. Individua prima la porta di debug sul lato non popolato del PCB, cioè sul fondo. La disposizione della porta di debug è mostrata nell'immagine seguente.", + "connectToTonieboxLink": "Trova qui le istruzioni di collegamento", + "connectToTonieboxProceed": "Ora sei pronto per procedere!", + "connectToTonieboxText": "Il LED verde sulla scheda madre del Toniebox deve rimanere acceso in modo fisso (senza lampeggiare). In caso contrario, i collegamenti potrebbero essere allentati o errati. Scollega il programmatore, ricontrolla tutti i collegamenti e riprova. Se utilizzi fili sottili, valuta l'uso di colla a caldo per fissarli e impedirne il movimento. Assicurati inoltre che il Toniebox sia alimentato tramite il caricatore.", + "connectToTonieboxTextUart": "Assicurati che la UART sia configurata a 3,3 V prima di collegare il programmatore UART al computer tramite USB.", + "createHostNamePatch": "Crea altUrl.custom.305.patch", + "createPortPatch": "Crea altPort.custom.patch", + "customPortPatch": "Opzionale: Crea una patch della porta (altPort.custom.json)", + "customPortPatchHint": "Necessaria solo se la Toniebox deve raggiungere TeddyCloud su una porta diversa da 443, ad esempio tramite un proxy che instrada in base alla porta. La patch funziona con le versioni firmware 3.1.0 BF2, 3.3.0 e 3.4.0. hackiebox-ng include già altPort.json per la porta 10443; crea una patch propria solo per un'altra porta. Le Toniebox CC3235 e la Toniebox 2 non si possono patchare: si connettono sempre alla porta 443.", + "customUrlPatch": "Crea una patch URL personalizzata (altUrl.custom.305.json)", + "customUrlPatchHint": "Crea questa patch solo se le patch predefinite altUrl.tc.fritz.box.json e altUrl.305.json non funzionano con la configurazione TeddyCloud corrente. Se soddisfano le tue esigenze, puoi procedere facendo clic su 'Avanti'.", + "dedicatedUart": "UART dedicata", + "dumpCertificates": "Estrai certificati per TeddyCloud", + "dumpCertificatesLink": "Trova qui le istruzioni per estrarre i certificati", + "esp32C3UartGateway": { + "intro": "Puoi utilizzare un ESP32-C3 come gateway UART.", + "new": "Nuovo", + "prepareStep1": "Collega l'ESP32-C3 al PC.", + "prepareStep2": "Apri il seguente strumento web di g3gg0, membro del Team Revvox:", + "prepareStep3": "Apri la scheda \"Flasher\", fai clic su \"Connect & Flash\" e attendi il completamento del processo.", + "prepareStep4": "Apri la scheda \"Config\", fai clic su \"Connect\", applica le impostazioni seguenti, quindi fai clic su \"Send Config\" e attendi nuovamente il completamento del processo:", + "prepareStep5": "Ora collega il Toniebox all'ESP32-C3 preparato come segue:", + "title": "Gateway UART ESP32-C3" + }, + "flashCAreplacement": "Flasha la CA sostitutiva per TeddyCloud", + "flashCAreplacementIntro": "Si consiglia di flashare la CA sostitutiva in /cert/c2.der e di utilizzare il bootloader Hackiebox-NG con la patch altCA.305 (maggiori dettagli nei passaggi successivi). Questa configurazione consente di passare facilmente tra il certificato originale e quello sostitutivo.", + "flashCAreplacementOutro": "Assicurati di aver selezionato il percorso corretto del file c2.der.", + "flashCAreplacementText": "Per scrivere il file c2.der scaricato (vedi i passaggi precedenti) sul Toniebox, usa il seguente comando:", + "hint": "Attualmente TeddyCloud non supporta direttamente il flashing del Toniebox CC3200, ma qui trovi una guida passo passo:", + "hint2": "Segui quindi il percorso CC3200:", + "hintPatchHost": "Inserisci l'hostname della tua istanza TeddyCloud e fai clic su 'Create altUrl.custom.305.patch'. La patch verrà scaricata automaticamente.", + "hintPatchPort": "Inserisci la porta a cui la Toniebox deve connettersi e fai clic su 'Crea altPort.custom.patch'. La patch verrà scaricata automaticamente.", + "hostname": "Hostname TeddyCloud:", + "hostnameTooLong": "L'hostname non deve superare 12 caratteri.", + "installBootloader": "Installa il bootloader personalizzato HackieboxNG", + "installBootloaderLink": "Trova qui le istruzioni di installazione", + "installingBootloader": { + "downloadLink": "qui", + "downloadText1": "Scarica l'ultima versione da ", + "downloadText2": ". Estrai la release nella directory di lavoro desiderata. Troverai una directory chiamata flash per i file da copiare nella flash e una directory chiamata sd per i file della scheda SD.", + "existingInstallation": { + "backup": "Backup", + "backupText": "Per verificare che il bootloader ofw sia attualmente installato in /sys/pre-img.bin puoi scaricare il file dalla flash:", + "copyOverPreloader": "Copia il preloader", + "copyOverPreloaderStep1": "Seleziona come destinazione \"Flash\"", + "copyOverPreloaderStep2": "Seleziona come file locale \"flash/sys/mcuimg.bin\"", + "copyOverPreloaderStep3": "Imposta Overwrite su \"on\"", + "copyOverPreloaderStep4": "Imposta il percorso SD/Flash su \"/sys/mcuimg.bin\"", + "copyOverPreloaderStep5": "Premi Upload!", + "intro": "Puoi usare la (vecchia) CFW Hackiebox per caricare il preloader. In questo modo puoi installarlo via rete senza accesso diretto alla flash se hai già installato una versione precedente del bootloader/preloader CFW. Avvia semplicemente la CFW Hackiebox e usa l'interfaccia web per caricare il nuovo /sys/mcuimg.bin.", + "outro": "Per verificare che il caricamento sia riuscito, puoi poi scaricare mcuimg.bin dalla flash e confrontarlo con flash/sys/mcuimg.bin appena caricato.", + "title": "Fase 1b: Preloader, aggiornamento di un'installazione esistente", + "updatingCFWUsingOldCFWWebGui": "Caricamento file tramite WebGui CFW" + }, + "firstInstallation": { + "installPreloader": "Installa preloader", + "installPreloaderText1": "Assicurati di aver salvato un backup del firmware originale in un luogo sicuro. Verifica di avere una copia di mcuimg.bin originale. Se è tutto corretto, installa il preloader con il seguente comando", + "installPreloaderText2": "Hai installato il preloader sul Toniebox. Prosegui con la fase 2: installazione del bootloader sulla scheda SD.", + "moveOriginal": "Sposta il bootloader originale", + "moveOriginalText1": "Per prima cosa devi copiare il file mcuimg.bin originale appena salvato (bootloader originale) dal Toniebox in un'altra posizione (flash:/sys/pre-img.bin) per poterlo avviare successivamente con il bootloader HackieboxNG. Non confondere il mcuimg.bin (bootloader ofw) che stai per estrarre con il mcuimg.bin incluso nel pacchetto ZIP di Hackiebox. (/flash/sys/mcuimg.bin)", + "moveOriginalText2": "Abbiamo già creato un backup completo, quindi mcuimg.bin si trova in /currentDir/ExtractedFromBox/sys/mcuimg.bin. Poiché dovresti ancora eseguire cc3200tool da quella directory, scrivi il file sul Toniebox:", + "title": "Fase 1a: Preloader, prima installazione" + }, + "importantTBFiles": { + "caDer": "Contiene l'autorità di certificazione per verificare la connessione HTTPS all'API", + "clientDer": "Chiave pubblica del box, non condividerla", + "description": "Descrizione", + "file": "File", + "firstSlot": "Primo slot firmware originale", + "mcuBootInfo": "Determina quale slot firmware avviare", + "originalBootloader": "Bootloader originale", + "privateDer": "Chiave privata del box, non condividerla; viene utilizzata per autenticarsi con il cloud originale", + "secondSlot": "Secondo slot firmware originale", + "thirdSlot": "Terzo slot firmware originale" + }, + "inCaseText": "Se alcuni file non esistono sul box, il comando potrebbe terminare con un errore e non creare correttamente backup.bin. In tal caso dividi il comando in due: ", + "installingBootloaderStage2": { + "here": "qui", + "intro": "Copia tutto il contenuto della cartella sd sulla scheda SD del Toniebox. Copia il bootloader originale (mcuimg.bin dal backup) nel primo slot ofw sd:/revvox/boot/ng-ofw1.bin. Ora il bootloader HackieboxNG avvierà immediatamente il bootloader originale ed eseguirà il firmware originale.", + "text1": "Nota: il preloader caricherà qualsiasi file presente in sd:/revvox/boot/ngbootloader.bin.", + "text2": "Ulteriori informazioni sui diversi slot firmware e sulla configurazione " + }, + "intro": "Prima di installare il bootloader Hackiebox NG, crea con cc3200tool un backup completo basato sui file + un backup della flash del Toniebox. Usiamo COM3 come porta di esempio, ma puoi utilizzare un'altra porta COM o il dispositivo corretto su Linux, ad es. /dev/ttyUSB0.", + "preface": { + "intro": "L'installazione del nuovo bootloader è suddivisa in due fasi e varia a seconda che si tratti di una prima installazione o dell'aggiornamento di un'installazione esistente: ", + "title": "Premessa" + }, + "resetCommand": "Se il programmatore UART dispone di un pin DTR, usa `--reset dtr` prima di ogni comando (ad es. `cc3200tool -p COM3 --reset dtr ...`). Altrimenti dovrai collegare brevemente il pin RST (Toniebox) a GND prima di ogni comando. Se salti questo passaggio, la console mostrerà \"timed out while waiting for ack\" e interromperà l'operazione.", + "stage1": "Fase 1: Installazione del preloader", + "stage2": "Fase 2: Installazione del bootloader", + "verifyBackup": "Assicurati che backup.bin sia molto più grande di 0 kB. Verifica che il dump e i file estratti contengano tutti i file importanti del Toniebox:" + }, + "linkGeneral": "Modifica del Toniebox per l'uso con TeddyCloud", + "linkSpecific": "Configurazione specifica CC3200", + "navigationTitle": "CC3200", + "next": "Avanti", + "patches": "Patch", + "patchesMoreInformationLink": "Ulteriori informazioni sulle patch personalizzate", + "port": "Porta TeddyCloud:", + "portInvalid": "La porta deve essere compresa tra 1 e 65535.", + "predefinedUrlPatches": "Patch URL predefinite", + "predefinedUrlPatchesIntro": "Per i box CC3200 si consiglia di usare una patch altUrl. HackieboxNG include due patch altUrl predefinite pronte all'uso:", + "previous": "Indietro", + "proceedWithCustomBootloader": "Procedi con il bootloader personalizzato", + "proceedWithoutCustomBootloader": "Procedi senza il bootloader personalizzato", + "tagConnector": "Connettore Tag quasi collegato", + "title": "Flashing del box CC3200", + "usingThinWiresAsTagConnector": "Fili sottili usati come connettore" + }, + "cc3235BoxFlashing": { + "certificatesDumpedCAreplacementFlashed": "Certificati estratti e CA sostitutiva flashata", + "CH341AProgrammer": { + "CH341AProgrammer": "Configura il collegamento del programmatore CH341A", + "CH341AProgrammerClampConnection": "Programmatore CH341A con clip SOP8 collegato alla memoria flash IS25LP032D", + "CH341Sop8remove": "Rifila qui finché non si adatta correttamente.", + "flashMemoryDumpText1": "Collega la pinza alla memoria flash IS25LP032D. Assicurati che il cavo rosso della pinza sia allineato con il punto sul chip che indica il pin 1.", + "flashMemoryDumpText2": "Se la pinza non rimane fissata, potrebbe urtare un blocco nero vicino. Rifila altra plastica se necessario. Non collegare ancora il CH341A. Ricontrolla la posizione dei cavi.", + "flashMemoryDumpText3": "Quando sei pronto, collega il CH341A alla porta USB del computer. Il LED del Toniebox dovrebbe diventare verde fisso. Se lampeggia, la pinza potrebbe non essere posizionata correttamente. Scollega il CH341A, regola la pinza e riprova per assicurarti che il collegamento sia stabile.", + "flashMemoryDumpText4": "Quando il LED è verde fisso, il collegamento è corretto. Apri un terminale sul computer e digita i seguenti comandi:", + "flashMemoryDumpText5": "Ripeti questa procedura almeno tre volte.", + "flashMemoryDumpText6": "Dopo tre letture, confronta tutti i dump per verificarne la coerenza.", + "flashMemoryDumpText7": "Dopo aver verificato che tutte le versioni siano coerenti, procedi con l'estrazione dei certificati.", + "flashMemoryDumpText8": "Scollega il CH341A dalla porta USB e rimuovi la pinza per evitare possibili cortocircuiti o problemi nel caso in cui la pinza si muova accidentalmente.", + "prep1": "Procurati un programmatore CH341A (acquista solo la versione nera con linea dorata e bordi arrotondati).", + "prep2": "Se necessario, rifila la plastica della pinza CH341A in modo che possa adattarsi alla memoria flash IS25LP032D.", + "prep3": "Assicurati che sia il PCB sia la batteria siano scollegati.", + "prep4": "Collega il CH341A al computer e verifica che venga riconosciuto. Puoi farlo nel modo seguente:", + "preparation": "Necessario solo se intendi eseguire il flashing con un programmatore CH341A", + "prepConclusions1": "Se l'output è diverso, il piccolo ponticello tra i pin 1 e 2 del CH341A potrebbe non essere impostato. Dopo aver verificato, scollega il CH341A dalla porta USB. Questo controllo assicura che tutto sia collegato correttamente.", + "prepConclusions2": "Assicurati che il modulo SOP8 del kit CH341A e la pinza siano collegati come mostrato nell'immagine.", + "prepConclusions3": "Solo dopo aver completato i preparativi è possibile procedere con il processo di estrazione.", + "readingFlash": "Lettura della flash con il programmatore CH341A", + "writingFlash": "Scrittura della flash con il programmatore CH341A" + }, + "checkBoxes": "Mostra box disponibili", + "dnsBeware": "Attenzione!", + "dnsBewareText": "Questa operazione interromperà la connessione di tutti i Toniebox nella tua rete che non sono stati modificati con la tua CA sostitutiva!", + "dnsHint": "Adattamenti DNS per TeddyCloud", + "dnsLink": "Trova qui le istruzioni per modificare il DNS", + "dnsText1": "Imposta le voci DNS per prod.de.tbs.toys e rtnl.bxcl.de sull'indirizzo IP del server TeddyCloud.", + "dnsText2": "In alternativa puoi impostare come gateway dei Toniebox l'IP di TeddyCloud. Con OpenWRT funziona così:", + "dumpCertificates": "Estrai certificati per TeddyCloud", + "dumpCertificatesCC3235": "Dovrai estrarli manualmente dalla flash del box usando una pinza SOP8 direttamente sulla memoria oppure dissaldandola. La lettura in-circuit può essere difficile, ma è possibile. Consigliamo flashrom come strumento. Potrebbe essere necessario utilizzare una versione più recente.", + "dumpCertificatesLink": "Trova qui le istruzioni per estrarre i certificati", + "extractCertificates": "Estrai certificati", + "flashCAreplacement": "Flasha la CA sostitutiva per TeddyCloud", + "flashCAReplacementDescription1": "Purtroppo il certificato CA generato da TeddyCloud durante la configurazione iniziale può talvolta causare un errore BAD_CERTIFICATE quando si collega un Toniebox CC3235 a TeddyCloud. Per risolvere il problema, prova a generare manualmente il certificato usando il vecchio", + "flashCAReplacementDescription2": "script con faketime installato. Copia i file generati nella directory certs/server all'interno della cartella di installazione di TeddyCloud.", + "flashCAReplacementDescription3": "Tieni presente che i nuovi certificati del server, incluso il certificato CA appena generato, possono interrompere le connessioni con Toniebox già collegati. Per garantire un servizio senza interruzioni, aggiorna il file CA sostitutivo su tutti i Toniebox con il certificato appena generato.", + "flashCAreplacementLink": "Trova qui le istruzioni di flashing", + "flashCAreplacementText1": "Sostituisci la CA originale nel dump della flash con la CA sostitutiva e riflasha il box.", + "flashCAreplacementText2": "Se il box non si connette affatto (parola in codice owl), puoi provare il vecchio gencerts.sh per generare nuovi certificati e riflashare il box.", + "flashCAreplacementText3": "Attenzione: il file ca.der indicato nello snippet di codice deve essere la CA sostitutiva di TeddyCloud!", + "flashCAReplacementTitle": "Attenzione: problemi di compatibilità del certificato CA TeddyCloud", + "flashCollapse": { + "cc3235flash": "Memoria flash con pin", + "cc3235flashWithSMDGrippers": "Memoria flash con pinze SMD collegate", + "collapseTitle": "Aiuto: memoria flash IS25LP032D su CC3235", + "sop8Clamp": "Memoria flash con pinza SOP8 collegata" + }, + "gencertLinkText": "gencert.sh", + "hint": "Attualmente TeddyCloud non supporta direttamente il flashing del Toniebox CC3235, ma qui trovi una guida passo passo:", + "hint2": "Segui quindi il percorso CC3235:", + "hwToolSpecific": "Preparativi dipendenti dall'hardware", + "hwToolSpecificText": "A seconda dell'hardware che intendi utilizzare per flashare il Toniebox, sono necessari preparativi aggiuntivi. Seleziona l'opzione che intendi seguire:", + "installflashromtool": "Installa Flashrom", + "installflashromtoolLink": "Visita la homepage dello strumento per ulteriori informazioni", + "installflashromtoolText": "Assicurati che sul computer usato per il flashing sia installato flashrom. In caso contrario, installalo con i seguenti comandi:", + "installSerprogFirmware": "Installa firmware serprog", + "linkGeneral": "Modifica del Toniebox per l'uso con TeddyCloud", + "linkSpecific": "Configurazione specifica CC3235", + "locateFlashMemory": "Individua il chip di memoria flash", + "locateFlashMemoryText": "Individua sulla scheda il chip di memoria flash IS25LP032D; si trova a destra della base di ricarica.", + "navigationTitle": "CC3235", + "next": "Avanti", + "pico": { + "preparation": "Necessario solo se intendi eseguire il flashing con un Raspberry Pi Pico", + "readingFlash": "Lettura della flash con Raspberry Pi Pico", + "readingFlashText1": "Come sonda puoi utilizzare un Raspberry Pi Pico con firmware che supporta il protocollo serprog.", + "readingFlashText2": "Si consiglia di eseguire più letture e confrontare i risultati.", + "writingFlash": "Scrittura della flash con Raspberry Pi Pico" + }, + "portHint": "I box CC3235 si collegano sempre alla porta 443: per loro non esiste una patch della porta, quindi TeddyCloud deve essere raggiungibile sulla porta 443 (un proxy può instradarli solo per IP).", + "previous": "Indietro", + "serprogFirmwareLink": "Trova qui le istruzioni di installazione", + "title": "Flashing del box CC3235", + "useCC3200ToolToExtract": "Successivamente puoi utilizzare cc3200tool per estrarre i certificati dal dump della flash." + }, + "confirmDeleteDialog": "Vuoi davvero eliminare {{tonieboxToDelete}}?", + "confirmDeleteModal": "Conferma eliminazione", + "connectESP32Modal": { + "beware": "Attenzione!", + "connectESP32Text1_1": "Collega il ponticello J100 (Boot) e reimposta il box per portarlo nella modalità UART richiesta.", + "connectESP32Text1_2": "Collega la UART a 3,3 V a J103:", + "connectESP32Text2": "Se non sei sicuro della modalità in cui si avvia l'ESP32, controlla quanto segue.", + "downloadMode": "Modalità download", + "downloadModeText": "LED spento, nessun suono, output seriale:", + "esp32UartJ100": "ESP32 J100", + "esp32UartJ103": "ESP32 J103", + "esp32UartJ103AnalogClamp": "ESP32 J103 con molletta", + "esp32UartJ103Clamp": "ESP32 J103 con pinza a 4 pin", + "esp32UartJ103Clamp4PinHint": "Se hai solo una pinza a 4 pin invece della necessaria pinza a 3 pin (come mostrato sopra), l'allineamento non sarà perfetto. Cerca di posizionare i pin il più possibile al centro dei contatti.", + "esp32UartJ103ClampTitle": "Collega UART a ESP32 J103 usando una pinza", + "normalMode": "Modalità di avvio normale", + "normalModeText": "LED verde lampeggiante, suono di avvio riprodotto, output seriale:", + "warningText": "Se il monitor seriale è aperto, impedirà a esptool.py di accedere all'ESP. Se compare “BROWNOUT_RST”, controlla l'alimentazione/la batteria. “SPI_FAST_FLASH_BOOT” indica un avvio senza il ponticello J100." + }, + "delete": "Elimina", + "downloadC2DerFile": "Scarica certificato CA sostitutivo (come c2.der)", + "downloadCADerFile": "Scarica certificato CA sostitutivo (ca.der)", + "editModelModal": { + "editModel": "Modifica Toniebox \"{{name}}\"", + "errorOnModelChange": "Errore durante il salvataggio del modello", + "errorOnModelChangeDetails": "Errore durante il salvataggio del modello del Toniebox {{mac}}: {{error}}", + "errorOnNameChange": "Errore durante il salvataggio del nome", + "errorOnNameChangeDetails": "Errore durante il salvataggio del nome del Toniebox {{mac}}: {{error}}", + "model": "Modello", + "name": "Nome", + "placeholderSearchForAModel": "Seleziona la variante di colore del Toniebox", + "successOnModelChange": "Modifica del modello salvata correttamente", + "successOnModelChangeDetails": "Il nuovo modello \"{{model}}\" del Toniebox {{mac}} è stato salvato correttamente.", + "successOnNameChange": "Modifica del nome salvata correttamente", + "successOnNameChangeDetails": "Il nuovo nome \"{{name}}\" del Toniebox {{mac}} è stato salvato correttamente.", + "unsetBoxName": "Modello sconosciuto/non impostato" + }, + "editTonieboxSettingsModal": { + "editTonieboxSettings": "Modifica impostazioni del Toniebox \"{{name}}\"", + "editTonieboxSettingsLabel": "Impostazioni" + }, + "errorFetchingModels": "Errore durante il recupero dei modelli Toniebox", + "errorFetchingTonieboxes": "Errore durante il recupero dei Toniebox", + "esp32BoxFlashing": { + "adaptBaudrateOnProblems": "Suggerimento", + "adaptBaudrateOnProblemsText": "Se l'avanzamento si blocca durante la lettura o la scrittura della memoria flash, prova a regolare il baud rate usando il campo di selezione \"Baudrate\" in alto.", + "attention": "Attenzione", + "baudRate": "Baud rate: ", + "baudRateInfo": "Se riscontri problemi durante la lettura o la scrittura della flash, prova a cambiare il baud rate. Il valore predefinito è 921600.", + "browserNotSupported": "La funzionalità integrata di flashing del box ESP32 può essere utilizzata solo con browser che supportano la Web Serial API. Purtroppo il browser corrente non supporta questa funzione. Utilizza un browser compatibile, come Chrome, Edge o Firefox (> v151).", + "esp32flasher": { + "ackBackUpWithOtherTool": "Ho creato un backup usando un altro strumento oppure comprendo e accetto il rischio di procedere senza backup.", + "ackLatestFirmware": "Confermo che il box è stato collegato al cloud originale, ha ricevuto l'ultimo firmware e ha riprodotto correttamente un Tonie.", + "ackRisk": "Ho letto e compreso questo avviso e sono consapevole dei rischi.", + "ackUARTHint": "Ho impostato la tensione corretta (3,3 V) e installato il driver più recente richiesto.", + "alternativeTools": "Di seguito sono elencati alcuni strumenti alternativi per creare un backup. Sono disponibili anche altri strumenti: usa quello con cui hai maggiore familiarità.", + "alternativeToolsNoLinks": "Usa un lettore ESP32 che conosci oppure cerca un altro lettore ESP32 adatto.", + "automatically": "Automaticamente", + "backupFlash": "Importante: esegui il backup del firmware originale non modificato", + "cancel": "Annulla", + "chipMAC": "MAC chip", + "chipType": "Tipo di chip", + "chipTypeError": "Errore nel tipo di chip - letto un tipo imprevisto. Letto {{actualname}}, previsto {{expectedname}}. Elaborazione interrotta: controlla il collegamento e riprova!", + "confirmFlashDialog": "Vuoi davvero flashare il Toniebox? Assicurati che la connessione sia stabile e non interrompere il processo!", + "confirmFlashDialogHint": "Per garantire il corretto funzionamento del Toniebox, l'istanza TeddyCloud specificata è assolutamente necessaria. Se TeddyCloud è offline, il Toniebox non potrà accedere a nuovi contenuti!", + "confirmFlashModal": "Flasha il Toniebox con il firmware modificato", + "connected": "Connesso", + "connectESPCollapseTitle": "Aiuto: collega UART al Toniebox", + "connectingReadFlash": "Connessione per leggere la flash...", + "connectingReadMac": "Connessione per leggere l'indirizzo MAC...", + "connectingTo": "Connessione a", + "connectingToESP": "Connessione all'ESP...", + "connectingWriteFlash": "Connessione per scrivere la flash...", + "downloadFlashFilesHint": "Le immagini del firmware vengono salvate nella directory dati del server nel caso tu voglia riflasharle. Se non lo hai ancora fatto, scarica subito e conserva in modo sicuro una copia del firmware originale! In questo modo potrai sempre ripristinare il Toniebox alle condizioni di fabbrica in caso di problemi. Salva il backup in più posizioni, ad esempio sul computer principale o laptop, su un disco esterno o chiavetta USB e/o su un servizio cloud affidabile (ad es. Google Drive, Dropbox, OneDrive). Non affidarti esclusivamente alla copia presente sul server TeddyCloud.", + "downloadLink": "Scarica firmware non modificato", + "downloadLinkPatched": "Scarica firmware modificato", + "downloadStubFailed": "Download Stub su ESP32 non riuscito! Controlla il collegamento e riprova!", + "error": "Errore", + "executingReliabilityCheck": "Esecuzione del controllo di affidabilità per una connessione ESP32 stabile. Attendi!", + "extendedFlashingLog": "Log di flashing esteso", + "extractCertificates": "Estrai certificati per TeddyCloud", + "extractCertificatesAutomaticallyHint": "Fai clic sul pulsante \"Estrai certificati per TeddyCloud\" per estrarre automaticamente i certificati e salvarli in TeddyCloud.", + "extractCertificatesAutomaticallyHint2": "I certificati verranno salvati in un massimo di due posizioni: prima nella directory base dei certificati client ../{{certDir}}/ per TeddyCloud (se non sono già presenti certificati) e poi nella cartella ../{{certDir}}/{{mac}}.", + "extractCertificatesManually": "Estrai certificati manualmente", + "extractCertificatesManuallyHint": "Puoi anche estrarre manualmente i certificati seguendo i passaggi riportati di seguito.", + "extractCertificatesManuallyHintP1": "Puoi farlo usando l'eseguibile teddycloud sul computer oppure tramite la shell Docker", + "extractCertificatesManuallyHintP2": "Controlla il nome dei file dei certificati estratti, in particolare maiuscole e minuscole! Se sono in maiuscolo, rinominali in minuscolo.", + "extractCertificatesManuallyHintP3": "Per la prima configurazione del Toniebox con TeddyCloud, copia i certificati nella directory base dei certificati client. TeddyCloud utilizza questi certificati per autenticarsi con il cloud ufficiale Tonies, consentendo di scaricare contenuti senza interazione con il Toniebox (ad es. facendo clic su 'Scarica' su un Tonie nella GUI).", + "extractingCertificates": "Estrazione dei certificati per TeddyCloud...", + "extractingCertificates409ResponseForceOverwrite": "Sovrascrivi certificati esistenti", + "extractingCertificates409ResponseForceOverwriteConfirmButton": "Sovrascrivi file", + "extractingCertificates409ResponseForceOverwriteContent": "Il primo tentativo di salvare i certificati non è riuscito: {{error}}. Vuoi sovrascrivere i file esistenti?", + "extractingCertificatesFailed": "Estrazione dei certificati non riuscita!", + "extractingCertificatesFailedDetails": "Impossibile estrarre i certificati da \"{{file}}\"", + "extractingCertificatesSuccessful": "Certificati estratti e salvati correttamente!", + "extractingCertificatesSuccessfulDetails": "Certificati estratti correttamente da \"{{file}}\" e salvati in TeddyCloud.", + "failedToCommunicate": "Comunicazione non riuscita:", + "failedToConnect": "Connessione non riuscita:", + "flagPreviousHostname": "L'immagine flash contiene un hostname TeddyCloud impostato in precedenza", + "flash": "Flash", + "flashDevice": "Dispositivo flash", + "flashEsp32": "Flasha ESP32", + "flashId": "ID flash", + "flashManuf": "Produttore flash", + "flashSize": "Dimensione flash", + "flashSizeError": "Errore nella dimensione della flash - letta una dimensione imprevista. Letto {{actualsize}}, previsto {{expectedsize}}. Elaborazione interrotta: controlla il collegamento e riprova!", + "flashSizeErrorLoadedFile": "Errore nella dimensione della flash - dimensione imprevista letta dal file caricato. Letto {{actualsize}}, previsto {{expectedsize}}. Elaborazione interrotta: controlla il file caricato e riprova!", + "hintESP32FirmwareFlashed": "L'applicazione delle patch dovrebbe essere completata. Scollega il Toniebox dalla UART, rimuovi il cortocircuito da J100 e riavvia il box. Ora dovrebbe potersi connettere a TeddyCloud!", + "hintESP32FirmwareReseted": "Il ripristino dovrebbe essere completato. Scollega il Toniebox dalla UART, rimuovi il cortocircuito da J100 e riavvia il box. Non dovrebbe più essere collegato a TeddyCloud. Esegui un freshness check per verificare che funzioni correttamente.", + "hintFlashESP32RevvoxFlasher": "Fai clic su \"Flash ESP32\", se necessario scegli la porta COM e attendi il completamento del passaggio!", + "hintPatchFlash": "Per applicare alla flash selezionata/letta il certificato CA TeddyCloud ed eventualmente altre modifiche, come l'hostname e il port di TeddyCloud e/o le credenziali Wi-Fi, fai clic su 'Patch' dopo aver inserito le informazioni oppure passa al passaggio successivo senza applicare patch.", + "hintPatchHost": "Inserisci l'hostname desiderato per la tua istanza TeddyCloud.", + "hintPatchPort": "Porta a cui si connette la Toniebox. Lascia 443, a meno che la Toniebox non debba raggiungere TeddyCloud su un'altra porta, ad esempio tramite un proxy che instrada in base alla porta. Le Toniebox CC3235 e la Toniebox 2 non si possono patchare: si connettono sempre alla porta 443.", + "hintPatchWifi": "Facoltativamente inserisci le credenziali Wi-Fi del Toniebox. Impostale solo se realmente necessario: di solito, se il Toniebox era collegato al Wi-Fi subito prima dell'applicazione delle patch, non serve.", + "hintReadESP32ImportFlash": "Ora scollega l'alimentazione del Toniebox. Assicurati che la UART funzioni in modalità 3,3 V. Collega la UART al PC e al Toniebox. Cortocircuita i fori del pin J100. Quindi alimenta nuovamente il Toniebox. Fai clic su \"Read Flash\" o \"Load File\", scegli la porta COM e attendi il completamento del passaggio!", + "hintSaveLog": "In caso di problemi, salva l'output del log in un file per ulteriori analisi.", + "hintUseOtherToolForAdditionalBackup": "Usa uno strumento aggiuntivo per creare un backup separato.", + "hostname": "Hostname TeddyCloud:", + "hostnameSettings": "Hostname TeddyCloud", + "hostnameTooLong": "Se in seguito vuoi collegare un Toniebox CC3200, l'hostname non deve superare 12 caratteri (limitazione del firmware).", + "importanceBackup": "Prima di apportare modifiche al Toniebox, scarica e conserva in modo sicuro una copia del firmware originale. In questo modo potrai sempre ripristinare il Toniebox alle condizioni di fabbrica in caso di problemi. Salvala in più posizioni, ad esempio sul computer principale o laptop, su un disco esterno o chiavetta USB e/o su un servizio cloud affidabile (ad es. Google Drive, Dropbox, OneDrive). Non conservare mai l'unica copia sul server TeddyCloud.", + "infoTable": "Informazioni ESP32", + "invalidFlashData": "La flash letta/caricata contiene solo 0x00 o 0xFF! Controlla il collegamento o il file caricato e riprova!", + "invalidFlashDataCertificatesMissing": "Nella flash letta/caricata mancano i certificati: qualcosa è andato storto! Controlla il collegamento o il file caricato e riprova! Errore: ", + "invalidSerialPort": "Porta seriale non valida", + "loadFile": "Carica file", + "manually": "Manualmente", + "next": "Avanti", + "noPortAvailable": "Nessuna porta disponibile oppure operazione annullata dall'utente", + "openPort": "Apri porta", + "patchImage": "Applica patch", + "patchingFailed": "Applicazione patch non riuscita", + "patchingFlashImage": "Applicazione patch all'immagine flash...", + "patchingSuccessful": "Patch applicata correttamente, pronto per flashare {{size}} MiB.", + "payloadReceived": "Payload ricevuto", + "port": "Porta TeddyCloud:", + "portInvalid": "La porta deve essere compresa tra 1 e 32767.", + "portOpenFailedInUse": "Impossibile aprire questa porta. È già in uso?", + "preparingFlash": "Preparazione della flash da {{size}} MiB", + "previous": "Indietro", + "previousHostname": "Vecchio hostname TeddyCloud:", + "readFlash": "Leggi flash", + "readingFinished": "Lettura completata", + "readingFlash": "Lettura flash...", + "readyToProceed": ", pronto per procedere.", + "reliabilityCheckFailed": "Controllo di affidabilità non riuscito. La connessione all'ESP32 sembra instabile. Controlla il collegamento e riprova!", + "resetBoxine": "Ripristina box", + "resetBoxineTooltip": "Per ripristinare il Toniebox al firmware originale, premi questo pulsante per iniziare. Dovrai selezionare il backup originale del firmware, che verrà quindi flashato sul Toniebox senza modifiche. Purtroppo, se non disponi di un backup del firmware originale, non sarà possibile ripristinare il Toniebox.", + "retrievingMac": "Recupero dell'indirizzo MAC...", + "save": "Salva log", + "showLog": "Mostra dettagli log", + "titleESP32FirmwareFlashed": "ESP32 flashato", + "titleFlashESP32": "Flasha ESP32", + "titlePatchFlash": "Applica patch alla flash", + "titlePreparations": "Preparativi e note importanti", + "titleReadESP32ImportFlash": "Leggi ESP32/Importa flash", + "uploadFailed": "Caricamento non riuscito", + "uploading": "Caricamento...", + "uploadSuccessful": "Caricamento riuscito, salvato come", + "verifyingReadFlash": "Verifica della flash letta...", + "verifyingWrittenFlash": "Verifica della flash scritta...", + "wifiCredentialsIncomplete": "Credenziali Wi-Fi incomplete! Inserisci sia SSID SIA password oppure lascia entrambi i campi vuoti!", + "wifiPassword": "Password Wi-Fi:", + "wifiSettings": "Dati Wi-Fi (facoltativi):", + "wifiSSID": "SSID Wi-Fi:", + "writingFinished": "Scrittura completata", + "writingFlash": "Scrittura di {{size}} MiB nella flash..." + }, + "hint": "Questa sezione è disponibile solo utilizzando HTTPS e un browser che supporta la Web Serial API, come Chrome, Edge o Firefox (> v151). Se vuoi flashare il Toniebox ESP32, fai clic su \"Procedi a HTTPS\" e verrai reindirizzato automaticamente alla versione HTTPS. Il browser potrebbe avvisarti che la connessione non è sicura. È normale: conferma e prosegui comunque aprendo la pagina. Potrebbe essere necessario visualizzare ulteriori dettagli nell'avviso.", + "hintLegacyApproach": "Se preferisci non utilizzare un browser che supporta WebSerial, puoi comunque usare il metodo legacy (senza GUI, solo riga di comando).", + "httpsSettings": "Impostazioni HTTPS", + "legacy": { + "checkBoxes": "Mostra box disponibili", + "checkDumpIsOk": "Assicurati che il dump sia corretto e che sia possibile estrarre i certificati.", + "connectESP32": "Collega UART al Toniebox", + "dnsIntro": "Puoi impostare IP/hostname direttamente nell'immagine oppure procedere come descritto di seguito.", + "dumpCertificates": "Estrai certificati per TeddyCloud", + "flashCAreplacement": "Flasha la CA sostitutiva per TeddyCloud", + "flashCAreplacementText1": "Sostituisci la CA originale nel dump della flash usando esptool.", + "flashCAreplacementText2": "Rimonta il Toniebox. Se hai già impostato l'hostname di TeddyCloud, puoi saltare il passaggio DNS per ESP32; in caso contrario, continua con il passaggio DNS per ESP32.", + "flashCAreplacementText3": "Ora il Toniebox dovrebbe potersi connettere a TeddyCloud. Esegui un freshness check e fai clic su \"Controlla box disponibili\".", + "flashESPtoolScreen": "Schermata ESPtool", + "followLegacyApproach": "Oppure usa il metodo legacy (senza GUI, solo riga di comando).", + "hint": "È disponibile il supporto diretto per flashare il Toniebox ESP32 con TeddyCloud!", + "installESPTool": "Installa una versione recente di ESPtool", + "installESPToolLink": "Repository ESPtool su GitHub", + "installESPToolText": "Puoi estrarre la memoria flash tramite la porta di debug del box e usare esptool. Conserva il backup! Utilizza una versione recente di esptool (>v4.4).", + "navigationTitle": "Metodo legacy ESP32", + "next": "Avanti", + "previous": "Indietro", + "proceedWithDNS": "Procedi con il passaggio DNS", + "skipDnsIfAlreadyDone": "Se hai già sostituito IP/hostname nel dump della flash, puoi saltare questo passaggio.", + "title": "Metodo legacy di flashing" + }, + "linkWikiGeneral": "Modifica del Toniebox per l'uso con TeddyCloud", + "linkWikiSpecific": "Configurazione specifica ESP32", + "navigationTitle": "ESP32", + "processing": "Elaborazione...", + "redirect": "Procedi a HTTPS", + "title": "Flashing del box ESP32" + }, + "hintLatestFirmware": "Per la configurazione iniziale del Toniebox con TeddyCloud, assicurati che sia installato il firmware più recente. Se non sei sicuro che il Toniebox sia aggiornato, collegalo al cloud ufficiale e attendi il completamento dell'aggiornamento firmware. Inoltre, posiziona un Tonie originale sul Toniebox e verifica che funzioni correttamente.", + "hintLatestFirmwareFactoryResetCC3200": "Se hai eseguito (accidentalmente) un ripristino alle impostazioni di fabbrica su un Toniebox già flashato, potrebbe essere sufficiente avviare una volta OFW1 per ripristinare il funzionamento: potrebbe non essere necessario riflashare.", + "hintLatestFirmwareFactoryResetESP32CC3235": "Se hai eseguito (accidentalmente) un ripristino alle impostazioni di fabbrica su un Toniebox già flashato, dovrai flashare il backup del firmware originale prima di flasharlo nuovamente per l'uso con TeddyCloud.", + "hintLatestFirmwareTitle": "Importante", + "lastOnline": "ultimo accesso", + "lastPlayedTonie": "Ultimo Tonie riprodotto: ", + "linkToBoxCFW": "Apri l'interfaccia web del Toniebox", + "messages": { + "apiAccessDisabled": "Accesso alle API TeddyCloud disabilitato!", + "apiAccessDisabledDetails": "Accesso alle API TeddyCloud disabilitato per il Toniebox {{mac}}", + "apiAccessEnabled": "Accesso alle API TeddyCloud abilitato!", + "apiAccessEnabledDetails": "Accesso alle API TeddyCloud abilitato per il Toniebox {{mac}}", + "apiAccessNotChangedError": "Modifica dell'accesso alle API TeddyCloud non riuscita", + "apiAccessNotChangedErrorDetails": "Modifica dell'accesso alle API TeddyCloud per il Toniebox {{mac}} non riuscita: ", + "deleteFailed": "Eliminazione non riuscita", + "deleteFailedDetails": "Eliminazione del Toniebox {{mac}} non riuscita", + "deleteSuccessful": "Eliminazione riuscita!", + "deleteSuccessfulDetails": "Toniebox {{mac}} eliminato correttamente da TeddyCloud!", + "deleting": "Eliminazione...", + "deletingDetails": "Eliminazione del Toniebox {{mac}} da TeddyCloud" + }, + "modelHint": { + "action": "per selezionare il modello corretto del tuo Toniebox", + "text": "Fai clic su" + }, + "navigationTitle": "Toniebox", + "newBoxesAllowed": "Nuovi Toniebox consentiti", + "newBoxesAllowedText": "Puoi collegare nuovi Toniebox a TeddyCloud. Dopo aver collegato tutti i tuoi box, disabilita l'impostazione \"Allow new boxes\" per motivi di sicurezza.", + "noData": "Nessun Toniebox trovato.", + "noDataText": "I Toniebox compariranno solo dopo aver stabilito una connessione corretta. Assicurati che il Toniebox sia collegato correttamente.", + "noNewBoxesAllowed": "Nuovi Toniebox non consentiti", + "noNewBoxesAllowedText": "Non puoi collegare nuovi Toniebox a TeddyCloud. Abilita prima l'impostazione \"Allow new boxes\". Dopo aver collegato tutti i tuoi box, disabilitala per motivi di sicurezza.", + "offline": "offline", + "online": "online", + "processing": "Elaborazione...", + "tb2BoxFlashing": { + "navigationTitle": "TB2", + "title": "Flashing del box TB2", + "underDevelopment": "In fase di sviluppo", + "underDevelopmentBePatient": "Stiamo lavorando a una soluzione per la versione TB2. Ti chiediamo di avere pazienza e di tornare più avanti per gli aggiornamenti. Nel frattempo puoi seguire i progressi e le discussioni nel nostro forum o nel gruppo Telegram." + }, + "title": "I tuoi Toniebox", + "uploadTonieboxCertificatesModal": { + "AttentionGeneralPath": "(ATTENZIONE: percorso predefinito!)", + "boxSpecific": " (sovrascritto per il Toniebox)", + "Label": "Certificati", + "uploadPath": "Percorso di caricamento (certDir):", + "uploadTonieboxCertificates": "Carica certificati per Toniebox{{name}}" + }, + "warningUseAtYourOwnRisk": "Avviso: usa a tuo rischio!", + "warningUseAtYourOwnRiskText": "Flashare il Toniebox con un firmware modificato invaliderà la garanzia e potrebbe danneggiare il box. Segui attentamente le istruzioni e assicurati di comprendere i rischi." + }, + "tonies": { + "addNewCustomTonie": "Crea nuovo modello", + "addNewCustomTonieHint": "Crea un nuovo modello e assegnalo al tag corrente", + "addNewCustomTonieModal": { + "addAudioIdHash": "Aggiungi ID audio e hash", + "addTrack": "Aggiungi traccia", + "audioId": "ID audio", + "category": "Categoria", + "episode": "Episodio", + "episodeHint": "Verrà visualizzato nella scheda Tonie", + "failedToCreate": "Creazione del modello non riuscita!", + "failedToCreateDetails": "Creazione del modello {{series}} [{{model}}] non riuscita: ", + "formfieldTitle": "Titolo", + "formfieldTitleHint": "Attualmente non visualizzato nella scheda Tonie", + "hash": "Hash", + "language": "Lingua", + "languageHint": "Formato: en-gb", + "model": "Modello", + "modelRequired": "Il modello deve essere compilato.", + "no": "No", + "pic": "URL dell'immagine", + "release": "Data di uscita", + "save": "Salva", + "series": "Serie", + "seriesHint": "Verrà visualizzata come titolo della scheda Tonie", + "seriesRequired": "La serie deve essere compilata.", + "successfullyCreated": "Modello creato correttamente!", + "successfullyCreatedDetails": "Modello {{series}} [{{model}}] creato correttamente!", + "title": "Crea nuovo Tonie/Tag personalizzato", + "track": "Traccia" + }, + "alternativeSource": "A questo Tonie {{originalTonie}} è assegnato il contenuto {{assignedContent}}!", + "alternativeSourceUnknown": "A questo Tonie {{originalTonie}} è assegnato un contenuto alternativo!", + "assignNextEpisode": { + "action": "Assegna l'episodio successivo \"{{file}}\"", + "failedDetails": "Impossibile assegnare \"{{episode}}\": ", + "failedMessage": "Assegnazione dell'episodio successivo non riuscita!", + "noneLeft": "Non ci sono più episodi non ascoltati in questa cartella.", + "successDetails": "\"{{episode}}\" è stato assegnato come episodio successivo!", + "successMessage": "Episodio successivo assegnato!" + }, + "assignPrevEpisode": { + "action": "Assegna l'episodio precedente \"{{file}}\"", + "failedDetails": "Impossibile assegnare \"{{episode}}\": ", + "failedMessage": "Assegnazione dell'episodio precedente non riuscita!", + "noneLeft": "Non ci sono più episodi precedenti disponibili in questa cartella.", + "successDetails": "\"{{episode}}\" è stato assegnato come episodio precedente!", + "successMessage": "Episodio precedente assegnato!" + }, + "cancel": "Annulla", + "closeAudioPlayer": "Chiudi lettore", + "closeAudioPlayerPopover": "La chiusura interrompe la riproduzione!", + "confirmAudioModelMismatchModal": { + "cancel": "Annulla", + "confirm": "Continua", + "content": "L'audio assegnato ha il modello \"{{audioModel}}\". La visualizzazione sul box deriva dall'audio_id del TAF. Il modello vuoto verrà reimpostato su \"{{audioModel}}\". Continuare?", + "title": "Modello e audio", + "unknownModel": "sconosciuto" + }, + "confirmHideModal": { + "cancel": "Annulla", + "confirmHideDialog": "Vuoi davvero nascondere il Tonie/Tag \"{{tonieToHide}}\" nella panoramica Tonie? Per renderlo nuovamente visibile dovrai posizionare il Tonie/Tag su uno dei tuoi Toniebox.", + "hide": "Nascondi", + "title": "Nascondi Tonie/Tag" + }, + "connectionToBoxineNotAvailable": "Impossibile connettersi a Boxine/Toniecloud. Abilita l'accesso per consentire il download.", + "content": { + "navigationTitle": "Contenuti", + "showToniesOfBoxes": "Contenuti dei seguenti Toniebox", + "title": "Contenuti", + "toniePicture": "Immagine Tonie" + }, + "currentPath": "Percorso corrente: ", + "customEditor": { + "actions": { + "browse": "Sfoglia", + "delete": "Elimina", + "deleteCount": "Elimina {{count}} modello/i", + "discard": "Annulla modifiche", + "duplicate": "Duplica", + "edit": "Modifica", + "newModel": "Nuovo modello", + "preview": "Anteprima", + "restore": "Ripristina" + }, + "audio": { + "libraryLabel": "Audio personalizzato ({{library}})", + "locateInLibrary": "Cerca nella libreria", + "notFoundInLibrary": "Non trovato nella libreria", + "notInIndex": "Non presente nell'indice audio personalizzato: {{audioId}}/{{hash}}", + "placeholder": "Seleziona audio personalizzato da {{library}}", + "selectFromLibrary": "Seleziona audio da {{library}}" + }, + "audioSource": { + "linkedDescription": "Le modifiche valgono per tutti i file e i tag che usano questo modello.", + "linkedTitle": "{{file}} è collegato a questo modello personalizzato", + "newDescription": "Salvando viene creato un nuovo modello personalizzato, collegato al file tramite ID audio e hash.", + "newTitle": "Nessun modello personalizzato per {{file}}" + }, + "batch": { + "description": "Le modifiche si applicano a tutti i modelli selezionati.", + "title": "Modifica in blocco attiva" + }, + "coinHint": { + "description": "Facoltativo - solo per i metadati (titolo, tracce). I tag personalizzati funzionano senza modello. Usa solo audio personalizzato: gli ID ufficiali sovrascrivono i Tonie reali. Non collega i tag NFC all'audio.", + "title": "Suggerimento: assegnazione audio" + }, + "columns": { + "actions": "Azioni", + "image": "Immagine" + }, + "deleteConfirm": { + "abort": "Annulla", + "confirm": "Elimina", + "deleted": "Il modello \"{{model}}\" è stato eliminato.", + "description": "Eliminare davvero \"{{model}}\"? L'operazione non può essere annullata.", + "title": "Elimina modello" + }, + "duplicateTitleSuffix": "Copia", + "editModalTitle": "Modifica modello {{current}} di {{total}}", + "editModalTitleCreate": "Nuovo modello", + "editModalTitleEdit": "Modifica modello", + "emptyState": "Nessun modello personalizzato", + "emptyStateHint": "tonies.custom.json è vuoto. Aggiungi il tuo primo modello.", + "errors": { + "duplicateAudioHash": "Combinazione audio_id+hash duplicata: {{audioId}}", + "duplicateModel": "Duplicato: {{model}}", + "emptyResult": "Deve rimanere almeno un modello.", + "invalidLanguageCode": "Codice lingua non valido. Usa un codice come {{example}}.", + "invalidSelectedIndex": "Indice selezionato non valido", + "modelExistsInBase": "Il modello esiste nel tonies.json di base: {{models}}", + "releaseNumeric": "Il valore di uscita deve essere numerico" + }, + "filterFieldsPlaceholder": "Cerca nei campi", + "filterPlaceholder": "Filtra modelli...", + "modelSavedSuccessful": "Modello {{title}} salvato correttamente", + "modelsTitle": "Modelli", + "noChangesBody": "Al momento non c'è nulla da salvare.", + "noChangesTitle": "Nessuna modifica rilevata", + "optionalColumns": "Colonne facoltative", + "overridesOriginal": { + "description": "Le modifiche salvate sostituiscono le informazioni originali ovunque venga usato questo audio o modello. Elimina il modello personalizzato per mostrare di nuovo l'originale.", + "tag": "Sostituisce l'originale {{model}}", + "title": "Sostituisce il Tonie originale {{model}} ({{name}})" + }, + "pagination": { + "modelsPerPage": "modelli/pagina" + }, + "picHint": "Seleziona un'immagine da custom_img o dai Tonie originali", + "preflight": { + "confirm": "Salva ora", + "deletes": "Eliminazioni: {{count}}", + "description": "Conferma l'ambito di questa operazione di salvataggio.", + "renames": "Ridenominazioni: {{count}}", + "title": "Controlla le modifiche prima di salvare", + "upserts": "Aggiornamenti/nuove voci: {{count}}" + }, + "previewTitle": "Anteprima immagine", + "renameConfirm": { + "abort": "Annulla", + "confirm": "Conferma", + "description": "L'ID modello cambierà da \"{{from}}\" a \"{{to}}\". I Tonie collegati verranno aggiornati automaticamente.", + "skipUpdate": "Rimuovi l'assegnazione del modello dai Tonie collegati (l'audio rimane)", + "title": "Rinomina modello" + }, + "renameFailed": "Ridenominazione non riuscita", + "savedState": "Tutte le modifiche sono state salvate", + "saveSuccessWithCount": "tonies.custom.json salvato ({{count}} voci). Backup e ricaricamento avviati.", + "sections": { + "audio": "Assegnazione audio", + "audioAssignment": "Assegnazione audio", + "media": "Media e immagini", + "metadata": "Metadati facoltativi", + "tracks": "Tracce" + }, + "selection": { + "description": "Il modulo di modifica seguente si applica sempre alla selezione corrente.", + "multiple": "{{count}} modelli selezionati", + "none": "Nessun modello selezionato", + "single": "Modello selezionato: {{model}}" + }, + "tableMenu": "Menu tabella", + "title": "Editor modelli", + "tracks": { + "fewerNamesDescription": "Il file ha {{tracks}} tracce. I nomi mantengono la loro posizione, ma la finestra informativa consente di saltare a una traccia solo se sono elencate tutte le tracce fino all'ultima.", + "fewerNamesTitle": "Le ultime tracce del file non hanno un nome", + "moreNamesDescription": "I nomi aggiuntivi vengono salvati ed elencati, ma non hanno una posizione nel file. La finestra informativa non può quindi saltare alle singole tracce.", + "moreNamesTitle": "Più nomi che tracce nel file ({{tracks}})", + "notInFile": "Nomi aggiuntivi senza traccia nel file:" + }, + "unsaved": "{{count}} modifica/e non salvata/e", + "validation": { + "title": "Correggi prima questi problemi" + } + }, + "customImages": { + "navigationTitle": "Immagini", + "title": "Gestisci immagini personalizzate" + }, + "customModelPicker": { + "addNew": "Aggiungi nuovo", + "loading": "Caricamento...", + "noModels": "Nessun modello personalizzato trovato", + "searchPlaceholder": "Cerca modello, serie...", + "title": "Seleziona modello personalizzato" + }, + "customToniesEditorJsonEntry": "Modelli personalizzati", + "editModal": { + "audioInfoHeading": "Informazioni audio", + "createNewModelTooltip": "Crea nuovo modello", + "editModelTooltip": "Modifica modello", + "editSelectedCustomModel": "Continua a modificare il modello", + "invalidModelError": "Il modello \"{{model}}\" non è valido. Seleziona un modello esistente dalla ricerca.", + "model": "Modello", + "modelInfoAudioPath": "Percorso audio", + "modelInfoCategory": "Categoria", + "modelInfoEpisode": "Episodio", + "modelInfoHeading": "Informazioni modello", + "modelInfoLanguage": "Lingua", + "modelInfoModel": "Modello", + "modelInfoNo": "No", + "modelInfoRelease": "Data di uscita", + "modelInfoSeries": "Serie", + "modelInfoTitle": "Titolo", + "modelReadOnlyHint": "Tonie originale: il modello non può essere modificato. Puoi assegnare solo audio personalizzato.", + "placeholderSearchForAModel": "Cerca un modello", + "placeholderSearchForARadioStream": "Cerca una stazione radio", + "save": "Salva", + "setAudioFromModel": "Imposta audio dal modello", + "setAudioFromModelNoMapping": "Il modello selezionato non ha alcun audio assegnato.", + "setAudioFromModelUnavailableInLibrary": "L'audio del modello non è ancora disponibile nella libreria.", + "setModelFromAudio": "Imposta modello dall'audio", + "source": "Sorgente", + "title": "Modifica Tonie/Tag" + }, + "encoder": { + "browserEncodingInProgress": "Codifica dei file audio nel browser...", + "browserSideEncoding": "Codifica lato browser", + "clearList": "Svuota elenco", + "encode": "Codifica", + "encoding": "Codifica", + "errorFileProcessing": "Impossibile elaborare il file audio: ", + "filename": "Nome file", + "loading": "Caricamento...", + "loadingWasmEncoder": "Caricamento del codificatore WASM...", + "maxFiles": "Puoi aggiungere al massimo {{maxFiles}} file.", + "navigationTitle": "Codificatore audio", + "processing": "Elaborazione...", + "processingDetails": "Codifica dei file in \"{{file}}\" in corso...", + "processingError": "Errore durante l'elaborazione!", + "removeFile": "Rimuovi file", + "saveAs": "Salva file come", + "serverSideEncoding": "Codifica lato server", + "sortAlphabeticallyLiteral": "Ordina alfabeticamente (lessicografico)", + "sortAlphabeticallyNatural": "Ordina alfabeticamente (ordine numerico)", + "targetDirectory": "Scegli directory di destinazione", + "title": "Codificatore audio", + "tooManyFilesError": "Troppi file!", + "unsupportedFileType": "Tipo di file non supportato!", + "unsupportedFileTypeDetails": "Il file \"{{file}}\" ha un tipo non supportato!", + "upload": "Carica", + "uploadFailed": "Caricamento non riuscito", + "uploadFailedDetails": "Si è verificato un errore durante il caricamento: ", + "uploadFiles": "Seleziona file", + "uploadHint": "Trascina qui i file audio!", + "uploading": "Caricamento...", + "uploadingDetails": "Caricamento del file \"{{file}}\"...", + "uploadSuccessful": "Codifica del file riuscita!", + "uploadSuccessfulDetails": "I file sono stati codificati come \"{{file}}\" e aggiunti correttamente alla libreria!", + "uploadText": "Fai clic o trascina in quest'area fino a {{maxFiles}} file audio che vuoi convertire in un file taf" + }, + "errorFetchingSystemSounds": "Errore durante il recupero dei suoni di sistema", + "errorFetchingTonies": "Errore durante il recupero dei Tonie", + "help": { + "actionItems": { + "cloudIcon": { + "text": "Un'icona grigia indica che la sincronizzazione cloud è abilitata; rossa significa che è disabilitata. Quando è abilitata, il Tonie/Tag può sincronizzarsi con Boxine/Toniecloud. Fai clic sull'icona per cambiarne lo stato.", + "title": "Stato cloud" + }, + "downloadIcon": { + "text": "Se l'accesso al cloud è abilitato e autorizzato, facendo clic su questa icona verranno scaricati i contenuti da Boxine/Toniecloud.", + "title": "Scarica contenuti" + }, + "editIcon": { + "createNewModel": { + "description": "Apre l'editor dei modelli personalizzati per inserire i dettagli di un nuovo modello per il tuo Tonie/Tag.", + "text": "Crea un nuovo modello personalizzato per il tuo Tonie/Tag.", + "title": "Crea nuovo modello" + }, + "editModel": { + "description": "Modifica il modello personalizzato selezionato nell'editor dei modelli personalizzati. Le voci audio mostrano subito ID audio e hash; con l'icona di localizzazione di una voce si trova il relativo file nella libreria.", + "text": "Modifica il modello personalizzato assegnato al Tonie/Tag.", + "title": "Modifica modello personalizzato" + }, + "model": { + "description": "Cerca un modello e assegnalo al Tonie/Tag. L'immagine verrà mostrata nella panoramica.", + "text": "Rappresenta il modello visivo assegnato al Tonie/Tag.", + "title": "Modello" + }, + "radioStreamSearch": { + "description": "Usa il campo di ricerca per trovare e assegnare un flusso dal database radio-browser.", + "text": "Cerca flussi radio online (disponibile se l'API radio-browser.info è accessibile).", + "title": "Ricerca stazione radio" + }, + "setAudioFromModel": { + "description": "Compare quando è selezionato un modello che non corrisponde all'audio attuale. La libreria viene cercata solo al clic sul pulsante, il che può richiedere un momento. Se non viene trovato alcun file, sotto il campo origine appare un avviso.", + "text": "Assegna il file audio che appartiene al modello selezionato.", + "title": "Imposta audio dal modello" + }, + "setModelFromAudio": { + "description": "Compare quando l'audio assegnato appartiene a un modello noto. Non serve cercare nella libreria: il modello viene ricavato dalle informazioni dell'audio.", + "text": "Assegna il modello che appartiene all'audio attuale.", + "title": "Imposta modello dall'audio" + }, + "source": { + "description": "Puoi assegnare contenuti dalla libreria (ad es. file TAF o TAP) oppure inserire l'URL di un flusso.", + "text": "Mostra la sorgente dei contenuti attualmente assegnata.", + "title": "Sorgente" + }, + "text": "Apre la finestra di modifica con le seguenti opzioni di configurazione:", + "title": "Modifica Tonie/Tag" + }, + "infoIcon": { + "download": { + "description": "Se disponibili, le tracce possono essere scaricate come file *.ogg separati (o come un unico file *.ogg).", + "text": "Apre la selezione delle tracce per scaricare il TAF come *.ogg.", + "title": "Scarica TAF come *.ogg" + }, + "exists": { + "description": "Lo stato 'esiste' è impostato su 'sì' se il file sorgente assegnato è presente.", + "text": "Mostra se la sorgente assegnata al Tonie/Tag esiste nel sistema.", + "title": "Esiste" + }, + "hide": { + "description": "Usa questa opzione per nascondere un Tonie/Tag. Per mostrarlo di nuovo, posiziona il Tonie/Tag su uno dei tuoi Toniebox.", + "text": "Rimuove il Tonie/Tag dalla visualizzazione della panoramica.", + "title": "Nascondi Tonie/Tag" + }, + "text": "Apre una finestra che mostra i dettagli principali del Tonie/Tag, tra cui titolo, immagine, UID, elenco tracce e vari indicatori di stato:", + "title": "Informazioni Tonie/Tag", + "valid": { + "description": "Un Tonie/Tag è considerato valido se la sorgente assegnata è un file TAF valido.", + "text": "Mostra se la sorgente assegnata al Tonie/Tag è valida.", + "title": "Valido" + } + }, + "liveIcon": { + "text": "Un'icona rossa indica la modalità 'live', cioè il contenuto ripartirà sempre dall'inizio e verrà riscaricato. Questa modalità viene abilitata automaticamente per i flussi radio. Fai clic per cambiarne lo stato.", + "title": "Stato live" + }, + "playIcon": { + "text": "Riproduce il contenuto assegnato al Tonie/Tag. Se l'icona appare grigio chiaro, non è assegnato alcun contenuto riproducibile.", + "title": "Riproduci contenuto" + } + }, + "blueTopBorder": { + "text": "Indica che questo Tonie/Tag è stato l'ultimo riprodotto su almeno uno dei tuoi Toniebox.", + "title": "Bordo superiore blu" + }, + "closeButton": "Chiudi", + "modelImage": { + "bigImage": { + "description": "Questa immagine riflette il modello selezionato assegnato al Tonie/Tag. Per cambiarla, assegna un modello diverso.", + "text": "Rappresenta l'aspetto visivo del Tonie/Tag.", + "title": "Immagine grande" + }, + "smallImage": { + "description": "Visualizzata quando la sorgente assegnata è diversa dal modello selezionato.", + "text": "Indicatore visivo della sorgente effettiva se differisce dal modello assegnato.", + "title": "Immagine piccola" + }, + "text": "Mostra la rappresentazione visiva del Tonie/Tag se viene riconosciuto. I Tonie/Tag sconosciuti mostreranno un punto interrogativo. Se l'impostazione 'Frontend' → 'Split content / model' è abilitata, possono comparire due immagini:", + "title": "Immagine modello Tonie/Tag" + }, + "title": "Aiuto" + }, + "imageManager": { + "noOriginalImages": "Nessuna immagine originale trovata", + "noOriginalSearchResults": "Nessun URL corrisponde alla ricerca", + "okText": "Applica", + "originalImagesLoading": "Caricamento immagini originali …", + "originalUrlColumn": "URL", + "originalUrlSearchPlaceholder": "Parole da cercare nell'URL (devono corrispondere tutte, ordine ignorato) …", + "sourceCustom": "Personalizzata", + "sourceOriginal": "Originale", + "title": "Libreria: seleziona e carica immagini", + "titleSelect": "Seleziona immagine" + }, + "infoModal": { + "download": "Scarica TAF come *.ogg", + "exists": "Esiste:", + "model": "Modello:", + "no": "No", + "tracklist": "Elenco tracce:", + "valid": "Valido:", + "yes": "Sì" + }, + "informationModal": { + "hideTag": "Nascondi Tonie/Tag", + "ok": "OK", + "unknownModel": "Modello sconosciuto" + }, + "lastPlayedOnModal": { + "lastPlayedOn": "Ultimo Tonie riprodotto", + "lastPlayedOnMessage": "Questo Tonie è stato l'ultimo riprodotto su", + "title": "Questo Tonie è stato riprodotto per ultimo!" + }, + "library": { + "navigationTitle": "Libreria", + "title": "Libreria" + }, + "libraryAudio": { + "navigationTitle": "Audio" + }, + "messages": { + "cloudAccessBlocked": "Accesso al cloud bloccato!", + "cloudAccessBlockedDetails": "Accesso al cloud bloccato per \"{{model}}\" [{{ruid}}]", + "cloudAccessEnabled": "Accesso al cloud abilitato!", + "cloudAccessEnabledDetails": "Accesso al cloud abilitato per \"{{model}}\" [{{ruid}}]", + "couldNotChangeCloudFlag": "Impossibile modificare il flag di accesso al cloud (nocloud)! ", + "couldNotChangeCloudFlagDetails": "Impossibile modificare il flag di accesso al cloud (nocloud) per \"{{model}}\" [{{ruid}}]: ", + "couldNotChangeLiveFlag": "Impossibile modificare il flag live! ", + "couldNotChangeLiveFlagDetails": "Impossibile modificare il flag live per \"{{model}}\" [{{ruid}}]: ", + "couldNotEmptySource": "Impossibile svuotare la sorgente!", + "couldNotSetSource": "Impossibile impostare la sorgente! ", + "downloadedFile": "File scaricato", + "downloadedFileDetails": "File sorgente di \"{{model}}\" [{{ruid}}] scaricato", + "downloading": "Download...", + "downloadingDetails": "Download di \"{{model}}\" [{{ruid}}]...", + "errorDuringDownload": "Errore durante il download in background", + "errorDuringDownloadDetails": "Errore durante il download in background di \"{{model}}\" [{{ruid}}]: ", + "errorFetchingUpdatedCard": "Errore durante il recupero della scheda aggiornata!", + "errorFetchingUpdatedCardDetails": "Errore durante il recupero della scheda aggiornata per \"{{model}}\" [{{ruid}}]: ", + "filterDeleted": "Filtro eliminato", + "filterDeletedDetails": "Filtro {{name}} eliminato correttamente!", + "filterLoaded": "Filtro caricato", + "filterLoadedDetails": "Filtro {{name}} caricato correttamente!", + "filterSaved": "Filtro salvato", + "filterSavedDetails": "Filtro {{name}} salvato correttamente!", + "hideTonieFailed": "Impossibile nascondere Tonie/Tag", + "hideTonieFailedDetails": "Impossibile nascondere Tonie/Tag [{{ruid}}]: ", + "hideTonieSuccessful": "Tonie/Tag contrassegnato correttamente come nascosto!", + "hideTonieSuccessfulDetails": "Tonie/Tag [{{ruid}}] contrassegnato correttamente come nascosto!", + "liveDisabled": "Live disabilitato!", + "liveDisabledDetails": "Flag live disabilitato per \"{{model}}\" [{{ruid}}]", + "liveEnabled": "Live abilitato!", + "liveEnabledDetails": "Flag live abilitato per \"{{model}}\" [{{ruid}}]", + "noFilterFound": "Nessun filtro trovato", + "noFilterFoundDetails": "Non è stato trovato alcun filtro con nome {{name}}", + "setToEmptyValue": "vuoto", + "setTonieToModelFailed": "Impossibile modificare il modello del Tonie!", + "setTonieToModelFailedDetails": "Impossibile modificare il modello del Tonie per [{{ruid}}]: ", + "setTonieToModelSuccessful": "Modello del Tonie impostato correttamente!", + "setTonieToModelSuccessfulDetails": "Modello del Tonie impostato su {{selectedModel}} per [{{ruid}}]", + "setTonieToSourceFailed": "Impossibile modificare la sorgente del Tonie!", + "setTonieToSourceFailedDetails": "Impossibile modificare la sorgente del Tonie per [{{ruid}}]: ", + "setTonieToSourceSuccessful": "Sorgente del Tonie impostata correttamente!", + "setTonieToSourceSuccessfulDetails": "Sorgente del Tonie impostata su {{selectedSource}} per [{{ruid}}]" + }, + "navigationTitle": "Tonie", + "noData": "Nessun Tonie/Tag personalizzato trovato.", + "noDataText": "I Tonie/Tag personalizzati compariranno solo dopo che un Toniebox avrà stabilito una connessione corretta e avrà già Tonie/Tag personalizzati sulla propria scheda SD. Assicurati che il Toniebox sia collegato correttamente e contenga alcuni Tonie/Tag personalizzati, oppure posiziona il primo Tonie/Tag personalizzato sul Toniebox e aggiorna questa pagina.", + "noPlayableContentHint": "Non è disponibile alcun contenuto riproducibile. Il contenuto non è assegnato oppure mancano dati dal cloud Boxine/Tonies. Posiziona la figurina sul box per risolvere il problema.", + "selectFileModal": { + "cancel": "Annulla", + "ok": "OK", + "selectFile": "Seleziona file" + }, + "selectMode": { + "cancel": "Annulla", + "cancelButtonTooltip": "Annulla modalità selezione", + "exportCompleteInfoToJson": "Esporta selezionati - informazioni complete - in JSON", + "exportCsv": "Esporta come CSV", + "exportCsvTooltip": "Esporta i tag come file CSV con informazioni su serie, episodio e modello del Tonie/Tag (non il contenuto assegnato)", + "exportToHtml": "Esporta in HTML", + "exportToHtmlInline": "Esporta in HTML con immagini incorporate se possibile (lento!)", + "exportToJson": "Esporta in JSON", + "hideSelectedTags": "Nascondi tag", + "select": "Seleziona", + "selectAll": "Seleziona tutto", + "selectButtonTooltip": "Abilita modalità selezione", + "selected": "Tag/Tonie selezionati", + "setLive": "Abilita live", + "setNoCloud": "Disabilita cloud", + "unselectAll": "Deseleziona tutto", + "unsetLive": "Disabilita live", + "unsetNoCloud": "Abilita cloud" + }, + "system-sounds": { + "navigationTitle": "Suoni di sistema", + "title": "Suoni di sistema" + }, + "tafDownload": { + "asOneFile": "Scarica come un unico file *.ogg", + "downloadCount": "Scarica {{count}} tracce come *.ogg", + "downloadOne": "Scarica 1 traccia come *.ogg", + "duration": "Durata", + "modalTitle": "Scarica tracce come *.ogg", + "selectAll": "Seleziona tutte le tracce", + "selectedCount": "{{selected}} di {{total}} selezionate", + "track": "Traccia", + "unnamedTrack": "Traccia {{number}}" + }, + "tafHeaderInvalid": "TAF non valido", + "tafHeaderOf": "Intestazione TAF di ", + "tafHeaderToniesJsonHint": "Non devi modificare questi dati quando li invii a tonies-json. Ciò include il numero di tracce, anche se è diverso dal numero di tracce nel campo track-desc!", + "tap": { + "navigationTitle": "Playlist TeddyAudio", + "title": "Playlist TeddyAudio" + }, + "tapEditor": { + "addFile": "Aggiungi file", + "audioId": "ID audio", + "audioIdTooltip": "Imposta l'ID audio se il file è memorizzato nella cache. Impostalo a 0 per forzare la ricreazione del file memorizzato nella cache", + "cancel": "Annulla", + "create": "Crea", + "fileNameContentFile": "Descrizione", + "filePath": "Percorso file playlist", + "filePathContentFile": "Percorso file", + "filePathContentFileRequired": "Il percorso del file deve essere impostato", + "filePathContentFileTooltip": "Imposta il percorso della risorsa audio (deve essere compatibile con ffmpeg)", + "filePathRequired": "Il percorso del file playlist deve essere impostato", + "filePathTooltip": "Imposta il percorso del TAF che verrà prodotto da ffmpeg", + "name": "Nome TAP", + "nameRequired": "Il nome TAP deve essere impostato", + "nameTooltip": "Imposta il nome della playlist", + "save": "Salva", + "titleCreate": "Crea nuovo TAP in {{path}}", + "titleCreateButton": "Crea nuovo TAP", + "titleEdit": "Modifica TAP {{name}}" + }, + "teddyaudioplayer": { + "continueInFooterAudioPlayer": "Continua nel lettore audio a piè di pagina", + "enterFullscreen": "Passa a schermo intero", + "exitFullscreen": "Esci da schermo intero", + "expand": "Espandi lettore audio", + "navigationTitle": "Lettore audio", + "noTracks": "Nessuna traccia disponibile", + "openInTonieAudioPlayer": "Apri nel lettore audio", + "openStandalone": "Apri il lettore in modalità autonoma", + "selectTonieToPlay": "Seleziona un Tonie/Tag da riprodurre", + "shrink": "Riduci lettore audio", + "standaloneTitle": "Lettore audio", + "title": "Lettore audio", + "unknown": "Sconosciuto" + }, + "teddystudio": { + "adaptLabelsHint": "Puoi modificare testi e immagini delle etichette facendo clic sul pulsante di modifica di ciascun gruppo di etichette.", + "addedCustomImageHint": "L'immagine personalizzata è stata aggiunta.", + "addedHint": "\"{{title}}\" è stato aggiunto.", + "bulkAdd": { + "button": "Aggiungi Tonie in blocco", + "modal": { + "clearSelection": "Deseleziona tutto", + "confirm": "Aggiungi {{count}} al foglio", + "itemsUnit": "Tonie", + "itemUnit": "Tonie", + "leftTitle": "tonies.json", + "notFound": "Nessun Tonie corrispondente", + "resultsTruncated": "Visualizzati i primi {{shown}} di {{total}} — continua a digitare per restringere la ricerca", + "rightTitle": "In coda per l'aggiunta", + "searchHint": "Digita almeno 2 caratteri per cercare in tonies.json.", + "searchPlaceholder": "Cerca", + "searchPrompt": "Cerca in tonies.json…", + "selectAll": "Seleziona tutto", + "selectedCount": "{{count}} in coda per l'aggiunta", + "title": "Aggiungi Tonie in blocco al foglio di stampa" + } + }, + "cancel": "Annulla", + "clear": "Svuota foglio", + "clearSettings": "Ripristina valori predefiniti", + "close": "Chiudi", + "contentPadding": "Spaziatura interna etichetta", + "currentLabel": "Etichetta corrente", + "custom": "Personalizzato", + "customImage": "Immagini personalizzate", + "customImageHint": "Puoi aggiungere immagini temporaneamente: NON verranno aggiunte alla libreria e saranno utilizzate solo per questo foglio di stampa.", + "customImageTitle": "Inserisci un titolo da stampare", + "customImageUpload": "Aggiungi immagine temporanea (senza libreria)", + "diameter": "Diametro interno", + "downloadPdf": "Scarica PDF", + "empty": "Vuoto - Seleziona prima Tonie o Tag oppure aggiungi un'immagine personalizzata!", + "episodes": "Episodi", + "fontFamily": "Carattere etichetta", + "fontFamilyPlaceholder": "Scegli un carattere", + "height": "Altezza", + "imageBottom": "Margine inferiore dell'immagine", + "imageBottomLeft": "Margine immagine inferiore/sinistro", + "imageLeft": "Margine sinistro dell'immagine", + "imagePosition": "Posizione immagine", + "imageScale": "Scala immagine", + "intro": "Cerca tutti i Tonie e i tag, sia ufficiali sia personalizzati, per creare il tuo foglio Traveltonies stampabile.", + "labelBackgroundColor": "Sfondo", + "labelEditTitle": "Modifica elementi etichetta", + "labelImage": "Immagine etichetta", + "labelImagePreview": "Immagine", + "labelSavedSuccesful": "Etichetta salvata correttamente", + "labelSavedSuccesfulDetails": "L'etichetta {{title}} è stata salvata correttamente", + "labelSettings": "Impostazioni etichetta", + "labelShape": "Forma etichetta", + "labelSize": "Larghezza/altezza etichetta", + "labelSpacing": "Spaziatura", + "labelSpacingX": "X", + "labelSpacingY": "Y", + "labelTitle": "Titolo", + "labelTracklist": "Elenco tracce", + "navigationTitle": "TeddyStudio", + "paperCustomSize": "Larghezza/altezza carta", + "paperLabelImageBleed": "Abbondanza immagine", + "paperMargin": "Margine superiore/sinistro", + "paperSettings": "Impostazioni carta", + "paperSize": "Formato carta", + "placeholder": "Inserisci un termine di ricerca!", + "preview": "Anteprima etichetta", + "printMarginNotification": "I margini di stampa CSS sono supportati in modo affidabile solo in Chrome. Usa Chrome per garantire una stampa corretta delle etichette.", + "printMode": "Modalità di stampa", + "printModeImageAndText": "Immagine + Testo", + "printModeImageAndTextTooltip": "Stampa entrambi i lati del Traveltonie con l'immagine del tag su un lato e le informazioni su titolo ed episodio sull'altro", + "printModeOnlyImage": "Immagine", + "printModeOnlyImageTooltip": "Stampa un solo lato con l'immagine del tag", + "printModeOnlyText": "Testo", + "printModeOnlyTextTooltip": "Stampa un solo lato con le informazioni su titolo ed episodio", + "printPage": "Stampa foglio", + "printSheet": "Il tuo foglio di stampa", + "printTrackListInsteadTitle": "Stampa l'elenco tracce al posto del titolo", + "replaceLabelImage": "Trascina qui un'immagine o scegli un file per sostituire l'immagine dell'etichetta esistente", + "restoreCommonSettings": "Ripristina", + "round": "Rotonda", + "save": "Salva", + "saveOnNavigate": "Salvataggio automatico durante la navigazione", + "saveSettings": "Salva come preset", + "selectPredefinedPaperOptions": "Seleziona preset carta", + "series": "Serie", + "seriesOnImageLabelFontSize": "Dimensione carattere serie", + "seriesOnImageLabelRotationDeg": "Ruota serie", + "settings": "Impostazioni", + "settingsSavedSuccessful": "Impostazioni TeddyStudio salvate correttamente", + "showLabelBorder": "Stampa bordo etichetta", + "showLanguageFlag": "Stampa bandiera della lingua", + "showModelNo": "Stampa n. modello", + "showSeriesOnImageLabel": "Mostra serie sull'etichetta immagine", + "square": "Quadrata", + "textFontSize": "Dimensione testo", + "title": "TeddyStudio", + "trackTitles": "Titoli delle tracce (uno per riga)", + "width": "Larghezza" + }, + "title": "Tonie", + "tonies": { + "filterBar": { + "applyFilters": "Applica filtro", + "basicFilters": "Filtro base", + "customFilter": { + "invalidFieldUnique": "Campo non valido in unique(): {{field}}", + "invalidToken": "Token non valido: {{token}}", + "label": "Filtro personalizzato (esperto)", + "placeholder": "Inserisci un filtro personalizzato, ad es. live && !nocloud && language=de-de", + "syntaxError": "Errore di sintassi", + "unknownFieldComparison": "Campo sconosciuto nel confronto: {{field}}", + "unknownFieldNegation": "Campo sconosciuto nella negazione: {{field}}", + "unknownFieldRegex": "Campo sconosciuto nell'espressione regolare: {{field}}" + }, + "customFilterHelp": { + "fields": { + "claimed": "Indica se questo Tonie è stato assegnato (true/false).", + "episode": "Il nome dell'episodio del Tonie.", + "exists": "Indica se il Tonie esiste (true/false).", + "hasCloudAuth": "Indica se il Tonie dispone dell'autorizzazione cloud (true/false).", + "language": "La lingua del Tonie.", + "live": "Indica se il Tonie è contrassegnato come live (true/false).", + "model": "Il modello del Tonie.", + "nocloud": "Indica se il Tonie è contrassegnato come nocloud (true/false).", + "picture": "Il percorso o l'URL dell'immagine assegnata al Tonie.", + "ruid": "L'UID invertito del Tonie in minuscolo senza due punti (:).", + "series": "Il nome della serie del Tonie.", + "source": "La sorgente assegnata al Tonie (taf, tap o stream).", + "track": "Cerca nel Tonie una traccia con nome esatto.", + "trackcount": "Il numero di tracce nel Tonie.", + "tracks": "Un array con i nomi delle tracce del Tonie.", + "trackseconds": "Un array con la posizione iniziale in secondi di ciascuna traccia del Tonie.", + "tracksecondscount": "Il numero di posizioni di inizio traccia nel Tonie.", + "uid": "L'UID (identificatore univoco) del Tonie.", + "valid": "Indica se il Tonie è valido (true/false)." + }, + "intro": "Usa il filtro personalizzato per filtrare i Tonie tramite espressioni logiche e condizioni specifiche dei campi.", + "syntaxExamples": { + "and": "Filtra i Tonie in cui la serie è 'Pixi' E il Tonie è valido.", + "combined": "Filtra i Tonie che sono live, hanno accesso al cloud, lingua tedesca e sono unici nella serie.", + "endswith": "Filtra i Tonie in cui il campo termina con il valore indicato.", + "inList": "Filtra i Tonie in cui il campo corrisponde a uno dei valori indicati.", + "isEmpty": "Filtra i Tonie in cui il campo è vuoto.", + "isNotEmpty": "Filtra i Tonie in cui il campo non è vuoto.", + "negation": "Filtra i Tonie che hanno accesso al cloud.", + "notInList": "Filtra i Tonie in cui il campo non corrisponde a nessuno dei valori indicati.", + "or": "Filtra i Tonie con lingua tedesca oppure francese.", + "series": "Filtra i Tonie in cui la serie è 'Pixi'.", + "startswith": "Filtra i Tonie in cui il campo inizia con il valore indicato.", + "title": "Esempi di sintassi", + "trackCount": "Filtra per numero di tracce", + "trackExact": "Filtra le tracce per nome esatto", + "trackSecondsCountCompare": "Filtra per numero di posizioni iniziali delle tracce", + "trackSecondsCountEqualsTrackCount": "Filtra i Tonie in cui la lunghezza dell'array delle posizioni iniziali delle tracce è uguale al numero di tracce", + "trackSecondsCountNotEqualsTrackCount": "Filtra i Tonie in cui la lunghezza dell'array delle posizioni iniziali delle tracce è diversa dal numero di tracce", + "tracksRegex": "Filtra le tracce usando un'espressione regolare", + "tracksTitle": "Esempi relativi alle tracce:", + "unique": "Filtra i Tonie che sono gli unici nella loro serie." + }, + "table": { + "description": "Descrizione", + "field": "Campo" + }, + "tips": { + "caseSensitive": "I nomi dei campi distinguono maiuscole e minuscole; usa esattamente il minuscolo mostrato.", + "combine": "Combina più condizioni con AND / OR e negale con !.", + "parentheses": "Usa le parentesi () per espressioni complesse.", + "title": "Suggerimenti" + }, + "title": "Guida al filtro personalizzato" + }, + "delete": "Elimina", + "enterNewFilterName": "Inserisci il nome del nuovo filtro", + "episodeFilterPlaceholder": "Episodio", + "exists": "Esiste: sì", + "filterLabel": "Filtro", + "filters": "Profili filtro", + "hasCloudAuth": "Autorizzazione cloud impostata", + "hideFilters": "Nascondi filtri", + "invalid": "Valido: no", + "languagePlaceholder": "Seleziona lingua", + "lastPlayed": "Ultimo riprodotto", + "live": "Live abilitato", + "loadFilter": "Carica", + "noCloud": "Cloud disabilitato", + "noSavedFilters": "Nessun filtro salvato disponibile", + "notExists": "Esiste: no", + "resetFilters": "Reimposta filtro", + "saveFilter": "Salva", + "searchPlaceholder": "Inserisci criteri di ricerca", + "selectExistingFilter": "Seleziona filtro esistente", + "seriesFilterPlaceholder": "Serie", + "showFilters": "Mostra filtri", + "statusFilters": "Filtro stato", + "title": "Barra dei filtri", + "unsetHasCloudAuth": "Nessuna autorizzazione cloud", + "unsetLive": "Live disabilitato", + "unsetNoCloud": "Cloud abilitato", + "valid": "Valido: sì" + }, + "navigationTitle": "Tonie", + "pageSelector": " Tonie/pagina", + "showAll": "Mostra tutti", + "showPagination": "Mostra paginazione", + "title": "Tonie" + }, + "toniesExportList": "Elenco esportazione Tonie", + "toniesExportListCreateDate": "Generato il", + "unsetTonie": "Tonie/Tag" + }, + "toniesJsonSearch": { + "failedToFetchSearchResults": "Errore durante il recupero dei modelli", + "failedToFetchSearchResultsDetails": "Impossibile recuperare i risultati della ricerca dei modelli: ", + "noResults": "Nessuna voce trovata in tonies(.custom).json per la ricerca." + }, + "utils": { + "copied": "Copiato!", + "copyToClipboard": "Copia negli appunti", + "failedToCopied": "Copia non riuscita!", + "noResults": "Nessun risultato" + } +} diff --git a/contrib/data/www/web/translations/pl.json b/contrib/data/www/web/translations/pl.json new file mode 100644 index 000000000..7104c4a7c --- /dev/null +++ b/contrib/data/www/web/translations/pl.json @@ -0,0 +1,2679 @@ +{ + "404": { + "description": "Ups - Strony nie znaleziono", + "navigationTitle": "404 - Strony nie znaleziono", + "title": "404" + }, + "audio": { + "errorConfirm": "Zamknij", + "errorNoOggOpusSupport": "Błąd - OGG/Opus (taf) nie jest obsługiwany.", + "errorNoOggOpusSupportByApple": "Twoja przeglądarka nie obsługuje obecnie plików OGG/Opus (taf). Jeśli korzystasz z urządzenia Apple, pomóż nam, zgłaszając to jako problem do Apple!", + "unknownSource": "Nieznane" + }, + "auth": { + "loginFailed": "Nieprawidłowa nazwa użytkownika lub hasło", + "loginHint": "Zaloguj się do TeddyCloud", + "loginRateLimited": "Zbyt wiele prób logowania. Spróbuj ponownie za kilka minut.", + "loginSubmit": "Zaloguj się", + "loginTitle": "Logowanie do interfejsu WWW", + "logout": "Wyloguj się", + "newPassword": "Nowe hasło", + "password": "Hasło", + "passwordMin": "Co najmniej 4 znaki", + "passwordRequired": "Hasło jest wymagane", + "retry": "Spróbuj ponownie", + "statusLoadFailed": "Nie udało się wczytać stanu uwierzytelniania", + "statusLoadFailedDetails": "Nie udało się wczytać stanu uwierzytelniania. Sprawdź połączenie z TeddyCloud i spróbuj ponownie.", + "username": "Nazwa użytkownika", + "usernameRequired": "Nazwa użytkownika jest wymagana" + }, + "common": { + "cancel": "Anuluj" + }, + "community": { + "attribution": { + "navigationTitle": "Podziękowania", + "title": "Podziękowania" + }, + "changelog": { + "allCommits": "Wszystkie zawarte commity znajdziesz tutaj (repozytorium teddy cloud)", + "changes": "Zmiany", + "discussion": "Dyskusja", + "githubRelease": "Wydanie na GitHub", + "navigationTitle": "Lista zmian", + "title": "Lista zmian", + "version": "Wersja" + }, + "community": { + "discourse": "Żywa baza wiedzy projektu — miejsce, w którym można zadawać pytania, których odpowiedzi mogą pomóc również innym.", + "getInvolved": "Zaangażuj się", + "getInvolvedText1": "Stań się częścią naszej globalnej społeczności współtwórców, pisząc kod, komentując zgłoszenia lub uczestnicząc w dyskusjach na naszej grupie Telegram.", + "getInvolvedText2": "Najlepsze miejsca kontaktu to", + "github": "Miejsce, w którym przechowywane są wszystkie źródła tego i powiązanych projektów. Tutaj możesz forkować repozytoria, dodawać nowe lub ulepszone funkcje, zgłaszać problemy lub po prostu przekazać opinię na temat już istniejących zgłoszeń.", + "teddyCloudWiki": "Wiki zawiera między innymi informacje o tym, jak zainstalować TeddyCloud i jak rozwiązywać ewentualne problemy.", + "telegram": "Pierwszy poziom wsparcia, jeśli potrzebujesz pomocy lub interesuje Cię zaangażowanie w rozwój TeddyCloud." + }, + "contribution": { + "communityValues": "Wartości społeczności", + "communityValues1": "Używaj przyjaznego i włączającego języka.", + "communityValues2": "Szanuj odmienne punkty widzenia i doświadczenia.", + "communityValues3": "Przyjmuj konstruktywną krytykę z gracją.", + "communityValues4": "Wspieraj to, co najlepsze dla społeczności.", + "communityValues5": "Okazuj empatię innym członkom społeczności.", + "communityValues6": "Decyzje podejmowane są na podstawie merytoryki technicznej i konsensusu. Społeczność Teddycloud dąży do traktowania wszystkich równo i doceniania każdego wkładu.", + "communityValuesExamples": "Przykłady zachowań, które przyczyniają się do tworzenia pozytywnej atmosfery, obejmują:", + "communityValuesIntro": "W trosce o wspieranie otwartego i przyjaznego środowiska, współtwórcy i opiekunowie projektu zobowiązują się, aby udział w naszym projekcie i naszej społeczności był doświadczeniem wolnym od nękania dla wszystkich — niezależnie od wieku, budowy ciała, niepełnosprawności, pochodzenia etnicznego, tożsamości i ekspresji płciowej, poziomu doświadczenia, narodowości, wyglądu, rasy, religii czy orientacji i tożsamości seksualnej.", + "contributionIntro": "Teddycloud może rozwijać się tylko dzięki wkładowi tej społeczności. Bardzo dziękujemy za Twój entuzjazm i pracę — doceniamy wszystko, co robisz!", + "makeFirstContribution": "Wnieś swój pierwszy wkład", + "makeFirstContribution1": "Pisz kod.", + "makeFirstContribution2": "Ulepszaj dokumentację.", + "makeFirstContribution3": "Odpowiadaj na pytania na naszej grupie Telegram.", + "makeFirstContribution4": "Badaj błędy i problemy na GitHubie.", + "makeFirstContribution5": "Przeglądaj i komentuj pull requesty innych deweloperów.", + "makeFirstContribution6": "Zgłoś problem.", + "makeFirstContribution7": "Daj \"kciuk w górę\" przy zgłoszeniach, które są dla Ciebie istotne.", + "makeFirstContributionIntro": "Istnieje wiele sposobów, by wnieść swój wkład w Teddycloud! Możesz współtworzyć kod i ulepszać dokumentację Teddycloud. Do naszych najczęstszych form wkładu należą kod, dokumentacja i wsparcie społeczności.", + "navigationTitle": "Wkład", + "title": "Wkład", + "toniesJson": { + "navigationTitle": "tonies.json", + "text": "Chętnie przyjmiemy Twoją pomoc w postaci przesłania nam brakujących danych o Tonies wymienionych poniżej. W przyszłości będzie to możliwe bezpośrednio w TeddyCloud, na razie możesz skorzystać z TeddyBench. Dziękujemy z góry!", + "title": "Wnieś wkład w tonies.json" + } + }, + "contributors": { + "bigRoundOfApplause": "Gromkie brawa dla nich!", + "contributorsIntro": "Ten projekt opiera się na niestrudzonych współtwórcach, którzy poświęcają swój wolny czas i umiejętności. Bezpośrednio z TeddyCloud związani są współtwórcy dwóch projektów: teddycloud i teddycloud_web:", + "navigationTitle": "Współtwórcy", + "others": "Więcej współtwórców całego kompleksu tematycznego znajdziesz w innych repozytoriach GitHub pod adresem", + "supportTeam": "Wesprzyj zespół", + "supportTeamLink": "Zostań sponsorem TeddyCloud na GitHubie", + "supportTeamText": "Jeśli podoba Ci się TeddyCloud, rozważ wsparcie nas sponsoringiem lub napiwkiem!", + "teddyCloud": "Najbardziej niezbędna, choć w większości niewidoczna część Twojego serwera TeddyCloud.", + "teddyCloudWeb": "Czyli właściwie ten frontend, z którego teraz korzystasz i który właśnie widzisz.", + "title": "Współtwórcy" + }, + "faq": { + "intro": "Tutaj znajdziesz odpowiedzi na niektóre często zadawane pytania. Jeśli nie znalazłeś/aś tu odpowiedzi na swoje pytanie, odwiedź forum lub skorzystaj z grupy Telegram, aby uzyskać pomoc.", + "navigationTitle": "FAQ", + "title": "FAQ" + }, + "forum": { + "navigationTitle": "Forum" + }, + "navigationTitle": "Społeczność", + "plugins": { + "addButton": "Dodaj", + "addButtonTooltip": "Kliknij tutaj, aby dodać nowe wtyczki lub zaktualizować istniejące", + "available": "Dostępne wtyczki", + "by": "autor:", + "cancel": "Anuluj", + "delete": "Usuń", + "deletion": { + "confirmDeleteDialog": "Czy na pewno chcesz usunąć wtyczkę \"{{pluginId}}\"?", + "confirmDeleteModal": "Potwierdź usunięcie", + "delete": "Usuń", + "errorDeletingPlugin": "Usuwanie wtyczki nie powiodło się", + "errorDeletingPluginDetails": "Usunięcie wtyczki \"{{filename}}\" nie powiodło się: ", + "successDeletingPlugin": "Wtyczka usunięta pomyślnie", + "successDeletingPluginDetails": "Usunięcie wtyczki \"{{filename}}\" powiodło się" + }, + "downloadPluginTemplate": "Pobierz szablon wtyczki", + "empty": "Brak dostępnych wtyczek", + "error": { + "notification": { + "message": "Brak pliku json z opisem - element \"pluginName\" we wtyczce \"{{pluginId}}\", pomijanie.", + "missingPluginIndexHtml": "Brak pliku index.html we wtyczce \"{{pluginId}}\"!", + "title": "Błąd podczas wczytywania wtyczki" + }, + "pluginNotFound": "Nie znaleziono pliku index.html wtyczki - sprawdź wtyczkę!", + "title": "Błąd podczas wczytywania wtyczki" + }, + "errorLoadingPlugin": "Nie udało się wczytać metadanych wtyczki {{pluginname}}", + "filter": { + "unknown": "Sekcja nieustawiona" + }, + "help": { + "descriptionExampleTitle": "plugin.json dla powyższego przykładu:", + "descriptionIntro": "Każda wtyczka musi zawierać plik plugin.json w swoim katalogu głównym. Plik ten opisuje wtyczkę i pozwala na jej prawidłowe wyświetlenie w interfejsie.", + "descriptionTitle": "plugin.json — wymagane metadane", + "exampleFolderTitle": "Przykładowy folder:", + "exampleTitle": "Przykład:", + "fields": { + "author": "Autor wtyczki", + "description": "Krótki opis wtyczki", + "hideInNav": "Flaga określająca, czy wtyczka ma być ukryta w nawigacji (true/false)", + "icon": "Ikona używana dla wtyczki. Musi być prawidłowym komponentem ikony Ant Design z https://ant.design/components/icon, np. TrophyOutlined, TagsOutlined", + "pluginHomepage": "Strona domowa wtyczki", + "pluginName": "Tytuł wyświetlany w menu (wymagane)", + "standalone": "Flaga logiczna określająca, czy wtyczka ma działać w trybie samodzielnym (bez nagłówka, paska bocznego i stopki). Ważne: nie umieszczaj true ani false w cudzysłowach.", + "teddyCloudSection": "Pożądana sekcja, do której wtyczka ma zostać dodana w nawigacji (prawidłowe wartości: home, tonies, tonieboxes, settings, community)", + "version": "Wersja wtyczki" + }, + "fieldsTitle": "Pola:", + "folderStructureTitle": "Struktura folderu wtyczki:", + "intro": "Aby Twoja wtyczka była wykrywalna i wyświetlana w nawigacji Społeczności, musi mieć tę strukturę oraz zawierać prawidłowe pliki index.html i plugin.json. Opcjonalny plik preview.png zostanie wyświetlony jako obraz na karcie wtyczki.", + "popoverTitle": "Pomoc dotycząca wtyczek", + "title": "Struktura folderu wtyczki i wymagania" + }, + "helpButton": "Pomoc", + "helpTextCreatePlugin": "Przykładowy tekst o tym, jak stworzyć wtyczkę", + "hidden": "Ukryta", + "installedPlugins": "Zainstalowane wtyczki", + "intro": "Tutaj znajdziesz dodane przez Ciebie wtyczki TeddyCloud oraz możesz dodać nowe wtyczki udostępnione przez Ciebie lub społeczność TeddyCloud!", + "navigationTitle": "Wtyczki TC", + "ok": "OK", + "open": "Otwórz wtyczkę", + "pagination": { + "pageSelector": "Wtyczki na stronę", + "showAll": "Pokaż wszystkie" + }, + "plugin": "Wtyczka", + "title": "Wtyczki TeddyCloud", + "upload": { + "add": "Dodaj wtyczkę", + "errorUploadingPlugin": "Przesyłanie nowej wtyczki nie powiodło się", + "errorUploadingPluginDetails": "Przesyłanie nowej wtyczki \"{{filename}}\" nie powiodło się: ", + "selectPluginZip": "Wybierz spakowany plik wtyczki", + "successUploadingPlugin": "Przesyłanie nowej wtyczki powiodło się", + "successUploadingPluginDetails": "Przesyłanie nowej wtyczki \"{{filename}}\" powiodło się", + "uploadModal": "Prześlij spakowany plik wtyczki", + "uploadModalHint": "Prześlij nową lub istniejącą wtyczkę. Jeśli przesłana spakowana wtyczka już istnieje w TeddyCloud, zostanie nadpisana.", + "warningUploadingPlugin": "Wybierz plik", + "warningUploadingPluginDetails": "Wybierz spakowany plik wtyczki przed kliknięciem \"Dodaj wtyczkę\"" + }, + "visitHomepage": "Odwiedź stronę domową wtyczek" + }, + "supportRequestGuide": { + "actions": { + "label": "Wykonane czynności" + }, + "actual": { + "label": "Rzeczywisty wynik" + }, + "additionalInfo": { + "label": "Dodatkowe informacje (notatki, dołączony zrzut ekranu lub nagranie)" + }, + "closing": "Podanie tak szczegółowych informacji pomoże społeczności skuteczniej rozwiązać Twój problem. Dziękujemy!", + "createSupportRequest": "Utwórz zgłoszenie", + "createSupportRequestFormTitle": "Utwórz zgłoszenie wsparcia", + "description": { + "label": "Opis problemu" + }, + "docker": { + "label": "Wersja Dockera" + }, + "englishHint": "Napisz swoje zgłoszenie wsparcia w języku angielskim, aby cała społeczność mogła Ci pomóc.", + "example": { + "description": "Poniżej znajduje się przykład dobrze skonstruowanego zgłoszenia wsparcia, który pomoże Ci zacząć:", + "details": "Temat: Błąd podczas przesyłania pliku w TeddyCloud (Docker)

Opis:
Napotkałem błąd podczas próby przesłania pliku w TeddyCloud.

1. Wykonane czynności:
- Zalogowałem się pomyślnie.
- Przeszedłem na stronę przesyłania i wybrałem plik (testfile.pdf).
- Kliknąłem przycisk przesyłania.

2. Oczekiwany wynik:
- Plik powinien zostać pomyślnie przesłany i pojawić się na liście plików.

3. Rzeczywisty wynik:
- Pojawił się komunikat błędu: 'Upload failed: Connection lost.'

4. Szczegóły środowiska:
- System operacyjny: Ubuntu 20.04
- Wersja Dockera: 24.0.2
- Wersja TeddyCloud: 0.6.3

5. Logi:
W załączeniu fragment logu (teddycloud_logs.txt) z momentu wystąpienia błędu.", + "title": "Przykładowe zgłoszenie wsparcia" + }, + "expected": { + "label": "Oczekiwany wynik" + }, + "formattedRequest": "Sformatowane zgłoszenie", + "generate": "Generuj", + "hintSendingSupportRequest": "Skopiuj poniższą treść do grupy Telegram lub nowego wątku na forum. Pamiętaj, aby dołączyć zebrane logi oraz, jeśli to możliwe, dodatkowe zrzuty ekranu lub nagrania.", + "intro": "Podczas proszenia o wsparcie podanie szczegółowych i jasnych informacji pomaga społeczności szybciej zdiagnozować i rozwiązać Twój problem. Wykonaj poniższe kroki, aby upewnić się, że Twoje zgłoszenie zawiera wszystkie niezbędne szczegóły:", + "logs": { + "label": "Istotny fragment logu" + }, + "navigationTitle": "Przewodnik zgłoszeń wsparcia", + "os": { + "label": "System operacyjny i wersja" + }, + "requiredField": "To pole jest wymagane.", + "step1": { + "content": "Zacznij od zwięzłego podsumowania problemu. Wspomnij o wszelkich konkretnych komunikatach błędów, które otrzymałeś/aś (jeśli to możliwe, skopiuj i wklej dokładny komunikat).", + "title": "Krok 1: Jasno opisz problem" + }, + "step2": { + "intro": "Opisz szczegółowo, co robiłeś/aś przed wystąpieniem błędu. Uwzględnij:", + "list": [ + "Wykonane czynności (np. 'Zalogowałem się i spróbowałem przesłać plik').", + "Oczekiwany wynik (np. 'Plik powinien zostać pomyślnie przesłany').", + "Rzeczywisty wynik (np. 'Otrzymałem komunikat błędu: Upload failed')." + ], + "title": "Krok 2: Podaj opis krok po kroku" + }, + "step3": { + "list": [ + "System operacyjny i wersja (np. Windows 10, Ubuntu 20.04).", + "Czy używasz Dockera i jaka jest jego wersja.", + "Wszelkie inne istotne szczegóły konfiguracji." + ], + "title": "Krok 3: Podaj informacje techniczne" + }, + "step4": { + "intro": "Podaj logi, aby pomóc zdiagnozować problem. Oto jak to zrobić:", + "list": [ + "Dołącz wynik logów z momentu wystąpienia problemu.", + "Upewnij się, że usunąłeś/aś poufne informacje przed udostępnieniem." + ], + "listFirstEntry": "Uruchom to polecenie, aby zebrać logi:", + "title": "Krok 4: Dołącz istotne logi" + }, + "step5": { + "content": "Jeśli to możliwe, dołącz zrzuty ekranu lub krótkie nagranie ekranu, aby wizualnie zademonstrować problem.", + "title": "Krok 5: Zrzuty ekranu lub filmy (opcjonalnie)" + }, + "step6": { + "content": "Daj nam znać, jeśli wypróbowałeś/aś już kroki rozwiązywania problemów, takie jak ponowne uruchomienie usługi lub sprawdzenie logów.", + "title": "Krok 6: Kroki podjęte w celu rozwiązania problemu" + }, + "stepsTaken": { + "label": "Kroki już podjęte w celu rozwiązania problemu" + }, + "subject": { + "label": "Temat", + "placeholder": "np. 'Błąd podczas przesyłania pliku w TeddyCloud (Docker)'" + }, + "title": "Jak stworzyć dobre zgłoszenie wsparcia", + "version": { + "label": "Wersja TeddyCloud" + } + }, + "title": "Społeczność", + "translations": { + "allTranslationStrings": "Wszystkie ciągi tłumaczeń", + "extraKeys": "Dodatkowe klucze", + "hint": "Jeśli zauważysz błędne tłumaczenie, skontaktuj się z nami na Telegramie. Aktywnie utrzymywane są tylko wersje angielska, niemiecka, francuska i hiszpańska, przy czym francuska i hiszpańska są obecnie w całości przetłumaczone przez AI. Pozostałe dodatkowe języki nie są aktywnie utrzymywane, dlatego zachęcamy do uzupełniania brakujących tłumaczeń. Możesz również dodać kolejne języki, tłumacząc istniejący plik językowy, zapisując go jako languagecode.json w folderze public/translations, a następnie ponownie uruchamiając serwer.", + "key": "Klucz", + "language": "Język", + "missing": "BRAK", + "missingExtraKeysHeadline": "Brakujące / dodatkowe klucze tłumaczeń", + "missingKeys": "Brakujące klucze", + "navigationTitle": "Tłumaczenia", + "noDiscrepancies": "Wszystko w porządku.", + "noExtraKeys": "Brak dodatkowych kluczy.", + "noMissingKeys": "Brak brakujących kluczy.", + "title": "Tłumaczenia" + } + }, + "confirmDialog": { + "contentHintTitle": "Pamiętaj" + }, + "faq": [ + { + "answer": "Aby wymusić aktualizację, wykonaj sprawdzenie aktualności (freshness check) na swoim Tonieboxie. Naciśnij i przytrzymaj jedno z uszu przez około 3 sekundy, aż usłyszysz dźwięk. Poczekaj, aż drugi dźwięk potwierdzi zakończenie aktualizacji.", + "question": "Jak wymusić aktualizację Tonie po przypisaniu nowej zawartości w TeddyCloud?" + }, + { + "answer": "Użyj narzędzia Audioencoder i dodaj do 99 plików audio. Każdemu plikowi zostanie przypisana osobna ścieżka w utworzonym pliku TAF.", + "question": "Jak utworzyć wielościeżkowy plik TAF?" + }, + { + "answer": "Do jednej instancji TeddyCloud możesz dodać do 16 Tonieboxów.", + "question": "Ile Tonieboxów mogę dodać do instancji TeddyCloud?" + }, + { + "answer": "Połóż tag na podłączonym Tonieboxie. Zostanie on automatycznie dodany do Twoich Tonies w TeddyCloud. Jeśli masz wiele nieznanych tagów, możesz filtrować po ostatnio odtwarzanych tagach lub otworzyć stronę zarządzania Tonieboxem i kliknąć na ostatnio odtwarzany tag.", + "question": "Jak dodać nowy tag do TeddyCloud?" + }, + { + "answer": "Najprostszym sposobem usunięcia tagu jest jego ukrycie. Otwórz okno informacyjne tagu, który chcesz usunąć, i kliknij przycisk 'Ukryj'. Tag pozostanie ukryty, dopóki nie zostanie ponownie położony na Tonieboxie. Alternatywnie, jeśli masz dostęp do kart SD wszystkich podłączonych Tonieboxów, możesz usunąć tag fizycznie, usuwając odpowiadający mu folder zawartości na każdej z boxów, a następnie usuwając go w widoku zawartości TeddyCloud.", + "question": "Jak usunąć tag z mojego TeddyCloud?" + }, + { + "answer": "Skonfiguruj osobny katalog zawartości dla każdego Tonieboxa w ustawieniach zarządzania Tonieboxami. Następnie w widoku Tonies pojawi się menu rozwijane w prawym górnym rogu, umożliwiające wybór między różnymi katalogami zawartości. Nie musisz przypisywać unikalnego katalogu zawartości do każdej boxy — boxy z tym samym katalogiem zawartości zostaną zgrupowane razem w widoku Tonies.", + "question": "Jak zarządzać różną zawartością dla tagu na różnych Tonieboxach?" + }, + { + "answer": "Wykonaj sprawdzenie aktualności (freshness check) na danym Tonieboxie. Spowoduje to ponowne dodanie do TeddyCloud wszystkich tagów, które kiedykolwiek zostały położone na boxie (i nie zostały usunięte). Oryginalne Tonies muszą zostać przynajmniej raz położone na boxie, aby uzyskać uwierzytelnienie w chmurze i pobrać powiązaną z nimi zawartość do TeddyCloud.", + "question": "Dlaczego widok Tonies jest pusty po dodaniu nowego katalogu zawartości do boxy?" + }, + { + "answer": "Upewnij się, że flaga 'nocloud' jest ustawiona na true, oraz sprawdź, czy nikt potajemnie nie położył innego tagu na boxie.", + "question": "Dlaczego mój tag z niestandardową zawartością zawsze zaczyna odtwarzanie od początku zamiast od ostatnio odtwarzanej pozycji?" + }, + { + "answer": "Upewnij się, że włączone są następujące ustawienia: 'Cache Content' i 'Dump rUID/auth'. Zalecamy również włączenie 'Cache to library'.", + "question": "Dlaczego zawartość moich tagów nie jest zapisywana w pamięci podręcznej TeddyCloud?" + }, + { + "answer": "Upewnij się, że używasz właściwego rodzaju tagu. Zasadniczo kompatybilne są tylko oryginalne tagi NSP SLIX-L. Więcej informacji na ten temat znajdziesz na forum. Jeśli masz wersję Tonieboxa z CC3200, możesz włączyć obsługę również innych tagów RFID — szczegóły znajdziesz na wiki. Dodatkowo upewnij się, że między tagiem a Tonieboxem zachowana jest pewna odległość (co najmniej 2-3 mm), ponieważ tag RFID przyłożony bezpośrednio nie będzie działał prawidłowo.", + "question": "Dlaczego mój tag RFID nie działa?" + } + ], + "fileBrowser": { + "actions": "Akcje", + "assignToTonie": { + "action": "Przypisz do Tonie", + "failedDetails": "Nie udało się przypisać „{{series}}” jako zawartości: ", + "failedMessage": "Przypisanie do Tonie nie powiodło się!", + "searchPlaceholder": "Szukaj Tonies...", + "successDetails": "Plik został pomyślnie przypisany do „{{series}}”!", + "successMessage": "Tonie przypisany!", + "title": "Przypisz do Tonie" + }, + "attention": "Uwaga!", + "cancel": "Anuluj", + "confirmDeleteDialog": "Czy na pewno chcesz usunąć {{fileToDelete}}?", + "confirmDeleteModal": "Potwierdź usunięcie", + "confirmMultipleDeleteDialog": "Czy na pewno chcesz usunąć zaznaczone wiersze?", + "createDirectory": { + "cancel": "Anuluj", + "create": "Utwórz", + "createDirectory": "Utwórz nowy katalog", + "directoryCreated": "Nowy katalog został utworzony!", + "directoryCreatedDetails": "Nowy katalog \"{{directory}}\" został utworzony!", + "directoryCreateFailed": "Nie udało się utworzyć katalogu!", + "directoryCreateFailedDetails": "Nie udało się utworzyć katalogu \"{{directory}}\": ", + "inDirectory": "w katalogu ", + "modalTitle": "Utwórz nowy katalog", + "parentPath": "Katalog nadrzędny:", + "placeholder": "Wpisz nazwę folderu" + }, + "customJsonSnippetModal": { + "allFiles": "Wyświetlanie fragmentu dla wszystkich plików TAF z poprawnymi danymi nagłówka.", + "noValidHeaders": "Nie znaleziono poprawnych nagłówków TAF.", + "ok": "OK", + "selectedFiles": "Wyświetlanie fragmentu tylko dla zaznaczonych plików TAF.", + "title": "Fragment tonies.custom.json" + }, + "date": "Data", + "delete": "Usuń", + "deleteMultiple": "Usuń {{selectedRowCount}} zaznaczonych wierszy", + "downloadFile": "Pobierz plik", + "downloadFileAsOgg": "Pobierz TAF jako *.ogg", + "downloadTracks": "Pobierz utwory TAF jako *.ogg", + "encodeFiles": { + "addFiles": "Dodaj pliki", + "cancel": "Anuluj", + "encode": "Koduj", + "encodeFiles": "Zakoduj {{selectedRowCount}} zaznaczonych plików do pliku taf. Uwzględnione zostaną tylko następujące typy plików: ", + "encoding": "Kodowanie", + "encodingFailed": "Kodowanie nie powiodło się!", + "encodingFailedDetails": "Kodowanie plików \"{{file}}\" nie powiodło się: ", + "encodingInProgress": "Pliki są kodowane...", + "encodingSuccessful": "Pliki zostały pomyślnie zakodowane!", + "encodingSuccessfulDetails": "Pliki zostały pomyślnie zakodowane do \"{{file}}\"", + "modalTitle": "Koduj pliki audio", + "noSupportedFiletypesChoosen": "Nie wybrano żadnego obsługiwanego typu pliku. Obsługiwane są następujące typy plików: " + }, + "episode": "Odcinek", + "fileTypesWarning": "Wybierz tylko obsługiwane typy plików!", + "filter": "Filtruj w bieżącym folderze", + "help": { + "actionItems": { + "assignToTonie": { + "text": "Przypisuje wybrany plik jako zawartość do Tonie/Taga.", + "title": "Przypisz do Tonie" + }, + "deleteFileFolder": { + "text": "Usuwa plik lub folder (tylko jeśli folder jest pusty). Uwaga: jeśli ten plik jest przypisany do Tonie/Taga, odwołanie nie zostanie zaktualizowane.", + "title": "Usuń plik/folder" + }, + "downloadFile": { + "text": "Pobiera wybrany plik. W przypadku plików TAF z wieloma utworami otwiera się wybór utworów, aby pobrać je jako osobne pliki *.ogg (ZIP) lub jako jeden plik *.ogg; pliki TAF z jednym utworem są pobierane jako jeden plik *.ogg.", + "title": "Pobierz plik" + }, + "duplicateTAPFile": { + "text": "Tworzy duplikat wybranego pliku TAP.", + "title": "Duplikuj plik TAP" + }, + "editTAFMetadata": { + "text": "Otwiera edytor modeli niestandardowych dla pliku TAF. Powiązany już model niestandardowy (ten sam identyfikator audio i hash) jest edytowany, w przeciwnym razie dla pliku tworzony jest nowy. Zmiany są zapisywane w tonies.custom.json.", + "title": "Edytuj metadane TAF" + }, + "editTAPPlaylist": { + "text": "Otwiera edytor playlist TeddyAudio do zarządzania plikami TAP.", + "title": "Edytuj playlistę TAP" + }, + "markAsListened": { + "listened": { + "text": "Plik jest obecnie oznaczony jako odsłuchany. Kliknij, aby oznaczyć go jako nieodsłuchany.", + "title": "Oznacz jako nieodsłuchane" + }, + "unlistened": { + "text": "Plik jest obecnie oznaczony jako nieodsłuchany. Kliknij, aby oznaczyć go jako odsłuchany.", + "title": "Oznacz jako odsłuchane" + } + }, + "migrateToAudioIdFolder": { + "text": "Przenosi plik TAF do folderu biblioteki 'by/audioId' i aktualizuje źródło w pliku JSON.", + "title": "Migruj plik TAF do biblioteki wg AudioId" + }, + "migrateToLibraryRoot": { + "text": "Przenosi plik TAF do folderu głównego biblioteki i aktualizuje źródło w odpowiednim pliku JSON.", + "title": "Migruj plik TAF do katalogu głównego biblioteki" + }, + "moveFile": { + "text": "Przenosi plik w nowe miejsce. Uwaga: jeśli ten plik jest przypisany do Tonie/Taga, odwołanie nie zostanie zaktualizowane.", + "title": "Przenieś plik" + }, + "playAudioFile": { + "text": "Otwiera odtwarzacz audio i rozpoczyna odtwarzanie pliku.", + "title": "Odtwórz plik audio" + }, + "renameFile": { + "text": "Zmienia nazwę pliku. Uwaga: jeśli ten plik jest przypisany do Tonie/Taga, odwołanie nie zostanie zaktualizowane automatycznie.", + "title": "Zmień nazwę pliku" + } + }, + "actionItemsMulti": { + "deleteSelectedFiles": { + "text": "Usuwa zaznaczone pliki lub foldery (tylko jeśli są puste). Uwaga: odwołania do usuniętych plików w Tonie/Tagach nie zostaną zaktualizowane.", + "title": "Usuń zaznaczone pliki/foldery" + }, + "encodeFilesToTAF": { + "text": "Dodaje obsługiwane pliki do kolejki kodowania. Możesz wybrać wiele plików, aby zakodować je do pliku TAF, który następnie można przypisać do Tonie/Taga.", + "title": "Koduj pliki do TAF" + }, + "moveSelectedFiles": { + "text": "Przenosi zaznaczone pliki w nowe miejsce. Uwaga: odwołania do tych plików w Tonie/Tagach nie zostaną zaktualizowane. Foldery zostaną pominięte.", + "title": "Przenieś zaznaczone pliki" + } + }, + "closeButton": "Zamknij", + "instructions": { + "fileActions": { + "title": "Akcje na plikach" + }, + "multiSelection": { + "text": "Aby wykonać akcje na wielu plikach, zaznacz ich pola wyboru. W nagłówku pojawią się nowe ikony akcji, umożliwiające przenoszenie, usuwanie lub kodowanie wielu plików:", + "title": "Akcje wieloelementowe" + }, + "openDirectory": { + "text": "Kliknij dwukrotnie katalog, aby go otworzyć. Katalogi mają ikonę folderu z przodu oraz jako rozmiar.", + "title": "Otwórz katalog" + }, + "openTAFViewer": { + "text": "Kliknij dwukrotnie plik TAF, aby zobaczyć jego nagłówek. Przeglądarka nagłówków TAF pokazuje szczegóły, takie jak AudioId, skrót SHA1 oraz rozmiar pliku. AudioId i skrót SHA1 są wymagane dla wpisów w tonies.custom.json.", + "title": "Otwórz przeglądarkę nagłówków TAF" + }, + "openTAPJSONViewer": { + "text": "Kliknij dwukrotnie plik TAP lub JSON, aby otworzyć przeglądarkę JSON.", + "title": "Otwórz przeglądarkę TAP/JSON" + }, + "viewTonieInfoModal": { + "text": "Kliknij na obraz Tonie/Taga w wierszu, aby otworzyć okno TonieInfo. Wyświetla ono szczegółowe informacje, w tym większy obraz i listę utworów (jeśli dostępna).", + "title": "Wyświetl okno TonieInfo" + } + }, + "showHelp": "Pomoc", + "title": "Pomoc" + }, + "hidden": "Ukryte", + "image": "Obraz", + "markAsListened": "Oznacz jako odsłuchane", + "maxSelectedRows": "Możesz wybrać maksymalnie {{maxSelectedRows}} plików", + "maxSelectedRowsWarning": "Wybrano zbyt wiele plików!", + "messages": { + "deleteFailed": "Usuwanie nie powiodło się", + "deleteFailedDetails": "Usunięcie pliku \"{{file}}\" nie powiodło się", + "deleteSuccessful": "Usunięto pomyślnie!", + "deleteSuccessfulDetails": "Plik \"{{file}}\" został pomyślnie usunięty!", + "deleting": "Usuwanie...", + "deletingDetails": "Usuwanie pliku \"{{file}}\"...", + "downloadFailed": "Pobieranie nie powiodło się", + "downloadFailedDetails": "Pobieranie pliku \"{{file}}\" nie powiodło się", + "errorFetchingDirContent": "Nie udało się wczytać zawartości katalogu", + "errorFetchingDirContentDetails": "Nie udało się wczytać zawartości katalogu \"{{path}}\": ", + "migrationFailed": "Migracja nie powiodła się", + "migrationFailedDetails": "Migracja {{ruid}} nie powiodła się: ", + "migrationOngoing": "Migracja w toku...", + "migrationOngoingDetails": "Migracja {{ruid}} w toku...", + "migrationSuccessful": "Migracja zakończona pomyślnie!", + "migrationSuccessfulDetails": "Migracja {{ruid}} zakończona pomyślnie!", + "moving": "Przenoszenie...", + "movingDetails": "Przenoszenie pliku \"{{file}}\"...", + "movingFailed": "Przenoszenie nie powiodło się", + "movingFailedDetails": "Przeniesienie pliku \"{{fileSource}}\" do \"{{fileTarget}}\" nie powiodło się: ", + "movingSuccessful": "Plik przeniesiony pomyślnie!", + "movingSuccessfulDetails": "Plik \"{{fileSource}}\" został pomyślnie przeniesiony do \"{{fileTarget}}\"!", + "noRowsSelected": "Nie zaznaczono żadnych wierszy", + "noRowsSelectedForDeletion": "Nie zaznaczono żadnych wierszy do usunięcia", + "noRowsSelectedForMoving": "Nie zaznaczono żadnych wierszy do przeniesienia", + "renaming": "Zmienianie nazwy...", + "renamingDetails": "Zmienianie nazwy pliku \"{{file}}\"...", + "renamingFailed": "Zmiana nazwy nie powiodła się", + "renamingFailedDetails": "Zmiana nazwy pliku \"{{fileSource}}\" na \"{{fileTarget}}\" nie powiodła się: ", + "renamingSuccessful": "Nazwa pliku została zmieniona pomyślnie!", + "renamingSuccessfulDetails": "Nazwa pliku \"{{fileSource}}\" została pomyślnie zmieniona na \"{{fileTarget}}\"!", + "toggleListenedFailed": "Nie udało się zmienić stanu odsłuchania", + "toggleListenedFailedDetails": "Nie udało się zmienić stanu odsłuchania pliku \"{{file}}\": " + }, + "migrateContentToLib": "Migruj TAF do biblioteki (by/audioId/)", + "migrateContentToLibRoot": "Migruj TAF do biblioteki (katalog główny)", + "model": "Model", + "move": "Przenieś", + "moveFile": { + "attention": "Przenoszenie plików nie aktualizuje powiązania z Tonies/Tagami. Musisz ręcznie dostosować wszystkie Tonies/Tagi, które mają przenoszone pliki ustawione jako źródło!", + "cancel": "Anuluj", + "destinationPlaceholder": "Wybierz folder docelowy", + "modalTitle": "Przenieś plik", + "modalTitleMultiple": "Przenieś zaznaczone pliki", + "move": "Przenieś", + "moveTo": "do" + }, + "moveMultiple": "Przenieś {{selectedRowCount}} zaznaczonych plików (tylko pliki, foldery są pomijane!)", + "name": "Nazwa", + "playFile": "Odtwórz", + "rename": "Zmień nazwę", + "renameFile": { + "attention": "Zmiana nazwy pliku nie aktualizuje powiązania z Tonies/Tagami. Musisz ręcznie dostosować wszystkie Tonies/Tagi, które mają zmieniany plik ustawiony jako źródło!", + "cancel": "Anuluj", + "modalTitle": "Zmień nazwę pliku", + "rename": "Zmień nazwę" + }, + "root": "Katalog główny", + "selectAllowedFileTypesOnly": "Możesz wybierać tylko następujące typy plików: {{fileTypes}}", + "series": "Seria", + "size": "Rozmiar", + "tafMeta": { + "edit": "Edytuj metadane (model niestandardowy)" + }, + "tap": { + "edit": "Edytuj playlistę TeddyAudio", + "messages": { + "savedSuccessfully": "TAP zapisany pomyślnie!", + "savedSuccessfullyDetails": "TAP \"{{file}}\" zapisany pomyślnie w \"{{path}}\"!", + "savingFailed": "Zapis TAP nie powiódł się!", + "savingFailedDetails": "Zapis TAP \"{{file}}\" w \"{{path}}\" nie powiódł się:" + } + }, + "title": "Przeglądarka plików", + "unmarkAsListened": "Oznacz jako nieodsłuchane", + "unusedTafsModal": { + "actions": "Akcje", + "fullPath": "Pełna ścieżka", + "name": "Nazwa pliku TAF", + "noUnusedTafs": "Nie znaleziono nieużywanych plików TAF!", + "ok": "OK", + "title": "Nieużywane pliki TAF" + }, + "upload": { + "cancel": "Anuluj", + "hideUploadFilesDragNDrop": "Ukryj przesyłanie plików", + "modalTitle": "Prześlij plik(i)", + "showUploadFilesDragNDrop": "Przesyłanie plików", + "upload": "Prześlij", + "uploadedFile": "Plik przesłany!", + "uploadedFileFailed": "Przesyłanie pliku nie powiodło się!", + "uploadFailed": "Nie udało się przesłać niektórych plików. Spróbuj ponownie.", + "uploadFailedForFile": "Przesyłanie \"{{file}}\" nie powiodło się.", + "uploadHint": "Kliknij lub przeciągnij pliki do obszaru przesyłania / na listę plików, aby je przesłać", + "uploading": "Przesyłanie...", + "uploadInProgress": "Przesyłanie \"{{file}}\"...", + "uploadSuccessful": "Pliki przesłane pomyślnie.", + "uploadSuccessfulDetails": "Wszystkie pliki zostały pomyślnie przesłane.", + "uploadSuccessfulForFile": "Plik \"{{file}}\" przesłany pomyślnie.", + "uploadText": "Kliknij lub przeciągnij pliki do tego obszaru, aby je przesłać", + "uploadTimeout": "Przekroczono limit czasu przesyłania po {{ms}}ms" + } + }, + "footer": { + "sponsor": "Zostań sponsorem!", + "sponsorText": "Podoba Ci się TeddyCloud?" + }, + "home": { + "build": "Kompilacja", + "error": "Błąd - GUI nie znajduje się w oczekiwanym stanie.", + "errorConfirm": "Zamknij", + "errorWebVersionMismatch": "Backend ma inną wersję niż frontend. W przypadku instalacji Docker upewnij się, że nie utworzono żadnego wolumenu pod data/www.", + "features": { + "currentlyImplementedFeatures": "Obecnie zaimplementowane funkcje", + "description": "TeddyCloud to alternatywny serwer dla Twojego Tonieboxa, umożliwiający hostowanie usług chmurowych lokalnie. Dzięki temu masz kontrolę nad tym, jakie dane są wysyłane do chmury oryginalnego producenta, oraz możesz hostować własne pliki audio figurek np. na swoim NAS-ie lub innym serwerze.", + "features": { + "01contentProvision": { + "items": { + "01provideAudioContent": "udostępnianie zawartości audio przez sieć", + "02passtroughAudio": "przekazywanie oryginalnej zawartości audio tonie", + "03provideCustomAudio": "udostępnianie własnej zawartości audio", + "04simulateLiveContent": "symulowanie zawartości na żywo", + "05convertAudio": "konwersja strumieni audio w locie za pomocą ffmpeg dla webradia i strumieni", + "06definePlaylists": "definiowanie playlist TeddyAudio (PoC w WebFrontend)", + "07cacheAudio": "buforowanie oryginalnej zawartości audio tonie", + "08filterTags": "filtrowanie własnych tagów, aby zapobiec ich usunięciu" + }, + "title": "Udostępnianie zawartości dla Twojego Tonieboxa" + }, + "02webUI": "WebUI", + "03manageTonies": { + "items": { + "01assignContent": "przypisywanie zawartości i innych właściwości do każdego tagu", + "02searchStreams": "wyszukiwanie i przypisywanie strumieni webradia do każdego tagu", + "03defineMetaData": "definiowanie metadanych własnych tagów (edytor niestandardowych modeli w WebFrontend)", + "04filterTonies": "filtrowanie Tonies/Tagów", + "05hideTonies": "ukrywanie niedostępnych Tonies/Tagów", + "06showLastPlayed": "wyświetlanie ostatnio odtwarzanych Tonies/Tagów" + }, + "title": "Zarządzaj wszystkimi swoimi Tonies/Tagami" + }, + "04manageLibrary": "Zarządzaj swoją biblioteką zawartości", + "05managePlaylists": "Zarządzaj swoją biblioteką playlist TeddyAudio (PoC w WebFrontend)", + "06convertAudioFile": "Konwertuj dowolny plik audio na plik audio tonie", + "07manageTonieboxes": { + "items": { + "01singleBoxManagement": "zarządzanie pojedynczą boxą, wszystkimi na raz lub grupowanie boxów wedle uznania", + "02cloudAccess": "wyłączanie/włączanie dostępu do chmury i innych właściwości związanych z chmurą", + "03maxVolume": "konfiguracja maksymalnej głośności głośnika i słuchawek", + "04configureLED": "konfiguracja diody LED", + "05configureSlapping": "konfiguracja funkcji \"slapping\"", + "06customizeSounds": "dostosowywanie oryginalnych dźwięków boxy (np. jingla) przez sieć" + }, + "title": "Zarządzaj do 16 tonieboxami" + }, + "08esp32Flashing": { + "items": { + "01certificates": "wyodrębnianie/wstrzykiwanie certyfikatów w zrzucie firmware ESP32", + "02newServerHost": "ustawianie nowego adresu serwera", + "03wifiCredentials": "ustawianie danych logowania Wi-Fi", + "04extractCertificates": "wyodrębnianie certyfikatów i zapisywanie ich w TeddyCloud", + "05guidelineLegacyMethod": "przewodnik flashowania metodą klasyczną (bez GUI, tylko wiersz poleceń)" + }, + "title": "Flashowanie boxy ESP32" + }, + "09CC3200Flashing": { + "items": { + "01Guideline": "przewodnik flashowania", + "02AltUrlCustomPatch": "generowanie formularza łatki AltUrl.custom.305" + }, + "title": "Flashowanie boxy CC3200" + }, + "10CC3235Flashing": { + "items": { + "01Guideline": "szczegółowy przewodnik procesu flashowania" + }, + "title": "Flashowanie boxy CC3235" + }, + "11decodeLogs": "Dekodowanie logów RTNL", + "12mqttClient": { + "items": { + "01homeAssistantIntegration": "integracja z Home Assistant (MQTT)" + }, + "title": "Klient MQTT" + }, + "13TeddyStudio": { + "items": { + "01Description": "Twórz i drukuj własne etykiety dla swoich niestandardowych tagów.", + "02Options": "Dostosuj format etykiety, rozmiar, odstępy i wiele dodatkowych opcji.", + "03Paperpresets": "Korzystaj z istniejących presetów papieru do etykiet lub udostępniaj nowe na forum lub Telegramie." + }, + "title": "TeddyStudio" + }, + "14plugins": { + "items": { + "01pluginFeature": "rozszerzanie funkcjonalności TeddyCloud za pomocą własnych wtyczek" + }, + "title": "Wtyczki TeddyCloud" + }, + "15webLogin": { + "items": { + "01optionalLogin": "opcjonalne logowanie do interfejsu WWW z obsługą wielu użytkowników (domyślnie wyłączone)", + "02rateLimit": "sesja cookie z ograniczeniem liczby prób po wielokrotnych nieudanych logowaniach", + "03userLimit": "do 32 użytkowników" + }, + "title": "Logowanie do interfejsu WWW" + } + }, + "futureFeatures": { + "01firmwareUpdate": "Aktualizacja firmware przez TeddyCloud", + "03reportUnknownTonies": "Zgłaszanie nieznanych Tonies", + "04completeTAPIntegration": "Dokończenie integracji TAP z WebFrontend", + "05moreStats": "Więcej statystyk o odtwarzanych Tonies/Tagach", + "06securityImprovements": "Ulepszenia bezpieczeństwa, takie jak kontrola dostępu (tonieboxy i użytkownicy)" + }, + "navigationTitle": "Funkcje", + "title": "Funkcje TeddyCloud", + "yetToCome": "Jeszcze nadejdzie" + }, + "forumIntroPart1": "Odkryj więcej informacji, nawiąż kontakt z osobami o podobnych zainteresowaniach i bądź na bieżąco z najnowszymi dyskusjami na naszym forum: ", + "forumIntroPart2": ". Zgłębiaj tematy, dziel się swoimi doświadczeniami i łącz się z naszą społecznością. Dołącz do rozmowy już dziś!", + "helpfulLinks": "Przydatne linki", + "intro": "TeddyCloud to alternatywny serwer dla Twojego Tonieboxa, umożliwiający hostowanie usług chmurowych lokalnie. Dzięki temu masz kontrolę nad tym, jakie dane są wysyłane do chmury oryginalnego producenta, oraz możesz hostować własne pliki audio figurek.", + "navigationTitle": "Strona główna", + "sponsor": { + "navigationTitle": "Sponsor" + }, + "stats": { + "claimed": "Przypisane", + "claimedTitle": "Status przypisania", + "cloud_blocked": "Zablokowane żądania do chmury", + "cloud_failed": "Nieudane żądania do chmury", + "cloud_requests": "Wykonane żądania do chmury", + "cloudDisabled": "Chmura wyłączona", + "cloudEnabled": "Chmura włączona", + "cloudTitle": "Status chmury", + "connections": "Połączenia nawiązane z tym serwerem", + "languageTitle": "Języki", + "live": "Na żywo", + "liveTitle": "Status \"na żywo\"", + "navigationTitle": "Statystyki", + "notLive": "Nie na żywo", + "reverse_requests": "Wywołania reverse proxy wykonane przez klientów", + "serverStats": "Statystyki serwera", + "title": "Statystyki", + "toniesStats": "Statystyki Tonies", + "totalTonieBoxes": "Łączna liczba Tonieboxów", + "totalTonies": "Łączna liczba Tonies", + "unclaimed": "Nieprzypisane" + }, + "title": "Witaj w TeddyCloud!", + "toAllYourTonieboxes": "Do wszystkich Tonieboxów", + "toAllYourTonies": "Do wszystkich Tonies", + "tonieMeeting": { + "navigationTitle": "Tonie Meeting" + }, + "yourTonieboxes": "Twoje Tonieboxy", + "yourTonies": "Twoje Tonies" + }, + "inputValidator": { + "invalidCharactersDetected": "Twoje dane zawierają niedozwolone znaki, np. {{invalidChar}}" + }, + "language": { + "change": "Zmień język" + }, + "languageUtil": { + "af-af": "Afrikaans (Republika Południowej Afryki)", + "am-et": "Amharski (Etiopia)", + "ar-ae": "Arabski (Zjednoczone Emiraty Arabskie)", + "ar-bh": "Arabski (Bahrajn)", + "ar-dz": "Arabski (Algieria)", + "ar-eg": "Arabski (Egipt)", + "ar-eh": "Arabski (Sahara Zachodnia)", + "ar-iq": "Arabski (Irak)", + "ar-jo": "Arabski (Jordania)", + "ar-km": "Arabski (Komory)", + "ar-kw": "Arabski (Kuwejt)", + "ar-lb": "Arabski (Liban)", + "ar-ly": "Arabski (Libia)", + "ar-ma": "Arabski (Maroko)", + "ar-mr": "Arabski (Mauretania)", + "ar-om": "Arabski (Oman)", + "ar-ps": "Arabski (Palestyna)", + "ar-qa": "Arabski (Katar)", + "ar-sa": "Arabski (Arabia Saudyjska)", + "ar-sd": "Arabski (Sudan)", + "ar-sy": "Arabski (Syria)", + "ar-tn": "Arabski (Tunezja)", + "ar-ye": "Arabski (Jemen)", + "az-az": "Azerski (Azerbejdżan)", + "be-by": "Białoruski (Białoruś)", + "bg-bg": "Bułgarski (Bułgaria)", + "bm-ml": "Bambara (Mali)", + "bn-bd": "Bengalski (Bangladesz)", + "bs-ba": "Bośniacki (Bośnia i Hercegowina)", + "ca-ad": "Kataloński (Andora)", + "cs-cz": "Czeski (Czechy)", + "da-dk": "Duński (Dania)", + "de-at": "Niemiecki (Austria)", + "de-ch": "Niemiecki (Szwajcaria)", + "de-de": "Niemiecki (Niemcy)", + "de-li": "Niemiecki (Liechtenstein)", + "dv-mv": "Malediwski (Malediwy)", + "dz-bt": "Dzongka (Bhutan)", + "el-cy": "Grecki (Cypr)", + "el-gr": "Grecki (Grecja)", + "en-ag": "Angielski (Antigua i Barbuda)", + "en-ai": "Angielski (Anguilla)", + "en-as": "Angielski (Samoa Amerykańskie)", + "en-au": "Angielski (Australia)", + "en-bb": "Angielski (Barbados)", + "en-bi": "Angielski (Burundi)", + "en-bm": "Angielski (Bermudy)", + "en-bs": "Angielski (Bahamy)", + "en-bw": "Angielski (Botswana)", + "en-bz": "Angielski (Belize)", + "en-ca": "Angielski (Kanada)", + "en-cc": "Angielski (Wyspy Kokosowe (Keelinga))", + "en-ck": "Angielski (Wyspy Cooka)", + "en-cx": "Angielski (Wyspa Bożego Narodzenia)", + "en-dm": "Angielski (Dominika)", + "en-fj": "Angielski (Fidżi)", + "en-fk": "Angielski (Falklandy)", + "en-fm": "Angielski (Mikronezja)", + "en-gb": "Angielski (Wielka Brytania)", + "en-gd": "Angielski (Grenada)", + "en-gg": "Angielski (Guernsey)", + "en-gh": "Angielski (Ghana)", + "en-gi": "Angielski (Gibraltar)", + "en-gm": "Angielski (Gambia)", + "en-gs": "Angielski (Georgia Południowa i Sandwich Południowy)", + "en-gu": "Angielski (Guam)", + "en-gy": "Angielski (Gujana)", + "en-hm": "Angielski (Wyspy Heard i McDonalda)", + "en-ie": "Angielski (Irlandia)", + "en-im": "Angielski (Wyspa Man)", + "en-io": "Angielski (Brytyjskie Terytorium Oceanu Indyjskiego)", + "en-je": "Angielski (Jersey)", + "en-jm": "Angielski (Jamajka)", + "en-ki": "Angielski (Kiribati)", + "en-kn": "Angielski (Saint Kitts i Nevis)", + "en-ky": "Angielski (Kajmany)", + "en-lc": "Angielski (Saint Lucia)", + "en-lr": "Angielski (Liberia)", + "en-ls": "Angielski (Lesotho)", + "en-mh": "Angielski (Wyspy Marshalla)", + "en-mp": "Angielski (Mariany Północne)", + "en-ms": "Angielski (Montserrat)", + "en-mu": "Angielski (Mauritius)", + "en-mw": "Angielski (Malawi)", + "en-na": "Angielski (Namibia)", + "en-nf": "Angielski (Norfolk)", + "en-ng": "Angielski (Nigeria)", + "en-nr": "Angielski (Nauru)", + "en-nu": "Angielski (Niue)", + "en-nz": "Angielski (Nowa Zelandia)", + "en-pg": "Angielski (Papua-Nowa Gwinea)", + "en-pn": "Angielski (Pitcairn)", + "en-pw": "Angielski (Palau)", + "en-sb": "Angielski (Wyspy Salomona)", + "en-sc": "Angielski (Seszele)", + "en-sg": "Angielski (Singapur)", + "en-sh": "Angielski (Wyspa Świętej Heleny)", + "en-sl": "Angielski (Sierra Leone)", + "en-ss": "Angielski (Sudan Południowy)", + "en-sz": "Angielski (Eswatini)", + "en-ta": "Angielski (Tristan da Cunha)", + "en-tc": "Angielski (Turks i Caicos)", + "en-tk": "Angielski (Tokelau)", + "en-to": "Angielski (Tonga)", + "en-tt": "Angielski (Trynidad i Tobago)", + "en-tv": "Angielski (Tuvalu)", + "en-ug": "Angielski (Uganda)", + "en-um": "Angielski (Dalekie Wyspy Mniejsze Stanów Zjednoczonych)", + "en-us": "Angielski (Stany Zjednoczone)", + "en-vc": "Angielski (Saint Vincent i Grenadyny)", + "en-vg": "Angielski (Brytyjskie Wyspy Dziewicze)", + "en-vi": "Angielski (Wyspy Dziewicze Stanów Zjednoczonych)", + "en-vu": "Angielski (Vanuatu)", + "en-ws": "Angielski (Samoa)", + "en-za": "Angielski (Republika Południowej Afryki)", + "en-zm": "Angielski (Zambia)", + "en-zw": "Angielski (Zimbabwe)", + "es-ar": "Hiszpański (Argentyna)", + "es-bo": "Hiszpański (Boliwia)", + "es-cl": "Hiszpański (Chile)", + "es-co": "Hiszpański (Kolumbia)", + "es-cr": "Hiszpański (Kostaryka)", + "es-cu": "Hiszpański (Kuba)", + "es-do": "Hiszpański (Dominikana)", + "es-ec": "Hiszpański (Ekwador)", + "es-es": "Hiszpański (Hiszpania)", + "es-gq": "Hiszpański (Gwinea Równikowa)", + "es-gt": "Hiszpański (Gwatemala)", + "es-hn": "Hiszpański (Honduras)", + "es-ic": "Hiszpański (Wyspy Kanaryjskie)", + "es-mx": "Hiszpański (Meksyk)", + "es-ni": "Hiszpański (Nikaragua)", + "es-pa": "Hiszpański (Panama)", + "es-pe": "Hiszpański (Peru)", + "es-pr": "Hiszpański (Portoryko)", + "es-py": "Hiszpański (Paragwaj)", + "es-sv": "Hiszpański (Salwador)", + "es-uy": "Hiszpański (Urugwaj)", + "es-ve": "Hiszpański (Wenezuela)", + "et-ee": "Estoński (Estonia)", + "fa-ir": "Perski (Iran)", + "fi-fi": "Fiński (Finlandia)", + "fo-fo": "Farerski (Wyspy Owcze)", + "fr-bf": "Francuski (Burkina Faso)", + "fr-bj": "Francuski (Benin)", + "fr-bl": "Francuski (Saint-Barthélemy)", + "fr-cd": "Francuski (Demokratyczna Republika Konga)", + "fr-cf": "Francuski (Republika Środkowoafrykańska)", + "fr-cg": "Francuski (Kongo)", + "fr-ci": "Francuski (Wybrzeże Kości Słoniowej)", + "fr-cm": "Francuski (Kamerun)", + "fr-dj": "Francuski (Dżibuti)", + "fr-fr": "Francuski (Francja)", + "fr-ga": "Francuski (Gabon)", + "fr-gf": "Francuski (Gujana Francuska)", + "fr-gn": "Francuski (Gwinea)", + "fr-gp": "Francuski (Gwadelupa)", + "fr-ht": "Francuski (Haiti)", + "fr-lu": "Francuski (Luksemburg)", + "fr-mc": "Francuski (Monako)", + "fr-mf": "Francuski (Saint-Martin)", + "fr-mq": "Francuski (Martynika)", + "fr-nc": "Francuski (Nowa Kaledonia)", + "fr-ne": "Francuski (Niger)", + "fr-pf": "Francuski (Polinezja Francuska)", + "fr-pm": "Francuski (Saint-Pierre i Miquelon)", + "fr-re": "Francuski (Reunion)", + "fr-sn": "Francuski (Senegal)", + "fr-td": "Francuski (Czad)", + "fr-tf": "Francuski (Francuskie Terytoria Południowe i Antarktyczne)", + "fr-tg": "Francuski (Togo)", + "fr-wf": "Francuski (Wallis i Futuna)", + "fr-yt": "Francuski (Majotta)", + "he-il": "Hebrajski (Izrael)", + "hi-in": "Hindi (Indie)", + "hr-hr": "Chorwacki (Chorwacja)", + "hu-hu": "Węgierski (Węgry)", + "hy-am": "Ormiański (Armenia)", + "id-id": "Indonezyjski (Indonezja)", + "is-is": "Islandzki (Islandia)", + "it-it": "Włoski (Włochy)", + "it-sm": "Włoski (San Marino)", + "it-va": "Włoski (Watykan)", + "ja-jp": "Japoński (Japonia)", + "ka-ge": "Gruziński (Gruzja)", + "kk-kz": "Kazachski (Kazachstan)", + "kl-gl": "Grenlandzki (Grenlandia)", + "km-kh": "Khmerski (Kambodża)", + "ko-kp": "Koreański (Korea Północna)", + "ko-kr": "Koreański (Korea Południowa)", + "ky-kg": "Kirgiski (Kirgistan)", + "lo-la": "Laotański (Laos)", + "lt-lt": "Litewski (Litwa)", + "lv-lv": "Łotewski (Łotwa)", + "mg-mg": "Malgaski (Madagaskar)", + "mk-mk": "Macedoński (Macedonia Północna)", + "mn-mn": "Mongolski (Mongolia)", + "ms-bn": "Malajski (Brunei)", + "ms-my": "Malajski (Malezja)", + "mt-mt": "Maltański (Malta)", + "my-mm": "Birmański (Mjanma)", + "ne-np": "Nepalski (Nepal)", + "nl-aw": "Niderlandzki (Aruba)", + "nl-be": "Niderlandzki (Belgia)", + "nl-bq": "Niderlandzki (Karaiby Holenderskie)", + "nl-cw": "Niderlandzki (Curaçao)", + "nl-nl": "Niderlandzki (Holandia)", + "nl-sr": "Niderlandzki (Surinam)", + "nl-sx": "Niderlandzki (Sint Maarten)", + "no-bv": "Norweski (Wyspa Bouveta)", + "no-no": "Norweski (Norwegia)", + "no-sj": "Norweski (Svalbard i Jan Mayen)", + "other": "Inne", + "pl-pl": "Polski (Polska)", + "pt-ao": "Portugalski (Angola)", + "pt-br": "Portugalski (Brazylia)", + "pt-cv": "Portugalski (Republika Zielonego Przylądka)", + "pt-gw": "Portugalski (Gwinea Bissau)", + "pt-mz": "Portugalski (Mozambik)", + "pt-pt": "Portugalski (Portugalia)", + "pt-st": "Portugalski (Wyspy Świętego Tomasza i Książęca)", + "ro-md": "Rumuński (Mołdawia)", + "ro-ro": "Rumuński (Rumunia)", + "ru-ru": "Rosyjski (Rosja)", + "rw-kg": "Kinyarwanda (Rwanda)", + "si-lk": "Syngaleski (Sri Lanka)", + "sk-sk": "Słowacki (Słowacja)", + "sl-si": "Słoweński (Słowenia)", + "so-so": "Somalijski (Somalia)", + "sq-al": "Albański (Albania)", + "sq-xk": "Albański (Kosowo)", + "sr-me": "Serbski (Czarnogóra)", + "sr-rs": "Serbski (Serbia)", + "sv-ax": "Szwedzki (Wyspy Alandzkie)", + "sv-se": "Szwedzki (Szwecja)", + "sw-ke": "Suahili (Kenia)", + "sw-tz": "Suahili (Tanzania)", + "tet-tl": "Tetum (Timor Wschodni)", + "tg-tj": "Tadżycki (Tadżykistan)", + "th-th": "Tajski (Tajlandia)", + "ti-er": "Tigrinia (Erytrea)", + "tk-tm": "Turkmeński (Turkmenistan)", + "tl-ph": "Tagalski (Filipiny)", + "tr-tr": "Turecki (Turcja)", + "uk-ua": "Ukraiński (Ukraina)", + "undefined": "Inny/nieokreślony", + "unknownLanguageCode": "Nieznany kod języka: ", + "ur-pk": "Urdu (Pakistan)", + "uz-uz": "Uzbecki (Uzbekistan)", + "vi-vn": "Wietnamski (Wietnam)", + "xx-aq": "Różne języki (Antarktyda)", + "xx-eu": "Różne języki (Unia Europejska)", + "zh-cn": "Chiński (Chiny)", + "zh-hk": "Chiński (Hongkong)", + "zh-mo": "Chiński (Makau)", + "zh-tw": "Chiński (Tajwan)" + }, + "radioStreamSearch": { + "failedToFetchSearchResults": "Nie udało się pobrać wyników wyszukiwania stacji radiowych", + "failedToFetchSearchResultsDetails": "Nie udało się pobrać wyników wyszukiwania stacji radiowych: ", + "noResults": "Nie znaleziono wyników wyszukiwania.", + "searchLabel": "[Opcjonalnie] Wyszukaj stację radiową" + }, + "security": { + "alert": "Alarm", + "incident_detected": "Wykryto incydent bezpieczeństwa! Sprawdź swoje logi!" + }, + "server": { + "boxineDisabled": "Boxine/Toniecloud jest obecnie wyłączone. Włącz ustawienie 'Cloud enabled', aby ponownie połączyć się z Boxine/Toniecloud.", + "boxineStatusOffline": "Brak połączenia z Boxine/Toniecloud", + "boxineStatusOnline": "Połączono z Boxine/Toniecloud", + "teddycloudStatusOffline": "Serwer Teddycloud offline", + "teddycloudStatusOnline": "Serwer Teddycloud działa prawidłowo" + }, + "settings": { + "certificates": { + "hint": "Są to certyfikaty używane do ogólnego korzystania z TeddyCloud. Dla Twoich Tonieboxów mogą być używane certyfikaty specyficzne. Aby je zmienić, przejdź do ", + "information": "Informacja", + "invalidFileType": "Nieprawidłowy typ pliku! Dozwolone są tylko pliki .der!", + "navigationTitle": "Certyfikaty", + "title": "Certyfikaty", + "uploadFailed": "Przesyłanie certyfikatu nie powiodło się!", + "uploadFailedDetails": "Przesyłanie certyfikatu {{filename}} nie powiodło się", + "uploadHint": "Przeciągnij i upuść tutaj certyfikaty klienta (*.der) z Twojej boxy", + "uploadSuccessful": "Certyfikat przesłany pomyślnie.", + "uploadSuccessfulDetails": "Certyfikat {{filename}} przesłany pomyślnie.", + "uploadText": "Kliknij lub przeciągnij certyfikaty klienta do tego obszaru, aby je przesłać" + }, + "close": "Zamknij", + "discard": "Odrzuć zmiany", + "errorFetchingFieldValue": "Błąd podczas pobierania wartości pola", + "errorFetchingFieldValueDetails": "Błąd podczas pobierania wartości pola dla \"{{setting}}\"{{overlay}}:", + "errorFetchingSetting": "Błąd podczas pobierania ustawienia!", + "errorFetchingSettingDetails": "Błąd podczas pobierania ustawienia \"{{setting}}\": ", + "errorSettingSettingsLevel": "Błąd podczas ustawiania poziomu ustawień", + "errorSettingSettingsLevelDetails": "Nie udało się ustawić nowego poziomu ustawień:", + "errorWhileSavingConfig": "Zapisywanie konfiguracji do pliku nie powiodło się", + "errorWhileSavingConfigDetails": "Błąd podczas zapisywania konfiguracji do pliku: ", + "general": { + "navigationTitle": "Ogólne", + "title": "Ustawienia ogólne" + }, + "guiSettings": { + "exportSettings": "Eksportuj", + "importSettings": "Importuj", + "jsonLoaded": "Pamięć lokalna wczytana", + "jsonLoadedDetails": "JSON pamięci lokalnej został pomyślnie wczytany!", + "jsonLoadFailed": "Nie udało się wczytać pamięci lokalnej", + "jsonLoadFailedDetails": "Wystąpił błąd podczas wczytywania JSON pamięci lokalnej: ", + "navigationTitle": "Ustawienia GUI", + "noLocalSettings": "Nie znaleziono lokalnie zapisanych wartości", + "title": "Ustawienia GUI i lokalnie zapisane wartości" + }, + "hint": "To są ustawienia domyślne. Niektóre z nich mogą zostać nadpisane przez każdy z Twoich Tonieboxów. Aby zmienić te ustawienia, przejdź do ", + "information": "Informacja", + "keepScrolling": "Przewiń dalej, aby zobaczyć więcej opcji.", + "levelLabel": "Settings level", + "navigationTitle": "Ustawienia", + "notifications": { + "clickToConfirm": "Kliknij, aby potwierdzić", + "colContext": "Kontekst", + "colDate": "Data", + "colDetails": "Szczegóły/Opis", + "colStatus": "Status", + "colTitle": "Tytuł", + "colType": "Typ", + "confirmed": "potwierdzone", + "confirmSelectedNotifications": "Potwierdź zaznaczone powiadomienia", + "endDate": "Data końcowa", + "error": "Błąd", + "filterBy": "Filtruj według", + "info": "Informacja", + "navigationTitle": "Powiadomienia", + "pageSelector": "powiadomień/stronę", + "removeAll": "Usuń wszystkie", + "removeSelectedNotifications": "Usuń zaznaczone powiadomienia", + "searchIn": "Szukaj w", + "startDate": "Data początkowa", + "success": "Sukces", + "title": "Powiadomienia", + "unconfirmed": "niepotwierdzone", + "warning": "Ostrzeżenie" + }, + "overlayDisabled": "Nadpisanie wyłączone!", + "overlayed": "nadpisane", + "overlayEnabled": "Nadpisanie włączone!", + "resetToTCDetails": "Ustawienie \"{{setting}}\"{{overlay}} zostało pomyślnie przywrócone do wartości TeddyCloud.", + "restartComplete": "Ponowne uruchomienie serwera TeddyCloud powiodło się!", + "restartFailed": "Ponowne uruchomienie serwera TeddyCloud nie powiodło się!", + "restartInProgress": "TeddyCloud uruchamia się ponownie...", + "restartServer": "Uruchom serwer ponownie", + "restartTC": "Ponowne uruchamianie TeddyCloud", + "rtnl": { + "clear": "Wyczyść log RTNL", + "navigationTitle": "Log RTNL", + "title": "Log RTNL" + }, + "save": "Zapisz", + "saved": "Ustawienie zapisane!", + "saveDetails": "Ustawienie \"{{setting}}\"{{overlay}} zostało pomyślnie zapisane.", + "title": "Ustawienia", + "tonieboxes": "tonieboxy", + "toniesJsonUpdate": "Wczytaj ponownie Tonies.json", + "toniesJsonUpdateFailed": "Nie udało się ponownie wczytać Tonies(.custom).json!", + "toniesJsonUpdateInProgress": "Wczytywanie Tonies(.custom).json...", + "toniesJsonUpdateSuccessful": "Tonies(.custom).json wczytany ponownie pomyślnie!", + "tryToRestartTC": "Spróbuj ponownie uruchomić TeddyCloud", + "warning": "Uwaga! Settings level", + "warningHint": "Jeśli brakuje Ci ustawień, zwiększ 'Settings level'.", + "webAuth": { + "addUser": "Dodaj użytkownika", + "changePassword": "Zmień hasło", + "changePasswordFor": "Zmień hasło użytkownika {{username}}", + "createFirstUser": "Utwórz użytkownika przed włączeniem ochrony logowaniem.", + "deleteContent": "Usunąć użytkownika {{username}}?", + "deleteLastContent": "Usunąć ostatniego użytkownika {{username}}? Ochrona logowaniem zostanie wyłączona.", + "deleteLastHint": "Po utworzeniu użytkownika możesz ponownie włączyć ochronę logowaniem.", + "deleteLastTitle": "Usuń ostatniego użytkownika", + "deleteTitle": "Usuń użytkownika", + "deleteUser": "Usuń", + "enabled": "Wymagaj logowania do interfejsu WWW", + "envOverride": "Ochrona logowaniem jest obecnie wyłączona przez TEDDYCLOUD_WEB_AUTH_DISABLE.", + "intro": "Wszyscy użytkownicy mają pełny dostęp do interfejsu WWW. Interfejsy API Tonieboxa pozostają bez zmian.", + "loadFailed": "Nie udało się wczytać użytkowników", + "navigationTitle": "Logowanie do interfejsu WWW", + "passwordChanged": "Hasło zaktualizowane", + "passwordChangedDetails": "Hasło użytkownika {{username}} zostało zaktualizowane.", + "saveFailed": "Nie udało się zapisać zmian", + "title": "Logowanie do interfejsu WWW", + "users": "Użytkownicy" + } + }, + "teddycloud": { + "newVersionAvailable": "Dostępna jest nowa wersja!", + "newVersionDetailsAvailable": "Nie korzystasz z najnowszej wersji TeddyCloud. Zaktualizuj, aby uzyskać wszystkie korzyści najnowszego wydania!", + "newVersionDetailsAvailableDevelop": "Nie korzystasz z najnowszej wersji rozwojowej TeddyCloud. Zaktualizuj, aby uzyskać wszystkie korzyści najnowszej wersji rozwojowej!", + "newVersionDetailsAvailableReleaseCandidate": "Nie korzystasz z najnowszej wersji kandydującej do wydania TeddyCloud. Zaktualizuj ją, aby móc korzystać ze wszystkich zalet najnowszej wersji kandydującej!", + "newVersionShort": "Dostępna aktualizacja", + "openNewVersionInGithub": "Otwórz najnowszą wersję na GitHubie" + }, + "tonieboxes": { + "accessApiDisabled": "Dostęp do API TeddyCloud wyłączony.", + "accessApiEnabled": "Dostęp do API TeddyCloud włączony.", + "availableBoxModal": { + "availableBoxes": "Dostępne Toniebox(y) (tylko Tonieboxy {{boxVersion}})", + "boxVersion": "Wersja", + "cancel": "Anuluj", + "cc3200Hint": ", że uruchamiasz właściwy slot firmware, że wszystkie niezbędne łatki są ustawione", + "commonName": "Adres MAC Tonieboxa", + "newBoxAvailable": "Sprawdź, czy nowa boxa pojawia się w poniższej tabeli. Jeśli nie jest widoczna, coś mogło pójść nie tak. Upewnij się, że włączyłeś/aś ustawienie \"Add new boxes\", że boxa jest widoczna w Twojej sieci Wi-Fi, że został wgrany zastępczy CA{{cc3200Hint}} oraz że używany jest właściwy adres IP/URL dla TeddyCloud. Jeśli boxa nadal się nie pojawia, sprawdź tutaj:", + "ok": "OK", + "recheck": "Sprawdź ponownie", + "status": "Status", + "troubleShooting": "Rozwiązywanie problemów" + }, + "boxCertificate": { + "nextConnection": "Status zostanie zaktualizowany przy następnym połączeniu Toniebox.", + "pin": "Przypięty certyfikat (SHA-256)", + "presented": "Przedstawiony certyfikat (SHA-256)", + "status": "Status", + "statusText": { + "mismatch": "Inny certyfikat niż przypięty!", + "none": "Jeszcze nie sprawdzono", + "pinned": "Przypięty certyfikat", + "trusted": "Certyfikat wystawiony przez TeddyCloud" + }, + "title": "Sprawdzanie certyfikatu" + }, + "boxFlashingCommon": { + "adguard": { + "adGuardRequirement": "AdGuard: upewnij się, że AdGuard Home jest zainstalowany i skonfigurowany na urządzeniu w Twojej sieci.", + "clientExplanation": "Ogranicza tę regułę do żądań pochodzących konkretnie z adresu IP YYY.YYY.YYY.YYY (IP Tonieboxa).", + "dnsRewriteExplanation": "Określa, że odpowiedź DNS powinna być pomyślnym (NOERROR) rekordem A wskazującym na XXX.XXX.XXX.XXX (IP TeddyCloud).", + "explanationTitle": "Wyjaśnienie każdego elementu:", + "ipReservationInstructions": "Rezerwacje adresów IP: upewnij się, że zarówno Twoja instancja TeddyCloud, jak i Toniebox mają statyczne adresy IP w sieci, przypisując je ręcznie lub korzystając z rezerwacji DHCP w ustawieniach routera.", + "loggingMonitoringInstructions": "Logowanie i monitorowanie: w AdGuard Home włącz logowanie zapytań, aby monitorować i potwierdzać, że żądania Tonieboxa są prawidłowo rozwiązywane.", + "moreInformation": "Więcej informacji o AdGuard:", + "prerequisitesTitle": "Wymagania wstępne", + "prodDomainExplanation": "Pasuje do dowolnej subdomeny prod.de.tbs.toys.", + "setupInstructionsTitle": "Aby skonfigurować AdGuard jako serwer DNS z niestandardowymi regułami przepisywania DNS dla Twojej instancji TeddyCloud i Tonieboxa, wykonaj poniższe kroki:", + "step1Instructions": "Zaloguj się do panelu AdGuard Home. Przejdź do Settings > General Settings. W ustawieniach DNS włącz AdGuard jako główny resolver DNS, ustawiając go jako serwer DNS w Twojej sieci/routerze lub dla poszczególnych urządzeń.", + "step1Title": "1. Skonfiguruj AdGuard Home jako swój serwer DNS", + "step2Instructions": "W panelu AdGuard Home przejdź do Filters > Custom Filtering Rules. Dodaj poniższe niestandardowe reguły, aby przekierować zapytania DNS dla konkretnych domen na adres IP Twojego TeddyCloud, tylko gdy żądanie pochodzi od Tonieboxa.", + "step2Title": "2. Dodaj niestandardowe reguły przepisywania DNS", + "step3Instructions": "Uruchom ponownie usługę AdGuard Home, aby zastosować zmiany. Przetestuj przepisywanie DNS, aby potwierdzić, że żądania z Twojego Tonieboxa do prod.de.tbs.toys i rtnl.bxcl.de są rozwiązywane na adres IP Twojego TeddyCloud. Pozostałe urządzenia w sieci powinny rozwiązywać te domeny za pomocą zwykłego DNS (chyba że ustawiono inaczej).", + "step3Title": "3. Zweryfikuj i przetestuj konfigurację", + "step4Title": "4. Dodatkowe bezpieczeństwo i zarządzanie (opcjonalnie)", + "stepsTitle": "Kroki", + "teddyCloudIp": "IP TeddyCloud: uzyskaj statyczny adres IP swojej instancji TeddyCloud (zastąp XXX.XXX.XXX.XXX).", + "tonieboxIp": "IP Tonieboxa: znajdź statyczny adres IP swojego Tonieboxa (zastąp YYY.YYY.YYY.YYY).", + "usingAdGuard": "Korzystanie z AdGuard" + }, + "alternativeDNSSolutions": "Inne nieinwazyjne rozwiązania", + "certificates": "Certyfikaty", + "certificatesIntro": "Przy pierwszym uruchomieniu TeddyCloud wygeneruje CA i certyfikaty z datą początkową 2015-11-03. Zostaną one umieszczone w /certs/server/. Generuje to również zastępczy CA dla certyfikatu toniebox certs/server/ca.der.", + "connectTBToWifi": { + "preparationText": "Podłącz swojego Tonieboxa do sieci Wi-Fi i zaktualizuj jego firmware. Wiele boxów jest dostarczanych z firmware produkcyjnym, który wymaga aktualizacji. W przeciwnym razie boxa nie będzie działać prawidłowo. Nie jest konieczne łączenie boxa z aplikacją/kontem mytonies.", + "step1": "**Włącz** Tonieboxa i ściśnij oba uszy przez 5 sekund, aż dioda LED zaświeci się na niebiesko i usłyszysz dźwięk.", + "step2": "Poczekaj ~20 sekund, aż usłyszysz drugi dźwięk.", + "step3": "Na swoim urządzeniu przejdź do ustawień Wi-Fi i połącz się z siecią `Toniebox-####`.", + "step4": "Zignoruj ostrzeżenie 'Brak dostępu do internetu', jeśli się pojawi.", + "step5": "Otwórz przeglądarkę internetową i przejdź do `192.168.1.1`.", + "step6": "Wybierz swój **język** i kliknij _Ok_, aby kontynuować.", + "step7": "Wybierz swoją domową **sieć Wi-Fi 2.4GHz**, wpisz hasło i kliknij `Connect Toniebox`.", + "step8": "Poczekaj, aż dioda LED zaświeci się na stałe na zielono. Gdy tak się stanie, umieść Tonie na Tonieboxie, aby przetestować.", + "title": "Połącz Tonieboxa z Wi-Fi (bez asystenta konfiguracji i konta tonies)" + }, + "dns": "DNS", + "dnsBeware": "Uwaga!", + "dnsBewareText": "Spowoduje to odcięcie połączenia wszystkich tonieboxów w Twojej sieci, które nie zostały spatchowane Twoim zastępczym CA!", + "dnsHint": "Dostosowania DNS dla TeddyCloud", + "dnsText1": "Ustaw wpisy DNS dla prod.de.tbs.toys i rtnl.bxcl.de na adres IP serwera TeddyCloud.", + "dumpCertificates": "Zrzuć certyfikaty dla TeddyCloud", + "dumpCertificatesIntro1": "Będziesz potrzebować flash:/cert/ca.der (CA Boxine), flash:/cert/client.der (certyfikat klienta) i flash:/cert/private.der (klucz prywatny klienta).", + "dumpCertificatesIntro2": "Zachowaj kopię zapasową certyfikatów, zwłaszcza client.der i private.der. Bez nich nie będziesz mógł/mogła już połączyć się z chmurą!", + "installCC3200Tool": { + "gitText1": "Zainstaluj ", + "gitText2": ", w razie potrzeby dodaj do PATH", + "gitTextLink": "git", + "intro": "To narzędzie działa na Pythonie >=3.6 z aktualnym pySerial. Konieczne są więc dodatkowe przygotowania, aby zainstalować narzędzie:", + "link": "Repozytorium GitHub narzędzia", + "moreInformation": "Więcej informacji o narzędziu znajdziesz tutaj:", + "pipText1": "(Opcjonalnie) zainstaluj ", + "pipText2": ", jeśli nie było jeszcze dołączone do instalacji Pythona", + "pipTextLink": "pip", + "pythonText1": "Zainstaluj Pythona za pomocą ", + "pythonText2": ", zaznacz opcję dodania Pythona do PATH", + "pythonTextLink": "instalatora UI", + "text": "Teraz możesz zainstalować cc3200tool. W zależności od tego, czy dodałeś/aś git do PATH, użyj wiersza poleceń lub uruchom git cmd. Następnie wpisz", + "textAlternatively": "Jeśli to nie działa, spróbuj następującego: ", + "title": "Zainstaluj narzędzie CC3200" + }, + "openWrt": { + "dnsText2": "Alternatywnie możesz ustawić bramę dla tonieboxów na adres IP TeddyCloud. W OpenWRT działa to w następujący sposób:", + "usingOpenWrt": "Korzystanie z openWRT" + }, + "preparations": "Przygotowania" + }, + "boxSetup": { + "boxSetupIntro1": "Ta strona zawiera pełny przegląd kroków wymaganych do dodania Tonieboxa do TeddyCloud. W razie problemów zapraszamy na", + "boxSetupIntro2": "lub dołącz do", + "boxSetupIntro3": "po wsparcie społeczności.", + "boxSetupIntroForum": "forum", + "boxSetupIntroTelegram": "grupy Telegram", + "boxVersion": { + "cc3200con1": "To najstarsza dostępna wersja", + "cc3200pro1": "Flashowanie przez wiersz poleceń + adapter UART", + "cc3200pro2": "Obsługuje niestandardowy bootloader (HackieboxNG) i firmware (Hackiebox)", + "cc3200pro3": "Łatwe przełączanie między różnymi wersjami firmware (do 9 slotów, włącznie z oryginalnym firmware) po zainstalowaniu niestandardowego bootloadera", + "cc3200pro4": "Łatki umożliwiają zmianę IP/hostname TeddyCloud po zainstalowaniu niestandardowego bootloadera", + "cc3200pro5": "Dostępne dodatkowe łatki, w tym obsługa tagów NFC innych niż SLIX-L przy użyciu niestandardowego bootloadera", + "cc3235con1": "Flashowanie jest trudne i wymaga bezpośredniego dostępu do pamięci", + "cc3235con2": "Firmware jest podpisany i zaszyfrowany", + "cc3235con3": "Podmiana CA może być niepewna, często wymaga kilku prób", + "cons": "Wady", + "countryOfManufacture": "Kraj produkcji:", + "countryOfManufactureText": "Sprawdź spód boxa, aby zobaczyć, czy został wyprodukowany w Chinach czy na Węgrzech. Tonieboxy wyprodukowane w Chinach (od 01/2024) prawdopodobnie są oparte na ESP32.", + "emptyCons": "Brak istotnych wad", + "emptyPros": "Brak istotnych zalet", + "esp32con1": "Niestandardowy firmware jest w trakcie rozwoju, ale obecnie wstrzymany i w obecnym stanie niefunkcjonalny", + "esp32con2": "Zmiana IP/hostname TeddyCloud lub powrót do oryginalnego firmware wymaga ponownego flashowania", + "esp32pro1": "Najnowsza dostępna wersja", + "esp32pro2": "Najłatwiejsza metoda flashowania przez przeglądarkę + adapter UART", + "esp32pro3": "ESP32 wspierany przez dużą społeczność deweloperów", + "howToIdentify": "Jak zidentyfikować wersję Tonieboxa 1 bez otwierania obudowy", + "howToIdentifyIntro": "Ponieważ nie ma etykiety określającej dokładną wersję, identyfikacja wersji Tonieboxa wymaga kilku drobnych wskazówek:", + "howToIdentifyOutro": "Więcej szczegółów znajdziesz ", + "intro": "Obecnie istnieją cztery znane wersje Tonieboxa 1, które różnią się głównie użytym MCU (mikrokontrolerem), oraz jedna wersja Tonieboxa 2:", + "macAddressCheck": "Sprawdzenie adresu MAC:", + "macAddressCheckText": "Najbardziej niezawodnym sposobem ustalenia wersji jest sprawdzenie wydrukowanego adresu MAC, który może znajdować się na opakowaniu lub bezpośrednio na Tonieboxie. Użyj macvendors.com, aby wyszukać adres MAC i ustalić producenta MCU, co pomoże Ci zidentyfikować wersję Tonieboxa.", + "navigationTitle": "Wersje", + "pros": "Zalety", + "title": "O różnych wersjach Tonieboxa", + "uses": "Wykorzystuje", + "whatVersion": "Jaką wersję Tonieboxa 1 wybrać?", + "whatVersionConclusion": "Nie zalecamy wersji TB1 V3 CC3235 ze względu na trudności we flashowaniu i ograniczone wsparcie. Obecnie wersja TB1 V1/V2 (TI CC3200) oferuje najwięcej możliwości, w tym obsługę niestandardowego bootloadera i firmware. Jednak z myślą o przyszłości, naszą główną rekomendacją jest wersja TB1 V4 oparta na ESP32.", + "whatVersionConclusionHeader": "Podsumowanie", + "whatVersionIntro": "Jeśli jeszcze nie wybrałeś/aś Tonieboxa 1, oto zalety i wady każdej wersji, które pomogą Ci podjąć decyzję. Ostatecznie wybór zależy od Twoich konkretnych potrzeb i preferencji. Przynajmniej wszystkie wersje są kompatybilne z TeddyCloud." + }, + "flashBox": "Flashuj Tonieboxa", + "flashBoxText": "Aby dodać swojego Tonieboxa do TeddyCloud, wymagane są pewne modyfikacje. W zależności od wersji Tonieboxa, dostępne są trzy szczegółowe instrukcje do wyboru:", + "identifyTonieboxVersion": "Zidentyfikuj wersję swojego Tonieboxa", + "identifyTonieboxVersionText": "Pierwszym krokiem dodania Tonieboxa do TeddyCloud jest zidentyfikowanie jego wersji. Wersja określa konkretną metodę potrzebną do zmodyfikowania Tonieboxa, aby był kompatybilny z TeddyCloud.", + "identifyVersion": { + "boxMacTooLong": "Wprowadzony adres MAC jest nieprawidłowy (zbyt długi)", + "boxVersion": "Oznacza to, że Twoja boxa to", + "cc3200text": "Ta wersja ma napis CC3200R1 na głównym układzie, jak pokazano na poniższych zdjęciach.", + "cc3235text": "Tutaj wersję również znajdziesz na głównym układzie, jak pokazano na poniższych zdjęciach, mianowicie CC3235.", + "errorMac": "Nie udało się zidentyfikować producenta dla podanego adresu MAC", + "esp32text": "Płytka PCB i główny układ wersji ESP32 wyglądają jak na poniższych zdjęciach.", + "generalInstruction1": "Jeśli nie możesz odczytać adresu MAC, możesz zidentyfikować konkretną wersję swojego Tonieboxa poprzez", + "generalInstruction2": "jego otwarcie i sprawdzenie głównego układu na PCB w celu identyfikacji. Kliknij zakładkę dla każdej wersji, aby uzyskać więcej szczegółów.", + "generalInstructionLinkText": "otwarcie", + "identify": "Zidentyfikuj", + "identifyUsingMac": "Zidentyfikuj wersję za pomocą adresu MAC swojego Tonieboxa. Adres MAC znajdziesz na spodzie Tonieboxa.", + "mac": "Wpisz adres MAC:", + "macvendors": "Sprawdzanie wykorzystuje API udostępnione przez", + "macvendorsLink": "macvendors.com", + "navigationTitle": "Zidentyfikuj wersję", + "or": "lub", + "proceedToFlash1": "Przejdź do strony flashowania dla wybranej wersji:", + "proceedToFlashLinkText": "Flashowanie boxa", + "tb2": "W kwestii Tonieboxa 2", + "tb2Details": "Znana jest tylko jedna wersja najnowszego Tonieboxa 2, więc identyfikacja jest prosta. Jeśli Twoja boxa to Toniebox 2, możesz przejść bezpośrednio do instrukcji flashowania dla Tonieboxa 2.", + "tb2text": "Ta wersja jest unikalna i ma na PCB nadruk TB-MAIN_2.0.P, jak pokazano na poniższym zdjęciu.", + "tiInstruction": "Aby ustalić konkretną wersję, musisz otworzyć Tonieboxa i wykorzystać główny układ na PCB do identyfikacji.", + "title": "Zidentyfikuj wersję swojego Tonieboxa", + "vendor": "Producentem głównego układu Twojego Tonieboxa jest", + "version": "wersja" + }, + "navigationTitle": "Dodaj Tonieboxa", + "newbieGuides": "Na forum Revvox dostępnych jest kilka przewodników dla początkujących. Warto się z nimi zapoznać:", + "openBoxGuide": { + "alternativeGuidelineVideo": "Alternatywnie możesz obejrzeć następujący przewodnik na YouTube:", + "finally": "Teraz masz dostęp do PCB, aby zidentyfikować wersję swojego Tonieboxa i go zmodyfikować!", + "guideSourceLicense": "licencja Creative Commons BY-NC-SA 3.0", + "guideSourcePart1": "Kroki 1-4 tego przewodnika pochodzą z", + "guideSourcePart2": "a krok 5 pochodzi z", + "guideSourcePart3": "Oba przewodniki pierwotnie napisane przez", + "guideSourcePart4": "który jest również autorem wszystkich użytych zdjęć. Ten przewodnik jest objęty licencją ", + "link1": "https://www.ifixit.com/Guide/Toniebox+Opening+Procedure/124139", + "link2": "https://www.ifixit.com/Teardown/Toniebox+Teardown/106148", + "navigationTitle": "Otwórz Tonieboxa", + "step1": "Krok 1", + "step1Text1": "Połóż Tonieboxa na płaskiej powierzchni (np. biurku lub blacie warsztatowym).", + "step1Text2": "Zastosuj lekki nacisk i obróć Tonieboxa w kierunku przeciwnym do ruchu wskazówek zegara, aby odblokować dwa zatrzaski pod dolną płytą.", + "step1Text3": "Usłyszysz \"kliknięcie\", gdy oba zatrzaski się odblokują.", + "step2": "Krok 2", + "step2Text": "Zdejmij dolną płytę.", + "step3": "Krok 3", + "step3Text": "Odkręć śrubę Phillips #1 na spodzie Tonieboxa.", + "step4": "Krok 4", + "step4Text": "Wyjmij wewnętrzną część z pianowej obudowy.", + "step5": "Krok 5", + "step5Text1": "Akumulator i płyta główna są przymocowane tylko dwoma standardowymi śrubami Phillips.", + "step5Text2": "Odkręć śruby, a następnie możesz odłączyć akumulator od PCB.", + "title": "Jak otworzyć i uzyskać dostęp do PCB Twojego Tonieboxa" + }, + "setupTeddyCloud": "Skonfiguruj TeddyCloud", + "setupTeddyCloudText": "Gratulacje! Docierając do tej strony, pomyślnie skonfigurowałeś/aś TeddyCloud!", + "title": "Dodaj swojego Tonieboxa do TeddyCloud", + "UARTHint": { + "ackUARTHint": "Mam ustawione prawidłowe napięcie (3,3 V) i zainstalowany najnowszy wymagany sterownik.", + "description": "Zanim przejdziesz dalej, sprawdź dwukrotnie:", + "imageAlt1": "Przykład zworki 1", + "imageAlt2": "Przykład zworki 2", + "imageAlt3": "Przykład zworki 3", + "noUSBCVersion": "Pojawiło się ostatnio kilka doniesień o problemach z wersją USB-C UART, w tym o błędach połączenia. Aby uniknąć tych problemów, zdecydowanie zalecamy zamiast tego użycie niezawodnej wersji micro-USB.", + "title": "Ważna uwaga: połączenie UART i ustawienia napięcia", + "uartDriver": "Instalacja sterownika UART", + "updateDriver": "Upewnij się, że dla Twojego urządzenia zainstalowany jest najnowszy sterownik UART. Sprawdź dwukrotnie w Menedżerze urządzeń. Jeśli żaden sterownik nie jest zainstalowany, pobierz i zainstaluj odpowiedni. Flashowanie może nie działać niezawodnie z ogólnymi lub przestarzałymi sterownikami.", + "voltageCompatibility": "Zgodność napięcia", + "voltageCompatibilityText1": "Upewnij się, że używasz adaptera UART obsługującego 3,3V.", + "voltageCompatibilityText2": "Jeśli Twój adapter UART obsługuje zarówno 3,3V, jak i 5V, musisz ustawić go na 3,3V. W większości przypadków na płytce znajduje się zworka do wyboru właściwego napięcia. Przykłady takich zworek można zobaczyć tutaj (Twoja płytka może wyglądać inaczej):", + "warning": "Ważne: bezwzględnie upewnij się, że napięcie jest ustawione na 3,3V (często oznaczone jako 3v3). Użycie ustawienia 5V może trwale uszkodzić Twojego Tonieboxa." + }, + "uploadCertificateButton": "Prześlij certyfikaty", + "uploadCertificateIntro": "Powinieneś/aś przesłać wyodrębnione certyfikaty do TeddyCloud, jeśli to Twój pierwszy Toniebox lub jeśli chcesz wyznaczyć tego Tonieboxa jako ten używany do pobierania zawartości z oryginalnej chmury Tonies. Dodatkowo musisz dodać certyfikaty do Tonieboxa w TeddyCloud. Aby to zrobić, przejdź do sekcji Tonieboxy, kliknij ikonę Certyfikat i prześlij certyfikaty ponownie.", + "uploadCertificates": "Prześlij certyfikaty (ca.der, client.der i private.der)", + "uploadCertificatesToBox": "Jeśli Twoja boxa jest widoczna, przejdź do sekcji Tonieboxy i prześlij certyfikaty wyodrębnione w poprzednim kroku do nowo dodanej boxa. Użyj ikony certyfikatu w stopce karty boxa, aby zakończyć przesyłanie: ", + "useIt": "Użyj go!", + "useItText": "Teraz możesz przypisać niestandardową zawartość do swoich Tonies lub niestandardowych tagów, usiąść wygodnie i cieszyć się nieograniczonymi możliwościami swojego odblokowanego Tonieboxa!" + }, + "boxWithoutAPIAccess": "Toniebox bez dostępu do API TeddyCloud!", + "boxWithoutAPIAccessGoToTonieboxes": "Jeśli chcesz to zmienić, włącz dostęp do API dla wymienionego ", + "boxWithoutAPIAccessText": "Następujące Tonieboxy nie mają włączonego dostępu do API:", + "cancel": "Anuluj", + "cc3200BoxFlashing": { + "altUrlFritzBoxPatch": { + "CollapseTitle": "Łatka altUrl.tc.fritz.box (altUrl.tc.fritz.box.json, domyślny preset w ngCfg.json dla slotu firmware ofw2)", + "text": "Jeśli używasz Fritz!Box, możesz zastosować łatkę altUrl.tc.fritz.box. Po prostu ustaw nazwę swojego serwera TeddyCloud na \"tc\" w ustawieniach Fritz!Box (Home Network -> Network -> Network Connections -> Edit)." + }, + "altUrlPatch": { + "CollapseTitle": "Łatka altURL (altUrl.305.json)", + "text": "Łatka altUrl.305 zastępuje domyślne adresy URL chmury niestandardowymi. Aby jej użyć, skonfiguruj swój DNS tak, aby zarówno prod.revvox, jak i rtnl.revvox wskazywały na adres IP Twojego serwera TeddyCloud." + }, + "applyingPatches": "Stosowanie łatek", + "applyingPatchesSection": { + "altPortHint": "Jeśli utworzyłeś poprawkę portu, umieść ją również w /revvox/boot/patch/ i dodaj \"altPort.custom\" do listy poprawek.", + "examplengCFGJsonCollapse": { + "collapseTitle": "Przykładowy plik \"ngCfg.json\" z łatką \"altUrl.custom.305\" używającą \"ofw2\"" + }, + "finish": "Jeśli zmodyfikowałeś/aś ngCfg.json zgodnie z instrukcjami, ponownie zmontuj Tonieboxa i włącz go. Wykonaj freshnesscheck, poczekaj chwilę, a następnie kliknij 'Show available boxes', aby wyświetlić dostępne Tonieboxy w Twoim TeddyCloud.", + "introduction": "Konfiguracja niestandardowego bootloadera odbywa się w pliku konfiguracyjnym ngCfg.json znajdującym się na karcie SD Tonieboxa w katalogu sd://revvox/boot (powinieneś/aś już umieścić wszystkie pliki bootloadera hackiebox NG na karcie SD Tonieboxa w kroku 2).", + "section2_part1": "Jeśli chcesz używać Tonieboxa z Teddycloud, zalecamy użycie drugiego slotu firmware. Ustaw więc w sekcji konfiguracji ogólnej element \"activeImg\" na \"ofw2\".", + "section2_part2": "Jeśli utworzyłeś/aś łatkę \"altUrl.custom.305\", skopiuj plik łatki na kartę SD Tonieboxa do sd://revvox/boot/patch. Następnie możesz dodać łatkę w sekcji konfiguracji drugiego slotu firmware (\"ofw2\"). Uwaga: stosowana jest tylko jedna łatka altUrl. Usuń \"altUrl.tc.fritz.box\" i/lub \"altUrl.305\", jeśli są obecne w konfiguracji.", + "technicalDetailsCollapse": { + "activeImgDesc": "Ustawia slot firmware wybierany przy starcie", + "bootFlashImgDesc": "Odczytuje firmware z pliku na flashu", + "checkHashDesc": "Sprawdza hash firmware", + "collapseTitle": "Szczegóły techniczne pliku konfiguracyjnego", + "default": "Domyślnie", + "description": "Opis", + "firmwareSection": "Sekcja firmware", + "firmwareSectionIntro": "Dostępnych jest dziewięć slotów firmware o nazwach ofw1, ofw2, ofw3, cfw1, cfw2, cfw3, add1, add2 i add3.", + "flashImgDesc": "Ścieżka do pliku na flashu", + "generalSection": "Sekcja ogólna", + "hashFileDesc": "Sprawdza hash z pliku ng-XXX?.sha (true) lub z ostatnich 64 bajtów samego firmware (ofw)", + "key": "Klucz", + "logColorDesc": "Włącz kolorowe logi", + "logLevelDesc": "Ustaw poziom logów 0:Trace - 5:Fatal", + "minBatteryLevelDesc": "Napięcie wyłączenia zasilania chroniące akumulator. Podziel przez 2785, aby uzyskać napięcie (standardowo 3,18V)", + "ofwFixDesc": "Dodaje magiczne bajty do obrazu firmware, aby ofw uruchamiał się bezpośrednio", + "ofwFixFlashDesc": "Magiczne bajty odczytane z bootloadera ofw na flashu", + "ofwFixValueDesc": "Magiczne bajty umieszczane w obrazie OFW podczas rozruchu (można je wyodrębnić z OFW BL data[-8:-4])", + "ofwSimBLDesc": "Odczytuje obraz do uruchomienia z flash:/sys/mcubootinfo.bin i ładuje obraz z flash:/sys/mcuimgN.bin zamiast odczytywać z karty SD (jak bootloader ofw)", + "patchDirectory": "katalog łatek", + "patchesDesc": "Lista łatek do wczytania, zobacz:", + "patchWiki": "wiki łatek", + "section1_link": "sd:/revvox/boot/ngCfg.json", + "section1_part1": "Konfiguracja bootloadera jest zapisana w", + "section1_part2": ". Wszystkie sekcje lub klucze zaczynające się od podkreślenia \"_\" są komentarzami i są ignorowane.", + "section1_part3": "Plik konfiguracyjny składa się z sekcji ogólnej i dedykowanych sekcji dla 9 dostępnych slotów firmware.", + "serialLogDesc": "Włącz logi do UART (TX) @921600 baud. Działa tylko w wersji debug!", + "title": "Konfiguracja", + "values": "Wartości", + "waitForBootDesc": "Czeka na naciśnięcie ucha przed uruchomieniem firmware z sekwencją mrugania (niebieski, zielony, cyjan, czarny)", + "waitForPressDesc": "Czeka na naciśnięcie ucha przy starcie z sekwencją mrugania (niebieski, zielony, cyjan, czarny)", + "waitTimeoutInSDesc": "Limit czasu w sekundach dla waitForPress, jeśli brak naciśnięcia ucha (hibernacja)", + "watchdogDesc": "Utrzymuje watchdog włączony podczas uruchamiania firmware (jeśli rozruch się nie powiedzie, boxa się zrestartuje)" + } + }, + "applyingPatchesWithNgCfgJson": "Zastosuj łatki za pomocą ngCfg.json", + "applyingPatchesWithNgCfgJsonLink": "Znajdź instrukcje tutaj", + "bootloader": "Bootloader", + "bootloaderInstalled": "Bootloader zainstalowany pomyślnie", + "certificates": { + "alreadyAvailable": "Certyfikaty ca.der, client.der i private.der powinny być już dostępne w:", + "extractAgain": "Jeśli nie, upewnij się, że poprawnie wykonałeś/aś krok 2. Możesz również ponownie wyodrębnić pliki z boxa za pomocą narzędzia cc3200 (Twój komputer powinien nadal być podłączony przez UART/ESP32-C3 i port debug do Tonieboxa)", + "uploadCertificates": "Prześlij te certyfikaty do TeddyCloud za pomocą poniższego przycisku." + }, + "certificatesDumpedCAreplacementFlashed": "Certyfikaty wyodrębnione, a zastępczy CA wgrany", + "checkBoxes": "Pokaż dostępne boxy", + "connectToToniebox": "Podłącz Tonieboxa do patchowania", + "connectToTonieboxConnectDebugPortText1": "Możesz użyć dedykowanego konektora Tag Connector, takiego jak ", + "connectToTonieboxConnectDebugPortText2": " lub alternatywnie użyć cienkich przewodów do połączenia.", + "connectToTonieboxConnectTableExplanation": "*Pin SOP2 Tonieboxa powinien być zmostkowany z VCC Tonieboxa.", + "connectToTonieboxConnectTableIntro": "Następnie podłącz port debug Tonieboxa do UART zgodnie z poniższą tabelą:", + "connectToTonieboxIntro": "Musisz podłączyć swój UART/ESP32-C3 do portu debug. Zacznij od zlokalizowania portu debug po nieobsadzonej stronie PCB, która znajduje się na spodzie. Układ portu debug pokazano na poniższym obrazku.", + "connectToTonieboxLink": "Znajdź instrukcje połączenia tutaj", + "connectToTonieboxProceed": "Możesz teraz kontynuować!", + "connectToTonieboxText": "Zielona dioda LED na płycie głównej Tonieboxa powinna świecić się stabilnie (bez migania lub mrugania). Jeśli tak nie jest, Twoje połączenia mogą być poluzowane lub nieprawidłowe. Odłącz programator, sprawdź dwukrotnie wszystkie połączenia i spróbuj ponownie. Jeśli pracujesz z cienkimi przewodami, rozważ zastosowanie kleju na gorąco, aby je zabezpieczyć i zapobiec ruchowi. Dodatkowo upewnij się, że Toniebox jest zasilany za pomocą swojej ładowarki.", + "connectToTonieboxTextUart": "Upewnij się, że UART jest skonfigurowany na 3,3V przed podłączeniem programatora UART do komputera przez USB.", + "createHostNamePatch": "Utwórz altUrl.custom.305.patch", + "createPortPatch": "Utwórz altPort.custom.patch", + "customPortPatch": "Opcjonalnie: Utwórz poprawkę portu (altPort.custom.json)", + "customPortPatchHint": "Potrzebne tylko wtedy, gdy Toniebox ma łączyć się z TeddyCloud przez inny port niż 443, np. przez proxy kierujące ruch według portu. Poprawka działa z wersjami firmware 3.1.0 BF2, 3.3.0 i 3.4.0. hackiebox-ng zawiera już altPort.json dla portu 10443; własną poprawkę twórz tylko dla innego portu. Toniebox z CC3235 i Toniebox 2 nie można załatać: zawsze łączą się z portem 443.", + "customUrlPatch": "Utwórz niestandardową łatkę URL (altUrl.custom.305.json)", + "customUrlPatchHint": "Utwórz tę łatkę tylko wtedy, gdy predefiniowane łatki altUrl.tc.fritz.box.json i altUrl.305.json nie działają z Twoją obecną konfiguracją TeddyCloud. Jeśli spełniają Twoje potrzeby, możesz kontynuować, klikając 'Next'.", + "dedicatedUart": "Dedykowany UART", + "dumpCertificates": "Zrzuć certyfikaty dla TeddyCloud", + "dumpCertificatesLink": "Znajdź instrukcje zrzucania certyfikatów tutaj", + "esp32C3UartGateway": { + "intro": "Możesz użyć ESP32-C3 jako bramki UART.", + "new": "Nowość", + "prepareStep1": "Podłącz swój ESP32-C3 do komputera.", + "prepareStep2": "Otwórz następujące narzędzie webowe od g3gg0, członka zespołu Team Revvox:", + "prepareStep3": "Otwórz zakładkę \"Flasher\", kliknij \"Connect & Flash\" i poczekaj, aż proces się zakończy.", + "prepareStep4": "Otwórz zakładkę \"Config\", kliknij \"Connect\", zastosuj poniższe ustawienia, a następnie kliknij \"Send Config\" i ponownie poczekaj, aż proces się zakończy:", + "prepareStep5": "Teraz podłącz swojego Tonieboxa do przygotowanego ESP32-C3 w następujący sposób:", + "title": "Bramka UART ESP32-C3" + }, + "flashCAreplacement": "Wgraj zastępczy CA dla TeddyCloud", + "flashCAreplacementIntro": "Zalecamy wgranie zastępczego CA do /cert/c2.der i użycie bootloadera Hackiebox-NG z łatką altCA.305 (więcej szczegółów w kolejnych krokach). Ta konfiguracja umożliwia płynne przełączanie się między oryginalnym certyfikatem a Twoim zastępczym certyfikatem.", + "flashCAreplacementOutro": "Upewnij się, że wybrałeś/aś właściwą ścieżkę do pliku c2.der.", + "flashCAreplacementText": "Aby zapisać pobrany plik c2.der (patrz powyższe kroki) na Tonieboxie, użyj następującego polecenia:", + "hint": "Obecnie nie ma bezpośredniego wsparcia flashowania Tonieboxa CC3200 z poziomu TeddyCloud, ale znajdziesz tutaj przewodnik krok po kroku:", + "hint2": "Następnie postępuj zgodnie ze ścieżką CC3200:", + "hintPatchHost": "Wprowadź nazwę hosta swojej instancji TeddyCloud, a następnie kliknij 'Create altUrl.custom.305.patch'. Łatka zostanie automatycznie pobrana.", + "hintPatchPort": "Wpisz port, z którym Toniebox ma się łączyć, i kliknij 'Utwórz altPort.custom.patch'. Poprawka zostanie automatycznie pobrana.", + "hostname": "Nazwa hosta TeddyCloud:", + "hostnameTooLong": "Nazwa hosta nie może przekraczać 12 znaków.", + "installBootloader": "Zainstaluj niestandardowy bootloader HackieboxNG", + "installBootloaderLink": "Znajdź instrukcje instalacji tutaj", + "installingBootloader": { + "downloadLink": "tutaj", + "downloadText1": "Pobierz najnowszą wersję z ", + "downloadText2": ". Rozpakuj wydanie do wybranego katalogu roboczego. Znajdziesz tam katalog flash z plikiem/plikami, które trzeba skopiować na flash, oraz katalog sd z plikami sd.", + "existingInstallation": { + "backup": "Kopia zapasowa", + "backupText": "Aby zweryfikować, że masz obecnie zainstalowany bootloader ofw w /sys/pre-img.bin, możesz pobrać plik z flash:", + "copyOverPreloader": "Skopiuj preloader", + "copyOverPreloaderStep1": "Wybierz cel \"Flash\"", + "copyOverPreloaderStep2": "Wybierz plik lokalny \"flash/sys/mcuimg.bin\"", + "copyOverPreloaderStep3": "Ustaw Overwrite na \"on\"", + "copyOverPreloaderStep4": "Ustaw ścieżkę SD/Flash na \"/sys/mcuimg.bin\"", + "copyOverPreloaderStep5": "Naciśnij Upload!", + "intro": "Możesz użyć (starego) Hackiebox CFW do przesłania preloadera. W ten sposób możesz zainstalować go bezprzewodowo, bez bezpośredniego dostępu do flasha, jeśli masz już zainstalowaną poprzednią wersję bootloadera/preloadera cfw. Po prostu uruchom Hackiebox CFW i użyj interfejsu webowego, aby przesłać nowy plik /sys/mcuimg.bin.", + "outro": "Aby zweryfikować, że przesyłanie się powiodło, możesz następnie pobrać plik mcuimg.bin z flasha i porównać go z przesłanym właśnie plikiem flash/sys/mcuimg.bin.", + "title": "Etap 1b: preloader, aktualizacja istniejącej instalacji", + "updatingCFWUsingOldCFWWebGui": "Przesyłanie plików przez WebGui CFW" + }, + "firstInstallation": { + "installPreloader": "Zainstaluj preloader", + "installPreloaderText1": "Upewnij się, że masz zapasową kopię oryginalnego firmware w bezpiecznym miejscu. Sprawdź dwukrotnie, że oryginalny plik mcuimg.bin jest gdzieś bezpiecznie zapisany. Jeśli wszystko jest w porządku, zainstaluj preloader za pomocą następującego polecenia", + "installPreloaderText2": "Zainstalowałeś/aś Preloader na Tonieboxie. Przejdź teraz do Etapu 2: instalacji bootloadera na karcie SD.", + "moveOriginal": "Przenieś oryginalny bootloader", + "moveOriginalText1": "Najpierw musisz skopiować właśnie zapasowo zapisany oryginalny plik mcuimg.bin (oryginalny bootloader) z Tonieboxa do innej lokalizacji (flash:/sys/pre-img.bin), aby móc uruchomić go później za pomocą bootloadera HackieboxNG. Nie myl pliku mcuimg.bin (bootloader ofw), który zamierzasz zrzucić, z plikiem mcuimg.bin wewnątrz paczki zip hackiebox. (/flash/sys/mcuimg.bin)", + "moveOriginalText2": "Wykonaliśmy już pełną kopię zapasową, więc plik mcuimg.bin znajdziemy pod /currentDir/ExtractedFromBox/sys/mcuimg.bin, ponieważ powinieneś/aś nadal uruchamiać cc3200tool z tego katalogu, zapisz plik na Tonieboxie:", + "title": "Etap 1a: preloader, pierwsza instalacja" + }, + "importantTBFiles": { + "caDer": "Zawiera urząd certyfikacji (CA) do sprawdzania połączenia https dla API", + "clientDer": "Klucz publiczny boxa, nie udostępniaj go", + "description": "Opis", + "file": "Plik", + "firstSlot": "Pierwszy oryginalny slot firmware", + "mcuBootInfo": "Decyduje, który slot firmware ma zostać uruchomiony", + "originalBootloader": "Oryginalny bootloader", + "privateDer": "Klucz prywatny boxa, nie udostępniaj go; jest używany do uwierzytelniania w oryginalnej chmurze", + "secondSlot": "Drugi oryginalny slot firmware", + "thirdSlot": "Trzeci oryginalny slot firmware" + }, + "inCaseText": "W przypadku, gdy niektóre pliki nie istnieją na boxie, polecenie może zakończyć się błędem i w związku z tym nigdy nie utworzy poprawnie pliku backup.bin - podziel polecenie na dwa: ", + "installingBootloaderStage2": { + "here": "tutaj", + "intro": "Skopiuj całą zawartość folderu sd na kartę SD Tonieboxa. Skopiuj oryginalny bootloader (mcuimg.bin z Twojej kopii zapasowej) do pierwszego slotu ofw sd:/revvox/boot/ng-ofw1.bin. Teraz bootloader HackieboxNG natychmiast uruchomi oryginalny bootloader i oryginalny firmware.", + "text1": "Uwaga: preloader wczyta dowolny plik umieszczony w sd:/revvox/boot/ngbootloader.bin.", + "text2": "Więcej o różnych slotach firmware i konfiguracji " + }, + "intro": "Przed instalacją bootloadera Hackiebox NG wykonaj pełną kopię zapasową opartą na plikach oraz flashu Tonieboxa za pomocą cc3200tool. Jako przykładowy port używamy COM3, ale możesz użyć innego portu COM lub odpowiedniego urządzenia na Linuksie, np. /dev/ttyUSB0.", + "preface": { + "intro": "Instalacja nowego bootloadera jest podzielona na dwa etapy i różni się w zależności od tego, czy jest to pierwsza instalacja, czy aktualizacja istniejącej instalacji: ", + "title": "Wstęp" + }, + "resetCommand": "Jeśli Twój programator UART ma pin DTR, użyj `--reset dtr` przed każdym poleceniem (np. `cc3200tool -p COM3 --reset dtr ...`). W przeciwnym razie przed każdym poleceniem musisz krótko zewrzeć pin RST (Toniebox) z GND. Jeśli pominiesz ten krok, konsola wyświetli \"timed out while waiting for ack\" i przerwie operację.", + "stage1": "Etap 1: instalacja preloadera", + "stage2": "Etap 2: instalacja bootloadera", + "verifyBackup": "Upewnij się, że backup.bin jest znacznie większy niż 0 KB. Sprawdź, czy zrzut i zrzucone pliki zawierają wszystkie ważne pliki Tonieboxa:" + }, + "linkGeneral": "Patchowanie Tonieboxa do użytku z TeddyCloud", + "linkSpecific": "Konfiguracja specyficzna dla CC3200", + "navigationTitle": "CC3200", + "next": "Dalej", + "patches": "Łatki", + "patchesMoreInformationLink": "Więcej informacji o niestandardowych łatkach", + "port": "Port TeddyCloud:", + "portInvalid": "Port musi mieścić się w zakresie od 1 do 65535.", + "predefinedUrlPatches": "Predefiniowane łatki URL", + "predefinedUrlPatchesIntro": "Dla boxów CC3200 zaleca się użycie łatki altUrl. HackieboxNG zawiera dwie predefiniowane, gotowe do użycia łatki altUrl:", + "previous": "Wstecz", + "proceedWithCustomBootloader": "Kontynuuj z niestandardowym bootloaderem", + "proceedWithoutCustomBootloader": "Kontynuuj bez niestandardowego bootloadera", + "tagConnector": "Prawie podłączony Tag Connector", + "title": "Flashowanie boxa CC3200", + "usingThinWiresAsTagConnector": "Cienkie przewody użyte jako konektor" + }, + "cc3235BoxFlashing": { + "certificatesDumpedCAreplacementFlashed": "Certyfikaty wyodrębnione, a zastępczy CA wgrany", + "CH341AProgrammer": { + "CH341AProgrammer": "Skonfiguruj połączenie programatora CH341A", + "CH341AProgrammerClampConnection": "Programator CH341A z klipsem SOP8 podłączonym do pamięci Flash IS25LP032D", + "CH341Sop8remove": "Przytnij tutaj, aż będzie pasować prawidłowo.", + "flashMemoryDumpText1": "Podłącz klips do pamięci flash IS25LP032D. Upewnij się, że czerwony przewód na klipsie pokrywa się z kropką oznaczającą pin 1 na układzie.", + "flashMemoryDumpText2": "Jeśli klips nie trzyma, może kolidować z pobliskim czarnym elementem. W razie potrzeby przytnij więcej plastiku. Nie podłączaj jeszcze CH341A. Dokładnie sprawdź pozycje przewodów.", + "flashMemoryDumpText3": "Gdy będziesz gotowy/a, podłącz CH341A do portu USB komputera. Dioda Tonieboxa powinna zaświecić się na stałe na zielono. Jeśli miga, klips może nie być dobrze umieszczony. Odłącz CH341A, popraw pozycję klipsa i spróbuj ponownie, aby zapewnić dobre połączenie.", + "flashMemoryDumpText4": "Gdy dioda LED świeci się stabilnie na zielono, połączenie jest dobre. Otwórz terminal na komputerze i wpisz następujące polecenia:", + "flashMemoryDumpText5": "Powtórz ten proces co najmniej trzy razy.", + "flashMemoryDumpText6": "Po trzech odczytach porównaj wszystkie zrzuty, aby potwierdzić ich spójność.", + "flashMemoryDumpText7": "Gdy potwierdzisz, że wszystkie wersje są spójne, przejdź do ekstrakcji certyfikatów.", + "flashMemoryDumpText8": "Wyjmij CH341A z portu USB i zdejmij klips, aby uniknąć potencjalnych zwarć lub problemów, gdyby klips przypadkowo się przesunął.", + "prep1": "Zdobądź programator CH341A (kupuj wyłącznie czarną wersję ze złotą linią i zaokrąglonymi krawędziami).", + "prep2": "W razie potrzeby przytnij plastik na klipsie CH341A, aby pasował do pamięci flash IS25LP032D.", + "prep3": "Upewnij się, że zarówno PCB, jak i akumulator są odłączone.", + "prep4": "Podłącz CH341A do komputera i potwierdź, że został rozpoznany. Możesz to zrobić w następujący sposób:", + "preparation": "Wymagane tylko wtedy, gdy planujesz flashowanie za pomocą programatora CH341A", + "prepConclusions1": "Jeśli wynik różni się, mały mostek między pinami 1 i 2 na CH341A może nie być ustawiony. Po potwierdzeniu odłącz CH341A od portu USB. Ta kontrola zapewnia, że wszystko jest prawidłowo podłączone.", + "prepConclusions2": "Upewnij się, że moduł SOP8 z zestawu CH341A i klips są połączone tak, jak pokazano na zdjęciu.", + "prepConclusions3": "Dopiero po zakończeniu przygotowań możemy przejść do procesu ekstrakcji.", + "readingFlash": "Odczyt Flasha za pomocą programatora CH341A", + "writingFlash": "Zapis Flasha za pomocą programatora CH341A" + }, + "checkBoxes": "Pokaż dostępne boxy", + "dnsBeware": "Uwaga!", + "dnsBewareText": "Spowoduje to odcięcie połączenia wszystkich tonieboxów w Twojej sieci, które nie zostały spatchowane Twoim zastępczym CA!", + "dnsHint": "Dostosowania DNS dla TeddyCloud", + "dnsLink": "Znajdź instrukcje dostosowania DNS tutaj", + "dnsText1": "Ustaw wpisy DNS dla prod.de.tbs.toys i rtnl.bxcl.de na adres IP serwera TeddyCloud.", + "dnsText2": "Alternatywnie możesz ustawić bramę dla tonieboxów na adres IP TeddyCloud. W OpenWRT działa to w następujący sposób:", + "dumpCertificates": "Zrzuć certyfikaty dla TeddyCloud", + "dumpCertificatesCC3235": "Musisz ręcznie wyodrębnić je z pamięci flash boxa za pomocą klipsa SOP8 bezpośrednio z pamięci lub poprzez wylutowanie. Odczyt in-circuit może być trudny, ale możliwy. Zalecamy do tego flashrom. Może być konieczne użycie jego nowszej wersji.", + "dumpCertificatesLink": "Znajdź instrukcje zrzucania certyfikatów tutaj", + "extractCertificates": "Wyodrębnij certyfikaty", + "flashCAreplacement": "Wgraj zastępczy CA dla TeddyCloud", + "flashCAReplacementDescription1": "Niestety certyfikat CA wygenerowany przez TeddyCloud podczas jego początkowej konfiguracji może czasami powodować błąd BAD_CERTIFICATE przy łączeniu Tonieboxa CC3235 z TeddyCloud. Aby rozwiązać ten problem, spróbuj ręcznie wygenerować certyfikat za pomocą starszego", + "flashCAReplacementDescription2": "skryptu z zainstalowanym faketime. Skopiuj wygenerowane pliki do katalogu certs/server w folderze instalacyjnym TeddyCloud.", + "flashCAReplacementDescription3": "Pamiętaj, że nowe certyfikaty serwera, w tym nowo wygenerowany certyfikat CA, mogą zakłócić połączenia z już podłączonymi Tonieboxami. Aby zapewnić nieprzerwaną obsługę, zaktualizuj plik zastępczego CA na wszystkich swoich Tonieboxach nowo wygenerowanym certyfikatem.", + "flashCAreplacementLink": "Znajdź instrukcje flashowania tutaj", + "flashCAreplacementText1": "Zastąp oryginalny CA w swoim zrzucie flasha zastępczym CA i wgraj go ponownie na boxa.", + "flashCAreplacementText2": "Jeśli boxa w ogóle się nie łączy (hasło owl), możesz spróbować użyć starego gencerts.sh, aby wygenerować nowe certyfikaty i ponownie wgrać boxa.", + "flashCAreplacementText3": "Uwaga: plik ca.der, do którego odnosi się fragment kodu, musi być zastępczym CA TeddyCloud!", + "flashCAReplacementTitle": "Uwaga: problemy ze zgodnością certyfikatu CA TeddyCloud", + "flashCollapse": { + "cc3235flash": "Pamięć flash z pinami", + "cc3235flashWithSMDGrippers": "Pamięć flash z podłączonymi chwytakami SMD", + "collapseTitle": "Pomoc: pamięć flash IS25LP032D na CC3235", + "sop8Clamp": "Pamięć flash z podłączonym klipsem SOP8" + }, + "gencertLinkText": "gencert.sh", + "hint": "Obecnie nie ma bezpośredniego wsparcia flashowania Tonieboxa CC3235 z poziomu TeddyCloud, ale znajdziesz tutaj przewodnik krok po kroku:", + "hint2": "Następnie postępuj zgodnie ze ścieżką CC3235:", + "hwToolSpecific": "Przygotowania zależne od sprzętu", + "hwToolSpecificText": "W zależności od sprzętu, którego planujesz użyć do flashowania Tonieboxa, wymagane są dodatkowe przygotowania. Wybierz opcję, którą będziesz stosować:", + "installflashromtool": "Zainstaluj Flashrom", + "installflashromtoolLink": "Odwiedź stronę domową narzędzia po więcej informacji", + "installflashromtoolText": "Upewnij się, że na komputerze, którego używasz do flashowania, zainstalowany jest flashrom. Jeśli nie, zainstaluj go za pomocą następujących poleceń:", + "installSerprogFirmware": "Zainstaluj firmware serprog", + "linkGeneral": "Patchowanie Tonieboxa do użytku z TeddyCloud", + "linkSpecific": "Konfiguracja specyficzna dla CC3235", + "locateFlashMemory": "Zlokalizuj układ pamięci flash", + "locateFlashMemoryText": "Zidentyfikuj układ pamięci flash IS25LP032D na płytce; znajduje się on na prawo od stacji dokującej ładowania.", + "navigationTitle": "CC3235", + "next": "Dalej", + "pico": { + "preparation": "Wymagane tylko wtedy, gdy planujesz flashowanie za pomocą Raspberry Pi Pico", + "readingFlash": "Odczyt Flasha za pomocą Raspberry Pi Pico", + "readingFlashText1": "Jako sondy możesz użyć Raspberry Pi Pico z firmware obsługującym protokół serprog.", + "readingFlashText2": "Zalecane jest wykonanie wielu odczytów i porównanie wyników.", + "writingFlash": "Zapis Flasha za pomocą Raspberry Pi Pico" + }, + "portHint": "Pudełka CC3235 zawsze łączą się z portem 443: nie ma dla nich łatki portu, więc TeddyCloud musi być dostępny na porcie 443 (proxy może je kierować tylko według IP).", + "previous": "Wstecz", + "serprogFirmwareLink": "Znajdź instrukcje instalacji tutaj", + "title": "Flashowanie boxa CC3235", + "useCC3200ToolToExtract": "Możesz następnie użyć cc3200tool, aby wyodrębnić certyfikaty ze zrzutu flasha." + }, + "confirmDeleteDialog": "Czy na pewno chcesz usunąć {{tonieboxToDelete}}?", + "confirmDeleteModal": "Potwierdź usunięcie", + "connectESP32Modal": { + "beware": "Uwaga!", + "connectESP32Text1_1": "Podłącz zworkę J100 (Boot) i zresetuj boxa, aby przełączyć go w wymagany tryb UART.", + "connectESP32Text1_2": "Podłącz swój UART 3,3V do J103:", + "connectESP32Text2": "Jeśli nie masz pewności, w jakim trybie uruchamia się ESP32, sprawdź poniższe.", + "downloadMode": "Tryb pobierania", + "downloadModeText": "Dioda LED jest wyłączona, brak dźwięku, wyjście szeregowe:", + "esp32UartJ100": "ESP32 J100", + "esp32UartJ103": "ESP32 J103", + "esp32UartJ103AnalogClamp": "ESP32 J103 ze spinaczem do bielizny", + "esp32UartJ103Clamp": "ESP32 J103 z klipsem 4-pinowym", + "esp32UartJ103Clamp4PinHint": "Jeśli masz tylko klips 4-pinowy zamiast wymaganego klipsa 3-pinowego (jak pokazano powyżej), nie jest on idealnie dopasowany. Spróbuj umieścić piny jak najbardziej centralnie na stykach.", + "esp32UartJ103ClampTitle": "Podłącz UART do ESP32 J103 za pomocą klipsa", + "normalMode": "Normalny tryb rozruchu", + "normalModeText": "Dioda LED miga na zielono, odtwarzany jest dźwięk startowy, wyjście szeregowe:", + "warningText": "Jeśli monitor portu szeregowego jest otwarty, uniemożliwi to esptool.py dostęp do esp. Jeśli pojawi się \"BROWNOUT_RST\", sprawdź swój zasilacz/akumulator. \"SPI_FAST_FLASH_BOOT\" oznacza rozruch bez zworki J100." + }, + "delete": "Usuń", + "downloadC2DerFile": "Pobierz zastępczy certyfikat CA (jako c2.der)", + "downloadCADerFile": "Pobierz zastępczy certyfikat CA (ca.der)", + "editModelModal": { + "editModel": "Edytuj Tonieboxa \"{{name}}\"", + "errorOnModelChange": "Błąd podczas zapisywania modelu", + "errorOnModelChangeDetails": "Błąd podczas zapisywania modelu Tonieboxa {{mac}}: {{error}}", + "errorOnNameChange": "Błąd podczas zapisywania nazwy", + "errorOnNameChangeDetails": "Błąd podczas zapisywania nazwy Tonieboxa {{mac}}: {{error}}", + "model": "Model", + "name": "Nazwa", + "placeholderSearchForAModel": "Wybierz wariant kolorystyczny swojego Tonieboxa", + "successOnModelChange": "Zmiana modelu zapisana pomyślnie", + "successOnModelChangeDetails": "Nowy model \"{{model}}\" Tonieboxa {{mac}} został pomyślnie zapisany.", + "successOnNameChange": "Zmiana nazwy zapisana pomyślnie", + "successOnNameChangeDetails": "Nowa nazwa \"{{name}}\" Tonieboxa {{mac}} została pomyślnie zapisana.", + "unsetBoxName": "Model nieznany/nieustawiony" + }, + "editTonieboxSettingsModal": { + "editTonieboxSettings": "Edytuj ustawienia Tonieboxa \"{{name}}\"", + "editTonieboxSettingsLabel": "Ustawienia" + }, + "errorFetchingModels": "Błąd podczas pobierania modeli Tonieboxów", + "errorFetchingTonieboxes": "Błąd podczas pobierania Tonieboxów", + "esp32BoxFlashing": { + "adaptBaudrateOnProblems": "Wskazówka", + "adaptBaudrateOnProblemsText": "Jeśli postęp zatrzymuje się lub zawiesza podczas odczytu lub zapisu pamięci flash, spróbuj zmienić szybkość transmisji za pomocą pola wyboru \"Baudrate\" u góry.", + "attention": "Uwaga", + "baudRate": "Szybkość transmisji: ", + "baudRateInfo": "Jeśli napotkasz problemy z odczytem lub zapisem flasha, spróbuj zmienić szybkość transmisji. Domyślna wartość to 921600.", + "browserNotSupported": "Wbudowana funkcja flashowania boxa ESP32 może być używana tylko w przeglądarkach obsługujących Web Serial API. Niestety Twoja obecna przeglądarka nie obsługuje tej funkcji. Użyj przeglądarki obsługującej Web Serial API, np. Chrome lub Edge.", + "esp32flasher": { + "ackBackUpWithOtherTool": "Utworzyłem/am kopię zapasową za pomocą innego narzędzia lub rozumiem i akceptuję ryzyko kontynuowania bez niej.", + "ackLatestFirmware": "Potwierdzam, że boxa była podłączona do oryginalnej chmury, otrzymała najnowszy firmware i pomyślnie odtworzyła Tonie.", + "ackRisk": "Przeczytałem/am i zrozumiałem/am to ostrzeżenie i jestem świadomy/a związanego z tym ryzyka.", + "ackUARTHint": "Ustawiłem/am prawidłowe napięcie (3,3 V) i zainstalowałem/am najnowszy wymagany sterownik.", + "alternativeTools": "Poniżej znajdują się alternatywne narzędzia do tworzenia kopii zapasowej. Dostępne są też inne narzędzia — możesz swobodnie użyć tego, które znasz.", + "alternativeToolsNoLinks": "Użyj znanego Ci czytnika ESP32 lub poszukaj innego odpowiedniego czytnika ESP32.", + "automatically": "Automatycznie", + "backupFlash": "Ważne: wykonaj kopię zapasową oryginalnego, niezmodyfikowanego firmware", + "cancel": "Anuluj", + "chipMAC": "MAC układu", + "chipType": "Typ układu", + "chipTypeError": "Błąd typu układu - odczytano nieoczekiwany typ. Odczytano {{actualname}}, oczekiwano {{expectedname}}. Przerwij przetwarzanie, sprawdź połączenie i spróbuj ponownie!", + "confirmFlashDialog": "Czy na pewno chcesz zflashować swojego Tonieboxa? Upewnij się, że masz stabilne połączenie i nie przerywaj procesu!", + "confirmFlashDialogHint": "Aby Twój Toniebox działał prawidłowo, podana instancja TeddyCloud jest absolutnie niezbędna. Jeśli Twój TeddyCloud jest offline, Twój Toniebox nie będzie mógł uzyskać dostępu do nowej zawartości!", + "confirmFlashModal": "Zflashuj Tonieboxa spatchowanym firmware", + "connected": "Połączono", + "connectESPCollapseTitle": "Pomoc: podłącz UART do Tonieboxa", + "connectingReadFlash": "Łączenie w celu odczytu Flasha...", + "connectingReadMac": "Łączenie w celu odczytu adresu MAC...", + "connectingTo": "Łączenie z", + "connectingToESP": "Łączenie z ESP...", + "connectingWriteFlash": "Łączenie w celu zapisu Flasha...", + "downloadFlashFilesHint": "Obrazy firmware są przechowywane w katalogu danych Twojego serwera, na wypadek gdybyś chciał/a je ponownie wgrać. Jeśli jeszcze tego nie zrobiłeś/aś, koniecznie pobierz teraz i bezpiecznie przechowaj kopię oryginalnego firmware! Zapewni to możliwość przywrócenia Tonieboxa do stanu fabrycznego, jeśli coś pójdzie nie tak. Zapisz kopię zapasową w kilku miejscach — np. na głównym komputerze lub laptopie, zewnętrznym dysku twardym lub pendrivie i/lub w zaufanej usłudze przechowywania w chmurze (np. Google Drive, Dropbox, OneDrive). Nie polegaj wyłącznie na kopii przechowywanej na serwerze TeddyCloud.", + "downloadLink": "Pobierz niezmodyfikowany firmware", + "downloadLinkPatched": "Pobierz spatchowany firmware", + "downloadStubFailed": "Pobieranie Stub do ESP32 nie powiodło się! Sprawdź połączenie i spróbuj ponownie!", + "error": "Błąd", + "executingReliabilityCheck": "Wykonywanie testu niezawodności dla stabilnego połączenia z ESP32. Proszę czekać!", + "extendedFlashingLog": "Rozszerzony log flashowania", + "extractCertificates": "Wyodrębnij certyfikaty dla TeddyCloud", + "extractCertificatesAutomaticallyHint": "Kliknij przycisk \"Extract certificates for TeddyCloud\", aby automatycznie wyodrębnić i zapisać certyfikaty w TeddyCloud.", + "extractCertificatesAutomaticallyHint2": "Certyfikaty zostaną zapisane w maksymalnie dwóch lokalizacjach: najpierw w podstawowym katalogu certyfikatów klienta ../{{certDir}}/ dla TeddyCloud (jeśli nie ma jeszcze żadnych certyfikatów), a po drugie w folderze ../{{certDir}}/{{mac}}.", + "extractCertificatesManually": "Wyodrębnij certyfikaty ręcznie", + "extractCertificatesManuallyHint": "Możesz również wyodrębnić certyfikaty ręcznie, wykonując poniższe kroki.", + "extractCertificatesManuallyHintP1": "Możesz to zrobić za pomocą pliku wykonywalnego teddycloud na swoim komputerze lub poprzez powłokę dockera", + "extractCertificatesManuallyHintP2": "Sprawdź nazwę pliku wyodrębnionych certyfikatów, zwłaszcza wielkość liter! Zmień je na małe litery, jeśli są zapisane wielkimi.", + "extractCertificatesManuallyHintP3": "Przy pierwszej konfiguracji Tonieboxa z TeddyCloud skopiuj certyfikaty do podstawowego katalogu certyfikatów klienta. TeddyCloud używa tych certyfikatów do uwierzytelniania w oficjalnej chmurze Tonies, co umożliwia pobieranie zawartości bez interakcji z Tonieboxem (np. gdy klikniesz 'Download' przy Tonie w interfejsie).", + "extractingCertificates": "Wyodrębnianie certyfikatów dla TeddyCloud...", + "extractingCertificates409ResponseForceOverwrite": "Nadpisz istniejące certyfikaty", + "extractingCertificates409ResponseForceOverwriteConfirmButton": "Nadpisz pliki", + "extractingCertificates409ResponseForceOverwriteContent": "Początkowa próba zapisania certyfikatów nie powiodła się: {{error}}. Czy chcesz nadpisać istniejące pliki?", + "extractingCertificatesFailed": "Nie udało się wyodrębnić certyfikatów!", + "extractingCertificatesFailedDetails": "Nie udało się wyodrębnić certyfikatów z \"{{file}}\"", + "extractingCertificatesSuccessful": "Certyfikaty zostały pomyślnie wyodrębnione i zapisane!", + "extractingCertificatesSuccessfulDetails": "Certyfikaty zostały pomyślnie wyodrębnione z \"{{file}}\" i zapisane w TeddyCloud.", + "failedToCommunicate": "Nie udało się nawiązać komunikacji:", + "failedToConnect": "Nie udało się połączyć:", + "flagPreviousHostname": "Obraz flasha zawiera wcześniej ustawioną nazwę hosta TeddyCloud", + "flash": "Flash", + "flashDevice": "Urządzenie flash", + "flashEsp32": "Flashuj ESP32", + "flashId": "ID flasha", + "flashManuf": "Producent flasha", + "flashSize": "Rozmiar flasha", + "flashSizeError": "Błąd rozmiaru flasha - odczytano nieoczekiwany rozmiar. Odczytano {{actualsize}}, oczekiwano {{expectedsize}}. Przerwij przetwarzanie, sprawdź połączenie i spróbuj ponownie!", + "flashSizeErrorLoadedFile": "Błąd rozmiaru flasha - odczytano nieoczekiwany rozmiar z wczytanego pliku. Odczytano {{actualsize}}, oczekiwano {{expectedsize}}. Przerwij przetwarzanie, sprawdź wczytany plik i spróbuj ponownie!", + "hintESP32FirmwareFlashed": "Patchowanie powinno być zakończone. Odłącz Tonieboxa od UART, usuń zwarcie z J100 i zrestartuj boxa. Powinien teraz móc połączyć się z TeddyCloud!", + "hintESP32FirmwareReseted": "Reset powinien być zakończony. Odłącz Tonieboxa od UART, usuń zwarcie z J100 i zrestartuj boxa. Nie powinien już być połączony z Twoim Teddycloud. Wykonaj freshnesscheck, aby sprawdzić, czy działa poprawnie.", + "hintFlashESP32RevvoxFlasher": "Kliknij \"Flash ESP32\", w razie potrzeby wybierz port COM i poczekaj, aż krok się zakończy!", + "hintPatchFlash": "Aby zastosować wybrany/odczytany flash z certyfikatem CA TeddyCloud i ewentualnie wprowadzić inne zmiany, takie jak nazwa hosta i port TeddyCloud i/lub dane Wi-Fi, kliknij 'Patch' po wprowadzeniu informacji lub przejdź do następnego kroku bez patchowania.", + "hintPatchHost": "Wprowadź żądaną nazwę hosta dla swojej instancji TeddyCloud.", + "hintPatchPort": "Port, z którym łączy się Toniebox. Pozostaw 443, chyba że Toniebox ma łączyć się z TeddyCloud przez inny port, np. przez proxy kierujące ruch według portu. Toniebox z CC3235 i Toniebox 2 nie można załatać: zawsze łączą się z portem 443.", + "hintPatchWifi": "Opcjonalnie wprowadź dane Wi-Fi dla swojego Tonieboxa. Ustaw to tylko, jeśli rzeczywiście musisz - zazwyczaj, jeśli Twój Toniebox był podłączony do Twojej sieci Wi-Fi bezpośrednio przed patchowaniem, nie jest to konieczne.", + "hintReadESP32ImportFlash": "Teraz odłącz źródło zasilania swojego Tonieboxa. Upewnij się, że UART działa w trybie 3,3V. Podłącz swój UART do komputera i Tonieboxa. Zewrzyj otwory pinów J100. Następnie podłącz Tonieboxa do zasilania. Kliknij \"Read Flash\" lub \"Load File\", a następnie wybierz port COM. Na koniec poczekaj, aż krok się zakończy!", + "hintSaveLog": "Zapisz dane wyjściowe logu do pliku w celu dalszej analizy w razie problemów.", + "hintUseOtherToolForAdditionalBackup": "Użyj dodatkowego narzędzia, aby utworzyć osobną kopię zapasową.", + "hostname": "Nazwa hosta TeddyCloud:", + "hostnameSettings": "Nazwa hosta TeddyCloud", + "hostnameTooLong": "Jeśli chcesz później podłączyć Tonieboxa CC3200, nazwa hosta nie może przekraczać 12 znaków (ograniczenie firmware).", + "importanceBackup": "Przed wprowadzeniem jakichkolwiek zmian w Tonieboxie pobierz i bezpiecznie przechowaj kopię oryginalnego firmware. Zapewni to możliwość przywrócenia Tonieboxa do stanu fabrycznego, jeśli coś pójdzie nie tak. Zapisz ją w kilku miejscach, np. na głównym komputerze lub laptopie, zewnętrznym dysku twardym lub pendrivie i/lub w zaufanej usłudze przechowywania w chmurze (np. Google Drive, Dropbox, OneDrive). Nigdy nie przechowuj jedynej kopii wyłącznie na serwerze TeddyCloud.", + "infoTable": "Informacje ESP32", + "invalidFlashData": "Odczytany/wczytany flash składa się wyłącznie z 0x00 lub 0xFF! Sprawdź połączenie lub wczytany plik i spróbuj ponownie!", + "invalidFlashDataCertificatesMissing": "Odczytanemu/wczytanemu flashowi brakuje certyfikatów, coś poszło nie tak! Sprawdź połączenie lub wczytany plik i spróbuj ponownie! Błąd: ", + "invalidSerialPort": "Nieprawidłowy port szeregowy", + "loadFile": "Wczytaj plik", + "manually": "Ręcznie", + "next": "Dalej", + "noPortAvailable": "Brak dostępnego portu lub użytkownik przerwał operację", + "openPort": "Otwórz port", + "patchImage": "Patch", + "patchingFailed": "Patchowanie nie powiodło się", + "patchingFlashImage": "Patchowanie obrazu flasha...", + "patchingSuccessful": "Patchowanie zakończone pomyślnie, gotowe do wgrania {{size}} MiB.", + "payloadReceived": "Otrzymano dane", + "port": "Port TeddyCloud:", + "portInvalid": "Port musi mieścić się w zakresie od 1 do 32767.", + "portOpenFailedInUse": "Nie udało się otworzyć tego portu. Może jest już używany?", + "preparingFlash": "Przygotowywanie {{size}} MiB flasha", + "previous": "Wstecz", + "previousHostname": "Poprzednia nazwa hosta TeddyCloud:", + "readFlash": "Odczytaj Flash", + "readingFinished": "Odczyt zakończony", + "readingFlash": "Odczytywanie Flasha...", + "readyToProceed": ", gotowe do kontynuacji.", + "reliabilityCheckFailed": "Test niezawodności nie powiódł się. Połączenie z ESP32 wydaje się niestabilne. Sprawdź połączenie i spróbuj ponownie!", + "resetBoxine": "Zresetuj boxa", + "resetBoxineTooltip": "Aby zresetować Tonieboxa do oryginalnego firmware, naciśnij ten przycisk, aby rozpocząć proces. Będziesz musiał/a wybrać oryginalną kopię zapasową firmware, która zostanie następnie wgrana na Tonieboxa w jego nienaruszonym stanie. Niestety, jeśli nie masz kopii zapasowej oryginalnego firmware, przywrócenie Tonieboxa nie będzie możliwe.", + "retrievingMac": "Pobieranie adresu MAC...", + "save": "Zapisz log", + "showLog": "Pokaż szczegóły logu", + "titleESP32FirmwareFlashed": "ESP32 zflashowany", + "titleFlashESP32": "Flashuj ESP32", + "titlePatchFlash": "Patchuj Flash", + "titlePreparations": "Przygotowania i ważne uwagi", + "titleReadESP32ImportFlash": "Odczytaj ESP32/Importuj Flash", + "uploadFailed": "Przesyłanie nie powiodło się", + "uploading": "Przesyłanie...", + "uploadSuccessful": "Przesyłanie zakończone pomyślnie, zapisano jako", + "verifyingReadFlash": "Weryfikowanie odczytanego flasha...", + "verifyingWrittenFlash": "Weryfikowanie zapisanego flasha...", + "wifiCredentialsIncomplete": "Niekompletne dane Wi-Fi! Wprowadź zarówno SSID, JAK i hasło, albo pozostaw oba pola puste!", + "wifiPassword": "Hasło Wi-Fi:", + "wifiSettings": "Dane Wi-Fi (opcjonalnie):", + "wifiSSID": "SSID Wi-Fi:", + "writingFinished": "Zapis zakończony", + "writingFlash": "Zapisywanie {{size}} MiB flasha..." + }, + "hint": "Ta sekcja jest dostępna tylko w przypadku korzystania z HTTPS i przeglądarki obsługującej Web Serial API, np. Chrome lub Edge. Jeśli chcesz zflashować swojego Tonieboxa ESP32, kliknij \"Proceed to HTTPS\", a zostaniesz automatycznie przekierowany/a do wersji HTTPS. Twoja przeglądarka może ostrzec, że połączenie nie jest bezpieczne. To normalne - potwierdź i mimo to przejdź do strony. Może być konieczne wyświetlenie dodatkowych szczegółów w ostrzeżeniu.", + "hintLegacyApproach": "Jeśli wolisz nie używać przeglądarki obsługującej WebSerial, nadal możesz skorzystać z metody klasycznej (bez GUI, tylko wiersz poleceń).", + "httpsSettings": "Ustawienia HTTPS", + "legacy": { + "checkBoxes": "Pokaż dostępne boxy", + "checkDumpIsOk": "Upewnij się, że zrzut jest poprawny i że możesz wyodrębnić z niego certyfikaty.", + "connectESP32": "Podłącz UART do Tonieboxa", + "dnsIntro": "Możesz ustawić IP/nazwę hosta bezpośrednio w obrazie lub zrobić to tak, jak opisano poniżej.", + "dumpCertificates": "Zrzuć certyfikaty dla TeddyCloud", + "flashCAreplacement": "Wgraj zastępczy CA dla TeddyCloud", + "flashCAreplacementText1": "Zastąp oryginalny CA w swoim zrzucie flasha za pomocą esptool.", + "flashCAreplacementText2": "Zmontuj ponownie swojego Tonieboxa. Jeśli już ustawiłeś/aś nazwę hosta TeddyCloud, możesz pominąć krok DNS dla ESP32; jeśli nie, przejdź do kroku DNS dla ESP32.", + "flashCAreplacementText3": "Twój Toniebox powinien teraz być w stanie połączyć się z Twoim TeddyCloud. Wykonaj freshnesscheck i kliknij \"Check available Boxes\".", + "flashESPtoolScreen": "Ekran ESPtool", + "followLegacyApproach": "Lub użyj metody klasycznej (bez GUI, tylko wiersz poleceń).", + "hint": "Dostępne jest bezpośrednie wsparcie flashowania Tonieboxa ESP32 z poziomu TeddyCloud!", + "installESPTool": "Zainstaluj aktualny ESPtool", + "installESPToolLink": "Repozytorium ESPtool na GitHub", + "installESPToolText": "Możesz wyodrębnić pamięć flash przez port debug boxa i esptool. Zachowaj kopię zapasową! Użyj aktualnej wersji esptool. (>v4.4)", + "navigationTitle": "Metoda klasyczna ESP32", + "next": "Dalej", + "previous": "Wstecz", + "proceedWithDNS": "Przejdź do kroku DNS", + "skipDnsIfAlreadyDone": "Jeśli już zastąpiłeś/aś IP/nazwę hosta w zrzucie flasha, możesz pominąć ten krok.", + "title": "Klasyczna metoda flashowania" + }, + "linkWikiGeneral": "Patchowanie Tonieboxa do użytku z TeddyCloud", + "linkWikiSpecific": "Konfiguracja specyficzna dla ESP32", + "navigationTitle": "ESP32", + "processing": "Przetwarzanie...", + "redirect": "Przejdź do HTTPS", + "title": "Flashowanie boxa ESP32" + }, + "hintLatestFirmware": "Przy pierwszej konfiguracji Tonieboxa z TeddyCloud upewnij się, że ma zainstalowany najnowszy firmware. Jeśli nie masz pewności, czy Twój Toniebox jest aktualny, podłącz go do oficjalnej chmury i daj trochę czasu na zakończenie aktualizacji firmware. Dodatkowo umieść oryginalne Tonie na Tonieboxie i sprawdź, czy działa prawidłowo.", + "hintLatestFirmwareFactoryResetCC3200": "Jeśli (przypadkowo) przywróciłeś/aś ustawienia fabryczne na zflashowanym Tonieboxie, samo jednorazowe uruchomienie OFW1 może wystarczyć do przywrócenia funkcjonalności - ponowne flashowanie może nie być konieczne.", + "hintLatestFirmwareFactoryResetESP32CC3235": "Jeśli (przypadkowo) przywróciłeś/aś ustawienia fabryczne na zflashowanym Tonieboxie, musisz wgrać kopię zapasową oryginalnego firmware przed ponownym zflashowaniem go do użytku z TeddyCloud.", + "hintLatestFirmwareTitle": "Ważne", + "lastOnline": "ostatnio online", + "lastPlayedTonie": "Ostatnio odtwarzany Tonie: ", + "linkToBoxCFW": "Otwórz interfejs webowy Tonieboxa", + "messages": { + "apiAccessDisabled": "Wyłączono dostęp do API TeddyCloud!", + "apiAccessDisabledDetails": "Wyłączono dostęp do API TeddyCloud dla Tonieboxa {{mac}}", + "apiAccessEnabled": "Włączono dostęp do API TeddyCloud!", + "apiAccessEnabledDetails": "Włączono dostęp do API TeddyCloud dla Tonieboxa {{mac}}", + "apiAccessNotChangedError": "Zmiana dostępu do API TeddyCloud nie powiodła się", + "apiAccessNotChangedErrorDetails": "Zmiana dostępu do API TeddyCloud dla Tonieboxa {{mac}} nie powiodła się: ", + "deleteFailed": "Usunięcie nie powiodło się", + "deleteFailedDetails": "Usunięcie Tonieboxa {{mac}} nie powiodło się", + "deleteSuccessful": "Usunięto pomyślnie!", + "deleteSuccessfulDetails": "Toniebox {{mac}} został pomyślnie usunięty z TeddyCloud!", + "deleting": "Usuwanie...", + "deletingDetails": "Usuwanie Tonieboxa {{mac}} z TeddyCloud" + }, + "modelHint": { + "action": "aby wybrać właściwy model swojego Tonieboxa", + "text": "Kliknij na" + }, + "navigationTitle": "Tonieboxy", + "newBoxesAllowed": "Dołączanie do TeddyCloud dozwolone", + "newBoxesAllowedText": "Możesz podłączać nowe Tonieboxy do TeddyCloud. Ze względów bezpieczeństwa gdy podłączysz już wszystkie swoje boxy, wyłącz ustawienie \"Allow new boxes\" .", + "noData": "Nie znaleziono Tonieboxów.", + "noDataText": "Tonieboxy pojawią się dopiero po nawiązaniu pomyślnego połączenia. Upewnij się, że Twój Toniebox jest prawidłowo podłączony.", + "noNewBoxesAllowed": "Nowe Tonieboxy nie są dozwolone", + "noNewBoxesAllowedText": "Nie możesz podłączać nowych Tonieboxów do TeddyCloud. Najpierw włącz ustawienie \"Allow new boxes\". Ze względów bezpieczeństwa gdy podłączysz już wszystkie swoje boxy, wyłącz ustawienie \"Allow new boxes\" .", + "offline": "offline", + "online": "online", + "processing": "Przetwarzanie...", + "tb2BoxFlashing": { + "navigationTitle": "TB2", + "title": "Flashowanie boxa TB2", + "underDevelopment": "W trakcie prac", + "underDevelopmentBePatient": "Obecnie pracujemy nad rozwiązaniem dla wersji TB2. Uzbrój się w cierpliwość i sprawdzaj tę stronę później, aby zobaczyć aktualizacje. W międzyczasie możesz śledzić postępy i dyskusje na naszym forum lub grupie Telegram." + }, + "title": "Twoje Tonieboxy", + "uploadTonieboxCertificatesModal": { + "AttentionGeneralPath": "(UWAGA: domyślna ścieżka!)", + "boxSpecific": " (nadpisane dla Tonieboxa)", + "Label": "Certyfikaty", + "uploadPath": "Ścieżka przesyłania (certDir):", + "uploadTonieboxCertificates": "Prześlij certyfikaty dla Tonieboxa {{name}}" + }, + "warningUseAtYourOwnRisk": "Ostrzeżenie: używasz na własne ryzyko!", + "warningUseAtYourOwnRiskText": "Flashowanie Tonieboxa spatchowanym firmware unieważni gwarancję i może doprowadzić do uszkodzenia boxa. Postępuj dokładnie zgodnie z instrukcjami i upewnij się, że rozumiesz związane z tym ryzyko." + }, + "tonies": { + "addNewCustomTonie": "Utwórz nowy model", + "addNewCustomTonieHint": "Utwórz nowy model i przypisz go do bieżącego tagu", + "addNewCustomTonieModal": { + "addAudioIdHash": "Dodaj audio ID i hash", + "addTrack": "Dodaj ścieżkę", + "audioId": "Audio ID", + "category": "Kategoria", + "episode": "Odcinek", + "episodeHint": "Będzie wyświetlane na Toniecard", + "failedToCreate": "Utworzenie modelu nie powiodło się!", + "failedToCreateDetails": "Utworzenie modelu {{series}} [{{model}}] nie powiodło się: ", + "formfieldTitle": "Tytuł", + "formfieldTitleHint": "Obecnie niewyświetlane na Toniecard", + "hash": "Hash", + "language": "Język", + "languageHint": "Format: en-gb", + "model": "Model", + "modelRequired": "Pole modelu musi być wypełnione.", + "no": "Nie", + "pic": "Adres URL obrazu", + "release": "Wydanie", + "save": "Zapisz", + "series": "Seria", + "seriesHint": "Będzie wyświetlane jako tytuł Toniecard", + "seriesRequired": "Pole serii musi być wypełnione.", + "successfullyCreated": "Model utworzony pomyślnie!", + "successfullyCreatedDetails": "Model {{series}} [{{model}}] utworzony pomyślnie!", + "title": "Utwórz nowy niestandardowy Tonie/Tag", + "track": "Ścieżka" + }, + "alternativeSource": "Do tego Tonie {{originalTonie}} przypisana jest zawartość {{assignedContent}}!", + "alternativeSourceUnknown": "Do tego Tonie {{originalTonie}} przypisana jest alternatywna zawartość!", + "assignNextEpisode": { + "action": "Przypisz następny odcinek \"{{file}}\"", + "failedDetails": "Nie udało się przypisać \"{{episode}}\": ", + "failedMessage": "Nie udało się przypisać następnego odcinka!", + "noneLeft": "W tym folderze nie ma już nieodsłuchanych odcinków.", + "successDetails": "Przypisano \"{{episode}}\" jako następny odcinek!", + "successMessage": "Przypisano następny odcinek!" + }, + "assignPrevEpisode": { + "action": "Przypisz poprzedni odcinek \"{{file}}\"", + "failedDetails": "Nie udało się przypisać \"{{episode}}\": ", + "failedMessage": "Nie udało się przypisać poprzedniego odcinka!", + "noneLeft": "W tym folderze nie ma już nieodsłuchanych odcinków.", + "successDetails": "Przypisano \"{{episode}}\" jako poprzedni odcinek!", + "successMessage": "Przypisano poprzedni odcinek!" + }, + "cancel": "Anuluj", + "closeAudioPlayer": "Zamknij odtwarzacz", + "closeAudioPlayerPopover": "Zamknięcie zatrzymuje odtwarzanie!", + "confirmAudioModelMismatchModal": { + "cancel": "Anuluj", + "confirm": "Kontynuuj", + "content": "Przypisane audio ma model \"{{audioModel}}\". Widoczny na boxie napis pochodzi z audio_id pliku TAF. Pusty model zostanie zresetowany do \"{{audioModel}}\". Kontynuować?", + "title": "Model a Audio", + "unknownModel": "nieznany" + }, + "confirmHideModal": { + "cancel": "Anuluj", + "confirmHideDialog": "Czy na pewno chcesz ukryć Tonie/Tag \"{{tonieToHide}}\" w widoku Tonies? Aby uczynić go ponownie widocznym, musisz umieścić Tonie/Tag na jednym ze swoich Tonieboxów.", + "hide": "Ukryj", + "title": "Ukryj Tonie/Tag" + }, + "connectionToBoxineNotAvailable": "Nie można połączyć się z Boxine/Toniecloud. Włącz dostęp, aby umożliwić pobieranie.", + "content": { + "navigationTitle": "Zawartość", + "showToniesOfBoxes": "Zawartość następujących Tonieboxów", + "title": "Zawartość", + "toniePicture": "Obraz Tonie" + }, + "currentPath": "Bieżąca ścieżka: ", + "customEditor": { + "actions": { + "browse": "Przeglądaj", + "delete": "Usuń", + "deleteCount": "Usuń {{count}} model(e/i)", + "discard": "Odrzuć", + "duplicate": "Duplikuj", + "edit": "Edytuj", + "newModel": "Nowy model", + "preview": "Podgląd", + "restore": "Przywróć" + }, + "audio": { + "libraryLabel": "Niestandardowe audio ({{library}})", + "locateInLibrary": "Znajdź w bibliotece", + "notFoundInLibrary": "Nie znaleziono w bibliotece", + "notInIndex": "Brak w indeksie niestandardowego audio: {{audioId}}/{{hash}}", + "placeholder": "Wybierz niestandardowe audio z {{library}}", + "selectFromLibrary": "Wybierz audio z {{library}}" + }, + "audioSource": { + "linkedDescription": "Zmiany dotyczą wszystkich plików i tagów korzystających z tego modelu.", + "linkedTitle": "{{file}} jest powiązany z tym modelem niestandardowym", + "newDescription": "Zapisanie tworzy nowy model niestandardowy i łączy go z plikiem przez identyfikator audio i hash.", + "newTitle": "Brak modelu niestandardowego dla {{file}}" + }, + "batch": { + "description": "Zmiany dotyczą wszystkich zaznaczonych modeli.", + "title": "Edycja zbiorcza aktywna" + }, + "coinHint": { + "description": "Opcjonalne - tylko dla metadanych (tytuł, ścieżki). Niestandardowe tagi działają bez modelu. Używaj tylko niestandardowego audio - oficjalne ID nadpisują prawdziwe Tonies. Nie łączy tagów NFC z audio.", + "title": "Wskazówka: przypisanie audio" + }, + "columns": { + "actions": "Akcje", + "image": "Obraz" + }, + "deleteConfirm": { + "abort": "Przerwij", + "confirm": "Usuń", + "deleted": "Model \"{{model}}\" został usunięty.", + "description": "Czy na pewno usunąć \"{{model}}\"? Tej operacji nie można cofnąć.", + "title": "Usuń model" + }, + "duplicateTitleSuffix": "Kopia", + "editModalTitle": "Edytuj model {{current}} z {{total}}", + "editModalTitleCreate": "Nowy model", + "editModalTitleEdit": "Edytuj model", + "emptyState": "Brak niestandardowych modeli", + "emptyStateHint": "tonies.custom.json jest pusty. Dodaj swój pierwszy model.", + "errors": { + "duplicateAudioHash": "Zduplikowana kombinacja audio_id+hash: {{audioId}}", + "duplicateModel": "Duplikat: {{model}}", + "emptyResult": "Musi pozostać przynajmniej jeden model.", + "invalidLanguageCode": "Nieprawidłowy kod języka. Użyj kodu takiego jak {{example}}.", + "invalidSelectedIndex": "Nieprawidłowy zaznaczony indeks", + "modelExistsInBase": "Model istnieje w podstawowym tonies.json: {{models}}", + "releaseNumeric": "Wydanie musi być liczbą" + }, + "filterFieldsPlaceholder": "Szukaj w polach", + "filterPlaceholder": "Filtruj modele...", + "modelSavedSuccessful": "Model {{title}} został pomyślnie zapisany", + "modelsTitle": "Modele", + "noChangesBody": "Obecnie nie ma nic do zapisania.", + "noChangesTitle": "Nie wykryto zmian", + "optionalColumns": "Opcjonalne kolumny", + "overridesOriginal": { + "description": "Zapisane zmiany zastępują oryginalne informacje wszędzie tam, gdzie używane jest to audio lub ten model. Usuń model niestandardowy, aby ponownie wyświetlić oryginał.", + "tag": "Zastępuje oryginał {{model}}", + "title": "Zastępuje oryginalnego Tonie {{model}} ({{name}})" + }, + "pagination": { + "modelsPerPage": "modeli/stronę" + }, + "picHint": "Wybierz obraz z custom_img lub z oryginalnych tonies", + "preflight": { + "confirm": "Zapisz teraz", + "deletes": "Usunięcia: {{count}}", + "description": "Potwierdź zakres tej operacji zapisu.", + "renames": "Zmiany nazw: {{count}}", + "title": "Przejrzyj zmiany przed zapisem", + "upserts": "Aktualizacje/nowe wpisy: {{count}}" + }, + "previewTitle": "Podgląd obrazu", + "renameConfirm": { + "abort": "Przerwij", + "confirm": "Potwierdź", + "description": "ID modelu zmieni się z \"{{from}}\" na \"{{to}}\". Powiązane Tonies zostaną zaktualizowane automatycznie.", + "skipUpdate": "Usuń przypisanie modelu z powiązanych Tonies (audio pozostaje)", + "title": "Zmień nazwę modelu" + }, + "renameFailed": "Zmiana nazwy nie powiodła się", + "savedState": "Wszystkie zmiany zapisane", + "saveSuccessWithCount": "tonies.custom.json zapisany ({{count}} wpisów). Uruchomiono kopię zapasową i ponowne wczytanie.", + "sections": { + "audio": "Przypisanie audio", + "audioAssignment": "Przypisanie audio", + "media": "Multimedia i obrazy", + "metadata": "Opcjonalne metadane", + "tracks": "Ścieżki" + }, + "selection": { + "description": "Poniższy formularz edycji zawsze dotyczy bieżącego zaznaczenia.", + "multiple": "Zaznaczono {{count}} modeli", + "none": "Nie zaznaczono modelu", + "single": "Zaznaczony model: {{model}}" + }, + "tableMenu": "Menu tabeli", + "title": "Edytor modeli", + "tracks": { + "fewerNamesDescription": "Plik ma {{tracks}} ścieżek. Nazwy zachowują swoją pozycję, ale okno informacji pozwala przejść do ścieżki tylko wtedy, gdy wymienione są wszystkie ścieżki aż do ostatniej.", + "fewerNamesTitle": "Ostatnie ścieżki pliku nie mają nazwy", + "moreNamesDescription": "Dodatkowe nazwy są zapisywane i wyświetlane, ale nie mają pozycji w pliku. Okno informacji nie może wtedy przechodzić do poszczególnych ścieżek.", + "moreNamesTitle": "Więcej nazw niż ścieżek w pliku ({{tracks}})", + "notInFile": "Dodatkowe nazwy bez ścieżki w pliku:" + }, + "unsaved": "{{count}} niezapisanych zmian", + "validation": { + "title": "Najpierw popraw poniższe problemy" + } + }, + "customImages": { + "navigationTitle": "Obrazy", + "title": "Zarządzaj niestandardowymi obrazami" + }, + "customModelPicker": { + "addNew": "Dodaj nowy", + "loading": "Wczytywanie...", + "noModels": "Nie znaleziono niestandardowych modeli", + "searchPlaceholder": "Szukaj modelu, serii...", + "title": "Wybierz niestandardowy model" + }, + "customToniesEditorJsonEntry": "Niestandardowe modele", + "editModal": { + "audioInfoHeading": "Informacje o audio", + "createNewModelTooltip": "Utwórz nowy model", + "editModelTooltip": "Edytuj model", + "editSelectedCustomModel": "Kontynuuj edycję modelu", + "invalidModelError": "Model \"{{model}}\" jest nieprawidłowy. Wybierz istniejący model z wyszukiwania.", + "model": "Model", + "modelInfoAudioPath": "Ścieżka audio", + "modelInfoCategory": "Kategoria", + "modelInfoEpisode": "Odcinek", + "modelInfoHeading": "Informacje o modelu", + "modelInfoLanguage": "Język", + "modelInfoModel": "Model", + "modelInfoNo": "Nie", + "modelInfoRelease": "Wydanie", + "modelInfoSeries": "Seria", + "modelInfoTitle": "Tytuł", + "modelReadOnlyHint": "Oryginalny tonie: model nie może być zmieniony. Możesz przypisać tylko niestandardowe audio.", + "placeholderSearchForAModel": "Szukaj modelu", + "placeholderSearchForARadioStream": "Szukaj strumienia radiowego", + "save": "Zapisz", + "setAudioFromModel": "Ustaw audio na podstawie modelu", + "setAudioFromModelNoMapping": "Wybrany model nie ma przypisanego audio.", + "setAudioFromModelUnavailableInLibrary": "Audio modelu nie jest jeszcze dostępne w bibliotece.", + "setModelFromAudio": "Ustaw model na podstawie audio", + "source": "Źródło", + "title": "Edytuj Tonie/Tag" + }, + "encoder": { + "browserEncodingInProgress": "Kodowanie plików audio w przeglądarce...", + "browserSideEncoding": "Kodowanie po stronie przeglądarki", + "clearList": "Wyczyść listę", + "encode": "Koduj", + "encoding": "Kodowanie", + "errorFileProcessing": "Nie udało się przetworzyć pliku audio: ", + "filename": "Nazwa pliku", + "loading": "Wczytywanie...", + "loadingWasmEncoder": "Wczytywanie kodera WASM...", + "maxFiles": "Możesz dodać maksymalnie {{maxFiles}} plików.", + "navigationTitle": "Koder audio", + "processing": "Przetwarzanie...", + "processingDetails": "Kodowanie plików do \"{{file}}\" w toku...", + "processingError": "Błąd podczas przetwarzania!", + "removeFile": "Usuń plik", + "saveAs": "Zapisz plik jako", + "serverSideEncoding": "Kodowanie po stronie serwera", + "sortAlphabeticallyLiteral": "Sortuj alfabetycznie (leksykograficznie)", + "sortAlphabeticallyNatural": "Sortuj alfabetycznie (kolejność numeryczna)", + "targetDirectory": "Wybierz katalog docelowy", + "title": "Koder audio", + "tooManyFilesError": "Zbyt wiele plików!", + "unsupportedFileType": "Nieobsługiwany typ pliku!", + "unsupportedFileTypeDetails": "Plik \"{{file}}\" ma nieobsługiwany typ!", + "upload": "Prześlij", + "uploadFailed": "Przesyłanie nie powiodło się", + "uploadFailedDetails": "Podczas przesyłania wystąpił błąd: ", + "uploadFiles": "Wybierz plik(i)", + "uploadHint": "Przeciągnij tutaj pliki audio!", + "uploading": "Przesyłanie...", + "uploadingDetails": "Przesyłanie pliku \"{{file}}\"...", + "uploadSuccessful": "Kodowanie pliku zakończone pomyślnie!", + "uploadSuccessfulDetails": "Pliki zostały zakodowane jako \"{{file}}\" i dodane do biblioteki pomyślnie!", + "uploadText": "Kliknij lub przeciągnij do {{maxFiles}} plików audio, które chcesz przekonwertować na plik taf, aby je przesłać w tym obszarze" + }, + "errorFetchingSystemSounds": "Błąd podczas pobierania dźwięków systemowych", + "errorFetchingTonies": "Błąd podczas pobierania Tonies", + "help": { + "actionItems": { + "cloudIcon": { + "text": "Szara ikona oznacza, że synchronizacja z chmurą jest włączona; czerwona oznacza, że jest wyłączona. Gdy jest włączona, Tonie/Tag może synchronizować się z Boxine/Toniecloud. Kliknij ikonę, aby przełączyć.", + "title": "Status chmury" + }, + "downloadIcon": { + "text": "Jeśli dostęp do chmury jest włączony i autoryzowany, kliknięcie tej ikony pobierze zawartość z Boxine/Toniecloud.", + "title": "Pobierz zawartość" + }, + "editIcon": { + "createNewModel": { + "description": "Otwiera edytor modeli niestandardowych, aby wprowadzić szczegóły nowego modelu dla Twojego Tonie/Tag.", + "text": "Utwórz nowy niestandardowy model dla swojego Tonie/Taga.", + "title": "Utwórz nowy model" + }, + "editModel": { + "description": "Edytuje wybrany model niestandardowy w edytorze modeli niestandardowych. Wpisy audio od razu pokazują identyfikator audio i hash; ikona lokalizacji przy wpisie pozwala znaleźć jego plik w bibliotece.", + "text": "Edytuje model niestandardowy przypisany do Tonie/Tag.", + "title": "Edytuj model niestandardowy" + }, + "model": { + "description": "Wyszukaj model i przypisz go do Tonie/Taga. Obraz zostanie wyświetlony w widoku przeglądowym.", + "text": "Reprezentuje wizualny model przypisany do Tonie/Taga.", + "title": "Model" + }, + "radioStreamSearch": { + "description": "Użyj pola wyszukiwania, aby znaleźć i przypisać strumień z bazy radio-browser.", + "text": "Wyszukaj internetowe strumienie radiowe (dostępne, jeśli API radio-browser.info jest osiągalne).", + "title": "Wyszukiwanie strumienia radiowego" + }, + "setAudioFromModel": { + "description": "Pojawia się, gdy wybrano model niepasujący do bieżącego audio. Biblioteka jest przeszukiwana dopiero po kliknięciu przycisku, co może chwilę potrwać. Jeśli nie znaleziono pasującego pliku, pod polem źródła wyświetlana jest wskazówka.", + "text": "Przypisuje plik audio należący do wybranego modelu.", + "title": "Ustaw audio z modelu" + }, + "setModelFromAudio": { + "description": "Pojawia się, gdy przypisane audio należy do znanego modelu. Przeszukiwanie biblioteki nie jest potrzebne, model jest pobierany z informacji o audio.", + "text": "Przypisuje model należący do bieżącego audio.", + "title": "Ustaw model z audio" + }, + "source": { + "description": "Możesz przypisać zawartość ze swojej biblioteki (np. pliki TAF lub TAP) lub wprowadzić adres URL strumienia.", + "text": "Wyświetla aktualnie przypisane źródło zawartości.", + "title": "Źródło" + }, + "text": "Otwiera okno edycji z następującymi opcjami konfiguracji:", + "title": "Edytuj Tonie/Tag" + }, + "infoIcon": { + "download": { + "description": "Jeśli dostępne, utwory można pobrać jako osobne pliki *.ogg (lub jako jeden plik *.ogg).", + "text": "Otwiera wybór utworów do pobrania pliku TAF jako *.ogg.", + "title": "Pobierz TAF jako *.ogg" + }, + "exists": { + "description": "Status 'exists' jest ustawiony na 'tak', jeśli przypisany plik źródłowy istnieje.", + "text": "Pokazuje, czy przypisane źródło Tonie/Taga istnieje w systemie.", + "title": "Istnieje" + }, + "hide": { + "description": "Użyj tej opcji, aby ukryć Tonie/Tag. Aby pokazać go ponownie, umieść Tonie/Tag na jednym ze swoich Tonieboxów.", + "text": "Usuwa Tonie/Tag z widoku przeglądowego.", + "title": "Ukryj Tonie/Tag" + }, + "text": "Otwiera okno wyświetlające kluczowe informacje o Tonie/Tagu, w tym tytuł, obraz, UID, listę utworów i różne wskaźniki statusu:", + "title": "Informacje o Tonie/Tagu", + "valid": { + "description": "Tonie/Tag jest uznawany za prawidłowy, jeśli jego przypisane źródło jest prawidłowym plikiem TAF.", + "text": "Pokazuje, czy przypisane źródło Tonie/Taga jest prawidłowe.", + "title": "Prawidłowy" + } + }, + "liveIcon": { + "text": "Czerwona ikona oznacza tryb 'live', co oznacza, że zawartość zawsze zacznie się od początku i zostanie pobrana ponownie. Jest to automatycznie włączane dla strumieni radiowych. Kliknij, aby przełączyć.", + "title": "Status live" + }, + "playIcon": { + "text": "Odtwarza zawartość przypisaną do Tonie/Taga. Jeśli ikona jest jasnoszara, nie przypisano żadnej odtwarzalnej zawartości.", + "title": "Odtwórz zawartość" + } + }, + "blueTopBorder": { + "text": "Wskazuje, że ten Tonie/Tag był ostatnio odtwarzany na przynajmniej jednym z Twoich Tonieboxów.", + "title": "Niebieska górna ramka" + }, + "closeButton": "Zamknij", + "modelImage": { + "bigImage": { + "description": "Ten obraz odzwierciedla wybrany model przypisany do Tonie/Taga. Aby go zmienić, przypisz inny model.", + "text": "Reprezentuje wygląd wizualny Tonie/Taga.", + "title": "Duży obraz" + }, + "smallImage": { + "description": "Wyświetlany, gdy przypisane źródło różni się od wybranego modelu.", + "text": "Wizualny wskaźnik rzeczywistego źródła, jeśli różni się ono od przypisanego modelu.", + "title": "Mały obraz" + }, + "text": "Wyświetla wizualną reprezentację Tonie/Taga, jeśli jest rozpoznany. Nieznane Tonies/Tagi pokażą znak zapytania. Jeśli włączone jest ustawienie 'Frontend' → 'Podziel zawartość / model', mogą pojawić się dwa obrazy:", + "title": "Obraz modelu Tonie/Taga" + }, + "title": "Pomoc" + }, + "imageManager": { + "noOriginalImages": "Nie znaleziono oryginalnych obrazów", + "noOriginalSearchResults": "Żaden adres URL nie pasuje do wyszukiwania", + "okText": "Zastosuj", + "originalImagesLoading": "Wczytywanie oryginalnych obrazów …", + "originalUrlColumn": "URL", + "originalUrlSearchPlaceholder": "Słowa do znalezienia w URL (wszystkie muszą pasować, kolejność nieistotna) …", + "sourceCustom": "Niestandardowy", + "sourceOriginal": "Oryginalny", + "title": "Biblioteka: wybierz i prześlij obrazy", + "titleSelect": "Wybierz obraz" + }, + "infoModal": { + "download": "Pobierz TAF jako *.ogg", + "exists": "Istnieje:", + "model": "Model:", + "no": "Nie", + "tracklist": "Lista utworów:", + "valid": "Prawidłowy:", + "yes": "Tak" + }, + "informationModal": { + "hideTag": "Ukryj Tonie/Tag", + "ok": "OK", + "unknownModel": "Nieznany model" + }, + "lastPlayedOnModal": { + "lastPlayedOn": "Ostatnio odtwarzany Tonie", + "lastPlayedOnMessage": "Ten Tonie był ostatnio odtwarzany na", + "title": "Ten Tonie był odtwarzany jako ostatni!" + }, + "library": { + "navigationTitle": "Biblioteka", + "title": "Biblioteka" + }, + "libraryAudio": { + "navigationTitle": "Audio" + }, + "messages": { + "cloudAccessBlocked": "Dostęp do chmury zablokowany!", + "cloudAccessBlockedDetails": "Dostęp do chmury zablokowany dla \"{{model}}\" [{{ruid}}]", + "cloudAccessEnabled": "Dostęp do chmury włączony!", + "cloudAccessEnabledDetails": "Dostęp do chmury włączony dla \"{{model}}\" [{{ruid}}]", + "couldNotChangeCloudFlag": "Nie udało się zmienić flagi dostępu do chmury (nocloud)! ", + "couldNotChangeCloudFlagDetails": "Nie udało się zmienić flagi dostępu do chmury (nocloud) dla \"{{model}}\" [{{ruid}}]: ", + "couldNotChangeLiveFlag": "Nie udało się zmienić flagi live! ", + "couldNotChangeLiveFlagDetails": "Nie udało się zmienić flagi live dla \"{{model}}\" [{{ruid}}]: ", + "couldNotEmptySource": "Nie udało się wyczyścić źródła!", + "couldNotSetSource": "Nie udało się ustawić źródła! ", + "downloadedFile": "Pobrano plik", + "downloadedFileDetails": "Pobrano plik źródłowy \"{{model}}\" [{{ruid}}]", + "downloading": "Pobieranie...", + "downloadingDetails": "Pobieranie \"{{model}}\" [{{ruid}}]...", + "errorDuringDownload": "Błąd podczas pobierania w tle", + "errorDuringDownloadDetails": "Błąd podczas pobierania w tle \"{{model}}\" [{{ruid}}]: ", + "errorFetchingUpdatedCard": "Błąd podczas pobierania zaktualizowanej karty!", + "errorFetchingUpdatedCardDetails": "Błąd podczas pobierania zaktualizowanej karty dla \"{{model}}\" [{{ruid}}]: ", + "filterDeleted": "Filtr usunięty", + "filterDeletedDetails": "Filtr {{name}} został pomyślnie usunięty!", + "filterLoaded": "Filtr wczytany", + "filterLoadedDetails": "Filtr {{name}} został pomyślnie wczytany!", + "filterSaved": "Filtr zapisany", + "filterSavedDetails": "Filtr {{name}} został pomyślnie zapisany!", + "hideTonieFailed": "Nie udało się ukryć Tonie/Taga", + "hideTonieFailedDetails": "Nie udało się ukryć Tonie/Taga [{{ruid}}]: ", + "hideTonieSuccessful": "Tonie/Tag pomyślnie oznaczony jako ukryty!", + "hideTonieSuccessfulDetails": "Tonie/Tag [{{ruid}}] pomyślnie oznaczony jako ukryty!", + "liveDisabled": "Live wyłączone!", + "liveDisabledDetails": "Flaga live wyłączona dla \"{{model}}\" [{{ruid}}]", + "liveEnabled": "Live włączone!", + "liveEnabledDetails": "Flaga live włączona dla \"{{model}}\" [{{ruid}}]", + "noFilterFound": "Nie znaleziono filtru", + "noFilterFoundDetails": "Nie znaleziono filtru o nazwie {{name}}", + "setToEmptyValue": "puste", + "setTonieToModelFailed": "Nie udało się zmienić modelu tonie!", + "setTonieToModelFailedDetails": "Nie udało się zmienić modelu tonie dla [{{ruid}}]: ", + "setTonieToModelSuccessful": "Ustawienie modelu tonie zakończone pomyślnie!", + "setTonieToModelSuccessfulDetails": "Ustawiono model tonie na {{selectedModel}} dla [{{ruid}}]", + "setTonieToSourceFailed": "Nie udało się zmienić źródła tonie!", + "setTonieToSourceFailedDetails": "Nie udało się zmienić źródła tonie dla [{{ruid}}]: ", + "setTonieToSourceSuccessful": "Ustawienie źródła tonie zakończone pomyślnie!", + "setTonieToSourceSuccessfulDetails": "Ustawiono źródło tonie na {{selectedSource}} dla [{{ruid}}]" + }, + "navigationTitle": "Tonies", + "noData": "Nie znaleziono Tonies/niestandardowych tagów.", + "noDataText": "Tonies/niestandardowe tagi pojawią się dopiero po nawiązaniu przez Toniebox pomyślnego połączenia i posiadaniu Tonies/niestandardowych tagów na karcie SD. Upewnij się, że Twój Toniebox jest prawidłowo podłączony i ma na sobie jakieś Tonies/niestandardowe tagi, lub umieść swój pierwszy Tonie/niestandardowy tag na Tonieboxie i odśwież tę stronę.", + "noPlayableContentHint": "Brak zawartości dostępnej do odtworzenia. Albo nie jest przypisana, albo brakuje danych z chmury Boxine/Tonies. Umieść figurkę na boxie, aby to naprawić.", + "selectFileModal": { + "cancel": "Anuluj", + "ok": "OK", + "selectFile": "Wybierz plik" + }, + "selectMode": { + "cancel": "Anuluj", + "cancelButtonTooltip": "Anuluj tryb zaznaczania", + "exportCompleteInfoToJson": "Eksportuj zaznaczone - pełne informacje - do JSON", + "exportCsv": "Eksportuj jako CSV", + "exportCsvTooltip": "Eksportuj tagi jako plik CSV z informacjami o serii, odcinku i modelu Tonie/tagu (bez przypisanej zawartości)", + "exportToHtml": "Eksportuj do HTML", + "exportToHtmlInline": "Eksportuj do HTML z osadzonymi obrazami, jeśli to możliwe (wolne!)", + "exportToJson": "Eksportuj do JSON", + "hideSelectedTags": "Ukryj tagi", + "select": "Wybierz", + "selectAll": "Zaznacz wszystko", + "selectButtonTooltip": "Włącz tryb zaznaczania", + "selected": "Zaznaczono tagów/Tonies", + "setLive": "Włącz live", + "setNoCloud": "Wyłącz chmurę", + "unselectAll": "Odznacz wszystko", + "unsetLive": "Wyłącz live", + "unsetNoCloud": "Włącz chmurę" + }, + "system-sounds": { + "navigationTitle": "Dźwięki systemowe", + "title": "Dźwięki systemowe" + }, + "tafDownload": { + "asOneFile": "Pobierz jako jeden plik *.ogg", + "downloadCount": "Pobierz utwory ({{count}}) jako *.ogg", + "downloadOne": "Pobierz 1 utwór jako *.ogg", + "duration": "Czas", + "modalTitle": "Pobierz utwory jako *.ogg", + "selectAll": "Zaznacz wszystkie utwory", + "selectedCount": "Wybrano {{selected}} z {{total}}", + "track": "Utwór", + "unnamedTrack": "Utwór {{number}}" + }, + "tafHeaderInvalid": "Nieprawidłowy TAF", + "tafHeaderOf": "Nagłówek TAF dla ", + "tafHeaderToniesJsonHint": "Nie wolno edytować tych danych podczas przesyłania ich do tonies-json. Dotyczy to również liczby ścieżek, nawet jeśli różni się ona od liczby ścieżek w polu track-desc!", + "tap": { + "navigationTitle": "Playlisty TeddyAudio", + "title": "Playlisty TeddyAudio" + }, + "tapEditor": { + "addFile": "Dodaj plik", + "audioId": "Audio ID", + "audioIdTooltip": "Ustawia audio id, jeśli plik jest zapisany w pamięci podręcznej. Ustaw na 0, aby wymusić ponowne utworzenie pliku w pamięci podręcznej", + "cancel": "Anuluj", + "create": "Utwórz", + "fileNameContentFile": "Opis", + "filePath": "Ścieżka pliku playlisty", + "filePathContentFile": "Ścieżka pliku", + "filePathContentFileRequired": "Ścieżka pliku musi być ustawiona", + "filePathContentFileTooltip": "Ustawia ścieżkę do zasobu audio (musi być zgodna z ffmpeg)", + "filePathRequired": "Ścieżka pliku playlisty musi być ustawiona", + "filePathTooltip": "Ustawia ścieżkę pliku TAF, który zostanie utworzony przez ffmpeg", + "name": "Nazwa TAP", + "nameRequired": "Nazwa TAP musi być ustawiona", + "nameTooltip": "Ustawia nazwę playlisty", + "save": "Zapisz", + "titleCreate": "Utwórz nowy TAP w {{path}}", + "titleCreateButton": "Utwórz nowy TAP", + "titleEdit": "Edytuj TAP {{name}}" + }, + "teddyaudioplayer": { + "continueInFooterAudioPlayer": "Kontynuuj w odtwarzaczu w stopce", + "enterFullscreen": "Włącz pełny ekran", + "exitFullscreen": "Wyłącz pełny ekran", + "expand": "Rozwiń odtwarzacz audio", + "navigationTitle": "Odtwarzacz audio", + "noTracks": "Brak dostępnych ścieżek", + "openInTonieAudioPlayer": "Otwórz w odtwarzaczu audio", + "openStandalone": "Otwórz odtwarzacz w trybie samodzielnym", + "selectTonieToPlay": "Wybierz Tonie/tag do odtworzenia", + "shrink": "Zmniejsz odtwarzacz audio", + "standaloneTitle": "Odtwarzacz audio", + "title": "Odtwarzacz audio", + "unknown": "Nieznany" + }, + "teddystudio": { + "adaptLabelsHint": "Możesz dostosować teksty i obrazy etykiet, klikając przycisk edycji na każdej grupie etykiet.", + "addedCustomImageHint": "Niestandardowy obraz został dodany.", + "addedHint": "\"{{title}}\" zostało dodane.", + "bulkAdd": { + "button": "Dodaj tonies zbiorczo", + "modal": { + "clearSelection": "Wyczyść wszystko", + "confirm": "Dodaj {{count}} do arkusza", + "itemsUnit": "tonies", + "itemUnit": "tonie", + "leftTitle": "tonies.json", + "notFound": "Brak pasujących tonies", + "resultsTruncated": "Wyświetlono pierwsze {{shown}} z {{total}} — wpisz więcej, aby zawęzić", + "rightTitle": "W kolejce do dodania", + "searchHint": "Wpisz co najmniej 2 znaki, aby przeszukać tonies.json.", + "searchPlaceholder": "Szukaj", + "searchPrompt": "Szukaj w tonies.json…", + "selectAll": "Zaznacz wszystko", + "selectedCount": "{{count}} w kolejce do dodania", + "title": "Dodaj tonies zbiorczo do arkusza wydruku" + } + }, + "cancel": "Anuluj", + "clear": "Wyczyść arkusz", + "clearSettings": "Przywróć domyślne", + "close": "Zamknij", + "contentPadding": "Wewnętrzny margines etykiety", + "currentLabel": "Bieżąca etykieta", + "custom": "Niestandardowy", + "customImage": "Niestandardowe obrazy", + "customImageHint": "Możesz dodać obrazy tymczasowo - NIE zostaną one dodane do biblioteki i są używane tylko dla tego arkusza wydruku.", + "customImageTitle": "Wpisz tytuł do wydrukowania", + "customImageUpload": "Dodaj tymczasowy obraz (bez biblioteki)", + "diameter": "Średnica wewnętrzna", + "downloadPdf": "Pobierz PDF", + "empty": "Pusto - najpierw wybierz Tonies lub Tagi albo dodaj niestandardowy obraz!", + "episodes": "Odcinki", + "fontFamily": "Czcionka etykiety", + "fontFamilyPlaceholder": "Wybierz czcionkę", + "height": "Wysokość", + "imageBottom": "Dolny margines obrazu", + "imageBottomLeft": "Margines obrazu dół/lewo", + "imageLeft": "Lewy margines obrazu", + "imagePosition": "Pozycja obrazu", + "imageScale": "Skala obrazu", + "intro": "Przeszukaj wszystkie Tonies i tagi - zarówno oficjalne, jak i Twoje niestandardowe - aby utworzyć drukowalny arkusz Traveltonies.", + "labelBackgroundColor": "Tło", + "labelEditTitle": "Edytuj elementy etykiety", + "labelImage": "Obraz etykiety", + "labelImagePreview": "Obraz", + "labelSavedSuccesful": "Etykieta zapisana pomyślnie", + "labelSavedSuccesfulDetails": "Etykieta {{title}} została pomyślnie zapisana", + "labelSettings": "Ustawienia etykiety", + "labelShape": "Kształt etykiety", + "labelSize": "Szerokość/wysokość etykiety", + "labelSpacing": "Odstęp", + "labelSpacingX": "X", + "labelSpacingY": "Y", + "labelTitle": "Tytuł", + "labelTracklist": "Lista utworów", + "navigationTitle": "TeddyStudio", + "paperCustomSize": "Szerokość/wysokość papieru", + "paperLabelImageBleed": "Spad obrazu", + "paperMargin": "Margines góra/lewo", + "paperSettings": "Ustawienia papieru", + "paperSize": "Rozmiar papieru", + "placeholder": "Wpisz szukane hasło!", + "preview": "Podgląd etykiety", + "printMarginNotification": "Marginesy wydruku CSS są niezawodnie obsługiwane tylko w Chrome. Aby zapewnić prawidłowy wydruk etykiet, użyj Chrome.", + "printMode": "Tryb druku", + "printModeImageAndText": "Obraz + tekst", + "printModeImageAndTextTooltip": "Wydrukuj obie strony Traveltonie - obraz tagu na jednej stronie oraz tytuł i informacje o odcinku na drugiej", + "printModeOnlyImage": "Obraz", + "printModeOnlyImageTooltip": "Wydrukuj tylko jedną stronę z obrazem tagu", + "printModeOnlyText": "Tekst", + "printModeOnlyTextTooltip": "Wydrukuj tylko jedną stronę z tytułem i informacjami o odcinku", + "printPage": "Drukuj arkusz", + "printSheet": "Twój arkusz wydruku", + "printTrackListInsteadTitle": "Drukuj listę utworów zamiast tytułu", + "replaceLabelImage": "Upuść tutaj obraz lub wybierz plik, aby zastąpić istniejący obraz etykiety", + "restoreCommonSettings": "Przywróć", + "round": "Okrągły", + "save": "Zapisz", + "saveOnNavigate": "Automatyczny zapis przy nawigacji", + "saveSettings": "Zapisz jako preset", + "selectPredefinedPaperOptions": "Wybierz preset papieru", + "series": "Seria", + "seriesOnImageLabelFontSize": "Rozmiar czcionki serii", + "seriesOnImageLabelRotationDeg": "Obróć serię", + "settings": "Ustawienia", + "settingsSavedSuccessful": "Ustawienia TeddyStudio zapisane pomyślnie", + "showLabelBorder": "Drukuj ramkę etykiety", + "showLanguageFlag": "Drukuj flagę języka", + "showModelNo": "Drukuj nr modelu", + "showSeriesOnImageLabel": "Pokaż serię na etykiecie z obrazem", + "square": "Kwadratowy", + "textFontSize": "Rozmiar tekstu", + "title": "TeddyStudio", + "trackTitles": "Tytuły ścieżek (jeden na linię)", + "width": "Szerokość" + }, + "title": "Tonies", + "tonies": { + "filterBar": { + "applyFilters": "Zastosuj filtr", + "basicFilters": "Filtr podstawowy", + "customFilter": { + "invalidFieldUnique": "Nieprawidłowe pole w unique(): {{field}}", + "invalidToken": "Nieprawidłowy token: {{token}}", + "label": "Filtr niestandardowy (zaawansowany)", + "placeholder": "Wpisz niestandardowy filtr, np. live && !nocloud && language=de-de", + "syntaxError": "Błąd składni", + "unknownFieldComparison": "Nieznane pole w porównaniu: {{field}}", + "unknownFieldNegation": "Nieznane pole w negacji: {{field}}", + "unknownFieldRegex": "Nieznane pole w wyrażeniu regularnym: {{field}}" + }, + "customFilterHelp": { + "fields": { + "claimed": "Czy ten Tonie został przypisany (true/false).", + "episode": "Nazwa odcinka Tonie.", + "exists": "Czy Tonie istnieje (true/false).", + "hasCloudAuth": "Czy Tonie ma autoryzację w chmurze (true/false).", + "language": "Język Tonie.", + "live": "Czy Tonie jest oznaczony jako live (true/false).", + "model": "Model Tonie.", + "nocloud": "Czy Tonie jest oznaczony jako nocloud (true/false).", + "picture": "Ścieżka lub URL obrazu przypisanego do Tonie.", + "ruid": "Odwrócony UID Tonie małymi literami, bez dwukropków (:).", + "series": "Nazwa serii Tonie.", + "source": "Przypisane źródło Tonie (taf, tap lub stream).", + "track": "Wyszukaj dokładną nazwę ścieżki w Tonie.", + "trackcount": "Liczba ścieżek w Tonie.", + "tracks": "Tablica nazw ścieżek w Tonie.", + "trackseconds": "Tablica z pozycją początkową w sekundach każdej ścieżki w Tonie.", + "tracksecondscount": "Liczba pozycji początkowych ścieżek w Tonie.", + "uid": "UID (unikalny identyfikator) Tonie.", + "valid": "Czy Tonie jest prawidłowy (true/false)." + }, + "intro": "Użyj filtra niestandardowego, aby filtrować Tonies za pomocą wyrażeń logicznych i warunków dla konkretnych pól.", + "syntaxExamples": { + "and": "Filtruje Tonies, gdzie seria to 'Pixi' ORAZ Tonie jest prawidłowy.", + "combined": "Filtruje Tonies, które są live, mają dostęp do chmury, język niemiecki i są unikalne w serii.", + "endswith": "Filtruje Tonies, gdzie pole kończy się podaną wartością.", + "inList": "Filtruje Tonies, gdzie pole pasuje do dowolnej z podanych wartości.", + "isEmpty": "Filtruje Tonies, gdzie pole jest puste.", + "isNotEmpty": "Filtruje Tonies, gdzie pole nie jest puste.", + "negation": "Filtruje Tonies, które mają dostęp do chmury.", + "notInList": "Filtruje Tonies, gdzie pole nie pasuje do żadnej z podanych wartości.", + "or": "Filtruje Tonies z językiem niemieckim lub francuskim.", + "series": "Filtruje Tonies, gdzie seria to 'Pixi'.", + "startswith": "Filtruje Tonies, gdzie pole zaczyna się podaną wartością.", + "title": "Przykłady składni", + "trackCount": "Filtruj według liczby ścieżek", + "trackExact": "Filtruj ścieżki według dokładnej nazwy", + "trackSecondsCountCompare": "Filtruj według liczby pozycji początkowych ścieżek", + "trackSecondsCountEqualsTrackCount": "Filtruj tonies, dla których długość tablicy pozycji początkowych ścieżek jest równa liczbie ścieżek", + "trackSecondsCountNotEqualsTrackCount": "Filtruj tonies, dla których długość tablicy pozycji początkowych ścieżek nie jest równa liczbie ścieżek", + "tracksRegex": "Filtruj ścieżki za pomocą wzorca regex", + "tracksTitle": "Przykłady związane ze ścieżkami:", + "unique": "Filtruje Tonies, które są jedyne w swojej serii." + }, + "table": { + "description": "Opis", + "field": "Pole" + }, + "tips": { + "caseSensitive": "Nazwy pól rozróżniają wielkość liter; używaj małych liter dokładnie tak, jak pokazano.", + "combine": "Łącz wiele warunków za pomocą AND / OR i neguj za pomocą !.", + "parentheses": "Użyj nawiasów () dla złożonych wyrażeń.", + "title": "Wskazówki" + }, + "title": "Pomoc dotycząca filtra niestandardowego" + }, + "delete": "Usuń", + "enterNewFilterName": "Wpisz nazwę nowego filtru", + "episodeFilterPlaceholder": "Odcinek", + "exists": "Istnieje: tak", + "filterLabel": "Filtr", + "filters": "Profile filtrów", + "hasCloudAuth": "Ustaw autoryzację chmury", + "hideFilters": "Ukryj filtry", + "invalid": "Prawidłowy: nie", + "languagePlaceholder": "Wybierz język", + "lastPlayed": "Ostatnio odtwarzane", + "live": "Live włączone", + "loadFilter": "Wczytaj", + "noCloud": "Chmura wyłączona", + "noSavedFilters": "Brak zapisanych filtrów", + "notExists": "Istnieje: nie", + "resetFilters": "Resetuj filtr", + "saveFilter": "Zapisz", + "searchPlaceholder": "Wpisz kryteria wyszukiwania", + "selectExistingFilter": "Wybierz istniejący filtr", + "seriesFilterPlaceholder": "Seria", + "showFilters": "Pokaż filtry", + "statusFilters": "Filtr statusu", + "title": "Pasek filtrów", + "unsetHasCloudAuth": "Brak autoryzacji chmury", + "unsetLive": "Live wyłączone", + "unsetNoCloud": "Chmura włączona", + "valid": "Prawidłowy: tak" + }, + "navigationTitle": "Tonies", + "pageSelector": " tonies/stronę", + "showAll": "Pokaż wszystko", + "showPagination": "Pokaż paginację", + "title": "Tonies" + }, + "toniesExportList": "Lista eksportu Tonies", + "toniesExportListCreateDate": "Wygenerowano", + "unsetTonie": "Tonie/Tag" + }, + "toniesJsonSearch": { + "failedToFetchSearchResults": "Błąd podczas pobierania modeli", + "failedToFetchSearchResultsDetails": "Nie udało się pobrać wyników wyszukiwania modeli: ", + "noResults": "Nie znaleziono wyników w tonies(.custom).json." + }, + "utils": { + "copied": "Skopiowano!", + "copyToClipboard": "Kopiuj do schowka", + "failedToCopied": "Nie udało się skopiować!", + "noResults": "Brak wyników" + } +} diff --git a/contrib/data/www/web/web_version.json b/contrib/data/www/web/web_version.json index 77cf03f75..309e13609 100644 --- a/contrib/data/www/web/web_version.json +++ b/contrib/data/www/web/web_version.json @@ -2,9 +2,9 @@ "web_version": "vX.X.X", "web_gitDirty": "0", "web_gitTag": "unknown", - "web_gitDateTime": "2026-08-20 10:06:46 +0200", - "web_gitSha": "e2f40c2c69a3b2b46093ddc5542773dd0fa0067e", + "web_gitDateTime": "2026-10-25 19:48:30 +0100", + "web_gitSha": "e7512b208d1ef205e86f387d1c54d0c0a3f46ff6", "web_gitTagPrefix": "unknown", - "web_gitShortSha": "e2f40c2", + "web_gitShortSha": "e7512b20", "_eof":"" } diff --git a/dev-sandbox/config/config.ini b/dev-sandbox/config/config.ini index 296a286c8..70a1234cd 100644 --- a/dev-sandbox/config/config.ini +++ b/dev-sandbox/config/config.ini @@ -11,7 +11,6 @@ core.librarydir=library core.datadir=data core.firmwaredir=firmware core.cachedir=cache -core.new_webgui_as_default=true core.allowNewBox=true core.tonies_json_auto_update=true core.full_taf_validation=false diff --git a/docker/docker-compose.traefik.example.yaml b/docker/docker-compose.traefik.example.yaml new file mode 100644 index 000000000..2e204d6cf --- /dev/null +++ b/docker/docker-compose.traefik.example.yaml @@ -0,0 +1,52 @@ +# Example: expose TeddyCloud behind Traefik. +# +# Use two hostnames: +# teddycloud.example.com -> web UI on container port 80 (HTTP) +# box.example.com -> Toniebox TLS on container port 443 (TCP passthrough) +# +# Do not publish the web UI to the internet without enabling web UI login. +# TeddyCloud cannot tell LAN clients from Traefik: the proxy source IP looks local. +# +# Replace the image/hostnames and attach this service to your Traefik network. + +services: + teddycloud: + image: ghcr.io/toniebox-reverse-engineering/teddycloud:latest + hostname: teddycloud + environment: + # Recovery only — bypasses web UI login: + # - TEDDYCLOUD_WEB_AUTH_DISABLE=1 + volumes: + - certs:/teddycloud/certs + - config:/teddycloud/config + - content:/teddycloud/data/content + - library:/teddycloud/data/library + - custom_img:/teddycloud/data/www/custom_img + - firmware:/teddycloud/data/firmware + - cache:/teddycloud/data/cache + - plugins:/teddycloud/data/www/plugins + labels: + - traefik.enable=true + # Web UI — HTTP router to port 80 + - traefik.http.routers.teddycloud-web.rule=Host(`teddycloud.example.com`) + - traefik.http.routers.teddycloud-web.entrypoints=websecure + - traefik.http.routers.teddycloud-web.tls.certresolver=letsencrypt + - traefik.http.routers.teddycloud-web.service=teddycloud-web + - traefik.http.services.teddycloud-web.loadbalancer.server.port=80 + # Toniebox — TCP passthrough of TLS (HostSNI) to port 443 + - traefik.tcp.routers.teddycloud-box.rule=HostSNI(`box.example.com`) + - traefik.tcp.routers.teddycloud-box.entrypoints=websecure + - traefik.tcp.routers.teddycloud-box.tls.passthrough=true + - traefik.tcp.routers.teddycloud-box.service=teddycloud-box + - traefik.tcp.services.teddycloud-box.loadbalancer.server.port=443 + restart: unless-stopped + +volumes: + certs: + config: + content: + library: + custom_img: + firmware: + cache: + plugins: diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 82a032f7e..271b7b085 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -6,6 +6,7 @@ services: # environment: # - PUID=1000 #optional (run as non-root; UID for /teddycloud files) # - PGID=1000 #optional (run as non-root; GID for /teddycloud files) + # - TEDDYCLOUD_WEB_AUTH_DISABLE=1 #optional (bypass web UI login) # ports: # - 80:80 #optional (for the webinterface) # - 8443:8443 #optional (for the webinterface) diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 6c14b9472..1f0b8fe7c 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -26,21 +26,27 @@ if [ -n "${PUID:-}" ] && [ -n "${PGID:-}" ] && [ "${PUID}" != "0" ] && [ "${PGID exit 1 fi - # Create or reconcile the teddy group/user with the requested ids. The - # `|| ... || true` chain handles re-runs (user/group already exists at the - # right ids) without failing the entrypoint. - groupadd -g "${PGID}" teddy 2>/dev/null \ - || groupmod -o -g "${PGID}" teddy 2>/dev/null \ - || true - useradd -u "${PUID}" -g "${PGID}" -M -s /bin/bash teddy 2>/dev/null \ - || usermod -o -u "${PUID}" -g "${PGID}" teddy 2>/dev/null \ - || true + # Resolve the requested ids to an account. Reuse whatever already owns them -- + # the ubuntu base image ships `ubuntu` at 1000:1000, and 1000 is the most + # common PUID/PGID value -- and only create teddy when they are free. Both + # steps are best-effort: the privilege drop below names the numeric uid:gid, + # which gosu and su-exec accept with no passwd entry at all. + if ! getent group "${PGID}" >/dev/null 2>&1; then + groupadd -o -g "${PGID}" teddy \ + || echo "groupadd -g ${PGID} teddy failed; using the numeric gid" >&2 + fi + if ! getent passwd "${PUID}" >/dev/null 2>&1; then + useradd -o -u "${PUID}" -g "${PGID}" -M -s /bin/bash teddy \ + || echo "useradd -u ${PUID} teddy failed; using the numeric uid" >&2 + fi echo "Adjusting /teddycloud ownership to ${PUID}:${PGID}..." chown -R "${PUID}:${PGID}" /teddycloud - RUN_AS=("${DROP_PRIVS}" "teddy") - echo "Will run teddycloud as ${PUID}:${PGID} via ${DROP_PRIVS}" + run_user="$(getent passwd "${PUID}" | cut -d: -f1 || true)" + run_group="$(getent group "${PGID}" | cut -d: -f1 || true)" + RUN_AS=("${DROP_PRIVS}" "${PUID}:${PGID}") + echo "Will run teddycloud as ${PUID}:${PGID} (${run_user:-no passwd entry}:${run_group:-no group entry}) via ${DROP_PRIVS}" fi if [ -n "${DOCKER_TEST:-}" ]; then diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..98805fd1c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,15 @@ +# Documentation + +- [Architecture](./architecture.md) — how the server is put together: startup, + HTTP(S) listeners and routing, the Tonie/TAF content model, the Toniebox + protocol, MQTT/Home Assistant, settings, and directory layout. +- [Box HTTPS certificate selection](./box-https-sni.md) — how the box-facing + HTTPS listener picks a TB1 vs. TB2 certificate. +- [Web radio streaming](./webradio-streaming.md) — how a stream tag is served + and its tuning settings. +- [Custom Tonies API](./api/custom-tonies.md) — the `/api/toniesCustomJson*` + endpoints. + +For end-user setup instructions, see the +[project documentation site](https://toniebox-reverse-engineering.github.io/docs/tools/teddycloud/) +linked from the main [README](../README.md). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..a2cc31878 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,175 @@ +# Architecture + +TeddyCloud is a single C server process that stands in between a Toniebox and +the manufacturer's cloud, and between a browser and that same data. This +document describes how the pieces in `src/` fit together. + +## Startup (`src/main.c`) + +`main()` first checks for one-shot CLI subcommands (certificate generation, +ESP32 firmware patching, ffmpeg encode tests, cloud URL tests, TAF +encode tests). If none match, it starts the server: + +1. `settings_init()` locates and loads `config.ini` / `config.overlay.ini`. +2. `toniebox_state_init()` sets up the in-memory box-state tracker. +3. `platform_init()` brings up the CycloneTCP stack. +4. `tls_init()` loads or generates the CA and server/box certificates. +5. `mqtt_init()` connects the outbound MQTT client (if configured). +6. `server_init()` creates the HTTP(S) listeners and then runs the main + loop until shutdown. + +There is no explicit thread spawning in `main.c` — CycloneTCP's +`platform_init()` and the HTTP server contexts own their own worker +threads. `server_init()`'s own loop (`osDelayTask(250)`) drives the +recurring housekeeping: `mqtt_server_task()`, `settings_loop()`, +periodic sanity checks, `mutex_manager_loop()`, and connection/online +bookkeeping. + +## HTTP/HTTPS listeners (`server.c`, `tls_adapter.c`, `cert.c`) + +`server_init()` starts three CycloneTCP `HttpServerContext` listeners: + +| Listener | Default port | Audience | TLS | +|---|---|---|---| +| HTTP | `core.server.http_port` (80) | Web UI | none | +| HTTPS "web" | `core.server.https_web_port` (8443) | Web UI (browser) | server certificate | +| HTTPS "API" | `core.server.https_api_port` (443) | Toniebox | SNI-selected box certificate + client-cert auth | + +The box-facing listener is the interesting one: `httpServerSelectBoxCertificate` +picks the TB1 or TB2 certificate based on whether the ClientHello carries an +SNI hostname (see [box-https-sni.md](./box-https-sni.md)), and the box +authenticates itself with its own client certificate. + +Both listeners funnel every request through `httpServerRequestCallback()` +(`server.c`), which walks a single data-driven table, `request_paths[]` +(URI prefix + HTTP method + `SERTY_WEB` / `SERTY_API` / `SERTY_BOTH` flag +→ handler function). The same function also does box user-agent +sniffing/box-generation detection and security-mitigation checks before +falling back to serving a static file from `core.wwwdir`, or a 404. + +Handlers are split by concern: + +- `handler_api.c` — browser-facing REST API (settings, file/content + management, tonies.json editing, ESP32 tools, SSE). +- `handler_cloud.c` — the Toniebox cloud protocol itself: `/v1`, `/v2`, + `/v3` endpoints for time, OTA, claim, content, freshness-check, log, + cloud-reset. +- `handler_reverse.c` — transparent proxy/passthrough to the real Tonie + cloud for anything not served locally. +- `handler_rtnl.c` — the box's real-time notification/log protocol. +- `handler_sse.c` — server-sent events that push live state to the web UI. + +## Tonie / TAF content model + +A "Tonie audio file" (`.taf`) is TeddyCloud's on-disk container: Ogg-Opus +audio (`toniefile.c`/`.h`, via `libopus`) wrapped in a protobuf header +(`proto/toniebox.pb.taf-header.proto`) carrying chapter/track offsets, an +`audio_id`, and a SHA1 integrity hash. `.tap` playlist files +(`tonie_audio_playlist.c`) are an alternative content source that points at +one or more other files instead of embedding audio directly. + +Per-content metadata lives in each item's `content.json` +(`contentJson.c`): the `nocloud` flag, the `source` (a local TAF/TAP path +or a stream URL — see [webradio-streaming.md](./webradio-streaming.md)), +and cloud claim/auth info. `toniesJson.c` manages the separate +`tonies.json` / `tonieboxes.json` catalog (the community-maintained +mapping from audio IDs to titles and cover art, with per-install custom +overrides). `cache.c` is a simple on-disk cache, keyed by URL hash, for +content fetched through the reverse-proxy path. + +## Toniebox protocol + +`handler_cloud.c` implements the endpoints the box actually calls: + +- `handleCloudFreshnessCheck` — parses a `TonieFreshnessCheckRequest` + protobuf, checks a `freshnessCache` of UIDs, and reports back which tags + changed. +- `handleCloudContentV1`/`V2` — serve TAF content, either from local + storage/library or passed through/streamed from the source, depending on + `content.json`. +- `handleCloudOTA` / V3 variants — firmware update delivery. +- `handleCloudClaim`, `handleCloudLog`, `handleCloudReset`. + +`cloud_request.c` is the matching outbound HTTPS client used to reach the +real Tonie cloud for passthrough/OTA. `esp32.c` handles the +community ESP32 firmware specifically (patching hostnames/certificates +into a firmware dump, FAT partition extraction/injection). Live box +state (tag placed/removed, knock, tilt, playback) is tracked in +`toniebox_state.c` and feeds both MQTT and SSE. `pcap_dump.c`/`pcaplog.c` +can record raw box traffic to pcap files for debugging. + +## MQTT and Home Assistant + +`mqtt.c` is the outbound MQTT client that publishes box/server events +under a configurable topic prefix; `mqtt_server.c` is an embedded MQTT +broker so local clients can subscribe without running an external one. +`home_assistant.c` builds Home Assistant MQTT-discovery messages +(`homeassistant////config`) so box state (tag, playback, +battery, ...) shows up as HA entities automatically. + +## Settings + +`settings.c` declares configuration as a macro-based option table +(`OPTION_STRING`/`OPTION_UNSIGNED`/`OPTION_BOOL`/...), each entry binding a +dotted key (e.g. `core.server.http_port`) to a field in `settings_t`, a +default, a validation range, and a UI visibility tier. On top of the +global defaults, `overlay_settings_init()` supports per-box overlays (by +client-certificate common name, up to `MAX_OVERLAYS`), so each Toniebox +can have its own settings. Code reads/writes settings through the +generic `settings_get_*`/`settings_set_*` helpers rather than touching +`settings_t` directly. + +## Directory layout + +Git submodules (third-party, see `.gitmodules`): `cJSON`, `teddycloud_web`, +`cyclone/*` (CycloneTCP/TLS/Crypto), `opus`, `ogg`, `emsdk`. `fat/` is a +vendored FatFs library used for ESP32 FAT image extraction. + +First-party: + +- `src/` — the server itself (see above). +- `proto/` — hand-maintained `.proto` definitions, compiled to `*_pb-c.h/c`. +- `config/` — runtime configuration and data, not source. +- `contrib/` — build-time assets, including `contrib/data/` which is the + template tree copied into the runtime `data/` directory (web UI, + library, firmware, content skeletons). +- `docker/` — Docker Compose files and entrypoint for container deployment. +- `dev-sandbox/` — a local dev Compose environment with sample + certs/content/library for testing without real hardware (see + [dev-sandbox/README.md](../dev-sandbox/README.md)). + +## Web frontend + +The actual frontend source lives in the `teddycloud_web` submodule, not in +this repository. The `Makefile` builds it (npm) and copies the build +output into `contrib/data/www/web`, which then gets copied into the +runtime `data/www/` directory. At request time, `web.c` and +`httpServerWebRequestCallback()` serve static files straight from +`core.wwwdir`: `/` and `index.shtm` redirect to the web UI at `/web`, +`/web` is rewritten to `/web/index.html`, and anything else is resolved +relative to `wwwdir` or answered with a 404. `contrib/data/www` also +ships `plugins/` and `custom_img/` alongside the built SPA. + +See the [README](../README.md#contribution-workflow-teddycloud--teddycloud_web) +for the contribution workflow between this repository and `teddycloud_web`. + +## Testing + +Tests live under `tests/`, split by what they need to exercise: + +- `tests/c/` — pure C unit tests for logic that doesn't need a running + server (e.g. `os_ext.c` helpers). Plain `assert()`-based: a small + `test_runner.c` main registers each `test_*.c` file's entry point in a + table and calls it; a failed assertion aborts with a file/line and a + non-zero exit code, which is enough for `make` to fail the build. + Run with `make test_c`. +- `tests/py/` — HTTP-level integration tests that talk to a real running + `teddycloud` server (e.g. `test_tonies_custom_json_api.py`). + `tests/py/with_server.sh` is the shared harness: it starts the server on + a given set of ports, waits for it to answer, runs the given command, + and always stops the server afterwards - so each test's Makefile target + is a few lines instead of duplicating the start/wait/cleanup logic. Run + a single suite with e.g. `make test_py TESTS=custom_json`. + +`make test` runs both: the C unit tests, then the Python integration +suite against a throwaway server instance. diff --git a/docs/github/issue-triage.md b/docs/github/issue-triage.md new file mode 100644 index 000000000..4aca0ecd5 --- /dev/null +++ b/docs/github/issue-triage.md @@ -0,0 +1,225 @@ +# teddycloud open issue triage — 2026-09-30 + +70 open issues, checked against `develop` + full comment threads, cross-referenced against all +170 PRs (open/merged/closed) in the repo. Legend: + +- **Validity**: valid / invalid / already-fixed / unclear / duplicate +- **Complexity**: easy / medium / complex (implementation effort, not issue-writing effort) +- **Usefulness**: high / medium / low / niche + +Checkboxes are for you to tick as you action each one — nothing here is applied yet. + +--- + +## 0. PR status across both repos + +Only **5 PRs are currently open** in `teddycloud`: #462, #471 (release), #477, #482, #484. +`teddycloud_web` (the companion frontend repo) has **5 more**: #326–#330. Cross-checked +every issue against GitHub's PR-linking metadata plus manual reading where linking wasn't +automatic (a plain pasted URL in a comment, e.g., doesn't create a GitHub cross-reference). + +**Update:** `teddycloud_web` PR #317 ("track listened status for library files") is now +**merged** — its conflicts against `develop` (in `CHANGELOG.md`, `FileBrowser.tsx`, +`Columns.tsx`) were resolved and a real bug found in review (missing `encodeURIComponent` +on file paths in `TeddyCloudApi.ts`'s `apiPostFileSetListened` and in +`useAssignNextEpisode.ts`) was fixed before it landed on `develop` at commit `6310d2c`. + +### Ready to merge right now (approved + green CI + no conflicts) + +| Repo | PR | Title | Status | +|---|---|---|---| +| teddycloud_web | [#330](https://github.com/toniebox-reverse-engineering/teddycloud_web/pull/330) | feat: add optional web UI login with multiple users | **APPROVED** by henryk86, CI green, clean merge. Frontend half of #85. | +| teddycloud_web | [#329](https://github.com/toniebox-reverse-engineering/teddycloud_web/pull/329) | feat: download TAF tracks as OGG files | Went CHANGES_REQUESTED → **APPROVED** (latest review), CI green, clean merge. Frontend half of #169/#331 — coordinate with backend #484 (see below), which isn't merged yet. | + +Nothing in `teddycloud` (backend) has an approving review yet — see the per-PR table below. + +### teddycloud (backend) open PRs — none formally approved + +| PR | Title | Review | CI / merge state | +|---|---|---|---| +| [#484](https://github.com/toniebox-reverse-engineering/teddycloud/pull/484) | feat: export selected TAF tracks as Ogg or Zip | No reviews yet | CI all green, mergeStateStatus CLEAN — closest to ready, just needs someone to look and approve | +| [#482](https://github.com/toniebox-reverse-engineering/teddycloud/pull/482) | feat: add optional web UI login with cookie sessions | Active back-and-forth (14 COMMENTED reviews from henryk86/ditschi), no approval yet | CI all green — still under discussion, not resolved | +| [#462](https://github.com/toniebox-reverse-engineering/teddycloud/pull/462) | Feature/rabbit hole tap playlist | No reviews, still **draft** | 2 CI failures (`build-windows-amd64`, `ubuntuAsanTest/ppc64le`) alongside passing reruns — flaky or unresolved, and it's a draft so not merge-eligible regardless | +| [#477](https://github.com/toniebox-reverse-engineering/teddycloud/pull/477) | fix(server): clear authenticated on every pooled connection reuse | No reviews | **Merge conflicts** (CONFLICTING/DIRTY), needs a rebase before anyone can review it properly | +| [#471](https://github.com/toniebox-reverse-engineering/teddycloud/pull/471) | Next Version | No reviews | Automated release-prep PR, not a normal review target | + +### teddycloud_web (frontend) open PRs — full list + +| PR | Title | Review | CI / merge state | +|---|---|---|---| +| [#330](https://github.com/toniebox-reverse-engineering/teddycloud_web/pull/330) | feat: add optional web UI login with multiple users | **APPROVED** | Clean, green — ready (see above) | +| [#329](https://github.com/toniebox-reverse-engineering/teddycloud_web/pull/329) | feat: download TAF tracks as OGG files | **APPROVED** (after an earlier changes-requested round) | Clean, green — ready pending backend #484 | +| [#328](https://github.com/toniebox-reverse-engineering/teddycloud_web/pull/328) | chore: update dependencies, add translation check | No reviews | Clean, green — low-risk chore, just unreviewed | +| [#327](https://github.com/toniebox-reverse-engineering/teddycloud_web/pull/327) | feature: List view for Tonies | **CHANGES_REQUESTED** (twice) | UNSTABLE — maintainer wants it to reuse the antd design system, not a bespoke list component | +| [#326](https://github.com/toniebox-reverse-engineering/teddycloud_web/pull/326) | Support serving the web UI under a runtime URL prefix | No formal review yet; maintainer's "feels like this could break everything" comment predates the changelog entry that's already in the diff | No CI has run at all (`gh pr checks` reports none — needs a maintainer to approve the workflow run for this external contributor). Fixes issue #245. **Code review: sound.** Single source of truth (`src/utils/basePath.ts`), a regex anchored on `/web` as a path segment so it can't false-match a prefix like `/webapp`, `withBase()` is idempotent, and it's applied consistently at every root-absolute URL call site (~20 files: API config, router basename, i18n, RTNL EventSource, audio/image URLs, plugin iframes, WASM encoder, cert download). Favicon/manifest hrefs are untouched but fine — Vite auto-rewrites those via `base: "./"` at build time. Blocked only on CI being triggered and a review/approval, not on a code issue. | + +### Merged just now (this session) + +| PR | Title | Note | +|---|---|---| +| [teddycloud_web #317](https://github.com/toniebox-reverse-engineering/teddycloud_web/pull/317) MERGED | feat: track listened status for library files | From a fork (`pladux/teddycloud_web`), had merge conflicts against `develop` and an `encodeURIComponent` bug — both fixed, then pushed directly to `develop` (commit `6310d2c`). Backend counterpart PR #467 (`teddycloud`) was already merged earlier. | + +### Already fixed by a *merged* PR (issue should just be closed) + +| Issue | Merged PR | Note | +|---|---|---| +| [#419](https://github.com/toniebox-reverse-engineering/teddycloud/issues/419) core.wwwdir/pluginsdir configurable | [#444](https://github.com/toniebox-reverse-engineering/teddycloud/pull/444) MERGED | Done — abandoned attempt #443 was superseded by this one | +| [#407](https://github.com/toniebox-reverse-engineering/teddycloud/issues/407) Stream started twice | [#479](https://github.com/toniebox-reverse-engineering/teddycloud/pull/479) MERGED | perf: skip encoding for range requests that force a box restart | +| [#311](https://github.com/toniebox-reverse-engineering/teddycloud/issues/311) Ubuntu AARCH64 build fails | [#461](https://github.com/toniebox-reverse-engineering/teddycloud/pull/461) MERGED | Native arm64 runners | +| [#131](https://github.com/toniebox-reverse-engineering/teddycloud/issues/131) MQTT event when Tonie removed | [#321](https://github.com/toniebox-reverse-engineering/teddycloud/pull/321) MERGED | Verified in current code: `src/handler_rtnl.c:348` calls `tbs_tag_removed()` → `src/toniebox_state.c:42` fires `mqtt_sendBoxEvent("TagInvalid", "", ...)` (CC3200/ESP32 scope per the PR title; TB2 coverage unconfirmed). Issue just needs the reporter to confirm and close. | +| [#483](https://github.com/toniebox-reverse-engineering/teddycloud/issues/483) CC3235 → TB2 | direct commit `0b6673f` (no PR, pushed straight to develop) | Fixed by us today | + +### Open PR pending merge — don't start new work, review/merge instead + +| Issue | Open PR | Note | +|---|---|---| +| [#169](https://github.com/toniebox-reverse-engineering/teddycloud/issues/169) `/web` download doesn't split TAF into per-track OGG | [#484](https://github.com/toniebox-reverse-engineering/teddycloud/pull/484) OPEN | PR body says "Closes #169" directly. Companion frontend PR [teddycloud_web#329](https://github.com/toniebox-reverse-engineering/teddycloud_web/pull/329) also open. | +| [#331](https://github.com/toniebox-reverse-engineering/teddycloud/issues/331) Enhance TAF download to OGG w/ chapters | [#484](https://github.com/toniebox-reverse-engineering/teddycloud/pull/484) OPEN | SciLor linked this PR in a comment on #331, but it's **not merged yet** — GitHub didn't auto-link it since the PR's own "Closes" keyword only names #169. | +| [#85](https://github.com/toniebox-reverse-engineering/teddycloud/issues/85) Real web UI auth | [#482](https://github.com/toniebox-reverse-engineering/teddycloud/pull/482) OPEN | Optional login w/ cookie sessions, exactly what's requested. Companion frontend PR [teddycloud_web#330](https://github.com/toniebox-reverse-engineering/teddycloud_web/pull/330) also open. Author's own note: they no longer personally need this (switched to Authelia+Traefik) but left it open for review. | +| [#262](https://github.com/toniebox-reverse-engineering/teddycloud/issues/262) TAP times out / box shuts down mid-encode | [#462](https://github.com/toniebox-reverse-engineering/teddycloud/pull/462) OPEN | A commenter on the issue explicitly confirms: "#462 reworks this path... That is the writer side; it does not clear the reader's EOF indicator, so the two changes would be complementary rather than alternatives" — i.e. #462 helps but may not fully close this alone (related to a separate PR #478, already merged, for the EOF/stall issue). | + +### Abandoned/closed PRs that attempted a fix but didn't land (worth reviving, not reinventing) + +| Issue | Closed PR | Note | +|---|---|---| +| [#417](https://github.com/toniebox-reverse-engineering/teddycloud/issues/417) Preencode TAF after TAP creation | [#460](https://github.com/toniebox-reverse-engineering/teddycloud/pull/460) CLOSED | "Keep TAP generation alive after client disconnect" — closed unmerged | +| [#262](https://github.com/toniebox-reverse-engineering/teddycloud/issues/262) (also) | [#460](https://github.com/toniebox-reverse-engineering/teddycloud/pull/460) CLOSED | Same abandoned PR touches this too | + +### Tangentially related merged PRs (didn't fully close the issue, worth a quick look before starting fresh) + +| Issue | Related merged PR | Note | +|---|---|---| +| [#342](https://github.com/toniebox-reverse-engineering/teddycloud/issues/342) Addon/plugin folder structure | [#220](https://github.com/toniebox-reverse-engineering/teddycloud/pull/220) MERGED | Early partial groundwork ("prepared feature teddycloud plugins"); upload/delete + full spec still open | +| [#283](https://github.com/toniebox-reverse-engineering/teddycloud/issues/283) Custom Tonies caching fails on paths | [#426](https://github.com/toniebox-reverse-engineering/teddycloud/pull/426) MERGED | "Feature/custom model editor" — uncertain overlap, worth checking if it already covers the relative-path case before implementing | +| [#430](https://github.com/toniebox-reverse-engineering/teddycloud/issues/430) / [#194](https://github.com/toniebox-reverse-engineering/teddycloud/issues/194) | [#439](https://github.com/toniebox-reverse-engineering/teddycloud/pull/439) MERGED | "Enhance condition for valid tonieInfo check" — tangential, doesn't fully resolve either | +| [#406](https://github.com/toniebox-reverse-engineering/teddycloud/issues/406) API to detect Tonie removed | [#321](https://github.com/toniebox-reverse-engineering/teddycloud/pull/321) MERGED | The MQTT *event* on removal exists (see #131 above) but the explicit ask here — a queryable REST status — is still not implemented | + +### Checked, no related PR found at all + +#384 (iOS Safari upload — PR #456 fixed a *Firefox* truncation bug, different root cause), #473 (boot loop — only a documented uid workaround exists, no PR fix), #405 (TB2 support — in progress via `teddycloud_web` repo, no teddycloud-side PR yet), #338, #383, #367, #120, #445, #303, #271, #270, #189, #172, #165, #164, #163, #89, #50, #27, #22, and everything in sections 4–7 below not mentioned above (#245 has a PR — see `teddycloud_web` #326 above). + +--- + +## 1. Close now — already fixed (5) + +- [ ] [#483](https://github.com/toniebox-reverse-engineering/teddycloud/issues/483) CC3235 detected as box generation TB2 — fixed by us today, commit `0b6673f` +- [ ] [#407](https://github.com/toniebox-reverse-engineering/teddycloud/issues/407) Stream started twice — merged PR #479 +- [ ] [#311](https://github.com/toniebox-reverse-engineering/teddycloud/issues/311) Ubuntu AARCH64 build fails (ASan allocator) — merged PR #461 +- [ ] [#419](https://github.com/toniebox-reverse-engineering/teddycloud/issues/419) Make `core.wwwdir`/`pluginsdir` configurable — merged PR #444 +- [ ] [#131](https://github.com/toniebox-reverse-engineering/teddycloud/issues/131) MQTT event when Tonie removed — merged PR #321, verified working in current `src/toniebox_state.c` + +## 2. Close now — invalid / wontfix (5) + +- [ ] [#430](https://github.com/toniebox-reverse-engineering/teddycloud/issues/430) Switch identity to RUID-based mapping — already RUID-based; real gap (MQTT uses audio-id) is tracked separately in #194 +- [ ] [#409](https://github.com/toniebox-reverse-engineering/teddycloud/issues/409) Expected end of current Tonie via MQTT — box doesn't report playback position, infeasible +- [ ] [#334](https://github.com/toniebox-reverse-engineering/teddycloud/issues/334) Time-based disabling of Toniebox — no trigger mechanism exists while box is idle +- [ ] [#186](https://github.com/toniebox-reverse-engineering/teddycloud/issues/186) HA: static picture when nothing playing — it's a question, not a bug; workaround exists +- [ ] [#174](https://github.com/toniebox-reverse-engineering/teddycloud/issues/174) MP3→TAF chapter encoding — explicitly labeled wontfix by maintainers already + +## 3. Review the open PR — pending merge, don't reimplement (4) + +- [ ] [#169](https://github.com/toniebox-reverse-engineering/teddycloud/issues/169) Split TAF download into per-track OGG — PR #484 (+ frontend #329) +- [ ] [#331](https://github.com/toniebox-reverse-engineering/teddycloud/issues/331) Enhance TAF download to OGG w/ chapters — PR #484 +- [ ] [#85](https://github.com/toniebox-reverse-engineering/teddycloud/issues/85) Real web UI auth — PR #482 (+ frontend #330) +- [ ] [#262](https://github.com/toniebox-reverse-engineering/teddycloud/issues/262) TAP times out mid-encode — PR #462 (may need a follow-up per commenter) + +## 4. Ping the reporter — need more info / can't confirm (8) + +- [ ] [#403](https://github.com/toniebox-reverse-engineering/teddycloud/issues/403) Error downloading a TAF — single report, no repro, no logs +- [ ] [#384](https://github.com/toniebox-reverse-engineering/teddycloud/issues/384) iOS Safari upload doesn't work — partially fixed, Safari-specific part unreproduced (no Mac); not the same bug as the merged Firefox fix in #456 +- [ ] [#348](https://github.com/toniebox-reverse-engineering/teddycloud/issues/348) Cloud settings not shown/forwarded via MQTT — no repro on current develop +- [ ] [#308](https://github.com/toniebox-reverse-engineering/teddycloud/issues/308) Service fails to start sporadically after reboot — no root cause, may be stale (one user says it stopped) +- [ ] [#138](https://github.com/toniebox-reverse-engineering/teddycloud/issues/138) 2048 vs 4096-bit certs — collaborator claims fixed in develop, SciLor disputes it +- [ ] [#124](https://github.com/toniebox-reverse-engineering/teddycloud/issues/124) Extract ESP32 client certs via web UI — maintainer asked "fixed in develop, or should this be added?" — unanswered +- [ ] [#88](https://github.com/toniebox-reverse-engineering/teddycloud/issues/88) Initial WLAN setup fails ("Ant" error) — "partly implemented" per maintainer, thread trailed off +- [ ] [#77](https://github.com/toniebox-reverse-engineering/teddycloud/issues/77) Slow/unstable initial box connection — no diagnosis confirmed, likely RTNL timing issue + +## 5. Quick wins — valid + easy, no PR yet (15) + +Sorted by usefulness, high first. + +| # | Title | Usefulness | Note | +|---|---|---|---| +| [473](https://github.com/toniebox-reverse-engineering/teddycloud/issues/473) | Boot loop after 0.7.0 update (Docker, DXP2800) | high | Regression from PR #442 (gosu uid switch fails w/o "teddy" passwd entry); no fix PR exists, only a documented workaround | +| [338](https://github.com/toniebox-reverse-engineering/teddycloud/issues/338) | Redownload TAF button on corruption | high | Corruption already detected & logged, just missing a UI/API trigger to redownload | +| [406](https://github.com/toniebox-reverse-engineering/teddycloud/issues/406) | API to detect Tonie removed from box | medium | MQTT event for this already exists (#131/PR #321); this issue wants a queryable REST endpoint specifically | +| [383](https://github.com/toniebox-reverse-engineering/teddycloud/issues/383) | Store structured settings as JSON via API | medium | Maintainer already agreed on the approach | +| [367](https://github.com/toniebox-reverse-engineering/teddycloud/issues/367) | Add `isOriginalTonie` flag to getTagIndex | medium | Maintainer confirmed it's needed | +| [120](https://github.com/toniebox-reverse-engineering/teddycloud/issues/120) | tonies.json auto-update never runs periodically | medium | Only called once at startup — one-line timer bug | +| [380](https://github.com/toniebox-reverse-engineering/teddycloud/issues/380) | HA: no content picture for radio stream source | low | | +| [296](https://github.com/toniebox-reverse-engineering/teddycloud/issues/296) | Library name as ContentTitle (MQTT) | niche | | +| [277](https://github.com/toniebox-reverse-engineering/teddycloud/issues/277) | HA MQTT discovery range restricted to INT32 | low | Preventive, not an active bug yet | +| [268](https://github.com/toniebox-reverse-engineering/teddycloud/issues/268) | Download Boxine CA cert from teddyCloud | low | | +| [264](https://github.com/toniebox-reverse-engineering/teddycloud/issues/264) | Docker tags don't follow semver | low | Pure CI/release workflow change | +| [233](https://github.com/toniebox-reverse-engineering/teddycloud/issues/233) | Different "unknown" icon per content type | low | Mockups provided, pure frontend | +| [231](https://github.com/toniebox-reverse-engineering/teddycloud/issues/231) | `--cloud-test` crashes (null deref) | niche | One-line null-check guard, confirmed root cause | +| [104](https://github.com/toniebox-reverse-engineering/teddycloud/issues/104) | Lower/uppercase content dirs | low | | +| [48](https://github.com/toniebox-reverse-engineering/teddycloud/issues/48) | Add screenshots/gifs/YouTube to README | low | Docs only | + +## 6. Valid, medium effort, no PR yet (17) + +| # | Title | Usefulness | +|---|---|---| +| [445](https://github.com/toniebox-reverse-engineering/teddycloud/issues/445) | Plugin settings via `/api/settings` (plugin.* namespace) | medium | +| [417](https://github.com/toniebox-reverse-engineering/teddycloud/issues/417) | Preencode TAF directly after TAP creation | medium — see abandoned PR #460 above | +| [342](https://github.com/toniebox-reverse-engineering/teddycloud/issues/342) | Addon/plugin folder structure | medium — partial groundwork in PR #220 | +| [335](https://github.com/toniebox-reverse-engineering/teddycloud/issues/335) | Show TAF encoding percentage | low | +| [303](https://github.com/toniebox-reverse-engineering/teddycloud/issues/303) | Detect TAF encoder (Original/Creative/TeddyCloud/etc) | medium | +| [283](https://github.com/toniebox-reverse-engineering/teddycloud/issues/283) | Custom Tonies caching fails on relative/space paths | medium — check PR #426 overlap first | +| [271](https://github.com/toniebox-reverse-engineering/teddycloud/issues/271) | Overlay config via MQTT | medium | +| [270](https://github.com/toniebox-reverse-engineering/teddycloud/issues/270) | ESP32 hostname flash: no UI feedback on failure | medium | +| [189](https://github.com/toniebox-reverse-engineering/teddycloud/issues/189) | Search API for taf/tap files | medium — labeled "prio" | +| [172](https://github.com/toniebox-reverse-engineering/teddycloud/issues/172) | "Report unknown Tonie" feature parity w/ old UI | medium | +| [165](https://github.com/toniebox-reverse-engineering/teddycloud/issues/165) | Cache-to-library source-value not set | medium — fix exists but disabled due to mutex deadlock risk | +| [164](https://github.com/toniebox-reverse-engineering/teddycloud/issues/164) | Mark settings as box-specific | medium | +| [163](https://github.com/toniebox-reverse-engineering/teddycloud/issues/163) | Persist toniebox internals to file | low | +| [89](https://github.com/toniebox-reverse-engineering/teddycloud/issues/89) | Combine/split tracks (99-track TAF limit) | medium | +| [50](https://github.com/toniebox-reverse-engineering/teddycloud/issues/50) | HASS/MQTT online status for device | medium | +| [27](https://github.com/toniebox-reverse-engineering/teddycloud/issues/27) | Decode info from `/v1/log` endpoint | low | +| [22](https://github.com/toniebox-reverse-engineering/teddycloud/issues/22) | Enhance RTNL decoder (RTNL2 func-codes undecoded) | medium | + +## 7. Valid, complex — bigger projects, no PR yet (11) + +Ranked, best value-for-effort first. + +| # | Title | Usefulness | Why it matters | +|---|---|---|---| +| [310](https://github.com/toniebox-reverse-engineering/teddycloud/issues/310) | Boxine comms deadlock / slow freshnessCheck | **high** | Core reliability issue, still reported unresolved (Dec 2025) | +| [405](https://github.com/toniebox-reverse-engineering/teddycloud/issues/405) | Toniebox 2 support | high | Actively in progress already (web UI PR merged there, cert extraction still WIP), no teddycloud-side PR | +| [245](https://github.com/toniebox-reverse-engineering/teddycloud/issues/245) | Support URL path prefix (reverse-proxy deployments) | medium | **Has an open PR:** `teddycloud_web` #326 — not merge-ready yet (needs changelog entry, maintainer wants more confidence it won't break the default path) | +| [378](https://github.com/toniebox-reverse-engineering/teddycloud/issues/378) | Expand tonie.json with API data from Tonies account | medium | Prototype exists, but full auth flow + crowdsourcing is a big scope | +| [267](https://github.com/toniebox-reverse-engineering/teddycloud/issues/267) | Cache TAF header for on-the-fly conversions | medium | Avoids re-encoding; needs persistent header cache | +| [210](https://github.com/toniebox-reverse-engineering/teddycloud/issues/210) | Freshnesscheck API (mark stale content) | medium | Maintainer's own proposal, scope still undefined | +| [188](https://github.com/toniebox-reverse-engineering/teddycloud/issues/188) | Metadata (series/episode/image) on custom TAFs | medium | Unresolved protobuf/firmware-compat concerns, needs a design decision first | +| [182](https://github.com/toniebox-reverse-engineering/teddycloud/issues/182) | MQTT removed-event not firing after long playtime | medium | Possibly tied to 90-min Kreativtonie limit, needs investigation | +| [159](https://github.com/toniebox-reverse-engineering/teddycloud/issues/159) | Library vs. Boxine-defined creative Tonies collide | medium | Real UX gap, partial mitigations exist (#156/#157) | +| [157](https://github.com/toniebox-reverse-engineering/teddycloud/issues/157) | Tagging/labeling + grouping of tonies/tags | medium | Needs backend + web UI schema work | + +## 8. Valid, complex, niche — low priority (7) + +- [ ] [#437](https://github.com/toniebox-reverse-engineering/teddycloud/issues/437) Enhance CC3200 flashing process — large multi-step feature, niche box type +- [ ] [#388](https://github.com/toniebox-reverse-engineering/teddycloud/issues/388) Multiple TAFs per Tonie w/ random playback +- [ ] [#363](https://github.com/toniebox-reverse-engineering/teddycloud/issues/363) Fetch ARD Audiothek — community scripts already solve this outside teddyCloud +- [ ] [#194](https://github.com/toniebox-reverse-engineering/teddycloud/issues/194) MQTT missing image/title for webstream Tonies — audio_id churns per placement, no fix proposed +- [ ] [#125](https://github.com/toniebox-reverse-engineering/teddycloud/issues/125) RTP streaming to Toniecloud — speculative, no design consensus, dead since 2024 +- [ ] [#105](https://github.com/toniebox-reverse-engineering/teddycloud/issues/105) Cycle through TAF files — needs box-orientation detection hack + +--- + +## Summary counts + +| Bucket | Count | +|---|---| +| Close — already fixed (merged PR or direct commit) | 5 | +| Close — invalid/wontfix | 5 | +| Review open PR, don't reimplement | 4 | +| Needs reporter follow-up | 8 | +| Quick wins (easy + valid, no PR) | 15 | +| Medium effort, valid, no PR | 17 | +| Complex, valid, worth doing, no PR | 10 | +| Complex, valid, niche/low priority | 6 | +| **Total** | **70** | + +Note: #262 and #406 each appear in two sections above (once for the PR/related-work note, once in their +priority bucket) since they have partial coverage but aren't fully resolved — not double-counted in the +total. diff --git a/docs/webradio-streaming.md b/docs/webradio-streaming.md new file mode 100644 index 000000000..75f53cebe --- /dev/null +++ b/docs/webradio-streaming.md @@ -0,0 +1,75 @@ +# Web radio streaming + +A tag becomes a stream when its `content.json` carries a `source` that is a URL +(anything containing `://`) or an existing file that is neither a TAF nor a TAP. +TeddyCloud then forces `live` and `nocloud` on for that tag. + +## How a stream is served + +A content request for such a tag starts two things that run concurrently: + +- `ffmpeg_stream_task` pipes the source through ffmpeg to raw PCM (48 kHz, + stereo, s16le), encodes it to Opus (60 ms frames, `encode.bitrate`) and appends + 4096 byte TAF blocks to `.stream`. `skip_seconds` from the + `content.json` is passed to ffmpeg as `-ss`. +- The HTTP connection thread serves that same file to the box while it is still + being written, and waits whenever it catches up with the encoder. + +The response announces `encode.stream_max_size` as its content length instead of +the current file size, so the box keeps downloading as the file grows. When the +box reports playback stop over RTNL, `stream_ctx.active` is cleared, ffmpeg is +stopped and the file is closed. One stream can be active per box. + +## Why playback is not live + +The box downloads content to its SD card and plays it from the beginning, and +teddyCloud has no way to point it at the live edge of a running stream. Two +consequences follow, and most of the tuning below is about them. + +**Delivery runs at 1x.** Icecast-style servers send several seconds from their +buffer immediately on connect; after that burst, audio arrives no faster than it +is broadcast. Whatever the box wants buffered beyond what is already in the file +therefore costs the same amount of real time. + +**A returning tag replays its cache.** The box still holds the prefix it +downloaded earlier, plays it from the start and resumes the download with a range +request. `encode.ffmpeg_stream_restart` exists to prevent that: it answers the +range request with the real file size instead of `stream_max_size`, so the box +discards its cache and requests the stream again from offset 0. + +## Settings + +All of these live under `encode` and are expert level. + +| Setting | Default | Effect | +|---|---|---| +| `bitrate` | `96` | Opus bitrate in kb/s. Higher means larger TAF blocks per second of audio. | +| `ffmpeg_sweep_startup_buffer` | `true` | Discard everything decoded during the first `ffmpeg_sweep_delay_ms` instead of encoding it, so playback starts near the live edge. | +| `ffmpeg_sweep_delay_ms` | `2000` | How long to discard for. | +| `ffmpeg_stream_buffer_ms` | `2000` | How long to keep encoding before the response starts, so the box has something to read. | +| `ffmpeg_stream_restart` | `false` | Force a returning box to start over instead of replaying its cached prefix. | +| `stream_max_size` | `251658239` | Announced content length, about 240 MB or 6 h. Must not be a multiple of 4096. | + +Sweeping is worth understanding before changing it, because it costs twice. +Besides the explicit `ffmpeg_sweep_delay_ms` wait, discarding the burst leaves the +file holding only `ffmpeg_stream_buffer_ms` of audio, and everything the box +needs on top of that arrives at 1x. Turning sweeping off puts the whole burst in +the file within a fraction of a second, so the box can fill its buffer at network +speed and `ffmpeg_stream_buffer_ms` can be lowered as well. The price is that +playback then stays behind the live signal by the length of the burst, for as +long as the stream runs. Whether that matters depends on the station. + +`stream_max_size` is what the box may reserve per streaming tag, so several +streaming tags can occupy several times that much on the SD card. Once the +announced length is reached the box stops and the tag has to be placed again. + +## Startup latency + +Most of the time between placing the tag and hearing audio is the box waking up — +WiFi association, DHCP, TLS — and cannot be tuned from the server. TeddyCloud's +own share is `ffmpeg_sweep_delay_ms` plus `ffmpeg_stream_buffer_ms`, and after +that whatever the box still wants buffered beyond what the file already holds. + +A box that holds a cached prefix sends a range request first. With +`ffmpeg_stream_restart` enabled that request only sends it back to offset 0, so a +second round trip happens before playback starts. diff --git a/include/box_cert.h b/include/box_cert.h new file mode 100644 index 000000000..115d43ed2 --- /dev/null +++ b/include/box_cert.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +#define BOX_CERT_ID_SIZE 13 + +/* Issuer or subject of a box certificate: Boxine/tonies or TeddyCloud. */ +bool box_cert_issuer_known(const char *issuer, const char *subject); + +/* The box id (MAC) from the certificate subject "b''" or "". */ +bool box_cert_id(const char *subject, char id[BOX_CERT_ID_SIZE]); diff --git a/include/box_cert_check.h b/include/box_cert_check.h new file mode 100644 index 000000000..615bfcc0d --- /dev/null +++ b/include/box_cert_check.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +#include "settings.h" +#include "tls.h" + +/* Checks the certificate of a box (core.boxCertAuth), pins it if needed and + * keeps the result in internal.boxCertStatus. False: refuse the box. */ +bool box_cert_accepted(const TlsContext *tls, settings_t *settings); diff --git a/include/cert.h b/include/cert.h index 5b3c040f7..b8c692686 100644 --- a/include/cert.h +++ b/include/cert.h @@ -36,7 +36,7 @@ error_t cert_get_rsa_priv(RsaPrivateKey *cert_privkey, uint8_t **priv_data, size * * The CA certificate and private key are loaded from PEM-formatted strings within the settings. */ -error_t cert_load_ca(X509CertInfo *cert, RsaPrivateKey *cert_priv); +error_t cert_load_ca(X509CertInfo *cert, RsaPrivateKey *cert_priv, uint8_t **server_ca_der_out); /** * @brief Generate a certificate based on MAC address diff --git a/include/cloud_request.h b/include/cloud_request.h index 99241ce62..88ae4d970 100644 --- a/include/cloud_request.h +++ b/include/cloud_request.h @@ -20,6 +20,5 @@ int_t cloud_request_get(const char *server, int port, const char *uri, const cha int_t cloud_request_post(const char *server, int port, const char *uri, const char *queryString, const uint8_t *body, size_t bodyLen, const uint8_t *hash, req_cbr_t *cbr); int_t cloud_request(const char *server, int port, bool https, const char *uri, const char *queryString, const char *method, const uint8_t *body, size_t bodyLen, const uint8_t *hash, req_cbr_t *cbr); error_t web_request(const char *server, int port, bool https, const char *uri, const char *queryString, const char *method, const uint8_t *body, size_t bodyLen, const uint8_t *hash, req_cbr_t *cbr, bool isCloud, bool printTextData, uint32_t *statusCode); -void split_url(const char *location, char *uri_base, char *uri_path, char *query_string); #endif diff --git a/include/contentJson.h b/include/contentJson.h index 64cafa746..e6f9d1808 100644 --- a/include/contentJson.h +++ b/include/contentJson.h @@ -50,6 +50,7 @@ typedef struct { size_t count; uint32_t *pos; + uint32_t length; // total audio length in seconds, 0 if unknown } track_positions_t; typedef struct diff --git a/include/esp32.h b/include/esp32.h index a7020c9a6..221cac853 100644 --- a/include/esp32.h +++ b/include/esp32.h @@ -18,5 +18,6 @@ error_t esp32_inject_cert(const char *rootPath, const char *patchedPath, const c error_t esp32_inject_ca(const char *rootPath, const char *patchedPath, const char *mac); error_t esp32_patch_host(const char *patchedPath, const char *hostname, const char *oldrtnl, const char *oldapi); error_t esp32_patch_wifi(const char *path, const char *ssid, const char *pass); +error_t esp32_patch_port(const char *patchedPath, uint32_t port); uint32_t mem_replace(uint8_t *buffer, size_t buffer_len, const char *pattern, const char *replace); diff --git a/include/esp32_port.h b/include/esp32_port.h new file mode 100644 index 000000000..11ef28088 --- /dev/null +++ b/include/esp32_port.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include +#include + +bool esp32_port_supported(uint32_t port); + +/* Sets the port the box firmware connects to (443 restores the original code). + * Returns how many connect functions were patched. */ +int esp32_port_patch(uint8_t *image, size_t length, uint16_t port); diff --git a/include/fs_ext.h b/include/fs_ext.h index 3b4ab6e14..1940ad7b7 100644 --- a/include/fs_ext.h +++ b/include/fs_ext.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include "fs_port.h" #ifdef _WIN32 @@ -19,6 +21,8 @@ void fsFixPath(char_t *path); FsFile *fsOpenFileEx(const char_t *path, char *mode); error_t fsCompareFiles(const char_t *source_path, const char_t *target_path, size_t *diff_position); error_t fsCopyFile(const char_t *source_path, const char_t *target_path, bool_t overwrite); +/* Copies the bytes [offset, end) of the open file src to the current position of dst */ +error_t fsCopyFileRange(FILE *src, uint32_t offset, uint32_t end, FILE *dst); error_t fsMoveFile(const char_t *source_path, const char_t *target_path, bool_t overwrite); error_t fsCreateDirEx(const char_t *path, bool_t recursive); error_t fsRemoveFilename(char *dir); \ No newline at end of file diff --git a/include/handler_api.h b/include/handler_api.h index e6323de78..d5dbf3e77 100644 --- a/include/handler_api.h +++ b/include/handler_api.h @@ -38,6 +38,7 @@ error_t handleApiSettingsReset(HttpConnection *connection, const char_t *uri, co error_t handleApiTrigger(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); error_t handleApiFileIndex(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); error_t handleApiFileIndexV2(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); +error_t handleApiFileSetListened(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); error_t handleApiFileUpload(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); error_t handleApiDirectoryCreate(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); error_t handleApiFileDelete(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); @@ -68,6 +69,12 @@ error_t handleApiTagInfo(HttpConnection *connection, const char_t *uri, const ch error_t handleApiAuthLogin(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); error_t handleApiAuthLogout(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); error_t handleApiAuthRefreshToken(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); +error_t handleApiAuthStatus(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); +error_t handleApiAuthUsersGet(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); +error_t handleApiAuthUsersCreate(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); +error_t handleApiAuthUsersDelete(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); +error_t handleApiAuthUsersPassword(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); +error_t handleApiAuthEnabled(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); error_t handleApiMigrateContent2Lib(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); error_t handleDeleteOverlay(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); diff --git a/include/handler_rtnl.h b/include/handler_rtnl.h index c244a269c..e0726d7fb 100644 --- a/include/handler_rtnl.h +++ b/include/handler_rtnl.h @@ -99,6 +99,8 @@ typedef enum RTNL3_TYPE_PLAYBACK_STOPPED = 13, } rtnl_log3_type; +int32_t read_big_endian32(const uint8_t *buf); + error_t handleRtnl(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx); void rtnlEvent(HttpConnection *connection, TonieRtnlRPC *rpc, client_ctx_t *client_ctx); void rtnlEventLog(HttpConnection *connection, TonieRtnlRPC *rpc); diff --git a/include/handler_web_auth.h b/include/handler_web_auth.h new file mode 100644 index 000000000..66e327d9c --- /dev/null +++ b/include/handler_web_auth.h @@ -0,0 +1,17 @@ +#ifndef _HANDLER_WEB_AUTH_H +#define _HANDLER_WEB_AUTH_H + +#include "handler.h" + +#define WEB_USERS_JSON_FILE "web_users.json" +#define WEB_SESSION_COOKIE "tc_web_session" +#define WEB_BEARER_TOKEN_MAX 64 + +void web_auth_init(void); +bool web_auth_env_override(void); +bool web_auth_requires_login(void); +bool web_auth_is_public_request(const char_t *uri, const char_t *method); +bool web_auth_is_authenticated(HttpConnection *connection, char *username, size_t username_size); +error_t web_auth_unauthorized(HttpConnection *connection); + +#endif diff --git a/include/libraryMeta.h b/include/libraryMeta.h new file mode 100644 index 000000000..ca6ee28d5 --- /dev/null +++ b/include/libraryMeta.h @@ -0,0 +1,17 @@ +#pragma once + +#include "fs_port.h" +#include "error.h" + +/* + * Minimal, standalone "listened" flag for library files (..json sidecar). + * Deliberately independent of contentJson_t/load_content_json: that format is shaped + * around a Tonie tag's content assignment (source, live, cache, cloud_ruid, ...), none + * of which applies to a plain library audio file. Reading only ever looks at the + * "listened" key; writing preserves any other keys already present in the sidecar + * (e.g. files created by the "migrate to library" feature, which carry a full + * contentJson_t blob) instead of overwriting them. + */ + +bool_t library_meta_get_listened(const char *content_path); +error_t library_meta_set_listened(const char *content_path, bool_t listened); diff --git a/include/mutex_manager.h b/include/mutex_manager.h index bfbc34bfb..c4e20f55f 100644 --- a/include/mutex_manager.h +++ b/include/mutex_manager.h @@ -5,6 +5,7 @@ typedef enum { MUTEX_SETTINGS = 0, + MUTEX_SETTINGS_FREE, MUTEX_CLIENT_CTX, MUTEX_SSE_CTX, MUTEX_SSE_EVENT, diff --git a/include/net_config.h b/include/net_config.h index 3241f6bd8..44a29f8aa 100644 --- a/include/net_config.h +++ b/include/net_config.h @@ -36,7 +36,6 @@ #include "pcaplog_types.h" #define TONIE_AUTH_TOKEN_LENGTH 32 -#define JWT_AUTH_TOKEN_LENGTH 32 typedef struct { @@ -59,6 +58,7 @@ typedef struct typedef struct { uint8_t authentication_token[TONIE_AUTH_TOKEN_LENGTH]; + char web_bearer_token[65]; client_ctx_t client_ctx; bool_t authenticated; pcaplog_t pcap_data; @@ -76,6 +76,7 @@ typedef struct void *sourceCtx; #define HTTP_SERVER_DIGEST_AUTH_SUPPORT ENABLED +#define HTTP_SERVER_COOKIE_SUPPORT ENABLED #define HTTP_SERVER_PRIVATE_CONTEXT http_connection_private_t private; #define HTTP_SERVER_PERSISTENT_CONN_SUPPORT ENABLED diff --git a/include/os_ext.h b/include/os_ext.h index 73c4b687c..bcfb33937 100644 --- a/include/os_ext.h +++ b/include/os_ext.h @@ -1,9 +1,34 @@ #pragma once +#include #include #include "os_port.h" FILE *osPopen(const char *command, const char *type); int osPclose(FILE *stream); + +/* + * Quotes `src` as one literal argument for the shell osPopen() runs + * (/bin/sh, or cmd.exe on Windows). Returns false if the result does not + * fit into dest_size, or on Windows if src contains a '"', which cmd.exe + * cannot escape (and Windows file names cannot contain). + */ +bool osShellQuote(char *dest, size_t dest_size, const char *src); + +/* + * Makes a file readable and writable by its owner only (0600), for files + * holding private keys. Call it right after creating the file, before the + * key is written. No-op on Windows. Returns false if chmod failed. + */ +bool osChmodOwnerOnly(const char *path); void osStringToUpper(char *str); -void osStringToLower(char *str); \ No newline at end of file +void osStringToLower(char *str); + +/* + * Runs `file` with the given argv (NULL-terminated, argv[0] is the program + * name) and waits for it to exit. No shell is involved, so no argument + * escaping is needed. `file` is searched for on PATH. + * Returns the child's exit code (0 = success), or -1 if it could not be + * spawned or its exit status could not be determined. + */ +int osSpawnvp(const char *file, char *const argv[]); \ No newline at end of file diff --git a/include/rand.h b/include/rand.h index 2b8a3e576..e65fbbd69 100644 --- a/include/rand.h +++ b/include/rand.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "rng/yarrow.h" #include "error.h" @@ -9,3 +11,4 @@ error_t rand_deinit(); void *rand_get_context(); const PrngAlgo *rand_get_algo(); +int rand_get_bytes(void *buf, size_t buflen); diff --git a/include/settings.h b/include/settings.h index dd206d8b5..209f2b861 100644 --- a/include/settings.h +++ b/include/settings.h @@ -99,6 +99,7 @@ typedef struct bool prioCustomContent; bool updateOnLowerAudioId; bool dumpRuidAuthContentJson; + bool autoMarkListenedOnSync; } settings_cloud_t; typedef struct @@ -119,6 +120,7 @@ typedef struct bool ignore_web_version_mismatch; bool confirm_audioplayer_close; bool check_cc3200_cfw; + bool web_auth_enabled; } settings_frontend_t; typedef struct @@ -156,6 +158,7 @@ typedef struct typedef struct { bool api_access; + char *certPin; bool overrideCloud; settings_box_generation boxGeneration; @@ -302,6 +305,8 @@ typedef struct time_t *last_ruid_time; char *ip; bool online; + char *boxCertStatus; + char *boxCertSha256; } settings_internal_t; typedef struct @@ -333,6 +338,7 @@ typedef struct char *allowOrigin; bool boxCertAuth; bool allowNewBox; + bool allowPrivateFetch; bool flex_enabled; char *flex_uid; @@ -341,8 +347,6 @@ typedef struct uint32_t http_client_timeout; uint32_t file_upload_timeout_ms; - bool new_webgui_as_default; - settings_level settings_level; bool tonies_json_auto_update; @@ -730,6 +734,7 @@ bool settings_set_by_string_id(const char *item, const char *value, uint8_t sett void settings_load_all_certs(); error_t settings_try_load_certs_id(uint8_t settingsId); error_t settings_load_certs_id(uint8_t settingsId); +void settings_load_client_certs_id(uint8_t settingsId); bool test_boxine_ca(uint8_t settingsId); #endif diff --git a/include/str_ext.h b/include/str_ext.h new file mode 100644 index 000000000..c3e7e378b --- /dev/null +++ b/include/str_ext.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +/* + * Hex-encodes as many bytes of data as fit into output (incl. NUL + * terminator) and returns the number of input bytes consumed. Call it in a + * loop to encode input larger than the buffer. + */ +size_t hexEncode(const uint8_t *data, size_t len, char *output, size_t output_size); + +/* + * CSV-escapes as many bytes of input as fit into output (incl. NUL + * terminator) and returns the number of input bytes consumed: '"' becomes + * '""', CR/LF become "\r"/"\n", any other non-alphanumeric byte becomes '.'. + */ +size_t escapeString(const char *input, size_t size, char *output, size_t output_size); + +/* + * Splits "scheme://host/path?query" into host, path and query, each + * written to a buffer of buf_size bytes. Returns false if the URL has no + * scheme or path, or a part does not fit. + */ +bool split_url(const char *location, char *uri_base, char *uri_path, char *query_string, size_t buf_size); + +/* + * True if the IPv4 address (4 bytes, a.b.c.d) is a public unicast address. False for everything a server + * must not be made to fetch from on behalf of a user: "this" network, loopback, private (RFC 1918), + * shared/CGNAT, link-local (cloud metadata), IETF/test/benchmark ranges, multicast, reserved, broadcast. + */ +bool ipv4_is_public(const uint8_t ip[4]); diff --git a/include/tls_adapter.h b/include/tls_adapter.h index a578a6346..6a243d65f 100644 --- a/include/tls_adapter.h +++ b/include/tls_adapter.h @@ -1,6 +1,7 @@ #ifndef __TLS_ADAPTER_H__ #define __TLS_ADAPTER_H__ +#include #include // for uint8_t #include "error.h" #include "rng/yarrow.h" // for YarrowContext @@ -16,4 +17,7 @@ extern YarrowContext yarrowContext; void tls_context_key_log_init(TlsContext *context); error_t load_cert(const char *dest_var, const char *src_file, const char *src_var, uint8_t settingsId); +/* SHA-256 (hex) and subject common name of a PEM certificate */ +bool tls_cert_fingerprint(const char_t *pem, char_t sha256[65], char_t *subject, size_t subjectSize); + #endif diff --git a/include/tls_config.h b/include/tls_config.h index 2feb7b1c5..0c8d355f5 100644 --- a/include/tls_config.h +++ b/include/tls_config.h @@ -37,7 +37,9 @@ #define TLS_PRIVATE_CONTEXT \ char client_cert_issuer[128]; \ char client_cert_subject[32]; \ - char client_cert_serial[64]; + char client_cert_serial[64]; \ + char client_cert_sha256[65]; \ + bool_t client_cert_trusted; // Desired trace level (for debugging purposes) #define TLS_TRACE_LEVEL TRACE_LEVEL_WARNING diff --git a/include/zip_writer.h b/include/zip_writer.h new file mode 100644 index 000000000..7ede4c0c2 --- /dev/null +++ b/include/zip_writer.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include "error.h" + +/** + * Writes a zip archive that stores the given files uncompressed (Ogg/Opus does not compress). + * The entry names are taken as UTF-8 and flagged as such (general purpose bit 11), so unzip + * tools show non-ASCII names correctly regardless of the locale of the server. + * Limited to the classic zip format: at most 65535 entries and 4 GiB in total. + * + * @param zip_path archive to create + * @param count number of entries + * @param file_paths files to add + * @param entry_names names of the entries inside the archive, UTF-8, no directories + * @return NO_ERROR or ERROR_FAILURE (the partial archive is removed) + */ +error_t zipWriteStored(const char *zip_path, size_t count, const char *const *file_paths, const char *const *entry_names); diff --git a/src/box_cert.c b/src/box_cert.c new file mode 100644 index 000000000..45524c44b --- /dev/null +++ b/src/box_cert.c @@ -0,0 +1,28 @@ +#include "box_cert.h" + +#include + +static bool contains(const char *text, const char *part) +{ + return strstr(text, part) != NULL; +} + +bool box_cert_issuer_known(const char *issuer, const char *subject) +{ + return contains(issuer, "Boxine Factory SubCA") || contains(issuer, "Toniebox SubCA") || + contains(issuer, "Toniebox Root CA") || contains(issuer, "TeddyCloud") || contains(subject, "TeddyCloud"); +} + +bool box_cert_id(const char *subject, char id[BOX_CERT_ID_SIZE]) +{ + size_t length = strlen(subject); + bool quoted = length == 15 && !strncmp(subject, "b'", 2) && subject[14] == '\''; + + if (!quoted && length != 12) + { + return false; + } + memcpy(id, quoted ? &subject[2] : subject, 12); + id[12] = '\0'; + return true; +} diff --git a/src/box_cert_check.c b/src/box_cert_check.c new file mode 100644 index 000000000..ae5db7e02 --- /dev/null +++ b/src/box_cert_check.c @@ -0,0 +1,68 @@ +#include "box_cert_check.h" + +#include "box_cert.h" +#include "debug.h" +#include "os_port.h" +#include "tls_adapter.h" + +/* The fingerprint of the client.der uploaded for this box, if it is the certificate of this box. */ +static bool uploaded_cert_pin(settings_t *settings, char_t pin[65]) +{ + char_t subject[32]; + char_t id[BOX_CERT_ID_SIZE]; + + return tls_cert_fingerprint(settings->internal.client.crt, pin, subject, sizeof(subject)) && + box_cert_id(subject, id) && osStrcasecmp(id, settings->commonName) == 0; +} + +static void pin_box_cert(settings_t *settings, const char_t *pin) +{ + TRACE_INFO("Box %s: certificate pinned, SHA256 %s\r\n", settings->commonName, pin); + settings_set_string_id("toniebox.certPin", pin, settings->internal.overlayNumber); + settings_save(); +} + +/* Only called for boxes TeddyCloud knows or accepts (core.allowNewBox). The uploaded + * client.der of the box is its real certificate; without one, the first certificate + * the box presents is pinned, also a trusted one, so no other certificate takes its place. */ +static const char *box_cert_status(const TlsContext *tls, settings_t *settings) +{ + char_t uploaded[65]; + + if (uploaded_cert_pin(settings, uploaded)) + { + if (osStrcasecmp(uploaded, settings->toniebox.certPin) != 0) + { + pin_box_cert(settings, uploaded); + } + } + else if (osStrlen(settings->toniebox.certPin) == 0) + { + pin_box_cert(settings, tls->client_cert_sha256); + } + if (tls->client_cert_trusted) + { + return "trusted"; + } + return osStrcasecmp(settings->toniebox.certPin, tls->client_cert_sha256) == 0 ? "pinned" : "mismatch"; +} + +bool box_cert_accepted(const TlsContext *tls, settings_t *settings) +{ + const char *status = box_cert_status(tls, settings); + bool verified = osStrcmp(status, "trusted") == 0 || osStrcmp(status, "pinned") == 0; + + if (osStrcmp(settings->internal.boxCertStatus, status) != 0) + { + if (!verified) + { + TRACE_WARNING("Box %s: certificate %s, SHA256 %s\r\n", settings->commonName, status, tls->client_cert_sha256); + } + settings_set_string_id("internal.boxCertStatus", status, settings->internal.overlayNumber); + } + if (osStrcmp(settings->internal.boxCertSha256, tls->client_cert_sha256) != 0) + { + settings_set_string_id("internal.boxCertSha256", tls->client_cert_sha256, settings->internal.overlayNumber); + } + return verified || !settings->core.boxCertAuth; +} diff --git a/src/cache.c b/src/cache.c index fa39af8c5..914437d25 100644 --- a/src/cache.c +++ b/src/cache.c @@ -158,6 +158,16 @@ cache_entry_t *cache_add(const char *url) return NULL; } + /* Return the existing entry if this URL is already cached. cache_entry_add() + drops a duplicate without linking it, so without this the freshly built + entry (and its three strdup'd strings) would be orphaned and leaked - which + happens a lot because tonies.json repeats the same picture URL. */ + cache_entry_t *existing = cache_fetch_by_url(url); + if (existing != NULL) + { + return existing; + } + uint8_t sha256_calc[SHA256_DIGEST_SIZE]; char sha256_calc_str[2 * SHA256_DIGEST_SIZE + 1]; @@ -297,7 +307,7 @@ cache_entry_t *cache_fetch_by_cached_url(const char *cached_url) while (pos != NULL) { - if (pos->hash == hash_from_url) + if (pos->cached_url && pos->hash == hash_from_url) { TRACE_INFO("Hash match found for hash: %08X. Checking full cached URL...\r\n", hash_from_url); @@ -354,7 +364,7 @@ cache_entry_t *cache_fetch_by_path(const char *path) while (pos != NULL) { - if (pos->hash == hash_from_uri) + if (pos->cached_url && pos->hash == hash_from_uri) { TRACE_DEBUG("Hash match found for hash: %08X. Checking full URI...\r\n", hash_from_uri); diff --git a/src/cert.c b/src/cert.c index fb114d72d..fc871a884 100644 --- a/src/cert.c +++ b/src/cert.c @@ -76,7 +76,7 @@ error_t cert_get_rsa_priv(RsaPrivateKey *cert_privkey, uint8_t **priv_data, size return NO_ERROR; } -error_t cert_load_ca(X509CertInfo *cert, RsaPrivateKey *cert_priv) +error_t cert_load_ca(X509CertInfo *cert, RsaPrivateKey *cert_priv, uint8_t **server_ca_der_out) { const char *server_ca = settings_get_string("internal.server.ca"); const char *server_key = settings_get_string("internal.server.ca_key"); @@ -93,6 +93,7 @@ error_t cert_load_ca(X509CertInfo *cert, RsaPrivateKey *cert_priv) if (pemImportCertificate(server_ca, strlen(server_ca), server_ca_der, &ca_size, NULL) != NO_ERROR) { TRACE_ERROR("pemImportCertificate failed\r\n"); + osFreeMem(server_ca_der); return ERROR_FAILURE; } @@ -100,6 +101,7 @@ error_t cert_load_ca(X509CertInfo *cert, RsaPrivateKey *cert_priv) if (x509ParseCertificateEx(server_ca_der, ca_size, cert, true) != NO_ERROR) { TRACE_ERROR("x509ParseCertificateEx failed\r\n"); + osFreeMem(server_ca_der); return ERROR_FAILURE; } @@ -110,11 +112,13 @@ error_t cert_load_ca(X509CertInfo *cert, RsaPrivateKey *cert_priv) if (pemImportRsaPrivateKey(server_key, osStrlen(server_key), NULL, cert_priv) != NO_ERROR) { TRACE_ERROR("pemImportRsaPrivateKey failed\r\n"); + osFreeMem(server_ca_der); return ERROR_FAILURE; } - /* we must not free this DER because the parsed certificate seems to point there */ - // osFreeMem(server_ca_der); + /* the parsed certificate points into this DER, so it must outlive the certificate: + the caller owns it and frees it once done with the certificate */ + *server_ca_der_out = server_ca_der; return NO_ERROR; } @@ -124,10 +128,11 @@ error_t cert_generate_signed(const char *subject, const uint8_t *serial_number, /* load server CA certificate */ X509CertInfo issuer_cert; RsaPrivateKey issuer_priv; + uint8_t *issuer_der = NULL; if (!self_sign) { - if (cert_load_ca(&issuer_cert, &issuer_priv) != NO_ERROR) + if (cert_load_ca(&issuer_cert, &issuer_priv, &issuer_der) != NO_ERROR) { TRACE_ERROR("cert_load_ca failed\r\n"); return ERROR_FAILURE; @@ -299,12 +304,17 @@ error_t cert_generate_signed(const char *subject, const uint8_t *serial_number, /* save the private key */ FsFile *file = fsOpenFile(priv_file_full, FS_FILE_MODE_WRITE); - osFreeMem(priv_file_full); if (!file) { TRACE_ERROR("fsOpenFile failed\r\n"); + osFreeMem(priv_file_full); return ERROR_FAILURE; } + if (!osChmodOwnerOnly(priv_file_full)) + { + TRACE_WARNING("Could not restrict permissions of '%s'\r\n", priv_file_full); + } + osFreeMem(priv_file_full); if (priv_pem_data) { fsWriteFile(file, priv_pem_data, priv_pem_size); @@ -324,6 +334,7 @@ error_t cert_generate_signed(const char *subject, const uint8_t *serial_number, if (!self_sign) { rsaFreePrivateKey(&issuer_priv); + osFreeMem(issuer_der); } return NO_ERROR; @@ -352,6 +363,8 @@ error_t cert_generate_mac(const char *mac, const char *dest) if (cert_generate_signed(subj, serial, 7, CERT_RSA_SIZE, false, true, client_file, private_file) != NO_ERROR) { TRACE_ERROR("cert_generate_signed failed\r\n"); + osFreeMem(client_file); + osFreeMem(private_file); return ERROR_FAILURE; } osFreeMem(client_file); @@ -979,16 +992,21 @@ error_t cert_generate_signed_ec( } FsFile *file = fsOpenFile(priv_file_full, FS_FILE_MODE_WRITE); - osFreeMem(priv_file_full); if (!file) { TRACE_ERROR("fsOpenFile failed\r\n"); + osFreeMem(priv_file_full); osFreeMem(cert_der_data); osFreeMem(priv_data); if (priv_pem_data) osFreeMem(priv_pem_data); osFreeMem(cert_pem_data); return ERROR_FAILURE; } + if (!osChmodOwnerOnly(priv_file_full)) + { + TRACE_WARNING("Could not restrict permissions of '%s'\r\n", priv_file_full); + } + osFreeMem(priv_file_full); if (priv_pem_format) { diff --git a/src/cloud_request.c b/src/cloud_request.c index 25ebe1b4f..4911b3c3a 100644 --- a/src/cloud_request.c +++ b/src/cloud_request.c @@ -21,6 +21,7 @@ #include "platform.h" // for resolve_free, resolve_get_ip, resolve_... #include "rand.h" // for rand_get_algo, rand_get_context #include "settings.h" // for settings_t, get_settings, settings_cert_t +#include "str_ext.h" // for split_url #include "stdbool.h" // for bool, true, false #include "tls.h" // for TlsContext, _TlsContext (ptr only) #include "tls_adapter.h" // for tls_context_key_log_init @@ -251,6 +252,16 @@ error_t web_request(const char *server, int port, bool https, const char *uri, c ipv4AddrToString(ipAddr.ipv4Addr, host); TRACE_INFO(" trying IP: %s\n", host); + /* Downloads of user supplied URLs (image cache, redirects) must not reach internal services (SSRF). + The address checked is the one connected to, so a changing DNS answer can't get around it. + Cloud requests go to the configured cloud host and are not affected. */ + if (!isCloud && !settings->core.allowPrivateFetch && !ipv4_is_public((const uint8_t *)&ipAddr.ipv4Addr)) + { + TRACE_ERROR("Refusing to connect to non-public address %s (host '%s'), see core.allowPrivateFetch\r\n", host, server); + error = ERROR_ACCESS_DENIED; + break; + } + do { error = httpClientConnect(&httpClientContext, &ipAddr, @@ -395,13 +406,19 @@ error_t web_request(const char *server, int port, bool https, const char *uri, c char uri_base[256], uri_path[256], query_string[256]; // TODO: handling of relative URLs - split_url(location, uri_base, uri_path, query_string); + if (!split_url(location, uri_base, uri_path, query_string, sizeof(uri_base))) + { + TRACE_ERROR("Failed to parse redirect Location: %s\r\n", location); + error = ERROR_INVALID_RESPONSE; + break; + } TRACE_DEBUG("URI Base: %s\r\n", uri_base); TRACE_DEBUG("URI Path: %s\r\n", uri_path); TRACE_DEBUG("Query String: %s\r\n", query_string); - error = web_request(uri_base, 443, true, uri_path, query_string, "GET", NULL, 0, NULL, cbr, false, false, NULL); + /* pass statusCode on, so the caller gets the status of the final response, not the 302 */ + error = web_request(uri_base, 443, true, uri_path, query_string, "GET", NULL, 0, NULL, cbr, false, false, statusCode); break; } } @@ -529,49 +546,3 @@ error_t web_request(const char *server, int port, bool https, const char *uri, c return error; } - -void split_url(const char *location, char *uri_base, char *uri_path, char *query_string) -{ - const char *scheme_end = strstr(location, "://"); - if (!scheme_end) - { - TRACE_ERROR("Invalid URL: Scheme not found\n"); - return; - } - // Move pointer to start after "://" - scheme_end += 3; - - const char *path_start = strchr(scheme_end, '/'); - if (!path_start) - { - TRACE_ERROR("Invalid URL: Path not found\n"); - return; - } - const char *query_start = strchr(path_start, '?'); - - if (query_start) - { - // Copy base URI without scheme - strncpy(uri_base, scheme_end, path_start - scheme_end); - uri_base[path_start - scheme_end] = '\0'; - - // Copy path - strncpy(uri_path, path_start, query_start - path_start); - uri_path[query_start - path_start] = '\0'; - - // Copy query string - strcpy(query_string, query_start + 1); - } - else - { - // Copy base URI without scheme - strncpy(uri_base, scheme_end, path_start - scheme_end); - uri_base[path_start - scheme_end] = '\0'; - - // Copy path - strcpy(uri_path, path_start); - - // No query string - query_string[0] = '\0'; - } -} diff --git a/src/contentJson.c b/src/contentJson.c index 380437470..ca8bad078 100644 --- a/src/contentJson.c +++ b/src/contentJson.c @@ -51,7 +51,10 @@ error_t load_content_json(const char *content_path, contentJson_t *content_json, while (pos < fileSize) { - fsReadFile(fsFile, &data[pos], fileSize - pos, &sizeRead); + if (fsReadFile(fsFile, &data[pos], fileSize - pos, &sizeRead) != NO_ERROR || sizeRead == 0) + { + break; + } pos += sizeRead; } fsCloseFile(fsFile); @@ -158,6 +161,10 @@ error_t load_content_json(const char *content_path, contentJson_t *content_json, error = save_content_json(jsonPath, content_json); if (error == NO_ERROR) { + /* The reload memsets content_json to 0; free the fields parsed in + this pass first so they are not leaked (e.g. on a _version + mismatch, where everything was already allocated). */ + free_content_json(content_json); load_content_json(content_path, content_json, true, settings); } } diff --git a/src/cyclone/cyclone_tcp/http/http_server.c b/src/cyclone/cyclone_tcp/http/http_server.c index eaaebd00c..dd4f6291c 100644 --- a/src/cyclone/cyclone_tcp/http/http_server.c +++ b/src/cyclone/cyclone_tcp/http/http_server.c @@ -469,6 +469,7 @@ void httpConnectionTask(void *param) osMemset(&connection->request, 0, sizeof(HttpRequest)); // Clear response header osMemset(&connection->response, 0, sizeof(HttpResponse)); + osMemset(connection->private.web_bearer_token, 0, sizeof(connection->private.web_bearer_token)); // Read the HTTP request header and parse its contents error = httpReadRequestHeader(connection); @@ -1075,7 +1076,16 @@ error_t httpSendResponseStreamUnsafe(HttpConnection *connection, const char_t *u } else if (connection->private.client_ctx.skip_taf_header) { - length -= TONIE_HEADER_LENGTH; + /* Guard against a content file shorter than the TAF header, which would + underflow length to a huge value and be sent as the Content-Length. */ + if (length >= TONIE_HEADER_LENGTH) + { + length -= TONIE_HEADER_LENGTH; + } + else + { + length = 0; + } } file_length = length; if (isStream) @@ -1088,20 +1098,36 @@ error_t httpSendResponseStreamUnsafe(HttpConnection *connection, const char_t *u } // Format HTTP response header - // TODO add status 416 on invalid ranges if (connection->request.Range.start > 0) { connection->request.Range.size = file_length; - if (connection->request.Range.end >= connection->request.Range.size || connection->request.Range.end == 0) - connection->request.Range.end = connection->request.Range.size - 1; - - if (connection->response.contentRange == NULL) - connection->response.contentRange = osAllocMem(255); - osSprintf((char *)connection->response.contentRange, "bytes %" PRIu32 "-%" PRIu32 "/%" PRIu32, connection->request.Range.start, connection->request.Range.end, connection->request.Range.size); - connection->response.statusCode = 206; - connection->response.contentLength = connection->request.Range.end - connection->request.Range.start + 1; - TRACE_DEBUG("Added response range %s\r\n", connection->response.contentRange); + uint32_t rangeStart = connection->request.Range.start; + uint32_t rangeEnd = connection->request.Range.end; + /* open-ended ("bytes=N-") or oversized end -> last byte */ + if (rangeEnd == 0 || rangeEnd >= file_length) + rangeEnd = (file_length > 0) ? file_length - 1 : 0; + + /* Reject an unsatisfiable range (start past the data, start after end, + or empty resource) instead of computing end-start+1, which would + underflow to a ~4 GB contentLength and desync the connection. Fall + back to serving the full content. */ + if (file_length == 0 || rangeStart >= file_length || rangeStart > rangeEnd || + (connection->response.contentRange == NULL && + (connection->response.contentRange = osAllocMem(255)) == NULL)) + { + connection->request.Range.start = 0; + connection->response.statusCode = 200; + connection->response.contentLength = length; + } + else + { + connection->request.Range.end = rangeEnd; + osSprintf((char *)connection->response.contentRange, "bytes %" PRIu32 "-%" PRIu32 "/%" PRIu32, rangeStart, rangeEnd, connection->request.Range.size); + connection->response.statusCode = 206; + connection->response.contentLength = rangeEnd - rangeStart + 1; + TRACE_DEBUG("Added response range %s\r\n", connection->response.contentRange); + } } else { @@ -1124,6 +1150,13 @@ error_t httpSendResponseStreamUnsafe(HttpConnection *connection, const char_t *u // Send the header to the client error = httpWriteHeader(connection); + // The Content-Range string has now been serialized into the header; free it + // here so it does not leak (the response struct is zeroed per request). + if (connection->response.contentRange != NULL) + { + osFreeMem((void *)connection->response.contentRange); + connection->response.contentRange = NULL; + } // Any error to report? if (error) { @@ -1196,6 +1229,8 @@ error_t httpSendResponseStreamUnsafe(HttpConnection *connection, const char_t *u error = httpCloseStream(connection); // Test connection??? won't work TODO: exit after some seconds if (error) break; + // Clear the sticky stdio EOF indicator so the next read sees appended blocks + fsSeekFile(file, 0, FS_SEEK_CUR); continue; } if (error) diff --git a/src/cyclone/cyclone_tcp/http/http_server_misc.c b/src/cyclone/cyclone_tcp/http/http_server_misc.c index e29c3176e..fb11acdc4 100644 --- a/src/cyclone/cyclone_tcp/http/http_server_misc.c +++ b/src/cyclone/cyclone_tcp/http/http_server_misc.c @@ -200,7 +200,7 @@ error_t httpParseRequestLine(HttpConnection *connection, char_t *requestLine) //The Method token indicates the method to be performed on the //resource identified by the Request-URI - error = strSafeCopy(connection->request.method, token, HTTP_SERVER_METHOD_MAX_LEN); + error = strSafeCopy(connection->request.method, token, HTTP_SERVER_METHOD_MAX_LEN + 1); //Any error to report? if(error) return ERROR_INVALID_REQUEST; @@ -493,7 +493,7 @@ void httpParseHeaderField(HttpConnection *connection, else if (osStrcasecmp(name, "User-Agent") == 0) { strSafeCopy(connection->request.userAgent, value, - sizeof(connection->request.userAgent) + 1); + sizeof(connection->request.userAgent)); } } diff --git a/src/esp32.c b/src/esp32.c index a204aa78b..beb4ec16b 100644 --- a/src/esp32.c +++ b/src/esp32.c @@ -2,6 +2,7 @@ #define TRACE_LEVEL TRACE_LEVEL_INFO #include "esp32.h" +#include "esp32_port.h" #include // for error_t #include // for PRIX32, PRIu32, PRIX8, PRIX16, PRIX64 @@ -19,6 +20,7 @@ #include "fs_ext.h" // for fsOpenFileEx #include "fs_port.h" // for FS_SEEK_SET, FsDirEntry, FS_FILE_MODE_WRITE #include "hash/sha256.h" // for sha256Update, sha256Final, sha256Init +#include "os_ext.h" // for osChmodOwnerOnly #include "os_port.h" // for osFreeMem, osAllocMem, osStrcpy, osStrlen #include "path.h" // for pathAddSlash, pathCanonicalize, pathCombine #include "pem_import.h" // for pemImportCertificate @@ -352,8 +354,16 @@ struct ESP32_nvs_item }; }; +/* state_bitmap holds 2 bits per entry */ +#define NVS_STATE_BITMAP_SLOTS (sizeof(((struct ESP32_nvs_page_header *)0)->state_bitmap) * 4) + uint8_t esp32_nvs_state_get(struct ESP32_nvs_page_header *page_header, uint8_t index) { + if (index >= NVS_STATE_BITMAP_SLOTS) + { + /* out of range: report as not written so callers skip it */ + return NVS_STATE_EMPTY; + } int bmp_idx = index / 4; int bmp_bit = (index % 4) * 2; uint8_t bmp = (page_header->state_bitmap[bmp_idx] >> (bmp_bit)) & 3; @@ -363,6 +373,10 @@ uint8_t esp32_nvs_state_get(struct ESP32_nvs_page_header *page_header, uint8_t i void esp32_nvs_state_set(struct ESP32_nvs_page_header *page_header, uint8_t index, uint8_t state) { + if (index >= NVS_STATE_BITMAP_SLOTS) + { + return; + } int bmp_idx = index / 4; int bmp_bit = (index % 4) * 2; @@ -477,12 +491,14 @@ static error_t process_nvs_item(FsFile *file, size_t offset, size_t part_offset, if (item->nsIndex == 0) { TRACE_INFO(" Namespace %s\r\n", item->key); - if (item->uint8 > MAX_NAMESPACE_COUNT) + if (item->uint8 >= MAX_NAMESPACE_COUNT) { TRACE_ERROR("namespace index is %" PRIu32 ", which seems invalid", item->uint8); return ERROR_FAILURE; } - osStrcpy((*namespaces)[item->uint8], item->key); + /* item->key is a fixed 16-byte field that need not be NUL-terminated */ + osMemcpy((*namespaces)[item->uint8], item->key, MAX_KEY_SIZE - 1); + (*namespaces)[item->uint8][MAX_KEY_SIZE - 1] = '\0'; uint32_t crc_header_calc = crc32_header(item); TRACE_INFO(" Header CRC %08" PRIX32 " (calc %08" PRIX32 ")\r\n", item->crc32, crc_header_calc); @@ -495,7 +511,7 @@ static error_t process_nvs_item(FsFile *file, size_t offset, size_t part_offset, } else { - if (item->nsIndex > MAX_NAMESPACE_COUNT) + if (item->nsIndex >= MAX_NAMESPACE_COUNT) { TRACE_ERROR(" Namespace index is %" PRIu32 ", which seems invalid", item->nsIndex); } @@ -739,6 +755,11 @@ error_t esp32_fixup_nvs(FsFile *file, size_t offset, size_t length, bool modify) return error; } + if (nvs_item.span == 0) + { + TRACE_ERROR("NVS item span is 0, aborting to avoid an endless loop\r\n"); + break; + } entry += nvs_item.span - 1; } } @@ -800,7 +821,7 @@ error_t esp32_nvs_del(FsFile *file, size_t offset, size_t length, const char *na if (nvs_item.nsIndex == 0) { - if (nvs_item.uint8 > MAX_NAMESPACE_COUNT) + if (nvs_item.uint8 >= MAX_NAMESPACE_COUNT) { TRACE_ERROR("namespace index is %" PRIu32 ", which seems invalid", nvs_item.uint8); return ERROR_FAILURE; @@ -830,6 +851,11 @@ error_t esp32_nvs_del(FsFile *file, size_t offset, size_t length, const char *na } } + if (nvs_item.span == 0) + { + TRACE_ERROR("NVS item span is 0, aborting to avoid an endless loop\r\n"); + break; + } entry += nvs_item.span - 1; } @@ -1107,6 +1133,11 @@ error_t esp32_fat_extract_folder(FsFile *file, size_t offset, size_t length, con TRACE_ERROR("Failed to open output file\r\n"); return ERROR_FAILURE; } + /* the CERT folder holds the box's private key */ + if (!osChmodOwnerOnly(outFileName)) + { + TRACE_WARNING("Could not restrict permissions of '%s'\r\n", outFileName); + } FIL fp; if (f_open(&fp, fatFileName, FA_READ) != FR_OK) @@ -1906,3 +1937,49 @@ error_t esp32_patch_host(const char *patchedPath, const char *hostname, const ch } return ret; } + +error_t esp32_patch_port(const char *patchedPath, uint32_t port) +{ + if (!esp32_port_supported(port)) + { + TRACE_ERROR("Port %" PRIu32 " not supported, must be 1..32767\r\n", port); + return ERROR_INVALID_PARAMETER; + } + + TRACE_INFO("Patching port %" PRIu32 " in '%s'\r\n", port, patchedPath); + + uint32_t size = 0; + if (fsGetFileSize(patchedPath, &size)) + { + TRACE_ERROR("File does not exist '%s'\r\n", patchedPath); + return ERROR_NOT_FOUND; + } + + FsFile *file = fsOpenFileEx(patchedPath, "rb+"); + if (file == NULL) + { + TRACE_ERROR("Failed to open firmware\r\n"); + return ERROR_NOT_FOUND; + } + + uint8_t *image = osAllocMem(size); + error_t error = image ? file_read_block(file, 0, image, size) : ERROR_OUT_OF_MEMORY; + if (!error) + { + int patched = esp32_port_patch(image, size, (uint16_t)port); + TRACE_INFO(" patched %d connect function(s)\r\n", patched); + if (patched == 0) + { + TRACE_ERROR("Connect function not found, firmware version not supported\r\n"); + error = ERROR_NOT_FOUND; + } + } + if (!error) + { + error = file_write_block(file, 0, image, size); + } + + fsCloseFile(file); + osFreeMem(image); + return error; +} diff --git a/src/esp32_port.c b/src/esp32_port.c new file mode 100644 index 000000000..b35bbba6e --- /dev/null +++ b/src/esp32_port.c @@ -0,0 +1,94 @@ +#include "esp32_port.h" + +#include + +/* The connect function of the box firmware (v5.231.0 to v5.237.0) passes the + * port to esp_tls_conn_new_sync() and uses TLS only for port 443. */ + +#define ANY -1 +#define CODE_SIZE 3 + +static const int16_t function_start[] = { + 0x36, 0x21, 0x01, 0x72, 0x61, 0x19, 0x62, 0x61, 0x18, ANY, ANY, ANY, + 0x20, 0x72, 0x20, 0x61, ANY, ANY, ANY, ANY, ANY, 0xa0, 0x2a, 0x20}; + +#define TAIL_OFFSET 0xd2 +static const int16_t function_tail[] = { + ANY, ANY, ANY, 0x0c, 0x15, 0x8a, 0x84, 0x99, 0x21, 0x52, 0x41, 0x34, + 0x92, 0x21, 0x16, 0x0c, 0x15, ANY, ANY, ANY, 0xa9, 0x11, 0xad, 0x03}; + +#define FUNCTION_LENGTH (TAIL_OFFSET + sizeof(function_tail) / sizeof(function_tail[0])) + +typedef struct +{ + size_t offset; + uint8_t original[CODE_SIZE]; + uint8_t patched_prefix[2]; +} site_t; + +static const site_t sites[] = { + {0x12, {0x40, 0x40, 0xf4}, {0x42, 0xa0}}, /* extui a4, a4, 0, 16 -> movi a4, port & 0xff */ + {0xd2, {0x82, 0xae, 0x45}, {0x42, 0xd4}}, /* movi a8, -443 -> addmi a4, a4, port & 0xff00 */ + {0xe3, {0x80, 0x57, 0x83}, {0x52, 0xa0}}, /* moveqz a5, a7, a8 -> movi a5, 0 (TLS on) */ +}; +#define SITE_COUNT (sizeof(sites) / sizeof(sites[0])) + +bool esp32_port_supported(uint32_t port) +{ + return port >= 1 && port <= 0x7fff; +} + +static bool matches(const uint8_t *data, const int16_t *pattern, size_t length) +{ + for (size_t pos = 0; pos < length; pos++) + { + if (pattern[pos] != ANY && data[pos] != pattern[pos]) + { + return false; + } + } + return true; +} + +static bool is_connect_function(const uint8_t *function) +{ + if (!matches(function, function_start, sizeof(function_start) / sizeof(function_start[0])) || + !matches(&function[TAIL_OFFSET], function_tail, sizeof(function_tail) / sizeof(function_tail[0]))) + { + return false; + } + for (size_t i = 0; i < SITE_COUNT; i++) + { + const uint8_t *code = &function[sites[i].offset]; + if (memcmp(code, sites[i].original, CODE_SIZE) != 0 && memcmp(code, sites[i].patched_prefix, 2) != 0) + { + return false; + } + } + return true; +} + +int esp32_port_patch(uint8_t *image, size_t length, uint16_t port) +{ + const uint8_t patched[SITE_COUNT][CODE_SIZE] = { + {0x42, 0xa0, port & 0xff}, + {0x42, 0xd4, port >> 8}, + {0x52, 0xa0, 0x00}, + }; + int count = 0; + + for (size_t pos = 0; pos + FUNCTION_LENGTH <= length; pos++) + { + uint8_t *function = &image[pos]; + if (!is_connect_function(function)) + { + continue; + } + for (size_t i = 0; i < SITE_COUNT; i++) + { + memcpy(&function[sites[i].offset], port == 443 ? sites[i].original : patched[i], CODE_SIZE); + } + count++; + } + return count; +} diff --git a/src/fs_ext.c b/src/fs_ext.c index e9c55cfbd..e9fdfcd2b 100644 --- a/src/fs_ext.c +++ b/src/fs_ext.c @@ -162,6 +162,33 @@ error_t fsCopyFile(const char_t *source_path, const char_t *target_path, bool_t return error; } +error_t fsCopyFileRange(FILE *src, uint32_t offset, uint32_t end, FILE *dst) +{ + uint8_t buffer[FILE_COPY_BUFFER_SIZE]; + + if (src == NULL || dst == NULL) + return ERROR_INVALID_FILE; + + if (end < offset || fseek(src, (long)offset, SEEK_SET) != 0) + return ERROR_FAILURE; + + while (offset < end) + { + size_t want = end - offset; + if (want > sizeof(buffer)) + want = sizeof(buffer); + + size_t got = fread(buffer, 1, want, src); + if (got == 0) + return ERROR_FAILURE; + + if (fwrite(buffer, 1, got, dst) != got) + return ERROR_FAILURE; + + offset += (uint32_t)got; + } + return NO_ERROR; +} error_t fsMoveFile(const char_t *source_path, const char_t *target_path, bool_t overwrite) { if (!overwrite && fsFileExists(target_path)) { @@ -227,7 +254,7 @@ error_t fsCreateDirEx(const char_t *path, bool_t recursive) error_t fsRemoveFilename(char *dir) { - if (dir == NULL) + if (dir == NULL || dir[0] == '\0') { return ERROR_INVALID_PARAMETER; } diff --git a/src/handler.c b/src/handler.c index f7b12d30b..23ea020c2 100644 --- a/src/handler.c +++ b/src/handler.c @@ -6,6 +6,41 @@ #include "cJSON.h" #include "mqtt_server.h" +#define TIME_DIGITS_MAX 11 +#define TIME_MIN_VALID 1600000000ULL /* 2020-09-13, an earlier time is no real one */ + +static bool isValidTime(const char *text) +{ + size_t length = osStrlen(text); + if (length == 0 || length > TIME_DIGITS_MAX) + { + return false; + } + for (size_t i = 0; i < length; i++) + { + if (!osIsdigit(text[i])) + { + return false; + } + } + return strtoull(text, NULL, 10) >= TIME_MIN_VALID; +} + +/* Streams a "name: value\r\n" header line without buffering it into a fixed + stack buffer, so an arbitrarily long header/value from an upstream response + cannot overflow. */ +static void httpSendHeaderLine(HttpConnection *connection, const char *name, const char *value) +{ + if (value == NULL) + { + value = ""; + } + httpSend(connection, name, osStrlen(name), HTTP_FLAG_DELAY); + httpSend(connection, ": ", 2, HTTP_FLAG_DELAY); + httpSend(connection, value, osStrlen(value), HTTP_FLAG_DELAY); + httpSend(connection, "\r\n", 2, HTTP_FLAG_DELAY); +} + void fillBaseCtx(HttpConnection *connection, const char_t *uri, const char_t *queryString, cloudapi_t api, cbr_ctx_t *ctx, client_ctx_t *client_ctx) { osMemset(ctx, 0, sizeof(cbr_ctx_t)); @@ -245,7 +280,6 @@ void cbrCloudResponsePassthrough(void *src_ctx, HttpClientContext *cloud_ctx) void cbrCloudHeaderPassthrough(void *src_ctx, HttpClientContext *cloud_ctx, const char *header, const char *value) { cbr_ctx_t *ctx = (cbr_ctx_t *)src_ctx; - char line[256]; bool passthrough = true; if (ctx->status != PROX_STATUS_HEAD) // Only once @@ -255,14 +289,13 @@ void cbrCloudHeaderPassthrough(void *src_ctx, HttpClientContext *cloud_ctx, cons char_t *allowOrigin = ctx->connection->serverContext->settings.allowOrigin; if (allowOrigin != NULL && osStrlen(allowOrigin) > 0) { - osSprintf(line, "Access-Control-Allow-Origin: %s\r\n", allowOrigin); - httpSend(ctx->connection, line, osStrlen(line), HTTP_FLAG_DELAY); - line[0] = '\0'; + httpSendHeaderLine(ctx->connection, "Access-Control-Allow-Origin", allowOrigin); } } } switch (ctx->api) { + case V1_TIME: case V1_FRESHNESS_CHECK: case V3_FRESHNESS_CHECK: if (!header || osStrcmp(header, "Content-Length") == 0) // Skip empty line at the and + contentlen @@ -285,16 +318,14 @@ void cbrCloudHeaderPassthrough(void *src_ctx, HttpClientContext *cloud_ctx, cons if (osStrcmp(header, "Access-Control-Allow-Origin") != 0) { TRACE_DEBUG(">> cbrCloudHeaderPassthrough: %s = %s\r\n", header, value); - osSprintf(line, "%s: %s\r\n", header, value); + httpSendHeaderLine(ctx->connection, header, value); } } else { TRACE_DEBUG(">> cbrCloudHeaderPassthrough: NULL\r\n"); - osStrcpy(line, "\r\n"); + httpSend(ctx->connection, "\r\n", 2, HTTP_FLAG_DELAY); } - - httpSend(ctx->connection, line, osStrlen(line), HTTP_FLAG_DELAY); } ctx->status = PROX_STATUS_HEAD; @@ -322,6 +353,33 @@ void cbrCloudBodyPassthrough(void *src_ctx, HttpClientContext *cloud_ctx, const // TRACE_INFO(">> cbrCloudBodyPassthrough: %lu received\r\n", length); switch (ctx->api) { + case V1_TIME: + /* a CC3200 (3.4.0) that got "teddycloud" as time blinked red and switched off */ + if (!ctx->buffer) + { + ctx->buffer = osAllocMem(TIME_DIGITS_MAX + 1); + } + if (ctx->bufferPos + length <= TIME_DIGITS_MAX) + { + osMemcpy(&ctx->buffer[ctx->bufferPos], payload, length); + } + ctx->bufferPos += length; + if (error != NO_ERROR) + { + ctx->buffer[MIN(ctx->bufferPos, TIME_DIGITS_MAX)] = '\0'; + if (ctx->bufferPos > TIME_DIGITS_MAX || !isValidTime(ctx->buffer)) + { + TRACE_WARNING(">> cloud sent no valid time, answering the local time\r\n"); + osSprintf(ctx->buffer, "%" PRIuTIME, time(NULL)); + } + char line[64]; + osSnprintf(line, sizeof(line), "Content-Length: %" PRIuSIZE "\r\n\r\n", osStrlen(ctx->buffer)); + httpSend(ctx->connection, line, osStrlen(line), HTTP_FLAG_DELAY); + httpSend(ctx->connection, ctx->buffer, osStrlen(ctx->buffer), HTTP_FLAG_DELAY); + osFreeMem(ctx->buffer); + ctx->buffer = NULL; + } + break; case V2_CONTENT: // Also handles V1_CONTENT if (ctx->client_ctx->settings->cloud.cacheContent && httpClientContext->statusCode == 200) { @@ -745,6 +803,7 @@ void readTrackPositions(tonie_info_t *tonieInfo, FsFile *file) track_positions_t *trackPos = &tonieInfo->additional.track_positions; TonieboxAudioFileHeader *tafHeader = tonieInfo->tafHeader; trackPos->count = tafHeader->n_track_page_nums; + trackPos->length = 0; if (trackPos->count > 0) { trackPos->pos = osAllocMem(trackPos->count * sizeof(uint32_t)); @@ -803,6 +862,22 @@ void readTrackPositions(tonie_info_t *tonieInfo, FsFile *file) correction = granulePosition; } } + if (!hasError && tafHeader->num_bytes >= 4096) + { + /* the granule position of the last ogg page is the total length */ + uint8_t buffer[14]; + size_t readBytes = 0; + size_t filePos = 4096 + 4096 * ((tafHeader->num_bytes - 1) / 4096); + if (fsSeekFile(file, filePos, SEEK_SET) == NO_ERROR && fsReadFile(file, buffer, sizeof(buffer), &readBytes) == NO_ERROR && readBytes == sizeof(buffer) && osMemcmp(buffer, "OggS", 4) == 0) + { + uint64_t granulePosition = 0; + osMemcpy(&granulePosition, &buffer[6], 8); + if (granulePosition >= correction) + { + trackPos->length = (uint32_t)((granulePosition - correction) / 48000); + } + } + } if (hasError) { trackPos->count = 0; @@ -983,6 +1058,7 @@ void freeTonieInfo(tonie_info_t *tonieInfo) osFreeMem(tonieInfo->additional.track_positions.pos); tonieInfo->additional.track_positions.pos = NULL; tonieInfo->additional.track_positions.count = 0; + tonieInfo->additional.track_positions.length = 0; } free_content_json(&tonieInfo->json); @@ -1223,7 +1299,7 @@ void cbrGenericResponsePassthrough(void *src_ctx, HttpClientContext *cloud_ctx) // This is fine: https://www.youtube.com/watch?v=0oBx7Jg4m-o const char *statusText = httpStatusCodeText(cloud_ctx->statusCode); - osSprintf(line, "HTTP/%d.%d %u %s\r\n", MSB(cloud_ctx->version), LSB(cloud_ctx->version), cloud_ctx->statusCode, statusText); + osSnprintf(line, sizeof(line), "HTTP/%d.%d %u %s\r\n", MSB(cloud_ctx->version), LSB(cloud_ctx->version), cloud_ctx->statusCode, statusText); httpSend(ctx->connection, line, osStrlen(line), HTTP_FLAG_DELAY); ctx->status = PROX_STATUS_CONN; } @@ -1231,7 +1307,6 @@ void cbrGenericResponsePassthrough(void *src_ctx, HttpClientContext *cloud_ctx) void cbrGenericHeaderPassthrough(void *src_ctx, HttpClientContext *cloud_ctx, const char *header, const char *value) { cbr_ctx_t *ctx = (cbr_ctx_t *)src_ctx; - char line[2048]; if (ctx->status != PROX_STATUS_HEAD) // Only once { @@ -1240,9 +1315,7 @@ void cbrGenericHeaderPassthrough(void *src_ctx, HttpClientContext *cloud_ctx, co char_t *allowOrigin = ctx->connection->serverContext->settings.allowOrigin; if (allowOrigin != NULL && osStrlen(allowOrigin) > 0) { - osSprintf(line, "Access-Control-Allow-Origin: %s\r\n", allowOrigin); - httpSend(ctx->connection, line, osStrlen(line), HTTP_FLAG_DELAY); - line[0] = '\0'; + httpSendHeaderLine(ctx->connection, "Access-Control-Allow-Origin", allowOrigin); } } } @@ -1252,16 +1325,15 @@ void cbrGenericHeaderPassthrough(void *src_ctx, HttpClientContext *cloud_ctx, co if (osStrcmp(header, "Access-Control-Allow-Origin") != 0) { TRACE_DEBUG(">> cbrGenericHeaderPassthrough: %s = %s\r\n", header, value); - osSprintf(line, "%s: %s\r\n", header, value); + httpSendHeaderLine(ctx->connection, header, value); } } else { TRACE_DEBUG(">> cbrGenericHeaderPassthrough: NULL\r\n"); - osStrcpy(line, "\r\n"); + httpSend(ctx->connection, "\r\n", 2, HTTP_FLAG_DELAY); } - httpSend(ctx->connection, line, osStrlen(line), HTTP_FLAG_DELAY); ctx->status = PROX_STATUS_HEAD; } diff --git a/src/handler_api.c b/src/handler_api.c index fe678bb34..5c6fe3b91 100644 --- a/src/handler_api.c +++ b/src/handler_api.c @@ -25,6 +25,8 @@ #include "cert.h" #include "esp32.h" #include "cache.h" +#include "libraryMeta.h" +#include "zip_writer.h" error_t parsePostData(HttpConnection *connection, char_t *post_data, size_t buffer_size) { @@ -66,6 +68,32 @@ void sanitizePath(char *path, bool isDir) pathCanonicalize(path); + /* Strip any leading "../" (and a bare "..") that survived canonicalization, + so a user-supplied relative path cannot climb above the base directory it + is later joined to. Without this the "merge to prevent directory traversal" + contract does not actually hold: "../../etc" stays "../../etc" and escapes + once prepended to the root. */ + { + size_t strip = 0; + while (osStrncmp(path + strip, "../", 3) == 0) + { + strip += 3; + } + if (osStrcmp(path + strip, "..") == 0) + { + strip += 2; + } + if (strip > 0) + { + size_t w = 0; + for (size_t r = strip; path[r] != '\0'; r++) + { + path[w++] = path[r]; + } + path[w] = '\0'; + } + } + /* Merge all double (or more) slashes // */ for (i = 0, j = 0; path[i]; ++i) { @@ -306,6 +334,13 @@ error_t handleApiGetIndex(HttpConnection *connection, const char_t *uri, const c continue; } + /* Never expose secret options (e.g. private keys) through the index, + regardless of the level/nolevel filter below. */ + if (opt->level == LEVEL_SECRET) + { + continue; + } + settings_level user_level = get_settings_ovl(overlay)->core.settings_level; if (!isNoLevel && opt->level > user_level) { @@ -671,6 +706,27 @@ error_t handleApiFileIndexV2(HttpConnection *connection, const char_t *uri, cons char *filePathAbsolute = custom_asprintf("%s%c%s", pathAbsolute, PATH_SEPARATOR, entry.name); pathSafeCanonicalize(filePathAbsolute); + /* skip content.json sidecar files (.json next to ), they are not browsable content. + * Only hide them when the sibling still exists - an orphaned .json (sibling deleted, e.g. by + * cache eviction that didn't clean up after itself) is intentionally left visible, so it can be + * spotted and cleaned up manually. */ + if (!isDir) + { + size_t nameLen = osStrlen(entry.name); + if (nameLen > 5 && !osStrcasecmp(&entry.name[nameLen - 5], ".json")) + { + char *siblingPath = strdup(filePathAbsolute); + siblingPath[osStrlen(siblingPath) - 5] = '\0'; + bool_t isSidecar = fsFileExists(siblingPath); + osFreeMem(siblingPath); + if (isSidecar) + { + osFreeMem(filePathAbsolute); + continue; + } + } + } + cJSON *jsonEntry = cJSON_CreateObject(); cJSON_AddStringToObject(jsonEntry, "name", entry.name); cJSON_AddNumberToObject(jsonEntry, "date", convertDateToUnixTime(&entry.modified)); @@ -707,6 +763,12 @@ error_t handleApiFileIndexV2(HttpConnection *connection, const char_t *uri, cons { cJSON_AddItemToArray(tracksArray, cJSON_CreateNumber(tafInfo->additional.track_positions.pos[i])); } + if (tafInfo->additional.track_positions.length > 0) + { + cJSON_AddNumberToObject(tafHeaderEntry, "lengthSeconds", tafInfo->additional.track_positions.length); + } + + cJSON_AddBoolToObject(jsonEntry, "listened", library_meta_get_listened(filePathAbsolute)); item = tonies_byAudioIdHashModel(tafInfo->tafHeader->audio_id, tafInfo->tafHeader->sha1_hash.data, tafInfo->json.tonie_model); } @@ -718,6 +780,7 @@ error_t handleApiFileIndexV2(HttpConnection *connection, const char_t *uri, cons char *filePathAbsoluteSub = NULL; FsDir *subdir = fsOpenDir(filePathAbsolute); FsDirEntry subentry; + bool_t subHide = false; if (subdir != NULL) { while (true) @@ -739,9 +802,10 @@ error_t handleApiFileIndexV2(HttpConnection *connection, const char_t *uri, cons load_content_json(filePathAbsoluteSub, &contentJson, false, client_ctx->settings); item = tonies_byModel(contentJson.tonie_model); osFreeMem(filePathAbsoluteSub); - cJSON_AddBoolToObject(jsonEntry, "hide", contentJson.hide); + subHide = contentJson.hide; free_content_json(&contentJson); } + cJSON_AddBoolToObject(jsonEntry, "hide", subHide); } } else @@ -756,6 +820,7 @@ error_t handleApiFileIndexV2(HttpConnection *connection, const char_t *uri, cons item = tonies_byModel(contentJson.tonie_model); cJSON_AddBoolToObject(jsonEntry, "hide", contentJson.hide); + cJSON_AddBoolToObject(jsonEntry, "listened", library_meta_get_listened(filePathAbsolute)); if (contentJson._has_cloud_auth) { cJSON_AddBoolToObject(jsonEntry, "has_cloud_auth", true); @@ -783,6 +848,65 @@ error_t handleApiFileIndexV2(HttpConnection *connection, const char_t *uri, cons return httpWriteResponse(connection, jsonString, connection->response.contentLength, true); } +error_t handleApiFileSetListened(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) +{ + char overlay[16]; + const char *rootPath = NULL; + + if (queryPrepare(queryString, &rootPath, overlay, sizeof(overlay), &client_ctx->settings) != NO_ERROR) + { + return ERROR_FAILURE; + } + + char path[128]; + + if (!queryGet(queryString, "path", path, sizeof(path))) + { + TRACE_ERROR("path missing!\r\n"); + return ERROR_INVALID_REQUEST; + } + + /* first canonicalize path, then merge to prevent directory traversal bugs */ + pathSafeCanonicalize(path); + char *pathAbsolute = custom_asprintf("%s%c%s", rootPath, PATH_SEPARATOR, path); + pathSafeCanonicalize(pathAbsolute); + + if (!fsFileExists(pathAbsolute)) + { + TRACE_ERROR("File not found: '%s'\r\n", pathAbsolute); + osFreeMem(pathAbsolute); + return ERROR_NOT_FOUND; + } + + char_t post_data[POST_BUFFER_SIZE]; + error_t error = parsePostData(connection, post_data, POST_BUFFER_SIZE); + if (error != NO_ERROR) + { + osFreeMem(pathAbsolute); + return error; + } + + char item_data[16]; + bool_t target_value = false; + if (queryGet(post_data, "listened", item_data, sizeof(item_data))) + { + target_value = !osStrcmp(item_data, "true"); + } + + if (library_meta_get_listened(pathAbsolute) != target_value) + { + error = library_meta_set_listened(pathAbsolute, target_value); + } + + osFreeMem(pathAbsolute); + + if (error != NO_ERROR) + { + return error; + } + + return httpOkResponse(connection); +} error_t handleApiFileIndex(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) { char *jsonString = strdup("{\"files\":[]}"); // Make warning go away @@ -1047,6 +1171,18 @@ error_t file_save_end(void *in_ctx) return NO_ERROR; } +/* uploaded box certificates include private.der, keep them readable by teddyCloud only */ +static error_t file_save_start_cert(void *in_ctx, const char *name, const char *filename) +{ + error_t error = file_save_start(in_ctx, name, filename); + const char *path = ((file_save_ctx *)in_ctx)->filename; + if (error == NO_ERROR && !osChmodOwnerOnly(path)) + { + TRACE_WARNING("Could not restrict permissions of '%s'\r\n", path); + } + return error; +} + error_t file_save_end_cert(void *in_ctx) { file_save_ctx *ctx = (file_save_ctx *)in_ctx; @@ -1058,27 +1194,40 @@ error_t file_save_end_cert(void *in_ctx) fsCloseFile(ctx->file); ctx->file = NULL; - /* file was uploaded, this is the cert-specific handler */ - if (!osStrcasecmp(ctx->filename, "ca.der")) + /* file was uploaded, this is the cert-specific handler. ctx->filename is the + full path, so compare its file name. Store the path as /, + the relative form box overlays get on creation, not the absolute path. */ + const char *name = pathGetFilename(ctx->filename); + const char *setting = NULL; + if (!osStrcasecmp(name, "ca.der")) + { + setting = "core.client_cert.file.ca"; + } + else if (!osStrcasecmp(name, "client.der")) { - TRACE_INFO("Set ca.der to %s\r\n", ctx->filename); - settings_set_string_ovl("core.client_cert.file.ca", ctx->filename, ctx->overlay); + setting = "core.client_cert.file.crt"; } - else if (!osStrcasecmp(ctx->filename, "client.der")) + else if (!osStrcasecmp(name, "private.der")) { - TRACE_INFO("Set client.der to %s\r\n", ctx->filename); - settings_set_string_ovl("core.client_cert.file.crt", ctx->filename, ctx->overlay); + setting = "core.client_cert.file.key"; } - else if (!osStrcasecmp(ctx->filename, "private.der")) + + if (setting) { - TRACE_INFO("Set private.der to %s\r\n", ctx->filename); - settings_set_string_ovl("core.client_cert.file.key", ctx->filename, ctx->overlay); + char *path = custom_asprintf("%s%c%s", settings_get_string_ovl("core.certdir", ctx->overlay), PATH_SEPARATOR, name); + TRACE_INFO("Set %s to %s\r\n", setting, path); + settings_set_string_ovl(setting, path, ctx->overlay); + settings_load_client_certs_id(get_overlay_id(ctx->overlay)); + osFreeMem(path); } else { TRACE_INFO("Unknown file type %s\r\n", ctx->filename); } + osFreeMem(ctx->filename); + ctx->filename = NULL; + return NO_ERROR; } @@ -1093,6 +1242,7 @@ error_t handleApiUploadCert(HttpConnection *connection, const char_t *uri, const TRACE_DEBUG("got overlay '%s'\r\n", overlay); } const char *rootPath = settings_get_string_ovl("internal.certdirfull", overlay); + error_t dirError = rootPath != NULL && !fsDirExists(rootPath) ? fsCreateDirEx(rootPath, true) : NO_ERROR; if (rootPath == NULL) { @@ -1100,14 +1250,10 @@ error_t handleApiUploadCert(HttpConnection *connection, const char_t *uri, const osSnprintf(message, sizeof(message), "internal.certdirfull not set to a valid path"); TRACE_ERROR("internal.certdirfull not set to a valid path\r\n"); } - else if (!fsDirExists(rootPath)) + else if (dirError != NO_ERROR || !fsDirExists(rootPath)) { - error_t error = fsCreateDirEx(rootPath, true); - if (error != NO_ERROR || !fsDirExists(rootPath)) - { - osSnprintf(message, sizeof(message), "internal.certdirfull '%s' does not exist and could not be created. Error: %s", rootPath, error2text(error)); - TRACE_ERROR("internal.certdirfull '%s' does not exist and could not be created. Error: %s\r\n", rootPath, error2text(error)); - } + osSnprintf(message, sizeof(message), "internal.certdirfull '%s' does not exist and could not be created. Error: %s", rootPath, error2text(dirError)); + TRACE_ERROR("internal.certdirfull '%s' does not exist and could not be created. Error: %s\r\n", rootPath, error2text(dirError)); } else { @@ -1117,7 +1263,7 @@ error_t handleApiUploadCert(HttpConnection *connection, const char_t *uri, const osMemset(&cbr, 0x00, sizeof(cbr)); osMemset(&ctx, 0x00, sizeof(ctx)); - cbr.multipart_start = &file_save_start; + cbr.multipart_start = &file_save_start_cert; cbr.multipart_add = &file_save_add; cbr.multipart_end = &file_save_end_cert; @@ -1153,34 +1299,44 @@ error_t file_save_start_suffix(void *in_ctx, const char *name, const char *filen } /* first canonicalize path, then merge to prevent directory traversal bugs */ + ctx->filename = NULL; for (int suffix = 0; suffix < 100; suffix++) { + char *candidate; if (suffix) { - ctx->filename = custom_asprintf("%s/%s_%d.bin", ctx->root_path, filename, suffix); + candidate = custom_asprintf("%s/%s_%d.bin", ctx->root_path, filename, suffix); } else { - ctx->filename = custom_asprintf("%s/%s.bin", ctx->root_path, filename); + candidate = custom_asprintf("%s/%s.bin", ctx->root_path, filename); } - sanitizePath(ctx->filename, false); + sanitizePath(candidate, false); - if (fsFileExists(ctx->filename)) + if (fsFileExists(candidate)) { - osFreeMem(ctx->filename); + osFreeMem(candidate); continue; } - else - { - TRACE_INFO("Writing to '%s'\r\n", ctx->filename); - break; - } + + /* only keep the first free name; never leave ctx->filename dangling */ + ctx->filename = candidate; + TRACE_INFO("Writing to '%s'\r\n", ctx->filename); + break; + } + + if (ctx->filename == NULL) + { + TRACE_ERROR("No free filename suffix available for '%s'\r\n", filename); + return ERROR_FILE_OPENING_FAILED; } ctx->file = fsOpenFile(ctx->filename, FS_FILE_MODE_WRITE | FS_FILE_MODE_CREATE | FS_FILE_MODE_TRUNC); if (ctx->file == NULL) { + osFreeMem(ctx->filename); + ctx->filename = NULL; return ERROR_FILE_OPENING_FAILED; } @@ -1217,6 +1373,8 @@ error_t handleApiESP32UploadFirmware(HttpConnection *connection, const char_t *u char message[128]; char overlay[16]; + message[0] = '\0'; + const char *rootPath = get_settings()->internal.firmwaredirfull; if (rootPath == NULL || !fsDirExists(rootPath)) @@ -1244,6 +1402,13 @@ error_t handleApiESP32UploadFirmware(HttpConnection *connection, const char_t *u switch (multipart_handle(connection, &cbr, &ctx)) { case NO_ERROR: + if (ctx.filename == NULL) + { + /* multipart parsed, but no file part was received */ + statusCode = 400; + osSnprintf(message, sizeof(message), "No file received"); + break; + } statusCode = 200; TRACE_INFO("Received new file:\r\n"); TRACE_INFO(" '%s'\r\n", ctx.filename); @@ -1251,6 +1416,7 @@ error_t handleApiESP32UploadFirmware(HttpConnection *connection, const char_t *u break; default: statusCode = 500; + osSnprintf(message, sizeof(message), "Firmware upload failed"); break; } @@ -1306,6 +1472,14 @@ error_t handleApiESP32ExtractCerts(HttpConnection *connection, const char_t *uri return ERROR_FAILURE; } + /* filename is joined to the firmware dir and opened; reject any path + separators or ".." so it cannot point outside that directory. */ + if (osStrchr(filename, '/') || osStrchr(filename, '\\') || osStrstr(filename, "..")) + { + TRACE_ERROR("Invalid firmware filename '%s'\r\n", filename); + return ERROR_NOT_FOUND; + } + bool overwrite = false; bool overwriteBase = false; if (queryGet(queryString, "overwrite", overwrite_s, sizeof(overwrite_s))) @@ -1326,6 +1500,17 @@ error_t handleApiESP32ExtractCerts(HttpConnection *connection, const char_t *uri mac[12] = 0; osStringToLower(mac); + /* mac becomes a directory name under the cert dir; require 12 hex chars so + it cannot contain separators or ".." and escape the cert directory. */ + for (size_t i = 0; i < 12; i++) + { + if (!((mac[i] >= '0' && mac[i] <= '9') || (mac[i] >= 'a' && mac[i] <= 'f'))) + { + TRACE_ERROR("Invalid MAC '%s'\r\n", mac); + return ERROR_NOT_FOUND; + } + } + char *file_path = custom_asprintf("%s%c%s", firmwareRootPath, PATH_SEPARATOR, filename); char *target_dir = custom_asprintf("%s%c%s", certRootPath, PATH_SEPARATOR, mac); @@ -1428,6 +1613,8 @@ error_t handleApiESP32PatchFirmware(HttpConnection *connection, const char_t *ur char patch_host[32] = {0}; char wifi_ssid[64] = {0}; char wifi_pass[64] = {0}; + char port_text[8] = {0}; + uint32_t port = 0; char filename[255] = {0}; char mac[13] = {0}; osStrcpy(old_patch_host, ""); @@ -1450,6 +1637,12 @@ error_t handleApiESP32PatchFirmware(HttpConnection *connection, const char_t *ur TRACE_INFO("Patch hostnames with old hostname '%s'\r\n", old_patch_host); } + if (queryGet(queryString, "port", port_text, sizeof(port_text))) + { + port = (uint32_t)strtoul(port_text, NULL, 10); + TRACE_INFO("Patch port %" PRIu32 "\r\n", port); + } + if (queryGet(queryString, "wifi_ssid", wifi_ssid, sizeof(wifi_ssid))) { TRACE_INFO("wifi ssid '%s'\r\n", wifi_ssid); @@ -1460,6 +1653,13 @@ error_t handleApiESP32PatchFirmware(HttpConnection *connection, const char_t *ur TRACE_INFO("wifi pass '%s'\r\n", wifi_pass); } + /* filename is joined to the firmware dir; reject separators and ".." */ + if (osStrchr(filename, '/') || osStrchr(filename, '\\') || osStrstr(filename, "..")) + { + TRACE_ERROR("Invalid firmware filename '%s'\r\n", filename); + return ERROR_NOT_FOUND; + } + const char *sep = osStrchr(filename, '_'); if (!sep || strlen(&sep[1]) < 12) { @@ -1469,6 +1669,16 @@ error_t handleApiESP32PatchFirmware(HttpConnection *connection, const char_t *ur osStrncpy(mac, &sep[1], 12); mac[12] = 0; + /* mac ends up in file/dir names; require 12 hex chars so it cannot contain separators or ".." */ + for (size_t i = 0; i < 12; i++) + { + if (!((mac[i] >= '0' && mac[i] <= '9') || (mac[i] >= 'a' && mac[i] <= 'f') || (mac[i] >= 'A' && mac[i] <= 'F'))) + { + TRACE_ERROR("Invalid MAC '%s'\r\n", mac); + return ERROR_NOT_FOUND; + } + } + char *file_path = custom_asprintf("%s%c%s", rootPath, PATH_SEPARATOR, filename); char *patched_path = custom_asprintf("%s%cpatched_%s.bin", rootPath, PATH_SEPARATOR, mac); @@ -1523,6 +1733,15 @@ error_t handleApiESP32PatchFirmware(HttpConnection *connection, const char_t *ur } } + if (port != 0) + { + if (esp32_patch_port(patched_path, port) != NO_ERROR) + { + TRACE_ERROR("Failed to patch port\r\n"); + return ERROR_NOT_FOUND; + } + } + if (osStrlen(wifi_ssid) > 0) { if (esp32_patch_wifi(patched_path, wifi_ssid, wifi_pass) != NO_ERROR) @@ -1669,6 +1888,327 @@ error_t handleApiFileUpload(HttpConnection *connection, const char_t *uri, const return httpWriteResponseString(connection, message, false); } +#define TAF_TRACK_EXPORT_MAX 99 +#define TAF_TEMP_PATH_LEN 512 +#define TAF_TEMP_FILE_LEN (TAF_TEMP_PATH_LEN + 264) + +static void sanitize_track_title(const char *in, char *out, size_t out_len) +{ + size_t j = 0; + + if (in == NULL) + { + in = ""; + } + for (size_t i = 0; in[i] != '\0';) + { + unsigned char c = (unsigned char)in[i]; + /* length of the UTF-8 sequence, so a truncated title never ends in half a character */ + size_t len = (c >= 0xF0) ? 4 : (c >= 0xE0) ? 3 : (c >= 0xC0) ? 2 : 1; + + if (j + len + 1 > out_len) + { + break; + } + if (c < 0x20 || c == '/' || c == '\\' || c == '"' || c == '`' || c == '$') + { + out[j++] = '_'; + i++; + continue; + } + for (size_t k = 0; k < len && in[i] != '\0'; k++) + { + out[j++] = in[i++]; + } + } + out[j] = '\0'; +} + +static void remove_taf_temp_dir(const char *dir) +{ + FsDir *handle; + FsDirEntry entry; + + if (dir == NULL || osStrstr(dir, "tc-taf-") == NULL) + { + return; + } + + handle = fsOpenDir(dir); + if (handle != NULL) + { + while (fsReadDir(handle, &entry) == NO_ERROR) + { + char file_path[TAF_TEMP_FILE_LEN]; + + if (osStrcmp(entry.name, ".") == 0 || osStrcmp(entry.name, "..") == 0) + { + continue; + } + osSnprintf(file_path, sizeof(file_path), "%s/%s", dir, entry.name); + fsDeleteFile(file_path); + } + fsCloseDir(handle); + } + + if (fsRemoveDir(dir) != NO_ERROR) + { + TRACE_WARNING("Could not remove temp dir %s\r\n", dir); + } +} + +static error_t create_taf_temp_dir(const settings_t *settings, char *out_path, size_t out_size) +{ + const char *base = "."; + int attempt; + + if (settings != NULL && settings->internal.cachedirfull != NULL && settings->internal.cachedirfull[0] != '\0') + { + base = settings->internal.cachedirfull; + } + fsCreateDirEx(base, true); + + srand((unsigned int)time(NULL) ^ (unsigned int)(uintptr_t)out_path); + + for (attempt = 0; attempt < 16; attempt++) + { + unsigned int token = ((unsigned int)rand() << 16) ^ (unsigned int)rand() ^ (unsigned int)attempt; + + osSnprintf(out_path, out_size, "%s/tc-taf-%08x", base, token); + if (fsDirExists(out_path)) + { + continue; + } + if (fsCreateDirEx(out_path, false) == NO_ERROR) + { + return NO_ERROR; + } + } + return ERROR_FAILURE; +} + +static error_t handleApiContentTrackExport(HttpConnection *connection, const char *file_path, const char *tracks_param, settings_t *settings) +{ + FILE *src = NULL; + tonie_info_t *tafInfo = NULL; + toniesJson_item_t *tonieItem = NULL; + char tmpdir[TAF_TEMP_PATH_LEN]; + bool have_dir = false; + uint32_t pages[TAF_TRACK_EXPORT_MAX]; + int track_numbers[TAF_TRACK_EXPORT_MAX]; + char titles[TAF_TRACK_EXPORT_MAX][96]; + char *out_paths[TAF_TRACK_EXPORT_MAX]; + char *entry_names[TAF_TRACK_EXPORT_MAX]; + size_t page_count = 0; + size_t track_count = 0; + uint64_t num_bytes = 0; + long file_size = 0; + error_t error = NO_ERROR; + + osMemset(out_paths, 0, sizeof(out_paths)); + osMemset(entry_names, 0, sizeof(entry_names)); + osMemset(titles, 0, sizeof(titles)); + + src = fopen(file_path, "rb"); + if (src == NULL) + { + return ERROR_NOT_FOUND; + } + if (fseek(src, 0, SEEK_END) != 0) + { + fclose(src); + return ERROR_FAILURE; + } + file_size = ftell(src); + if (file_size < 4096) + { + fclose(src); + return ERROR_FAILURE; + } + + tafInfo = getTonieInfo(file_path, false, settings); + if (tafInfo == NULL || !tafInfo->valid || tafInfo->tafHeader == NULL || tafInfo->tafHeader->n_track_page_nums == 0) + { + if (tafInfo != NULL) + { + freeTonieInfo(tafInfo); + } + fclose(src); + return ERROR_FAILURE; + } + + page_count = tafInfo->tafHeader->n_track_page_nums; + if (page_count > TAF_TRACK_EXPORT_MAX) + { + page_count = TAF_TRACK_EXPORT_MAX; + } + osMemcpy(pages, tafInfo->tafHeader->track_page_nums, page_count * sizeof(uint32_t)); + num_bytes = tafInfo->tafHeader->num_bytes; + tonieItem = tonies_byAudioId(tafInfo->tafHeader->audio_id); + if (tonieItem != NULL) + { + size_t name_count = tonieItem->tracks_count; + if (name_count > page_count) + { + name_count = page_count; + } + for (size_t i = 0; i < name_count; i++) + { + sanitize_track_title(tonieItem->tracks[i], titles[i], sizeof(titles[i])); + } + } + freeTonieInfo(tafInfo); + tafInfo = NULL; + + { + const char *p = tracks_param; + while (*p != '\0' && track_count < TAF_TRACK_EXPORT_MAX) + { + char *end = NULL; + long value; + + while (*p == ',' || *p == ' ') + { + p++; + } + if (*p == '\0') + { + break; + } + value = strtol(p, &end, 10); + if (end == p || value < 1 || (size_t)value > page_count) + { + fclose(src); + return httpSendErrorResponse(connection, 400, "Invalid track selection"); + } + track_numbers[track_count++] = (int)value; + p = end; + } + } + if (track_count == 0) + { + fclose(src); + return httpSendErrorResponse(connection, 400, "Invalid track selection"); + } + + if (create_taf_temp_dir(settings, tmpdir, sizeof(tmpdir)) != NO_ERROR) + { + fclose(src); + return ERROR_FAILURE; + } + have_dir = true; + + for (size_t i = 0; i < track_count; i++) + { + int chapter = track_numbers[i] - 1; + uint32_t start_block = pages[chapter]; + uint32_t end_block; + uint32_t start_off; + uint32_t end_off; + char name[160]; + char path[TAF_TEMP_FILE_LEN]; + FILE *dst; + + if ((size_t)chapter + 1 < page_count) + { + end_block = pages[chapter + 1]; + } + else + { + end_block = (uint32_t)(num_bytes / 4096); + } + start_off = 4096u + start_block * 4096u; + end_off = 4096u + end_block * 4096u; + if (end_off > (uint32_t)file_size) + { + end_off = (uint32_t)file_size; + } + if (end_block <= start_block || start_off >= end_off) + { + error = ERROR_FAILURE; + break; + } + + /* the title only goes into the zip entry name, the temp file name stays ASCII */ + if (titles[chapter][0] != '\0') + { + osSnprintf(name, sizeof(name), "%02d %s.ogg", track_numbers[i], titles[chapter]); + } + else + { + osSnprintf(name, sizeof(name), "%02d.ogg", track_numbers[i]); + } + osSnprintf(path, sizeof(path), "%s/%02d.ogg", tmpdir, track_numbers[i]); + dst = fopen(path, "wb"); + if (dst == NULL) + { + error = ERROR_FAILURE; + break; + } + if (chapter > 0) + { + error = fsCopyFileRange(src, 4096, 4096 + 512, dst); + } + if (error == NO_ERROR) + { + error = fsCopyFileRange(src, start_off, end_off, dst); + } + fclose(dst); + if (error != NO_ERROR) + { + break; + } + out_paths[i] = strdup(path); + entry_names[i] = strdup(name); + if (out_paths[i] == NULL || entry_names[i] == NULL) + { + error = ERROR_FAILURE; + break; + } + } + fclose(src); + src = NULL; + if (error != NO_ERROR) + { + goto cleanup; + } + + if (track_count == 1) + { + /* the uri only selects the content type by extension */ + error = httpSendResponseUnsafe(connection, "track.ogg", out_paths[0]); + } + else + { + char zip_path[TAF_TEMP_FILE_LEN]; + + osSnprintf(zip_path, sizeof(zip_path), "%s/tracks.zip", tmpdir); + if (zipWriteStored(zip_path, track_count, (const char *const *)out_paths, (const char *const *)entry_names) != NO_ERROR) + { + TRACE_ERROR("Failed to zip selected TAF tracks from %s\r\n", file_path); + error = ERROR_FAILURE; + goto cleanup; + } + error = httpSendResponseUnsafe(connection, "tracks.zip", zip_path); + } + +cleanup: + if (src != NULL) + { + fclose(src); + } + for (size_t i = 0; i < track_count; i++) + { + free(out_paths[i]); + free(entry_names[i]); + } + if (have_dir) + { + remove_taf_temp_dir(tmpdir); + } + return error; +} + error_t handleApiContent(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) { TRACE_DEBUG("Query: '%s'\r\n", queryString); @@ -1697,6 +2237,14 @@ error_t handleApiContent(HttpConnection *connection, const char_t *uri, const ch TRACE_DEBUG("Request for '%s', ogg: %s\r\n", file_path, ogg); + char tracks_param[TAF_TRACK_EXPORT_MAX * 4]; + if (queryGet(queryString, "tracks", tracks_param, sizeof(tracks_param))) + { + error_t export_error = handleApiContentTrackExport(connection, file_path, tracks_param, client_ctx->settings); + free(file_path); + return export_error; + } + error_t error; size_t n; uint32_t length; @@ -1759,7 +2307,9 @@ error_t handleApiContent(HttpConnection *connection, const char_t *uri, const ch connection->response.statusCode = 200; connection->response.contentLength = length; } - connection->response.contentType = "audio/ogg"; + /* content files are mostly extensionless TAFs, but tag configs are JSON (iOS names the download after the type) */ + const char *ext = strrchr(uri, '.'); + connection->response.contentType = (ext != NULL && !osStrcasecmp(ext, ".json")) ? "application/json" : "audio/ogg"; connection->response.chunkedEncoding = FALSE; error = httpWriteHeader(connection); @@ -1860,6 +2410,27 @@ error_t handleApiContentDownload(HttpConnection *connection, const char_t *uri, char *path = (char *)uri + 1 + 7 + 1 + 8 + 1; char *pathAbsolute = custom_asprintf("%s%c%s", rootPath, PATH_SEPARATOR, path); + char tracks_param[TAF_TRACK_EXPORT_MAX * 4]; + if (queryGet(queryString, "tracks", tracks_param, sizeof(tracks_param))) + { + /* export from the TAF the tag points to (e.g. a lib:// source), same as /content/ does */ + tonie_info_t *tonieInfo = getTonieInfo(pathAbsolute, false, client_ctx->settings); + osFreeMem(pathAbsolute); + + error_t export_error = ERROR_NOT_FOUND; + if (tonieInfo->exists && tonieInfo->valid && + (tonieInfo->json._source_type == CT_SOURCE_NONE || tonieInfo->json._source_type == CT_SOURCE_TAF || tonieInfo->json._source_type == CT_SOURCE_TAP_CACHED)) + { + export_error = handleApiContentTrackExport(connection, tonieInfo->contentPath, tracks_param, client_ctx->settings); + } + else + { + TRACE_ERROR("No local TAF for track export of '%s'\r\n", path); + } + freeTonieInfo(tonieInfo); + return export_error; + } + char ruid[17]; contentJson_t contentJson; @@ -2000,8 +2571,7 @@ error_t handleApiEncodeFile(HttpConnection *connection, const char_t *uri, const return error; } - char multisource[99][PATH_LEN]; - uint8_t multisource_size = 0; + size_t multisource_size = 0; char source[PATH_LEN]; char target[PATH_LEN]; @@ -2025,16 +2595,34 @@ error_t handleApiEncodeFile(HttpConnection *connection, const char_t *uri, const } else { - while (queryGetMulti(post_data, "source", source, sizeof(source), multisource_size)) + /* ~405 KB; keep it off the request-thread stack (ffmpeg_convert takes + char[99][PATH_LEN], which a char (*)[PATH_LEN] decays to). */ + char(*multisource)[PATH_LEN] = osAllocMem(sizeof(char[99][PATH_LEN])); + if (multisource == NULL) + { + osFreeMem(targetAbsolute); + return ERROR_OUT_OF_MEMORY; + } + + while (multisource_size < 99 && + queryGetMulti(post_data, "source", source, sizeof(source), multisource_size)) { sanitizePath(source, false); - osSprintf(multisource[multisource_size], "%s%c%s", rootPath, PATH_SEPARATOR, source); + int_t written = osSnprintf(multisource[multisource_size], PATH_LEN, "%s%c%s", rootPath, PATH_SEPARATOR, source); + if (written < 0 || written >= PATH_LEN) + { + TRACE_ERROR("Source path too long!\r\n"); + osFreeMem(targetAbsolute); + osFreeMem(multisource); + return ERROR_INVALID_REQUEST; + } sanitizePath(multisource[multisource_size], false); // TRACE_INFO("Source %s\r\n", multisource[multisource_size]); if (!fsFileExists(multisource[multisource_size])) { TRACE_ERROR("Source %s does not exist!\r\n", multisource[multisource_size]); osFreeMem(targetAbsolute); + osFreeMem(multisource); return ERROR_INVALID_REQUEST; } multisource_size++; @@ -2043,13 +2631,15 @@ error_t handleApiEncodeFile(HttpConnection *connection, const char_t *uri, const { TRACE_ERROR("Source missing!\r\n"); osFreeMem(targetAbsolute); + osFreeMem(multisource); return ERROR_INVALID_REQUEST; } - TRACE_INFO("Encode %" PRIu8 " files to %s\r\n", multisource_size, targetAbsolute); + TRACE_INFO("Encode %" PRIuSIZE " files to %s\r\n", multisource_size, targetAbsolute); size_t current_source = 0; error = ffmpeg_convert(multisource, multisource_size, ¤t_source, targetAbsolute, 0); osFreeMem(targetAbsolute); + osFreeMem(multisource); if (error != NO_ERROR) { TRACE_ERROR("ffmpeg_convert failed with error %s\r\n", error2text(error)); @@ -4400,6 +4990,10 @@ error_t getTagInfoJson(char ruid[17], cJSON *jsonTarget, client_ctx_t *client_ct { cJSON_AddItemToArray(tracksArray, cJSON_CreateNumber(tafInfo->additional.track_positions.pos[i])); } + if (tafInfo->additional.track_positions.length > 0) + { + cJSON_AddNumberToObject(jsonEntry, "lengthSeconds", tafInfo->additional.track_positions.length); + } char *downloadUrl = custom_asprintf("/content/download/%s?overlay=%s", tagPath, client_ctx->settings->internal.overlayUniqueId); char *audioUrl = custom_asprintf("%s&skip_header=true", downloadUrl); @@ -4585,72 +5179,6 @@ error_t handleApiTagIndex(HttpConnection *connection, const char_t *uri, const c return httpWriteResponse(connection, jsonString, connection->response.contentLength, true); } -#define TEST_TOKEN "THIS_IS_A_TEST_TOKEN" - -error_t handleApiAuthLogin(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) -{ - char_t post_data[POST_BUFFER_SIZE]; - error_t error = parsePostData(connection, post_data, POST_BUFFER_SIZE); - if (error != NO_ERROR) - { - return error; - } - - char username[256]; - if (queryGet(post_data, "username", username, sizeof(username))) - { - char passwordHash[256]; - if (queryGet(post_data, "passwordHash", passwordHash, sizeof(passwordHash))) - { - if (osStrcmp("admin", username) == 0) // && osStrcmp("admin", passwordHash) == 0) - { - char *token = TEST_TOKEN; - httpInitResponseHeader(connection); - connection->response.contentType = "text/plain"; - connection->response.contentLength = osStrlen(token); - - return httpWriteResponse(connection, token, connection->response.contentLength, false); - } - } - } - httpInitResponseHeader(connection); - connection->response.contentLength = 0; - connection->response.statusCode = 401; // Unauthorized - return httpWriteResponse(connection, "", connection->response.contentLength, false); -} -error_t handleApiAuthLogout(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) -{ - httpInitResponseHeader(connection); - connection->response.contentLength = 0; - connection->response.statusCode = 200; // Unauthorized - connection->response.contentType = "text/plain"; - return httpWriteResponse(connection, "", connection->response.contentLength, false); -} -error_t handleApiAuthRefreshToken(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) -{ - char_t post_data[POST_BUFFER_SIZE]; - error_t error = parsePostData(connection, post_data, POST_BUFFER_SIZE); - if (error != NO_ERROR) - { - return error; - } - - httpInitResponseHeader(connection); - connection->response.statusCode = 401; // Unauthorized - connection->response.contentType = "text/plain"; - char refreshToken[256]; - refreshToken[0] = '\0'; - if (queryGet(post_data, "refreshToken", refreshToken, sizeof(refreshToken))) - { - if (osStrcmp(TEST_TOKEN, refreshToken) == 0) - { - connection->response.statusCode = 200; - } - } - connection->response.contentLength = osStrlen(refreshToken); - return httpWriteResponse(connection, refreshToken, connection->response.contentLength, false); -} - error_t handleApiMigrateContent2Lib(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) { char overlay[16]; diff --git a/src/handler_cloud.c b/src/handler_cloud.c index 10afcec5d..14ca88a10 100644 --- a/src/handler_cloud.c +++ b/src/handler_cloud.c @@ -17,6 +17,7 @@ #include "toniefile.h" #include "toniesJson.h" #include "tonie_audio_playlist.h" +#include "libraryMeta.h" #include "cJSON.h" #include @@ -352,9 +353,14 @@ error_t handleCloudLog(HttpConnection *connection, const char_t *uri, const char { cbr_ctx_t ctx; req_cbr_t cbr = getCloudCbr(connection, uri, queryString, V1_LOG, &ctx, client_ctx); - cloud_request_post(NULL, 0, uri, queryString, data, size, NULL, &cbr); + if (!cloud_request_post(NULL, 0, uri, queryString, data, size, NULL, &cbr)) + { + return NO_ERROR; + } } - return NO_ERROR; + /* without an answer the box closes the connection after a few seconds */ + httpPrepareHeader(connection, NULL, 0); + return httpWriteResponse(connection, NULL, 0, false); } error_t handleCloudClaim(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) @@ -387,7 +393,7 @@ error_t handleCloudClaim(HttpConnection *connection, const char_t *uri, const ch /* allow to override HTTP status code if needed */ bool served = false; httpPrepareHeader(connection, NULL, 0); - connection->response.statusCode = 200; + connection->response.statusCode = 204; /* after a 200 the boxes close the connection */ if (client_ctx->settings->cloud.dumpRuidAuthContentJson && connection->request.auth.found) { @@ -656,20 +662,33 @@ error_t handleCloudContentExt(HttpConnection *connection, const char_t *uri, con stream_ctx->ctx = &ffmpeg_ctx; stream_ctx->taskParams.priority = 0; stream_ctx->taskParams.stackSize = 10 * 1024; - stream_ctx->taskId = osCreateTask(streamFileRel, &ffmpeg_stream_task, stream_ctx, &stream_ctx->taskParams); - while (!stream_ctx->active && stream_ctx->error == NO_ERROR) + // The answer sends the box back to offset 0, so anything encoded for it is discarded + bool force_restart = client_ctx->settings->encode.ffmpeg_stream_restart && + connection->request.Range.start != 0 && + fsFileExists(tonieInfo->json._streamFile); + if (force_restart) { - osDelayTask(100); + TRACE_INFO("Range request on stream, letting box restart from the beginning\r\n"); + stream_ctx->quit = true; + } + else + { + stream_ctx->taskId = osCreateTask(streamFileRel, &ffmpeg_stream_task, stream_ctx, &stream_ctx->taskParams); + + while (!stream_ctx->active && stream_ctx->error == NO_ERROR) + { + osDelayTask(100); + } } if (stream_ctx->error == NO_ERROR) { - if (client_ctx->settings->encode.ffmpeg_sweep_startup_buffer) + if (!force_restart && client_ctx->settings->encode.ffmpeg_sweep_startup_buffer) { osDelayTask(client_ctx->settings->encode.ffmpeg_sweep_delay_ms); } - uint32_t delay = client_ctx->settings->encode.ffmpeg_stream_buffer_ms; + uint32_t delay = force_restart ? 0 : client_ctx->settings->encode.ffmpeg_stream_buffer_ms; TRACE_INFO("Serve streaming content from %s, delay %" PRIu32 "ms\r\n", tonieInfo->json.source, delay); ffmpeg_ctx.sweep = false; @@ -739,6 +758,17 @@ error_t handleCloudContentExt(HttpConnection *connection, const char_t *uri, con TRACE_INFO("Serve local content from %s\r\n", tonieInfo->contentPath); connection->response.keepAlive = true; + if (client_ctx->settings->cloud.autoMarkListenedOnSync && tonieInfo->json._source_type == CT_SOURCE_TAF) + { + /* Download-time heuristic: a future iteration could use the RTNL-derived + * "Playback ON/OFF" MQTT events (toniebox_state.c) for a real playback-based signal. + * This mutates the library file's own listened flag, not tonieInfo->json (the tag's). */ + if (!library_meta_get_listened(tonieInfo->contentPath)) + { + library_meta_set_listened(tonieInfo->contentPath, true); + } + } + if (tonieInfo->json._source_type == CT_SOURCE_TAF_INCOMPLETE) { TRACE_INFO("Found incomplete TAF, streaming...\r\n"); @@ -1581,11 +1611,25 @@ error_t handleCloudOtaV3(HttpConnection *connection, const char_t *uri, const ch char *hash = strtok_r(NULL, "?", &savelocalUri); if (!typeStr || !hash) { - osFreeMem(localUri); + osFreeMem(localUri); osFreeMem(query); return ERROR_FAILURE; } - + + /* hash is concatenated into a local file path (local_dir + hash + ".bin") + and served with the Unsafe streamer, so it must be a plain hex string - + otherwise "/" or ".." would allow reading files outside the OTA dir. */ + for (const char *h = hash; *h != '\0'; h++) + { + if (!((*h >= '0' && *h <= '9') || (*h >= 'a' && *h <= 'f') || (*h >= 'A' && *h <= 'F'))) + { + TRACE_WARNING(" >> Rejecting V3 OTA request with non-hex hash\r\n"); + osFreeMem(localUri); + osFreeMem(query); + return ERROR_FAILURE; + } + } + cloudapi_ota_t fileId = (cloudapi_ota_t)atoi(typeStr); TRACE_INFO(" >> V3 OTA-Request for type %d with hash %s\r\n", fileId, hash); diff --git a/src/handler_reverse.c b/src/handler_reverse.c index 42676f608..db874dd7d 100644 --- a/src/handler_reverse.c +++ b/src/handler_reverse.c @@ -64,6 +64,7 @@ typedef struct reverse_target_t reverse_targets[] = { {"macvendor", "api.macvendors.com", "/", 443, true, true, true, 24, false, "text/plain"}, {"teddycloud_release", "api.github.com", "/repos/toniebox-reverse-engineering/teddycloud/releases/latest", 443, true, false, true, 24, true, "application/json"}, + {"teddycloud_release_candidate", "api.github.com", "/repos/toniebox-reverse-engineering/teddycloud/git/matching-refs/tags/rc", 443, true, false, true, 1, true, "application/json"}, {"teddycloud_develop", "api.github.com", "/repos/toniebox-reverse-engineering/teddycloud/commits/develop", 443, true, false, true, 1, true, "application/json"}, {NULL, NULL, NULL, 0, false, false, false, 0, false, NULL}}; @@ -327,4 +328,4 @@ error_t handleReverseGeneric(HttpConnection *connection, const char_t *uri, cons TRACE_DEBUG("handleReverseGeneric: (done)\r\n"); return error; -} \ No newline at end of file +} diff --git a/src/handler_rtnl.c b/src/handler_rtnl.c index d35e80cde..792d56ed2 100644 --- a/src/handler_rtnl.c +++ b/src/handler_rtnl.c @@ -16,72 +16,10 @@ #include "toniesJson.h" #include "server_helpers.h" #include "toniebox_state.h" +#include "str_ext.h" #include "proto/toniebox.pb.rtnl.pb-c.h" -static void escapeString(const char_t *input, size_t size, char_t *output); -static void escapeString(const char_t *input, size_t size, char_t *output) -{ - // Replacement sequences for special characters - const char_t *replacements[] = { - "\"", "\"\"", // Double quote (") - "\n", "\\n", // Newline - "\r", "\\r" // Carriage return - }; - const size_t num_replacements = sizeof(replacements) / sizeof(replacements[0]); - - size_t input_length = size; - - /* ToDo: this escaped_length code is not used - intentional through refacotring? */ - size_t escaped_length = 0; - - // First pass to count the number of additional characters required for escaping - for (size_t i = 0; i < input_length; i++) - { - for (size_t j = 0; j < num_replacements; j++) - { - if (input[i] == replacements[j][0]) - { - escaped_length += osStrlen(replacements[j]) - 1; - break; - } - } - } - - size_t j = 0; - // Second pass to actually escape the characters - for (size_t i = 0; i < input_length; i++) - { - bool_t replaced = false; - for (size_t k = 0; k < num_replacements; k++) - { - if (input[i] == replacements[k][0]) - { - size_t len = osStrlen(replacements[k]); - osStrcpy(&output[j], replacements[k]); - j += len; - replaced = true; - break; - } - } - - if (!replaced) - { - if (isalnum(input[i])) - { - output[j++] = input[i]; - } - else - { - output[j++] = '.'; - } - } - } - - // Null-terminate the escaped string - output[j] = '\0'; -} - error_t handleRtnl(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) { char_t *buffer = connection->buffer; @@ -99,7 +37,8 @@ error_t handleRtnl(HttpConnection *connection, const char_t *uri, const char_t * { break; } - uint32_t protoLength = (uint32_t)((buffer[pos] << 24) | (buffer[pos + 1] << 16) | (buffer[pos + 2] << 8) | buffer[pos + 3]); + /* buffer is signed char_t, read the bytes unsigned so a byte >= 0x80 is not sign-extended */ + uint32_t protoLength = (uint32_t)read_big_endian32((const uint8_t *)&buffer[pos]); if (pos + 4 + protoLength > size) { @@ -167,6 +106,26 @@ int64_t read_big_endian64(const uint8_t *buf) return ((int64_t)read_big_endian32(buf)) | (((uint64_t)read_big_endian32(&buf[4])) << 32); } +/* field6 is a protobuf bytes field: protobuf-c neither pads it to any size nor + NUL-terminates it. Return a NUL-terminated heap copy of field6 starting at + `offset` (empty string if shorter), so string consumers cannot over-read past + the allocation. Caller frees. */ +static char *rtnl_field6_strdup(const TonieRtnlLog2 *log2, size_t offset) +{ + size_t len = (log2->field6.len > offset) ? (log2->field6.len - offset) : 0; + char *out = osAllocMem(len + 1); + if (out == NULL) + { + return NULL; + } + if (len > 0) + { + osMemcpy(out, log2->field6.data + offset, len); + } + out[len] = '\0'; + return out; +} + static char *absolute_url(const char *url_or_path) { char *url = strdup(url_or_path); @@ -220,12 +179,9 @@ void rtnlEvent(HttpConnection *connection, TonieRtnlRPC *rpc, client_ctx_t *clie rpc->log2->function); sse_rawData(buffer); - if (rpc->log2->field6.len > 0) + for (size_t pos = 0; pos < rpc->log2->field6.len;) { - for (size_t i = 0; i < rpc->log2->field6.len; i++) - { - osSprintf(&buffer[i * 2], "%02X", rpc->log2->field6.data[i]); - } + pos += hexEncode(&rpc->log2->field6.data[pos], rpc->log2->field6.len - pos, buffer, sizeof(buffer)); sse_rawData(buffer); } @@ -235,12 +191,9 @@ void rtnlEvent(HttpConnection *connection, TonieRtnlRPC *rpc, client_ctx_t *clie rpc->log2->field8); sse_rawData(buffer); - if (rpc->log2->field9.len > 0) + for (size_t pos = 0; pos < rpc->log2->field9.len;) { - for (size_t i = 0; i < rpc->log2->field9.len; i++) - { - osSprintf(&buffer[i * 2], "%02X", rpc->log2->field9.data[i]); - } + pos += hexEncode(&rpc->log2->field9.data[pos], rpc->log2->field9.len - pos, buffer, sizeof(buffer)); sse_rawData(buffer); } sse_rawData("\"}"); @@ -333,7 +286,7 @@ void rtnlEvent(HttpConnection *connection, TonieRtnlRPC *rpc, client_ctx_t *clie { char str_buf[33]; - if (rpc->log2->function_group == RTNL2_FUGR_TAG) + if (rpc->log2->function_group == RTNL2_FUGR_TAG && rpc->log2->field6.len >= 8) { if (rpc->log2->function == RTNL2_FUNC_TAG_INVALID_CC3200 || rpc->log2->function == RTNL2_FUNC_TAG_INVALID_ESP32) { @@ -348,7 +301,7 @@ void rtnlEvent(HttpConnection *connection, TonieRtnlRPC *rpc, client_ctx_t *clie tbs_tag_removed(client_ctx, read_big_endian64(rpc->log2->field6.data), true); } } - else if ((rpc->log2->function_group == RTNL2_FUGR_AUDIO_A && (rpc->log2->function == RTNL2_FUNC_AUDIO_ID_CC3200 || rpc->log2->function == RTNL2_FUNC_AUDIO_ID_ESP32)) || (rpc->log2->function_group == RTNL2_FUGR_AUDIO_B && rpc->log2->function == RTNL2_FUNC_AUDIO_ID)) + else if (((rpc->log2->function_group == RTNL2_FUGR_AUDIO_A && (rpc->log2->function == RTNL2_FUNC_AUDIO_ID_CC3200 || rpc->log2->function == RTNL2_FUNC_AUDIO_ID_ESP32)) || (rpc->log2->function_group == RTNL2_FUGR_AUDIO_B && rpc->log2->function == RTNL2_FUNC_AUDIO_ID)) && rpc->log2->field6.len >= 4) { uint32_t audioId = read_little_endian32(rpc->log2->field6.data); client_ctx->state->tag.audio_id = audioId; @@ -398,7 +351,7 @@ void rtnlEvent(HttpConnection *connection, TonieRtnlRPC *rpc, client_ctx_t *clie osFreeMem(url); } } - else if (rpc->log2->function_group == RTNL2_FUGR_TILT) + else if (rpc->log2->function_group == RTNL2_FUGR_TILT && rpc->log2->field6.len >= 4) { if (rpc->log2->function == RTNL2_FUNC_TILT_A_ESP32) { @@ -417,7 +370,7 @@ void rtnlEvent(HttpConnection *connection, TonieRtnlRPC *rpc, client_ctx_t *clie } else if (rpc->log2->function_group == RTNL2_FUGR_VOLUME) { - if (rpc->log2->function == RTNL2_FUNC_VOLUME_CHANGE_CC3200 || rpc->log2->function == RTNL2_FUNC_VOLUME_CHANGE_ESP32) + if ((rpc->log2->function == RTNL2_FUNC_VOLUME_CHANGE_CC3200 || rpc->log2->function == RTNL2_FUNC_VOLUME_CHANGE_ESP32) && rpc->log2->field6.len >= 12) { /* DE210000 DBFFFFFF 01000000 */ /* 963C0000 D8FFFFFF 00000000 */ int32_t volumedB = read_little_endian32(&rpc->log2->field6.data[4]); @@ -441,19 +394,32 @@ void rtnlEvent(HttpConnection *connection, TonieRtnlRPC *rpc, client_ctx_t *clie { if (rpc->log2->function == RTNL2_FUNC_FIRMWARE_VERSION) { - const char *rtnlVersion = (const char *)rpc->log2->field6.data; - settings_set_string_id("internal.toniebox_firmware.rtnlVersion", rtnlVersion, client_ctx->settings->internal.overlayNumber); + char *rtnlVersion = rtnl_field6_strdup(rpc->log2, 0); + if (rtnlVersion != NULL) + { + settings_set_string_id("internal.toniebox_firmware.rtnlVersion", rtnlVersion, client_ctx->settings->internal.overlayNumber); + osFreeMem(rtnlVersion); + } } else if (rpc->log2->function == RTNL2_FUNC_FIRMWARE_FULL_VERSION) { - const char *rtnlFullVersion = (const char *)rpc->log2->field6.data; - settings_set_string_id("internal.toniebox_firmware.rtnlFullVersion", rtnlFullVersion, client_ctx->settings->internal.overlayNumber); + char *rtnlFullVersion = rtnl_field6_strdup(rpc->log2, 0); + if (rtnlFullVersion != NULL) + { + settings_set_string_id("internal.toniebox_firmware.rtnlFullVersion", rtnlFullVersion, client_ctx->settings->internal.overlayNumber); + osFreeMem(rtnlFullVersion); + } } - else if (rpc->log2->function == RTNL2_FUNC_FIRMWARE_INFOS) + else if (rpc->log2->function == RTNL2_FUNC_FIRMWARE_INFOS && rpc->log2->field6.len > 8) { // Raw2 | #158 Uptime: 13505 Func: 8-7146 Payload: 'A93394604657000032363430633166003036204D61792032303A32310009000000030000000100000000000000' ASCII: '.3.`FW..2640c1f.06 May 20:21.................' // TODO - settings_set_string_id("internal.toniebox_firmware.rtnlDetail", (const char *)&rpc->log2->field6.data[8], client_ctx->settings->internal.overlayNumber); + char *rtnlDetail = rtnl_field6_strdup(rpc->log2, 8); + if (rtnlDetail != NULL) + { + settings_set_string_id("internal.toniebox_firmware.rtnlDetail", rtnlDetail, client_ctx->settings->internal.overlayNumber); + osFreeMem(rtnlDetail); + } } } else if (rpc->log2->function_group == RTNL2_FUGR_NETWORK_HTTP) @@ -462,13 +428,23 @@ void rtnlEvent(HttpConnection *connection, TonieRtnlRPC *rpc, client_ctx_t *clie { // Raw2 | #102 Uptime: 7606 Func: 6-791 Payload: '45550030010000' ASCII: 'EU.0...' // TODO - settings_set_string_id("internal.toniebox_firmware.rtnlRegion", (const char *)rpc->log2->field6.data, client_ctx->settings->internal.overlayNumber); + char *rtnlRegion = rtnl_field6_strdup(rpc->log2, 0); + if (rtnlRegion != NULL) + { + settings_set_string_id("internal.toniebox_firmware.rtnlRegion", rtnlRegion, client_ctx->settings->internal.overlayNumber); + osFreeMem(rtnlRegion); + } } else if (rpc->log2->function == RTNL2_FUNC_NETWORK_DOMAIN) { // Raw2 | #142 Uptime: 13973 Func: 6-1009 Payload: '70726F642E726576766F7800C0000000A80000007B0000008D000000' ASCII: 'prod.revvox.........{.......' - settings_set_string_id("internal.rtnl.prodDomain", (const char *)rpc->log2->field6.data, client_ctx->settings->internal.overlayNumber); - settings_set_string("internal.rtnl.prodDomain", (const char *)rpc->log2->field6.data); + char *prodDomain = rtnl_field6_strdup(rpc->log2, 0); + if (prodDomain != NULL) + { + settings_set_string_id("internal.rtnl.prodDomain", prodDomain, client_ctx->settings->internal.overlayNumber); + settings_set_string("internal.rtnl.prodDomain", prodDomain); + osFreeMem(prodDomain); + } } } else if (rpc->log2->function_group == RTNL2_FUGR_NETWORK_TCP) @@ -476,17 +452,25 @@ void rtnlEvent(HttpConnection *connection, TonieRtnlRPC *rpc, client_ctx_t *clie if (rpc->log2->function == RTNL2_FUNC_NETWORK_DOMAIN) { // Raw2 | #171 Uptime: 19080 Func: 37-1009 Payload: '72746E6C2E726576766F7800C0000000A80000007B0000008D000000' ASCII: 'rtnl.revvox.........{.......' - settings_set_string_id("internal.rtnl.rtnlDomain", (const char *)rpc->log2->field6.data, client_ctx->settings->internal.overlayNumber); - settings_set_string("internal.rtnl.rtnlDomain", (const char *)rpc->log2->field6.data); + char *rtnlDomain = rtnl_field6_strdup(rpc->log2, 0); + if (rtnlDomain != NULL) + { + settings_set_string_id("internal.rtnl.rtnlDomain", rtnlDomain, client_ctx->settings->internal.overlayNumber); + settings_set_string("internal.rtnl.rtnlDomain", rtnlDomain); + osFreeMem(rtnlDomain); + } } } else if (rpc->log2->function_group == RTNL2_FUGR_AUDIO_B) { if (rpc->log2->function == RTNL2_FUNC_AUDIO_PLAY) { - char *filepath = strdup((const char *)rpc->log2->field6.data); - tbs_playback_file(client_ctx, filepath); - osFreeMem(filepath); + char *filepath = rtnl_field6_strdup(rpc->log2, 0); + if (filepath != NULL) + { + tbs_playback_file(client_ctx, filepath); + osFreeMem(filepath); + } } } } @@ -555,18 +539,18 @@ void rtnlEventDump(HttpConnection *connection, TonieRtnlRPC *rpc, settings_t *se rpc->log2->field6.len); fsWriteFile(file, buffer, osStrlen(buffer)); - if (rpc->log2->field6.len > 0) + for (size_t pos = 0; pos < rpc->log2->field6.len;) { - for (size_t i = 0; i < rpc->log2->field6.len; i++) - { - osSprintf(&buffer[i * 2], "%02X", rpc->log2->field6.data[i]); - } + pos += hexEncode(&rpc->log2->field6.data[pos], rpc->log2->field6.len - pos, buffer, sizeof(buffer)); fsWriteFile(file, buffer, osStrlen(buffer)); } - osSprintf(buffer, ";\""); - escapeString((char_t *)rpc->log2->field6.data, rpc->log2->field6.len, &buffer[2]); - fsWriteFile(file, buffer, osStrlen(buffer)); + fsWriteFile(file, ";\"", 2); + for (size_t pos = 0; pos < rpc->log2->field6.len;) + { + pos += escapeString((char_t *)&rpc->log2->field6.data[pos], rpc->log2->field6.len - pos, buffer, sizeof(buffer)); + fsWriteFile(file, buffer, osStrlen(buffer)); + } osSprintf(buffer, "\";%" PRIu32 ";%" PRIuSIZE ";", rpc->log2->field8, // TODO hasfield @@ -575,17 +559,17 @@ void rtnlEventDump(HttpConnection *connection, TonieRtnlRPC *rpc, settings_t *se if (rpc->log2->has_field9) { - if (rpc->log2->field9.len > 0) + for (size_t pos = 0; pos < rpc->log2->field9.len;) { - for (size_t i = 0; i < rpc->log2->field9.len; i++) - { - osSprintf(&buffer[i * 2], "%02X", rpc->log2->field9.data[i]); - } + pos += hexEncode(&rpc->log2->field9.data[pos], rpc->log2->field9.len - pos, buffer, sizeof(buffer)); + fsWriteFile(file, buffer, osStrlen(buffer)); + } + fsWriteFile(file, ";\"", 2); + for (size_t pos = 0; pos < rpc->log2->field9.len;) + { + pos += escapeString((char_t *)&rpc->log2->field9.data[pos], rpc->log2->field9.len - pos, buffer, sizeof(buffer)); fsWriteFile(file, buffer, osStrlen(buffer)); } - osSprintf(buffer, ";\""); - escapeString((char_t *)rpc->log2->field9.data, rpc->log2->field9.len, &buffer[2]); - fsWriteFile(file, buffer, osStrlen(buffer)); char_t *output = "\";"; fsWriteFile(file, output, osStrlen(output)); } diff --git a/src/handler_security_mit.c b/src/handler_security_mit.c index 69b77ad8d..4858ca6c2 100644 --- a/src/handler_security_mit.c +++ b/src/handler_security_mit.c @@ -66,13 +66,25 @@ error_t checkSecMitHandlers(HttpConnection *connection, const char_t *uri, const { return NO_ERROR; } - else if (settings->security_mit.onBlacklistDomain) + + /* Both mitigations are independent - run each enabled one, don't let + the first enabled check suppress the others. */ + error_t error = NO_ERROR; + if (settings->security_mit.onBlacklistDomain) { - return handleSecMitDomain(connection, uri, queryString, client_ctx); + error = handleSecMitDomain(connection, uri, queryString, client_ctx); + if (error != NO_ERROR) + { + return error; + } } - else if (settings->security_mit.onCrawler) + if (settings->security_mit.onCrawler) { - return handleSecMitCrawler(connection, uri, queryString, client_ctx); + error = handleSecMitCrawler(connection, uri, queryString, client_ctx); + if (error != NO_ERROR) + { + return error; + } } return NO_ERROR; diff --git a/src/handler_sse.c b/src/handler_sse.c index a0a3db995..2009274d9 100644 --- a/src/handler_sse.c +++ b/src/handler_sse.c @@ -8,6 +8,11 @@ static uint8_t sseSubscriptionCount = 0; error_t handleApiSse(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *ctx) { + /* A subscriber joins between events: event writers hold MUTEX_SSE_EVENT from + * the first piece of an event to the last. It becomes active only after its + * response header is out, so no event piece can come before the header and a + * failed header leaves the slot free. */ + mutex_lock(MUTEX_SSE_EVENT); mutex_lock(MUTEX_SSE_CTX); SseSubscriptionContext *sseCtx = NULL; @@ -26,6 +31,7 @@ error_t handleApiSse(HttpConnection *connection, const char_t *uri, const char_t if (sseCtx == NULL) { mutex_unlock(MUTEX_SSE_CTX); + mutex_unlock(MUTEX_SSE_EVENT); TRACE_ERROR("All slots full, in total %" PRIu8 " clients\r\n", sseSubscriptionCount); httpInitResponseHeader(connection); connection->response.contentLength = 0; @@ -35,16 +41,6 @@ error_t handleApiSse(HttpConnection *connection, const char_t *uri, const char_t return httpWriteHeader(connection); } - sseCtx->lastConnection = time(NULL); - sseCtx->connection = connection; - sseCtx->active = TRUE; - sseCtx->error = NO_ERROR; - sseSubscriptionCount++; - - mutex_unlock(MUTEX_SSE_CTX); - - TRACE_INFO("SSE Client connected in slot %" PRIu8 " in total %" PRIu8 " clients\r\n", sseCtx->channel, sseSubscriptionCount); - httpInitResponseHeader(connection); connection->response.contentType = "text/event-stream"; connection->response.keepAlive = TRUE; @@ -53,13 +49,34 @@ error_t handleApiSse(HttpConnection *connection, const char_t *uri, const char_t error_t error = httpWriteHeader(connection); if (error != NO_ERROR) { + mutex_unlock(MUTEX_SSE_CTX); + mutex_unlock(MUTEX_SSE_EVENT); TRACE_ERROR("Failed to send header\r\n"); return error; } + sseCtx->lastConnection = time(NULL); + sseCtx->connection = connection; + sseCtx->active = TRUE; + sseCtx->error = NO_ERROR; + sseSubscriptionCount++; + + mutex_unlock(MUTEX_SSE_CTX); + mutex_unlock(MUTEX_SSE_EVENT); + + TRACE_INFO("SSE Client connected in slot %" PRIu8 " in total %" PRIu8 " clients\r\n", sseCtx->channel, sseSubscriptionCount); + time_t last = 0; while (true) { + /* A keep-alive must not land inside an event, so it holds MUTEX_SSE_EVENT. + * Event writers take MUTEX_SSE_EVENT before MUTEX_SSE_CTX; taking them in + * the other order here deadlocks both and every later /api/sse request. */ + bool_t keepAlive = time(NULL) - last > SSE_KEEPALIVE_S; + if (keepAlive) + { + mutex_lock(MUTEX_SSE_EVENT); + } mutex_lock(MUTEX_SSE_CTX); if (sseCtx->error != NO_ERROR || sseCtx->active == FALSE || (sseCtx->lastConnection + SSE_TIMEOUT_S < time(NULL))) { @@ -73,13 +90,16 @@ error_t handleApiSse(HttpConnection *connection, const char_t *uri, const char_t TRACE_ERROR("SSE Client with error %s\r\n", error2text(error)); } mutex_unlock(MUTEX_SSE_CTX); + if (keepAlive) + { + mutex_unlock(MUTEX_SSE_EVENT); + } break; } - time_t now = time(NULL); - if (now - last > SSE_KEEPALIVE_S) + if (keepAlive) { - mutex_lock(MUTEX_SSE_EVENT); + time_t now = time(NULL); sseCtx->error = httpWriteString(connection, "event: keep-alive\ndata: { \"type\":\"keep-alive\", \"data\":\"\" }\n\n"); mutex_unlock(MUTEX_SSE_EVENT); last = now; diff --git a/src/handler_web_auth.c b/src/handler_web_auth.c new file mode 100644 index 000000000..28b8d7596 --- /dev/null +++ b/src/handler_web_auth.c @@ -0,0 +1,1067 @@ +#include +#include +#include +#include +#include + +#include "handler_web_auth.h" +#include "cJSON.h" +#include "debug.h" +#include "fs_ext.h" +#include "fs_port.h" +#include "handler_api.h" +#include "hash/sha256.h" +#include "os_port.h" +#include "rand.h" +#include "server_helpers.h" +#include "settings.h" + +#define MAX_WEB_USERS 32 +#define MAX_WEB_SESSIONS 64 +#define SESSION_TTL_SECONDS (7 * 24 * 60 * 60) +#define USERNAME_MAX 32 +#define PASSWORD_MIN 4 +#define PASSWORD_MAX 128 +#define SALT_BYTES 16 +#define TOKEN_BYTES 32 +#define LOGIN_FAIL_LIMIT 5 +#define LOGIN_LOCK_SECONDS 600 +#define LOGIN_BUCKETS 32 +#define LOGIN_IP_MAX 48 + +typedef struct +{ + char username[USERNAME_MAX + 1]; + char salt_hex[(SALT_BYTES * 2) + 1]; + char hash_hex[(SHA256_DIGEST_SIZE * 2) + 1]; +} web_user_t; + +typedef struct +{ + bool used; + char token_hex[(TOKEN_BYTES * 2) + 1]; + char username[USERNAME_MAX + 1]; + time_t expires; +} web_session_t; + +typedef struct +{ + bool used; + char ip[LOGIN_IP_MAX]; + int failures; + time_t locked_until; +} login_limit_t; + +static web_user_t users[MAX_WEB_USERS]; +static int user_count = 0; +static web_session_t sessions[MAX_WEB_SESSIONS]; +static login_limit_t login_limits[LOGIN_BUCKETS]; +static OsMutex web_auth_mutex; +static bool mutex_ready = false; +static bool initialized = false; + +static void copy_str(char *dst, size_t dst_size, const char *src) +{ + size_t n = 0; + if (dst == NULL || dst_size == 0) + { + return; + } + if (src != NULL) + { + n = osStrlen(src); + if (n >= dst_size) + { + n = dst_size - 1; + } + osMemcpy(dst, src, n); + } + dst[n] = '\0'; +} + +static void lock_auth(void) +{ + if (mutex_ready) + { + osAcquireMutex(&web_auth_mutex); + } +} + +static void unlock_auth(void) +{ + if (mutex_ready) + { + osReleaseMutex(&web_auth_mutex); + } +} + +static const char *env_override_value(void) +{ + return getenv("TEDDYCLOUD_WEB_AUTH_DISABLE"); +} + +bool web_auth_env_override(void) +{ + const char *value = env_override_value(); + if (value == NULL || value[0] == '\0') + { + return false; + } + return osStrcasecmp(value, "1") == 0 || osStrcasecmp(value, "true") == 0 || osStrcasecmp(value, "yes") == 0; +} + +static void bytes_to_hex(const uint8_t *bytes, size_t len, char *out, size_t out_size) +{ + static const char hex[] = "0123456789abcdef"; + size_t o = 0; + for (size_t i = 0; i < len && o + 2 < out_size; i++) + { + out[o++] = hex[bytes[i] >> 4]; + out[o++] = hex[bytes[i] & 0x0F]; + } + out[o] = '\0'; +} + +static bool hex_to_bytes(const char *hex, uint8_t *out, size_t out_len) +{ + size_t hex_len = osStrlen(hex); + if (hex_len != out_len * 2) + { + return false; + } + for (size_t i = 0; i < out_len; i++) + { + unsigned int value = 0; + if (sscanf(hex + (i * 2), "%2x", &value) != 1) + { + return false; + } + out[i] = (uint8_t)value; + } + return true; +} + +static bool const_time_equal(const char *a, const char *b) +{ + size_t la = osStrlen(a); + size_t lb = osStrlen(b); + size_t n = la < lb ? la : lb; + unsigned diff = (unsigned)(la ^ lb); + for (size_t i = 0; i < n; i++) + { + diff |= (unsigned char)a[i] ^ (unsigned char)b[i]; + } + return diff == 0; +} + +static bool valid_username(const char *username) +{ + size_t len = osStrlen(username); + if (len < 1 || len > USERNAME_MAX) + { + return false; + } + for (size_t i = 0; i < len; i++) + { + unsigned char c = (unsigned char)username[i]; + if (!(isalnum(c) || c == '_' || c == '-' || c == '.')) + { + return false; + } + } + return true; +} + +static char *users_path(void) +{ + const char *dir = settings_get_string("internal.configdirfull"); + if (dir == NULL || dir[0] == '\0') + { + return NULL; + } + return custom_asprintf("%s%c%s", dir, PATH_SEPARATOR, WEB_USERS_JSON_FILE); +} + +static void hash_password(const char *password, const uint8_t *salt, char *hash_hex, size_t hash_hex_size) +{ + Sha256Context ctx; + uint8_t digest[SHA256_DIGEST_SIZE]; + sha256Init(&ctx); + sha256Update(&ctx, salt, SALT_BYTES); + sha256Update(&ctx, password, osStrlen(password)); + sha256Final(&ctx, digest); + bytes_to_hex(digest, sizeof(digest), hash_hex, hash_hex_size); +} + +static web_user_t *find_user_unlocked(const char *username) +{ + for (int i = 0; i < user_count; i++) + { + if (osStrcasecmp(users[i].username, username) == 0) + { + return &users[i]; + } + } + return NULL; +} + +static bool verify_password_unlocked(const web_user_t *user, const char *password) +{ + uint8_t salt[SALT_BYTES]; + if (!hex_to_bytes(user->salt_hex, salt, sizeof(salt))) + { + return false; + } + char hash_hex[(SHA256_DIGEST_SIZE * 2) + 1]; + hash_password(password, salt, hash_hex, sizeof(hash_hex)); + return const_time_equal(user->hash_hex, hash_hex); +} + +static bool save_users_unlocked(void) +{ + char *path = users_path(); + if (path == NULL) + { + return false; + } + cJSON *root = cJSON_CreateObject(); + cJSON *array = cJSON_AddArrayToObject(root, "users"); + for (int i = 0; i < user_count; i++) + { + cJSON *entry = cJSON_CreateObject(); + cJSON_AddStringToObject(entry, "username", users[i].username); + cJSON_AddStringToObject(entry, "salt", users[i].salt_hex); + cJSON_AddStringToObject(entry, "hash", users[i].hash_hex); + cJSON_AddItemToArray(array, entry); + } + char *body = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + bool ok = false; + if (body != NULL) + { + FsFile *file = fsOpenFile(path, FS_FILE_MODE_WRITE | FS_FILE_MODE_CREATE | FS_FILE_MODE_TRUNC); + if (file != NULL) + { + ok = (fsWriteFile(file, body, osStrlen(body)) == NO_ERROR); + fsCloseFile(file); + } + osFreeMem(body); + } + osFreeMem(path); + return ok; +} + +static void load_users_unlocked(void) +{ + user_count = 0; + osMemset(users, 0, sizeof(users)); + char *path = users_path(); + if (path == NULL) + { + return; + } + uint32_t length = 0; + if (fsGetFileSize(path, &length) != NO_ERROR || length == 0 || length > (64 * 1024)) + { + osFreeMem(path); + return; + } + char *buf = osAllocMem(length + 1); + if (buf == NULL) + { + osFreeMem(path); + return; + } + FsFile *file = fsOpenFile(path, FS_FILE_MODE_READ); + osFreeMem(path); + if (file == NULL) + { + osFreeMem(buf); + return; + } + size_t n = 0; + if (fsReadFile(file, buf, length, &n) != NO_ERROR) + { + fsCloseFile(file); + osFreeMem(buf); + return; + } + fsCloseFile(file); + buf[n] = '\0'; + cJSON *root = cJSON_Parse(buf); + osFreeMem(buf); + if (root == NULL) + { + return; + } + cJSON *array = cJSON_GetObjectItemCaseSensitive(root, "users"); + if (cJSON_IsArray(array)) + { + cJSON *entry = NULL; + cJSON_ArrayForEach(entry, array) + { + if (user_count >= MAX_WEB_USERS) + { + break; + } + cJSON *username = cJSON_GetObjectItemCaseSensitive(entry, "username"); + cJSON *salt = cJSON_GetObjectItemCaseSensitive(entry, "salt"); + cJSON *hash = cJSON_GetObjectItemCaseSensitive(entry, "hash"); + if (!cJSON_IsString(username) || !cJSON_IsString(salt) || !cJSON_IsString(hash)) + { + continue; + } + copy_str(users[user_count].username, sizeof(users[user_count].username), username->valuestring); + copy_str(users[user_count].salt_hex, sizeof(users[user_count].salt_hex), salt->valuestring); + copy_str(users[user_count].hash_hex, sizeof(users[user_count].hash_hex), hash->valuestring); + user_count++; + } + } + cJSON_Delete(root); +} + +static void purge_sessions_unlocked(void) +{ + time_t now = time(NULL); + for (int i = 0; i < MAX_WEB_SESSIONS; i++) + { + if (sessions[i].used && sessions[i].expires < now) + { + sessions[i].used = false; + } + } +} + +static bool create_session_unlocked(const char *username, char *token, size_t token_size) +{ + uint8_t bytes[TOKEN_BYTES]; + if (rand_get_bytes(bytes, sizeof(bytes)) != 0) + { + return false; + } + purge_sessions_unlocked(); + int slot = -1; + time_t oldest = 0; + for (int i = 0; i < MAX_WEB_SESSIONS; i++) + { + if (!sessions[i].used) + { + slot = i; + break; + } + if (slot < 0 || sessions[i].expires < oldest) + { + slot = i; + oldest = sessions[i].expires; + } + } + if (slot < 0) + { + slot = 0; + } + bytes_to_hex(bytes, sizeof(bytes), sessions[slot].token_hex, sizeof(sessions[slot].token_hex)); + copy_str(sessions[slot].username, sizeof(sessions[slot].username), username); + sessions[slot].expires = time(NULL) + SESSION_TTL_SECONDS; + sessions[slot].used = true; + copy_str(token, token_size, sessions[slot].token_hex); + return true; +} + +static bool session_user_unlocked(const char *token, char *username, size_t username_size) +{ + if (token == NULL || token[0] == '\0') + { + return false; + } + purge_sessions_unlocked(); + for (int i = 0; i < MAX_WEB_SESSIONS; i++) + { + if (sessions[i].used && const_time_equal(sessions[i].token_hex, token)) + { + if (username != NULL && username_size > 0) + { + copy_str(username, username_size, sessions[i].username); + } + return true; + } + } + return false; +} + +static void extract_cookie_token(const char *cookie, char *out, size_t out_size) +{ + out[0] = '\0'; +#if (HTTP_SERVER_COOKIE_SUPPORT == ENABLED) + if (cookie == NULL || cookie[0] == '\0') + { + return; + } + const char *found = osStrstr(cookie, WEB_SESSION_COOKIE "="); + if (found == NULL) + { + return; + } + found += osStrlen(WEB_SESSION_COOKIE "="); + size_t i = 0; + while (found[i] && found[i] != ';' && found[i] != ' ' && i + 1 < out_size) + { + out[i] = found[i]; + i++; + } + out[i] = '\0'; +#else + (void)cookie; + (void)out_size; +#endif +} + +static void set_session_cookie(HttpConnection *connection, const char *token, bool clear) +{ +#if (HTTP_SERVER_COOKIE_SUPPORT == ENABLED) + if (clear || token == NULL || token[0] == '\0') + { + osSnprintf(connection->response.setCookie, HTTP_SERVER_COOKIE_MAX_LEN, + "%s=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax%s", + WEB_SESSION_COOKIE, + connection->tlsContext ? "; Secure" : ""); + return; + } + osSnprintf(connection->response.setCookie, HTTP_SERVER_COOKIE_MAX_LEN, + "%s=%s; Path=/; Max-Age=%d; HttpOnly; SameSite=Lax%s", + WEB_SESSION_COOKIE, + token, + SESSION_TTL_SECONDS, + connection->tlsContext ? "; Secure" : ""); +#else + (void)connection; + (void)token; + (void)clear; +#endif +} + +static error_t send_json_status(HttpConnection *connection, uint_t status, cJSON *json, const char *token, bool clear_cookie) +{ + char *body = cJSON_PrintUnformatted(json); + cJSON_Delete(json); + if (body == NULL) + { + return ERROR_FAILURE; + } + httpPrepareHeader(connection, "application/json; charset=utf-8", osStrlen(body)); + connection->response.statusCode = status; + if (token != NULL || clear_cookie) + { + set_session_cookie(connection, token, clear_cookie); + } + return httpWriteResponseString(connection, body, true); +} + +static error_t send_message(HttpConnection *connection, uint_t status, const char *error, const char *message) +{ + cJSON *json = cJSON_CreateObject(); + if (error) + { + cJSON_AddStringToObject(json, "error", error); + } + if (message) + { + cJSON_AddStringToObject(json, "message", message); + } + return send_json_status(connection, status, json, NULL, false); +} + +static error_t read_json_body(HttpConnection *connection, cJSON **outJson) +{ + if (connection->request.byteCount == 0 || connection->request.byteCount > (64 * 1024)) + { + return ERROR_INVALID_LENGTH; + } + size_t bodySize = connection->request.byteCount; + char *postData = osAllocMem(bodySize + 1); + if (postData == NULL) + { + return ERROR_OUT_OF_MEMORY; + } + osMemset(postData, 0, bodySize + 1); + size_t totalRead = 0; + while (totalRead < bodySize) + { + size_t chunkRead = 0; + error_t error = httpReadStream(connection, postData + totalRead, bodySize - totalRead, &chunkRead, 0x00); + if (error != NO_ERROR) + { + osFreeMem(postData); + return error; + } + if (chunkRead == 0) + { + break; + } + totalRead += chunkRead; + } + postData[totalRead] = '\0'; + *outJson = cJSON_Parse(postData); + osFreeMem(postData); + if (*outJson == NULL) + { + return ERROR_INVALID_SYNTAX; + } + return NO_ERROR; +} + +static bool json_string(cJSON *obj, const char *key, char *out, size_t out_size) +{ + cJSON *item = cJSON_GetObjectItemCaseSensitive(obj, key); + if (!cJSON_IsString(item) || item->valuestring == NULL) + { + return false; + } + if (osStrlen(item->valuestring) >= out_size) + { + return false; + } + osStrcpy(out, item->valuestring); + return true; +} + +static void client_ip(HttpConnection *connection, char *out, size_t out_size) +{ + out[0] = '\0'; + if (connection == NULL || connection->socket == NULL || out_size == 0) + { + return; + } + const char *ip = ipAddrToString(&connection->socket->remoteIpAddr, NULL); + if (ip != NULL) + { + copy_str(out, out_size, ip); + } +} + +static login_limit_t *find_login_limit_unlocked(const char *ip, bool create) +{ + if (ip == NULL || ip[0] == '\0') + { + return NULL; + } + int empty = -1; + for (int i = 0; i < LOGIN_BUCKETS; i++) + { + if (login_limits[i].used && !osStrcmp(login_limits[i].ip, ip)) + { + return &login_limits[i]; + } + if (!login_limits[i].used && empty < 0) + { + empty = i; + } + } + if (!create) + { + return NULL; + } + int idx = empty >= 0 ? empty : 0; + osMemset(&login_limits[idx], 0, sizeof(login_limits[idx])); + login_limits[idx].used = true; + copy_str(login_limits[idx].ip, sizeof(login_limits[idx].ip), ip); + return &login_limits[idx]; +} + +static bool login_is_locked_unlocked(const char *ip) +{ + login_limit_t *bucket = find_login_limit_unlocked(ip, false); + if (bucket == NULL) + { + return false; + } + time_t now = time(NULL); + if (bucket->locked_until != 0 && now >= bucket->locked_until) + { + bucket->failures = 0; + bucket->locked_until = 0; + return false; + } + return bucket->locked_until != 0 && now < bucket->locked_until; +} + +static void login_register_failure_unlocked(const char *ip) +{ + login_limit_t *bucket = find_login_limit_unlocked(ip, true); + if (bucket == NULL) + { + return; + } + time_t now = time(NULL); + if (bucket->locked_until != 0 && now >= bucket->locked_until) + { + bucket->failures = 0; + bucket->locked_until = 0; + } + bucket->failures++; + if (bucket->failures >= LOGIN_FAIL_LIMIT) + { + bucket->locked_until = now + LOGIN_LOCK_SECONDS; + TRACE_WARNING("Web UI login locked for %s after %d failures\r\n", ip, bucket->failures); + } +} + +static void login_register_success_unlocked(const char *ip) +{ + login_limit_t *bucket = find_login_limit_unlocked(ip, false); + if (bucket != NULL) + { + bucket->used = false; + } +} + +static bool add_user_unlocked(const char *username, const char *password) +{ + if (user_count >= MAX_WEB_USERS || !valid_username(username) || osStrlen(password) < PASSWORD_MIN || osStrlen(password) > PASSWORD_MAX) + { + return false; + } + if (find_user_unlocked(username) != NULL) + { + return false; + } + uint8_t salt[SALT_BYTES]; + if (rand_get_bytes(salt, sizeof(salt)) != 0) + { + return false; + } + copy_str(users[user_count].username, sizeof(users[user_count].username), username); + bytes_to_hex(salt, sizeof(salt), users[user_count].salt_hex, sizeof(users[user_count].salt_hex)); + hash_password(password, salt, users[user_count].hash_hex, sizeof(users[user_count].hash_hex)); + user_count++; + return save_users_unlocked(); +} + +void web_auth_init(void) +{ + if (osCreateMutex(&web_auth_mutex)) + { + mutex_ready = true; + } + lock_auth(); + osMemset(sessions, 0, sizeof(sessions)); + osMemset(login_limits, 0, sizeof(login_limits)); + load_users_unlocked(); + initialized = true; + unlock_auth(); + TRACE_INFO("Web UI auth loaded %d user(s), enabled=%s, envOverride=%s\r\n", + user_count, + settings_get_bool("frontend.web_auth_enabled") ? "true" : "false", + web_auth_env_override() ? "true" : "false"); +} + +bool web_auth_requires_login(void) +{ + if (web_auth_env_override()) + { + return false; + } + if (!settings_get_bool("frontend.web_auth_enabled")) + { + return false; + } + lock_auth(); + if (!initialized) + { + load_users_unlocked(); + initialized = true; + } + int count = user_count; + unlock_auth(); + return count > 0; +} + +bool web_auth_is_public_request(const char_t *uri, const char_t *method) +{ + (void)method; + if (uri == NULL) + { + return false; + } + if (!osStrcmp(uri, "/api/auth/login") || !osStrcmp(uri, "/api/auth/status") || !osStrcmp(uri, "/api/auth/logout") || !osStrcmp(uri, "/api/auth/refresh-token")) + { + return true; + } + /* Login page is part of the SPA and must load before the session exists. + * The HTTP parser rewrites "/" to the default document "index.shtm" (without leading slash). */ + if (!osStrcmp(uri, "/") || !osStrcmp(uri, "index.shtm") || !osStrcmp(uri, "/index.shtm") || !osStrcmp(uri, "/favicon.ico")) + { + return true; + } + if (!osStrncmp(uri, "/web", 4) && (uri[4] == '\0' || uri[4] == '/')) + { + return true; + } + return false; +} + +bool web_auth_is_authenticated(HttpConnection *connection, char *username, size_t username_size) +{ + char token[WEB_BEARER_TOKEN_MAX + 1]; + token[0] = '\0'; + if (connection->private.web_bearer_token[0] != '\0') + { + copy_str(token, sizeof(token), connection->private.web_bearer_token); + } +#if (HTTP_SERVER_COOKIE_SUPPORT == ENABLED) + if (token[0] == '\0') + { + extract_cookie_token(connection->request.cookie, token, sizeof(token)); + } +#endif + lock_auth(); + bool ok = session_user_unlocked(token, username, username_size); + unlock_auth(); + return ok; +} + +error_t web_auth_unauthorized(HttpConnection *connection) +{ + return send_message(connection, 401, "unauthorized", "Login required"); +} + +error_t handleApiAuthStatus(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) +{ + (void)uri; + (void)queryString; + (void)client_ctx; + char username[USERNAME_MAX + 1]; + username[0] = '\0'; + bool logged_in = web_auth_is_authenticated(connection, username, sizeof(username)); + bool env = web_auth_env_override(); + lock_auth(); + int count = user_count; + unlock_auth(); + bool enabled = !env && settings_get_bool("frontend.web_auth_enabled") && count > 0; + + cJSON *json = cJSON_CreateObject(); + cJSON_AddBoolToObject(json, "enabled", enabled); + cJSON_AddBoolToObject(json, "loggedIn", logged_in && enabled); + cJSON_AddStringToObject(json, "username", logged_in ? username : ""); + cJSON_AddBoolToObject(json, "envOverride", env); + cJSON_AddNumberToObject(json, "userCount", count); + return send_json_status(connection, 200, json, NULL, false); +} + +error_t handleApiAuthLogin(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) +{ + (void)uri; + (void)queryString; + (void)client_ctx; + cJSON *body = NULL; + error_t error = read_json_body(connection, &body); + if (error != NO_ERROR) + { + return send_message(connection, 400, "invalid_body", "Invalid login payload"); + } + char username[USERNAME_MAX + 1]; + char password[PASSWORD_MAX + 1]; + if (!json_string(body, "username", username, sizeof(username)) || !json_string(body, "password", password, sizeof(password))) + { + cJSON_Delete(body); + return send_message(connection, 400, "invalid_body", "Username and password required"); + } + cJSON_Delete(body); + + char ip[LOGIN_IP_MAX]; + client_ip(connection, ip, sizeof(ip)); + + char token[WEB_BEARER_TOKEN_MAX + 1]; + token[0] = '\0'; + bool ok = false; + bool locked = false; + lock_auth(); + if (login_is_locked_unlocked(ip)) + { + locked = true; + } + else + { + web_user_t *user = find_user_unlocked(username); + if (user != NULL && verify_password_unlocked(user, password)) + { + copy_str(username, sizeof(username), user->username); + ok = create_session_unlocked(username, token, sizeof(token)); + if (ok) + { + login_register_success_unlocked(ip); + } + } + else + { + login_register_failure_unlocked(ip); + } + } + unlock_auth(); + if (locked) + { + return send_message(connection, 429, "rate_limited", "Too many login attempts, try again later"); + } + if (!ok) + { + return send_message(connection, 401, "unauthorized", "Invalid username or password"); + } + cJSON *json = cJSON_CreateObject(); + cJSON_AddBoolToObject(json, "ok", true); + cJSON_AddStringToObject(json, "username", username); + cJSON_AddStringToObject(json, "token", token); + return send_json_status(connection, 200, json, token, false); +} + +error_t handleApiAuthLogout(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) +{ + (void)uri; + (void)queryString; + (void)client_ctx; + char token[WEB_BEARER_TOKEN_MAX + 1]; + token[0] = '\0'; + if (connection->private.web_bearer_token[0] != '\0') + { + copy_str(token, sizeof(token), connection->private.web_bearer_token); + } +#if (HTTP_SERVER_COOKIE_SUPPORT == ENABLED) + if (token[0] == '\0') + { + extract_cookie_token(connection->request.cookie, token, sizeof(token)); + } +#endif + lock_auth(); + purge_sessions_unlocked(); + for (int i = 0; i < MAX_WEB_SESSIONS; i++) + { + if (sessions[i].used && const_time_equal(sessions[i].token_hex, token)) + { + sessions[i].used = false; + } + } + unlock_auth(); + cJSON *json = cJSON_CreateObject(); + cJSON_AddBoolToObject(json, "ok", true); + return send_json_status(connection, 200, json, NULL, true); +} + +error_t handleApiAuthRefreshToken(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) +{ + (void)uri; + (void)queryString; + (void)client_ctx; + char username[USERNAME_MAX + 1]; + if (!web_auth_is_authenticated(connection, username, sizeof(username))) + { + return send_message(connection, 401, "unauthorized", "Login required"); + } + cJSON *json = cJSON_CreateObject(); + cJSON_AddBoolToObject(json, "ok", true); + cJSON_AddStringToObject(json, "username", username); + return send_json_status(connection, 200, json, NULL, false); +} + +error_t handleApiAuthUsersGet(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) +{ + (void)uri; + (void)queryString; + (void)client_ctx; + lock_auth(); + cJSON *json = cJSON_CreateObject(); + cJSON *array = cJSON_AddArrayToObject(json, "users"); + for (int i = 0; i < user_count; i++) + { + cJSON_AddItemToArray(array, cJSON_CreateString(users[i].username)); + } + int count = user_count; + unlock_auth(); + bool env = web_auth_env_override(); + cJSON_AddBoolToObject(json, "enabled", !env && settings_get_bool("frontend.web_auth_enabled") && count > 0); + cJSON_AddBoolToObject(json, "envOverride", env); + return send_json_status(connection, 200, json, NULL, false); +} + +error_t handleApiAuthUsersCreate(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) +{ + (void)uri; + (void)queryString; + (void)client_ctx; + cJSON *body = NULL; + if (read_json_body(connection, &body) != NO_ERROR) + { + return send_message(connection, 400, "invalid_body", "Invalid JSON payload"); + } + char username[USERNAME_MAX + 1]; + char password[PASSWORD_MAX + 1]; + if (!json_string(body, "username", username, sizeof(username)) || !json_string(body, "password", password, sizeof(password))) + { + cJSON_Delete(body); + return send_message(connection, 400, "invalid_body", "Username and password required"); + } + cJSON_Delete(body); + if (!valid_username(username) || osStrlen(password) < PASSWORD_MIN) + { + return send_message(connection, 400, "invalid_user", "Invalid username or password"); + } + lock_auth(); + if (find_user_unlocked(username) != NULL) + { + unlock_auth(); + return send_message(connection, 409, "exists", "User already exists"); + } + if (user_count >= MAX_WEB_USERS) + { + unlock_auth(); + return send_message(connection, 400, "limit", "Maximum number of users reached"); + } + bool ok = add_user_unlocked(username, password); + unlock_auth(); + if (!ok) + { + return send_message(connection, 500, "save_failed", "Could not save users"); + } + cJSON *json = cJSON_CreateObject(); + cJSON_AddBoolToObject(json, "ok", true); + cJSON_AddStringToObject(json, "username", username); + return send_json_status(connection, 200, json, NULL, false); +} + +error_t handleApiAuthUsersDelete(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) +{ + (void)uri; + (void)queryString; + (void)client_ctx; + cJSON *body = NULL; + if (read_json_body(connection, &body) != NO_ERROR) + { + return send_message(connection, 400, "invalid_body", "Invalid JSON payload"); + } + char username[USERNAME_MAX + 1]; + if (!json_string(body, "username", username, sizeof(username))) + { + cJSON_Delete(body); + return send_message(connection, 400, "invalid_body", "Username required"); + } + cJSON_Delete(body); + + bool auth_disabled = false; + lock_auth(); + web_user_t *user = find_user_unlocked(username); + if (user == NULL) + { + unlock_auth(); + return send_message(connection, 404, "not_found", "User not found"); + } + int index = (int)(user - users); + for (int i = index; i < user_count - 1; i++) + { + users[i] = users[i + 1]; + } + user_count--; + osMemset(&users[user_count], 0, sizeof(users[user_count])); + if (!save_users_unlocked()) + { + unlock_auth(); + return send_message(connection, 500, "save_failed", "Could not save users"); + } + if (user_count == 0) + { + auth_disabled = true; + } + unlock_auth(); + if (auth_disabled) + { + settings_set_bool("frontend.web_auth_enabled", false); + settings_save(); + } + cJSON *json = cJSON_CreateObject(); + cJSON_AddBoolToObject(json, "ok", true); + cJSON_AddBoolToObject(json, "authDisabled", auth_disabled); + return send_json_status(connection, 200, json, NULL, false); +} + +error_t handleApiAuthUsersPassword(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) +{ + (void)uri; + (void)queryString; + (void)client_ctx; + cJSON *body = NULL; + if (read_json_body(connection, &body) != NO_ERROR) + { + return send_message(connection, 400, "invalid_body", "Invalid JSON payload"); + } + char username[USERNAME_MAX + 1]; + char password[PASSWORD_MAX + 1]; + if (!json_string(body, "username", username, sizeof(username)) || !json_string(body, "password", password, sizeof(password))) + { + cJSON_Delete(body); + return send_message(connection, 400, "invalid_body", "Username and password required"); + } + cJSON_Delete(body); + if (osStrlen(password) < PASSWORD_MIN) + { + return send_message(connection, 400, "invalid_user", "Invalid password"); + } + lock_auth(); + web_user_t *user = find_user_unlocked(username); + if (user == NULL) + { + unlock_auth(); + return send_message(connection, 404, "not_found", "User not found"); + } + uint8_t salt[SALT_BYTES]; + if (rand_get_bytes(salt, sizeof(salt)) != 0) + { + unlock_auth(); + return send_message(connection, 500, "save_failed", "Could not save users"); + } + bytes_to_hex(salt, sizeof(salt), user->salt_hex, sizeof(user->salt_hex)); + hash_password(password, salt, user->hash_hex, sizeof(user->hash_hex)); + bool ok = save_users_unlocked(); + unlock_auth(); + if (!ok) + { + return send_message(connection, 500, "save_failed", "Could not save users"); + } + cJSON *json = cJSON_CreateObject(); + cJSON_AddBoolToObject(json, "ok", true); + return send_json_status(connection, 200, json, NULL, false); +} + +error_t handleApiAuthEnabled(HttpConnection *connection, const char_t *uri, const char_t *queryString, client_ctx_t *client_ctx) +{ + (void)uri; + (void)queryString; + (void)client_ctx; + cJSON *body = NULL; + if (read_json_body(connection, &body) != NO_ERROR) + { + return send_message(connection, 400, "invalid_body", "Invalid JSON payload"); + } + cJSON *enabled_item = cJSON_GetObjectItemCaseSensitive(body, "enabled"); + if (!cJSON_IsBool(enabled_item)) + { + cJSON_Delete(body); + return send_message(connection, 400, "invalid_body", "enabled required"); + } + bool enabled = cJSON_IsTrue(enabled_item); + cJSON_Delete(body); + if (web_auth_env_override()) + { + return send_message(connection, 400, "env_override", "Login protection is disabled by TEDDYCLOUD_WEB_AUTH_DISABLE"); + } + lock_auth(); + int count = user_count; + unlock_auth(); + if (enabled && count == 0) + { + return send_message(connection, 400, "no_users", "Create a user before enabling login protection"); + } + if (!settings_set_bool("frontend.web_auth_enabled", enabled) || settings_save() != NO_ERROR) + { + return send_message(connection, 500, "save_failed", "Could not update login setting"); + } + cJSON *json = cJSON_CreateObject(); + cJSON_AddBoolToObject(json, "ok", true); + cJSON_AddBoolToObject(json, "enabled", enabled); + return send_json_status(connection, 200, json, NULL, false); +} diff --git a/src/libraryMeta.c b/src/libraryMeta.c new file mode 100644 index 000000000..a000d5ec4 --- /dev/null +++ b/src/libraryMeta.c @@ -0,0 +1,93 @@ +#include "libraryMeta.h" + +#include "cJSON.h" +#include "os_port.h" +#include "debug.h" +#include "json_helper.h" +#include "server_helpers.h" +#include "fs_ext.h" + +static cJSON *library_meta_read(const char *json_path) +{ + if (!fsFileExists(json_path)) + { + return cJSON_CreateObject(); + } + + size_t fileSize = 0; + fsGetFileSize(json_path, (uint32_t *)(&fileSize)); + + FsFile *fsFile = fsOpenFile(json_path, FS_FILE_MODE_READ); + if (fsFile == NULL) + { + return cJSON_CreateObject(); + } + + size_t sizeRead; + char *data = osAllocMem(fileSize); + size_t pos = 0; + + while (pos < fileSize) + { + fsReadFile(fsFile, &data[pos], fileSize - pos, &sizeRead); + pos += sizeRead; + } + fsCloseFile(fsFile); + + cJSON *root = cJSON_ParseWithLengthOpts(data, fileSize, 0, 0); + osFreeMem(data); + + if (root == NULL) + { + TRACE_WARNING("Failed to parse library meta json '%s', starting fresh\r\n", json_path); + return cJSON_CreateObject(); + } + + return root; +} + +bool_t library_meta_get_listened(const char *content_path) +{ + char *jsonPath = custom_asprintf("%s.json", content_path); + cJSON *root = library_meta_read(jsonPath); + bool_t listened = jsonGetBool(root, "listened"); + cJSON_Delete(root); + osFreeMem(jsonPath); + return listened; +} + +error_t library_meta_set_listened(const char *content_path, bool_t listened) +{ + char *jsonPath = custom_asprintf("%s.json", content_path); + cJSON *root = library_meta_read(jsonPath); + + cJSON_DeleteItemFromObject(root, "listened"); + cJSON_AddBoolToObject(root, "listened", listened); + + char *jsonPathTmp = custom_asprintf("%s.tmp", jsonPath); + error_t error = NO_ERROR; + char *jsonRaw = cJSON_Print(root); + + FsFile *file = fsOpenFile(jsonPathTmp, FS_FILE_MODE_WRITE); + if (file != NULL) + { + error = fsWriteFile(file, jsonRaw, osStrlen(jsonRaw)); + fsCloseFile(file); + } + else + { + error = ERROR_FILE_OPENING_FAILED; + } + + if (error == NO_ERROR) + { + error = fsMoveFile(jsonPathTmp, jsonPath, true); + } + + cJSON_Delete(root); + osFreeMem(jsonRaw); + osFreeMem(jsonPathTmp); + osFreeMem(jsonPath); + + return error; +} diff --git a/src/main.c b/src/main.c index 65f4b32c4..0dffe2f65 100644 --- a/src/main.c +++ b/src/main.c @@ -262,6 +262,8 @@ int_t main(int argc, char *argv[]) const char *encode_test; int skip_seconds; const char *esp32_hostpatch; + const char *esp32_portpatch; + int port; const char *esp32_fixup; const char *esp32_inject; const char *esp32_extract; @@ -293,6 +295,8 @@ int_t main(int argc, char *argv[]) {"encode_test", required_argument, 0, 'E'}, {"skip-seconds", required_argument, 0, 'S'}, {"esp32-hostpatch", required_argument, 0, 'P'}, + {"esp32-portpatch", required_argument, 0, 0x104}, + {"port", required_argument, 0, 0x105}, {"oldrtnlhost", required_argument, 0, 0x100}, {"oldapihost", required_argument, 0, 0x101}, {"esp32-fixup", required_argument, 0, 'F'}, @@ -333,6 +337,8 @@ int_t main(int argc, char *argv[]) OPT_SIMPLE_STR('E', encode_test); OPT_SIMPLE_INT('S', skip_seconds); OPT_SIMPLE_STR('P', esp32_hostpatch); + OPT_SIMPLE_STR(0x104, esp32_portpatch); + OPT_SIMPLE_INT(0x105, port); OPT_SIMPLE_STR('F', esp32_fixup); OPT_SIMPLE_STR('I', esp32_inject); OPT_SIMPLE_STR('X', esp32_extract); @@ -362,6 +368,7 @@ int_t main(int argc, char *argv[]) autogen &= !options.encode; autogen &= !options.encode_test; autogen &= !options.esp32_hostpatch; + autogen &= !options.esp32_portpatch; autogen &= !options.esp32_fixup; autogen &= !options.esp32_inject; autogen &= !options.esp32_extract; @@ -493,6 +500,22 @@ int_t main(int argc, char *argv[]) exit_cleanup(error); } + if (options.esp32_portpatch) + { + if (!options.port) + { + TRACE_ERROR("Missing --port\r\n"); + exit_cleanup(-1); + } + + int_t error = esp32_patch_port(options.esp32_portpatch, options.port); + if (error == 0) + { + error = esp32_fixup(options.esp32_portpatch, true); + } + exit_cleanup(error); + } + if (options.esp32_fixup) { int_t error = esp32_fixup(options.esp32_fixup, true); @@ -687,6 +710,10 @@ static void print_usage(char *argv[]) " Requires: --hostname to specify the new host.\r\n" " Optional: --oldrtnlhost and --oldapihost to specify old hosts to be replaced.\r\n" "\r\n" + " --esp32-portpatch \r\n" + " Patch the port the box connects to in an ESP32 image and does a fixup of the image afterwards.\r\n" + " Requires: --port (1..32767, 443 restores the original).\r\n" + "\r\n" " --esp32-fixup \r\n" " Perform a checksum fixup operation on an ESP32 image.\r\n" "\r\n" diff --git a/src/mqtt.c b/src/mqtt.c index 57d0e897d..78957c778 100644 --- a/src/mqtt.c +++ b/src/mqtt.c @@ -644,7 +644,7 @@ void mqtt_publish_string(const char *name, const char *value) { char path_buffer[128]; - sprintf(path_buffer, name, settings_get_string("mqtt.topic")); + osSnprintf(path_buffer, sizeof(path_buffer), name, settings_get_string("mqtt.topic")); if (!mqtt_publish(path_buffer, value)) { @@ -657,8 +657,8 @@ void mqtt_publish_float(const char *name, float value) char path_buffer[128]; char buffer[32]; - sprintf(path_buffer, name, settings_get_string("mqtt.topic")); - sprintf(buffer, "%0.4f", value); + osSnprintf(path_buffer, sizeof(path_buffer), name, settings_get_string("mqtt.topic")); + osSnprintf(buffer, sizeof(buffer), "%0.4f", value); if (!mqtt_publish(path_buffer, buffer)) { @@ -675,8 +675,8 @@ void mqtt_publish_int(const char *name, uint32_t value) { return; } - sprintf(path_buffer, name, settings_get_string("mqtt.topic")); - sprintf(buffer, "%u", value); + osSnprintf(path_buffer, sizeof(path_buffer), name, settings_get_string("mqtt.topic")); + osSnprintf(buffer, sizeof(buffer), "%u", value); if (!mqtt_publish(path_buffer, buffer)) { @@ -977,9 +977,9 @@ error_t mqtt_init_box(t_ha_info *ha_box_instance, client_ctx_t *client_ctx) } ha_setup(ha_box_instance); - osSprintf(ha_box_instance->name, "%s", box_name); - osSprintf(ha_box_instance->id, "%s_Box_%s", settings_get_string("mqtt.topic"), box_id); - osSprintf(ha_box_instance->base_topic, "%s/box/%s", settings_get_string("mqtt.topic"), box_id); + osSnprintf(ha_box_instance->name, sizeof(ha_box_instance->name), "%s", box_name); + osSnprintf(ha_box_instance->id, sizeof(ha_box_instance->id), "%s_Box_%s", settings_get_string("mqtt.topic"), box_id); + osSnprintf(ha_box_instance->base_topic, sizeof(ha_box_instance->base_topic), "%s/box/%s", settings_get_string("mqtt.topic"), box_id); osSprintf(ha_box_instance->mf, "%s", "tonies"); osSprintf(ha_box_instance->mdl, "%s", "Toniebox"); osStrcpy(ha_box_instance->via, ha_server_instance.id); diff --git a/src/mqtt_server.c b/src/mqtt_server.c index 8822356bd..d02d7c981 100644 --- a/src/mqtt_server.c +++ b/src/mqtt_server.c @@ -8,6 +8,8 @@ #include "debug.h" #include "settings.h" #include "mqtt_server.h" +#include "box_cert.h" +#include "box_cert_check.h" #include "tls.h" #include "rand.h" #include "tls_adapter.h" @@ -185,10 +187,6 @@ error_t mqtt_server_tls_init(TlsContext *tlsContext) if (error) return error; - error = tlsSetCache(tlsContext, tlsCache); - if (error) - return error; - error = tlsEnableSecureRenegotiation(tlsContext, TRUE); if (error) return error; @@ -415,52 +413,67 @@ static void mqtt_server_publish(client_ctx_t *client_ctx, const char *topic, con } } -static void mqtt_connection_update_context_from_cert(MqttClientConnection *conn) +/* False when a box presents a certificate the box certificate check refuses. An unknown + * box only counts as a box while core.allowNewBox is on, like on the box port. */ +static bool mqtt_connection_update_context_from_cert(MqttClientConnection *conn) { - if (conn->tlsContext != NULL && osStrlen(conn->tlsContext->client_cert_subject) > 0) + if (conn->tlsContext == NULL) { - char_t *subject = conn->tlsContext->client_cert_subject; - char_t *issuer = conn->tlsContext->client_cert_issuer; + return true; + } - if (osStrstr(issuer, "Boxine Factory SubCA") != NULL || osStrstr(issuer, "Toniebox SubCA") != NULL - || osStrstr(issuer, "TeddyCloud") != NULL || osStrstr(subject, "TeddyCloud") != NULL || osStrstr(issuer, "Toniebox Root CA") != NULL) - { - char_t *commonName = NULL; - if (osStrlen(subject) == 15 && !osStrncmp(subject, "b'", 2) && subject[14] == '\'') // tonies standard cn with b'[MAC]' - { - commonName = strdup(&subject[2]); - commonName[osStrlen(commonName) - 1] = '\0'; - } else if (osStrlen(subject) == 12) { - commonName = strdup(subject); - } + char_t *subject = conn->tlsContext->client_cert_subject; + char_t *issuer = conn->tlsContext->client_cert_issuer; + char_t boxId[BOX_CERT_ID_SIZE]; - if (commonName != NULL) { - settings_t *box_settings = get_settings_cn(commonName); - if (box_settings != NULL && box_settings->internal.config_used) - { - conn->client_ctx.settings = box_settings; - conn->client_ctx.settingsNoOverlay = box_settings; - conn->client_ctx.state = get_toniebox_state_id(box_settings->internal.overlayNumber); - conn->client_ctx.state->box.id = conn->client_ctx.settings->commonName; - conn->client_ctx.state->box.name = conn->client_ctx.settings->boxName; - } - osFreeMem(commonName); - } - } + if (!box_cert_issuer_known(issuer, subject) || !box_cert_id(subject, boxId)) + { + return true; + } + + if (get_overlay_id(boxId) == 0 && !get_settings()->core.allowNewBox) + { + return true; } + settings_t *box_settings = get_settings_cn(boxId); + if (box_settings == NULL || !box_settings->internal.config_used) + { + return true; + } + if (!box_cert_accepted(conn->tlsContext, box_settings)) + { + return false; + } + + conn->client_ctx.settings = box_settings; + conn->client_ctx.settingsNoOverlay = box_settings; + conn->client_ctx.state = get_toniebox_state_id(box_settings->internal.overlayNumber); + conn->client_ctx.state->box.id = conn->client_ctx.settings->commonName; + conn->client_ctx.state->box.name = conn->client_ctx.settings->boxName; + return true; } +static error_t handle_mqtt_disconnect(MqttClientConnection *conn, MqttMessageType type, const char *topic, const uint8_t *payload, size_t payload_len); + static error_t handle_mqtt_connect(MqttClientConnection *conn, MqttMessageType type, const char *topic, const uint8_t *payload, size_t payload_len) { - mqtt_connection_update_context_from_cert(conn); - TRACE_INFO("MQTT: connection established for %s\r\n", conn->client_ctx.settings->commonName); - - uint8_t connack[] = {0x20, 0x02, 0x00, 0x00}; + bool accepted = mqtt_connection_update_context_from_cert(conn); + if (accepted) + { + TRACE_INFO("MQTT: connection established for %s\r\n", conn->client_ctx.settings->commonName); + } + + uint8_t connack[] = {0x20, 0x02, 0x00, accepted ? 0x00 : 0x05}; // 0x05: not authorized size_t written = 0; - if (conn->tlsContext) - return tlsWrite(conn->tlsContext, connack, sizeof(connack), &written, 0); - else - return socketSend(conn->socket, connack, sizeof(connack), &written, 0); + error_t error = conn->tlsContext ? tlsWrite(conn->tlsContext, connack, sizeof(connack), &written, 0) + : socketSend(conn->socket, connack, sizeof(connack), &written, 0); + if (!accepted) + { + TRACE_WARNING("MQTT: box certificate refused\r\n"); + handle_mqtt_disconnect(conn, MQTT_MSG_DISCONNECT, NULL, NULL, 0); + return ERROR_ACCESS_DENIED; + } + return error; } static error_t handle_mqtt_pingreq(MqttClientConnection *conn, MqttMessageType type, const char *topic, const uint8_t *payload, size_t payload_len) diff --git a/src/os_ext.c b/src/os_ext.c index eb1021b0c..29e9d18ab 100644 --- a/src/os_ext.c +++ b/src/os_ext.c @@ -1,5 +1,13 @@ #include "os_ext.h" +#ifdef _WIN32 +#include +#else +#include +#include +#include +#endif + FILE *osPopen(const char *command, const char *type) { #ifdef _WIN32 @@ -18,6 +26,57 @@ int osPclose(FILE *stream) #endif } +bool osChmodOwnerOnly(const char *path) +{ +#ifdef _WIN32 + (void)path; + return true; +#else + return chmod(path, S_IRUSR | S_IWUSR) == 0; +#endif +} + +bool osShellQuote(char *dest, size_t dest_size, const char *src) +{ +#ifdef _WIN32 + /* cmd.exe: everything inside double quotes is literal except '"' itself */ + if (strchr(src, '"')) + { + return false; + } + int len = snprintf(dest, dest_size, "\"%s\"", src); + return len >= 0 && (size_t)len < dest_size; +#else + /* sh: everything inside single quotes is literal, a ' becomes '\'' */ + if (dest_size < 3) + { + return false; + } + size_t j = 0; + dest[j++] = '\''; + for (; *src; src++) + { + size_t n = (*src == '\'') ? 4 : 1; + if (j + n + 2 > dest_size) /* keep room for the closing quote and NUL */ + { + return false; + } + if (n == 4) + { + memcpy(&dest[j], "'\\''", 4); + } + else + { + dest[j] = *src; + } + j += n; + } + dest[j++] = '\''; + dest[j] = '\0'; + return true; +#endif +} + void osStringToUpper(char *str) { while (*str) @@ -34,4 +93,29 @@ void osStringToLower(char *str) *str = tolower(*str); str++; } +} + +int osSpawnvp(const char *file, char *const argv[]) +{ +#ifdef _WIN32 + intptr_t rc = _spawnvp(_P_WAIT, file, (const char *const *)argv); + return (rc == -1) ? -1 : (int)rc; +#else + pid_t pid = fork(); + if (pid < 0) + { + return -1; + } + if (pid == 0) + { + execvp(file, argv); + _exit(127); // execvp only returns on failure + } + int status; + if (waitpid(pid, &status, 0) < 0) + { + return -1; + } + return WIFEXITED(status) ? WEXITSTATUS(status) : -1; +#endif } \ No newline at end of file diff --git a/src/platform/platform_linux.c b/src/platform/platform_linux.c index ad3aba1fd..f28d1f7f3 100644 --- a/src/platform/platform_linux.c +++ b/src/platform/platform_linux.c @@ -313,7 +313,8 @@ error_t socketReceive(Socket *socket, void *data_in, } } - if (buff->buffer_used >= size) + /* cap at the requested size, but never override a line end found above */ + if (buff->buffer_used >= size && (!return_count || return_count > size)) { return_count = size; } diff --git a/src/server.c b/src/server.c index 97553a1a5..8900bfce3 100644 --- a/src/server.c +++ b/src/server.c @@ -20,6 +20,7 @@ #include "handler_rtnl.h" // for handleRtnl #include "handler_security_mit.h" // for handleSecMitRobotsTxt, checkSecMit... #include "handler_sse.h" // for handleApiSse, sse_init +#include "handler_web_auth.h" // for web_auth_init, web_auth_requires_login #include "http/http_common.h" // for HTTP_AUTH_MODE_DIGEST #include "http/http_server.h" // for _HttpConnection, HttpServerSettings #include "mutex_manager.h" // for mutex_unlock, mutex_lock, MUTEX_CL... @@ -33,6 +34,8 @@ #include "mqtt_server.h" // for mqtt_server_init, mqtt_server_task #include "stdbool.h" // for true, bool, false #include "tls.h" // for _TlsContext, tlsLoadCertificate +#include "box_cert.h" // for box_cert_id, box_cert_issuer_known +#include "box_cert_check.h" // for box_cert_accepted #include "tls_adapter.h" // for tls_context_key_log_init, tlsCache #include "toniebox_state.h" // for get_toniebox_state, get_toniebox_s... #include "toniebox_state_type.h" // for toniebox_state_box_t, toniebox_sta... @@ -51,6 +54,7 @@ #include "handler_rtnl.h" // for handleRtnl #include "handler_security_mit.h" // for handleSecMitRobotsTxt, checkSecMit... #include "handler_sse.h" // for handleApiSse, sse_init +#include "handler_web_auth.h" // for web_auth_init, web_auth_requires_login #include "http/http_common.h" // for HTTP_AUTH_MODE_DIGEST #include "http/http_server.h" // for _HttpConnection, HttpServerSettings #include "mutex_manager.h" // for mutex_unlock, mutex_lock, MUTEX_CL... @@ -119,6 +123,12 @@ request_type_t request_paths[] = { {REQ_POST, "/api/auth/login", SERTY_WEB, &handleApiAuthLogin}, {REQ_GET, "/api/auth/logout", SERTY_WEB, &handleApiAuthLogout}, {REQ_POST, "/api/auth/refresh-token", SERTY_WEB, &handleApiAuthRefreshToken}, + {REQ_GET, "/api/auth/status", SERTY_WEB, &handleApiAuthStatus}, + {REQ_GET, "/api/auth/users/get", SERTY_WEB, &handleApiAuthUsersGet}, + {REQ_POST, "/api/auth/users/create", SERTY_WEB, &handleApiAuthUsersCreate}, + {REQ_POST, "/api/auth/users/delete", SERTY_WEB, &handleApiAuthUsersDelete}, + {REQ_POST, "/api/auth/users/updatePassword", SERTY_WEB, &handleApiAuthUsersPassword}, + {REQ_POST, "/api/auth/enabled", SERTY_WEB, &handleApiAuthEnabled}, /* plugins API */ {REQ_GET, "/api/plugins/get", SERTY_WEB, &handleApiPluginsGet}, /* custom API */ @@ -137,6 +147,7 @@ request_type_t request_paths[] = { {REQ_POST, "/api/pcmUpload", SERTY_WEB, &handleApiPcmUpload}, {REQ_POST, "/api/tafUpload", SERTY_WEB, &handleApiTafUpload}, {REQ_GET, "/api/fileIndexV2", SERTY_WEB, &handleApiFileIndexV2}, + {REQ_POST, "/api/fileSetListened", SERTY_WEB, &handleApiFileSetListened}, {REQ_GET, "/api/fileIndex", SERTY_WEB, &handleApiFileIndex}, {REQ_GET, "/api/stats", SERTY_WEB, &handleApiStats}, {REQ_GET, "/api/toniesJsonSearch", SERTY_WEB, &handleApiToniesJsonSearch}, @@ -269,6 +280,43 @@ error_t handleCacheDownload(HttpConnection *connection, const char_t *uri, const return err; } +static error_t handleCorsOptions( + HttpConnection *connection, + const char_t *uri, + const char_t *queryString, + client_ctx_t *client_ctx) +{ + char line[256]; + const char_t *allowOrigin = connection->serverContext->settings.allowOrigin; + + osSnprintf(line, sizeof(line), "HTTP/%d.%d 204 No Content\r\n", MSB(connection->request.version), LSB(connection->request.version)); + + httpSend(connection, line, osStrlen(line), HTTP_FLAG_DELAY); + + if (allowOrigin != NULL && osStrlen(allowOrigin) > 0) + { + osSnprintf(line, sizeof(line), "Access-Control-Allow-Origin: %s\r\nVary: Origin\r\n", allowOrigin); + httpSend(connection, line, osStrlen(line), HTTP_FLAG_DELAY); + } + + osSnprintf(line, sizeof(line), "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"); + httpSend(connection, line, osStrlen(line), HTTP_FLAG_DELAY); + + osSnprintf(line, sizeof(line), "Access-Control-Allow-Headers: Content-Type, Authorization\r\n"); + httpSend(connection, line, osStrlen(line), HTTP_FLAG_DELAY); + + osSnprintf(line, sizeof(line), "Access-Control-Max-Age: 86400\r\n"); + httpSend(connection, line, osStrlen(line), HTTP_FLAG_DELAY); + + osSnprintf(line, sizeof(line), "Content-Length: 0\r\n"); + httpSend(connection, line, osStrlen(line), HTTP_FLAG_DELAY); + + osSnprintf(line, sizeof(line), "\r\n"); + httpSend(connection, line, osStrlen(line), HTTP_FLAG_DELAY); + + return NO_ERROR; +} + error_t resGetData(const char_t *path, const uint8_t **data, size_t *length) { TRACE_DEBUG("resGetData: %s (static response)\n", path); @@ -284,6 +332,7 @@ error_t httpServerRequestCallback(HttpConnection *connection, const char_t *uri, size_t openRequests = ++openRequestsLast; error_t error = NO_ERROR; connection->private.api_access_only = is_api_only; + connection->private.authenticated = false; stats_update("connections", 1); @@ -294,6 +343,7 @@ error_t httpServerRequestCallback(HttpConnection *connection, const char_t *uri, TRACE_DEBUG(" Issuer: '%s'\r\n", connection->tlsContext->client_cert_issuer); TRACE_DEBUG(" Subject: '%s'\r\n", connection->tlsContext->client_cert_subject); TRACE_DEBUG(" Serial: '%s'\r\n", connection->tlsContext->client_cert_serial); + TRACE_DEBUG(" SHA256: '%s'\r\n", connection->tlsContext->client_cert_sha256); request_source = connection->tlsContext->client_cert_subject; } else @@ -317,44 +367,28 @@ error_t httpServerRequestCallback(HttpConnection *connection, const char_t *uri, char_t *issuer = connection->tlsContext->client_cert_issuer; uint32_t boxGen = GENERATION_UNKNOWN; - if (osStrstr(issuer, "Boxine Factory SubCA") != NULL || osStrstr(issuer, "Toniebox SubCA") != NULL - || osStrstr(issuer, "TeddyCloud") != NULL || osStrstr(subject, "TeddyCloud") != NULL || osStrstr(issuer, "Toniebox Root CA") != NULL) + if (box_cert_issuer_known(issuer, subject)) { - char_t *commonName = NULL; - if (osStrlen(subject) == 15 && !osStrncmp(subject, "b'", 2) && subject[14] == '\'') // tonies standard cn with b'[MAC]' + char_t boxId[BOX_CERT_ID_SIZE]; + bool hasBoxId = box_cert_id(subject, boxId); + const char_t *commonName = hasBoxId ? boxId : subject; + + if (hasBoxId && get_overlay_id(commonName) == 0) { - commonName = strdup(&subject[2]); - commonName[osStrlen(commonName) - 1] = '\0'; - } else if (osStrlen(subject) == 12) { - commonName = strdup(subject); - } - if (commonName != NULL) { - if (get_overlay_id(commonName) == 0) + if (client_ctx->settings->core.allowNewBox) { - if (client_ctx->settings->core.allowNewBox) - { - TRACE_INFO("Added new client certificate with CN=%s\n", commonName); - } - else - { - TRACE_WARNING("Found unknown client certificate with CN=%s\n", commonName); - } + TRACE_INFO("Added new client certificate with CN=%s\n", commonName); } - if (get_overlay_id(commonName) > 0 || client_ctx->settings->core.allowNewBox) + else { - client_ctx->settings = get_settings_cn(commonName); - connection->private.authenticated = client_ctx->settings->toniebox.api_access; + TRACE_WARNING("Found unknown client certificate with CN=%s\n", commonName); } - osFreeMem(commonName); - // TODO: CHECK THE CERTIFICATES FOR REAL!!!!! } - else + if (get_overlay_id(commonName) > 0 || client_ctx->settings->core.allowNewBox) { - if (get_overlay_id(subject) > 0 || client_ctx->settings->core.allowNewBox) - { - client_ctx->settings = get_settings_cn(subject); - connection->private.authenticated = client_ctx->settings->toniebox.api_access; - } + client_ctx->settings = get_settings_cn(commonName); + connection->private.authenticated = client_ctx->settings->toniebox.api_access && + box_cert_accepted(connection->tlsContext, client_ctx->settings); } client_ctx->state = get_toniebox_state_id(client_ctx->settings->internal.overlayNumber); @@ -430,7 +464,7 @@ error_t httpServerRequestCallback(HttpConnection *connection, const char_t *uri, { // CC3235 User-Agent: TB/%firmware-ts% SP/%sp% HW/%hw% client_ctx->settings->internal.toniebox_firmware.boxIC = BOX_CC3235; - boxGen = GENERATION_TB2; + boxGen = GENERATION_TB1; } else { @@ -519,6 +553,18 @@ error_t httpServerRequestCallback(HttpConnection *connection, const char_t *uri, break; } + if (!connection->private.api_access_only && !osStrcasecmp(connection->request.method, "OPTIONS")) + { + error = handleCorsOptions(connection, uri, connection->request.queryString, client_ctx); + break; + } + + if (!connection->private.api_access_only && web_auth_requires_login() && !web_auth_is_public_request(uri, connection->request.method) && !web_auth_is_authenticated(connection, NULL, 0)) + { + error = web_auth_unauthorized(connection); + break; + } + for (size_t i = 0; i < sizeof(request_paths) / sizeof(request_paths[0]); i++) { size_t pathLen = osStrlen(request_paths[i].path); @@ -547,20 +593,12 @@ error_t httpServerRequestCallback(HttpConnection *connection, const char_t *uri, { if (!strcmp(uri, "/") || !strcmp(uri, "index.shtm")) { - if (!client_ctx->settings->core.new_webgui_as_default) - { - uri = "/legacy.html"; - } - else - { - uri = "/web"; - httpPrepareHeader(connection, "", 0); - connection->response.keepAlive = false; - connection->response.location = uri; - connection->response.statusCode = 301; - return httpWriteResponseString(connection, "", false); - continue; - } + uri = "/web"; + httpPrepareHeader(connection, "", 0); + connection->response.keepAlive = false; + connection->response.location = uri; + connection->response.statusCode = 301; + return httpWriteResponseString(connection, "", false); } if (!strncmp(uri, "/web", 4) && (uri[4] == '\0' || uri[strlen(uri) - 1] == '/' || !strchr(uri, '.'))) @@ -627,34 +665,17 @@ void httpParseAuthorizationField(HttpConnection *connection, char_t *value) } if (!strncmp(value, "Bearer ", 7)) { - if (strlen(value) != 7 + 2 * JWT_AUTH_TOKEN_LENGTH) + /* web UI session token, validated later by the web auth handlers */ + const char *token = value + 7; + size_t tlen = osStrlen(token); + if (tlen > 0 && tlen < sizeof(connection->private.web_bearer_token)) { - TRACE_WARNING("Authentication: Failed to parse auth token '%s'\r\n", value); - return; + osStrcpy(connection->private.web_bearer_token, token); } - // TODO: check JWT TOKEN - for (int pos = 0; pos < JWT_AUTH_TOKEN_LENGTH; pos++) + else { - char hex_digits[3]; - char *end_ptr = NULL; - - /* get a hex byte into a buffer for parsing it */ - osStrncpy(hex_digits, &value[3 + 2 * pos], 2); - hex_digits[2] = 0; - - /* will still fail for minus sign and possibly other things, but then the token is just incorrect */ - connection->private.authentication_token[pos] = (uint8_t)osStrtoul(hex_digits, &end_ptr, 16); - - if (end_ptr != &hex_digits[2]) - { - TRACE_WARNING("Authentication: Failed to parse auth token '%s'\n", value); - return; - } + TRACE_DEBUG("Authentication: Ignoring bearer token with invalid length %" PRIuSIZE "\r\n", tlen); } - /* if we come across this part, this means the token was most likely correctly *parsed* */ - connection->request.auth.found = 1; - connection->request.auth.mode = HTTP_AUTH_MODE_DIGEST; - connection->status = HTTP_ACCESS_ALLOWED; } } @@ -750,11 +771,13 @@ static error_t httpServerConfigureTls( tls_context_key_log_init(tlsContext); - // Session cache that will be used to save/resume TLS sessions - error = tlsSetCache(tlsContext, tlsCache); - // Any error to report? - if (error) - return error; + // A resumed session carries no client certificate, so the box cannot be identified on it + if (authMode == TLS_CLIENT_AUTH_NONE) + { + error = tlsSetCache(tlsContext, tlsCache); + if (error) + return error; + } // Enable secure renegotiation, otherwise modern OpenSSL clients (e.g., Python 3 requests) reject the connection error = tlsEnableSecureRenegotiation(tlsContext, TRUE); @@ -885,6 +908,7 @@ void server_init(bool test) } settings_set_bool("internal.exit", FALSE); sse_init(); + web_auth_init(); HttpServerSettings http_settings; HttpServerSettings https_web_settings; diff --git a/src/server_helpers.c b/src/server_helpers.c index 93bfc099e..0f8a2a042 100644 --- a/src/server_helpers.c +++ b/src/server_helpers.c @@ -337,6 +337,14 @@ error_t multipart_handle(HttpConnection *connection, multipart_cbr_t *cbr, void eMultipartState state = PARSE_HEADER; char *boundary = connection->request.boundary; + /* An empty boundary makes find_string() match at offset 0 everywhere, + which turns the data-end arithmetic below into an underflow. */ + if (boundary == NULL || osStrlen(boundary) == 0) + { + TRACE_ERROR("multipart request without a boundary\r\n"); + return ERROR_INVALID_REQUEST; + } + size_t leftover = 0; bool fetch = true; int save_start = 0; @@ -473,8 +481,9 @@ error_t multipart_handle(HttpConnection *connection, multipart_cbr_t *cbr, void if (data_end >= 0) { - /* We've found a boundary, let's finish up the current file, but skip the "--\r\n" */ - if (cbr->multipart_add(multipart_ctx, (uint8_t *)buffer, data_end - 4) != NO_ERROR) + /* We've found a boundary, let's finish up the current file, but skip the preceding "\r\n--" */ + size_t add_len = (data_end >= 4) ? (size_t)(data_end - 4) : 0; + if (cbr->multipart_add(multipart_ctx, (uint8_t *)buffer, add_len) != NO_ERROR) { TRACE_ERROR("multipart_add failed\r\n"); return ERROR_FAILURE; @@ -669,6 +678,12 @@ error_t ipv6StringToAddr(const char_t *str, Ipv6Addr *ipAddr) // The "::" symbol is preceded by a number? if (value >= 0) { + // Too many 16-bit words? (bounds the w[] write) + if (i >= 8) + { + error = ERROR_INVALID_SYNTAX; + break; + } // Save the current 16-bit word ipAddr->w[i++] = htons(value); // Prepare to decode the next 16-bit word diff --git a/src/settings.c b/src/settings.c index 03a7197ac..a625cbac5 100644 --- a/src/settings.c +++ b/src/settings.c @@ -3,6 +3,7 @@ #include #include #include +#include #include "version.h" #include "debug.h" @@ -18,7 +19,7 @@ /* static functions*/ static void settings_init_opt(setting_item_t *opt); -static void settings_deinit_ovl(uint8_t overlayNumber); +static void settings_deinit_ovl(uint8_t overlayNumber, bool reload); static void overlay_settings_init(); static void settings_generate_internal_dirs(settings_t *settings); static void settings_changed(); @@ -42,6 +43,84 @@ static char *settings_sanitize_box_id(const char *input_id); #define OVERLAY_CONFIG_PREFIX "overlay." static settings_t Settings_Overlay[MAX_OVERLAYS]; static setting_item_t *Option_Map_Overlay[MAX_OVERLAYS]; +/* name-sorted index into Option_Map_Overlay for O(log n) lookups by name; + rebuilt whenever the option map is (re)built, freed in settings_deinit_ovl */ +static setting_item_t **Option_Sorted_Overlay[MAX_OVERLAYS]; + +/* Request threads read settings strings/arrays without a lock while the main loop reloads and replaces + them (settings_loop -> settings_load, settings changes). Freeing the old memory right away is a + use-after-free for such a reader, so replaced memory is parked here and released after a grace period. + ponytail: fixed grace period; a request running longer than that could still read freed memory + (http client timeouts are far below). Proper fix: reference-counted settings snapshots. */ +#define SETTINGS_FREE_GRACE_S 120 +typedef struct +{ + void *ptr; + time_t when; +} settings_parked_t; +static settings_parked_t *settings_parked = NULL; +static size_t settings_parked_len = 0; + +static void settings_free_later(void *ptr) +{ + if (ptr == NULL) + { + return; + } + mutex_lock(MUTEX_SETTINGS_FREE); + settings_parked_t *grown = realloc(settings_parked, sizeof(settings_parked_t) * (settings_parked_len + 1)); + if (grown != NULL) + { + settings_parked = grown; + settings_parked[settings_parked_len].ptr = ptr; + settings_parked[settings_parked_len].when = time(NULL); + settings_parked_len++; + } + else + { + osFreeMem(ptr); // out of memory: better a (very unlikely) race than a leak + } + mutex_unlock(MUTEX_SETTINGS_FREE); +} + +/* all: also release what is still within the grace period (shutdown) */ +static void settings_free_parked(bool all) +{ + mutex_lock(MUTEX_SETTINGS_FREE); + time_t now = time(NULL); + size_t keep = 0; + for (size_t i = 0; i < settings_parked_len; i++) + { + if (all || now - settings_parked[i].when >= SETTINGS_FREE_GRACE_S) + { + osFreeMem(settings_parked[i].ptr); + } + else + { + settings_parked[keep++] = settings_parked[i]; + } + } + settings_parked_len = keep; + if (keep == 0 && settings_parked != NULL) + { + free(settings_parked); + settings_parked = NULL; + } + mutex_unlock(MUTEX_SETTINGS_FREE); +} + +static int settings_option_name_qsort_cmp(const void *a, const void *b) +{ + const setting_item_t *x = *(setting_item_t *const *)a; + const setting_item_t *y = *(setting_item_t *const *)b; + return osStrcmp(x->option_name, y->option_name); +} + +static int settings_option_name_bsearch_cmp(const void *key, const void *elem) +{ + const setting_item_t *opt = *(setting_item_t *const *)elem; + return osStrcmp((const char *)key, opt->option_name); +} static uint16_t settings_size = 0; static char *config_file_path = NULL; static char *config_overlay_file_path = NULL; @@ -85,8 +164,6 @@ static void option_map_init(uint8_t settingsId) OPTION_STRING("core.sslkeylogfile", &settings->core.sslkeylogfile, "", "SSL-key logfile", "SSL/TLS key log filename", LEVEL_EXPERT) OPTION_UNSIGNED("core.server.http_client_timeout", &settings->core.http_client_timeout, 2000, 250, 10000, "Connection timeout", "HTTP client connection timeout (default: 500ms)", LEVEL_DETAIL) OPTION_UNSIGNED("core.file_upload_timeout_ms", &settings->core.file_upload_timeout_ms, 120000, 15000, 300000, "File upload timeout", "Client-side timeout for file uploads in ms (15s–5min). Default 120s for large audio files.", LEVEL_DETAIL) - OPTION_BOOL("core.new_webgui_as_default", &settings->core.new_webgui_as_default, TRUE, "New WebGUI", "Use new WebGUI as default", LEVEL_EXPERT) - OPTION_TREE_DESC("core.server_cert", "HTTPS server certificates", LEVEL_EXPERT) OPTION_TREE_DESC("core.client_cert.file", "File certificates", LEVEL_EXPERT) OPTION_STRING("core.server_cert.file.ca", &settings->core.server_cert.file.ca, "certs/server/ca-root.pem", "CA certificate", "CA certificate", LEVEL_EXPERT) @@ -137,6 +214,7 @@ static void option_map_init(uint8_t settingsId) OPTION_STRING("core.allowOrigin", &settings->core.allowOrigin, "", "CORS Allow-Origin", "Set CORS Access-Control-Allow-Origin header", LEVEL_EXPERT) OPTION_BOOL("core.boxCertAuth", &settings->core.boxCertAuth, TRUE, "HTTPS box cert auth", "Client certificates are required for access to the HTTPS API for the boxes", LEVEL_EXPERT) + OPTION_BOOL("core.allowPrivateFetch", &settings->core.allowPrivateFetch, FALSE, "Allow downloads from private networks", "Allow the server to download from addresses in the local/private network (e.g. images of tonies hosted on a NAS). Off by default, as a user-supplied URL could otherwise make the server read internal services (SSRF).", LEVEL_EXPERT) OPTION_BOOL("core.allowNewBox", &settings->core.allowNewBox, TRUE, "Allow new boxes", "Allow new boxes to be added, if they try to connect", LEVEL_BASIC) OPTION_BOOL("core.flex_enabled", &settings->core.flex_enabled, TRUE, "Enable Flex-Tonie", "When enabled this UID always gets assigned the audio selected from web interface", LEVEL_DETAIL) @@ -231,6 +309,8 @@ static void option_map_init(uint8_t settingsId) OPTION_INTERNAL_STRING("internal.toniebox_firmware.uaEsp32Firmware", &settings->internal.toniebox_firmware.uaEsp32Firmware, "", "Firmware version from User Agent (esp32)", LEVEL_NONE) OPTION_INTERNAL_STRING("internal.toniebox_firmware.rtnlVersion", &settings->internal.toniebox_firmware.rtnlVersion, "", "Firmware version from RTNL", LEVEL_NONE) OPTION_INTERNAL_STRING("internal.toniebox_firmware.rtnlFullVersion", &settings->internal.toniebox_firmware.rtnlFullVersion, "", "Firmware full version from RTNL", LEVEL_NONE) + OPTION_INTERNAL_STRING("internal.boxCertStatus", &settings->internal.boxCertStatus, "", "Result of the box certificate check", LEVEL_NONE) + OPTION_INTERNAL_STRING("internal.boxCertSha256", &settings->internal.boxCertSha256, "", "SHA-256 of the certificate the box presented last", LEVEL_NONE) OPTION_INTERNAL_STRING("internal.toniebox_firmware.rtnlDetail", &settings->internal.toniebox_firmware.rtnlDetail, "", "Firmware detail information", LEVEL_NONE) OPTION_INTERNAL_STRING("internal.toniebox_firmware.rtnlRegion", &settings->internal.toniebox_firmware.rtnlRegion, "", "Firmware region", LEVEL_NONE) OPTION_INTERNAL_UNSIGNED("internal.toniebox_firmware.otaVersionSfx", &settings->internal.toniebox_firmware.otaVersionSfx, 0, 0, UINT64_MAX, " ota version", LEVEL_NONE) @@ -281,6 +361,7 @@ static void option_map_init(uint8_t settingsId) OPTION_BOOL("cloud.prioCustomContent", &settings->cloud.prioCustomContent, TRUE, "Prioritize custom content", "Prioritize custom content over tonies content (force update, only if \"Update content on lower audio id\" is disabled)", LEVEL_EXPERT) OPTION_BOOL("cloud.updateOnLowerAudioId", &settings->cloud.updateOnLowerAudioId, TRUE, "Update content on lower audio id", "Update content on a lower audio id", LEVEL_EXPERT) OPTION_BOOL("cloud.dumpRuidAuthContentJson", &settings->cloud.dumpRuidAuthContentJson, TRUE, "Dump rUID/auth", "Dump the rUID and authentication into the content JSON.", LEVEL_EXPERT) + OPTION_BOOL("cloud.autoMarkListenedOnSync", &settings->cloud.autoMarkListenedOnSync, TRUE, "Auto-mark listened on sync", "Automatically mark library content as listened once a Toniebox downloads/plays it from this server", LEVEL_BASIC) OPTION_TREE_DESC("encode", "TAF encoding", LEVEL_EXPERT) OPTION_UNSIGNED("encode.bitrate", &settings->encode.bitrate, 96, 0, 256, "Opus bitrate", "Opus bitrate, tested 64, 96(default), 128, 192, 256 - be aware that this increases the TAF size!", LEVEL_EXPERT) @@ -296,9 +377,11 @@ static void option_map_init(uint8_t settingsId) OPTION_BOOL("frontend.ignore_web_version_mismatch", &settings->frontend.ignore_web_version_mismatch, FALSE, "Ignore web version mismatch", "Ignore web version mismatch and don't show the mismatch warning", LEVEL_EXPERT) OPTION_BOOL("frontend.confirm_audioplayer_close", &settings->frontend.confirm_audioplayer_close, TRUE, "Confirm audioplayer close", "Confirm dialog when closing the audioplayer during active playback", LEVEL_BASIC) OPTION_BOOL("frontend.check_cc3200_cfw", &settings->frontend.check_cc3200_cfw, FALSE, "Check for CFW on CC3200 box", "Enable detection of CFW on CC3200 boxes to link MAC addresses to IPs.", LEVEL_DETAIL) + OPTION_BOOL("frontend.web_auth_enabled", &settings->frontend.web_auth_enabled, FALSE, "Web UI login", "Require a login for the web interface. Manage users in the web UI. Disabled by default. Set TEDDYCLOUD_WEB_AUTH_DISABLE=1 to bypass.", LEVEL_BASIC) OPTION_TREE_DESC("toniebox", "Toniebox", LEVEL_BASIC) OPTION_BOOL("toniebox.api_access", &settings->toniebox.api_access, TRUE, "API access", "Grant access to the API (default value for new boxes)", LEVEL_EXPERT) + OPTION_STRING("toniebox.certPin", &settings->toniebox.certPin, "", "Certificate pin", "SHA-256 of the certificate this box is known by. Empty: taken on the next connection, from the uploaded client.der of the box or from the box itself", LEVEL_EXPERT) OPTION_BOOL("toniebox.overrideCloud", &settings->toniebox.overrideCloud, TRUE, "Override cloud settings", "Override tonies cloud settings for the toniebox with those set here", LEVEL_BASIC) OPTION_UNSIGNED("toniebox.boxGeneration", &settings->toniebox.boxGeneration, GENERATION_UNKNOWN, GENERATION_UNKNOWN, GENERATION_TB2, "Box Generation", "Generation of the box (TB1/TB2)", LEVEL_EXPERT) // TB1 specific @@ -367,6 +450,22 @@ static void option_map_init(uint8_t settingsId) } osMemcpy(Option_Map_Overlay[settingsId], option_map_array, sizeof(option_map_array)); + + /* (Re)build the name-sorted lookup index. Leaves Option_Map_Overlay in its + original declaration order (the web UI relies on it); only this parallel + pointer array is sorted. */ + if (Option_Sorted_Overlay[settingsId] == NULL) + { + Option_Sorted_Overlay[settingsId] = osAllocMem(sizeof(setting_item_t *) * settings_size); + } + if (Option_Sorted_Overlay[settingsId] != NULL) + { + for (uint16_t idx = 0; idx < settings_size; idx++) + { + Option_Sorted_Overlay[settingsId][idx] = &Option_Map_Overlay[settingsId][idx]; + } + qsort(Option_Sorted_Overlay[settingsId], settings_size, sizeof(setting_item_t *), settings_option_name_qsort_cmp); + } } static setting_item_t *get_option_map(const char *overlay) @@ -425,7 +524,7 @@ static void overlay_settings_init() { for (uint8_t i = 1; i < MAX_OVERLAYS; i++) { - settings_deinit_ovl(i); + settings_deinit_ovl(i, true); option_map_init(i); @@ -503,6 +602,7 @@ settings_t *get_settings_cn(const char *commonName) osFreeMem(crt); osFreeMem(key); + settings_generate_internal_dirs(&Settings_Overlay[i]); Settings_Overlay[i].internal.config_used = true; settings_save_ovl(true); mutex_unlock(MUTEX_SETTINGS); @@ -565,52 +665,69 @@ void settings_resolve_dir(char **resolvedPath, char *path, char *basePath) fsFixPath(*resolvedPath); } +/* Builds the new paths on the side and swaps the pointers in one go: readers see either the old (parked, + still valid) or the new complete path, never a half written one. */ +static char *settings_alloc_path() +{ + char *path = osAllocMem(256); + osMemset(path, 0, 256); + return path; +} + static void settings_generate_internal_dirs(settings_t *settings) { - osFreeMem(settings->internal.basedirfull); - osFreeMem(settings->internal.certdirfull); - osFreeMem(settings->internal.configdirfull); - osFreeMem(settings->internal.contentdirrel); - osFreeMem(settings->internal.contentdirfull); - osFreeMem(settings->internal.librarydirfull); - osFreeMem(settings->internal.datadirfull); - osFreeMem(settings->internal.wwwdirfull); - osFreeMem(settings->internal.pluginsdirfull); - osFreeMem(settings->internal.firmwaredirfull); - osFreeMem(settings->internal.cachedirfull); - - settings->internal.basedirfull = osAllocMem(256); - settings->internal.certdirfull = osAllocMem(256); - settings->internal.configdirfull = osAllocMem(256); - settings->internal.contentdirrel = osAllocMem(256); - settings->internal.contentdirfull = osAllocMem(256); - settings->internal.librarydirfull = osAllocMem(256); - settings->internal.datadirfull = osAllocMem(256); - settings->internal.wwwdirfull = osAllocMem(256); - settings->internal.pluginsdirfull = osAllocMem(256); - settings->internal.firmwaredirfull = osAllocMem(256); - settings->internal.cachedirfull = osAllocMem(256); - - char *tmpPath = osAllocMem(256); - settings_resolve_dir(&settings->internal.basedirfull, settings->internal.basedir, settings->internal.cwd); - - settings_resolve_dir(&settings->internal.certdirfull, settings->core.certdir, settings->internal.basedirfull); - settings_resolve_dir(&settings->internal.datadirfull, settings->core.datadir, settings->internal.basedirfull); - settings_resolve_dir(&settings->internal.configdirfull, settings->core.configdir, settings->internal.basedirfull); - - settings_resolve_dir(&settings->internal.wwwdirfull, settings->core.wwwdir, settings->internal.datadirfull); - settings_resolve_dir(&settings->internal.pluginsdirfull, settings->core.pluginsdir, settings->internal.wwwdirfull); - settings_resolve_dir(&settings->internal.firmwaredirfull, settings->core.firmwaredir, settings->internal.datadirfull); - settings_resolve_dir(&settings->internal.cachedirfull, settings->core.cachedir, settings->internal.datadirfull); + char *basedirfull = settings_alloc_path(); + char *certdirfull = settings_alloc_path(); + char *configdirfull = settings_alloc_path(); + char *contentdirrel = settings_alloc_path(); + char *contentdirfull = settings_alloc_path(); + char *librarydirfull = settings_alloc_path(); + char *datadirfull = settings_alloc_path(); + char *wwwdirfull = settings_alloc_path(); + char *pluginsdirfull = settings_alloc_path(); + char *firmwaredirfull = settings_alloc_path(); + char *cachedirfull = settings_alloc_path(); + + char *tmpPath = settings_alloc_path(); + settings_resolve_dir(&basedirfull, settings->internal.basedir, settings->internal.cwd); + + settings_resolve_dir(&certdirfull, settings->core.certdir, basedirfull); + settings_resolve_dir(&datadirfull, settings->core.datadir, basedirfull); + settings_resolve_dir(&configdirfull, settings->core.configdir, basedirfull); + + settings_resolve_dir(&wwwdirfull, settings->core.wwwdir, datadirfull); + settings_resolve_dir(&pluginsdirfull, settings->core.pluginsdir, wwwdirfull); + settings_resolve_dir(&firmwaredirfull, settings->core.firmwaredir, datadirfull); + settings_resolve_dir(&cachedirfull, settings->core.cachedir, datadirfull); settings_resolve_dir(&tmpPath, settings->core.contentdir, "content"); - settings_resolve_dir(&settings->internal.contentdirrel, tmpPath, settings->core.datadir); - settings_resolve_dir(&settings->internal.contentdirfull, tmpPath, settings->internal.datadirfull); - fsCreateDir(settings->internal.contentdirfull); + settings_resolve_dir(&contentdirrel, tmpPath, settings->core.datadir); + settings_resolve_dir(&contentdirfull, tmpPath, datadirfull); + fsCreateDir(contentdirfull); - settings_resolve_dir(&settings->internal.librarydirfull, settings->core.librarydir, settings->internal.datadirfull); + settings_resolve_dir(&librarydirfull, settings->core.librarydir, datadirfull); osFreeMem(tmpPath); + +#define SETTINGS_SWAP_PATH(field) \ + do \ + { \ + char *old = settings->internal.field; \ + settings->internal.field = field; \ + settings_free_later(old); \ + } while (0) + SETTINGS_SWAP_PATH(basedirfull); + SETTINGS_SWAP_PATH(certdirfull); + SETTINGS_SWAP_PATH(configdirfull); + SETTINGS_SWAP_PATH(contentdirrel); + SETTINGS_SWAP_PATH(contentdirfull); + SETTINGS_SWAP_PATH(librarydirfull); + SETTINGS_SWAP_PATH(datadirfull); + SETTINGS_SWAP_PATH(wwwdirfull); + SETTINGS_SWAP_PATH(pluginsdirfull); + SETTINGS_SWAP_PATH(firmwaredirfull); + SETTINGS_SWAP_PATH(cachedirfull); +#undef SETTINGS_SWAP_PATH } static void settings_changed() @@ -624,14 +741,8 @@ void settings_changed_id(uint8_t settingsId) Settings_Overlay[settingsId].internal.config_changed = true; settings_generate_internal_dirs(get_settings_id((settingsId))); - if (config_file_path != NULL) - { - osFreeMem(config_file_path); - } - if (config_overlay_file_path != NULL) - { - osFreeMem(config_overlay_file_path); - } + settings_free_later(config_file_path); + settings_free_later(config_overlay_file_path); config_file_path = custom_asprintf("%s%c%s", settings_get_string("internal.configdirfull"), PATH_SEPARATOR, CONFIG_FILE); config_overlay_file_path = custom_asprintf("%s%c%s", settings_get_string("internal.configdirfull"), PATH_SEPARATOR, CONFIG_OVERLAY_FILE); @@ -643,7 +754,10 @@ void settings_changed_id(uint8_t settingsId) mutex_unlock(MUTEX_SETTINGS); } -static void settings_deinit_ovl(uint8_t overlayNumber) +/* reload: the overlay is re-initialised right after, readers may still use it. Old strings/arrays are + parked (see settings_free_later), the pointers stay valid (no NULL window) until the init replaces + them, and the option map is kept (option_map_init refreshes it in place). */ +static void settings_deinit_ovl(uint8_t overlayNumber, bool reload) { if (overlayNumber >= MAX_OVERLAYS) { @@ -672,14 +786,28 @@ static void settings_deinit_ovl(uint8_t overlayNumber) case TYPE_STRING: if (*((char **)opt->ptr)) { - osFreeMem(*((char **)opt->ptr)); - *((char **)opt->ptr) = NULL; + if (reload) + { + settings_free_later(*((char **)opt->ptr)); + } + else + { + osFreeMem(*((char **)opt->ptr)); + *((char **)opt->ptr) = NULL; + } } break; case TYPE_U64_ARRAY: if (opt->size > 0) { - osFreeMem(*((uint64_t **)opt->ptr)); + if (reload) + { + settings_free_later(*((uint64_t **)opt->ptr)); + } + else + { + osFreeMem(*((uint64_t **)opt->ptr)); + } opt->size = 0; } break; @@ -690,6 +818,11 @@ static void settings_deinit_ovl(uint8_t overlayNumber) } Settings_Overlay[overlayNumber].internal.config_init = false; + if (reload) + { + return; + } + if (overlayNumber == 0) { osFreeMem(config_file_path); @@ -700,14 +833,18 @@ static void settings_deinit_ovl(uint8_t overlayNumber) osFreeMem(Option_Map_Overlay[overlayNumber]); Option_Map_Overlay[overlayNumber] = NULL; + + osFreeMem(Option_Sorted_Overlay[overlayNumber]); + Option_Sorted_Overlay[overlayNumber] = NULL; } void settings_deinit() { for (uint8_t i = 0; i < MAX_OVERLAYS; i++) { - settings_deinit_ovl(i); + settings_deinit_ovl(i, false); } + settings_free_parked(true); } static void settings_init_opt(setting_item_t *opt) @@ -1069,8 +1206,11 @@ static error_t settings_load_ovl(bool overlay) TRACE_DEBUG("%s=%f\r\n", opt->option_name, *((float *)opt->ptr)); break; case TYPE_STRING: - osFreeMem(*((char **)opt->ptr)); - *((char **)opt->ptr) = strdup(value_str); + { + char *old = *((char **)opt->ptr); + *((char **)opt->ptr) = strdup(value_str); + settings_free_later(old); + } TRACE_DEBUG("%s=%s\r\n", opt->option_name, *((char **)opt->ptr)); break; @@ -1235,6 +1375,20 @@ static setting_item_t *settings_get_by_name_id(const char *item, uint8_t setting TRACE_ERROR("Overlay %d not found\r\n", settingsId); return NULL; } + /* Fast path: binary search the name-sorted index built in option_map_init. */ + setting_item_t **sorted = Option_Sorted_Overlay[settingsId]; + if (sorted != NULL) + { + setting_item_t **found = bsearch(item, sorted, settings_size, sizeof(setting_item_t *), settings_option_name_bsearch_cmp); + if (found != NULL) + { + return *found; + } + TRACE_WARNING("Setting item '%s' not found\r\n", item); + return NULL; + } + + /* Fallback: linear scan if the sorted index is unavailable. */ while (option_map[pos].type != TYPE_END) { if (!strcmp(item, option_map[pos].option_name)) @@ -1554,10 +1708,7 @@ bool settings_set_string_id(const char *item, const char *value, uint8_t setting settings_changed_id(settingsId); } - if (old_ptr) - { - osFreeMem(old_ptr); - } + settings_free_later(old_ptr); return true; } @@ -1614,7 +1765,7 @@ bool settings_set_u64_array_id(const char *item, const uint64_t *value, size_t l if (opt->size > 0) { opt->size = 0; - osFreeMem(*ptr); + settings_free_later(*ptr); } } @@ -1639,6 +1790,7 @@ bool settings_set_u64_array_id(const char *item, const uint64_t *value, size_t l void settings_loop() { FsFileStat stat; + settings_free_parked(false); if (fsGetFileStat(config_file_path, &stat) == NO_ERROR) { if (compareDateTime(&stat.modified, &settings_last_load)) @@ -1760,6 +1912,13 @@ void settings_load_all_certs() } } +void settings_load_client_certs_id(uint8_t settingsId) +{ + load_cert("internal.client.ca", "core.client_cert.file.ca", "core.client_cert.data.ca", settingsId); + load_cert("internal.client.crt", "core.client_cert.file.crt", "core.client_cert.data.crt", settingsId); + load_cert("internal.client.key", "core.client_cert.file.key", "core.client_cert.data.key", settingsId); +} + error_t settings_try_load_certs_id(uint8_t settingsId) { ERR_RETURN(load_cert("internal.server.ca", "core.server_cert.file.ca", "core.server_cert.data.ca", settingsId)); @@ -1774,9 +1933,7 @@ error_t settings_try_load_certs_id(uint8_t settingsId) load_cert("internal.server_tb2.key", "core.server_cert_tb2.file.key", "core.server_cert_tb2.data.key", settingsId); /* do not fail when client-role certs are missing */ - load_cert("internal.client.ca", "core.client_cert.file.ca", "core.client_cert.data.ca", settingsId); - load_cert("internal.client.crt", "core.client_cert.file.crt", "core.client_cert.data.crt", settingsId); - load_cert("internal.client.key", "core.client_cert.file.key", "core.client_cert.data.key", settingsId); + settings_load_client_certs_id(settingsId); test_boxine_ca(settingsId); diff --git a/src/str_ext.c b/src/str_ext.c new file mode 100644 index 000000000..d91f9934b --- /dev/null +++ b/src/str_ext.c @@ -0,0 +1,122 @@ +#include +#include +#include + +#include "str_ext.h" + +size_t hexEncode(const uint8_t *data, size_t len, char *output, size_t output_size) +{ + size_t i = 0; + for (; i < len && (i + 1) * 2 < output_size; i++) + { + sprintf(&output[i * 2], "%02X", data[i]); + } + output[i * 2] = '\0'; + return i; +} + +size_t escapeString(const char *input, size_t size, char *output, size_t output_size) +{ + // Replacement sequences for special characters + const char *replacements[] = { + "\"", "\"\"", // Double quote (") + "\n", "\\n", // Newline + "\r", "\\r" // Carriage return + }; + const size_t num_replacements = sizeof(replacements) / sizeof(replacements[0]); + + size_t i = 0; + size_t j = 0; + // a character expands to at most 2 bytes + for (; i < size && j + 2 < output_size; i++) + { + bool replaced = false; + for (size_t k = 0; k < num_replacements; k += 2) + { + if (input[i] == replacements[k][0]) + { + size_t len = strlen(replacements[k + 1]); + memcpy(&output[j], replacements[k + 1], len); + j += len; + replaced = true; + break; + } + } + + if (!replaced) + { + output[j++] = isalnum((unsigned char)input[i]) ? input[i] : '.'; + } + } + + // Null-terminate the escaped string + output[j] = '\0'; + return i; +} + +bool split_url(const char *location, char *uri_base, char *uri_path, char *query_string, size_t buf_size) +{ + if (buf_size == 0) + { + return false; + } + + const char *scheme_end = strstr(location, "://"); + if (!scheme_end) + { + return false; + } + // Move pointer to start after "://" + scheme_end += 3; + + const char *path_start = strchr(scheme_end, '/'); + if (!path_start) + { + return false; + } + const char *query_start = strchr(path_start, '?'); + + // Base URI without scheme + size_t base_len = path_start - scheme_end; + // Path runs up to the query string (if any) or the end of the location + size_t path_len = query_start ? (size_t)(query_start - path_start) : strlen(path_start); + // Query string follows the '?' + size_t query_len = query_start ? strlen(query_start + 1) : 0; + + if (base_len >= buf_size || path_len >= buf_size || query_len >= buf_size) + { + return false; + } + + memcpy(uri_base, scheme_end, base_len); + uri_base[base_len] = '\0'; + + memcpy(uri_path, path_start, path_len); + uri_path[path_len] = '\0'; + + if (query_len > 0) + { + memcpy(query_string, query_start + 1, query_len); + } + query_string[query_len] = '\0'; + + return true; +} + +bool ipv4_is_public(const uint8_t ip[4]) +{ + uint8_t a = ip[0], b = ip[1], c = ip[2]; + return !(a == 0 /* 0.0.0.0/8 this network */ + || a == 10 /* 10.0.0.0/8 private */ + || (a == 100 && (b & 0xC0) == 64) /* 100.64.0.0/10 shared address space */ + || a == 127 /* 127.0.0.0/8 loopback */ + || (a == 169 && b == 254) /* 169.254.0.0/16 link-local, cloud metadata */ + || (a == 172 && (b & 0xF0) == 16) /* 172.16.0.0/12 private */ + || (a == 192 && b == 0 && c == 0) /* 192.0.0.0/24 IETF protocol assignments */ + || (a == 192 && b == 0 && c == 2) /* 192.0.2.0/24 documentation */ + || (a == 192 && b == 168) /* 192.168.0.0/16 private */ + || (a == 198 && (b & 0xFE) == 18) /* 198.18.0.0/15 benchmarking */ + || (a == 198 && b == 51 && c == 100) /* 198.51.100.0/24 documentation */ + || (a == 203 && b == 0 && c == 113) /* 203.0.113.0/24 documentation */ + || a >= 224); /* multicast, reserved, broadcast */ +} diff --git a/src/tls_adapter.c b/src/tls_adapter.c index e90369e86..5f0253d21 100644 --- a/src/tls_adapter.c +++ b/src/tls_adapter.c @@ -15,8 +15,10 @@ #include "fs_ext.h" // for fsOpenFileEx #include "fs_port.h" // for FS_FILE_MODE_READ #include "fs_port_posix.h" // for fsReadFile, fsCloseFile, fsOpen... +#include "hash/sha256.h" // for sha256Compute #include "os_port.h" // for osFreeMem, osAllocMem, osMemset #include "pem_common.h" // for pemEncodeFile +#include "pkix/pem_import.h" // for pemImportCertificate #include "pkix/x509_cert_parse.h" // for x509ParseCertificate #include "pkix/x509_cert_validate.h" // for x509CheckNameConstraints, x509C... #include "settings.h" // for settings_get_string_id, setting... @@ -449,6 +451,29 @@ static void copyAsString(char *dst, size_t dstLen, size_t srcLen, const char_t * dst[len] = '\0'; } +/* Box certificates of TeddyCloud are issued directly by one of its CAs. */ +static bool issued_by(const X509CertInfo *cert, const char_t *caPem) +{ + size_t pemLength = caPem ? osStrlen(caPem) : 0; + size_t derLength = 0; + + if (pemLength == 0 || pemImportCertificate(caPem, pemLength, NULL, &derLength, NULL) != NO_ERROR) + { + return false; + } + + uint8_t *der = osAllocMem(derLength); + X509CertInfo *ca = osAllocMem(sizeof(X509CertInfo)); + bool issued = der != NULL && ca != NULL && + pemImportCertificate(caPem, pemLength, der, &derLength, NULL) == NO_ERROR && + x509ParseCertificate(der, derLength, ca) == NO_ERROR && + x509ValidateCertificate(cert, ca, 0) == NO_ERROR; + + osFreeMem(ca); + osFreeMem(der); + return issued; +} + static void copyAsHex(char *dst, size_t dstLen, size_t payloadLen, const uint8_t *payloadData) { if (payloadLen == 0 || dstLen == 0) @@ -472,6 +497,34 @@ static void copyAsHex(char *dst, size_t dstLen, size_t payloadLen, const uint8_t dst[maxHexBytes * 2] = '\0'; } +bool tls_cert_fingerprint(const char_t *pem, char_t sha256[65], char_t *subject, size_t subjectSize) +{ + size_t pemLength = pem ? osStrlen(pem) : 0; + size_t derLength = 0; + + if (pemLength == 0 || pemImportCertificate(pem, pemLength, NULL, &derLength, NULL) != NO_ERROR) + { + return false; + } + + uint8_t *der = osAllocMem(derLength); + X509CertInfo *cert = osAllocMem(sizeof(X509CertInfo)); + bool parsed = der != NULL && cert != NULL && + pemImportCertificate(pem, pemLength, der, &derLength, NULL) == NO_ERROR && + x509ParseCertificate(der, derLength, cert) == NO_ERROR; + if (parsed) + { + uint8_t digest[SHA256_DIGEST_SIZE]; + sha256Compute(der, derLength, digest); + copyAsHex(sha256, 65, sizeof(digest), digest); + copyAsString(subject, subjectSize, cert->tbsCert.subject.commonName.length, cert->tbsCert.subject.commonName.value); + } + + osFreeMem(cert); + osFreeMem(der); + return parsed; +} + /** * @brief Parse certificate chain * @param[in] context Pointer to the TLS context @@ -626,6 +679,11 @@ error_t tlsParseCertificateList(TlsContext *context, copyAsString(context->client_cert_issuer, sizeof(context->client_cert_issuer), certInfo->tbsCert.issuer.commonName.length, certInfo->tbsCert.issuer.commonName.value); copyAsString(context->client_cert_subject, sizeof(context->client_cert_subject), certInfo->tbsCert.subject.commonName.length, certInfo->tbsCert.subject.commonName.value); copyAsHex(context->client_cert_serial, sizeof(context->client_cert_serial), certInfo->tbsCert.serialNumber.length, certInfo->tbsCert.serialNumber.value); + uint8_t sha256[SHA256_DIGEST_SIZE]; + sha256Compute(p, n, sha256); + copyAsHex(context->client_cert_sha256, sizeof(context->client_cert_sha256), sizeof(sha256), sha256); + context->client_cert_trusted = issued_by(certInfo, settings_get_string("internal.server.ca")) || + issued_by(certInfo, settings_get_string("internal.server_tb2.ca")); } // Check if the end-user certificate can be matched with a trusted CA @@ -716,7 +774,10 @@ error_t tlsParseCertificateList(TlsContext *context, error = x509CheckNameConstraints(subjectName, issuerCertInfo); // Should the application reject the certificate? if (error) - return ERROR_BAD_CERTIFICATE; + { + error = ERROR_BAD_CERTIFICATE; + break; + } // Check the version of the certificate if (issuerCertInfo->tbsCert.version < X509_VERSION_3) diff --git a/src/tonie_audio_playlist.c b/src/tonie_audio_playlist.c index 726ab7bc3..f36133caf 100644 --- a/src/tonie_audio_playlist.c +++ b/src/tonie_audio_playlist.c @@ -1,12 +1,17 @@ #include "tonie_audio_playlist.h" #include "fs_port.h" +#include "str.h" #include "toniefile.h" #include "server_helpers.h" #include "cJSON.h" #include "json_helper.h" #include "handler.h" +/* Upper bound on playlist entries, matching the fixed source array the + ffmpeg encoder (ffmpeg_stream/ffmpeg_convert) operates on. */ +#define TAP_MAX_FILES 99 + bool_t is_valid_tap_file(char *filename) { @@ -45,7 +50,10 @@ error_t tap_load(char *filename, tonie_audio_playlist_t *tap) while (pos < fileSize) { - fsReadFile(fsFile, &data[pos], fileSize - pos, &sizeRead); + if (fsReadFile(fsFile, &data[pos], fileSize - pos, &sizeRead) != NO_ERROR || sizeRead == 0) + { + break; + } pos += sizeRead; } fsCloseFile(fsFile); @@ -72,13 +80,24 @@ error_t tap_load(char *filename, tonie_audio_playlist_t *tap) tap->name = jsonGetString(tapJson, "name"); const cJSON *filesJson = cJSON_GetObjectItemCaseSensitive(tapJson, "files"); tap->filesCount = cJSON_GetArraySize(filesJson); + /* The downstream encoder works on a fixed char[TAP_MAX_FILES][PATH_LEN] + array, so never load more entries than it can process. */ + if (tap->filesCount > TAP_MAX_FILES) + { + TRACE_WARNING("TAP has %" PRIuSIZE " files, truncating to %d\r\n", tap->filesCount, TAP_MAX_FILES); + tap->filesCount = TAP_MAX_FILES; + } if (tap->filesCount > 0) { tap->files = osAllocMem(tap->filesCount * sizeof(tap_file_t)); - uint8_t i = 0; + size_t i = 0; cJSON *fileJson; cJSON_ArrayForEach(fileJson, filesJson) { + if (i >= tap->filesCount) + { + break; + } tap->files[i].filepath = jsonGetString(fileJson, "filepath"); tap->files[i]._filepath_resolved = strdup(tap->files[i].filepath); resolveSpecialPathPrefix(&tap->files[i]._filepath_resolved, get_settings()); @@ -149,6 +168,7 @@ void tap_free(tonie_audio_playlist_t *tap) for (size_t i = 0; i < tap->filesCount; i++) { osFreeMem(tap->files[i].filepath); + osFreeMem(tap->files[i]._filepath_resolved); osFreeMem(tap->files[i].name); } if (tap->filesCount > 0) @@ -186,8 +206,8 @@ error_t tap_generate_taf(tonie_audio_playlist_t *tap, size_t *current_source, bo if (force || !tonieInfo->valid || tonieInfo->tafHeader->audio_id != tap->audio_id) { char *tmp_taf = custom_asprintf("%s.tmp", tap->_filepath_resolved); - char source[99][PATH_LEN]; - if (tap->filesCount == 0) + char source[TAP_MAX_FILES][PATH_LEN]; + if (tap->filesCount == 0 || tap->filesCount > TAP_MAX_FILES) { osFreeMem(tmp_taf); freeTonieInfo(tonieInfo); @@ -196,7 +216,7 @@ error_t tap_generate_taf(tonie_audio_playlist_t *tap, size_t *current_source, bo for (size_t i = 0; i < tap->filesCount; i++) { - osStrcpy(source[i], tap->files[i]._filepath_resolved); + strSafeCopy(source[i], tap->files[i]._filepath_resolved, PATH_LEN); } // toniefile_t *taf = toniefile_create(tmp_taf, tap->audio_id, false, 0); error = ffmpeg_stream(source, tap->filesCount, current_source, tmp_taf, 0, active, &sweep, false, false); diff --git a/src/toniefile.c b/src/toniefile.c index 9f7904e90..8fff5b1bf 100644 --- a/src/toniefile.c +++ b/src/toniefile.c @@ -125,13 +125,25 @@ toniefile_t *toniefile_create(const char *fullPath, uint32_t audio_id, bool appe ctx->file = fsOpenFileEx(fullPath, "r+"); TRACE_INFO("Append to TAF: %s\n", fullPath); - char buffer[TONIEFILE_FRAME_SIZE]; - size_t read_length = 0; - fsSeekFile(ctx->file, 4, SEEK_SET); - fsReadFile(ctx->file, buffer, TONIEFILE_FRAME_SIZE - 4, &read_length); - tafHeader = toniebox_audio_file_header__unpack(NULL, read_length, (uint8_t *)buffer); - audio_id = tafHeader->audio_id; - ctx->taf.audio_id = audio_id; + if (ctx->file != NULL) + { + char buffer[TONIEFILE_FRAME_SIZE]; + size_t read_length = 0; + fsSeekFile(ctx->file, 4, SEEK_SET); + fsReadFile(ctx->file, buffer, TONIEFILE_FRAME_SIZE - 4, &read_length); + tafHeader = toniebox_audio_file_header__unpack(NULL, read_length, (uint8_t *)buffer); + if (tafHeader == NULL) + { + TRACE_ERROR("Cannot parse existing TAF header, refusing to append: %s\n", fullPath); + fsCloseFile(ctx->file); + osFreeMem(ctx->taf.track_page_nums); + osFreeMem(ctx); + return NULL; + } + audio_id = tafHeader->audio_id; + ctx->taf.audio_id = audio_id; + toniebox_audio_file_header__free_unpacked(tafHeader, NULL); + } } else { @@ -590,13 +602,32 @@ FILE *ffmpeg_decode_audio_start_skip(const char *input_source, size_t skip_secon // Construct the FFmpeg command based on the input source char ffmpeg_command[1024]; // Adjust the buffer size as needed + + /* input_source is attacker-controllable (content source URL/path), so it + must be shell-quoted - never interpolated raw into the command line. */ + char quoted_source[sizeof(ffmpeg_command)]; + if (!osShellQuote(quoted_source, sizeof(quoted_source), input_source)) + { + TRACE_ERROR("ffmpeg source cannot be passed to the shell: %s\r\n", input_source); + return NULL; + } + + int written; if (skip_bytes == 0) { - snprintf(ffmpeg_command, sizeof(ffmpeg_command), "ffmpeg -i \"%s\" -f s16le -acodec pcm_s16le -ar 48000 -ac 2 -ss %" PRIuSIZE " -", input_source, skip_seconds); + written = snprintf(ffmpeg_command, sizeof(ffmpeg_command), "ffmpeg -i %s -f s16le -acodec pcm_s16le -ar 48000 -ac 2 -ss %" PRIuSIZE " -", quoted_source, skip_seconds); } else { - snprintf(ffmpeg_command, sizeof(ffmpeg_command), "tail -c +%" PRIuSIZE " \"%s\" | ffmpeg -i - -f s16le -acodec pcm_s16le -ar 48000 -ac 2 -ss %" PRIuSIZE " -", skip_bytes + 1, input_source, skip_seconds); + written = snprintf(ffmpeg_command, sizeof(ffmpeg_command), "tail -c +%" PRIuSIZE " %s | ffmpeg -i - -f s16le -acodec pcm_s16le -ar 48000 -ac 2 -ss %" PRIuSIZE " -", skip_bytes + 1, quoted_source, skip_seconds); + } + + /* bail out on truncation so a partially built (and potentially + quote-unbalanced) command is never executed */ + if (written < 0 || (size_t)written >= sizeof(ffmpeg_command)) + { + TRACE_ERROR("ffmpeg command too long for source\r\n"); + return NULL; } TRACE_INFO("FFmpeg command: %s\r\n", ffmpeg_command); @@ -770,6 +801,7 @@ error_t ffmpeg_stream(char source[99][PATH_LEN], size_t source_len, size_t *curr if (*current_source < source_len) { error = ffmpeg_decode_audio_end(ffmpeg_pipe, error); + ffmpeg_pipe = NULL; if (error != NO_ERROR) { TRACE_ERROR("Could not close FFmpeg pipe error=%s\r\n", error2text(error)); diff --git a/src/toniesJson.c b/src/toniesJson.c index a7ac9afdf..4ba797c59 100644 --- a/src/toniesJson.c +++ b/src/toniesJson.c @@ -71,6 +71,11 @@ void tonies_downloadBody(void *src_ctx, HttpClientContext *cloud_ctx, const char if (ctx->file == NULL) { ctx->file = fsOpenFile(tonies_json_tmp_path, FS_FILE_MODE_WRITE | FS_FILE_MODE_TRUNC); + if (ctx->file == NULL) + { + TRACE_ERROR("Failed to open %s\r\n", tonies_json_tmp_path); + return; + } } error_t errorWrite = NO_ERROR; if (length > 0) @@ -78,20 +83,27 @@ void tonies_downloadBody(void *src_ctx, HttpClientContext *cloud_ctx, const char errorWrite = fsWriteFile(ctx->file, (void *)payload, length); } + bool_t finished = false; if (error == ERROR_END_OF_STREAM) { - fsCloseFile(ctx->file); + finished = true; } else if (error != NO_ERROR) { - fsCloseFile(ctx->file); + finished = true; TRACE_ERROR("tonies.json download body error=%s\r\n", error2text(error)); } if (errorWrite != NO_ERROR) { - fsCloseFile(ctx->file); + finished = true; TRACE_ERROR("tonies.json (%s) write error=%s\r\n", tonies_json_tmp_path, error2text(error)); } + + if (finished) + { + fsCloseFile(ctx->file); + ctx->file = NULL; + } } } @@ -183,6 +195,11 @@ void tonieboxes_downloadBody(void *src_ctx, HttpClientContext *cloud_ctx, const ctx->file = fsOpenFile(target_tmp, FS_FILE_MODE_WRITE | FS_FILE_MODE_TRUNC); osFreeMem(target); osFreeMem(target_tmp); + if (ctx->file == NULL) + { + TRACE_ERROR("Failed to open tonieboxes.json temp file\r\n"); + return; + } } error_t errorWrite = NO_ERROR; if (length > 0) @@ -190,20 +207,27 @@ void tonieboxes_downloadBody(void *src_ctx, HttpClientContext *cloud_ctx, const errorWrite = fsWriteFile(ctx->file, (void *)payload, length); } + bool_t finished = false; if (error == ERROR_END_OF_STREAM) { - fsCloseFile(ctx->file); + finished = true; } else if (error != NO_ERROR) { - fsCloseFile(ctx->file); + finished = true; TRACE_ERROR("tonieboxes.json download body error=%s\r\n", error2text(error)); } if (errorWrite != NO_ERROR) { - fsCloseFile(ctx->file); + finished = true; TRACE_ERROR("tonieboxes.json (%s) write error=%s\r\n", tonies_json_tmp_path, error2text(error)); } + + if (finished) + { + fsCloseFile(ctx->file); + ctx->file = NULL; + } } } error_t tonieboxes_update() @@ -288,7 +312,10 @@ void tonies_readJson(char *source, toniesJson_item_t **retCache, size_t *retCoun while (pos < fileSize) { - fsReadFile(fsFile, &data[pos], fileSize - pos, &sizeRead); + if (fsReadFile(fsFile, &data[pos], fileSize - pos, &sizeRead) != NO_ERROR || sizeRead == 0) + { + break; + } pos += sizeRead; } fsCloseFile(fsFile); @@ -362,10 +389,18 @@ void tonies_readJson(char *source, toniesJson_item_t **retCache, size_t *retCoun if (item->tracks_count > 0) { item->tracks = osAllocMem(item->tracks_count * sizeof(char *)); - uint8_t i = 0; + size_t i = 0; const cJSON *track; cJSON_ArrayForEach(track, tracks) { + /* tracks_count is a uint8_t and was assigned from a + (possibly larger, truncated) array size; never write + past the allocation, and use a size_t index so it + cannot wrap. */ + if (i >= item->tracks_count) + { + break; + } if (track != NULL && cJSON_IsString(track) && track->valuestring != NULL) { item->tracks[i++] = strdup(track->valuestring); diff --git a/src/web.c b/src/web.c index ad61002bd..b194ad8d2 100644 --- a/src/web.c +++ b/src/web.c @@ -124,26 +124,39 @@ void web_dl_cbr(void *src_ctx, HttpClientContext *cloud_ctx, const char *payload return; } } + if (ctx->file == NULL) + { + /* open failed earlier or file already closed */ + return; + } + error_t errorWrite = NO_ERROR; if (length > 0) { errorWrite = fsWriteFile(ctx->file, (void *)payload, length); } + bool_t finished = false; if (error == ERROR_END_OF_STREAM) { - fsCloseFile(ctx->file); + finished = true; } else if (error != NO_ERROR) { - fsCloseFile(ctx->file); + finished = true; TRACE_ERROR("body error=%s\r\n", error2text(error)); } if (errorWrite != NO_ERROR) { - fsCloseFile(ctx->file); + finished = true; TRACE_ERROR("write error=%s\r\n", error2text(error)); } + + if (finished) + { + fsCloseFile(ctx->file); + ctx->file = NULL; + } } } @@ -183,14 +196,18 @@ error_t web_download(const char *url, const char *filename, uint32_t *statusCode return error; } - if (fsFileExists(filename)) + /* The body is only written on HTTP 200; without this check a non-200 + response would be reported as success whenever a stale file from an + earlier download happens to still exist. */ + if (*statusCode != 200) { - return NO_ERROR; + TRACE_ERROR("download failed, HTTP status=%" PRIu32 "\r\n", *statusCode); + return (*statusCode == 404) ? ERROR_NOT_FOUND : ERROR_FAILURE; } - if (*statusCode == 404) + if (fsFileExists(filename)) { - return ERROR_NOT_FOUND; + return NO_ERROR; } return ERROR_FAILURE; diff --git a/src/zip_writer.c b/src/zip_writer.c new file mode 100644 index 000000000..6ece1fd61 --- /dev/null +++ b/src/zip_writer.c @@ -0,0 +1,226 @@ +#include +#include +#include +#include +#include + +#include "zip_writer.h" + +#define ZIP_LOCAL_HEADER_SIG 0x04034b50u +#define ZIP_CENTRAL_HEADER_SIG 0x02014b50u +#define ZIP_END_OF_CENTRAL_DIR_SIG 0x06054b50u +#define ZIP_VERSION 20 +#define ZIP_FLAG_UTF8 0x0800 +#define ZIP_METHOD_STORED 0 + +typedef struct +{ + uint32_t crc; + uint32_t size; + uint32_t offset; + size_t name_len; +} zip_entry_t; + +static uint32_t crc32_update(uint32_t crc, const uint8_t *data, size_t len) +{ + crc = ~crc; + for (size_t i = 0; i < len; i++) + { + crc ^= data[i]; + for (int bit = 0; bit < 8; bit++) + { + crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); + } + } + return ~crc; +} + +static void put16(FILE *f, uint16_t v) +{ + uint8_t b[2] = {(uint8_t)v, (uint8_t)(v >> 8)}; + fwrite(b, 1, sizeof(b), f); +} + +static void put32(FILE *f, uint32_t v) +{ + uint8_t b[4] = {(uint8_t)v, (uint8_t)(v >> 8), (uint8_t)(v >> 16), (uint8_t)(v >> 24)}; + fwrite(b, 1, sizeof(b), f); +} + +static void dos_time(uint16_t *dos_date, uint16_t *dos_time_out) +{ + time_t now = time(NULL); + struct tm *t = localtime(&now); + + if (t == NULL || t->tm_year + 1900 < 1980) + { + *dos_date = (1 << 5) | 1; + *dos_time_out = 0; + return; + } + *dos_date = (uint16_t)(((t->tm_year + 1900 - 1980) << 9) | ((t->tm_mon + 1) << 5) | t->tm_mday); + *dos_time_out = (uint16_t)((t->tm_hour << 11) | (t->tm_min << 5) | (t->tm_sec / 2)); +} + +/* the local header needs crc and size, so read the file once before writing it */ +static int measure_file(const char *path, uint32_t *crc, uint32_t *size) +{ + uint8_t buf[8192]; + uint64_t total = 0; + size_t n; + FILE *f = fopen(path, "rb"); + + if (f == NULL) + { + return -1; + } + *crc = 0; + while ((n = fread(buf, 1, sizeof(buf), f)) > 0) + { + *crc = crc32_update(*crc, buf, n); + total += n; + } + if (ferror(f) || total > 0xFFFFFFFFu) + { + fclose(f); + return -1; + } + fclose(f); + *size = (uint32_t)total; + return 0; +} + +static int copy_file(const char *path, FILE *out) +{ + uint8_t buf[8192]; + size_t n; + FILE *f = fopen(path, "rb"); + + if (f == NULL) + { + return -1; + } + while ((n = fread(buf, 1, sizeof(buf), f)) > 0) + { + if (fwrite(buf, 1, n, out) != n) + { + fclose(f); + return -1; + } + } + fclose(f); + return 0; +} + +error_t zipWriteStored(const char *zip_path, size_t count, const char *const *file_paths, const char *const *entry_names) +{ + zip_entry_t *entries = NULL; + uint64_t offset = 0; + uint64_t central_start; + uint64_t central_size = 0; + uint16_t date, time_of_day; + FILE *out = NULL; + error_t error = ERROR_FAILURE; + + if (count == 0 || count > 0xFFFF) + { + return ERROR_FAILURE; + } + entries = calloc(count, sizeof(zip_entry_t)); + out = fopen(zip_path, "wb"); + if (entries == NULL || out == NULL) + { + goto done; + } + dos_time(&date, &time_of_day); + + for (size_t i = 0; i < count; i++) + { + zip_entry_t *e = &entries[i]; + + e->name_len = strlen(entry_names[i]); + if (e->name_len == 0 || e->name_len > 0xFFFF || measure_file(file_paths[i], &e->crc, &e->size) != 0) + { + goto done; + } + e->offset = (uint32_t)offset; + + put32(out, ZIP_LOCAL_HEADER_SIG); + put16(out, ZIP_VERSION); + put16(out, ZIP_FLAG_UTF8); + put16(out, ZIP_METHOD_STORED); + put16(out, time_of_day); + put16(out, date); + put32(out, e->crc); + put32(out, e->size); + put32(out, e->size); + put16(out, (uint16_t)e->name_len); + put16(out, 0); + fwrite(entry_names[i], 1, e->name_len, out); + if (copy_file(file_paths[i], out) != 0) + { + goto done; + } + offset += 30 + e->name_len + e->size; + if (offset > 0xFFFFFFFFu) + { + goto done; + } + } + + central_start = offset; + for (size_t i = 0; i < count; i++) + { + const zip_entry_t *e = &entries[i]; + + put32(out, ZIP_CENTRAL_HEADER_SIG); + put16(out, ZIP_VERSION); + put16(out, ZIP_VERSION); + put16(out, ZIP_FLAG_UTF8); + put16(out, ZIP_METHOD_STORED); + put16(out, time_of_day); + put16(out, date); + put32(out, e->crc); + put32(out, e->size); + put32(out, e->size); + put16(out, (uint16_t)e->name_len); + put16(out, 0); /* extra field */ + put16(out, 0); /* comment */ + put16(out, 0); /* disk number */ + put16(out, 0); /* internal attributes */ + put32(out, 0); /* external attributes */ + put32(out, e->offset); + fwrite(entry_names[i], 1, e->name_len, out); + central_size += 46 + e->name_len; + } + if (central_start + central_size > 0xFFFFFFFFu) + { + goto done; + } + + put32(out, ZIP_END_OF_CENTRAL_DIR_SIG); + put16(out, 0); + put16(out, 0); + put16(out, (uint16_t)count); + put16(out, (uint16_t)count); + put32(out, (uint32_t)central_size); + put32(out, (uint32_t)central_start); + put16(out, 0); + + if (ferror(out) == 0) + { + error = NO_ERROR; + } + +done: + if (out != NULL && fclose(out) != 0) + { + error = ERROR_FAILURE; + } + if (error != NO_ERROR) + { + remove(zip_path); + } + free(entries); + return error; +} diff --git a/teddycloud_web b/teddycloud_web index e2f40c2c6..e7512b208 160000 --- a/teddycloud_web +++ b/teddycloud_web @@ -1 +1 @@ -Subproject commit e2f40c2c69a3b2b46093ddc5542773dd0fa0067e +Subproject commit e7512b208d1ef205e86f387d1c54d0c0a3f46ff6 diff --git a/tests/c/test_box_cert.c b/tests/c/test_box_cert.c new file mode 100644 index 000000000..b940fdef7 --- /dev/null +++ b/tests/c/test_box_cert.c @@ -0,0 +1,24 @@ +#include +#include + +#include "box_cert.h" +#include "tests.h" + +void test_box_cert_id(void) +{ + char id[BOX_CERT_ID_SIZE]; + + assert(box_cert_id("b'aabbccddeeff'", id) && !strcmp(id, "aabbccddeeff")); + assert(box_cert_id("AABBCCDDEEFF", id) && !strcmp(id, "AABBCCDDEEFF")); + assert(!box_cert_id("b'aabbccddeeff", id)); + assert(!box_cert_id("TeddyCloud Server", id)); + assert(!box_cert_id("", id)); +} + +void test_box_cert_issuer_known(void) +{ + assert(box_cert_issuer_known("Boxine Factory SubCA 13", "b'aabbccddeeff'")); + assert(box_cert_issuer_known("TeddyCloud Root CA", "AABBCCDDEEFF")); + assert(box_cert_issuer_known("", "TeddyCloud")); + assert(!box_cert_issuer_known("Some CA", "b'aabbccddeeff'")); +} diff --git a/tests/c/test_esp32_port.c b/tests/c/test_esp32_port.c new file mode 100644 index 000000000..cfb64c913 --- /dev/null +++ b/tests/c/test_esp32_port.c @@ -0,0 +1,55 @@ +#include +#include + +#include "esp32_port.h" +#include "tests.h" + +/* the connect function of v5.237.0, with the bytes outside the patterns zeroed */ +static const uint8_t function_start[] = { + 0x36, 0x21, 0x01, 0x72, 0x61, 0x19, 0x62, 0x61, 0x18, 0x25, 0xe1, 0x3c, + 0x20, 0x72, 0x20, 0x61, 0xe6, 0xac, 0x40, 0x40, 0xf4, 0xa0, 0x2a, 0x20}; +static const uint8_t function_tail[] = { + 0x82, 0xae, 0x45, 0x0c, 0x15, 0x8a, 0x84, 0x99, 0x21, 0x52, 0x41, 0x34, + 0x92, 0x21, 0x16, 0x0c, 0x15, 0x80, 0x57, 0x83, 0xa9, 0x11, 0xad, 0x03}; + +#define FUNCTION_AT 0x100 + +static void place_function(uint8_t *image, size_t size) +{ + memset(image, 0, size); + memcpy(&image[FUNCTION_AT], function_start, sizeof(function_start)); + memcpy(&image[FUNCTION_AT + 0xd2], function_tail, sizeof(function_tail)); +} + +static void assert_code(const uint8_t *image, size_t offset, uint8_t b0, uint8_t b1, uint8_t b2) +{ + const uint8_t *code = &image[FUNCTION_AT + offset]; + assert(code[0] == b0 && code[1] == b1 && code[2] == b2); +} + +void test_esp32_port_patch(void) +{ + uint8_t image[0x400], original[sizeof(image)]; + place_function(image, sizeof(image)); + memcpy(original, image, sizeof(image)); + + assert(esp32_port_patch(image, sizeof(image), 8443) == 1); + assert_code(image, 0x12, 0x42, 0xa0, 0xfb); /* movi a4, 0xfb */ + assert_code(image, 0xd2, 0x42, 0xd4, 0x20); /* addmi a4, a4, 0x2000 */ + assert_code(image, 0xe3, 0x52, 0xa0, 0x00); /* movi a5, 0 */ + + assert(esp32_port_patch(image, sizeof(image), 1443) == 1); + assert_code(image, 0x12, 0x42, 0xa0, 0xa3); + assert_code(image, 0xd2, 0x42, 0xd4, 0x05); + + assert(esp32_port_patch(image, sizeof(image), 443) == 1); + assert(memcmp(image, original, sizeof(image)) == 0); + + image[FUNCTION_AT + 0xd2 + 4] ^= 0xff; + assert(esp32_port_patch(image, sizeof(image), 8443) == 0); + + assert(!esp32_port_supported(0)); + assert(esp32_port_supported(1)); + assert(esp32_port_supported(32767)); + assert(!esp32_port_supported(32768)); +} diff --git a/tests/c/test_fs_ext.c b/tests/c/test_fs_ext.c new file mode 100644 index 000000000..45e037ee0 --- /dev/null +++ b/tests/c/test_fs_ext.c @@ -0,0 +1,26 @@ +#include +#include + +#include "error.h" +#include "fs_ext.h" +#include "tests.h" + +void test_fs_remove_filename(void) +{ + char path[] = "dir/file.bin"; + assert(fsRemoveFilename(path) == NO_ERROR); + assert(strcmp(path, "dir") == 0); + + char dir[] = "dir/"; + assert(fsRemoveFilename(dir) == NO_ERROR); + assert(strcmp(dir, "dir/") == 0); + + char bare[] = "file.bin"; + assert(fsRemoveFilename(bare) != NO_ERROR); + assert(fsRemoveFilename(NULL) != NO_ERROR); + + /* An empty string used to be indexed at [-1]. Put a separator in front of it, so the + old code mistook the empty string for a directory path and returned NO_ERROR. */ + char area[2] = {'/', '\0'}; + assert(fsRemoveFilename(&area[1]) != NO_ERROR); +} diff --git a/tests/c/test_os_ext.c b/tests/c/test_os_ext.c new file mode 100644 index 000000000..c80f93f2a --- /dev/null +++ b/tests/c/test_os_ext.c @@ -0,0 +1,79 @@ +#include +#include +#include +#include +#include + +#include "os_ext.h" +#include "tests.h" + +static void check_quote(const char *src, const char *expected) +{ + char buf[64]; + assert(osShellQuote(buf, sizeof(buf), src)); + assert(strcmp(buf, expected) == 0); +} + +/* the shell must hand the quoted argument back unchanged */ +static void check_round_trip(const char *src) +{ + char quoted[256]; + char cmd[300]; + char out[256]; + assert(osShellQuote(quoted, sizeof(quoted), src)); + snprintf(cmd, sizeof(cmd), "printf '%%s' %s", quoted); + FILE *pipe = osPopen(cmd, "r"); + assert(pipe != NULL); + size_t n = fread(out, 1, sizeof(out) - 1, pipe); + osPclose(pipe); + out[n] = '\0'; + assert(strcmp(out, src) == 0); +} + +void test_os_chmod_owner_only(void) +{ + char path[] = "/tmp/teddycloud_test_XXXXXX"; + int fd = mkstemp(path); + assert(fd >= 0); + close(fd); + assert(chmod(path, 0644) == 0); + + assert(osChmodOwnerOnly(path)); + struct stat st; + assert(stat(path, &st) == 0); + assert((st.st_mode & 0777) == 0600); + + unlink(path); + assert(!osChmodOwnerOnly(path)); +} + +void test_os_shell_quote(void) +{ + check_quote("", "''"); + check_quote("a b.mp3", "'a b.mp3'"); + check_quote("it's", "'it'\\''s'"); + + check_round_trip("/content/a b/it's.mp3"); + check_round_trip("'\"$HOME ; & | \\ '"); + + char buf[6]; + assert(osShellQuote(buf, sizeof(buf), "abc")); /* 'abc' + NUL fits exactly */ + assert(!osShellQuote(buf, sizeof(buf), "abcd")); /* one byte short */ + assert(!osShellQuote(buf, sizeof(buf), "a'b")); /* 'a'\''b' needs 9 */ + assert(!osShellQuote(buf, 2, "")); +} + +void test_os_spawnvp(void) +{ + char *argv_true[] = {"true", NULL}; + assert(osSpawnvp("true", argv_true) == 0); + + char *argv_false[] = {"false", NULL}; + assert(osSpawnvp("false", argv_false) == 1); + + char *argv_exit[] = {"sh", "-c", "exit 42", NULL}; + assert(osSpawnvp("sh", argv_exit) == 42); + + char *argv_missing[] = {"this-binary-does-not-exist-xyz", NULL}; + assert(osSpawnvp("this-binary-does-not-exist-xyz", argv_missing) == 127); +} diff --git a/tests/c/test_runner.c b/tests/c/test_runner.c new file mode 100644 index 000000000..95e96193e --- /dev/null +++ b/tests/c/test_runner.c @@ -0,0 +1,47 @@ +/* + * Runs pure C unit tests that don't need a running server (see tests/py/ + * for the HTTP-level integration tests). Each test function uses assert() + * and aborts on the first failure, which is enough for `make test_c` to + * fail loudly and point at the exact check that broke. + */ +#include + +#include "tests.h" + +typedef struct +{ + const char *name; + void (*fn)(void); +} test_case_t; + +static const test_case_t tests[] = { + {"os_spawnvp", test_os_spawnvp}, + {"os_shell_quote", test_os_shell_quote}, + {"os_chmod_owner_only", test_os_chmod_owner_only}, + {"fs_remove_filename", test_fs_remove_filename}, + {"hex_encode", test_hex_encode}, + {"escape_string", test_escape_string}, + {"split_url", test_split_url}, + {"ipv4_is_public", test_ipv4_is_public}, + {"esp32_port_patch", test_esp32_port_patch}, + {"socket_receive_line_from_full_buffer", test_socket_receive_line_from_full_buffer}, + {"socket_receive_line_capped_at_size", test_socket_receive_line_capped_at_size}, + {"box_cert_id", test_box_cert_id}, + {"box_cert_issuer_known", test_box_cert_issuer_known}, + {"zip_write_stored", test_zip_write_stored}, +}; + +int main(void) +{ + size_t count = sizeof(tests) / sizeof(tests[0]); + + for (size_t i = 0; i < count; i++) + { + printf("[ RUN ] %s\n", tests[i].name); + tests[i].fn(); + printf("[ OK ] %s\n", tests[i].name); + } + + printf("%zu C test(s) passed\n", count); + return 0; +} diff --git a/tests/c/test_socket_receive.c b/tests/c/test_socket_receive.c new file mode 100644 index 000000000..1abb8123d --- /dev/null +++ b/tests/c/test_socket_receive.c @@ -0,0 +1,71 @@ +#include +#include +#include +#include +#include + +#include "core/net.h" +#include "core/socket.h" +#include "tests.h" + +/* The HTTP server reads its request line with this size (HTTP_SERVER_BUFFER_SIZE - 1). */ +#define LINE_READ_SIZE 32767 + +static const char HEAD[] = "POST /api/fileUpload HTTP/1.1\r\n" + "Content-Length: 100000\r\n" + "\r\n"; + +/* a socket whose peer has already sent the header plus more body than one read takes */ +static Socket *socket_with_waiting_request(void) +{ + int fds[2]; + assert(socketpair(AF_UNIX, SOCK_STREAM, 0, fds) == 0); + + static char request[LINE_READ_SIZE * 2]; + memset(request, 'x', sizeof(request)); + memcpy(request, HEAD, strlen(HEAD)); + assert(write(fds[1], request, sizeof(request)) == (ssize_t)sizeof(request)); + close(fds[1]); + + Socket *socket = calloc(1, sizeof(Socket)); + assert(socket != NULL); + socket->descriptor = fds[0]; + return socket; +} + +static void expect_line(Socket *socket, size_t size, const char *expected) +{ + static char line[LINE_READ_SIZE]; + size_t received = 0; + assert(socketReceive(socket, line, size, &received, SOCKET_FLAG_BREAK_CRLF) == NO_ERROR); + assert(received == strlen(expected)); + assert(memcmp(line, expected, received) == 0); +} + +/* + * When the first read of a connection already fills the whole buffer, the + * line read must still end at the first line feed. Returning the full buffer + * instead makes the request line swallow the header and the start of the body. + */ +void test_socket_receive_line_from_full_buffer(void) +{ + Socket *socket = socket_with_waiting_request(); + + expect_line(socket, LINE_READ_SIZE, "POST /api/fileUpload HTTP/1.1\r\n"); + expect_line(socket, LINE_READ_SIZE, "Content-Length: 100000\r\n"); + expect_line(socket, LINE_READ_SIZE, "\r\n"); + + socketClose(socket); +} + +/* a buffered line end beyond the caller's size still yields only that size */ +void test_socket_receive_line_capped_at_size(void) +{ + Socket *socket = socket_with_waiting_request(); + + expect_line(socket, LINE_READ_SIZE, "POST /api/fileUpload HTTP/1.1\r\n"); + expect_line(socket, 10, "Content-Le"); + expect_line(socket, LINE_READ_SIZE, "ngth: 100000\r\n"); + + socketClose(socket); +} diff --git a/tests/c/test_str_ext.c b/tests/c/test_str_ext.c new file mode 100644 index 000000000..e52d67602 --- /dev/null +++ b/tests/c/test_str_ext.c @@ -0,0 +1,152 @@ +#include +#include +#include + +#include "str_ext.h" +#include "tests.h" + +/* buffers get CANARY_LEN guard bytes behind them to catch writes past the end */ +#define CANARY_LEN 8 + +static void assert_canary(const char *area, size_t size) +{ + for (size_t i = size; i < size + CANARY_LEN; i++) + { + assert(area[i] == '#'); + } +} + +void test_hex_encode(void) +{ + uint8_t data[1000]; + for (size_t i = 0; i < sizeof(data); i++) + { + data[i] = (uint8_t)(i * 7); + } + + char expected[2 * sizeof(data) + 1]; + for (size_t i = 0; i < sizeof(data); i++) + { + snprintf(&expected[2 * i], 3, "%02X", data[i]); + } + + /* 17 bytes fit 8 encoded bytes and the NUL; larger input comes out in chunks */ + char area[17 + CANARY_LEN]; + char joined[sizeof(expected)] = ""; + size_t pos = 0; + while (pos < sizeof(data)) + { + memset(area, '#', sizeof(area)); + size_t n = hexEncode(&data[pos], sizeof(data) - pos, area, 17); + assert(n > 0 && n <= 8); + assert(strlen(area) == 2 * n); + assert_canary(area, 17); + strcat(joined, area); + pos += n; + } + assert(strcmp(joined, expected) == 0); + + /* an odd size leaves the last byte unused instead of writing half a pair */ + memset(area, '#', sizeof(area)); + assert(hexEncode(data, sizeof(data), area, 4) == 1); + assert_canary(area, 4); +} + +void test_escape_string(void) +{ + char out[64]; + const char input[] = "a\"b\nc\rd\\e f"; + assert(escapeString(input, sizeof(input) - 1, out, sizeof(out)) == sizeof(input) - 1); + assert(strcmp(out, "a\"\"b\\nc\\rd.e.f") == 0); + + /* every '"' doubles; with 7 bytes, 3 of them fit per chunk, never a half pair */ + char quotes[50]; + memset(quotes, '"', sizeof(quotes)); + char area[7 + CANARY_LEN]; + size_t total = 0; + size_t pos = 0; + while (pos < sizeof(quotes)) + { + memset(area, '#', sizeof(area)); + size_t n = escapeString("es[pos], sizeof(quotes) - pos, area, 7); + assert(n > 0); + assert(strlen(area) == 2 * n); + assert(strspn(area, "\"") == 2 * n); + assert_canary(area, 7); + total += strlen(area); + pos += n; + } + assert(total == 2 * sizeof(quotes)); +} + +static void check_split(const char *url, const char *base, const char *path, const char *query) +{ + char b[32], p[32], q[32]; + assert(split_url(url, b, p, q, sizeof(b))); + assert(strcmp(b, base) == 0); + assert(strcmp(p, path) == 0); + assert(strcmp(q, query) == 0); +} + +void test_split_url(void) +{ + check_split("https://host.example/a/b?x=1&y=2", "host.example", "/a/b", "x=1&y=2"); + check_split("https://host/path", "host", "/path", ""); + check_split("http://h/?", "h", "/", ""); + + char b[32], p[32], q[32]; + assert(!split_url("host/path", b, p, q, sizeof(b))); /* no scheme */ + assert(!split_url("https://host", b, p, q, sizeof(b))); /* no path */ + assert(!split_url("https://h/p", b, p, q, 0)); + + /* each part may use buf_size - 1 bytes, one more is rejected */ + char area[8 + CANARY_LEN]; + memset(area, '#', sizeof(area)); + assert(split_url("https://1234567/x", area, p, q, 8)); + assert(strcmp(area, "1234567") == 0); + assert_canary(area, 8); + assert(!split_url("https://12345678/x", b, p, q, 8)); + assert(!split_url("https://h/1234567?q", b, p, q, 8)); + assert(!split_url("https://h/p?12345678", b, p, q, 8)); +} + +static bool is_public(uint8_t a, uint8_t b, uint8_t c, uint8_t d) +{ + const uint8_t ip[4] = {a, b, c, d}; + return ipv4_is_public(ip); +} + +void test_ipv4_is_public(void) +{ + /* public */ + assert(is_public(8, 8, 8, 8)); + assert(is_public(1, 1, 1, 1)); + assert(is_public(172, 15, 255, 255)); /* just below 172.16/12 */ + assert(is_public(172, 32, 0, 1)); /* just above */ + assert(is_public(100, 63, 255, 255)); /* just below 100.64/10 */ + assert(is_public(100, 128, 0, 1)); /* just above */ + assert(is_public(198, 17, 0, 1)); + assert(is_public(198, 20, 0, 1)); + assert(is_public(223, 255, 255, 255)); + + /* not public */ + assert(!is_public(0, 0, 0, 0)); + assert(!is_public(10, 1, 2, 3)); + assert(!is_public(100, 64, 0, 1)); + assert(!is_public(100, 127, 255, 255)); + assert(!is_public(127, 0, 0, 1)); + assert(!is_public(127, 255, 255, 254)); + assert(!is_public(169, 254, 169, 254)); /* cloud metadata */ + assert(!is_public(172, 16, 0, 1)); + assert(!is_public(172, 31, 255, 255)); + assert(!is_public(192, 0, 0, 1)); + assert(!is_public(192, 0, 2, 1)); + assert(!is_public(192, 168, 1, 1)); + assert(!is_public(198, 18, 0, 1)); + assert(!is_public(198, 19, 255, 255)); + assert(!is_public(198, 51, 100, 7)); + assert(!is_public(203, 0, 113, 9)); + assert(!is_public(224, 0, 0, 1)); + assert(!is_public(240, 0, 0, 1)); + assert(!is_public(255, 255, 255, 255)); +} diff --git a/tests/c/test_zip_writer.c b/tests/c/test_zip_writer.c new file mode 100644 index 000000000..655e99976 --- /dev/null +++ b/tests/c/test_zip_writer.c @@ -0,0 +1,77 @@ +#include +#include +#include +#include + +#include "tests.h" +#include "zip_writer.h" + +static uint32_t rd16(const uint8_t *p) +{ + return (uint32_t)p[0] | ((uint32_t)p[1] << 8); +} + +static uint32_t rd32(const uint8_t *p) +{ + return rd16(p) | (rd16(p + 2) << 16); +} + +static void write_file(const char *path, const char *data) +{ + FILE *f = fopen(path, "wb"); + assert(f != NULL); + assert(fwrite(data, 1, strlen(data), f) == strlen(data)); + fclose(f); +} + +void test_zip_write_stored(void) +{ + const char *zip_path = "bin/test_zip_writer.zip"; + const char *files[] = {"bin/test_zip_writer_a.bin", "bin/test_zip_writer_b.bin"}; + /* typographic quotes as in teddycloud#552 */ + const char *names[] = {"01 Intro.ogg", "09 3. Satz: \xE2\x80\x9EAllegro\xE2\x80\x9D.ogg"}; + uint8_t buf[1024]; + + write_file(files[0], "123456789"); + write_file(files[1], ""); + assert(zipWriteStored(zip_path, 2, files, names) == NO_ERROR); + + FILE *f = fopen(zip_path, "rb"); + assert(f != NULL); + size_t len = fread(buf, 1, sizeof(buf), f); + fclose(f); + + /* first local header: stored, UTF-8 flag, crc32("123456789"), sizes, name, data */ + assert(rd32(buf) == 0x04034b50u); + assert(rd16(buf + 6) == 0x0800); + assert(rd16(buf + 8) == 0); + assert(rd32(buf + 14) == 0xCBF43926u); + assert(rd32(buf + 18) == 9 && rd32(buf + 22) == 9); + size_t name0 = strlen(names[0]); + assert(rd16(buf + 26) == name0); + assert(memcmp(buf + 30, names[0], name0) == 0); + assert(memcmp(buf + 30 + name0, "123456789", 9) == 0); + + /* end of central directory: two entries, UTF-8 name preserved byte for byte */ + const uint8_t *eocd = buf + len - 22; + assert(rd32(eocd) == 0x06054b50u); + assert(rd16(eocd + 10) == 2); + size_t name1 = strlen(names[1]); + int found = 0; + for (size_t i = 0; i + name1 <= len; i++) + { + found |= memcmp(buf + i, names[1], name1) == 0; + } + assert(found); + const uint8_t *central = buf + rd32(eocd + 16); + assert(rd32(central) == 0x02014b50u); + assert(rd16(central + 8) == 0x0800); + + /* a missing source file fails and leaves no partial archive */ + const char *missing[] = {"bin/does_not_exist.bin"}; + assert(zipWriteStored(zip_path, 1, missing, names) != NO_ERROR); + assert(fopen(zip_path, "rb") == NULL); + + remove(files[0]); + remove(files[1]); +} diff --git a/tests/c/tests.h b/tests/c/tests.h new file mode 100644 index 000000000..13e7f8d26 --- /dev/null +++ b/tests/c/tests.h @@ -0,0 +1,17 @@ +#pragma once + +/* Each test_*.c file defines one of these and registers it in test_runner.c. */ +void test_os_spawnvp(void); +void test_os_shell_quote(void); +void test_os_chmod_owner_only(void); +void test_fs_remove_filename(void); +void test_hex_encode(void); +void test_escape_string(void); +void test_split_url(void); +void test_ipv4_is_public(void); +void test_esp32_port_patch(void); +void test_socket_receive_line_from_full_buffer(void); +void test_socket_receive_line_capped_at_size(void); +void test_box_cert_id(void); +void test_box_cert_issuer_known(void); +void test_zip_write_stored(void); diff --git a/tests/requests.py b/tests/py/requests.py similarity index 100% rename from tests/requests.py rename to tests/py/requests.py diff --git a/tests/py/run_auth_pool_reuse.sh b/tests/py/run_auth_pool_reuse.sh new file mode 100755 index 000000000..9a18e1243 --- /dev/null +++ b/tests/py/run_auth_pool_reuse.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# +# Generates a box client certificate signed by the running test server's own +# CA and runs the pooled-connection auth regression test with it. Meant to be +# run through with_server.sh, which provides the server and the ports. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cert_dir="$(mktemp -d)" +trap 'rm -rf "$cert_dir"' EXIT INT TERM + +"$REPO_ROOT/bin/teddycloud" --base_path "${TC_SANDBOX:-$REPO_ROOT}" --generate-client-cert deadbeef0001 --destination "$cert_dir" >/dev/null +openssl x509 -inform der -in "$cert_dir/client.der" -outform pem -out "$cert_dir/client.pem" +openssl rsa -inform der -in "$cert_dir/private.der" -outform pem -out "$cert_dir/private.pem" 2>/dev/null + +TEDDYCLOUD_CLIENT_CERT="$cert_dir/client.pem" \ +TEDDYCLOUD_CLIENT_KEY="$cert_dir/private.pem" \ + python3 "$REPO_ROOT/tests/py/test_auth_pool_reuse.py" diff --git a/tests/py/test_auth_pool_reuse.py b/tests/py/test_auth_pool_reuse.py new file mode 100644 index 000000000..dcdba1ec6 --- /dev/null +++ b/tests/py/test_auth_pool_reuse.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +Regression test for the pooled HTTPS connection reuse leaking client +certificate authentication (`connection->private.authenticated`) from one +client to another. + +Recommended usage: + +1) Fully automated via Makefile (build + start server + generate a box + certificate + run the test + stop server): + make test_auth_pool_reuse_with_server + +2) Against an already running TeddyCloud server, given a box client + certificate signed by that server's own CA (see + `--generate-client-cert --destination `): + TEDDYCLOUD_HTTPS_API_PORT=443 \ + TEDDYCLOUD_CLIENT_CERT=/path/client.pem \ + TEDDYCLOUD_CLIENT_KEY=/path/private.pem \ + python3 tests/py/test_auth_pool_reuse.py +""" + +import concurrent.futures +import os +import socket +import ssl +import time +import unittest + +HOST = os.environ.get("TEDDYCLOUD_HOST", "127.0.0.1") +HTTPS_API_PORT = int(os.environ.get("TEDDYCLOUD_HTTPS_API_PORT", "18454")) +CLIENT_CERT = os.environ.get("TEDDYCLOUD_CLIENT_CERT") +CLIENT_KEY = os.environ.get("TEDDYCLOUD_CLIENT_KEY") + +PATH = "/v1/time" +# Matches the informal measurement that first found this bug: 10 +# certificate-authenticated connections leave enough pooled slots +# `authenticated`, then 16 concurrent certificate-less requests land on +# some of them. +AUTH_CONNECTIONS = 10 +NOAUTH_CONCURRENT = 16 +ROUNDS = 5 + + +class AuthPoolReuseTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not CLIENT_CERT or not CLIENT_KEY: + raise RuntimeError( + "TEDDYCLOUD_CLIENT_CERT and TEDDYCLOUD_CLIENT_KEY must point at a box " + "client certificate signed by the running server's own CA, e.g.:\n" + " ./bin/teddycloud --generate-client-cert deadbeef0001 --destination /tmp/box-cert\n" + "or run `make test_auth_pool_reuse_with_server`, which does this for you." + ) + cls._wait_for_server() + + @classmethod + def _connect(cls, use_cert): + # Connect by IP literal so no SNI is sent: this server picks a + # different (unrelated) certificate chain whenever SNI is present, + # and a real Toniebox never sends one either. + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + if use_cert: + ctx.load_cert_chain(certfile=CLIENT_CERT, keyfile=CLIENT_KEY) + raw = socket.create_connection((HOST, HTTPS_API_PORT), timeout=5) + return ctx.wrap_socket(raw) + + @classmethod + def _status_line(cls, use_cert): + s = cls._connect(use_cert) + try: + s.sendall(f"GET {PATH} HTTP/1.1\r\nHost: {HOST}\r\nConnection: close\r\n\r\n".encode()) + data = b"" + while True: + chunk = s.recv(4096) + if not chunk: + break + data += chunk + return data.split(b"\r\n", 1)[0].decode(errors="replace") + finally: + s.close() + + @classmethod + def _wait_for_server(cls, timeout_seconds=12.0): + deadline = time.time() + timeout_seconds + last_error = None + while time.time() < deadline: + try: + if "401" in cls._status_line(use_cert=False): + return + except Exception as exc: + last_error = exc + time.sleep(0.5) + raise RuntimeError( + f"TeddyCloud HTTPS API not reachable at {HOST}:{HTTPS_API_PORT}. " + f"Original error: {last_error!r}" + ) + + def test_no_cert_request_never_reuses_an_authenticated_pooled_slot(self): + """A request presenting no client certificate must always get 401, + never the 200 a prior, unrelated client's certificate earned on the + same pooled connection slot.""" + for round_num in range(1, ROUNDS + 1): + with self.subTest(round=round_num): + # Burn AUTH_CONNECTIONS pooled slots with a certificate- + # authenticated request each, then close them so the slot + # can be reused - each slot is left with `authenticated` + # however the server last set it. + for _ in range(AUTH_CONNECTIONS): + status = self._status_line(use_cert=True) + self.assertIn("200", status, f"certificate request itself failed: {status}") + + # Concurrently hit the same endpoint with NO client + # certificate. Every one of these must be refused. + with concurrent.futures.ThreadPoolExecutor(max_workers=NOAUTH_CONCURRENT) as pool: + futures = [pool.submit(self._status_line, False) for _ in range(NOAUTH_CONCURRENT)] + results = [f.result() for f in concurrent.futures.as_completed(futures)] + + bypassed = [r for r in results if "200" in r] + self.assertEqual( + bypassed, + [], + f"{len(bypassed)}/{NOAUTH_CONCURRENT} certificate-less requests were " + "answered 200 - a pooled connection slot leaked a prior client's " + "authentication.", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/py/test_box_cert_check.py b/tests/py/test_box_cert_check.py new file mode 100644 index 000000000..dee1f637f --- /dev/null +++ b/tests/py/test_box_cert_check.py @@ -0,0 +1,190 @@ +""" +Box certificate check (core.boxCertAuth): certificates issued by TeddyCloud are +trusted, other certificates are pinned per box (toniebox.certPin); a box with +another certificate than its pinned one is refused (only logged without +core.boxCertAuth). + + make test_py TESTS=box_cert_check +""" + +import http.client +import json +import os +import subprocess +import time +import sys +from pathlib import Path +from urllib.parse import urlparse + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from sim.box import Box # noqa: E402 + +BASE = urlparse(os.environ.get("TEDDYCLOUD_BASE_URL", "http://127.0.0.1:80")) +SANDBOX = os.environ.get("TC_SANDBOX") +HTTPS_API_PORT = int(os.environ.get("TEDDYCLOUD_HTTPS_API_PORT", "0")) + +pytestmark = pytest.mark.skipif(not SANDBOX or not HTTPS_API_PORT, reason="needs the sandbox from tests/py/with_server.sh") + + +def api(method, path, body=None): + conn = http.client.HTTPConnection(BASE.hostname, BASE.port, timeout=10) + try: + conn.request(method, path, body) + resp = conn.getresponse() + return resp.status, resp.read().decode(errors="replace") + finally: + conn.close() + + +def overlay(box): + boxes = json.loads(api("GET", "/api/getBoxes")[1])["boxes"] + return next(b["ID"] for b in boxes if box.mac in b["commonName"].lower()) + + +def cert_status(box): + return api("GET", f"/api/settings/get/internal.boxCertStatus?overlay={overlay(box)}")[1] + + +def openssl(*args): + subprocess.run(["openssl", *args], check=True, capture_output=True) + + +@pytest.fixture(scope="module") +def teddycloud_box(): + box = Box("deadbeef0010", SANDBOX, BASE.hostname, HTTPS_API_PORT) + yield box + box.close() + + +def box_with_own_cert(directory, mac): + """A certificate like an original one: issued by a "Boxine Factory SubCA" TeddyCloud does not know.""" + openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "30", "-subj", "/CN=Boxine Factory SubCA 13", + "-keyout", directory / "ca.key", "-out", directory / "ca.pem") + openssl("req", "-newkey", "rsa:2048", "-nodes", "-subj", f"/CN=b'{mac}'", "-keyout", directory / "box.key", "-out", directory / "box.csr") + openssl("x509", "-req", "-in", directory / "box.csr", "-CA", directory / "ca.pem", "-CAkey", directory / "ca.key", + "-CAcreateserial", "-days", "30", "-out", directory / "box.pem") + box = Box(mac, SANDBOX, BASE.hostname, HTTPS_API_PORT) + box.cert, box.key = directory / "box.pem", directory / "box.key" + return box + + +@pytest.fixture(scope="module") +def original_box(tmp_path_factory): + box = box_with_own_cert(tmp_path_factory.mktemp("original"), "deadbeef0011") + yield box + box.close() + + +@pytest.fixture(scope="module") +def impostor(tmp_path_factory): + box = box_with_own_cert(tmp_path_factory.mktemp("impostor"), "deadbeef0011") + yield box + box.close() + + +@pytest.fixture +def without_box_cert_auth(): + assert api("POST", "/api/settings/set/core.boxCertAuth", "false")[0] == 200 + yield + api("POST", "/api/settings/set/core.boxCertAuth", "true") + + +def test_teddycloud_certificate_is_trusted(teddycloud_box): + assert teddycloud_box.request("GET", "/v1/time")[0] == 200 + assert cert_status(teddycloud_box) == "trusted" + + +def test_original_certificate_is_pinned_on_first_contact(original_box): + assert original_box.request("GET", "/v1/time")[0] == 200 + assert cert_status(original_box) == "pinned" + + +def test_other_certificate_of_a_pinned_box_is_refused(teddycloud_box, original_box, impostor): + original_box.request("GET", "/v1/time") + assert impostor.request("GET", "/v1/time")[0] == 401 + assert cert_status(impostor) == "mismatch" + assert original_box.request("GET", "/v1/time")[0] == 200 + assert teddycloud_box.request("GET", "/v1/time")[0] == 200 + + +def test_box_with_teddycloud_certificate_is_pinned_too(tmp_path): + box = Box("deadbeef0014", SANDBOX, BASE.hostname, HTTPS_API_PORT) + impostor = box_with_own_cert(tmp_path, "deadbeef0014") + try: + assert box.request("GET", "/v1/time")[0] == 200 + assert impostor.request("GET", "/v1/time")[0] == 401 + assert box.request("GET", "/v1/time")[0] == 200 + finally: + box.close() + impostor.close() + + +def test_other_certificate_is_only_logged_without_box_cert_auth(original_box, impostor): + original_box.request("GET", "/v1/time") + box_overlay = overlay(original_box) + assert api("POST", f"/api/settings/set/core.boxCertAuth?overlay={box_overlay}", "false")[0] == 200 + try: + # the settings are reloaded asynchronously from the changed file + for _ in range(50): + if api("GET", f"/api/settings/get/core.boxCertAuth?overlay={box_overlay}")[1].strip() == "false": + break + time.sleep(0.1) + assert impostor.request("GET", "/v1/time")[0] == 200 + finally: + api("POST", f"/api/settings/set/core.boxCertAuth?overlay={box_overlay}", "true") + + +def test_reset_pin_takes_the_next_certificate(original_box, impostor): + original_box.request("GET", "/v1/time") + assert api("POST", f"/api/settings/reset/toniebox.certPin?overlay={overlay(impostor)}")[0] == 200 + impostor.request("GET", "/v1/time") + assert cert_status(impostor) == "pinned" + original_box.request("GET", "/v1/time") + assert cert_status(original_box) == "mismatch" + + +def test_known_box_is_pinned_without_allow_new_box(tmp_path): + box = box_with_own_cert(tmp_path, "deadbeef0012") + box.request("GET", "/v1/time") + assert api("POST", f"/api/settings/reset/toniebox.certPin?overlay={overlay(box)}")[0] == 200 + assert api("POST", "/api/settings/set/core.allowNewBox", "false")[0] == 200 + try: + assert box.request("GET", "/v1/time")[0] == 200 + assert cert_status(box) == "pinned" + finally: + api("POST", "/api/settings/set/core.allowNewBox", "true") + box.close() + + +def upload_client_der(box, pem): + der = subprocess.run(["openssl", "x509", "-in", pem, "-outform", "der"], check=True, capture_output=True).stdout + boundary = "----boxcert" + body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"client.der\"\r\n" + f"Content-Type: application/octet-stream\r\n\r\n").encode() + der + f"\r\n--{boundary}--\r\n".encode() + conn = http.client.HTTPConnection(BASE.hostname, BASE.port, timeout=10) + try: + conn.request("POST", f"/api/uploadCert?overlay={overlay(box)}", body, {"Content-Type": f"multipart/form-data; boundary={boundary}"}) + return conn.getresponse().status + finally: + conn.close() + + +def test_uploaded_client_der_is_the_real_certificate(tmp_path): + (tmp_path / "real").mkdir() + (tmp_path / "first").mkdir() + real = box_with_own_cert(tmp_path / "real", "deadbeef0013") + first = box_with_own_cert(tmp_path / "first", "deadbeef0013") + try: + first.request("GET", "/v1/time") + assert cert_status(first) == "pinned" + + assert upload_client_der(real, real.cert) == 200 + assert real.request("GET", "/v1/time")[0] == 200 + assert cert_status(real) == "pinned" + assert first.request("GET", "/v1/time")[0] == 401 + assert cert_status(first) == "mismatch" + finally: + real.close() + first.close() diff --git a/tests/py/test_box_cert_upload.py b/tests/py/test_box_cert_upload.py new file mode 100644 index 000000000..2d4279591 --- /dev/null +++ b/tests/py/test_box_cert_upload.py @@ -0,0 +1,54 @@ +""" +Certificates uploaded for a box land in its own directory, also right after the box +connected for the first time. + + make test_py TESTS=box_cert_upload +""" + +import http.client +import json +import os +import sys +from pathlib import Path +from urllib.parse import urlparse + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from sim.box import Box # noqa: E402 + +BASE = urlparse(os.environ.get("TEDDYCLOUD_BASE_URL", "http://127.0.0.1:80")) +SANDBOX = os.environ.get("TC_SANDBOX") +HTTPS_API_PORT = int(os.environ.get("TEDDYCLOUD_HTTPS_API_PORT", "0")) + +pytestmark = pytest.mark.skipif(not SANDBOX or not HTTPS_API_PORT, reason="needs the sandbox from tests/py/with_server.sh") + + +def request(method, path, body=None, headers=None): + conn = http.client.HTTPConnection(BASE.hostname, BASE.port, timeout=10) + try: + conn.request(method, path, body, headers or {}) + resp = conn.getresponse() + return resp.status, resp.read().decode(errors="replace") + finally: + conn.close() + + +def test_upload_for_a_new_box_goes_to_its_own_directory(): + box = Box("deadbeef0030", SANDBOX, BASE.hostname, HTTPS_API_PORT) + box.request("GET", "/v1/time") + box.close() + boxes = json.loads(request("GET", "/api/getBoxes")[1])["boxes"] + overlay = next(b["ID"] for b in boxes if "deadbeef0030" in b["commonName"].lower()) + + global_client = Path(SANDBOX) / "certs" / "client" / "client.der" + before = global_client.read_bytes() if global_client.exists() else None + + boundary = "----upload" + body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"client.der\"\r\n" + f"Content-Type: application/octet-stream\r\n\r\n").encode() + b"\x30\x03\x02\x01\x00" + f"\r\n--{boundary}--\r\n".encode() + status, _ = request("POST", f"/api/uploadCert?overlay={overlay}", body, {"Content-Type": f"multipart/form-data; boundary={boundary}"}) + assert status == 200 + + assert (Path(SANDBOX) / "certs" / "client" / "deadbeef0030" / "client.der").exists() + assert (global_client.read_bytes() if global_client.exists() else None) == before diff --git a/tests/py/test_box_profiles.py b/tests/py/test_box_profiles.py new file mode 100644 index 000000000..38f525422 --- /dev/null +++ b/tests/py/test_box_profiles.py @@ -0,0 +1,331 @@ +""" +Per-hardware box simulation: CC3200, CC3235, ESP32 (both user agent styles) and TB2. + +What the server code defines is tested (hardware detection from the user agent, RTNL events +with the hardware's function codes). For CC3200 and ESP32 also what captures of real boxes +show: their TLS handshake, HTTP requests at boot and the way they cut the RTNL stream into +TLS records. Behaviour that still needs a capture is a skipped stub per profile, see +tests/sim/profiles.py. + + make test_py TESTS=box_profiles +""" + +import json +import os +import sys +import time +from pathlib import Path +from urllib.parse import urlparse + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from sim import freshness, profiles, rtnl, rtnl_captured, taf, tls_hello # noqa: E402 +from sim.box import Box, SseListener # noqa: E402 + +BASE = urlparse(os.environ.get("TEDDYCLOUD_BASE_URL", "http://127.0.0.1:80")) +SANDBOX = os.environ.get("TC_SANDBOX") +HTTPS_API_PORT = int(os.environ.get("TEDDYCLOUD_HTTPS_API_PORT", "0")) + +pytestmark = pytest.mark.skipif(not SANDBOX or not HTTPS_API_PORT, reason="needs the sandbox from tests/py/with_server.sh") + + +def api_get(path): + import http.client + + conn = http.client.HTTPConnection(BASE.hostname, BASE.port, timeout=10) + try: + conn.request("GET", path) + resp = conn.getresponse() + return resp.status, resp.read().decode(errors="replace") + finally: + conn.close() + + +@pytest.fixture(params=profiles.ALL_PROFILES, ids=lambda p: p.name) +def box(request): + try: + b = Box(None, SANDBOX, BASE.hostname, HTTPS_API_PORT, profile=request.param) + except NotImplementedError as e: + pytest.skip(f"stub: {e}") + yield b + b.close() + + +def overlay_id(box): + boxes = json.loads(api_get("/api/getBoxes")[1])["boxes"] + return next(b["ID"] for b in boxes if box.mac in b["commonName"].lower()) + + +def overlay_setting(box, name): + status, text = api_get(f"/api/settings/get/{name}?overlay={overlay_id(box)}") + assert status == 200, text + return int(text) + + +def test_hardware_is_detected_from_user_agent(box): + assert box.request("GET", "/v1/time")[0] == 200 + assert overlay_setting(box, "internal.toniebox_firmware.boxIC") == box.profile.box_ic + assert overlay_setting(box, "toniebox.boxGeneration") == box.profile.generation + + +def test_tls_handshake_with_the_profile_parameters(box): + """The box connects with its own TLS version range, cipher suites and SNI use.""" + status, _, body = box.request("GET", "/v1/time") + assert status == 200, body + with box.connect() as s: + print(box.profile.name, s.version(), s.cipher()) + + +NO_DHE = pytest.mark.xfail(strict=True, reason="the server has no DH parameters; the CC3200 offers enough other suites") + + +@pytest.mark.parametrize("suite", [pytest.param(s, marks=NO_DHE) if s.startswith("DHE-") else s for s in profiles.CC3200_SUITES.split(":")]) +def test_legacy_cipher_suites_are_accepted(suite): + """Each suite of the CC3200 on its own (its fallbacks if the server dropped the one it picks).""" + import dataclasses + + legacy = dataclasses.replace(profiles.CC3200, tls=dataclasses.replace(profiles.CC3200.tls, ciphers=suite + ":@SECLEVEL=0")) + b = Box(None, SANDBOX, BASE.hostname, HTTPS_API_PORT, profile=legacy) + try: + assert b.request("GET", "/v1/time")[0] == 200 + with b.connect() as s: + assert s.cipher()[0] == suite + finally: + b.close() + + +def test_rtnl_tag_events_use_the_hardware_function_codes(box): + sse = SseListener(BASE.hostname, BASE.port) + try: + time.sleep(0.3) + p = box.profile + box.rtnl( + rtnl.frame(rtnl.log2(1000, 1, rtnl.FUGR_TAG, p.tag_valid, (0xE0040350AABBCCDD).to_bytes(8, "big"), field9=rtnl.CRLF)), + rtnl.frame(rtnl.log2(1001, 2, rtnl.FUGR_TAG, p.tag_invalid, (0xE0040350AABBCCDD).to_bytes(8, "big"), field9=rtnl.CRLF)), + ) + events = sse.wait_for("rtnl-raw-log2", count=2) + assert [(e["function_group"], e["function"]) for e in events] == [(rtnl.FUGR_TAG, p.tag_valid), (rtnl.FUGR_TAG, p.tag_invalid)] + finally: + sse.close() + + +@pytest.fixture(params=profiles.CAPTURED_PROFILES, ids=lambda p: p.name) +def captured(request): + b = Box(None, SANDBOX, BASE.hostname, HTTPS_API_PORT, profile=request.param) + yield b + b.close() + + +def test_server_answers_the_real_client_hello(captured): + """The captured ClientHello as it is: the server picks the suite it picked for the real box, and + only uses what the box offers.""" + tls = captured.profile.tls + offered = tls_hello.offers(tls.client_hello) + flight = tls_hello.server_flight(BASE.hostname, HTTPS_API_PORT, tls.client_hello) + assert flight.alert is None + assert (flight.version, flight.cipher) == (0x0303, tls.cipher) + # renegotiation_info answers the TLS_EMPTY_RENEGOTIATION_INFO_SCSV, which only the ESP32 sends + assert set(flight.extensions) <= set(offered["extensions"]) | {0xFF01} + assert flight.ske_curve in offered["groups"] + assert flight.ske_sigalg in offered["sigalgs"] + assert tls.cert_verify in flight.cert_request_sigalgs + + +def test_handshake_with_the_box_signature_algorithms(captured): + """Full handshake with the box's signature algorithms, the CertificateVerify signed like the box does + (CC3200: RSA/SHA-1).""" + assert captured.openssl_request("GET", "/v1/time").startswith(b"HTTP/1.1 200 ") + + +def test_boot_requests_on_one_connection(captured): + p = captured.profile + uids = iter(range(0xE00403500A000001, 0xE00403500B000000)) + with captured.keep_alive() as c: + for path in p.boot: + status, _, body = c.request("GET", path) + assert status == (200 if path == "/v1/time" else 304), path + for count in p.freshness_batches: + body = freshness.request([(next(uids), 0x5E000000 + i) for i in range(count)]) + assert len(body) == 16 * count + status, headers, answer = c.request("POST", "/v1/freshness-check", body) + assert status == 200 + assert set(freshness.response_fields(answer)) >= {2, 3, 4, 5, 6, 7, 8} + status, _, _ = c.request("GET", "/v1/claim/0a0bccddee0304e0", headers={"Authorization": profiles.AUTHORIZATION}) + assert status == 204 + + +# record sizes of the RTNL stream of a CC3200 (3.4.0) after its first frame: frames are cut anywhere, also +# within the 4-byte length header +CC3200_RTNL_RECORDS = (3, 1347, 1, 1381, 3, 1372, 789, 3, 59, 1, 96, 202, 2, 690, 2, 48, 314, 27, 3, 539, 1, 30, 2, 75, 1, 34, 2, 597, 3) + + +def rtnl_records(kind, frames): + """The stream cut like the box does: "split" = CC3200 (first frame alone, then CC3200_RTNL_RECORDS), + "batched" = ESP32 (whole frames, up to ~1400 bytes per record).""" + if kind == "batched": + records, current = [], b"" + for f in frames: + if len(current) + len(f) > 1400: + records.append(current) + current = b"" + current += f + return records + [current] + data, records, pos = b"".join(frames), [frames[0]], len(frames[0]) + for size in CC3200_RTNL_RECORDS: + records.append(data[pos:pos + size]) + pos += size + return records + [data[pos:]] + + +def test_rtnl_stream_cut_like_the_box_does(captured): + """Every frame shape the box sent in the capture (zero payloads), cut into records like the box does.""" + p = captured.profile + # the first frame carries a line feed, see test_first_rtnl_record_without_line_feed + frames = [rtnl.frame(rtnl.log2(5000, 1, rtnl.FUGR_TAG, p.tag_valid, (0xE0040350AABBCCDD).to_bytes(8, "big"), field9=rtnl.CRLF))] + frames += rtnl_captured.frames(p.rtnl_log2, p.rtnl_log3, first_sequence=2) + sse = SseListener(BASE.hostname, BASE.port) + try: + time.sleep(0.3) + s = captured.rtnl(keep_open=True) + for record in rtnl_records(p.rtnl_records, frames): + s.sendall(record) + log2 = sse.wait_for("rtnl-raw-log2", count=1 + len(p.rtnl_log2), timeout=10) + log3 = sse.wait_for("rtnl-raw-log3", count=len(p.rtnl_log3), timeout=10) + assert [(e["function_group"], e["function"], e["field3"], len(e["field6"]) // 2) for e in log2[1:]] == \ + [(group, function, field3, len6) for field3, group, function, len6, *_ in p.rtnl_log2] + assert [e["field2"] for e in log3] == [field2 for field2, _ in p.rtnl_log3] + s.close() + finally: + sse.close() + + +def put_content(box, audio_id=0x5E000001): + """A TAF for a tonie of this box in the sandbox's content directory. Returns (ruid, file).""" + ruid = box.mac[4:] + "500304e0" + content = taf.build(audio_id, os.urandom(3 * 4096)) + path = Path(SANDBOX) / "data" / "content" / "default" / ruid[:8].upper() / ruid[8:].upper() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + return ruid, content + + +def test_content_download_like_the_box(captured): + """/v2/content without Range on a keep-alive connection, then the claim; the ESP32 claims once more on a + new connection.""" + ruid, content = put_content(captured) + auth = {"Authorization": profiles.AUTHORIZATION} + with captured.keep_alive() as c: + status, headers, body = c.request("GET", f"/v2/content/{ruid}", headers=auth) + assert (status, headers.get("Content-Type"), body == content) == (200, "application/octet-stream", True) + assert c.request("GET", f"/v1/claim/{ruid}", headers=auth)[0] == 204 + with captured.keep_alive() as c: + assert c.request("GET", f"/v1/claim/{ruid}", headers=auth)[0] == 204 + + +@pytest.mark.parametrize("ending", ["close_notify", "fin"]) +def test_connection_ends_like_the_box(captured, ending): + """The CC3200 ends a connection with close_notify and FIN or with a FIN only, the ESP32 with a FIN only: + the server closes its side either way and keeps serving.""" + if ending not in captured.profile.endings: + pytest.skip(f"a {captured.profile.name} does not end a connection with {ending}") + for _ in range(2): + c = captured.keep_alive() + assert c.request("GET", "/v1/claim/0a0bccddee0304e0", headers={"Authorization": profiles.AUTHORIZATION})[0] == 204 + c.finish(ending) + + +@pytest.mark.xfail(strict=True, reason="the server reads the start of a connection line by line before it tells HTTP " + "from RTNL: a first record without a line feed waits for more data. The first record of a CC3200 on " + "3.1.0 BF2 has none (3.3.0, 3.4.0 and the ESP32 have a 0x0a in a payload)") +def test_first_rtnl_record_without_line_feed(captured): + frame = rtnl.frame(rtnl.log2(1000, 1, rtnl.FUGR_TAG, captured.profile.tag_valid, (0xE0040350AABBCCDD).to_bytes(8, "big"))) + assert b"\n" not in frame + sse = SseListener(BASE.hostname, BASE.port) + try: + time.sleep(0.3) + s = captured.rtnl(frame, keep_open=True) + sse.wait_for("rtnl-raw-log2", timeout=3) + s.close() + finally: + sse.close() + + +def needs(box, limit): + if not getattr(box.profile, limit): + pytest.skip(f"{limit} not known for a {box.profile.name}") + + +def test_claim_status_the_box_expects(captured): + """The boxes close the connection after a claim answered with 200 (CC3200 3.4.0, ESP32 v5.233.0), after a 204 + they keep it open.""" + needs(captured, "claim_status") + with captured.keep_alive() as c: + status, _, _ = c.request("GET", "/v1/claim/0a0bccddee0304e0", headers={"Authorization": profiles.AUTHORIZATION}) + assert status == captured.profile.claim_status + + +def test_response_headers_fit_the_box(captured): + """No header longer than the box was seen to take (a 3.4.0 CC3200 dropped a /v1/time answer with a + 776-byte header and stayed offline for that boot).""" + needs(captured, "header_accepted") + ruid, _ = put_content(captured) + auth = {"Authorization": profiles.AUTHORIZATION} + with captured.keep_alive() as c: + sizes = {} + for path in captured.profile.boot: + c.request("GET", path) + sizes[path] = c.header_size + c.request("POST", "/v1/freshness-check", freshness.request([(0xE00403500A000001, 1)])) + sizes["freshness"] = c.header_size + c.request("GET", f"/v2/content/{ruid}", headers=auth) + sizes["content"] = c.header_size + c.request("GET", f"/v2/content/{ruid}", headers=dict(auth, Range="bytes=5000-")) + sizes["content 206"] = c.header_size + assert max(sizes.values()) <= captured.profile.header_accepted, sizes + + +def test_freshness_answer_fits_the_box(captured): + """No freshness answer longer than the box was seen to take (a 3.4.0 CC3200 dropped a 1121-byte one).""" + needs(captured, "freshness_answer_accepted") + count = max(captured.profile.freshness_batches) + with captured.keep_alive() as c: + _, _, answer = c.request("POST", "/v1/freshness-check", freshness.request([(0xE00403500C000001 + i, i) for i in range(count)])) + assert len(answer) <= captured.profile.freshness_answer_accepted + + +def test_resume_like_the_box(captured): + """After an aborted download the box asks for the rest with Range and If-Range (the audio id, decimal), from + a little before the abort (seen: CC3200 512-byte aligned, ESP32 4096-byte aligned).""" + needs(captured, "resume") + ruid, content = put_content(captured, audio_id=0x1A8DECAE) + headers = {"Authorization": profiles.AUTHORIZATION, "Range": "bytes=5000-", "If-Range": str(0x1A8DECAE)} + with captured.keep_alive() as c: + status, h, body = c.request("GET", f"/v2/content/{ruid}", headers=headers) + assert (status, h.get("Content-Range")) == (206, f"bytes 5000-{len(content) - 1}/{len(content)}") + assert body == content[5000:] + + +def test_missing_content_is_404(captured): + """After a 410 the box shows an error and later downloads the whole file again, so a missing file must be + a 404 - also for the system slots, which the box fetches from /v1/content without Authorization.""" + needs(captured, "drops_copy_on_410") + with captured.keep_alive() as c: + assert c.request("GET", "/v1/content/0000000100000000")[0] == 404 + with captured.keep_alive() as c: + assert c.request("GET", "/v2/content/0a0b0c0d0e0304e0", headers={"Authorization": profiles.AUTHORIZATION})[0] == 404 + + +def test_log_is_answered(captured): + """With RTNL down the box posts to /v1/log and closes if there is no answer (after 2.9 s and 7.7 s).""" + needs(captured, "log_wait") + with captured.keep_alive() as c: + c.sock.settimeout(captured.profile.log_wait) + status, _, _ = c.request("POST", "/v1/log", b"992 (0)") + assert status == 200 + + +@pytest.mark.parametrize("behaviour", profiles.STUB_BEHAVIOUR) +def test_hardware_specific_behaviour(box, behaviour): + if behaviour in box.profile.stubs: + pytest.skip(f"stub, needs a capture of a {box.profile.name}: {profiles.STUB_BEHAVIOUR[behaviour]}") diff --git a/tests/py/test_box_sim.py b/tests/py/test_box_sim.py new file mode 100644 index 000000000..70a7b1646 --- /dev/null +++ b/tests/py/test_box_sim.py @@ -0,0 +1,131 @@ +""" +Toniebox simulator tests: a simulated box connects to the sandboxed server with a +client certificate and talks the HTTPS API and the RTNL log protocol. + + make test_py TESTS=box_sim +""" + +import os +import sys +import time +import json +from pathlib import Path +from urllib.parse import urlparse + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from sim import rtnl # noqa: E402 +from sim.box import Box, SseListener # noqa: E402 + +BASE = urlparse(os.environ.get("TEDDYCLOUD_BASE_URL", "http://127.0.0.1:80")) +SANDBOX = os.environ.get("TC_SANDBOX") +HTTPS_API_PORT = int(os.environ.get("TEDDYCLOUD_HTTPS_API_PORT", "0")) + +pytestmark = pytest.mark.skipif(not SANDBOX or not HTTPS_API_PORT, reason="needs the sandbox from tests/py/with_server.sh") + + +def api(method, path, body=None): + import http.client + + conn = http.client.HTTPConnection(BASE.hostname, BASE.port, timeout=10) + try: + conn.request(method, path, body) + resp = conn.getresponse() + return resp.status, resp.read().decode(errors="replace") + finally: + conn.close() + + +def alive(): + return api("GET", "/web/")[0] == 200 + + +@pytest.fixture(scope="module") +def box(): + b = Box("deadbeef0001", SANDBOX, BASE.hostname, HTTPS_API_PORT) + yield b + b.close() + + +@pytest.fixture +def sse(): + s = SseListener(BASE.hostname, BASE.port) + time.sleep(0.3) # let the server register the subscription + yield s + s.close() + + +def test_box_with_cert_is_authenticated(box): + status, _, body = box.request("GET", "/v1/time") + assert status == 200, body + + +def test_box_is_authenticated_on_a_second_connection(box): + first = box.connect() + session = first.session + first.close() + status, _, _ = box.request("GET", "/v1/time", session=session) + assert status == 200 + + +def test_request_without_cert_is_rejected(box): + assert box.request("GET", "/v1/time", use_cert=False)[0] == 401 + + +def test_new_box_is_listed(box): + box.request("GET", "/v1/time") + status, text = api("GET", "/api/getBoxes") + assert status == 200 + assert any(box.mac in b["commonName"].lower() for b in json.loads(text)["boxes"]), text + + +def test_unknown_box_is_rejected_without_allow_new_box(box): + assert api("POST", "/api/settings/set/core.allowNewBox", "false")[0] == 200 + try: + stranger = Box("deadbeef0002", SANDBOX, BASE.hostname, HTTPS_API_PORT) + try: + assert stranger.request("GET", "/v1/time")[0] == 401 + finally: + stranger.close() + finally: + api("POST", "/api/settings/set/core.allowNewBox", "true") + + +def test_rtnl_log2_reaches_sse(box, sse): + box.rtnl(rtnl.frame(rtnl.tag_placed(0xE0040350AABBCCDD, sequence=42))) + ev = sse.wait_for("rtnl-raw-log2")[0] + assert (ev["sequence"], ev["function_group"], ev["function"]) == (42, rtnl.FUGR_TAG, rtnl.FUNC_TAG_VALID_CC3200) + + +def test_rtnl_batched_frames_are_all_delivered(box, sse): + box.rtnl(*(rtnl.frame(rtnl.volume_changed(5, -20, sequence=i)) for i in range(1, 6))) + assert [e["sequence"] for e in sse.wait_for("rtnl-raw-log2", count=5)] == [1, 2, 3, 4, 5] + + +def test_rtnl_log3(box, sse): + box.rtnl(rtnl.frame(rtnl.tag_placed(1)), rtnl.frame(rtnl.log3(1700000000, 7))) + assert sse.wait_for("rtnl-raw-log3")[0]["field2"] == 7 + + +MALFORMED = { + "zero length": b"\x00\x00\x00\x00" + rtnl.CRLF, + "length beyond data": b"\x00\x00\x10\x00" + b"\x12\x34" + rtnl.CRLF, + "not protobuf": rtnl.frame(b"\xff" * 40 + rtnl.CRLF), + "huge field6": rtnl.frame(rtnl.log2(1, 1, rtnl.FUGR_FIRMWARE, 1, b"A" * 20000, field9=b"B" * 5000 + rtnl.CRLF)), + "short tag field6": rtnl.frame(rtnl.log2(1, 1, rtnl.FUGR_TAG, rtnl.FUNC_TAG_VALID_CC3200, b"\x01", field9=rtnl.CRLF)), + "short volume field6": rtnl.frame(rtnl.log2(1, 1, rtnl.FUGR_VOLUME, rtnl.FUNC_VOLUME_CHANGE_CC3200, b"\x01\x02", field9=rtnl.CRLF)), + "empty field6": rtnl.frame(rtnl.log2(1, 1, rtnl.FUGR_TILT, 15426, b"", field9=rtnl.CRLF)), +} + + +@pytest.mark.parametrize("name", MALFORMED) +def test_rtnl_malformed_input_does_not_kill_the_server(box, sse, name): + try: + box.rtnl(MALFORMED[name]) + except OSError: + pass # the server may drop the connection + time.sleep(0.3) + assert alive(), f"server died on '{name}'" + box.rtnl(rtnl.frame(rtnl.tag_placed(1, sequence=99))) + assert sse.wait_for("rtnl-raw-log2")[-1]["sequence"] in (1, 99) diff --git a/tests/py/test_cloud_proxy.py b/tests/py/test_cloud_proxy.py new file mode 100644 index 000000000..89a42a7f6 --- /dev/null +++ b/tests/py/test_cloud_proxy.py @@ -0,0 +1,224 @@ +""" +Cloud proxy tests against a local fake cloud (tests/sim/upstream.py): forwarding, the +local fallback, and redirect handling with hostile Location headers. + + make test_py TESTS=cloud_proxy +""" + +import os +import re +import sys +import time +from pathlib import Path +from urllib.parse import urlparse + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from sim import profiles # noqa: E402 +from sim.box import Box # noqa: E402 +from sim.upstream import FakeCloud # noqa: E402 + +BASE = urlparse(os.environ.get("TEDDYCLOUD_BASE_URL", "http://127.0.0.1:80")) +SANDBOX = os.environ.get("TC_SANDBOX") +HTTPS_API_PORT = int(os.environ.get("TEDDYCLOUD_HTTPS_API_PORT", "0")) + +pytestmark = pytest.mark.skipif(not SANDBOX or not HTTPS_API_PORT, reason="needs the sandbox from tests/py/with_server.sh") + + +def api(method, path, body=None): + import http.client + + conn = http.client.HTTPConnection(BASE.hostname, BASE.port, timeout=10) + try: + conn.request(method, path, body) + resp = conn.getresponse() + return resp.status, resp.read().decode(errors="replace") + finally: + conn.close() + + +_current = {} + + +def setting(name, value): + value = str(value).lower() + if _current.get(name) == value: + return + assert api("POST", f"/api/settings/set/{name}", value)[0] == 200 + # API changes live in memory only: the next reload from disk (every new box saves the overlay file and + # triggers one) would drop them. The web UI persists with triggerWriteConfig as well. + assert api("GET", "/api/triggerWriteConfig")[0] == 200 + _current[name] = value + # The box's overlay follows the global value only after the next reload of the config (main loop: 250 ms + # poll, file time granularity); the API readback is true earlier, so there is nothing better to poll. + time.sleep(1.2) + + +def alive(): + return api("GET", "/web/")[0] == 200 + + +@pytest.fixture(scope="module") +def cloud(): + c = FakeCloud(SANDBOX) + setting("cloud.remote_hostname", "localhost") + setting("cloud.remote_port", c.port) + setting("cloud.enableV1Time", "true") + yield c + c.close() + + +@pytest.fixture(scope="module") +def box(cloud): + b = Box(None, SANDBOX, BASE.hostname, HTTPS_API_PORT, profile=profiles.CC3200) + b.request("GET", "/v1/time") # creates the overlay (cloud still disabled: answered locally) + time.sleep(1.2) # the new overlay is saved and reloaded + yield b + b.close() + + +@pytest.fixture(autouse=True) +def fresh(cloud): + cloud.requests.clear() + cloud.script = cloud._default + setting("cloud.enabled", "true") + yield + assert alive(), "server is not answering after the test (crashed?)" + + +def is_local_time(text): + return re.fullmatch(r"\d{9,}", text.strip()) is not None and abs(int(text) - time.time()) < 3600 + + +def test_time_is_forwarded_to_the_cloud(box, cloud): + status, _, body = box.request("GET", "/v1/time") + assert (status, body) == (200, FakeCloud.TIME) + assert [r[1] for r in cloud.requests] == ["/v1/time"] + + +@pytest.mark.parametrize("answer", [b"maintenance", b"-1", b" 1700000000", b"0"]) +def test_cloud_time_that_is_no_number_is_not_passed_on(box, cloud, answer): + """A 3.4.0 CC3200 that got "teddycloud" blinked red and switched off.""" + cloud.script = lambda h: cloud.reply(h, 200, answer, {"Content-Type": "text/plain"}) + status, _, body = box.request("GET", "/v1/time") + assert status == 200 and is_local_time(body.decode()), body + + +def test_cloud_disabled_stays_local(box, cloud): + setting("cloud.enabled", "false") + status, _, body = box.request("GET", "/v1/time") + assert status == 200 and is_local_time(body.decode()) + assert cloud.requests == [] + + +def test_unreachable_cloud_falls_back_to_local(box, cloud): + cloud.script = lambda h: h.connection.close() # drop the connection without an answer + status, _, body = box.request("GET", "/v1/time") + assert status == 200 and is_local_time(body.decode()) + + +@pytest.mark.parametrize("location", [ + "https://127.0.0.1/" + "x" * 5000, # longer than the fixed buffers in the redirect handling + "https://" + "h" * 400 + "/path", + "https://127.0.0.1/p?" + "q=" + "y" * 3000, + "not a url", + "/relative", + "", +]) +def test_redirect_with_hostile_location_is_survived(box, cloud, location): + cloud.script = lambda h: cloud.reply(h, 302, b"", {"Location": location}) + status, _, _ = box.request("GET", "/v1/time") + assert status in (200, 302, 404, 500, 502, 504) + time.sleep(0.2) + + +def test_redirect_loop_is_bounded(box, cloud): + # the redirect target is always port 443 on the host, so this ends in a connect error; it must end + cloud.script = lambda h: cloud.reply(h, 302, b"", {"Location": "https://127.0.0.1/v1/time"}) + start = time.time() + box.request("GET", "/v1/time") + assert time.time() - start < 30 + assert len(cloud.requests) <= 6 + + +def test_settings_reload_while_cloud_requests_run(box, cloud): + """The main loop reloads the settings on every config change and replaced the strings other threads were + reading (use-after-free, ASan crash in web_request). Hammer cloud requests while forcing reloads.""" + import threading + + stop = threading.Event() + results = [] + + def requests_loop(): + while not stop.is_set(): + try: + results.append(box.request("GET", "/v1/time")[2]) + except OSError: + results.append(None) + + t = threading.Thread(target=requests_loop) + t.start() + try: + for i in range(12): + assert api("POST", "/api/settings/set/hass.name", f"reload {i}")[0] == 200 + assert api("GET", "/api/triggerWriteConfig")[0] == 200 + time.sleep(0.4) + finally: + stop.set() + t.join(15) + assert len(results) > 20 and results.count(FakeCloud.TIME) > 10, results + + +# what Boxine answered to /v1/ota/3 of a CC3200 on 3.3.0 (capture): the firmware image with its SHA-256 as 64 +# hex characters appended (the ESP32 gets "-esp32-toniebox-eu-v5.237.0-app.ota" in the same format) +OTA_HEADERS = { + "Content-Type": "binary/octet-stream", + "Content-Disposition": "attachment;filename=1779199517_toniebox-eu_v3.4.0.hashed.bin", + "ETag": '"0123456789abcdef0123456789abcdef"', + "Last-Modified": "Wed, 20 May 2026 12:33:19 GMT", + "Accept-Ranges": "bytes", +} + + +def test_ota_is_passed_through_as_the_cloud_sends_it(box, cloud): + import hashlib + + image = os.urandom(163328 - 64) + ota = image + hashlib.sha256(image).hexdigest().encode() + setting("cloud.enableV1Ota", "true") + setting("cloud.cacheOta", "false") + try: + cloud.script = lambda h: cloud.reply(h, 200, ota, OTA_HEADERS) if "/v1/ota/3" in h.path else cloud.reply(h, 304) + assert box.request("GET", "/v1/ota/5?cv=1669853893")[0] == 304 + status, headers, body = box.request("GET", "/v1/ota/3?cv=1777468756") + assert status == 200 and body == ota + assert [r[1] for r in cloud.requests] == ["/v1/ota/5?cv=1669853893", "/v1/ota/3?cv=1777468756"] + assert cloud.requests[1][2]["User-Agent"] == box.user_agent + assert {k: headers.get(k) for k in OTA_HEADERS} == OTA_HEADERS + finally: + setting("cloud.cacheOta", "true") + setting("cloud.enableV1Ota", "false") + + +def test_ota_is_cached_and_held_back_by_default(box, cloud): + """Defaults (cacheOta on, localOta off): the server asks the cloud for anything newer than what it has + cached (cv=1 with an empty cache), keeps the file and tells the box there is no update. With localOta it + delivers the cached file.""" + import hashlib + + image = os.urandom(5000) + ota = image + hashlib.sha256(image).hexdigest().encode() + setting("cloud.enableV1Ota", "true") + cloud.script = lambda h: cloud.reply(h, 200, ota, OTA_HEADERS) + try: + assert box.request("GET", "/v1/ota/3?cv=1777468756")[0] == 304 + assert [r[1] for r in cloud.requests] == ["/v1/ota/3?cv=1"] + setting("cloud.enableV1Ota", "false") + setting("cloud.localOta", "true") + with box.keep_alive() as c: + status, headers, body = c.request("GET", "/v1/ota/3?cv=1777468756") + assert status == 200 and body == ota + finally: + setting("cloud.localOta", "false") + setting("cloud.enableV1Ota", "false") diff --git a/tests/py/test_content_mime.py b/tests/py/test_content_mime.py new file mode 100644 index 000000000..67bc90e70 --- /dev/null +++ b/tests/py/test_content_mime.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +""" +/content/ labels the response by file type: tag configs are JSON, everything else +(extensionless TAFs) stays audio/ogg. iOS/Safari names a download after the Content-Type, +so a JSON file served as audio/ogg was saved as .json.ogg (#556). + +Run via: make test_py TESTS=content_mime +""" + +import http.client +import json +import unittest + +from test_hardening import BASE, BASE_DIR, Base + +TAG_DIR, TAG_FILE = "A64E581D", "500304E0" + + +def get_content_type(path): + conn = http.client.HTTPConnection(BASE.hostname, BASE.port, timeout=30) + try: + conn.request("GET", path) + resp = conn.getresponse() + resp.read() + return resp.status, resp.getheader("Content-Type", "") + finally: + conn.close() + + +class ContentMime(Base): + def setUp(self): + super().setUp() + self.tag_dir = BASE_DIR / "data" / "content" / "default" / TAG_DIR + self.tag_dir.mkdir(parents=True, exist_ok=True) + self.files = { + f"{TAG_FILE}.json": json.dumps({"source": "lib://x.taf", "nocloud": True}), + f"{TAG_FILE}.JSON": "{}", # extension is matched case-insensitively + TAG_FILE: "not a taf\n", + } + for name, data in self.files.items(): + (self.tag_dir / name).write_text(data) + + def tearDown(self): + for name in self.files: + (self.tag_dir / name).unlink(missing_ok=True) + super().tearDown() + + def test_json_is_not_audio(self): + for name in (f"{TAG_FILE}.json", f"{TAG_FILE}.JSON"): + status, content_type = get_content_type(f"/content/{TAG_DIR}/{name}") + self.assertEqual(status, 200, name) + self.assertEqual(content_type, "application/json", name) + + def test_extensionless_stays_audio(self): + status, content_type = get_content_type(f"/content/{TAG_DIR}/{TAG_FILE}") + self.assertEqual(status, 200) + self.assertEqual(content_type, "audio/ogg") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/py/test_cors_preflight.py b/tests/py/test_cors_preflight.py new file mode 100644 index 000000000..4d4972345 --- /dev/null +++ b/tests/py/test_cors_preflight.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +""" +CORS preflight must be answered without a login, even when web login is enabled. + +Run via: make test_py TESTS=cors +""" + +import http.client +import json +import os +import unittest +from urllib.parse import urlparse + +BASE = urlparse(os.environ.get("TEDDYCLOUD_BASE_URL", "http://127.0.0.1:80")) +USER, PASSWORD = "cors-test", "cors-test-password" + + +def request(method, path, body=None, headers=None): + conn = http.client.HTTPConnection(BASE.hostname, BASE.port, timeout=10) + h = dict(headers or {}) + data = None + if body is not None: + data = json.dumps(body) + h["Content-Type"] = "application/json" + conn.request(method, path, data, h) + resp = conn.getresponse() + text = resp.read().decode() + out = (resp.status, {k.lower(): v for k, v in resp.getheaders()}, text) + conn.close() + return out + + +class CorsPreflight(unittest.TestCase): + token = None + + @classmethod + def setUpClass(cls): + status, _, _ = request("POST", "/api/auth/users/create", {"username": USER, "password": PASSWORD}) + assert status == 200, status + status, _, _ = request("POST", "/api/auth/enabled", {"enabled": True}) + assert status == 200, status + status, _, text = request("POST", "/api/auth/login", {"username": USER, "password": PASSWORD}) + assert status == 200, status + cls.token = json.loads(text)["token"] + + @classmethod + def tearDownClass(cls): + auth = {"Authorization": f"Bearer {cls.token}"} + request("POST", "/api/auth/enabled", {"enabled": False}, auth) + request("POST", "/api/auth/users/delete", {"username": USER}, auth) + + def test_login_is_enforced(self): + self.assertEqual(request("GET", "/api/auth/users/get")[0], 401) + + def test_preflight_without_login(self): + status, headers, _ = request( + "OPTIONS", + "/api/auth/users/get", + headers={ + "Origin": "http://localhost:3000", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "authorization", + }, + ) + self.assertEqual(status, 204) + self.assertIn("authorization", headers["access-control-allow-headers"].lower()) + self.assertIn("GET", headers["access-control-allow-methods"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/py/test_hardening.py b/tests/py/test_hardening.py new file mode 100644 index 000000000..22658944a --- /dev/null +++ b/tests/py/test_hardening.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +""" +Robustness regression tests: malformed input must not crash the server and +secrets must not leak. Every test checks that the server still answers +afterwards. + +A crash kills the server for all following tests, so the Makefile starts a +fresh server per test class: + + make test_py TESTS=hardening + +Single class against a running server: + + TEDDYCLOUD_BASE_URL=http://127.0.0.1:80 python3 tests/py/test_hardening.py Cache499 +""" + +import http.client +import json +import os +import subprocess +import tempfile +import time +import unittest +from pathlib import Path +from urllib.parse import quote, urlparse + +BASE = urlparse(os.environ.get("TEDDYCLOUD_BASE_URL", "http://127.0.0.1:80")) +REPO = Path(__file__).resolve().parents[2] +BIN = REPO / "bin" / "teddycloud" +# base directory of the server under test (with_server.sh exports the sandbox) +BASE_DIR = Path(os.environ.get("TC_SANDBOX", REPO)) +BOUNDARY = "----hardening" +# Known, not yet understood: an upload built by multipart() never gets a response +# once the body reaches 64 KB (16 KB works). curl uploads 88 KB to the same +# endpoint fine, so it depends on how the body is sent. Stay below this size. +MAX_MULTIPART_BODY = 16000 + + +def request(method, path, body=None, headers=None, timeout=10): + conn = http.client.HTTPConnection(BASE.hostname, BASE.port, timeout=timeout) + try: + conn.request(method, path, body, headers or {}) + resp = conn.getresponse() + return resp.status, resp.read().decode(errors="replace") + finally: + conn.close() + + +def multipart(parts, boundary=BOUNDARY): + """parts: list of (filename, bytes). Returns (body, headers). + Bodies are limited to MAX_MULTIPART_BODY, see the note there.""" + body = b"" + for name, data in parts: + body += ( + f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n' + "Content-Type: application/octet-stream\r\n\r\n" + ).encode() + data + b"\r\n" + body += f"--{boundary}--\r\n".encode() + if len(body) > MAX_MULTIPART_BODY: + raise ValueError( + f"multipart body is {len(body)} bytes, larger uploads than {MAX_MULTIPART_BODY} bytes hang (see MAX_MULTIPART_BODY)" + ) + return body, {"Content-Type": f"multipart/form-data; boundary={boundary}"} + + +class Base(unittest.TestCase): + def setUp(self): + self.library_files = [] + + def tearDown(self): + alive = self.server_alive() + if alive: + for name in self.library_files: + request("POST", "/api/fileDelete?special=library", name) + self.assertTrue(alive, "server is not answering after the test (crashed?)") + + @staticmethod + def server_alive(): + for _ in range(10): + try: + if request("GET", "/web/", timeout=3)[0] == 200: + return True + except OSError: + pass + time.sleep(0.3) + return False + + def put_library(self, name, data=b"not audio\n"): + body, headers = multipart([(name, data)]) + status, text = request("POST", "/api/fileUpload?special=library&path=/", body, headers) + self.assertEqual(status, 200, text) + self.library_files.append(name) + + def encode(self, sources, target): + form = "&".join(["target=" + quote(target)] + ["source=" + quote(s) for s in sources]) + self.library_files.append(target) # removed in tearDown, ignored if it was never created + return request( + "POST", "/api/fileEncode?special=library", form, {"Content-Type": "application/x-www-form-urlencoded"}, timeout=60 + ) + + +class Cache499(Base): + """GET /cache/00000000 matched the list sentinel (cached_url == NULL).""" + + def test_hash_zero(self): + status, _ = request("GET", "/cache/00000000") + self.assertEqual(status, 404) + + +class Multipart502(Base): + """A multipart request without boundary made the data length underflow.""" + + def test_empty_boundary(self): + body = b'--\r\nContent-Disposition: form-data; name="file"; filename="x.bin"\r\n\r\ndata\r\n--\r\n' + try: + request("POST", "/api/esp32/uploadFirmware", body, {"Content-Type": "multipart/form-data; boundary="}) + except OSError: + pass # a dropped connection is fine, tearDown checks that the server survived + + +class Firmware503(Base): + """Firmware upload without any file part used the still NULL filename.""" + + def test_no_file_part(self): + body = f"--{BOUNDARY}--\r\n".encode() + status, _ = request("POST", "/api/esp32/uploadFirmware", body, {"Content-Type": f"multipart/form-data; boundary={BOUNDARY}"}) + self.assertEqual(status, 400) + + +class Encode501(Base): + """A failing first source made ffmpeg_stream pclose() the same pipe twice.""" + + def test_two_undecodable_sources(self): + self.put_library("__hardening_a.txt") + self.put_library("__hardening_b.txt") + status, _ = self.encode(["__hardening_a.txt", "__hardening_b.txt"], "__hardening_out.taf") + self.assertEqual(status, 500) + + +class Tap495(Base): + """A TAP playlist with more than 255 entries wrapped the uint8_t load index, leaving + entries uninitialised that tap_free() then freed.""" + + RUID = "0000049500000495" + CONTENT_DIR = RUID[:8] + CONTENT_JSON = f"{RUID[:8]}/{RUID[8:]}.json" + + def set_content_json(self, form): + return request( + "POST", f"/content/json/set/{self.RUID}", form, {"Content-Type": "application/x-www-form-urlencoded"} + ) + + def test_playlist_with_300_entries(self): + if request("GET", f"/content/json/get/{self.RUID}")[0] == 200: + self.skipTest(f"content JSON for {self.RUID} already exists, not touching it") + playlist = { + "type": "tap", + "audio_id": 495, + "filepath": "lib://__hardening.taf", + "name": "hardening", + "files": [{"filepath": f"lib://{i}.mp3", "name": ""} for i in range(300)], + } + self.put_library("__hardening.tap", json.dumps(playlist).encode()) + try: + self.assertEqual(self.set_content_json("source=" + quote("lib://__hardening.tap"))[0], 200) + # set loads the existing content JSON first, so this call parses and frees the playlist + self.set_content_json("hide=true") + except OSError: + pass # a dropped connection is fine, tearDown checks that the server survived + finally: + if self.server_alive(): + request("POST", "/api/fileDelete", self.CONTENT_JSON) + request("POST", "/api/dirDelete", self.CONTENT_DIR) # rmdir, only removes it if empty + + +class Crawler508(Base): + """With onBlacklistDomain enabled (the default) an else-if skipped the crawler check.""" + + def test_crawler_locks_access(self): + for name, value in ( + ("security_mit.onBlacklistDomain", "true"), + ("security_mit.onCrawler", "true"), + ("security_mit.lockAccess", "true"), + ("security_mit.httpsOnly", "false"), + ): + self.assertEqual(request("POST", f"/api/settings/set/{name}", value)[0], 200) + _, text = request("GET", "/web/", headers={"User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)"}) + self.assertTrue("locked to mitigate security risks" in text, "crawler User-Agent was not detected") + + +class KeyPermissions512(Base): + """Generated private keys were left readable for other local users (default umask).""" + + def test_generated_key_is_owner_only(self): + with tempfile.TemporaryDirectory() as d: + subprocess.run( + [BIN, "--base_path", str(BASE_DIR), "--generate-client-cert", "0123456789ab", "--destination", d], + check=True, + capture_output=True, + cwd=BASE_DIR, # the CA to sign with is read from certs/server below the cwd + preexec_fn=lambda: os.umask(0o022), # what most systems use + ) + mode = os.stat(os.path.join(d, "private.der")).st_mode & 0o777 + self.assertEqual(mode & 0o077, 0, f"private.der has mode {mode:o}") + + +class Traversal513(Base): + """A relative path starting with ".." survived sanitizePath() and escaped the base directory.""" + + OUTSIDE = BASE_DIR / "data" / "__hardening513.txt" # next to data/library + + def test_dotdot_path_stays_in_root(self): + self.OUTSIDE.write_text("must survive") + try: + request("POST", "/api/fileDelete?special=library", "../__hardening513.txt") + self.assertTrue(self.OUTSIDE.exists(), "fileDelete removed a file outside the library directory") + finally: + self.OUTSIDE.unlink(missing_ok=True) + + +class Ota(Base): + """Local V3 OTA delivery: /v3/ota// serves /ota//.bin. + OTA hashes with ".." can't be tested over HTTP, the URI is canonicalised before the handler runs.""" + + FIRMWARE = BASE_DIR / "data" / "firmware" + + def setUp(self): + super().setUp() + self.files = [] + for name, value in (("cloud.enabled", "false"), ("cloud.localOta", "true")): + self.assertEqual(request("POST", f"/api/settings/set/{name}", value)[0], 200) + + def tearDown(self): + for f in self.files: + f.unlink(missing_ok=True) + for d in ("ota/1", "ota"): + try: + (self.FIRMWARE / d).rmdir() # only removes the directories the test created, if empty + except OSError: + pass + super().tearDown() + + def put_firmware(self, relative, data): + f = self.FIRMWARE / relative + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) + self.files.append(f) + + +class Range515(Ota): + """A Range start behind the end of the file made Content-Length underflow to ~4 GB.""" + + def test_range_start_past_end(self): + self.put_firmware("ota/1/abcdef.bin", b"0123456789") + status, text = request("GET", "/v3/ota/1/abcdef", headers={"Range": "bytes=100-"}, timeout=5) + self.assertEqual((status, text), (200, "0123456789")) + + +class Extract522(Base): + """extractCerts used the filename and MAC as path components without validation.""" + + def test_traversal_mac_is_rejected(self): + # "../../../../" is 12 characters and resolves to an existing directory, so an + # unfixed server fails later on the missing firmware file instead of creating anything + status, _ = request( + "POST", "/api/esp32/extractCerts?filename=" + quote("x_../../../../") + "&overwrite=false" + ) + self.assertEqual(status, 404) + + def test_patch_firmware_rejects_traversal(self): + for filename in ("x_../../../../", "../../etc/x_0123456789ab", "a/b_0123456789ab", "x_zzzzzzzzzzzz"): + status, _ = request("POST", "/api/esp32/patchFirmware?filename=" + quote(filename)) + self.assertEqual(status, 404, filename) + + +class PrivateFetch(Base): + """A tonie `pic` URL is downloaded into the image cache and served back from /cache/: it must not be + possible to make the server read internal services (SSRF), unless core.allowPrivateFetch is set.""" + + def setUp(self): + super().setUp() + import hashlib + import threading + import http.server + + self.hits = [] + hits = self.hits + + class Internal(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + hits.append(self.path) + body = b"INTERNAL-SECRET" + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + self.internal = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Internal) + threading.Thread(target=self.internal.serve_forever, daemon=True).start() + self.sha = lambda url: hashlib.sha256(url.encode()).hexdigest().upper() + self.set("tonie_json.cache_images", "true") + self.set("core.allowPrivateFetch", "false") # the server is shared with the other test of this class + + def tearDown(self): + self.internal.shutdown() + self.internal.server_close() + super().tearDown() + + def set(self, name, value): + self.assertEqual(request("POST", f"/api/settings/set/{name}", value)[0], 200) + self.assertEqual(request("GET", "/api/triggerWriteConfig")[0], 200) # a reload would drop it otherwise + time.sleep(1.5) + + def fetch_via_cache(self, name): + url = f"http://127.0.0.1:{self.internal.server_address[1]}/{name}.txt" + audio_id = 999000 + sum(map(ord, name)) # unique per name, duplicates are rejected + entry = [{"model": "ZZZ_" + name, "series": "S", "audio_id": [audio_id], "hash": [f"{audio_id:040x}"], + "title": "t", "tracks": ["a"], "pic": url}] + self.assertEqual(request("POST", "/api/toniesCustomJsonUpsert", json.dumps(entry), {"Content-Type": "application/json"})[0], 200) + self.assertEqual(request("GET", "/api/toniesJsonReload")[0], 200) + time.sleep(1) + return request("GET", f"/cache/{self.sha(url)}.txt") + + def test_private_address_is_refused_by_default(self): + status, text = self.fetch_via_cache("blocked") + self.assertEqual(self.hits, [], "the server connected to a private address") + self.assertNotIn("INTERNAL-SECRET", text) + + def test_private_address_can_be_allowed(self): + self.set("core.allowPrivateFetch", "true") + status, text = self.fetch_via_cache("allowed") + self.assertEqual((status, text), (200, "INTERNAL-SECRET")) + self.assertEqual(self.hits, ["/allowed.txt"]) + + +class Settings528(Base): + """Settings are looked up by name; every option listed in the index must be found.""" + + def test_every_listed_setting_is_found(self): + status, text = request("GET", "/api/settings/getIndex?internal=t") + self.assertEqual(status, 200) + options = json.loads(text)["options"] + self.assertGreater(len(options), 50) + missing = [] + for option in options: + if option.get("type") in ("header", None) or "ID" not in option: + continue + status, value = request("GET", "/api/settings/get/" + option["ID"]) + if status != 200: + missing.append((option["ID"], status)) + elif option.get("type") in ("string", "bool", "int", "uint") and "value" in option: + self.assertEqual(value, str(option["value"]).lower() if option["type"] == "bool" else str(option["value"]), option["ID"]) + self.assertEqual(missing, []) + + +class Secrets505(Base): + """getIndex with nolevel=t returned the LEVEL_SECRET values, i.e. the private keys.""" + + def test_index_has_no_private_keys(self): + status, text = request("GET", "/api/settings/getIndex?internal=t&nolevel=t") + self.assertEqual(status, 200) + leaked = [o["ID"] for o in json.loads(text)["options"] if "PRIVATE KEY" in str(o.get("value", ""))] + self.assertEqual(leaked, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/py/test_sse_join.py b/tests/py/test_sse_join.py new file mode 100644 index 000000000..e468ee012 --- /dev/null +++ b/tests/py/test_sse_join.py @@ -0,0 +1,91 @@ +""" +A client subscribing to /api/sse while box events are being written must get +its response header first, not a piece of an event. + + make test_py TESTS=sse_join +""" + +import os +import socket +import sys +import threading +import time +from pathlib import Path +from urllib.parse import urlparse + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from sim import rtnl # noqa: E402 +from sim.box import Box # noqa: E402 + +BASE = urlparse(os.environ.get("TEDDYCLOUD_BASE_URL", "http://127.0.0.1:80")) +SANDBOX = os.environ.get("TC_SANDBOX") +HTTPS_API_PORT = int(os.environ.get("TEDDYCLOUD_HTTPS_API_PORT", "0")) + +# The window is short and opens once per event, so it takes many subscriptions +# to hit. A slot is free again only after an event write to the closed client +# fails, so subscriptions are limited to the 8 slots turning over; refused and +# 503 attempts are paced and not counted. +SUBSCRIPTIONS = 1000 +MAX_S = 60 + +pytestmark = pytest.mark.skipif(not SANDBOX or not HTTPS_API_PORT, reason="needs the sandbox from tests/py/with_server.sh") + + +def first_line_of_sse_response(): + with socket.create_connection((BASE.hostname, BASE.port), timeout=5) as sock: + sock.sendall(f"GET /api/sse HTTP/1.1\r\nHost: {BASE.hostname}:{BASE.port}\r\n\r\n".encode()) + data = b"" + while b"\r\n" not in data: + chunk = sock.recv(256) + if not chunk: + break + data += chunk + return data.split(b"\r\n", 1)[0].decode(errors="replace") + + +def send_events(box, stop): + stream = box.rtnl(keep_open=True) + stream.settimeout(2) + sequence = 0 + try: + while not stop.is_set(): + sequence += 1 + try: + stream.sendall(rtnl.frame(rtnl.volume_changed(5, -20, sequence=sequence))) + except OSError: + return + time.sleep(0.001) + finally: + stream.close() + + +def test_sse_header_comes_first_while_events_flow(): + box = Box("deadbeef0003", SANDBOX, BASE.hostname, HTTPS_API_PORT) + stop = threading.Event() + sender = threading.Thread(target=send_events, args=(box, stop), daemon=True) + sender.start() + try: + time.sleep(0.5) # events are flowing + bad = [] + subscribed = 0 + deadline = time.time() + MAX_S + while subscribed < SUBSCRIPTIONS and time.time() < deadline: + try: + line = first_line_of_sse_response() + except (ConnectionRefusedError, socket.timeout, TimeoutError): + time.sleep(0.05) + continue + if line.startswith("HTTP/1.1 200"): + subscribed += 1 + elif line.startswith("HTTP/1.1 "): + time.sleep(0.01) # all SSE slots taken + else: + bad.append(line) + finally: + stop.set() + sender.join(5) + box.close() + assert not bad, f"{len(bad)} subscribers got event data before the response header: {bad[:5]}" + assert subscribed >= SUBSCRIPTIONS // 2, f"only {subscribed} subscriptions in {MAX_S} s, the test proves little" diff --git a/tests/py/test_sse_keepalive.py b/tests/py/test_sse_keepalive.py new file mode 100644 index 000000000..8c33197f1 --- /dev/null +++ b/tests/py/test_sse_keepalive.py @@ -0,0 +1,95 @@ +""" +Box events and the SSE keep-alive share two locks. Taken in opposite order they +deadlock: every later /api/sse request then hangs and holds an HTTP connection. + + make test_py TESTS=sse_keepalive +""" + +import http.client +import os +import socket +import sys +import threading +import time +from pathlib import Path +from urllib.parse import urlparse + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from sim import rtnl # noqa: E402 +from sim.box import Box, SseListener # noqa: E402 + +BASE = urlparse(os.environ.get("TEDDYCLOUD_BASE_URL", "http://127.0.0.1:80")) +SANDBOX = os.environ.get("TC_SANDBOX") +HTTPS_API_PORT = int(os.environ.get("TEDDYCLOUD_HTTPS_API_PORT", "0")) + +# SSE_KEEPALIVE_S in include/handler_sse.h +KEEPALIVE_S = 15 +# Each subscriber sends a keep-alive when it connects and every KEEPALIVE_S after. +# The deadlock needs a keep-alive while an event is half written, so the test +# staggers several subscribers to get many keep-alives during the events. It +# stays below SSE_MAX_CHANNELS (8) so that the final check finds a free slot. +SUBSCRIBERS = 6 + +pytestmark = pytest.mark.skipif(not SANDBOX or not HTTPS_API_PORT, reason="needs the sandbox from tests/py/with_server.sh") + + +def sse_answers(timeout=5): + conn = http.client.HTTPConnection(BASE.hostname, BASE.port, timeout=timeout) + try: + conn.request("GET", "/api/sse") + return conn.getresponse().status == 200 + except (socket.timeout, TimeoutError): + return False + finally: + conn.close() + + +def send_events(box, stop): + """Sends volume events in batches until stopped or the server stops reading.""" + stream = box.rtnl(keep_open=True) + stream.settimeout(2) + sequence = 0 + try: + while not stop.is_set(): + batch = [] + for _ in range(5): + sequence += 1 + batch.append(rtnl.frame(rtnl.volume_changed(5, -20, sequence=sequence))) + try: + stream.sendall(b"".join(batch)) + except (socket.timeout, TimeoutError, OSError): + return + time.sleep(0.002) + finally: + stream.close() + + +def test_sse_survives_box_events_across_keepalives(): + box = Box("deadbeef0002", SANDBOX, BASE.hostname, HTTPS_API_PORT) + stop = threading.Event() + sender = threading.Thread(target=send_events, args=(box, stop), daemon=True) + listeners = [] + try: + # subscribe before the events start, so that no subscriber joins mid-event + for _ in range(SUBSCRIBERS): + listeners.append(SseListener(BASE.hostname, BASE.port)) + time.sleep(KEEPALIVE_S / SUBSCRIBERS) + sender.start() + time.sleep(KEEPALIVE_S + 2) # every subscriber's next keep-alive falls into the events + stop.set() + sender.join(5) + + assert sse_answers(), "a new /api/sse request got no answer: the event stream is deadlocked" + last = 1_000_000 + box.rtnl(rtnl.frame(rtnl.tag_placed(1, sequence=last))) + deadline = time.time() + 5 + while not any(n == "rtnl-raw-log2" and isinstance(d, dict) and d.get("sequence") == last for n, d in listeners[0].events): + assert time.time() < deadline, "the subscribed client stopped receiving box events" + time.sleep(0.05) + finally: + stop.set() + for listener in listeners: + listener.close() + box.close() diff --git a/tests/py/test_taf_length.py b/tests/py/test_taf_length.py new file mode 100644 index 000000000..b8a23e116 --- /dev/null +++ b/tests/py/test_taf_length.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +""" +The total audio length of a TAF is reported as tafHeader.lengthSeconds by fileIndexV2. + +The TAF is encoded by the server from generated white noise (needs ffmpeg). + +Run via: make test_py TESTS=taf_length +""" + +import io +import json +import os +import unittest +import wave +from urllib.parse import quote + +from test_hardening import Base, request + +TRACK_SECONDS = [2, 3] +RATE = 4000 # low rate: the test upload helper only handles small bodies, the server resamples + + +def white_noise_wav(seconds): + buf = io.BytesIO() + with wave.open(buf, "wb") as w: + w.setnchannels(1) + w.setsampwidth(1) + w.setframerate(RATE) + w.writeframes(os.urandom(RATE * seconds)) + return buf.getvalue() + + +class TafLength(Base): + def test_length_seconds(self): + sources = [] + for i, seconds in enumerate(TRACK_SECONDS): + name = f"__taflength{i}.wav" + self.put_library(name, white_noise_wav(seconds)) + sources.append(name) + status, text = self.encode(sources, "__taflength.taf") + self.assertEqual(status, 200, text) + + status, text = request("GET", "/api/fileIndexV2?special=library&path=" + quote("/")) + self.assertEqual(status, 200, text) + entry = next(f for f in json.loads(text)["files"] if f["name"] == "__taflength.taf") + header = entry["tafHeader"] + + # the encoder may add a few frames, so allow one second of slack + self.assertAlmostEqual(header["lengthSeconds"], sum(TRACK_SECONDS), delta=1) + self.assertEqual(len(header["trackSeconds"]), len(TRACK_SECONDS)) + self.assertLess(header["trackSeconds"][-1], header["lengthSeconds"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tonies_custom_json_api.py b/tests/py/test_tonies_custom_json_api.py similarity index 98% rename from tests/test_tonies_custom_json_api.py rename to tests/py/test_tonies_custom_json_api.py index 3278d514b..8d6a4990e 100644 --- a/tests/test_tonies_custom_json_api.py +++ b/tests/py/test_tonies_custom_json_api.py @@ -5,12 +5,10 @@ Recommended usage: 1) Fully automated via Makefile (build + start server + run tests + stop server): - make test_api_custom_json_with_server + make test_py TESTS=custom_json 2) Against an already running TeddyCloud server: - make test_api_custom_json - or: - TEDDYCLOUD_BASE_URL=http://127.0.0.1:80 python3 tests/test_tonies_custom_json_api.py + TEDDYCLOUD_BASE_URL=http://127.0.0.1:80 python3 -m pytest tests/py/test_tonies_custom_json_api.py """ import json diff --git a/tests/py/test_track_export.py b/tests/py/test_track_export.py new file mode 100644 index 000000000..7b6450f77 --- /dev/null +++ b/tests/py/test_track_export.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" +Selected tracks of a TAF are exported as a zip of Ogg files (several tracks) +or as a single Ogg (one track), on both download routes the web UI uses: + + /content/?ogg=true&tracks=... file browser + /content/download/?...&tracks=... tonie card, tag with a lib:// source (#350) + +The TAF is encoded by the server from generated white noise (needs ffmpeg), +the zip is written by the server itself (zip_writer.c). + +Run via: make test_py TESTS=track_export +""" + +import http.client +import io +import json +import os +import unittest +import wave +import zipfile + +from test_hardening import BASE, BASE_DIR, Base, request + +TRACK_SECONDS = [2, 2, 2] +RATE = 4000 # low rate: the test upload helper only handles small bodies, the server resamples +TAF = "__trackexport.taf" +TAG_DIR, TAG_FILE = "A64E581D", "500304E0" +CUSTOM_MODEL = "TC_TEST_TRACK_EXPORT" + + +def white_noise_wav(seconds): + buf = io.BytesIO() + with wave.open(buf, "wb") as w: + w.setnchannels(1) + w.setsampwidth(1) + w.setframerate(RATE) + w.writeframes(os.urandom(RATE * seconds)) + return buf.getvalue() + + +def get(path): + conn = http.client.HTTPConnection(BASE.hostname, BASE.port, timeout=30) + try: + conn.request("GET", path) + resp = conn.getresponse() + return resp.status, resp.getheader("Content-Type", ""), resp.read() + finally: + conn.close() + + +class TrackExport(Base): + def setUp(self): + super().setUp() + sources = [] + for i, seconds in enumerate(TRACK_SECONDS): + name = f"__trackexport{i}.wav" + self.put_library(name, white_noise_wav(seconds)) + sources.append(name) + status, text = self.encode(sources, TAF) + self.assertEqual(status, 200, text) + + # a tag whose audio lives in the library, as set up by "assign to tag" in the web UI + self.tag_json = BASE_DIR / "data" / "content" / "default" / TAG_DIR / f"{TAG_FILE}.json" + self.tag_json.parent.mkdir(parents=True, exist_ok=True) + self.tag_json.write_text(json.dumps({"source": f"lib://{TAF}", "nocloud": True})) + + def tearDown(self): + self.tag_json.unlink(missing_ok=True) + body = json.dumps({"models": [CUSTOM_MODEL]}) + request("POST", "/api/toniesCustomJsonDelete", body, {"Content-Type": "application/json"}) + super().tearDown() + + def name_tracks(self, titles): + """Give the TAF track titles, like a tonies.custom.json entry does for a real tonie.""" + status, text = request("GET", "/api/fileIndexV2?path=/&special=library") + self.assertEqual(status, 200, text) + header = next(f["tafHeader"] for f in json.loads(text)["files"] if f["name"] == TAF) + entry = { + "model": CUSTOM_MODEL, + "series": "Track export test", + "title": "Track export test", + "audio_id": [header["audioId"]], + "hash": [header["sha1Hash"].upper()], + "tracks": titles, + } + status, text = request("POST", "/api/toniesCustomJsonUpsert", json.dumps([entry]), {"Content-Type": "application/json"}) + self.assertEqual(status, 200, text) + + def assert_zip_of_tracks(self, path, count): + status, content_type, body = get(path) + self.assertEqual(status, 200, body[:200]) + self.assertEqual(content_type, "application/zip") + with zipfile.ZipFile(io.BytesIO(body)) as z: + self.assertIsNone(z.testzip()) + names = z.namelist() + self.assertEqual(len(names), count, names) + for name in names: + self.assertTrue(z.read(name).startswith(b"OggS"), name) + + def assert_single_ogg(self, path): + status, _, body = get(path) + self.assertEqual(status, 200, body[:200]) + self.assertTrue(body.startswith(b"OggS")) + # one track only, not the whole TAF + _, _, whole = get(f"/content/{TAF}?special=library&ogg=true") + self.assertLess(len(body), len(whole)) + + def test_content_path(self): + self.assert_zip_of_tracks(f"/content/{TAF}?special=library&ogg=true&tracks=2%2C3", 2) + self.assert_single_ogg(f"/content/{TAF}?special=library&ogg=true&tracks=2") + + def test_content_download_tag(self): + url = f"/content/download/{TAG_DIR}/{TAG_FILE}?skip_header=true&ogg=true" + self.assert_zip_of_tracks(url + "&tracks=2%2C3", 2) + self.assert_single_ogg(url + "&tracks=2") + + def test_content_invalid_track(self): + for query in ("tracks=9", "tracks=0", "tracks=abc", "tracks=,"): + status, _, _ = get(f"/content/{TAF}?special=library&ogg=true&{query}") + self.assertEqual(status, 400, query) + + def test_zip_entry_names_are_utf8(self): + # typographic quotes as in #552, a path separator, and a title longer than the name buffer + self.name_tracks(["Käse/Mäuse – Überraschung", "3. Satz: „Allegro”", "ä" * 60 + " Ende"]) + status, content_type, body = get(f"/content/{TAF}?special=library&ogg=true&tracks=1%2C2%2C3") + self.assertEqual(status, 200, body[:200]) + self.assertEqual(content_type, "application/zip") + with zipfile.ZipFile(io.BytesIO(body)) as z: + self.assertIsNone(z.testzip()) + infos = z.infolist() + # general purpose bit 11: the names are UTF-8 whatever the locale of the server + self.assertTrue(all(i.flag_bits & 0x800 for i in infos)) + names = [i.filename for i in infos] + self.assertEqual(names[0], "01 Käse_Mäuse – Überraschung.ogg") + self.assertEqual(names[1], "02 3. Satz: „Allegro”.ogg") + # cut at a character boundary, never in the middle of a UTF-8 sequence + self.assertEqual(names[2], "03 " + "ä" * 47 + ".ogg") + + def test_content_download_tag_invalid_track(self): + for query in ("tracks=9", "tracks=0", "tracks=abc", "tracks=,"): + status, _, _ = get(f"/content/download/{TAG_DIR}/{TAG_FILE}?skip_header=true&ogg=true&{query}") + self.assertEqual(status, 400, query) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/py/test_web_legacy_gone.py b/tests/py/test_web_legacy_gone.py new file mode 100644 index 000000000..85c70c378 --- /dev/null +++ b/tests/py/test_web_legacy_gone.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +Web UI tests: the legacy admin GUI is gone (#489). + +Verifies the sunset of the legacy admin GUI: +- "/" and the parser-rewritten "index.shtm" always redirect to the web UI at /web +- "/web/" serves the built SPA +- the removed "/legacy.html" is no longer served + +Recommended usage: + +1) Fully automated via Makefile (build + start server + run tests + stop server): + make test_py TESTS=legacy_gone + +2) Against an already running TeddyCloud server: + TEDDYCLOUD_BASE_URL=http://127.0.0.1:80 python3 tests/py/test_web_legacy_gone.py +""" + +import http.client +import os +import unittest +import urllib.parse + + +BASE_URL = os.environ.get("TEDDYCLOUD_BASE_URL", "http://127.0.0.1:80").rstrip("/") +_PARSED_URL = urllib.parse.urlsplit(BASE_URL) +HOST = _PARSED_URL.hostname or "127.0.0.1" +PORT = _PARSED_URL.port or 80 + + +class WebLegacyGoneTests(unittest.TestCase): + @classmethod + def request(cls, path): + """Return (status, headers, body) without following redirects.""" + connection = http.client.HTTPConnection(HOST, PORT, timeout=10) + try: + connection.request("GET", path) + response = connection.getresponse() + headers = {name.lower(): value for name, value in response.getheaders()} + body = response.read().decode("utf-8", errors="replace") + return response.status, headers, body + finally: + connection.close() + + def test_root_redirects_to_web(self): + status, headers, _ = self.request("/") + self.assertEqual(status, 301) + self.assertEqual(headers.get("location"), "/web") + + def test_web_serves_spa(self): + status, _, body = self.request("/web/") + self.assertEqual(status, 200) + self.assertIn('id="root"', body) + + def test_legacy_admin_gui_is_gone(self): + status, _, body = self.request("/legacy.html") + self.assertNotEqual(status, 200) + self.assertNotIn("library/react.development.js", body) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/py/with_server.sh b/tests/py/with_server.sh new file mode 100755 index 000000000..a11bdad37 --- /dev/null +++ b/tests/py/with_server.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# +# Starts a throwaway teddycloud server on the given ports, waits for it to +# answer on the HTTP port, runs the given command, and always stops the +# server afterwards - regardless of whether the command succeeds. +# +# The server runs in a sandbox: a copy of a template base directory (--base_path), +# so tests never touch the dev config/data/certs. The sandbox path is exported +# as TC_SANDBOX and removed afterwards. Ports given as 0 are picked free; the +# ports and TEDDYCLOUD_BASE_URL are exported to the command. +# +# Usage: +# with_server.sh -- +# +# Exists so integration tests under tests/py/ don't each duplicate the +# same start/wait/cleanup logic in the Makefile. +set -euo pipefail + +usage() { + echo "Usage: $0 -- " >&2 + exit 1 +} + +[ "$#" -ge 5 ] || usage +HTTP_PORT="$1" +HTTPS_WEB_PORT="$2" +HTTPS_API_PORT="$3" +READY_TIMEOUT="$4" +shift 4 +[ "$1" = "--" ] || usage +shift + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BIN="$REPO_ROOT/bin/teddycloud" + +# a port of 0 means: pick a free one (so tests don't collide with whatever runs on the dev machine) +free_port() { + python3 -c ' +import socket +s = socket.socket() +s.bind(("127.0.0.1", 0)) +print(s.getsockname()[1]) +' +} +[ "$HTTP_PORT" != 0 ] || HTTP_PORT="$(free_port)" +[ "$HTTPS_WEB_PORT" != 0 ] || HTTPS_WEB_PORT="$(free_port)" +[ "$HTTPS_API_PORT" != 0 ] || HTTPS_API_PORT="$(free_port)" +export TC_HTTP_PORT="$HTTP_PORT" TC_HTTPS_WEB_PORT="$HTTPS_WEB_PORT" TC_HTTPS_API_PORT="$HTTPS_API_PORT" +export TEDDYCLOUD_BASE_URL="http://127.0.0.1:$HTTP_PORT" TEDDYCLOUD_HTTPS_API_PORT="$HTTPS_API_PORT" + +for port in "$HTTP_PORT" "$HTTPS_WEB_PORT" "$HTTPS_API_PORT"; do + if ! python3 -c ' +import socket, sys +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +s.settimeout(0.2) +sys.exit(0 if s.connect_ex(("127.0.0.1", int(sys.argv[1]))) else 1) +' "$port"; then + echo "[ERR] Test port $port is already in use. Free it first, e.g.:" >&2 + echo " ps -ef | awk '/teddycloud/ && !/awk/ {print}'" >&2 + echo " kill " >&2 + exit 1 + fi +done + +tmp_log="$(mktemp /tmp/teddycloud_test_XXXXXX.log)" +# no tonies.json download from the internet, tests must not depend on the network +PORT_SETTINGS="core.tonies_json_auto_update=false,core.server.http_port=$HTTP_PORT,core.server.https_web_port=$HTTPS_WEB_PORT,core.server.https_api_port=$HTTPS_API_PORT" + +# Generating the server certificates takes minutes, hence the template is +# created once and reused. It is regenerated when the certificate code changes +# (stamp in .complete), or delete the directory. CI caches it with the same key. +TEMPLATE="${TC_TEST_TEMPLATE:-$REPO_ROOT/bin/test-template}" +WEB_UI="$REPO_ROOT/data/www/web" +# bump the layout number when the template directory structure below changes +TEMPLATE_LAYOUT=4 +TEMPLATE_STAMP="$( (echo "$TEMPLATE_LAYOUT"; cat "$REPO_ROOT/src/cert.c" "$REPO_ROOT/include/cert.h") | sha256sum | cut -d' ' -f1)" +srv_pid= +LINEBUF=; command -v stdbuf >/dev/null && LINEBUF="stdbuf -oL -eL" # the log is otherwise lost when the server is killed +SANDBOX= + +wait_ready() { # [any] (any: every HTTP answer counts, not only 200) + local deadline=$(($(date +%s) + $2)) code + while [ "$(date +%s)" -lt "$deadline" ]; do + kill -0 "$1" >/dev/null 2>&1 || return 1 + code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$HTTP_PORT/web/" || true) + [ "$code" = "200" ] && return 0 + [ "${3:-}" = any ] && [ "$code" != "000" ] && return 0 + sleep 0.2 + done + return 1 +} + +start_server() { # + (cd "$1" && exec $LINEBUF "$BIN" --base_path "$1" --config-set "$PORT_SETTINGS") >"$tmp_log" 2>&1 & + srv_pid=$! +} + +stop_server() { + if [ -n "$srv_pid" ]; then + kill "$srv_pid" >/dev/null 2>&1 || true + wait "$srv_pid" 2>/dev/null || true # the server may write config on shutdown + srv_pid= + fi +} + +fail_log() { + echo "[ERR] $1. Log:" >&2 + sed -n '1,140p' "$tmp_log" >&2 + exit 1 +} + +cleanup() { + stop_server + [ -n "$SANDBOX" ] && rm -rf "$SANDBOX" + rm -f "$tmp_log" +} +trap cleanup EXIT INT TERM + +if [ "$(cat "$TEMPLATE/.complete" 2>/dev/null)" != "$TEMPLATE_STAMP" ]; then + echo "[INFO] Creating test template in $TEMPLATE (generates certificates, takes a while)..." >&2 + rm -rf "$TEMPLATE" + mkdir -p "$TEMPLATE/config" "$TEMPLATE/data/www" "$TEMPLATE/data/content/default" "$TEMPLATE/data/firmware" "$TEMPLATE/data/cache" "$TEMPLATE/data/library" "$TEMPLATE/certs/server" "$TEMPLATE/certs/client" "$TEMPLATE/certs/server_tb2" "$TEMPLATE/certs/client/tb2" + start_server "$TEMPLATE" + if ! wait_ready "$srv_pid" 900 any; then + stop_server + rm -rf "$TEMPLATE" + fail_log "Template server did not become ready" + fi + stop_server + # the cloud client needs a client certificate (normally a genuine box's); any one will do for a fake cloud + "$BIN" --base_path "$TEMPLATE" --generate-client-cert 000000000000 --destination "$TEMPLATE/certs/client" >/dev/null + openssl x509 -in "$TEMPLATE/certs/server/ca-root.pem" -outform der -out "$TEMPLATE/certs/client/ca.der" + echo "$TEMPLATE_STAMP" >"$TEMPLATE/.complete" +fi + +SANDBOX="$(mktemp -d /tmp/teddycloud_sandbox_XXXXXX)" +cp -a "$TEMPLATE/." "$SANDBOX/" +rm -f "$SANDBOX/.complete" +# the web UI is a symlink, so the template stays relocatable (and cacheable) +[ -d "$WEB_UI" ] && ln -s "$WEB_UI" "$SANDBOX/data/www/web" +export TC_SANDBOX="$SANDBOX" TC_TEMPLATE="$TEMPLATE" # TC_TEMPLATE: tests may cache things signed by the template CA (box certs) there + +start_server "$SANDBOX" + +if ! wait_ready "$srv_pid" "$READY_TIMEOUT"; then + fail_log "Test server did not become ready on port $HTTP_PORT within ${READY_TIMEOUT}s" +fi + +rc=0 +"$@" || rc=$? +if [ "$rc" -ne 0 ]; then + stop_server # the log is buffered until the server exits + echo "[ERR] Test command failed ($rc). Server log, last lines:" >&2 + # an ASan report is long, show it from its start + if grep -q "ERROR: AddressSanitizer" "$tmp_log"; then + sed -n '/ERROR: AddressSanitizer/,/^SUMMARY/p' "$tmp_log" | head -60 >&2 + fi + tail -n 40 "$tmp_log" >&2 + cp "$tmp_log" "$REPO_ROOT/bin/test-last-server.log" && echo "[ERR] full server log: bin/test-last-server.log" >&2 +fi +exit "$rc" diff --git a/tests/sim/__init__.py b/tests/sim/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/sim/box.py b/tests/sim/box.py new file mode 100644 index 000000000..33e3e4244 --- /dev/null +++ b/tests/sim/box.py @@ -0,0 +1,234 @@ +"""Toniebox simulator: connects to a (sandboxed) TeddyCloud server like a box does, +with a client certificate signed by the server's own CA and no SNI.""" + +import http.client +import json +import os +import shutil +import socket +import ssl +import subprocess +import tempfile +import threading +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +BIN = REPO / "bin" / "teddycloud" + + +class Box: + def __init__(self, mac, base_dir, host, https_port, user_agent="TB/sim", profile=None): + if profile is not None: + mac, user_agent = profile.mac, profile.user_agent + if profile.generation == 2: + raise NotImplementedError("TB2 needs the EC client certificate of the server_tb2 CA and its TLS chain, not simulated yet") + self.profile = profile + self._contexts = {} + self.mac = mac.lower() + self.host = host + self.port = https_port + self.user_agent = user_agent + self._dir = tempfile.TemporaryDirectory(prefix="tcbox_") + # the sandbox is a copy of the template, so they share the CA: box certificates + # (slow RSA keygen) are cached in the template and reused by every test run + template = os.environ.get("TC_TEMPLATE") + cache = Path(template) / "boxcerts" / self.mac if template else None + if cache and (cache / "client.pem").exists(): + self.cert, self.key = cache / "client.pem", cache / "private.pem" + return + d = Path(self._dir.name) + # the CLI writes ca.der/client.der/private.der; Python's ssl needs PEM + subprocess.run([BIN, "--base_path", base_dir, "--generate-client-cert", self.mac, "--destination", d], + check=True, capture_output=True) + subprocess.run(["openssl", "x509", "-inform", "der", "-in", d / "client.der", "-outform", "pem", "-out", d / "client.pem"], check=True) + subprocess.run(["openssl", "rsa", "-inform", "der", "-in", d / "private.der", "-outform", "pem", "-out", d / "private.pem"], + check=True, capture_output=True) + self.cert, self.key = d / "client.pem", d / "private.pem" + if cache: + cache.mkdir(parents=True, exist_ok=True) + for f in ("client.pem", "private.pem"): + shutil.copy(d / f, cache / f) + + def close(self): + self._dir.cleanup() + + def connect(self, use_cert=True, session=None): + # IP literal: no SNI, like a real box (the server picks other certs when SNI is present) + ctx = self._contexts.get(use_cert) or self._context(use_cert) + return ctx.wrap_socket(socket.create_connection((self.host, self.port), timeout=5), + server_hostname=(self.profile.tls.sni if self.profile and self.profile.tls.sni else None), + session=session) + + def _context(self, use_cert): + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + tls = self.profile.tls if self.profile else None + if tls: + ctx.minimum_version = getattr(ssl.TLSVersion, tls.min_version) + ctx.maximum_version = getattr(ssl.TLSVersion, tls.max_version) + if tls.ciphers: + ctx.set_ciphers(tls.ciphers) + if use_cert: + ctx.load_cert_chain(certfile=self.cert, keyfile=self.key) + self._contexts[use_cert] = ctx + return ctx + + def request(self, method, path, body=b"", headers=None, use_cert=True, session=None): + """One request on a fresh connection. Returns (status, headers, body).""" + s = self.connect(use_cert, session) + try: + head = f"{method} {path} HTTP/1.1\r\nHost: {self.host}\r\nUser-Agent: {self.user_agent}\r\nConnection: close\r\n" + head += f"Content-Length: {len(body)}\r\n" + "".join(f"{k}: {v}\r\n" for k, v in (headers or {}).items()) + s.sendall(head.encode() + b"\r\n" + body) + data = b"" + while chunk := s.recv(65536): + data += chunk + finally: + s.close() + raw_head, _, payload = data.partition(b"\r\n\r\n") + lines = raw_head.decode(errors="replace").split("\r\n") + return int(lines[0].split()[1]), dict(l.split(": ", 1) for l in lines[1:] if ": " in l), payload + + def keep_alive(self): + """A connection that sends requests like the firmware does, see BoxConnection.""" + return BoxConnection(self) + + def openssl_request(self, method, path): + """One request over openssl s_client with the profile's TLS parameters (signature algorithms, + curves), which Python's ssl module cannot set. Returns the raw response.""" + req = f"{method} {path} HTTP/1.1\r\nHost: {self.host}\r\nUser-Agent: {self.user_agent}\r\nConnection: close\r\n\r\n" + cmd = ["openssl", "s_client", "-connect", f"{self.host}:{self.port}", "-noservername", "-quiet", "-ign_eof", + "-cert", str(self.cert), "-key", str(self.key), *self.profile.tls.openssl] + return subprocess.run(cmd, input=req.encode(), capture_output=True, timeout=10).stdout + + def rtnl(self, *frames, keep_open=False): + """Sends raw RTNL frames (see rtnl.frame). Returns the socket if keep_open.""" + s = self.connect() + for f in frames: + s.sendall(f) + if keep_open: + return s + time.sleep(0.3) # a real box keeps the connection open; let the server read before the close + s.close() + + +class BoxConnection: + """Keep-alive connection with the request format of the profile: Host and User-Agent, the profile's + extra headers, Content-Length only with a body, and the body in its own TLS record.""" + + def __init__(self, box): + self.box = box + self.sock = box.connect() + self._buf = b"" + + def request(self, method, path, body=b"", headers=None): + p = self.box.profile + lines = [f"{method} {path} HTTP/1.1", f"Host: {self.box.host}", f"User-Agent: {self.box.user_agent}"] + lines += [f"{k}: {v.format(mac=self.box.mac)}" for k, v in p.extra_headers] + lines += [f"{k}: {v}" for k, v in (headers or {}).items()] + if body: + lines.append(f"Content-Length: {len(body)}") + self.sock.sendall(("\r\n".join(lines) + "\r\n\r\n").encode()) + if body: + self.sock.sendall(body) + return self._response() + + def _response(self): + while b"\r\n\r\n" not in self._buf: + self._recv() + raw_head, self._buf = self._buf.split(b"\r\n\r\n", 1) + self.header_size = len(raw_head) + 4 + lines = raw_head.decode(errors="replace").split("\r\n") + headers = dict(l.split(": ", 1) for l in lines[1:] if ": " in l) + length = int(headers.get("Content-Length", 0)) + while len(self._buf) < length: + self._recv() + body, self._buf = self._buf[:length], self._buf[length:] + return int(lines[0].split()[1]), headers, body + + def _recv(self): + chunk = self.sock.recv(65536) + if not chunk: + raise ConnectionError("server closed the connection") + self._buf += chunk + + def finish(self, ending, timeout=5): + """Ends the connection like the firmware does (see BoxProfile.endings). Returns once the server has + closed its side too.""" + self.sock.settimeout(timeout) + if ending == "close_notify": + raw = self.sock.unwrap() # raises unless the server answers with its close_notify + else: + self.sock.shutdown(socket.SHUT_WR) # a plain FIN: SSLSocket.shutdown drops the TLS layer first + raw = self.sock + while raw.recv(4096): + pass + raw.close() + + def close(self): + self.sock.close() + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + +class SseListener: + """Collects the server-sent events of /api/sse (plain HTTP port) in the background.""" + + def __init__(self, host, http_port): + self.events = [] # (event name, data) + self._conn = http.client.HTTPConnection(host, http_port, timeout=30) + self._conn.request("GET", "/api/sse") + self._resp = self._conn.getresponse() + self._stop = False + self._thread = threading.Thread(target=self._read, daemon=True) + self._thread.start() + + def _read(self): + # the response is chunked and the server writes events in many small pieces: + # let http.client dechunk (read1) and split the events on the blank line + buf = b"" + try: + while not self._stop: + chunk = self._resp.read1(65536) + if not chunk: + return + buf += chunk + while b"\n\n" in buf: + raw, buf = buf.split(b"\n\n", 1) + name = data = None + for line in raw.decode(errors="replace").split("\n"): + if line.startswith("event:"): + name = line[6:].strip() + elif line.startswith("data:"): + data = line[5:].strip() + if name and data: + try: + self.events.append((name, json.loads(data)["data"])) + except (ValueError, KeyError): + self.events.append((name, data)) + except (OSError, ValueError, AttributeError, http.client.HTTPException): # closed under our feet + pass + + def wait_for(self, name, timeout=5, count=1): + deadline = time.time() + timeout + while time.time() < deadline: + found = [d for n, d in self.events if n == name] + if len(found) >= count: + return found + time.sleep(0.05) + raise AssertionError(f"no '{name}' event within {timeout}s, got {[n for n, _ in self.events]}") + + def close(self): + self._stop = True + try: + self._conn.sock.shutdown(socket.SHUT_RDWR) # unblocks the reader thread + except (OSError, AttributeError): + pass + self._thread.join(2) + self._conn.close() diff --git a/tests/sim/freshness.py b/tests/sim/freshness.py new file mode 100644 index 000000000..4bb5c0400 --- /dev/null +++ b/tests/sim/freshness.py @@ -0,0 +1,30 @@ +"""Freshness check (proto/toniebox.pb.freshness-check.*.proto) without a protobuf package.""" + +import struct + +from .rtnl import _bytes, _tag + + +def request(tonies): + """TonieFreshnessCheckRequest; tonies = [(uid, audio_id)]. 16 bytes per tonie, as the boxes send it.""" + return b"".join(_bytes(1, _tag(1, 1) + struct.pack("> 3, key & 7 + if wire == 0: + while data[pos] & 0x80: + pos += 1 + pos += 1 + elif wire == 1: + pos += 8 + else: + raise ValueError(f"unexpected wire type {wire} at {pos - 1}") + fields.append(number) + return fields diff --git a/tests/sim/profiles.py b/tests/sim/profiles.py new file mode 100644 index 000000000..2d76bf13c --- /dev/null +++ b/tests/sim/profiles.py @@ -0,0 +1,189 @@ +"""Hardware profiles of the Toniebox generations. + +CC3200 and ESP32 are filled in from captures of real boxes on their original firmware +(CC3200 3.1.0 BF2, 3.3.0, 3.4.0; ESP32 v5.233.0), decrypted with the server's +core.sslkeylogfile. CC3235 and TB2 only have what is derivable from the server code (user +agent detection in server.c, RTNL function codes in include/handler_rtnl.h); what needs a +capture is a stub: `stubs` names it, the tests skip with that reason. +""" + +from dataclasses import dataclass, field + +from . import rtnl, rtnl_captured + +# include/settings.h +BOX_CC3200, BOX_CC3235, BOX_ESP32, BOX_TB2 = 1, 2, 3, 4 +GENERATION_TB1, GENERATION_TB2 = 1, 2 + +STUB_BEHAVIOUR = { + "ota": "OTA check/download (/v1/ota, /v3/ota) as the real firmware sends it", + "content": "content download incl. headers/ranges of the real firmware", + "freshness": "freshness check request/response of the real firmware", + "rtnl_stream": "real RTNL stream: function codes, ordering, framing, what the hardware sends at boot", + "client_cert": "the box's original client certificate (Boxine CA chain, key type/size, extensions) instead of a generated one; CC3200 vs. CC3235 vs. ESP32 differ", + "tls": "TLS behaviour of the real stack: cipher suites, SNI use, session resumption, renegotiation", +} + +# signature algorithms (TLS SignatureScheme) +RSA_PKCS1_SHA1, RSA_PKCS1_SHA256, RSA_PKCS1_SHA384 = 0x0201, 0x0401, 0x0501 + + +@dataclass(frozen=True) +class TlsProfile: + """How the box's TLS stack connects. + + client_hello is the captured ClientHello record (random zeroed), sent as it is by + tests/sim/tls_hello.py. Python's ssl module cannot send it, so max/min_version, ciphers and + sni only approximate it for the HTTP tests, and `openssl` holds s_client arguments for a full + handshake with the box's signature algorithms (OpenSSL 3 has no RC4).""" + + max_version: str = "TLSv1_2" # attribute names of ssl.TLSVersion + min_version: str = "TLSv1_2" + ciphers: str = "" # OpenSSL cipher string, "" = Python default + sni: str = "" # server_hostname, "" = none (the boxes send none; the server then picks the TB1 cert) + client_hello: str = "" + cipher: int = 0 # suite TeddyCloud picks for client_hello (the same in the capture) + cert_verify: int = 0 # signature algorithm of the box's CertificateVerify + openssl: tuple = () + + +# 3.1.0 BF2, 3.3.0 and 3.4.0 send the same hello: +# TLS 1.2 only; ECDHE-RSA-AES256-SHA, ECDHE-RSA-RC4-SHA, DHE-RSA-AES256-SHA, AES256-SHA, RC4-SHA, RC4-MD5, +# AES128-SHA256, AES256-SHA256, ECDHE-RSA-AES128-SHA256, ECDHE-ECDSA-AES128-SHA256; only the extensions +# signature_algorithms (SHA-1 and SHA-256) and supported_groups (secp160r1 .. secp521r1); no SNI, no +# session ticket, no renegotiation_info, no extended master secret. Signs its CertificateVerify with +# RSA/SHA-1 and does not resume sessions (session_id always empty, also when the server offers one). +CC3200_HELLO = ( + "16030300630100005f0303" + "00" * 32 + "000014c014c0110039003500050004003c003dc027c02301000022" + "000d000c000a02010202020304010403000a000e000c001000130015001700180019" +) +# what the CC3200 offers and OpenSSL 3 still has, in the box's order +CC3200_SUITES = "ECDHE-RSA-AES256-SHA:DHE-RSA-AES256-SHA:AES256-SHA:AES128-SHA256:AES256-SHA256:ECDHE-RSA-AES128-SHA256" + +# TLS 1.2, 72 suites (GCM, CCM, CBC, ARIA, Camellia), groups x25519, secp256/384/521r1, +# brainpool; signature algorithms SHA-512/384/256 (no SHA-1); ec_point_formats, encrypt_then_mac, +# extended_master_secret, session_ticket (empty); no SNI (the Host header has the name). +# Signs its CertificateVerify with RSA/SHA-384. +ESP32_HELLO = ( + "16030300f7010000f30303" + "00" * 32 + "000092c02cc030009fc0adc09fc024c028006bc00ac0140039c0afc0a3c05dc061c053c049" + "c04dc045c02bc02f009ec0acc09ec023c0270067c009c0130033c0aec0a2c05cc060c052c048c04cc044009dc09d003d0035" + "c032c02ac00fc02ec026c005c0a1c05fc063c051c04bc04fc03d009cc09c003c002fc031c029c00ec02dc025c004c0a0c05e" + "c062c050c04ac04ec03c00ff01000038000a0010000e001d001700180019001a001b001c000d000e000c0603060105030501" + "04030401000b00020100001600000017000000230000" +) + +LEGACY_RSA_CBC = CC3200_SUITES + ":@SECLEVEL=0" + + +@dataclass(frozen=True) +class BoxProfile: + name: str + mac: str # unique per profile, becomes the overlay CN + user_agent: str + box_ic: int # expected result of the server's UA detection + generation: int + # RTNL function codes known for this hardware (log2 function_group/function) + tag_valid: int = rtnl.FUNC_TAG_VALID_CC3200 + tag_invalid: int = rtnl.FUNC_TAG_INVALID_CC3200 + volume_change: int = rtnl.FUNC_VOLUME_CHANGE_CC3200 + tls: TlsProfile = field(default_factory=TlsProfile) + # HTTP as the firmware sends it: keep-alive, headers Host and User-Agent (+ extra_headers), Content-Length + # only with a body, the body in its own TLS record + extra_headers: tuple = () # (name, value); "{mac}" is replaced + boot: tuple = () # GET requests after power on, in order, before the freshness check + freshness_batches: tuple = () # tonies per POST /v1/freshness-check + rtnl_records: str = "" # how the RTNL stream is cut into TLS records: "split" or "batched", see tests + rtnl_log2: tuple = () # frame shapes the box sends, see rtnl_captured.py + rtnl_log3: tuple = () + endings: tuple = () # how the box ends a connection: "close_notify" (then FIN) and/or "fin" (FIN only) + # seen by giving a real box wrong answers (0/False = not known) + claim_status: int = 0 # keeps the connection open after it; after a 200 the box closes it + header_accepted: int = 0 # largest response header (bytes) the box took; it dropped a 776-byte one + freshness_answer_accepted: int = 0 # largest freshness answer (bytes) it took; it dropped a 1121-byte one + resume: bool = False # continues an aborted download with Range: bytes=N- and If-Range: