26 Commits

Author SHA1 Message Date
Renovate Bot
2147a0b15f Update https://git.quad4.io/actions/checkout action to v6
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 1m2s
2025-12-29 20:05:34 +00:00
ec011f7442 Merge pull request 'Update dependency cookie to v1' (#6) from renovate/cookie-1.x into master
All checks were successful
CI / build-frontend (push) Successful in 1m44s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m38s
CI / build-backend (push) Successful in 9m37s
Reviewed-on: #6
2025-12-29 20:02:14 +00:00
5228640a21 Merge pull request 'Update dependency gradle to v9' (#7) from renovate/gradle-9.x into master
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 1m2s
CI / build-frontend (push) Successful in 1m3s
CI / build-backend (push) Successful in 43s
Reviewed-on: #7
2025-12-29 20:02:05 +00:00
49df78f553 Update CI and workflow files to use custom action URLs instead of default GitHub actions. Remove obsolete renovate.yml workflow file.
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 38s
CI / build-frontend (push) Successful in 1m33s
CI / build-backend (push) Successful in 59s
2025-12-28 20:58:54 -06:00
ivan
5549171165 Merge pull request 'Update ghcr.io/renovatebot/renovate Docker tag to v42.66.11' (#12) from renovate/ghcr.io-renovatebot-renovate-42.x into master
Some checks failed
renovate / renovate (push) Failing after 27s
CI / build-backend (push) Has been skipped
CI / build-frontend (push) Failing after 29s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m39s
Reviewed-on: #12
2025-12-29 00:21:33 +00:00
Renovate Bot
39f2986150 Update ghcr.io/renovatebot/renovate Docker tag to v42.66.11
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 9m36s
2025-12-29 00:04:22 +00:00
ivan
e0f85b450c Merge pull request 'Update ghcr.io/renovatebot/renovate Docker tag to v42' (#9) from renovate/ghcr.io-renovatebot-renovate-42.x into master
Some checks failed
CI / build-frontend (push) Failing after 9s
CI / build-backend (push) Has been skipped
renovate / renovate (push) Failing after 25s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m35s
Reviewed-on: #9
2025-12-28 18:53:37 +00:00
Renovate Bot
19a9e0506d Update ghcr.io/renovatebot/renovate Docker tag to v42
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 9m35s
2025-12-28 14:53:19 +00:00
3cdca944ef 0.2.3
Some checks failed
renovate / renovate (push) Failing after 4s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 54s
CI / build-frontend (push) Successful in 1m6s
CI / build-backend (push) Successful in 36s
Build and Publish Docker Image / build (push) Successful in 11m1s
Publish / publish (push) Successful in 33m35s
2025-12-27 21:27:15 -06:00
895fba9ded Update fetching and filtering capabilities by adding category support in GetArticles methods across multiple files. Update NewsStore to manage selected category state and integrate category filtering in article loading. Improve feed fetching with abort signal handling in fetchFeed function. Update UI components to reflect category selection and enhance user experience with new feed health management features.
Some checks failed
renovate / renovate (push) Failing after 13s
CI / build-frontend (push) Successful in 50s
CI / build-backend (push) Successful in 49s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m39s
2025-12-27 21:26:28 -06:00
a4503563e3 Update version to 0.2.2 in package.json
Some checks failed
renovate / renovate (push) Failing after 15s
CI / build-frontend (push) Successful in 51s
CI / build-backend (push) Successful in 35s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m37s
2025-12-27 21:01:03 -06:00
82da01ca45 Add OPML import functionality in AddFeedModal component, including file handling and toast notifications. Update ArticleCard to display feed icons and enhance article source display. Modify NewsStore to track last articles update and improve article refresh logic. Update UI components for better user experience and consistency. 2025-12-27 21:00:36 -06:00
7e235cb9d1 Format configuration files for consistency and readability. Update docker-compose.coolify.yml to use single quotes for labels. Adjust pnpm-lock.yaml to format resolution entries uniformly. Clean up README.md by removing an empty checklist item. Standardize quotes in renovate.json and .gitea/workflows/renovate.yml. Enhance HTML structure in app.html for better readability. 2025-12-27 20:32:18 -06:00
a626a7cb33 Add newlyRegisteredToken state to NewsStore and implement account number display and copy functionality in the registration flow. Update UI to show success message upon registration and allow users to copy their account number to clipboard. 2025-12-27 20:32:06 -06:00
2c6bee84b4 Add caching support in main.go and related files, including SQLite integration for cache storage. Update Docker configurations to include cache settings and enable public instance optimizations.
Some checks failed
renovate / renovate (push) Failing after 15s
CI / build-frontend (push) Successful in 51s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m31s
CI / build-backend (push) Successful in 9m36s
2025-12-27 20:25:38 -06:00
965c2b6daf Update version to 0.2.1
Some checks failed
renovate / renovate (push) Failing after 14s
CI / build-frontend (push) Successful in 49s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m32s
CI / build-backend (push) Successful in 9m27s
2025-12-27 20:13:28 -06:00
20b83eb052 Update sidebar version display to use dynamic APP_VERSION import for improved maintainability 2025-12-27 20:13:17 -06:00
9bd06c1f70 Add rate limiting environment variables to docker-compose.coolify.yml for improved service configuration
Some checks failed
renovate / renovate (push) Failing after 20s
CI / build-frontend (push) Successful in 47s
CI / build-backend (push) Successful in 25s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m37s
2025-12-27 20:09:28 -06:00
0765f47083 Add rate limiting environment variables to Dockerfile for enhanced configuration 2025-12-27 20:09:23 -06:00
0b7c15f2f0 Fix rate limit environment variable handling in main.go to improve parsing logic and remove redundant code 2025-12-27 20:09:16 -06:00
7180776daa Add rate limiting configuration options in main.go and implement SetLimit method in RateLimiter to dynamically adjust rate limits based on environment variables 2025-12-27 20:08:20 -06:00
4ed6fcd752 Add GetRealIP function to improve IP retrieval in middleware; update BotBlockerMiddleware to use new function for logging blocked requests
Some checks failed
renovate / renovate (push) Failing after 21s
CI / build-frontend (push) Successful in 51s
CI / build-backend (push) Successful in 26s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m39s
2025-12-27 20:01:05 -06:00
4e364bec74 Fix authentication flags in main.go to support environment variable defaults for auth mode, auth file, registration allowance, hashes file, and protection settings 2025-12-27 20:01:00 -06:00
8e41a88599 Update docker-compose.coolify.yml to enhance environment variable defaults and add Traefik rate limiting labels 2025-12-27 20:00:49 -06:00
Renovate Bot
cafe5b8fd0 Update dependency gradle to v9
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 42s
2025-12-28 01:10:21 +00:00
Renovate Bot
3534ba9b89 Update dependency cookie to v1
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 42s
2025-12-28 01:10:07 +00:00
31 changed files with 1020 additions and 326 deletions

View File

@@ -11,14 +11,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: https://git.quad4.io/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 uses: https://git.quad4.io/actions/setup-pnpm@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
with: with:
version: 10 version: 10
run_install: false run_install: false
@@ -29,7 +29,7 @@ jobs:
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache - name: Setup pnpm cache
uses: actions/cache@v4 uses: https://git.quad4.io/actions/cache@v4
with: with:
path: ${{ env.STORE_PATH }} path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -43,7 +43,7 @@ jobs:
- name: Build frontend - name: Build frontend
run: bash scripts/build.sh run: bash scripts/build.sh
- name: Upload frontend assets - name: Upload frontend assets
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3 uses: https://git.quad4.io/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with: with:
name: frontend-build name: frontend-build
path: build/ path: build/
@@ -53,14 +53,14 @@ jobs:
needs: build-frontend needs: build-frontend
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download frontend assets - name: Download frontend assets
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3 uses: https://git.quad4.io/actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
with: with:
name: frontend-build name: frontend-build
path: build/ path: build/
- name: Setup Go - name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with: with:
go-version: '1.25.4' go-version: '1.25.4'
- name: Build backend - name: Build backend

View File

@@ -22,18 +22,18 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 uses: https://git.quad4.io/actions/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
with: with:
platforms: amd64,arm64 platforms: amd64,arm64
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 uses: https://git.quad4.io/actions/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 uses: https://git.quad4.io/actions/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }} username: ${{ secrets.REGISTRY_USERNAME }}
@@ -41,7 +41,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 uses: https://git.quad4.io/actions/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
@@ -54,7 +54,7 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
id: build id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 uses: https://git.quad4.io/actions/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile

View File

@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Go - name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'

View File

@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Go - name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'

View File

@@ -11,33 +11,33 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 uses: https://git.quad4.io/actions/setup-pnpm@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
with: with:
version: 10 version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: https://git.quad4.io/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
- name: Setup Go - name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with: with:
go-version: '1.25.4' go-version: '1.25.4'
- name: Setup Java - name: Setup Java
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 uses: https://git.quad4.io/actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
cache: gradle cache: gradle
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 uses: https://git.quad4.io/actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with: with:
log-accepted-android-sdk-licenses: false log-accepted-android-sdk-licenses: false

View File

@@ -1,24 +0,0 @@
name: renovate
on:
schedule:
- cron: "@daily"
push:
branches:
- main
- master
workflow_dispatch:
jobs:
renovate:
runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:37.440.7
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Fetch remote configuration
run: curl -sL https://git.quad4.io/Quad4-Extra/renovate-config/raw/branch/master/config.js -o config.js
- run: renovate
env:
RENOVATE_CONFIG_FILE: "config.js"
LOG_LEVEL: "debug"
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}

View File

@@ -53,7 +53,11 @@ ENV PORT=8080
ENV NODE_ENV=production ENV NODE_ENV=production
ENV AUTH_FILE=/app/data/accounts.json ENV AUTH_FILE=/app/data/accounts.json
ENV HASHES_FILE=/app/data/client_hashes.json ENV HASHES_FILE=/app/data/client_hashes.json
ENV RATE_LIMIT=100
ENV RATE_BURST=200
ENV CACHE_FILE=/app/data/cache.db
ENV PUBLIC_INSTANCE=false
USER 65532 USER 65532
CMD ["./web-news", "-auth-file", "/app/data/accounts.json", "-hashes-file", "/app/data/client_hashes.json"] CMD ["./web-news", "-auth-file", "/app/data/accounts.json", "-hashes-file", "/app/data/client_hashes.json", "-cache-file", "/app/data/cache.db"]

View File

@@ -29,12 +29,9 @@ Web News follows a "zero-knowledge" philosophy:
- [ ] Reading time - [ ] Reading time
- [ ] UI/UX Cleanup - [ ] UI/UX Cleanup
- [ ] Add feed fetching timeout and button to remove if failed 3 times
- [ ] Use Go Mobile, remove Java RSS plugin. - [ ] Use Go Mobile, remove Java RSS plugin.
- [ ] Dont show loading screen if not initial load (eg. reloading tab)
- [ ] Fix feeds double image (Feed image and article image at top)
- [ ] Export article(s) - [ ] Export article(s)
- [ ] Export/Import OPML on add feed modal - [ ] Favicon fetcher and caching
## Getting Started ## Getting Started

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

View File

@@ -151,12 +151,12 @@ func (a *App) SaveArticles(articles string) error {
return a.db.SaveArticles(articles) return a.db.SaveArticles(articles)
} }
func (a *App) GetArticles(feedId string, offset, limit int) (string, error) { func (a *App) GetArticles(feedId string, offset, limit int, categoryId string) (string, error) {
a.logDebug("GetArticles feedId=%s offset=%d limit=%d", feedId, offset, limit) a.logDebug("GetArticles feedId=%s categoryId=%s offset=%d limit=%d", feedId, categoryId, offset, limit)
if a.db == nil { if a.db == nil {
return "[]", nil return "[]", nil
} }
return a.db.GetArticles(feedId, offset, limit) return a.db.GetArticles(feedId, offset, limit, categoryId)
} }
func (a *App) SearchArticles(query string, limit int) (string, error) { func (a *App) SearchArticles(query string, limit int) (string, error) {

View File

@@ -43,6 +43,7 @@ require (
golang.org/x/crypto v0.46.0 // indirect golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect

View File

@@ -5,15 +5,24 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
image: ${DOCKER_IMAGE:-web-news:latest} image: ${DOCKER_IMAGE:-web-news:latest}
environment: environment:
- PORT=${PORT:?8080} - PORT=${PORT:-8080}
- NODE_ENV=production - NODE_ENV=production
- AUTH_MODE=multi - AUTH_MODE=${AUTH_MODE:-multi}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
# Coolify automatically populates SERVICE_URL_WEB_NEWS with the domain # Coolify automatically populates SERVICE_URL_WEB_NEWS with the domain
- ALLOWED_ORIGINS=${SERVICE_URL_WEB_NEWS:-*} - ALLOWED_ORIGINS=${SERVICE_URL_WEB_NEWS:-*}
- AUTH_FILE=/app/data/accounts.json - AUTH_FILE=/app/data/accounts.json
- HASHES_FILE=/app/data/client_hashes.json - HASHES_FILE=/app/data/client_hashes.json
- RATE_LIMIT=${RATE_LIMIT:-100}
- RATE_BURST=${RATE_BURST:-200}
- CACHE_FILE=/app/data/cache.db
- PUBLIC_INSTANCE=${PUBLIC_INSTANCE:-true}
volumes: volumes:
- web-news-data:/app/data - web-news-data:/app/data
labels:
- 'traefik.enable=true'
- 'traefik.http.middlewares.web-news-ratelimit.ratelimit.average=100'
- 'traefik.http.middlewares.web-news-ratelimit.ratelimit.burst=50'
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
restart: unless-stopped restart: unless-stopped

View File

@@ -13,6 +13,8 @@ services:
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
- AUTH_FILE=/app/data/accounts.json - AUTH_FILE=/app/data/accounts.json
- HASHES_FILE=/app/data/client_hashes.json - HASHES_FILE=/app/data/client_hashes.json
- CACHE_FILE=/app/data/cache.db
- PUBLIC_INSTANCE=${PUBLIC_INSTANCE:-false}
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
restart: unless-stopped restart: unless-stopped

View File

@@ -14,6 +14,7 @@ services:
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
- AUTH_FILE=/app/data/accounts.json - AUTH_FILE=/app/data/accounts.json
- HASHES_FILE=/app/data/client_hashes.json - HASHES_FILE=/app/data/client_hashes.json
- CACHE_FILE=/app/data/cache.db
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
restart: unless-stopped restart: unless-stopped

View File

@@ -52,6 +52,7 @@ export default [
FileReader: 'readonly', FileReader: 'readonly',
performance: 'readonly', performance: 'readonly',
AbortController: 'readonly', AbortController: 'readonly',
AbortSignal: 'readonly',
DOMParser: 'readonly', DOMParser: 'readonly',
Element: 'readonly', Element: 'readonly',
Node: 'readonly', Node: 'readonly',

1
go.mod
View File

@@ -23,6 +23,7 @@ require (
github.com/stretchr/testify v1.10.0 // indirect github.com/stretchr/testify v1.10.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
modernc.org/libc v1.66.10 // indirect modernc.org/libc v1.66.10 // indirect

View File

@@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"log" "log"
"net" "net"
@@ -16,7 +17,9 @@ import (
"time" "time"
"git.quad4.io/Go-Libs/RSS" "git.quad4.io/Go-Libs/RSS"
"git.quad4.io/Quad4-Software/webnews/internal/storage"
readability "github.com/go-shiori/go-readability" readability "github.com/go-shiori/go-readability"
"golang.org/x/sync/singleflight"
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
@@ -45,6 +48,71 @@ type Article struct {
ImageURL string `json:"imageUrl"` ImageURL string `json:"imageUrl"`
} }
type cacheEntry struct {
data any
expiresAt time.Time
}
type Cache struct {
entries sync.Map
TTL time.Duration
Enabled bool
Storage *storage.SQLiteDB
}
func (c *Cache) Get(key string) (any, bool) {
if !c.Enabled {
return nil, false
}
if c.Storage != nil {
data, err := c.Storage.GetCache(key)
if err != nil || data == nil {
return nil, false
}
var val any
if err := json.Unmarshal(data, &val); err != nil {
return nil, false
}
return val, true
}
val, ok := c.entries.Load(key)
if !ok {
return nil, false
}
entry := val.(cacheEntry)
if time.Now().After(entry.expiresAt) {
c.entries.Delete(key)
return nil, false
}
return entry.data, true
}
func (c *Cache) Set(key string, data any) {
if !c.Enabled {
return
}
if c.Storage != nil {
b, err := json.Marshal(data)
if err != nil {
return
}
_ = c.Storage.SetCache(key, b, c.TTL)
return
}
c.entries.Store(key, cacheEntry{
data: data,
expiresAt: time.Now().Add(c.TTL),
})
}
var FeedCache = &Cache{TTL: 10 * time.Minute, Enabled: false}
var FullTextCache = &Cache{TTL: 1 * time.Hour, Enabled: false}
var RequestGroup = &singleflight.Group{}
type RateLimiter struct { type RateLimiter struct {
clients map[string]*rate.Limiter clients map[string]*rate.Limiter
mu *sync.RWMutex mu *sync.RWMutex
@@ -122,7 +190,16 @@ func (rl *RateLimiter) GetLimiter(id string) *rate.Limiter {
return limiter return limiter
} }
var Limiter = NewRateLimiter(rate.Every(time.Second), 5, "") func (rl *RateLimiter) SetLimit(r rate.Limit, b int) {
rl.mu.Lock()
defer rl.mu.Unlock()
rl.r = r
rl.b = b
// Reset existing limiters to apply new rate
rl.clients = make(map[string]*rate.Limiter)
}
var Limiter = NewRateLimiter(rate.Limit(50), 100, "")
var ForbiddenPatterns = []string{ var ForbiddenPatterns = []string{
".git", ".env", ".aws", ".config", ".ssh", ".git", ".env", ".aws", ".config", ".ssh",
@@ -130,14 +207,34 @@ var ForbiddenPatterns = []string{
"etc/passwd", "cgi-bin", "etc/passwd", "cgi-bin",
} }
func GetRealIP(r *http.Request) string {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if comma := strings.IndexByte(xff, ','); comma != -1 {
return strings.TrimSpace(xff[:comma])
}
return strings.TrimSpace(xff)
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
return ip
}
func BotBlockerMiddleware(next http.HandlerFunc) http.HandlerFunc { func BotBlockerMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
path := strings.ToLower(r.URL.Path) path := strings.ToLower(r.URL.Path)
query := strings.ToLower(r.URL.RawQuery)
for _, pattern := range ForbiddenPatterns { for _, pattern := range ForbiddenPatterns {
if strings.Contains(path, pattern) || strings.Contains(query, pattern) { if strings.Contains(path, pattern) {
log.Printf("Blocked suspicious request: %s from %s", r.URL.String(), r.RemoteAddr) ip := GetRealIP(r)
log.Printf("Blocked suspicious request: %s from %s", r.URL.String(), ip)
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }
@@ -276,18 +373,7 @@ func AuthMiddleware(am *AuthManager, next http.HandlerFunc) http.HandlerFunc {
func LimitMiddleware(next http.HandlerFunc) http.HandlerFunc { func LimitMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ip, _, err := net.SplitHostPort(r.RemoteAddr) ip := GetRealIP(r)
if err != nil {
ip = r.RemoteAddr
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if comma := strings.IndexByte(xff, ','); comma != -1 {
ip = xff[:comma]
} else {
ip = xff
}
}
ua := r.Header.Get("User-Agent") ua := r.Header.Get("User-Agent")
hash := sha256.New() hash := sha256.New()
@@ -316,94 +402,111 @@ func HandleFeedProxy(w http.ResponseWriter, r *http.Request) {
return return
} }
client := &http.Client{Timeout: 15 * time.Second} if data, ok := FeedCache.Get(feedURL); ok {
req, err := http.NewRequest("GET", feedURL, nil) w.Header().Set("Content-Type", "application/json")
if err != nil { if err := json.NewEncoder(w).Encode(data); err != nil {
http.Error(w, "Failed to create request: "+err.Error(), http.StatusInternalServerError) log.Printf("Error encoding cached feed proxy response: %v", err)
}
return return
} }
// Add browser-like headers to avoid being blocked by Cloudflare/Bot protection val, err, _ := RequestGroup.Do(feedURL, func() (any, error) {
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") client := &http.Client{Timeout: 15 * time.Second}
req.Header.Set("Accept", "application/rss+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.7") req, err := http.NewRequest("GET", feedURL, nil)
req.Header.Set("Cache-Control", "no-cache") if err != nil {
req.Header.Set("Pragma", "no-cache") return nil, fmt.Errorf("failed to create request: %w", err)
resp, err := client.Do(req)
if err != nil {
http.Error(w, "Failed to fetch feed: "+err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.Error(w, "Feed returned status "+resp.Status, http.StatusBadGateway)
return
}
data, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, "Failed to read feed body", http.StatusInternalServerError)
return
}
parsedFeed, err := rss.Parse(data)
if err != nil {
http.Error(w, "Failed to parse feed: "+err.Error(), http.StatusInternalServerError)
return
}
articles := make([]Article, 0, len(parsedFeed.Items))
for _, item := range parsedFeed.Items {
id := item.GUID
if id == "" {
id = item.Link
} }
pubDate := time.Now().UnixMilli() // Add browser-like headers to avoid being blocked by Cloudflare/Bot protection
if item.Published != nil { req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
pubDate = item.Published.UnixMilli() req.Header.Set("Accept", "application/rss+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.7")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch feed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("feed returned status %s", resp.Status)
} }
author := "" data, err := io.ReadAll(resp.Body)
if item.Author != nil { if err != nil {
author = item.Author.Name return nil, fmt.Errorf("failed to read feed body: %w", err)
} }
imageURL := "" parsedFeed, err := rss.Parse(data)
for _, enc := range item.Enclosures { if err != nil {
if enc.Type == "image/jpeg" || enc.Type == "image/png" || enc.Type == "image/gif" { return nil, fmt.Errorf("failed to parse feed: %w", err)
imageURL = enc.URL }
break
articles := make([]Article, 0, len(parsedFeed.Items))
for _, item := range parsedFeed.Items {
id := item.GUID
if id == "" {
id = item.Link
} }
pubDate := time.Now().UnixMilli()
if item.Published != nil {
pubDate = item.Published.UnixMilli()
}
author := ""
if item.Author != nil {
author = item.Author.Name
}
imageURL := ""
for _, enc := range item.Enclosures {
if enc.Type == "image/jpeg" || enc.Type == "image/png" || enc.Type == "image/gif" {
imageURL = enc.URL
break
}
}
articles = append(articles, Article{
ID: id,
FeedID: feedURL,
Title: item.Title,
Link: item.Link,
Description: item.Description,
Author: author,
PubDate: pubDate,
Read: false,
Saved: false,
ImageURL: imageURL,
})
} }
articles = append(articles, Article{ response := ProxyResponse{
ID: id, Feed: FeedInfo{
FeedID: feedURL, Title: parsedFeed.Title,
Title: item.Title, SiteURL: parsedFeed.Link,
Link: item.Link, Description: parsedFeed.Description,
Description: item.Description, LastFetched: time.Now().UnixMilli(),
Author: author, },
PubDate: pubDate, Articles: articles,
Read: false, }
Saved: false,
ImageURL: imageURL,
})
}
response := ProxyResponse{ FeedCache.Set(feedURL, response)
Feed: FeedInfo{ return response, nil
Title: parsedFeed.Title, })
SiteURL: parsedFeed.Link,
Description: parsedFeed.Description, if err != nil {
LastFetched: time.Now().UnixMilli(), if strings.Contains(err.Error(), "status") {
}, http.Error(w, err.Error(), http.StatusBadGateway)
Articles: articles, } else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil { if err := json.NewEncoder(w).Encode(val); err != nil {
log.Printf("Error encoding feed proxy response: %v", err) log.Printf("Error encoding feed proxy response: %v", err)
} }
} }
@@ -473,46 +576,61 @@ func HandleFullText(w http.ResponseWriter, r *http.Request) {
return return
} }
parsedURL, _ := url.Parse(targetURL) if data, ok := FullTextCache.Get(targetURL); ok {
article, err := readability.FromURL(targetURL, 15*time.Second) w.Header().Set("Content-Type", "application/json")
if err != nil { if err := json.NewEncoder(w).Encode(data); err != nil {
client := &http.Client{Timeout: 15 * time.Second} log.Printf("Error encoding cached fulltext response: %v", err)
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
http.Error(w, "Failed to create request: "+err.Error(), http.StatusInternalServerError)
return
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
resp, err := client.Do(req)
if err != nil {
http.Error(w, "Failed to fetch content: "+err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
article, err = readability.FromReader(resp.Body, parsedURL)
if err != nil {
http.Error(w, "Failed to extract content: "+err.Error(), http.StatusInternalServerError)
return
} }
return
} }
response := FullTextResponse{ val, err, _ := RequestGroup.Do("ft-"+targetURL, func() (any, error) {
Title: article.Title, parsedURL, _ := url.Parse(targetURL)
Content: article.Content, article, err := readability.FromURL(targetURL, 15*time.Second)
TextContent: article.TextContent, if err != nil {
Excerpt: article.Excerpt, client := &http.Client{Timeout: 15 * time.Second}
Byline: article.Byline, req, err := http.NewRequest("GET", targetURL, nil)
SiteName: article.SiteName, if err != nil {
Image: article.Image, return nil, fmt.Errorf("failed to create request: %w", err)
Favicon: article.Favicon, }
URL: targetURL, req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch content: %w", err)
}
defer resp.Body.Close()
article, err = readability.FromReader(resp.Body, parsedURL)
if err != nil {
return nil, fmt.Errorf("failed to extract content: %w", err)
}
}
response := FullTextResponse{
Title: article.Title,
Content: article.Content,
TextContent: article.TextContent,
Excerpt: article.Excerpt,
Byline: article.Byline,
SiteName: article.SiteName,
Image: article.Image,
Favicon: article.Favicon,
URL: targetURL,
}
FullTextCache.Set(targetURL, response)
return response, nil
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil { if err := json.NewEncoder(w).Encode(val); err != nil {
log.Printf("Error encoding fulltext response: %v", err) log.Printf("Error encoding fulltext response: %v", err)
} }
} }

View File

@@ -87,6 +87,12 @@ func (s *SQLiteDB) init() error {
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT value TEXT
);`, );`,
`CREATE TABLE IF NOT EXISTS caches (
key TEXT PRIMARY KEY,
value BLOB,
expiresAt INTEGER
);`,
`CREATE INDEX IF NOT EXISTS idx_caches_expiresAt ON caches(expiresAt);`,
} }
for _, q := range queries { for _, q := range queries {
@@ -316,15 +322,22 @@ func (s *SQLiteDB) SaveArticles(articlesJSON string) error {
return tx.Commit() return tx.Commit()
} }
func (s *SQLiteDB) GetArticles(feedId string, offset, limit int) (string, error) { func (s *SQLiteDB) GetArticles(feedId string, offset, limit int, categoryId string) (string, error) {
var rows *sql.Rows var rows *sql.Rows
var err error var err error
if feedId != "" { if feedId != "" {
rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?", feedId, limit, offset) rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?", feedId, limit, offset)
} else if categoryId != "" {
rows, err = s.db.Query(`
SELECT a.id, a.feedId, a.title, a.link, a.description, a.content, a.author, a.pubDate, a.read, a.saved, a.imageUrl, a.readAt
FROM articles a
JOIN feeds f ON a.feedId = f.id
WHERE f.categoryId = ?
ORDER BY a.pubDate DESC LIMIT ? OFFSET ?`, categoryId, limit, offset)
} else { } else {
rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?", limit, offset) rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?", limit, offset)
} }
if err != nil { if err != nil {
return "[]", err return "[]", err
} }
@@ -371,7 +384,7 @@ func (s *SQLiteDB) SearchArticles(query string, limit int) (string, error) {
FROM articles FROM articles
WHERE title LIKE ? OR description LIKE ? OR content LIKE ? WHERE title LIKE ? OR description LIKE ? OR content LIKE ?
ORDER BY pubDate DESC LIMIT ?`, q, q, q, limit) ORDER BY pubDate DESC LIMIT ?`, q, q, q, limit)
if err != nil { if err != nil {
return "[]", err return "[]", err
} }
@@ -482,7 +495,7 @@ func (s *SQLiteDB) ClearAll() error {
func (s *SQLiteDB) GetReadingHistory(days int) (string, error) { func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
cutoff := time.Now().AddDate(0, 0, -days).UnixMilli() cutoff := time.Now().AddDate(0, 0, -days).UnixMilli()
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch')) as date, COUNT(*) as count SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch', 'localtime')) as date, COUNT(*) as count
FROM articles FROM articles
WHERE read = 1 AND readAt > ? WHERE read = 1 AND readAt > ?
GROUP BY date GROUP BY date
@@ -499,8 +512,11 @@ func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
if err := rows.Scan(&date, &count); err != nil { if err := rows.Scan(&date, &count); err != nil {
continue continue
} }
// Convert date string back to timestamp for frontend // Convert local date string back to local midnight timestamp for frontend
t, _ := time.Parse("2006-01-02", date) t, err := time.ParseInLocation("2006-01-02", date, time.Local)
if err != nil {
continue
}
history = append(history, map[string]any{ history = append(history, map[string]any{
"date": t.UnixMilli(), "date": t.UnixMilli(),
"count": count, "count": count,
@@ -510,3 +526,32 @@ func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
b, _ := json.Marshal(history) b, _ := json.Marshal(history)
return string(b), nil return string(b), nil
} }
func (s *SQLiteDB) SetCache(key string, value []byte, ttl time.Duration) error {
expiresAt := time.Now().Add(ttl).UnixMilli()
_, err := s.db.Exec("INSERT OR REPLACE INTO caches (key, value, expiresAt) VALUES (?, ?, ?)", key, value, expiresAt)
return err
}
func (s *SQLiteDB) GetCache(key string) ([]byte, error) {
var value []byte
var expiresAt int64
err := s.db.QueryRow("SELECT value, expiresAt FROM caches WHERE key = ?", key).Scan(&value, &expiresAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if time.Now().UnixMilli() > expiresAt {
_, _ = s.db.Exec("DELETE FROM caches WHERE key = ?", key)
return nil, nil
}
return value, nil
}
func (s *SQLiteDB) PurgeExpiredCaches() error {
now := time.Now().UnixMilli()
_, err := s.db.Exec("DELETE FROM caches WHERE expiresAt < ?", now)
return err
}

99
main.go
View File

@@ -4,6 +4,7 @@ import (
"embed" "embed"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt"
"io/fs" "io/fs"
"log" "log"
"net" "net"
@@ -13,6 +14,8 @@ import (
"time" "time"
"git.quad4.io/Quad4-Software/webnews/internal/api" "git.quad4.io/Quad4-Software/webnews/internal/api"
"git.quad4.io/Quad4-Software/webnews/internal/storage"
"golang.org/x/time/rate"
) )
//go:embed build/* //go:embed build/*
@@ -72,15 +75,101 @@ func main() {
allowedOriginsStr := flag.String("allowed-origins", os.Getenv("ALLOWED_ORIGINS"), "Comma-separated list of allowed CORS origins") allowedOriginsStr := flag.String("allowed-origins", os.Getenv("ALLOWED_ORIGINS"), "Comma-separated list of allowed CORS origins")
// Auth flags // Auth flags
authMode := flag.String("auth-mode", "none", "Authentication mode: none, token, multi") defaultAuthMode := os.Getenv("AUTH_MODE")
if defaultAuthMode == "" {
defaultAuthMode = "none"
}
authMode := flag.String("auth-mode", defaultAuthMode, "Authentication mode: none, token, multi")
authToken := flag.String("auth-token", os.Getenv("AUTH_TOKEN"), "Master token for 'token' auth mode") authToken := flag.String("auth-token", os.Getenv("AUTH_TOKEN"), "Master token for 'token' auth mode")
authFile := flag.String("auth-file", "accounts.json", "File to store accounts for 'multi' auth mode")
allowReg := flag.Bool("allow-registration", true, "Allow new account generation in 'multi' mode") defaultAuthFile := os.Getenv("AUTH_FILE")
hashesFile := flag.String("hashes-file", "client_hashes.json", "File to store IP+UA hashes for rate limiting") if defaultAuthFile == "" {
disableProtection := flag.Bool("disable-protection", false, "Disable rate limiting and bot protection") defaultAuthFile = "accounts.json"
}
authFile := flag.String("auth-file", defaultAuthFile, "File to store accounts for 'multi' auth mode")
defaultAllowReg := true
if os.Getenv("ALLOW_REGISTRATION") == "false" {
defaultAllowReg = false
}
allowReg := flag.Bool("allow-registration", defaultAllowReg, "Allow new account generation in 'multi' mode")
defaultHashesFile := os.Getenv("HASHES_FILE")
if defaultHashesFile == "" {
defaultHashesFile = "client_hashes.json"
}
hashesFile := flag.String("hashes-file", defaultHashesFile, "File to store IP+UA hashes for rate limiting")
rateLimit := flag.Float64("rate-limit", 50.0, "Rate limit in requests per second (env: RATE_LIMIT)")
rateBurst := flag.Int("rate-burst", 100, "Rate limit burst size (env: RATE_BURST)")
disableProtection := flag.Bool("disable-protection", os.Getenv("DISABLE_PROTECTION") == "true", "Disable rate limiting and bot protection")
publicInstance := flag.Bool("public-instance", os.Getenv("PUBLIC_INSTANCE") == "true", "Enable optimizations for public instances (caching, etc.)")
cacheEnabled := flag.Bool("cache-enabled", os.Getenv("CACHE_ENABLED") == "true", "Explicitly enable/disable caching")
cacheTTL := flag.Duration("cache-ttl", 10*time.Minute, "Cache TTL (env: CACHE_TTL)")
cacheFile := flag.String("cache-file", os.Getenv("CACHE_FILE"), "SQLite file for caching (reduces memory load)")
flag.Parse() flag.Parse()
// Handle cache config
if envTTL := os.Getenv("CACHE_TTL"); envTTL != "" {
if d, err := time.ParseDuration(envTTL); err == nil {
*cacheTTL = d
}
}
api.FeedCache.TTL = *cacheTTL
api.FullTextCache.TTL = *cacheTTL * 6 // Full text stays longer
if *cacheFile != "" {
db, err := storage.NewSQLiteDB(*cacheFile)
if err != nil {
log.Fatalf("Failed to initialize cache database: %v", err)
}
api.FeedCache.Storage = db
api.FullTextCache.Storage = db
log.Printf("Using SQLite for caching: %s\n", *cacheFile)
// Background cleanup of expired items
go func() {
for {
time.Sleep(1 * time.Hour)
if err := db.PurgeExpiredCaches(); err != nil {
log.Printf("Error purging expired caches: %v", err)
}
}
}()
}
if *publicInstance {
api.FeedCache.Enabled = true
api.FullTextCache.Enabled = true
log.Printf("Public instance optimizations enabled (caching enabled, TTL: %v)\n", *cacheTTL)
}
if os.Getenv("CACHE_ENABLED") != "" {
api.FeedCache.Enabled = *cacheEnabled
api.FullTextCache.Enabled = *cacheEnabled
log.Printf("Caching explicitly %v (TTL: %v)\n", map[bool]string{true: "enabled", false: "disabled"}[*cacheEnabled], *cacheTTL)
}
// Override rate limits from environment if set
if envRate := os.Getenv("RATE_LIMIT"); envRate != "" {
var r float64
if _, err := fmt.Sscanf(envRate, "%f", &r); err == nil {
*rateLimit = r
}
}
if envBurst := os.Getenv("RATE_BURST"); envBurst != "" {
var b int
if _, err := fmt.Sscanf(envBurst, "%d", &b); err == nil {
*rateBurst = b
}
}
api.Limiter.SetLimit(rate.Limit(*rateLimit), *rateBurst)
if *hashesFile != "" { if *hashesFile != "" {
api.Limiter.File = *hashesFile api.Limiter.File = *hashesFile
api.Limiter.LoadHashes() api.Limiter.LoadHashes()

View File

@@ -1,6 +1,6 @@
{ {
"name": "web-news", "name": "web-news",
"version": "0.2.0", "version": "0.2.3",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
"bin": { "bin": {
@@ -57,6 +57,6 @@
"tailwindcss": "^3.4.19" "tailwindcss": "^3.4.19"
}, },
"overrides": { "overrides": {
"cookie": "^0.7.0" "cookie": "^1.0.0"
} }
} }

View File

@@ -1,3 +1,3 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json" "$schema": "https://docs.renovatebot.com/renovate-schema.json"
} }

View File

@@ -5,24 +5,30 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Web News - Personal RSS Reader</title> <title>Web News - Personal RSS Reader</title>
<meta name="description" content="A fast, clean, and private RSS reader for all your news." /> <meta name="description" content="A fast, clean, and private RSS reader for all your news." />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="shortcut icon" href="/favicon.svg" /> <link rel="shortcut icon" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" /> <link rel="apple-touch-icon" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content="https://webnews.quad4.io" /> <meta property="og:url" content="https://webnews.quad4.io" />
<meta property="og:title" content="Web News" /> <meta property="og:title" content="Web News" />
<meta property="og:description" content="A fast, clean, and private RSS reader for all your news." /> <meta
property="og:description"
content="A fast, clean, and private RSS reader for all your news."
/>
<meta property="og:image" content="/favicon.svg" /> <meta property="og:image" content="/favicon.svg" />
<!-- Twitter --> <!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" /> <meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://webnews.quad4.io" /> <meta property="twitter:url" content="https://webnews.quad4.io" />
<meta property="twitter:title" content="Web News" /> <meta property="twitter:title" content="Web News" />
<meta property="twitter:description" content="A fast, clean, and private RSS reader for all your news." /> <meta
property="twitter:description"
content="A fast, clean, and private RSS reader for all your news."
/>
<meta property="twitter:image" content="/favicon.svg" /> <meta property="twitter:image" content="/favicon.svg" />
<meta name="theme-color" content="#1a73e8" /> <meta name="theme-color" content="#1a73e8" />

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { db } from '$lib/db'; import { db } from '$lib/db';
import { fetchFeed } from '$lib/rss'; import { fetchFeed } from '$lib/rss';
import { parseOPML } from '$lib/opml';
import { newsStore } from '$lib/store.svelte'; import { newsStore } from '$lib/store.svelte';
import { X, Loader2 } from 'lucide-svelte'; import { X, Loader2, Upload } from 'lucide-svelte';
import { toast } from '$lib/toast.svelte';
let { onOpenChange } = $props(); let { onOpenChange } = $props();
let feedUrl = $state(''); let feedUrl = $state('');
@@ -37,6 +39,36 @@
loading = false; loading = false;
} }
} }
async function handleImport(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
loading = true;
error = '';
try {
const text = await file.text();
const { feeds, categories } = parseOPML(text);
if (categories.length > 0) {
await db.saveCategories(categories as any);
}
if (feeds.length > 0) {
await db.saveFeeds(feeds as any);
}
toast.success(`Imported ${feeds.length} feeds`);
await newsStore.init();
onOpenChange(false);
} catch (err) {
console.error('Import failed:', err);
error = 'Failed to import OPML';
} finally {
loading = false;
target.value = '';
}
}
</script> </script>
<div class="fixed inset-0 z-[100] flex items-center justify-center p-4"> <div class="fixed inset-0 z-[100] flex items-center justify-center p-4">
@@ -107,6 +139,30 @@
Add Feed Add Feed
{/if} {/if}
</button> </button>
<div class="relative py-2">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-border-color"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-bg-primary px-2 text-text-secondary">Or</span>
</div>
</div>
<label
class="w-full flex items-center justify-center gap-2 py-3 bg-bg-secondary border border-border-color rounded-xl text-sm font-semibold text-text-primary hover:bg-bg-primary transition-all cursor-pointer {loading
? 'opacity-50 pointer-events-none'
: ''}"
>
{#if loading}
<Loader2 size={18} class="animate-spin" />
Importing...
{:else}
<Upload size={18} />
Import OPML File
{/if}
<input type="file" accept=".opml,.xml" class="hidden" onchange={handleImport} />
</label>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@
import { Bookmark, Share2, MoreVertical, Check, FileText, Loader2 } from 'lucide-svelte'; import { Bookmark, Share2, MoreVertical, Check, FileText, Loader2 } from 'lucide-svelte';
let { article }: { article: Article } = $props(); let { article }: { article: Article } = $props();
const feed = $derived(newsStore.feeds.find((f) => f.id === article.feedId));
let copied = $state(false); let copied = $state(false);
let loadingFullText = $state(false); let loadingFullText = $state(false);
@@ -18,9 +19,11 @@
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} }
function getSource(feedId: string) { function getSourceTitle() {
const feed = newsStore.feeds.find((f) => f.id === feedId); return (
return feed?.title || new URL(feedId).hostname; feed?.title ||
(article.feedId.startsWith('http') ? new URL(article.feedId).hostname : article.feedId)
);
} }
async function shareArticle(e: MouseEvent) { async function shareArticle(e: MouseEvent) {
@@ -78,6 +81,30 @@
? 'ring-2 ring-accent-blue shadow-lg bg-accent-blue/5' ? 'ring-2 ring-accent-blue shadow-lg bg-accent-blue/5'
: ''}" : ''}"
> >
<!-- Background Flavor Layer -->
{#if article.imageUrl || feed?.icon}
<div class="absolute inset-0 z-0 overflow-hidden rounded-2xl pointer-events-none">
{#if article.imageUrl && article.imageUrl !== feed?.icon}
<div
class="absolute right-0 top-0 bottom-0 w-full sm:w-3/4 overflow-hidden"
style="mask-image: linear-gradient(to left, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%); -webkit-mask-image: linear-gradient(to left, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%);"
>
<img
src={article.imageUrl}
alt=""
class="w-full h-full object-cover opacity-[0.22] dark:opacity-[0.10] group-hover:opacity-[0.32] dark:group-hover:opacity-[0.18] transition-all duration-700 group-hover:scale-110 origin-right"
/>
</div>
{:else if feed?.icon}
<div
class="absolute inset-0 opacity-0 group-hover:opacity-[0.08] dark:group-hover:opacity-[0.05] transition-opacity duration-500"
>
<img src={feed.icon} alt="" class="w-full h-full object-cover blur-3xl scale-150" />
</div>
{/if}
</div>
{/if}
{#if newsStore.isSelectMode} {#if newsStore.isSelectMode}
<div class="flex items-center pl-4 z-20"> <div class="flex items-center pl-4 z-20">
<div class="relative w-5 h-5"> <div class="relative w-5 h-5">
@@ -112,8 +139,11 @@
<div class="flex-1 min-w-0 p-4 relative z-10 pointer-events-none"> <div class="flex-1 min-w-0 p-4 relative z-10 pointer-events-none">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
{#if feed?.icon}
<img src={feed.icon} alt="" class="w-4 h-4 rounded-sm flex-shrink-0" />
{/if}
<span class="text-xs font-semibold text-accent-blue hover:underline pointer-events-auto" <span class="text-xs font-semibold text-accent-blue hover:underline pointer-events-auto"
>{getSource(article.feedId)}</span >{getSourceTitle()}</span
> >
<span class="text-text-secondary text-xs">•</span> <span class="text-text-secondary text-xs">•</span>
<span class="text-text-secondary text-xs">{formatDate(article.pubDate)}</span> <span class="text-text-secondary text-xs">{formatDate(article.pubDate)}</span>
@@ -174,16 +204,4 @@
</button> </button>
</div> </div>
</div> </div>
{#if article.imageUrl}
<div
class="w-full sm:w-32 h-48 sm:h-32 flex-shrink-0 sm:m-4 rounded-xl overflow-hidden bg-bg-secondary border border-border-color relative z-10 pointer-events-none"
>
<img
src={article.imageUrl}
alt=""
class="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
</div>
{/if}
</article> </article>

View File

@@ -20,9 +20,11 @@
Download, Download,
Upload, Upload,
GitBranch, GitBranch,
RefreshCw,
} from 'lucide-svelte'; } from 'lucide-svelte';
import { toast } from '$lib/toast.svelte'; import { toast } from '$lib/toast.svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { APP_VERSION } from '$lib/version';
let { onOpenSettings } = $props(); let { onOpenSettings } = $props();
@@ -351,8 +353,16 @@
</div> </div>
{:else} {:else}
<button <button
class="flex-1 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium text-text-secondary hover:bg-bg-secondary transition-colors text-left min-w-0" class="flex-1 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors text-left min-w-0 {newsStore.selectedCategoryId ===
onclick={() => toggleCategory(cat.id)} cat.id
? 'bg-accent-blue/10 text-accent-blue font-semibold'
: 'text-text-secondary hover:bg-bg-secondary'}"
onclick={() => {
newsStore.selectCategory(cat.id);
toggleCategory(cat.id);
newsStore.readingArticle = null;
if (!isManageMode) newsStore.showSidebar = false;
}}
title={cat.name} title={cat.name}
> >
{#if expandedCategories[cat.id]} {#if expandedCategories[cat.id]}
@@ -454,6 +464,27 @@
> >
</button> </button>
{#if feed.error && !isManageMode}
<div
class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5"
>
<button
class="p-1 text-text-secondary hover:text-accent-blue"
onclick={() => newsStore.refreshFeed(feed.id)}
title="Retry fetching feed"
>
<RefreshCw size={12} />
</button>
<button
class="p-1 text-text-secondary hover:text-red-500"
onclick={() => newsStore.deleteFeed(feed.id)}
title="Remove feed"
>
<Trash2 size={12} />
</button>
</div>
{/if}
{#if isManageMode} {#if isManageMode}
<div <div
class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5" class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5"
@@ -502,7 +533,7 @@
class="flex items-center gap-1.5 text-[11px] text-text-secondary hover:text-accent-blue transition-colors font-medium" class="flex items-center gap-1.5 text-[11px] text-text-secondary hover:text-accent-blue transition-colors font-medium"
> >
<GitBranch size={13} /> <GitBranch size={13} />
<span>v0.2.0</span> <span>v{APP_VERSION}</span>
</a> </a>
<p class="text-[11px] text-text-secondary font-medium"> <p class="text-[11px] text-text-secondary font-medium">
Created by <a Created by <a

View File

@@ -87,7 +87,12 @@ export interface IDB {
saveCategory(category: Category): Promise<void>; saveCategory(category: Category): Promise<void>;
saveCategories(categories: Category[]): Promise<void>; saveCategories(categories: Category[]): Promise<void>;
deleteCategory(id: string): Promise<void>; deleteCategory(id: string): Promise<void>;
getArticles(feedId?: string, offset?: number, limit?: number): Promise<Article[]>; getArticles(
feedId?: string,
offset?: number,
limit?: number,
categoryId?: string
): Promise<Article[]>;
saveArticles(articles: Article[]): Promise<void>; saveArticles(articles: Article[]): Promise<void>;
searchArticles(query: string, limit?: number): Promise<Article[]>; searchArticles(query: string, limit?: number): Promise<Article[]>;
getReadingHistory(days?: number): Promise<{ date: number; count: number }[]>; getReadingHistory(days?: number): Promise<{ date: number; count: number }[]>;
@@ -249,14 +254,42 @@ class IndexedDBImpl implements IDB {
}); });
} }
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> { async getArticles(
feedId?: string,
offset = 0,
limit = 20,
categoryId?: string
): Promise<Article[]> {
const db = await this.open(); const db = await this.open();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readonly'); const transaction = db.transaction(['articles', 'feeds'], 'readonly');
const store = transaction.objectStore('articles'); const store = transaction.objectStore('articles');
let request: IDBRequest<any[]>; let request: IDBRequest<any[]>;
if (feedId) request = store.index('feedId').getAll(IDBKeyRange.only(feedId));
else request = store.index('pubDate').getAll(); if (feedId) {
request = store.index('feedId').getAll(IDBKeyRange.only(feedId));
} else if (categoryId) {
// For IndexedDB, we need to get all feeds in category first
const feedStore = transaction.objectStore('feeds');
const feedsRequest = feedStore.getAll();
feedsRequest.onsuccess = () => {
const feeds = feedsRequest.result as Feed[];
const catFeedIds = new Set(
feeds.filter((f) => f.categoryId === categoryId).map((f) => f.id)
);
const allArticlesRequest = store.getAll();
allArticlesRequest.onsuccess = () => {
const articles = allArticlesRequest.result as Article[];
const filtered = articles.filter((a) => catFeedIds.has(a.feedId));
filtered.sort((a, b) => b.pubDate - a.pubDate);
resolve(filtered.slice(offset, offset + limit));
};
};
return;
} else {
request = store.index('pubDate').getAll();
}
request.onsuccess = () => { request.onsuccess = () => {
const articles = request.result as Article[]; const articles = request.result as Article[];
articles.sort((a, b) => b.pubDate - a.pubDate); articles.sort((a, b) => b.pubDate - a.pubDate);
@@ -316,7 +349,8 @@ class IndexedDBImpl implements IDB {
if (cursor) { if (cursor) {
const article = cursor.value as Article; const article = cursor.value as Article;
if (article.readAt) { if (article.readAt) {
const date = new Date(article.readAt).toISOString().split('T')[0]; const d = new Date(article.readAt);
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
history[date] = (history[date] || 0) + 1; history[date] = (history[date] || 0) + 1;
} }
cursor.continue(); cursor.continue();
@@ -605,7 +639,12 @@ class CapacitorSQLiteDBImpl implements IDB {
await db.run('DELETE FROM categories WHERE id = ?', [id]); await db.run('DELETE FROM categories WHERE id = ?', [id]);
} }
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> { async getArticles(
feedId?: string,
offset = 0,
limit = 20,
categoryId?: string
): Promise<Article[]> {
const db = await this.open(); const db = await this.open();
let res; let res;
if (feedId) { if (feedId) {
@@ -613,6 +652,14 @@ class CapacitorSQLiteDBImpl implements IDB {
'SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?', 'SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?',
[feedId, limit, offset] [feedId, limit, offset]
); );
} else if (categoryId) {
res = await db.query(
`SELECT a.* FROM articles a
JOIN feeds f ON a.feedId = f.id
WHERE f.categoryId = ?
ORDER BY a.pubDate DESC LIMIT ? OFFSET ?`,
[categoryId, limit, offset]
);
} else { } else {
res = await db.query('SELECT * FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?', [ res = await db.query('SELECT * FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?', [
limit, limit,
@@ -662,17 +709,21 @@ class CapacitorSQLiteDBImpl implements IDB {
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const res = await db.query( const res = await db.query(
` `
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch')) as date, COUNT(*) as count SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch', 'localtime')) as date, COUNT(*) as count
FROM articles FROM articles
WHERE read = 1 AND readAt > ? WHERE read = 1 AND readAt > ?
GROUP BY date GROUP BY date
ORDER BY date DESC`, ORDER BY date DESC`,
[cutoff] [cutoff]
); );
return (res.values || []).map((row) => ({ return (res.values || []).map((row) => {
date: new Date(row.date).getTime(), const [y, m, d] = row.date.split('-').map(Number);
count: row.count, const date = new Date(y, m - 1, d).getTime();
})); return {
date,
count: row.count,
};
});
} }
async markAsRead(id: string): Promise<void> { async markAsRead(id: string): Promise<void> {
@@ -839,8 +890,15 @@ class WailsDBImpl implements IDB {
JSON.stringify((await this.getCategories()).filter((c) => c.id !== id)) JSON.stringify((await this.getCategories()).filter((c) => c.id !== id))
); );
} }
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> { async getArticles(
return JSON.parse(await this.call('GetArticles', feedId || '', offset, limit)); feedId?: string,
offset = 0,
limit = 20,
categoryId?: string
): Promise<Article[]> {
return JSON.parse(
await this.call('GetArticles', feedId || '', offset, limit, categoryId || '')
);
} }
async saveArticles(articles: Article[]): Promise<void> { async saveArticles(articles: Article[]): Promise<void> {
await this.call('SaveArticles', JSON.stringify(articles)); await this.call('SaveArticles', JSON.stringify(articles));
@@ -983,8 +1041,8 @@ class LazyDBWrapper implements IDB {
deleteCategory(id: string) { deleteCategory(id: string) {
return this.getImpl().deleteCategory(id); return this.getImpl().deleteCategory(id);
} }
getArticles(feedId?: string, offset?: number, limit?: number) { getArticles(feedId?: string, offset?: number, limit?: number, categoryId?: string) {
return this.getImpl().getArticles(feedId, offset, limit); return this.getImpl().getArticles(feedId, offset, limit, categoryId);
} }
saveArticles(articles: Article[]) { saveArticles(articles: Article[]) {
return this.getImpl().saveArticles(articles); return this.getImpl().saveArticles(articles);

View File

@@ -5,13 +5,19 @@ import { registerPlugin } from '@capacitor/core';
const RSS = registerPlugin<any>('RSS'); const RSS = registerPlugin<any>('RSS');
export async function fetchFeed( export async function fetchFeed(
feedUrl: string feedUrl: string,
signal?: AbortSignal
): Promise<{ feed: Partial<Feed>; articles: Article[] }> { ): Promise<{ feed: Partial<Feed>; articles: Article[] }> {
// Try native RSS fetch first if on mobile to bypass CORS/Bot protection // Try native RSS fetch first if on mobile to bypass CORS/Bot protection
if (newsStore.isCapacitor) { if (newsStore.isCapacitor) {
try { try {
// Capacitor plugin might not support signal directly, but we can check it
if (signal?.aborted) throw new Error('Aborted');
const data = await RSS.fetchFeed({ url: feedUrl }); const data = await RSS.fetchFeed({ url: feedUrl });
if (signal?.aborted) throw new Error('Aborted');
const articles: Article[] = data.articles.map((item: any) => ({ const articles: Article[] = data.articles.map((item: any) => ({
...item, ...item,
description: stripHtml(item.description || '').substring(0, 200), description: stripHtml(item.description || '').substring(0, 200),
@@ -25,6 +31,7 @@ export async function fetchFeed(
articles, articles,
}; };
} catch (e: any) { } catch (e: any) {
if (e.message === 'Aborted') throw e;
console.warn('Native RSS fetch failed, falling back to API proxy:', e); console.warn('Native RSS fetch failed, falling back to API proxy:', e);
// Show actual error in toast if it's not a "not implemented" error // Show actual error in toast if it's not a "not implemented" error
if (e.message && !e.message.includes('not implemented')) { if (e.message && !e.message.includes('not implemented')) {
@@ -38,7 +45,10 @@ export async function fetchFeed(
if (newsStore.settings.authToken) { if (newsStore.settings.authToken) {
headers['X-Account-Number'] = newsStore.settings.authToken; headers['X-Account-Number'] = newsStore.settings.authToken;
} }
const response = await fetch(`${apiBase}/feed?url=${encodeURIComponent(feedUrl)}`, { headers }); const response = await fetch(`${apiBase}/feed?url=${encodeURIComponent(feedUrl)}`, {
headers,
signal,
});
if (response.status === 401) { if (response.status === 401) {
newsStore.logout(); newsStore.logout();
throw new Error('Unauthorized'); throw new Error('Unauthorized');
@@ -93,9 +103,10 @@ function stripHtml(html: string): string {
.trim(); .trim();
} }
export async function refreshAllFeeds() { export async function refreshAllFeeds(signal?: AbortSignal) {
const feeds = await db.getFeeds(); const feeds = await db.getFeeds();
for (const feed of feeds) { for (const feed of feeds) {
if (signal?.aborted) throw new Error('Aborted');
if (!feed.enabled) continue; if (!feed.enabled) continue;
const now = Date.now(); const now = Date.now();
@@ -103,7 +114,7 @@ export async function refreshAllFeeds() {
if (shouldFetch) { if (shouldFetch) {
try { try {
const { feed: updatedFeed, articles } = await fetchFeed(feed.id); const { feed: updatedFeed, articles } = await fetchFeed(feed.id, signal);
await db.saveFeed({ await db.saveFeed({
...feed, ...feed,
...updatedFeed, ...updatedFeed,
@@ -112,6 +123,7 @@ export async function refreshAllFeeds() {
}); });
await db.saveArticles(articles); await db.saveArticles(articles);
} catch (e: any) { } catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') throw e;
console.error(`Failed to refresh feed ${feed.id}:`, e); console.error(`Failed to refresh feed ${feed.id}:`, e);
const consecutiveErrors = (feed.consecutiveErrors || 0) + 1; const consecutiveErrors = (feed.consecutiveErrors || 0) + 1;
await db.saveFeed({ await db.saveFeed({
@@ -123,3 +135,30 @@ export async function refreshAllFeeds() {
} }
} }
} }
export async function refreshFeed(feedId: string, signal?: AbortSignal) {
const feeds = await db.getFeeds();
const feed = feeds.find((f) => f.id === feedId);
if (!feed) return;
try {
const { feed: updatedFeed, articles } = await fetchFeed(feed.id, signal);
await db.saveFeed({
...feed,
...updatedFeed,
error: undefined,
consecutiveErrors: 0,
});
await db.saveArticles(articles);
} catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') throw e;
console.error(`Failed to refresh feed ${feed.id}:`, e);
const consecutiveErrors = (feed.consecutiveErrors || 0) + 1;
await db.saveFeed({
...feed,
error: e.message || 'Unknown error',
consecutiveErrors,
});
throw e;
}
}

View File

@@ -1,5 +1,5 @@
import { db, type Article, type Feed, type Settings, type Category } from './db'; import { db, type Article, type Feed, type Settings, type Category } from './db';
import { refreshAllFeeds } from './rss'; import { refreshAllFeeds, refreshFeed } from './rss';
import { toast } from './toast.svelte'; import { toast } from './toast.svelte';
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
@@ -39,6 +39,7 @@ class NewsStore {
isInitialLoading = $state(false); isInitialLoading = $state(false);
showSidebar = $state(false); showSidebar = $state(false);
selectedFeedId = $state<string | null>(null); selectedFeedId = $state<string | null>(null);
selectedCategoryId = $state<string | null>(null);
currentView = $state<'all' | 'saved' | 'following' | 'settings'>('all'); currentView = $state<'all' | 'saved' | 'following' | 'settings'>('all');
readingArticle = $state<any | null>(null); readingArticle = $state<any | null>(null);
searchQuery = $state(''); searchQuery = $state('');
@@ -46,13 +47,16 @@ class NewsStore {
isSelectMode = $state(false); isSelectMode = $state(false);
selectedArticleIds = $state(new Set<string>()); selectedArticleIds = $state(new Set<string>());
private limit = 20; private limit = 20;
private refreshController: AbortController | null = null;
// Connection status // Connection status
isOnline = $state(true); isOnline = $state(true);
ping = $state<number | null>(null); ping = $state<number | null>(null);
lastStatusCheck = $state<number>(Date.now()); lastStatusCheck = $state<number>(Date.now());
lastArticlesUpdate = $state<number>(Date.now());
authInfo = $state<{ required: boolean; mode: string; canReg: boolean } | null>(null); authInfo = $state<{ required: boolean; mode: string; canReg: boolean } | null>(null);
isAuthenticated = $state(false); isAuthenticated = $state(false);
newlyRegisteredToken = $state<string | null>(null);
isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go); isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform(); isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform();
@@ -185,7 +189,7 @@ class NewsStore {
const response = await fetch(`${apiBase}/auth/register`, { method: 'POST' }); const response = await fetch(`${apiBase}/auth/register`, { method: 'POST' });
if (!response.ok) throw new Error('Registration failed'); if (!response.ok) throw new Error('Registration failed');
const data = await response.json(); const data = await response.json();
await this.login(data.accountNumber); this.newlyRegisteredToken = data.accountNumber;
return data.accountNumber; return data.accountNumber;
} catch { } catch {
toast.error('Could not generate account'); toast.error('Could not generate account');
@@ -276,11 +280,20 @@ class NewsStore {
this.statusInterval = setInterval(() => this.checkStatus(), 30000); this.statusInterval = setInterval(() => this.checkStatus(), 30000);
} }
window.addEventListener('online', () => this.checkStatus()); if (typeof window !== 'undefined') {
window.addEventListener('offline', () => { window.addEventListener('online', () => this.checkStatus());
this.isOnline = false; window.addEventListener('offline', () => {
this.ping = null; this.isOnline = false;
}); this.ping = null;
});
// Auto-refresh when tab becomes visible
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && this.isAuthenticated) {
this.refresh();
}
});
}
} }
async loadArticles() { async loadArticles() {
@@ -297,7 +310,12 @@ class NewsStore {
} else if (this.currentView === 'following') { } else if (this.currentView === 'following') {
articles = await db.getArticles(undefined, 0, this.limit); articles = await db.getArticles(undefined, 0, this.limit);
} else { } else {
articles = await db.getArticles(this.selectedFeedId || undefined, 0, this.limit * 2); articles = await db.getArticles(
this.selectedFeedId || undefined,
0,
this.limit * 2,
this.selectedCategoryId || undefined
);
} }
} }
@@ -323,6 +341,7 @@ class NewsStore {
} else { } else {
this.articles = articles; this.articles = articles;
} }
this.lastArticlesUpdate = Date.now();
} }
private rankArticles(articles: Article[]): Article[] { private rankArticles(articles: Article[]): Article[] {
@@ -445,7 +464,12 @@ class NewsStore {
if (this.currentView === 'saved') { if (this.currentView === 'saved') {
this.hasMore = false; this.hasMore = false;
} else { } else {
more = await db.getArticles(this.selectedFeedId || undefined, offset, this.limit); more = await db.getArticles(
this.selectedFeedId || undefined,
offset,
this.limit,
this.selectedCategoryId || undefined
);
} }
if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) { if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) {
@@ -470,11 +494,20 @@ class NewsStore {
async selectView(view: 'all' | 'saved' | 'following') { async selectView(view: 'all' | 'saved' | 'following') {
this.currentView = view; this.currentView = view;
this.selectedFeedId = null; this.selectedFeedId = null;
this.selectedCategoryId = null;
await this.loadArticles(); await this.loadArticles();
} }
async selectFeed(feedId: string | null) { async selectFeed(feedId: string | null) {
this.selectedFeedId = feedId; this.selectedFeedId = feedId;
this.selectedCategoryId = null;
this.currentView = 'all';
await this.loadArticles();
}
async selectCategory(categoryId: string | null) {
this.selectedCategoryId = categoryId;
this.selectedFeedId = null;
this.currentView = 'all'; this.currentView = 'all';
await this.loadArticles(); await this.loadArticles();
} }
@@ -545,10 +578,20 @@ class NewsStore {
} }
async refresh() { async refresh() {
if (this.loading && this.refreshController) {
this.refreshController.abort();
this.refreshController = null;
this.loading = false;
toast.info('Refresh cancelled');
return;
}
if (this.loading || !this.isAuthenticated) return; if (this.loading || !this.isAuthenticated) return;
this.loading = true; this.loading = true;
this.refreshController = new AbortController();
try { try {
await refreshAllFeeds(); await refreshAllFeeds(this.refreshController.signal);
await this.loadArticles(); await this.loadArticles();
this.feeds = (await db.getFeeds()) || []; this.feeds = (await db.getFeeds()) || [];
this.categories = (await db.getCategories()) || []; this.categories = (await db.getCategories()) || [];
@@ -565,7 +608,11 @@ class NewsStore {
} }
toast.success('Feeds refreshed'); toast.success('Feeds refreshed');
} catch (e) { } catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') {
console.log('Refresh aborted');
return;
}
console.error('Refresh failed:', e); console.error('Refresh failed:', e);
if (e instanceof Error && e.message.includes('401')) { if (e instanceof Error && e.message.includes('401')) {
this.logout(); this.logout();
@@ -574,6 +621,37 @@ class NewsStore {
} }
} finally { } finally {
this.loading = false; this.loading = false;
this.refreshController = null;
}
}
async refreshFeed(feedId: string) {
if (this.loading && this.refreshController) {
this.refreshController.abort();
this.refreshController = null;
this.loading = false;
toast.info('Refresh cancelled');
return;
}
this.loading = true;
this.refreshController = new AbortController();
try {
await refreshFeed(feedId, this.refreshController.signal);
this.feeds = await db.getFeeds();
await this.loadArticles();
toast.success('Feed refreshed');
} catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') {
console.log('Refresh aborted');
return;
}
console.error('Feed refresh failed:', e);
toast.error('Failed to refresh feed');
this.feeds = await db.getFeeds();
} finally {
this.loading = false;
this.refreshController = null;
} }
} }
@@ -616,6 +694,7 @@ class NewsStore {
art.read = true; art.read = true;
art.readAt = Date.now(); art.readAt = Date.now();
if (data.content) art.content = data.content; if (data.content) art.content = data.content;
data.feedId = art.feedId;
} }
if (data.content) { if (data.content) {
@@ -645,6 +724,27 @@ class NewsStore {
toast.success('Feed removed'); toast.success('Feed removed');
} }
async purgeProblematicFeeds(threshold = 5) {
const problematic = this.feeds.filter((f) => f.consecutiveErrors >= threshold);
if (problematic.length === 0) {
toast.info('No problematic feeds found');
return;
}
if (
!confirm(
`Are you sure you want to remove ${problematic.length} feeds that have failed ${threshold}+ times?`
)
)
return;
for (const feed of problematic) {
await db.deleteFeed(feed.id);
}
this.feeds = this.feeds.filter((f) => f.consecutiveErrors < threshold);
toast.success(`Removed ${problematic.length} problematic feeds`);
}
async updateFeed(feed: Feed, oldId?: string) { async updateFeed(feed: Feed, oldId?: string) {
const plainFeed = $state.snapshot(feed); const plainFeed = $state.snapshot(feed);
if (oldId && oldId !== feed.id) { if (oldId && oldId !== feed.id) {
@@ -743,7 +843,9 @@ class NewsStore {
startAutoFetch() { startAutoFetch() {
if (this.fetchInterval) clearInterval(this.fetchInterval); if (this.fetchInterval) clearInterval(this.fetchInterval);
this.fetchInterval = setInterval(() => { this.fetchInterval = setInterval(() => {
this.refresh(); if (this.isAuthenticated) {
this.refresh();
}
}, this.settings.globalFetchInterval * 60000); }, this.settings.globalFetchInterval * 60000);
} }
} }

View File

@@ -20,6 +20,8 @@
X, X,
Hash, Hash,
ChevronRight, ChevronRight,
Copy,
Check,
} from 'lucide-svelte'; } from 'lucide-svelte';
import { db } from '$lib/db'; import { db } from '$lib/db';
import { toast } from '$lib/toast.svelte'; import { toast } from '$lib/toast.svelte';
@@ -37,6 +39,8 @@
let fontSize = $state(newsStore.settings.fontSize); let fontSize = $state(newsStore.settings.fontSize);
let lineHeight = $state(newsStore.settings.lineHeight); let lineHeight = $state(newsStore.settings.lineHeight);
let contentPurgeDays = $state(newsStore.settings.contentPurgeDays); let contentPurgeDays = $state(newsStore.settings.contentPurgeDays);
let muteFilters = $state<string[]>([]);
let errorThreshold = $state(5);
$effect(() => { $effect(() => {
theme = newsStore.settings.theme; theme = newsStore.settings.theme;
@@ -49,6 +53,7 @@
fontSize = newsStore.settings.fontSize; fontSize = newsStore.settings.fontSize;
lineHeight = newsStore.settings.lineHeight; lineHeight = newsStore.settings.lineHeight;
contentPurgeDays = newsStore.settings.contentPurgeDays; contentPurgeDays = newsStore.settings.contentPurgeDays;
muteFilters = [...newsStore.settings.muteFilters];
}); });
async function handleSaveSettings() { async function handleSaveSettings() {
@@ -64,7 +69,8 @@
fontSize, fontSize,
lineHeight, lineHeight,
contentPurgeDays, contentPurgeDays,
// muteFilters and shortcuts are already updated in newsStore.settings via UI binding muteFilters: [...muteFilters],
// shortcuts are already updated in newsStore.settings via UI binding
}; };
newsStore.settings = newSettings; newsStore.settings = newSettings;
@@ -115,6 +121,18 @@
let isResizing = $state(false); let isResizing = $state(false);
let paneWidth = $state(newsStore.settings.paneWidth || 40); let paneWidth = $state(newsStore.settings.paneWidth || 40);
let showDismissHatch = $state(false); let showDismissHatch = $state(false);
let copied = $state(false);
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => (copied = false), 2000);
toast.success('Account number copied to clipboard');
} catch {
toast.error('Failed to copy');
}
}
function startResizing(e: MouseEvent) { function startResizing(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
@@ -280,62 +298,118 @@
<div <div
class="card p-8 w-full max-w-md space-y-8 animate-in fade-in slide-in-from-bottom-8 duration-500" class="card p-8 w-full max-w-md space-y-8 animate-in fade-in slide-in-from-bottom-8 duration-500"
> >
<div class="text-center space-y-2"> {#if newsStore.newlyRegisteredToken}
<div <div class="text-center space-y-4">
class="w-16 h-16 bg-accent-blue rounded-2xl flex items-center justify-center text-white mx-auto shadow-xl shadow-accent-blue/20" <div
> class="w-16 h-16 bg-green-500 rounded-2xl flex items-center justify-center text-white mx-auto shadow-xl shadow-green-500/20"
<Zap size={32} fill="currentColor" /> >
</div> <Check size={32} />
<h1 class="text-3xl font-bold tracking-tight pt-4">Welcome to Web News</h1> </div>
<p class="text-text-secondary">Please enter your account number to continue</p> <h1 class="text-3xl font-bold tracking-tight pt-4">Account Created!</h1>
</div> <p class="text-text-secondary text-sm">
Save your account number now. You will need it to log back in. <br />
<strong class="text-red-500">We cannot recover this for you.</strong>
</p>
<div class="space-y-4"> <div class="relative group">
<div class="space-y-2"> <button
<label for="token" class="text-sm font-medium">Account Number</label> type="button"
<div class="relative"> class="w-full bg-accent-blue/5 border-2 border-accent-blue/20 rounded-xl p-4 font-mono text-lg text-accent-blue break-all text-center select-all cursor-pointer group-hover:border-accent-blue/40 transition-all"
<Hash onclick={() => copyToClipboard(newsStore.newlyRegisteredToken!)}
class="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" >
size={18} {newsStore.newlyRegisteredToken}
/> </button>
<input <button
id="token" type="button"
type="text" class="absolute -top-3 -right-3 bg-accent-blue text-white p-2 rounded-lg shadow-lg hover:scale-110 transition-transform"
placeholder="1234-5678-abcd-efgh" onclick={() => copyToClipboard(newsStore.newlyRegisteredToken!)}
class="w-full bg-bg-secondary border border-border-color rounded-xl py-3 pl-10 pr-4 focus:ring-2 focus:ring-accent-blue/20 outline-none transition-all font-mono" >
bind:value={loginToken} {#if copied}
onkeydown={(e) => e.key === 'Enter' && newsStore.login(loginToken)} <Check size={16} />
/> {:else}
<Copy size={16} />
{/if}
</button>
</div>
<div class="pt-4 space-y-3">
<button
class="w-full btn-primary py-4 rounded-xl text-lg font-bold shadow-xl shadow-accent-blue/20 flex items-center justify-center gap-2"
onclick={() => {
const token = newsStore.newlyRegisteredToken;
newsStore.newlyRegisteredToken = null;
newsStore.login(token!);
}}
>
I've saved it, let's go!
<ChevronRight size={20} />
</button>
<button
class="w-full text-xs text-text-secondary hover:underline"
onclick={() => (newsStore.newlyRegisteredToken = null)}
>
Go back
</button>
</div> </div>
</div> </div>
{:else}
<div class="text-center space-y-2">
<div
class="w-16 h-16 bg-accent-blue rounded-2xl flex items-center justify-center text-white mx-auto shadow-xl shadow-accent-blue/20"
>
<Zap size={32} fill="currentColor" />
</div>
<h1 class="text-3xl font-bold tracking-tight pt-4">Welcome to Web News</h1>
<p class="text-text-secondary">Please enter your account number to continue</p>
</div>
<button <div class="space-y-4">
class="w-full btn-primary py-4 rounded-xl text-lg font-bold shadow-xl shadow-accent-blue/20 flex items-center justify-center gap-2" <div class="space-y-2">
onclick={() => newsStore.login(loginToken)} <label for="token" class="text-sm font-medium">Account Number</label>
disabled={!loginToken} <div class="relative">
> <Hash
Access Dashboard class="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary"
<ChevronRight size={20} /> size={18}
</button> />
<input
{#if newsStore.authInfo?.canReg} id="token"
<div class="relative py-4"> type="text"
<div class="absolute inset-0 flex items-center"> placeholder="1234-5678-abcd-efgh"
<div class="w-full border-t border-border-color"></div> class="w-full bg-bg-secondary border border-border-color rounded-xl py-3 pl-10 pr-4 focus:ring-2 focus:ring-accent-blue/20 outline-none transition-all font-mono"
</div> bind:value={loginToken}
<div class="relative flex justify-center text-xs uppercase"> onkeydown={(e) => e.key === 'Enter' && newsStore.login(loginToken)}
<span class="bg-bg-primary px-2 text-text-secondary">Or</span> />
</div> </div>
</div> </div>
<button <button
class="w-full py-4 border border-border-color rounded-xl font-semibold hover:bg-bg-secondary transition-all flex items-center justify-center gap-2" class="w-full btn-primary py-4 rounded-xl text-lg font-bold shadow-xl shadow-accent-blue/20 flex items-center justify-center gap-2"
onclick={() => newsStore.registerAuth()} onclick={() => newsStore.login(loginToken)}
disabled={!loginToken}
> >
Generate New Account Number Access Dashboard
<ChevronRight size={20} />
</button> </button>
{/if}
</div> {#if newsStore.authInfo?.canReg}
<div class="relative py-4">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-border-color"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-bg-primary px-2 text-text-secondary">Or</span>
</div>
</div>
<button
class="w-full py-4 border border-border-color rounded-xl font-semibold hover:bg-bg-secondary transition-all flex items-center justify-center gap-2"
onclick={() => newsStore.registerAuth()}
>
Generate New Account Number
</button>
{/if}
</div>
{/if}
<p class="text-[10px] text-text-secondary text-center leading-relaxed"> <p class="text-[10px] text-text-secondary text-center leading-relaxed">
Web News is privacy-focused. We don't use emails or passwords. <br /> Web News is privacy-focused. We don't use emails or passwords. <br />
@@ -639,6 +713,43 @@
</div> </div>
</section> </section>
<!-- Feed Health -->
<section id="feed-health" class="space-y-4 w-full min-w-0">
<h3
class="text-sm font-semibold text-text-secondary uppercase tracking-wider flex items-center gap-2"
>
<Zap size={16} />
Feed Health
</h3>
<div class="card p-4 sm:p-6 space-y-6">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-text-secondary"
>Purge feeds with consecutive errors ≥</span
>
<span class="font-medium text-accent-blue">{errorThreshold} failures</span
>
</div>
<input
type="range"
min="1"
max="20"
step="1"
bind:value={errorThreshold}
class="w-full h-1.5 bg-bg-secondary rounded-lg appearance-none cursor-pointer accent-accent-blue"
aria-label="Error threshold"
/>
</div>
<button
class="w-full py-2.5 bg-red-500/10 hover:bg-red-500/20 text-red-500 rounded-xl text-xs font-semibold transition-colors flex items-center justify-center gap-2"
onclick={() => newsStore.purgeProblematicFeeds(errorThreshold)}
>
<Trash2 size={14} />
Purge All Problematic Feeds
</button>
</div>
</section>
<!-- Mute Filters --> <!-- Mute Filters -->
<section id="mute-filters" class="space-y-4 w-full min-w-0"> <section id="mute-filters" class="space-y-4 w-full min-w-0">
<h3 <h3
@@ -661,10 +772,7 @@
class="flex-1 bg-bg-secondary border border-border-color rounded-xl px-4 py-2 outline-none focus:ring-2 focus:ring-accent-blue/20" class="flex-1 bg-bg-secondary border border-border-color rounded-xl px-4 py-2 outline-none focus:ring-2 focus:ring-accent-blue/20"
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter' && newFilter) { if (e.key === 'Enter' && newFilter) {
newsStore.settings.muteFilters = [ muteFilters = [...muteFilters, newFilter];
...newsStore.settings.muteFilters,
newFilter,
];
newFilter = ''; newFilter = '';
} }
}} }}
@@ -673,10 +781,7 @@
class="btn-primary px-4 py-2 rounded-xl whitespace-nowrap" class="btn-primary px-4 py-2 rounded-xl whitespace-nowrap"
onclick={() => { onclick={() => {
if (newFilter) { if (newFilter) {
newsStore.settings.muteFilters = [ muteFilters = [...muteFilters, newFilter];
...newsStore.settings.muteFilters,
newFilter,
];
newFilter = ''; newFilter = '';
} }
}} }}
@@ -687,7 +792,7 @@
</div> </div>
<div class="flex flex-wrap gap-2 pt-2"> <div class="flex flex-wrap gap-2 pt-2">
{#each newsStore.settings.muteFilters as filter} {#each muteFilters as filter}
<span <span
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-bg-secondary border border-border-color text-xs font-medium" class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-bg-secondary border border-border-color text-xs font-medium"
> >
@@ -695,8 +800,7 @@
<button <button
class="text-text-secondary hover:text-red-500 transition-colors" class="text-text-secondary hover:text-red-500 transition-colors"
onclick={() => onclick={() =>
(newsStore.settings.muteFilters = (muteFilters = muteFilters.filter((f) => f !== filter))}
newsStore.settings.muteFilters.filter((f) => f !== filter))}
> >
<X size={12} /> <X size={12} />
</button> </button>
@@ -765,12 +869,14 @@
{#each Array(30) {#each Array(30)
.fill(0) .fill(0)
.map((_, i) => { .map((_, i) => {
const date = new Date(); const d = new Date();
date.setDate(date.getDate() - (29 - i)); d.setDate(d.getDate() - (29 - i));
const dStr = date.toISOString().split('T')[0]; const dStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const entry = readingHistory.find((h) => new Date(h.date) const entry = readingHistory.find((h) => {
.toISOString() const hd = new Date(h.date);
.split('T')[0] === dStr); const hStr = `${hd.getFullYear()}-${String(hd.getMonth() + 1).padStart(2, '0')}-${String(hd.getDate()).padStart(2, '0')}`;
return hStr === dStr;
});
return { date: dStr, count: entry?.count || 0 }; return { date: dStr, count: entry?.count || 0 };
}) as day} }) as day}
<div <div
@@ -994,7 +1100,7 @@
</a> </a>
</div> </div>
{#if newsStore.readingArticle.image} {#if newsStore.readingArticle.image && newsStore.readingArticle.image !== newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)?.icon}
<img <img
src={newsStore.readingArticle.image} src={newsStore.readingArticle.image}
alt="" alt=""
@@ -1007,6 +1113,14 @@
{newsStore.readingArticle.title} {newsStore.readingArticle.title}
</h1> </h1>
<div class="flex items-center gap-2 text-sm text-text-secondary"> <div class="flex items-center gap-2 text-sm text-text-secondary">
{#if newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)?.icon}
<img
src={newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)
?.icon}
alt=""
class="w-4 h-4 rounded-sm"
/>
{/if}
<span class="font-medium text-accent-blue" <span class="font-medium text-accent-blue"
>{newsStore.readingArticle.siteName || ''}</span >{newsStore.readingArticle.siteName || ''}</span
> >
@@ -1039,6 +1153,11 @@
>{feed?.id}</span >{feed?.id}</span
> >
</div> </div>
{:else if newsStore.selectedCategoryId}
{@const cat = newsStore.categories.find(
(c) => c.id === newsStore.selectedCategoryId
)}
<span>{cat?.name}</span>
{:else if newsStore.currentView === 'saved'} {:else if newsStore.currentView === 'saved'}
Saved stories Saved stories
{:else if newsStore.currentView === 'following'} {:else if newsStore.currentView === 'following'}
@@ -1048,7 +1167,7 @@
{/if} {/if}
</h2> </h2>
<span class="text-xs text-text-secondary"> <span class="text-xs text-text-secondary">
Updated {new Date(newsStore.lastStatusCheck).toLocaleTimeString([], { Updated {new Date(newsStore.lastArticlesUpdate).toLocaleTimeString([], {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
})} })}
@@ -1199,7 +1318,7 @@
</button> </button>
</div> </div>
{#if newsStore.readingArticle.image} {#if newsStore.readingArticle.image && newsStore.readingArticle.image !== newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)?.icon}
<img <img
src={newsStore.readingArticle.image} src={newsStore.readingArticle.image}
alt="" alt=""
@@ -1211,9 +1330,21 @@
<h1 class="text-3xl font-bold leading-tight tracking-tight"> <h1 class="text-3xl font-bold leading-tight tracking-tight">
{newsStore.readingArticle.title} {newsStore.readingArticle.title}
</h1> </h1>
<p class="text-xs text-text-secondary"> <div class="flex items-center gap-2 text-xs text-text-secondary">
{newsStore.readingArticle.byline || newsStore.readingArticle.siteName || ''} {#if newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)?.icon}
</p> <img
src={newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)
?.icon}
alt=""
class="w-4 h-4 rounded-sm"
/>
{/if}
<span
>{newsStore.readingArticle.byline ||
newsStore.readingArticle.siteName ||
''}</span
>
</div>
</div> </div>
<div class="prose prose-invert max-w-none text-text-secondary leading-relaxed pb-20"> <div class="prose prose-invert max-w-none text-text-secondary leading-relaxed pb-20">

View File

@@ -64,12 +64,18 @@
<svelte:head> <svelte:head>
<title>{article ? article.title : 'Shared Article on Webnews'}</title> <title>{article ? article.title : 'Shared Article on Webnews'}</title>
<meta name="description" content={article ? article.description : 'A shared article on Webnews RSS reader'} /> <meta
name="description"
content={article ? article.description : 'A shared article on Webnews RSS reader'}
/>
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:type" content="article" /> <meta property="og:type" content="article" />
<meta property="og:title" content={article ? article.title : 'Shared Article on Webnews'} /> <meta property="og:title" content={article ? article.title : 'Shared Article on Webnews'} />
<meta property="og:description" content={article ? article.description : 'A shared article on Webnews RSS reader'} /> <meta
property="og:description"
content={article ? article.description : 'A shared article on Webnews RSS reader'}
/>
{#if article?.imageUrl} {#if article?.imageUrl}
<meta property="og:image" content={article.imageUrl} /> <meta property="og:image" content={article.imageUrl} />
{:else} {:else}
@@ -79,7 +85,10 @@
<!-- Twitter --> <!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" /> <meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content={article ? article.title : 'Shared Article on Webnews'} /> <meta property="twitter:title" content={article ? article.title : 'Shared Article on Webnews'} />
<meta property="twitter:description" content={article ? article.description : 'A shared article on Webnews RSS reader'} /> <meta
property="twitter:description"
content={article ? article.description : 'A shared article on Webnews RSS reader'}
/>
{#if article?.imageUrl} {#if article?.imageUrl}
<meta property="twitter:image" content={article.imageUrl} /> <meta property="twitter:image" content={article.imageUrl} />
{:else} {:else}

View File

@@ -1,4 +1,4 @@
const CACHE_VERSION = '0.2.0'; const CACHE_VERSION = '0.2.3';
const CACHE_NAME = `web-news-${CACHE_VERSION}`; const CACHE_NAME = `web-news-${CACHE_VERSION}`;
const urlsToCache = ['/', '/favicon.svg', '/manifest.json']; const urlsToCache = ['/', '/favicon.svg', '/manifest.json'];