diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c5ebec..799f30f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,8 @@ jobs: outputs: code: ${{ steps.filter.outputs.code }} steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: filter with: filters: | @@ -51,12 +51,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 cache: npm @@ -82,7 +82,7 @@ jobs: - name: Upload coverage if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-report path: coverage/ diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 7d81309..9c89f95 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -7,18 +7,19 @@ on: - '.github/workflows/**' - '.github/actions/**' -permissions: - contents: write - pull-requests: write +permissions: read-all jobs: auto-merge: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write if: github.actor == 'dependabot[bot]' steps: - name: Fetch Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v2 + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6ebeaed..a2a6c13 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -25,9 +25,7 @@ concurrency: group: docker-publish cancel-in-progress: false -permissions: - contents: read - packages: write +permissions: read-all env: REGISTRY: ghcr.io @@ -37,6 +35,9 @@ jobs: build-and-push: runs-on: ubuntu-latest timeout-minutes: 20 + permissions: + contents: read + packages: write # For workflow_run: only run if the Release workflow succeeded AND a new # release was actually created (check for a tag matching the latest release). # For release/workflow_dispatch: always run. @@ -47,7 +48,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Determine version id: version @@ -83,15 +84,15 @@ jobs: - name: Set up QEMU if: steps.version.outputs.skip != 'true' - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx if: steps.version.outputs.skip != 'true' - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to GHCR if: steps.version.outputs.skip != 'true' - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -100,7 +101,7 @@ jobs: - name: Extract metadata if: steps.version.outputs.skip != 'true' id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -109,7 +110,7 @@ jobs: - name: Build and push if: steps.version.outputs.skip != 'true' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 30fc7c3..f24af1e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,10 +8,7 @@ on: - 'package.json' workflow_dispatch: -permissions: - contents: read - pages: write - id-token: write +permissions: read-all concurrency: group: pages @@ -21,9 +18,9 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 cache: npm @@ -34,18 +31,21 @@ jobs: - run: npm run docs:build - - uses: actions/configure-pages@v5 + - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 with: path: docs/.vitepress/dist deploy: needs: build runs-on: ubuntu-latest + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/release-pr-checks.yml b/.github/workflows/release-pr-checks.yml index b8c1552..1e6d8d6 100644 --- a/.github/workflows/release-pr-checks.yml +++ b/.github/workflows/release-pr-checks.yml @@ -29,10 +29,7 @@ on: workflows: [Release] types: [completed] -permissions: - contents: read - statuses: write - security-events: write +permissions: read-all jobs: # Gate: only run for release-please PRs. Finds PR details from either trigger. @@ -73,12 +70,12 @@ jobs: timeout-minutes: 10 steps: - name: Checkout PR code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: ref: ${{ needs.should-run.outputs.head_sha }} - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 cache: npm @@ -105,6 +102,8 @@ jobs: runs-on: ubuntu-latest if: always() needs: [should-run, lint-and-check] + permissions: + statuses: write steps: - name: Check CI status run: | @@ -152,23 +151,24 @@ jobs: timeout-minutes: 15 permissions: security-events: write + contents: read steps: - name: Checkout PR code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: ref: ${{ needs.should-run.outputs.head_sha }} - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 with: languages: javascript-typescript config-file: .github/codeql/codeql-config.yml queries: security-extended - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 with: category: '/language:javascript-typescript' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63c40cb..75769ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,20 +10,21 @@ on: # That workflow posts commit statuses to the PR head SHA so branch protection # sees the results. Docker publishing uses docker-publish.yml (also workflow_run). -permissions: - contents: write - pull-requests: write +permissions: read-all jobs: release-please: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write outputs: pr: ${{ steps.rp.outputs.pr }} release_created: ${{ steps.rp.outputs.release_created }} steps: - name: Run release-please id: rp - uses: googleapis/release-please-action@v4 + uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 with: config-file: release-please-config.json manifest-file: .release-please-manifest.json diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index fc455d7..3659312 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -18,18 +18,18 @@ jobs: id-token: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: persist-credentials: false - name: Run Scorecard - uses: ossf/scorecard-action@v2.4.3 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif publish_results: true - name: Upload SARIF - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 with: sarif_file: results.sarif diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 56e8ae4..cdff3ce 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -39,8 +39,8 @@ jobs: outputs: security_relevant: ${{ steps.filter.outputs.security_relevant }} steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: filter with: filters: | @@ -64,20 +64,20 @@ jobs: security-events: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 with: languages: javascript-typescript config-file: .github/codeql/codeql-config.yml queries: security-extended - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 with: category: '/language:javascript-typescript' @@ -90,7 +90,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 @@ -109,10 +109,10 @@ jobs: if: needs.changes.outputs.security_relevant == 'true' || github.event_name == 'schedule' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 cache: npm @@ -130,13 +130,13 @@ jobs: if: needs.changes.outputs.security_relevant == 'true' || github.event_name == 'schedule' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . push: false @@ -146,7 +146,7 @@ jobs: cache-to: type=gha,mode=max - name: Upload image artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: docker-image path: /tmp/scrolly-image.tar @@ -158,13 +158,13 @@ jobs: needs: [build-image] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: sparse-checkout: .trivyignore sparse-checkout-cone-mode: false - name: Download image artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: docker-image path: /tmp @@ -193,7 +193,7 @@ jobs: image: semgrep/semgrep:1.112.0 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Run Semgrep run: semgrep scan --config=auto --sarif --output=semgrep-results.sarif . @@ -202,7 +202,7 @@ jobs: - name: Upload Semgrep SARIF if: always() - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 with: sarif_file: semgrep-results.sarif category: semgrep @@ -216,10 +216,10 @@ jobs: github.event.pull_request.head.repo.fork != true steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Download image artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: docker-image path: /tmp @@ -250,7 +250,7 @@ jobs: exit 1 - name: Run OWASP ZAP baseline scan - uses: zaproxy/action-baseline@v0.14.0 + uses: zaproxy/action-baseline@7c4deb10e6261301961c86d65d54a516394f9aed # v0.14.0 with: target: http://localhost:3000 rules_file_name: .zap/rules.tsv diff --git a/.gitignore b/.gitignore index defdae2..5480696 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ docs/.vitepress/dist *.swo *~ .vscode/ +REFACTOR-TRACKER.md diff --git a/SECURITY.md b/SECURITY.md index 7d3715a..0b74bc5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,9 +10,42 @@ **Please do not report security vulnerabilities through public GitHub issues.** -Instead, please report them via [GitHub Security Advisories](https://github.com/312-dev/scrolly/security/advisories/new). +Instead, please report them via [GitHub Security Advisories](https://github.com/312-dev/scrolly/security/advisories/new). This ensures that sensitive vulnerability information is handled privately and responsibly. -You should receive a response within 48 hours. If the issue is confirmed, a patch will be released as soon as possible. +### What to Include + +When reporting a vulnerability, please provide: + +- A description of the vulnerability and its potential impact +- Detailed steps to reproduce the issue +- Any proof-of-concept code or screenshots +- The affected version(s) and configuration +- Your suggested fix or mitigation, if any + +## Vulnerability Disclosure Policy + +We follow a coordinated vulnerability disclosure process: + +1. **Reporter submits** a vulnerability via [GitHub Security Advisories](https://github.com/312-dev/scrolly/security/advisories/new) +2. **We acknowledge** receipt within 48 hours +3. **We investigate** and validate the report within 7 days +4. **We develop and test** a fix for confirmed vulnerabilities +5. **We release** a patch and publish a security advisory +6. **Public disclosure** occurs after the fix is released + +We ask that reporters refrain from publicly disclosing the vulnerability until a fix has been released. + +## Response Timeline + +| Action | Timeframe | +|--------|-----------| +| Acknowledgement of report | Within 48 hours | +| Initial assessment and validation | Within 7 days | +| Status update to reporter | Every 7 days until resolved | +| Patch development and release | Within 30 days for critical/high severity | +| Public disclosure | After patch release | + +If a vulnerability is accepted, we will work with the reporter to coordinate disclosure timing. If a vulnerability is declined (e.g., out of scope or not reproducible), we will explain our reasoning. ## Security Practices @@ -29,16 +62,18 @@ You should receive a response within 48 hours. If the issue is confirmed, a patc ### Infrastructure - Multi-stage Docker builds with minimal runtime image - Health check endpoint at `/api/health` -- Non-root process execution recommended +- Non-root process execution in containers ### CI/CD Security - **CodeQL SAST** — Static analysis on every PR and push +- **Semgrep** — Additional static analysis with community rules - **Gitleaks** — Secret detection across full git history - **npm audit** — Dependency vulnerability scanning -- **Trivy** — Container image scanning (MEDIUM+ severity) -- **OWASP ZAP** — Dynamic application security testing +- **Trivy** — Container image scanning (HIGH/CRITICAL severity) +- **OWASP ZAP** — Dynamic application security testing (DAST) - **OpenSSF Scorecard** — Supply chain security assessment -- **Dependabot** — Automated dependency updates +- **Dependabot** — Automated dependency updates (npm and GitHub Actions) +- **GitHub Actions pinned by SHA** — All workflow actions pinned to commit hashes ### Self-Hosting Security Checklist - [ ] Generate a strong `SESSION_SECRET` (32+ random bytes) diff --git a/package-lock.json b/package-lock.json index 1e897d1..5871ebc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -218,6 +218,7 @@ "integrity": "sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", @@ -2551,6 +2552,7 @@ "integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -2593,6 +2595,7 @@ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", @@ -2632,6 +2635,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -3023,6 +3027,7 @@ "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -3109,6 +3114,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -3709,6 +3715,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3761,6 +3768,7 @@ "integrity": "sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.15.1", "@algolia/client-abtesting": "5.49.1", @@ -3961,6 +3969,7 @@ "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -4200,6 +4209,7 @@ "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.1.2", "@chevrotain/gast": "11.1.2", @@ -4518,13 +4528,17 @@ } }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/copy-anything": { @@ -4672,6 +4686,7 @@ "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -5116,6 +5131,7 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -5795,6 +5811,7 @@ "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -5914,22 +5931,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-plugin-sonarjs/node_modules/minimatch": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz", - "integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/eslint-plugin-svelte": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.15.0.tgz", @@ -6267,6 +6268,7 @@ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -8211,6 +8213,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8382,6 +8385,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8691,6 +8695,7 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9314,6 +9319,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz", "integrity": "sha512-lP5DGF3oDDI9fhHcSpaBiJEkFLuS16h92DhM1L5K1lFm0WjOmUh1i2sNkBBk8rkxJRpob0dBE75jRfUzGZUOGA==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -9654,6 +9660,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9838,6 +9845,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10866,6 +10874,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -10926,6 +10935,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -11059,6 +11069,7 @@ "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", diff --git a/package.json b/package.json index a70f1f8..2c1da0a 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,10 @@ "uuid": "^13.0.0", "web-push": "^3.6.7" }, + "overrides": { + "minimatch": ">=10.2.3", + "cookie": ">=0.7.0" + }, "devDependencies": { "@commitlint/cli": "^20.4.2", "@commitlint/config-conventional": "^20.4.2", diff --git a/src/app.html b/src/app.html index 0136c35..5986ff4 100644 --- a/src/app.html +++ b/src/app.html @@ -15,8 +15,8 @@ - - + + diff --git a/src/hooks.server.ts b/src/hooks.server.ts index c46ca98..3eadb8f 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -71,7 +71,7 @@ function setThemeCookies(event: RequestEvent, response: Response): void { if (event.locals.user?.themePreference && !cookies.includes('scrolly_theme=')) { response.headers.append( 'Set-Cookie', - `scrolly_theme=${event.locals.user.themePreference};Path=/;Max-Age=31536000;SameSite=Lax` + `scrolly_theme=${event.locals.user.themePreference};Path=/;Max-Age=31536000;SameSite=Lax;Secure` ); } @@ -80,7 +80,7 @@ function setThemeCookies(event: RequestEvent, response: Response): void { const accentValue = encodeURIComponent(JSON.stringify({ hex: accent.hex, dark: accent.dark })); response.headers.append( 'Set-Cookie', - `scrolly_accent=${accentValue};Path=/;Max-Age=31536000;SameSite=Lax` + `scrolly_accent=${accentValue};Path=/;Max-Age=31536000;SameSite=Lax;Secure` ); } } @@ -98,7 +98,14 @@ export const handle: Handle = async ({ event, resolve }) => { if (userId) { const data = await getUserWithGroup(userId); if (data && !data.user.removedAt) { - event.locals.user = data.user; + // Only expose authenticated user to routes if onboarding is complete. + // Users with empty username haven't finished onboarding and should not + // have access to group data via API routes. The /api/auth onboarding + // actions read the cookie directly via getUserIdFromCookies, so they + // still work without locals.user. + if (data.user.username) { + event.locals.user = data.user; + } event.locals.group = data.group; } } diff --git a/src/lib/commentsApi.ts b/src/lib/commentsApi.ts index 0a446cb..96f06a6 100644 --- a/src/lib/commentsApi.ts +++ b/src/lib/commentsApi.ts @@ -101,7 +101,7 @@ export async function toggleCommentHeart( } export function markCommentsRead(clipId: string): void { - for (const type of ['comment', 'reply']) { + for (const type of ['comment', 'reply', 'mention']) { fetch('/api/notifications/mark-read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/lib/components/ActionSidebar.svelte b/src/lib/components/ActionSidebar.svelte index 262b8ec..953e5de 100644 --- a/src/lib/components/ActionSidebar.svelte +++ b/src/lib/components/ActionSidebar.svelte @@ -104,7 +104,14 @@ -
+ +
e.stopPropagation()} + ontouchmove={(e) => e.stopPropagation()} + ontouchend={(e) => e.stopPropagation()} +> {#if onmute} -
-
{#if loading}
@@ -256,55 +253,12 @@ opacity: 1; } - .header { - display: flex; - align-items: center; - justify-content: center; - padding: var(--space-md) var(--space-lg); - border-bottom: 1px solid var(--border); - flex-shrink: 0; - position: relative; - } - - .header-title { - font-family: var(--font-display); - font-weight: 700; - font-size: 1.0625rem; - letter-spacing: -0.01em; - color: var(--text-primary); - } - - .close-btn { - position: absolute; - right: var(--space-lg); - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: var(--radius-full); - background: var(--bg-surface); - border: none; - color: var(--text-secondary); - cursor: pointer; - transition: background 0.2s ease; - } - - .close-btn:active { - background: var(--bg-subtle); - } - - .close-btn :global(svg) { - width: 18px; - height: 18px; - } - .content { flex: 1; overflow-y: auto; overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch; - padding: 0 var(--space-sm) var(--space-lg); + padding: var(--space-md) var(--space-sm) var(--space-lg); max-width: 520px; margin: 0 auto; width: 100%; @@ -329,7 +283,7 @@ flex-direction: column; align-items: center; justify-content: center; - padding: var(--space-3xl) var(--space-lg); + padding: var(--space-xl) var(--space-lg); gap: var(--space-sm); } diff --git a/src/lib/components/AddVideo.svelte b/src/lib/components/AddVideo.svelte index aa1ed33..dc134c5 100644 --- a/src/lib/components/AddVideo.svelte +++ b/src/lib/components/AddVideo.svelte @@ -9,7 +9,7 @@ } from '$lib/url-validation'; import { addToast } from '$lib/stores/toasts'; import { showShortcutNudge } from '$lib/stores/shortcutNudge'; - import { page } from '$app/stores'; + import { page } from '$app/state'; import type { GroupMember } from '$lib/types'; import DownloadSimpleIcon from 'phosphor-svelte/lib/DownloadSimpleIcon'; import ClipboardIcon from 'phosphor-svelte/lib/ClipboardIcon'; @@ -27,11 +27,11 @@ members?: GroupMember[]; } = $props(); - const hasProvider = $derived(!!$page.data.group?.downloadProvider); - const platformFilterMode = $derived(($page.data.group?.platformFilterMode as string) ?? 'all'); + const hasProvider = $derived(!!page.data.group?.downloadProvider); + const platformFilterMode = $derived((page.data.group?.platformFilterMode as string) ?? 'all'); const platformFilterList = $derived( - $page.data.group?.platformFilterList - ? JSON.parse($page.data.group.platformFilterList as string) + page.data.group?.platformFilterList + ? JSON.parse(page.data.group.platformFilterList as string) : null ); let url = $state(''); diff --git a/src/lib/components/AddVideoModal.svelte b/src/lib/components/AddVideoModal.svelte index cc9a991..859982b 100644 --- a/src/lib/components/AddVideoModal.svelte +++ b/src/lib/components/AddVideoModal.svelte @@ -1,9 +1,11 @@
- + {#snippet header()} {#if phase === 'form'}
Add to feed +
{/if} {/snippet} @@ -165,14 +164,37 @@ } .add-header { - padding: 0 var(--space-lg) var(--space-sm); + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-md) var(--space-lg); + border-bottom: 1px solid var(--border); + position: relative; } .add-title { font-family: var(--font-display); - font-size: 1.0625rem; - font-weight: 700; + font-size: 0.9375rem; + font-weight: 500; color: var(--text-primary); } + .close-btn { + position: absolute; + right: var(--space-lg); + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-full); + background: var(--bg-surface); + border: none; + color: var(--text-secondary); + cursor: pointer; + transition: background 0.2s ease; + } + .close-btn:active { + background: var(--bg-subtle); + } .sheet-body { padding-bottom: max(var(--space-lg), env(safe-area-inset-bottom)); diff --git a/src/lib/components/BaseSheet.svelte b/src/lib/components/BaseSheet.svelte index 3052d05..9153685 100644 --- a/src/lib/components/BaseSheet.svelte +++ b/src/lib/components/BaseSheet.svelte @@ -3,18 +3,18 @@ import { pushState, beforeNavigate } from '$app/navigation'; import { onDestroy } from 'svelte'; import { openSheet, closeSheet } from '$lib/stores/sheetOpen'; + import { createSafeTimeout } from '$lib/safeTimeout'; + import XIcon from 'phosphor-svelte/lib/XIcon'; let { title = '', sheetId = 'sheet', - showHandle = true, ondismiss, header, children }: { title?: string; sheetId?: string; - showHandle?: boolean; ondismiss: () => void; header?: Snippet; children: Snippet; @@ -22,7 +22,7 @@ let visible = $state(false); let closedViaBack = false; - let timers: ReturnType[] = []; + const { safeTimeout, clearAll } = createSafeTimeout(); let dragZoneEl: HTMLElement | null = $state(null); let dragY = $state(0); @@ -77,12 +77,6 @@ closedViaBack = true; }); - function safeTimeout(fn: () => void, ms: number) { - const id = setTimeout(fn, ms); - timers.push(id); - return id; - } - // Animate in, lock scroll, manage history $effect(() => { openSheet(); @@ -111,7 +105,7 @@ safeTimeout(ondismiss, 300); } - onDestroy(() => timers.forEach(clearTimeout)); + onDestroy(clearAll); @@ -131,25 +125,14 @@ onpointercancel={endDrag} role="presentation" > - {#if showHandle} -
{ - if (e.key === 'Enter' || e.key === ' ') dismiss(); - }} - role="button" - tabindex="-1" - > -
-
- {/if} - {#if header} {@render header()} {:else if title}
{title} +
{/if}
@@ -194,27 +177,36 @@ touch-action: none; } - .base-handle-bar { + .base-header { display: flex; + align-items: center; justify-content: center; - padding: var(--space-md); - cursor: pointer; - } - .base-handle { - width: 36px; - height: 4px; - background: var(--bg-subtle); - border-radius: 2px; - } - - .base-header { - padding: 0 var(--space-lg) var(--space-md); + padding: var(--space-md) var(--space-lg); border-bottom: 1px solid var(--border); + position: relative; } .base-title { font-family: var(--font-display); font-size: 0.9375rem; - font-weight: 600; + font-weight: 500; color: var(--text-primary); } + .base-close-btn { + position: absolute; + right: var(--space-lg); + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-full); + background: var(--bg-surface); + border: none; + color: var(--text-secondary); + cursor: pointer; + transition: background 0.2s ease; + } + .base-close-btn:active { + background: var(--bg-subtle); + } diff --git a/src/lib/components/ClipOverlay.svelte b/src/lib/components/ClipOverlay.svelte index d127447..9517a09 100644 --- a/src/lib/components/ClipOverlay.svelte +++ b/src/lib/components/ClipOverlay.svelte @@ -225,10 +225,6 @@ if (ok && clip) clip = { ...clip, status: 'downloading' }; } - function handleCaptionEdit(id: string, newCaption: string) { - if (clip) clip = { ...clip, title: newCaption }; - } - function handleDelete() { handleDismiss(); } @@ -274,7 +270,6 @@ index={0} {autoScroll} {gifEnabled} - canEditCaption={clip.addedBy === currentUserId && !clip.seenByOthers} seenByOthers={clip.seenByOthers} hideViewBadge={true} onwatched={handleWatched} @@ -282,7 +277,6 @@ onreaction={handleReaction} onretry={handleRetry} onended={() => {}} - oncaptionedit={handleCaptionEdit} ondelete={handleDelete} />
diff --git a/src/lib/components/CommentPrompt.svelte b/src/lib/components/CommentPrompt.svelte index 035da51..e335ecc 100644 --- a/src/lib/components/CommentPrompt.svelte +++ b/src/lib/components/CommentPrompt.svelte @@ -1,19 +1,78 @@ - + + diff --git a/src/lib/components/FilterBar.svelte b/src/lib/components/FilterBar.svelte index 5020547..602211c 100644 --- a/src/lib/components/FilterBar.svelte +++ b/src/lib/components/FilterBar.svelte @@ -1,5 +1,6 @@ -{#if showSpeedIndicator} -
{speed}x
-{/if} - {#if showMuteIndicator}
{#if muted} diff --git a/src/lib/components/ReelItem.svelte b/src/lib/components/ReelItem.svelte index 545e6eb..4fa3e9d 100644 --- a/src/lib/components/ReelItem.svelte +++ b/src/lib/components/ReelItem.svelte @@ -1,3 +1,12 @@ + +
-
- {#if !hideViewBadge && clip.viewCount > 0} + +
e.stopPropagation()} + ontouchstart={(e) => e.stopPropagation()} + ontouchmove={(e) => e.stopPropagation()} + ontouchend={(e) => e.stopPropagation()} + > + { + if (pillTimer) { + clearTimeout(pillTimer); + pillTimer = null; + } + pillExpanded = !pillExpanded; + }} + /> +
+ + {#if !hideViewBadge && clip.viewCount > 0} + +
e.stopPropagation()} + ontouchstart={(e) => e.stopPropagation()} + ontouchmove={(e) => e.stopPropagation()} + ontouchend={(e) => e.stopPropagation()} + > { @@ -413,8 +546,8 @@ showViewers = true; }} /> - {/if} -
+
+ {/if} {#if clip.contentType === 'music'} {/if} - + {#if duration > 0} {/if} - -
- -
+ -
+ +
e.stopPropagation()} + ontouchstart={(e) => e.stopPropagation()} + ontouchmove={(e) => e.stopPropagation()} + ontouchend={(e) => e.stopPropagation()} + > {#if active} { e.stopPropagation(); commentsAutoFocus = true; @@ -569,17 +697,6 @@ overflow: hidden; background: var(--bg-primary); } - .reel-content-frame { - position: absolute; - inset: 0; - max-width: 480px; - margin: 0 auto; - z-index: 5; - pointer-events: none; - } - .reel-content-frame > :global(*) { - pointer-events: auto; - } .top-left-row { position: absolute; top: max(var(--space-md), env(safe-area-inset-top)); @@ -595,6 +712,20 @@ opacity: 0; pointer-events: none; } + .top-right-row { + position: absolute; + top: max(var(--space-md), env(safe-area-inset-top)); + right: calc(var(--space-sm) + 44px); + z-index: 6; + display: flex; + align-items: center; + min-height: 44px; + transition: opacity 0.3s ease; + } + .top-right-row.ui-hidden { + opacity: 0; + pointer-events: none; + } .bottom-row { position: absolute; bottom: 14px; diff --git a/src/lib/components/ReelMusic.svelte b/src/lib/components/ReelMusic.svelte index 5764af0..c94f516 100644 --- a/src/lib/components/ReelMusic.svelte +++ b/src/lib/components/ReelMusic.svelte @@ -8,7 +8,6 @@ muted, autoScroll, forceLoop = false, - playbackRate = 1, onretry, onended, audioEl = $bindable(null) @@ -27,7 +26,6 @@ muted: boolean; autoScroll: boolean; forceLoop?: boolean; - playbackRate?: number; onretry: (id: string) => void; onended: () => void; audioEl: HTMLAudioElement | null; @@ -58,13 +56,6 @@ audioEl.muted = muted; } }); - - // Sync playback rate - $effect(() => { - if (audioEl) { - audioEl.playbackRate = playbackRate; - } - });
diff --git a/src/lib/components/ReelOverlay.svelte b/src/lib/components/ReelOverlay.svelte index 6a728f2..6427433 100644 --- a/src/lib/components/ReelOverlay.svelte +++ b/src/lib/components/ReelOverlay.svelte @@ -1,55 +1,46 @@ -
+ +
e.stopPropagation()} + ontouchstart={(e) => e.stopPropagation()} + ontouchmove={(e) => e.stopPropagation()} + ontouchend={(e) => e.stopPropagation()} +>
@@ -103,7 +89,7 @@ diff --git a/src/lib/components/ViewBadge.svelte b/src/lib/components/ViewBadge.svelte index eeaf83d..0aa7ed1 100644 --- a/src/lib/components/ViewBadge.svelte +++ b/src/lib/components/ViewBadge.svelte @@ -16,6 +16,10 @@ e.stopPropagation(); ontap(); }} + onpointerdown={(e) => e.stopPropagation()} + ontouchstart={(e) => e.stopPropagation()} + ontouchmove={(e) => e.stopPropagation()} + ontouchend={(e) => e.stopPropagation()} > {viewCount} @@ -27,9 +31,9 @@ align-items: center; gap: var(--space-xs); padding: 6px 12px; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); + background: var(--reel-icon-circle-bg); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); border: none; border-radius: var(--radius-full); color: var(--reel-text); diff --git a/src/lib/components/ViewersSheet.svelte b/src/lib/components/ViewersSheet.svelte index 7ef07b0..6c12e4c 100644 --- a/src/lib/components/ViewersSheet.svelte +++ b/src/lib/components/ViewersSheet.svelte @@ -1,15 +1,15 @@ -
{}} role="presentation">
+
-
- Views{viewers.length > 0 ? ` (${viewers.length})` : ''} - -
-
{#if loading}
@@ -102,17 +100,8 @@ {viewer.username.charAt(0).toUpperCase()} {/if}
-
- {viewer.username} - {relativeTime(viewer.watchedAt)} -
- - Viewed - + {viewer.username} + {relativeTime(viewer.watchedAt)}
{/each}
@@ -137,16 +126,16 @@ .sheet { position: fixed; top: calc(56px + env(safe-area-inset-top)); - left: var(--space-lg); + right: var(--space-lg); width: calc(100vw - 2 * var(--space-lg)); - max-width: 400px; + max-width: 200px; max-height: 65vh; background: var(--bg-elevated); border-radius: var(--radius-lg); z-index: 100; display: flex; flex-direction: column; - transform-origin: top left; + transform-origin: top right; transform: scale(0.9); opacity: 0; transition: @@ -160,55 +149,12 @@ opacity: 1; } - .header { - display: flex; - align-items: center; - justify-content: center; - padding: var(--space-md) var(--space-lg); - border-bottom: 1px solid var(--border); - flex-shrink: 0; - position: relative; - } - - .header-title { - font-family: var(--font-display); - font-weight: 700; - font-size: 1.0625rem; - letter-spacing: -0.01em; - color: var(--text-primary); - } - - .close-btn { - position: absolute; - right: var(--space-lg); - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: var(--radius-full); - background: var(--bg-surface); - border: none; - color: var(--text-secondary); - cursor: pointer; - transition: background 0.2s ease; - } - - .close-btn:active { - background: var(--bg-subtle); - } - - .close-btn :global(svg) { - width: 18px; - height: 18px; - } - .content { flex: 1; overflow-y: auto; overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch; - padding: 0 var(--space-sm) var(--space-lg); + padding: var(--space-sm) var(--space-sm) var(--space-sm); } .viewers-empty { @@ -216,7 +162,7 @@ flex-direction: column; align-items: center; justify-content: center; - padding: var(--space-3xl) var(--space-lg); + padding: var(--space-xl) var(--space-lg); gap: var(--space-sm); } @@ -261,9 +207,9 @@ .viewer-row { display: flex; align-items: center; - gap: var(--space-md); - padding: var(--space-md) var(--space-sm); - border-radius: var(--radius-sm); + justify-content: space-between; + gap: var(--space-sm); + padding: var(--space-xs) var(--space-sm); animation: viewer-in 250ms cubic-bezier(0.32, 0.72, 0, 1) both; } @@ -279,8 +225,8 @@ } .viewer-avatar { - width: 44px; - height: 44px; + width: 28px; + height: 28px; border-radius: var(--radius-full); overflow: hidden; flex-shrink: 0; @@ -293,7 +239,6 @@ .avatar-img { width: 100%; height: 100%; - border-radius: var(--radius-full); object-fit: cover; } @@ -301,44 +246,22 @@ color: var(--text-secondary); font-family: var(--font-display); font-weight: 700; - font-size: 1rem; - } - - .viewer-info { - flex: 1; - min-width: 0; + font-size: 0.6875rem; } .viewer-name { - display: block; - font-size: 0.875rem; + flex: 1; + font-size: 0.8125rem; font-weight: 600; color: var(--text-primary); } .viewer-time { - font-size: 0.75rem; - color: var(--text-muted); - } - - .status-badge { - padding: 3px 10px; - border-radius: var(--radius-full); font-size: 0.6875rem; - font-weight: 600; + color: var(--text-muted); flex-shrink: 0; } - .status-badge.viewed { - background: rgba(56, 161, 105, 0.15); - color: var(--success); - } - - .status-badge.skipped { - background: rgba(251, 191, 36, 0.15); - color: var(--warning); - } - .spinner { display: inline-block; width: 32px; diff --git a/src/lib/components/settings/ClipRow.svelte b/src/lib/components/settings/ClipRow.svelte index 98eb245..7f98241 100644 --- a/src/lib/components/settings/ClipRow.svelte +++ b/src/lib/components/settings/ClipRow.svelte @@ -1,6 +1,7 @@
- +
+ + +

{description}

@@ -56,6 +80,10 @@ gap: var(--space-sm); } + .select-wrapper { + position: relative; + } + select { width: 100%; padding: var(--space-md) var(--space-lg); @@ -68,12 +96,18 @@ cursor: pointer; transition: border-color 0.2s ease; appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right var(--space-md) center; padding-right: 2.5rem; } + .select-chevron { + position: absolute; + right: var(--space-md); + top: 50%; + transform: translateY(-50%); + color: var(--text-secondary); + pointer-events: none; + } + select:focus { outline: none; border-color: var(--accent-primary); diff --git a/src/lib/components/settings/SettingRow.svelte b/src/lib/components/settings/SettingRow.svelte new file mode 100644 index 0000000..f292469 --- /dev/null +++ b/src/lib/components/settings/SettingRow.svelte @@ -0,0 +1,74 @@ + + +
+
+ {#if name} + {name} + {/if} + {#if description} + {description} + {/if} +
+ {#if children} + {@render children()} + {/if} +
+ + diff --git a/src/lib/components/settings/Toggle.svelte b/src/lib/components/settings/Toggle.svelte new file mode 100644 index 0000000..af26054 --- /dev/null +++ b/src/lib/components/settings/Toggle.svelte @@ -0,0 +1,57 @@ + + + + + diff --git a/src/lib/feed.ts b/src/lib/feed.ts index 4a3f2ad..68c7898 100644 --- a/src/lib/feed.ts +++ b/src/lib/feed.ts @@ -21,24 +21,8 @@ export function buildClipParams( export async function fetchClips( filter: FeedFilter, pageSize: number, - sort?: FeedSort -): Promise<{ clips: FeedClip[]; hasMore: boolean } | null> { - try { - const params = buildClipParams(filter, 0, pageSize, sort); - const res = await fetch(`/api/clips?${params}`); - if (res.ok) return res.json(); - return null; - } catch (err) { - console.warn('[feed]', err); - return null; - } -} - -export async function fetchMoreClips( - filter: FeedFilter, - offset: number, - pageSize: number, - sort?: FeedSort + sort?: FeedSort, + offset = 0 ): Promise<{ clips: FeedClip[]; hasMore: boolean } | null> { try { const params = buildClipParams(filter, offset, pageSize, sort); diff --git a/src/lib/reelInteractions.ts b/src/lib/reelInteractions.ts index 3bef843..5828a91 100644 --- a/src/lib/reelInteractions.ts +++ b/src/lib/reelInteractions.ts @@ -9,7 +9,10 @@ const IGNORE_SELECTORS = [ '.progress-row', '.platform-links', '.music-disc-area', - '.music-links-backdrop' + '.music-links-backdrop', + '.top-left-row', + '.top-right-row', + '.bottom-row' ]; function shouldIgnoreTarget(e: { clientX: number; clientY: number }): boolean { diff --git a/src/lib/safeTimeout.ts b/src/lib/safeTimeout.ts new file mode 100644 index 0000000..295f290 --- /dev/null +++ b/src/lib/safeTimeout.ts @@ -0,0 +1,27 @@ +/** + * Creates a managed timeout utility that tracks all pending timeouts + * and can clear them on component teardown (via onDestroy). + * + * Usage: + * const { safeTimeout, clearAll } = createSafeTimeout(); + * safeTimeout(() => doSomething(), 300); + * onDestroy(clearAll); + */ +export function createSafeTimeout(): { + safeTimeout: (fn: () => void, ms: number) => ReturnType; + clearAll: () => void; +} { + const timers: ReturnType[] = []; + + function safeTimeout(fn: () => void, ms: number): ReturnType { + const id = setTimeout(fn, ms); + timers.push(id); + return id; + } + + function clearAll(): void { + timers.forEach(clearTimeout); + } + + return { safeTimeout, clearAll }; +} diff --git a/src/lib/server/__tests__/auth.test.ts b/src/lib/server/__tests__/auth.test.ts index 77ab850..77029a9 100644 --- a/src/lib/server/__tests__/auth.test.ts +++ b/src/lib/server/__tests__/auth.test.ts @@ -11,7 +11,7 @@ const { db } = await import('$lib/server/db'); const data = await seed(db as any); // Import after mocks -const { createSessionCookie, getUserIdFromCookies, getUser, getUserWithGroup, validateInviteCode } = +const { createSessionCookie, getUserIdFromCookies, getUserWithGroup, validateInviteCode } = await import('../auth'); describe('createSessionCookie', () => { @@ -72,19 +72,6 @@ describe('getUserIdFromCookies', () => { }); }); -describe('getUser', () => { - it('returns user when exists', async () => { - const user = await getUser(data.host.id); - expect(user).not.toBeNull(); - expect(user!.username).toBe('hostuser'); - }); - - it('returns null when not found', async () => { - const user = await getUser('nonexistent-id'); - expect(user).toBeNull(); - }); -}); - describe('getUserWithGroup', () => { it('returns user and group when both exist', async () => { const result = await getUserWithGroup(data.host.id); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 13cbe04..5173584 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -24,13 +24,16 @@ function verify(token: string): string | null { if (lastDot === -1) return null; const payload = token.substring(0, lastDot); const expected = sign(payload); - if (!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected))) return null; + const tokenBuf = Buffer.from(token); + const expectedBuf = Buffer.from(expected); + if (tokenBuf.length !== expectedBuf.length) return null; + if (!crypto.timingSafeEqual(tokenBuf, expectedBuf)) return null; return payload; } export function createSessionCookie(userId: string): string { const token = sign(userId); - return `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${MAX_AGE}`; + return `${COOKIE_NAME}=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${MAX_AGE}`; } export function getUserIdFromCookies(cookieHeader: string | null): string | null { @@ -47,22 +50,15 @@ export function getUserIdFromCookies(cookieHeader: string | null): string | null return verify(token); } -export async function getUser(userId: string) { - const result = await db.query.users.findFirst({ - where: eq(users.id, userId) - }); - return result ?? null; -} - export async function getUserWithGroup(userId: string) { - const user = await db.query.users.findFirst({ - where: eq(users.id, userId) - }); - if (!user) return null; - const group = await db.query.groups.findFirst({ - where: eq(groups.id, user.groupId) - }); - return { user, group: group ?? null }; + const row = db + .select({ user: users, group: groups }) + .from(users) + .innerJoin(groups, eq(users.groupId, groups.id)) + .where(eq(users.id, userId)) + .get(); + if (!row) return null; + return { user: row.user, group: row.group }; } export async function validateInviteCode(code: string) { diff --git a/src/lib/server/clip-download.ts b/src/lib/server/clip-download.ts new file mode 100644 index 0000000..7d832cc --- /dev/null +++ b/src/lib/server/clip-download.ts @@ -0,0 +1,32 @@ +import { db } from '$lib/server/db'; +import { clips } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { downloadVideo } from '$lib/server/video/download'; +import { downloadMusic } from '$lib/server/music/download'; +import { createLogger } from '$lib/server/logger'; + +const log = createLogger('clip-download'); + +/** Set clip status to 'downloading' and trigger the download pipeline. Marks as 'failed' on error. */ +export async function startDownload( + clipId: string, + url: string, + contentType: string, + label: string +) { + await db.update(clips).set({ status: 'downloading' }).where(eq(clips.id, clipId)); + + const onError = async (err: unknown) => { + log.error({ err, clipId }, `download failed (${label})`); + await db + .update(clips) + .set({ status: 'failed' }) + .where(and(eq(clips.id, clipId), eq(clips.status, 'downloading'))); + }; + + if (contentType === 'music') { + downloadMusic(clipId, url).catch(onError); + } else { + downloadVideo(clipId, url).catch(onError); + } +} diff --git a/src/lib/server/download-utils.ts b/src/lib/server/download-utils.ts index 6b64395..a7557c5 100644 --- a/src/lib/server/download-utils.ts +++ b/src/lib/server/download-utils.ts @@ -11,11 +11,33 @@ export const DATA_DIR = resolve(process.env.DATA_DIR || 'data', 'videos'); * Returns the limit in bytes, or null if no limit is configured. */ export async function getMaxFileSize(clipId: string): Promise { - const clip = await db.query.clips.findFirst({ where: eq(clips.id, clipId) }); - if (!clip) return null; - const group = await db.query.groups.findFirst({ where: eq(groups.id, clip.groupId) }); - if (group?.maxFileSizeMb === null || group?.maxFileSizeMb === undefined) return null; - return group.maxFileSizeMb * 1024 * 1024; + const row = db + .select({ maxFileSizeMb: groups.maxFileSizeMb }) + .from(clips) + .innerJoin(groups, eq(clips.groupId, groups.id)) + .where(eq(clips.id, clipId)) + .get(); + if (row?.maxFileSizeMb === null || row?.maxFileSizeMb === undefined) return null; + return row.maxFileSizeMb * 1024 * 1024; +} + +/** + * Fetch a clip and its group's max file size in a single query. + * Used by download pipelines that need both the clip record and the size limit. + */ +export function getClipWithMaxFileSize(clipId: string) { + const row = db + .select({ clip: clips, maxFileSizeMb: groups.maxFileSizeMb }) + .from(clips) + .innerJoin(groups, eq(clips.groupId, groups.id)) + .where(eq(clips.id, clipId)) + .get(); + if (!row) return null; + const maxFileSizeBytes = + row.maxFileSizeMb !== null && row.maxFileSizeMb !== undefined + ? row.maxFileSizeMb * 1024 * 1024 + : null; + return { clip: row.clip, maxFileSizeBytes }; } /** diff --git a/src/lib/server/music/download.ts b/src/lib/server/music/download.ts index 58a9da9..d5f5eb1 100644 --- a/src/lib/server/music/download.ts +++ b/src/lib/server/music/download.ts @@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'; import { deduplicatedDownload } from '../download-lock'; import { getActiveProvider } from '../providers/registry'; import type { AudioDownloadResult } from '../providers/types'; -import { DATA_DIR, getMaxFileSize, cleanupClipFiles } from '$lib/server/download-utils'; +import { DATA_DIR, getClipWithMaxFileSize, cleanupClipFiles } from '$lib/server/download-utils'; import { notifyNewClip } from '$lib/server/push'; import { createLogger } from '$lib/server/logger'; @@ -72,7 +72,8 @@ async function finalizeMusicClip( clipId: string, result: AudioDownloadResult, metadata: MusicMetadata, - maxFileSizeBytes: number | null + maxFileSizeBytes: number | null, + existingTitle: string | null ): Promise { // Calculate file size let fileSizeBytes = 0; @@ -103,9 +104,8 @@ async function finalizeMusicClip( return; } - // Keep existing title (caption from SMS) if present - const existing = await db.query.clips.findFirst({ where: eq(clips.id, clipId) }); - const title = existing?.title || metadata.title || null; + // Keep existing title (caption from SMS or metadata update) if present + const title = existingTitle || metadata.title || null; await db .update(clips) @@ -125,11 +125,12 @@ async function finalizeMusicClip( } async function downloadMusicInner(clipId: string, url: string): Promise { - const maxFileSizeBytes = await getMaxFileSize(clipId); + // Single query: fetch clip record + group's max file size via JOIN + const data = getClipWithMaxFileSize(clipId); + if (!data) return; + const { clip, maxFileSizeBytes } = data; // Resolve the active provider for this clip's group - const clip = await db.query.clips.findFirst({ where: eq(clips.id, clipId) }); - if (!clip) return; const provider = await getActiveProvider(clip.groupId); if (!provider) { await db @@ -147,10 +148,12 @@ async function downloadMusicInner(clipId: string, url: string): Promise { const metadata = await resolveOdesli(url); // Step 2: Update clip immediately with metadata (UI can show song info while downloading) + // Preserve user-provided caption if present (e.g. from SMS share) + const resolvedTitle = clip.title || metadata.title; await db .update(clips) .set({ - title: metadata.title, + title: resolvedTitle, artist: metadata.artist, albumArt: metadata.albumArt, spotifyUrl: metadata.spotifyUrl, @@ -188,7 +191,7 @@ async function downloadMusicInner(clipId: string, url: string): Promise { } if (result) { - await finalizeMusicClip(clipId, result, metadata, maxFileSizeBytes); + await finalizeMusicClip(clipId, result, metadata, maxFileSizeBytes, resolvedTitle ?? null); } else { // Failed to download audio, but metadata + platform links are still visible await cleanupClipFiles(clipId); diff --git a/src/lib/server/scheduler.ts b/src/lib/server/scheduler.ts index cbd824e..e57a84c 100644 --- a/src/lib/server/scheduler.ts +++ b/src/lib/server/scheduler.ts @@ -10,10 +10,12 @@ import { eq, and, sql, isNull, gte } from 'drizzle-orm'; import { sendNotification } from '$lib/server/push'; import { runBackup } from '$lib/server/backup'; import { createLogger } from '$lib/server/logger'; +import { v4 as uuid } from 'uuid'; const log = createLogger('scheduler'); let lastBackupDate: string | null = null; +let lastReminderDate: string | null = null; const CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour const REMINDER_HOUR = 9; // 9 AM server time @@ -36,14 +38,21 @@ export function startScheduler(): void { async function checkAndSendReminders(): Promise { const now = new Date(); + const today = now.toISOString().split('T')[0]; + + // In-memory guard: only attempt once per calendar day + if (lastReminderDate === today) return; + + // Only send during the reminder hour window (e.g., 9:00–9:59) + if (now.getHours() < REMINDER_HOUR || now.getHours() >= REMINDER_HOUR + 1) return; - // Only send after the reminder hour - if (now.getHours() < REMINDER_HOUR) return; + lastReminderDate = today; try { await sendDailyReminders(); } catch (err) { log.error({ err }, 'daily reminder failed'); + lastReminderDate = null; // Retry next hour if within window } } @@ -95,9 +104,12 @@ async function sendDailyReminders(): Promise { for (const user of usersToNotify) { try { - // Count unwatched ready clips using a single SQL query + // Count unwatched ready clips and grab one clip ID for the notification record const [result] = await db - .select({ count: sql`count(*)` }) + .select({ + count: sql`count(*)`, + clipId: sql`min(${clips.id})` + }) .from(clips) .where( and( @@ -108,7 +120,7 @@ async function sendDailyReminders(): Promise { ); const unwatchedCount = result.count; - if (unwatchedCount === 0) continue; + if (unwatchedCount === 0 || !result.clipId) continue; await sendNotification(user.id, { title: `${unwatchedCount} unwatched ${unwatchedCount === 1 ? 'clip' : 'clips'}`, @@ -116,6 +128,17 @@ async function sendDailyReminders(): Promise { url: '/', tag: 'daily-reminder' }); + + // Record the notification so deduplication works across restarts + await db.insert(notifications).values({ + id: uuid(), + userId: user.id, + type: 'daily_reminder', + clipId: result.clipId, + actorId: user.id, + createdAt: new Date() + }); + sent++; } catch (err) { log.error({ err, userId: user.id }, 'reminder failed for user'); diff --git a/src/lib/server/video/download.ts b/src/lib/server/video/download.ts index c86cf53..468ce18 100644 --- a/src/lib/server/video/download.ts +++ b/src/lib/server/video/download.ts @@ -5,7 +5,7 @@ import { deduplicatedDownload } from '../download-lock'; import { getActiveProvider } from '../providers/registry'; import { DATA_DIR, - getMaxFileSize, + getClipWithMaxFileSize, cleanupClipFiles, totalFileSize } from '$lib/server/download-utils'; @@ -48,11 +48,12 @@ async function handleDownloadError( } async function downloadVideoInner(clipId: string, url: string): Promise { - const maxFileSizeBytes = await getMaxFileSize(clipId); + // Single query: fetch clip record + group's max file size via JOIN + const data = getClipWithMaxFileSize(clipId); + if (!data) return; + const { clip, maxFileSizeBytes } = data; // Resolve the active provider for this clip's group - const clip = await db.query.clips.findFirst({ where: eq(clips.id, clipId) }); - if (!clip) return; const provider = await getActiveProvider(clip.groupId); if (!provider) { await db @@ -97,10 +98,7 @@ async function downloadVideoInner(clipId: string, url: string): Promise { } // Keep existing title (caption from SMS) if present, otherwise use provider-extracted title - const existing = await db.query.clips.findFirst({ - where: eq(clips.id, clipId) - }); - const title = existing?.title || result.title || null; + const title = clip.title || result.title || null; await db .update(clips) diff --git a/src/lib/stores/notifications.ts b/src/lib/stores/notifications.ts index a9dfd90..6d15484 100644 --- a/src/lib/stores/notifications.ts +++ b/src/lib/stores/notifications.ts @@ -30,7 +30,7 @@ export async function fetchUnwatchedCount(): Promise { } } -export function updateAppBadge(count: number): void { +function updateAppBadge(count: number): void { if (!('setAppBadge' in navigator)) return; if (count > 0) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Badge API not in lib.dom.d.ts diff --git a/src/lib/stores/playbackSpeed.ts b/src/lib/stores/playbackSpeed.ts deleted file mode 100644 index 207fd1f..0000000 --- a/src/lib/stores/playbackSpeed.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { writable } from 'svelte/store'; - -/** Speeds in "tap to cycle" order starting from 1x */ -const SPEED_CYCLE = [1, 1.5, 2, 0.5, 0.75] as const; - -/** Speeds in ascending order for keyboard step up/down */ -const SPEED_LADDER = [0.5, 0.75, 1, 1.5, 2] as const; - -/** Global playback speed — shared across all reels (video + music). */ -export const globalPlaybackSpeed = writable(1); - -/** Cycle to next speed in tap order (1→1.5→2→0.5→0.75→1). */ -export function cycleSpeed(): void { - globalPlaybackSpeed.update((current) => { - const idx = SPEED_CYCLE.indexOf(current as (typeof SPEED_CYCLE)[number]); - return SPEED_CYCLE[(idx + 1) % SPEED_CYCLE.length]; - }); -} - -/** Step up to the next higher speed. */ -export function stepSpeedUp(): void { - globalPlaybackSpeed.update((current) => { - const idx = SPEED_LADDER.indexOf(current as (typeof SPEED_LADDER)[number]); - return idx < SPEED_LADDER.length - 1 ? SPEED_LADDER[idx + 1] : current; - }); -} - -/** Step down to the next lower speed. */ -export function stepSpeedDown(): void { - globalPlaybackSpeed.update((current) => { - const idx = SPEED_LADDER.indexOf(current as (typeof SPEED_LADDER)[number]); - return idx > 0 ? SPEED_LADDER[idx - 1] : current; - }); -} diff --git a/src/lib/stores/pwa.ts b/src/lib/stores/pwa.ts index bae7a0a..d38ba7a 100644 --- a/src/lib/stores/pwa.ts +++ b/src/lib/stores/pwa.ts @@ -19,8 +19,9 @@ export function detectStandaloneMode(): boolean { export function detectIosSafari(): boolean { if (typeof window === 'undefined') return false; const ua = navigator.userAgent; - // Must be iPhone/iPad/iPod - const isIos = /iPhone|iPad|iPod/.test(ua); + // Must be iPhone/iPad/iPod — modern iPads report "Macintosh" in the UA + const isIpad = /Macintosh/.test(ua) && navigator.maxTouchPoints > 1; + const isIos = /iPhone|iPad|iPod/.test(ua) || isIpad; // Exclude Chrome, Firefox, and other in-app browsers on iOS const isSafari = !/(CriOS|FxiOS|OPiOS|EdgiOS)/.test(ua); return isIos && isSafari; diff --git a/src/lib/stores/shortcutNudge.ts b/src/lib/stores/shortcutNudge.ts index ebd260c..38ce1f7 100644 --- a/src/lib/stores/shortcutNudge.ts +++ b/src/lib/stores/shortcutNudge.ts @@ -14,7 +14,7 @@ function isAppleDevice(): boolean { return /iPhone|iPad|iPod|Macintosh/.test(navigator.userAgent); } -export const shortcutNudgeDismissed = writable(getInitial()); +const shortcutNudgeDismissed = writable(getInitial()); /** Show the shortcut nudge on Apple devices (iOS + Mac), when install banner is not visible and nudge hasn't been dismissed */ export const showShortcutNudge = derived( diff --git a/src/lib/stores/uiHidden.ts b/src/lib/stores/uiHidden.ts index 32a27a9..c36bf47 100644 --- a/src/lib/stores/uiHidden.ts +++ b/src/lib/stores/uiHidden.ts @@ -2,3 +2,6 @@ import { writable } from 'svelte/store'; /** Whether the active reel's UI overlays are hidden (feed page only). */ export const feedUiHidden = writable(false); + +/** Whether the contributor pill overlaps the filter bar and it should dim. */ +export const filterBarDimmed = writable(false); diff --git a/src/lib/types.ts b/src/lib/types.ts index a01a814..f6a3f36 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,3 +1,27 @@ +export type ClipStatus = 'downloading' | 'ready' | 'failed' | 'deleted'; +export type ContentType = 'video' | 'music'; +export type Platform = + | 'tiktok' + | 'instagram' + | 'facebook' + | 'youtube' + | 'twitter' + | 'reddit' + | 'streamable' + | 'twitch' + | 'vimeo' + | 'threads' + | 'bluesky' + | 'snapchat' + | 'pinterest' + | 'kick' + | 'dailymotion' + | 'imgur' + | 'soundcloud' + | 'spotify' + | 'apple_music' + | 'youtube_music'; + export interface FeedClip { id: string; originalUrl: string; @@ -13,11 +37,11 @@ export interface FeedClip { addedBy: string; addedByUsername: string; addedByAvatar: string | null; - platform: string; + platform: Platform; creatorName: string | null; creatorUrl: string | null; - status: string; - contentType: string; + status: ClipStatus; + contentType: ContentType; durationSeconds: number | null; watched: boolean; favorited: boolean; @@ -39,12 +63,12 @@ export interface GroupMember { export interface ClipSummary { id: string; title: string | null; - platform: string; - contentType: string; + platform: Platform; + contentType: ContentType; addedBy: string; addedByUsername: string; createdAt: string; sizeMb: number; thumbnailPath: string | null; - status: string; + status: ClipStatus; } diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index e8e10b1..424f56c 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -10,7 +10,6 @@ import { initAudioContext } from '$lib/audio/normalizer'; import { feedUiHidden } from '$lib/stores/uiHidden'; import { fetchGroupMembers } from '$lib/stores/members'; - import { anySheetOpen } from '$lib/stores/sheetOpen'; import ActivitySheet from '$lib/components/ActivitySheet.svelte'; import AddVideoModal from '$lib/components/AddVideoModal.svelte'; import BellIcon from 'phosphor-svelte/lib/BellIcon'; @@ -115,7 +114,7 @@ class:ui-hidden={$feedUiHidden} onclick={() => activitySheetOpen.set(true)} > - + {#if $unreadCount > 0} {$unreadCount > 99 ? '99+' : $unreadCount} {/if} @@ -126,7 +125,7 @@ {pageTitle} {#if !isSettings} -
-
-
- Auto-scroll - Advance to next clip when current one ends -
- -
+ label="Toggle start muted" + /> + + + +

Feed Order

-
-
- Choose how clips are sorted in your feed -
-
+
@@ -414,9 +406,7 @@
@@ -687,42 +677,6 @@ color: var(--bg-primary); } - .setting-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-md); - padding: var(--space-sm) 0; - border-bottom: 1px solid var(--bg-surface); - } - - .setting-row:first-child { - padding-top: 0; - } - - .setting-row:last-child { - border-bottom: none; - padding-bottom: 0; - } - - .setting-label { - display: flex; - flex-direction: column; - gap: 1px; - min-width: 0; - } - - .setting-name { - font-size: 0.875rem; - font-weight: 500; - color: var(--text-primary); - } - - .setting-desc { - font-size: 0.75rem; - color: var(--text-muted); - } - .share-cta { display: flex; align-items: center; @@ -785,39 +739,6 @@ border-radius: var(--radius-full); } - .toggle { - position: relative; - width: 44px; - height: 26px; - border-radius: 13px; - border: none; - background: var(--border); - cursor: pointer; - flex-shrink: 0; - transition: background 0.2s; - padding: 0; - } - - .toggle.active { - background: var(--accent-primary); - } - - .toggle-thumb { - position: absolute; - top: 2px; - left: 2px; - width: 22px; - height: 22px; - border-radius: var(--radius-full); - background: var(--constant-white); - transition: transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - } - - .toggle.active .toggle-thumb { - transform: translateX(18px); - } - .version-footer { text-align: center; color: var(--text-muted); diff --git a/src/routes/api/__tests__/clips.test.ts b/src/routes/api/__tests__/clips.test.ts index e7d13f0..63546db 100644 --- a/src/routes/api/__tests__/clips.test.ts +++ b/src/routes/api/__tests__/clips.test.ts @@ -371,56 +371,6 @@ describe('GET /api/clips/[id]', () => { }); }); -// --------------------------------------------------------------------------- -// PATCH /api/clips/[id] -// --------------------------------------------------------------------------- -describe('PATCH /api/clips/[id]', () => { - it('returns 401 without auth', async () => { - const event = createMockEvent({ - method: 'PATCH', - path: `/api/clips/${data.clip.id}`, - params: { id: data.clip.id }, - body: { title: 'New Title' }, - user: null, - group: null - }); - const res = await clipIdMod.PATCH(event as any); - expect(res.status).toBe(401); - }); - - it('returns 403 when not the uploader', async () => { - // readyClip was added by host; member should not be able to edit - const event = createMockEvent({ - method: 'PATCH', - path: `/api/clips/${data.readyClip.id}`, - params: { id: data.readyClip.id }, - body: { title: 'Hijacked Title' }, - user: data.member, - group: data.group - }); - const res = await clipIdMod.PATCH(event as any); - expect(res.status).toBe(403); - const body = await res.json(); - expect(body.error).toMatch(/uploader/i); - }); - - it('updates title when authorized', async () => { - // clip was added by member and has not been watched by anyone else - const event = createMockEvent({ - method: 'PATCH', - path: `/api/clips/${data.clip.id}`, - params: { id: data.clip.id }, - body: { title: 'Updated Caption' }, - user: data.member, - group: data.group - }); - const res = await clipIdMod.PATCH(event as any); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.title).toBe('Updated Caption'); - }); -}); - // --------------------------------------------------------------------------- // DELETE /api/clips/[id] // --------------------------------------------------------------------------- diff --git a/src/routes/api/auth/+server.ts b/src/routes/api/auth/+server.ts index 9be5936..9dddf4b 100644 --- a/src/routes/api/auth/+server.ts +++ b/src/routes/api/auth/+server.ts @@ -9,7 +9,7 @@ import { import { db } from '$lib/server/db'; import { users, groups, notificationPreferences, verificationCodes } from '$lib/server/db/schema'; import { v4 as uuid } from 'uuid'; -import { eq, and, desc } from 'drizzle-orm'; +import { eq, and, desc, ne, isNull } from 'drizzle-orm'; import { sendVerification, checkVerification } from '$lib/server/sms/verify'; import { dev } from '$app/environment'; import { createLogger } from '$lib/server/logger'; @@ -93,11 +93,46 @@ async function handleVerifyCode(userId: string, body: Record) { } async function handleOnboard(userId: string, body: Record) { - const { username, phone } = body; + const { phone } = body; + const username = typeof body.username === 'string' ? body.username.trim() : ''; if (!username || !phone) { return json({ error: 'Username and phone number required' }, { status: 400 }); } + // Validate username length (1-30 characters) + if (username.length < 1 || username.length > 30) { + return json({ error: 'Username must be 1–30 characters' }, { status: 400 }); + } + + // Validate username format (alphanumeric, underscores, hyphens, periods) + if (!/^[a-zA-Z0-9._-]+$/.test(username)) { + return json( + { error: 'Username can only contain letters, numbers, underscores, hyphens, and periods' }, + { status: 400 } + ); + } + + // Get current user to find their group + const currentUser = await db.query.users.findFirst({ + where: eq(users.id, userId) + }); + if (!currentUser) { + return json({ error: 'User not found' }, { status: 404 }); + } + + // Check username uniqueness within the group (excluding current user, excluding removed members) + const existingUser = await db.query.users.findFirst({ + where: and( + eq(users.groupId, currentUser.groupId), + eq(users.username, username), + ne(users.id, userId), + isNull(users.removedAt) + ) + }); + if (existingUser) { + return json({ error: 'Username is already taken in this group' }, { status: 409 }); + } + if (!/^\+[1-9]\d{1,14}$/.test(phone)) { return json({ error: 'Phone must be in E.164 format (e.g., +1234567890)' }, { status: 400 }); } diff --git a/src/routes/api/clips/+server.ts b/src/routes/api/clips/+server.ts index bee2e78..b01bf08 100644 --- a/src/routes/api/clips/+server.ts +++ b/src/routes/api/clips/+server.ts @@ -17,9 +17,8 @@ import { isPlatformAllowed, platformLabel } from '$lib/url-validation'; -import { downloadVideo } from '$lib/server/video/download'; -import { downloadMusic } from '$lib/server/music/download'; import { normalizeUrl } from '$lib/server/download-lock'; +import { startDownload } from '$lib/server/clip-download'; import { getActiveProvider } from '$lib/server/providers/registry'; import { v4 as uuid } from 'uuid'; import { @@ -36,25 +35,6 @@ import { createLogger } from '$lib/server/logger'; const log = createLogger('clips'); -/** Set clip status to 'downloading' and trigger the download pipeline. Marks as 'failed' on error. */ -async function startDownload(clipId: string, url: string, contentType: string, label: string) { - await db.update(clips).set({ status: 'downloading' }).where(eq(clips.id, clipId)); - - const onError = async (err: unknown) => { - log.error({ err, clipId }, `download failed (${label})`); - await db - .update(clips) - .set({ status: 'failed' }) - .where(and(eq(clips.id, clipId), eq(clips.status, 'downloading'))); - }; - - if (contentType === 'music') { - downloadMusic(clipId, url).catch(onError); - } else { - downloadVideo(clipId, url).catch(onError); - } -} - /** Validate a clip URL and return an error response, or null if valid. */ function validateClipUrl( videoUrl: string | undefined, @@ -333,31 +313,36 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user, group } const now = new Date(); // Insert clip + auto-watched in a transaction so both succeed or fail together - db.transaction((tx) => { - tx.insert(clips) - .values({ - id: clipId, - groupId: user.groupId, - addedBy: user.id, - originalUrl: normalizedUrl, - title: title || null, - platform, - contentType, - status: 'downloading', - createdAt: now - }) - .run(); - - // Auto-mark as watched by the uploader so it never appears in their "New" tab - tx.insert(watched) - .values({ - clipId, - userId: user.id, - watchPercent: 100, - watchedAt: now - }) - .run(); - }); + try { + db.transaction((tx) => { + tx.insert(clips) + .values({ + id: clipId, + groupId: user.groupId, + addedBy: user.id, + originalUrl: normalizedUrl, + title: title || null, + platform, + contentType, + status: 'downloading', + createdAt: now + }) + .run(); + + // Auto-mark as watched by the uploader so it never appears in their "New" tab + tx.insert(watched) + .values({ + clipId, + userId: user.id, + watchPercent: 100, + watchedAt: now + }) + .run(); + }); + } catch (err) { + log.error({ err, clipId }, 'failed to insert clip'); + return json({ error: 'Failed to create clip' }, { status: 500 }); + } // Route to appropriate download pipeline await startDownload(clipId, validUrl, contentType, 'new clip'); diff --git a/src/routes/api/clips/[id]/+server.ts b/src/routes/api/clips/[id]/+server.ts index 2cc9684..d28e602 100644 --- a/src/routes/api/clips/[id]/+server.ts +++ b/src/routes/api/clips/[id]/+server.ts @@ -12,13 +12,7 @@ import { notifications } from '$lib/server/db/schema'; import { eq, and, ne, count, inArray } from 'drizzle-orm'; -import { - withClipAuth, - parseBody, - isResponse, - mapUsersByIds, - groupReactions -} from '$lib/server/api-utils'; +import { withClipAuth, mapUsersByIds, groupReactions } from '$lib/server/api-utils'; import { cleanupClipFiles } from '$lib/server/download-utils'; export const GET: RequestHandler = withClipAuth(async ({ params }, { user, clip }) => { @@ -53,7 +47,6 @@ export const GET: RequestHandler = withClipAuth(async ({ params }, { user, clip const favoriteCount = allFavRows.length; const viewCount = watchedRows.length; const seenByOthers = watchedRows.some((w) => w.userId !== clip.addedBy); - const canEditCaption = clip.addedBy === userId && !seenByOthers; const reactionData = groupReactions(clipReactions, userId); @@ -94,38 +87,10 @@ export const GET: RequestHandler = withClipAuth(async ({ params }, { user, clip unreadCommentCount, viewCount, seenByOthers, - createdAt: clip.createdAt, - canEditCaption + createdAt: clip.createdAt }); }); -export const PATCH: RequestHandler = withClipAuth(async ({ params, request }, { user, clip }) => { - if (clip.addedBy !== user.id) - return json({ error: 'Only the uploader can edit' }, { status: 403 }); - - // Check edit lock: anyone else watched? - const [watchResult] = await db - .select({ count: count() }) - .from(watched) - .where(and(eq(watched.clipId, params.id), ne(watched.userId, clip.addedBy))); - - if (watchResult.count > 0) { - return json({ error: 'Caption can no longer be edited' }, { status: 403 }); - } - - const body = await parseBody<{ title?: string }>(request); - if (isResponse(body)) return body; - - const title = typeof body.title === 'string' ? body.title.trim().slice(0, 500) : null; - - await db - .update(clips) - .set({ title: title || null }) - .where(eq(clips.id, params.id)); - - return json({ title: title || null }); -}); - export const DELETE: RequestHandler = withClipAuth(async ({ params }, { user, clip }) => { if (clip.addedBy !== user.id) return json({ error: 'Only the uploader can delete' }, { status: 403 }); @@ -145,21 +110,25 @@ export const DELETE: RequestHandler = withClipAuth(async ({ params }, { user, cl where: eq(comments.clipId, params.id) }); - db.transaction((tx) => { - tx.delete(notifications).where(eq(notifications.clipId, params.id)).run(); - tx.delete(watched).where(eq(watched.clipId, params.id)).run(); - tx.delete(favorites).where(eq(favorites.clipId, params.id)).run(); - tx.delete(reactions).where(eq(reactions.clipId, params.id)).run(); - tx.delete(commentViews).where(eq(commentViews.clipId, params.id)).run(); - - // Delete comment hearts before comments (FK constraint) - const commentIds = clipComments.map((c) => c.id); - if (commentIds.length > 0) { - tx.delete(commentHearts).where(inArray(commentHearts.commentId, commentIds)).run(); - } - tx.delete(comments).where(eq(comments.clipId, params.id)).run(); - tx.delete(clips).where(eq(clips.id, params.id)).run(); - }); + try { + db.transaction((tx) => { + tx.delete(notifications).where(eq(notifications.clipId, params.id)).run(); + tx.delete(watched).where(eq(watched.clipId, params.id)).run(); + tx.delete(favorites).where(eq(favorites.clipId, params.id)).run(); + tx.delete(reactions).where(eq(reactions.clipId, params.id)).run(); + tx.delete(commentViews).where(eq(commentViews.clipId, params.id)).run(); + + // Delete comment hearts before comments (FK constraint) + const commentIds = clipComments.map((c) => c.id); + if (commentIds.length > 0) { + tx.delete(commentHearts).where(inArray(commentHearts.commentId, commentIds)).run(); + } + tx.delete(comments).where(eq(comments.clipId, params.id)).run(); + tx.delete(clips).where(eq(clips.id, params.id)).run(); + }); + } catch { + return json({ error: 'Failed to delete clip' }, { status: 500 }); + } // Clean up video/thumbnail/audio files from disk (best-effort, after DB transaction) await cleanupClipFiles(params.id); diff --git a/src/routes/api/clips/[id]/comments/+server.ts b/src/routes/api/clips/[id]/comments/+server.ts index b10dd5b..6bcb7d8 100644 --- a/src/routes/api/clips/[id]/comments/+server.ts +++ b/src/routes/api/clips/[id]/comments/+server.ts @@ -344,15 +344,19 @@ export const DELETE: RequestHandler = withClipAuth(async ({ params, request }, { // Delete hearts, replies, and parent in a single transaction to avoid race conditions const idsToDelete = [commentId, ...childReplies.map((r) => r.id)]; - db.transaction((tx) => { - if (idsToDelete.length > 0) { - tx.delete(commentHearts).where(inArray(commentHearts.commentId, idsToDelete)).run(); - } - if (childReplies.length > 0) { - tx.delete(comments).where(eq(comments.parentId, commentId)).run(); - } - tx.delete(comments).where(eq(comments.id, commentId)).run(); - }); + try { + db.transaction((tx) => { + if (idsToDelete.length > 0) { + tx.delete(commentHearts).where(inArray(commentHearts.commentId, idsToDelete)).run(); + } + if (childReplies.length > 0) { + tx.delete(comments).where(eq(comments.parentId, commentId)).run(); + } + tx.delete(comments).where(eq(comments.id, commentId)).run(); + }); + } catch { + return json({ error: 'Failed to delete comment' }, { status: 500 }); + } return json({ deleted: true, deletedIds: idsToDelete }); }); diff --git a/src/routes/api/clips/[id]/favorite/+server.ts b/src/routes/api/clips/[id]/favorite/+server.ts index dc19968..9cd078c 100644 --- a/src/routes/api/clips/[id]/favorite/+server.ts +++ b/src/routes/api/clips/[id]/favorite/+server.ts @@ -10,65 +10,87 @@ export const POST: RequestHandler = withClipAuth(async ({ params }, { user, clip const clipId = params.id; const userId = user.id; - // Toggle — check if already favorited - const existing = await db.query.favorites.findFirst({ - where: and(eq(favorites.clipId, clipId), eq(favorites.userId, userId)) - }); + // Wrap toggle in a transaction to prevent race conditions (e.g. double-tap) + let result: { favorited: boolean; createdReaction: boolean }; + try { + result = db.transaction((tx) => { + const existing = tx + .select() + .from(favorites) + .where(and(eq(favorites.clipId, clipId), eq(favorites.userId, userId))) + .get(); - if (existing) { - await db - .delete(favorites) - .where(and(eq(favorites.clipId, clipId), eq(favorites.userId, userId))); + if (existing) { + tx.delete(favorites) + .where(and(eq(favorites.clipId, clipId), eq(favorites.userId, userId))) + .run(); - // Also remove ❤️ reaction when un-favoriting - const heartReaction = await db.query.reactions.findFirst({ - where: and( - eq(reactions.clipId, clipId), - eq(reactions.userId, userId), - eq(reactions.emoji, '❤️') - ) - }); - if (heartReaction) { - await db.delete(reactions).where(eq(reactions.id, heartReaction.id)); - await db - .delete(notifications) - .where( - and( - eq(notifications.clipId, clipId), - eq(notifications.actorId, userId), - eq(notifications.type, 'reaction'), - eq(notifications.emoji, '❤️') + // Also remove ❤️ reaction when un-favoriting + const heartReaction = tx + .select() + .from(reactions) + .where( + and( + eq(reactions.clipId, clipId), + eq(reactions.userId, userId), + eq(reactions.emoji, '❤️') + ) ) - ); - } + .get(); + if (heartReaction) { + tx.delete(reactions).where(eq(reactions.id, heartReaction.id)).run(); + tx.delete(notifications) + .where( + and( + eq(notifications.clipId, clipId), + eq(notifications.actorId, userId), + eq(notifications.type, 'reaction'), + eq(notifications.emoji, '❤️') + ) + ) + .run(); + } - return json({ favorited: false }); - } + return { favorited: false, createdReaction: false }; + } - await db.insert(favorites).values({ - clipId, - userId, - createdAt: new Date() - }); + tx.insert(favorites) + .values({ + clipId, + userId, + createdAt: new Date() + }) + .run(); - // Also create ❤️ reaction if one doesn't already exist - const existingReaction = await db.query.reactions.findFirst({ - where: and( - eq(reactions.clipId, clipId), - eq(reactions.userId, userId), - eq(reactions.emoji, '❤️') - ) - }); - if (!existingReaction) { - await db.insert(reactions).values({ - id: uuid(), - clipId, - userId, - emoji: '❤️', - createdAt: new Date() + // Also create ❤️ reaction if one doesn't already exist + const existingReaction = tx + .select() + .from(reactions) + .where( + and(eq(reactions.clipId, clipId), eq(reactions.userId, userId), eq(reactions.emoji, '❤️')) + ) + .get(); + if (!existingReaction) { + tx.insert(reactions) + .values({ + id: uuid(), + clipId, + userId, + emoji: '❤️', + createdAt: new Date() + }) + .run(); + return { favorited: true, createdReaction: true }; + } + + return { favorited: true, createdReaction: false }; }); + } catch { + return json({ error: 'Failed to toggle favorite' }, { status: 500 }); + } - // Notify clip owner (skips self-notification automatically) + // Send push notification outside the transaction (async, network I/O) + if (result.createdReaction) { await notifyClipOwner({ recipientId: clip.addedBy, actorId: userId, @@ -84,5 +106,5 @@ export const POST: RequestHandler = withClipAuth(async ({ params }, { user, clip }); } - return json({ favorited: true }); + return json({ favorited: result.favorited }); }); diff --git a/src/routes/api/clips/share/+server.ts b/src/routes/api/clips/share/+server.ts index 1b92bc6..f36f78b 100644 --- a/src/routes/api/clips/share/+server.ts +++ b/src/routes/api/clips/share/+server.ts @@ -12,33 +12,13 @@ import { isPlatformAllowed, platformLabel } from '$lib/url-validation'; -import { downloadVideo } from '$lib/server/video/download'; -import { downloadMusic } from '$lib/server/music/download'; import { normalizeUrl } from '$lib/server/download-lock'; +import { startDownload } from '$lib/server/clip-download'; import { getActiveProvider } from '$lib/server/providers/registry'; import { createLogger } from '$lib/server/logger'; const log = createLogger('share'); -/** Set clip status to 'downloading' and trigger the download pipeline. Marks as 'failed' on error. */ -async function startDownload(clipId: string, url: string, contentType: string, label: string) { - await db.update(clips).set({ status: 'downloading' }).where(eq(clips.id, clipId)); - - const onError = async (err: unknown) => { - log.error({ err, clipId }, `download failed (${label})`); - await db - .update(clips) - .set({ status: 'failed' }) - .where(and(eq(clips.id, clipId), eq(clips.status, 'downloading'))); - }; - - if (contentType === 'music') { - downloadMusic(clipId, url).catch(onError); - } else { - downloadVideo(clipId, url).catch(onError); - } -} - /** Shortcut-friendly JSON response. Every response includes `success` (1|0) and `message`. */ function shareResponse( success: boolean, @@ -199,27 +179,39 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { return shareResponse(false, '❌ This clip has already been shared!', 409); } - // 8. Create clip + // 8. Create clip + auto-watched in a transaction so both succeed or fail together const clipId = uuid(); - await db.insert(clips).values({ - id: clipId, - groupId: group.id, - addedBy: matchedUser.id, - originalUrl: normalizedVideoUrl, - title: null, - platform, - contentType, - status: 'downloading', - createdAt: new Date() - }); - - // 9. Auto-mark as watched by uploader - await db.insert(watched).values({ - clipId, - userId: matchedUser.id, - watchPercent: 100, - watchedAt: new Date() - }); + const now = new Date(); + try { + db.transaction((tx) => { + tx.insert(clips) + .values({ + id: clipId, + groupId: group.id, + addedBy: matchedUser.id, + originalUrl: normalizedVideoUrl, + title: null, + platform, + contentType, + status: 'downloading', + createdAt: now + }) + .run(); + + // Auto-mark as watched by uploader so it never appears in their "New" tab + tx.insert(watched) + .values({ + clipId, + userId: matchedUser.id, + watchPercent: 100, + watchedAt: now + }) + .run(); + }); + } catch (err) { + log.error({ err, clipId }, 'failed to insert clip'); + return shareResponse(false, 'Something went wrong. Try sharing again.', 500); + } // 10. Async download await startDownload(clipId, videoUrl, contentType, 'new clip'); diff --git a/src/routes/api/group/members/+server.ts b/src/routes/api/group/members/+server.ts index 9189aca..8cd0843 100644 --- a/src/routes/api/group/members/+server.ts +++ b/src/routes/api/group/members/+server.ts @@ -62,19 +62,23 @@ export const POST: RequestHandler = withHost(async ({ request }, { group }) => { const now = new Date(); // Create user + notification preferences atomically - db.transaction((tx) => { - tx.insert(users) - .values({ - id: userId, - username, - phone, - groupId: group.id, - createdAt: now - }) - .run(); + try { + db.transaction((tx) => { + tx.insert(users) + .values({ + id: userId, + username, + phone, + groupId: group.id, + createdAt: now + }) + .run(); - tx.insert(notificationPreferences).values({ userId }).run(); - }); + tx.insert(notificationPreferences).values({ userId }).run(); + }); + } catch { + return json({ error: 'Failed to create member' }, { status: 500 }); + } return json( { diff --git a/src/routes/api/group/shortcut/+server.ts b/src/routes/api/group/shortcut/+server.ts index b40a86a..a7aec2f 100644 --- a/src/routes/api/group/shortcut/+server.ts +++ b/src/routes/api/group/shortcut/+server.ts @@ -11,6 +11,10 @@ export const PATCH: RequestHandler = withHost(async ({ request }, { group }) => const body = await parseBody<{ shortcutUrl?: string | null }>(request); if (isResponse(body)) return body; + if (!('shortcutUrl' in body)) { + return json({ error: 'shortcutUrl field is required' }, { status: 400 }); + } + const { shortcutUrl } = body; if (shortcutUrl !== null && typeof shortcutUrl !== 'string') { diff --git a/src/routes/api/group/stats/+server.ts b/src/routes/api/group/stats/+server.ts index c822687..554224e 100644 --- a/src/routes/api/group/stats/+server.ts +++ b/src/routes/api/group/stats/+server.ts @@ -6,8 +6,12 @@ import { eq, count, isNull, and, sql } from 'drizzle-orm'; import { withAuth } from '$lib/server/api-utils'; export const GET: RequestHandler = withAuth(async (_event, { group }) => { - const [clipCount] = await db - .select({ count: count() }) + // Combine clip count + storage into a single query (both scan the clips table) + const [clipStats] = await db + .select({ + clipCount: count(), + totalBytes: sql`coalesce(sum(${clips.fileSizeBytes}), 0)` + }) .from(clips) .where(eq(clips.groupId, group.id)); @@ -16,15 +20,10 @@ export const GET: RequestHandler = withAuth(async (_event, { group }) => { .from(users) .where(and(eq(users.groupId, group.id), isNull(users.removedAt))); - const [storage] = await db - .select({ totalBytes: sql`coalesce(sum(${clips.fileSizeBytes}), 0)` }) - .from(clips) - .where(eq(clips.groupId, group.id)); - return json({ - clipCount: clipCount.count, + clipCount: clipStats.clipCount, memberCount: memberCount.count, - storageMb: Math.round((storage.totalBytes / 1024 / 1024) * 10) / 10, + storageMb: Math.round((clipStats.totalBytes / 1024 / 1024) * 10) / 10, maxStorageMb: group.maxStorageMb }); }); diff --git a/src/routes/api/notifications/mark-read/+server.ts b/src/routes/api/notifications/mark-read/+server.ts index 6d71aa9..05145f5 100644 --- a/src/routes/api/notifications/mark-read/+server.ts +++ b/src/routes/api/notifications/mark-read/+server.ts @@ -28,6 +28,8 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user }) => { isNull(notifications.readAt) ) ); + } else { + return json({ error: 'Provide either { all: true } or { clipId, type }' }, { status: 400 }); } return json({ ok: true }); diff --git a/src/routes/api/profile/avatar/[filename]/+server.ts b/src/routes/api/profile/avatar/[filename]/+server.ts index 7e048e5..49dde64 100644 --- a/src/routes/api/profile/avatar/[filename]/+server.ts +++ b/src/routes/api/profile/avatar/[filename]/+server.ts @@ -1,28 +1,29 @@ import type { RequestHandler } from './$types'; import { resolve } from 'path'; -import { readFileSync, statSync } from 'fs'; +import { readFile, stat } from 'fs/promises'; +import { unauthorized, forbidden, notFound } from '$lib/server/api-utils'; const DATA_DIR = resolve(process.env.DATA_DIR || 'data', 'avatars'); export const GET: RequestHandler = async ({ params, locals }) => { if (!locals.user) { - return new Response('Unauthorized', { status: 401 }); + return unauthorized(); } const filePath = resolve(DATA_DIR, params.filename); // Prevent directory traversal if (!filePath.startsWith(DATA_DIR)) { - return new Response('Forbidden', { status: 403 }); + return forbidden(); } try { - statSync(filePath); // eslint-disable-line security/detect-non-literal-fs-filename + await stat(filePath); // eslint-disable-line security/detect-non-literal-fs-filename } catch { - return new Response('Not found', { status: 404 }); + return notFound(); } - const data = readFileSync(filePath); // eslint-disable-line security/detect-non-literal-fs-filename + const data = await readFile(filePath); // eslint-disable-line security/detect-non-literal-fs-filename return new Response(data, { headers: { 'Content-Type': 'image/jpeg', diff --git a/src/routes/manifest.json/+server.ts b/src/routes/manifest.json/+server.ts index 1d3e3a3..5734325 100644 --- a/src/routes/manifest.json/+server.ts +++ b/src/routes/manifest.json/+server.ts @@ -15,31 +15,6 @@ export const GET: RequestHandler = () => { background_color: '#000000', theme_color: '#000000', icons: [ - { - src: '/icon/icon-192.svg', - sizes: 'any', - type: 'image/svg+xml', - purpose: 'any' - }, - { - src: '/icon/icon-512.svg', - sizes: 'any', - type: 'image/svg+xml', - purpose: 'any' - }, - { - src: '/icon/icon-maskable-192.svg', - sizes: 'any', - type: 'image/svg+xml', - purpose: 'maskable' - }, - { - src: '/icon/icon-maskable-512.svg', - sizes: 'any', - type: 'image/svg+xml', - purpose: 'maskable' - }, - // PNG fallbacks for platforms that don't support SVG manifest icons { src: '/icons/icon-192.png', sizes: '192x192', @@ -65,9 +40,9 @@ export const GET: RequestHandler = () => { purpose: 'maskable' }, { - src: '/icon/badge-72.svg', - sizes: 'any', - type: 'image/svg+xml', + src: '/icons/badge-72.png', + sizes: '72x72', + type: 'image/png', purpose: 'monochrome' } ], diff --git a/src/routes/share/+page.svelte b/src/routes/share/+page.svelte index e6be64e..0794c3b 100644 --- a/src/routes/share/+page.svelte +++ b/src/routes/share/+page.svelte @@ -1,5 +1,5 @@