mirror of
https://github.com/jlengrand/RemarkablePocket.git
synced 2026-03-10 08:41:19 +00:00
Check auth validity on every sync
This commit is contained in:
@@ -43,7 +43,7 @@ An example run of the program can be found below:
|
||||
The easiest way to run the application is using Docker. First install Docker for your platform from https://docs.docker.com/get-docker/. Then run the following command to start the application on Linux or Mac (I have not tested it on Windows yet):
|
||||
|
||||
```
|
||||
touch ~/.remarkable-pocket && docker run -it --env TZ=$(date +%Z) -p 65112:65112 -v ~/.remarkable-pocket:/root/.remarkable-pocket ghcr.io/nov1n/remarkable-pocket:0.0.2
|
||||
touch ~/.remarkable-pocket && docker run -it --env TZ=$(date +%Z) -p 65112:65112 -v ~/.remarkable-pocket:/root/.remarkable-pocket ghcr.io/nov1n/remarkable-pocket:0.0.3
|
||||
```
|
||||
The first time you run the application, you will be asked to authorize Pocket and Remarkable Cloud. Once you have done this subsequent runs will read the credentials from the `~/.remarkable-pocket` file.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "nl.carosi"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<array>
|
||||
<string>/bin/sh</string>
|
||||
<string>-c</string>
|
||||
<string>while ! /usr/local/bin/docker version > /dev/null 2>&1; do sleep 5; done && /usr/local/bin/docker run --env TZ=$(date +%Z) -v ~/.remarkable-pocket:/root/.remarkable-pocket ghcr.io/nov1n/remarkable-pocket:0.0.2 1>>$HOME/.remarkable-pocket.log 2>&1</string>
|
||||
<string>while ! /usr/local/bin/docker version > /dev/null 2>&1; do sleep 5; done && /usr/local/bin/docker run --env TZ=$(date +%Z) -v ~/.remarkable-pocket:/root/.remarkable-pocket ghcr.io/nov1n/remarkable-pocket:0.0.3 1>>$HOME/.remarkable-pocket.log 2>&1</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
77
src/main/java/nl/carosi/remarkablepocket/AuthService.java
Normal file
77
src/main/java/nl/carosi/remarkablepocket/AuthService.java
Normal file
@@ -0,0 +1,77 @@
|
||||
package nl.carosi.remarkablepocket;
|
||||
|
||||
import static java.time.temporal.ChronoUnit.MINUTES;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import es.jlarriba.jrmapi.Jrmapi;
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.FormatStyle;
|
||||
import java.util.Base64;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
||||
// Hacky, but unfortunately jrmapi does not refresh bearer tokens.
|
||||
final class AuthService {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AuthService.class);
|
||||
private static final Base64.Decoder B64_DECODER = Base64.getDecoder();
|
||||
private static final DateTimeFormatter TEMPORAL_FORMATTER =
|
||||
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
|
||||
.withZone(ZoneId.systemDefault());
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AtomicReference<Jrmapi> rmapi;
|
||||
private final String deviceToken;
|
||||
private Instant nextRefresh;
|
||||
|
||||
public AuthService(
|
||||
ObjectMapper objectMapper,
|
||||
AtomicReference<Jrmapi> rmapi,
|
||||
@Value("${rm.device-token}") String deviceToken) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.rmapi = rmapi;
|
||||
this.deviceToken = deviceToken;
|
||||
}
|
||||
|
||||
void ensureValid() {
|
||||
if (nextRefresh != null && Instant.now().isBefore(nextRefresh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
refreshToken();
|
||||
} catch (NoSuchFieldException | IllegalAccessException | JsonProcessingException e) {
|
||||
LOG.debug("Exception occurred while refreshing auth token:", e);
|
||||
throw new RuntimeException("Could not refresh auth token.");
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshToken()
|
||||
throws NoSuchFieldException, IllegalAccessException, JsonProcessingException {
|
||||
Field tokenField = Jrmapi.class.getDeclaredField("userToken");
|
||||
tokenField.setAccessible(true);
|
||||
String token = (String) tokenField.get(rmapi.get());
|
||||
Instant exp = getExpiration(token);
|
||||
nextRefresh = exp.minus(30, MINUTES);
|
||||
rmapi.set(new Jrmapi(deviceToken));
|
||||
LOG.debug(
|
||||
"Refreshed jrmapi token. Valid until: {}, refreshing at {}.",
|
||||
TEMPORAL_FORMATTER.format(exp),
|
||||
TEMPORAL_FORMATTER.format(nextRefresh));
|
||||
}
|
||||
|
||||
private Instant getExpiration(String token) throws JsonProcessingException {
|
||||
String[] chunks = token.split("\\.");
|
||||
String payload = new String(B64_DECODER.decode(chunks[1]));
|
||||
return objectMapper.readValue(payload, JWT.class).exp();
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record JWT(Instant exp) {}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
@EnableRetry
|
||||
@Import({
|
||||
ArticleDownloader.class,
|
||||
AuthService.class,
|
||||
DownloadService.class,
|
||||
EpubReader.class,
|
||||
EpubWriter.class,
|
||||
@@ -26,7 +27,6 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
PocketService.class,
|
||||
RemarkableService.class,
|
||||
SyncService.class,
|
||||
TokenRefresher.class
|
||||
})
|
||||
public class SyncApplication {
|
||||
@Bean
|
||||
|
||||
@@ -39,7 +39,7 @@ import picocli.CommandLine.Option;
|
||||
sortOptions = false,
|
||||
usageHelpAutoWidth = true,
|
||||
// TODO: Read from gradle.properties
|
||||
version = "0.0.2",
|
||||
version = "0.0.3",
|
||||
mixinStandardHelpOptions = true)
|
||||
class SyncCommand implements Callable<Integer> {
|
||||
@Option(
|
||||
@@ -131,7 +131,7 @@ class SyncCommand implements Callable<Integer> {
|
||||
entry("sync.interval", "PT" + interval),
|
||||
entry("sync.run-once", Boolean.toString(runOnce)),
|
||||
entry("pocket.tag-filter", tagFilter),
|
||||
entry("logging.level" + this.getClass().getPackageName(), verbose ? "DEBUG" : "INFO")
|
||||
entry("logging.level." + this.getClass().getPackageName(), verbose ? "DEBUG" : "INFO")
|
||||
);
|
||||
|
||||
new SpringApplicationBuilder(SyncApplication.class)
|
||||
|
||||
@@ -24,6 +24,7 @@ final class SyncService {
|
||||
private final PocketService pocketService;
|
||||
private final DownloadService downloadService;
|
||||
private final RemarkableService remarkableService;
|
||||
private final AuthService authService;
|
||||
private final ApplicationContext appContext;
|
||||
private final int articleLimit;
|
||||
private final boolean archiveRead;
|
||||
@@ -34,6 +35,7 @@ final class SyncService {
|
||||
PocketService pocketService,
|
||||
DownloadService downloadService,
|
||||
RemarkableService remarkableService,
|
||||
AuthService authService,
|
||||
ApplicationContext appContext,
|
||||
@Value("${rm.article-limit}") int articleLimit,
|
||||
@Value("${pocket.archive-read}") boolean archiveRead,
|
||||
@@ -42,6 +44,7 @@ final class SyncService {
|
||||
this.pocketService = pocketService;
|
||||
this.downloadService = downloadService;
|
||||
this.remarkableService = remarkableService;
|
||||
this.authService = authService;
|
||||
this.appContext = appContext;
|
||||
this.articleLimit = articleLimit;
|
||||
this.archiveRead = archiveRead;
|
||||
@@ -60,6 +63,7 @@ final class SyncService {
|
||||
@Scheduled(fixedDelayString = "${sync.interval}")
|
||||
void sync() {
|
||||
ensureConnected(LOG::error);
|
||||
authService.ensureValid();
|
||||
|
||||
try {
|
||||
syncImpl();
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package nl.carosi.remarkablepocket;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import es.jlarriba.jrmapi.Jrmapi;
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import javax.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.TaskScheduler;
|
||||
|
||||
// Hacky, but unfortunately jrmapi does not refresh bearer tokens.
|
||||
final class TokenRefresher {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(TokenRefresher.class);
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TaskScheduler scheduler;
|
||||
private final AtomicReference<Jrmapi> rmapi;
|
||||
private final String deviceToken;
|
||||
private final Base64.Decoder decoder = Base64.getDecoder();
|
||||
|
||||
public TokenRefresher(
|
||||
ObjectMapper objectMapper,
|
||||
TaskScheduler scheduler,
|
||||
AtomicReference<Jrmapi> rmapi,
|
||||
@Value("${rm.device-token}") String deviceToken) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.scheduler = scheduler;
|
||||
this.rmapi = rmapi;
|
||||
this.deviceToken = deviceToken;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void scheduleRefresh() {
|
||||
try {
|
||||
scheduleRefreshImpl();
|
||||
} catch (NoSuchFieldException | IllegalAccessException | JsonProcessingException e) {
|
||||
throw new RuntimeException("Could not refresh jrmapi token.");
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleRefreshImpl()
|
||||
throws NoSuchFieldException, IllegalAccessException, JsonProcessingException {
|
||||
Field tokenField = Jrmapi.class.getDeclaredField("userToken");
|
||||
tokenField.setAccessible(true);
|
||||
String token = (String) tokenField.get(rmapi.get());
|
||||
int exp = getExpiration(token);
|
||||
Instant nextRefresh = Instant.ofEpochSecond(exp - 60 * 10); // 10 min margin.
|
||||
scheduler.schedule(this::refresh, nextRefresh);
|
||||
LOG.debug("Next token refresh scheduled at: {}.", nextRefresh);
|
||||
}
|
||||
|
||||
private void refresh() {
|
||||
rmapi.set(new Jrmapi(deviceToken));
|
||||
LOG.debug("Refreshed jrmapi token.");
|
||||
scheduleRefresh();
|
||||
}
|
||||
|
||||
private int getExpiration(String jwt) throws JsonProcessingException {
|
||||
String[] chunks = jwt.split("\\.");
|
||||
String payload = new String(decoder.decode(chunks[1]));
|
||||
JWT exp = objectMapper.readValue(payload, JWT.class);
|
||||
return exp.exp();
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record JWT(int exp) {}
|
||||
}
|
||||
Reference in New Issue
Block a user