mirror of
https://github.com/Athou/commafeed.git
synced 2026-10-24 05:59:11 +00:00
Compare commits
288 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5f77a80a3 | ||
|
|
1d1099492e | ||
|
|
ccc2564929 | ||
|
|
084574ffd4 | ||
|
|
6a6978c491 | ||
|
|
0399375288 | ||
|
|
394346fbc1 | ||
|
|
0feec109bd | ||
|
|
fdb5c9b536 | ||
|
|
f7ad8aed60 | ||
|
|
4621d7e79f | ||
|
|
b64a49cfb0 | ||
|
|
aaddfbaf40 | ||
|
|
994da5e1ee | ||
|
|
811e282e28 | ||
|
|
892fcf8168 | ||
|
|
032ee9243f | ||
|
|
30cdf34d56 | ||
|
|
fd52caa3fd | ||
|
|
9788712808 | ||
|
|
af9d086f05 | ||
|
|
fc7190868e | ||
|
|
d3c1892c0b | ||
|
|
1fb4cdf3ca | ||
|
|
7928183036 | ||
|
|
ffa276df9e | ||
|
|
9f05e66e9d | ||
|
|
8ba90ded85 | ||
|
|
4e5e2f06d3 | ||
|
|
72396254d2 | ||
|
|
f9313006ba | ||
|
|
c894d701ff | ||
|
|
e9d0466018 | ||
|
|
28b4f61f20 | ||
|
|
ab6b3b225d | ||
|
|
ffb4a43e38 | ||
|
|
0653e2dc97 | ||
|
|
912e03d2ce | ||
|
|
a8df765cda | ||
|
|
b865b3ff84 | ||
|
|
69c1e8a5b8 | ||
|
|
c1522f1005 | ||
|
|
8144d24b06 | ||
|
|
4c2e2efbb5 | ||
|
|
ad452c9a7a | ||
|
|
a43da1304d | ||
|
|
c2da59a92f | ||
|
|
a89166d1c4 | ||
|
|
45039593bf | ||
|
|
d56c35cdfb | ||
|
|
73836c6800 | ||
|
|
f9a0440db8 | ||
|
|
6e7a8c1ef6 | ||
|
|
44147cc062 | ||
|
|
352b756e22 | ||
|
|
a5d9eb757f | ||
|
|
a19a73d675 | ||
|
|
4a57f87284 | ||
|
|
b523fe4c58 | ||
|
|
c208450abc | ||
|
|
b73ab2a781 | ||
|
|
ec679cc23c | ||
|
|
1dbbcc4d21 | ||
|
|
1573b61584 | ||
|
|
1ac8cc7674 | ||
|
|
793db4e9b6 | ||
|
|
12863e557a | ||
|
|
d999fc1e06 | ||
|
|
6c288d160c | ||
|
|
57d5dd8b20 | ||
|
|
0a8c0e0c7f | ||
|
|
f72bd4cc5c | ||
|
|
26cce1faac | ||
|
|
3101b4f423 | ||
|
|
e8e30faf3e | ||
|
|
3f6158fc47 | ||
|
|
3465645b70 | ||
|
|
2dd397757d | ||
|
|
5496419bb4 | ||
|
|
f1b950c38e | ||
|
|
4a3e97ad52 | ||
|
|
9fe17996b4 | ||
|
|
c7a2f87148 | ||
|
|
1125dd3fe7 | ||
|
|
adf48aee44 | ||
|
|
534d8a333e | ||
|
|
03fc91c19c | ||
|
|
d2429dada2 | ||
|
|
0fae323647 | ||
|
|
fae58d56be | ||
|
|
fc56d5e7cb | ||
|
|
04c85acae0 | ||
|
|
af2fd9bc09 | ||
|
|
d4ce2a213f | ||
|
|
7d9ba805e4 | ||
|
|
4c315ca3b8 | ||
|
|
bcf812ef7a | ||
|
|
fd603aa76f | ||
|
|
e34c08c803 | ||
|
|
9252ef1531 | ||
|
|
63153ca195 | ||
|
|
a8c2f69251 | ||
|
|
91eaef05e1 | ||
|
|
4822642fd7 | ||
|
|
a712d8f900 | ||
|
|
86e507f93e | ||
|
|
86eba276c5 | ||
|
|
4f18a98a4a | ||
|
|
547eff9f06 | ||
|
|
5bd1225d3f | ||
|
|
309a0aa94c | ||
|
|
f8286a6334 | ||
|
|
916086d60e | ||
|
|
4fa0eb0a87 | ||
|
|
295eb57922 | ||
|
|
2179cf57b6 | ||
|
|
cca0f2d5db | ||
|
|
d3c3ffb88d | ||
|
|
10c7196f9f | ||
|
|
254def6152 | ||
|
|
09bae2565c | ||
|
|
5572918750 | ||
|
|
4e91db09a6 | ||
|
|
bbaee20097 | ||
|
|
7554e2b03e | ||
|
|
3baac1138f | ||
|
|
8815cef66c | ||
|
|
afa836c6df | ||
|
|
bf513508e4 | ||
|
|
a0423f301d | ||
|
|
302fa8f4dd | ||
|
|
4a75a22a6c | ||
|
|
c7d5cf1cc2 | ||
|
|
e031ca3ef4 | ||
|
|
8e0e926a68 | ||
|
|
d4ed634c4e | ||
|
|
18582f1a2d | ||
|
|
3688b128e4 | ||
|
|
4718b75f89 | ||
|
|
aa7f8f79a5 | ||
|
|
c794a6aaf8 | ||
|
|
0786a45995 | ||
|
|
66fde65550 | ||
|
|
caf56aca94 | ||
|
|
06581692e6 | ||
|
|
ca2fbd9fdd | ||
|
|
81ce6c9950 | ||
|
|
31b67f2217 | ||
|
|
0ee76d39b2 | ||
|
|
6a3f4816d5 | ||
|
|
1365754d62 | ||
|
|
ac4c98bead | ||
|
|
74306719ca | ||
|
|
cc028dd37f | ||
|
|
43184fff6d | ||
|
|
d41f94f254 | ||
|
|
fb741a4e54 | ||
|
|
4319dfd0fe | ||
|
|
a06a0e893c | ||
|
|
8517092a88 | ||
|
|
52e79dfaa7 | ||
|
|
7fb19fd032 | ||
|
|
6344657940 | ||
|
|
3f01a99b66 | ||
|
|
69d7350e89 | ||
|
|
9ee502f437 | ||
|
|
b8b876535b | ||
|
|
816edefe25 | ||
|
|
c523ff608e | ||
|
|
663f14c439 | ||
|
|
20c6ef6702 | ||
|
|
91241f045f | ||
|
|
82e5c54cdc | ||
|
|
4b17102e5a | ||
|
|
bc58324a56 | ||
|
|
b43bcf244a | ||
|
|
befe1a0bdc | ||
|
|
1aae5433f3 | ||
|
|
afeb12686b | ||
|
|
75f4b94b26 | ||
|
|
4a867904ad | ||
|
|
249b161243 | ||
|
|
a709a3268a | ||
|
|
50b8a06f63 | ||
|
|
29e6338b41 | ||
|
|
82e9fd7f2e | ||
|
|
8eb41eec53 | ||
|
|
07f96019b1 | ||
|
|
1a0d4cb8cd | ||
|
|
5ae34047ee | ||
|
|
260181ec7c | ||
|
|
21bc826eda | ||
|
|
e1d9125a5c | ||
|
|
8d1b836f59 | ||
|
|
bd50399483 | ||
|
|
d641d25392 | ||
|
|
a8c9f41fce | ||
|
|
2ff9d84584 | ||
|
|
11cd61cd44 | ||
|
|
cf43988fae | ||
|
|
4ef27e3027 | ||
|
|
5ffabb387b | ||
|
|
d78a45b7e5 | ||
|
|
bd5a9c382d | ||
|
|
e238e829ad | ||
|
|
689c20d367 | ||
|
|
43588df2b4 | ||
|
|
d2aaddea33 | ||
|
|
de233f8ae5 | ||
|
|
3179e74dc7 | ||
|
|
6f5e9f5d49 | ||
|
|
8a43fdf200 | ||
|
|
69051e5bbb | ||
|
|
c648e7e0ec | ||
|
|
c91cf376ad | ||
|
|
f4e07597d0 | ||
|
|
64b64d3340 | ||
|
|
57ea87871e | ||
|
|
6154d80bb9 | ||
|
|
ad85745f85 | ||
|
|
41fc77322c | ||
|
|
1b83c26417 | ||
|
|
10ea5f0b93 | ||
|
|
032684de68 | ||
|
|
3efec5ce58 | ||
|
|
4ed32964d9 | ||
|
|
05c1d0cc6e | ||
|
|
3c54924971 | ||
|
|
e57dfc0e3f | ||
|
|
eff8bb16a0 | ||
|
|
87e681b71e | ||
|
|
ac73ef4ea7 | ||
|
|
20945e7443 | ||
|
|
b7b1278111 | ||
|
|
6f9378404f | ||
|
|
79787aca6e | ||
|
|
2d90ccf856 | ||
|
|
7f77cf0268 | ||
|
|
f3c390e0e0 | ||
|
|
1fdc471197 | ||
|
|
ecf5d9e50e | ||
|
|
97a49e8faa | ||
|
|
f5a020cdc2 | ||
|
|
1f0507ee65 | ||
|
|
393c90f334 | ||
|
|
03ecc2f49b | ||
|
|
17743adce6 | ||
|
|
126dde103a | ||
|
|
14a25581b4 | ||
|
|
c7c95efb3a | ||
|
|
4c1293d357 | ||
|
|
76a92ea44c | ||
|
|
ba454322db | ||
|
|
a3b9f471e1 | ||
|
|
d58ddc888e | ||
|
|
fe9837c45c | ||
|
|
332d126bd7 | ||
|
|
0c66bf2695 | ||
|
|
4d6d07c98b | ||
|
|
8f7d4bfa10 | ||
|
|
7b3f39001f | ||
|
|
b9630809a0 | ||
|
|
ce3244da5e | ||
|
|
4067297d2a | ||
|
|
03c11fa181 | ||
|
|
3676e78eb9 | ||
|
|
1e08c4486f | ||
|
|
fd8bb60daa | ||
|
|
557d56ffc5 | ||
|
|
30f76a2349 | ||
|
|
cf0fa5e22e | ||
|
|
f16becbd82 | ||
|
|
4855450ddc | ||
|
|
0733aedce5 | ||
|
|
399ad14d9b | ||
|
|
7881ea3c5c | ||
|
|
7e9de622da | ||
|
|
c6655078f2 | ||
|
|
b86cef9499 | ||
|
|
5375ab6e97 | ||
|
|
75920a6473 | ||
|
|
f474e76866 | ||
|
|
d0ae27c4ce | ||
|
|
ab191dded6 | ||
|
|
950b571a9f | ||
|
|
7a153ca69a | ||
|
|
effd8946de | ||
|
|
ae3ad3745c |
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
@@ -29,14 +29,14 @@ jobs:
|
||||
|
||||
# Setup
|
||||
- name: Set up GraalVM
|
||||
uses: graalvm/setup-graalvm@0def53c0fd8534bc13416c9469f5be45265824fd # v1.6.3
|
||||
uses: graalvm/setup-graalvm@27b360dbb857524eaf0b4c4cef364d38e5b4f173 # v1.6.7
|
||||
with:
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
distribution: "graalvm"
|
||||
cache: "maven"
|
||||
|
||||
- name: Set up NodeJS cache
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: 'commafeed-client/package-lock.json'
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
# Build & Test
|
||||
- name: Build with Maven
|
||||
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
|
||||
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipITs=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
|
||||
|
||||
# Build pages
|
||||
- name: Create pages directory structure
|
||||
@@ -73,14 +73,14 @@ jobs:
|
||||
|
||||
# Upload artifacts
|
||||
- name: Upload cross-platform app
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
if: matrix.os == 'ubuntu-latest' # we only need to upload the cross-platform artifact once per database
|
||||
with:
|
||||
name: commafeed-${{ matrix.database }}-jvm
|
||||
path: commafeed-server/target/commafeed-*.zip
|
||||
|
||||
- name: Upload native executable
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
|
||||
path: commafeed-server/target/commafeed-*-runner*
|
||||
@@ -110,17 +110,17 @@ jobs:
|
||||
|
||||
# Setup
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Install required packages
|
||||
run: sudo apt-get install -y rename unzip
|
||||
|
||||
# Prepare artifacts
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: commafeed-${{ matrix.database }}-*
|
||||
path: ./artifacts
|
||||
@@ -141,7 +141,7 @@ jobs:
|
||||
|
||||
# Docker
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
if: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
|
||||
## build but don't push for PRs and renovate
|
||||
- name: Docker build - native
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
|
||||
- name: Docker build - jvm
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
|
||||
## build and push tag
|
||||
- name: Docker build and push tag - native
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
with:
|
||||
context: .
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
|
||||
|
||||
- name: Docker build and push tag - jvm
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
with:
|
||||
context: .
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
|
||||
## build and push master
|
||||
- name: Docker build and push master - native
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
if: ${{ github.ref_name == 'master' }}
|
||||
with:
|
||||
context: .
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
tags: athou/commafeed:master-${{ matrix.database }}
|
||||
|
||||
- name: Docker build and push master - jvm
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
if: ${{ github.ref_name == 'master' }}
|
||||
with:
|
||||
context: .
|
||||
@@ -226,7 +226,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: commafeed-*
|
||||
path: ./artifacts
|
||||
@@ -280,5 +280,5 @@ jobs:
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
steps:
|
||||
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
- uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
||||
id: deployment
|
||||
|
||||
2
.mvn/wrapper/maven-wrapper.properties
vendored
2
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -1,3 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.10.0/apache-maven-3.10.0-bin.zip
|
||||
|
||||
143
CHANGELOG.md
143
CHANGELOG.md
@@ -1,9 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## [7.4.1]
|
||||
|
||||
- Fix entries being marked as read when scrolling back to an entry that was explicitly marked as unread (#2337)
|
||||
- Add a global failsafe timeout to the new HTTP/2 client to prevent it from hanging indefinitely
|
||||
- Add OCI source label to container images (#2335)
|
||||
|
||||
## [7.4.0]
|
||||
|
||||
- Add support for HTTP/2 when fetching feeds (#2317)
|
||||
- Send more default headers when fetching feeds, to avoid being rejected by some servers (#2315)
|
||||
- Harden the content cleaning of feed entries by only allowing `http`/`https` protocols in `iframe` `src` attributes
|
||||
- Fix the header in mobile mode being wrapped/cut off when too wide, even above the mobile breakpoint (#2080)
|
||||
- Document how to configure Quarkus host validation when running CommaFeed behind a reverse proxy
|
||||
|
||||
## [7.3.2]
|
||||
|
||||
- Prevent users from starring/tagging entries that are not theirs and subsequently reading them (GHSA-prfv-88mm-5gpg)
|
||||
|
||||
## [7.3.1]
|
||||
|
||||
- Prevent Host header injection attacks on the password recovery endpoint. A new setting
|
||||
`commafeed.password-recovery-public-base-url` has been added to specify the base URL to use in password recovery
|
||||
(GHSA-hp8h-jqfm-v7x5)
|
||||
emails. This setting is only required when the password recovery feature is enabled, which is not the default.
|
||||
|
||||
## [7.3.0]
|
||||
|
||||
- Add support for the Google Reader API for native mobile apps that don't support the Fever API
|
||||
- The profile menu now works again on mobile when action buttons are at the bottom of the screen
|
||||
- More SSRF protection regarding IPv6 (GHSA-q5qw-345w-55xg)
|
||||
- Changed the default value of `blockLocalAddresses` to make CommaFeed more secure out-of-the-box. See the "Access to
|
||||
local address blocked" section of the README if you subscribe to feeds that are only available on your local network.
|
||||
|
||||
## [7.2.1]
|
||||
|
||||
- correctly handle favicons hosted at the root of the domain for feeds with non-default ports
|
||||
- prevent XXS injections in third party apps. The CommaFeed React client was already stripping malicious "javascript:" URLs, they are now filtered during feed parsing and no longer stored in the database/served through the REST API.
|
||||
- prevent XXS injections in third party apps. The CommaFeed React client was already stripping malicious "javascript:"
|
||||
URLs, they are now filtered during feed parsing and no longer stored in the database/served through the REST API
|
||||
(GHSA-44pq-9929-f8mq)
|
||||
|
||||
## [7.2.0]
|
||||
|
||||
@@ -13,26 +48,36 @@
|
||||
- Small performance improvements to the feed refresh engine
|
||||
- Removed the Pocket sharing feature because the Pocket service has been discontinued
|
||||
- Prevent the image proxy feature to access any URL to avoid SSRF attacks
|
||||
- Strengthened the SSRF protection by also blocking ULA and CGNAT ranges
|
||||
- Prevent DDOS attacks by filtering OPML files during imports
|
||||
- Strengthened the SSRF protection by also blocking ULA and CGNAT ranges (GHSA-hgrr-mjfp-gmr6)
|
||||
- Prevent DDOS attacks by filtering OPML files during imports (GHSA-83jp-rww3-57rr)
|
||||
|
||||
## [7.1.0]
|
||||
|
||||
- Display the unread count in the header on mobile, since the tree is hidden by default and the unread count is not visible otherwise (#2055)
|
||||
- Display the unread count in the header on mobile, since the tree is hidden by default and the unread count is not
|
||||
visible otherwise (#2055)
|
||||
- The feed refresh engine is now a little bit more performant (#2089)
|
||||
- On shutdown, give the feed refresh engine some time to finish refreshing feeds before killing it. This is controlled by the new `commafeed.shutdown-timeout` setting, which defaults to 2 seconds
|
||||
- On shutdown, give the feed refresh engine some time to finish refreshing feeds before killing it. This is controlled
|
||||
by the new `commafeed.shutdown-timeout` setting, which defaults to 2 seconds
|
||||
|
||||
## [7.0.0]
|
||||
|
||||
- Replaced the JEXL filter expression for marking feed entries as read automatically with a user-friendly visual query builder. Expressions are now evaluated with Common Expression Language, which is safer than JEXL and sanboxed by default.
|
||||
- Added a per-feed setting for sending push notifications to ntfy, Gotify or Pushover when new feed entries are discovered (#1610)
|
||||
- Replaced the JEXL filter expression for marking feed entries as read automatically with a user-friendly visual query
|
||||
builder. Expressions are now evaluated with Common Expression Language, which is safer than JEXL and sanboxed by
|
||||
default.
|
||||
- Added a per-feed setting for sending push notifications to ntfy, Gotify or Pushover when new feed entries are
|
||||
discovered (#1610)
|
||||
- Added a per-feed setting for marking entries as read after a number of days (#2041)
|
||||
- The default value of `commafeed.http-client.block-local-addresses` is now false, allowing users to subscribe to feeds only available on their local network. This may be a security risk (SSRF) if your instance is accessible by untrusted users, so you may want to set it to true if you host a public instance of CommaFeed with user registeration enabled.
|
||||
- When `commafeed.http-client.block-local-addresses` is enabled, SSRF is now also mitigated by blocking public websites redirecting to local ones.
|
||||
- The default value of `commafeed.http-client.block-local-addresses` is now false, allowing users to subscribe to feeds
|
||||
only available on their local network. This may be a security risk (SSRF) if your instance is accessible by untrusted
|
||||
users, so you may want to set it to true if you host a public instance of CommaFeed with user registeration enabled.
|
||||
- When `commafeed.http-client.block-local-addresses` is enabled, SSRF is now also mitigated by blocking public websites
|
||||
redirecting to local ones.
|
||||
|
||||
## [6.2.0]
|
||||
|
||||
- Starred entries are no longer deleted after a certain amount of time, they are now kept indefinitely. The new `commafeed.database.cleanup.keep-starred-entries` setting can be disabled to restore the previous behavior if you want to keep deleting starred entries during normal entries cleanup (#1581)
|
||||
- Starred entries are no longer deleted after a certain amount of time, they are now kept indefinitely. The new
|
||||
`commafeed.database.cleanup.keep-starred-entries` setting can be disabled to restore the previous behavior if you want
|
||||
to keep deleting starred entries during normal entries cleanup (#1581)
|
||||
|
||||
## [6.1.1]
|
||||
|
||||
@@ -40,16 +85,21 @@
|
||||
|
||||
## [6.1.0]
|
||||
|
||||
- When clicking on the password reset link, a random password is no longer generated automatically. The user is now redirected to a page where they can set their own password (#2023)
|
||||
- When clicking on the password reset link, a random password is no longer generated automatically. The user is now
|
||||
redirected to a page where they can set their own password (#2023)
|
||||
- Use browser preferred language instead of English when using CommaFeed for the first time (#2018)
|
||||
- The profile menu is now closed when scrolling the page (#2019)
|
||||
- The "disable pull to refresh" feature is now disabled by default (#2030)
|
||||
|
||||
## [6.0.0]
|
||||
|
||||
- When booting CommaFeed for the first time, the default "admin" account is no longer created automatically. A setup wizard will guide you through the creation of an admin account
|
||||
- Default password complexity requirements have been lowered for local network deployments, where strict password rules are often unnecessary. The `commafeed.users.strict-password-policy` setting has been replaced by `commafeed.users.minimum-password-length` with a default value of `4` (#1916)
|
||||
- Email addresses are no longer required when creating users and when they update their profile. The `commafeed.users.email-address-required` setting has been added to restore the previous behavior (#1914)
|
||||
- When booting CommaFeed for the first time, the default "admin" account is no longer created automatically. A setup
|
||||
wizard will guide you through the creation of an admin account
|
||||
- Default password complexity requirements have been lowered for local network deployments, where strict password rules
|
||||
are often unnecessary. The `commafeed.users.strict-password-policy` setting has been replaced by
|
||||
`commafeed.users.minimum-password-length` with a default value of `4` (#1916)
|
||||
- Email addresses are no longer required when creating users and when they update their profile. The
|
||||
`commafeed.users.email-address-required` setting has been added to restore the previous behavior (#1914)
|
||||
- Java 25+ is now required to build and run CommaFeed
|
||||
|
||||
## [5.12.1]
|
||||
@@ -79,15 +129,18 @@
|
||||
|
||||
## [5.10.0]
|
||||
|
||||
- Add an indicator next to each feed's unread count in the tree to show when new entries are discovered while the app is open (#1762)
|
||||
- Add an indicator next to each feed's unread count in the tree to show when new entries are discovered while the app is
|
||||
open (#1762)
|
||||
- Feeds with uppercase HTTP:// or HTTPS:// URLs are now correctly handled again
|
||||
- The aarch64 native executable now also works on the Raspberry Pi 5 (#1795)
|
||||
- Improve general performance of the UI by reducing the number of re-renders, especially when a lot of entries are displayed (#1087)
|
||||
- Improve general performance of the UI by reducing the number of re-renders, especially when a lot of entries are
|
||||
displayed (#1087)
|
||||
|
||||
## [5.9.0]
|
||||
|
||||
- A lot of CSS classes have been added to the elements of the application to ease custom CSS rules (#1757)
|
||||
- Added a link in the README to the [documentation](https://athou.github.io/commafeed/documentation/custom-css/) of the new CSS classes
|
||||
- Added a link in the README to the [documentation](https://athou.github.io/commafeed/documentation/custom-css/) of the
|
||||
new CSS classes
|
||||
- Static resources are now cached for much longer (#1782)
|
||||
|
||||
## [5.8.0]
|
||||
@@ -96,7 +149,8 @@
|
||||
- A font size slider is now available to change the size of the text of feed entries (#1462)
|
||||
- The "mark all as read" confirmation setting now also applies to the "shift+a" keyboard shortcut (#1744)
|
||||
- CommaFeed wil try to match the language of the browser before defaulting to english (#1767)
|
||||
- The default value for the number of entries to keep above the selected entry when scrolling is now 1 instead of 0 to match what other feed readers do
|
||||
- The default value for the number of entries to keep above the selected entry when scrolling is now 1 instead of 0 to
|
||||
match what other feed readers do
|
||||
|
||||
## [5.7.0]
|
||||
|
||||
@@ -115,14 +169,22 @@
|
||||
|
||||
## [5.6.0]
|
||||
|
||||
- To better respect the bandwidth of feed owners, the default value of `commafeed.feed-refresh.interval-empirical` is now true. This means feeds no longer refresh exactly every 5 minutes (the default value of `commafeed.feed-refresh.interval`) but between 5 minutes and 4 hours (the default value of the new `commafeed.feed-refresh.max-interval` setting). The interval is calculated based on feed activity, so highly active feeds refresh more often (#1677)
|
||||
- To better respect the bandwidth of feed owners, the default value of `commafeed.feed-refresh.interval-empirical` is
|
||||
now true. This means feeds no longer refresh exactly every 5 minutes (the default value of
|
||||
`commafeed.feed-refresh.interval`) but between 5 minutes and 4 hours (the default value of the new
|
||||
`commafeed.feed-refresh.max-interval` setting). The interval is calculated based on feed activity, so highly active
|
||||
feeds refresh more often (#1677)
|
||||
- Many previously hardcoded values used in feed refresh interval calculation are now exposed as settings (#1677)
|
||||
- Access to local addresses is now blocked to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal resources. You might want to disable the new `commafeed.http-client.block-local-addresses` setting if you subscribe to feeds only available on your local network and you trust all your users
|
||||
- If a feed responds with a "429 - Too many requests" response, a backoff mechanism is triggered when the response does not contain a "Retry-After" header
|
||||
- Access to local addresses is now blocked to mitigate server-side request forgery (SSRF) attacks, which could
|
||||
potentially expose internal resources. You might want to disable the new `commafeed.http-client.block-local-addresses`
|
||||
setting if you subscribe to feeds only available on your local network and you trust all your users
|
||||
- If a feed responds with a "429 - Too many requests" response, a backoff mechanism is triggered when the response does
|
||||
not contain a "Retry-After" header
|
||||
|
||||
## [5.5.0]
|
||||
|
||||
- CommaFeed now honors the Retry-After response header and will not try to refresh a feed sooner than the value of this header
|
||||
- CommaFeed now honors the Retry-After response header and will not try to refresh a feed sooner than the value of this
|
||||
header
|
||||
- Audio enclosures (e.g. podcasts) now fill available entry width
|
||||
- Fix an issue with some labels not correctly internationalized
|
||||
|
||||
@@ -139,8 +201,10 @@
|
||||
## [5.3.5]
|
||||
|
||||
- Fixed an issue with the aspect ratio of images of some feeds (#1595)
|
||||
- CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age property (#1615)
|
||||
- Added support for compilation with JDK 23+. If you're building CommaFeed from sources with a JDK 17 or 21, you may need to update it to the most recent patch version to support `-proc:full` (#1618)
|
||||
- CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age
|
||||
property (#1615)
|
||||
- Added support for compilation with JDK 23+. If you're building CommaFeed from sources with a JDK 17 or 21, you may
|
||||
need to update it to the most recent patch version to support `-proc:full` (#1618)
|
||||
|
||||
## [5.3.4]
|
||||
|
||||
@@ -167,19 +231,22 @@
|
||||
|
||||
- Added an option to keep a number of entries above the selected entry when scrolling
|
||||
- Added a cache to the HTTP client to reduce the number of requests made to feeds when subscribing (#1431)
|
||||
- Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up (every hour)
|
||||
- Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up
|
||||
(every hour)
|
||||
- Fixed an issue that could cause entries to not correctly load when using keyboard navigation (#1557)
|
||||
|
||||
## [5.1.1]
|
||||
|
||||
- Fixed database migration issue when upgrading from 5.0.0 to 5.1.0 on MariaDB (#1544)
|
||||
- When feeds without unread entries are hidden from the tree, the feed is displayed in the tree until another one is selected (#1543)
|
||||
- When feeds without unread entries are hidden from the tree, the feed is displayed in the tree until another one is
|
||||
selected (#1543)
|
||||
|
||||
## [5.1.0]
|
||||
|
||||
- Added a setting for showing/hiding unread count in the browser's tab title/favicon (#1518)
|
||||
- Fixed an issue that could prevent the app from starting on some systems (#1532)
|
||||
- Added a cache busting filter for the webapp index.html and openapi documentation to make sure they are always up to date
|
||||
- Added a cache busting filter for the webapp index.html and openapi documentation to make sure they are always up to
|
||||
date
|
||||
- Reduced database cleanup log verbosity
|
||||
|
||||
## [5.0.2]
|
||||
@@ -194,9 +261,9 @@
|
||||
## [5.0.0]
|
||||
|
||||
CommaFeed is now powered by Quarkus instead of Dropwizard. Read the rationale behind this change in
|
||||
the [announcement](https://github.com/Athou/commafeed/discussions/1517).
|
||||
The gist of it is that CommaFeed can now be compiled to a native binary, resulting in blazing fast startup times (around
|
||||
0.3s) and very low memory footprint (< 50M).
|
||||
the [announcement](https://github.com/Athou/commafeed/discussions/1517). The gist of it is that CommaFeed can now be
|
||||
compiled to a native binary, resulting in blazing fast startup times (around 0.3s) and very low memory footprint (<
|
||||
50M).
|
||||
|
||||
- CommaFeed now has a different package for each supported database.
|
||||
- If you are deploying CommaFeed with a precompiled package, please
|
||||
@@ -252,8 +319,8 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
|
||||
- add a button in the entry headers to open links in a new tab (#1333)
|
||||
- add two options in the settings to toggle those buttons
|
||||
- accept .opml file extension when importing and export with the .opml extension
|
||||
- the "mark as read" option is no longer shown in the context menu for entries that are too old to be marked as read (
|
||||
older than `keepStatusDays`) (#1303)
|
||||
- the "mark as read" option is no longer shown in the context menu for entries that are too old to be marked as read
|
||||
(older than `keepStatusDays`) (#1303)
|
||||
|
||||
## [4.3.3]
|
||||
|
||||
@@ -297,9 +364,8 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
|
||||
- it is now possible to open the sidebar on mobile by swiping to the right (#1098)
|
||||
- swiping to mark entries as read/unread changed from swiping right to left because swiping right now opens the sidebar
|
||||
- the full hierarchy of categories are now displayed in the category dropdown (#1045)
|
||||
- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup.
|
||||
The setting is disabled by default for existing installations, except for the docker image where it is enabled and set
|
||||
to 365 days
|
||||
- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup. The setting is
|
||||
disabled by default for existing installations, except for the docker image where it is enabled and set to 365 days
|
||||
- if user registrations are disabled on your instance which is the default behavior, users are redirected on the login
|
||||
page instead of the welcome page when not logged in (#1185)
|
||||
- the sidebar resizer is no longer shown in the middle of the screen on mobile
|
||||
@@ -325,8 +391,8 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
|
||||
request, reducing CPU usage
|
||||
- updated UI library Mantine to 7.0, improving performance
|
||||
- the h2 embedded database is now compacted on shutdown to reclaim unused space
|
||||
- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is
|
||||
recommended (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
|
||||
- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is recommended
|
||||
(see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
|
||||
- migrated documentation from swagger 2 to openapi 3
|
||||
- added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser
|
||||
- the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be
|
||||
@@ -568,7 +634,6 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
|
||||
## [2.0.0]
|
||||
|
||||
- The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory
|
||||
consumption and better overall performances.
|
||||
See the README on how to build CommaFeed from now on.
|
||||
consumption and better overall performances. See the README on how to build CommaFeed from now on.
|
||||
- CommaFeed should no longer fetch the same feed multiple times in a row
|
||||
- Users can use their username or email to log in
|
||||
|
||||
24
README.md
24
README.md
@@ -15,7 +15,7 @@ Google Reader inspired self-hosted RSS reader, based on Quarkus and React/TypeSc
|
||||
- Supports thousands of users and millions of feeds
|
||||
- OPML import/export
|
||||
- REST API
|
||||
- Fever-compatible API for native mobile apps
|
||||
- Fever and Google Reader API for native mobile apps
|
||||
- Can automatically mark articles as read based on user-defined rules
|
||||
- Push notifications when new articles are published
|
||||
- Highly customizable with [custom CSS](https://athou.github.io/commafeed/documentation/custom-css) and JavaScript
|
||||
@@ -159,6 +159,28 @@ named [Semeru](https://developer.ibm.com/languages/java/semeru-runtimes/download
|
||||
This is the JVM used in
|
||||
the [Docker image](https://github.com/Athou/commafeed/blob/master/commafeed-server/src/main/docker/Dockerfile.jvm).
|
||||
|
||||
## FAQ
|
||||
|
||||
### Getting "Access to local address blocked" when adding a feed
|
||||
|
||||
CommaFeed blocks access to local resources by default to prevent [SSRF](https://en.wikipedia.org/wiki/Server-side_request_forgery) attacks.
|
||||
If you want to subscribe to feeds that are only available on your local network, you can disable this security measure by setting the `commafeed.http-client.block-local-addresses` variable to `false`.
|
||||
Do this only if you trust all users of your CommaFeed instance not to access private resources.
|
||||
|
||||
### Listen on a single network interface
|
||||
|
||||
By default, CommaFeed listens on all interfaces. You can restrict it by setting `quarkus.http.host`.
|
||||
|
||||
Note that if you set it to a local name like `127.0.0.1` host validation is enabled automatically. This prevents
|
||||
access if you're using a reverse proxy like Nginx. To fix, add your actual hostname to `allowed-hosts`:
|
||||
|
||||
```
|
||||
quarkus.http.host=127.0.0.1
|
||||
quarkus.http.proxy.proxy-address-forwarding=true
|
||||
quarkus.http.proxy.allow-forwarded=true
|
||||
quarkus.http.host-validation.allowed-hosts=commafeed.example.com
|
||||
```
|
||||
|
||||
## Translation
|
||||
|
||||
Files for internationalization are
|
||||
|
||||
1
commafeed-client/.npmrc
Normal file
1
commafeed-client/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
loglevel=warn
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.15/schema.json",
|
||||
"formatter": {
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 4,
|
||||
|
||||
2034
commafeed-client/package-lock.json
generated
2034
commafeed-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,35 +17,35 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.14.0",
|
||||
"@fontsource/open-sans": "5.3.0",
|
||||
"@lingui/core": "6.6.0",
|
||||
"@lingui/react": "6.6.0",
|
||||
"@mantine/core": "9.5.0",
|
||||
"@mantine/dates": "9.5.0",
|
||||
"@mantine/form": "9.5.0",
|
||||
"@mantine/hooks": "9.5.0",
|
||||
"@mantine/modals": "9.5.0",
|
||||
"@mantine/notifications": "9.5.0",
|
||||
"@mantine/spotlight": "9.5.0",
|
||||
"@lingui/core": "6.10.0",
|
||||
"@lingui/react": "6.10.0",
|
||||
"@mantine/core": "9.7.3",
|
||||
"@mantine/dates": "9.7.3",
|
||||
"@mantine/form": "9.7.3",
|
||||
"@mantine/hooks": "9.7.3",
|
||||
"@mantine/modals": "9.7.3",
|
||||
"@mantine/notifications": "9.7.3",
|
||||
"@mantine/spotlight": "9.7.3",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@react-querybuilder/mantine": "8.21.2",
|
||||
"@reduxjs/toolkit": "2.12.0",
|
||||
"@rolldown/plugin-babel": "0.2.3",
|
||||
"axios": "1.19.0",
|
||||
"dayjs": "1.11.21",
|
||||
"@react-querybuilder/mantine": "8.25.0",
|
||||
"@reduxjs/toolkit": "2.13.0",
|
||||
"@rolldown/plugin-babel": "0.2.4",
|
||||
"axios": "1.20.0",
|
||||
"dayjs": "1.11.23",
|
||||
"escape-string-regexp": "5.0.0",
|
||||
"interweave": "13.1.1",
|
||||
"monaco-editor": "0.56.0",
|
||||
"monaco-editor": "0.57.0",
|
||||
"mousetrap": "1.6.5",
|
||||
"react": "19.2.8",
|
||||
"react": "19.3.1",
|
||||
"react-async-hook": "4.0.0",
|
||||
"react-contexify": "6.0.0",
|
||||
"react-dom": "19.2.8",
|
||||
"react-draggable": "4.7.1",
|
||||
"react-dom": "19.3.1",
|
||||
"react-draggable": "4.7.2",
|
||||
"react-icons": "5.7.0",
|
||||
"react-infinite-scroller": "1.2.6",
|
||||
"react-querybuilder": "8.21.2",
|
||||
"react-querybuilder": "8.25.0",
|
||||
"react-redux": "9.3.0",
|
||||
"react-router-dom": "7.18.2",
|
||||
"react-router-dom": "7.18.4",
|
||||
"react-swipeable": "7.0.2",
|
||||
"style-to-object": "2.0.2",
|
||||
"throttle-debounce": "5.0.2",
|
||||
@@ -54,31 +54,31 @@
|
||||
"websocket-heartbeat-js": "1.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.6",
|
||||
"@lingui/babel-plugin-lingui-macro": "6.6.0",
|
||||
"@lingui/cli": "6.6.0",
|
||||
"@lingui/vite-plugin": "6.6.0",
|
||||
"@testing-library/jest-dom": "7.0.0",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@babel/types": "^8.0.6",
|
||||
"@biomejs/biome": "2.5.15",
|
||||
"@lingui/cli": "6.10.0",
|
||||
"@lingui/vite-plugin": "6.10.0",
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@testing-library/user-event": "14.6.8",
|
||||
"@types/mousetrap": "1.6.15",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@types/react-infinite-scroller": "1.2.5",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycon": "0.6.7",
|
||||
"@vitejs/plugin-react": "6.0.5",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"jsdom": "30.0.1",
|
||||
"@vitejs/plugin-react": "6.1.2",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"jsdom": "30.1.3",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.2.0",
|
||||
"vite": "8.3.4",
|
||||
"vite-plugin-checker": "0.14.5",
|
||||
"vitest": "4.1.10",
|
||||
"yaml": "2.9.0"
|
||||
"vitest": "5.0.5",
|
||||
"yaml": "2.9.1"
|
||||
},
|
||||
"overrides": {
|
||||
"react-infinite-scroller": {
|
||||
"react": "19.2.8"
|
||||
"react": "19.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
<parent>
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed</artifactId>
|
||||
<version>7.2.1</version>
|
||||
<version>7.4.1</version>
|
||||
</parent>
|
||||
<artifactId>commafeed-client</artifactId>
|
||||
<name>CommaFeed Client</name>
|
||||
|
||||
<properties>
|
||||
<!-- renovate: datasource=node-version depName=node -->
|
||||
<node.version>v24.18.1</node.version>
|
||||
<node.version>v24.22.1</node.version>
|
||||
<!-- renovate: datasource=npm depName=npm -->
|
||||
<npm.version>12.0.2</npm.version>
|
||||
<npm.version>12.2.1</npm.version>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
@@ -110,7 +110,7 @@
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.6.3</version>
|
||||
<version>3.6.4</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>kill-biome</id>
|
||||
|
||||
36
commafeed-client/src/app/client.test.ts
Normal file
36
commafeed-client/src/app/client.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { i18n } from "@lingui/core"
|
||||
import type { AxiosError } from "axios"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { errorToStrings } from "./client"
|
||||
|
||||
const axiosError = (status: number, data: unknown) =>
|
||||
({
|
||||
isAxiosError: true,
|
||||
response: { status, data },
|
||||
}) as AxiosError
|
||||
|
||||
describe("errorToStrings", () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it("translates known application error types", () => {
|
||||
vi.spyOn(i18n, "_").mockReturnValue("Translated authentication error")
|
||||
const error = axiosError(401, {
|
||||
type: "WRONG_USERNAME_OR_PASSWORD",
|
||||
message: "wrong username or password",
|
||||
})
|
||||
|
||||
expect(errorToStrings(error)).toEqual(["Translated authentication error"])
|
||||
})
|
||||
|
||||
it("preserves backend messages for unexpected errors", () => {
|
||||
const error = axiosError(500, { message: "unexpected error" })
|
||||
|
||||
expect(errorToStrings(error)).toEqual(["unexpected error"])
|
||||
})
|
||||
|
||||
it("preserves backend messages for unknown application error types", () => {
|
||||
const error = axiosError(400, { type: "UNKNOWN_ERROR", message: "unknown error" })
|
||||
|
||||
expect(errorToStrings(error)).toEqual(["unknown error"])
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { i18n, type MessageDescriptor } from "@lingui/core"
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import axios, { type AxiosError } from "axios"
|
||||
import type {
|
||||
AddCategoryRequest,
|
||||
@@ -6,6 +8,8 @@ import type {
|
||||
Category,
|
||||
CategoryModificationRequest,
|
||||
CollapseRequest,
|
||||
CommaFeedApplicationError,
|
||||
CommaFeedExceptionType,
|
||||
Entries,
|
||||
FeedInfo,
|
||||
FeedInfoRequest,
|
||||
@@ -31,6 +35,10 @@ import type {
|
||||
UserModel,
|
||||
} from "./types"
|
||||
|
||||
const applicationErrorMessages = {
|
||||
WRONG_USERNAME_OR_PASSWORD: msg`Wrong username or password`,
|
||||
} satisfies Record<CommaFeedExceptionType, MessageDescriptor>
|
||||
|
||||
const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true })
|
||||
axiosInstance.interceptors.response.use(
|
||||
response => response,
|
||||
@@ -128,14 +136,25 @@ export const errorToStrings = (err: unknown) => {
|
||||
let strings: string[] = []
|
||||
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
if (typeof err.response.data === "string") strings.push(err.response.data)
|
||||
if (isMessageError(err)) strings.push(err.response.data.message)
|
||||
if (isMessageArrayError(err)) strings = [...strings, ...err.response.data.errors]
|
||||
if (isCommaFeedApplicationError(err)) {
|
||||
strings.push(i18n._(applicationErrorMessages[err.response.data.type]))
|
||||
} else {
|
||||
if (typeof err.response.data === "string") strings.push(err.response.data)
|
||||
if (isMessageError(err)) strings.push(err.response.data.message)
|
||||
if (isMessageArrayError(err)) strings = [...strings, ...err.response.data.errors]
|
||||
}
|
||||
}
|
||||
|
||||
return strings
|
||||
}
|
||||
|
||||
function isCommaFeedApplicationError(err: AxiosError): err is AxiosError<CommaFeedApplicationError> {
|
||||
const data = err.response?.data
|
||||
if (!data || typeof data !== "object" || !("type" in data)) return false
|
||||
const type = data.type
|
||||
return typeof type === "string" && Object.hasOwn(applicationErrorMessages, type)
|
||||
}
|
||||
|
||||
function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> {
|
||||
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
|
||||
}
|
||||
|
||||
@@ -116,6 +116,30 @@ describe("entries", () => {
|
||||
expect(client.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
|
||||
})
|
||||
|
||||
it("flags an entry marked as unread by the user until entries are reloaded", async () => {
|
||||
vi.mocked(client.feed.getEntries).mockResolvedValue({
|
||||
data: {
|
||||
entries: [{ id: "3", read: true, markable: true } as Entry],
|
||||
hasMore: false,
|
||||
name: "my-feed",
|
||||
errorCount: 0,
|
||||
feedLink: "https://mysite.com/feed",
|
||||
timestamp: 123,
|
||||
ignoredReadStatus: false,
|
||||
},
|
||||
} as AxiosResponse<Entries>)
|
||||
|
||||
const store = configureStore({ reducer: reducers })
|
||||
await store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true }))
|
||||
|
||||
store.dispatch(markEntry({ entry: store.getState().entries.entries[0], read: false }))
|
||||
expect(store.getState().entries.entries[0].keepUnread).toBe(true)
|
||||
|
||||
// reloading the entry list resets the flag
|
||||
await store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: false }))
|
||||
expect(store.getState().entries.entries[0].keepUnread).toBeUndefined()
|
||||
})
|
||||
|
||||
it("marks all entries as read", () => {
|
||||
const store = configureStore({
|
||||
reducer: reducers,
|
||||
|
||||
@@ -10,7 +10,11 @@ export interface EntrySource {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type ExpendableEntry = Entry & { expanded?: boolean }
|
||||
export type ExpendableEntry = Entry & {
|
||||
expanded?: boolean
|
||||
/** set when the user explicitly marks the entry as unread, prevents it from being marked as read again by scrolling in expanded mode until the entry list is reloaded */
|
||||
keepUnread?: boolean
|
||||
}
|
||||
|
||||
interface EntriesState {
|
||||
/** selected source */
|
||||
@@ -71,11 +75,13 @@ export const entriesSlice = createSlice({
|
||||
builder.addCase(markEntry.pending, (state, action) => {
|
||||
for (const e of state.entries.filter(e => e.id === action.meta.arg.entry.id)) {
|
||||
e.read = action.meta.arg.read
|
||||
if (!action.meta.arg.read) e.keepUnread = true
|
||||
}
|
||||
})
|
||||
builder.addCase(markMultipleEntries.pending, (state, action) => {
|
||||
for (const e of state.entries.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))) {
|
||||
e.read = action.meta.arg.read
|
||||
if (!action.meta.arg.read) e.keepUnread = true
|
||||
}
|
||||
})
|
||||
builder.addCase(markAllEntries.pending, (state, action) => {
|
||||
|
||||
@@ -166,7 +166,6 @@ export const starEntry = createAppAsyncThunk(
|
||||
(arg: { entry: Entry; starred: boolean }) => {
|
||||
client.entry.star({
|
||||
id: arg.entry.id,
|
||||
feedId: +arg.entry.feedId,
|
||||
starred: arg.starred,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -297,7 +297,6 @@ export interface LocalSettings {
|
||||
|
||||
export interface StarRequest {
|
||||
id: string
|
||||
feedId: number
|
||||
starred: boolean
|
||||
}
|
||||
|
||||
@@ -338,3 +337,10 @@ export interface AuthenticationError {
|
||||
message: string
|
||||
allowRegistrations: boolean
|
||||
}
|
||||
|
||||
export type CommaFeedExceptionType = "WRONG_USERNAME_OR_PASSWORD"
|
||||
|
||||
export interface CommaFeedApplicationError {
|
||||
type: CommaFeedExceptionType
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -5,14 +5,15 @@ import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { useActionButton } from "@/hooks/useActionButton"
|
||||
|
||||
export type Mode = "auto" | "mobile" | "desktop"
|
||||
|
||||
interface ActionButtonProps {
|
||||
icon: ReactNode
|
||||
className?: string
|
||||
label?: string | MessageDescriptor
|
||||
onClick?: MouseEventHandler
|
||||
variant?: ActionIconVariant & ButtonVariant
|
||||
hideLabelOnDesktop?: boolean
|
||||
showLabelOnMobile?: boolean
|
||||
mode?: Mode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,7 +26,9 @@ export const ActionButton = forwardRef<HTMLDivElement, ActionButtonProps>((props
|
||||
|
||||
const label = typeof props.label === "string" ? props.label : props.label && _(props.label)
|
||||
const variant = props.variant ?? "subtle"
|
||||
const iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop)
|
||||
|
||||
const mode: Mode = props.mode ?? "auto"
|
||||
const iconOnly = mode === "mobile" || (mode === "auto" && mobile)
|
||||
|
||||
return (
|
||||
<Box ref={ref} className="cf-action-button">
|
||||
|
||||
@@ -111,12 +111,13 @@ export function FeedEntries() {
|
||||
const el = document.getElementById(Constants.dom.entryId(e))
|
||||
return el && !Constants.layout.isTopVisible(el)
|
||||
})
|
||||
if (currentEntry) {
|
||||
if (currentEntry && currentEntry.id !== selectedEntryId) {
|
||||
dispatch(
|
||||
selectEntry({
|
||||
entry: currentEntry,
|
||||
expand: false,
|
||||
markAsRead: !!scrollMarks,
|
||||
// don't mark as read entries that the user explicitly marked as unread
|
||||
markAsRead: !!scrollMarks && !currentEntry.keepUnread,
|
||||
scrollToEntry: false,
|
||||
})
|
||||
)
|
||||
@@ -124,7 +125,7 @@ export function FeedEntries() {
|
||||
})
|
||||
window.addEventListener("scroll", listener)
|
||||
return () => window.removeEventListener("scroll", listener)
|
||||
}, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry])
|
||||
}, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry, selectedEntryId])
|
||||
|
||||
useMousetrap("r", async () => await dispatch(reloadEntries()))
|
||||
useMousetrap(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { useEffect } from "react"
|
||||
import { type ReactNode, useEffect } from "react"
|
||||
import {
|
||||
TbArrowDown,
|
||||
TbArrowUp,
|
||||
@@ -31,7 +31,7 @@ function HeaderDivider() {
|
||||
return <Divider orientation="vertical" />
|
||||
}
|
||||
|
||||
function HeaderToolbar(props: { children: React.ReactNode }) {
|
||||
function HeaderToolbar(props: { children: ReactNode }) {
|
||||
const { spacing } = useActionButton()
|
||||
const mobile = useMobile("480px")
|
||||
return mobile ? (
|
||||
@@ -47,7 +47,7 @@ function HeaderToolbar(props: { children: React.ReactNode }) {
|
||||
{props.children}
|
||||
</Box>
|
||||
) : (
|
||||
<Group gap={spacing} className="cf-toolbar">
|
||||
<Group gap={spacing} wrap="nowrap" className="cf-toolbar">
|
||||
{props.children}
|
||||
</Group>
|
||||
)
|
||||
|
||||
@@ -124,7 +124,7 @@ export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end" closeOnItemClick={false} opened={opened} onChange={setOpened}>
|
||||
<Menu position="bottom-end" closeOnItemClick={false} opened={opened} onChange={setOpened} preventPositionChangeWhenVisible={false}>
|
||||
<Menu.Target>{props.control}</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{profile && <Menu.Label>{profile.name}</Menu.Label>}
|
||||
|
||||
@@ -91,8 +91,8 @@ export function ProfileSettings() {
|
||||
label={<Trans>API key</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
This is your API key. It can be used for some read-only API operations and grants access to the Fever API.
|
||||
Use the form at the bottom of the page to generate a new API key
|
||||
This is your API key. It can be used for some read-only API operations and grants access to the Fever API
|
||||
and the Google Reader API. Use the form at the bottom of the page to generate a new API key
|
||||
</Trans>
|
||||
}
|
||||
readOnly
|
||||
@@ -130,6 +130,22 @@ export function ProfileSettings() {
|
||||
</Box>
|
||||
</Input.Wrapper>
|
||||
|
||||
<Input.Wrapper
|
||||
label={<Trans>Google Reader API</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible
|
||||
mobile client. Login with your username and your <u>API key</u>.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Box>
|
||||
<Anchor href="rest/googlereader" target="_blank">
|
||||
<Trans>Google Reader API URL</Trans>
|
||||
</Anchor>
|
||||
</Box>
|
||||
</Input.Wrapper>
|
||||
|
||||
<Divider />
|
||||
|
||||
<PasswordInput
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "إصدار إضافة متصفح CommaFeed هو {browserExtensionVersion
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed متوافق مع Fever API. استخدم عنوان URL التالي في تطبيق الهاتف المتوافق مع Fever. قم بتسجيل الدخول باستخدام اسم المستخدم و <0>مفتاح API</0> الخاص بك."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed متوافق مع Google Reader API. استخدم عنوان URL التالي في تطبيق الهاتف المتوافق مع Google Reader. قم بتسجيل الدخول باستخدام اسم المستخدم و <0>مفتاح API</0> الخاص بك."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed التالي العنصر غير المقروء"
|
||||
@@ -454,6 +458,14 @@ msgstr "انتقل إلى وثائق API."
|
||||
msgid "Goodies"
|
||||
msgstr "الأشياء الجيدة"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "عنبي"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "تحتوي هذه الخلاصة على فلتر قديم لا يمكن تحريره ولا يتم تطبيقه. يرجى إعادة إنشاء الفلتر باستخدام محرر التعبيرات الجديد. كان تعبير الفلتر القديم هو: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "هذا هو مفتاح API الخاص بك. يمكن استخدامه لبعض عمليات API للقراءة فقط ويمنح الوصول إلى Fever API. استخدم النموذج الموجود في أسفل الصفحة لإنشاء مفتاح API جديد"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "هذا هو مفتاح API الخاص بك. يمكن استخدامه لبعض عمليات API للقراءة فقط ويمنح حق الوصول إلى Fever API و Google Reader API. استخدم النموذج الموجود أسفل الصفحة لإنشاء مفتاح API جديد"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "موقع الكتروني"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "مرحباً! يبدو أن هذه هي المرة الأولى التي تقوم فيها بتشغيل CommaFeed. يرجى إنشاء حساب مسؤول للبدء."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "أصفر"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "Versió de l'extensió del navegador CommaFeed {browserExtensionVersion}
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed és compatible amb l'API Fever. Utilitzeu l'URL següent al vostre client mòbil compatible amb Fever. Inicieu sessió amb el vostre nom d'usuari i la vostra <0>clau API</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed és compatible amb l'API de Google Reader. Utilitzeu l'URL següent al vostre client mòbil compatible amb Google Reader. Inicieu sessió amb el vostre nom d'usuari i la vostra <0>clau API</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed següent element no llegit"
|
||||
@@ -454,6 +458,14 @@ msgstr "Vés a la documentació de l'API."
|
||||
msgid "Goodies"
|
||||
msgstr "Extres"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Raïm"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Aquest feed té un filtre heretat que no es pot editar i no s'aplica. Torneu a crear el filtre amb el nou editor d'expressions. L'expressió del filtre heretat era: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Aquesta és la vostra clau de l'API. Es pot utilitzar per a algunes operacions de l'API de només lectura i permet accedir a l'API Fever. Utilitzeu el formulari de la part inferior de la pàgina per generar una nova clau d'API."
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Aquesta és la vostra clau API. Es pot utilitzar per a algunes operacions de l'API de només lectura i dona accés a l'API Fever i a l'API de Google Reader. Utilitzeu el formulari a la part inferior de la pàgina per generar una nova clau API"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Lloc web"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Benvingut! Sembla que aquesta és la primera vegada que executeu CommaFeed. Creeu un compte d'administrador per començar."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Groc"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "Verze rozšíření prohlížeče CommaFeed {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed je kompatibilní s Fever API. Použijte následující URL ve svém mobilním klientovi kompatibilním s Fever. Přihlaste se pomocí svého uživatelského jména a svého <0>klíče API</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed je kompatibilní s Google Reader API. Použijte následující URL ve svém mobilním klientovi kompatibilním s Google Reader. Přihlaste se pomocí svého uživatelského jména a svého <0>klíče API</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed další nepřečtená položka"
|
||||
@@ -454,6 +458,14 @@ msgstr "Přejděte na dokumentaci API."
|
||||
msgid "Goodies"
|
||||
msgstr "Dobroty"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Hroznová"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Tento zdroj má zastaralý filtr, který nelze upravovat a není aplikován. Znovu vytvořte filtr pomocí nového editoru výrazů. Původní výraz filtru byl: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Toto je váš klíč API. Lze jej použít pro některé operace API pouze pro čtení a poskytuje přístup k Fever API. Pro vygenerování nového klíče API použijte formulář v dolní části stránky."
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Toto je váš klíč API. Lze jej použít pro některé operace API pouze pro čtení a uděluje přístup k Fever API a Google Reader API. Použijte formulář v dolní části stránky k vygenerování nového klíče API"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Webová stránka"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Vítejte! Zdá se, že je to poprvé, co spouštíte CommaFeed. Chcete-li začít, vytvořte si účet správce."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Žlutá"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "Fersiwn estyniad porwr CommaFeed {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "Mae CommaFeed yn gydnaws â'r Fever API. Defnyddiwch y URL canlynol yn eich cleient symudol sy'n gydnaws â Fever. Mewngofnodwch gyda'ch enw defnyddiwr a'ch <0>allwedd API</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "Mae CommaFeed yn gydnaws â Google Reader API. Defnyddiwch y URL canlynol yn eich cleient symudol sy'n gydnaws â Google Reader. Mewngofnodwch gyda'ch enw defnyddiwr a'ch <0>allwedd API</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed eitem nesaf heb ei darllen"
|
||||
@@ -454,6 +458,14 @@ msgstr "Ewch i'r ddogfennaeth API."
|
||||
msgid "Goodies"
|
||||
msgstr "nwyddau"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Grawnwin"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Mae gan y porthiant hwn hen hidlydd na ellir ei olygu ac nid yw'n cael ei gymhwyso. Crëwch yr hidlydd eto gan ddefnyddio'r golygydd mynegiant newydd. Yr hen fynegiant hidlo oedd: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Dyma'ch allwedd API. Gellir ei ddefnyddio ar gyfer rhai gweithrediadau API darllen-yn-unig ac mae'n caniatáu mynediad i'r Fever API. Defnyddiwch y ffurflen ar waelod y dudalen i gynhyrchu allwedd API newydd"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Dyma eich allwedd API. Gellir ei defnyddio ar gyfer rhai gweithrediadau API darllen-yn-unig ac mae'n rhoi mynediad i'r Fever API a'r Google Reader API. Defnyddiwch y ffurflen ar waelod y dudalen i gynhyrchu allwedd API newydd"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Gwefan"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Croeso! Ymddengys mai dyma'r tro cyntaf i chi redeg CommaFeed. Creuwch gyfrif gweinyddwr i ddechrau."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Melyn"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "CommaFeed browserudvidelse version {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed er kompatibel med Fever API. Brug følgende URL i din Fever-kompatible mobilklient. Log ind med dit brugernavn og din <0>API-nøgle</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed er kompatibel med Google Reader API. Brug følgende URL i din Google Reader-kompatible mobilklient. Log ind med dit brugernavn og din <0>API-nøgle</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed næste ulæste element"
|
||||
@@ -454,6 +458,14 @@ msgstr "Gå til API-dokumentationen."
|
||||
msgid "Goodies"
|
||||
msgstr "Godbidder"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Grape"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Dette feed har et ældre filter, der ikke kan redigeres og ikke anvendes. Genopret venligst filteret ved hjælp af den nye udtrykseditor. Det ældre filterudtryk var: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Dette er din API-nøgle. Den kan bruges til visse skrivebeskyttede API-operationer og giver adgang til Fever API. Brug formularen nederst på siden til at generere en ny API-nøgle"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Dette er din API-nøgle. Den kan bruges til nogle skrivebeskyttede API-operationer og giver adgang til Fever API og Google Reader API. Brug formularen nederst på siden for at generere en ny API-nøgle"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Hjemmeside"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Velkommen! Det ser ud til at være første gang, du kører CommaFeed. Opret venligst en administratorkonto for at komme i gang."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Gul"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "CommaFeed Browser Erweiterung Version {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed ist mit der Fever-API kompatibel. Verwenden Sie die folgende URL in Ihrem Fever-kompatiblen mobilen Client. Melden Sie sich mit Ihrem Benutzernamen und Ihrem <0>API-Schlüssel</0> an."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed ist mit der Google-Reader-API kompatibel. Verwenden Sie die folgende URL in Ihrem Google-Reader-kompatiblen mobilen Client. Melden Sie sich mit Ihrem Benutzernamen und Ihrem <0>API-Schlüssel</0> an."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed nächstes ungelesenes Element"
|
||||
@@ -454,6 +458,14 @@ msgstr "Gehen Sie zur API-Dokumentation."
|
||||
msgid "Goodies"
|
||||
msgstr "Goodies"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Traube"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Dieser Feed verfügt über einen veralteten Filter, der nicht bearbeitet werden kann und nicht angewendet wird. Bitte erstellen Sie den Filter mit dem neuen Ausdruckseditor neu. Der veraltete Filterausdruck war: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Dies ist Ihr API-Schlüssel. Er kann für einige schreibgeschützte API-Vorgänge verwendet werden und ermöglicht den Zugriff auf die Fever-API. Verwenden Sie das Formular unten auf der Seite, um einen neuen API-Schlüssel zu generieren"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Dies ist Ihr API-Schlüssel. Er kann für einige schreibgeschützte API-Vorgänge verwendet werden und gewährt Zugriff auf die Fever-API und die Google-Reader-API. Verwenden Sie das Formular am Ende der Seite, um einen neuen API-Schlüssel zu generieren"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Webseite"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Willkommen! Dies scheint das erste Mal zu sein, dass Sie CommaFeed ausführen. Bitte erstellen Sie ein Administrator-Konto, um zu beginnen."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Gelb"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed next unread item"
|
||||
@@ -454,6 +458,14 @@ msgstr "Go to the API documentation."
|
||||
msgid "Goodies"
|
||||
msgstr "Goodies"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr "Google Reader API"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr "Google Reader API URL"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Grape"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "This feed has a legacy filter that cannot be edited and is not applied. Please recreate the filter using the new expression editor. The legacy filter expression was: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Website"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr "Wrong username or password"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Yellow"
|
||||
|
||||
@@ -211,6 +211,10 @@ msgstr "Versión de la extensión del navegador CommaFeed {browserExtensionVersi
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed es compatible con Fever API. Utilice la siguiente URL en su cliente móvil compatible con Fever. Inicie sesión con su nombre de usuario y su <0>clave API</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed es compatible con Google Reader API. Utilice la siguiente URL en su cliente móvil compatible con Google Reader. Inicie sesión con su nombre de usuario y su <0>clave API</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed siguiente elemento no leído"
|
||||
@@ -455,6 +459,14 @@ msgstr "Ir a la documentación de la API."
|
||||
msgid "Goodies"
|
||||
msgstr "Golosinas"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Uva"
|
||||
@@ -1066,8 +1078,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Este feed tiene un filtro heredado que no se puede editar y no se aplica. Por favor, vuelve a crear el filtro usando el nuevo editor de expresiones. La expresión del filtro heredado era: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Esta es su clave API. Se puede utilizar para algunas operaciones API de solo lectura y otorga acceso a Fever API. Utilice el formulario en la parte inferior de la página para generar una nueva clave API"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Esta es su clave API. Se puede utilizar para algunas operaciones API de solo lectura y otorga acceso a la API Fever y a la API de Google Reader. Utilice el formulario en la parte inferior de la página para generar una nueva clave API"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1139,6 +1151,10 @@ msgstr "Sitio web"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "¡Bienvenido! Parece que esta es la primera vez que ejecutas CommaFeed. Por favor, crea una cuenta de administrador para empezar."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Amarillo"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "نسخه افزونه مرورگر CommaFeed {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed با Fever API سازگار است. از آدرس زیر در کلاینت موبایل سازگار با Fever استفاده کنید. با نام کاربری و <0>کلید API</0> خود وارد شوید."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed با Google Reader API سازگار است. از آدرس زیر در کلاینت موبایل سازگار با Google Reader استفاده کنید. با نام کاربری و <0>کلید API</0> خود وارد شوید."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "مورد خوانده نشده بعدی CommaFeed"
|
||||
@@ -454,6 +458,14 @@ msgstr "به مستندات API بروید."
|
||||
msgid "Goodies"
|
||||
msgstr "خوبی ها"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "عنبی"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "این فید دارای یک فیلتر قدیمی است که قابل ویرایش نیست و اعمال نمیشود. لطفاً فیلتر را با استفاده از ویرایشگر جدید بازسازی کنید. عبارت فیلتر قدیمی: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "این کلید API شماست. میتوان از آن برای برخی عملیات API فقط خواندنی استفاده کرد و دسترسی به Fever API را فراهم میکند. از فرم پایین صفحه برای تولید کلید جدید استفاده کنید"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "این کلید API شماست. میتوان از آن برای برخی عملیات فقط خواندنی API استفاده کرد و دسترسی به Fever API و Google Reader API را میدهد. از فرم پایین صفحه برای ایجاد کلید API جدید استفاده کنید"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "وب سایت"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "خوش آمدید! به نظر میرسد این اولین بار است که CommaFeed را اجرا میکنید. لطفاً برای شروع یک حساب مدیر ایجاد کنید."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "زرد"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "CommaFeed-selainlaajennuksen versio {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed on yhteensopiva Fever-API:n kanssa. Käytä seuraavaa URL-osoitetta Fever-yhteensopivassa mobiiliohjelmassasi. Kirjaudu sisään käyttäjätunnuksellasi ja <0>API-avaimellasi</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed on yhteensopiva Google Reader -API:n kanssa. Käytä seuraavaa URL-osoitetta Google Reader -yhteensopivassa mobiiliohjelmassasi. Kirjaudu sisään käyttäjätunnuksellasi ja <0>API-avaimellasi</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed seuraava lukematon kohde"
|
||||
@@ -454,6 +458,14 @@ msgstr "Siirry API-dokumentaatioon."
|
||||
msgid "Goodies"
|
||||
msgstr "Hyvää"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Viinirypäle"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Tässä syötteessä on vanha suodatin, jota ei voi muokata ja jota ei sovelleta. Luo suodatin uudelleen käyttämällä uutta lauseke-editoria. Vanha suodatinlauseke oli: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Tämä on API-avaimesi. Sitä voidaan käyttää joihinkin vain luku -API-toimintoihin ja se antaa pääsyn Fever-API:iin. Käytä sivun alareunassa olevaa lomaketta uuden API-avaimen luomiseen"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Tämä on API-avaimesi. Sitä voidaan käyttää joihinkin vain luku -API-toimintoihin, ja se myöntää pääsyn Fever-API:hin ja Google Reader -API:hin. Käytä sivun alareunassa olevaa lomaketta luodaksesi uuden API-avaimen"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Verkkosivusto"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Tervetuloa! Näyttää siltä, että suoritat CommaFeediä ensimmäistä kertaa. Luo ylläpitäjän tili aloittaaksesi."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Keltainen"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "Extension CommaFeed pour navigateur version {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "Commafeed est compatible avec l'API Fever, en inscrivant l'URL suivante dans votre client mobile compatible. Entrez votre nom d'utilisateur habituel, et votre <0>clef API</0> comme mot de passe."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "Commafeed est compatible avec l'API Google Reader, en inscrivant l'URL suivante dans votre client mobile compatible. Entrez votre nom d'utilisateur habituel, et votre <0>clef API</0> comme mot de passe."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed prochain article non lu"
|
||||
@@ -454,6 +458,14 @@ msgstr "Aller à la documentation de l'API."
|
||||
msgid "Goodies"
|
||||
msgstr "Extensions"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr "API Google Reader"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr "URL de l'API Google Reader"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Bordeaux"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Ce flux est configuré avec un filtre obsolète, qui ne peut être ni modifié ni appliqué. Vous devez recréer ce filtre à l'aide du nouvel éditeur d'expressions régulières. Le filtre configuré était : <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Ceci est votre clef API. Elle peut être utilisée pour certaines opérations en lecture seule et donne accès à l'API Fever. Utilisez le formulaire en bas de la page pour générer une nouvelle clef API"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Ceci est votre clef API. Elle peut être utilisée pour certaines opérations en lecture seule et donne accès à l'API Fever et à l'API Google Reader. Utilisez le formulaire en bas de la page pour générer une nouvelle clef API"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Site web"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Bienvenue ! Il semble que ce soit le premier démarrage de Commafeed. Avant tout, vous devez créer un compte administrateur."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr "Utilisateur ou mot de passe incorrect"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Jaune"
|
||||
|
||||
@@ -211,6 +211,10 @@ msgstr "Versión do complemento do navegador para CommaFeed {browserExtensionVer
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed é compatible coa API de Fever. Usa o seguinte URL no teu cliente móbil compatible con Fever. Accede co identificador e a túa <0>clave API</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed é compatible coa API de Google Reader. Usa o seguinte URL no teu cliente móbil compatible con Google Reader. Accede co identificador e a túa <0>clave API</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "Seguinte elemento non lido en CommaFeed"
|
||||
@@ -455,6 +459,14 @@ msgstr "Ir á documentación da API."
|
||||
msgid "Goodies"
|
||||
msgstr "Agasallos"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Bago"
|
||||
@@ -1066,8 +1078,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Esta canle ten un filtro antigo que non se pode editar e que non se aplica. Volve crear o filtro usando o novo editor de expresións. A expresión do filtro antigo era: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Esta é a túa clave da API. Pode usarse para algunhas operacións da API de só-lectura e concede acceso á API Fever. Usa o formulario da parte inferior da páxina para crear unha nova clave da API"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Esta é a túa clave da API. Pode usarse para algunhas operacións da API de só-lectura e concede acceso á API Fever e á API de Google Reader. Usa o formulario da parte inferior da páxina para crear unha nova clave da API"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1139,6 +1151,10 @@ msgstr "Páxina web"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Benvida! Semella que é a primeira vez que executas CommaFeed. Para comezar, crea unha conta de administración."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Amarelo"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "CommaFeed böngészőbővítmény {browserExtensionVersion} verzió."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "A CommaFeed kompatibilis a Fever API-val. Használja a következő URL-t Fever-kompatibilis mobilkliensében. Jelentkezzen be felhasználónevével és <0>API-kulcsával</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "A CommaFeed kompatibilis a Google Reader API-val. Használja a következő URL-t Google Reader-kompatibilis mobilkliensében. Jelentkezzen be felhasználónevével és <0>API-kulcsával</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed következő olvasatlan elem"
|
||||
@@ -454,6 +458,14 @@ msgstr "Nyissa meg az API dokumentációját."
|
||||
msgid "Goodies"
|
||||
msgstr "Jók"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Szőlő"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Ez a feed egy régi szűrővel rendelkezik, amely nem szerkeszthető és nem kerül alkalmazásra. Kérjük, hozza létre újra a szűrőt az új kifejezésszerkesztővel. A régi szűrőkifejezés a következő volt: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Ez az Ön API-kulcsa. Felhasználható néhány írásvédett API-művelethez, és hozzáférést biztosít a Fever API-hoz. Használja az oldal alján található űrlapot egy új API-kulcs létrehozásához"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Ez az Ön API-kulcsa. Néhány csak olvasható API-művelethez használható, és hozzáférést biztosít a Fever API-hoz és a Google Reader API-hoz. Használja az oldal alján található űrlapot új API-kulcs létrehozásához"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Webhely"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Üdvözöljük! Úgy tűnik, ez az első alkalom, hogy a CommaFeedet futtatja. Kérjük, hozzon létre egy rendszergazdai fiókot a kezdéshez."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Sárga"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "Ekstensi browser CommaFeed versi {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed kompatibel dengan Fever API. Gunakan URL berikut di klien seluler Anda yang kompatibel dengan Fever. Login dengan nama pengguna dan <0>kunci API</0> Anda."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed kompatibel dengan Google Reader API. Gunakan URL berikut di klien seluler Anda yang kompatibel dengan Google Reader. Login dengan nama pengguna dan <0>kunci API</0> Anda."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed item yang belum dibaca berikutnya"
|
||||
@@ -454,6 +458,14 @@ msgstr "Buka dokumentasi API."
|
||||
msgid "Goodies"
|
||||
msgstr "Pernak-pernik"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Anggur"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Umpan ini memiliki filter lama yang tidak dapat diedit dan tidak diterapkan. Silakan buat ulang filter menggunakan editor ekspresi baru. Ekspresi filter lama adalah: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Ini adalah kunci API Anda. Ini dapat digunakan untuk beberapa operasi API baca-saja dan memberikan akses ke Fever API. Gunakan formulir di bagian bawah halaman untuk membuat kunci API baru"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Ini adalah kunci API Anda. Ini dapat digunakan untuk beberapa operasi API yang hanya-baca dan memberikan akses ke Fever API dan Google Reader API. Gunakan formulir di bagian bawah halaman untuk membuat kunci API baru"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Situs Web"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Selamat datang! Ini sepertinya pertama kalinya Anda menjalankan CommaFeed. Silakan buat akun administrator untuk memulai."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Kuning"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "Versione dell'estensione del browser CommaFeed {browserExtensionVersion}
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed è compatibile con l'API Fever. Utilizza il seguente URL nel tuo client mobile compatibile con Fever. Accedi con il tuo nome utente e la tua <0>chiave API</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed è compatibile con l'API Google Reader. Utilizza il seguente URL nel tuo client mobile compatibile con Google Reader. Accedi con il tuo nome utente e la tua <0>chiave API</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed successivo elemento non letto"
|
||||
@@ -454,6 +458,14 @@ msgstr "Vai alla documentazione dell'API."
|
||||
msgid "Goodies"
|
||||
msgstr "Chicche"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Uva"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Questo feed ha un filtro legacy che non può essere modificato e non viene applicato. Ricrea il filtro utilizzando il nuovo editor di espressioni. L'espressione del filtro legacy era: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Questa è la tua chiave API. Può essere utilizzata per alcune operazioni API di sola lettura e garantisce l'accesso all'API Fever. Utilizza il modulo in fondo alla pagina per generare una nuova chiave API"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Questa è la tua chiave API. Può essere utilizzata per alcune operazioni API di sola lettura e concede l'accesso all'API Fever e all'API Google Reader. Usa il modulo in fondo alla pagina per generare una nuova chiave API"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Sito web"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Benvenuto! Sembra che sia la prima volta che esegui CommaFeed. Crea un account amministratore per iniziare."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Giallo"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "CommaFeed ブラウザー拡張機能のバージョンは {browserExten
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeedはFever APIと互換性があります。Fever互換のモバイルクライアントで次のURLを使用してください。ユーザー名と <0>API キー</0> でログインしてください。"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeedはGoogle Reader APIと互換性があります。Google Reader互換のモバイルクライアントで次のURLを使用してください。ユーザー名と <0>API キー</0> でログインしてください。"
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed 次の未読アイテム"
|
||||
@@ -454,6 +458,14 @@ msgstr "APIドキュメントに移動します。"
|
||||
msgid "Goodies"
|
||||
msgstr "グッズ"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "グレープ"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "このフィードには、編集できず適用もされない古い形式のフィルタがあります。新しい式エディタを使用してフィルタを再作成してください。古いフィルタ式は次のとおりでした: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "これはあなたのAPIキーです。いくつかの読み取り専用API操作に使用できます。これにより、Fever APIへのアクセスが可能になります。ページの下部のフォームを使用して新しいAPIキーを生成します。"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "これはあなたのAPIキーです。一部の読み取り専用API操作に使用でき、Fever APIおよびGoogle Reader APIへのアクセスを許可します。新しいAPIキーを生成するには、ページ下部のフォームを使用してください"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "ウェブサイト"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "ようこそ!CommaFeedを初めて実行するようです。開始するには管理者アカウントを作成してください。"
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "イエロー"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "CommaFeed 브라우저 확장 프로그램 버전 {browserExtensionVersi
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed는 Fever API와 호환됩니다. Fever 호환 모바일 클라이언트에서 다음 URL을 사용하십시오. 사용자 이름과 <0>API 키</0>로 로그인하십시오."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed는 Google Reader API와 호환됩니다. Google Reader 호환 모바일 클라이언트에서 다음 URL을 사용하십시오. 사용자 이름과 <0>API 키</0>로 로그인하십시오."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "다음 읽지 않은 항목을 쉼표로 피드"
|
||||
@@ -454,6 +458,14 @@ msgstr "API 문서로 이동합니다."
|
||||
msgid "Goodies"
|
||||
msgstr "굿즈"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "그레이프"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "이 피드에는 편집할 수 없고 적용되지 않는 기존 필터가 있습니다. 새 표현식 편집기를 사용하여 필터를 다시 만드십시오. 기존 필터 표현식은 <0>{0}</0>이었습니다."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "이것은 귀하의 API 키입니다. 일부 읽기 전용 API 작업에 사용할 수 있으며 Fever API에 대한 액세스 권한을 부여합니다. 새 API 키를 생성하려면 페이지 하단의 양식을 사용하십시오."
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "이것은 회원님의 API 키입니다. 일부 읽기 전용 API 작업에 사용할 수 있으며 Fever API 및 Google Reader API에 대한 액세스 권한을 부여합니다. 새 API 키를 생성하려면 페이지 하단의 양식을 사용하십시오"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "웹사이트"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "환영합니다! CommaFeed를 처음 실행하는 것 같습니다. 시작하려면 관리자 계정을 만드십시오."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "옐로우"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "Ekstensi penyemak imbas CommaFeed versi {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed serasi dengan Fever API. Gunakan URL berikut dalam klien mudah alih serasi Fever anda. Log masuk dengan nama pengguna anda dan <0>kunci API</0> anda."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed serasi dengan Google Reader API. Gunakan URL berikut dalam klien mudah alih serasi Google Reader anda. Log masuk dengan nama pengguna anda dan <0>kunci API</0> anda."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed item belum dibaca seterusnya"
|
||||
@@ -454,6 +458,14 @@ msgstr "Pergi ke dokumentasi API."
|
||||
msgid "Goodies"
|
||||
msgstr "Barangan"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Anggur"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Suapan ini mempunyai penapis warisan yang tidak boleh diedit dan tidak digunakan. Sila buat semula penapis menggunakan editor ungkapan baharu. Ungkapan penapis warisan ialah: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Ini ialah kunci API anda. Ia boleh digunakan untuk beberapa operasi API baca sahaja dan memberikan akses kepada Fever API. Gunakan borang di bahagian bawah halaman untuk menjana kunci API baharu"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Ini ialah kunci API anda. Ia boleh digunakan untuk sesetengah operasi API baca sahaja dan memberikan akses kepada Fever API dan Google Reader API. Gunakan borang di bahagian bawah halaman untuk menjana kunci API baharu"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Laman web"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Selamat datang! Ini nampaknya kali pertama anda menjalankan CommaFeed. Sila cipta akaun pentadbir untuk bermula."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Kuning"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "CommaFeed nettleserutvidelse versjon {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed er kompatibel med Fever API. Bruk følgende URL i din Fever-kompatible mobilklient. Logg inn med brukernavn og din <0>API-nøkkel</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed er kompatibel med Google Reader API. Bruk følgende URL i din Google Reader-kompatible mobilklient. Logg inn med brukernavn og din <0>API-nøkkel</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed neste uleste element"
|
||||
@@ -454,6 +458,14 @@ msgstr "Gå til API-dokumentasjonen."
|
||||
msgid "Goodies"
|
||||
msgstr "Godbiter"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Drue"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Denne feeden har et utdatert filter som ikke kan redigeres og ikke blir brukt. Vennligst opprett filteret på nytt med den nye uttrykkseditoren. Det utdaterte filteruttrykket var: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Dette er API-nøkkelen din. Den kan brukes til enkelte skrivebeskyttede API-operasjoner og gir tilgang til Fever API. Bruk skjemaet nederst på siden for å generere en ny API-nøkkel"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Dette er din API-nøkkel. Den kan brukes til noen skrivebeskyttede API-operasjoner og gir tilgang til Fever API og Google Reader API. Bruk skjemaet nederst på siden for å generere en ny API-nøkkel"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Nettsted"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Velkommen! Det ser ut til å være første gang du kjører CommaFeed. Vennligst opprett en administratorkonto for å komme i gang."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Gul"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "CommaFeed browser extensie versie {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed is compatibel met de Fever API. Gebruik de volgende URL in uw Fever-compatibele mobiele client. Log in met uw gebruikersnaam en uw <0>API-sleutel</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed is compatibel met de Google Reader API. Gebruik de volgende URL in uw Google Reader-compatibele mobiele client. Log in met uw gebruikersnaam en uw <0>API-sleutel</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed volgende ongelezen item"
|
||||
@@ -454,6 +458,14 @@ msgstr "Ga naar de API-documentatie."
|
||||
msgid "Goodies"
|
||||
msgstr "Goederen"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Druif"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Deze feed heeft een legacy-filter dat niet kan worden bewerkt en niet wordt toegepast. Maak het filter opnieuw met de nieuwe expressie-editor. De legacy-filterexpressie was: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Dit is je API-sleutel. Het kan worden gebruikt voor sommige alleen-lezen API-bewerkingen en geeft toegang tot de Fever API. Gebruik het formulier onderaan de pagina om een nieuwe API-sleutel te genereren"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Dit is uw API-sleutel. Deze kan worden gebruikt voor sommige alleen-lezen API-bewerkingen en geeft toegang tot de Fever API en de Google Reader API. Gebruik het formulier onderaan de pagina om een nieuwe API-sleutel te genereren"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Website"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Welkom! Dit lijkt de eerste keer te zijn dat je CommaFeed draait. Maak een beheerdersaccount aan om aan de slag te gaan."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Geel"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "CommaFeed nettlesarutviding versjon {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed er kompatibel med Fever API. Bruk følgjande URL i din Fever-kompatible mobilklient. Logg inn med brukarnamn og din <0>API-nøkkel</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed er kompatibel med Google Reader API. Bruk følgjande URL i din Google Reader-kompatible mobilklient. Logg inn med brukarnamn og din <0>API-nøkkel</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed neste ulesne element"
|
||||
@@ -454,6 +458,14 @@ msgstr "Gå til API-dokumentasjonen."
|
||||
msgid "Goodies"
|
||||
msgstr "Godbitar"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Drue"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Denne suapen har eit gamalt filter som ikkje kan redigerast og som ikkje vert brukt. Ver venleg og lag filteret på nytt med den nye uttrykkseditoren. Det gamle filteruttrykket var: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Dette er API-nøkkelen din. Han kan brukast til enkelte skrivebeskytta API-handlingar og gjev tilgang til Fever API. Bruk skjemaet nedst på sida for å laga ein ny API-nøkkel"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Dette er din API-nøkkel. Ho kan brukast til nokre skrivebeskytta API-operasjonar og gir tilgang til Fever API og Google Reader API. Bruk skjemaet nedst på sida for å generere ein ny API-nøkkel"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Nettstad"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Velkomen! Det ser ut til å vera fyrste gongen du køyrer CommaFeed. Ver venleg og opprett ein administratorkonto for å koma i gang."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Gul"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "Wersja rozszerzenia przeglądarki CommaFeed {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed jest kompatybilny z API Fever. Użyj następującego adresu URL w swoim kliencie mobilnym zgodnym z Fever. Zaloguj się przy użyciu swojej nazwy użytkownika i <0>klucza API</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed jest kompatybilny z API Google Reader. Użyj następującego adresu URL w swoim kliencie mobilnym zgodnym z Google Reader. Zaloguj się przy użyciu swojej nazwy użytkownika i <0>klucza API</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "Przecinek następny nieprzeczytany element"
|
||||
@@ -454,6 +458,14 @@ msgstr "Przejdź do dokumentacji API."
|
||||
msgid "Goodies"
|
||||
msgstr "Gadżety"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Winogronowy"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Ten kanał ma stary filtr, którego nie można edytować i który nie jest stosowany. Proszę utworzyć filtr ponownie, korzystając z nowego edytora wyrażeń. Stare wyrażenie filtra to: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "To jest Twój klucz API. Może być używany do niektórych operacji API tylko do odczytu i zapewnia dostęp do API Fever. Użyj formularza na dole strony, aby wygenerować nowy klucz API"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "To jest Twój klucz API. Może być używany do niektórych operacji API tylko do odczytu i zapewnia dostęp do API Fever i API Google Reader. Użyj formularza na dole strony, aby wygenerować nowy klucz API"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Strona internetowa"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Witaj! Wygląda na to, że uruchamiasz CommaFeed po raz pierwszy. Aby rozpocząć, utwórz konto administratora."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Żółty"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "Extensão de navegador CommaFeed versão {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed é compatível com a API Fever. Use a URL a seguir no seu aplicativo móvel compatível com Fever. Entre com seu nome de usuário e sua <0>chave de API</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed é compatível com a API Google Reader. Use a URL a seguir no seu aplicativo móvel compatível com Google Reader. Entre com seu nome de usuário e sua <0>chave de API</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed próximo item não lido"
|
||||
@@ -454,6 +458,14 @@ msgstr "Vá para a documentação da API."
|
||||
msgid "Goodies"
|
||||
msgstr "Brindes"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Uva"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Este feed tem um filtro legado que não pode ser editado e não é aplicado. Recrie o filtro usando o novo editor de expressões. A expressão do filtro legado era: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Esta é sua chave de API. Ela pode ser usada para algumas operações somente leitura da API e concede acesso à API do Fever. Use o formulário abaixo para gerar uma nova chave de API"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Esta é sua chave de API. Ela pode ser usada para algumas operações de API somente leitura e concede acesso à API Fever e à API Google Reader. Use o formulário na parte inferior da página para gerar uma nova chave de API"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Site"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Bem-vindo! Esta parece ser a primeira vez que você está executando o CommaFeed. Crie uma conta de administrador para começar."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Amarelo"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "Версия расширения браузера CommaFeed {browserExt
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed совместим с API Fever. Используйте следующий URL-адрес в мобильном клиенте, совместимом с Fever. Войдите в систему, указав свое имя пользователя и <0>API-ключ</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed совместим с API Google Reader. Используйте следующий URL-адрес в мобильном клиенте, совместимом с Google Reader. Войдите в систему, указав свое имя пользователя и <0>API-ключ</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed следующий непрочитанный элемент"
|
||||
@@ -454,6 +458,14 @@ msgstr "Перейдите к документации по API."
|
||||
msgid "Goodies"
|
||||
msgstr "Сладости"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Виноградный"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Этот фид имеет устаревший фильтр, который нельзя редактировать и который не применяется. Пожалуйста, создайте фильтр заново, используя новый редактор выражений. Устаревшее выражение фильтра было: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Это ваш ключ API. Он может использоваться для некоторых операций API только для чтения и предоставляет доступ к API Fever. Чтобы сгенерировать новый ключ API, воспользуйтесь формой в нижней части страницы"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Это ваш ключ API. Он может использоваться для некоторых операций API только для чтения и предоставляет доступ к API Fever и API Google Reader. Используйте форму внизу страницы, чтобы сгенерировать новый ключ API"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Веб-сайт"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Добро пожаловать! Похоже, вы запускаете CommaFeed в первый раз. Пожалуйста, создайте учетную запись администратора, чтобы начать работу."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Желтый"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "Rozšírenie prehliadača CommaFeed verzie {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed je kompatibilný s Fever API. Vo svojom mobilnom klientovi kompatibilnom s Fever použite nasledujúcu adresu URL. Prihláste sa pomocou svojho používateľského mena a <0>kľúča API</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed je kompatibilný s Google Reader API. Vo svojom mobilnom klientovi kompatibilnom s Google Reader použite nasledujúcu adresu URL. Prihláste sa pomocou svojho používateľského mena a <0>kľúča API</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed ďalšia neprečítaná položka"
|
||||
@@ -454,6 +458,14 @@ msgstr "Prejdite na dokumentáciu API."
|
||||
msgid "Goodies"
|
||||
msgstr "Dobrôtky"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Hroznová"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Tento kanál má starší filter, ktorý sa nedá upravovať a nepoužíva sa. Znova vytvorte filter pomocou nového editora výrazov. Pôvodný výraz filtra bol: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Toto je váš kľúč API. Môže sa použiť na niektoré operácie rozhrania API len na čítanie a poskytuje prístup k rozhraniu Fever API. Na vygenerovanie nového kľúča API použite formulár v dolnej časti stránky."
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Toto je váš kľúč API. Môže sa použiť pre niektoré operácie API iba na čítanie a udeľuje prístup k Fever API a Google Reader API. Na vygenerovanie nového kľúča API použite formulár v spodnej časti stránky"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Webová stránka"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Vitajte! Zdá sa, že CommaFeed spúšťate prvýkrát. Ak chcete začať, vytvorte si účet správcu."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Žltá"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "Webbläsartillägget CommaFeed version {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed är kompatibelt med Fever API. Använd följande URL i din Fever-kompatibla mobilklient. Logga in med ditt användarnamn och din <0>API-nyckel</0>."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed är kompatibelt med Google Reader API. Använd följande URL i din Google Reader-kompatibla mobilklient. Logga in med ditt användarnamn och din <0>API-nyckel</0>."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed nästa olästa objekt"
|
||||
@@ -454,6 +458,14 @@ msgstr "Gå till API-dokumentationen."
|
||||
msgid "Goodies"
|
||||
msgstr "Godsaker"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Lila"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Detta flöde har ett gammalt filter som inte kan redigeras och inte tillämpas. Skapa filtret på nytt med den nya uttryckseditorern. Det gamla filteruttrycket var: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Detta är din API-nyckel. Den kan användas för vissa skrivskyddade API-operationer och ger åtkomst till Fever API. Använd formuläret längst ner på sidan för att generera en ny API-nyckel"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Det här är din API-nyckel. Den kan användas för vissa skrivskyddade API-åtgärder och ger åtkomst till Fever API och Google Reader API. Använd formuläret längst ner på sidan för att generera en ny API-nyckel"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Webbplats"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Välkommen! Det verkar vara första gången du kör CommaFeed. Skapa ett administratörskonto för att komma igång."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Gul"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "CommaFeed tarayıcı eklentisi sürüm {browserExtensionVersion}."
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed, Fever API ile uyumludur. Fever uyumlu mobil istemcinizde aşağıdaki URL'yi kullanın. Kullanıcı adınız ve <0>API anahtarınız</0> ile giriş yapın."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed, Google Reader API ile uyumludur. Google Reader uyumlu mobil istemcinizde aşağıdaki URL'yi kullanın. Kullanıcı adınız ve <0>API anahtarınız</0> ile giriş yapın."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed sonraki okunmamış öğe"
|
||||
@@ -454,6 +458,14 @@ msgstr "API belgelerine gidin."
|
||||
msgid "Goodies"
|
||||
msgstr "İyilikler"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "Üzüm"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "Bu beslemenin düzenlenemeyen ve uygulanmayan eski bir filtresi var. Lütfen yeni ifade düzenleyiciyi kullanarak filtreyi yeniden oluşturun. Eski filtre ifadesi şuydu: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Bu sizin API anahtarınızdır. Bazı salt okunur API işlemleri için kullanılabilir ve Fever API'sine erişim sağlar. Yeni bir API anahtarı oluşturmak için sayfanın altındaki formu kullanın"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "Bu, API anahtarınızdır. Bazı salt okunur API işlemleri için kullanılabilir ve Fever API ile Google Reader API'ye erişim sağlar. Yeni bir API anahtarı oluşturmak için sayfanın altındaki formu kullanın"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "Web sitesi"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "Hoş geldiniz! Görünüşe göre CommaFeed'i ilk kez çalıştırıyorsunuz. Başlamak için lütfen bir yönetici hesabı oluşturun."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Sarı"
|
||||
|
||||
@@ -210,6 +210,10 @@ msgstr "CommaFeed的浏览器扩展版本:{browserExtensionVersion}。"
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed 和 Fever API 兼容,可以在您Fever兼容的移动客户端中使用以下网址,通过用户名和<0>API密钥</0>登录。"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr "CommaFeed 和 Google Reader API 兼容,可以在您Google Reader兼容的移动客户端中使用以下网址,通过用户名和<0>API密钥</0>登录。"
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
msgstr "CommaFeed 下一个未读项目"
|
||||
@@ -454,6 +458,14 @@ msgstr "API 文档"
|
||||
msgid "Goodies"
|
||||
msgstr "好东西"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "深紫"
|
||||
@@ -1065,8 +1077,8 @@ msgid "This feed has a legacy filter that cannot be edited and is not applied. P
|
||||
msgstr "此信息流具有无法编辑且未应用的旧版过滤器。请使用新的表达式编辑器重新创建过滤器。旧版过滤器表达式为:<0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "这是您的API 密钥,它可以被用于Fever API的只读操作及访问授权。使用页面底部的表单生成一个新的API密钥。"
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API and the Google Reader API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr "这是您的 API 密钥。它可用于某些只读 API 操作,并授予对 Fever API 和 Google Reader API 的访问权限。使用页面底部的表单生成新的 API 密钥"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
@@ -1138,6 +1150,10 @@ msgstr "网站"
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "欢迎!当前页仅当您第一次使用CommaFeed时出现,请创建一个管理员帐号以开始使用。"
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr "用户名或密码错误"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "黄"
|
||||
|
||||
@@ -15,9 +15,10 @@ dayjs.extend(relativeTime)
|
||||
dayjs.extend(duration)
|
||||
|
||||
const root = document.getElementById("root")
|
||||
root &&
|
||||
ReactDOM.createRoot(root).render(
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
)
|
||||
if (!root) throw new Error("root element not found")
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ export function WelcomePage() {
|
||||
icon={<TbClock size={iconSize} />}
|
||||
variant="outline"
|
||||
onClick={async () => await login.execute({ name: "demo", password: "demo" })}
|
||||
showLabelOnMobile
|
||||
mode="desktop"
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
@@ -100,7 +100,7 @@ function Buttons() {
|
||||
icon={<TbKey size={iconSize} />}
|
||||
variant="outline"
|
||||
onClick={async () => await dispatch(redirectToLogin())}
|
||||
showLabelOnMobile
|
||||
mode="desktop"
|
||||
/>
|
||||
{serverInfos?.allowRegistrations && (
|
||||
<ActionButton
|
||||
@@ -108,7 +108,7 @@ function Buttons() {
|
||||
icon={<TbUserPlus size={iconSize} />}
|
||||
variant="filled"
|
||||
onClick={async () => await dispatch(redirectToRegistration())}
|
||||
showLabelOnMobile
|
||||
mode="desktop"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -116,7 +116,7 @@ function Buttons() {
|
||||
label={dark ? msg`Switch to light theme` : msg`Switch to dark theme`}
|
||||
icon={colorScheme === "dark" ? <TbSun size={18} /> : <TbMoon size={iconSize} />}
|
||||
onClick={() => toggleColorScheme()}
|
||||
hideLabelOnDesktop
|
||||
mode="mobile"
|
||||
/>
|
||||
|
||||
{isBrowserExtensionPopup && (
|
||||
@@ -124,7 +124,7 @@ function Buttons() {
|
||||
label={msg`Extension options`}
|
||||
icon={<TbSettings size={iconSize} />}
|
||||
onClick={() => openSettingsPage()}
|
||||
hideLabelOnDesktop
|
||||
mode="mobile"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -227,7 +227,7 @@ export default function Layout(props: Readonly<LayoutProps>) {
|
||||
)}
|
||||
</OnMobile>
|
||||
<OnDesktop>
|
||||
<Group p="md">
|
||||
<Group p="md" wrap="nowrap">
|
||||
<Group justify="space-between" style={{ width: sidebarWidth - 16 }}>
|
||||
<Box>
|
||||
<LogoAndTitle />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import "@testing-library/jest-dom"
|
||||
import "@testing-library/jest-dom/vitest"
|
||||
import { vi } from "vitest"
|
||||
import { Constants } from "@/app/constants"
|
||||
|
||||
|
||||
@@ -7,11 +7,8 @@ import checker from "vite-plugin-checker"
|
||||
export default defineConfig(() => ({
|
||||
plugins: [
|
||||
react(),
|
||||
babel({
|
||||
presets: [reactCompilerPreset()],
|
||||
plugins: ["@lingui/babel-plugin-lingui-macro"],
|
||||
}),
|
||||
lingui(),
|
||||
lingui({ macroTransform: true }),
|
||||
babel({ presets: [reactCompilerPreset()] }),
|
||||
checker({
|
||||
// temporary disabled until TypeScript 7 exposes a stable api
|
||||
// typescript: true,
|
||||
@@ -44,6 +41,7 @@ export default defineConfig(() => ({
|
||||
inconsistentCjsInterop: true,
|
||||
},
|
||||
test: {
|
||||
isolate: false,
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: "./src/setupTests.ts",
|
||||
@@ -51,6 +49,9 @@ export default defineConfig(() => ({
|
||||
build: {
|
||||
chunkSizeWarningLimit: 4000,
|
||||
rolldownOptions: {
|
||||
checks: {
|
||||
pluginTimings: false,
|
||||
},
|
||||
output: {
|
||||
codeSplitting: {
|
||||
groups: [
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
<parent>
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed</artifactId>
|
||||
<version>7.2.1</version>
|
||||
<version>7.4.1</version>
|
||||
</parent>
|
||||
<artifactId>commafeed-server</artifactId>
|
||||
<name>CommaFeed Server</name>
|
||||
<packaging>quarkus</packaging>
|
||||
|
||||
<properties>
|
||||
<quarkus.version>3.38.0</quarkus.version>
|
||||
<querydsl.version>7.5</querydsl.version>
|
||||
<quarkus.version>3.40.1.1</quarkus.version>
|
||||
<querydsl.version>7.7</querydsl.version>
|
||||
<rome.version>2.1.0</rome.version>
|
||||
<!-- renovate: depName=com.google.googlejavaformat:google-java-format -->
|
||||
<google-java-format.version>1.36.1</google-java-format.version>
|
||||
@@ -35,7 +35,7 @@
|
||||
<dependency>
|
||||
<groupId>com.google.protobuf</groupId>
|
||||
<artifactId>protobuf-java</artifactId>
|
||||
<version>4.35.1</version>
|
||||
<version>4.36.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
@@ -64,7 +64,7 @@
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>properties-maven-plugin</artifactId>
|
||||
<version>1.3.0</version>
|
||||
<version>1.3.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
@@ -116,7 +116,7 @@
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.6.3</version>
|
||||
<version>3.6.4</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
@@ -164,7 +164,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.6</version>
|
||||
<version>3.6.0</version>
|
||||
<configuration>
|
||||
<argLine>@{argLine}</argLine>
|
||||
<systemPropertyVariables>
|
||||
@@ -176,7 +176,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>3.5.6</version>
|
||||
<version>3.6.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
@@ -221,7 +221,7 @@
|
||||
<plugin>
|
||||
<groupId>io.github.git-commit-id</groupId>
|
||||
<artifactId>git-commit-id-maven-plugin</artifactId>
|
||||
<version>10.0.0</version>
|
||||
<version>10.0.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
@@ -245,7 +245,7 @@
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>13.9.0</version>
|
||||
<version>14.3.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<executions>
|
||||
@@ -274,7 +274,7 @@
|
||||
<plugin>
|
||||
<groupId>com.diffplug.spotless</groupId>
|
||||
<artifactId>spotless-maven-plugin</artifactId>
|
||||
<version>3.9.0</version>
|
||||
<version>3.10.4</version>
|
||||
<?m2e ignore?>
|
||||
<executions>
|
||||
<execution>
|
||||
@@ -302,14 +302,14 @@
|
||||
<dependency>
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed-client</artifactId>
|
||||
<version>7.2.1</version>
|
||||
<version>7.4.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- compile-time processors -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.46</version>
|
||||
<version>1.18.48</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.kohsuke.metainf-services</groupId>
|
||||
@@ -322,6 +322,11 @@
|
||||
<artifactId>quarkus-extension-processor</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.graalvm.sdk</groupId>
|
||||
<artifactId>nativeimage</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- quarkus dependencies -->
|
||||
<dependency>
|
||||
@@ -368,7 +373,7 @@
|
||||
<dependency>
|
||||
<groupId>io.dropwizard.metrics</groupId>
|
||||
<artifactId>metrics-json</artifactId>
|
||||
<version>4.2.39</version>
|
||||
<version>4.2.40</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.openfeign.querydsl</groupId>
|
||||
@@ -389,7 +394,7 @@
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-collections4</artifactId>
|
||||
<version>4.5.0</version>
|
||||
<version>4.6.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
@@ -399,7 +404,7 @@
|
||||
<dependency>
|
||||
<groupId>dev.cel</groupId>
|
||||
<artifactId>cel</artifactId>
|
||||
<version>0.13.1</version>
|
||||
<version>0.14.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.passay</groupId>
|
||||
@@ -429,12 +434,12 @@
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.23.1</version>
|
||||
<version>1.23.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ibm.icu</groupId>
|
||||
<artifactId>icu4j</artifactId>
|
||||
<version>78.3</version>
|
||||
<version>79.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.sourceforge.cssparser</groupId>
|
||||
@@ -449,17 +454,12 @@
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||
<artifactId>httpclient5</artifactId>
|
||||
<version>5.6.3</version>
|
||||
<version>5.6.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.hakky54</groupId>
|
||||
<artifactId>ayza-for-apache5</artifactId>
|
||||
<version>10.0.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.brotli</groupId>
|
||||
<artifactId>dec</artifactId>
|
||||
<version>0.1.2</version>
|
||||
<version>10.1.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.seancfoley</groupId>
|
||||
@@ -476,13 +476,13 @@
|
||||
<dependency>
|
||||
<groupId>io.quarkiverse.playwright</groupId>
|
||||
<artifactId>quarkus-playwright</artifactId>
|
||||
<version>2.3.8</version>
|
||||
<version>2.3.10</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mock-server</groupId>
|
||||
<artifactId>mockserver-junit-jupiter</artifactId>
|
||||
<version>7.5.0</version>
|
||||
<artifactId>mockserver-junit-jupiter-no-dependencies</artifactId>
|
||||
<version>9.1.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
FROM ibm-semeru-runtimes:open-jdk-25.0.3.0-jre@sha256:43ec947fee9c4c2ed880620ae9e78911ac5eef66391e5a2c43f956dcc9000491
|
||||
FROM ibm-semeru-runtimes:open-jdk-25.0.4.15-jre@sha256:630c738228597b4cd31d9823e479384729b03684bac23478b33fdbda13ae31b8
|
||||
LABEL org.opencontainers.image.source="https://github.com/Athou/commafeed"
|
||||
|
||||
EXPOSE 8082
|
||||
|
||||
RUN mkdir -p /commafeed/data
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
FROM debian:13.6@sha256:fac46bff2e02f51425b6e33b0e1169f55dfb053d83511ca28aa50c09fd5ed7a4
|
||||
FROM debian:13.7@sha256:4c25275446ca371766b8b093c696782eabce06263fccacb8d5d813b3dc44b308
|
||||
LABEL org.opencontainers.image.source="https://github.com/Athou/commafeed"
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
EXPOSE 8082
|
||||
|
||||
@@ -93,3 +93,12 @@ Tags are of the form `<version>-<database>[-jvm]` where:
|
||||
- `master` (always points to the latest git commit)
|
||||
- `<database>` is the database to use (`h2`, `postgresql`, `mysql` or `mariadb`)
|
||||
- `-jvm` is optional and indicates that CommaFeed is running on a JVM, and not compiled natively.
|
||||
|
||||
## FAQ
|
||||
|
||||
### Getting "Access to local address blocked" when adding a feed
|
||||
|
||||
CommaFeed blocks access to local resources by default to prevent [SSRF](https://en.wikipedia.org/wiki/Server-side_request_forgery) attacks.
|
||||
If you want to subscribe to feeds that are only available on your local network, you can disable this security measure by setting the `COMMAFEED_HTTP_CLIENT_BLOCK_LOCAL_ADDRESSES` variable to `false`.
|
||||
Do this only if you trust all users of your CommaFeed instance not to access private resources.
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import jakarta.inject.Singleton;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.apache.commons.lang3.SystemProperties;
|
||||
|
||||
@Slf4j
|
||||
@Singleton
|
||||
@RequiredArgsConstructor
|
||||
@@ -26,6 +28,12 @@ public class CommaFeedApplication {
|
||||
public void start(@Observes StartupEvent ev) {
|
||||
log.info("starting up...");
|
||||
|
||||
// disable entity expansion limits added in JDK24+ (#1961)
|
||||
// we already strip doctype declarations in XMLCleaner to prevent xxe attacks
|
||||
// we also already limit the size of feeds we download in HttpGetter
|
||||
System.setProperty(SystemProperties.JDK_XML_MAX_GENERAL_ENTITY_SIZE_LIMIT, "0");
|
||||
System.setProperty(SystemProperties.JDK_XML_TOTAL_ENTITY_SIZE_LIMIT, "0");
|
||||
|
||||
PasswordConstraintValidator.setMinimumPasswordLength(
|
||||
config.users().minimumPasswordLength());
|
||||
|
||||
@@ -33,6 +41,11 @@ public class CommaFeedApplication {
|
||||
ImageProxyUrl.generateKey();
|
||||
}
|
||||
|
||||
if (config.passwordRecoveryEnabled() && config.passwordRecoveryPublicBaseUrl().isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"password recovery is enabled but no public base url is set");
|
||||
}
|
||||
|
||||
feedRefreshEngine.start();
|
||||
taskScheduler.start();
|
||||
}
|
||||
|
||||
@@ -49,6 +49,13 @@ public interface CommaFeedConfiguration {
|
||||
@WithDefault("false")
|
||||
boolean passwordRecoveryEnabled();
|
||||
|
||||
/**
|
||||
* The base URL of the application to use in the password recovery link in the email. We can't
|
||||
* use the URL sent by the browser of the user because of malicious Host header injection
|
||||
* attacks.
|
||||
*/
|
||||
Optional<String> passwordRecoveryPublicBaseUrl();
|
||||
|
||||
/** Message displayed in a notification at the bottom of the page. */
|
||||
Optional<String> announcement();
|
||||
|
||||
@@ -130,7 +137,7 @@ public interface CommaFeedConfiguration {
|
||||
* <p>You may want to enable this if you host a public instance of CommaFeed with
|
||||
* registrations open.
|
||||
*/
|
||||
@WithDefault("false")
|
||||
@WithDefault("true")
|
||||
boolean blockLocalAddresses();
|
||||
|
||||
/** HTTP client cache configuration */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.commafeed;
|
||||
|
||||
import com.commafeed.frontend.exception.CommaFeedApplicationException;
|
||||
import com.commafeed.frontend.exception.CommaFeedExceptionType;
|
||||
import com.commafeed.security.CookieService;
|
||||
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||
@@ -26,6 +28,18 @@ public class ExceptionMappers {
|
||||
private final CookieService cookieService;
|
||||
private final CommaFeedConfiguration config;
|
||||
|
||||
@ServerExceptionMapper(CommaFeedApplicationException.class)
|
||||
public RestResponse<CommaFeedApplicationError> applicationError(
|
||||
CommaFeedApplicationException e) {
|
||||
ResponseBuilder<CommaFeedApplicationError> response =
|
||||
ResponseBuilder.create(
|
||||
e.type().status(), new CommaFeedApplicationError(e.type(), e.getMessage()));
|
||||
if (e.type().status() == Status.UNAUTHORIZED) {
|
||||
response.cookie(cookieService.buildLogoutCookie());
|
||||
}
|
||||
return response.build();
|
||||
}
|
||||
|
||||
@ServerExceptionMapper(UnauthorizedException.class)
|
||||
public RestResponse<UnauthorizedResponse> unauthorized(UnauthorizedException e) {
|
||||
return RestResponse.status(
|
||||
@@ -55,4 +69,7 @@ public class ExceptionMappers {
|
||||
|
||||
@RegisterForReflection
|
||||
public record ValidationFailed(String message) {}
|
||||
|
||||
@RegisterForReflection
|
||||
public record CommaFeedApplicationError(CommaFeedExceptionType type, String message) {}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import com.codahale.metrics.Histogram;
|
||||
import com.codahale.metrics.Meter;
|
||||
import com.codahale.metrics.MetricRegistry;
|
||||
import com.codahale.metrics.Timer;
|
||||
import com.oracle.svm.core.annotate.Substitute;
|
||||
import com.oracle.svm.core.annotate.TargetClass;
|
||||
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||
|
||||
@@ -346,4 +348,22 @@ import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||
com.rometools.opml.feed.synd.impl.ConverterForOPML10.class,
|
||||
com.rometools.opml.feed.synd.impl.ConverterForOPML20.class,
|
||||
})
|
||||
public class NativeImageClasses {}
|
||||
public class NativeImageClasses {
|
||||
|
||||
// disable zstd and brotli runtime detection because it causes native image failure
|
||||
@TargetClass(className = "org.apache.hc.client5.http.impl.ZstdRuntime")
|
||||
static final class ZstdRuntime {
|
||||
@Substitute
|
||||
public static boolean available() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@TargetClass(className = "org.apache.hc.client5.http.impl.Brotli4jRuntime")
|
||||
static final class Brotli4jRuntime {
|
||||
@Substitute
|
||||
public static boolean available() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.google.common.net.HttpHeaders;
|
||||
import inet.ipaddr.IPAddress;
|
||||
import inet.ipaddr.IPAddressNetwork;
|
||||
import inet.ipaddr.IPAddressString;
|
||||
import inet.ipaddr.ipv4.IPv4Address;
|
||||
import inet.ipaddr.ipv6.IPv6Address;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
@@ -17,27 +19,27 @@ import nl.altindag.ssl.apache5.util.Apache5SslUtils;
|
||||
|
||||
import org.apache.hc.client5.http.DnsResolver;
|
||||
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
|
||||
import org.apache.hc.client5.http.async.methods.InflatingAsyncDataConsumer;
|
||||
import org.apache.hc.client5.http.async.methods.InflatingGzipDataConsumer;
|
||||
import org.apache.hc.client5.http.config.ConnectionConfig;
|
||||
import org.apache.hc.client5.http.config.TlsConfig;
|
||||
import org.apache.hc.client5.http.entity.DeflateInputStream;
|
||||
import org.apache.hc.client5.http.entity.InputStreamFactory;
|
||||
import org.apache.hc.client5.http.entity.compress.ContentCoding;
|
||||
import org.apache.hc.client5.http.impl.DefaultRedirectStrategy;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
|
||||
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
|
||||
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
|
||||
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
|
||||
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
|
||||
import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
|
||||
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager;
|
||||
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
|
||||
import org.apache.hc.client5.http.protocol.RedirectStrategy;
|
||||
import org.apache.hc.core5.http.Header;
|
||||
import org.apache.hc.core5.http.HttpException;
|
||||
import org.apache.hc.core5.http.HttpRequest;
|
||||
import org.apache.hc.core5.http.HttpResponse;
|
||||
import org.apache.hc.core5.http.message.BasicHeader;
|
||||
import org.apache.hc.core5.http.nio.AsyncDataConsumer;
|
||||
import org.apache.hc.core5.http.protocol.HttpContext;
|
||||
import org.apache.hc.core5.http2.HttpVersionPolicy;
|
||||
import org.apache.hc.core5.util.TimeValue;
|
||||
import org.apache.hc.core5.util.Timeout;
|
||||
import org.brotli.dec.BrotliInputStream;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
@@ -46,7 +48,7 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.SequencedMap;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
@Singleton
|
||||
@RequiredArgsConstructor
|
||||
@@ -58,8 +60,8 @@ public class HttpClientFactory {
|
||||
private final CommaFeedConfiguration config;
|
||||
private final CommaFeedVersion version;
|
||||
|
||||
public CloseableHttpClient newClient(int poolSize) {
|
||||
PoolingHttpClientConnectionManager connectionManager =
|
||||
public CloseableHttpAsyncClient newClient(int poolSize) {
|
||||
PoolingAsyncClientConnectionManager connectionManager =
|
||||
newConnectionManager(config, poolSize);
|
||||
String userAgent =
|
||||
config.httpClient()
|
||||
@@ -72,42 +74,48 @@ public class HttpClientFactory {
|
||||
return newClient(config, connectionManager, userAgent);
|
||||
}
|
||||
|
||||
private CloseableHttpClient newClient(
|
||||
private CloseableHttpAsyncClient newClient(
|
||||
CommaFeedConfiguration config,
|
||||
HttpClientConnectionManager connectionManager,
|
||||
PoolingAsyncClientConnectionManager connectionManager,
|
||||
String userAgent) {
|
||||
List<Header> headers = new ArrayList<>();
|
||||
headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en"));
|
||||
headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache"));
|
||||
headers.add(new BasicHeader(HttpHeaders.CACHE_CONTROL, "no-cache"));
|
||||
headers.add(new BasicHeader(HttpHeaders.ACCEPT, "*/*"));
|
||||
headers.add(new BasicHeader(HttpHeaders.SEC_FETCH_MODE, "navigate"));
|
||||
|
||||
SequencedMap<String, InputStreamFactory> contentDecoderMap = new LinkedHashMap<>();
|
||||
contentDecoderMap.put(ContentCoding.GZIP.token(), GZIPInputStream::new);
|
||||
contentDecoderMap.put(ContentCoding.DEFLATE.token(), DeflateInputStream::new);
|
||||
contentDecoderMap.put(ContentCoding.BROTLI.token(), BrotliInputStream::new);
|
||||
SequencedMap<String, UnaryOperator<AsyncDataConsumer>> contentDecoderMap =
|
||||
new LinkedHashMap<>();
|
||||
contentDecoderMap.put(
|
||||
ContentCoding.DEFLATE.token(), d -> new InflatingAsyncDataConsumer(d, null));
|
||||
contentDecoderMap.put(ContentCoding.GZIP.token(), InflatingGzipDataConsumer::new);
|
||||
|
||||
RedirectStrategy redirectStrategy =
|
||||
config.httpClient().blockLocalAddresses()
|
||||
? new BlockLocalAddressesRedirectStrategy(DNS_RESOLVER)
|
||||
: new DefaultRedirectStrategy();
|
||||
|
||||
return HttpClientBuilder.create()
|
||||
.disableConnectionState()
|
||||
.useSystemProperties()
|
||||
.disableAutomaticRetries()
|
||||
.disableCookieManagement()
|
||||
.setUserAgent(userAgent)
|
||||
.setDefaultHeaders(headers)
|
||||
.setConnectionManager(connectionManager)
|
||||
.evictExpiredConnections()
|
||||
.evictIdleConnections(
|
||||
TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
|
||||
.setContentDecoderRegistry(new LinkedHashMap<>(contentDecoderMap))
|
||||
.setRedirectStrategy(redirectStrategy)
|
||||
.build();
|
||||
CloseableHttpAsyncClient client =
|
||||
HttpAsyncClients.custom()
|
||||
.disableConnectionState()
|
||||
.useSystemProperties()
|
||||
.disableAutomaticRetries()
|
||||
.disableCookieManagement()
|
||||
.setUserAgent(userAgent)
|
||||
.setDefaultHeaders(headers)
|
||||
.setConnectionManager(connectionManager)
|
||||
.evictExpiredConnections()
|
||||
.evictIdleConnections(
|
||||
TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
|
||||
.setContentDecoderMap(new LinkedHashMap<>(contentDecoderMap))
|
||||
.setRedirectStrategy(redirectStrategy)
|
||||
.build();
|
||||
client.start();
|
||||
return client;
|
||||
}
|
||||
|
||||
private PoolingHttpClientConnectionManager newConnectionManager(
|
||||
private PoolingAsyncClientConnectionManager newConnectionManager(
|
||||
CommaFeedConfiguration config, int poolSize) {
|
||||
SSLFactory sslFactory =
|
||||
SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
|
||||
@@ -116,8 +124,8 @@ public class HttpClientFactory {
|
||||
? new BlockLocalAddressesDnsResolver(DNS_RESOLVER)
|
||||
: DNS_RESOLVER;
|
||||
|
||||
return PoolingHttpClientConnectionManagerBuilder.create()
|
||||
.setTlsSocketStrategy(Apache5SslUtils.toTlsSocketStrategy(sslFactory))
|
||||
return PoolingAsyncClientConnectionManagerBuilder.create()
|
||||
.setTlsStrategy(Apache5SslUtils.toTlsStrategy(sslFactory))
|
||||
.setDefaultConnectionConfig(
|
||||
ConnectionConfig.custom()
|
||||
.setConnectTimeout(Timeout.of(config.httpClient().connectTimeout()))
|
||||
@@ -127,6 +135,7 @@ public class HttpClientFactory {
|
||||
.build())
|
||||
.setDefaultTlsConfig(
|
||||
TlsConfig.custom()
|
||||
.setVersionPolicy(HttpVersionPolicy.NEGOTIATE)
|
||||
.setHandshakeTimeout(
|
||||
Timeout.of(config.httpClient().sslHandshakeTimeout()))
|
||||
.build())
|
||||
@@ -137,8 +146,38 @@ public class HttpClientFactory {
|
||||
}
|
||||
|
||||
private static boolean isLocalAddress(InetAddress address) {
|
||||
IPAddress ip = new IPAddressNetwork.IPAddressGenerator().from(address);
|
||||
return ip.isLocal() || ip.isLoopback() || ip.isMulticast() || CGNAT_RANGE.contains(ip);
|
||||
return isLocalAddress(new IPAddressNetwork.IPAddressGenerator().from(address));
|
||||
}
|
||||
|
||||
private static boolean isLocalAddress(IPAddress ip) {
|
||||
if (ip.isLocal() || ip.isLoopback() || ip.isMulticast() || CGNAT_RANGE.contains(ip)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ip.isIPv6()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv6 transition mechanisms embed an IPv4 address that must be validated too, otherwise
|
||||
// they could be used to smuggle a blocked IPv4 target past the IPv6-only checks above
|
||||
IPv6Address ipv6 = ip.toIPv6();
|
||||
if (ipv6.isIPv4Mapped() || ipv6.isIPv4Compatible() || ipv6.isWellKnownIPv4Translatable()) {
|
||||
// IPv4-mapped (::ffff:x.x.x.x), IPv4-compatible (::x.x.x.x) and NAT64
|
||||
// (64:ff9b::/96, RFC 6052) addresses all embed the IPv4 address in the lowest 32 bits
|
||||
return isLocalAddress(ipv6.getEmbeddedIPv4Address());
|
||||
}
|
||||
if (ipv6.is6To4()) {
|
||||
// 6to4 (2002::/16, RFC 3056) embeds the IPv4 address in bits 16-47
|
||||
return isLocalAddress(ipv6.get6To4IPv4Address());
|
||||
}
|
||||
if (ipv6.isTeredo()) {
|
||||
// Teredo (2001::/32, RFC 4380) embeds the IPv4 address in the lowest 32 bits,
|
||||
// obfuscated with a bitwise complement
|
||||
IPv4Address obfuscated = ipv6.getEmbeddedIPv4Address();
|
||||
return isLocalAddress(new IPv4Address(~obfuscated.intValue()));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private record BlockLocalAddressesDnsResolver(DnsResolver delegate) implements DnsResolver {
|
||||
|
||||
@@ -6,7 +6,6 @@ import com.commafeed.CommaFeedConfiguration.HttpClientCache;
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import com.google.common.collect.Iterables;
|
||||
import com.google.common.io.ByteStreams;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
@@ -17,39 +16,53 @@ import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.Lombok;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.Delegate;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
|
||||
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
|
||||
import org.apache.hc.client5.http.async.methods.SimpleRequestProducer;
|
||||
import org.apache.hc.client5.http.config.RequestConfig;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
|
||||
import org.apache.hc.client5.http.protocol.HttpClientContext;
|
||||
import org.apache.hc.client5.http.protocol.RedirectLocations;
|
||||
import org.apache.hc.client5.http.utils.DateUtils;
|
||||
import org.apache.hc.core5.http.ClassicHttpRequest;
|
||||
import org.apache.hc.core5.http.HttpEntity;
|
||||
import org.apache.hc.core5.concurrent.FutureCallback;
|
||||
import org.apache.hc.core5.http.ContentType;
|
||||
import org.apache.hc.core5.http.EntityDetails;
|
||||
import org.apache.hc.core5.http.HttpException;
|
||||
import org.apache.hc.core5.http.HttpStatus;
|
||||
import org.apache.hc.core5.http.Message;
|
||||
import org.apache.hc.core5.http.NameValuePair;
|
||||
import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
|
||||
import org.apache.hc.core5.util.Timeout;
|
||||
import org.apache.hc.core5.http.nio.AsyncEntityConsumer;
|
||||
import org.apache.hc.core5.http.nio.AsyncRequestProducer;
|
||||
import org.apache.hc.core5.http.nio.AsyncResponseConsumer;
|
||||
import org.apache.hc.core5.http.nio.entity.AbstractBinAsyncEntityConsumer;
|
||||
import org.apache.hc.core5.http.nio.support.BasicResponseConsumer;
|
||||
import org.jboss.resteasy.reactive.common.headers.CacheControlDelegate;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.InstantSource;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/** Smart HTTP getter: handles gzip, ssl, last modified and etag headers */
|
||||
/** Smart HTTP getter: handles gzip, ssl, last modified, and etag headers */
|
||||
@Singleton
|
||||
@Slf4j
|
||||
public class HttpGetter {
|
||||
private final CommaFeedConfiguration config;
|
||||
private final InstantSource instantSource;
|
||||
private final CloseableHttpClient client;
|
||||
private final CloseableHttpAsyncClient client;
|
||||
private final Cache<HttpRequest, HttpResponse> cache;
|
||||
|
||||
public HttpGetter(
|
||||
@@ -150,70 +163,96 @@ public class HttpGetter {
|
||||
HttpClientContext context = HttpClientContext.create();
|
||||
context.setRequestConfig(
|
||||
RequestConfig.custom()
|
||||
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
|
||||
// causes issues with some feeds
|
||||
// see https://github.com/Athou/commafeed/issues/1572
|
||||
// and https://issues.apache.org/jira/browse/HTTPCLIENT-2344
|
||||
.setProtocolUpgradeEnabled(false)
|
||||
.build());
|
||||
|
||||
return client.execute(
|
||||
request.toClassicHttpRequest(),
|
||||
context,
|
||||
resp -> {
|
||||
byte[] content =
|
||||
resp.getEntity() == null
|
||||
? null
|
||||
: toByteArray(
|
||||
resp.getEntity(),
|
||||
config.httpClient().maxResponseSize().asLongValue());
|
||||
int code = resp.getCode();
|
||||
String lastModifiedHeader =
|
||||
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.LAST_MODIFIED))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.orElse(null);
|
||||
String eTagHeader =
|
||||
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.ETAG))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.orElse(null);
|
||||
long maxResponseSize = config.httpClient().maxResponseSize().asLongValue();
|
||||
AsyncRequestProducer req = SimpleRequestProducer.create(request.toSimpleHttpRequest());
|
||||
AsyncResponseConsumer<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> resp =
|
||||
new BasicResponseConsumer<>(
|
||||
new LimitedContentLengthEntityConsumer(
|
||||
new LimitedBytesEntityConsumer(maxResponseSize), maxResponseSize));
|
||||
FutureCallback<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> callback =
|
||||
new FutureCallback<>() {
|
||||
@Override
|
||||
public void completed(
|
||||
Message<org.apache.hc.core5.http.HttpResponse, byte[]> result) {}
|
||||
|
||||
CacheControl cacheControl =
|
||||
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.CACHE_CONTROL))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.map(HttpGetter::toCacheControl)
|
||||
.orElse(null);
|
||||
@Override
|
||||
public void failed(Exception ex) {}
|
||||
|
||||
Instant retryAfter =
|
||||
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.RETRY_AFTER))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.map(this::toInstant)
|
||||
.orElse(null);
|
||||
@Override
|
||||
public void cancelled() {}
|
||||
};
|
||||
Future<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> future =
|
||||
client.execute(req, resp, context, callback);
|
||||
|
||||
String contentType =
|
||||
Optional.ofNullable(resp.getEntity())
|
||||
.map(HttpEntity::getContentType)
|
||||
.orElse(null);
|
||||
String urlAfterRedirect =
|
||||
Optional.ofNullable(context.getRedirectLocations())
|
||||
.map(RedirectLocations::getAll)
|
||||
.map(l -> Iterables.getLast(l, null))
|
||||
.map(URI::toString)
|
||||
.orElse(request.getUrl());
|
||||
try {
|
||||
Message<org.apache.hc.core5.http.HttpResponse, byte[]> message =
|
||||
future.get(config.httpClient().responseTimeout().toSeconds(), TimeUnit.SECONDS);
|
||||
|
||||
return new HttpResponse(
|
||||
code,
|
||||
lastModifiedHeader,
|
||||
eTagHeader,
|
||||
cacheControl,
|
||||
retryAfter,
|
||||
content,
|
||||
contentType,
|
||||
urlAfterRedirect);
|
||||
});
|
||||
byte[] content = message.getBody();
|
||||
int code = message.getHead().getCode();
|
||||
String lastModifiedHeader =
|
||||
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.LAST_MODIFIED))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.orElse(null);
|
||||
String eTagHeader =
|
||||
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.ETAG))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.orElse(null);
|
||||
|
||||
CacheControl cacheControl =
|
||||
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.CACHE_CONTROL))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.map(HttpGetter::toCacheControl)
|
||||
.orElse(null);
|
||||
|
||||
Instant retryAfter =
|
||||
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.RETRY_AFTER))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.map(this::toInstant)
|
||||
.orElse(null);
|
||||
|
||||
String contentType =
|
||||
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.CONTENT_TYPE))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.orElse(null);
|
||||
String urlAfterRedirect =
|
||||
Optional.ofNullable(context.getRedirectLocations())
|
||||
.map(RedirectLocations::getAll)
|
||||
.map(l -> Iterables.getLast(l, null))
|
||||
.map(URI::toString)
|
||||
.orElse(request.getUrl());
|
||||
|
||||
return new HttpResponse(
|
||||
code,
|
||||
lastModifiedHeader,
|
||||
eTagHeader,
|
||||
cacheControl,
|
||||
retryAfter,
|
||||
content,
|
||||
contentType,
|
||||
urlAfterRedirect);
|
||||
} catch (InterruptedException e) {
|
||||
future.cancel(true);
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("interrupted while fetching " + request.getUrl(), e);
|
||||
} catch (TimeoutException e) {
|
||||
future.cancel(true);
|
||||
throw new IOException("timed out while fetching " + request.getUrl(), e);
|
||||
} catch (ExecutionException e) {
|
||||
future.cancel(true);
|
||||
throw Lombok.sneakyThrow(e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
private static CacheControl toCacheControl(String headerValue) {
|
||||
@@ -237,28 +276,6 @@ public class HttpGetter {
|
||||
return DateUtils.parseStandardDate(headerValue);
|
||||
}
|
||||
|
||||
private static byte[] toByteArray(HttpEntity entity, long maxBytes) throws IOException {
|
||||
if (entity.getContentLength() > maxBytes) {
|
||||
throw new IOException(
|
||||
"Response size (%s bytes) exceeds the maximum allowed size (%s bytes)"
|
||||
.formatted(entity.getContentLength(), maxBytes));
|
||||
}
|
||||
|
||||
try (InputStream input = entity.getContent()) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] bytes = ByteStreams.limit(input, maxBytes + 1).readAllBytes();
|
||||
if (bytes.length > maxBytes) {
|
||||
throw new IOException(
|
||||
"Response size exceeds the maximum allowed size (%s bytes)"
|
||||
.formatted(maxBytes));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
private static Cache<HttpRequest, HttpResponse> newCache(CommaFeedConfiguration config) {
|
||||
HttpClientCache cacheConfig = config.httpClient().cache();
|
||||
if (!cacheConfig.enabled()) {
|
||||
@@ -274,6 +291,60 @@ public class HttpGetter {
|
||||
.build();
|
||||
}
|
||||
|
||||
private record LimitedContentLengthEntityConsumer(
|
||||
@Delegate AsyncEntityConsumer<byte[]> delegate, long maxBytes)
|
||||
implements AsyncEntityConsumer<byte[]> {
|
||||
|
||||
@Override
|
||||
public void streamStart(EntityDetails entityDetails, FutureCallback<byte[]> resultCallback)
|
||||
throws HttpException, IOException {
|
||||
long contentLength = entityDetails.getContentLength();
|
||||
if (contentLength > maxBytes) {
|
||||
throw new IOException(
|
||||
"Response size (%s bytes) exceeds the maximum allowed size (%s bytes)"
|
||||
.formatted(contentLength, maxBytes));
|
||||
}
|
||||
delegate.streamStart(entityDetails, resultCallback);
|
||||
}
|
||||
}
|
||||
|
||||
@RequiredArgsConstructor
|
||||
private static class LimitedBytesEntityConsumer extends AbstractBinAsyncEntityConsumer<byte[]> {
|
||||
|
||||
private final long maxBytes;
|
||||
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
|
||||
@Override
|
||||
protected void streamStart(ContentType contentType) {}
|
||||
|
||||
@Override
|
||||
protected int capacityIncrement() {
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void data(ByteBuffer src, boolean endOfStream) throws IOException {
|
||||
if (out.size() + src.remaining() > maxBytes) {
|
||||
throw new IOException(
|
||||
"Response size exceeds the maximum allowed size (%s bytes)"
|
||||
.formatted(maxBytes));
|
||||
}
|
||||
byte[] chunk = new byte[src.remaining()];
|
||||
src.get(chunk);
|
||||
out.write(chunk, 0, chunk.length);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected byte[] generateContent() {
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void releaseResources() {
|
||||
out.reset();
|
||||
}
|
||||
}
|
||||
|
||||
public static class SchemeNotAllowedException extends Exception {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -336,15 +407,15 @@ public class HttpGetter {
|
||||
return new HttpRequestBuilder().url(url);
|
||||
}
|
||||
|
||||
public ClassicHttpRequest toClassicHttpRequest() {
|
||||
ClassicHttpRequest req = ClassicRequestBuilder.get(url).build();
|
||||
public SimpleHttpRequest toSimpleHttpRequest() {
|
||||
SimpleRequestBuilder builder = SimpleRequestBuilder.get(url);
|
||||
if (lastModified != null) {
|
||||
req.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified);
|
||||
builder.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified);
|
||||
}
|
||||
if (eTag != null) {
|
||||
req.addHeader(HttpHeaders.IF_NONE_MATCH, eTag);
|
||||
builder.addHeader(HttpHeaders.IF_NONE_MATCH, eTag);
|
||||
}
|
||||
return req;
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import jakarta.inject.Singleton;
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.SystemProperties;
|
||||
import org.apache.commons.math3.stat.descriptive.SummaryStatistics;
|
||||
import org.jdom2.Element;
|
||||
import org.jdom2.Namespace;
|
||||
@@ -58,12 +57,6 @@ public class FeedParser {
|
||||
public FeedParser(EncodingDetector encodingDetector, XMLCleaner xmlCleaner) {
|
||||
this.encodingDetector = encodingDetector;
|
||||
this.xmlCleaner = xmlCleaner;
|
||||
|
||||
// disable entity expansion limits added in JDK24+ (#1961)
|
||||
// we already strip doctype declarations in XMLCleaner to prevent xxe attacks
|
||||
// we also already limit the size of feeds we download in HttpGetter
|
||||
System.setProperty(SystemProperties.JDK_XML_MAX_GENERAL_ENTITY_SIZE_LIMIT, "0");
|
||||
System.setProperty(SystemProperties.JDK_XML_TOTAL_ENTITY_SIZE_LIMIT, "0");
|
||||
}
|
||||
|
||||
public FeedParserResult parse(String feedUrl, byte[] xml) throws FeedParsingException {
|
||||
|
||||
@@ -148,6 +148,7 @@ public class FeedEntryContentCleaningService {
|
||||
|
||||
whitelist.addProtocols("a", "href", "ftp", "http", "https", "magnet", "mailto");
|
||||
whitelist.addProtocols("blockquote", "cite", "http", "https");
|
||||
whitelist.addProtocols("iframe", "src", "http", "https");
|
||||
whitelist.addProtocols("img", "src", "http", "https");
|
||||
whitelist.addProtocols("q", "cite", "http", "https");
|
||||
|
||||
|
||||
@@ -98,15 +98,14 @@ public class FeedEntryService {
|
||||
}
|
||||
}
|
||||
|
||||
public void starEntry(User user, Long entryId, Long subscriptionId, boolean starred) {
|
||||
|
||||
FeedSubscription sub = feedSubscriptionDAO.findById(user, subscriptionId);
|
||||
if (sub == null) {
|
||||
public void starEntry(User user, Long entryId, boolean starred) {
|
||||
FeedEntry entry = feedEntryDAO.findById(entryId);
|
||||
if (entry == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
FeedEntry entry = feedEntryDAO.findById(entryId);
|
||||
if (entry == null) {
|
||||
FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, entry.getFeed());
|
||||
if (sub == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.commafeed.backend.service;
|
||||
|
||||
import com.commafeed.backend.dao.FeedEntryDAO;
|
||||
import com.commafeed.backend.dao.FeedEntryTagDAO;
|
||||
import com.commafeed.backend.dao.FeedSubscriptionDAO;
|
||||
import com.commafeed.backend.model.FeedEntry;
|
||||
import com.commafeed.backend.model.FeedEntryTag;
|
||||
import com.commafeed.backend.model.FeedSubscription;
|
||||
import com.commafeed.backend.model.User;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
@@ -20,6 +22,7 @@ public class FeedEntryTagService {
|
||||
|
||||
private final FeedEntryDAO feedEntryDAO;
|
||||
private final FeedEntryTagDAO feedEntryTagDAO;
|
||||
private final FeedSubscriptionDAO feedSubscriptionDAO;
|
||||
|
||||
public void updateTags(User user, Long entryId, List<String> tagNames) {
|
||||
FeedEntry entry = feedEntryDAO.findById(entryId);
|
||||
@@ -27,6 +30,11 @@ public class FeedEntryTagService {
|
||||
return;
|
||||
}
|
||||
|
||||
FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, entry.getFeed());
|
||||
if (sub == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<FeedEntryTag> existingTags = feedEntryTagDAO.findByEntry(user, entry);
|
||||
Set<String> existingTagNames =
|
||||
existingTags.stream().map(FeedEntryTag::getName).collect(Collectors.toSet());
|
||||
|
||||
@@ -16,26 +16,33 @@ import jakarta.inject.Singleton;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpPost;
|
||||
import org.apache.hc.client5.http.config.RequestConfig;
|
||||
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
|
||||
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
|
||||
import org.apache.hc.client5.http.async.methods.SimpleRequestProducer;
|
||||
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
|
||||
import org.apache.hc.core5.http.ContentType;
|
||||
import org.apache.hc.core5.http.HttpResponse;
|
||||
import org.apache.hc.core5.http.Message;
|
||||
import org.apache.hc.core5.http.NameValuePair;
|
||||
import org.apache.hc.core5.http.io.entity.StringEntity;
|
||||
import org.apache.hc.core5.http.message.BasicNameValuePair;
|
||||
import org.apache.hc.core5.util.Timeout;
|
||||
import org.apache.hc.core5.http.nio.entity.DiscardingEntityConsumer;
|
||||
import org.apache.hc.core5.http.nio.support.BasicResponseConsumer;
|
||||
import org.apache.hc.core5.net.WWWFormCodec;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
@Singleton
|
||||
@Slf4j
|
||||
public class PushNotificationService {
|
||||
|
||||
private final CloseableHttpClient httpClient;
|
||||
private final CloseableHttpAsyncClient httpClient;
|
||||
private final Meter meter;
|
||||
private final CommaFeedConfiguration config;
|
||||
|
||||
@@ -97,13 +104,9 @@ public class PushNotificationService {
|
||||
return;
|
||||
}
|
||||
|
||||
HttpPost request = new HttpPost(serverUrl + "/" + topic);
|
||||
request.setConfig(
|
||||
RequestConfig.custom()
|
||||
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
|
||||
.build());
|
||||
SimpleRequestBuilder request = SimpleRequestBuilder.post(serverUrl + "/" + topic);
|
||||
request.addHeader("Title", feedTitle);
|
||||
request.setEntity(new StringEntity(entryTitle, StandardCharsets.UTF_8));
|
||||
request.setBody(entryTitle, ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8));
|
||||
|
||||
if (StringUtils.isNotBlank(entryUrl)) {
|
||||
request.addHeader("Click", entryUrl);
|
||||
@@ -113,15 +116,7 @@ public class PushNotificationService {
|
||||
request.addHeader("Authorization", "Bearer " + settings.getUserSecret());
|
||||
}
|
||||
|
||||
httpClient.execute(
|
||||
request,
|
||||
response -> {
|
||||
if (response.getCode() >= 400) {
|
||||
throw new PushNotificationException(
|
||||
"ntfy notification failed with status " + response.getCode());
|
||||
}
|
||||
return null;
|
||||
});
|
||||
send("ntfy", request.build());
|
||||
}
|
||||
|
||||
private void sendGotify(
|
||||
@@ -152,23 +147,11 @@ public class PushNotificationService {
|
||||
.put("click", new JsonObject().put("url", entryUrl))));
|
||||
}
|
||||
|
||||
HttpPost request = new HttpPost(serverUrl + "/message");
|
||||
request.setConfig(
|
||||
RequestConfig.custom()
|
||||
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
|
||||
.build());
|
||||
SimpleRequestBuilder request = SimpleRequestBuilder.post(serverUrl + "/message");
|
||||
request.addHeader("X-Gotify-Key", token);
|
||||
request.setEntity(new StringEntity(json.toString(), ContentType.APPLICATION_JSON));
|
||||
request.setBody(json.toString(), ContentType.APPLICATION_JSON);
|
||||
|
||||
httpClient.execute(
|
||||
request,
|
||||
response -> {
|
||||
if (response.getCode() >= 400) {
|
||||
throw new PushNotificationException(
|
||||
"gotify notification failed with status " + response.getCode());
|
||||
}
|
||||
return null;
|
||||
});
|
||||
send("gotify", request.build());
|
||||
}
|
||||
|
||||
private void sendPushover(
|
||||
@@ -194,22 +177,40 @@ public class PushNotificationService {
|
||||
params.add(new BasicNameValuePair("url", entryUrl));
|
||||
}
|
||||
|
||||
HttpPost request = new HttpPost("https://api.pushover.net/1/messages.json");
|
||||
request.setConfig(
|
||||
RequestConfig.custom()
|
||||
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
|
||||
.build());
|
||||
request.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8));
|
||||
SimpleRequestBuilder request =
|
||||
SimpleRequestBuilder.post("https://api.pushover.net/1/messages.json");
|
||||
request.setBody(
|
||||
WWWFormCodec.format(params, StandardCharsets.UTF_8),
|
||||
ContentType.APPLICATION_FORM_URLENCODED.withCharset(StandardCharsets.UTF_8));
|
||||
|
||||
httpClient.execute(
|
||||
request,
|
||||
response -> {
|
||||
if (response.getCode() >= 400) {
|
||||
throw new PushNotificationException(
|
||||
"pushover notification failed with status " + response.getCode());
|
||||
}
|
||||
return null;
|
||||
});
|
||||
send("pushover", request.build());
|
||||
}
|
||||
|
||||
private void send(String name, SimpleHttpRequest request) throws IOException {
|
||||
Future<Message<HttpResponse, Object>> future =
|
||||
httpClient.execute(
|
||||
SimpleRequestProducer.create(request),
|
||||
new BasicResponseConsumer<>(new DiscardingEntityConsumer<>()),
|
||||
null);
|
||||
try {
|
||||
HttpResponse response =
|
||||
future.get(config.httpClient().responseTimeout().toSeconds(), TimeUnit.SECONDS)
|
||||
.getHead();
|
||||
if (response.getCode() >= 400) {
|
||||
throw new PushNotificationException(
|
||||
name + " notification failed with status " + response.getCode());
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
future.cancel(true);
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("interrupted while sending " + name + " notification", e);
|
||||
} catch (TimeoutException e) {
|
||||
future.cancel(true);
|
||||
throw new IOException("timed out while sending " + name + " notification", e);
|
||||
} catch (ExecutionException e) {
|
||||
future.cancel(true);
|
||||
throw new IOException(e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
public static class PushNotificationException extends RuntimeException {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.commafeed.frontend.exception;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Objects;
|
||||
|
||||
public class CommaFeedApplicationException extends RuntimeException {
|
||||
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
private final CommaFeedExceptionType type;
|
||||
|
||||
public CommaFeedApplicationException(CommaFeedExceptionType type) {
|
||||
super(Objects.requireNonNull(type).message());
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public CommaFeedExceptionType type() {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.commafeed.frontend.exception;
|
||||
|
||||
import org.jboss.resteasy.reactive.RestResponse.Status;
|
||||
|
||||
public enum CommaFeedExceptionType {
|
||||
WRONG_USERNAME_OR_PASSWORD(Status.UNAUTHORIZED, "wrong username or password");
|
||||
|
||||
private final Status status;
|
||||
private final String message;
|
||||
|
||||
CommaFeedExceptionType(Status status, String message) {
|
||||
this.status = status;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public Status status() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String message() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,6 @@ public class StarRequest implements Serializable {
|
||||
@Size(max = 128)
|
||||
private String id;
|
||||
|
||||
@Schema(description = "feed id", required = true)
|
||||
private Long feedId;
|
||||
|
||||
@Schema(description = "starred or not", required = true)
|
||||
private boolean starred;
|
||||
}
|
||||
|
||||
@@ -89,11 +89,9 @@ public class EntryREST {
|
||||
@Valid @Parameter(description = "Star Request", required = true) StarRequest req) {
|
||||
Preconditions.checkNotNull(req);
|
||||
Preconditions.checkNotNull(req.getId());
|
||||
Preconditions.checkNotNull(req.getFeedId());
|
||||
|
||||
User user = authenticationContext.getCurrentUser();
|
||||
feedEntryService.starEntry(
|
||||
user, Long.valueOf(req.getId()), req.getFeedId(), req.isStarred());
|
||||
feedEntryService.starEntry(user, Long.valueOf(req.getId()), req.isStarred());
|
||||
|
||||
return Response.ok().build();
|
||||
}
|
||||
|
||||
@@ -413,7 +413,8 @@ public class UserREST {
|
||||
}
|
||||
|
||||
private String buildEmailContent(User user) throws URISyntaxException {
|
||||
String publicUrl = Urls.removeTrailingSlash(uri.getBaseUri().toString());
|
||||
String publicUrl =
|
||||
Urls.removeTrailingSlash(config.passwordRecoveryPublicBaseUrl().orElseThrow());
|
||||
return String.format(
|
||||
"You asked for password recovery for account '%s', <a href='%s'>follow this link</a> to change your password. Ignore this if you didn't request a password recovery.",
|
||||
user.getName(), callbackUrl(user, publicUrl));
|
||||
|
||||
@@ -388,9 +388,7 @@ public class FeverREST {
|
||||
if ("read".equals(action) || "unread".equals(action)) {
|
||||
feedEntryService.markEntry(user, id, "read".equals(action));
|
||||
} else if ("saved".equals(action) || "unsaved".equals(action)) {
|
||||
FeedEntry entry = feedEntryDAO.findById(id);
|
||||
FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, entry.getFeed());
|
||||
feedEntryService.starEntry(user, id, sub.getId(), "saved".equals(action));
|
||||
feedEntryService.starEntry(user, id, "saved".equals(action));
|
||||
}
|
||||
} else if ("feed".equals(source)) {
|
||||
FeedSubscription subscription = feedSubscriptionDAO.findById(user, id);
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.commafeed.frontend.resource.googlereader;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@JsonInclude(Include.NON_NULL)
|
||||
public class GoogleReaderModel {
|
||||
|
||||
private GoogleReaderModel() {}
|
||||
|
||||
@Data
|
||||
public static class UserInfo {
|
||||
|
||||
@JsonProperty("userId")
|
||||
private String userId;
|
||||
|
||||
@JsonProperty("userName")
|
||||
private String userName;
|
||||
|
||||
@JsonProperty("userProfileId")
|
||||
private String userProfileId;
|
||||
|
||||
@JsonProperty("userEmail")
|
||||
private String userEmail;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SubscriptionList {
|
||||
|
||||
@JsonProperty("subscriptions")
|
||||
private List<Subscription> subscriptions = new ArrayList<>();
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Subscription {
|
||||
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
|
||||
@JsonProperty("title")
|
||||
private String title;
|
||||
|
||||
@JsonProperty("categories")
|
||||
private List<Category> categories = new ArrayList<>();
|
||||
|
||||
@JsonProperty("url")
|
||||
private String url;
|
||||
|
||||
@JsonProperty("htmlUrl")
|
||||
private String htmlUrl;
|
||||
|
||||
@JsonProperty("iconUrl")
|
||||
private String iconUrl;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Category {
|
||||
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
|
||||
@JsonProperty("label")
|
||||
private String label;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TagList {
|
||||
|
||||
@JsonProperty("tags")
|
||||
private List<Tag> tags = new ArrayList<>();
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Tag {
|
||||
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class StreamContents {
|
||||
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
|
||||
@JsonProperty("title")
|
||||
private String title;
|
||||
|
||||
@JsonProperty("updated")
|
||||
private long updated;
|
||||
|
||||
@JsonProperty("items")
|
||||
private List<StreamItem> items = new ArrayList<>();
|
||||
|
||||
@JsonProperty("continuation")
|
||||
private String continuation;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class StreamItem {
|
||||
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
|
||||
@JsonProperty("crawlTimeMsec")
|
||||
private String crawlTimeMsec;
|
||||
|
||||
@JsonProperty("timestampUsec")
|
||||
private String timestampUsec;
|
||||
|
||||
@JsonProperty("published")
|
||||
private long published;
|
||||
|
||||
@JsonProperty("updated")
|
||||
private long updated;
|
||||
|
||||
@JsonProperty("title")
|
||||
private String title;
|
||||
|
||||
@JsonProperty("author")
|
||||
private String author;
|
||||
|
||||
@JsonProperty("categories")
|
||||
private List<String> categories = new ArrayList<>();
|
||||
|
||||
@JsonProperty("canonical")
|
||||
private List<HrefEntry> canonical = new ArrayList<>();
|
||||
|
||||
@JsonProperty("alternate")
|
||||
private List<HrefEntry> alternate = new ArrayList<>();
|
||||
|
||||
@JsonProperty("summary")
|
||||
private Content summary;
|
||||
|
||||
@JsonProperty("origin")
|
||||
private Origin origin;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class HrefEntry {
|
||||
|
||||
@JsonProperty("href")
|
||||
private String href;
|
||||
|
||||
@JsonProperty("type")
|
||||
private String type;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Content {
|
||||
|
||||
@JsonProperty("content")
|
||||
private String content;
|
||||
|
||||
@JsonProperty("direction")
|
||||
private String direction;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Origin {
|
||||
|
||||
@JsonProperty("streamId")
|
||||
private String streamId;
|
||||
|
||||
@JsonProperty("title")
|
||||
private String title;
|
||||
|
||||
@JsonProperty("htmlUrl")
|
||||
private String htmlUrl;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ItemRefs {
|
||||
|
||||
@JsonProperty("itemRefs")
|
||||
private List<ItemRef> itemRefs = new ArrayList<>();
|
||||
|
||||
@JsonProperty("continuation")
|
||||
private String continuation;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class ItemRef {
|
||||
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class UnreadCounts {
|
||||
|
||||
@JsonProperty("max")
|
||||
private long max;
|
||||
|
||||
@JsonProperty("unreadcounts")
|
||||
private List<UnreadCountEntry> unreadCounts = new ArrayList<>();
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class UnreadCountEntry {
|
||||
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
|
||||
@JsonProperty("count")
|
||||
private long count;
|
||||
|
||||
@JsonProperty("newestItemTimestampUsec")
|
||||
private String newestItemTimestampUsec;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
package com.commafeed.frontend.resource.googlereader;
|
||||
|
||||
import com.commafeed.backend.Digests;
|
||||
import com.commafeed.backend.dao.FeedCategoryDAO;
|
||||
import com.commafeed.backend.dao.FeedEntryDAO;
|
||||
import com.commafeed.backend.dao.FeedEntryStatusDAO;
|
||||
import com.commafeed.backend.dao.FeedSubscriptionDAO;
|
||||
import com.commafeed.backend.feed.FeedUtils;
|
||||
import com.commafeed.backend.model.FeedCategory;
|
||||
import com.commafeed.backend.model.FeedEntry;
|
||||
import com.commafeed.backend.model.FeedEntryContent;
|
||||
import com.commafeed.backend.model.FeedEntryStatus;
|
||||
import com.commafeed.backend.model.FeedSubscription;
|
||||
import com.commafeed.backend.model.User;
|
||||
import com.commafeed.backend.model.UserSettings.ReadingOrder;
|
||||
import com.commafeed.backend.service.FeedEntryService;
|
||||
import com.commafeed.backend.service.FeedSubscriptionService;
|
||||
import com.commafeed.backend.service.UserService;
|
||||
import com.commafeed.frontend.model.UnreadCount;
|
||||
import com.commafeed.security.AuthenticationContext;
|
||||
import com.commafeed.security.Roles;
|
||||
import com.commafeed.security.mechanism.GoogleReaderAuthenticationMechanism;
|
||||
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.inject.Singleton;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.FormParam;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Google Reader compatible API
|
||||
*
|
||||
* <ul>
|
||||
* <li>url: /rest/googlereader
|
||||
* <li>login: username
|
||||
* <li>password: api key
|
||||
* </ul>
|
||||
*/
|
||||
@Path(GoogleReaderAuthenticationMechanism.PATH_PREFIX)
|
||||
@RolesAllowed(Roles.USER)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@RequiredArgsConstructor
|
||||
@Singleton
|
||||
public class GoogleReaderREST {
|
||||
|
||||
private static final int DEFAULT_ITEM_COUNT = 20;
|
||||
private static final int MAX_ITEM_COUNT = 1000;
|
||||
// cap at which clients should display "count+" instead of the exact unread count
|
||||
private static final int MAX_UNREAD_COUNT = 10000;
|
||||
|
||||
private final AuthenticationContext authenticationContext;
|
||||
private final UserService userService;
|
||||
private final FeedSubscriptionService feedSubscriptionService;
|
||||
private final FeedEntryService feedEntryService;
|
||||
private final FeedSubscriptionDAO feedSubscriptionDAO;
|
||||
private final FeedCategoryDAO feedCategoryDAO;
|
||||
private final FeedEntryDAO feedEntryDAO;
|
||||
private final FeedEntryStatusDAO feedEntryStatusDAO;
|
||||
|
||||
@GET
|
||||
@PermitAll
|
||||
@Path("/")
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
@Operation(hidden = true)
|
||||
public Response welcome() {
|
||||
return Response.ok("This is the Google Reader API endpoint.").build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@PermitAll
|
||||
@Path("/accounts/ClientLogin")
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public Response clientLogin(@Context UriInfo uri, MultivaluedMap<String, String> form) {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.getFirst()));
|
||||
form.forEach((k, v) -> params.put(k, v.getFirst()));
|
||||
|
||||
String password = params.get("Passwd");
|
||||
User user = StringUtils.isBlank(password) ? null : userService.login(password).orElse(null);
|
||||
if (user == null) {
|
||||
return Response.status(Status.FORBIDDEN).entity("Error=BadAuthentication\n").build();
|
||||
}
|
||||
|
||||
String token = user.getName() + "/" + user.getApiKey();
|
||||
String body = "SID=" + token + "\nLSID=" + token + "\nAuth=" + token + "\n";
|
||||
return Response.ok(body).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/token")
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public Response token() {
|
||||
return Response.ok(computeEditToken(currentUser())).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/user-info")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.UserInfo userInfo() {
|
||||
User user = currentUser();
|
||||
GoogleReaderModel.UserInfo info = new GoogleReaderModel.UserInfo();
|
||||
info.setUserId(String.valueOf(user.getId()));
|
||||
info.setUserName(user.getName());
|
||||
info.setUserProfileId(String.valueOf(user.getId()));
|
||||
info.setUserEmail(user.getEmail());
|
||||
return info;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/subscription/list")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.SubscriptionList subscriptionList() {
|
||||
User user = currentUser();
|
||||
GoogleReaderModel.SubscriptionList resp = new GoogleReaderModel.SubscriptionList();
|
||||
resp.setSubscriptions(
|
||||
feedSubscriptionDAO.findAll(user).stream().map(this::mapSubscription).toList());
|
||||
return resp;
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/reader/api/0/subscription/edit")
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public Response subscriptionEdit(
|
||||
@FormParam("ac") String action,
|
||||
@FormParam("s") String streamId,
|
||||
@FormParam("t") String title,
|
||||
@FormParam("a") String addLabel,
|
||||
@FormParam("r") String removeLabel,
|
||||
@FormParam("T") String token) {
|
||||
User user = currentUser();
|
||||
if (!validToken(user, token)) {
|
||||
return Response.status(Status.UNAUTHORIZED).build();
|
||||
}
|
||||
if (StringUtils.isBlank(action) || StringUtils.isBlank(streamId)) {
|
||||
return Response.status(Status.BAD_REQUEST).build();
|
||||
}
|
||||
|
||||
if ("subscribe".equals(action)) {
|
||||
String url =
|
||||
streamId.startsWith(GoogleReaderStreamId.FEED_PREFIX)
|
||||
? streamId.substring(GoogleReaderStreamId.FEED_PREFIX.length())
|
||||
: streamId;
|
||||
FeedCategory category = resolveLabelCategory(user, addLabel, true);
|
||||
String subTitle = StringUtils.defaultIfBlank(title, url);
|
||||
feedSubscriptionService.subscribe(user, url, subTitle, category, 0);
|
||||
} else {
|
||||
GoogleReaderStreamId parsed = GoogleReaderStreamId.parse(streamId);
|
||||
Long subId = parsed.feedSubscriptionId();
|
||||
if (subId == null) {
|
||||
return Response.status(Status.BAD_REQUEST).build();
|
||||
}
|
||||
|
||||
if ("unsubscribe".equals(action)) {
|
||||
feedSubscriptionService.unsubscribe(user, subId);
|
||||
} else if ("edit".equals(action)) {
|
||||
FeedSubscription sub = feedSubscriptionDAO.findById(user, subId);
|
||||
if (sub != null) {
|
||||
if (StringUtils.isNotBlank(title)) {
|
||||
sub.setTitle(title);
|
||||
}
|
||||
if (StringUtils.isNotBlank(addLabel)) {
|
||||
sub.setCategory(resolveLabelCategory(user, addLabel, true));
|
||||
}
|
||||
if (StringUtils.isNotBlank(removeLabel)) {
|
||||
sub.setCategory(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Response.ok("OK").build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/tag/list")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.TagList tagList() {
|
||||
User user = currentUser();
|
||||
List<GoogleReaderModel.Tag> tags = new ArrayList<>();
|
||||
feedCategoryDAO
|
||||
.findAll(user)
|
||||
.forEach(
|
||||
c ->
|
||||
tags.add(
|
||||
new GoogleReaderModel.Tag(
|
||||
GoogleReaderStreamId.label(c.getName()))));
|
||||
tags.add(new GoogleReaderModel.Tag(GoogleReaderStreamId.STARRED));
|
||||
|
||||
GoogleReaderModel.TagList resp = new GoogleReaderModel.TagList();
|
||||
resp.setTags(tags);
|
||||
return resp;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/unread-count")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.UnreadCounts unreadCounts() {
|
||||
User user = currentUser();
|
||||
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
|
||||
Map<Long, UnreadCount> unreadCounts = feedSubscriptionService.getUnreadCount(user);
|
||||
|
||||
List<GoogleReaderModel.UnreadCountEntry> entries = new ArrayList<>();
|
||||
Map<Long, Long> unreadByCategory = new HashMap<>();
|
||||
long totalUnread = 0;
|
||||
|
||||
for (FeedSubscription sub : subs) {
|
||||
UnreadCount uc = unreadCounts.get(sub.getId());
|
||||
|
||||
GoogleReaderModel.UnreadCountEntry entry = new GoogleReaderModel.UnreadCountEntry();
|
||||
entry.setId(GoogleReaderStreamId.feed(sub.getId()));
|
||||
entry.setCount(uc.getUnreadCount());
|
||||
entry.setNewestItemTimestampUsec(toUsec(uc.getNewestItemTime()));
|
||||
entries.add(entry);
|
||||
|
||||
totalUnread += uc.getUnreadCount();
|
||||
|
||||
long categoryId = sub.getCategory() == null ? 0 : sub.getCategory().getId();
|
||||
unreadByCategory.merge(categoryId, uc.getUnreadCount(), Long::sum);
|
||||
}
|
||||
|
||||
Map<Long, FeedCategory> categoriesById =
|
||||
feedCategoryDAO.findAll(user).stream()
|
||||
.collect(Collectors.toMap(FeedCategory::getId, c -> c));
|
||||
unreadByCategory.forEach(
|
||||
(categoryId, count) -> {
|
||||
if (categoryId == 0) {
|
||||
return;
|
||||
}
|
||||
FeedCategory category = categoriesById.get(categoryId);
|
||||
if (category == null) {
|
||||
return;
|
||||
}
|
||||
GoogleReaderModel.UnreadCountEntry entry =
|
||||
new GoogleReaderModel.UnreadCountEntry();
|
||||
entry.setId(GoogleReaderStreamId.label(category.getName()));
|
||||
entry.setCount(count);
|
||||
entries.add(entry);
|
||||
});
|
||||
|
||||
GoogleReaderModel.UnreadCountEntry readingList = new GoogleReaderModel.UnreadCountEntry();
|
||||
readingList.setId(GoogleReaderStreamId.READING_LIST);
|
||||
readingList.setCount(totalUnread);
|
||||
entries.add(readingList);
|
||||
|
||||
GoogleReaderModel.UnreadCounts resp = new GoogleReaderModel.UnreadCounts();
|
||||
resp.setMax(MAX_UNREAD_COUNT);
|
||||
resp.setUnreadCounts(entries);
|
||||
return resp;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/stream/contents/{streamId : .+}")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.StreamContents streamContentsPath(
|
||||
@PathParam("streamId") String streamId,
|
||||
@QueryParam("n") Integer n,
|
||||
@QueryParam("c") String continuation,
|
||||
@QueryParam("r") String order,
|
||||
@QueryParam("xt") List<String> excludeTargets) {
|
||||
return buildStreamContents(streamId, n, continuation, order, excludeTargets);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/stream/contents")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.StreamContents streamContentsQuery(
|
||||
@QueryParam("s") String streamId,
|
||||
@QueryParam("n") Integer n,
|
||||
@QueryParam("c") String continuation,
|
||||
@QueryParam("r") String order,
|
||||
@QueryParam("xt") List<String> excludeTargets) {
|
||||
return buildStreamContents(streamId, n, continuation, order, excludeTargets);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/stream/items/ids")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.ItemRefs streamItemIds(
|
||||
@QueryParam("s") String streamId,
|
||||
@QueryParam("n") Integer n,
|
||||
@QueryParam("c") String continuation,
|
||||
@QueryParam("r") String order,
|
||||
@QueryParam("xt") List<String> excludeTargets) {
|
||||
User user = currentUser();
|
||||
GoogleReaderStreamId parsed = GoogleReaderStreamId.parse(streamId);
|
||||
int limit = clampLimit(n);
|
||||
ReadingOrder readingOrder = "o".equals(order) ? ReadingOrder.ASC : ReadingOrder.DESC;
|
||||
boolean unreadOnly = isUnreadOnly(excludeTargets);
|
||||
|
||||
PagedStatuses paged =
|
||||
fetchStatuses(user, parsed, limit, continuation, readingOrder, unreadOnly);
|
||||
|
||||
GoogleReaderModel.ItemRefs resp = new GoogleReaderModel.ItemRefs();
|
||||
resp.setItemRefs(
|
||||
paged.statuses().stream()
|
||||
.map(
|
||||
s ->
|
||||
new GoogleReaderModel.ItemRef(
|
||||
String.valueOf(s.getEntry().getId())))
|
||||
.toList());
|
||||
resp.setContinuation(paged.continuation());
|
||||
return resp;
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/reader/api/0/stream/items/contents")
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.StreamContents streamItemsContentsPost(
|
||||
@FormParam("i") List<String> ids) {
|
||||
return buildStreamItemsContents(ids);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/stream/items/contents")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.StreamContents streamItemsContentsGet(
|
||||
@QueryParam("i") List<String> ids) {
|
||||
return buildStreamItemsContents(ids);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/reader/api/0/edit-tag")
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public Response editTag(
|
||||
@FormParam("i") List<String> ids,
|
||||
@FormParam("a") List<String> add,
|
||||
@FormParam("r") List<String> remove,
|
||||
@FormParam("T") String token) {
|
||||
User user = currentUser();
|
||||
if (!validToken(user, token)) {
|
||||
return Response.status(Status.UNAUTHORIZED).build();
|
||||
}
|
||||
if (ids == null) {
|
||||
return Response.ok("OK").build();
|
||||
}
|
||||
|
||||
boolean markRead = containsTag(add, GoogleReaderStreamId.READ);
|
||||
boolean markUnread = containsTag(remove, GoogleReaderStreamId.READ);
|
||||
boolean star = containsTag(add, GoogleReaderStreamId.STARRED);
|
||||
boolean unstar = containsTag(remove, GoogleReaderStreamId.STARRED);
|
||||
|
||||
for (String rawId : ids) {
|
||||
Long entryId = parseItemId(rawId);
|
||||
if (entryId == null) {
|
||||
continue;
|
||||
}
|
||||
FeedEntry entry = feedEntryDAO.findById(entryId);
|
||||
if (entry == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (markRead) {
|
||||
feedEntryService.markEntry(user, entryId, true);
|
||||
}
|
||||
if (markUnread) {
|
||||
feedEntryService.markEntry(user, entryId, false);
|
||||
}
|
||||
if (star) {
|
||||
feedEntryService.starEntry(user, entryId, true);
|
||||
}
|
||||
if (unstar) {
|
||||
feedEntryService.starEntry(user, entryId, false);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.ok("OK").build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/reader/api/0/mark-all-as-read")
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public Response markAllAsRead(
|
||||
@FormParam("s") String streamId,
|
||||
@FormParam("ts") Long ts,
|
||||
@FormParam("T") String token) {
|
||||
User user = currentUser();
|
||||
if (!validToken(user, token)) {
|
||||
return Response.status(Status.UNAUTHORIZED).build();
|
||||
}
|
||||
|
||||
GoogleReaderStreamId parsed = GoogleReaderStreamId.parse(streamId);
|
||||
Instant olderThan = (ts != null && ts > 0) ? Instant.ofEpochSecond(ts / 1_000_000) : null;
|
||||
|
||||
switch (parsed.type()) {
|
||||
case STARRED -> feedEntryService.markStarredEntries(user, olderThan, null);
|
||||
case FEED -> {
|
||||
Long subId = parsed.feedSubscriptionId();
|
||||
FeedSubscription sub =
|
||||
subId == null ? null : feedSubscriptionDAO.findById(user, subId);
|
||||
if (sub != null) {
|
||||
feedEntryService.markSubscriptionEntries(
|
||||
user, List.of(sub), olderThan, null, null);
|
||||
}
|
||||
}
|
||||
case LABEL -> {
|
||||
FeedCategory category = findCategoryByName(user, parsed.value());
|
||||
if (category != null) {
|
||||
List<FeedCategory> categories =
|
||||
feedCategoryDAO.findAllChildrenCategories(user, category);
|
||||
List<FeedSubscription> subs =
|
||||
feedSubscriptionDAO.findByCategories(user, categories);
|
||||
feedEntryService.markSubscriptionEntries(user, subs, olderThan, null, null);
|
||||
}
|
||||
}
|
||||
default ->
|
||||
feedEntryService.markSubscriptionEntries(
|
||||
user, feedSubscriptionDAO.findAll(user), olderThan, null, null);
|
||||
}
|
||||
|
||||
return Response.ok("OK").build();
|
||||
}
|
||||
|
||||
private GoogleReaderModel.StreamContents buildStreamContents(
|
||||
String rawStreamId,
|
||||
Integer n,
|
||||
String continuation,
|
||||
String order,
|
||||
List<String> excludeTargets) {
|
||||
User user = currentUser();
|
||||
GoogleReaderStreamId parsed = GoogleReaderStreamId.parse(rawStreamId);
|
||||
int limit = clampLimit(n);
|
||||
ReadingOrder readingOrder = "o".equals(order) ? ReadingOrder.ASC : ReadingOrder.DESC;
|
||||
boolean unreadOnly = isUnreadOnly(excludeTargets);
|
||||
|
||||
PagedStatuses paged =
|
||||
fetchStatuses(user, parsed, limit, continuation, readingOrder, unreadOnly);
|
||||
|
||||
GoogleReaderModel.StreamContents resp = new GoogleReaderModel.StreamContents();
|
||||
resp.setId(streamIdString(parsed));
|
||||
resp.setTitle(streamTitle(user, parsed));
|
||||
resp.setUpdated(Instant.now().getEpochSecond());
|
||||
resp.setItems(paged.statuses().stream().map(this::mapItem).toList());
|
||||
resp.setContinuation(paged.continuation());
|
||||
return resp;
|
||||
}
|
||||
|
||||
private GoogleReaderModel.StreamContents buildStreamItemsContents(List<String> ids) {
|
||||
User user = currentUser();
|
||||
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
|
||||
Map<Long, FeedSubscription> subsByFeedId =
|
||||
subs.stream()
|
||||
.collect(Collectors.toMap(s -> s.getFeed().getId(), s -> s, (a, b) -> a));
|
||||
|
||||
List<GoogleReaderModel.StreamItem> items = new ArrayList<>();
|
||||
if (ids != null) {
|
||||
for (String rawId : ids) {
|
||||
Long entryId = parseItemId(rawId);
|
||||
if (entryId == null) {
|
||||
continue;
|
||||
}
|
||||
FeedEntry entry = feedEntryDAO.findById(entryId);
|
||||
if (entry == null) {
|
||||
continue;
|
||||
}
|
||||
FeedSubscription sub = subsByFeedId.get(entry.getFeed().getId());
|
||||
if (sub == null) {
|
||||
continue;
|
||||
}
|
||||
FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry);
|
||||
items.add(mapItem(status));
|
||||
}
|
||||
}
|
||||
|
||||
GoogleReaderModel.StreamContents resp = new GoogleReaderModel.StreamContents();
|
||||
resp.setId(GoogleReaderStreamId.READING_LIST);
|
||||
resp.setUpdated(Instant.now().getEpochSecond());
|
||||
resp.setItems(items);
|
||||
return resp;
|
||||
}
|
||||
|
||||
private PagedStatuses fetchStatuses(
|
||||
User user,
|
||||
GoogleReaderStreamId streamId,
|
||||
int limit,
|
||||
String continuation,
|
||||
ReadingOrder order,
|
||||
boolean unreadOnly) {
|
||||
Long minEntryId = null;
|
||||
Long maxEntryId = null;
|
||||
int offset = 0;
|
||||
|
||||
if (streamId.type() == GoogleReaderStreamId.Type.STARRED) {
|
||||
offset = parseOffset(continuation);
|
||||
} else if (StringUtils.isNotBlank(continuation)) {
|
||||
try {
|
||||
long cursor = Long.parseLong(continuation);
|
||||
if (order == ReadingOrder.ASC) {
|
||||
minEntryId = cursor;
|
||||
} else {
|
||||
maxEntryId = cursor;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// invalid continuation token, ignore and start from the beginning
|
||||
}
|
||||
}
|
||||
|
||||
List<FeedEntryStatus> statuses;
|
||||
int fetchedCount;
|
||||
switch (streamId.type()) {
|
||||
case STARRED -> {
|
||||
List<FeedEntryStatus> starred =
|
||||
feedEntryStatusDAO.findStarred(
|
||||
user, null, null, offset, limit, order, true);
|
||||
fetchedCount = starred.size();
|
||||
statuses =
|
||||
unreadOnly ? starred.stream().filter(s -> !s.isRead()).toList() : starred;
|
||||
}
|
||||
case FEED -> {
|
||||
Long subId = streamId.feedSubscriptionId();
|
||||
FeedSubscription sub =
|
||||
subId == null ? null : feedSubscriptionDAO.findById(user, subId);
|
||||
List<FeedSubscription> subs = sub == null ? List.of() : List.of(sub);
|
||||
statuses =
|
||||
feedEntryStatusDAO.findBySubscriptions(
|
||||
user,
|
||||
subs,
|
||||
unreadOnly,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
limit,
|
||||
order,
|
||||
true,
|
||||
null,
|
||||
minEntryId,
|
||||
maxEntryId);
|
||||
fetchedCount = statuses.size();
|
||||
}
|
||||
case LABEL -> {
|
||||
FeedCategory category = findCategoryByName(user, streamId.value());
|
||||
List<FeedSubscription> subs =
|
||||
category == null
|
||||
? List.of()
|
||||
: feedSubscriptionDAO.findByCategories(
|
||||
user,
|
||||
feedCategoryDAO.findAllChildrenCategories(user, category));
|
||||
statuses =
|
||||
feedEntryStatusDAO.findBySubscriptions(
|
||||
user,
|
||||
subs,
|
||||
unreadOnly,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
limit,
|
||||
order,
|
||||
true,
|
||||
null,
|
||||
minEntryId,
|
||||
maxEntryId);
|
||||
fetchedCount = statuses.size();
|
||||
}
|
||||
default -> {
|
||||
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
|
||||
statuses =
|
||||
feedEntryStatusDAO.findBySubscriptions(
|
||||
user,
|
||||
subs,
|
||||
unreadOnly,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
limit,
|
||||
order,
|
||||
true,
|
||||
null,
|
||||
minEntryId,
|
||||
maxEntryId);
|
||||
fetchedCount = statuses.size();
|
||||
}
|
||||
}
|
||||
|
||||
String nextContinuation = null;
|
||||
if (fetchedCount >= limit && fetchedCount > 0) {
|
||||
if (streamId.type() == GoogleReaderStreamId.Type.STARRED) {
|
||||
nextContinuation = String.valueOf(offset + limit);
|
||||
} else if (!statuses.isEmpty()) {
|
||||
nextContinuation = String.valueOf(statuses.getLast().getEntry().getId());
|
||||
}
|
||||
}
|
||||
|
||||
return new PagedStatuses(statuses, nextContinuation);
|
||||
}
|
||||
|
||||
private GoogleReaderModel.StreamItem mapItem(FeedEntryStatus status) {
|
||||
FeedEntry entry = status.getEntry();
|
||||
FeedSubscription sub = status.getSubscription();
|
||||
|
||||
GoogleReaderModel.StreamItem item = new GoogleReaderModel.StreamItem();
|
||||
item.setId(formatItemId(entry.getId()));
|
||||
|
||||
Instant published = entry.getPublished() != null ? entry.getPublished() : Instant.EPOCH;
|
||||
item.setPublished(published.getEpochSecond());
|
||||
item.setUpdated(published.getEpochSecond());
|
||||
item.setCrawlTimeMsec(String.valueOf(entry.getInserted().toEpochMilli()));
|
||||
item.setTimestampUsec(String.valueOf(published.toEpochMilli() * 1000));
|
||||
item.setTitle(entry.getContent().getTitle());
|
||||
item.setAuthor(entry.getContent().getAuthor());
|
||||
|
||||
if (StringUtils.isNotBlank(entry.getUrl())) {
|
||||
item.setCanonical(List.of(new GoogleReaderModel.HrefEntry(entry.getUrl(), null)));
|
||||
item.setAlternate(
|
||||
List.of(new GoogleReaderModel.HrefEntry(entry.getUrl(), "text/html")));
|
||||
}
|
||||
item.setSummary(
|
||||
new GoogleReaderModel.Content(
|
||||
Optional.ofNullable(entry.getContent().getContent()).orElse(""),
|
||||
entry.getContent().getDirection() == FeedEntryContent.Direction.RTL
|
||||
? "rtl"
|
||||
: "ltr"));
|
||||
|
||||
List<String> categories = new ArrayList<>();
|
||||
categories.add(GoogleReaderStreamId.READING_LIST);
|
||||
if (status.isRead()) {
|
||||
categories.add(GoogleReaderStreamId.READ);
|
||||
}
|
||||
if (status.isStarred()) {
|
||||
categories.add(GoogleReaderStreamId.STARRED);
|
||||
}
|
||||
if (sub != null && sub.getCategory() != null) {
|
||||
categories.add(GoogleReaderStreamId.label(sub.getCategory().getName()));
|
||||
}
|
||||
item.setCategories(categories);
|
||||
|
||||
if (sub != null) {
|
||||
GoogleReaderModel.Origin origin = new GoogleReaderModel.Origin();
|
||||
origin.setStreamId(GoogleReaderStreamId.feed(sub.getId()));
|
||||
origin.setTitle(sub.getTitle());
|
||||
origin.setHtmlUrl(sub.getFeed().getLink());
|
||||
item.setOrigin(origin);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private GoogleReaderModel.Subscription mapSubscription(FeedSubscription sub) {
|
||||
GoogleReaderModel.Subscription s = new GoogleReaderModel.Subscription();
|
||||
s.setId(GoogleReaderStreamId.feed(sub.getId()));
|
||||
s.setTitle(sub.getTitle());
|
||||
s.setUrl(sub.getFeed().getUrl());
|
||||
s.setHtmlUrl(sub.getFeed().getLink());
|
||||
s.setIconUrl(FeedUtils.getFaviconUrl(sub));
|
||||
if (sub.getCategory() != null) {
|
||||
s.getCategories()
|
||||
.add(
|
||||
new GoogleReaderModel.Category(
|
||||
GoogleReaderStreamId.label(sub.getCategory().getName()),
|
||||
sub.getCategory().getName()));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private FeedCategory resolveLabelCategory(
|
||||
User user, String labelParam, boolean createIfMissing) {
|
||||
if (StringUtils.isBlank(labelParam)) {
|
||||
return null;
|
||||
}
|
||||
GoogleReaderStreamId parsed = GoogleReaderStreamId.parse(labelParam);
|
||||
if (parsed.type() != GoogleReaderStreamId.Type.LABEL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String name = parsed.value();
|
||||
FeedCategory category = findCategoryByName(user, name);
|
||||
if (category == null && createIfMissing) {
|
||||
category = new FeedCategory();
|
||||
category.setUser(user);
|
||||
category.setName(FeedUtils.truncate(name, 128));
|
||||
feedCategoryDAO.persist(category);
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
private FeedCategory findCategoryByName(User user, String name) {
|
||||
return feedCategoryDAO.findAll(user).stream()
|
||||
.filter(c -> c.getName().equals(name))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String streamIdString(GoogleReaderStreamId parsed) {
|
||||
return switch (parsed.type()) {
|
||||
case FEED -> GoogleReaderStreamId.FEED_PREFIX + parsed.value();
|
||||
case LABEL -> GoogleReaderStreamId.label(parsed.value());
|
||||
case STARRED -> GoogleReaderStreamId.STARRED;
|
||||
default -> GoogleReaderStreamId.READING_LIST;
|
||||
};
|
||||
}
|
||||
|
||||
private String streamTitle(User user, GoogleReaderStreamId parsed) {
|
||||
return switch (parsed.type()) {
|
||||
case FEED -> {
|
||||
Long subId = parsed.feedSubscriptionId();
|
||||
FeedSubscription sub =
|
||||
subId == null ? null : feedSubscriptionDAO.findById(user, subId);
|
||||
yield sub == null ? null : sub.getTitle();
|
||||
}
|
||||
case LABEL -> parsed.value();
|
||||
case STARRED -> "Starred items";
|
||||
default -> "reading list";
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isUnreadOnly(List<String> excludeTargets) {
|
||||
return excludeTargets != null
|
||||
&& excludeTargets.stream().anyMatch(t -> t.endsWith("/state/com.google/read"));
|
||||
}
|
||||
|
||||
private boolean containsTag(List<String> tags, String suffix) {
|
||||
return tags != null && tags.stream().anyMatch(t -> t.endsWith(suffix));
|
||||
}
|
||||
|
||||
private String formatItemId(long id) {
|
||||
// Google Reader's canonical long-form item id, e.g.
|
||||
// "tag:google.com,2005:reader/item/00000000148b9369", where the suffix is the item's
|
||||
// numeric id, represented as a zero-padded, 16-digit hex string
|
||||
return "tag:google.com,2005:reader/item/" + String.format("%016x", id);
|
||||
}
|
||||
|
||||
private Long parseItemId(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
int idx = raw.lastIndexOf('/');
|
||||
if (raw.startsWith("tag:google.com") && idx != -1) {
|
||||
try {
|
||||
return Long.parseUnsignedLong(raw.substring(idx + 1), 16);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(raw);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private int parseOffset(String continuation) {
|
||||
if (StringUtils.isBlank(continuation)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(continuation);
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private int clampLimit(Integer n) {
|
||||
if (n == null || n <= 0) {
|
||||
return DEFAULT_ITEM_COUNT;
|
||||
}
|
||||
return Math.min(n, MAX_ITEM_COUNT);
|
||||
}
|
||||
|
||||
private String toUsec(Instant instant) {
|
||||
return instant == null ? "0" : String.valueOf(instant.toEpochMilli() * 1000);
|
||||
}
|
||||
|
||||
private boolean validToken(User user, String token) {
|
||||
// the edit token is a CSRF protection inherited from Google Reader's cookie-based
|
||||
// authentication model. Since this API is authenticated via an api key header instead of
|
||||
// cookies, that risk does not apply here. Some real-world clients (e.g. RSSGuard, unless
|
||||
// configured as "Reedah"/"Miniflux") never call the "token" endpoint and always submit a
|
||||
// blank token, so, blank tokens are accepted.
|
||||
return StringUtils.isBlank(token) || computeEditToken(user).equals(token);
|
||||
}
|
||||
|
||||
private String computeEditToken(User user) {
|
||||
return Digests.md5Hex(user.getApiKey() + ":" + user.getId());
|
||||
}
|
||||
|
||||
private User currentUser() {
|
||||
return authenticationContext.getCurrentUser();
|
||||
}
|
||||
|
||||
private record PagedStatuses(List<FeedEntryStatus> statuses, String continuation) {}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.commafeed.frontend.resource.googlereader;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
public record GoogleReaderStreamId(Type type, String value) {
|
||||
|
||||
public enum Type {
|
||||
ALL,
|
||||
FEED,
|
||||
LABEL,
|
||||
STARRED
|
||||
}
|
||||
|
||||
public static final String READING_LIST = "user/-/state/com.google/reading-list";
|
||||
public static final String STARRED = "user/-/state/com.google/starred";
|
||||
public static final String READ = "user/-/state/com.google/read";
|
||||
public static final String LABEL_PREFIX = "user/-/label/";
|
||||
public static final String FEED_PREFIX = "feed/";
|
||||
|
||||
public static final GoogleReaderStreamId ALL_STREAM = new GoogleReaderStreamId(Type.ALL, null);
|
||||
public static final GoogleReaderStreamId STARRED_STREAM =
|
||||
new GoogleReaderStreamId(Type.STARRED, null);
|
||||
|
||||
public static GoogleReaderStreamId parse(String raw) {
|
||||
if (StringUtils.isBlank(raw) || "reading-list".equals(raw) || READING_LIST.equals(raw)) {
|
||||
return ALL_STREAM;
|
||||
}
|
||||
if (STARRED.equals(raw)) {
|
||||
return STARRED_STREAM;
|
||||
}
|
||||
if (raw.startsWith(LABEL_PREFIX)) {
|
||||
return new GoogleReaderStreamId(Type.LABEL, raw.substring(LABEL_PREFIX.length()));
|
||||
}
|
||||
if (raw.startsWith(FEED_PREFIX)) {
|
||||
return new GoogleReaderStreamId(Type.FEED, raw.substring(FEED_PREFIX.length()));
|
||||
}
|
||||
// unrecognized stream id, default to the whole reading list
|
||||
return ALL_STREAM;
|
||||
}
|
||||
|
||||
public static String feed(long subscriptionId) {
|
||||
return FEED_PREFIX + subscriptionId;
|
||||
}
|
||||
|
||||
public static String label(String categoryName) {
|
||||
return LABEL_PREFIX + categoryName;
|
||||
}
|
||||
|
||||
/** extracts the subscription id out of a "feed/<id>" stream id, or null if invalid */
|
||||
public Long feedSubscriptionId() {
|
||||
if (type != Type.FEED) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import com.commafeed.backend.dao.UnitOfWork;
|
||||
import com.commafeed.backend.model.User;
|
||||
import com.commafeed.backend.model.UserRole.Role;
|
||||
import com.commafeed.backend.service.UserService;
|
||||
import com.commafeed.frontend.exception.CommaFeedApplicationException;
|
||||
import com.commafeed.frontend.exception.CommaFeedExceptionType;
|
||||
|
||||
import io.quarkus.security.AuthenticationFailedException;
|
||||
import io.quarkus.security.identity.AuthenticationRequestContext;
|
||||
import io.quarkus.security.identity.IdentityProvider;
|
||||
import io.quarkus.security.identity.SecurityIdentity;
|
||||
@@ -48,7 +49,8 @@ public class DatabaseUsernamePasswordIdentityProvider
|
||||
new String(
|
||||
request.getPassword().getPassword())));
|
||||
if (user.isEmpty()) {
|
||||
throw new AuthenticationFailedException("wrong username or password");
|
||||
throw new CommaFeedApplicationException(
|
||||
CommaFeedExceptionType.WRONG_USERNAME_OR_PASSWORD);
|
||||
}
|
||||
|
||||
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user.get()));
|
||||
|
||||
@@ -16,7 +16,7 @@ import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
@Singleton
|
||||
public class ApiKeyAuthenticationMecanism implements HttpAuthenticationMechanism {
|
||||
public class ApiKeyAuthenticationMechanism implements HttpAuthenticationMechanism {
|
||||
|
||||
@Override
|
||||
public Uni<SecurityIdentity> authenticate(
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.commafeed.security.mechanism;
|
||||
|
||||
import io.quarkus.security.credential.TokenCredential;
|
||||
import io.quarkus.security.identity.IdentityProviderManager;
|
||||
import io.quarkus.security.identity.SecurityIdentity;
|
||||
import io.quarkus.security.identity.request.AuthenticationRequest;
|
||||
import io.quarkus.security.identity.request.TokenAuthenticationRequest;
|
||||
import io.quarkus.vertx.http.runtime.security.ChallengeData;
|
||||
import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism;
|
||||
import io.smallrye.mutiny.Uni;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Authenticates requests to the Google Reader compatible API, using the "Authorization: GoogleLogin
|
||||
* auth=<token>" header set by Google Reader API clients.
|
||||
*
|
||||
* <p>This mechanism only applies to requests under {@link #PATH_PREFIX}, so that a stray
|
||||
* "Authorization: GoogleLogin ..." header sent to any other endpoint is not mistakenly accepted as
|
||||
* an api key authentication.
|
||||
*/
|
||||
@Singleton
|
||||
public class GoogleReaderAuthenticationMechanism implements HttpAuthenticationMechanism {
|
||||
|
||||
public static final String PATH_PREFIX = "/rest/googlereader";
|
||||
|
||||
private static final String AUTH_HEADER = "Authorization";
|
||||
private static final String AUTH_PREFIX = "GoogleLogin auth=";
|
||||
|
||||
@Override
|
||||
public Uni<SecurityIdentity> authenticate(
|
||||
RoutingContext context, IdentityProviderManager identityProviderManager) {
|
||||
if (!context.normalizedPath().startsWith(PATH_PREFIX)) {
|
||||
return Uni.createFrom().optional(Optional.empty());
|
||||
}
|
||||
|
||||
String header = context.request().getHeader(AUTH_HEADER);
|
||||
if (StringUtils.isBlank(header) || !header.startsWith(AUTH_PREFIX)) {
|
||||
return Uni.createFrom().optional(Optional.empty());
|
||||
}
|
||||
|
||||
String token = header.substring(AUTH_PREFIX.length()).trim();
|
||||
int slashIndex = token.lastIndexOf('/');
|
||||
String apiKey = slashIndex == -1 ? token : token.substring(slashIndex + 1);
|
||||
if (StringUtils.isBlank(apiKey)) {
|
||||
return Uni.createFrom().optional(Optional.empty());
|
||||
}
|
||||
|
||||
TokenCredential credential = new TokenCredential(apiKey, "apiKey");
|
||||
TokenAuthenticationRequest request = new TokenAuthenticationRequest(credential);
|
||||
return identityProviderManager.authenticate(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uni<ChallengeData> getChallenge(RoutingContext context) {
|
||||
return Uni.createFrom().optional(Optional.empty());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Class<? extends AuthenticationRequest>> getCredentialTypes() {
|
||||
return Set.of(TokenAuthenticationRequest.class);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ quarkus.http.port=8082
|
||||
quarkus.http.test-port=8085
|
||||
quarkus.http.enable-compression=true
|
||||
quarkus.http.enable-decompression=true
|
||||
|
||||
# http cache
|
||||
quarkus.http.static-resources.max-age=P365d
|
||||
## make sure the webapp is always up to date
|
||||
@@ -15,7 +14,6 @@ quarkus.http.filter.openapi.matches=/openapi
|
||||
## make sure /next always redirects to the next unread entry
|
||||
quarkus.http.filter.next.header."Cache-Control"=no-cache
|
||||
quarkus.http.filter.next.matches=/next
|
||||
|
||||
# security
|
||||
quarkus.http.auth.basic=true
|
||||
quarkus.http.auth.form.enabled=true
|
||||
@@ -25,51 +23,42 @@ quarkus.http.auth.form.cookie-max-age=P30d
|
||||
quarkus.http.auth.form.landing-page=
|
||||
quarkus.http.auth.form.login-page=
|
||||
quarkus.http.auth.form.error-page=
|
||||
|
||||
# websocket
|
||||
quarkus.websocket.dispatch-to-worker=true
|
||||
|
||||
# database
|
||||
quarkus.liquibase.change-log=migrations.xml
|
||||
quarkus.liquibase.migrate-at-start=true
|
||||
|
||||
# openapi
|
||||
quarkus.smallrye-openapi.path=/openapi
|
||||
quarkus.swagger-ui.path=/api-documentation
|
||||
quarkus.swagger-ui.always-include=true
|
||||
|
||||
# shutdown
|
||||
quarkus.shutdown.timeout=5s
|
||||
|
||||
# native
|
||||
quarkus.native.march=compatibility
|
||||
quarkus.native.add-all-charsets=true
|
||||
# fix for https://github.com/Athou/commafeed/issues/1795
|
||||
quarkus.native.additional-build-args=-H:PageSize=65536
|
||||
|
||||
|
||||
# dev profile overrides
|
||||
%dev.quarkus.http.port=8083
|
||||
%dev.quarkus.http.auth.session.encryption-key=123456789012345678901234567890
|
||||
%dev.quarkus.log.category."com.commafeed".level=DEBUG
|
||||
# %dev.quarkus.hibernate-orm.log.sql=true
|
||||
%dev.commafeed.users.create-demo-account=true
|
||||
|
||||
|
||||
# test profile overrides
|
||||
%test.quarkus.log.category."org.mockserver".level=WARN
|
||||
%test.quarkus.log.category."liquibase".level=WARN
|
||||
%test.quarkus.log.level=WARN
|
||||
%test.quarkus.log.category."org.mockserver".level=ERROR
|
||||
%test.commafeed.shutdown-timeout=100ms
|
||||
%test.commafeed.users.create-demo-account=true
|
||||
%test.commafeed.users.allow-registrations=true
|
||||
%test.commafeed.password-recovery-enabled=true
|
||||
%test.commafeed.password-recovery-public-base-url=https://commafeed.example.com
|
||||
%test.commafeed.http-client.cache.enabled=false
|
||||
%test.commafeed.http-client.block-local-addresses=false
|
||||
%test.commafeed.database.cleanup.entries-max-age=0
|
||||
%test.commafeed.feed-refresh.force-refresh-cooldown-duration=1m
|
||||
## GitHub actions runners are very slow, we need to increase this timeout to avoid intermittent test failures
|
||||
%test.commafeed.feed-refresh.filtering-expression-evaluation-timeout=5s
|
||||
|
||||
# prod profile overrides
|
||||
%prod.quarkus.datasource.jdbc.url=jdbc:h2:./data/db;DEFRAG_ALWAYS=TRUE
|
||||
%prod.quarkus.datasource.username=sa
|
||||
|
||||
@@ -11,6 +11,8 @@ import com.google.common.net.HttpHeaders;
|
||||
|
||||
import io.quarkus.runtime.configuration.MemorySize;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.hc.client5.http.ConnectTimeoutException;
|
||||
import org.apache.hc.core5.http.HttpStatus;
|
||||
@@ -29,12 +31,12 @@ import org.mockserver.model.Delay;
|
||||
import org.mockserver.model.HttpRequest;
|
||||
import org.mockserver.model.HttpResponse;
|
||||
import org.mockserver.model.MediaType;
|
||||
import org.mockserver.model.Protocol;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.NoRouteToHostException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
@@ -45,6 +47,7 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.zip.DeflaterOutputStream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
@Slf4j
|
||||
class HttpGetterTest {
|
||||
|
||||
private static final Instant NOW = Instant.now();
|
||||
@@ -237,7 +240,7 @@ class HttpGetterTest {
|
||||
.when(HttpRequest.request().withMethod("GET"))
|
||||
.respond(HttpResponse.response().withDelay(Delay.milliseconds(1000)));
|
||||
|
||||
Assertions.assertThrows(SocketTimeoutException.class, () -> getter.get(this.feedUrl));
|
||||
Assertions.assertThrows(IOException.class, () -> getter.get(this.feedUrl));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -416,7 +419,7 @@ class HttpGetterTest {
|
||||
@Nested
|
||||
class Compression {
|
||||
|
||||
private static final String ACCEPT_ENCODING = "gzip, deflate, br";
|
||||
private static final String ACCEPT_ENCODING = "deflate, gzip";
|
||||
|
||||
@Test
|
||||
void gzip() throws Exception {
|
||||
@@ -437,24 +440,30 @@ class HttpGetterTest {
|
||||
.when(HttpRequest.request().withMethod("GET"))
|
||||
.respond(
|
||||
req -> {
|
||||
String acceptEncodingHeader =
|
||||
req.getFirstHeader(HttpHeaders.ACCEPT_ENCODING);
|
||||
if (!ACCEPT_ENCODING.equals(acceptEncodingHeader)) {
|
||||
throw new Exception(
|
||||
"Wrong value in the Accept-Encoding header, should be '%s' but was '%s'"
|
||||
.formatted(
|
||||
ACCEPT_ENCODING, acceptEncodingHeader));
|
||||
}
|
||||
try {
|
||||
String acceptEncodingHeader =
|
||||
req.getFirstHeader(HttpHeaders.ACCEPT_ENCODING);
|
||||
if (!ACCEPT_ENCODING.equals(acceptEncodingHeader)) {
|
||||
throw new Exception(
|
||||
"Wrong value in the Accept-Encoding header, should be '%s' but was '%s'"
|
||||
.formatted(
|
||||
ACCEPT_ENCODING,
|
||||
acceptEncodingHeader));
|
||||
}
|
||||
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
try (OutputStream compressionOutputStream =
|
||||
compressionFunction.apply(output)) {
|
||||
compressionOutputStream.write(body.getBytes());
|
||||
}
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
try (OutputStream compressionOutputStream =
|
||||
compressionFunction.apply(output)) {
|
||||
compressionOutputStream.write(body.getBytes());
|
||||
}
|
||||
|
||||
return HttpResponse.response()
|
||||
.withBody(output.toByteArray())
|
||||
.withHeader(HttpHeaders.CONTENT_ENCODING, encoding);
|
||||
return HttpResponse.response()
|
||||
.withBody(output.toByteArray())
|
||||
.withHeader(HttpHeaders.CONTENT_ENCODING, encoding);
|
||||
} catch (Exception e) {
|
||||
log.error("Error in compression callback", e);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
HttpResult result = getter.get(HttpGetterTest.this.feedUrl);
|
||||
@@ -547,5 +556,73 @@ class HttpGetterTest {
|
||||
Assertions.assertThrows(
|
||||
UnknownHostException.class, () -> getter.get("http://[fd00:dead:beef::50]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nat64() {
|
||||
// 64:ff9b::/96 embeds the IPv4 address in the lower 32 bits (RFC 6052)
|
||||
Assertions.assertThrows(
|
||||
UnknownHostException.class,
|
||||
() -> getter.get("http://[64:ff9b::a9fe:a9fe]")); // 169.254.169.254
|
||||
Assertions.assertThrows(
|
||||
UnknownHostException.class,
|
||||
() -> getter.get("http://[64:ff9b::a00:1]")); // 10.0.0.1
|
||||
}
|
||||
|
||||
@Test
|
||||
void sixToFour() {
|
||||
// 2002::/16 embeds the IPv4 address in bits 16-47 (RFC 3056)
|
||||
Assertions.assertThrows(
|
||||
UnknownHostException.class,
|
||||
() -> getter.get("http://[2002:a9fe:a9fe::]")); // 169.254.169.254
|
||||
Assertions.assertThrows(
|
||||
UnknownHostException.class,
|
||||
() -> getter.get("http://[2002:a00:1::]")); // 10.0.0.1
|
||||
}
|
||||
|
||||
@Test
|
||||
void teredo() {
|
||||
// 2001:0000::/32 embeds the obfuscated (bitwise NOT) IPv4 address in the lower 32
|
||||
// bits (RFC 4380)
|
||||
Assertions.assertThrows(
|
||||
UnknownHostException.class,
|
||||
() -> getter.get("http://[2001:0:4136:e378:8000:63bf:f5ff:fffe]")); // 10.0.0.1
|
||||
}
|
||||
|
||||
@Test
|
||||
void ipv4Compatible() {
|
||||
// deprecated ::/96 IPv4-compatible addresses embed the IPv4 address in the lower 32
|
||||
// bits
|
||||
Assertions.assertThrows(
|
||||
UnknownHostException.class,
|
||||
() -> getter.get("http://[::a9fe:a9fe]")); // 169.254.169.254
|
||||
Assertions.assertThrows(
|
||||
UnknownHostException.class, () -> getter.get("http://[::a00:1]")); // 10.0.0.1
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Protocols {
|
||||
@Test
|
||||
void usesHttp2OverTls() throws Exception {
|
||||
mockServerClient
|
||||
.when(HttpRequest.request().withMethod("GET").withProtocol(Protocol.HTTP_2))
|
||||
.respond(HttpResponse.response().withBody("h2"));
|
||||
mockServerClient
|
||||
.when(HttpRequest.request().withMethod("GET").withProtocol(Protocol.HTTP_1_1))
|
||||
.respond(HttpResponse.response().withBody("h1"));
|
||||
|
||||
HttpResult result = getter.get("https://localhost:" + mockServerClient.getPort());
|
||||
Assertions.assertEquals("h2", new String(result.content()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToHttp1OverCleartext() throws Exception {
|
||||
mockServerClient
|
||||
.when(HttpRequest.request().withMethod("GET").withProtocol(Protocol.HTTP_1_1))
|
||||
.respond(HttpResponse.response().withBody("h1"));
|
||||
|
||||
HttpResult result = getter.get("http://localhost:" + mockServerClient.getPort());
|
||||
Assertions.assertEquals("h1", new String(result.content()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class FeedEntryContentCleaningServiceTest {
|
||||
<p>
|
||||
Some text
|
||||
<img width="965" height="320" src="https://localhost/an-image.png" class="attachment-post-thumbnail size-post-thumbnail wp-post-image" alt="alt-desc" decoding="async" sizes="(max-width: 965px) 100vw, 965px" style="width: 100%; opacity: 0">
|
||||
<iframe src="url" style="width: 100%; opacity: 0"></iframe>
|
||||
<iframe src="https://localhost/an-iframe" style="width: 100%; opacity: 0"></iframe>
|
||||
<forbidden-element>aaa</forbidden-element>
|
||||
""";
|
||||
String result = feedEntryContentCleaningService.clean(content, "baseUri", false);
|
||||
@@ -25,7 +25,7 @@ class FeedEntryContentCleaningServiceTest {
|
||||
<p>
|
||||
Some text
|
||||
<img width="965" height="320" src="https://localhost/an-image.png" alt="alt-desc" style="width:100%;">
|
||||
<iframe src="url" style="width:100%;"></iframe>
|
||||
<iframe src="https://localhost/an-iframe" style="width:100%;"></iframe>
|
||||
aaa
|
||||
</p>
|
||||
"""
|
||||
|
||||
@@ -36,7 +36,7 @@ class AuthentificationIT {
|
||||
page.navigate(getLoginPageUrl());
|
||||
PlaywrightTestUtils.login(page, TestConstants.ADMIN_USERNAME, "wrong_password");
|
||||
PlaywrightAssertions.assertThat(page.getByRole(AriaRole.ALERT))
|
||||
.containsText("wrong username or password");
|
||||
.containsText("Wrong username or password");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.commafeed.integration;
|
||||
|
||||
import com.commafeed.ExceptionMappers.CommaFeedApplicationError;
|
||||
import com.commafeed.ExceptionMappers.UnauthorizedResponse;
|
||||
import com.commafeed.TestConstants;
|
||||
import com.commafeed.frontend.exception.CommaFeedExceptionType;
|
||||
import com.commafeed.frontend.model.Entries;
|
||||
import com.commafeed.frontend.model.UserModel;
|
||||
import com.commafeed.frontend.model.request.MarkRequest;
|
||||
@@ -57,6 +59,27 @@ class SecurityIT extends BaseIT {
|
||||
.statusCode(HttpStatus.SC_OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void formLoginWrongPassword() {
|
||||
CommaFeedApplicationError error =
|
||||
RestAssured.given()
|
||||
.auth()
|
||||
.none()
|
||||
.formParams(
|
||||
"j_username",
|
||||
TestConstants.ADMIN_USERNAME,
|
||||
"j_password",
|
||||
"wrong-password")
|
||||
.post("j_security_check")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_UNAUTHORIZED)
|
||||
.extract()
|
||||
.as(CommaFeedApplicationError.class);
|
||||
|
||||
Assertions.assertEquals(CommaFeedExceptionType.WRONG_USERNAME_OR_PASSWORD, error.type());
|
||||
Assertions.assertEquals("wrong username or password", error.message());
|
||||
}
|
||||
|
||||
@Test
|
||||
void basicAuthLogin() {
|
||||
RestAssured.given()
|
||||
|
||||
@@ -45,7 +45,6 @@ class DatabaseCleaningIT extends BaseIT {
|
||||
private void starEntry(String entryId, Long subscriptionId) {
|
||||
StarRequest starRequest = new StarRequest();
|
||||
starRequest.setId(entryId);
|
||||
starRequest.setFeedId(subscriptionId);
|
||||
starRequest.setStarred(true);
|
||||
RestAssured.given()
|
||||
.body(starRequest)
|
||||
@@ -58,7 +57,6 @@ class DatabaseCleaningIT extends BaseIT {
|
||||
private void unstarEntry(String entryId, Long subscriptionId) {
|
||||
StarRequest starRequest = new StarRequest();
|
||||
starRequest.setId(entryId);
|
||||
starRequest.setFeedId(subscriptionId);
|
||||
starRequest.setStarred(false);
|
||||
RestAssured.given()
|
||||
.body(starRequest)
|
||||
|
||||
@@ -240,7 +240,6 @@ class CategoryIT extends BaseIT {
|
||||
|
||||
StarRequest starRequest = new StarRequest();
|
||||
starRequest.setId(entry.getId());
|
||||
starRequest.setFeedId(subscriptionId);
|
||||
starRequest.setStarred(true);
|
||||
RestAssured.given()
|
||||
.body(starRequest)
|
||||
|
||||
@@ -121,7 +121,6 @@ class FeverIT extends BaseIT {
|
||||
|
||||
StarRequest starRequest = new StarRequest();
|
||||
starRequest.setId(entry.getId());
|
||||
starRequest.setFeedId(subscriptionId);
|
||||
starRequest.setStarred(true);
|
||||
RestAssured.given().body(starRequest).contentType(ContentType.JSON).post("rest/entry/star");
|
||||
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
package com.commafeed.integration.rest;
|
||||
|
||||
import com.commafeed.TestConstants;
|
||||
import com.commafeed.frontend.model.Entry;
|
||||
import com.commafeed.frontend.model.UserModel;
|
||||
import com.commafeed.frontend.model.request.ProfileModificationRequest;
|
||||
import com.commafeed.frontend.resource.googlereader.GoogleReaderModel.ItemRefs;
|
||||
import com.commafeed.frontend.resource.googlereader.GoogleReaderModel.StreamContents;
|
||||
import com.commafeed.frontend.resource.googlereader.GoogleReaderModel.SubscriptionList;
|
||||
import com.commafeed.frontend.resource.googlereader.GoogleReaderModel.TagList;
|
||||
import com.commafeed.frontend.resource.googlereader.GoogleReaderModel.UnreadCounts;
|
||||
import com.commafeed.frontend.resource.googlereader.GoogleReaderModel.UserInfo;
|
||||
import com.commafeed.integration.BaseIT;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import io.restassured.http.ContentType;
|
||||
import io.restassured.response.Response;
|
||||
import io.restassured.specification.RequestSpecification;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
import org.apache.hc.core5.http.HttpStatus;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@QuarkusTest
|
||||
class GoogleReaderIT extends BaseIT {
|
||||
|
||||
private static final String BASE = "rest/googlereader";
|
||||
|
||||
@Getter private String apiKey;
|
||||
private GoogleReaderClient client;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
initialSetup(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
|
||||
RestAssured.authentication =
|
||||
RestAssured.preemptive()
|
||||
.basic(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
|
||||
|
||||
// create api key
|
||||
ProfileModificationRequest req = new ProfileModificationRequest();
|
||||
req.setCurrentPassword(TestConstants.ADMIN_PASSWORD);
|
||||
req.setNewApiKey(true);
|
||||
RestAssured.given()
|
||||
.body(req)
|
||||
.contentType(ContentType.JSON)
|
||||
.post("rest/user/profile")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK);
|
||||
|
||||
// retrieve api key
|
||||
UserModel user =
|
||||
RestAssured.given()
|
||||
.get("rest/user/profile")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK)
|
||||
.extract()
|
||||
.as(UserModel.class);
|
||||
this.apiKey = user.getApiKey();
|
||||
this.client = new GoogleReaderClient(clientLogin(TestConstants.ADMIN_USERNAME, apiKey));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
RestAssured.reset();
|
||||
}
|
||||
|
||||
private String clientLogin(String email, String password) {
|
||||
String body =
|
||||
RestAssured.given()
|
||||
.auth()
|
||||
.none()
|
||||
.formParam("Email", email)
|
||||
.formParam("Passwd", password)
|
||||
.post(BASE + "/accounts/ClientLogin")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK)
|
||||
.extract()
|
||||
.asString();
|
||||
|
||||
Map<String, String> values =
|
||||
Arrays.stream(body.split("\n"))
|
||||
.map(line -> line.split("=", 2))
|
||||
.collect(Collectors.toMap(kv -> kv[0], kv -> kv[1]));
|
||||
return values.get("Auth");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidClientLogin() {
|
||||
RestAssured.given()
|
||||
.auth()
|
||||
.none()
|
||||
.formParam("Email", TestConstants.ADMIN_USERNAME)
|
||||
.formParam("Passwd", "invalid-key")
|
||||
.post(BASE + "/accounts/ClientLogin")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validClientLogin() {
|
||||
String token = clientLogin(TestConstants.ADMIN_USERNAME, apiKey);
|
||||
Assertions.assertNotNull(token);
|
||||
Assertions.assertTrue(token.endsWith("/" + apiKey));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidAuthToken() {
|
||||
Response response =
|
||||
RestAssured.given()
|
||||
.auth()
|
||||
.none()
|
||||
.header("Authorization", "GoogleLogin auth=invalid/invalid-key")
|
||||
.get(BASE + "/reader/api/0/user-info");
|
||||
Assertions.assertEquals(HttpStatus.SC_UNAUTHORIZED, response.statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authTokenOnlyAppliesToGoogleReaderEndpoints() {
|
||||
String token = clientLogin(TestConstants.ADMIN_USERNAME, apiKey);
|
||||
Response response =
|
||||
RestAssured.given()
|
||||
.auth()
|
||||
.none()
|
||||
.header("Authorization", "GoogleLogin auth=" + token)
|
||||
.get("rest/user/profile");
|
||||
Assertions.assertNotEquals(HttpStatus.SC_OK, response.statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void userInfo() {
|
||||
UserInfo info = client.get(BASE + "/reader/api/0/user-info").as(UserInfo.class);
|
||||
Assertions.assertEquals(TestConstants.ADMIN_USERNAME, info.getUserName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscriptionList() {
|
||||
subscribe(getFeedUrl());
|
||||
SubscriptionList list =
|
||||
client.get(BASE + "/reader/api/0/subscription/list").as(SubscriptionList.class);
|
||||
Assertions.assertEquals(1, list.getSubscriptions().size());
|
||||
Assertions.assertEquals(
|
||||
"my title for this feed", list.getSubscriptions().getFirst().getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tagList() {
|
||||
createCategory("test-category");
|
||||
TagList tagList = client.get(BASE + "/reader/api/0/tag/list").as(TagList.class);
|
||||
Assertions.assertTrue(
|
||||
tagList.getTags().stream()
|
||||
.anyMatch(t -> t.getId().equals("user/-/label/test-category")));
|
||||
Assertions.assertTrue(
|
||||
tagList.getTags().stream()
|
||||
.anyMatch(t -> t.getId().equals("user/-/state/com.google/starred")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreadCount() {
|
||||
subscribeAndWaitForEntries(getFeedUrl());
|
||||
UnreadCounts counts =
|
||||
client.get(BASE + "/reader/api/0/unread-count").as(UnreadCounts.class);
|
||||
Assertions.assertTrue(
|
||||
counts.getUnreadCounts().stream()
|
||||
.anyMatch(
|
||||
c ->
|
||||
c.getId().equals("user/-/state/com.google/reading-list")
|
||||
&& c.getCount() == 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamContents() {
|
||||
subscribeAndWaitForEntries(getFeedUrl());
|
||||
StreamContents contents =
|
||||
client.get(BASE + "/reader/api/0/stream/contents/reading-list")
|
||||
.as(StreamContents.class);
|
||||
Assertions.assertEquals(2, contents.getItems().size());
|
||||
Assertions.assertEquals("Item 2", contents.getItems().getFirst().getTitle());
|
||||
Assertions.assertTrue(
|
||||
contents.getItems()
|
||||
.getFirst()
|
||||
.getId()
|
||||
.matches("tag:google\\.com,2005:reader/item/[0-9a-f]{16}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamItemIds() {
|
||||
subscribeAndWaitForEntries(getFeedUrl());
|
||||
ItemRefs refs =
|
||||
client.get(
|
||||
BASE
|
||||
+ "/reader/api/0/stream/items/ids?s=user/-/state/com.google/reading-list")
|
||||
.as(ItemRefs.class);
|
||||
Assertions.assertEquals(2, refs.getItemRefs().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editTagMarkRead() {
|
||||
Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl());
|
||||
Entry entry = getFeedEntries(subscriptionId).getEntries().getFirst();
|
||||
|
||||
String token = client.get(BASE + "/reader/api/0/token").asString();
|
||||
client.postForm(
|
||||
BASE + "/reader/api/0/edit-tag",
|
||||
Map.of("i", entry.getId(), "a", "user/-/state/com.google/read", "T", token));
|
||||
|
||||
Assertions.assertEquals(
|
||||
1,
|
||||
getFeedEntries(subscriptionId).getEntries().stream().filter(Entry::isRead).count());
|
||||
|
||||
client.postForm(
|
||||
BASE + "/reader/api/0/edit-tag",
|
||||
Map.of("i", entry.getId(), "r", "user/-/state/com.google/read", "T", token));
|
||||
|
||||
Assertions.assertEquals(
|
||||
0,
|
||||
getFeedEntries(subscriptionId).getEntries().stream().filter(Entry::isRead).count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editTagMarkReadWithoutToken() {
|
||||
// some real-world Google Reader API clients never fetch the edit token and always submit
|
||||
// a blank one, this must still be accepted
|
||||
Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl());
|
||||
Entry entry = getFeedEntries(subscriptionId).getEntries().getFirst();
|
||||
|
||||
client.postForm(
|
||||
BASE + "/reader/api/0/edit-tag",
|
||||
Map.of("i", entry.getId(), "a", "user/-/state/com.google/read"));
|
||||
|
||||
Assertions.assertEquals(
|
||||
1,
|
||||
getFeedEntries(subscriptionId).getEntries().stream().filter(Entry::isRead).count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editTagStar() {
|
||||
Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl());
|
||||
Entry entry = getFeedEntries(subscriptionId).getEntries().getFirst();
|
||||
|
||||
String token = client.get(BASE + "/reader/api/0/token").asString();
|
||||
client.postForm(
|
||||
BASE + "/reader/api/0/edit-tag",
|
||||
Map.of("i", entry.getId(), "a", "user/-/state/com.google/starred", "T", token));
|
||||
|
||||
Assertions.assertEquals(
|
||||
1,
|
||||
getFeedEntries(subscriptionId).getEntries().stream()
|
||||
.filter(Entry::isStarred)
|
||||
.count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void markAllAsRead() {
|
||||
Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl());
|
||||
|
||||
String token = client.get(BASE + "/reader/api/0/token").asString();
|
||||
client.postForm(
|
||||
BASE + "/reader/api/0/mark-all-as-read",
|
||||
Map.of("s", "user/-/state/com.google/reading-list", "T", token));
|
||||
|
||||
Assertions.assertTrue(
|
||||
getFeedEntries(subscriptionId).getEntries().stream().allMatch(Entry::isRead));
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribeAndUnsubscribe() {
|
||||
String token = client.get(BASE + "/reader/api/0/token").asString();
|
||||
|
||||
client.postForm(
|
||||
BASE + "/reader/api/0/subscription/edit",
|
||||
Map.of(
|
||||
"ac",
|
||||
"subscribe",
|
||||
"s",
|
||||
"feed/" + getFeedUrl(),
|
||||
"t",
|
||||
"new subscription",
|
||||
"T",
|
||||
token));
|
||||
|
||||
SubscriptionList list =
|
||||
client.get(BASE + "/reader/api/0/subscription/list").as(SubscriptionList.class);
|
||||
Assertions.assertEquals(1, list.getSubscriptions().size());
|
||||
String feedStreamId = list.getSubscriptions().getFirst().getId();
|
||||
|
||||
client.postForm(
|
||||
BASE + "/reader/api/0/subscription/edit",
|
||||
Map.of("ac", "unsubscribe", "s", feedStreamId, "T", token));
|
||||
|
||||
list = client.get(BASE + "/reader/api/0/subscription/list").as(SubscriptionList.class);
|
||||
Assertions.assertTrue(list.getSubscriptions().isEmpty());
|
||||
}
|
||||
|
||||
private record GoogleReaderClient(String token) {
|
||||
|
||||
private RequestSpecification given() {
|
||||
return RestAssured.given()
|
||||
.auth()
|
||||
.none()
|
||||
.header("Authorization", "GoogleLogin auth=" + token);
|
||||
}
|
||||
|
||||
Response get(String path) {
|
||||
return given().get(path).then().statusCode(HttpStatus.SC_OK).extract().response();
|
||||
}
|
||||
|
||||
void postForm(String path, Map<String, ?> form) {
|
||||
RequestSpecification spec = given();
|
||||
form.forEach(spec::formParam);
|
||||
spec.post(path).then().statusCode(HttpStatus.SC_OK);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ class UserIT extends BaseIT {
|
||||
RestAssured.given()
|
||||
.body(req)
|
||||
.contentType(ContentType.JSON)
|
||||
.header("Host", "malicious.url.com")
|
||||
.post("rest/user/passwordReset")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
@@ -64,6 +65,7 @@ class UserIT extends BaseIT {
|
||||
Assertions.assertTrue(
|
||||
message.getHtml()
|
||||
.startsWith("You asked for password recovery for account 'admin'"));
|
||||
Assertions.assertTrue(message.getHtml().contains("https://commafeed.example.com"));
|
||||
Assertions.assertEquals("admin@commafeed.com", message.getTo().getFirst());
|
||||
|
||||
Element a = Jsoup.parse(message.getHtml()).select("a").getFirst();
|
||||
|
||||
4
pom.xml
4
pom.xml
@@ -5,7 +5,7 @@
|
||||
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed</artifactId>
|
||||
<version>7.2.1</version>
|
||||
<version>7.4.1</version>
|
||||
<name>CommaFeed</name>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.15.0</version>
|
||||
<version>3.16.0</version>
|
||||
<configuration>
|
||||
<parameters>true</parameters>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user