This commit is contained in:
Athou
2024-08-07 08:10:14 +02:00
parent 2f6ddf0e70
commit cc32f8ad16
164 changed files with 2011 additions and 3288 deletions

View File

@@ -7,115 +7,39 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
java: [ "17", "21" ]
database: [ "h2", "postgresql", "mysql", "mariadb" ]
steps:
# Checkout
- name: Configure git to checkout as-is
run: git config --global core.autocrlf false
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# Setup
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up Java
uses: actions/setup-java@v4
- name: Set up GraalVM
uses: graalvm/setup-graalvm@v1
with:
java-version: ${{ matrix.java }}
distribution: "temurin"
java-version: "21"
distribution: "graalvm"
cache: "maven"
# Build & Test
- name: Build with Maven
run: mvn --batch-mode --no-transfer-progress install
env:
TEST_DATABASE: h2
- name: Run integration tests on PostgreSQL
run: mvn --batch-mode --no-transfer-progress failsafe:integration-test failsafe:verify
env:
TEST_DATABASE: postgresql
- name: Run integration tests on MySQL
run: mvn --batch-mode --no-transfer-progress failsafe:integration-test failsafe:verify
env:
TEST_DATABASE: mysql
- name: Run integration tests on MariaDB
run: mvn --batch-mode --no-transfer-progress failsafe:integration-test failsafe:verify
env:
TEST_DATABASE: mariadb
- name: Run integration tests with Redis cache enabled
run: mvn --batch-mode --no-transfer-progress failsafe:integration-test failsafe:verify
env:
TEST_DATABASE: h2
REDIS: true
run: mvn --batch-mode --no-transfer-progress install -Pnative -D"quarkus.datasource.db-kind"=${{ matrix.database }}
# Upload artifacts
- name: Upload JAR
uses: actions/upload-artifact@v4
if: ${{ matrix.java == '17' }}
with:
name: commafeed.jar
path: commafeed-server/target/commafeed.jar
- name: Upload Playwright artifacts
if: failure()
- name: Upload cross-platform app
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts
path: |
**/target/playwright-artifacts/
name: commafeed-${{ matrix.database }}-jvm
path: commafeed-server/target/commafeed-*.zip
# Docker
- name: Login to Container Registry
uses: docker/login-action@v3
if: ${{ matrix.java == '17' && (github.ref_type == 'tag' || github.ref_name == 'master') }}
- name: Upload native executable
uses: actions/upload-artifact@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker build and push tag
uses: docker/build-push-action@v6
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
with:
context: .
push: true
platforms: linux/amd64,linux/arm64/v8
tags: |
athou/commafeed:latest
athou/commafeed:${{ github.ref_name }}
- name: Docker build and push master
uses: docker/build-push-action@v6
if: ${{ matrix.java == '17' && github.ref_name == 'master' }}
with:
context: .
push: true
platforms: linux/amd64,linux/arm64/v8
tags: athou/commafeed:master
# Create GitHub release after Docker image has been published
- name: Extract Changelog Entry
uses: mindsers/changelog-reader-action@v2
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
id: changelog_reader
with:
version: ${{ github.ref_name }}
- name: Create GitHub release
uses: softprops/action-gh-release@v2
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
with:
name: CommaFeed ${{ github.ref_name }}
body: ${{ steps.changelog_reader.outputs.changes }}
draft: false
prerelease: false
files: |
commafeed-server/target/commafeed.jar
commafeed-server/config.yml.example
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
path: commafeed-server/target/commafeed-*-runner

View File

@@ -80,7 +80,7 @@
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/classes/assets</outputDirectory>
<outputDirectory>${project.build.directory}/classes/META-INF/resources</outputDirectory>
<resources>
<resource>
<directory>dist</directory>

View File

@@ -81,7 +81,17 @@ export const client = {
},
},
user: {
login: async (req: LoginRequest) => await axiosInstance.post("user/login", req),
login: async (req: LoginRequest) => {
const formData = new URLSearchParams()
formData.append("j_username", req.name)
formData.append("j_password", req.password)
return await axiosInstance.post("j_security_check", formData, {
baseURL: ".",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
})
},
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
getSettings: async () => await axiosInstance.get<Settings>("user/settings"),

View File

@@ -24,12 +24,18 @@ export function RegistrationPage() {
},
})
const register = useAsyncCallback(client.user.register, {
const login = useAsyncCallback(client.user.login, {
onSuccess: () => {
dispatch(redirectToRootCategory())
},
})
const register = useAsyncCallback(client.user.register, {
onSuccess: () => {
login.execute(form.values)
},
})
return (
<Container size="xs">
<PageTitle />
@@ -50,6 +56,12 @@ export function RegistrationPage() {
</Box>
)}
{login.error && (
<Box mb="md">
<Alert messages={errorToStrings(login.error)} />
</Box>
)}
<form onSubmit={form.onSubmit(register.execute)}>
<Stack>
<TextInput label="User Name" placeholder="User Name" {...form.getInputProps("name")} size="md" required />
@@ -68,7 +80,7 @@ export function RegistrationPage() {
size="md"
required
/>
<Button type="submit" loading={register.loading}>
<Button type="submit" loading={register.loading || login.loading}>
<Trans>Sign up</Trans>
</Button>
<Center>

View File

@@ -32,6 +32,7 @@ export default defineConfig(env => ({
"/openapi.json": "http://localhost:8083",
"/custom_css.css": "http://localhost:8083",
"/custom_js.js": "http://localhost:8083",
"/j_security_check": "http://localhost:8083",
"/logout": "http://localhost:8083",
},
},

30
commafeed-server/TODO.md Normal file
View File

@@ -0,0 +1,30 @@
TODO
----
MVP:
- quarkus mailer for smtp
- https://quarkus.io/guides/mailer
- cookie duration too short
- https://github.com/quarkusio/quarkus/issues/42463
- update dockerfile
- update github actions (build and copy outside target each database artifact)
- update readme
- update release notes (+ mention h2 migration has been removed, upgrade to last 4.x is required)
Nice to have:
- find a better way to scan rome classes
- remove suppresswarnings "deprecation"
- remove rest assured or use only rest assured
- rename "servlets" since they are now rest endpoints
- warnings hibernate on startup
- OPML encoding is not handled correctly
- remove Timers metrics page
native-image
-------------
- https://www.graalvm.org/latest/reference-manual/native-image/dynamic-features/Resources/
- https://github.com/rometools/rome/pull/636/files

View File

@@ -1,157 +0,0 @@
# CommaFeed settings
# ------------------
app:
# url used to access commafeed
publicUrl: http://localhost:8082/
# whether to expose a robots.txt file that disallows web crawlers and search engine indexers
hideFromWebCrawlers: true
# whether to allow user registrations
allowRegistrations: true
# whether to enable strict password validation (1 uppercase char, 1 lowercase char, 1 digit, 1 special char)
strictPasswordPolicy: true
# create a demo account the first time the app starts
createDemoAccount: true
# put your google analytics tracking code here
googleAnalyticsTrackingCode:
# put your google server key (used for youtube favicon fetching)
googleAuthKey:
# number of http threads
backgroundThreads: 3
# number of database updating threads
databaseUpdateThreads: 1
# rows to delete per query while cleaning up old entries
databaseCleanupBatchSize: 100
# settings for sending emails (password recovery)
smtpHost: localhost
smtpPort: 25
smtpTls: false
smtpUserName: user
smtpPassword: pass
smtpFromAddress:
# Graphite Metric settings
# Allows those who use Graphite to have CommaFeed send metrics for graphing (time in seconds)
graphiteEnabled: false
graphitePrefix: "test.commafeed"
graphiteHost: "localhost"
graphitePort: 2003
graphiteInterval: 60
# whether this commafeed instance has a lot of feeds to refresh
# leave this to false in almost all cases
heavyLoad: false
# minimum amount of time commafeed will wait before refreshing the same feed
refreshIntervalMinutes: 5
# if enabled, images in feed entries will be proxied through the server instead of accessed directly by the browser
# useful if commafeed is usually accessed through a restricting proxy
imageProxyEnabled: true
# database query timeout (in milliseconds), 0 to disable
queryTimeout: 0
# time to keep unread statuses (in days), 0 to disable
keepStatusDays: 0
# entries to keep per feed, old entries will be deleted, 0 to disable
maxFeedCapacity: 500
# entries older than this will be deleted, 0 to disable
maxEntriesAgeDays: 365
# limit the number of feeds a user can subscribe to, 0 to disable
maxFeedsPerUser: 0
# don't parse feeds that are too large to prevent memory issues
maxFeedResponseSize: 5M
# cache service to use, possible values are 'noop' and 'redis'
cache: noop
# announcement string displayed on the main page
announcement:
# user-agent string that will be used by the http client, leave empty for the default one
userAgent:
# enable websocket connection so the server can notify the web client that there are new entries for your feeds
websocketEnabled: true
# interval at which the client will send a ping message on the websocket to keep the connection alive
websocketPingInterval: 15m
# if websocket is disabled or the connection is lost, the client will reload the feed tree at this interval
treeReloadInterval: 30s
# Database connection
# -------------------
# for MariaDB
# driverClass is org.mariadb.jdbc.Driver
# url is jdbc:mariadb://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC
#
# for MySQL
# driverClass is com.mysql.cj.jdbc.Driver
# url is jdbc:mysql://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC
#
# for PostgreSQL
# driverClass is org.postgresql.Driver
# url is jdbc:postgresql://localhost:5432/commafeed
database:
driverClass: org.h2.Driver
url: jdbc:h2:./target/commafeed
user: sa
password: sa
properties:
charSet: UTF-8
validationQuery: "/* CommaFeed Health Check */ SELECT 1"
server:
applicationConnectors:
- type: http
port: 8083
adminConnectors:
- type: http
port: 8084
logging:
level: INFO
loggers:
com.commafeed: DEBUG
liquibase: INFO
org.hibernate.SQL: INFO # or ALL for sql debugging
org.hibernate.engine.internal.StatisticalLoggingSessionEventListener: WARN
appenders:
- type: console
- type: file
currentLogFilename: log/commafeed.log
threshold: ALL
archive: true
archivedLogFilenamePattern: log/commafeed-%d.log
archivedFileCount: 5
timeZone: UTC
# Redis pool configuration
# (only used if app.cache is 'redis')
# -----------------------------------
redis:
host: localhost
port: 6379
# username is only required when using ACLs
username:
password:
timeout: 2000
database: 0
maxTotal: 500

View File

@@ -1,158 +0,0 @@
# CommaFeed settings
# ------------------
app:
# url used to access commafeed
publicUrl: http://localhost:8082/
# whether to expose a robots.txt file that disallows web crawlers and search engine indexers
hideFromWebCrawlers: true
# whether to allow user registrations
allowRegistrations: false
# whether to enable strict password validation (1 uppercase char, 1 lowercase char, 1 digit, 1 special char)
strictPasswordPolicy: true
# create a demo account the first time the app starts
createDemoAccount: false
# put your google analytics tracking code here
googleAnalyticsTrackingCode:
# put your google server key (used for youtube favicon fetching)
googleAuthKey:
# number of http threads
backgroundThreads: 3
# number of database updating threads
databaseUpdateThreads: 1
# rows to delete per query while cleaning up old entries
databaseCleanupBatchSize: 100
# settings for sending emails (password recovery)
smtpHost:
smtpPort:
smtpTls: false
smtpUserName:
smtpPassword:
smtpFromAddress:
# Graphite Metric settings
# Allows those who use Graphite to have CommaFeed send metrics for graphing (time in seconds)
graphiteEnabled: false
graphitePrefix: "test.commafeed"
graphiteHost: "localhost"
graphitePort: 2003
graphiteInterval: 60
# whether this commafeed instance has a lot of feeds to refresh
# leave this to false in almost all cases
heavyLoad: false
# minimum amount of time commafeed will wait before refreshing the same feed
refreshIntervalMinutes: 5
# if enabled, images in feed entries will be proxied through the server instead of accessed directly by the browser
# useful if commafeed is usually accessed through a restricting proxy
imageProxyEnabled: false
# database query timeout (in milliseconds), 0 to disable
queryTimeout: 0
# time to keep unread statuses (in days), 0 to disable
keepStatusDays: 0
# entries to keep per feed, old entries will be deleted, 0 to disable
maxFeedCapacity: 500
# entries older than this will be deleted, 0 to disable
maxEntriesAgeDays: 365
# limit the number of feeds a user can subscribe to, 0 to disable
maxFeedsPerUser: 0
# don't parse feeds that are too large to prevent memory issues
maxFeedResponseSize: 5M
# cache service to use, possible values are 'noop' and 'redis'
cache: noop
# announcement string displayed on the main page
announcement:
# user-agent string that will be used by the http client, leave empty for the default one
userAgent:
# enable websocket connection so the server can notify the web client that there are new entries for your feeds
websocketEnabled: true
# interval at which the client will send a ping message on the websocket to keep the connection alive
websocketPingInterval: 15m
# if websocket is disabled or the connection is lost, the client will reload the feed tree at this interval
treeReloadInterval: 30s
# Database connection
# -------------------
# for MariaDB
# driverClass is org.mariadb.jdbc.Driver
# url is jdbc:mariadb://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC
#
# for MySQL
# driverClass is com.mysql.cj.jdbc.Driver
# url is jdbc:mysql://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC
#
# for PostgreSQL
# driverClass is org.postgresql.Driver
# url is jdbc:postgresql://localhost:5432/commafeed
database:
driverClass: org.h2.Driver
url: jdbc:h2:/commafeed/data/db;DEFRAG_ALWAYS=TRUE
user: sa
password: sa
properties:
charSet: UTF-8
validationQuery: "/* CommaFeed Health Check */ SELECT 1"
minSize: 1
maxSize: 50
maxConnectionAge: 30m
server:
applicationConnectors:
- type: http
port: 8082
adminConnectors: [ ]
requestLog:
appenders: [ ]
logging:
level: ERROR
loggers:
com.commafeed: INFO
liquibase: INFO
io.dropwizard.server.ServerFactory: INFO
appenders:
- type: console
- type: file
currentLogFilename: log/commafeed.log
threshold: ALL
archive: true
archivedLogFilenamePattern: log/commafeed-%d.log
archivedFileCount: 5
timeZone: UTC
# Redis pool configuration
# (only used if app.cache is 'redis')
# -----------------------------------
redis:
host: localhost
port: 6379
# username is only required when using ACLs
username:
password:
timeout: 2000
database: 0
maxTotal: 500

View File

@@ -4,7 +4,7 @@ services:
mysql:
image: mariadb
environment:
- MYSQL_ROOT_PASSWORD=root
- MYSQL_ROOT_PASSWORD=commafeed
- MYSQL_DATABASE=commafeed
ports:
- "3306:3306"
@@ -12,8 +12,8 @@ services:
postgresql:
image: postgres
environment:
- POSTGRES_USER=root
- POSTGRES_PASSWORD=root
- POSTGRES_USER=commafeed
- POSTGRES_PASSWORD=commafeed
- POSTGRES_DB=commafeed
ports:
- "5432:5432"

View File

@@ -12,28 +12,20 @@
<name>CommaFeed Server</name>
<properties>
<guice.version>7.0.0</guice.version>
<quarkus.version>3.13.1</quarkus.version>
<querydsl.version>6.6</querydsl.version>
<rome.version>2.1.0</rome.version>
<bouncycastle.version>1.78.1</bouncycastle.version>
<jersey.version>3.1.8</jersey.version>
<testcontainers.version>1.20.1</testcontainers.version>
<!-- renovate: datasource=docker depName=postgres -->
<postgresql.image.version>16.4</postgresql.image.version>
<!-- renovate: datasource=docker depName=mysql -->
<mysql.image.version>9.0.1</mysql.image.version>
<!-- renovate: datasource=docker depName=mariadb -->
<mariadb.image.version>11.4.3</mariadb.image.version>
<!-- renovate: datasource=docker depName=redis -->
<redis.image.version>7.4.0</redis.image.version>
<quarkus.datasource.db-kind>h2</quarkus.datasource.db-kind>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-dependencies</artifactId>
<version>4.0.7</version>
<groupId>io.quarkus.platform</groupId>
<artifactId>quarkus-bom</artifactId>
<version>${quarkus.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@@ -41,26 +33,67 @@
</dependencyManagement>
<build>
<finalName>commafeed</finalName>
<testResources>
<testResource>
<directory>src/test/resources</directory>
<filtering>false</filtering>
</testResource>
<testResource>
<directory>src/test/resources</directory>
<includes>
<include>docker-images.properties</include>
</includes>
<filtering>true</filtering>
</testResource>
</testResources>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.7.1</version>
</extension>
</extensions>
<plugins>
<plugin>
<groupId>io.quarkus.platform</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
<goal>native-image-agent</goal>
</goals>
<configuration>
<properties>
<quarkus.package.output-name>commafeed-${project.version}</quarkus.package.output-name>
<quarkus.package.runner-suffix>
-${quarkus.datasource.db-kind}-${os.detected.name}-${os.detected.arch}-runner
</quarkus.package.runner-suffix>
</properties>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.7.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
<configuration>
<finalName>commafeed-${project.version}-${quarkus.datasource.db-kind}</finalName>
<appendAssemblyId>false</appendAssemblyId>
<descriptors>
<descriptor>src/main/assembly/zip-quarkus-app.xml</descriptor>
</descriptors>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.3.1</version>
<configuration>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@@ -74,6 +107,13 @@
</goals>
</execution>
</executions>
<configuration>
<systemPropertyVariables>
<native.image.path>${project.build.directory}/${project.build.finalName}-runner
</native.image.path>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>io.github.git-commit-id</groupId>
@@ -94,63 +134,13 @@
<failOnUnableToExtractRepoInfo>false</failOnUnableToExtractRepoInfo>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<dependencies>
<dependency>
<groupId>org.kordamp.shade</groupId>
<artifactId>maven-shade-ext-transformers</artifactId>
<version>1.4.0</version>
</dependency>
</dependencies>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>module-info.class</exclude>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.commafeed.CommaFeedApplication</mainClass>
</transformer>
<transformer implementation="org.kordamp.shade.resources.PropertiesFileTransformer">
<paths>
<path>rome.properties</path>
</paths>
<mergeStrategy>append</mergeStrategy>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-maven-plugin-jakarta</artifactId>
<version>2.2.22</version>
<?m2e ignore?>
<configuration>
<outputPath>${project.build.directory}/classes/assets</outputPath>
<outputPath>${project.build.directory}/classes/META-INF/resources</outputPath>
<outputFormat>JSONANDYAML</outputFormat>
<resourcePackages>
<package>com.commafeed.frontend.resource</package>
@@ -168,18 +158,6 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<configuration>
<archive>
<manifest>
<addDefaultImplementationEntries>true</addDefaultImplementationEntries>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
@@ -247,60 +225,65 @@
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jcl-over-slf4j</artifactId>
<groupId>org.kohsuke.metainf-services</groupId>
<artifactId>metainf-services</artifactId>
<version>1.11</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.google.inject</groupId>
<artifactId>guice</artifactId>
<version>${guice.version}</version>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-security</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-websockets</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-liquibase</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-h2</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-mysql</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-mariadb</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>
<dependency>
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-core</artifactId>
</dependency>
<dependency>
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-unix-socket</artifactId>
</dependency>
<dependency>
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-hibernate</artifactId>
</dependency>
<dependency>
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-migrations</artifactId>
</dependency>
<dependency>
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-assets</artifactId>
</dependency>
<dependency>
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-forms</artifactId>
</dependency>
<dependency>
<groupId>io.dropwizard.metrics</groupId>
<artifactId>metrics-graphite</artifactId>
</dependency>
<dependency>
<groupId>io.dropwizard.metrics</groupId>
<artifactId>metrics-json</artifactId>
</dependency>
<dependency>
<groupId>io.whitfin</groupId>
<artifactId>dropwizard-environment-substitutor</artifactId>
<version>1.1.1</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.websocket</groupId>
<artifactId>websocket-jakarta-server</artifactId>
<version>4.2.26</version>
</dependency>
<dependency>
@@ -357,7 +340,6 @@
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>jakarta.mail</artifactId>
<version>2.0.1</version>
</dependency>
<dependency>
@@ -409,7 +391,15 @@
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.3.1</version>
</dependency>
<!-- add brotli support for httpclient5 -->
<dependency>
<groupId>org.brotli</groupId>
<artifactId>dec</artifactId>
<version>0.1.2</version>
</dependency>
<dependency>
<groupId>io.github.hakky54</groupId>
<artifactId>sslcontext-kickstart-for-apache5</artifactId>
@@ -423,35 +413,8 @@
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.3.232</version>
</dependency>
<dependency>
<groupId>com.manticore-projects.tools</groupId>
<artifactId>h2migrationtool</artifactId>
<version>1.7</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>9.0.0</version>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
@@ -464,39 +427,41 @@
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mock-server</groupId>
<artifactId>mockserver-junit-jupiter</artifactId>
<version>5.15.0</version>
<scope>test</scope>
</dependency>
<!-- dropwizard pulls a version of bouncycastle different than the one pulled by mockserver, causing NoSuchFieldError on BCObjectIdentifiers.sphincsPlus_shake_256 -->
<!-- bouncycastle is required by mockserver to generate a self-signed certificate dynamically when https is used -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
<groupId>org.glassfish.jersey.core</groupId>
<artifactId>jersey-client</artifactId>
<version>${jersey.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
<groupId>org.glassfish.jersey.media</groupId>
<artifactId>jersey-media-multipart</artifactId>
<version>${jersey.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.media</groupId>
<artifactId>jersey-media-json-jackson</artifactId>
<version>${jersey.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.icegreen</groupId>
<artifactId>greenmail-junit5</artifactId>
<version>2.0.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-testing</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
@@ -508,30 +473,19 @@
<version>1.46.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mariadb</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<profiles>
<profile>
<id>native</id>
<activation>
<property>
<name>native</name>
</property>
</activation>
<properties>
<quarkus.native.enabled>true</quarkus.native.enabled>
</properties>
</profile>
</profiles>
</project>

View File

@@ -0,0 +1,21 @@
<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.2.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.2.0 https://maven.apache.org/xsd/assembly-2.2.0.xsd">
<id>zip-quarkus-app</id>
<includeBaseDirectory>true</includeBaseDirectory>
<baseDirectory>commafeed-${project.version}-${quarkus.datasource.db-kind}</baseDirectory>
<formats>
<format>zip</format>
</formats>
<fileSets>
<fileSet>
<directory>${project.build.directory}/quarkus-app</directory>
<outputDirectory>/</outputDirectory>
<includes>
<include>**/*</include>
</includes>
</fileSet>
</fileSets>
</assembly>

View File

@@ -1,277 +1,44 @@
package com.commafeed;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.EnumSet;
import java.util.Set;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.eclipse.jetty.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer;
import org.hibernate.cfg.AvailableSettings;
import com.codahale.metrics.json.MetricsModule;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.feed.FeedRefreshEngine;
import com.commafeed.backend.model.AbstractModel;
import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryContent;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserRole;
import com.commafeed.backend.model.UserSettings;
import com.commafeed.backend.service.UserService;
import com.commafeed.backend.service.db.DatabaseStartupService;
import com.commafeed.backend.service.db.H2MigrationService;
import com.commafeed.backend.task.ScheduledTask;
import com.commafeed.frontend.auth.PasswordConstraintValidator;
import com.commafeed.frontend.auth.SecurityCheckFactoryProvider;
import com.commafeed.frontend.resource.AdminREST;
import com.commafeed.frontend.resource.CategoryREST;
import com.commafeed.frontend.resource.EntryREST;
import com.commafeed.frontend.resource.FeedREST;
import com.commafeed.frontend.resource.ServerREST;
import com.commafeed.frontend.resource.UserREST;
import com.commafeed.frontend.resource.fever.FeverREST;
import com.commafeed.frontend.servlet.CustomCssServlet;
import com.commafeed.frontend.servlet.CustomJsServlet;
import com.commafeed.frontend.servlet.LogoutServlet;
import com.commafeed.frontend.servlet.NextUnreadServlet;
import com.commafeed.frontend.servlet.RobotsTxtDisallowAllServlet;
import com.commafeed.frontend.session.SessionHelperFactoryProvider;
import com.commafeed.frontend.ws.WebSocketConfigurator;
import com.commafeed.frontend.ws.WebSocketEndpoint;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.google.inject.Guice;
import com.google.inject.Injector;
import com.google.inject.Key;
import com.google.inject.TypeLiteral;
import com.commafeed.backend.task.TaskScheduler;
import com.commafeed.security.password.PasswordConstraintValidator;
import io.dropwizard.assets.AssetsBundle;
import io.dropwizard.configuration.DefaultConfigurationFactoryFactory;
import io.dropwizard.configuration.EnvironmentVariableSubstitutor;
import io.dropwizard.configuration.SubstitutingSourceProvider;
import io.dropwizard.core.Application;
import io.dropwizard.core.ConfiguredBundle;
import io.dropwizard.core.setup.Bootstrap;
import io.dropwizard.core.setup.Environment;
import io.dropwizard.db.DataSourceFactory;
import io.dropwizard.forms.MultiPartBundle;
import io.dropwizard.hibernate.HibernateBundle;
import io.dropwizard.migrations.MigrationsBundle;
import io.dropwizard.servlets.CacheBustingFilter;
import io.whitfin.dropwizard.configuration.EnvironmentSubstitutor;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.websocket.server.ServerEndpointConfig;
import io.quarkus.runtime.ShutdownEvent;
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
public class CommaFeedApplication extends Application<CommaFeedConfiguration> {
@Singleton
@RequiredArgsConstructor
public class CommaFeedApplication {
public static final String USERNAME_ADMIN = "admin";
public static final String USERNAME_DEMO = "demo";
public static final Instant STARTUP_TIME = Instant.now();
private HibernateBundle<CommaFeedConfiguration> hibernateBundle;
private final DatabaseStartupService databaseStartupService;
private final FeedRefreshEngine feedRefreshEngine;
private final TaskScheduler taskScheduler;
private final CommaFeedConfiguration config;
@Override
public String getName() {
return "CommaFeed";
public void start(@Observes StartupEvent ev) {
PasswordConstraintValidator.setStrict(config.users().strictPasswordPolicy());
databaseStartupService.populateInitialData();
feedRefreshEngine.start();
taskScheduler.start();
}
@Override
public void initialize(Bootstrap<CommaFeedConfiguration> bootstrap) {
configureEnvironmentSubstitutor(bootstrap);
configureObjectMapper(bootstrap.getObjectMapper());
// run h2 migration as the first bundle because we need to migrate before hibernate is initialized
bootstrap.addBundle(new ConfiguredBundle<>() {
@Override
public void run(CommaFeedConfiguration config, Environment environment) {
DataSourceFactory dataSourceFactory = config.getDataSourceFactory();
String url = dataSourceFactory.getUrl();
if (isFileBasedH2(url)) {
Path path = getFilePath(url);
String user = dataSourceFactory.getUser();
String password = dataSourceFactory.getPassword();
new H2MigrationService().migrateIfNeeded(path, user, password);
}
}
private boolean isFileBasedH2(String url) {
return url.startsWith("jdbc:h2:") && !url.startsWith("jdbc:h2:mem:");
}
private Path getFilePath(String url) {
String name = url.substring("jdbc:h2:".length()).split(";")[0];
return Paths.get(name + ".mv.db");
}
});
bootstrap.addBundle(hibernateBundle = new HibernateBundle<>(AbstractModel.class, Feed.class, FeedCategory.class, FeedEntry.class,
FeedEntryContent.class, FeedEntryStatus.class, FeedEntryTag.class, FeedSubscription.class, User.class, UserRole.class,
UserSettings.class) {
@Override
public DataSourceFactory getDataSourceFactory(CommaFeedConfiguration configuration) {
DataSourceFactory factory = configuration.getDataSourceFactory();
factory.getProperties().put(AvailableSettings.PREFERRED_POOLED_OPTIMIZER, "pooled-lo");
factory.getProperties().put(AvailableSettings.STATEMENT_BATCH_SIZE, "50");
factory.getProperties().put(AvailableSettings.BATCH_VERSIONED_DATA, "true");
factory.getProperties().put(AvailableSettings.ORDER_INSERTS, "true");
factory.getProperties().put(AvailableSettings.ORDER_UPDATES, "true");
return factory;
}
});
bootstrap.addBundle(new MigrationsBundle<>() {
@Override
public DataSourceFactory getDataSourceFactory(CommaFeedConfiguration configuration) {
return configuration.getDataSourceFactory();
}
});
bootstrap.addBundle(new AssetsBundle("/assets/", "/", "index.html"));
bootstrap.addBundle(new MultiPartBundle());
public void stop(@Observes ShutdownEvent ev) {
feedRefreshEngine.stop();
taskScheduler.stop();
}
private static void configureEnvironmentSubstitutor(Bootstrap<CommaFeedConfiguration> bootstrap) {
bootstrap.setConfigurationFactoryFactory(new DefaultConfigurationFactoryFactory<>() {
@Override
protected ObjectMapper configureObjectMapper(ObjectMapper objectMapper) {
// disable case sensitivity because EnvironmentSubstitutor maps MYPROPERTY to myproperty and not to myProperty
return objectMapper
.setConfig(objectMapper.getDeserializationConfig().with(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES));
}
});
bootstrap.setConfigurationSourceProvider(buildEnvironmentSubstitutor(bootstrap));
}
private static void configureObjectMapper(ObjectMapper objectMapper) {
// read and write instants as milliseconds instead of nanoseconds
objectMapper.configure(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, false)
.configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false);
// add support for serializing metrics
objectMapper.registerModule(new MetricsModule(TimeUnit.SECONDS, TimeUnit.SECONDS, false));
}
private static EnvironmentSubstitutor buildEnvironmentSubstitutor(Bootstrap<CommaFeedConfiguration> bootstrap) {
// enable config.yml string substitution
// e.g. having a custom config.yml file with app.session.path=${SOME_ENV_VAR} will substitute SOME_ENV_VAR
SubstitutingSourceProvider substitutingSourceProvider = new SubstitutingSourceProvider(bootstrap.getConfigurationSourceProvider(),
new EnvironmentVariableSubstitutor(false));
// enable config.yml properties override with env variables prefixed with CF_
// e.g. setting CF_APP_ALLOWREGISTRATIONS=true will set app.allowRegistrations to true
return new EnvironmentSubstitutor("CF", substitutingSourceProvider);
}
@Override
public void run(CommaFeedConfiguration config, Environment environment) {
PasswordConstraintValidator.setStrict(config.getApplicationSettings().getStrictPasswordPolicy());
// guice init
Injector injector = Guice.createInjector(new CommaFeedModule(hibernateBundle.getSessionFactory(), config, environment.metrics()));
// session management
environment.servlets().setSessionHandler(config.getSessionHandlerFactory().build(config.getDataSourceFactory()));
// support for "@SecurityCheck User user" injection
environment.jersey()
.register(new SecurityCheckFactoryProvider.Binder(injector.getInstance(UserDAO.class),
injector.getInstance(UserService.class), config));
// support for "@Context SessionHelper sessionHelper" injection
environment.jersey().register(new SessionHelperFactoryProvider.Binder());
// REST resources
environment.jersey().setUrlPattern("/rest/*");
environment.jersey().register(injector.getInstance(AdminREST.class));
environment.jersey().register(injector.getInstance(CategoryREST.class));
environment.jersey().register(injector.getInstance(EntryREST.class));
environment.jersey().register(injector.getInstance(FeedREST.class));
environment.jersey().register(injector.getInstance(ServerREST.class));
environment.jersey().register(injector.getInstance(UserREST.class));
environment.jersey().register(injector.getInstance(FeverREST.class));
// Servlets
environment.servlets().addServlet("next", injector.getInstance(NextUnreadServlet.class)).addMapping("/next");
environment.servlets().addServlet("logout", injector.getInstance(LogoutServlet.class)).addMapping("/logout");
environment.servlets().addServlet("customCss", injector.getInstance(CustomCssServlet.class)).addMapping("/custom_css.css");
environment.servlets().addServlet("customJs", injector.getInstance(CustomJsServlet.class)).addMapping("/custom_js.js");
if (Boolean.TRUE.equals(config.getApplicationSettings().getHideFromWebCrawlers())) {
environment.servlets()
.addServlet("robots.txt", injector.getInstance(RobotsTxtDisallowAllServlet.class))
.addMapping("/robots.txt");
}
// WebSocket endpoint
JakartaWebSocketServletContainerInitializer.configure(environment.getApplicationContext(), (context, container) -> {
container.setDefaultMaxSessionIdleTimeout(config.getApplicationSettings().getWebsocketPingInterval().toMilliseconds() + 10000);
container.addEndpoint(ServerEndpointConfig.Builder.create(WebSocketEndpoint.class, "/ws")
.configurator(injector.getInstance(WebSocketConfigurator.class))
.build());
});
// Scheduled tasks
Set<ScheduledTask> tasks = injector.getInstance(Key.get(new TypeLiteral<>() {
}));
ScheduledExecutorService executor = environment.lifecycle()
.scheduledExecutorService("task-scheduler", true)
.threads(tasks.size())
.build();
for (ScheduledTask task : tasks) {
task.register(executor);
}
// database init/changelogs
environment.lifecycle().manage(injector.getInstance(DatabaseStartupService.class));
// start feed fetching engine
environment.lifecycle().manage(injector.getInstance(FeedRefreshEngine.class));
// prevent caching index.html, so that the webapp is always up to date
environment.servlets()
.addFilter("index-cache-busting-filter", new CacheBustingFilter())
.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/");
// prevent caching openapi files, so that the documentation is always up to date
environment.servlets()
.addFilter("openapi-cache-busting-filter", new CacheBustingFilter())
.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/openapi.json", "/openapi.yaml");
// prevent caching REST resources, except for favicons
environment.servlets().addFilter("rest-cache-busting-filter", new CacheBustingFilter() {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String path = ((HttpServletRequest) request).getRequestURI();
if (path.contains("/feed/favicon")) {
chain.doFilter(request, response);
} else {
super.doFilter(request, response, chain);
}
}
}).addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/rest/*");
}
public static void main(String[] args) throws Exception {
new CommaFeedApplication().run(args);
}
}

View File

@@ -1,188 +1,287 @@
package com.commafeed;
import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Properties;
import java.util.Optional;
import com.commafeed.backend.cache.RedisPoolFactory;
import com.commafeed.frontend.session.SessionHandlerFactory;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.commafeed.backend.feed.FeedRefreshIntervalCalculator;
import io.dropwizard.core.Configuration;
import io.dropwizard.db.DataSourceFactory;
import io.dropwizard.util.DataSize;
import io.dropwizard.util.Duration;
import jakarta.validation.Valid;
import io.quarkus.runtime.configuration.MemorySize;
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Getter;
import lombok.Setter;
import redis.clients.jedis.DefaultJedisClientConfig;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.JedisClientConfig;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.Protocol;
@Getter
@Setter
public class CommaFeedConfiguration extends Configuration {
/**
* CommaFeed configuration
*
* Default values are for production, they can be overridden in application.properties
*/
@ConfigMapping(prefix = "commafeed")
public interface CommaFeedConfiguration {
public enum CacheType {
NOOP, REDIS
/**
* URL used to access commafeed, used for various redirects.
*
*/
@NotBlank
@WithDefault("http://localhost:8082")
String publicUrl();
/**
* 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.
*
* This is useful if commafeed is accessed through a restricting proxy that blocks some feeds that are followed.
*/
@WithDefault("false")
boolean imageProxyEnabled();
/**
* Message displayed in a notification at the bottom of the page.
*/
Optional<String> announcement();
/**
* Google Analytics tracking code.
*/
Optional<String> googleAnalyticsTrackingCode();
/**
* Google Auth key for fetching Youtube favicons.
*/
Optional<String> googleAuthKey();
/**
* Feed refresh engine settings.
*/
FeedRefresh feedRefresh();
/**
* Database settings.
*/
Database database();
/**
* Users settings.
*/
Users users();
/**
* Websocket settings.
*/
Websocket websocket();
/**
* SMTP settings for password recovery.
*/
Optional<Smtp> smtp();
/**
* Redis settings to enable caching. This is only really useful on instances with a lot of users.
*/
Redis redis();
interface FeedRefresh {
/**
* Amount of time CommaFeed will wait before refreshing the same feed.
*/
@WithDefault("5m")
Duration interval();
/**
* If true, CommaFeed will calculate the next refresh time based on the feed's average entry interval and the time since the last
* entry was published. See {@link FeedRefreshIntervalCalculator} for details.
*/
@WithDefault("false")
boolean intervalEmpirical();
/**
* Amount of http threads used to fetch feeds.
*/
@Min(1)
@WithDefault("3")
int httpThreads();
/**
* Amount of threads used to insert new entries in the database.
*/
@Min(1)
@WithDefault("1")
int databaseThreads();
/**
* If a feed is larger than this, it will be discarded to prevent memory issues while parsing the feed.
*/
@WithDefault("5M")
MemorySize maxResponseSize();
/**
* Duration after which a user is considered inactive. Feeds for inactive users are not refreshed until they log in again.
*/
@WithDefault("0")
Duration userInactivityPeriod();
/**
* User-Agent string that will be used by the http client, leave empty for the default one.
*/
Optional<String> userAgent();
}
@Valid
@NotNull
@JsonProperty("database")
private final DataSourceFactory dataSourceFactory = new DataSourceFactory();
interface Database {
/**
* Database query timeout.
*/
@WithDefault("0")
int queryTimeout();
@Valid
@NotNull
@JsonProperty("redis")
private final RedisPoolFactory redisPoolFactory = new RedisPoolFactory();
Cleanup cleanup();
@Valid
@NotNull
@JsonProperty("session")
private final SessionHandlerFactory sessionHandlerFactory = new SessionHandlerFactory();
interface Cleanup {
/**
* Maximum age of feed entries in the database. Older entries will be deleted. 0 to disable.
*/
@WithDefault("365d")
Duration entriesMaxAge();
@Valid
@NotNull
@JsonProperty("app")
private ApplicationSettings applicationSettings;
/**
* Maximum age of feed entry statuses (read/unread) in the database. Older statuses will be deleted. 0 to disable.
*/
@WithDefault("0")
Duration statusesMaxAge();
private final String version;
private final String gitCommit;
/**
* Maximum number of entries per feed to keep in the database. 0 to disable.
*/
@WithDefault("500")
int maxFeedCapacity();
public CommaFeedConfiguration() {
Properties properties = new Properties();
try (InputStream stream = getClass().getResourceAsStream("/git.properties")) {
if (stream != null) {
properties.load(stream);
/**
* Limit the number of feeds a user can subscribe to. 0 to disable.
*/
@WithDefault("0")
int maxFeedsPerUser();
/**
* Rows to delete per query while cleaning up old entries.
*/
@Positive
@WithDefault("100")
int batchSize();
default Instant statusesInstantThreshold() {
return statusesMaxAge().toMillis() > 0 ? Instant.now().minus(statusesMaxAge()) : null;
}
} catch (IOException e) {
throw new RuntimeException(e);
}
this.version = properties.getProperty("git.build.version", "unknown");
this.gitCommit = properties.getProperty("git.commit.id.abbrev", "unknown");
}
@Getter
@Setter
public static class ApplicationSettings {
@NotNull
@NotBlank
@Valid
private String publicUrl;
interface Users {
/**
* Whether to let users create accounts for themselves.
*/
@WithDefault("false")
boolean allowRegistrations();
@NotNull
@Valid
private Boolean hideFromWebCrawlers = true;
/**
* Whether to enable strict password validation (1 uppercase char, 1 lowercase char, 1 digit, 1 special char).
*/
@WithDefault("true")
boolean strictPasswordPolicy();
@NotNull
@Valid
private Boolean allowRegistrations;
/**
* Whether to create a demo account the first time the app starts.
*/
@WithDefault("false")
boolean createDemoAccount();
}
@NotNull
@Valid
private Boolean strictPasswordPolicy = true;
interface Smtp {
String host();
@NotNull
@Valid
private Boolean createDemoAccount;
int port();
private String googleAnalyticsTrackingCode;
boolean tls();
private String googleAuthKey;
String userName();
@NotNull
@Min(1)
@Valid
private Integer backgroundThreads;
String password();
@NotNull
@Min(1)
@Valid
private Integer databaseUpdateThreads;
String fromAddress();
}
@NotNull
@Positive
@Valid
private Integer databaseCleanupBatchSize = 100;
interface Websocket {
/**
* Enable websocket connection so the server can notify the web client that there are new entries for your feeds.
*/
@WithDefault("true")
boolean enabled();
private String smtpHost;
private int smtpPort;
private boolean smtpTls;
private String smtpUserName;
private String smtpPassword;
private String smtpFromAddress;
/**
* Interval at which the client will send a ping message on the websocket to keep the connection alive.
*/
@WithDefault("15m")
Duration pingInterval();
private boolean graphiteEnabled;
private String graphitePrefix;
private String graphiteHost;
private int graphitePort;
private int graphiteInterval;
/**
* 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();
}
@NotNull
@Valid
private Boolean heavyLoad;
interface Redis {
@NotNull
@Valid
private Boolean imageProxyEnabled;
Optional<String> host();
@NotNull
@Min(0)
@Valid
private Integer queryTimeout;
@WithDefault("" + Protocol.DEFAULT_PORT)
int port();
@NotNull
@Min(0)
@Valid
private Integer keepStatusDays;
/**
* Username is only required when using Redis ACLs
*/
Optional<String> username();
@NotNull
@Min(0)
@Valid
private Integer maxFeedCapacity;
Optional<String> password();
@NotNull
@Min(0)
@Valid
private Integer maxEntriesAgeDays = 0;
@WithDefault("" + Protocol.DEFAULT_TIMEOUT)
int timeout();
@NotNull
@Valid
private Integer maxFeedsPerUser = 0;
@WithDefault("" + Protocol.DEFAULT_DATABASE)
int database();
@NotNull
@Valid
private DataSize maxFeedResponseSize = DataSize.megabytes(5);
@WithDefault("500")
int maxTotal();
@NotNull
@Min(0)
@Valid
private Integer refreshIntervalMinutes;
default JedisPool build() {
Optional<String> host = host();
if (host.isEmpty()) {
throw new IllegalStateException("Redis host is required");
}
@NotNull
@Valid
private CacheType cache;
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(maxTotal());
@Valid
private String announcement;
JedisClientConfig clientConfig = DefaultJedisClientConfig.builder()
.user(username().orElse(null))
.password(password().orElse(null))
.timeoutMillis(timeout())
.database(database())
.build();
private String userAgent;
private Boolean websocketEnabled = true;
private Duration websocketPingInterval = Duration.minutes(15);
private Duration treeReloadInterval = Duration.seconds(30);
public Instant getUnreadThreshold() {
return getKeepStatusDays() > 0 ? Instant.now().minus(getKeepStatusDays(), ChronoUnit.DAYS) : null;
return new JedisPool(poolConfig, new HostAndPort(host.get(), port()), clientConfig);
}
}
}

View File

@@ -1,97 +0,0 @@
package com.commafeed;
import java.net.InetSocketAddress;
import java.util.concurrent.TimeUnit;
import org.hibernate.SessionFactory;
import com.codahale.metrics.MetricFilter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.graphite.Graphite;
import com.codahale.metrics.graphite.GraphiteReporter;
import com.commafeed.CommaFeedConfiguration.ApplicationSettings;
import com.commafeed.CommaFeedConfiguration.CacheType;
import com.commafeed.backend.cache.CacheService;
import com.commafeed.backend.cache.NoopCacheService;
import com.commafeed.backend.cache.RedisCacheService;
import com.commafeed.backend.favicon.AbstractFaviconFetcher;
import com.commafeed.backend.favicon.DefaultFaviconFetcher;
import com.commafeed.backend.favicon.FacebookFaviconFetcher;
import com.commafeed.backend.favicon.YoutubeFaviconFetcher;
import com.commafeed.backend.task.DemoAccountCleanupTask;
import com.commafeed.backend.task.EntriesExceedingFeedCapacityCleanupTask;
import com.commafeed.backend.task.OldEntriesCleanupTask;
import com.commafeed.backend.task.OldStatusesCleanupTask;
import com.commafeed.backend.task.OrphanedContentsCleanupTask;
import com.commafeed.backend.task.OrphanedFeedsCleanupTask;
import com.commafeed.backend.task.ScheduledTask;
import com.commafeed.backend.urlprovider.FeedURLProvider;
import com.commafeed.backend.urlprovider.InPageReferenceFeedURLProvider;
import com.commafeed.backend.urlprovider.YoutubeFeedURLProvider;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.multibindings.Multibinder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@RequiredArgsConstructor
@Slf4j
public class CommaFeedModule extends AbstractModule {
@Getter(onMethod = @__({ @Provides }))
private final SessionFactory sessionFactory;
@Getter(onMethod = @__({ @Provides }))
private final CommaFeedConfiguration config;
@Getter(onMethod = @__({ @Provides }))
private final MetricRegistry metrics;
@Override
protected void configure() {
CacheService cacheService = config.getApplicationSettings().getCache() == CacheType.NOOP ? new NoopCacheService()
: new RedisCacheService(config.getRedisPoolFactory().build());
log.info("using cache {}", cacheService.getClass());
bind(CacheService.class).toInstance(cacheService);
Multibinder<AbstractFaviconFetcher> faviconMultibinder = Multibinder.newSetBinder(binder(), AbstractFaviconFetcher.class);
faviconMultibinder.addBinding().to(YoutubeFaviconFetcher.class);
faviconMultibinder.addBinding().to(FacebookFaviconFetcher.class);
faviconMultibinder.addBinding().to(DefaultFaviconFetcher.class);
Multibinder<FeedURLProvider> urlProviderMultibinder = Multibinder.newSetBinder(binder(), FeedURLProvider.class);
urlProviderMultibinder.addBinding().to(InPageReferenceFeedURLProvider.class);
urlProviderMultibinder.addBinding().to(YoutubeFeedURLProvider.class);
Multibinder<ScheduledTask> taskMultibinder = Multibinder.newSetBinder(binder(), ScheduledTask.class);
taskMultibinder.addBinding().to(OldStatusesCleanupTask.class);
taskMultibinder.addBinding().to(EntriesExceedingFeedCapacityCleanupTask.class);
taskMultibinder.addBinding().to(OldEntriesCleanupTask.class);
taskMultibinder.addBinding().to(OrphanedFeedsCleanupTask.class);
taskMultibinder.addBinding().to(OrphanedContentsCleanupTask.class);
taskMultibinder.addBinding().to(DemoAccountCleanupTask.class);
ApplicationSettings settings = config.getApplicationSettings();
if (settings.isGraphiteEnabled()) {
final String graphitePrefix = settings.getGraphitePrefix();
final String graphiteHost = settings.getGraphiteHost();
final int graphitePort = settings.getGraphitePort();
final int graphiteInterval = settings.getGraphiteInterval();
log.info("Graphite Metrics will be sent to host={}, port={}, prefix={}, interval={}sec", graphiteHost, graphitePort,
graphitePrefix, graphiteInterval);
final Graphite graphite = new Graphite(new InetSocketAddress(graphiteHost, graphitePort));
final GraphiteReporter reporter = GraphiteReporter.forRegistry(metrics)
.prefixedWith(graphitePrefix)
.convertRatesTo(TimeUnit.SECONDS)
.convertDurationsTo(TimeUnit.MILLISECONDS)
.filter(MetricFilter.ALL)
.build(graphite);
reporter.start(graphiteInterval, TimeUnit.SECONDS);
}
}
}

View File

@@ -0,0 +1,31 @@
package com.commafeed;
import com.codahale.metrics.MetricRegistry;
import com.commafeed.CommaFeedConfiguration.Redis;
import com.commafeed.backend.cache.CacheService;
import com.commafeed.backend.cache.NoopCacheService;
import com.commafeed.backend.cache.RedisCacheService;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Singleton;
@Singleton
public class CommaFeedProducers {
@Produces
@Singleton
public CacheService cacheService(CommaFeedConfiguration config) {
Redis redis = config.redis();
if (redis.host().isEmpty()) {
return new NoopCacheService();
}
return new RedisCacheService(redis.build());
}
@Produces
@Singleton
public MetricRegistry metricRegistry() {
return new MetricRegistry();
}
}

View File

@@ -0,0 +1,31 @@
package com.commafeed;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
import jakarta.inject.Singleton;
import lombok.Getter;
@Singleton
@Getter
public class CommaFeedVersion {
private final String version;
private final String gitCommit;
public CommaFeedVersion() {
Properties properties = new Properties();
try (InputStream stream = getClass().getResourceAsStream("/git.properties")) {
if (stream != null) {
properties.load(stream);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
this.version = properties.getProperty("git.build.version", "unknown");
this.gitCommit = properties.getProperty("git.commit.id.abbrev", "unknown");
}
}

View File

@@ -0,0 +1,33 @@
package com.commafeed;
import org.jboss.resteasy.reactive.RestResponse;
import org.jboss.resteasy.reactive.RestResponse.Status;
import org.jboss.resteasy.reactive.server.ServerExceptionMapper;
import io.quarkus.security.AuthenticationFailedException;
import jakarta.annotation.Priority;
import jakarta.validation.ValidationException;
import jakarta.ws.rs.ext.Provider;
@Provider
@Priority(1)
public class ExceptionMappers {
// display a message when the user fails to authenticate
@ServerExceptionMapper(AuthenticationFailedException.class)
public RestResponse<AuthenticationExceptionInfo> authenticationFailed(AuthenticationFailedException e) {
return RestResponse.status(RestResponse.Status.UNAUTHORIZED, new AuthenticationExceptionInfo(e.getMessage()));
}
// display a message for validation errors
@ServerExceptionMapper(ValidationException.class)
public RestResponse<ValidationExceptionInfo> validationException(ValidationException e) {
return RestResponse.status(Status.BAD_REQUEST, new ValidationExceptionInfo(e.getMessage()));
}
public record AuthenticationExceptionInfo(String message) {
}
public record ValidationExceptionInfo(String message) {
}
}

View File

@@ -0,0 +1,27 @@
package com.commafeed;
import java.util.concurrent.TimeUnit;
import com.codahale.metrics.json.MetricsModule;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import io.quarkus.jackson.ObjectMapperCustomizer;
import jakarta.inject.Singleton;
@Singleton
public class JacksonCustomizer implements ObjectMapperCustomizer {
@Override
public void customize(ObjectMapper objectMapper) {
objectMapper.registerModule(new JavaTimeModule());
// read and write instants as milliseconds instead of nanoseconds
objectMapper.configure(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, false)
.configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false);
// add support for serializing metrics
objectMapper.registerModule(new MetricsModule(TimeUnit.SECONDS, TimeUnit.SECONDS, false));
}
}

View File

@@ -0,0 +1,165 @@
package com.commafeed;
import com.codahale.metrics.Counter;
import com.codahale.metrics.Gauge;
import com.codahale.metrics.Histogram;
import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.Timer;
import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection(
targets = {
// metrics
MetricRegistry.class, Meter.class, Gauge.class, Counter.class, Timer.class, Histogram.class,
// rome
com.rometools.rome.feed.module.DCModuleImpl.class, com.rometools.rome.feed.module.DCSubjectImpl.class,
com.rometools.modules.content.ContentModuleImpl.class, com.rometools.modules.mediarss.MediaModuleImpl.class,
com.rometools.modules.mediarss.MediaEntryModuleImpl.class,
// extracted from all 3 rome.properties files of rome library
com.rometools.rome.io.impl.RSS090Parser.class, com.rometools.rome.io.impl.RSS091NetscapeParser.class,
com.rometools.rome.io.impl.RSS091UserlandParser.class, com.rometools.rome.io.impl.RSS092Parser.class,
com.rometools.rome.io.impl.RSS093Parser.class, com.rometools.rome.io.impl.RSS094Parser.class,
com.rometools.rome.io.impl.RSS10Parser.class, com.rometools.rome.io.impl.RSS20wNSParser.class,
com.rometools.rome.io.impl.RSS20Parser.class, com.rometools.rome.io.impl.Atom10Parser.class,
com.rometools.rome.io.impl.Atom03Parser.class,
com.rometools.rome.io.impl.SyModuleParser.class, com.rometools.rome.io.impl.DCModuleParser.class,
com.rometools.rome.io.impl.RSS090Generator.class, com.rometools.rome.io.impl.RSS091NetscapeGenerator.class,
com.rometools.rome.io.impl.RSS091UserlandGenerator.class, com.rometools.rome.io.impl.RSS092Generator.class,
com.rometools.rome.io.impl.RSS093Generator.class, com.rometools.rome.io.impl.RSS094Generator.class,
com.rometools.rome.io.impl.RSS10Generator.class, com.rometools.rome.io.impl.RSS20Generator.class,
com.rometools.rome.io.impl.Atom10Generator.class, com.rometools.rome.io.impl.Atom03Generator.class,
com.rometools.rome.feed.synd.impl.ConverterForAtom10.class, com.rometools.rome.feed.synd.impl.ConverterForAtom03.class,
com.rometools.rome.feed.synd.impl.ConverterForRSS090.class,
com.rometools.rome.feed.synd.impl.ConverterForRSS091Netscape.class,
com.rometools.rome.feed.synd.impl.ConverterForRSS091Userland.class,
com.rometools.rome.feed.synd.impl.ConverterForRSS092.class, com.rometools.rome.feed.synd.impl.ConverterForRSS093.class,
com.rometools.rome.feed.synd.impl.ConverterForRSS094.class, com.rometools.rome.feed.synd.impl.ConverterForRSS10.class,
com.rometools.rome.feed.synd.impl.ConverterForRSS20.class,
com.rometools.modules.mediarss.io.RSS20YahooParser.class,
com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.content.io.ContentModuleParser.class,
com.rometools.modules.itunes.io.ITunesParser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class, com.rometools.modules.georss.SimpleParser.class,
com.rometools.modules.georss.W3CGeoParser.class, com.rometools.modules.photocast.io.Parser.class,
com.rometools.modules.mediarss.io.MediaModuleParser.class, com.rometools.modules.atom.io.AtomModuleParser.class,
com.rometools.modules.itunes.io.ITunesParserOldNamespace.class,
com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class, com.rometools.modules.sle.io.ModuleParser.class,
com.rometools.modules.yahooweather.io.WeatherModuleParser.class, com.rometools.modules.feedpress.io.FeedpressParser.class,
com.rometools.modules.fyyd.io.FyydParser.class,
com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.content.io.ContentModuleParser.class,
com.rometools.modules.itunes.io.ITunesParser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class, com.rometools.modules.georss.SimpleParser.class,
com.rometools.modules.georss.W3CGeoParser.class, com.rometools.modules.photocast.io.Parser.class,
com.rometools.modules.mediarss.io.MediaModuleParser.class, com.rometools.modules.atom.io.AtomModuleParser.class,
com.rometools.modules.itunes.io.ITunesParserOldNamespace.class,
com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class, com.rometools.modules.sle.io.ModuleParser.class,
com.rometools.modules.yahooweather.io.WeatherModuleParser.class, com.rometools.modules.feedpress.io.FeedpressParser.class,
com.rometools.modules.fyyd.io.FyydParser.class,
com.rometools.modules.cc.io.ModuleParserRSS1.class, com.rometools.modules.content.io.ContentModuleParser.class,
com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class,
com.rometools.modules.georss.SimpleParser.class, com.rometools.modules.georss.W3CGeoParser.class,
com.rometools.modules.photocast.io.Parser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class,
com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class,
com.rometools.modules.georss.SimpleParser.class, com.rometools.modules.georss.W3CGeoParser.class,
com.rometools.modules.photocast.io.Parser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class,
com.rometools.modules.feedpress.io.FeedpressParser.class, com.rometools.modules.fyyd.io.FyydParser.class,
com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.base.io.GoogleBaseParser.class,
com.rometools.modules.content.io.ContentModuleParser.class, com.rometools.modules.slash.io.SlashModuleParser.class,
com.rometools.modules.itunes.io.ITunesParser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.atom.io.AtomModuleParser.class, com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class,
com.rometools.modules.georss.SimpleParser.class, com.rometools.modules.georss.W3CGeoParser.class,
com.rometools.modules.photocast.io.Parser.class, com.rometools.modules.itunes.io.ITunesParserOldNamespace.class,
com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class, com.rometools.modules.sle.io.ItemParser.class,
com.rometools.modules.yahooweather.io.WeatherModuleParser.class,
com.rometools.modules.psc.io.PodloveSimpleChapterParser.class,
com.rometools.modules.cc.io.ModuleParserRSS1.class, com.rometools.modules.base.io.GoogleBaseParser.class,
com.rometools.modules.base.io.CustomTagParser.class, com.rometools.modules.content.io.ContentModuleParser.class,
com.rometools.modules.slash.io.SlashModuleParser.class,
com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.base.io.GoogleBaseParser.class,
com.rometools.modules.base.io.CustomTagParser.class, com.rometools.modules.slash.io.SlashModuleParser.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class, com.rometools.modules.georss.SimpleParser.class,
com.rometools.modules.georss.W3CGeoParser.class, com.rometools.modules.photocast.io.Parser.class,
com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class,
com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.base.io.GoogleBaseParser.class,
com.rometools.modules.base.io.CustomTagParser.class, com.rometools.modules.slash.io.SlashModuleParser.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class, com.rometools.modules.georss.SimpleParser.class,
com.rometools.modules.georss.W3CGeoParser.class, com.rometools.modules.photocast.io.Parser.class,
com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class,
com.rometools.modules.thr.io.ThreadingModuleParser.class, com.rometools.modules.psc.io.PodloveSimpleChapterParser.class,
com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.content.io.ContentModuleGenerator.class,
com.rometools.modules.itunes.io.ITunesGenerator.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class, com.rometools.modules.georss.SimpleGenerator.class,
com.rometools.modules.georss.W3CGeoGenerator.class, com.rometools.modules.photocast.io.Generator.class,
com.rometools.modules.mediarss.io.MediaModuleGenerator.class, com.rometools.modules.atom.io.AtomModuleGenerator.class,
com.rometools.modules.sle.io.ModuleGenerator.class, com.rometools.modules.yahooweather.io.WeatherModuleGenerator.class,
com.rometools.modules.feedpress.io.FeedpressGenerator.class, com.rometools.modules.fyyd.io.FyydGenerator.class,
com.rometools.modules.content.io.ContentModuleGenerator.class,
com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class,
com.rometools.modules.georss.SimpleGenerator.class, com.rometools.modules.georss.W3CGeoGenerator.class,
com.rometools.modules.photocast.io.Generator.class, com.rometools.modules.mediarss.io.MediaModuleGenerator.class,
com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class,
com.rometools.modules.georss.SimpleGenerator.class, com.rometools.modules.georss.W3CGeoGenerator.class,
com.rometools.modules.photocast.io.Generator.class, com.rometools.modules.mediarss.io.MediaModuleGenerator.class,
com.rometools.modules.feedpress.io.FeedpressGenerator.class, com.rometools.modules.fyyd.io.FyydGenerator.class,
com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.base.io.GoogleBaseGenerator.class,
com.rometools.modules.base.io.CustomTagGenerator.class, com.rometools.modules.content.io.ContentModuleGenerator.class,
com.rometools.modules.slash.io.SlashModuleGenerator.class, com.rometools.modules.itunes.io.ITunesGenerator.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class, com.rometools.modules.georss.SimpleGenerator.class,
com.rometools.modules.georss.W3CGeoGenerator.class, com.rometools.modules.photocast.io.Generator.class,
com.rometools.modules.mediarss.io.MediaModuleGenerator.class, com.rometools.modules.atom.io.AtomModuleGenerator.class,
com.rometools.modules.yahooweather.io.WeatherModuleGenerator.class,
com.rometools.modules.psc.io.PodloveSimpleChapterGenerator.class,
com.rometools.modules.base.io.GoogleBaseGenerator.class, com.rometools.modules.content.io.ContentModuleGenerator.class,
com.rometools.modules.slash.io.SlashModuleGenerator.class,
com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.base.io.GoogleBaseGenerator.class,
com.rometools.modules.base.io.CustomTagGenerator.class, com.rometools.modules.slash.io.SlashModuleGenerator.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class, com.rometools.modules.georss.SimpleGenerator.class,
com.rometools.modules.georss.W3CGeoGenerator.class, com.rometools.modules.photocast.io.Generator.class,
com.rometools.modules.mediarss.io.MediaModuleGenerator.class,
com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.base.io.CustomTagGenerator.class,
com.rometools.modules.slash.io.SlashModuleGenerator.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class, com.rometools.modules.georss.SimpleGenerator.class,
com.rometools.modules.georss.W3CGeoGenerator.class, com.rometools.modules.photocast.io.Generator.class,
com.rometools.modules.mediarss.io.MediaModuleGenerator.class, com.rometools.modules.thr.io.ThreadingModuleGenerator.class,
com.rometools.modules.psc.io.PodloveSimpleChapterGenerator.class,
com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.mediarss.io.MediaModuleGenerator.class,
com.rometools.opml.io.impl.OPML10Generator.class, com.rometools.opml.io.impl.OPML20Generator.class,
com.rometools.opml.io.impl.OPML10Parser.class, com.rometools.opml.io.impl.OPML20Parser.class,
com.rometools.opml.feed.synd.impl.ConverterForOPML10.class, com.rometools.opml.feed.synd.impl.ConverterForOPML20.class, })
public class NativeImageClasses {
}

View File

@@ -21,21 +21,21 @@ import org.apache.hc.client5.http.protocol.RedirectLocations;
import org.apache.hc.core5.http.ClassicHttpRequest;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.http.NameValuePair;
import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
import org.apache.hc.core5.http.message.BasicHeader;
import org.apache.hc.core5.util.TimeValue;
import org.apache.hc.core5.util.Timeout;
import org.eclipse.jetty.http.HttpStatus;
import com.codahale.metrics.MetricRegistry;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.CommaFeedVersion;
import com.google.common.collect.Iterables;
import com.google.common.io.ByteStreams;
import com.google.common.net.HttpHeaders;
import io.dropwizard.util.DataSize;
import jakarta.inject.Inject;
import io.quarkus.runtime.configuration.MemorySize;
import jakarta.inject.Singleton;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@@ -51,15 +51,15 @@ import nl.altindag.ssl.apache5.util.Apache5SslUtils;
public class HttpGetter {
private final CloseableHttpClient client;
private final DataSize maxResponseSize;
private final MemorySize maxResponseSize;
@Inject
public HttpGetter(CommaFeedConfiguration config, MetricRegistry metrics) {
PoolingHttpClientConnectionManager connectionManager = newConnectionManager(config.getApplicationSettings().getBackgroundThreads());
String userAgent = Optional.ofNullable(config.getApplicationSettings().getUserAgent())
.orElseGet(() -> String.format("CommaFeed/%s (https://github.com/Athou/commafeed)", config.getVersion()));
public HttpGetter(CommaFeedConfiguration config, CommaFeedVersion version, MetricRegistry metrics) {
PoolingHttpClientConnectionManager connectionManager = newConnectionManager(config.feedRefresh().httpThreads());
String userAgent = config.feedRefresh()
.userAgent()
.orElseGet(() -> String.format("CommaFeed/%s (https://github.com/Athou/commafeed)", version.getVersion()));
this.client = newClient(connectionManager, userAgent);
this.maxResponseSize = config.getApplicationSettings().getMaxFeedResponseSize();
this.maxResponseSize = config.feedRefresh().maxResponseSize();
metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "max"), () -> connectionManager.getTotalStats().getMax());
metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "size"),
@@ -98,7 +98,7 @@ public class HttpGetter {
context.setRequestConfig(RequestConfig.custom().setResponseTimeout(timeout, TimeUnit.MILLISECONDS).build());
HttpResponse response = client.execute(request, context, resp -> {
byte[] content = resp.getEntity() == null ? null : toByteArray(resp.getEntity(), maxResponseSize.toBytes());
byte[] content = resp.getEntity() == null ? null : toByteArray(resp.getEntity(), maxResponseSize.asLongValue());
int code = resp.getCode();
String lastModifiedHeader = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.LAST_MODIFIED))
.map(NameValuePair::getValue)
@@ -120,7 +120,7 @@ public class HttpGetter {
});
int code = response.getCode();
if (code == HttpStatus.NOT_MODIFIED_304) {
if (code == HttpStatus.SC_NOT_MODIFIED) {
throw new NotModifiedException("'304 - not modified' http code received");
} else if (code >= 300) {
throw new HttpResponseException(code, "Server returned HTTP error code " + code);

View File

@@ -1,51 +0,0 @@
package com.commafeed.backend.cache;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import redis.clients.jedis.DefaultJedisClientConfig;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.JedisClientConfig;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.Protocol;
@Getter
public class RedisPoolFactory {
@JsonProperty
private String host = "localhost";
@JsonProperty
private int port = Protocol.DEFAULT_PORT;
@JsonProperty
private String username;
@JsonProperty
private String password;
@JsonProperty
private int timeout = Protocol.DEFAULT_TIMEOUT;
@JsonProperty
private int database = Protocol.DEFAULT_DATABASE;
@JsonProperty
private int maxTotal = 500;
public JedisPool build() {
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(maxTotal);
JedisClientConfig clientConfig = DefaultJedisClientConfig.builder()
.user(username)
.password(password)
.timeoutMillis(timeout)
.database(database)
.build();
return new JedisPool(poolConfig, new HostAndPort(host, port), clientConfig);
}
}

View File

@@ -3,25 +3,22 @@ package com.commafeed.backend.dao;
import java.util.List;
import java.util.Objects;
import org.hibernate.SessionFactory;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.QFeedCategory;
import com.commafeed.backend.model.QUser;
import com.commafeed.backend.model.User;
import com.querydsl.core.types.Predicate;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
@Singleton
public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory;
@Inject
public FeedCategoryDAO(SessionFactory sessionFactory) {
super(sessionFactory);
public FeedCategoryDAO(EntityManager entityManager) {
super(entityManager, FeedCategory.class);
}
public List<FeedCategory> findAll(User user) {

View File

@@ -4,7 +4,6 @@ import java.time.Instant;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.hibernate.SessionFactory;
import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.QFeed;
@@ -12,8 +11,8 @@ import com.commafeed.backend.model.QFeedSubscription;
import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.impl.JPAQuery;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
@Singleton
public class FeedDAO extends GenericDAO<Feed> {
@@ -21,9 +20,8 @@ public class FeedDAO extends GenericDAO<Feed> {
private static final QFeed FEED = QFeed.feed;
private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription;
@Inject
public FeedDAO(SessionFactory sessionFactory) {
super(sessionFactory);
public FeedDAO(EntityManager entityManager) {
super(entityManager, Feed.class);
}
public List<Feed> findNextUpdatable(int count, Instant lastLoginThreshold) {

View File

@@ -2,16 +2,14 @@ package com.commafeed.backend.dao;
import java.util.List;
import org.hibernate.SessionFactory;
import com.commafeed.backend.model.FeedEntryContent;
import com.commafeed.backend.model.QFeedEntry;
import com.commafeed.backend.model.QFeedEntryContent;
import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.JPQLSubQuery;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
@Singleton
public class FeedEntryContentDAO extends GenericDAO<FeedEntryContent> {
@@ -19,9 +17,8 @@ public class FeedEntryContentDAO extends GenericDAO<FeedEntryContent> {
private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent;
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
@Inject
public FeedEntryContentDAO(SessionFactory sessionFactory) {
super(sessionFactory);
public FeedEntryContentDAO(EntityManager entityManager) {
super(entityManager, FeedEntryContent.class);
}
public List<FeedEntryContent> findExisting(String contentHash, String titleHash) {

View File

@@ -3,16 +3,14 @@ package com.commafeed.backend.dao;
import java.time.Instant;
import java.util.List;
import org.hibernate.SessionFactory;
import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.QFeedEntry;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.dsl.NumberExpression;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
import lombok.AllArgsConstructor;
import lombok.Getter;
@@ -21,9 +19,8 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
@Inject
public FeedEntryDAO(SessionFactory sessionFactory) {
super(sessionFactory);
public FeedEntryDAO(EntityManager entityManager) {
super(entityManager, FeedEntry.class);
}
public FeedEntry findExisting(String guidHash, Feed feed) {

View File

@@ -7,7 +7,6 @@ import java.util.Map;
import java.util.stream.Collectors;
import org.apache.commons.collections4.CollectionUtils;
import org.hibernate.SessionFactory;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.feed.FeedEntryKeyword;
@@ -28,8 +27,8 @@ import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.Tuple;
import com.querydsl.jpa.impl.JPAQuery;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
@Singleton
public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
@@ -42,9 +41,8 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
private final FeedEntryTagDAO feedEntryTagDAO;
private final CommaFeedConfiguration config;
@Inject
public FeedEntryStatusDAO(SessionFactory sessionFactory, FeedEntryTagDAO feedEntryTagDAO, CommaFeedConfiguration config) {
super(sessionFactory);
public FeedEntryStatusDAO(EntityManager entityManager, FeedEntryTagDAO feedEntryTagDAO, CommaFeedConfiguration config) {
super(entityManager, FeedEntryStatus.class);
this.feedEntryTagDAO = feedEntryTagDAO;
this.config = config;
}
@@ -60,8 +58,8 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
*/
private FeedEntryStatus handleStatus(User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) {
if (status == null) {
Instant unreadThreshold = config.getApplicationSettings().getUnreadThreshold();
boolean read = unreadThreshold != null && entry.getPublished().isBefore(unreadThreshold);
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,6 +82,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
boolean includeContent) {
JPAQuery<FeedEntryStatus> query = query().selectFrom(STATUS).where(STATUS.user.eq(user), STATUS.starred.isTrue());
if (includeContent) {
query.join(STATUS.entry).fetchJoin();
query.join(STATUS.entry.content).fetchJoin();
}
@@ -105,7 +104,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
query.limit(limit);
}
setTimeout(query, config.getApplicationSettings().getQueryTimeout());
setTimeout(query, config.database().queryTimeout());
List<FeedEntryStatus> statuses = query.fetch();
statuses.forEach(s -> s.setMarkable(true));
@@ -179,7 +178,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
query.limit(limit);
}
setTimeout(query, config.getApplicationSettings().getQueryTimeout());
setTimeout(query, config.database().queryTimeout());
List<FeedEntryStatus> statuses = new ArrayList<>();
List<Tuple> tuples = query.fetch();
@@ -217,9 +216,9 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
or.or(STATUS.read.isNull());
or.or(STATUS.read.isFalse());
Instant unreadThreshold = config.getApplicationSettings().getUnreadThreshold();
if (unreadThreshold != null) {
return or.and(ENTRY.published.goe(unreadThreshold));
Instant statusesInstantThreshold = config.database().cleanup().statusesInstantThreshold();
if (statusesInstantThreshold != null) {
return or.and(ENTRY.published.goe(statusesInstantThreshold));
} else {
return or;
}

View File

@@ -4,24 +4,21 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.hibernate.SessionFactory;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.QFeedEntryTag;
import com.commafeed.backend.model.User;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
@Singleton
public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> {
private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag;
@Inject
public FeedEntryTagDAO(SessionFactory sessionFactory) {
super(sessionFactory);
public FeedEntryTagDAO(EntityManager entityManager) {
super(entityManager, FeedEntryTag.class);
}
public List<String> findByUser(User user) {

View File

@@ -5,12 +5,11 @@ import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import org.hibernate.SessionFactory;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.event.service.spi.EventListenerRegistry;
import org.hibernate.event.spi.EventType;
import org.hibernate.event.spi.PostCommitInsertEventListener;
import org.hibernate.event.spi.PostInsertEvent;
import org.hibernate.event.spi.PostInsertEventListener;
import org.hibernate.persister.entity.EntityPersister;
import com.commafeed.backend.model.AbstractModel;
@@ -23,28 +22,28 @@ import com.commafeed.backend.model.User;
import com.google.common.collect.Iterables;
import com.querydsl.jpa.JPQLQuery;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
@Singleton
public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription;
private final SessionFactory sessionFactory;
private final EntityManager entityManager;
@Inject
public FeedSubscriptionDAO(SessionFactory sessionFactory) {
super(sessionFactory);
this.sessionFactory = sessionFactory;
public FeedSubscriptionDAO(EntityManager entityManager) {
super(entityManager, FeedSubscription.class);
this.entityManager = entityManager;
}
public void onPostCommitInsert(Consumer<FeedSubscription> consumer) {
sessionFactory.unwrap(SessionFactoryImplementor.class)
entityManager.unwrap(SharedSessionContractImplementor.class)
.getFactory()
.getServiceRegistry()
.getService(EventListenerRegistry.class)
.getEventListenerGroup(EventType.POST_COMMIT_INSERT)
.appendListener(new PostInsertEventListener() {
.appendListener(new PostCommitInsertEventListener() {
@Override
public void onPostInsert(PostInsertEvent event) {
if (event.getEntity() instanceof FeedSubscription s) {
@@ -56,6 +55,11 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
public boolean requiresPostCommitHandling(EntityPersister persister) {
return true;
}
@Override
public void onPostInsertCommitFailed(PostInsertEvent event) {
// do nothing
}
});
}

View File

@@ -2,7 +2,7 @@ package com.commafeed.backend.dao;
import java.util.Collection;
import org.hibernate.SessionFactory;
import org.hibernate.Session;
import org.hibernate.jpa.SpecHints;
import com.commafeed.backend.model.AbstractModel;
@@ -12,45 +12,43 @@ import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.querydsl.jpa.impl.JPAUpdateClause;
import io.dropwizard.hibernate.AbstractDAO;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
public abstract class GenericDAO<T extends AbstractModel> extends AbstractDAO<T> {
@RequiredArgsConstructor
public abstract class GenericDAO<T extends AbstractModel> {
protected GenericDAO(SessionFactory sessionFactory) {
super(sessionFactory);
}
private final EntityManager entityManager;
private final Class<T> entityClass;
protected JPAQueryFactory query() {
return new JPAQueryFactory(currentSession());
return new JPAQueryFactory(entityManager);
}
protected JPAUpdateClause updateQuery(EntityPath<T> entityPath) {
return new JPAUpdateClause(currentSession(), entityPath);
return new JPAUpdateClause(entityManager, entityPath);
}
protected JPADeleteClause deleteQuery(EntityPath<T> entityPath) {
return new JPADeleteClause(currentSession(), entityPath);
return new JPADeleteClause(entityManager, entityPath);
}
@SuppressWarnings("deprecation")
public void saveOrUpdate(T model) {
persist(model);
entityManager.unwrap(Session.class).saveOrUpdate(model);
}
public void saveOrUpdate(Collection<T> models) {
models.forEach(this::persist);
}
public void update(T model) {
currentSession().merge(model);
models.forEach(this::saveOrUpdate);
}
public T findById(Long id) {
return get(id);
return entityManager.find(entityClass, id);
}
public void delete(T object) {
if (object != null) {
currentSession().remove(object);
entityManager.remove(object);
}
}

View File

@@ -1,68 +1,20 @@
package com.commafeed.backend.dao;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.context.internal.ManagedSessionContext;
import jakarta.inject.Inject;
import io.quarkus.narayana.jta.QuarkusTransaction;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Singleton
public class UnitOfWork {
private final SessionFactory sessionFactory;
public void run(SessionRunner sessionRunner) {
public void run(SessionRunner runner) {
call(() -> {
sessionRunner.runInSession();
runner.runInSession();
return null;
});
}
public <T> T call(SessionRunnerReturningValue<T> sessionRunner) {
T t = null;
boolean sessionAlreadyBound = ManagedSessionContext.hasBind(sessionFactory);
try (Session session = sessionFactory.openSession()) {
if (!sessionAlreadyBound) {
ManagedSessionContext.bind(session);
}
Transaction tx = session.beginTransaction();
try {
t = sessionRunner.runInSession();
commitTransaction(tx);
} catch (Exception e) {
rollbackTransaction(tx);
UnitOfWork.rethrow(e);
}
} finally {
if (!sessionAlreadyBound) {
ManagedSessionContext.unbind(sessionFactory);
}
}
return t;
}
private static void rollbackTransaction(Transaction tx) {
if (tx != null && tx.isActive()) {
tx.rollback();
}
}
private static void commitTransaction(Transaction tx) {
if (tx != null && tx.isActive()) {
tx.commit();
}
}
@SuppressWarnings("unchecked")
private static <E extends Exception> void rethrow(Exception e) throws E {
throw (E) e;
public <T> T call(SessionRunnerReturningValue<T> runner) {
return QuarkusTransaction.joiningExisting().call(runner::runInSession);
}
@FunctionalInterface

View File

@@ -1,21 +1,18 @@
package com.commafeed.backend.dao;
import org.hibernate.SessionFactory;
import com.commafeed.backend.model.QUser;
import com.commafeed.backend.model.User;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
@Singleton
public class UserDAO extends GenericDAO<User> {
private static final QUser USER = QUser.user;
@Inject
public UserDAO(SessionFactory sessionFactory) {
super(sessionFactory);
public UserDAO(EntityManager entityManager) {
super(entityManager, User.class);
}
public User findByName(String name) {

View File

@@ -4,24 +4,21 @@ import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.hibernate.SessionFactory;
import com.commafeed.backend.model.QUserRole;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserRole;
import com.commafeed.backend.model.UserRole.Role;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
@Singleton
public class UserRoleDAO extends GenericDAO<UserRole> {
private static final QUserRole ROLE = QUserRole.userRole;
@Inject
public UserRoleDAO(SessionFactory sessionFactory) {
super(sessionFactory);
public UserRoleDAO(EntityManager entityManager) {
super(entityManager, UserRole.class);
}
public List<UserRole> findAll() {

View File

@@ -1,22 +1,19 @@
package com.commafeed.backend.dao;
import org.hibernate.SessionFactory;
import com.commafeed.backend.model.QUserSettings;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
@Singleton
public class UserSettingsDAO extends GenericDAO<UserSettings> {
private static final QUserSettings SETTINGS = QUserSettings.userSettings;
@Inject
public UserSettingsDAO(SessionFactory sessionFactory) {
super(sessionFactory);
public UserSettingsDAO(EntityManager entityManager) {
super(entityManager, UserSettings.class);
}
public UserSettings findByUser(User user) {

View File

@@ -10,7 +10,7 @@ import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.Feed;
import jakarta.inject.Inject;
import jakarta.annotation.Priority;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -20,8 +20,9 @@ import lombok.extern.slf4j.Slf4j;
*
*/
@Slf4j
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
@Priority(Integer.MIN_VALUE)
public class DefaultFaviconFetcher extends AbstractFaviconFetcher {
private final HttpGetter getter;

View File

@@ -11,13 +11,12 @@ import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.model.Feed;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class FacebookFaviconFetcher extends AbstractFaviconFetcher {

View File

@@ -22,13 +22,12 @@ import com.google.api.services.youtube.model.ChannelListResponse;
import com.google.api.services.youtube.model.PlaylistListResponse;
import com.google.api.services.youtube.model.Thumbnail;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
@@ -43,8 +42,8 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
return null;
}
String googleAuthKey = config.getApplicationSettings().getGoogleAuthKey();
if (googleAuthKey == null) {
Optional<String> googleAuthKey = config.googleAuthKey();
if (googleAuthKey.isEmpty()) {
log.debug("no google auth key configured");
return null;
}
@@ -63,13 +62,13 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
ChannelListResponse response = null;
if (userId.isPresent()) {
log.debug("contacting youtube api for user {}", userId.get().getValue());
response = fetchForUser(youtube, googleAuthKey, userId.get().getValue());
response = fetchForUser(youtube, googleAuthKey.get(), userId.get().getValue());
} else if (channelId.isPresent()) {
log.debug("contacting youtube api for channel {}", channelId.get().getValue());
response = fetchForChannel(youtube, googleAuthKey, channelId.get().getValue());
response = fetchForChannel(youtube, googleAuthKey.get(), channelId.get().getValue());
} else if (playlistId.isPresent()) {
log.debug("contacting youtube api for playlist {}", playlistId.get().getValue());
response = fetchForPlaylist(youtube, googleAuthKey, playlistId.get().getValue());
response = fetchForPlaylist(youtube, googleAuthKey.get(), playlistId.get().getValue());
}
if (response == null || response.isEmpty() || CollectionUtils.isEmpty(response.getItems())) {

View File

@@ -2,7 +2,7 @@ package com.commafeed.backend.feed;
import java.io.IOException;
import java.time.Instant;
import java.util.Set;
import java.util.List;
import org.apache.commons.codec.binary.StringUtils;
@@ -15,22 +15,26 @@ import com.commafeed.backend.feed.parser.FeedParserResult;
import com.commafeed.backend.urlprovider.FeedURLProvider;
import com.rometools.rome.io.FeedException;
import jakarta.inject.Inject;
import io.quarkus.arc.All;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Fetches a feed then parses it
*/
@Slf4j
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Singleton
public class FeedFetcher {
private final FeedParser parser;
private final HttpGetter getter;
private final Set<FeedURLProvider> urlProviders;
private final List<FeedURLProvider> urlProviders;
public FeedFetcher(FeedParser parser, HttpGetter getter, @All List<FeedURLProvider> urlProviders) {
this.parser = parser;
this.getter = getter;
this.urlProviders = urlProviders;
}
public FeedFetcherResult fetch(String feedUrl, boolean extractFeedUrlFromHtml, String lastModified, String eTag,
Instant lastPublishedDate, String lastContentHash) throws FeedException, IOException, NotModifiedException {
@@ -87,7 +91,7 @@ public class FeedFetcher {
result.getDuration());
}
private static String extractFeedUrl(Set<FeedURLProvider> urlProviders, String url, String urlContent) {
private static String extractFeedUrl(List<FeedURLProvider> urlProviders, String url, String urlContent) {
for (FeedURLProvider urlProvider : urlProviders) {
String feedUrl = urlProvider.get(url, urlContent);
if (feedUrl != null) {

View File

@@ -1,6 +1,5 @@
package com.commafeed.backend.feed;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.BlockingDeque;
@@ -21,14 +20,12 @@ import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.model.AbstractModel;
import com.commafeed.backend.model.Feed;
import io.dropwizard.lifecycle.Managed;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Singleton
public class FeedRefreshEngine implements Managed {
public class FeedRefreshEngine {
private final UnitOfWork unitOfWork;
private final FeedDAO feedDAO;
@@ -45,7 +42,6 @@ public class FeedRefreshEngine implements Managed {
private final ThreadPoolExecutor workerExecutor;
private final ThreadPoolExecutor databaseUpdaterExecutor;
@Inject
public FeedRefreshEngine(UnitOfWork unitOfWork, FeedDAO feedDAO, FeedRefreshWorker worker, FeedRefreshUpdater updater,
CommaFeedConfiguration config, MetricRegistry metrics) {
this.unitOfWork = unitOfWork;
@@ -60,15 +56,14 @@ public class FeedRefreshEngine implements Managed {
this.feedProcessingLoopExecutor = Executors.newSingleThreadExecutor();
this.refillLoopExecutor = Executors.newSingleThreadExecutor();
this.refillExecutor = newDiscardingSingleThreadExecutorService();
this.workerExecutor = newBlockingExecutorService(config.getApplicationSettings().getBackgroundThreads());
this.databaseUpdaterExecutor = newBlockingExecutorService(config.getApplicationSettings().getDatabaseUpdateThreads());
this.workerExecutor = newBlockingExecutorService(config.feedRefresh().httpThreads());
this.databaseUpdaterExecutor = newBlockingExecutorService(config.feedRefresh().databaseThreads());
metrics.register(MetricRegistry.name(getClass(), "queue", "size"), (Gauge<Integer>) queue::size);
metrics.register(MetricRegistry.name(getClass(), "worker", "active"), (Gauge<Integer>) workerExecutor::getActiveCount);
metrics.register(MetricRegistry.name(getClass(), "updater", "active"), (Gauge<Integer>) databaseUpdaterExecutor::getActiveCount);
}
@Override
public void start() {
startFeedProcessingLoop();
startRefillLoop();
@@ -165,22 +160,20 @@ public class FeedRefreshEngine implements Managed {
private List<Feed> getNextUpdatableFeeds(int max) {
return unitOfWork.call(() -> {
Instant lastLoginThreshold = Boolean.TRUE.equals(config.getApplicationSettings().getHeavyLoad())
? Instant.now().minus(Duration.ofDays(30))
: null;
Instant lastLoginThreshold = config.feedRefresh().userInactivityPeriod().isZero() ? null
: Instant.now().minus(config.feedRefresh().userInactivityPeriod());
List<Feed> feeds = feedDAO.findNextUpdatable(max, lastLoginThreshold);
// update disabledUntil to prevent feeds from being returned again by feedDAO.findNextUpdatable()
Instant nextUpdateDate = Instant.now().plus(Duration.ofMinutes(config.getApplicationSettings().getRefreshIntervalMinutes()));
Instant nextUpdateDate = Instant.now().plus(config.feedRefresh().interval());
feedDAO.setDisabledUntil(feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
return feeds;
});
}
private int getBatchSize() {
return Math.min(100, 3 * config.getApplicationSettings().getBackgroundThreads());
return Math.min(100, 3 * config.feedRefresh().httpThreads());
}
@Override
public void stop() {
this.feedProcessingLoopExecutor.shutdownNow();
this.refillLoopExecutor.shutdownNow();

View File

@@ -6,24 +6,22 @@ import java.time.temporal.ChronoUnit;
import com.commafeed.CommaFeedConfiguration;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
@Singleton
public class FeedRefreshIntervalCalculator {
private final boolean heavyLoad;
private final int refreshIntervalMinutes;
private final Duration refreshInterval;
private final boolean empiricalInterval;
@Inject
public FeedRefreshIntervalCalculator(CommaFeedConfiguration config) {
this.heavyLoad = config.getApplicationSettings().getHeavyLoad();
this.refreshIntervalMinutes = config.getApplicationSettings().getRefreshIntervalMinutes();
this.refreshInterval = config.feedRefresh().interval();
this.empiricalInterval = config.feedRefresh().intervalEmpirical();
}
public Instant onFetchSuccess(Instant publishedDate, Long averageEntryInterval) {
Instant defaultRefreshInterval = getDefaultRefreshInterval();
return heavyLoad ? computeRefreshIntervalForHeavyLoad(publishedDate, averageEntryInterval, defaultRefreshInterval)
return empiricalInterval ? computeRefreshIntervalForHeavyLoad(publishedDate, averageEntryInterval, defaultRefreshInterval)
: defaultRefreshInterval;
}
@@ -33,7 +31,7 @@ public class FeedRefreshIntervalCalculator {
public Instant onFetchError(int errorCount) {
int retriesBeforeDisable = 3;
if (errorCount < retriesBeforeDisable || !heavyLoad) {
if (errorCount < retriesBeforeDisable || !empiricalInterval) {
return getDefaultRefreshInterval();
}
@@ -42,7 +40,7 @@ public class FeedRefreshIntervalCalculator {
}
private Instant getDefaultRefreshInterval() {
return Instant.now().plus(Duration.ofMinutes(refreshIntervalMinutes));
return Instant.now().plus(refreshInterval);
}
private Instant computeRefreshIntervalForHeavyLoad(Instant publishedDate, Long averageEntryInterval, Instant defaultRefreshInterval) {

View File

@@ -32,7 +32,6 @@ import com.commafeed.frontend.ws.WebSocketMessageBuilder;
import com.commafeed.frontend.ws.WebSocketSessions;
import com.google.common.util.concurrent.Striped;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -58,7 +57,6 @@ public class FeedRefreshUpdater {
private final Meter feedUpdated;
private final Meter entryInserted;
@Inject
public FeedRefreshUpdater(UnitOfWork unitOfWork, FeedService feedService, FeedEntryService feedEntryService, MetricRegistry metrics,
FeedSubscriptionDAO feedSubscriptionDAO, CacheService cache, WebSocketSessions webSocketSessions) {
this.unitOfWork = unitOfWork;

View File

@@ -16,7 +16,6 @@ import com.commafeed.backend.feed.FeedFetcher.FeedFetcherResult;
import com.commafeed.backend.feed.parser.FeedParserResult.Entry;
import com.commafeed.backend.model.Feed;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
@@ -32,7 +31,6 @@ public class FeedRefreshWorker {
private final CommaFeedConfiguration config;
private final Meter feedFetched;
@Inject
public FeedRefreshWorker(FeedRefreshIntervalCalculator refreshIntervalCalculator, FeedFetcher fetcher, CommaFeedConfiguration config,
MetricRegistry metrics) {
this.refreshIntervalCalculator = refreshIntervalCalculator;
@@ -51,14 +49,14 @@ public class FeedRefreshWorker {
List<Entry> entries = result.feed().entries();
Integer maxFeedCapacity = config.getApplicationSettings().getMaxFeedCapacity();
int maxFeedCapacity = config.database().cleanup().maxFeedCapacity();
if (maxFeedCapacity > 0) {
entries = entries.stream().limit(maxFeedCapacity).toList();
}
Integer maxEntriesAgeDays = config.getApplicationSettings().getMaxEntriesAgeDays();
if (maxEntriesAgeDays > 0) {
Instant threshold = Instant.now().minus(Duration.ofDays(maxEntriesAgeDays));
Duration maxEntriesAgeDays = config.database().cleanup().entriesMaxAge();
if (!maxEntriesAgeDays.isZero()) {
Instant threshold = Instant.now().minus(maxEntriesAgeDays);
entries = entries.stream().filter(entry -> entry.published().isAfter(threshold)).toList();
}

View File

@@ -38,14 +38,13 @@ import com.rometools.rome.feed.synd.SyndLinkImpl;
import com.rometools.rome.io.FeedException;
import com.rometools.rome.io.SyndFeedInput;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
/**
* Parses raw xml into a FeedParserResult object
*/
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class FeedParser {

View File

@@ -15,11 +15,10 @@ import com.rometools.opml.feed.opml.Attribute;
import com.rometools.opml.feed.opml.Opml;
import com.rometools.opml.feed.opml.Outline;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class OPMLExporter {
@@ -28,7 +27,7 @@ public class OPMLExporter {
public Opml export(User user) {
Opml opml = new Opml();
opml.setFeedType("opml_1.1");
opml.setFeedType("opml_1.0");
opml.setTitle(String.format("%s subscriptions in CommaFeed", user.getName()));
opml.setCreated(new Date());

View File

@@ -17,13 +17,12 @@ import com.rometools.opml.feed.opml.Outline;
import com.rometools.rome.io.FeedException;
import com.rometools.rome.io.WireFeedInput;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class OPMLImporter {

View File

@@ -4,10 +4,13 @@ import org.jdom2.Element;
import com.rometools.opml.feed.opml.Opml;
import io.quarkus.runtime.annotations.RegisterForReflection;
/**
* Add missing title to the generated OPML
*
*/
@RegisterForReflection
public class OPML11Generator extends com.rometools.opml.io.impl.OPML10Generator {
public OPML11Generator() {

View File

@@ -9,10 +9,13 @@ import com.rometools.opml.io.impl.OPML10Parser;
import com.rometools.rome.feed.WireFeed;
import com.rometools.rome.io.FeedException;
import io.quarkus.runtime.annotations.RegisterForReflection;
/**
* Support for OPML 1.1 parsing
*
*/
@RegisterForReflection
public class OPML11Parser extends OPML10Parser {
public OPML11Parser() {

View File

@@ -6,10 +6,13 @@ import com.rometools.rome.feed.synd.SyndContentImpl;
import com.rometools.rome.feed.synd.SyndEntry;
import com.rometools.rome.feed.synd.impl.ConverterForRSS090;
import io.quarkus.runtime.annotations.RegisterForReflection;
/**
* Support description tag for RSS09
*
*/
@RegisterForReflection
public class RSS090DescriptionConverter extends ConverterForRSS090 {
@Override

View File

@@ -8,10 +8,13 @@ import com.rometools.rome.feed.rss.Description;
import com.rometools.rome.feed.rss.Item;
import com.rometools.rome.io.impl.RSS090Parser;
import io.quarkus.runtime.annotations.RegisterForReflection;
/**
* Support description tag for RSS09
*
*/
@RegisterForReflection
public class RSS090DescriptionParser extends RSS090Parser {
@Override

View File

@@ -10,6 +10,9 @@ import org.jdom2.Namespace;
import com.google.common.collect.Lists;
import com.rometools.rome.io.impl.RSS10Parser;
import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection
public class RSSRDF10Parser extends RSS10Parser {
private static final String RSS_URI = "http://purl.org/rss/1.0/";

View File

@@ -21,12 +21,11 @@ import org.w3c.dom.css.CSSStyleDeclaration;
import com.steadystate.css.parser.CSSOMParser;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Slf4j
@Singleton
public class FeedEntryContentCleaningService {

View File

@@ -12,11 +12,10 @@ import com.commafeed.backend.feed.parser.FeedParserResult.Enclosure;
import com.commafeed.backend.feed.parser.FeedParserResult.Media;
import com.commafeed.backend.model.FeedEntryContent;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class FeedEntryContentService {

View File

@@ -25,11 +25,10 @@ import org.jsoup.Jsoup;
import com.commafeed.backend.model.FeedEntry;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class FeedEntryFilteringService {

View File

@@ -18,13 +18,12 @@ import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User;
import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterException;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class FeedEntryService {

View File

@@ -10,11 +10,10 @@ import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.User;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class FeedEntryTagService {

View File

@@ -2,8 +2,8 @@ package com.commafeed.backend.service;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import com.commafeed.backend.Digests;
import com.commafeed.backend.dao.FeedDAO;
@@ -14,19 +14,18 @@ import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.Models;
import com.google.common.io.Resources;
import jakarta.inject.Inject;
import io.quarkus.arc.All;
import jakarta.inject.Singleton;
@Singleton
public class FeedService {
private final FeedDAO feedDAO;
private final Set<AbstractFaviconFetcher> faviconFetchers;
private final List<AbstractFaviconFetcher> faviconFetchers;
private final Favicon defaultFavicon;
@Inject
public FeedService(FeedDAO feedDAO, Set<AbstractFaviconFetcher> faviconFetchers) {
public FeedService(FeedDAO feedDAO, @All List<AbstractFaviconFetcher> faviconFetchers) {
this.feedDAO = feedDAO;
this.faviconFetchers = faviconFetchers;

View File

@@ -21,7 +21,6 @@ import com.commafeed.backend.model.Models;
import com.commafeed.backend.model.User;
import com.commafeed.frontend.model.UnreadCount;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
@@ -37,7 +36,6 @@ public class FeedSubscriptionService {
private final CacheService cache;
private final CommaFeedConfiguration config;
@Inject
public FeedSubscriptionService(FeedDAO feedDAO, FeedEntryStatusDAO feedEntryStatusDAO, FeedSubscriptionDAO feedSubscriptionDAO,
FeedService feedService, FeedRefreshEngine feedRefreshEngine, CacheService cache, CommaFeedConfiguration config) {
this.feedDAO = feedDAO;
@@ -63,7 +61,7 @@ public class FeedSubscriptionService {
public long subscribe(User user, String url, String title, FeedCategory category, int position) {
final String pubUrl = config.getApplicationSettings().getPublicUrl();
final String pubUrl = config.publicUrl();
if (StringUtils.isBlank(pubUrl)) {
throw new FeedSubscriptionException("Public URL of this CommaFeed instance is not set");
}
@@ -71,7 +69,7 @@ public class FeedSubscriptionService {
throw new FeedSubscriptionException("Could not subscribe to a feed from this CommaFeed instance");
}
Integer maxFeedsPerUser = config.getApplicationSettings().getMaxFeedsPerUser();
Integer maxFeedsPerUser = config.database().cleanup().maxFeedsPerUser();
if (maxFeedsPerUser > 0 && feedSubscriptionDAO.count(user) >= maxFeedsPerUser) {
String message = String.format("You cannot subscribe to more feeds on this CommaFeed instance (max %s feeds per user)",
maxFeedsPerUser);

View File

@@ -4,10 +4,9 @@ import java.util.Optional;
import java.util.Properties;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.CommaFeedConfiguration.ApplicationSettings;
import com.commafeed.CommaFeedConfiguration.Smtp;
import com.commafeed.backend.model.User;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.mail.Authenticator;
import jakarta.mail.Message;
@@ -22,27 +21,29 @@ import lombok.RequiredArgsConstructor;
* Mailing service
*
*/
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class MailService {
private final CommaFeedConfiguration config;
public void sendMail(User user, String subject, String content) throws Exception {
Optional<Smtp> settings = config.smtp();
if (settings.isEmpty()) {
throw new IllegalArgumentException("SMTP settings not configured");
}
ApplicationSettings settings = config.getApplicationSettings();
final String username = settings.getSmtpUserName();
final String password = settings.getSmtpPassword();
final String fromAddress = Optional.ofNullable(settings.getSmtpFromAddress()).orElse(settings.getSmtpUserName());
final String username = settings.get().userName();
final String password = settings.get().password();
final String fromAddress = Optional.ofNullable(settings.get().fromAddress()).orElse(settings.get().userName());
String dest = user.getEmail();
Properties props = new Properties();
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.starttls.enable", String.valueOf(settings.isSmtpTls()));
props.put("mail.smtp.host", settings.getSmtpHost());
props.put("mail.smtp.port", String.valueOf(settings.getSmtpPort()));
props.put("mail.smtp.starttls.enable", String.valueOf(settings.get().tls()));
props.put("mail.smtp.host", settings.get().host());
props.put("mail.smtp.port", String.valueOf(settings.get().port()));
Session session = Session.getInstance(props, new Authenticator() {
@Override

View File

@@ -12,7 +12,6 @@ import javax.crypto.spec.PBEKeySpec;
import org.apache.commons.lang3.StringUtils;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -20,7 +19,7 @@ import lombok.extern.slf4j.Slf4j;
// taken from http://www.javacodegeeks.com/2012/05/secure-password-storage-donts-dos-and.html
@SuppressWarnings("serial")
@Slf4j
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class PasswordEncryptionService implements Serializable {

View File

@@ -24,11 +24,10 @@ import com.commafeed.backend.model.UserRole.Role;
import com.commafeed.backend.service.internal.PostLoginActivities;
import com.google.common.base.Preconditions;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class UserService {
@@ -117,8 +116,7 @@ public class UserService {
public User register(String name, String password, String email, Collection<Role> roles, boolean forceRegistration) {
if (!forceRegistration) {
Preconditions.checkState(config.getApplicationSettings().getAllowRegistrations(),
"Registrations are closed on this CommaFeed instance");
Preconditions.checkState(config.users().allowRegistrations(), "Registrations are closed on this CommaFeed instance");
}
Preconditions.checkArgument(userDAO.findByName(name) == null, "Name already taken");

View File

@@ -14,7 +14,6 @@ import com.commafeed.backend.dao.FeedEntryStatusDAO;
import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.model.Feed;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
@@ -35,7 +34,6 @@ public class DatabaseCleaningService {
private final FeedEntryStatusDAO feedEntryStatusDAO;
private final Meter entriesDeletedMeter;
@Inject
public DatabaseCleaningService(CommaFeedConfiguration config, UnitOfWork unitOfWork, FeedDAO feedDAO, FeedEntryDAO feedEntryDAO,
FeedEntryContentDAO feedEntryContentDAO, FeedEntryStatusDAO feedEntryStatusDAO, MetricRegistry metrics) {
this.unitOfWork = unitOfWork;
@@ -43,7 +41,7 @@ public class DatabaseCleaningService {
this.feedEntryDAO = feedEntryDAO;
this.feedEntryContentDAO = feedEntryContentDAO;
this.feedEntryStatusDAO = feedEntryStatusDAO;
this.batchSize = config.getApplicationSettings().getDatabaseCleanupBatchSize();
this.batchSize = config.database().cleanup().batchSize();
this.entriesDeletedMeter = metrics.meter(MetricRegistry.name(getClass(), "entriesDeleted"));
}

View File

@@ -1,109 +1,41 @@
package com.commafeed.backend.service.db;
import java.util.HashMap;
import java.util.Map;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.kohsuke.MetaInfServices;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.service.UserService;
import io.dropwizard.lifecycle.Managed;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import liquibase.Scope;
import liquibase.UpdateSummaryEnum;
import liquibase.changelog.ChangeLogParameters;
import liquibase.command.CommandScope;
import liquibase.command.core.UpdateCommandStep;
import liquibase.command.core.helpers.DatabaseChangelogCommandStep;
import liquibase.command.core.helpers.DbUrlConnectionArgumentsCommandStep;
import liquibase.command.core.helpers.ShowSummaryArgument;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.core.PostgresDatabase;
import liquibase.database.jvm.JdbcConnection;
import liquibase.exception.DatabaseException;
import liquibase.resource.ClassLoaderResourceAccessor;
import liquibase.structure.DatabaseObject;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class DatabaseStartupService implements Managed {
public class DatabaseStartupService {
private final UnitOfWork unitOfWork;
private final SessionFactory sessionFactory;
private final UserDAO userDAO;
private final UserService userService;
private final CommaFeedConfiguration config;
@Override
public void start() {
updateSchema();
public void populateInitialData() {
long count = unitOfWork.call(userDAO::count);
if (count == 0) {
unitOfWork.run(this::initialData);
}
}
private void updateSchema() {
log.info("checking if database schema needs updating");
try (Session session = sessionFactory.openSession()) {
session.doWork(connection -> {
try {
JdbcConnection jdbcConnection = new JdbcConnection(connection);
Database database = getDatabase(jdbcConnection);
Map<String, Object> scopeObjects = new HashMap<>();
scopeObjects.put(Scope.Attr.database.name(), database);
scopeObjects.put(Scope.Attr.resourceAccessor.name(),
new ClassLoaderResourceAccessor(Thread.currentThread().getContextClassLoader()));
Scope.child(scopeObjects, () -> {
CommandScope command = new CommandScope(UpdateCommandStep.COMMAND_NAME);
command.addArgumentValue(DbUrlConnectionArgumentsCommandStep.DATABASE_ARG, database);
command.addArgumentValue(UpdateCommandStep.CHANGELOG_FILE_ARG, "migrations.xml");
command.addArgumentValue(DatabaseChangelogCommandStep.CHANGELOG_PARAMETERS, new ChangeLogParameters(database));
command.addArgumentValue(ShowSummaryArgument.SHOW_SUMMARY, UpdateSummaryEnum.OFF);
command.execute();
});
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
log.info("database schema is up to date");
}
private Database getDatabase(JdbcConnection connection) throws DatabaseException {
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(connection);
if (database instanceof PostgresDatabase) {
database = new PostgresDatabase() {
@Override
public String escapeObjectName(String objectName, Class<? extends DatabaseObject> objectType) {
return objectName;
}
};
database.setConnection(connection);
}
return database;
}
private void initialData() {
log.info("populating database with default values");
try {
userService.createAdminUser();
if (config.getApplicationSettings().getCreateDemoAccount()) {
if (config.users().createDemoAccount()) {
userService.createDemoUser();
}
} catch (Exception e) {
@@ -111,4 +43,20 @@ public class DatabaseStartupService implements Managed {
}
}
/**
* Register a postgresql database in liquibase that doesn't escape columns, so that we can use lower case columns
*/
@MetaInfServices(Database.class)
public static class LowerCaseColumnsPostgresDatabase extends PostgresDatabase {
@Override
public String escapeObjectName(String objectName, Class<? extends DatabaseObject> objectType) {
return objectName;
}
@Override
public int getPriority() {
return super.getPriority() + 1;
}
}
}

View File

@@ -1,87 +0,0 @@
package com.commafeed.backend.service.db;
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import org.apache.commons.lang3.StringUtils;
import com.manticore.h2.H2MigrationTool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@RequiredArgsConstructor
@Slf4j
public class H2MigrationService {
private static final String H2_FILE_SUFFIX = ".mv.db";
public void migrateIfNeeded(Path path, String user, String password) {
if (Files.notExists(path)) {
return;
}
int format;
try {
format = getH2FileFormat(path);
} catch (IOException e) {
throw new RuntimeException("could not detect H2 format", e);
}
if (format == 2) {
try {
migrate(path, user, password, "2.1.214", "2.2.224");
} catch (Exception e) {
throw new RuntimeException("could not migrate H2 to format 3", e);
}
}
}
public int getH2FileFormat(Path path) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(path)) {
String headers = reader.readLine();
return Stream.of(headers.split(","))
.filter(h -> h.startsWith("format:"))
.map(h -> h.split(":")[1])
.map(Integer::parseInt)
.findFirst()
.orElseThrow(() -> new RuntimeException("could not find format in H2 file headers"));
}
}
private void migrate(Path path, String user, String password, String fromVersion, String toVersion) throws Exception {
log.info("migrating H2 database at {} from format {} to format {}", path, fromVersion, toVersion);
Path scriptPath = path.resolveSibling("script-%d.sql".formatted(System.currentTimeMillis()));
Path newVersionPath = path.resolveSibling("%s.%s%s".formatted(StringUtils.removeEnd(path.getFileName().toString(), H2_FILE_SUFFIX),
getPatchVersion(toVersion), H2_FILE_SUFFIX));
Path oldVersionBackupPath = path.resolveSibling("%s.%s.backup".formatted(path.getFileName(), getPatchVersion(fromVersion)));
Files.deleteIfExists(scriptPath);
Files.deleteIfExists(newVersionPath);
Files.deleteIfExists(oldVersionBackupPath);
H2MigrationTool.readDriverRecords();
new H2MigrationTool().migrate(fromVersion, toVersion, path.toAbsolutePath().toString(), user, password,
scriptPath.toAbsolutePath().toString(), "", "", false, false, "");
if (!Files.exists(newVersionPath)) {
throw new RuntimeException("H2 migration failed, new version file not found");
}
Files.move(path, oldVersionBackupPath);
Files.move(newVersionPath, path);
Files.delete(oldVersionBackupPath);
Files.delete(scriptPath);
log.info("migrated H2 database from format {} to format {}", fromVersion, toVersion);
}
private String getPatchVersion(String version) {
return StringUtils.substringAfterLast(version, ".");
}
}

View File

@@ -7,11 +7,10 @@ import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.model.User;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class PostLoginActivities {

View File

@@ -9,12 +9,11 @@ import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.service.UserService;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
@Slf4j
public class DemoAccountCleanupTask extends ScheduledTask {
@@ -26,7 +25,7 @@ public class DemoAccountCleanupTask extends ScheduledTask {
@Override
protected void run() {
if (!config.getApplicationSettings().getCreateDemoAccount()) {
if (!config.users().createDemoAccount()) {
return;
}

View File

@@ -5,11 +5,10 @@ import java.util.concurrent.TimeUnit;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.service.db.DatabaseCleaningService;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class EntriesExceedingFeedCapacityCleanupTask extends ScheduledTask {
@@ -18,7 +17,7 @@ public class EntriesExceedingFeedCapacityCleanupTask extends ScheduledTask {
@Override
public void run() {
int maxFeedCapacity = config.getApplicationSettings().getMaxFeedCapacity();
int maxFeedCapacity = config.database().cleanup().maxFeedCapacity();
if (maxFeedCapacity > 0) {
cleaner.cleanEntriesForFeedsExceedingCapacity(maxFeedCapacity);
}

View File

@@ -7,11 +7,10 @@ import java.util.concurrent.TimeUnit;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.service.db.DatabaseCleaningService;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class OldEntriesCleanupTask extends ScheduledTask {
@@ -20,9 +19,9 @@ public class OldEntriesCleanupTask extends ScheduledTask {
@Override
public void run() {
int maxAgeDays = config.getApplicationSettings().getMaxEntriesAgeDays();
if (maxAgeDays > 0) {
Instant threshold = Instant.now().minus(Duration.ofDays(maxAgeDays));
Duration entriesMaxAge = config.database().cleanup().entriesMaxAge();
if (!entriesMaxAge.isZero()) {
Instant threshold = Instant.now().minus(entriesMaxAge);
cleaner.cleanEntriesOlderThan(threshold);
}
}

View File

@@ -6,11 +6,10 @@ import java.util.concurrent.TimeUnit;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.service.db.DatabaseCleaningService;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class OldStatusesCleanupTask extends ScheduledTask {
@@ -19,7 +18,7 @@ public class OldStatusesCleanupTask extends ScheduledTask {
@Override
public void run() {
Instant threshold = config.getApplicationSettings().getUnreadThreshold();
Instant threshold = config.database().cleanup().statusesInstantThreshold();
if (threshold != null) {
cleaner.cleanStatusesOlderThan(threshold);
}

View File

@@ -4,11 +4,10 @@ import java.util.concurrent.TimeUnit;
import com.commafeed.backend.service.db.DatabaseCleaningService;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class OrphanedContentsCleanupTask extends ScheduledTask {

View File

@@ -4,11 +4,10 @@ import java.util.concurrent.TimeUnit;
import com.commafeed.backend.service.db.DatabaseCleaningService;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
public class OrphanedFeedsCleanupTask extends ScheduledTask {

View File

@@ -0,0 +1,28 @@
package com.commafeed.backend.task;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import io.quarkus.arc.All;
import jakarta.inject.Singleton;
@Singleton
public class TaskScheduler {
private final List<ScheduledTask> tasks;
private final ScheduledExecutorService executor;
public TaskScheduler(@All List<ScheduledTask> tasks) {
this.tasks = tasks;
this.executor = Executors.newScheduledThreadPool(tasks.size());
}
public void start() {
tasks.forEach(task -> task.register(executor));
}
public void stop() {
executor.shutdownNow();
}
}

View File

@@ -4,6 +4,9 @@ import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.select.Elements;
import jakarta.inject.Singleton;
@Singleton
public class InPageReferenceFeedURLProvider implements FeedURLProvider {
@Override

View File

@@ -3,12 +3,15 @@ package com.commafeed.backend.urlprovider;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import jakarta.inject.Singleton;
/**
* Workaround for Youtube channels
*
* converts the channel URL https://www.youtube.com/channel/CHANNEL_ID to the valid feed URL
* https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID
*/
@Singleton
public class YoutubeFeedURLProvider implements FeedURLProvider {
private static final Pattern REGEXP = Pattern.compile("(.*\\byoutube\\.com)\\/channel\\/([^\\/]+)", Pattern.CASE_INSENSITIVE);

View File

@@ -1,22 +0,0 @@
package com.commafeed.frontend.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import com.commafeed.backend.model.UserRole.Role;
@Inherited
@Target({ ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
public @interface SecurityCheck {
/**
* Roles needed.
*/
Role value() default Role.USER;
boolean apiKeyAllowed() default false;
}

View File

@@ -1,103 +0,0 @@
package com.commafeed.frontend.auth;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import org.glassfish.jersey.server.ContainerRequest;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserRole.Role;
import com.commafeed.backend.service.UserService;
import com.commafeed.frontend.session.SessionHelper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class SecurityCheckFactory implements Function<ContainerRequest, User> {
private static final String PREFIX = "Basic";
private final UserDAO userDAO;
private final UserService userService;
private final CommaFeedConfiguration config;
private final HttpServletRequest request;
private final Role role;
private final boolean apiKeyAllowed;
@Override
public User apply(ContainerRequest req) {
Optional<User> user = apiKeyLogin();
if (user.isEmpty()) {
user = basicAuthenticationLogin();
}
if (user.isEmpty()) {
user = cookieSessionLogin(new SessionHelper(request));
}
if (user.isPresent()) {
Set<Role> roles = userService.getRoles(user.get());
if (roles.contains(role)) {
return user.get();
} else {
throw buildWebApplicationException(Response.Status.FORBIDDEN, "You don't have the required role to access this resource.");
}
} else {
throw buildWebApplicationException(Response.Status.UNAUTHORIZED, "Credentials are required to access this resource.");
}
}
Optional<User> cookieSessionLogin(SessionHelper sessionHelper) {
Optional<User> loggedInUser = sessionHelper.getLoggedInUserId().map(userDAO::findById);
loggedInUser.ifPresent(userService::performPostLoginActivities);
return loggedInUser;
}
private Optional<User> basicAuthenticationLogin() {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header != null) {
int space = header.indexOf(' ');
if (space > 0) {
String method = header.substring(0, space);
if (PREFIX.equalsIgnoreCase(method)) {
byte[] decodedBytes = Base64.getDecoder().decode(header.substring(space + 1));
String decoded = new String(decodedBytes, StandardCharsets.ISO_8859_1);
int i = decoded.indexOf(':');
if (i > 0) {
String username = decoded.substring(0, i);
String password = decoded.substring(i + 1);
return userService.login(username, password);
}
}
}
}
return Optional.empty();
}
private Optional<User> apiKeyLogin() {
String apiKey = request.getParameter("apiKey");
if (apiKey != null && apiKeyAllowed) {
return userService.login(apiKey);
}
return Optional.empty();
}
private WebApplicationException buildWebApplicationException(Response.Status status, String message) {
Map<String, Object> response = new HashMap<>();
response.put("message", message);
response.put("allowRegistrations", config.getApplicationSettings().getAllowRegistrations());
return new WebApplicationException(Response.status(status).entity(response).type(MediaType.APPLICATION_JSON).build());
}
}

View File

@@ -1,72 +0,0 @@
package com.commafeed.frontend.auth;
import java.util.function.Function;
import org.glassfish.hk2.utilities.binding.AbstractBinder;
import org.glassfish.jersey.server.ContainerRequest;
import org.glassfish.jersey.server.internal.inject.AbstractValueParamProvider;
import org.glassfish.jersey.server.internal.inject.MultivaluedParameterExtractorProvider;
import org.glassfish.jersey.server.model.Parameter;
import org.glassfish.jersey.server.spi.internal.ValueParamProvider;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.service.UserService;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@Singleton
public class SecurityCheckFactoryProvider extends AbstractValueParamProvider {
private final UserService userService;
private final UserDAO userDAO;
private final CommaFeedConfiguration config;
private final HttpServletRequest request;
@Inject
public SecurityCheckFactoryProvider(final MultivaluedParameterExtractorProvider extractorProvider, UserDAO userDAO,
UserService userService, CommaFeedConfiguration config, HttpServletRequest request) {
super(() -> extractorProvider, Parameter.Source.UNKNOWN);
this.userDAO = userDAO;
this.userService = userService;
this.config = config;
this.request = request;
}
@Override
protected Function<ContainerRequest, ?> createValueProvider(Parameter parameter) {
final Class<?> classType = parameter.getRawType();
SecurityCheck securityCheck = parameter.getAnnotation(SecurityCheck.class);
if (securityCheck == null) {
return null;
}
if (!classType.isAssignableFrom(User.class)) {
return null;
}
return new SecurityCheckFactory(userDAO, userService, config, request, securityCheck.value(), securityCheck.apiKeyAllowed());
}
@RequiredArgsConstructor
public static class Binder extends AbstractBinder {
private final UserDAO userDAO;
private final UserService userService;
private final CommaFeedConfiguration config;
@Override
protected void configure() {
bind(SecurityCheckFactoryProvider.class).to(ValueParamProvider.class).in(Singleton.class);
bind(userDAO).to(UserDAO.class);
bind(userService).to(UserService.class);
bind(config).to(CommaFeedConfiguration.class);
}
}
}

View File

@@ -4,6 +4,7 @@ import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@@ -11,6 +12,7 @@ import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Entry details")
@Data
@RegisterForReflection
public class Category implements Serializable {
@Schema(description = "category id", requiredMode = RequiredMode.REQUIRED)

View File

@@ -4,6 +4,7 @@ import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@@ -11,6 +12,7 @@ import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "List of entries with some metadata")
@Data
@RegisterForReflection
public class Entries implements Serializable {
@Schema(description = "name of the feed or the category requested", requiredMode = RequiredMode.REQUIRED)

View File

@@ -19,6 +19,7 @@ import com.rometools.rome.feed.synd.SyndEnclosureImpl;
import com.rometools.rome.feed.synd.SyndEntry;
import com.rometools.rome.feed.synd.SyndEntryImpl;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@@ -26,6 +27,7 @@ import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Entry details")
@Data
@RegisterForReflection
public class Entry implements Serializable {
@Schema(description = "entry id", requiredMode = RequiredMode.REQUIRED)

View File

@@ -2,6 +2,7 @@ package com.commafeed.frontend.model;
import java.io.Serializable;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@@ -9,6 +10,7 @@ import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Feed details")
@Data
@RegisterForReflection
public class FeedInfo implements Serializable {
@Schema(description = "url", requiredMode = RequiredMode.REQUIRED)

View File

@@ -2,6 +2,7 @@ package com.commafeed.frontend.model;
import java.io.Serializable;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@@ -9,6 +10,7 @@ import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Server infos")
@Data
@RegisterForReflection
public class ServerInfo implements Serializable {
@Schema

View File

@@ -2,6 +2,7 @@ package com.commafeed.frontend.model;
import java.io.Serializable;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@@ -9,6 +10,7 @@ import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "User settings")
@Data
@RegisterForReflection
public class Settings implements Serializable {
@Schema(description = "user's preferred language, english if none", requiredMode = RequiredMode.REQUIRED)

View File

@@ -8,6 +8,7 @@ import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedSubscription;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@@ -15,6 +16,7 @@ import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "User information")
@Data
@RegisterForReflection
public class Subscription implements Serializable {
@Schema(description = "subscription id", requiredMode = RequiredMode.REQUIRED)

View File

@@ -3,12 +3,14 @@ package com.commafeed.frontend.model;
import java.io.Serializable;
import java.time.Instant;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Unread count")
@Data
@RegisterForReflection
public class UnreadCount implements Serializable {
@Schema

View File

@@ -3,6 +3,7 @@ package com.commafeed.frontend.model;
import java.io.Serializable;
import java.time.Instant;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@@ -10,6 +11,7 @@ import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "User information")
@Data
@RegisterForReflection
public class UserModel implements Serializable {
@Schema(description = "user id", requiredMode = RequiredMode.REQUIRED)

View File

@@ -1,24 +0,0 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;
@SuppressWarnings("serial")
@Data
@Schema
public class LoginRequest implements Serializable {
@Schema(description = "username", requiredMode = RequiredMode.REQUIRED)
@Size(min = 3, max = 32)
private String name;
@Schema(description = "password", requiredMode = RequiredMode.REQUIRED)
@NotEmpty
@Size(max = 128)
private String password;
}

View File

@@ -2,7 +2,7 @@ package com.commafeed.frontend.model.request;
import java.io.Serializable;
import com.commafeed.frontend.auth.ValidPassword;
import com.commafeed.security.password.ValidPassword;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;

View File

@@ -2,7 +2,7 @@ package com.commafeed.frontend.model.request;
import java.io.Serializable;
import com.commafeed.frontend.auth.ValidPassword;
import com.commafeed.security.password.ValidPassword;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;

View File

@@ -7,10 +7,7 @@ import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.annotation.Timed;
import com.commafeed.CommaFeedApplication;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.CommaFeedConfiguration.ApplicationSettings;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.dao.UserRoleDAO;
import com.commafeed.backend.model.User;
@@ -18,14 +15,14 @@ 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.auth.SecurityCheck;
import com.commafeed.frontend.model.UserModel;
import com.commafeed.frontend.model.request.AdminSaveUserRequest;
import com.commafeed.frontend.model.request.IDRequest;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
import com.google.common.collect.Sets;
import io.dropwizard.hibernate.UnitOfWork;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
@@ -33,8 +30,9 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.inject.Inject;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
@@ -46,30 +44,29 @@ import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import lombok.RequiredArgsConstructor;
@Path("/admin")
@Path("/rest/admin")
@RolesAllowed(Roles.ADMIN)
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
@Tag(name = "Admin")
public class AdminREST {
private final AuthenticationContext authenticationContext;
private final UserDAO userDAO;
private final UserRoleDAO userRoleDAO;
private final UserService userService;
private final PasswordEncryptionService encryptionService;
private final CommaFeedConfiguration config;
private final MetricRegistry metrics;
@Path("/user/save")
@POST
@UnitOfWork
@Transactional
@Operation(
summary = "Save or update a user",
description = "Save or update a user. If the id is not specified, a new user will be created")
@Timed
public Response adminSaveUser(@Parameter(hidden = true) @SecurityCheck(Role.ADMIN) User user,
@Parameter(required = true) AdminSaveUserRequest req) {
public Response adminSaveUser(@Parameter(required = true) AdminSaveUserRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getName());
@@ -87,6 +84,7 @@ public class AdminREST {
return Response.status(Status.CONFLICT).entity(e.getMessage()).build();
}
} else {
User user = authenticationContext.getCurrentUser();
if (req.getId().equals(user.getId()) && !req.isEnabled()) {
return Response.status(Status.FORBIDDEN).entity("You cannot disable your own account.").build();
}
@@ -121,14 +119,12 @@ public class AdminREST {
@Path("/user/get/{id}")
@GET
@UnitOfWork
@Transactional
@Operation(
summary = "Get user information",
description = "Get user information",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = UserModel.class))) })
@Timed
public Response adminGetUser(@Parameter(hidden = true) @SecurityCheck(Role.ADMIN) User user,
@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
public Response adminGetUser(@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
Preconditions.checkNotNull(id);
User u = userDAO.findById(id);
UserModel userModel = new UserModel();
@@ -142,13 +138,12 @@ public class AdminREST {
@Path("/user/getAll")
@GET
@UnitOfWork
@Transactional
@Operation(
summary = "Get all users",
description = "Get all users",
responses = { @ApiResponse(content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserModel.class)))) })
@Timed
public Response adminGetUsers(@Parameter(hidden = true) @SecurityCheck(Role.ADMIN) User user) {
public Response adminGetUsers() {
Map<Long, UserModel> users = new HashMap<>();
for (UserRole role : userRoleDAO.findAll()) {
User u = role.getUser();
@@ -173,11 +168,9 @@ public class AdminREST {
@Path("/user/delete")
@POST
@UnitOfWork
@Transactional
@Operation(summary = "Delete a user", description = "Delete a user, and all his subscriptions")
@Timed
public Response adminDeleteUser(@Parameter(hidden = true) @SecurityCheck(Role.ADMIN) User user,
@Parameter(required = true) IDRequest req) {
public Response adminDeleteUser(@Parameter(required = true) IDRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
@@ -185,6 +178,8 @@ public class AdminREST {
if (u == null) {
return Response.status(Status.NOT_FOUND).build();
}
User user = authenticationContext.getCurrentUser();
if (user.getId().equals(u.getId())) {
return Response.status(Status.FORBIDDEN).entity("You cannot delete your own user.").build();
}
@@ -192,24 +187,11 @@ public class AdminREST {
return Response.ok().build();
}
@Path("/settings")
@GET
@UnitOfWork
@Operation(
summary = "Retrieve application settings",
description = "Retrieve application settings",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = ApplicationSettings.class))) })
@Timed
public Response getApplicationSettings(@Parameter(hidden = true) @SecurityCheck(Role.ADMIN) User user) {
return Response.ok(config.getApplicationSettings()).build();
}
@Path("/metrics")
@GET
@UnitOfWork
@Transactional
@Operation(summary = "Retrieve server metrics")
@Timed
public Response getMetrics(@Parameter(hidden = true) @SecurityCheck(Role.ADMIN) User user) {
public Response getMetrics() {
return Response.ok(metrics).build();
}

View File

@@ -13,7 +13,6 @@ import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import com.codahale.metrics.annotation.Timed;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.cache.CacheService;
import com.commafeed.backend.dao.FeedCategoryDAO;
@@ -29,7 +28,6 @@ import com.commafeed.backend.model.UserSettings.ReadingMode;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.backend.service.FeedSubscriptionService;
import com.commafeed.frontend.auth.SecurityCheck;
import com.commafeed.frontend.model.Category;
import com.commafeed.frontend.model.Entries;
import com.commafeed.frontend.model.Entry;
@@ -40,13 +38,14 @@ import com.commafeed.frontend.model.request.CategoryModificationRequest;
import com.commafeed.frontend.model.request.CollapseRequest;
import com.commafeed.frontend.model.request.IDRequest;
import com.commafeed.frontend.model.request.MarkRequest;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import com.rometools.rome.feed.synd.SyndFeed;
import com.rometools.rome.feed.synd.SyndFeedImpl;
import com.rometools.rome.io.SyndFeedOutput;
import io.dropwizard.hibernate.UnitOfWork;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
@@ -54,8 +53,9 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.inject.Inject;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
@@ -70,11 +70,12 @@ import jakarta.ws.rs.core.Response.Status;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Path("/category")
@Path("/rest/category")
@RolesAllowed(Roles.USER)
@Slf4j
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
@Tag(name = "Feed categories")
public class CategoryREST {
@@ -82,6 +83,7 @@ public class CategoryREST {
public static final String ALL = "all";
public static final String STARRED = "starred";
private final AuthenticationContext authenticationContext;
private final FeedCategoryDAO feedCategoryDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
private final FeedSubscriptionDAO feedSubscriptionDAO;
@@ -92,13 +94,12 @@ public class CategoryREST {
@Path("/entries")
@GET
@UnitOfWork
@Transactional
@Operation(
summary = "Get category entries",
description = "Get a list of category entries",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Entries.class))) })
@Timed
public Response getCategoryEntries(@Parameter(hidden = true) @SecurityCheck(apiKeyAllowed = true) User user,
public Response getCategoryEntries(
@Parameter(description = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id,
@Parameter(
description = "all entries or only unread ones",
@@ -137,22 +138,24 @@ public class CategoryREST {
excludedIds = Arrays.stream(excludedSubscriptionIds.split(",")).map(Long::valueOf).toList();
}
User user = authenticationContext.getCurrentUser();
if (ALL.equals(id)) {
entries.setName(Optional.ofNullable(tag).orElse("All"));
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
removeExcludedSubscriptions(subs, excludedIds);
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate,
offset, limit + 1, order, true, tag, null, null);
for (FeedEntryStatus status : list) {
entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled()));
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
}
} else if (STARRED.equals(id)) {
entries.setName("Starred");
List<FeedEntryStatus> starred = feedEntryStatusDAO.findStarred(user, newerThanDate, offset, limit + 1, order, true);
for (FeedEntryStatus status : starred) {
entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled()));
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
}
} else {
FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(id));
@@ -164,7 +167,7 @@ public class CategoryREST {
offset, limit + 1, order, true, tag, null, null);
for (FeedEntryStatus status : list) {
entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled()));
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
}
entries.setName(parent.getName());
} else {
@@ -186,11 +189,10 @@ public class CategoryREST {
@Path("/entriesAsFeed")
@GET
@UnitOfWork
@Transactional
@Operation(summary = "Get category entries as feed", description = "Get a feed of category entries")
@Produces(MediaType.APPLICATION_XML)
@Timed
public Response getCategoryEntriesAsFeed(@Parameter(hidden = true) @SecurityCheck(apiKeyAllowed = true) User user,
public Response getCategoryEntriesAsFeed(
@Parameter(description = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id,
@Parameter(
description = "all entries or only unread ones",
@@ -205,7 +207,7 @@ public class CategoryREST {
description = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds,
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) {
Response response = getCategoryEntries(user, id, readType, newerThan, offset, limit, order, keywords, excludedSubscriptionIds, tag);
Response response = getCategoryEntries(id, readType, newerThan, offset, limit, order, keywords, excludedSubscriptionIds, tag);
if (response.getStatus() != Status.OK.getStatusCode()) {
return response;
}
@@ -215,7 +217,7 @@ public class CategoryREST {
feed.setFeedType("rss_2.0");
feed.setTitle("CommaFeed - " + entries.getName());
feed.setDescription("CommaFeed - " + entries.getName());
feed.setLink(config.getApplicationSettings().getPublicUrl());
feed.setLink(config.publicUrl());
feed.setEntries(entries.getEntries().stream().map(Entry::asRss).toList());
SyndFeedOutput output = new SyndFeedOutput();
@@ -231,11 +233,9 @@ public class CategoryREST {
@Path("/mark")
@POST
@UnitOfWork
@Transactional
@Operation(summary = "Mark category entries", description = "Mark feed entries of this category as read")
@Timed
public Response markCategoryEntries(@Parameter(hidden = true) @SecurityCheck User user,
@Valid @Parameter(description = "category id, or 'all'", required = true) MarkRequest req) {
public Response markCategoryEntries(@Valid @Parameter(description = "category id, or 'all'", required = true) MarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
@@ -244,6 +244,7 @@ public class CategoryREST {
String keywords = req.getKeywords();
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
User user = authenticationContext.getCurrentUser();
if (ALL.equals(req.getId())) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
@@ -268,17 +269,17 @@ public class CategoryREST {
@Path("/add")
@POST
@UnitOfWork
@Transactional
@Operation(
summary = "Add a category",
description = "Add a new feed category",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Long.class))) })
@Timed
public Response addCategory(@Parameter(hidden = true) @SecurityCheck User user,
@Valid @Parameter(required = true) AddCategoryRequest req) {
public Response addCategory(@Valid @Parameter(required = true) AddCategoryRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getName());
User user = authenticationContext.getCurrentUser();
FeedCategory cat = new FeedCategory();
cat.setName(req.getName());
cat.setUser(user);
@@ -296,14 +297,14 @@ public class CategoryREST {
@POST
@Path("/delete")
@UnitOfWork
@Transactional
@Operation(summary = "Delete a category", description = "Delete an existing feed category")
@Timed
public Response deleteCategory(@Parameter(hidden = true) @SecurityCheck User user, @Parameter(required = true) IDRequest req) {
public Response deleteCategory(@Parameter(required = true) IDRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
User user = authenticationContext.getCurrentUser();
FeedCategory cat = feedCategoryDAO.findById(user, req.getId());
if (cat != null) {
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategory(user, cat);
@@ -329,14 +330,13 @@ public class CategoryREST {
@POST
@Path("/modify")
@UnitOfWork
@Transactional
@Operation(summary = "Rename a category", description = "Rename an existing feed category")
@Timed
public Response modifyCategory(@Parameter(hidden = true) @SecurityCheck User user,
@Valid @Parameter(required = true) CategoryModificationRequest req) {
public Response modifyCategory(@Valid @Parameter(required = true) CategoryModificationRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
User user = authenticationContext.getCurrentUser();
FeedCategory category = feedCategoryDAO.findById(user, req.getId());
if (StringUtils.isNotBlank(req.getName())) {
@@ -380,13 +380,13 @@ public class CategoryREST {
@POST
@Path("/collapse")
@UnitOfWork
@Transactional
@Operation(summary = "Collapse a category", description = "Save collapsed or expanded status for a category")
@Timed
public Response collapseCategory(@Parameter(hidden = true) @SecurityCheck User user, @Parameter(required = true) CollapseRequest req) {
public Response collapseCategory(@Parameter(required = true) CollapseRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
User user = authenticationContext.getCurrentUser();
FeedCategory category = feedCategoryDAO.findById(user, req.getId());
if (category == null) {
return Response.status(Status.NOT_FOUND).build();
@@ -399,25 +399,25 @@ public class CategoryREST {
@GET
@Path("/unreadCount")
@UnitOfWork
@Transactional
@Operation(
summary = "Get unread count for feed subscriptions",
responses = { @ApiResponse(content = @Content(array = @ArraySchema(schema = @Schema(implementation = UnreadCount.class)))) })
@Timed
public Response getUnreadCount(@Parameter(hidden = true) @SecurityCheck(apiKeyAllowed = true) User user) {
public Response getUnreadCount() {
User user = authenticationContext.getCurrentUser();
Map<Long, UnreadCount> unreadCount = feedSubscriptionService.getUnreadCount(user);
return Response.ok(Lists.newArrayList(unreadCount.values())).build();
}
@GET
@Path("/get")
@UnitOfWork
@Transactional
@Operation(
summary = "Get root category",
description = "Get all categories and subscriptions of the user",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Category.class))) })
@Timed
public Response getRootCategory(@Parameter(hidden = true) @SecurityCheck User user) {
public Response getRootCategory() {
User user = authenticationContext.getCurrentUser();
Category root = cache.getUserRootCategory(user);
if (root == null) {
log.debug("tree cache miss for {}", user.getId());

View File

@@ -2,24 +2,24 @@ package com.commafeed.frontend.resource;
import java.util.List;
import com.codahale.metrics.annotation.Timed;
import com.commafeed.backend.dao.FeedEntryTagDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.backend.service.FeedEntryTagService;
import com.commafeed.frontend.auth.SecurityCheck;
import com.commafeed.frontend.model.request.MarkRequest;
import com.commafeed.frontend.model.request.MultipleMarkRequest;
import com.commafeed.frontend.model.request.StarRequest;
import com.commafeed.frontend.model.request.TagRequest;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
import io.dropwizard.hibernate.UnitOfWork;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.inject.Inject;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
@@ -30,42 +30,42 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
@Path("/entry")
@Path("/rest/entry")
@RolesAllowed(Roles.USER)
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
@Tag(name = "Feed entries")
public class EntryREST {
private final AuthenticationContext authenticationContext;
private final FeedEntryTagDAO feedEntryTagDAO;
private final FeedEntryService feedEntryService;
private final FeedEntryTagService feedEntryTagService;
@Path("/mark")
@POST
@UnitOfWork
@Transactional
@Operation(summary = "Mark a feed entry", description = "Mark a feed entry as read/unread")
@Timed
public Response markEntry(@Parameter(hidden = true) @SecurityCheck User user,
@Valid @Parameter(description = "Mark Request", required = true) MarkRequest req) {
public Response markEntry(@Valid @Parameter(description = "Mark Request", required = true) MarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
User user = authenticationContext.getCurrentUser();
feedEntryService.markEntry(user, Long.valueOf(req.getId()), req.isRead());
return Response.ok().build();
}
@Path("/markMultiple")
@POST
@UnitOfWork
@Transactional
@Operation(summary = "Mark multiple feed entries", description = "Mark feed entries as read/unread")
@Timed
public Response markEntries(@Parameter(hidden = true) @SecurityCheck User user,
@Valid @Parameter(description = "Multiple Mark Request", required = true) MultipleMarkRequest req) {
public Response markEntries(@Valid @Parameter(description = "Multiple Mark Request", required = true) MultipleMarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getRequests());
User user = authenticationContext.getCurrentUser();
for (MarkRequest r : req.getRequests()) {
Preconditions.checkNotNull(r.getId());
feedEntryService.markEntry(user, Long.valueOf(r.getId()), r.isRead());
@@ -76,15 +76,14 @@ public class EntryREST {
@Path("/star")
@POST
@UnitOfWork
@Transactional
@Operation(summary = "Star a feed entry", description = "Mark a feed entry as read/unread")
@Timed
public Response starEntry(@Parameter(hidden = true) @SecurityCheck User user,
@Valid @Parameter(description = "Star Request", required = true) StarRequest req) {
public Response starEntry(@Valid @Parameter(description = "Star Request", required = true) StarRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
Preconditions.checkNotNull(req.getFeedId());
User user = authenticationContext.getCurrentUser();
feedEntryService.starEntry(user, Long.valueOf(req.getId()), req.getFeedId(), req.isStarred());
return Response.ok().build();
@@ -92,24 +91,23 @@ public class EntryREST {
@Path("/tags")
@GET
@UnitOfWork
@Transactional
@Operation(summary = "Get list of tags for the user", description = "Get list of tags for the user")
@Timed
public Response getTags(@Parameter(hidden = true) @SecurityCheck User user) {
public Response getTags() {
User user = authenticationContext.getCurrentUser();
List<String> tags = feedEntryTagDAO.findByUser(user);
return Response.ok(tags).build();
}
@Path("/tag")
@POST
@UnitOfWork
@Transactional
@Operation(summary = "Set feed entry tags")
@Timed
public Response tagEntry(@Parameter(hidden = true) @SecurityCheck User user,
@Valid @Parameter(description = "Tag Request", required = true) TagRequest req) {
public Response tagEntry(@Valid @Parameter(description = "Tag Request", required = true) TagRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getEntryId());
User user = authenticationContext.getCurrentUser();
feedEntryTagService.updateTags(user, req.getEntryId(), req.getTags());
return Response.ok().build();

View File

@@ -1,9 +1,7 @@
package com.commafeed.frontend.resource;
import java.io.InputStream;
import java.io.StringWriter;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Calendar;
import java.util.Collections;
@@ -13,9 +11,8 @@ import java.util.Objects;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.glassfish.jersey.media.multipart.FormDataParam;
import org.jboss.resteasy.reactive.RestForm;
import com.codahale.metrics.annotation.Timed;
import com.commafeed.CommaFeedApplication;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.cache.CacheService;
@@ -44,7 +41,6 @@ import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterEx
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.backend.service.FeedService;
import com.commafeed.backend.service.FeedSubscriptionService;
import com.commafeed.frontend.auth.SecurityCheck;
import com.commafeed.frontend.model.Entries;
import com.commafeed.frontend.model.Entry;
import com.commafeed.frontend.model.FeedInfo;
@@ -55,6 +51,8 @@ import com.commafeed.frontend.model.request.FeedModificationRequest;
import com.commafeed.frontend.model.request.IDRequest;
import com.commafeed.frontend.model.request.MarkRequest;
import com.commafeed.frontend.model.request.SubscribeRequest;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
import com.google.common.base.Throwables;
import com.rometools.opml.feed.opml.Opml;
@@ -63,15 +61,15 @@ import com.rometools.rome.feed.synd.SyndFeedImpl;
import com.rometools.rome.io.SyndFeedOutput;
import com.rometools.rome.io.WireFeedOutput;
import io.dropwizard.hibernate.UnitOfWork;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.inject.Inject;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
@@ -90,17 +88,19 @@ import jakarta.ws.rs.core.Response.Status;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Path("/feed")
@Path("/rest/feed")
@RolesAllowed(Roles.USER)
@Slf4j
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
@Tag(name = "Feeds")
public class FeedREST {
private static final FeedEntry TEST_ENTRY = initTestEntry();
private final AuthenticationContext authenticationContext;
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedCategoryDAO feedCategoryDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
@@ -129,14 +129,12 @@ public class FeedREST {
@Path("/entries")
@GET
@UnitOfWork
@Transactional
@Operation(
summary = "Get feed entries",
description = "Get a list of feed entries",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Entries.class))) })
@Timed
public Response getFeedEntries(@Parameter(hidden = true) @SecurityCheck(apiKeyAllowed = true) User user,
@Parameter(description = "id of the feed", required = true) @QueryParam("id") String id,
public Response getFeedEntries(@Parameter(description = "id of the feed", required = true) @QueryParam("id") String id,
@Parameter(
description = "all entries or only unread ones",
required = true) @DefaultValue("unread") @QueryParam("readType") ReadingMode readType,
@@ -164,6 +162,7 @@ public class FeedREST {
Instant newerThanDate = newerThan == null ? null : Instant.ofEpochMilli(newerThan);
User user = authenticationContext.getCurrentUser();
FeedSubscription subscription = feedSubscriptionDAO.findById(user, Long.valueOf(id));
if (subscription != null) {
entries.setName(subscription.getTitle());
@@ -175,7 +174,7 @@ public class FeedREST {
entryKeywords, newerThanDate, offset, limit + 1, order, true, null, null, null);
for (FeedEntryStatus status : list) {
entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled()));
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
}
boolean hasMore = entries.getEntries().size() > limit;
@@ -195,12 +194,10 @@ public class FeedREST {
@Path("/entriesAsFeed")
@GET
@UnitOfWork
@Transactional
@Operation(summary = "Get feed entries as a feed", description = "Get a feed of feed entries")
@Produces(MediaType.APPLICATION_XML)
@Timed
public Response getFeedEntriesAsFeed(@Parameter(hidden = true) @SecurityCheck(apiKeyAllowed = true) User user,
@Parameter(description = "id of the feed", required = true) @QueryParam("id") String id,
public Response getFeedEntriesAsFeed(@Parameter(description = "id of the feed", required = true) @QueryParam("id") String id,
@Parameter(
description = "all entries or only unread ones",
required = true) @DefaultValue("all") @QueryParam("readType") ReadingMode readType,
@@ -210,7 +207,7 @@ public class FeedREST {
@Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, @Parameter(
description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords) {
Response response = getFeedEntries(user, id, readType, newerThan, offset, limit, order, keywords);
Response response = getFeedEntries(id, readType, newerThan, offset, limit, order, keywords);
if (response.getStatus() != Status.OK.getStatusCode()) {
return response;
}
@@ -220,7 +217,7 @@ public class FeedREST {
feed.setFeedType("rss_2.0");
feed.setTitle("CommaFeed - " + entries.getName());
feed.setDescription("CommaFeed - " + entries.getName());
feed.setLink(config.getApplicationSettings().getPublicUrl());
feed.setLink(config.publicUrl());
feed.setEntries(entries.getEntries().stream().map(Entry::asRss).toList());
SyndFeedOutput output = new SyndFeedOutput();
@@ -253,14 +250,12 @@ public class FeedREST {
@POST
@Path("/fetch")
@UnitOfWork
@Transactional
@Operation(
summary = "Fetch a feed",
description = "Fetch a feed by its url",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = FeedInfo.class))) })
@Timed
public Response fetchFeed(@Parameter(hidden = true) @SecurityCheck User user,
@Valid @Parameter(description = "feed url", required = true) FeedInfoRequest req) {
public Response fetchFeed(@Valid @Parameter(description = "feed url", required = true) FeedInfoRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getUrl());
@@ -279,25 +274,23 @@ public class FeedREST {
@Path("/refreshAll")
@GET
@UnitOfWork
@Transactional
@Operation(summary = "Queue all feeds of the user for refresh", description = "Manually add all feeds of the user to the refresh queue")
@Timed
public Response queueAllForRefresh(@Parameter(hidden = true) @SecurityCheck User user) {
public Response queueAllForRefresh() {
User user = authenticationContext.getCurrentUser();
feedSubscriptionService.refreshAll(user);
return Response.ok().build();
}
@Path("/refresh")
@POST
@UnitOfWork
@Transactional
@Operation(summary = "Queue a feed for refresh", description = "Manually add a feed to the refresh queue")
@Timed
public Response queueForRefresh(@Parameter(hidden = true) @SecurityCheck User user,
@Parameter(description = "Feed id", required = true) IDRequest req) {
public Response queueForRefresh(@Parameter(description = "Feed id", required = true) IDRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
User user = authenticationContext.getCurrentUser();
FeedSubscription sub = feedSubscriptionDAO.findById(user, req.getId());
if (sub != null) {
Feed feed = sub.getFeed();
@@ -309,11 +302,9 @@ public class FeedREST {
@Path("/mark")
@POST
@UnitOfWork
@Transactional
@Operation(summary = "Mark feed entries", description = "Mark feed entries as read (unread is not supported)")
@Timed
public Response markFeedEntries(@Parameter(hidden = true) @SecurityCheck User user,
@Valid @Parameter(description = "Mark request", required = true) MarkRequest req) {
public Response markFeedEntries(@Valid @Parameter(description = "Mark request", required = true) MarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
@@ -322,6 +313,7 @@ public class FeedREST {
String keywords = req.getKeywords();
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
User user = authenticationContext.getCurrentUser();
FeedSubscription subscription = feedSubscriptionDAO.findById(user, Long.valueOf(req.getId()));
if (subscription != null) {
feedEntryService.markSubscriptionEntries(user, Collections.singletonList(subscription), olderThan, insertedBefore,
@@ -332,15 +324,14 @@ public class FeedREST {
@GET
@Path("/get/{id}")
@UnitOfWork
@Transactional
@Operation(
summary = "get feed",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Subscription.class))) })
@Timed
public Response getFeed(@Parameter(hidden = true) @SecurityCheck User user,
@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
public Response getFeed(@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
Preconditions.checkNotNull(id);
User user = authenticationContext.getCurrentUser();
FeedSubscription sub = feedSubscriptionDAO.findById(user, id);
if (sub == null) {
return Response.status(Status.NOT_FOUND).build();
@@ -351,13 +342,12 @@ public class FeedREST {
@GET
@Path("/favicon/{id}")
@UnitOfWork
@Transactional
@Operation(summary = "Fetch a feed's icon", description = "Fetch a feed's icon")
@Timed
public Response getFeedFavicon(@Parameter(hidden = true) @SecurityCheck User user,
@Parameter(description = "subscription id", required = true) @PathParam("id") Long id) {
public Response getFeedFavicon(@Parameter(description = "subscription id", required = true) @PathParam("id") Long id) {
Preconditions.checkNotNull(id);
User user = authenticationContext.getCurrentUser();
FeedSubscription subscription = feedSubscriptionDAO.findById(user, id);
if (subscription == null) {
return Response.status(Status.NOT_FOUND).build();
@@ -382,14 +372,12 @@ public class FeedREST {
@POST
@Path("/subscribe")
@UnitOfWork
@Transactional
@Operation(
summary = "Subscribe to a feed",
description = "Subscribe to a feed",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Long.class))) })
@Timed
public Response subscribe(@Parameter(hidden = true) @SecurityCheck User user,
@Valid @Parameter(description = "subscription request", required = true) SubscribeRequest req) {
public Response subscribe(@Valid @Parameter(description = "subscription request", required = true) SubscribeRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getTitle());
Preconditions.checkNotNull(req.getUrl());
@@ -401,6 +389,7 @@ public class FeedREST {
}
FeedInfo info = fetchFeedInternal(prependHttp(req.getUrl()));
User user = authenticationContext.getCurrentUser();
long subscriptionId = feedSubscriptionService.subscribe(user, info.getUrl(), req.getTitle(), category);
return Response.ok(subscriptionId).build();
} catch (Exception e) {
@@ -413,19 +402,18 @@ public class FeedREST {
@GET
@Path("/subscribe")
@UnitOfWork
@Transactional
@Operation(summary = "Subscribe to a feed", description = "Subscribe to a feed")
@Timed
public Response subscribeFromUrl(@Parameter(hidden = true) @SecurityCheck User user,
@Parameter(description = "feed url", required = true) @QueryParam("url") String url) {
public Response subscribeFromUrl(@Parameter(description = "feed url", required = true) @QueryParam("url") String url) {
try {
Preconditions.checkNotNull(url);
FeedInfo info = fetchFeedInternal(prependHttp(url));
User user = authenticationContext.getCurrentUser();
feedSubscriptionService.subscribe(user, info.getUrl(), info.getTitle());
} catch (Exception e) {
log.info("Could not subscribe to url {} : {}", url, e.getMessage());
}
return Response.temporaryRedirect(URI.create(config.getApplicationSettings().getPublicUrl())).build();
return Response.temporaryRedirect(URI.create(config.publicUrl())).build();
}
private String prependHttp(String url) {
@@ -437,13 +425,13 @@ public class FeedREST {
@POST
@Path("/unsubscribe")
@UnitOfWork
@Transactional
@Operation(summary = "Unsubscribe from a feed", description = "Unsubscribe from a feed")
@Timed
public Response unsubscribe(@Parameter(hidden = true) @SecurityCheck User user, @Parameter(required = true) IDRequest req) {
public Response unsubscribe(@Parameter(required = true) IDRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
User user = authenticationContext.getCurrentUser();
boolean deleted = feedSubscriptionService.unsubscribe(user, req.getId());
if (deleted) {
return Response.ok().build();
@@ -454,11 +442,9 @@ public class FeedREST {
@POST
@Path("/modify")
@UnitOfWork
@Transactional
@Operation(summary = "Modify a subscription", description = "Modify a feed subscription")
@Timed
public Response modifyFeed(@Parameter(hidden = true) @SecurityCheck User user,
@Valid @Parameter(description = "subscription id", required = true) FeedModificationRequest req) {
public Response modifyFeed(@Valid @Parameter(description = "subscription id", required = true) FeedModificationRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
@@ -468,6 +454,7 @@ public class FeedREST {
return Response.status(Status.BAD_REQUEST).entity(e.getCause().getMessage()).type(MediaType.TEXT_PLAIN).build();
}
User user = authenticationContext.getCurrentUser();
FeedSubscription subscription = feedSubscriptionDAO.findById(user, req.getId());
subscription.setFilter(StringUtils.lowerCase(req.getFilter()));
@@ -509,39 +496,36 @@ public class FeedREST {
@POST
@Path("/import")
@UnitOfWork
@Transactional
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Operation(summary = "OPML import", description = "Import an OPML file, posted as a FORM with the 'file' name")
@Timed
public Response importOpml(@Parameter(hidden = true) @SecurityCheck User user,
@Parameter(description = "ompl file", required = true) @FormDataParam("file") InputStream input) {
public Response importOpml(@Parameter(description = "ompl file", required = true) @RestForm("file") String opml) {
User user = authenticationContext.getCurrentUser();
if (CommaFeedApplication.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN).entity("Import is disabled for the demo account").build();
}
try {
String opml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
opmlImporter.importOpml(user, opml);
} catch (Exception e) {
log.error(e.getMessage(), e);
throw new WebApplicationException(Response.status(Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build());
return Response.status(Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build();
}
return Response.ok().build();
}
@GET
@Path("/export")
@UnitOfWork
@Transactional
@Produces(MediaType.APPLICATION_XML)
@Operation(summary = "OPML export", description = "Export an OPML file of the user's subscriptions")
@Timed
public Response exportOpml(@Parameter(hidden = true) @SecurityCheck User user) {
public Response exportOpml() {
User user = authenticationContext.getCurrentUser();
Opml opml = opmlExporter.export(user);
WireFeedOutput output = new WireFeedOutput();
String opmlString;
try {
opmlString = output.outputString(opml);
} catch (Exception e) {
return Response.status(Status.INTERNAL_SERVER_ERROR).entity(e).build();
return Response.status(Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build();
}
return Response.ok(opmlString).build();
}

View File

@@ -1,25 +1,23 @@
package com.commafeed.frontend.resource;
import org.apache.commons.lang3.StringUtils;
import com.codahale.metrics.annotation.Timed;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.CommaFeedVersion;
import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.User;
import com.commafeed.frontend.auth.SecurityCheck;
import com.commafeed.frontend.model.ServerInfo;
import com.commafeed.security.Roles;
import io.dropwizard.hibernate.UnitOfWork;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.inject.Inject;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
@@ -30,49 +28,50 @@ import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import lombok.RequiredArgsConstructor;
@Path("/server")
@Path("/rest/server")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
@Tag(name = "Server")
public class ServerREST {
private final HttpGetter httpGetter;
private final CommaFeedConfiguration config;
private final CommaFeedVersion version;
@Path("/get")
@GET
@UnitOfWork
@PermitAll
@Transactional
@Operation(
summary = "Get server infos",
description = "Get server infos",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = ServerInfo.class))) })
@Timed
public Response getServerInfos() {
ServerInfo infos = new ServerInfo();
infos.setAnnouncement(config.getApplicationSettings().getAnnouncement());
infos.setVersion(config.getVersion());
infos.setGitCommit(config.getGitCommit());
infos.setAllowRegistrations(config.getApplicationSettings().getAllowRegistrations());
infos.setGoogleAnalyticsCode(config.getApplicationSettings().getGoogleAnalyticsTrackingCode());
infos.setSmtpEnabled(StringUtils.isNotBlank(config.getApplicationSettings().getSmtpHost()));
infos.setDemoAccountEnabled(config.getApplicationSettings().getCreateDemoAccount());
infos.setWebsocketEnabled(config.getApplicationSettings().getWebsocketEnabled());
infos.setWebsocketPingInterval(config.getApplicationSettings().getWebsocketPingInterval().toMilliseconds());
infos.setTreeReloadInterval(config.getApplicationSettings().getTreeReloadInterval().toMilliseconds());
infos.setAnnouncement(config.announcement().orElse(null));
infos.setVersion(version.getVersion());
infos.setGitCommit(version.getGitCommit());
infos.setAllowRegistrations(config.users().allowRegistrations());
infos.setGoogleAnalyticsCode(config.googleAnalyticsTrackingCode().orElse(null));
infos.setSmtpEnabled(config.smtp().isPresent());
infos.setDemoAccountEnabled(config.users().createDemoAccount());
infos.setWebsocketEnabled(config.websocket().enabled());
infos.setWebsocketPingInterval(config.websocket().pingInterval().toMillis());
infos.setTreeReloadInterval(config.websocket().treeReloadInterval().toMillis());
return Response.ok(infos).build();
}
@Path("/proxy")
@GET
@UnitOfWork
@RolesAllowed(Roles.USER)
@Transactional
@Operation(summary = "proxy image")
@Produces("image/png")
@Timed
public Response getProxiedImage(@Parameter(hidden = true) @SecurityCheck User user,
@Parameter(description = "image url", required = true) @QueryParam("u") String url) {
if (!config.getApplicationSettings().getImageProxyEnabled()) {
public Response getProxiedImage(@Parameter(description = "image url", required = true) @QueryParam("u") String url) {
if (!config.imageProxyEnabled()) {
return Response.status(Status.FORBIDDEN).build();
}

View File

@@ -10,7 +10,6 @@ import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.core5.net.URIBuilder;
import com.codahale.metrics.annotation.Timed;
import com.commafeed.CommaFeedApplication;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.Digests;
@@ -29,25 +28,25 @@ import com.commafeed.backend.model.UserSettings.ScrollMode;
import com.commafeed.backend.service.MailService;
import com.commafeed.backend.service.PasswordEncryptionService;
import com.commafeed.backend.service.UserService;
import com.commafeed.frontend.auth.SecurityCheck;
import com.commafeed.frontend.model.Settings;
import com.commafeed.frontend.model.UserModel;
import com.commafeed.frontend.model.request.LoginRequest;
import com.commafeed.frontend.model.request.PasswordResetRequest;
import com.commafeed.frontend.model.request.ProfileModificationRequest;
import com.commafeed.frontend.model.request.RegistrationRequest;
import com.commafeed.frontend.session.SessionHelper;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
import io.dropwizard.hibernate.UnitOfWork;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.inject.Inject;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
@@ -56,22 +55,23 @@ import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Path("/user")
@Path("/rest/user")
@RolesAllowed(Roles.USER)
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Slf4j
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
@Tag(name = "Users")
public class UserREST {
private final AuthenticationContext authenticationContext;
private final UserDAO userDAO;
private final UserRoleDAO userRoleDAO;
private final UserSettingsDAO userSettingsDAO;
@@ -82,14 +82,15 @@ public class UserREST {
@Path("/settings")
@GET
@UnitOfWork
@Transactional
@Operation(
summary = "Retrieve user settings",
description = "Retrieve user settings",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Settings.class))) })
@Timed
public Response getUserSettings(@Parameter(hidden = true) @SecurityCheck User user) {
public Response getUserSettings() {
Settings s = new Settings();
User user = authenticationContext.getCurrentUser();
UserSettings settings = userSettingsDAO.findByUser(user);
if (settings != null) {
s.setReadingMode(settings.getReadingMode().name());
@@ -145,12 +146,12 @@ public class UserREST {
@Path("/settings")
@POST
@UnitOfWork
@Transactional
@Operation(summary = "Save user settings", description = "Save user settings")
@Timed
public Response saveUserSettings(@Parameter(hidden = true) @SecurityCheck User user, @Parameter(required = true) Settings settings) {
public Response saveUserSettings(@Parameter(required = true) Settings settings) {
Preconditions.checkNotNull(settings);
User user = authenticationContext.getCurrentUser();
UserSettings s = userSettingsDAO.findByUser(user);
if (s == null) {
s = new UserSettings();
@@ -187,12 +188,13 @@ public class UserREST {
@Path("/profile")
@GET
@UnitOfWork
@Transactional
@Operation(
summary = "Retrieve user's profile",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = UserModel.class))) })
@Timed
public Response getUserProfile(@Parameter(hidden = true) @SecurityCheck User user) {
public Response getUserProfile() {
User user = authenticationContext.getCurrentUser();
UserModel userModel = new UserModel();
userModel.setId(user.getId());
userModel.setName(user.getName());
@@ -209,11 +211,10 @@ public class UserREST {
@Path("/profile")
@POST
@UnitOfWork
@Transactional
@Operation(summary = "Save user's profile")
@Timed
public Response saveUserProfile(@Parameter(hidden = true) @SecurityCheck User user,
@Valid @Parameter(required = true) ProfileModificationRequest request) {
public Response saveUserProfile(@Valid @Parameter(required = true) ProfileModificationRequest request) {
User user = authenticationContext.getCurrentUser();
if (CommaFeedApplication.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN).build();
}
@@ -242,49 +243,29 @@ public class UserREST {
user.setApiKey(userService.generateApiKey(user));
}
userDAO.update(user);
userDAO.saveOrUpdate(user);
return Response.ok().build();
}
@Path("/register")
@PermitAll
@POST
@UnitOfWork
@Transactional
@Operation(summary = "Register a new account")
@Timed
public Response registerUser(@Valid @Parameter(required = true) RegistrationRequest req,
@Context @Parameter(hidden = true) SessionHelper sessionHelper) {
public Response registerUser(@Valid @Parameter(required = true) RegistrationRequest req) {
try {
User registeredUser = userService.register(req.getName(), req.getPassword(), req.getEmail(),
Collections.singletonList(Role.USER));
userService.login(req.getName(), req.getPassword());
sessionHelper.setLoggedInUser(registeredUser);
userService.register(req.getName(), req.getPassword(), req.getEmail(), Collections.singletonList(Role.USER));
return Response.ok().build();
} catch (final IllegalArgumentException e) {
throw new BadRequestException(e.getMessage());
}
}
@Path("/login")
@POST
@UnitOfWork
@Operation(summary = "Login and create a session")
@Timed
public Response login(@Valid @Parameter(required = true) LoginRequest req,
@Parameter(hidden = true) @Context SessionHelper sessionHelper) {
Optional<User> user = userService.login(req.getName(), req.getPassword());
if (user.isPresent()) {
sessionHelper.setLoggedInUser(user.get());
return Response.ok().build();
} else {
return Response.status(Response.Status.UNAUTHORIZED).entity("wrong username or password").type(MediaType.TEXT_PLAIN).build();
}
}
@Path("/passwordReset")
@PermitAll
@POST
@UnitOfWork
@Transactional
@Operation(summary = "send a password reset email")
@Timed
public Response sendPasswordReset(@Valid @Parameter(required = true) PasswordResetRequest req) {
User user = userDAO.findByEmail(req.getEmail());
if (user == null) {
@@ -304,7 +285,7 @@ public class UserREST {
}
private String buildEmailContent(User user) throws Exception {
String publicUrl = FeedUtils.removeTrailingSlash(config.getApplicationSettings().getPublicUrl());
String publicUrl = FeedUtils.removeTrailingSlash(config.publicUrl());
publicUrl += "/rest/user/passwordResetCallback";
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.",
@@ -320,10 +301,10 @@ public class UserREST {
}
@Path("/passwordResetCallback")
@PermitAll
@GET
@UnitOfWork
@Transactional
@Produces(MediaType.TEXT_HTML)
@Timed
public Response passwordRecoveryCallback(@Parameter(required = true) @QueryParam("email") String email,
@Parameter(required = true) @QueryParam("token") String token) {
Preconditions.checkNotNull(email);
@@ -352,16 +333,16 @@ public class UserREST {
String message = "Your new password is: " + passwd;
message += "<br />";
message += String.format("<a href=\"%s\">Back to Homepage</a>", config.getApplicationSettings().getPublicUrl());
message += String.format("<a href=\"%s\">Back to Homepage</a>", config.publicUrl());
return Response.ok(message).build();
}
@Path("/profile/deleteAccount")
@POST
@UnitOfWork
@Transactional
@Operation(summary = "Delete the user account")
@Timed
public Response deleteUser(@Parameter(hidden = true) @SecurityCheck User user) {
public Response deleteUser() {
User user = authenticationContext.getCurrentUser();
if (CommaFeedApplication.USERNAME_ADMIN.equals(user.getName()) || CommaFeedApplication.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN).build();
}

View File

@@ -13,9 +13,9 @@ import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.glassfish.jersey.media.multipart.FormDataMultiPart;
import org.jboss.resteasy.reactive.server.multipart.FormValue;
import org.jboss.resteasy.reactive.server.multipart.MultipartFormDataInput;
import com.codahale.metrics.annotation.Timed;
import com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.dao.FeedEntryDAO;
import com.commafeed.backend.dao.FeedEntryStatusDAO;
@@ -37,10 +37,10 @@ import com.commafeed.frontend.resource.fever.FeverResponse.FeverFeedGroup;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverGroup;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverItem;
import io.dropwizard.hibernate.UnitOfWork;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.inject.Inject;
import jakarta.annotation.security.PermitAll;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
@@ -64,9 +64,10 @@ import lombok.RequiredArgsConstructor;
*
* See https://feedafever.com/api
*/
@Path("/fever")
@Path("/rest/fever")
@PermitAll
@Produces(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@RequiredArgsConstructor
@Singleton
@Hidden
public class FeverREST {
@@ -88,8 +89,7 @@ public class FeverREST {
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Path(PATH)
@POST
@UnitOfWork
@Timed
@Transactional
public FeverResponse formUrlencoded(@Context UriInfo uri, @PathParam("userId") Long userId, MultivaluedMap<String, String> form) {
Map<String, String> params = new HashMap<>();
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.get(0)));
@@ -101,8 +101,7 @@ public class FeverREST {
// e.g. FeedMe
@Path(PATH)
@POST
@UnitOfWork
@Timed
@Transactional
public FeverResponse noForm(@Context UriInfo uri, @PathParam("userId") Long userId) {
Map<String, String> params = new HashMap<>();
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.get(0)));
@@ -113,8 +112,7 @@ public class FeverREST {
// e.g. Unread
@Path(PATH)
@GET
@UnitOfWork
@Timed
@Transactional
public FeverResponse get(@Context UriInfo uri, @PathParam("userId") Long userId) {
Map<String, String> params = new HashMap<>();
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.get(0)));
@@ -126,12 +124,11 @@ public class FeverREST {
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Path(PATH)
@POST
@UnitOfWork
@Timed
public FeverResponse formData(@Context UriInfo uri, @PathParam("userId") Long userId, FormDataMultiPart form) {
@Transactional
public FeverResponse formData(@Context UriInfo uri, @PathParam("userId") Long userId, MultipartFormDataInput form) {
Map<String, String> params = new HashMap<>();
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.get(0)));
form.getFields().forEach((k, v) -> params.put(k, v.get(0).getValue()));
form.getValues().forEach((k, v) -> params.put(k, v.stream().map(FormValue::getValue).findFirst().orElse(null)));
return handle(userId, params);
}

View File

@@ -1,54 +0,0 @@
package com.commafeed.frontend.servlet;
import java.io.IOException;
import java.util.Optional;
import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.dao.UserSettingsDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings;
import com.commafeed.frontend.session.SessionHelper;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
abstract class AbstractCustomCodeServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private final transient UnitOfWork unitOfWork;
private final transient UserDAO userDAO;
private final transient UserSettingsDAO userSettingsDAO;
@Override
protected final void doGet(final HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType(getMimeType());
SessionHelper sessionHelper = new SessionHelper(req);
Optional<Long> userId = sessionHelper.getLoggedInUserId();
final Optional<User> user = unitOfWork.call(() -> userId.map(userDAO::findById));
if (user.isEmpty()) {
return;
}
UserSettings settings = unitOfWork.call(() -> userSettingsDAO.findByUser(user.get()));
if (settings == null) {
return;
}
String customCode = getCustomCode(settings);
if (customCode == null) {
return;
}
resp.getWriter().write(customCode);
}
protected abstract String getMimeType();
protected abstract String getCustomCode(UserSettings settings);
}

View File

@@ -1,28 +1,39 @@
package com.commafeed.frontend.servlet;
import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.dao.UserSettingsDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings;
import com.commafeed.security.AuthenticationContext;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import lombok.RequiredArgsConstructor;
public class CustomCssServlet extends AbstractCustomCodeServlet {
@Path("/custom_css.css")
@Produces("text/css")
@RequiredArgsConstructor
@Singleton
public class CustomCssServlet {
private static final long serialVersionUID = 1L;
private final AuthenticationContext authenticationContext;
private final UserSettingsDAO userSettingsDAO;
private final UnitOfWork unitOfWork;
@Inject
public CustomCssServlet(UnitOfWork unitOfWork, UserDAO userDAO, UserSettingsDAO userSettingsDAO) {
super(unitOfWork, userDAO, userSettingsDAO);
}
@GET
public String get() {
User user = authenticationContext.getCurrentUser();
if (user == null) {
return "";
}
@Override
protected String getMimeType() {
return "text/css";
}
UserSettings settings = unitOfWork.call(() -> userSettingsDAO.findByUser(user));
if (settings == null) {
return "";
}
@Override
protected String getCustomCode(UserSettings settings) {
return settings.getCustomCss();
}

View File

@@ -1,30 +1,39 @@
package com.commafeed.frontend.servlet;
import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.dao.UserSettingsDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings;
import com.commafeed.security.AuthenticationContext;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import lombok.RequiredArgsConstructor;
@Path("/custom_js.js")
@Produces("application/javascript")
@RequiredArgsConstructor
@Singleton
public class CustomJsServlet extends AbstractCustomCodeServlet {
public class CustomJsServlet {
private static final long serialVersionUID = 1L;
private final AuthenticationContext authenticationContext;
private final UserSettingsDAO userSettingsDAO;
private final UnitOfWork unitOfWork;
@Inject
public CustomJsServlet(UnitOfWork unitOfWork, UserDAO userDAO, UserSettingsDAO userSettingsDAO) {
super(unitOfWork, userDAO, userSettingsDAO);
}
@GET
public String get() {
User user = authenticationContext.getCurrentUser();
if (user == null) {
return "";
}
@Override
protected String getMimeType() {
return "application/javascript";
}
UserSettings settings = unitOfWork.call(() -> userSettingsDAO.findByUser(user));
if (settings == null) {
return "";
}
@Override
protected String getCustomCode(UserSettings settings) {
return settings.getCustomJs();
}

View File

@@ -1,26 +1,35 @@
package com.commafeed.frontend.servlet;
import java.io.IOException;
import java.net.URI;
import java.time.Instant;
import java.util.Date;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import com.commafeed.CommaFeedConfiguration;
import jakarta.inject.Inject;
import jakarta.annotation.security.PermitAll;
import jakarta.inject.Singleton;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
@SuppressWarnings("serial")
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Path("/logout")
@PermitAll
@RequiredArgsConstructor
@Singleton
public class LogoutServlet extends HttpServlet {
public class LogoutServlet {
@ConfigProperty(name = "quarkus.http.auth.form.cookie-name")
String cookieName;
private final CommaFeedConfiguration config;
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
req.getSession().invalidate();
resp.sendRedirect(resp.encodeRedirectURL(config.getApplicationSettings().getPublicUrl()));
@GET
public Response get() {
NewCookie removeCookie = new NewCookie.Builder(cookieName).maxAge(0).expiry(Date.from(Instant.EPOCH)).path("/").build();
return Response.temporaryRedirect(URI.create(config.publicUrl())).cookie(removeCookie).build();
}
}

View File

@@ -1,8 +1,7 @@
package com.commafeed.frontend.servlet;
import java.io.IOException;
import java.net.URI;
import java.util.List;
import java.util.Optional;
import org.apache.commons.lang3.StringUtils;
@@ -11,86 +10,68 @@ import com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.dao.FeedEntryStatusDAO;
import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.backend.service.UserService;
import com.commafeed.frontend.resource.CategoryREST;
import com.commafeed.frontend.session.SessionHelper;
import com.commafeed.security.AuthenticationContext;
import com.google.common.collect.Iterables;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
@SuppressWarnings("serial")
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Path("/next")
@RequiredArgsConstructor
@Singleton
public class NextUnreadServlet extends HttpServlet {
private static final String PARAM_CATEGORYID = "category";
private static final String PARAM_READINGORDER = "order";
public class NextUnreadServlet {
private final UnitOfWork unitOfWork;
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
private final FeedCategoryDAO feedCategoryDAO;
private final UserDAO userDAO;
private final UserService userService;
private final FeedEntryService feedEntryService;
private final CommaFeedConfiguration config;
private final AuthenticationContext authenticationContext;
@Override
protected void doGet(final HttpServletRequest req, HttpServletResponse resp) throws IOException {
final String categoryId = req.getParameter(PARAM_CATEGORYID);
String orderParam = req.getParameter(PARAM_READINGORDER);
SessionHelper sessionHelper = new SessionHelper(req);
Optional<Long> userId = sessionHelper.getLoggedInUserId();
Optional<User> user = unitOfWork.call(() -> userId.map(userDAO::findById));
user.ifPresent(value -> unitOfWork.run(() -> userService.performPostLoginActivities(value)));
if (user.isEmpty()) {
resp.sendRedirect(resp.encodeRedirectURL(config.getApplicationSettings().getPublicUrl()));
return;
@GET
public Response get(@QueryParam("category") String categoryId, @QueryParam("order") @DefaultValue("desc") ReadingOrder order) {
User user = authenticationContext.getCurrentUser();
if (user == null) {
return Response.temporaryRedirect(URI.create(config.publicUrl())).build();
}
final ReadingOrder order = StringUtils.equals(orderParam, "asc") ? ReadingOrder.asc : ReadingOrder.desc;
FeedEntryStatus status = unitOfWork.call(() -> {
FeedEntryStatus s = null;
if (StringUtils.isBlank(categoryId) || CategoryREST.ALL.equals(categoryId)) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user.get());
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user.get(), subs, true, null, null, 0, 1, order,
true, null, null, null);
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subs, true, null, null, 0, 1, order, true,
null, null, null);
s = Iterables.getFirst(statuses, null);
} else {
FeedCategory category = feedCategoryDAO.findById(user.get(), Long.valueOf(categoryId));
FeedCategory category = feedCategoryDAO.findById(user, Long.valueOf(categoryId));
if (category != null) {
List<FeedCategory> children = feedCategoryDAO.findAllChildrenCategories(user.get(), category);
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findByCategories(user.get(), children);
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user.get(), subscriptions, true, null, null, 0,
1, order, true, null, null, null);
List<FeedCategory> children = feedCategoryDAO.findAllChildrenCategories(user, category);
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findByCategories(user, children);
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, null, null, 0, 1,
order, true, null, null, null);
s = Iterables.getFirst(statuses, null);
}
}
if (s != null) {
feedEntryService.markEntry(user.get(), s.getEntry().getId(), true);
feedEntryService.markEntry(user, s.getEntry().getId(), true);
}
return s;
});
if (status == null) {
resp.sendRedirect(resp.encodeRedirectURL(config.getApplicationSettings().getPublicUrl()));
} else {
String url = status.getEntry().getUrl();
resp.sendRedirect(resp.encodeRedirectURL(url));
}
String url = status == null ? config.publicUrl() : status.getEntry().getUrl();
return Response.temporaryRedirect(URI.create(url)).build();
}
}

View File

@@ -1,22 +1,33 @@
package com.commafeed.frontend.servlet;
import java.io.IOException;
import org.apache.hc.core5.http.HttpStatus;
import com.commafeed.CommaFeedConfiguration;
import jakarta.annotation.security.PermitAll;
import jakarta.inject.Singleton;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
@Path("/robots.txt")
@PermitAll
@Produces(MediaType.TEXT_PLAIN)
@RequiredArgsConstructor
@Singleton
public class RobotsTxtDisallowAllServlet extends HttpServlet {
public class RobotsTxtDisallowAllServlet {
private static final long serialVersionUID = 1L;
private final CommaFeedConfiguration config;
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType("text/plain");
resp.getWriter().write("User-agent: *");
resp.getWriter().write("\n");
resp.getWriter().write("Disallow: /");
@GET
public Response get() {
if (config.hideFromWebCrawlers()) {
return Response.ok("User-agent: *\nDisallow: /").build();
} else {
return Response.status(HttpStatus.SC_NOT_FOUND).build();
}
}
}
}

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