mirror of
https://github.com/Athou/commafeed.git
synced 2026-11-02 18:34:48 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01ac8d6ebd | ||
|
|
ddff408afe | ||
|
|
0c280c4dc4 | ||
|
|
ca4642f7c9 | ||
|
|
93edb47dca | ||
|
|
801680e373 | ||
|
|
5704e1e1fa | ||
|
|
a2a6720cd1 | ||
|
|
4f8cddeb54 | ||
|
|
90d0846bad | ||
|
|
698b2f4dfd | ||
|
|
0053c54ae4 | ||
|
|
16b6077496 | ||
|
|
edb4d5e178 | ||
|
|
4b091fb90b | ||
|
|
30c049d10a | ||
|
|
fa8a447787 | ||
|
|
20ec5d3eb9 | ||
|
|
796dc88c33 | ||
|
|
f979f5426f | ||
|
|
8f9be28002 | ||
|
|
89845681be | ||
|
|
8fdf63dd73 | ||
|
|
8f07454270 | ||
|
|
946d82d8ee | ||
|
|
f8d625b651 | ||
|
|
eefdd40a02 | ||
|
|
08f2889f1d | ||
|
|
45453fcf4f | ||
|
|
ef7bab4841 | ||
|
|
ac277f9ca5 | ||
|
|
44dab0287b | ||
|
|
88b75ec0cb | ||
|
|
cbb45ce68c | ||
|
|
b0c4dbf533 | ||
|
|
1891b63a0e | ||
|
|
c420fec8ee | ||
|
|
2ee2a5dfbe | ||
|
|
a5b98f9ea2 | ||
|
|
baedab2960 | ||
|
|
004e18bbb8 | ||
|
|
72e364afe9 | ||
|
|
86ff2dfcb2 | ||
|
|
69ce73012c | ||
|
|
f3bd1a2e11 | ||
|
|
a819283661 | ||
|
|
8255c1e929 | ||
|
|
eb7990f8dc | ||
|
|
982476b2d0 | ||
|
|
3ec653c3b7 | ||
|
|
5dc5b3795e | ||
|
|
f5f77a80a3 | ||
|
|
1d1099492e | ||
|
|
ccc2564929 | ||
|
|
084574ffd4 |
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$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,
|
||||
|
||||
1131
commafeed-client/package-lock.json
generated
1131
commafeed-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,20 +17,20 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.14.0",
|
||||
"@fontsource/open-sans": "5.3.0",
|
||||
"@lingui/core": "6.10.0",
|
||||
"@lingui/react": "6.10.0",
|
||||
"@mantine/core": "9.7.3",
|
||||
"@mantine/dates": "9.7.3",
|
||||
"@mantine/form": "9.7.3",
|
||||
"@mantine/hooks": "9.7.3",
|
||||
"@mantine/modals": "9.7.3",
|
||||
"@mantine/notifications": "9.7.3",
|
||||
"@mantine/spotlight": "9.7.3",
|
||||
"@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.10.0",
|
||||
"@lingui/vite-plugin": "6.10.0",
|
||||
"@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"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<properties>
|
||||
<!-- renovate: datasource=node-version depName=node -->
|
||||
<node.version>v24.22.1</node.version>
|
||||
<node.version>v26.13.0</node.version>
|
||||
<!-- renovate: datasource=npm depName=npm -->
|
||||
<npm.version>12.2.1</npm.version>
|
||||
</properties>
|
||||
|
||||
@@ -40,7 +40,7 @@ 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 +137,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 +152,7 @@ export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
|
||||
read: true,
|
||||
olderThan: Date.now(),
|
||||
insertedBefore: entriesTimestamp,
|
||||
keywords: search,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ 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 search = useAppSelector(state => state.entries.search)
|
||||
const markAllAsReadNavigateToNextUnread = useAppSelector(state => state.user.settings?.markAllAsReadNavigateToNextUnread)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
@@ -26,6 +27,7 @@ export function MarkAllAsReadConfirmationDialog() {
|
||||
read: true,
|
||||
olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000,
|
||||
insertedBefore: entriesTimestamp,
|
||||
keywords: search,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -37,6 +37,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,7 +128,7 @@ 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} />
|
||||
</>
|
||||
|
||||
@@ -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"
|
||||
@@ -138,7 +138,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>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<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>
|
||||
@@ -404,7 +404,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>
|
||||
@@ -439,7 +439,7 @@
|
||||
<dependency>
|
||||
<groupId>com.ibm.icu</groupId>
|
||||
<artifactId>icu4j</artifactId>
|
||||
<version>78.3</version>
|
||||
<version>79.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.sourceforge.cssparser</groupId>
|
||||
@@ -482,7 +482,7 @@
|
||||
<dependency>
|
||||
<groupId>org.mock-server</groupId>
|
||||
<artifactId>mockserver-junit-jupiter-no-dependencies</artifactId>
|
||||
<version>9.1.0</version>
|
||||
<version>9.2.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM debian:13.7@sha256:4c25275446ca371766b8b093c696782eabce06263fccacb8d5d813b3dc44b308
|
||||
FROM debian:13.7@sha256:2dc419c6d7fe3dad0a2f9fd469c34cc90b0f9ba642d8fde837fe3d7d34a0e7f1
|
||||
LABEL org.opencontainers.image.source="https://github.com/Athou/commafeed"
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -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;
|
||||
@@ -49,8 +52,19 @@ public class ExceptionMappers {
|
||||
|
||||
@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();
|
||||
|
||||
@@ -56,6 +56,8 @@ 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;
|
||||
@@ -66,8 +68,7 @@ public class HttpClientFactory {
|
||||
String userAgent =
|
||||
config.httpClient()
|
||||
.userAgent()
|
||||
.orElseGet(
|
||||
() ->
|
||||
.orElseGet(() ->
|
||||
String.format(
|
||||
"CommaFeed/%s (https://github.com/Athou/commafeed)",
|
||||
version.getVersion()));
|
||||
@@ -150,7 +151,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -283,8 +283,7 @@ public class HttpGetter {
|
||||
}
|
||||
|
||||
return CacheBuilder.newBuilder()
|
||||
.weigher(
|
||||
(HttpRequest key, HttpResponse value) ->
|
||||
.weigher((HttpRequest key, HttpResponse value) ->
|
||||
value.content() != null ? value.content().length : 0)
|
||||
.maximumWeight(cacheConfig.maximumMemorySize().asLongValue())
|
||||
.expireAfterWrite(cacheConfig.expiration())
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -50,7 +51,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
|
||||
EntityManager entityManager,
|
||||
FeedEntryTagDAO feedEntryTagDAO,
|
||||
CommaFeedConfiguration config) {
|
||||
super(entityManager, FeedEntryStatus.class);
|
||||
super(entityManager, FeedEntryStatus.class, config);
|
||||
this.feedEntryTagDAO = feedEntryTagDAO;
|
||||
this.config = config;
|
||||
}
|
||||
@@ -129,8 +130,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 +206,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 +235,41 @@ 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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -124,10 +125,8 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -104,17 +104,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 +132,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()) {
|
||||
@@ -184,8 +180,7 @@ public class FeedRefreshEngine {
|
||||
}
|
||||
},
|
||||
refillExecutor)
|
||||
.whenComplete(
|
||||
(data, ex) -> {
|
||||
.whenComplete((data, ex) -> {
|
||||
if (ex != null) {
|
||||
log.error("error while refilling the queue", ex);
|
||||
}
|
||||
@@ -195,49 +190,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),
|
||||
() -> 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());
|
||||
Instant nextUpdateDate = Instant.now().plus(config.feedRefresh().interval());
|
||||
feedDAO.setDisabledUntil(
|
||||
feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
|
||||
}
|
||||
@@ -292,8 +276,7 @@ public class FeedRefreshEngine {
|
||||
ThreadPoolExecutor pool =
|
||||
new ThreadPoolExecutor(
|
||||
threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
|
||||
pool.setRejectedExecutionHandler(
|
||||
(r, e) -> {
|
||||
pool.setRejectedExecutionHandler((r, e) -> {
|
||||
if (e.isShutdown()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,8 +91,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 +100,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);
|
||||
}
|
||||
@@ -147,8 +145,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<>())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -65,18 +65,15 @@ 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 ->
|
||||
.filter(s ->
|
||||
s.getCategory() != null
|
||||
&& s.getCategory().getId().equals(cat.getId()))
|
||||
.toList()) {
|
||||
|
||||
@@ -45,8 +45,7 @@ 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;
|
||||
|
||||
@@ -148,15 +148,13 @@ public class FeedEntryService {
|
||||
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);
|
||||
})
|
||||
.filter(
|
||||
s -> {
|
||||
.filter(s -> {
|
||||
Instant insertedDate = s.getEntry().getInserted();
|
||||
return insertedBefore == null
|
||||
|| insertedDate == null
|
||||
@@ -164,8 +162,7 @@ public class FeedEntryService {
|
||||
})
|
||||
.toList();
|
||||
|
||||
statusesToMark.forEach(
|
||||
s -> {
|
||||
statusesToMark.forEach(s -> {
|
||||
s.setRead(true);
|
||||
feedEntryStatusDAO.merge(s);
|
||||
});
|
||||
|
||||
@@ -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,8 +46,7 @@ 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())) {
|
||||
@@ -116,10 +114,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")
|
||||
|
||||
@@ -155,6 +155,17 @@ 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(
|
||||
|
||||
@@ -79,13 +79,10 @@ public class DatabaseCleaningService {
|
||||
} while (entriesDeleted > 0);
|
||||
}
|
||||
deleted =
|
||||
unitOfWork.call(
|
||||
() ->
|
||||
unitOfWork.call(() ->
|
||||
feedDAO.delete(
|
||||
feedDAO.findByIds(
|
||||
feeds.stream()
|
||||
.map(AbstractModel::getId)
|
||||
.toList())));
|
||||
feeds.stream().map(AbstractModel::getId).toList())));
|
||||
total += deleted;
|
||||
log.debug("removed {} feeds without subscriptions", total);
|
||||
} while (deleted != 0);
|
||||
@@ -119,8 +116,7 @@ public class DatabaseCleaningService {
|
||||
}
|
||||
|
||||
List<FeedCapacity> feeds =
|
||||
unitOfWork.call(
|
||||
() ->
|
||||
unitOfWork.call(() ->
|
||||
feedEntryDAO.findFeedsExceedingCapacity(
|
||||
maxFeedCapacity, batchSize, keepStarredEntries));
|
||||
if (feeds.isEmpty()) {
|
||||
@@ -139,8 +135,7 @@ public class DatabaseCleaningService {
|
||||
|
||||
final long rem = remaining;
|
||||
deleted =
|
||||
unitOfWork.call(
|
||||
() ->
|
||||
unitOfWork.call(() ->
|
||||
feedEntryDAO.deleteOldEntries(
|
||||
feed.id(),
|
||||
Math.min(batchSize, rem),
|
||||
@@ -166,8 +161,7 @@ public class DatabaseCleaningService {
|
||||
}
|
||||
|
||||
deleted =
|
||||
unitOfWork.call(
|
||||
() ->
|
||||
unitOfWork.call(() ->
|
||||
feedEntryDAO.deleteEntriesOlderThan(
|
||||
olderThan, batchSize, keepStarredEntries));
|
||||
entriesDeletedMeter.mark(deleted);
|
||||
|
||||
@@ -31,8 +31,7 @@ public class DemoAccountCleanupTask extends ScheduledTask {
|
||||
}
|
||||
|
||||
log.info("recreating demo user account");
|
||||
unitOfWork.run(
|
||||
() -> {
|
||||
unitOfWork.run(() -> {
|
||||
User demoUser = userDAO.findByName(CommaFeedConstants.USERNAME_DEMO);
|
||||
if (demoUser == null) {
|
||||
return;
|
||||
|
||||
@@ -6,7 +6,6 @@ import com.commafeed.backend.dao.UserRoleDAO;
|
||||
import com.commafeed.backend.model.User;
|
||||
import com.commafeed.backend.model.UserRole;
|
||||
import com.commafeed.backend.model.UserRole.Role;
|
||||
import com.commafeed.backend.service.PasswordEncryptionService;
|
||||
import com.commafeed.backend.service.UserService;
|
||||
import com.commafeed.frontend.model.UserModel;
|
||||
import com.commafeed.frontend.model.request.AdminSaveUserRequest;
|
||||
@@ -56,7 +55,6 @@ public class AdminREST {
|
||||
private final UserDAO userDAO;
|
||||
private final UserRoleDAO userRoleDAO;
|
||||
private final UserService userService;
|
||||
private final PasswordEncryptionService encryptionService;
|
||||
private final MetricRegistry metrics;
|
||||
|
||||
@Path("/user/save")
|
||||
@@ -102,10 +100,15 @@ public class AdminREST {
|
||||
User u = userDAO.findById(id);
|
||||
u.setName(req.getName());
|
||||
if (StringUtils.isNotBlank(req.getPassword())) {
|
||||
u.setPassword(
|
||||
encryptionService.getEncryptedPassword(req.getPassword(), u.getSalt()));
|
||||
userService.changePassword(u, req.getPassword());
|
||||
if (StringUtils.isNotBlank(u.getApiKey())) {
|
||||
u.setApiKey(userService.generateApiKey(u));
|
||||
}
|
||||
}
|
||||
u.setEmail(req.getEmail());
|
||||
if (!u.isDisabled() && !req.isEnabled()) {
|
||||
userService.invalidateSessions(u);
|
||||
}
|
||||
u.setDisabled(!req.isEnabled());
|
||||
|
||||
Set<Role> roles = userRoleDAO.findRoles(u);
|
||||
|
||||
@@ -435,6 +435,19 @@ public class CategoryREST {
|
||||
&& !Strings.CS.equals(req.getParentId(), String.valueOf(req.getId()))) {
|
||||
parent = feedCategoryDAO.findById(user, Long.valueOf(req.getParentId()));
|
||||
}
|
||||
if (parent != null) {
|
||||
Long parentId = parent.getId();
|
||||
List<FeedCategory> descendants =
|
||||
feedCategoryDAO.findAllChildrenCategories(user, category);
|
||||
boolean parentIsDescendant =
|
||||
descendants.stream().anyMatch(c -> c.getId().equals(parentId));
|
||||
if (parentIsDescendant) {
|
||||
return Response.status(Status.BAD_REQUEST)
|
||||
.entity(
|
||||
"<message>cannot set a category's own descendant as its parent</message>")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
category.setParent(parent);
|
||||
|
||||
if (req.getPosition() != null) {
|
||||
|
||||
@@ -21,7 +21,6 @@ import com.commafeed.backend.model.UserSettings.ReadingMode;
|
||||
import com.commafeed.backend.model.UserSettings.ReadingOrder;
|
||||
import com.commafeed.backend.model.UserSettings.ScrollMode;
|
||||
import com.commafeed.backend.service.MailService;
|
||||
import com.commafeed.backend.service.PasswordEncryptionService;
|
||||
import com.commafeed.backend.service.PushNotificationService;
|
||||
import com.commafeed.backend.service.UserService;
|
||||
import com.commafeed.backend.service.db.DatabaseStartupService;
|
||||
@@ -36,6 +35,7 @@ import com.commafeed.frontend.model.request.RegistrationRequest;
|
||||
import com.commafeed.security.AuthenticationContext;
|
||||
import com.commafeed.security.Roles;
|
||||
import com.google.common.base.Preconditions;
|
||||
import com.google.common.html.HtmlEscapers;
|
||||
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
@@ -86,7 +86,6 @@ public class UserREST {
|
||||
private final UserRoleDAO userRoleDAO;
|
||||
private final UserSettingsDAO userSettingsDAO;
|
||||
private final UserService userService;
|
||||
private final PasswordEncryptionService encryptionService;
|
||||
private final DatabaseStartupService databaseStartupService;
|
||||
private final MailService mailService;
|
||||
private final CommaFeedConfiguration config;
|
||||
@@ -316,10 +315,7 @@ public class UserREST {
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(request.getNewPassword())) {
|
||||
byte[] password =
|
||||
encryptionService.getEncryptedPassword(
|
||||
request.getNewPassword(), user.getSalt());
|
||||
user.setPassword(password);
|
||||
userService.changePassword(user, request.getNewPassword());
|
||||
user.setApiKey(userService.generateApiKey(user));
|
||||
}
|
||||
|
||||
@@ -417,7 +413,7 @@ public class UserREST {
|
||||
Urls.removeTrailingSlash(config.passwordRecoveryPublicBaseUrl().orElseThrow());
|
||||
return String.format(
|
||||
"You asked for password recovery for account '%s', <a href='%s'>follow this link</a> to change your password. Ignore this if you didn't request a password recovery.",
|
||||
user.getName(), callbackUrl(user, publicUrl));
|
||||
HtmlEscapers.htmlEscaper().escape(user.getName()), callbackUrl(user, publicUrl));
|
||||
}
|
||||
|
||||
private String callbackUrl(User user, String publicUrl) throws URISyntaxException {
|
||||
@@ -455,8 +451,7 @@ public class UserREST {
|
||||
return Response.status(Status.UNAUTHORIZED).entity("Token expired.").build();
|
||||
}
|
||||
|
||||
byte[] encryptedPassword = encryptionService.getEncryptedPassword(password, user.getSalt());
|
||||
user.setPassword(encryptedPassword);
|
||||
userService.changePassword(user, password);
|
||||
if (StringUtils.isNotBlank(user.getApiKey())) {
|
||||
user.setApiKey(userService.generateApiKey(user));
|
||||
}
|
||||
|
||||
@@ -140,14 +140,9 @@ public class FeverREST {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.getFirst()));
|
||||
form.getValues()
|
||||
.forEach(
|
||||
(k, v) ->
|
||||
.forEach((k, v) ->
|
||||
params.put(
|
||||
k,
|
||||
v.stream()
|
||||
.map(FormValue::getValue)
|
||||
.findFirst()
|
||||
.orElse(null)));
|
||||
k, v.stream().map(FormValue::getValue).findFirst().orElse(null)));
|
||||
return handle(userId, params);
|
||||
}
|
||||
|
||||
@@ -246,12 +241,10 @@ public class FeverREST {
|
||||
s -> s.getCategory() == null ? 0 : s.getCategory().getId()))
|
||||
.entrySet()
|
||||
.stream()
|
||||
.map(
|
||||
e -> {
|
||||
.map(e -> {
|
||||
FeverFeedGroup fg = new FeverFeedGroup();
|
||||
fg.setGroupId(e.getKey());
|
||||
fg.setFeedIds(
|
||||
e.getValue().stream().map(FeedSubscription::getId).toList());
|
||||
fg.setFeedIds(e.getValue().stream().map(FeedSubscription::getId).toList());
|
||||
return fg;
|
||||
})
|
||||
.toList();
|
||||
@@ -259,8 +252,7 @@ public class FeverREST {
|
||||
|
||||
private List<FeverGroup> buildGroups(List<FeedCategory> categories) {
|
||||
return categories.stream()
|
||||
.map(
|
||||
c -> {
|
||||
.map(c -> {
|
||||
FeverGroup g = new FeverGroup();
|
||||
g.setId(c.getId());
|
||||
g.setTitle(c.getName());
|
||||
@@ -271,8 +263,7 @@ public class FeverREST {
|
||||
|
||||
private List<FeverFeed> buildFeeds(List<FeedSubscription> subscriptions) {
|
||||
return subscriptions.stream()
|
||||
.map(
|
||||
s -> {
|
||||
.map(s -> {
|
||||
FeverFeed f = new FeverFeed();
|
||||
f.setId(s.getId());
|
||||
f.setFaviconId(s.getId());
|
||||
@@ -367,8 +358,7 @@ public class FeverREST {
|
||||
|
||||
private List<FeverFavicon> buildFavicons(List<FeedSubscription> subscriptions) {
|
||||
return subscriptions.stream()
|
||||
.map(
|
||||
s -> {
|
||||
.map(s -> {
|
||||
Favicon favicon = feedFaviconService.fetchFavicon(s.getFeed());
|
||||
|
||||
FeverFavicon f = new FeverFavicon();
|
||||
|
||||
@@ -216,8 +216,7 @@ public class GoogleReaderREST {
|
||||
List<GoogleReaderModel.Tag> tags = new ArrayList<>();
|
||||
feedCategoryDAO
|
||||
.findAll(user)
|
||||
.forEach(
|
||||
c ->
|
||||
.forEach(c ->
|
||||
tags.add(
|
||||
new GoogleReaderModel.Tag(
|
||||
GoogleReaderStreamId.label(c.getName()))));
|
||||
@@ -259,8 +258,7 @@ public class GoogleReaderREST {
|
||||
Map<Long, FeedCategory> categoriesById =
|
||||
feedCategoryDAO.findAll(user).stream()
|
||||
.collect(Collectors.toMap(FeedCategory::getId, c -> c));
|
||||
unreadByCategory.forEach(
|
||||
(categoryId, count) -> {
|
||||
unreadByCategory.forEach((categoryId, count) -> {
|
||||
if (categoryId == 0) {
|
||||
return;
|
||||
}
|
||||
@@ -268,8 +266,7 @@ public class GoogleReaderREST {
|
||||
if (category == null) {
|
||||
return;
|
||||
}
|
||||
GoogleReaderModel.UnreadCountEntry entry =
|
||||
new GoogleReaderModel.UnreadCountEntry();
|
||||
GoogleReaderModel.UnreadCountEntry entry = new GoogleReaderModel.UnreadCountEntry();
|
||||
entry.setId(GoogleReaderStreamId.label(category.getName()));
|
||||
entry.setCount(count);
|
||||
entries.add(entry);
|
||||
@@ -334,10 +331,8 @@ public class GoogleReaderREST {
|
||||
GoogleReaderModel.ItemRefs resp = new GoogleReaderModel.ItemRefs();
|
||||
resp.setItemRefs(
|
||||
paged.statuses().stream()
|
||||
.map(
|
||||
s ->
|
||||
new GoogleReaderModel.ItemRef(
|
||||
String.valueOf(s.getEntry().getId())))
|
||||
.map(s ->
|
||||
new GoogleReaderModel.ItemRef(String.valueOf(s.getEntry().getId())))
|
||||
.toList());
|
||||
resp.setContinuation(paged.continuation());
|
||||
return resp;
|
||||
|
||||
@@ -21,11 +21,11 @@ public class AuthenticationContext {
|
||||
return null;
|
||||
}
|
||||
|
||||
String userId = securityIdentity.getPrincipal().getName();
|
||||
if (userId == null) {
|
||||
String name = securityIdentity.getPrincipal().getName();
|
||||
if (name == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return userDAO.findById(Long.valueOf(userId));
|
||||
return userDAO.findById(SessionPrincipal.parse(name).userId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.commafeed.security;
|
||||
|
||||
import com.commafeed.backend.model.User;
|
||||
|
||||
/** Value of the security principal, which is also what ends up in the encrypted session cookie. */
|
||||
public record SessionPrincipal(long userId, int sessionVersion) {
|
||||
|
||||
private static final String SEPARATOR = ":";
|
||||
|
||||
public static SessionPrincipal of(User user) {
|
||||
return new SessionPrincipal(user.getId(), user.getSessionVersion());
|
||||
}
|
||||
|
||||
public static SessionPrincipal parse(String name) {
|
||||
// cookies created before session versions were introduced only contain the user id
|
||||
if (!name.contains(SEPARATOR)) {
|
||||
return new SessionPrincipal(Long.parseLong(name), 0);
|
||||
}
|
||||
|
||||
String[] parts = name.split(SEPARATOR, 2);
|
||||
return new SessionPrincipal(Long.parseLong(parts[0]), Integer.parseInt(parts[1]));
|
||||
}
|
||||
|
||||
public String toName() {
|
||||
return userId + SEPARATOR + sessionVersion;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.commafeed.backend.dao.UnitOfWork;
|
||||
import com.commafeed.backend.model.User;
|
||||
import com.commafeed.backend.model.UserRole.Role;
|
||||
import com.commafeed.backend.service.UserService;
|
||||
import com.commafeed.security.SessionPrincipal;
|
||||
|
||||
import io.quarkus.security.AuthenticationFailedException;
|
||||
import io.quarkus.security.identity.AuthenticationRequestContext;
|
||||
@@ -38,18 +39,16 @@ public class DatabaseApiKeyIdentityProvider
|
||||
@Override
|
||||
public Uni<SecurityIdentity> authenticate(
|
||||
TokenAuthenticationRequest request, AuthenticationRequestContext context) {
|
||||
return context.runBlocking(
|
||||
() -> {
|
||||
return context.runBlocking(() -> {
|
||||
Optional<User> user =
|
||||
unitOfWork.call(() -> userService.login(request.getToken().getToken()));
|
||||
if (user.isEmpty()) {
|
||||
throw new AuthenticationFailedException(
|
||||
"could not find a user with this api key");
|
||||
throw new AuthenticationFailedException("could not find a user with this api key");
|
||||
}
|
||||
|
||||
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user.get()));
|
||||
return QuarkusSecurityIdentity.builder()
|
||||
.setPrincipal(new QuarkusPrincipal(String.valueOf(user.get().getId())))
|
||||
.setPrincipal(new QuarkusPrincipal(SessionPrincipal.of(user.get()).toName()))
|
||||
.addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet()))
|
||||
.build();
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.commafeed.backend.model.UserRole.Role;
|
||||
import com.commafeed.backend.service.UserService;
|
||||
import com.commafeed.frontend.exception.CommaFeedApplicationException;
|
||||
import com.commafeed.frontend.exception.CommaFeedExceptionType;
|
||||
import com.commafeed.security.SessionPrincipal;
|
||||
|
||||
import io.quarkus.security.identity.AuthenticationRequestContext;
|
||||
import io.quarkus.security.identity.IdentityProvider;
|
||||
@@ -39,15 +40,12 @@ public class DatabaseUsernamePasswordIdentityProvider
|
||||
@Override
|
||||
public Uni<SecurityIdentity> authenticate(
|
||||
UsernamePasswordAuthenticationRequest request, AuthenticationRequestContext context) {
|
||||
return context.runBlocking(
|
||||
() -> {
|
||||
return context.runBlocking(() -> {
|
||||
Optional<User> user =
|
||||
unitOfWork.call(
|
||||
() ->
|
||||
unitOfWork.call(() ->
|
||||
userService.login(
|
||||
request.getUsername(),
|
||||
new String(
|
||||
request.getPassword().getPassword())));
|
||||
new String(request.getPassword().getPassword())));
|
||||
if (user.isEmpty()) {
|
||||
throw new CommaFeedApplicationException(
|
||||
CommaFeedExceptionType.WRONG_USERNAME_OR_PASSWORD);
|
||||
@@ -55,7 +53,7 @@ public class DatabaseUsernamePasswordIdentityProvider
|
||||
|
||||
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user.get()));
|
||||
return QuarkusSecurityIdentity.builder()
|
||||
.setPrincipal(new QuarkusPrincipal(String.valueOf(user.get().getId())))
|
||||
.setPrincipal(new QuarkusPrincipal(SessionPrincipal.of(user.get()).toName()))
|
||||
.addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet()))
|
||||
.build();
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.commafeed.backend.model.User;
|
||||
import com.commafeed.backend.model.UserRole.Role;
|
||||
import com.commafeed.backend.service.UserService;
|
||||
import com.commafeed.backend.service.internal.PostLoginActivities;
|
||||
import com.commafeed.security.SessionPrincipal;
|
||||
|
||||
import io.quarkus.security.AuthenticationFailedException;
|
||||
import io.quarkus.security.identity.AuthenticationRequestContext;
|
||||
@@ -40,13 +41,18 @@ public class TrustedIdentityProvider implements IdentityProvider<TrustedAuthenti
|
||||
@Override
|
||||
public Uni<SecurityIdentity> authenticate(
|
||||
TrustedAuthenticationRequest request, AuthenticationRequestContext context) {
|
||||
return context.runBlocking(
|
||||
() -> {
|
||||
Long userId = Long.valueOf(request.getPrincipal());
|
||||
User user = unitOfWork.call(() -> userDAO.findById(userId));
|
||||
return context.runBlocking(() -> {
|
||||
SessionPrincipal principal = SessionPrincipal.parse(request.getPrincipal());
|
||||
User user = unitOfWork.call(() -> userDAO.findById(principal.userId()));
|
||||
if (user == null) {
|
||||
throw new AuthenticationFailedException("user not found");
|
||||
}
|
||||
if (user.isDisabled()) {
|
||||
throw new AuthenticationFailedException("user disabled");
|
||||
}
|
||||
if (user.getSessionVersion() != principal.sessionVersion()) {
|
||||
throw new AuthenticationFailedException("session invalidated");
|
||||
}
|
||||
|
||||
// execute post login activities manually because we didn't call login() since
|
||||
// we received
|
||||
@@ -55,7 +61,7 @@ public class TrustedIdentityProvider implements IdentityProvider<TrustedAuthenti
|
||||
|
||||
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user));
|
||||
return QuarkusSecurityIdentity.builder()
|
||||
.setPrincipal(new QuarkusPrincipal(String.valueOf(userId)))
|
||||
.setPrincipal(new QuarkusPrincipal(principal.toName()))
|
||||
.addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet()))
|
||||
.build();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||
|
||||
<changeSet id="user-session-version" author="athou">
|
||||
<addColumn tableName="USERS">
|
||||
<column name="sessionVersion" type="INT" valueNumeric="0" defaultValueNumeric="0">
|
||||
<constraints nullable="false" />
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -39,5 +39,6 @@
|
||||
<include file="changelogs/db.changelog-5.12.xml" />
|
||||
<include file="changelogs/db.changelog-7.0.xml" />
|
||||
<include file="changelogs/db.changelog-7.2.xml" />
|
||||
<include file="changelogs/db.changelog-7.5.xml" />
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -41,8 +41,7 @@ class NativeImageClassesTest {
|
||||
WireFeedGenerator.class)) {
|
||||
Set<Class<?>> moduleClasses =
|
||||
new HashSet<>(reflections.get(Scanners.SubTypes.of(clazz).asClass()));
|
||||
moduleClasses.removeIf(
|
||||
c ->
|
||||
moduleClasses.removeIf(c ->
|
||||
c.isInterface()
|
||||
|| Modifier.isAbstract(c.getModifiers())
|
||||
|| !Modifier.isPublic(c.getModifiers()));
|
||||
|
||||
@@ -317,8 +317,7 @@ class HttpGetterTest {
|
||||
|
||||
this.mockServerClient
|
||||
.when(HttpRequest.request().withMethod("GET"))
|
||||
.respond(
|
||||
req -> {
|
||||
.respond(req -> {
|
||||
calls.incrementAndGet();
|
||||
|
||||
if (req.containsHeader(HttpHeaders.COOKIE)) {
|
||||
@@ -341,8 +340,7 @@ class HttpGetterTest {
|
||||
|
||||
this.mockServerClient
|
||||
.when(HttpRequest.request().withMethod("GET"))
|
||||
.respond(
|
||||
req -> {
|
||||
.respond(req -> {
|
||||
calls.incrementAndGet();
|
||||
return HttpResponse.response().withBody("ok");
|
||||
});
|
||||
@@ -400,13 +398,11 @@ class HttpGetterTest {
|
||||
|
||||
this.mockServerClient
|
||||
.when(HttpRequest.request().withMethod("GET"))
|
||||
.respond(
|
||||
req -> {
|
||||
.respond(req -> {
|
||||
calls.incrementAndGet();
|
||||
|
||||
if (req.containsHeader(HttpHeaders.UPGRADE)) {
|
||||
throw new Exception(
|
||||
"upgrade header should not be sent by the client");
|
||||
throw new Exception("upgrade header should not be sent by the client");
|
||||
}
|
||||
|
||||
return HttpResponse.response().withBody("ok");
|
||||
@@ -438,17 +434,14 @@ class HttpGetterTest {
|
||||
HttpGetterTest.this
|
||||
.mockServerClient
|
||||
.when(HttpRequest.request().withMethod("GET"))
|
||||
.respond(
|
||||
req -> {
|
||||
.respond(req -> {
|
||||
try {
|
||||
String acceptEncodingHeader =
|
||||
req.getFirstHeader(HttpHeaders.ACCEPT_ENCODING);
|
||||
if (!ACCEPT_ENCODING.equals(acceptEncodingHeader)) {
|
||||
throw new Exception(
|
||||
"Wrong value in the Accept-Encoding header, should be '%s' but was '%s'"
|
||||
.formatted(
|
||||
ACCEPT_ENCODING,
|
||||
acceptEncodingHeader));
|
||||
.formatted(ACCEPT_ENCODING, acceptEncodingHeader));
|
||||
}
|
||||
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
@@ -568,6 +561,17 @@ class HttpGetterTest {
|
||||
() -> getter.get("http://[64:ff9b::a00:1]")); // 10.0.0.1
|
||||
}
|
||||
|
||||
@Test
|
||||
void nat64LocalUse() {
|
||||
// 64:ff9b:1::/48 is reserved for local use (RFC 8215), the whole range is blocked
|
||||
Assertions.assertThrows(
|
||||
UnknownHostException.class,
|
||||
() -> getter.get("http://[64:ff9b:1::a9fe:a9fe]")); // 169.254.169.254
|
||||
Assertions.assertThrows(
|
||||
UnknownHostException.class,
|
||||
() -> getter.get("http://[64:ff9b:1:ffff::a00:1]")); // 10.0.0.1
|
||||
}
|
||||
|
||||
@Test
|
||||
void sixToFour() {
|
||||
// 2002::/16 embeds the IPv4 address in bits 16-47 (RFC 3056)
|
||||
|
||||
@@ -68,7 +68,8 @@ class YoutubeFaviconFetcherTest {
|
||||
|
||||
byte[] apiResponse =
|
||||
"""
|
||||
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}"""
|
||||
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}\
|
||||
"""
|
||||
.getBytes();
|
||||
HttpResult apiHttpResult =
|
||||
new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO);
|
||||
@@ -102,7 +103,8 @@ class YoutubeFaviconFetcherTest {
|
||||
|
||||
byte[] apiResponse =
|
||||
"""
|
||||
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}"""
|
||||
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}\
|
||||
"""
|
||||
.getBytes();
|
||||
HttpResult apiHttpResult =
|
||||
new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO);
|
||||
@@ -136,7 +138,8 @@ class YoutubeFaviconFetcherTest {
|
||||
|
||||
byte[] playlistResponse =
|
||||
"""
|
||||
{"items":[{"snippet":{"channelId":"testChannelId"}}]}"""
|
||||
{"items":[{"snippet":{"channelId":"testChannelId"}}]}\
|
||||
"""
|
||||
.getBytes();
|
||||
HttpResult playlistHttpResult =
|
||||
new HttpResult(
|
||||
@@ -151,7 +154,8 @@ class YoutubeFaviconFetcherTest {
|
||||
|
||||
byte[] channelResponse =
|
||||
"""
|
||||
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}"""
|
||||
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}\
|
||||
"""
|
||||
.getBytes();
|
||||
HttpResult channelHttpResult =
|
||||
new HttpResult(
|
||||
|
||||
@@ -229,11 +229,13 @@ class XMLCleanerTest {
|
||||
<!DOCTYPE
|
||||
html
|
||||
>
|
||||
<html><head></head><body></body></html>""";
|
||||
<html><head></head><body></body></html>\
|
||||
""";
|
||||
Assertions.assertEquals(
|
||||
"""
|
||||
|
||||
<html><head></head><body></body></html>""",
|
||||
<html><head></head><body></body></html>\
|
||||
""",
|
||||
xmlCleaner.removeDoctypeDeclarations(source));
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ class InPageReferenceFeedURLProviderTest {
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>""";
|
||||
</html>\
|
||||
""";
|
||||
|
||||
Assertions.assertIterableEquals(
|
||||
List.of("http://example.com/feed.atom", "http://example.com/feed.rss"),
|
||||
@@ -35,7 +36,8 @@ class InPageReferenceFeedURLProviderTest {
|
||||
"""
|
||||
<?xml version="1.0"?>
|
||||
<feed></feed>
|
||||
</xml>""";
|
||||
</xml>\
|
||||
""";
|
||||
|
||||
Assertions.assertTrue(provider.get(url, html).isEmpty());
|
||||
}
|
||||
@@ -51,7 +53,8 @@ class InPageReferenceFeedURLProviderTest {
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>""";
|
||||
</html>\
|
||||
""";
|
||||
|
||||
Assertions.assertTrue(provider.get(url, html).isEmpty());
|
||||
}
|
||||
|
||||
@@ -109,15 +109,15 @@ public abstract class BaseIT {
|
||||
}
|
||||
|
||||
protected List<HttpCookie> login() {
|
||||
return login(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
|
||||
}
|
||||
|
||||
protected List<HttpCookie> login(String userName, String password) {
|
||||
List<Header> setCookieHeaders =
|
||||
RestAssured.given()
|
||||
.auth()
|
||||
.none()
|
||||
.formParams(
|
||||
"j_username",
|
||||
TestConstants.ADMIN_USERNAME,
|
||||
"j_password",
|
||||
TestConstants.ADMIN_PASSWORD)
|
||||
.formParams("j_username", userName, "j_password", password)
|
||||
.post("j_security_check")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK)
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.commafeed.TestConstants;
|
||||
import com.commafeed.frontend.exception.CommaFeedExceptionType;
|
||||
import com.commafeed.frontend.model.Entries;
|
||||
import com.commafeed.frontend.model.UserModel;
|
||||
import com.commafeed.frontend.model.request.AdminSaveUserRequest;
|
||||
import com.commafeed.frontend.model.request.MarkRequest;
|
||||
import com.commafeed.frontend.model.request.ProfileModificationRequest;
|
||||
import com.commafeed.frontend.model.request.SubscribeRequest;
|
||||
@@ -17,6 +18,7 @@ import io.restassured.http.ContentType;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
|
||||
import org.apache.hc.core5.http.HttpStatus;
|
||||
import org.hamcrest.Matchers;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -28,6 +30,10 @@ import java.util.stream.Collectors;
|
||||
@QuarkusTest
|
||||
class SecurityIT extends BaseIT {
|
||||
|
||||
private static final String USER_NAME = "session-user";
|
||||
private static final String USER_PASSWORD = "MyPassword123!";
|
||||
private static final String NEW_USER_PASSWORD = "MyNewPassword123!";
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
initialSetup(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
|
||||
@@ -184,4 +190,232 @@ class SecurityIT extends BaseIT {
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selfPasswordChangeInvalidatesSessions() {
|
||||
createUser(false);
|
||||
String sessionA = session(USER_NAME, USER_PASSWORD);
|
||||
String sessionB = session(USER_NAME, USER_PASSWORD);
|
||||
String adminSession = session(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
|
||||
|
||||
ProfileModificationRequest req = new ProfileModificationRequest();
|
||||
req.setCurrentPassword(USER_PASSWORD);
|
||||
req.setNewPassword(NEW_USER_PASSWORD);
|
||||
RestAssured.given()
|
||||
.header(HttpHeaders.COOKIE, sessionA)
|
||||
.body(req)
|
||||
.contentType(ContentType.JSON)
|
||||
.post("rest/user/profile")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK);
|
||||
|
||||
assertSessionStatus(sessionA, HttpStatus.SC_UNAUTHORIZED);
|
||||
assertSessionStatus(sessionB, HttpStatus.SC_UNAUTHORIZED);
|
||||
assertSessionStatus(adminSession, HttpStatus.SC_OK);
|
||||
assertSessionStatus(session(USER_NAME, NEW_USER_PASSWORD), HttpStatus.SC_OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void profileChangeWithoutNewPasswordKeepsSessions() {
|
||||
createUser(false);
|
||||
String session = session(USER_NAME, USER_PASSWORD);
|
||||
|
||||
ProfileModificationRequest req = new ProfileModificationRequest();
|
||||
req.setCurrentPassword(USER_PASSWORD);
|
||||
req.setEmail("session-user@commafeed.com");
|
||||
RestAssured.given()
|
||||
.header(HttpHeaders.COOKIE, session)
|
||||
.body(req)
|
||||
.contentType(ContentType.JSON)
|
||||
.post("rest/user/profile")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK);
|
||||
|
||||
assertSessionStatus(session, HttpStatus.SC_OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminPasswordChangeInvalidatesSessionsAndRotatesApiKey() {
|
||||
Long id = createUser(false);
|
||||
String session = session(USER_NAME, USER_PASSWORD);
|
||||
|
||||
ProfileModificationRequest apiKeyReq = new ProfileModificationRequest();
|
||||
apiKeyReq.setCurrentPassword(USER_PASSWORD);
|
||||
apiKeyReq.setNewApiKey(true);
|
||||
RestAssured.given()
|
||||
.header(HttpHeaders.COOKIE, session)
|
||||
.body(apiKeyReq)
|
||||
.contentType(ContentType.JSON)
|
||||
.post("rest/user/profile")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK);
|
||||
String apiKey = profile(session).getApiKey();
|
||||
Assertions.assertNotNull(apiKey);
|
||||
|
||||
AdminSaveUserRequest req = saveUserRequest(false);
|
||||
req.setId(id);
|
||||
req.setPassword(NEW_USER_PASSWORD);
|
||||
saveUser(req);
|
||||
|
||||
assertSessionStatus(session, HttpStatus.SC_UNAUTHORIZED);
|
||||
String newSession = session(USER_NAME, NEW_USER_PASSWORD);
|
||||
assertSessionStatus(newSession, HttpStatus.SC_OK);
|
||||
Assertions.assertNotEquals(apiKey, profile(newSession).getApiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminPasswordChangeDoesNotCreateApiKey() {
|
||||
Long id = createUser(false);
|
||||
|
||||
AdminSaveUserRequest req = saveUserRequest(false);
|
||||
req.setId(id);
|
||||
req.setPassword(NEW_USER_PASSWORD);
|
||||
saveUser(req);
|
||||
|
||||
Assertions.assertNull(profile(session(USER_NAME, NEW_USER_PASSWORD)).getApiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disablingUserInvalidatesSessions() {
|
||||
Long id = createUser(false);
|
||||
String session = session(USER_NAME, USER_PASSWORD);
|
||||
assertSessionStatus(session, HttpStatus.SC_OK);
|
||||
|
||||
AdminSaveUserRequest req = saveUserRequest(false);
|
||||
req.setId(id);
|
||||
req.setEnabled(false);
|
||||
saveUser(req);
|
||||
|
||||
assertSessionStatus(session, HttpStatus.SC_UNAUTHORIZED);
|
||||
|
||||
// sessions opened before the user was disabled stay invalid after enabling it again
|
||||
req.setEnabled(true);
|
||||
saveUser(req);
|
||||
|
||||
assertSessionStatus(session, HttpStatus.SC_UNAUTHORIZED);
|
||||
assertSessionStatus(session(USER_NAME, USER_PASSWORD), HttpStatus.SC_OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void savingUserWithoutChangesKeepsSessions() {
|
||||
Long id = createUser(false);
|
||||
String session = session(USER_NAME, USER_PASSWORD);
|
||||
|
||||
AdminSaveUserRequest req = saveUserRequest(false);
|
||||
req.setId(id);
|
||||
saveUser(req);
|
||||
|
||||
assertSessionStatus(session, HttpStatus.SC_OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disablingAdminRevokesAdminAccess() {
|
||||
Long id = createUser(true);
|
||||
String session = session(USER_NAME, USER_PASSWORD);
|
||||
RestAssured.given()
|
||||
.header(HttpHeaders.COOKIE, session)
|
||||
.get("rest/admin/metrics")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK);
|
||||
|
||||
AdminSaveUserRequest req = saveUserRequest(true);
|
||||
req.setId(id);
|
||||
req.setEnabled(false);
|
||||
saveUser(req);
|
||||
|
||||
RestAssured.given()
|
||||
.header(HttpHeaders.COOKIE, session)
|
||||
.get("rest/admin/metrics")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidatedSessionRemovesCookie() {
|
||||
Long id = createUser(false);
|
||||
String session = session(USER_NAME, USER_PASSWORD);
|
||||
|
||||
AdminSaveUserRequest req = saveUserRequest(false);
|
||||
req.setId(id);
|
||||
req.setEnabled(false);
|
||||
saveUser(req);
|
||||
|
||||
// api calls receive an error
|
||||
String apiSetCookie =
|
||||
RestAssured.given()
|
||||
.header(HttpHeaders.COOKIE, session)
|
||||
.get("rest/user/profile")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_UNAUTHORIZED)
|
||||
.extract()
|
||||
.header(HttpHeaders.SET_COOKIE);
|
||||
Assertions.assertTrue(apiSetCookie.contains("Max-Age=0"));
|
||||
|
||||
// browsers are redirected to the page they asked for, this time without the cookie
|
||||
String browserSetCookie =
|
||||
RestAssured.given()
|
||||
.redirects()
|
||||
.follow(false)
|
||||
.header(HttpHeaders.COOKIE, session)
|
||||
.header(HttpHeaders.ACCEPT, "text/html,application/xhtml+xml,*/*;q=0.8")
|
||||
.get("next?category=all")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_TEMPORARY_REDIRECT)
|
||||
.header(HttpHeaders.LOCATION, Matchers.endsWith("/next?category=all"))
|
||||
.extract()
|
||||
.header(HttpHeaders.SET_COOKIE);
|
||||
Assertions.assertTrue(browserSetCookie.contains("Max-Age=0"));
|
||||
}
|
||||
|
||||
private Long createUser(boolean admin) {
|
||||
AdminSaveUserRequest req = saveUserRequest(admin);
|
||||
req.setPassword(USER_PASSWORD);
|
||||
return saveUser(req);
|
||||
}
|
||||
|
||||
private AdminSaveUserRequest saveUserRequest(boolean admin) {
|
||||
AdminSaveUserRequest req = new AdminSaveUserRequest();
|
||||
req.setName(USER_NAME);
|
||||
req.setEnabled(true);
|
||||
req.setAdmin(admin);
|
||||
return req;
|
||||
}
|
||||
|
||||
private Long saveUser(AdminSaveUserRequest req) {
|
||||
return RestAssured.given()
|
||||
.auth()
|
||||
.preemptive()
|
||||
.basic(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD)
|
||||
.body(req)
|
||||
.contentType(ContentType.JSON)
|
||||
.post("rest/admin/user/save")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK)
|
||||
.extract()
|
||||
.as(Long.class);
|
||||
}
|
||||
|
||||
private String session(String userName, String password) {
|
||||
return login(userName, password).stream()
|
||||
.map(HttpCookie::toString)
|
||||
.collect(Collectors.joining(";"));
|
||||
}
|
||||
|
||||
private UserModel profile(String session) {
|
||||
return RestAssured.given()
|
||||
.header(HttpHeaders.COOKIE, session)
|
||||
.get("rest/user/profile")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK)
|
||||
.extract()
|
||||
.as(UserModel.class);
|
||||
}
|
||||
|
||||
private void assertSessionStatus(String session, int status) {
|
||||
RestAssured.given()
|
||||
.header(HttpHeaders.COOKIE, session)
|
||||
.get("rest/user/profile")
|
||||
.then()
|
||||
.statusCode(status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,8 +170,7 @@ class GoogleReaderIT extends BaseIT {
|
||||
client.get(BASE + "/reader/api/0/unread-count").as(UnreadCounts.class);
|
||||
Assertions.assertTrue(
|
||||
counts.getUnreadCounts().stream()
|
||||
.anyMatch(
|
||||
c ->
|
||||
.anyMatch(c ->
|
||||
c.getId().equals("user/-/state/com.google/reading-list")
|
||||
&& c.getCount() == 2));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.commafeed.TestConstants;
|
||||
import com.commafeed.frontend.model.Settings;
|
||||
import com.commafeed.frontend.model.request.PasswordResetConfirmationRequest;
|
||||
import com.commafeed.frontend.model.request.PasswordResetRequest;
|
||||
import com.commafeed.frontend.model.request.RegistrationRequest;
|
||||
import com.commafeed.integration.BaseIT;
|
||||
|
||||
import io.quarkus.mailer.MockMailbox;
|
||||
@@ -13,17 +14,21 @@ import io.restassured.http.ContentType;
|
||||
import io.vertx.ext.mail.MailMessage;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.select.Elements;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.HttpCookie;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@QuarkusTest
|
||||
class UserIT extends BaseIT {
|
||||
@@ -47,6 +52,9 @@ class UserIT extends BaseIT {
|
||||
|
||||
@Test
|
||||
void resetPassword() {
|
||||
String session =
|
||||
login().stream().map(HttpCookie::toString).collect(Collectors.joining(";"));
|
||||
|
||||
PasswordResetRequest req = new PasswordResetRequest();
|
||||
req.setEmail("admin@commafeed.com");
|
||||
RestAssured.given()
|
||||
@@ -99,11 +107,59 @@ class UserIT extends BaseIT {
|
||||
.then()
|
||||
.statusCode(200);
|
||||
|
||||
// sessions opened before the reset are no longer valid
|
||||
RestAssured.given()
|
||||
.auth()
|
||||
.none()
|
||||
.header(HttpHeaders.COOKIE, session)
|
||||
.get("rest/user/settings")
|
||||
.then()
|
||||
.statusCode(401);
|
||||
|
||||
RestAssured.authentication =
|
||||
RestAssured.preemptive().basic(TestConstants.ADMIN_USERNAME, newPassword);
|
||||
RestAssured.given().get("rest/user/settings").then().statusCode(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetPasswordEscapesUsernameInEmail() {
|
||||
String name = "<a href=//evil.example>CLICK</a>";
|
||||
String email = "victim@commafeed.com";
|
||||
|
||||
RegistrationRequest registrationReq = new RegistrationRequest();
|
||||
registrationReq.setName(name);
|
||||
registrationReq.setPassword("MyPassword123!");
|
||||
registrationReq.setEmail(email);
|
||||
RestAssured.given()
|
||||
.body(registrationReq)
|
||||
.contentType(ContentType.JSON)
|
||||
.post("rest/user/register")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
|
||||
PasswordResetRequest req = new PasswordResetRequest();
|
||||
req.setEmail(email);
|
||||
RestAssured.given()
|
||||
.body(req)
|
||||
.contentType(ContentType.JSON)
|
||||
.post("rest/user/passwordReset")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
|
||||
List<MailMessage> mails = mailbox.getMailMessagesSentTo(email);
|
||||
Assertions.assertEquals(1, mails.size());
|
||||
|
||||
String html = mails.getFirst().getHtml();
|
||||
Assertions.assertTrue(
|
||||
html.startsWith(
|
||||
"You asked for password recovery for account '<a href=//evil.example>CLICK</a>'"));
|
||||
|
||||
Elements links = Jsoup.parse(html).select("a");
|
||||
Assertions.assertEquals(1, links.size());
|
||||
Assertions.assertTrue(
|
||||
links.getFirst().attr("href").startsWith("https://commafeed.example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveSettings() {
|
||||
Settings settings =
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.commafeed.security;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SessionPrincipalTest {
|
||||
|
||||
@Test
|
||||
void roundTrip() {
|
||||
SessionPrincipal principal = new SessionPrincipal(12, 3);
|
||||
Assertions.assertEquals("12:3", principal.toName());
|
||||
Assertions.assertEquals(principal, SessionPrincipal.parse(principal.toName()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cookieWithoutSessionVersion() {
|
||||
Assertions.assertEquals(new SessionPrincipal(12, 0), SessionPrincipal.parse("12"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user