25 Commits

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

View File

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

View File

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

View File

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

View File

@@ -11,33 +11,33 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
uses: https://git.quad4.io/actions/setup-pnpm@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: https://git.quad4.io/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: pnpm
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.4'
- name: Setup Java
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
uses: https://git.quad4.io/actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
with:
distribution: 'temurin'
java-version: '21'
cache: gradle
- name: Setup Android SDK
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
uses: https://git.quad4.io/actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
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 AUTH_FILE=/app/data/accounts.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
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
- [ ] UI/UX Cleanup
- [ ] Add feed fetching timeout and button to remove if failed 3 times
- [ ] 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/Import OPML on add feed modal
- [ ] Favicon fetcher and caching
## Getting Started

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
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
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -151,12 +151,12 @@ func (a *App) SaveArticles(articles string) error {
return a.db.SaveArticles(articles)
}
func (a *App) GetArticles(feedId string, offset, limit int) (string, error) {
a.logDebug("GetArticles feedId=%s offset=%d limit=%d", feedId, offset, limit)
func (a *App) GetArticles(feedId string, offset, limit int, categoryId string) (string, error) {
a.logDebug("GetArticles feedId=%s categoryId=%s offset=%d limit=%d", feedId, categoryId, offset, limit)
if a.db == 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) {

View File

@@ -43,6 +43,7 @@ require (
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // 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/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect

View File

@@ -5,15 +5,24 @@ services:
dockerfile: Dockerfile
image: ${DOCKER_IMAGE:-web-news:latest}
environment:
- PORT=${PORT:?8080}
- PORT=${PORT:-8080}
- 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
- ALLOWED_ORIGINS=${SERVICE_URL_WEB_NEWS:-*}
- AUTH_FILE=/app/data/accounts.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:
- 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:
- no-new-privileges:true
restart: unless-stopped

View File

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

View File

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

View File

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

1
go.mod
View File

@@ -23,6 +23,7 @@ require (
github.com/stretchr/testify v1.10.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // 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/text v0.32.0 // indirect
modernc.org/libc v1.66.10 // indirect

View File

@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net"
@@ -16,7 +17,9 @@ import (
"time"
"git.quad4.io/Go-Libs/RSS"
"git.quad4.io/Quad4-Software/webnews/internal/storage"
readability "github.com/go-shiori/go-readability"
"golang.org/x/sync/singleflight"
"golang.org/x/time/rate"
)
@@ -45,6 +48,71 @@ type Article struct {
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 {
clients map[string]*rate.Limiter
mu *sync.RWMutex
@@ -122,7 +190,16 @@ func (rl *RateLimiter) GetLimiter(id string) *rate.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{
".git", ".env", ".aws", ".config", ".ssh",
@@ -130,14 +207,34 @@ var ForbiddenPatterns = []string{
"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 {
return func(w http.ResponseWriter, r *http.Request) {
path := strings.ToLower(r.URL.Path)
query := strings.ToLower(r.URL.RawQuery)
for _, pattern := range ForbiddenPatterns {
if strings.Contains(path, pattern) || strings.Contains(query, pattern) {
log.Printf("Blocked suspicious request: %s from %s", r.URL.String(), r.RemoteAddr)
if strings.Contains(path, pattern) {
ip := GetRealIP(r)
log.Printf("Blocked suspicious request: %s from %s", r.URL.String(), ip)
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@@ -276,18 +373,7 @@ func AuthMiddleware(am *AuthManager, next http.HandlerFunc) http.HandlerFunc {
func LimitMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
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 {
ip = xff[:comma]
} else {
ip = xff
}
}
ip := GetRealIP(r)
ua := r.Header.Get("User-Agent")
hash := sha256.New()
@@ -316,94 +402,111 @@ func HandleFeedProxy(w http.ResponseWriter, r *http.Request) {
return
}
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest("GET", feedURL, nil)
if err != nil {
http.Error(w, "Failed to create request: "+err.Error(), http.StatusInternalServerError)
if data, ok := FeedCache.Get(feedURL); ok {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Printf("Error encoding cached feed proxy response: %v", err)
}
return
}
// Add browser-like headers to avoid being blocked by Cloudflare/Bot protection
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", "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 {
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
val, err, _ := RequestGroup.Do(feedURL, func() (any, error) {
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest("GET", feedURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
pubDate := time.Now().UnixMilli()
if item.Published != nil {
pubDate = item.Published.UnixMilli()
// Add browser-like headers to avoid being blocked by Cloudflare/Bot protection
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", "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 := ""
if item.Author != nil {
author = item.Author.Name
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read feed body: %w", err)
}
imageURL := ""
for _, enc := range item.Enclosures {
if enc.Type == "image/jpeg" || enc.Type == "image/png" || enc.Type == "image/gif" {
imageURL = enc.URL
break
parsedFeed, err := rss.Parse(data)
if err != nil {
return nil, fmt.Errorf("failed to parse feed: %w", err)
}
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{
ID: id,
FeedID: feedURL,
Title: item.Title,
Link: item.Link,
Description: item.Description,
Author: author,
PubDate: pubDate,
Read: false,
Saved: false,
ImageURL: imageURL,
})
}
response := ProxyResponse{
Feed: FeedInfo{
Title: parsedFeed.Title,
SiteURL: parsedFeed.Link,
Description: parsedFeed.Description,
LastFetched: time.Now().UnixMilli(),
},
Articles: articles,
}
response := ProxyResponse{
Feed: FeedInfo{
Title: parsedFeed.Title,
SiteURL: parsedFeed.Link,
Description: parsedFeed.Description,
LastFetched: time.Now().UnixMilli(),
},
Articles: articles,
FeedCache.Set(feedURL, response)
return response, nil
})
if err != nil {
if strings.Contains(err.Error(), "status") {
http.Error(w, err.Error(), http.StatusBadGateway)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
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)
}
}
@@ -473,46 +576,61 @@ func HandleFullText(w http.ResponseWriter, r *http.Request) {
return
}
parsedURL, _ := url.Parse(targetURL)
article, err := readability.FromURL(targetURL, 15*time.Second)
if err != nil {
client := &http.Client{Timeout: 15 * time.Second}
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
if data, ok := FullTextCache.Get(targetURL); ok {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Printf("Error encoding cached fulltext response: %v", err)
}
return
}
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,
val, err, _ := RequestGroup.Do("ft-"+targetURL, func() (any, error) {
parsedURL, _ := url.Parse(targetURL)
article, err := readability.FromURL(targetURL, 15*time.Second)
if err != nil {
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
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")
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)
}
}

View File

@@ -87,6 +87,12 @@ func (s *SQLiteDB) init() error {
key TEXT PRIMARY KEY,
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 {
@@ -316,15 +322,22 @@ func (s *SQLiteDB) SaveArticles(articlesJSON string) error {
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 err error
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)
} 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 {
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 {
return "[]", err
}
@@ -371,7 +384,7 @@ func (s *SQLiteDB) SearchArticles(query string, limit int) (string, error) {
FROM articles
WHERE title LIKE ? OR description LIKE ? OR content LIKE ?
ORDER BY pubDate DESC LIMIT ?`, q, q, q, limit)
if err != nil {
return "[]", err
}
@@ -482,7 +495,7 @@ func (s *SQLiteDB) ClearAll() error {
func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
cutoff := time.Now().AddDate(0, 0, -days).UnixMilli()
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
WHERE read = 1 AND readAt > ?
GROUP BY date
@@ -499,8 +512,11 @@ func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
if err := rows.Scan(&date, &count); err != nil {
continue
}
// Convert date string back to timestamp for frontend
t, _ := time.Parse("2006-01-02", date)
// Convert local date string back to local midnight timestamp for frontend
t, err := time.ParseInLocation("2006-01-02", date, time.Local)
if err != nil {
continue
}
history = append(history, map[string]any{
"date": t.UnixMilli(),
"count": count,
@@ -510,3 +526,32 @@ func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
b, _ := json.Marshal(history)
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"
"encoding/json"
"flag"
"fmt"
"io/fs"
"log"
"net"
@@ -13,6 +14,8 @@ import (
"time"
"git.quad4.io/Quad4-Software/webnews/internal/api"
"git.quad4.io/Quad4-Software/webnews/internal/storage"
"golang.org/x/time/rate"
)
//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")
// 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")
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")
hashesFile := flag.String("hashes-file", "client_hashes.json", "File to store IP+UA hashes for rate limiting")
disableProtection := flag.Bool("disable-protection", false, "Disable rate limiting and bot protection")
defaultAuthFile := os.Getenv("AUTH_FILE")
if defaultAuthFile == "" {
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()
// 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 != "" {
api.Limiter.File = *hashesFile
api.Limiter.LoadHashes()

View File

@@ -1,6 +1,6 @@
{
"name": "web-news",
"version": "0.2.0",
"version": "0.2.3",
"type": "module",
"main": "./build/index.js",
"bin": {
@@ -57,6 +57,6 @@
"tailwindcss": "^3.4.19"
},
"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" />
<title>Web News - Personal RSS Reader</title>
<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="shortcut icon" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://webnews.quad4.io" />
<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" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://webnews.quad4.io" />
<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 name="theme-color" content="#1a73e8" />

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import { db } from '$lib/db';
import { fetchFeed } from '$lib/rss';
import { parseOPML } from '$lib/opml';
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 feedUrl = $state('');
@@ -37,6 +39,36 @@
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>
<div class="fixed inset-0 z-[100] flex items-center justify-center p-4">
@@ -107,6 +139,30 @@
Add Feed
{/if}
</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>
</div>
</div>

View File

@@ -5,6 +5,7 @@
import { Bookmark, Share2, MoreVertical, Check, FileText, Loader2 } from 'lucide-svelte';
let { article }: { article: Article } = $props();
const feed = $derived(newsStore.feeds.find((f) => f.id === article.feedId));
let copied = $state(false);
let loadingFullText = $state(false);
@@ -18,9 +19,11 @@
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function getSource(feedId: string) {
const feed = newsStore.feeds.find((f) => f.id === feedId);
return feed?.title || new URL(feedId).hostname;
function getSourceTitle() {
return (
feed?.title ||
(article.feedId.startsWith('http') ? new URL(article.feedId).hostname : article.feedId)
);
}
async function shareArticle(e: MouseEvent) {
@@ -78,6 +81,30 @@
? '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}
<div class="flex items-center pl-4 z-20">
<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 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"
>{getSource(article.feedId)}</span
>{getSourceTitle()}</span
>
<span class="text-text-secondary text-xs">•</span>
<span class="text-text-secondary text-xs">{formatDate(article.pubDate)}</span>
@@ -174,16 +204,4 @@
</button>
</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>

View File

@@ -20,9 +20,11 @@
Download,
Upload,
GitBranch,
RefreshCw,
} from 'lucide-svelte';
import { toast } from '$lib/toast.svelte';
import { slide } from 'svelte/transition';
import { APP_VERSION } from '$lib/version';
let { onOpenSettings } = $props();
@@ -351,8 +353,16 @@
</div>
{:else}
<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"
onclick={() => toggleCategory(cat.id)}
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 ===
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}
>
{#if expandedCategories[cat.id]}
@@ -454,6 +464,27 @@
>
</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}
<div
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"
>
<GitBranch size={13} />
<span>v0.2.0</span>
<span>v{APP_VERSION}</span>
</a>
<p class="text-[11px] text-text-secondary font-medium">
Created by <a

View File

@@ -87,7 +87,12 @@ export interface IDB {
saveCategory(category: Category): Promise<void>;
saveCategories(categories: Category[]): 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>;
searchArticles(query: string, limit?: number): Promise<Article[]>;
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();
return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readonly');
const transaction = db.transaction(['articles', 'feeds'], 'readonly');
const store = transaction.objectStore('articles');
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 = () => {
const articles = request.result as Article[];
articles.sort((a, b) => b.pubDate - a.pubDate);
@@ -316,7 +349,8 @@ class IndexedDBImpl implements IDB {
if (cursor) {
const article = cursor.value as Article;
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;
}
cursor.continue();
@@ -605,7 +639,12 @@ class CapacitorSQLiteDBImpl implements IDB {
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();
let res;
if (feedId) {
@@ -613,6 +652,14 @@ class CapacitorSQLiteDBImpl implements IDB {
'SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC 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 {
res = await db.query('SELECT * FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?', [
limit,
@@ -662,17 +709,21 @@ class CapacitorSQLiteDBImpl implements IDB {
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
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
WHERE read = 1 AND readAt > ?
GROUP BY date
ORDER BY date DESC`,
[cutoff]
);
return (res.values || []).map((row) => ({
date: new Date(row.date).getTime(),
count: row.count,
}));
return (res.values || []).map((row) => {
const [y, m, d] = row.date.split('-').map(Number);
const date = new Date(y, m - 1, d).getTime();
return {
date,
count: row.count,
};
});
}
async markAsRead(id: string): Promise<void> {
@@ -839,8 +890,15 @@ class WailsDBImpl implements IDB {
JSON.stringify((await this.getCategories()).filter((c) => c.id !== id))
);
}
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
return JSON.parse(await this.call('GetArticles', feedId || '', offset, limit));
async getArticles(
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> {
await this.call('SaveArticles', JSON.stringify(articles));
@@ -983,8 +1041,8 @@ class LazyDBWrapper implements IDB {
deleteCategory(id: string) {
return this.getImpl().deleteCategory(id);
}
getArticles(feedId?: string, offset?: number, limit?: number) {
return this.getImpl().getArticles(feedId, offset, limit);
getArticles(feedId?: string, offset?: number, limit?: number, categoryId?: string) {
return this.getImpl().getArticles(feedId, offset, limit, categoryId);
}
saveArticles(articles: Article[]) {
return this.getImpl().saveArticles(articles);

View File

@@ -5,13 +5,19 @@ import { registerPlugin } from '@capacitor/core';
const RSS = registerPlugin<any>('RSS');
export async function fetchFeed(
feedUrl: string
feedUrl: string,
signal?: AbortSignal
): Promise<{ feed: Partial<Feed>; articles: Article[] }> {
// Try native RSS fetch first if on mobile to bypass CORS/Bot protection
if (newsStore.isCapacitor) {
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 });
if (signal?.aborted) throw new Error('Aborted');
const articles: Article[] = data.articles.map((item: any) => ({
...item,
description: stripHtml(item.description || '').substring(0, 200),
@@ -25,6 +31,7 @@ export async function fetchFeed(
articles,
};
} catch (e: any) {
if (e.message === 'Aborted') throw 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
if (e.message && !e.message.includes('not implemented')) {
@@ -38,7 +45,10 @@ export async function fetchFeed(
if (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) {
newsStore.logout();
throw new Error('Unauthorized');
@@ -93,9 +103,10 @@ function stripHtml(html: string): string {
.trim();
}
export async function refreshAllFeeds() {
export async function refreshAllFeeds(signal?: AbortSignal) {
const feeds = await db.getFeeds();
for (const feed of feeds) {
if (signal?.aborted) throw new Error('Aborted');
if (!feed.enabled) continue;
const now = Date.now();
@@ -103,7 +114,7 @@ export async function refreshAllFeeds() {
if (shouldFetch) {
try {
const { feed: updatedFeed, articles } = await fetchFeed(feed.id);
const { feed: updatedFeed, articles } = await fetchFeed(feed.id, signal);
await db.saveFeed({
...feed,
...updatedFeed,
@@ -112,6 +123,7 @@ export async function refreshAllFeeds() {
});
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({
@@ -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 { refreshAllFeeds } from './rss';
import { refreshAllFeeds, refreshFeed } from './rss';
import { toast } from './toast.svelte';
import { Capacitor } from '@capacitor/core';
@@ -39,6 +39,7 @@ class NewsStore {
isInitialLoading = $state(false);
showSidebar = $state(false);
selectedFeedId = $state<string | null>(null);
selectedCategoryId = $state<string | null>(null);
currentView = $state<'all' | 'saved' | 'following' | 'settings'>('all');
readingArticle = $state<any | null>(null);
searchQuery = $state('');
@@ -46,13 +47,16 @@ class NewsStore {
isSelectMode = $state(false);
selectedArticleIds = $state(new Set<string>());
private limit = 20;
private refreshController: AbortController | null = null;
// Connection status
isOnline = $state(true);
ping = $state<number | null>(null);
lastStatusCheck = $state<number>(Date.now());
lastArticlesUpdate = $state<number>(Date.now());
authInfo = $state<{ required: boolean; mode: string; canReg: boolean } | null>(null);
isAuthenticated = $state(false);
newlyRegisteredToken = $state<string | null>(null);
isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform();
@@ -185,7 +189,7 @@ class NewsStore {
const response = await fetch(`${apiBase}/auth/register`, { method: 'POST' });
if (!response.ok) throw new Error('Registration failed');
const data = await response.json();
await this.login(data.accountNumber);
this.newlyRegisteredToken = data.accountNumber;
return data.accountNumber;
} catch {
toast.error('Could not generate account');
@@ -276,11 +280,20 @@ class NewsStore {
this.statusInterval = setInterval(() => this.checkStatus(), 30000);
}
window.addEventListener('online', () => this.checkStatus());
window.addEventListener('offline', () => {
this.isOnline = false;
this.ping = null;
});
if (typeof window !== 'undefined') {
window.addEventListener('online', () => this.checkStatus());
window.addEventListener('offline', () => {
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() {
@@ -297,7 +310,12 @@ class NewsStore {
} else if (this.currentView === 'following') {
articles = await db.getArticles(undefined, 0, this.limit);
} 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 {
this.articles = articles;
}
this.lastArticlesUpdate = Date.now();
}
private rankArticles(articles: Article[]): Article[] {
@@ -445,7 +464,12 @@ class NewsStore {
if (this.currentView === 'saved') {
this.hasMore = false;
} 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) {
@@ -470,11 +494,20 @@ class NewsStore {
async selectView(view: 'all' | 'saved' | 'following') {
this.currentView = view;
this.selectedFeedId = null;
this.selectedCategoryId = null;
await this.loadArticles();
}
async selectFeed(feedId: string | null) {
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';
await this.loadArticles();
}
@@ -545,10 +578,20 @@ class NewsStore {
}
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;
this.loading = true;
this.refreshController = new AbortController();
try {
await refreshAllFeeds();
await refreshAllFeeds(this.refreshController.signal);
await this.loadArticles();
this.feeds = (await db.getFeeds()) || [];
this.categories = (await db.getCategories()) || [];
@@ -565,7 +608,11 @@ class NewsStore {
}
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);
if (e instanceof Error && e.message.includes('401')) {
this.logout();
@@ -574,6 +621,37 @@ class NewsStore {
}
} finally {
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.readAt = Date.now();
if (data.content) art.content = data.content;
data.feedId = art.feedId;
}
if (data.content) {
@@ -645,6 +724,27 @@ class NewsStore {
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) {
const plainFeed = $state.snapshot(feed);
if (oldId && oldId !== feed.id) {
@@ -743,7 +843,9 @@ class NewsStore {
startAutoFetch() {
if (this.fetchInterval) clearInterval(this.fetchInterval);
this.fetchInterval = setInterval(() => {
this.refresh();
if (this.isAuthenticated) {
this.refresh();
}
}, this.settings.globalFetchInterval * 60000);
}
}

View File

@@ -20,6 +20,8 @@
X,
Hash,
ChevronRight,
Copy,
Check,
} from 'lucide-svelte';
import { db } from '$lib/db';
import { toast } from '$lib/toast.svelte';
@@ -37,6 +39,8 @@
let fontSize = $state(newsStore.settings.fontSize);
let lineHeight = $state(newsStore.settings.lineHeight);
let contentPurgeDays = $state(newsStore.settings.contentPurgeDays);
let muteFilters = $state<string[]>([]);
let errorThreshold = $state(5);
$effect(() => {
theme = newsStore.settings.theme;
@@ -49,6 +53,7 @@
fontSize = newsStore.settings.fontSize;
lineHeight = newsStore.settings.lineHeight;
contentPurgeDays = newsStore.settings.contentPurgeDays;
muteFilters = [...newsStore.settings.muteFilters];
});
async function handleSaveSettings() {
@@ -64,7 +69,8 @@
fontSize,
lineHeight,
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;
@@ -115,6 +121,18 @@
let isResizing = $state(false);
let paneWidth = $state(newsStore.settings.paneWidth || 40);
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) {
e.preventDefault();
@@ -280,62 +298,118 @@
<div
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">
<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>
{#if newsStore.newlyRegisteredToken}
<div class="text-center space-y-4">
<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"
>
<Check size={32} />
</div>
<h1 class="text-3xl font-bold tracking-tight pt-4">Account Created!</h1>
<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="space-y-2">
<label for="token" class="text-sm font-medium">Account Number</label>
<div class="relative">
<Hash
class="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary"
size={18}
/>
<input
id="token"
type="text"
placeholder="1234-5678-abcd-efgh"
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}
onkeydown={(e) => e.key === 'Enter' && newsStore.login(loginToken)}
/>
<div class="relative group">
<button
type="button"
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"
onclick={() => copyToClipboard(newsStore.newlyRegisteredToken!)}
>
{newsStore.newlyRegisteredToken}
</button>
<button
type="button"
class="absolute -top-3 -right-3 bg-accent-blue text-white p-2 rounded-lg shadow-lg hover:scale-110 transition-transform"
onclick={() => copyToClipboard(newsStore.newlyRegisteredToken!)}
>
{#if copied}
<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>
{: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
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.login(loginToken)}
disabled={!loginToken}
>
Access Dashboard
<ChevronRight size={20} />
</button>
{#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 class="space-y-4">
<div class="space-y-2">
<label for="token" class="text-sm font-medium">Account Number</label>
<div class="relative">
<Hash
class="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary"
size={18}
/>
<input
id="token"
type="text"
placeholder="1234-5678-abcd-efgh"
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}
onkeydown={(e) => e.key === 'Enter' && newsStore.login(loginToken)}
/>
</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()}
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.login(loginToken)}
disabled={!loginToken}
>
Generate New Account Number
Access Dashboard
<ChevronRight size={20} />
</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">
Web News is privacy-focused. We don't use emails or passwords. <br />
@@ -639,6 +713,43 @@
</div>
</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 -->
<section id="mute-filters" class="space-y-4 w-full min-w-0">
<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"
onkeydown={(e) => {
if (e.key === 'Enter' && newFilter) {
newsStore.settings.muteFilters = [
...newsStore.settings.muteFilters,
newFilter,
];
muteFilters = [...muteFilters, newFilter];
newFilter = '';
}
}}
@@ -673,10 +781,7 @@
class="btn-primary px-4 py-2 rounded-xl whitespace-nowrap"
onclick={() => {
if (newFilter) {
newsStore.settings.muteFilters = [
...newsStore.settings.muteFilters,
newFilter,
];
muteFilters = [...muteFilters, newFilter];
newFilter = '';
}
}}
@@ -687,7 +792,7 @@
</div>
<div class="flex flex-wrap gap-2 pt-2">
{#each newsStore.settings.muteFilters as filter}
{#each muteFilters as filter}
<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"
>
@@ -695,8 +800,7 @@
<button
class="text-text-secondary hover:text-red-500 transition-colors"
onclick={() =>
(newsStore.settings.muteFilters =
newsStore.settings.muteFilters.filter((f) => f !== filter))}
(muteFilters = muteFilters.filter((f) => f !== filter))}
>
<X size={12} />
</button>
@@ -765,12 +869,14 @@
{#each Array(30)
.fill(0)
.map((_, i) => {
const date = new Date();
date.setDate(date.getDate() - (29 - i));
const dStr = date.toISOString().split('T')[0];
const entry = readingHistory.find((h) => new Date(h.date)
.toISOString()
.split('T')[0] === dStr);
const d = new Date();
d.setDate(d.getDate() - (29 - i));
const dStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const entry = readingHistory.find((h) => {
const hd = new Date(h.date);
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 };
}) as day}
<div
@@ -994,7 +1100,7 @@
</a>
</div>
{#if newsStore.readingArticle.image}
{#if newsStore.readingArticle.image && newsStore.readingArticle.image !== newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)?.icon}
<img
src={newsStore.readingArticle.image}
alt=""
@@ -1007,6 +1113,14 @@
{newsStore.readingArticle.title}
</h1>
<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"
>{newsStore.readingArticle.siteName || ''}</span
>
@@ -1039,6 +1153,11 @@
>{feed?.id}</span
>
</div>
{:else if newsStore.selectedCategoryId}
{@const cat = newsStore.categories.find(
(c) => c.id === newsStore.selectedCategoryId
)}
<span>{cat?.name}</span>
{:else if newsStore.currentView === 'saved'}
Saved stories
{:else if newsStore.currentView === 'following'}
@@ -1048,7 +1167,7 @@
{/if}
</h2>
<span class="text-xs text-text-secondary">
Updated {new Date(newsStore.lastStatusCheck).toLocaleTimeString([], {
Updated {new Date(newsStore.lastArticlesUpdate).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
@@ -1199,7 +1318,7 @@
</button>
</div>
{#if newsStore.readingArticle.image}
{#if newsStore.readingArticle.image && newsStore.readingArticle.image !== newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)?.icon}
<img
src={newsStore.readingArticle.image}
alt=""
@@ -1211,9 +1330,21 @@
<h1 class="text-3xl font-bold leading-tight tracking-tight">
{newsStore.readingArticle.title}
</h1>
<p class="text-xs text-text-secondary">
{newsStore.readingArticle.byline || newsStore.readingArticle.siteName || ''}
</p>
<div class="flex items-center gap-2 text-xs 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
>{newsStore.readingArticle.byline ||
newsStore.readingArticle.siteName ||
''}</span
>
</div>
</div>
<div class="prose prose-invert max-w-none text-text-secondary leading-relaxed pb-20">

View File

@@ -64,12 +64,18 @@
<svelte:head>
<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 -->
<meta property="og:type" content="article" />
<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}
<meta property="og:image" content={article.imageUrl} />
{:else}
@@ -79,7 +85,10 @@
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<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}
<meta property="twitter:image" content={article.imageUrl} />
{: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 urlsToCache = ['/', '/favicon.svg', '/manifest.json'];