Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec011f7442 | |||
| 5228640a21 | |||
|
49df78f553
|
|||
|
|
5549171165 | ||
|
|
39f2986150 | ||
|
|
e0f85b450c | ||
|
|
19a9e0506d | ||
|
3cdca944ef
|
|||
|
895fba9ded
|
|||
|
a4503563e3
|
|||
|
82da01ca45
|
|||
|
7e235cb9d1
|
|||
|
a626a7cb33
|
|||
|
2c6bee84b4
|
|||
|
965c2b6daf
|
|||
|
20b83eb052
|
|||
|
9bd06c1f70
|
|||
|
0765f47083
|
|||
|
0b7c15f2f0
|
|||
|
7180776daa
|
|||
|
4ed6fcd752
|
|||
|
4e364bec74
|
|||
|
8e41a88599
|
|||
|
|
cafe5b8fd0 | ||
|
|
3534ba9b89 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,6 +52,7 @@ export default [
|
||||
FileReader: 'readonly',
|
||||
performance: 'readonly',
|
||||
AbortController: 'readonly',
|
||||
AbortSignal: 'readonly',
|
||||
DOMParser: 'readonly',
|
||||
Element: 'readonly',
|
||||
Node: 'readonly',
|
||||
|
||||
1
go.mod
1
go.mod
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
99
main.go
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
|
||||
14
src/app.html
14
src/app.html
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user