Compare commits

..

78 Commits

Author SHA1 Message Date
Athou
69cf674a02 use 120 max line length for both server and client code (was 100 and 140) 2026-11-03 06:48:15 +01:00
Jérémie Panzer
29987a460d Merge pull request #2362 from Athou/renovate/org.jsoup-jsoup-1.x
Update dependency org.jsoup:jsoup to v1.24.1
2026-11-03 06:42:50 +01:00
renovate[bot]
fc8cd9c0a2 Update dependency org.jsoup:jsoup to v1.24.1 2026-11-03 04:47:00 +00:00
Jérémie Panzer
44c2893fff Merge pull request #2361 from Athou/renovate/com.diffplug.spotless-spotless-maven-plugin-3.x
Update dependency com.diffplug.spotless:spotless-maven-plugin to v3.11.0
2026-11-03 02:35:16 +01:00
renovate[bot]
2e66fd4702 Update dependency com.diffplug.spotless:spotless-maven-plugin to v3.11.0 2026-11-03 00:12:34 +00:00
Athou
01ac8d6ebd block NAT64 local-use prefix in SSRF protection 2026-11-02 13:09:31 +01:00
Athou
ddff408afe escape username in password recovery email 2026-11-02 12:34:14 +01:00
Athou
0c280c4dc4 invalidate existing sessions on password change 2026-11-02 12:17:45 +01:00
renovate[bot]
ca4642f7c9 Lock file maintenance 2026-11-02 00:55:45 +00:00
Jérémie Panzer
93edb47dca Merge pull request #2360 from Athou/renovate/linguijs-monorepo
Update linguijs monorepo to v6.11.0
2026-11-01 16:02:26 +01:00
renovate[bot]
801680e373 Update linguijs monorepo to v6.11.0 2026-11-01 14:10:40 +00:00
renovate[bot]
5704e1e1fa Update dependency @biomejs/biome to v2.6.4 2026-11-01 08:42:11 +00:00
Jérémie Panzer
a2a6720cd1 Merge pull request #2358 from Athou/renovate/com.puppycrawl.tools-checkstyle-14.x
Update dependency com.puppycrawl.tools:checkstyle to v14.4.0
2026-11-01 09:41:16 +01:00
renovate[bot]
4f8cddeb54 Update dependency com.puppycrawl.tools:checkstyle to v14.4.0 2026-10-31 21:57:58 +00:00
Athou
90d0846bad apply timeout to all queries 2026-10-31 08:46:39 +01:00
renovate[bot]
698b2f4dfd Update dependency react-router-dom to v7.18.5 2026-10-31 00:27:01 +00:00
renovate[bot]
0053c54ae4 Update dependency react-redux to v9.3.1 2026-10-30 22:37:38 +00:00
Athou
16b6077496 keep using ubuntu-24.04 (#2357) 2026-10-30 23:36:23 +01:00
Athou
edb4d5e178 speed up tree building by looking up unread counts in batches 2026-10-30 23:15:22 +01:00
Athou
4b091fb90b fix "Mark all as read" ignoring active search filter (#2354) 2026-10-30 21:08:27 +01:00
Athou
30c049d10a fix infinite scroll not loading more search results when entries are already read (#2353) 2026-10-30 20:54:56 +01:00
Athou
fa8a447787 prevent moving a category into one of its own descendants (#2355) 2026-10-30 20:51:45 +01:00
Athou
20ec5d3eb9 use mandrel instead of graalvm for better compatibility with quarkus
(cherry picked from commit c020b0ae08936d6f57b16c1b6bdd8aea17981ede)
2026-10-30 20:07:18 +01:00
Jérémie Panzer
796dc88c33 Merge pull request #2356 from Athou/renovate/com.google.googlejavaformat-google-java-format-2.x
Update dependency com.google.googlejavaformat:google-java-format to v2
2026-10-30 19:55:46 +01:00
Athou
f979f5426f reformat using google-java-format 2.0 2026-10-30 19:39:53 +01:00
renovate[bot]
8f9be28002 Update dependency com.google.googlejavaformat:google-java-format to v2 2026-10-30 17:25:44 +00:00
Jérémie Panzer
89845681be Merge pull request #2352 from Athou/renovate/axios-1.x
Update dependency axios to v1.21.0
2026-10-30 13:33:33 +01:00
renovate[bot]
8fdf63dd73 Update dependency axios to v1.21.0 2026-10-30 11:17:25 +00:00
Jérémie Panzer
8f07454270 Merge pull request #2350 from dajiaohuang/feat/2268-feed-settings-icon
Use a settings icon for feed controls
2026-10-30 07:23:37 +01:00
Jérémie Panzer
946d82d8ee Merge pull request #2351 from Athou/renovate/react-querybuilder
Update react-querybuilder to v8.26.0
2026-10-30 07:18:05 +01:00
renovate[bot]
f8d625b651 Update react-querybuilder to v8.26.0 2026-10-30 05:54:28 +00:00
dajiaohuang
eefdd40a02 Use settings icon for feed controls 2026-10-29 19:34:22 +08:00
renovate[bot]
08f2889f1d Update debian:13.7 Docker digest to 2dc419c 2026-10-29 01:59:45 +00:00
renovate[bot]
45453fcf4f Update mantine monorepo to v9.7.4 (#2349)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 01:59:27 +00:00
Jérémie Panzer
ef7bab4841 Merge pull request #2348 from Athou/renovate/node-26.x
Update Node.js to v26
2026-10-28 22:03:31 +01:00
renovate[bot]
ac277f9ca5 Update Node.js to v26 2026-10-28 20:44:59 +00:00
renovate[bot]
44dab0287b Update Node.js to v24.22.2 2026-10-28 19:20:32 +00:00
renovate[bot]
88b75ec0cb Update debian:13.7 Docker digest to 8c4733a 2026-10-28 05:34:10 +00:00
renovate[bot]
cbb45ce68c Update ibm-semeru-runtimes:open-jdk-25.0.4.15-jre Docker digest to c4f8cd5 2026-10-28 03:04:23 +00:00
renovate[bot]
b0c4dbf533 Update dependency @biomejs/biome to v2.6.3 2026-10-27 14:52:39 +00:00
renovate[bot]
1891b63a0e Update ibm-semeru-runtimes:open-jdk-25.0.4.15-jre Docker digest to 1e5bef9 2026-10-27 08:26:53 +00:00
Jérémie Panzer
c420fec8ee Merge pull request #2344 from Athou/renovate/dev.cel-cel-0.x
Update dependency dev.cel:cel to v0.15.0
2026-10-27 09:25:55 +01:00
Athou
2ee2a5dfbe standardCelRuntimeBuilder is deprecated 2026-10-27 07:23:50 +01:00
Jérémie Panzer
a5b98f9ea2 Merge pull request #2345 from Athou/renovate/org.mock-server-mockserver-junit-jupiter-no-dependencies-9.x
Update dependency org.mock-server:mockserver-junit-jupiter-no-dependencies to v9.2.0
2026-10-27 07:20:19 +01:00
renovate[bot]
baedab2960 Update dependency org.mock-server:mockserver-junit-jupiter-no-dependencies to v9.2.0 2026-10-27 02:09:09 +00:00
renovate[bot]
004e18bbb8 Update dependency dev.cel:cel to v0.15.0 2026-10-27 02:09:03 +00:00
Athou
72e364afe9 babel types are no longer needed now that linguijs does not require babel anymore 2026-10-26 20:15:32 +01:00
renovate[bot]
86ff2dfcb2 Update graalvm/setup-graalvm action to v1.6.8 2026-10-26 18:41:05 +00:00
renovate[bot]
69ce73012c Update dependency @babel/types to ^8.0.8 2026-10-26 17:46:09 +00:00
renovate[bot]
f3bd1a2e11 Update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.6.1 2026-10-26 06:38:33 +00:00
renovate[bot]
a819283661 Update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.6.1 2026-10-26 04:33:45 +00:00
renovate[bot]
8255c1e929 Lock file maintenance 2026-10-26 00:53:26 +00:00
renovate[bot]
eb7990f8dc Update linguijs monorepo to v6.10.1 2026-10-25 16:24:22 +00:00
Jérémie Panzer
982476b2d0 Merge pull request #2343 from Athou/renovate/biomejs-biome-2.x
Update dependency @biomejs/biome to v2.6.2
2026-10-24 19:58:21 +02:00
renovate[bot]
3ec653c3b7 Update dependency @biomejs/biome to v2.6.2 2026-10-24 13:30:33 +00:00
renovate[bot]
5dc5b3795e Update dependency vite to v8.3.5 2026-10-24 08:49:46 +00:00
Jérémie Panzer
f5f77a80a3 Merge pull request #2341 from Athou/renovate/docker-login-action-4.x
Update docker/login-action action to v4.7.0
2026-10-23 20:36:02 +02:00
renovate[bot]
1d1099492e Update docker/login-action action to v4.7.0 2026-10-23 18:01:41 +00:00
Jérémie Panzer
ccc2564929 Merge pull request #2340 from Athou/renovate/com.ibm.icu-icu4j-79.x
Update dependency com.ibm.icu:icu4j to v79
2026-10-23 08:55:25 +02:00
renovate[bot]
084574ffd4 Update dependency com.ibm.icu:icu4j to v79 2026-10-22 20:43:24 +00:00
Athou
6a6978c491 release 7.4.1 2026-10-22 17:03:04 +02:00
Athou
0399375288 don't mark as read entries that the user explicitly marked as unread (#2337) 2026-10-22 09:04:57 +02:00
renovate[bot]
394346fbc1 Update dependency npm to v12.2.1 2026-10-22 01:00:28 +00:00
renovate[bot]
0feec109bd Update Node.js to v24.22.1 2026-10-21 12:22:26 +00:00
Jérémie Panzer
fdb5c9b536 Merge pull request #2339 from Athou/renovate/org.mock-server-mockserver-junit-jupiter-no-dependencies-9.x
Update dependency org.mock-server:mockserver-junit-jupiter-no-dependencies to v9.1.0
2026-10-21 14:21:44 +02:00
renovate[bot]
f7ad8aed60 Update dependency org.mock-server:mockserver-junit-jupiter-no-dependencies to v9.1.0 2026-10-21 11:48:07 +00:00
renovate[bot]
4621d7e79f Update debian:13.7 Docker digest to 4c25275 2026-10-21 05:25:57 +00:00
Jérémie Panzer
b64a49cfb0 Merge pull request #2338 from Athou/renovate/patch-mantine-monorepo
Update mantine monorepo to v9.7.3
2026-10-21 07:25:22 +02:00
renovate[bot]
aaddfbaf40 Update mantine monorepo to v9.7.3 2026-10-20 20:33:54 +00:00
Athou
994da5e1ee don't mark as read if we are still on the same entry (#2337) 2026-10-20 16:51:42 +02:00
Athou
811e282e28 mockserver already uses jackson3 2026-10-20 13:34:06 +02:00
Jérémie Panzer
892fcf8168 Merge pull request #2336 from Athou/renovate/linguijs-monorepo
Update linguijs monorepo to v6.10.0
2026-10-20 13:15:59 +02:00
renovate[bot]
032ee9243f Update linguijs monorepo to v6.10.0 2026-10-20 10:46:22 +00:00
renovate[bot]
30cdf34d56 Update quarkus.version to v3.40.1.1 2026-10-20 10:45:55 +00:00
Athou
fd52caa3fd use responseTimeout as the global timeout for the request 2026-10-20 08:47:59 +02:00
Athou
9788712808 add a global failsafe timeout to the feed fetching client 2026-10-20 08:28:15 +02:00
Jérémie Panzer
af9d086f05 Merge pull request #2335 from agross/add-oci-source-label
Add OCI source label for Renovate release-note discovery
2026-10-19 11:31:42 +02:00
Alexander Groß
fc7190868e Add OCI source label to container images 2026-10-19 10:35:18 +02:00
180 changed files with 2574 additions and 3168 deletions

View File

@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
os: [ "ubuntu-latest", "ubuntu-22.04-arm", "windows-latest" ]
os: [ "ubuntu-24.04", "ubuntu-22.04-arm", "windows-latest" ]
database: [ "h2", "postgresql", "mysql", "mariadb" ]
runs-on: ${{ matrix.os }}
@@ -29,10 +29,10 @@ jobs:
# Setup
- name: Set up GraalVM
uses: graalvm/setup-graalvm@27b360dbb857524eaf0b4c4cef364d38e5b4f173 # v1.6.7
uses: graalvm/setup-graalvm@f70bc8032f90a104502824a54bcb94b6f01715bc # v1.6.8
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: "graalvm"
distribution: "mandrel"
cache: "maven"
- name: Set up NodeJS cache
@@ -74,7 +74,7 @@ jobs:
# Upload artifacts
- name: Upload cross-platform app
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
if: matrix.os == 'ubuntu-24.04' # we only need to upload the cross-platform artifact once per database
with:
name: commafeed-${{ matrix.database }}-jvm
path: commafeed-server/target/commafeed-*.zip
@@ -87,7 +87,7 @@ jobs:
- name: Upload pages
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
if: matrix.os == 'ubuntu-latest' && matrix.database == 'h2' # we only need to upload the pages once
if: matrix.os == 'ubuntu-24.04' && matrix.database == 'h2' # we only need to upload the pages once
with:
path: target/pages
@@ -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 }}

View File

@@ -1,5 +1,11 @@
# 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)

View File

@@ -1,10 +1,10 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.15/schema.json",
"$schema": "https://biomejs.dev/schemas/2.6.4/schema.json",
"formatter": {
"indentStyle": "space",
"indentWidth": 4,
"lineEnding": "lf",
"lineWidth": 140
"lineWidth": 120
},
"javascript": {
"formatter": {

File diff suppressed because it is too large Load Diff

View File

@@ -17,20 +17,20 @@
"dependencies": {
"@emotion/react": "11.14.0",
"@fontsource/open-sans": "5.3.0",
"@lingui/core": "6.9.1",
"@lingui/react": "6.9.1",
"@mantine/core": "9.7.2",
"@mantine/dates": "9.7.2",
"@mantine/form": "9.7.2",
"@mantine/hooks": "9.7.2",
"@mantine/modals": "9.7.2",
"@mantine/notifications": "9.7.2",
"@mantine/spotlight": "9.7.2",
"@lingui/core": "6.11.0",
"@lingui/react": "6.11.0",
"@mantine/core": "9.7.4",
"@mantine/dates": "9.7.4",
"@mantine/form": "9.7.4",
"@mantine/hooks": "9.7.4",
"@mantine/modals": "9.7.4",
"@mantine/notifications": "9.7.4",
"@mantine/spotlight": "9.7.4",
"@monaco-editor/react": "4.7.0",
"@react-querybuilder/mantine": "8.25.0",
"@react-querybuilder/mantine": "8.26.0",
"@reduxjs/toolkit": "2.13.0",
"@rolldown/plugin-babel": "0.2.4",
"axios": "1.20.0",
"axios": "1.21.0",
"dayjs": "1.11.23",
"escape-string-regexp": "5.0.0",
"interweave": "13.1.1",
@@ -43,9 +43,9 @@
"react-draggable": "4.7.2",
"react-icons": "5.7.0",
"react-infinite-scroller": "1.2.6",
"react-querybuilder": "8.25.0",
"react-redux": "9.3.0",
"react-router-dom": "7.18.4",
"react-querybuilder": "8.26.0",
"react-redux": "9.3.1",
"react-router-dom": "7.18.5",
"react-swipeable": "7.0.2",
"style-to-object": "2.0.2",
"throttle-debounce": "5.0.2",
@@ -54,10 +54,9 @@
"websocket-heartbeat-js": "1.1.3"
},
"devDependencies": {
"@babel/types": "^8.0.6",
"@biomejs/biome": "2.5.15",
"@lingui/cli": "6.9.1",
"@lingui/vite-plugin": "6.9.1",
"@biomejs/biome": "2.6.4",
"@lingui/cli": "6.11.0",
"@lingui/vite-plugin": "6.11.0",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.8",
@@ -71,7 +70,7 @@
"babel-plugin-react-compiler": "^1.0.0",
"jsdom": "30.1.3",
"typescript": "7.0.2",
"vite": "8.3.4",
"vite": "8.3.5",
"vite-plugin-checker": "0.14.5",
"vitest": "5.0.5",
"yaml": "2.9.1"

View File

@@ -6,16 +6,16 @@
<parent>
<groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId>
<version>7.4.0</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.22.0</node.version>
<node.version>v26.13.0</node.version>
<!-- renovate: datasource=npm depName=npm -->
<npm.version>12.2.0</npm.version>
<npm.version>12.2.1</npm.version>
</properties>
<build>

View File

@@ -38,7 +38,8 @@ function Providers(
children: React.ReactNode
}>
) {
const primaryColor = useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
const primaryColor =
useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
return (
<I18nProvider i18n={i18n}>
<MantineProvider
@@ -87,7 +88,10 @@ function AppRoutes() {
<Route path="register" element={<RegistrationPage />} />
<Route path="passwordRecovery" element={<PasswordRecoveryPage />} />
<Route path="passwordReset" element={<PasswordResetPage />} />
<Route path="app" element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}>
<Route
path="app"
element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}
>
<Route path="category">
<Route path=":id" element={<FeedEntriesPage sourceType="category" />} />
<Route path=":id/details" element={<CategoryDetailsPage />} />

View File

@@ -61,7 +61,8 @@ export const client = {
getRoot: async () => await axiosInstance.get<Category>("category/get"),
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("category/entries", { params: req }),
getEntries: async (req: GetEntriesPaginatedRequest) =>
await axiosInstance.get<Entries>("category/entries", { params: req }),
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
@@ -76,7 +77,8 @@ export const client = {
feed: {
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("feed/entries", { params: req }),
getEntries: async (req: GetEntriesPaginatedRequest) =>
await axiosInstance.get<Entries>("feed/entries", { params: req }),
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
@@ -107,7 +109,8 @@ export const client = {
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
initialSetup: async (req: InitialSetupRequest) => await axiosInstance.post("user/initialSetup", req),
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
passwordResetCallback: async (req: PasswordResetConfirmationRequest) => await axiosInstance.post("user/passwordResetCallback", req),
passwordResetCallback: async (req: PasswordResetConfirmationRequest) =>
await axiosInstance.post("user/passwordResetCallback", req),
getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
sendTestPushNotification: async (settings: PushNotificationSettings) =>
@@ -156,9 +159,13 @@ function isCommaFeedApplicationError(err: AxiosError): err is AxiosError<CommaFe
}
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
return (
!!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
)
}
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> {
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
return (
!!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
)
}

View File

@@ -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,

View File

@@ -1,6 +1,14 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
import { Constants } from "@/app/constants"
import { loadEntries, loadMoreEntries, markAllEntries, markEntry, markMultipleEntries, starEntry, tagEntry } from "@/app/entries/thunks"
import {
loadEntries,
loadMoreEntries,
markAllEntries,
markEntry,
markMultipleEntries,
starEntry,
tagEntry,
} from "@/app/entries/thunks"
import type { Entry } from "@/app/types"
export type EntrySourceType = "category" | "feed" | "tag"
@@ -10,7 +18,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,20 +83,26 @@ 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) => {
for (const e of state.entries.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true))) {
for (const e of state.entries.filter(e =>
action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true
)) {
e.read = true
}
})
builder.addCase(starEntry.pending, (state, action) => {
for (const e of state.entries.filter(e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId)) {
for (const e of state.entries.filter(
e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId
)) {
e.starred = action.meta.arg.starred
}
})

View File

@@ -40,7 +40,9 @@ export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_,
const state = thunkApi.getState()
const { source } = state.entries
const offset =
state.user.settings?.readingMode === "all" || (source.type === "category" && source.id === "starred")
state.user.settings?.readingMode === "all" ||
state.entries.search ||
(source.type === "category" && source.id === "starred")
? state.entries.entries.length
: state.entries.entries.filter(e => !e.read).length
const endpoint = getEndpoint(state.entries.source.type)
@@ -137,6 +139,7 @@ export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
const state = thunkApi.getState()
const source = state.entries.source
const entriesTimestamp = state.entries.timestamp ?? Date.now()
const search = state.entries.search
const markAllAsReadConfirmation = state.user.settings?.markAllAsReadConfirmation
const markAllAsReadNavigateToNextUnread = state.user.settings?.markAllAsReadNavigateToNextUnread
@@ -151,6 +154,7 @@ export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
read: true,
olderThan: Date.now(),
insertedBefore: entriesTimestamp,
keywords: search,
},
})
)
@@ -218,7 +222,9 @@ export const selectEntry = createAppAsyncThunk(
const entryIndex = state.entries.entries.indexOf(entry)
const entriesToKeepOnTopWhenScrolling =
viewMode === "expanded" ? 0 : Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
viewMode === "expanded"
? 0
: Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
const entryElement = document.getElementById(Constants.dom.entryId(entry))
@@ -226,7 +232,8 @@ export const selectEntry = createAppAsyncThunk(
if (entryElement && entryElementToScrollTo) {
const scrollMode = state.user.settings?.scrollMode
const entryEntirelyVisible =
Constants.layout.isTopVisible(entryElementToScrollTo) && Constants.layout.isBottomVisible(entryElement)
Constants.layout.isTopVisible(entryElementToScrollTo) &&
Constants.layout.isBottomVisible(entryElement)
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
const scrollSpeed = state.user.settings?.scrollSpeed
const margin = viewMode === "detailed" ? 8 : 3
@@ -240,7 +247,12 @@ export const selectEntry = createAppAsyncThunk(
}
)
const scrollToEntry = (entryElement: HTMLElement, margin: number, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
const scrollToEntry = (
entryElement: HTMLElement,
margin: number,
scrollSpeed: number | undefined,
onScrollEnded: () => void
) => {
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
const offset = (header?.bottom ?? 0) + margin
scrollToWithCallback({

View File

@@ -2,11 +2,17 @@ import { createAppAsyncThunk } from "@/app/async-thunk"
import { Constants } from "@/app/constants"
import { redirectTo } from "@/app/redirect/slice"
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) => thunkApi.dispatch(redirectTo("/login")))
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/login"))
)
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register")))
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/register"))
)
export const redirectToInitialSetup = createAppAsyncThunk("redirect/initialSetup", (_, thunkApi) => thunkApi.dispatch(redirectTo("/setup")))
export const redirectToInitialSetup = createAppAsyncThunk("redirect/initialSetup", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/setup"))
)
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", () => {
window.location.href = "api-documentation/"
@@ -38,15 +44,21 @@ export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details"
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
)
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) => thunkApi.dispatch(redirectTo(`/app/tag/${id}`)))
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/tag/${id}`))
)
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
)
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/add")))
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/app/add"))
)
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/settings")))
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/app/settings"))
)
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/app/admin/users"))
@@ -56,6 +68,10 @@ export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
)
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/donate")))
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/app/donate"))
)
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/about")))
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/app/about"))
)

View File

@@ -1,4 +1,7 @@
import { createAppAsyncThunk } from "@/app/async-thunk"
import { client } from "@/app/client"
export const reloadServerInfos = createAppAsyncThunk("server/infos", async () => await client.server.getServerInfos().then(r => r.data))
export const reloadServerInfos = createAppAsyncThunk(
"server/infos",
async () => await client.server.getServerInfos().then(r => r.data)
)

View File

@@ -41,4 +41,5 @@ export type AppDispatch = typeof store.dispatch
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
export const useShallowEqualAppSelector: TypedUseSelectorHook<RootState> = selector => useSelector(selector, shallowEqual)
export const useShallowEqualAppSelector: TypedUseSelectorHook<RootState> = selector =>
useSelector(selector, shallowEqual)

View File

@@ -6,7 +6,10 @@ import { incrementUnreadCount } from "@/app/tree/slice"
import type { CollapseRequest, Subscription } from "@/app/types"
import { flattenCategoryTree, visitCategoryTree } from "@/app/utils"
export const reloadTree = createAppAsyncThunk("tree/reload", async () => await client.category.getRoot().then(r => r.data))
export const reloadTree = createAppAsyncThunk(
"tree/reload",
async () => await client.category.getRoot().then(r => r.data)
)
export const collapseTreeCategory = createAppAsyncThunk(
"tree/category/collapse",

View File

@@ -2,14 +2,26 @@ import { createAppAsyncThunk } from "@/app/async-thunk"
import { client } from "@/app/client"
import type { Settings } from "@/app/types"
export const reloadSettings = createAppAsyncThunk("settings/reload", async () => await client.user.getSettings().then(r => r.data))
export const reloadSettings = createAppAsyncThunk(
"settings/reload",
async () => await client.user.getSettings().then(r => r.data)
)
export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data))
export const reloadProfile = createAppAsyncThunk(
"profile/reload",
async () => await client.user.getProfile().then(r => r.data)
)
export const reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().then(r => r.data))
export const reloadTags = createAppAsyncThunk(
"entries/tags",
async () => await client.entry.getTags().then(r => r.data)
)
export const changeSettings = createAppAsyncThunk("settings/change", async (newSettings: Partial<Settings>, thunkApi) => {
export const changeSettings = createAppAsyncThunk(
"settings/change",
async (newSettings: Partial<Settings>, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
await client.user.saveSettings({ ...settings, ...newSettings })
})
}
)

View File

@@ -43,13 +43,27 @@ export function categoryHasNewEntries(category?: TreeCategory): boolean {
.some(f => f.hasNewEntries)
}
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
export const calculatePlaceholderSize = ({
width,
height,
maxWidth,
}: {
width?: number
height?: number
maxWidth: number
}) => {
const placeholderWidth = width && Math.min(width, maxWidth)
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
return { width: placeholderWidth, height: placeholderHeight }
}
export const scrollToWithCallback = ({ options, onScrollEnded }: { options: ScrollToOptions; onScrollEnded: () => void }) => {
export const scrollToWithCallback = ({
options,
onScrollEnded,
}: {
options: ScrollToOptions
onScrollEnded: () => void
}) => {
const offset = (options.top ?? 0).toFixed()
const onScroll = throttle(100, () => {

View File

@@ -1,6 +1,14 @@
import type { MessageDescriptor } from "@lingui/core"
import { useLingui } from "@lingui/react"
import { ActionIcon, type ActionIconVariant, Box, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
import {
ActionIcon,
type ActionIconVariant,
Box,
Button,
type ButtonVariant,
Tooltip,
useMantineTheme,
} from "@mantine/core"
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
import { Constants } from "@/app/constants"
import { useActionButton } from "@/hooks/useActionButton"

View File

@@ -13,7 +13,10 @@ export function MarkAllAsReadConfirmationDialog() {
const source = useAppSelector(state => state.entries.source)
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
const markAllAsReadNavigateToNextUnread = useAppSelector(state => state.user.settings?.markAllAsReadNavigateToNextUnread)
const search = useAppSelector(state => state.entries.search)
const markAllAsReadNavigateToNextUnread = useAppSelector(
state => state.user.settings?.markAllAsReadNavigateToNextUnread
)
const dispatch = useAppDispatch()
const onConfirm = async () => {
@@ -26,12 +29,14 @@ export function MarkAllAsReadConfirmationDialog() {
read: true,
olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000,
insertedBefore: entriesTimestamp,
keywords: search,
},
})
)
const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected) await dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected)
await dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
}
return (
@@ -49,7 +54,8 @@ export function MarkAllAsReadConfirmationDialog() {
)}
{threshold > 0 && (
<Trans>
Are you sure you want to mark entries older than {threshold} days of <Code>{sourceLabel}</Code> as read?
Are you sure you want to mark entries older than {threshold} days of{" "}
<Code>{sourceLabel}</Code> as read?
</Trans>
)}
</Text>

View File

@@ -15,5 +15,12 @@ export const ReceivePushNotificationsChechbox = (props: CheckboxProps) => {
description = <Trans>Push notifications are not configured in your user settings.</Trans>
}
return <Checkbox label={<Trans>Receive push notifications</Trans>} disabled={disabled} description={description} {...props} />
return (
<Checkbox
label={<Trans>Receive push notifications</Trans>}
disabled={disabled}
description={description}
{...props}
/>
)
}

View File

@@ -34,7 +34,11 @@ export function UserEdit(props: Readonly<UserEditProps>) {
<form onSubmit={form.onSubmit(saveUser.execute)}>
<Stack>
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
<PasswordInput label={<Trans>Password</Trans>} {...form.getInputProps("password")} required={!props.user} />
<PasswordInput
label={<Trans>Password</Trans>}
{...form.getInputProps("password")}
required={!props.user}
/>
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} />
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />

View File

@@ -15,7 +15,9 @@ describe("Content component", () => {
})
it("renders iframe tag when included in content", () => {
const { container } = render(<Content content='<iframe src="https://example.com"></iframe>' />, { wrapper: MantineProvider })
const { container } = render(<Content content='<iframe src="https://example.com"></iframe>' />, {
wrapper: MantineProvider,
})
expect(container.querySelector("iframe")).toHaveAttribute("src", "https://example.com")
})

View File

@@ -1,6 +1,14 @@
import { Box, Mark } from "@mantine/core"
import escapeStringRegexp from "escape-string-regexp"
import { ALLOWED_TAG_LIST, type ChildrenNode, Interweave, Matcher, type MatchResponse, type Node, type TransformCallback } from "interweave"
import {
ALLOWED_TAG_LIST,
type ChildrenNode,
Interweave,
Matcher,
type MatchResponse,
type Node,
type TransformCallback,
} from "interweave"
import React from "react"
import styleToObject from "style-to-object"
import { Constants } from "@/app/constants"

View File

@@ -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(
@@ -299,7 +300,9 @@ export function FeedEntries() {
entry={entry}
expanded={!!entry.expanded || viewMode === "expanded"}
selected={entry.id === selectedEntryId}
showSelectionIndicator={entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")}
showSelectionIndicator={
entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")
}
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
onHeaderClick={event => headerClicked(entry, event)}
onHeaderRightClick={event => headerRightClicked(entry, event)}

View File

@@ -34,7 +34,18 @@ const useStyles = tss
maxWidth?: number
fontSizePercentage: number
}>()
.create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth, fontSizePercentage }) => {
.create(
({
theme,
colorScheme,
read,
expanded,
viewMode,
rtl,
showSelectionIndicator,
maxWidth,
fontSizePercentage,
}) => {
let backgroundColor: string
if (colorScheme === "dark") {
backgroundColor = read ? "inherit" : theme.colors.dark[5]
@@ -63,7 +74,8 @@ const useStyles = tss
let paperBorderLeftColor = ""
if (showSelectionIndicator) {
const borderLeftColor = colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
const borderLeftColor =
colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
paperBorderLeftColor = `${borderLeftColor} !important`
}
@@ -94,7 +106,8 @@ const useStyles = tss
maxWidth: maxWidth ?? "100%",
},
}
})
}
)
export function FeedEntry(props: Readonly<FeedEntryProps>) {
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
@@ -116,7 +129,9 @@ export function FeedEntry(props: Readonly<FeedEntryProps>) {
const showExternalLinkIcon =
externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode)
const showStarIcon =
props.entry.markable && starIconDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
props.entry.markable &&
starIconDisplayMode &&
["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
const swipeHandlers = useSwipeable({
onSwipedLeft: props.onSwipedLeft,

View File

@@ -35,7 +35,12 @@ export function FeedEntryContextMenu(props: Readonly<FeedEntryContextMenuProps>)
const { openLinkInBackgroundTab } = useBrowserExtension()
return (
<Menu id={Constants.dom.entryContextMenuId(props.entry)} theme={colorScheme} animation={false} className={classes.menu}>
<Menu
id={Constants.dom.entryContextMenuId(props.entry)}
theme={colorScheme}
animation={false}
className={classes.menu}
>
<Item
onClick={() => {
window.open(props.entry.url, "_blank", "noreferrer")
@@ -63,13 +68,19 @@ export function FeedEntryContextMenu(props: Readonly<FeedEntryContextMenuProps>)
{props.entry.markable && (
<>
<Item onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}>
<Item
onClick={async () =>
await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))
}
>
<Group>
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
</Group>
</Item>
<Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}>
<Item
onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}
>
<Group>
{props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}

View File

@@ -1,7 +1,16 @@
import { msg } from "@lingui/core/macro"
import { useLingui } from "@lingui/react"
import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
import {
TbArrowBarToDown,
TbExternalLink,
TbMail,
TbMailOpened,
TbShare,
TbStar,
TbStarOff,
TbTag,
} from "react-icons/tb"
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "@/app/entries/thunks"
import { useAppDispatch, useAppSelector } from "@/app/store"
import type { Entry } from "@/app/types"
@@ -71,7 +80,12 @@ export function FeedEntryFooter(props: Readonly<FeedEntryFooterProps>) {
{tags && (
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
<Popover.Target>
<Indicator label={props.entry.tags.length} disabled={props.entry.tags.length === 0} inline size={16}>
<Indicator
label={props.entry.tags.length}
disabled={props.entry.tags.length === 0}
inline
size={16}
>
<ActionButton icon={<TbTag size={18} />} label={msg`Tags`} />
</Indicator>
</Popover.Target>

View File

@@ -104,7 +104,9 @@ export function ShareButtons(
}>
) {
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(site => sharingSettings?.[site])
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(
site => sharingSettings?.[site]
)
const url = encodeURIComponent(props.url)
const desc = encodeURIComponent(props.description)
const clipboardAvailable = typeof navigator.clipboard !== "undefined"
@@ -119,7 +121,9 @@ export function ShareButtons(
{showNativeSection && (
<SimpleGrid cols={4}>
{clipboardAvailable && <CopyUrlButton url={props.url} />}
{nativeSharingAvailable && <BrowserNativeShareButton url={props.url} description={props.description} />}
{nativeSharingAvailable && (
<BrowserNativeShareButton url={props.url} description={props.description} />
)}
</SimpleGrid>
)}

View File

@@ -36,7 +36,12 @@ export function AddCategory() {
<form onSubmit={form.onSubmit(addCategory.execute)}>
<Stack>
<TextInput label={<Trans>Category</Trans>} placeholder={_(msg`Category`)} {...form.getInputProps("name")} required />
<TextInput
label={<Trans>Category</Trans>}
placeholder={_(msg`Category`)}
{...form.getInputProps("name")}
required
/>
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
<Group justify="center">
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>

View File

@@ -44,8 +44,8 @@ export function ImportOpml() {
placeholder={_(msg`OPML file`)}
description={
<Trans>
An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your
data from other feed reading services.
An opml file is an XML file containing feed URLs and categories. You can get an OPML
file by exporting your data from other feed reading services.
</Trans>
}
{...form.getInputProps("file")}

View File

@@ -85,8 +85,8 @@ export function Subscribe() {
placeholder="https://www.mysite.com/rss"
description={
<Trans>
The URL for the feed you want to subscribe to. You can also use the website's url directly and CommaFeed
will try to find the feed in the page.
The URL for the feed you want to subscribe to. You can also use the website's url
directly and CommaFeed will try to find the feed in the page.
</Trans>
}
required
@@ -101,8 +101,17 @@ export function Subscribe() {
>
<Stack>
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
<TextInput label={<Trans>Feed name</Trans>} {...step1Form.getInputProps("title")} required autoFocus />
<CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable />
<TextInput
label={<Trans>Feed name</Trans>}
{...step1Form.getInputProps("title")}
required
autoFocus
/>
<CategorySelect
label={<Trans>Category</Trans>}
{...step1Form.getInputProps("categoryId")}
clearable
/>
</Stack>
</Stepper.Step>
</Stepper>
@@ -117,7 +126,11 @@ export function Subscribe() {
</Button>
)}
{activeStep === 1 && (
<Button type="submit" leftSection={<TbRss size={16} />} loading={fetchFeed.loading || subscribe.loading}>
<Button
type="submit"
leftSection={<TbRss size={16} />}
loading={fetchFeed.loading || subscribe.loading}
>
<Trans>Subscribe</Trans>
</Button>
)}

View File

@@ -26,7 +26,16 @@ const fields: Field[] = [
{ name: "categoriesLower", label: "Categories (lower case)" },
]
const textOperators = new Set(["=", "!=", "contains", "beginsWith", "endsWith", "doesNotContain", "doesNotBeginWith", "doesNotEndWith"])
const textOperators = new Set([
"=",
"!=",
"contains",
"beginsWith",
"endsWith",
"doesNotContain",
"doesNotBeginWith",
"doesNotEndWith",
])
function toCelString(query: RuleGroupType): string {
if (query.rules.length === 0) {

View File

@@ -24,7 +24,10 @@ export function Star(
}
return (
<Tooltip label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>} openDelay={Constants.tooltip.delay}>
<Tooltip
label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
openDelay={Constants.tooltip.delay}
>
<ActionIcon variant="transparent" onClick={onClick}>
{props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />}
</ActionIcon>

View File

@@ -17,7 +17,13 @@ import {
TbSortDescending,
TbUser,
} from "react-icons/tb"
import { markAllAsReadWithConfirmationIfRequired, reloadEntries, search, selectNextEntry, selectPreviousEntry } from "@/app/entries/thunks"
import {
markAllAsReadWithConfirmationIfRequired,
reloadEntries,
search,
selectNextEntry,
selectPreviousEntry,
} from "@/app/entries/thunks"
import { useAppDispatch, useAppSelector } from "@/app/store"
import { changeSettings } from "@/app/user/thunks"
import { ActionButton } from "@/components/ActionButton"
@@ -143,7 +149,13 @@ export function Header() {
onClick={toggleReadingMode}
/>
<ActionButton
icon={settings.readingOrder === "asc" ? <TbSortAscending size={iconSize} /> : <TbSortDescending size={iconSize} />}
icon={
settings.readingOrder === "asc" ? (
<TbSortAscending size={iconSize} />
) : (
<TbSortDescending size={iconSize} />
)
}
label={settings.readingOrder === "asc" ? msg`Asc` : msg`Desc`}
onClick={toggleReadingOrder}
/>
@@ -160,7 +172,11 @@ export function Header() {
placeholder={_(msg`Search`)}
{...searchForm.getInputProps("search")}
leftSection={<TbSearch size={iconSize} />}
rightSection={<CloseButton onClick={async () => await (searchFromStore && dispatch(search("")))} />}
rightSection={
<CloseButton
onClick={async () => await (searchFromStore && dispatch(search("")))}
/>
}
autoFocus
/>
</form>

View File

@@ -31,7 +31,13 @@ import {
} from "react-icons/tb"
import { throttle } from "throttle-debounce"
import { client } from "@/app/client"
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "@/app/redirect/thunks"
import {
redirectToAbout,
redirectToAdminUsers,
redirectToDonate,
redirectToMetrics,
redirectToSettings,
} from "@/app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "@/app/store"
import type { ViewMode } from "@/app/types"
import { setFontSizePercentage, setViewMode } from "@/app/user/slice"
@@ -124,7 +130,13 @@ export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
}
return (
<Menu position="bottom-end" closeOnItemClick={false} opened={opened} onChange={setOpened} preventPositionChangeWhenVisible={false}>
<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>}
@@ -164,7 +176,9 @@ export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
}}
>
<Trans>Fetch all my feeds now</Trans>
{!forceRefreshEnabled && <span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>}
{!forceRefreshEnabled && (
<span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>
)}
</Menu.Item>
<Divider />

View File

@@ -83,7 +83,11 @@ export function CustomCodeSettings() {
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveCustomCode.loading}>
<Button
type="submit"
leftSection={<TbDeviceFloppy size={16} />}
loading={saveCustomCode.loading}
>
<Trans>Save</Trans>
</Button>
</Group>

View File

@@ -111,7 +111,11 @@ export function DisplaySettings() {
/>
<Switch
label={<Trans>Navigate to the next category/feed with unread entries when marking all entries as read</Trans>}
label={
<Trans>
Navigate to the next category/feed with unread entries when marking all entries as read
</Trans>
}
checked={settings.markAllAsReadNavigateToNextUnread}
onChange={e => dispatch(changeSettings({ markAllAsReadNavigateToNextUnread: e.currentTarget.checked }))}
/>

View File

@@ -1,6 +1,18 @@
import { useLingui } from "@lingui/react"
import { Trans } from "@lingui/react/macro"
import { Anchor, Box, Button, Checkbox, Divider, Group, Input, PasswordInput, Stack, Text, TextInput } from "@mantine/core"
import {
Anchor,
Box,
Button,
Checkbox,
Divider,
Group,
Input,
PasswordInput,
Stack,
Text,
TextInput,
} from "@mantine/core"
import { useForm } from "@mantine/form"
import { openConfirmModal } from "@mantine/modals"
import { useEffect } from "react"
@@ -91,8 +103,9 @@ 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
and the Google Reader 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
@@ -103,7 +116,8 @@ export function ProfileSettings() {
label={<Trans>OPML export</Trans>}
description={
<Trans>
Export your subscriptions and categories as an OPML file that can be imported in other feed reading services
Export your subscriptions and categories as an OPML file that can be imported in other
feed reading services
</Trans>
}
>
@@ -118,8 +132,8 @@ export function ProfileSettings() {
label={<Trans>Fever API</Trans>}
description={
<Trans>
CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client.
Login with your username and your <u>API key</u>.
CommaFeed is compatible with the Fever API. Use the following URL in your
Fever-compatible mobile client. Login with your username and your <u>API key</u>.
</Trans>
}
>
@@ -134,8 +148,8 @@ export function ProfileSettings() {
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>.
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>
}
>
@@ -165,8 +179,14 @@ export function ProfileSettings() {
description={<Trans>Changing password will generate a new API key</Trans>}
{...form.getInputProps("newPassword")}
/>
<PasswordInput label={<Trans>Confirm password</Trans>} {...form.getInputProps("newPasswordConfirmation")} />
<Checkbox label={<Trans>Generate new API key</Trans>} {...form.getInputProps("newApiKey", { type: "checkbox" })} />
<PasswordInput
label={<Trans>Confirm password</Trans>}
{...form.getInputProps("newPasswordConfirmation")}
/>
<Checkbox
label={<Trans>Generate new API key</Trans>}
{...form.getInputProps("newApiKey", { type: "checkbox" })}
/>
<Group>
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>

View File

@@ -50,8 +50,8 @@ export function PushNotificationSettings() {
label={<Trans>Push notification service</Trans>}
description={
<Trans>
Receive push notifications when new feed entries are discovered. Enable "Receive push notifications" in the
settings of each feed for which you want to receive notifications.
Receive push notifications when new feed entries are discovered. Enable "Receive push
notifications" in the settings of each feed for which you want to receive notifications.
</Trans>
}
data={[
@@ -77,7 +77,12 @@ export function PushNotificationSettings() {
required
{...form.getInputProps("serverUrl")}
/>
<TextInput label={<Trans>Topic</Trans>} placeholder="commafeed" required {...form.getInputProps("topic")} />
<TextInput
label={<Trans>Topic</Trans>}
placeholder="commafeed"
required
{...form.getInputProps("topic")}
/>
<TextInput label={<Trans>Access token</Trans>} {...form.getInputProps("userSecret")} />
</>
)}

View File

@@ -119,8 +119,11 @@ export function Tree() {
const categoryNode = (category: Category, level = 0) => {
if (!isCategoryDisplayed(category)) return null
const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
const hasWarning = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => !!f.filterLegacy))
const hasError =
!category.expanded &&
flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
const hasWarning =
!category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => !!f.filterLegacy))
return (
<TreeNode
id={category.id}

View File

@@ -80,7 +80,11 @@ export function TreeNode(props: Readonly<TreeNodeProps>) {
data-type={props.type}
data-unread-count={props.unread}
>
<Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)} className="cf-treenode-icon">
<Box
mr={6}
onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}
className="cf-treenode-icon"
>
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
</Box>
<Box className={classes.nodeText}>{props.name}</Box>

View File

@@ -1,10 +1,26 @@
import { msg } from "@lingui/core/macro"
import { Anchor, Box, Center, Container, Divider, Group, Image, Space, Title, useMantineColorScheme } from "@mantine/core"
import {
Anchor,
Box,
Center,
Container,
Divider,
Group,
Image,
Space,
Title,
useMantineColorScheme,
} from "@mantine/core"
import { useAsyncCallback } from "react-async-hook"
import { SiGithub, SiX } from "react-icons/si"
import { TbClock, TbKey, TbMoon, TbSettings, TbSun, TbUserPlus } from "react-icons/tb"
import { client } from "@/app/client"
import { redirectToApiDocumentation, redirectToLogin, redirectToRegistration, redirectToRootCategory } from "@/app/redirect/thunks"
import {
redirectToApiDocumentation,
redirectToLogin,
redirectToRegistration,
redirectToRootCategory,
} from "@/app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "@/app/store"
import welcomePageDark from "@/assets/welcome_page_dark.png"
import welcomePageLight from "@/assets/welcome_page_light.png"

View File

@@ -69,7 +69,11 @@ export function AdminUsersPage() {
<Title order={3} mb="md">
<Group>
<Trans>Manage users</Trans>
<ActionIcon color={theme.primaryColor} variant="subtle" onClick={() => openUserEditModal(<Trans>Add user</Trans>)}>
<ActionIcon
color={theme.primaryColor}
variant="subtle"
onClick={() => openUserEditModal(<Trans>Add user</Trans>)}
>
<TbPlus size={20} />
</ActionIcon>
</Group>

View File

@@ -19,8 +19,10 @@ const shownGauges: Record<string, string> = {
"com.commafeed.backend.feed.FeedRefreshEngine.queue.size": "Feed Refresh Engine queue size",
"com.commafeed.backend.feed.FeedRefreshEngine.worker.active": "Feed Refresh Engine active HTTP workers",
"com.commafeed.backend.feed.FeedRefreshEngine.updater.active": "Feed Refresh Engine active database update workers",
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.active": "Feed Refresh Engine active push notifications workers",
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.queue": "Feed Refresh Engine queued push notifications workers",
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.active":
"Feed Refresh Engine active push notifications workers",
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.queue":
"Feed Refresh Engine queued push notifications workers",
"com.commafeed.backend.HttpGetter.cache.size": "HttpGetter cached entries",
"com.commafeed.backend.HttpGetter.cache.memoryUsage": "HttpGetter cache memory usage",
"com.commafeed.frontend.ws.WebSocketSessions.users": "WebSocket users",

View File

@@ -51,7 +51,12 @@ function NextUnreadBookmarklet() {
return (
<Box>
<CategorySelect value={categoryId} onChange={c => c && setCategoryId(c)} withAll description={<Trans>Category</Trans>} />
<CategorySelect
value={categoryId}
onChange={c => c && setCategoryId(c)}
withAll
description={<Trans>Category</Trans>}
/>
<NativeSelect
data={[
{ value: "desc", label: _(msg`Newest first`) },
@@ -73,7 +78,8 @@ function NextUnreadBookmarklet() {
export function AboutPage() {
const version = useAppSelector(state => state.server.serverInfos?.version)
const revision = useAppSelector(state => state.server.serverInfos?.gitCommit)
const { isBrowserExtensionInstalled, browserExtensionVersion, isBrowserExtensionInstallable } = useBrowserExtension()
const { isBrowserExtensionInstalled, browserExtensionVersion, isBrowserExtensionInstallable } =
useBrowserExtension()
const dispatch = useAppDispatch()
return (
@@ -100,7 +106,9 @@ export function AboutPage() {
</Trans>
</Box>
<Box>
<Trans>If you encounter an issue, please report it on the issues page of the GitHub project.</Trans>
<Trans>
If you encounter an issue, please report it on the issues page of the GitHub project.
</Trans>
</Box>
</Section>
<Section title={<Trans>Goodies</Trans>} icon={<TbPuzzle size={24} />}>

View File

@@ -1,7 +1,21 @@
import { msg } from "@lingui/core/macro"
import { useLingui } from "@lingui/react"
import { Trans } from "@lingui/react/macro"
import { Anchor, Box, Button, Code, Container, Divider, Group, Input, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core"
import {
Anchor,
Box,
Button,
Code,
Container,
Divider,
Group,
Input,
NumberInput,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core"
import { useForm } from "@mantine/form"
import { openConfirmModal } from "@mantine/modals"
import { useEffect } from "react"
@@ -37,6 +51,9 @@ export function CategoryDetailsPage() {
category = query.result && flattenCategoryTree(query.result.data).find(c => c.id === id)
}
// a category cannot be moved under itself or one of its own descendants
const excludedCategoryIds = category ? flattenCategoryTree(category).map(c => c.id) : [id]
const form = useForm<CategoryModificationRequest>()
const { setValues } = form
@@ -125,9 +142,14 @@ export function CategoryDetailsPage() {
label={<Trans>Parent Category</Trans>}
{...form.getInputProps("parentId")}
clearable
withoutCategoryIds={[id]}
withoutCategoryIds={excludedCategoryIds}
/>
<NumberInput
label={<Trans>Position</Trans>}
{...form.getInputProps("position")}
required
min={0}
/>
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
</>
)}
@@ -137,7 +159,11 @@ export function CategoryDetailsPage() {
</Button>
{editable && (
<>
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={modifyCategory.loading}>
<Button
type="submit"
leftSection={<TbDeviceFloppy size={16} />}
loading={modifyCategory.loading}
>
<Trans>Save</Trans>
</Button>
<Divider orientation="vertical" />

View File

@@ -19,8 +19,8 @@ export function DonatePage() {
<Trans>
<Box>Hey,</Box>
<Box mt="xs">
I'm Jérémie from Belgium and I've been working on CommaFeed in my free time for over 10 years now. Thanks for taking
an interest in helping me continue supporting CommaFeed.
I'm Jérémie from Belgium and I've been working on CommaFeed in my free time for over 10 years
now. Thanks for taking an interest in helping me continue supporting CommaFeed.
</Box>
</Trans>

View File

@@ -133,7 +133,11 @@ export function FeedDetailsPage() {
<Input.Wrapper label={<Trans>Generated feed url</Trans>}>
<Box>
{apiKey && (
<Anchor href={`rest/feed/entriesAsFeed?id=${feed.id}&apiKey=${apiKey}`} target="_blank" rel="noreferrer">
<Anchor
href={`rest/feed/entriesAsFeed?id=${feed.id}&apiKey=${apiKey}`}
target="_blank"
rel="noreferrer"
>
<Trans>Link</Trans>
</Anchor>
)}
@@ -146,10 +150,16 @@ export function FeedDetailsPage() {
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
<CategorySelect label={<Trans>Category</Trans>} {...form.getInputProps("categoryId")} clearable />
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
<ReceivePushNotificationsChechbox {...form.getInputProps("pushNotificationsEnabled", { type: "checkbox" })} />
<ReceivePushNotificationsChechbox
{...form.getInputProps("pushNotificationsEnabled", { type: "checkbox" })}
/>
<NumberInput
label={<Trans>Auto-mark as read</Trans>}
description={<Trans>Mark entries in this feed as read after this number of days. Leave empty to disable.</Trans>}
description={
<Trans>
Mark entries in this feed as read after this number of days. Leave empty to disable.
</Trans>
}
suffix={` ${_(msg`days`)}`}
{...form.getInputProps("autoMarkAsReadAfterDays")}
min={1}
@@ -159,21 +169,25 @@ export function FeedDetailsPage() {
label={<Trans>Filtering expression</Trans>}
description={
<Trans>
Build a filter expression to indicate what you want to read. Entries that don't match will be marked as read
automatically.
Build a filter expression to indicate what you want to read. Entries that don't match
will be marked as read automatically.
</Trans>
}
>
{feed.filterLegacy && (
<MantineAlert color="yellow" icon={<TbAlertTriangle />}>
<Trans>
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: <Code>{feed.filterLegacy}</Code>
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: <Code>{feed.filterLegacy}</Code>
</Trans>
</MantineAlert>
)}
<Box mt="xs">
<FilteringExpressionEditor initialValue={feed.filter} onChange={value => form.setFieldValue("filter", value)} />
<FilteringExpressionEditor
initialValue={feed.filter}
onChange={value => form.setFieldValue("filter", value)}
/>
</Box>
</Input.Wrapper>

View File

@@ -2,7 +2,7 @@ import { Trans } from "@lingui/react/macro"
import { ActionIcon, Box, Center, Divider, Group, Title, useMantineTheme } from "@mantine/core"
import { useViewportSize } from "@mantine/hooks"
import { useEffect } from "react"
import { TbEdit } from "react-icons/tb"
import { TbSettings } from "react-icons/tb"
import { useLocation, useParams } from "react-router-dom"
import { Constants } from "@/app/constants"
import type { EntrySourceType } from "@/app/entries/slice"
@@ -20,7 +20,8 @@ function NoSubscriptionHelp() {
<Box>
<Center>
<Trans>
You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of the page?
You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of
the page?
</Trans>
</Center>
</Box>
@@ -51,7 +52,8 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
const viewport = useViewportSize()
const theme = useMantineTheme()
const noSubscriptions = useAppSelector(
state => state.tree.rootCategory && flattenCategoryTree(state.tree.rootCategory).every(c => c.feeds.length === 0)
state =>
state.tree.rootCategory && flattenCategoryTree(state.tree.rootCategory).every(c => c.feeds.length === 0)
)
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
const sourceWebsiteUrl = useAppSelector(state => state.entries.sourceWebsiteUrl)
@@ -63,7 +65,8 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
if (!root) return { unreadCount: 0, hasNewEntries: false }
if (props.sourceType === "category") {
const category = id === Constants.categories.all.id ? root : flattenCategoryTree(root).find(c => c.id === id)
const category =
id === Constants.categories.all.id ? root : flattenCategoryTree(root).find(c => c.id === id)
return {
unreadCount: categoryUnreadCount(category),
hasNewEntries: categoryHasNewEntries(category),
@@ -138,7 +141,7 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
</Title>
)}
<ActionIcon onClick={titleClicked} variant="subtle" color={theme.primaryColor}>
<TbEdit size={18} />
<TbSettings size={18} />
</ActionIcon>
{showUnreadCount && <UnreadCount unreadCount={unreadCount} showIndicator={hasNewEntries} />}
</Group>

View File

@@ -50,8 +50,8 @@ export function InitialSetupPage() {
</Title>
<Box mb="md">
<Trans>
Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get
started.
Welcome! This appears to be the first time you're running CommaFeed. Please create an
administrator account to get started.
</Trans>
</Box>
{setup.error && (

View File

@@ -1,7 +1,19 @@
import { msg } from "@lingui/core/macro"
import { useLingui } from "@lingui/react"
import { Trans } from "@lingui/react/macro"
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
import {
Anchor,
Box,
Button,
Center,
Container,
Group,
Paper,
PasswordInput,
Stack,
TextInput,
Title,
} from "@mantine/core"
import { useForm } from "@mantine/form"
import { useAsyncCallback } from "react-async-hook"
import { Link } from "react-router-dom"
@@ -49,7 +61,11 @@ export function LoginPage() {
placeholder={_(msg`User Name or E-mail`)}
{...form.getInputProps("name")}
description={
serverInfos?.demoAccountEnabled ? <Trans>Try out CommaFeed with the demo account: demo/demo</Trans> : ""
serverInfos?.demoAccountEnabled ? (
<Trans>Try out CommaFeed with the demo account: demo/demo</Trans>
) : (
""
)
}
size="md"
required

View File

@@ -1,7 +1,19 @@
import { msg } from "@lingui/core/macro"
import { useLingui } from "@lingui/react"
import { Trans } from "@lingui/react/macro"
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
import {
Anchor,
Box,
Button,
Center,
Container,
Group,
Paper,
PasswordInput,
Stack,
TextInput,
Title,
} from "@mantine/core"
import { useForm } from "@mantine/form"
import { useAsyncCallback } from "react-async-hook"
import { Link } from "react-router-dom"
@@ -71,7 +83,13 @@ export function RegistrationPage() {
<form onSubmit={form.onSubmit(register.execute)}>
<Stack>
<TextInput label="User Name" placeholder="User Name" {...form.getInputProps("name")} size="md" required />
<TextInput
label="User Name"
placeholder="User Name"
{...form.getInputProps("name")}
size="md"
required
/>
<TextInput
type="email"
label={<Trans>E-mail address</Trans>}

View File

@@ -6,18 +6,18 @@
<parent>
<groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId>
<version>7.4.0</version>
<version>7.4.1</version>
</parent>
<artifactId>commafeed-server</artifactId>
<name>CommaFeed Server</name>
<packaging>quarkus</packaging>
<properties>
<quarkus.version>3.40.1</quarkus.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>
<google-java-format.version>2.0.0</google-java-format.version>
<build.database>h2</build.database>
</properties>
@@ -164,7 +164,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
<version>3.6.1</version>
<configuration>
<argLine>@{argLine}</argLine>
<systemPropertyVariables>
@@ -176,7 +176,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.6.0</version>
<version>3.6.1</version>
<executions>
<execution>
<goals>
@@ -245,7 +245,7 @@
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>14.3.0</version>
<version>14.4.0</version>
</dependency>
</dependencies>
<executions>
@@ -274,7 +274,7 @@
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>3.10.4</version>
<version>3.11.0</version>
<?m2e ignore?>
<executions>
<execution>
@@ -290,6 +290,7 @@
<googleJavaFormat>
<version>${google-java-format.version}</version>
<style>AOSP</style>
<maxLineLength>120</maxLineLength>
<reorderImports>true</reorderImports>
</googleJavaFormat>
</java>
@@ -302,7 +303,7 @@
<dependency>
<groupId>com.commafeed</groupId>
<artifactId>commafeed-client</artifactId>
<version>7.4.0</version>
<version>7.4.1</version>
</dependency>
<!-- compile-time processors -->
@@ -404,7 +405,7 @@
<dependency>
<groupId>dev.cel</groupId>
<artifactId>cel</artifactId>
<version>0.14.0</version>
<version>0.15.0</version>
</dependency>
<dependency>
<groupId>org.passay</groupId>
@@ -434,12 +435,12 @@
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.23.2</version>
<version>1.24.1</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>
@@ -481,8 +482,8 @@
</dependency>
<dependency>
<groupId>org.mock-server</groupId>
<artifactId>mockserver-junit-jupiter</artifactId>
<version>7.6.0</version>
<artifactId>mockserver-junit-jupiter-no-dependencies</artifactId>
<version>9.2.0</version>
<scope>test</scope>
</dependency>
<dependency>

View File

@@ -1,4 +1,6 @@
FROM ibm-semeru-runtimes:open-jdk-25.0.4.15-jre@sha256:630c738228597b4cd31d9823e479384729b03684bac23478b33fdbda13ae31b8
FROM ibm-semeru-runtimes:open-jdk-25.0.4.15-jre@sha256:c4f8cd5ee9d607addbf957a5066144c46c853c6a81235819f6952e60237904ca
LABEL org.opencontainers.image.source="https://github.com/Athou/commafeed"
EXPOSE 8082
RUN mkdir -p /commafeed/data

View File

@@ -1,4 +1,6 @@
FROM debian:13.7@sha256:913f6706df59a68922d1dd08f78c2476560a8d367897200a6005b00e5f67c2d5
FROM debian:13.7@sha256:2dc419c6d7fe3dad0a2f9fd469c34cc90b0f9ba642d8fde837fe3d7d34a0e7f1
LABEL org.opencontainers.image.source="https://github.com/Athou/commafeed"
ARG TARGETARCH
EXPOSE 8082

View File

@@ -34,16 +34,14 @@ public class CommaFeedApplication {
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());
PasswordConstraintValidator.setMinimumPasswordLength(config.users().minimumPasswordLength());
if (config.imageProxyEnabled()) {
ImageProxyUrl.generateKey();
}
if (config.passwordRecoveryEnabled() && config.passwordRecoveryPublicBaseUrl().isEmpty()) {
throw new IllegalStateException(
"password recovery is enabled but no public base url is set");
throw new IllegalStateException("password recovery is enabled but no public base url is set");
}
feedRefreshEngine.start();

View File

@@ -19,24 +19,20 @@ import java.util.Optional;
/**
* CommaFeed configuration
*
* <p>Default values are for production, they can be overridden in application.properties for other
* profiles
* <p>Default values are for production, they can be overridden in application.properties for other profiles
*/
@ConfigMapping(prefix = "commafeed")
@ConfigRoot(phase = ConfigPhase.RUN_TIME)
public interface CommaFeedConfiguration {
/**
* Whether to expose a robots.txt file that disallows web crawlers and search engine indexers.
*/
/** Whether to expose a robots.txt file that disallows web crawlers and search engine indexers. */
@WithDefault("true")
boolean hideFromWebCrawlers();
/**
* If enabled, images in feed entries will be proxied through the server instead of accessed
* directly by the browser.
* If enabled, images in feed entries will be proxied through the server instead of accessed directly by the
* browser.
*
* <p>This is useful if commafeed is accessed through a restricting proxy that blocks some feeds
* that are followed.
* <p>This is useful if commafeed is accessed through a restricting proxy that blocks some feeds that are followed.
*/
@WithDefault("false")
boolean imageProxyEnabled();
@@ -50,9 +46,8 @@ public interface CommaFeedConfiguration {
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.
* 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();
@@ -87,16 +82,14 @@ public interface CommaFeedConfiguration {
Websocket websocket();
/**
* Duration to wait for the feed refresh engine and the task scheduler to stop when the
* application is shutting down.
* Duration to wait for the feed refresh engine and the task scheduler to stop when the application is shutting
* down.
*/
@WithDefault("2s")
Duration shutdownTimeout();
interface HttpClient {
/**
* User-Agent string that will be used by the http client, leave empty for the default one.
*/
/** User-Agent string that will be used by the http client, leave empty for the default one. */
Optional<String> userAgent();
/** Time to wait for a connection to be established. */
@@ -123,19 +116,15 @@ public interface CommaFeedConfiguration {
@WithDefault("1m")
Duration idleConnectionsEvictionInterval();
/**
* If a feed is larger than this, it will be discarded to prevent memory issues while
* parsing the feed.
*/
/** If a feed is larger than this, it will be discarded to prevent memory issues while parsing the feed. */
@WithDefault("5M")
MemorySize maxResponseSize();
/**
* Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks,
* which could potentially expose internal resources.
* Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks, which could
* potentially expose internal resources.
*
* <p>You may want to enable this if you host a public instance of CommaFeed with
* registrations open.
* <p>You may want to enable this if you host a public instance of CommaFeed with registrations open.
*/
@WithDefault("true")
boolean blockLocalAddresses();
@@ -147,9 +136,8 @@ public interface CommaFeedConfiguration {
interface HttpClientCache {
/**
* Whether to enable the cache. This cache is used to avoid spamming feeds in short bursts
* (e.g. when subscribing to a feed for the first time or when clicking "fetch all my feeds
* now").
* Whether to enable the cache. This cache is used to avoid spamming feeds in short bursts (e.g. when
* subscribing to a feed for the first time or when clicking "fetch all my feeds now").
*/
@WithDefault("true")
boolean enabled();
@@ -169,8 +157,7 @@ public interface CommaFeedConfiguration {
Duration interval();
/**
* Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an
* upper bound when:
* Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an upper bound when:
*
* <ul>
* <li>an error occurs while refreshing a feed and we're backing off exponentially
@@ -182,10 +169,10 @@ public interface CommaFeedConfiguration {
Duration maxInterval();
/**
* If enabled, CommaFeed will calculate the next refresh time based on the feed's average
* time between entries and the time since the last entry was published. The interval will
* be sometimes between the default refresh interval (`commafeed.feed-refresh.interval`) and
* the maximum refresh interval (`commafeed.feed-refresh.max-interval`).
* If enabled, CommaFeed will calculate the next refresh time based on the feed's average time between entries
* and the time since the last entry was published. The interval will be sometimes between the default refresh
* interval (`commafeed.feed-refresh.interval`) and the maximum refresh interval
* (`commafeed.feed-refresh.max-interval`).
*
* <p>See {@link FeedRefreshIntervalCalculator} for details.
*/
@@ -207,8 +194,8 @@ public interface CommaFeedConfiguration {
int databaseThreads();
/**
* Duration after which a user is considered inactive. Feeds for inactive users are not
* refreshed until they log in again.
* Duration after which a user is considered inactive. Feeds for inactive users are not refreshed until they log
* in again.
*
* <p>0 to disable.
*/
@@ -216,15 +203,15 @@ public interface CommaFeedConfiguration {
Duration userInactivityPeriod();
/**
* Duration after which the evaluation of a filtering expresion to mark an entry as read is
* considered to have timed out.
* Duration after which the evaluation of a filtering expresion to mark an entry as read is considered to have
* timed out.
*/
@WithDefault("500ms")
Duration filteringExpressionEvaluationTimeout();
/**
* Duration after which the "Fetch all my feeds now" action is available again after use to
* avoid spamming feeds.
* Duration after which the "Fetch all my feeds now" action is available again after use to avoid spamming
* feeds.
*/
@WithDefault("0")
Duration forceRefreshCooldownDuration();
@@ -240,10 +227,7 @@ public interface CommaFeedConfiguration {
@WithDefault("5")
int threads();
/**
* Maximum amount of notifications that can be queued before new notifications are
* discarded.
*/
/** Maximum amount of notifications that can be queued before new notifications are discarded. */
@Min(1)
@WithDefault("100")
int queueCapacity();
@@ -256,8 +240,8 @@ public interface CommaFeedConfiguration {
int retriesBeforeBackoff();
/**
* Duration to wait before retrying after an error. Will be multiplied by the number of
* errors since the last successful fetch.
* Duration to wait before retrying after an error. Will be multiplied by the number of errors since the last
* successful fetch.
*/
@WithDefault("1h")
Duration backoffInterval();
@@ -286,8 +270,7 @@ public interface CommaFeedConfiguration {
Duration entriesMaxAge();
/**
* Maximum age of feed entry statuses (read/unread) in the database. Older statuses will
* be deleted.
* Maximum age of feed entry statuses (read/unread) in the database. Older statuses will be deleted.
*
* <p>0 to disable.
*/
@@ -320,9 +303,7 @@ public interface CommaFeedConfiguration {
boolean keepStarredEntries();
default Instant statusesInstantThreshold() {
return statusesMaxAge().toMillis() > 0
? Instant.now().minus(statusesMaxAge())
: null;
return statusesMaxAge().toMillis() > 0 ? Instant.now().minus(statusesMaxAge()) : null;
}
}
}
@@ -346,23 +327,17 @@ public interface CommaFeedConfiguration {
}
interface Websocket {
/**
* Enable websocket connection so the server can notify web clients that there are new
* entries for feeds.
*/
/** Enable websocket connection so the server can notify web clients that there are new entries for feeds. */
@WithDefault("true")
boolean enabled();
/**
* Interval at which the client will send a ping message on the websocket to keep the
* connection alive.
*/
/** Interval at which the client will send a ping message on the websocket to keep the connection alive. */
@WithDefault("15m")
Duration pingInterval();
/**
* If the websocket connection is disabled or the connection is lost, the client will reload
* the feed tree at this interval.
* If the websocket connection is disabled or the connection is lost, the client will reload the feed tree at
* this interval.
*/
@WithDefault("30s")
Duration treeReloadInterval();

View File

@@ -10,7 +10,10 @@ import io.quarkus.security.UnauthorizedException;
import jakarta.annotation.Priority;
import jakarta.validation.ValidationException;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.UriInfo;
import jakarta.ws.rs.ext.Provider;
import lombok.RequiredArgsConstructor;
@@ -29,11 +32,9 @@ public class ExceptionMappers {
private final CommaFeedConfiguration config;
@ServerExceptionMapper(CommaFeedApplicationException.class)
public RestResponse<CommaFeedApplicationError> applicationError(
CommaFeedApplicationException e) {
public RestResponse<CommaFeedApplicationError> applicationError(CommaFeedApplicationException e) {
ResponseBuilder<CommaFeedApplicationError> response =
ResponseBuilder.create(
e.type().status(), new CommaFeedApplicationError(e.type(), e.getMessage()));
ResponseBuilder.create(e.type().status(), new CommaFeedApplicationError(e.type(), e.getMessage()));
if (e.type().status() == Status.UNAUTHORIZED) {
response.cookie(cookieService.buildLogoutCookie());
}
@@ -43,14 +44,24 @@ public class ExceptionMappers {
@ServerExceptionMapper(UnauthorizedException.class)
public RestResponse<UnauthorizedResponse> unauthorized(UnauthorizedException e) {
return RestResponse.status(
Status.UNAUTHORIZED,
new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations()));
Status.UNAUTHORIZED, new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations()));
}
@ServerExceptionMapper(AuthenticationFailedException.class)
public RestResponse<AuthenticationFailed> authenticationFailed(
AuthenticationFailedException e) {
AuthenticationFailedException e, HttpHeaders headers, UriInfo uriInfo) {
NewCookie logoutCookie = cookieService.buildLogoutCookie();
// a browser navigating with an invalidated session would otherwise display the error
// instead of the page, retry the request now that the cookie is removed
if (headers.getAcceptableMediaTypes().stream()
.anyMatch(m -> MediaType.TEXT_HTML_TYPE.isCompatible(m) && !m.isWildcardType())) {
return ResponseBuilder.<AuthenticationFailed>create(Status.TEMPORARY_REDIRECT)
.location(uriInfo.getRequestUri())
.cookie(logoutCookie)
.build();
}
return ResponseBuilder.create(Status.UNAUTHORIZED, new AuthenticationFailed(e.getMessage()))
.cookie(logoutCookie)
.build();

View File

@@ -56,28 +56,24 @@ public class HttpClientFactory {
private static final DnsResolver DNS_RESOLVER = SystemDefaultDnsResolver.INSTANCE;
private static final IPAddress CGNAT_RANGE = new IPAddressString("100.64.0.0/10").getAddress();
private static final IPAddress NAT64_LOCAL_USE_RANGE = new IPAddressString("64:ff9b:1::/48").getAddress();
private final CommaFeedConfiguration config;
private final CommaFeedVersion version;
public CloseableHttpAsyncClient newClient(int poolSize) {
PoolingAsyncClientConnectionManager connectionManager =
newConnectionManager(config, poolSize);
PoolingAsyncClientConnectionManager connectionManager = newConnectionManager(config, poolSize);
String userAgent =
config.httpClient()
.userAgent()
.orElseGet(
() ->
.orElseGet(() ->
String.format(
"CommaFeed/%s (https://github.com/Athou/commafeed)",
version.getVersion()));
"CommaFeed/%s (https://github.com/Athou/commafeed)", version.getVersion()));
return newClient(config, connectionManager, userAgent);
}
private CloseableHttpAsyncClient newClient(
CommaFeedConfiguration config,
PoolingAsyncClientConnectionManager connectionManager,
String userAgent) {
CommaFeedConfiguration config, 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"));
@@ -85,10 +81,8 @@ public class HttpClientFactory {
headers.add(new BasicHeader(HttpHeaders.ACCEPT, "*/*"));
headers.add(new BasicHeader(HttpHeaders.SEC_FETCH_MODE, "navigate"));
SequencedMap<String, UnaryOperator<AsyncDataConsumer>> contentDecoderMap =
new LinkedHashMap<>();
contentDecoderMap.put(
ContentCoding.DEFLATE.token(), d -> new InflatingAsyncDataConsumer(d, null));
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 =
@@ -106,8 +100,7 @@ public class HttpClientFactory {
.setDefaultHeaders(headers)
.setConnectionManager(connectionManager)
.evictExpiredConnections()
.evictIdleConnections(
TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
.evictIdleConnections(TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
.setContentDecoderMap(new LinkedHashMap<>(contentDecoderMap))
.setRedirectStrategy(redirectStrategy)
.build();
@@ -115,10 +108,8 @@ public class HttpClientFactory {
return client;
}
private PoolingAsyncClientConnectionManager newConnectionManager(
CommaFeedConfiguration config, int poolSize) {
SSLFactory sslFactory =
SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
private PoolingAsyncClientConnectionManager newConnectionManager(CommaFeedConfiguration config, int poolSize) {
SSLFactory sslFactory = SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
DnsResolver dnsResolver =
config.httpClient().blockLocalAddresses()
? new BlockLocalAddressesDnsResolver(DNS_RESOLVER)
@@ -130,14 +121,12 @@ public class HttpClientFactory {
ConnectionConfig.custom()
.setConnectTimeout(Timeout.of(config.httpClient().connectTimeout()))
.setSocketTimeout(Timeout.of(config.httpClient().socketTimeout()))
.setTimeToLive(
Timeout.of(config.httpClient().connectionTimeToLive()))
.setTimeToLive(Timeout.of(config.httpClient().connectionTimeToLive()))
.build())
.setDefaultTlsConfig(
TlsConfig.custom()
.setVersionPolicy(HttpVersionPolicy.NEGOTIATE)
.setHandshakeTimeout(
Timeout.of(config.httpClient().sslHandshakeTimeout()))
.setHandshakeTimeout(Timeout.of(config.httpClient().sslHandshakeTimeout()))
.build())
.setMaxConnPerRoute(poolSize)
.setMaxConnTotal(poolSize)
@@ -150,7 +139,11 @@ public class HttpClientFactory {
}
private static boolean isLocalAddress(IPAddress ip) {
if (ip.isLocal() || ip.isLoopback() || ip.isMulticast() || CGNAT_RANGE.contains(ip)) {
if (ip.isLocal()
|| ip.isLoopback()
|| ip.isMulticast()
|| CGNAT_RANGE.contains(ip)
|| NAT64_LOCAL_USE_RANGE.contains(ip)) {
return true;
}
@@ -186,8 +179,7 @@ public class HttpClientFactory {
InetAddress[] addresses = delegate.resolve(host);
for (InetAddress addr : addresses) {
if (isLocalAddress(addr)) {
throw new UnknownHostException(
"Access to local address blocked: " + addr.getHostAddress());
throw new UnknownHostException("Access to local address blocked: " + addr.getHostAddress());
}
}
return addresses;
@@ -223,8 +215,7 @@ public class HttpClientFactory {
for (InetAddress addr : addresses) {
if (isLocalAddress(addr)) {
throw new HttpException(
"Access to local address blocked: " + addr.getHostAddress());
throw new HttpException("Access to local address blocked: " + addr.getHostAddress());
}
}

View File

@@ -41,7 +41,6 @@ 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.apache.hc.core5.util.Timeout;
import org.jboss.resteasy.reactive.common.headers.CacheControlDelegate;
import java.io.ByteArrayOutputStream;
@@ -54,6 +53,8 @@ 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 */
@Singleton
@@ -74,32 +75,22 @@ public class HttpGetter {
this.client = httpClientFactory.newClient(config.feedRefresh().httpThreads());
this.cache = newCache(config);
metrics.registerGauge(
MetricRegistry.name(getClass(), "cache", "size"),
() -> cache == null ? 0 : cache.size());
metrics.registerGauge(MetricRegistry.name(getClass(), "cache", "size"), () -> cache == null ? 0 : cache.size());
metrics.registerGauge(
MetricRegistry.name(getClass(), "cache", "memoryUsage"),
() ->
cache == null
? 0
: cache.asMap().values().stream()
.mapToInt(e -> ArrayUtils.getLength(e.content))
.sum());
: cache.asMap().values().stream().mapToInt(e -> ArrayUtils.getLength(e.content)).sum());
}
public HttpResult get(String url)
throws IOException,
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
return get(HttpRequest.builder(url).build());
}
public HttpResult get(HttpRequest request)
throws IOException,
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
URI uri = URI.create(request.getUrl());
ensureHttpScheme(uri.getScheme());
@@ -162,7 +153,6 @@ 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
@@ -178,8 +168,7 @@ public class HttpGetter {
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) {}
public void completed(Message<org.apache.hc.core5.http.HttpResponse, byte[]> result) {}
@Override
public void failed(Exception ex) {}
@@ -191,7 +180,8 @@ public class HttpGetter {
client.execute(req, resp, context, callback);
try {
Message<org.apache.hc.core5.http.HttpResponse, byte[]> message = future.get();
Message<org.apache.hc.core5.http.HttpResponse, byte[]> message =
future.get(config.httpClient().responseTimeout().toSeconds(), TimeUnit.SECONDS);
byte[] content = message.getBody();
int code = message.getHead().getCode();
@@ -245,7 +235,11 @@ public class HttpGetter {
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());
}
}
@@ -278,16 +272,13 @@ public class HttpGetter {
}
return CacheBuilder.newBuilder()
.weigher(
(HttpRequest key, HttpResponse value) ->
value.content() != null ? value.content().length : 0)
.weigher((HttpRequest key, HttpResponse value) -> value.content() != null ? value.content().length : 0)
.maximumWeight(cacheConfig.maximumMemorySize().asLongValue())
.expireAfterWrite(cacheConfig.expiration())
.build();
}
private record LimitedContentLengthEntityConsumer(
@Delegate AsyncEntityConsumer<byte[]> delegate, long maxBytes)
private record LimitedContentLengthEntityConsumer(@Delegate AsyncEntityConsumer<byte[]> delegate, long maxBytes)
implements AsyncEntityConsumer<byte[]> {
@Override
@@ -320,9 +311,7 @@ public class HttpGetter {
@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));
throw new IOException("Response size exceeds the maximum allowed size (%s bytes)".formatted(maxBytes));
}
byte[] chunk = new byte[src.remaining()];
src.get(chunk);
@@ -362,8 +351,7 @@ public class HttpGetter {
this(message, null, null);
}
public NotModifiedException(
String message, String newLastModifiedHeader, String newEtagHeader) {
public NotModifiedException(String message, String newLastModifiedHeader, String newEtagHeader) {
super(message);
this.newLastModifiedHeader = newLastModifiedHeader;
this.newEtagHeader = newEtagHeader;

View File

@@ -65,11 +65,7 @@ public class Urls {
try {
return URI.create(baseUrl).resolve(relativeUrl).toString();
} catch (IllegalArgumentException e) {
log.debug(
"Unable to create absolute url from relative url: {} base: {}",
relativeUrl,
baseUrl,
e);
log.debug("Unable to create absolute url from relative url: {} base: {}", relativeUrl, baseUrl, e);
return null;
}
}
@@ -86,8 +82,8 @@ public class Urls {
}
/**
* Normalize the url. The resulting url is not meant to be fetched but rather used as a mean to
* identify a feed and avoid duplicates
* Normalize the url. The resulting url is not meant to be fetched but rather used as a mean to identify a feed and
* avoid duplicates
*/
public static String normalize(String url) {
if (url == null) {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.QFeedCategory;
import com.commafeed.backend.model.QUser;
@@ -17,8 +18,8 @@ public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory;
public FeedCategoryDAO(EntityManager entityManager) {
super(entityManager, FeedCategory.class);
public FeedCategoryDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedCategory.class, config);
}
public List<FeedCategory> findAll(User user) {
@@ -30,9 +31,7 @@ public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
}
public FeedCategory findById(User user, Long id) {
return query().selectFrom(CATEGORY)
.where(CATEGORY.user.eq(user), CATEGORY.id.eq(id))
.fetchOne();
return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), CATEGORY.id.eq(id)).fetchOne();
}
public FeedCategory findByName(User user, String name, FeedCategory parent) {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.QFeed;
import com.commafeed.backend.model.QFeedSubscription;
@@ -20,8 +21,8 @@ public class FeedDAO extends GenericDAO<Feed> {
private static final QFeed FEED = QFeed.feed;
private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription;
public FeedDAO(EntityManager entityManager) {
super(entityManager, Feed.class);
public FeedDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, Feed.class, config);
}
public List<Feed> findByIds(List<Long> id) {
@@ -35,10 +36,7 @@ public class FeedDAO extends GenericDAO<Feed> {
// join on subscriptions to only refresh feeds that have subscribers
.join(SUBSCRIPTION)
.on(SUBSCRIPTION.feed.eq(FEED))
.where(
FEED.disabledUntil
.isNull()
.or(FEED.disabledUntil.lt(Instant.now())));
.where(FEED.disabledUntil.isNull().or(FEED.disabledUntil.lt(Instant.now())));
if (lastLoginThreshold != null) {
query.join(SUBSCRIPTION.user).where(SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold));
@@ -52,11 +50,7 @@ public class FeedDAO extends GenericDAO<Feed> {
}
public Feed findByUrl(String normalizedUrl, String normalizedUrlHash) {
return query()
.selectFrom(FEED)
.where(FEED.normalizedUrlHash.eq(normalizedUrlHash))
.fetch()
.stream()
return query().selectFrom(FEED).where(FEED.normalizedUrlHash.eq(normalizedUrlHash)).fetch().stream()
.filter(f -> Strings.CS.equals(normalizedUrl, f.getNormalizedUrl()))
.findFirst()
.orElse(null);

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.FeedEntryContent;
import com.commafeed.backend.model.QFeedEntry;
import com.commafeed.backend.model.QFeedEntryContent;
@@ -15,8 +16,8 @@ public class FeedEntryContentDAO extends GenericDAO<FeedEntryContent> {
private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent;
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
public FeedEntryContentDAO(EntityManager entityManager) {
super(entityManager, FeedEntryContent.class);
public FeedEntryContentDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedEntryContent.class, config);
}
public List<FeedEntryContent> findExisting(String contentHash, String titleHash) {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.QFeedEntry;
@@ -23,8 +24,8 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
private static final int IN_CLAUSE_BATCH_SIZE = 1000;
public FeedEntryDAO(EntityManager entityManager) {
super(entityManager, FeedEntry.class);
public FeedEntryDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedEntry.class, config);
}
public FeedEntry findExisting(String guidHash, Feed feed) {
@@ -41,8 +42,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
}
Set<String> result = new HashSet<>();
for (List<String> batch :
Lists.partition(new ArrayList<>(guidHashes), IN_CLAUSE_BATCH_SIZE)) {
for (List<String> batch : Lists.partition(new ArrayList<>(guidHashes), IN_CLAUSE_BATCH_SIZE)) {
result.addAll(
query().select(ENTRY.guidHash)
.from(ENTRY)
@@ -52,8 +52,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
return result;
}
public List<FeedCapacity> findFeedsExceedingCapacity(
long maxCapacity, long max, boolean keepStarredEntries) {
public List<FeedCapacity> findFeedsExceedingCapacity(long maxCapacity, long max, boolean keepStarredEntries) {
NumberExpression<Long> count = ENTRY.id.count();
JPAQuery<Tuple> query = query().select(ENTRY.feed.id, count).from(ENTRY);
@@ -67,8 +66,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
}
public int delete(Long feedId, long max) {
List<FeedEntry> list =
query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).limit(max).fetch();
List<FeedEntry> list = query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).limit(max).fetch();
return delete(list);
}
@@ -90,10 +88,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
/** Delete the oldest entries of a feed */
public int deleteOldEntries(Long feedId, long max, boolean keepStarredEntries) {
JPAQuery<FeedEntry> query =
query().selectFrom(ENTRY)
.where(ENTRY.feed.id.eq(feedId))
.orderBy(ENTRY.published.asc())
.limit(max);
query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).orderBy(ENTRY.published.asc()).limit(max);
if (keepStarredEntries) {
query.where(Predicates.isNotStarred(ENTRY));

View File

@@ -29,6 +29,7 @@ import org.apache.commons.collections4.CollectionUtils;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -47,32 +48,24 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
private final CommaFeedConfiguration config;
public FeedEntryStatusDAO(
EntityManager entityManager,
FeedEntryTagDAO feedEntryTagDAO,
CommaFeedConfiguration config) {
super(entityManager, FeedEntryStatus.class);
EntityManager entityManager, FeedEntryTagDAO feedEntryTagDAO, CommaFeedConfiguration config) {
super(entityManager, FeedEntryStatus.class, config);
this.feedEntryTagDAO = feedEntryTagDAO;
this.config = config;
}
public FeedEntryStatus getStatus(User user, FeedSubscription sub, FeedEntry entry) {
List<FeedEntryStatus> statuses =
query().selectFrom(STATUS)
.where(STATUS.entry.eq(entry), STATUS.subscription.eq(sub))
.fetch();
query().selectFrom(STATUS).where(STATUS.entry.eq(entry), STATUS.subscription.eq(sub)).fetch();
FeedEntryStatus status = statuses.stream().findFirst().orElse(null);
return handleStatus(user, status, sub, entry);
}
/** creates an artificial "unread" status if status is null */
private FeedEntryStatus handleStatus(
User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) {
private FeedEntryStatus handleStatus(User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) {
if (status == null) {
Instant statusesInstantThreshold =
config.database().cleanup().statusesInstantThreshold();
boolean read =
statusesInstantThreshold != null
&& entry.getPublished().isBefore(statusesInstantThreshold);
Instant statusesInstantThreshold = config.database().cleanup().statusesInstantThreshold();
boolean read = statusesInstantThreshold != null && entry.getPublished().isBefore(statusesInstantThreshold);
status = new FeedEntryStatus(user, sub, entry);
status.setRead(read);
status.setMarkable(!read);
@@ -84,8 +77,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
private void fetchTags(User user, List<FeedEntryStatus> statuses) {
Map<Long, List<FeedEntryTag>> tagsByEntryIds =
feedEntryTagDAO.findByEntries(
user, statuses.stream().map(FeedEntryStatus::getEntry).toList());
feedEntryTagDAO.findByEntries(user, statuses.stream().map(FeedEntryStatus::getEntry).toList());
for (FeedEntryStatus status : statuses) {
List<FeedEntryTag> tags = tagsByEntryIds.get(status.getEntry().getId());
status.setTags(tags == null ? List.of() : tags);
@@ -129,8 +121,6 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
query.limit(limit);
}
setTimeout(query, config.database().queryTimeout());
List<FeedEntryStatus> statuses = query.fetch();
statuses.forEach(s -> s.setMarkable(true));
if (includeContent) {
@@ -207,8 +197,6 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
query.limit(limit);
}
setTimeout(query, config.database().queryTimeout());
List<FeedEntryStatus> statuses = new ArrayList<>();
List<Tuple> tuples = query.fetch();
for (Tuple tuple : tuples) {
@@ -238,19 +226,39 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
}
}
public UnreadCount getUnreadCount(FeedSubscription sub) {
public Map<Long, UnreadCount> getUnreadCounts(List<FeedSubscription> subs) {
if (subs.isEmpty()) {
return Map.of();
}
Map<Long, List<FeedSubscription>> subsByFeedId =
subs.stream().collect(Collectors.groupingBy(s -> s.getFeed().getId()));
JPAQuery<Tuple> query =
query().select(ENTRY.count(), ENTRY.published.max())
query().select(ENTRY.feed.id, ENTRY.count(), ENTRY.published.max())
.from(ENTRY)
.leftJoin(ENTRY.statuses, STATUS)
.on(STATUS.subscription.eq(sub))
.where(ENTRY.feed.eq(sub.getFeed()))
.where(buildUnreadPredicate());
.on(STATUS.subscription.in(subs))
.where(ENTRY.feed.id.in(subsByFeedId.keySet()))
.where(buildUnreadPredicate())
.groupBy(ENTRY.feed.id);
Tuple tuple = query.fetchOne();
Map<Long, UnreadCount> result = new HashMap<>();
for (Tuple tuple : query.fetch()) {
Long feedId = tuple.get(ENTRY.feed.id);
Long count = tuple.get(ENTRY.count());
Instant published = tuple.get(ENTRY.published.max());
return new UnreadCount(sub.getId(), count == null ? 0 : count, published);
for (FeedSubscription sub : subsByFeedId.get(feedId)) {
result.put(sub.getId(), new UnreadCount(sub.getId(), count == null ? 0 : count, published));
}
}
// feeds with no unread entries are absent from the query result, default them to zero
for (FeedSubscription sub : subs) {
result.putIfAbsent(sub.getId(), new UnreadCount(sub.getId(), 0, null));
}
return result;
}
private BooleanBuilder buildUnreadPredicate() {
@@ -284,8 +292,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
where.and(SUBSCRIPTION.autoMarkAsReadAfterDays.gt(0));
NumberExpression<Integer> daysDiff =
Expressions.numberTemplate(
Integer.class, "TIMESTAMPDIFF(DAY, {0}, {1})", ENTRY.published, now);
Expressions.numberTemplate(Integer.class, "TIMESTAMPDIFF(DAY, {0}, {1})", ENTRY.published, now);
where.and(daysDiff.goe(SUBSCRIPTION.autoMarkAsReadAfterDays));
where.and(buildUnreadPredicate());
@@ -314,11 +321,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
.toList();
if (!statusIdsToUpdate.isEmpty()) {
updated +=
updateQuery(STATUS)
.where(STATUS.id.in(statusIdsToUpdate))
.set(STATUS.read, true)
.execute();
updated += updateQuery(STATUS).where(STATUS.id.in(statusIdsToUpdate)).set(STATUS.read, true).execute();
}
// Insert new statuses for entries without existing status

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.QFeedEntryTag;
@@ -17,8 +18,8 @@ public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> {
private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag;
public FeedEntryTagDAO(EntityManager entityManager) {
super(entityManager, FeedEntryTag.class);
public FeedEntryTagDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedEntryTag.class, config);
}
public List<String> findByUser(User user) {
@@ -30,11 +31,7 @@ public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> {
}
public Map<Long, List<FeedEntryTag>> findByEntries(User user, List<FeedEntry> entries) {
return query()
.selectFrom(TAG)
.where(TAG.user.eq(user), TAG.entry.in(entries))
.fetch()
.stream()
return query().selectFrom(TAG).where(TAG.user.eq(user), TAG.entry.in(entries)).fetch().stream()
.collect(Collectors.groupingBy(t -> t.getEntry().getId()));
}
}

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.AbstractModel;
import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedCategory;
@@ -31,8 +32,8 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
private final EntityManager entityManager;
public FeedSubscriptionDAO(EntityManager entityManager) {
super(entityManager, FeedSubscription.class);
public FeedSubscriptionDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedSubscription.class, config);
this.entityManager = entityManager;
}
@@ -83,9 +84,7 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
public FeedSubscription findByFeed(User user, Feed feed) {
List<FeedSubscription> subs =
query().selectFrom(SUBSCRIPTION)
.where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.feed.eq(feed))
.fetch();
query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.feed.eq(feed)).fetch();
FeedSubscription sub = subs.stream().findFirst().orElse(null);
return initRelations(sub);
}
@@ -103,15 +102,11 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
}
public Long count(User user) {
return query().select(SUBSCRIPTION.count())
.from(SUBSCRIPTION)
.where(SUBSCRIPTION.user.eq(user))
.fetchOne();
return query().select(SUBSCRIPTION.count()).from(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user)).fetchOne();
}
public List<FeedSubscription> findByCategory(User user, FeedCategory category) {
JPQLQuery<FeedSubscription> query =
query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user));
JPQLQuery<FeedSubscription> query = query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user));
if (category == null) {
query.where(SUBSCRIPTION.category.isNull());
} else {
@@ -121,13 +116,9 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
}
public List<FeedSubscription> findByCategories(User user, List<FeedCategory> categories) {
Set<Long> categoryIds =
categories.stream().map(AbstractModel::getId).collect(Collectors.toSet());
Set<Long> categoryIds = categories.stream().map(AbstractModel::getId).collect(Collectors.toSet());
return findAll(user).stream()
.filter(
s ->
s.getCategory() != null
&& categoryIds.contains(s.getCategory().getId()))
.filter(s -> s.getCategory() != null && categoryIds.contains(s.getCategory().getId()))
.toList();
}

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.AbstractModel;
import com.querydsl.core.types.EntityPath;
import com.querydsl.jpa.impl.JPADeleteClause;
@@ -21,9 +22,15 @@ public abstract class GenericDAO<T extends AbstractModel> {
private final EntityManager entityManager;
private final Class<T> entityClass;
private final CommaFeedConfiguration config;
protected JPAQueryFactory query() {
return new JPAQueryFactory(entityManager);
return new JPAQueryFactory(entityManager) {
@Override
public JPAQuery<?> query() {
return withTimeout(super.query(), config.database().queryTimeout());
}
};
}
protected JPAUpdateClause updateQuery(EntityPath<T> entityPath) {
@@ -57,9 +64,10 @@ public abstract class GenericDAO<T extends AbstractModel> {
return objects.size();
}
protected void setTimeout(JPAQuery<?> query, Duration timeout) {
private JPAQuery<?> withTimeout(JPAQuery<?> query, Duration timeout) {
if (!timeout.isZero()) {
query.setHint(SpecHints.HINT_SPEC_QUERY_TIMEOUT, Math.toIntExact(timeout.toMillis()));
}
return query;
}
}

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.QUser;
import com.commafeed.backend.model.User;
@@ -11,8 +12,8 @@ public class UserDAO extends GenericDAO<User> {
private static final QUser USER = QUser.user;
public UserDAO(EntityManager entityManager) {
super(entityManager, User.class);
public UserDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, User.class, config);
}
public User findByName(String name) {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.QUserRole;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserRole;
@@ -17,8 +18,8 @@ public class UserRoleDAO extends GenericDAO<UserRole> {
private static final QUserRole ROLE = QUserRole.userRole;
public UserRoleDAO(EntityManager entityManager) {
super(entityManager, UserRole.class);
public UserRoleDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, UserRole.class, config);
}
public List<UserRole> findAll() {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.QUserSettings;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings;
@@ -12,8 +13,8 @@ public class UserSettingsDAO extends GenericDAO<UserSettings> {
private static final QUserSettings SETTINGS = QUserSettings.userSettings;
public UserSettingsDAO(EntityManager entityManager) {
super(entityManager, UserSettings.class);
public UserSettingsDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, UserSettings.class, config);
}
public UserSettings findByUser(User user) {

View File

@@ -37,9 +37,7 @@ public class FacebookFaviconFetcher implements FaviconFetcher {
return null;
}
String iconUrl =
String.format(
"https://graph.facebook.com/%s/picture?type=square&height=16", userName);
String iconUrl = String.format("https://graph.facebook.com/%s/picture?type=square&height=16", userName);
try {
log.debug("Getting Facebook user's icon, {}", url);

View File

@@ -17,8 +17,7 @@ public record Favicon(byte[] icon, MediaType mediaType) {
try {
return MediaType.valueOf(contentType);
} catch (Exception e) {
log.debug(
"invalid content type '{}' received, returning default value", contentType, e);
log.debug("invalid content type '{}' received, returning default value", contentType, e);
return DEFAULT_MEDIA_TYPE;
}
}

View File

@@ -32,10 +32,7 @@ public class RootFaviconFetcher implements FaviconFetcher {
URI uri = URI.create(url.trim());
String faviconUrl =
"%s://%s%s/favicon.ico"
.formatted(
uri.getScheme(),
uri.getHost(),
uri.getPort() > 0 ? ":" + uri.getPort() : "");
.formatted(uri.getScheme(), uri.getHost(), uri.getPort() > 0 ? ":" + uri.getPort() : "");
log.debug("getting root icon at {}", faviconUrl);
HttpResult result = getter.get(faviconUrl);

View File

@@ -37,8 +37,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
private static final JsonPointer CHANNEL_THUMBNAIL_URL =
JsonPointer.compile("/items/0/snippet/thumbnails/default/url");
private static final JsonPointer PLAYLIST_CHANNEL_ID =
JsonPointer.compile("/items/0/snippet/channelId");
private static final JsonPointer PLAYLIST_CHANNEL_ID = JsonPointer.compile("/items/0/snippet/channelId");
private final HttpGetter getter;
private final CommaFeedConfiguration config;
@@ -60,17 +59,11 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
try {
List<NameValuePair> params = new URIBuilder(url).getQueryParams();
Optional<NameValuePair> userId =
params.stream()
.filter(nvp -> nvp.getName().equalsIgnoreCase("user"))
.findFirst();
params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("user")).findFirst();
Optional<NameValuePair> channelId =
params.stream()
.filter(nvp -> nvp.getName().equalsIgnoreCase("channel_id"))
.findFirst();
params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("channel_id")).findFirst();
Optional<NameValuePair> playlistId =
params.stream()
.filter(nvp -> nvp.getName().equalsIgnoreCase("playlist_id"))
.findFirst();
params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("playlist_id")).findFirst();
byte[] response = null;
if (userId.isPresent()) {
@@ -103,10 +96,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
}
private byte[] fetchForUser(String googleAuthKey, String userId)
throws IOException,
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
URI uri =
UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
.queryParam("part", PART_SNIPPET)
@@ -117,10 +107,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
}
private byte[] fetchForChannel(String googleAuthKey, String channelId)
throws IOException,
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
URI uri =
UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
.queryParam("part", PART_SNIPPET)
@@ -131,10 +118,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
}
private byte[] fetchForPlaylist(String googleAuthKey, String playlistId)
throws IOException,
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
URI uri =
UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/playlists")
.queryParam("part", PART_SNIPPET)

View File

@@ -36,8 +36,7 @@ public class FeedFetcher {
private final HttpGetter getter;
private final List<FeedURLProvider> urlProviders;
public FeedFetcher(
FeedParser parser, HttpGetter getter, @All List<FeedURLProvider> urlProviders) {
public FeedFetcher(FeedParser parser, HttpGetter getter, @All List<FeedURLProvider> urlProviders) {
this.parser = parser;
this.getter = getter;
this.urlProviders = urlProviders;
@@ -58,9 +57,7 @@ public class FeedFetcher {
NoFeedFoundException {
log.debug("Fetching feed {}", feedUrl);
HttpResult result =
getter.get(
HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build());
HttpResult result = getter.get(HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build());
byte[] content = result.content();
FeedParserResult parserResult;
@@ -69,19 +66,12 @@ public class FeedFetcher {
} catch (FeedParsingException e) {
if (extractFeedUrlFromHtml) {
String extractedUrl =
extractFeedUrl(
urlProviders,
feedUrl,
new String(result.content(), StandardCharsets.UTF_8));
extractFeedUrl(urlProviders, feedUrl, new String(result.content(), StandardCharsets.UTF_8));
if (StringUtils.isNotBlank(extractedUrl)) {
feedUrl = extractedUrl;
result =
getter.get(
HttpRequest.builder(extractedUrl)
.lastModified(lastModified)
.eTag(eTag)
.build());
getter.get(HttpRequest.builder(extractedUrl).lastModified(lastModified).eTag(eTag).build());
content = result.content();
parserResult = parser.parse(result.urlAfterRedirect(), content);
} else {
@@ -96,8 +86,7 @@ public class FeedFetcher {
throw new IOException("Feed content is empty.");
}
boolean lastModifiedHeaderValueChanged =
!Strings.CS.equals(lastModified, result.lastModifiedSince());
boolean lastModifiedHeaderValueChanged = !Strings.CS.equals(lastModified, result.lastModifiedSince());
boolean etagHeaderValueChanged = !Strings.CS.equals(eTag, result.eTag());
String hash = Digests.sha1Hex(content);
@@ -109,8 +98,7 @@ public class FeedFetcher {
etagHeaderValueChanged ? result.eTag() : null);
}
if (lastPublishedDate != null
&& lastPublishedDate.equals(parserResult.lastPublishedDate())) {
if (lastPublishedDate != null && lastPublishedDate.equals(parserResult.lastPublishedDate())) {
log.debug("publishedDate not modified: {}", feedUrl);
throw new NotModifiedException(
"publishedDate not modified",
@@ -127,8 +115,7 @@ public class FeedFetcher {
result.validFor());
}
private static String extractFeedUrl(
List<FeedURLProvider> urlProviders, String url, String urlContent) {
private static String extractFeedUrl(List<FeedURLProvider> urlProviders, String url, String urlContent) {
return urlProviders.stream()
.flatMap(provider -> provider.get(url, urlContent).stream())
.filter(StringUtils::isNotBlank)

View File

@@ -67,8 +67,7 @@ public class FeedRefreshEngine {
this.queue = new LinkedBlockingDeque<>();
metrics.register(
MetricRegistry.name(getClass(), "queue", "size"), (Gauge<Integer>) queue::size);
metrics.register(MetricRegistry.name(getClass(), "queue", "size"), (Gauge<Integer>) queue::size);
metrics.register(
MetricRegistry.name(getClass(), "worker", "active"),
(Gauge<Integer>) () -> workerExecutor.getActiveCount());
@@ -88,12 +87,10 @@ public class FeedRefreshEngine {
this.refillLoopExecutor = Executors.newSingleThreadExecutor();
this.refillExecutor = newDiscardingSingleThreadExecutorService();
this.workerExecutor = newBlockingExecutorService(config.feedRefresh().httpThreads());
this.databaseUpdaterExecutor =
newBlockingExecutorService(config.feedRefresh().databaseThreads());
this.databaseUpdaterExecutor = newBlockingExecutorService(config.feedRefresh().databaseThreads());
this.notifierExecutor =
newDiscardingExecutorService(
config.pushNotifications().threads(),
config.pushNotifications().queueCapacity());
config.pushNotifications().threads(), config.pushNotifications().queueCapacity());
}
public void start() {
@@ -104,17 +101,14 @@ public class FeedRefreshEngine {
private void startFeedProcessingLoop() {
// take a feed from the queue, process it, rince, repeat
feedProcessingLoopExecutor.submit(
() -> {
feedProcessingLoopExecutor.submit(() -> {
while (!feedProcessingLoopExecutor.isShutdown()) {
try {
// take() is blocking until a feed is available from the queue
Feed feed = queue.take();
// send the feed to be processed
log.debug(
"got feed {} from the queue, send it for processing",
feed.getId());
log.debug("got feed {} from the queue, send it for processing", feed.getId());
processFeedAsync(feed);
// we removed a feed from the queue, try to refill it as it may now be
@@ -135,8 +129,7 @@ public class FeedRefreshEngine {
private void startRefillLoop() {
// refill the queue at regular intervals if it's empty
refillLoopExecutor.submit(
() -> {
refillLoopExecutor.submit(() -> {
while (!refillLoopExecutor.isShutdown()) {
try {
if (queue.isEmpty()) {
@@ -173,9 +166,7 @@ public class FeedRefreshEngine {
refill.mark();
List<Feed> nextUpdatableFeeds = getNextUpdatableFeeds(getBatchSize());
log.debug(
"found {} feeds that are up for refresh",
nextUpdatableFeeds.size());
log.debug("found {} feeds that are up for refresh", nextUpdatableFeeds.size());
for (Feed feed : nextUpdatableFeeds) {
// add the feed only if it was not already queued
if (queue.stream().noneMatch(f -> f.getId().equals(feed.getId()))) {
@@ -184,8 +175,7 @@ public class FeedRefreshEngine {
}
},
refillExecutor)
.whenComplete(
(data, ex) -> {
.whenComplete((data, ex) -> {
if (ex != null) {
log.error("error while refilling the queue", ex);
}
@@ -195,51 +185,38 @@ public class FeedRefreshEngine {
private void processFeedAsync(Feed feed) {
CompletableFuture.supplyAsync(() -> worker.update(feed), workerExecutor)
.thenApplyAsync(r -> updater.update(r.feed(), r.entries()), databaseUpdaterExecutor)
.thenCompose(
r -> {
.thenCompose(r -> {
List<CompletableFuture<Void>> futures =
r.insertedUnreadEntriesBySubscription().entrySet().stream()
.map(
e -> {
.map(e -> {
FeedSubscription sub = e.getKey();
List<FeedEntry> entries = e.getValue();
notifier.notifyOverWebsocket(sub, entries);
return CompletableFuture.runAsync(
() ->
notifier
.sendPushNotifications(
sub,
entries),
notifierExecutor);
() -> notifier.sendPushNotifications(sub, entries), notifierExecutor);
})
.toList();
return CompletableFuture.allOf(
futures.toArray(CompletableFuture[]::new));
return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new));
})
.exceptionally(
ex -> {
.exceptionally(ex -> {
log.error("error while processing feed {}", feed.getUrl(), ex);
return null;
});
}
private List<Feed> getNextUpdatableFeeds(int max) {
return unitOfWork.call(
() -> {
return unitOfWork.call(() -> {
Instant lastLoginThreshold =
config.feedRefresh().userInactivityPeriod().isZero()
? null
: Instant.now()
.minus(config.feedRefresh().userInactivityPeriod());
: Instant.now().minus(config.feedRefresh().userInactivityPeriod());
List<Feed> feeds = feedDAO.findNextUpdatable(max, lastLoginThreshold);
if (!feeds.isEmpty()) {
// update disabledUntil to prevent feeds from being returned again by
// feedDAO.findNextUpdatable()
Instant nextUpdateDate =
Instant.now().plus(config.feedRefresh().interval());
feedDAO.setDisabledUntil(
feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
Instant nextUpdateDate = Instant.now().plus(config.feedRefresh().interval());
feedDAO.setDisabledUntil(feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
}
return feeds;
});
@@ -250,26 +227,19 @@ public class FeedRefreshEngine {
}
public void stop() {
MoreExecutors.shutdownAndAwaitTermination(
this.feedProcessingLoopExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(
this.refillLoopExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.feedProcessingLoopExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.refillLoopExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.refillExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.workerExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(
this.databaseUpdaterExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.databaseUpdaterExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.notifierExecutor, config.shutdownTimeout());
queue.clear();
}
/**
* returns an ExecutorService with a single thread that discards tasks if a task is already
* running
*/
/** returns an ExecutorService with a single thread that discards tasks if a task is already running */
private ThreadPoolExecutor newDiscardingSingleThreadExecutorService() {
ThreadPoolExecutor pool =
new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
return pool;
}
@@ -278,11 +248,7 @@ public class FeedRefreshEngine {
private ThreadPoolExecutor newDiscardingExecutorService(int threads, int queueCapacity) {
ThreadPoolExecutor pool =
new ThreadPoolExecutor(
threads,
threads,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(queueCapacity));
threads, threads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(queueCapacity));
pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
return pool;
}
@@ -290,10 +256,8 @@ public class FeedRefreshEngine {
/** returns an ExecutorService that blocks submissions until a thread is available */
private ThreadPoolExecutor newBlockingExecutorService(int threads) {
ThreadPoolExecutor pool =
new ThreadPoolExecutor(
threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
pool.setRejectedExecutionHandler(
(r, e) -> {
new ThreadPoolExecutor(threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
pool.setRejectedExecutionHandler((r, e) -> {
if (e.isShutdown()) {
return;
}

View File

@@ -22,8 +22,7 @@ public class FeedRefreshIntervalCalculator {
private final FeedRefreshErrorHandling errorHandling;
private final InstantSource instantSource;
public FeedRefreshIntervalCalculator(
CommaFeedConfiguration config, InstantSource instantSource) {
public FeedRefreshIntervalCalculator(CommaFeedConfiguration config, InstantSource instantSource) {
this.interval = config.feedRefresh().interval();
this.maxInterval = config.feedRefresh().maxInterval();
this.empirical = config.feedRefresh().intervalEmpirical();
@@ -31,8 +30,7 @@ public class FeedRefreshIntervalCalculator {
this.instantSource = instantSource;
}
public Instant onFetchSuccess(
Instant publishedDate, Long averageEntryInterval, Duration validFor) {
public Instant onFetchSuccess(Instant publishedDate, Long averageEntryInterval, Duration validFor) {
Instant instant =
empirical
? computeEmpiricalRefreshInterval(publishedDate, averageEntryInterval)
@@ -54,14 +52,11 @@ public class FeedRefreshIntervalCalculator {
}
Duration retryInterval =
errorHandling
.backoffInterval()
.multipliedBy(errorCount - errorHandling.retriesBeforeBackoff() + 1L);
errorHandling.backoffInterval().multipliedBy(errorCount - errorHandling.retriesBeforeBackoff() + 1L);
return constrainToBounds(instantSource.instant().plus(retryInterval));
}
private Instant computeEmpiricalRefreshInterval(
Instant publishedDate, Long averageEntryInterval) {
private Instant computeEmpiricalRefreshInterval(Instant publishedDate, Long averageEntryInterval) {
Instant now = instantSource.instant();
if (publishedDate == null) {
@@ -80,9 +75,7 @@ public class FeedRefreshIntervalCalculator {
int factor = 2;
long millis =
Longs.constrainToRange(
averageEntryInterval / factor,
interval.toMillis(),
maxInterval.dividedBy(4).toMillis());
averageEntryInterval / factor, interval.toMillis(), maxInterval.dividedBy(4).toMillis());
return now.plusMillis(millis);
} else {
// unknown case

View File

@@ -64,8 +64,7 @@ public class FeedRefreshUpdater {
entryInserted = metrics.meter(MetricRegistry.name(getClass(), "entryInserted"));
}
private AddEntryResult addEntry(
final Feed feed, final Entry entry, final List<FeedSubscription> subscriptions) {
private AddEntryResult addEntry(final Feed feed, final Entry entry, final List<FeedSubscription> subscriptions) {
boolean processed = false;
FeedEntry insertedEntry = null;
Set<FeedSubscription> subscriptionsForWhichEntryIsUnread = new HashSet<>();
@@ -91,8 +90,7 @@ public class FeedRefreshUpdater {
if (locked1 && locked2) {
processed = true;
insertedEntry =
unitOfWork.call(
() -> {
unitOfWork.call(() -> {
if (feedEntryService.find(feed, entry) != null) {
// entry already exists, nothing to do
return null;
@@ -101,8 +99,7 @@ public class FeedRefreshUpdater {
FeedEntry feedEntry = feedEntryService.create(feed, entry);
entryInserted.mark();
for (FeedSubscription sub : subscriptions) {
boolean unread =
feedEntryService.applyFilter(sub, feedEntry);
boolean unread = feedEntryService.applyFilter(sub, feedEntry);
if (unread) {
subscriptionsForWhichEntryIsUnread.add(sub);
}
@@ -113,11 +110,7 @@ public class FeedRefreshUpdater {
log.error("lock timeout for {} - {}", feed.getUrl(), key1);
}
} catch (InterruptedException e) {
log.error(
"interrupted while waiting for lock for {} : {}",
feed.getUrl(),
e.getMessage(),
e);
log.error("interrupted while waiting for lock for {} : {}", feed.getUrl(), e.getMessage(), e);
Thread.currentThread().interrupt();
} finally {
if (locked1) {
@@ -133,13 +126,11 @@ public class FeedRefreshUpdater {
public FeedRefreshUpdaterResult update(Feed feed, List<Entry> entries) {
boolean processed = true;
long inserted = 0;
Map<FeedSubscription, List<FeedEntry>> insertedUnreadEntriesBySubscription =
new HashMap<>();
Map<FeedSubscription, List<FeedEntry>> insertedUnreadEntriesBySubscription = new HashMap<>();
if (!entries.isEmpty()) {
List<FeedSubscription> subscriptions = null;
List<Entry> newEntries =
unitOfWork.call(() -> feedEntryService.removeExistingEntries(feed, entries));
List<Entry> newEntries = unitOfWork.call(() -> feedEntryService.removeExistingEntries(feed, entries));
for (Entry entry : newEntries) {
if (subscriptions == null) {
subscriptions = unitOfWork.call(() -> feedSubscriptionDAO.findByFeed(feed));
@@ -147,8 +138,7 @@ public class FeedRefreshUpdater {
AddEntryResult addEntryResult = addEntry(feed, entry, subscriptions);
processed &= addEntryResult.processed;
inserted += addEntryResult.insertedEntry != null ? 1 : 0;
addEntryResult.subscriptionsForWhichEntryIsUnread.forEach(
sub -> {
addEntryResult.subscriptionsForWhichEntryIsUnread.forEach(sub -> {
if (addEntryResult.insertedEntry != null) {
insertedUnreadEntriesBySubscription
.computeIfAbsent(sub, k -> new ArrayList<>())
@@ -179,9 +169,7 @@ public class FeedRefreshUpdater {
}
private record AddEntryResult(
boolean processed,
FeedEntry insertedEntry,
Set<FeedSubscription> subscriptionsForWhichEntryIsUnread) {}
boolean processed, FeedEntry insertedEntry, Set<FeedSubscription> subscriptionsForWhichEntryIsUnread) {}
public record FeedRefreshUpdaterResult(
Map<FeedSubscription, List<FeedEntry>> insertedUnreadEntriesBySubscription) {}

View File

@@ -22,8 +22,8 @@ import java.util.List;
import java.util.Optional;
/**
* Calls {@link FeedFetcher} and updates the Feed object, but does not update the database, ({@link
* FeedRefreshUpdater} does that)
* Calls {@link FeedFetcher} and updates the Feed object, but does not update the database, ({@link FeedRefreshUpdater}
* does that)
*/
@Slf4j
@Singleton
@@ -68,10 +68,7 @@ public class FeedRefreshWorker {
Duration entriesMaxAge = config.database().cleanup().entriesMaxAge();
if (!entriesMaxAge.isZero()) {
Instant threshold = Instant.now().minus(entriesMaxAge);
entries =
entries.stream()
.filter(entry -> entry.published().isAfter(threshold))
.toList();
entries = entries.stream().filter(entry -> entry.published().isAfter(threshold)).toList();
}
String urlAfterRedirect = result.urlAfterRedirect();
@@ -121,9 +118,7 @@ public class FeedRefreshWorker {
feed.setErrorCount(feed.getErrorCount() + 1);
feed.setMessage("Server indicated that we are sending too many requests");
feed.setDisabledUntil(
refreshIntervalCalculator.onTooManyRequests(
e.getRetryAfter(), feed.getErrorCount()));
feed.setDisabledUntil(refreshIntervalCalculator.onTooManyRequests(e.getRetryAfter(), feed.getErrorCount()));
return new FeedRefreshWorkerResult(feed, Collections.emptyList());
} catch (Exception e) {

View File

@@ -30,15 +30,12 @@ public class FeedUpdateNotifier {
public void notifyOverWebsocket(FeedSubscription sub, List<FeedEntry> entries) {
if (!entries.isEmpty()) {
webSocketSessions.sendMessage(
sub.getUser(), WebSocketMessageBuilder.newFeedEntries(sub, entries.size()));
webSocketSessions.sendMessage(sub.getUser(), WebSocketMessageBuilder.newFeedEntries(sub, entries.size()));
}
}
public void sendPushNotifications(FeedSubscription sub, List<FeedEntry> entries) {
if (!config.pushNotifications().enabled()
|| !sub.isPushNotificationsEnabled()
|| entries.isEmpty()) {
if (!config.pushNotifications().enabled() || !sub.isPushNotificationsEnabled() || entries.isEmpty()) {
return;
}

View File

@@ -40,8 +40,7 @@ public class FeedUtils {
return false;
}
return TextDirectionDetector.detect(stripped)
== TextDirectionDetector.Direction.RIGHT_TO_LEFT;
return TextDirectionDetector.detect(stripped) == TextDirectionDetector.Direction.RIGHT_TO_LEFT;
}
public static String getFaviconUrl(FeedSubscription subscription) {

View File

@@ -14,8 +14,8 @@ import java.nio.charset.Charset;
public class EncodingDetector {
/**
* Detect feed encoding by using the declared encoding in the xml processing instruction and by
* detecting the characters used in the feed
* Detect feed encoding by using the declared encoding in the xml processing instruction and by detecting the
* characters used in the feed
*/
public Charset getEncoding(byte[] bytes) {
String extracted = extractDeclaredEncoding(bytes);

View File

@@ -42,14 +42,12 @@ import java.util.stream.Collectors;
@Singleton
public class FeedParser {
private static final Namespace ATOM_10_NS =
Namespace.getNamespace("http://www.w3.org/2005/Atom");
private static final Namespace ATOM_10_NS = Namespace.getNamespace("http://www.w3.org/2005/Atom");
private static final Instant START = Instant.ofEpochMilli(86400000);
private static final Instant END = Instant.ofEpochMilli(1000L * Integer.MAX_VALUE - 86400000);
private static final Comparator<Entry> ENTRY_COMPARATOR =
Comparator.comparing(Entry::published).reversed();
private static final Comparator<Entry> ENTRY_COMPARATOR = Comparator.comparing(Entry::published).reversed();
private final EncodingDetector encodingDetector;
private final XMLCleaner xmlCleaner;
@@ -78,20 +76,13 @@ public class FeedParser {
List<Entry> entries = buildEntries(feed, feedUrl);
Instant lastEntryDate = entries.stream().findFirst().map(Entry::published).orElse(null);
Instant lastPublishedDate = toValidInstant(feed.getPublishedDate(), false);
if (lastPublishedDate == null
|| lastEntryDate != null && lastPublishedDate.isBefore(lastEntryDate)) {
if (lastPublishedDate == null || lastEntryDate != null && lastPublishedDate.isBefore(lastEntryDate)) {
lastPublishedDate = lastEntryDate;
}
Long averageEntryInterval = averageTimeBetweenEntries(entries);
return new FeedParserResult(
title,
link,
iconUrl,
lastPublishedDate,
averageEntryInterval,
lastEntryDate,
entries);
title, link, iconUrl, lastPublishedDate, averageEntryInterval, lastEntryDate, entries);
} catch (FeedParsingException e) {
throw e;
} catch (Exception e) {
@@ -151,9 +142,7 @@ public class FeedParser {
String author = StringUtils.trimToNull(item.getAuthor());
String categories =
StringUtils.trimToNull(
item.getCategories().stream()
.map(SyndCategory::getName)
.collect(Collectors.joining(", ")));
item.getCategories().stream().map(SyndCategory::getName).collect(Collectors.joining(", ")));
Enclosure enclosure = buildEnclosure(item);
Media media = buildMedia(item);
@@ -281,9 +270,7 @@ public class FeedParser {
SummaryStatistics stats = new SummaryStatistics();
for (int i = 0; i < entries.size() - 1; i++) {
long diff =
Math.abs(
entries.get(i).published().toEpochMilli()
- entries.get(i + 1).published().toEpochMilli());
Math.abs(entries.get(i).published().toEpochMilli() - entries.get(i + 1).published().toEpochMilli());
stats.addValue(diff);
}
return (long) stats.getMean();

View File

@@ -14,18 +14,9 @@ public record FeedParserResult(
public record Entry(String guid, String url, Instant published, Content content) {}
public record Content(
String title,
String content,
String author,
String categories,
Enclosure enclosure,
Media media) {}
String title, String content, String author, String categories, Enclosure enclosure, Media media) {}
public record Enclosure(String url, String type) {}
public record Media(
String description,
String thumbnailUrl,
Integer thumbnailWidth,
Integer thumbnailHeight) {}
public record Media(String description, String thumbnailUrl, Integer thumbnailWidth, Integer thumbnailHeight) {}
}

View File

@@ -13,11 +13,9 @@ import java.util.regex.Pattern;
@Singleton
public class XMLCleaner {
private static final Pattern DOCTYPE_PATTERN =
Pattern.compile("<!DOCTYPE[^>]*>", Pattern.CASE_INSENSITIVE);
private static final Pattern DOCTYPE_PATTERN = Pattern.compile("<!DOCTYPE[^>]*>", Pattern.CASE_INSENSITIVE);
private final Trie trie =
Trie.builder().ignoreOverlaps().addKeywords(HtmlEntities.HTML_ENTITIES).build();
private final Trie trie = Trie.builder().ignoreOverlaps().addKeywords(HtmlEntities.HTML_ENTITIES).build();
public String clean(String xml) {
xml = removeCharactersBeforeFirstXmlTag(xml);

View File

@@ -42,6 +42,9 @@ public class User extends AbstractModel {
@Column(nullable = false)
private boolean disabled;
@Column(nullable = false)
private int sessionVersion;
@Column private Instant lastLogin;
@Column private Instant created;

View File

@@ -155,8 +155,7 @@ public class UserSettings extends AbstractModel {
private boolean instapaper;
private boolean buffer;
@Embedded
private PushNotificationUserSettings pushNotifications = new PushNotificationUserSettings();
@Embedded private PushNotificationUserSettings pushNotifications = new PushNotificationUserSettings();
@Embeddable
@SuppressWarnings("serial")

View File

@@ -49,8 +49,7 @@ public class OPMLExporter {
}
// export root subscriptions
for (FeedSubscription sub :
subscriptions.stream().filter(s -> s.getCategory() == null).toList()) {
for (FeedSubscription sub : subscriptions.stream().filter(s -> s.getCategory() == null).toList()) {
opml.getOutlines().add(buildSubscriptionOutline(sub));
}
@@ -65,20 +64,14 @@ public class OPMLExporter {
for (FeedCategory child :
categories.stream()
.filter(
c ->
c.getParent() != null
&& c.getParent().getId().equals(cat.getId()))
.filter(c -> c.getParent() != null && c.getParent().getId().equals(cat.getId()))
.toList()) {
outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions));
}
for (FeedSubscription sub :
subscriptions.stream()
.filter(
s ->
s.getCategory() != null
&& s.getCategory().getId().equals(cat.getId()))
.filter(s -> s.getCategory() != null && s.getCategory().getId().equals(cat.getId()))
.toList()) {
outline.getChildren().add(buildSubscriptionOutline(sub));
}

View File

@@ -79,8 +79,7 @@ public class OPMLImporter {
}
// make sure we continue with the import process even if a feed failed
try {
feedSubscriptionService.subscribe(
user, outline.getXmlUrl(), name, parent, position);
feedSubscriptionService.subscribe(user, outline.getXmlUrl(), name, parent, position);
} catch (Exception e) {
log.error("error while importing {}: {}", outline.getXmlUrl(), e.getMessage());
}

View File

@@ -33,10 +33,8 @@ import java.util.List;
public class FeedEntryContentCleaningService {
private static final Safelist HTML_WHITELIST = buildWhiteList();
private static final List<String> ALLOWED_IFRAME_CSS_RULES =
Arrays.asList("height", "width", "border");
private static final List<String> ALLOWED_IMG_CSS_RULES =
Arrays.asList("display", "width", "height");
private static final List<String> ALLOWED_IFRAME_CSS_RULES = Arrays.asList("height", "width", "border");
private static final List<String> ALLOWED_IMG_CSS_RULES = Arrays.asList("display", "width", "height");
private static final char[] FORBIDDEN_CSS_RULE_CHARACTERS = new char[] {'(', ')'};
public String clean(String content, String baseUri, boolean keepTextOnly) {
@@ -59,8 +57,7 @@ public class FeedEntryContentCleaningService {
e.attr("style", escaped);
}
clean.outputSettings(
new OutputSettings().escapeMode(EscapeMode.base).prettyPrint(false));
clean.outputSettings(new OutputSettings().escapeMode(EscapeMode.base).prettyPrint(false));
Element body = clean.body();
if (keepTextOnly) {
content = body.text();
@@ -126,24 +123,13 @@ public class FeedEntryContentCleaningService {
whitelist.addAttributes("blockquote", "cite");
whitelist.addAttributes("col", "span", "width");
whitelist.addAttributes("colgroup", "span", "width");
whitelist.addAttributes(
"iframe", "src", "height", "width", "allowfullscreen", "frameborder", "style");
whitelist.addAttributes("iframe", "src", "height", "width", "allowfullscreen", "frameborder", "style");
whitelist.addAttributes("img", "align", "alt", "height", "src", "title", "width", "style");
whitelist.addAttributes("ol", "start", "type");
whitelist.addAttributes("q", "cite");
whitelist.addAttributes("table", "border", "bordercolor", "summary", "width");
whitelist.addAttributes(
"td", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "width");
whitelist.addAttributes(
"th",
"border",
"bordercolor",
"abbr",
"axis",
"colspan",
"rowspan",
"scope",
"width");
whitelist.addAttributes("td", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "width");
whitelist.addAttributes("th", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "scope", "width");
whitelist.addAttributes("ul", "type");
whitelist.addProtocols("a", "href", "ftp", "http", "https", "magnet", "mailto");
@@ -161,8 +147,7 @@ public class FeedEntryContentCleaningService {
String rule = "";
try {
List<String> rules = new ArrayList<>();
CSSStyleDeclaration decl =
buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
for (int i = 0; i < decl.getLength(); i++) {
String property = decl.item(i);
@@ -187,8 +172,7 @@ public class FeedEntryContentCleaningService {
String rule = "";
try {
List<String> rules = new ArrayList<>();
CSSStyleDeclaration decl =
buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
for (int i = 0; i < decl.getLength(); i++) {
String property = decl.item(i);
@@ -216,8 +200,7 @@ public class FeedEntryContentCleaningService {
new ErrorHandler() {
@Override
public void warning(CSSParseException exception) throws CSSException {
log.debug(
"warning while parsing css: {}", exception.getMessage(), exception);
log.debug("warning while parsing css: {}", exception.getMessage(), exception);
}
@Override
@@ -227,10 +210,7 @@ public class FeedEntryContentCleaningService {
@Override
public void fatalError(CSSParseException exception) throws CSSException {
log.debug(
"fatal error while parsing css: {}",
exception.getMessage(),
exception);
log.debug("fatal error while parsing css: {}", exception.getMessage(), exception);
}
});

View File

@@ -27,9 +27,7 @@ public class FeedEntryContentService {
public FeedEntryContent findOrCreate(Content content, String baseUrl) {
FeedEntryContent entryContent = buildContent(content, baseUrl);
Optional<FeedEntryContent> existing =
feedEntryContentDAO
.findExisting(entryContent.getContentHash(), entryContent.getTitleHash())
.stream()
feedEntryContentDAO.findExisting(entryContent.getContentHash(), entryContent.getTitleHash()).stream()
.filter(entryContent::equivalentTo)
.findFirst();
if (existing.isPresent()) {
@@ -44,11 +42,9 @@ public class FeedEntryContentService {
FeedEntryContent entryContent = new FeedEntryContent();
entryContent.setTitleHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.title())));
entryContent.setContentHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.content())));
entryContent.setTitle(
FeedUtils.truncate(cleaningService.clean(content.title(), baseUrl, true), 2048));
entryContent.setTitle(FeedUtils.truncate(cleaningService.clean(content.title(), baseUrl, true), 2048));
entryContent.setContent(cleaningService.clean(content.content(), baseUrl, false));
entryContent.setAuthor(
FeedUtils.truncate(cleaningService.clean(content.author(), baseUrl, true), 128));
entryContent.setAuthor(FeedUtils.truncate(cleaningService.clean(content.author(), baseUrl, true), 128));
entryContent.setCategories(FeedUtils.truncate(content.categories(), 4096));
entryContent.setDirection(
FeedUtils.isRTL(content.title(), content.content())
@@ -63,8 +59,7 @@ public class FeedEntryContentService {
Media media = content.media();
if (media != null) {
entryContent.setMediaDescription(
cleaningService.clean(media.description(), baseUrl, false));
entryContent.setMediaDescription(cleaningService.clean(media.description(), baseUrl, false));
entryContent.setMediaThumbnailUrl(FeedUtils.truncate(media.thumbnailUrl(), 2048));
entryContent.setMediaThumbnailWidth(media.thumbnailWidth());
entryContent.setMediaThumbnailHeight(media.thumbnailHeight());

View File

@@ -45,33 +45,22 @@ public class FeedEntryFilteringService {
.addVar("categories", SimpleType.STRING)
.addVar("categoriesLower", SimpleType.STRING)
.build();
private static final CelRuntime CEL_RUNTIME =
CelRuntimeFactory.standardCelRuntimeBuilder().build();
private static final CelRuntime CEL_RUNTIME = CelRuntimeFactory.plannerRuntimeBuilder().build();
private final ExecutorService executor = Executors.newCachedThreadPool();
private final CommaFeedConfiguration config;
public boolean filterMatchesEntry(String filter, FeedEntry entry)
throws FeedEntryFilterException {
public boolean filterMatchesEntry(String filter, FeedEntry entry) throws FeedEntryFilterException {
if (StringUtils.isBlank(filter)) {
return true;
}
String title =
entry.getContent().getTitle() == null
? ""
: Jsoup.parse(entry.getContent().getTitle()).text();
String author =
entry.getContent().getAuthor() == null ? "" : entry.getContent().getAuthor();
String title = entry.getContent().getTitle() == null ? "" : Jsoup.parse(entry.getContent().getTitle()).text();
String author = entry.getContent().getAuthor() == null ? "" : entry.getContent().getAuthor();
String content =
entry.getContent().getContent() == null
? ""
: Jsoup.parse(entry.getContent().getContent()).text();
entry.getContent().getContent() == null ? "" : Jsoup.parse(entry.getContent().getContent()).text();
String url = entry.getUrl() == null ? "" : entry.getUrl();
String categories =
entry.getContent().getCategories() == null
? ""
: entry.getContent().getCategories();
String categories = entry.getContent().getCategories() == null ? "" : entry.getContent().getCategories();
Map<String, Object> data = new HashMap<>();
data.put("title", title);
@@ -98,11 +87,9 @@ public class FeedEntryFilteringService {
TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new FeedEntryFilterException(
"interrupted while evaluating expression " + filter, e);
throw new FeedEntryFilterException("interrupted while evaluating expression " + filter, e);
} catch (ExecutionException e) {
throw new FeedEntryFilterException(
"Exception while evaluating expression " + filter, e);
throw new FeedEntryFilterException("Exception while evaluating expression " + filter, e);
} catch (TimeoutException e) {
throw new FeedEntryFilterException("Took too long evaluating expression " + filter, e);
}

View File

@@ -55,12 +55,9 @@ public class FeedEntryService {
}
public List<Entry> removeExistingEntries(Feed feed, List<Entry> entries) {
Set<String> guidHashes =
entries.stream().map(e -> Digests.sha1Hex(e.guid())).collect(Collectors.toSet());
Set<String> guidHashes = entries.stream().map(e -> Digests.sha1Hex(e.guid())).collect(Collectors.toSet());
Set<String> existingGuidHashes = feedEntryDAO.findExistingGuidHashes(guidHashes, feed);
return entries.stream()
.filter(e -> !existingGuidHashes.contains(Digests.sha1Hex(e.guid())))
.toList();
return entries.stream().filter(e -> !existingGuidHashes.contains(Digests.sha1Hex(e.guid()))).toList();
}
public boolean applyFilter(FeedSubscription sub, FeedEntry entry) {
@@ -122,41 +119,24 @@ public class FeedEntryService {
List<FeedEntryKeyword> keywords) {
List<FeedEntryStatus> statuses =
feedEntryStatusDAO.findBySubscriptions(
user,
subscriptions,
true,
keywords,
null,
-1,
-1,
null,
false,
null,
null,
null);
user, subscriptions, true, keywords, null, -1, -1, null, false, null, null, null);
markList(statuses, olderThan, insertedBefore);
}
public void markStarredEntries(User user, Instant olderThan, Instant insertedBefore) {
List<FeedEntryStatus> statuses =
feedEntryStatusDAO.findStarred(user, null, null, -1, -1, null, false);
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findStarred(user, null, null, -1, -1, null, false);
markList(statuses, olderThan, insertedBefore);
}
private void markList(
List<FeedEntryStatus> statuses, Instant olderThan, Instant insertedBefore) {
private void markList(List<FeedEntryStatus> statuses, Instant olderThan, Instant insertedBefore) {
List<FeedEntryStatus> statusesToMark =
statuses.stream()
.filter(FeedEntryStatus::isMarkable)
.filter(
s -> {
.filter(s -> {
Instant entryDate = s.getEntry().getPublished();
return olderThan == null
|| entryDate == null
|| entryDate.isBefore(olderThan);
return olderThan == null || entryDate == null || entryDate.isBefore(olderThan);
})
.filter(
s -> {
.filter(s -> {
Instant insertedDate = s.getEntry().getInserted();
return insertedBefore == null
|| insertedDate == null
@@ -164,8 +144,7 @@ public class FeedEntryService {
})
.toList();
statusesToMark.forEach(
s -> {
statusesToMark.forEach(s -> {
s.setRead(true);
feedEntryStatusDAO.merge(s);
});

View File

@@ -36,16 +36,14 @@ public class FeedEntryTagService {
}
List<FeedEntryTag> existingTags = feedEntryTagDAO.findByEntry(user, entry);
Set<String> existingTagNames =
existingTags.stream().map(FeedEntryTag::getName).collect(Collectors.toSet());
Set<String> existingTagNames = existingTags.stream().map(FeedEntryTag::getName).collect(Collectors.toSet());
List<FeedEntryTag> addList =
tagNames.stream()
.filter(name -> !existingTagNames.contains(name))
.map(name -> new FeedEntryTag(user, entry, name))
.toList();
List<FeedEntryTag> removeList =
existingTags.stream().filter(tag -> !tagNames.contains(tag.getName())).toList();
List<FeedEntryTag> removeList = existingTags.stream().filter(tag -> !tagNames.contains(tag.getName())).toList();
addList.forEach(feedEntryTagDAO::persist);
feedEntryTagDAO.delete(removeList);

View File

@@ -36,8 +36,7 @@ public class FeedFaviconService {
this.defaultFavicon =
new Favicon(
Resources.toByteArray(
Objects.requireNonNull(
getClass().getResource("/images/default_favicon.gif"))),
Objects.requireNonNull(getClass().getResource("/images/default_favicon.gif"))),
"image/gif");
}

View File

@@ -19,7 +19,6 @@ import lombok.extern.slf4j.Slf4j;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Singleton
@@ -47,18 +46,15 @@ public class FeedSubscriptionService {
// we need to use this hook because the feed needs to have been persisted before being
// processed
// by the feed engine
feedSubscriptionDAO.onPostCommitInsert(
sub -> {
feedSubscriptionDAO.onPostCommitInsert(sub -> {
Feed feed = sub.getFeed();
if (feed.getDisabledUntil() == null
|| feed.getDisabledUntil().isBefore(Instant.now())) {
if (feed.getDisabledUntil() == null || feed.getDisabledUntil().isBefore(Instant.now())) {
feedRefreshEngine.refreshImmediately(feed);
}
});
}
public long subscribe(
User user, String url, String title, FeedCategory category, int position) {
public long subscribe(User user, String url, String title, FeedCategory category, int position) {
Integer maxFeedsPerUser = config.database().cleanup().maxFeedsPerUser();
if (maxFeedsPerUser > 0 && feedSubscriptionDAO.count(user) >= maxFeedsPerUser) {
String message =
@@ -100,9 +96,7 @@ public class FeedSubscriptionService {
public void refreshAll(User user) throws ForceFeedRefreshTooSoonException {
Instant lastForceRefresh = user.getLastForceRefresh();
if (lastForceRefresh != null
&& lastForceRefresh
.plus(config.feedRefresh().forceRefreshCooldownDuration())
.isAfter(Instant.now())) {
&& lastForceRefresh.plus(config.feedRefresh().forceRefreshCooldownDuration()).isAfter(Instant.now())) {
throw new ForceFeedRefreshTooSoonException();
}
@@ -116,10 +110,7 @@ public class FeedSubscriptionService {
}
public Map<Long, UnreadCount> getUnreadCount(User user) {
return feedSubscriptionDAO.findAll(user).stream()
.collect(
Collectors.toMap(
FeedSubscription::getId, feedEntryStatusDAO::getUnreadCount));
return feedEntryStatusDAO.getUnreadCounts(feedSubscriptionDAO.findAll(user));
}
@SuppressWarnings("serial")

View File

@@ -19,7 +19,6 @@ 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.async.CloseableHttpAsyncClient;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.HttpResponse;
@@ -29,7 +28,6 @@ import org.apache.hc.core5.http.message.BasicNameValuePair;
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 org.apache.hc.core5.util.Timeout;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -37,6 +35,8 @@ 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
@@ -47,16 +47,13 @@ public class PushNotificationService {
private final CommaFeedConfiguration config;
public PushNotificationService(
HttpClientFactory httpClientFactory,
MetricRegistry metrics,
CommaFeedConfiguration config) {
HttpClientFactory httpClientFactory, MetricRegistry metrics, CommaFeedConfiguration config) {
this.httpClient = httpClientFactory.newClient(config.pushNotifications().threads());
this.meter = metrics.meter(MetricRegistry.name(getClass(), "notify"));
this.config = config;
}
public void notify(
PushNotificationUserSettings settings, FeedSubscription subscription, FeedEntry entry) {
public void notify(PushNotificationUserSettings settings, FeedSubscription subscription, FeedEntry entry) {
if (!config.pushNotifications().enabled() || settings.getType() == null) {
return;
}
@@ -79,9 +76,7 @@ public class PushNotificationService {
case NTFY -> sendNtfy(settings, feedTitle, entryTitle, entryUrl);
case GOTIFY -> sendGotify(settings, feedTitle, entryTitle, entryUrl);
case PUSHOVER -> sendPushover(settings, feedTitle, entryTitle, entryUrl);
default ->
throw new IllegalStateException(
"unsupported notification type: " + settings.getType());
default -> throw new IllegalStateException("unsupported notification type: " + settings.getType());
}
} catch (IOException e) {
throw new PushNotificationException("Failed to send external notification", e);
@@ -90,11 +85,7 @@ public class PushNotificationService {
meter.mark();
}
private void sendNtfy(
PushNotificationUserSettings settings,
String feedTitle,
String entryTitle,
String entryUrl)
private void sendNtfy(PushNotificationUserSettings settings, String feedTitle, String entryTitle, String entryUrl)
throws IOException {
String serverUrl = Urls.removeTrailingSlash(settings.getServerUrl());
String topic = settings.getTopic();
@@ -105,10 +96,6 @@ public class PushNotificationService {
}
SimpleRequestBuilder request = SimpleRequestBuilder.post(serverUrl + "/" + topic);
request.setRequestConfig(
RequestConfig.custom()
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
.build());
request.addHeader("Title", feedTitle);
request.setBody(entryTitle, ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8));
@@ -123,11 +110,7 @@ public class PushNotificationService {
send("ntfy", request.build());
}
private void sendGotify(
PushNotificationUserSettings settings,
String feedTitle,
String entryTitle,
String entryUrl)
private void sendGotify(PushNotificationUserSettings settings, String feedTitle, String entryTitle, String entryUrl)
throws IOException {
String serverUrl = Urls.removeTrailingSlash(settings.getServerUrl());
String token = settings.getUserSecret();
@@ -147,15 +130,10 @@ public class PushNotificationService {
new JsonObject()
.put(
"client::notification",
new JsonObject()
.put("click", new JsonObject().put("url", entryUrl))));
new JsonObject().put("click", new JsonObject().put("url", entryUrl))));
}
SimpleRequestBuilder request = SimpleRequestBuilder.post(serverUrl + "/message");
request.setRequestConfig(
RequestConfig.custom()
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
.build());
request.addHeader("X-Gotify-Key", token);
request.setBody(json.toString(), ContentType.APPLICATION_JSON);
@@ -163,10 +141,7 @@ public class PushNotificationService {
}
private void sendPushover(
PushNotificationUserSettings settings,
String feedTitle,
String entryTitle,
String entryUrl)
PushNotificationUserSettings settings, String feedTitle, String entryTitle, String entryUrl)
throws IOException {
String token = settings.getUserSecret();
String userKey = settings.getUserId();
@@ -185,12 +160,7 @@ public class PushNotificationService {
params.add(new BasicNameValuePair("url", entryUrl));
}
SimpleRequestBuilder request =
SimpleRequestBuilder.post("https://api.pushover.net/1/messages.json");
request.setRequestConfig(
RequestConfig.custom()
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
.build());
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));
@@ -205,16 +175,20 @@ public class PushNotificationService {
new BasicResponseConsumer<>(new DiscardingEntityConsumer<>()),
null);
try {
HttpResponse response = future.get().getHead();
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());
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());
}
}

View File

@@ -53,8 +53,7 @@ public class UserService {
user = userDAO.findByEmail(nameOrEmail);
}
if (user != null && !user.isDisabled()) {
boolean authenticated =
encryptionService.authenticate(password, user.getPassword(), user.getSalt());
boolean authenticated = encryptionService.authenticate(password, user.getPassword(), user.getSalt());
if (authenticated) {
performPostLoginActivities(user);
return Optional.of(user);
@@ -107,16 +106,11 @@ public class UserService {
}
public User register(
String name,
String password,
String email,
Collection<Role> roles,
boolean forceRegistration) {
String name, String password, String email, Collection<Role> roles, boolean forceRegistration) {
if (!forceRegistration) {
Preconditions.checkState(
config.users().allowRegistrations(),
"Registrations are closed on this CommaFeed instance");
config.users().allowRegistrations(), "Registrations are closed on this CommaFeed instance");
}
Preconditions.checkArgument(userDAO.findByName(name) == null, "Name already taken");
@@ -155,10 +149,19 @@ public class UserService {
userDAO.delete(user);
}
public void changePassword(User user, String password) {
user.setPassword(encryptionService.getEncryptedPassword(password, user.getSalt()));
userDAO.merge(user);
invalidateSessions(user);
}
public void invalidateSessions(User user) {
user.setSessionVersion(user.getSessionVersion() + 1);
}
public String generateApiKey(User user) {
byte[] key =
encryptionService.getEncryptedPassword(
UUID.randomUUID().toString(), user.getSalt());
byte[] key = encryptionService.getEncryptedPassword(UUID.randomUUID().toString(), user.getSalt());
return Digests.sha1Hex(key);
}

Some files were not shown because too many files have changed in this diff Show More