From 69569d35437f4acd44ccadeec22de0c9a9f2cc98 Mon Sep 17 00:00:00 2001 From: Robert Carosi Date: Fri, 5 Nov 2021 13:20:19 +0100 Subject: [PATCH] Add TokenRefresher --- README.md | 1 + .../remarkablepocket/DownloadService.java | 1 + .../remarkablepocket/MetadataProvider.java | 14 +++- .../remarkablepocket/RemarkableService.java | 17 +++-- .../remarkablepocket/SyncApplication.java | 6 +- .../remarkablepocket/TokenRefresher.java | 73 +++++++++++++++++++ .../remarkablepocket/model/Article.java | 6 +- 7 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 src/main/java/nl/carosi/remarkablepocket/TokenRefresher.java diff --git a/README.md b/README.md index f6d1b7b..a105aa4 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Synchronizes articles from Pocket to the Remarkable tablet. ## Limitations - Articles behind a paywall cannot be downloaded. +- Articles on websites with sophisticated DDOS protection cannot be downloaded. - Articles that use javascript to load the the content cannot be downloaded. ## Thanks diff --git a/src/main/java/nl/carosi/remarkablepocket/DownloadService.java b/src/main/java/nl/carosi/remarkablepocket/DownloadService.java index bae4c24..e1e0be8 100644 --- a/src/main/java/nl/carosi/remarkablepocket/DownloadService.java +++ b/src/main/java/nl/carosi/remarkablepocket/DownloadService.java @@ -28,6 +28,7 @@ final class DownloadService { @PostConstruct void createStorageDir() throws IOException { storageDir = Files.createTempDirectory(null); + LOG.debug("Created temporary storage directory: {}.", storageDir); } @SuppressWarnings("UnstableApiUsage") diff --git a/src/main/java/nl/carosi/remarkablepocket/MetadataProvider.java b/src/main/java/nl/carosi/remarkablepocket/MetadataProvider.java index 75330f9..1e86705 100644 --- a/src/main/java/nl/carosi/remarkablepocket/MetadataProvider.java +++ b/src/main/java/nl/carosi/remarkablepocket/MetadataProvider.java @@ -11,6 +11,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import javax.annotation.PostConstruct; import net.lingala.zip4j.core.ZipFile; import net.lingala.zip4j.exception.ZipException; @@ -18,14 +19,19 @@ import net.lingala.zip4j.io.ZipInputStream; import net.lingala.zip4j.model.FileHeader; import nl.carosi.remarkablepocket.model.DocumentMetadata; import nl.siegmann.epublib.epub.EpubReader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; final class MetadataProvider { - private final Jrmapi rmapi; + private static final Logger LOG = LoggerFactory.getLogger(MetadataProvider.class); + + private final AtomicReference rmapi; private final EpubReader epubReader; private final ObjectMapper objectMapper; private Path workDir; - public MetadataProvider(Jrmapi rmapi, EpubReader epubReader, ObjectMapper objectMapper) { + public MetadataProvider( + AtomicReference rmapi, EpubReader epubReader, ObjectMapper objectMapper) { this.rmapi = rmapi; this.epubReader = epubReader; this.objectMapper = objectMapper; @@ -34,13 +40,15 @@ final class MetadataProvider { @PostConstruct void createWorkDir() throws IOException { workDir = Files.createTempDirectory(null); + LOG.debug("Created temporary working directory: {}.", workDir); } DocumentMetadata getMetadata(Document doc) { + LOG.debug("Getting metadata for document: {}.", doc.getVissibleName()); ZipFile zip; String fileHash; try { - zip = new ZipFile(rmapi.fetchZip(doc, workDir.toString() + File.separator)); + zip = new ZipFile(rmapi.get().fetchZip(doc, workDir.toString() + File.separator)); fileHash = getFileHash(zip); } catch (ZipException e) { throw new RuntimeException(e); diff --git a/src/main/java/nl/carosi/remarkablepocket/RemarkableService.java b/src/main/java/nl/carosi/remarkablepocket/RemarkableService.java index ae2510c..916573d 100644 --- a/src/main/java/nl/carosi/remarkablepocket/RemarkableService.java +++ b/src/main/java/nl/carosi/remarkablepocket/RemarkableService.java @@ -6,6 +6,7 @@ import es.jlarriba.jrmapi.Jrmapi; import es.jlarriba.jrmapi.model.Document; import java.nio.file.Path; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; import javax.annotation.PostConstruct; import nl.carosi.remarkablepocket.model.DocumentMetadata; @@ -15,13 +16,13 @@ import org.springframework.beans.factory.annotation.Value; final class RemarkableService { private static final Logger LOG = LoggerFactory.getLogger(RemarkableService.class); - private final Jrmapi rmapi; + private final AtomicReference rmapi; private final MetadataProvider metadataProvider; private final String rmStorageDir; private String rmStorageDirId = ""; public RemarkableService( - Jrmapi rmapi, + AtomicReference rmapi, MetadataProvider metadataProvider, @Value("${rm.storage-dir}") String rmStorageDir) { if (!rmStorageDir.endsWith("/")) { @@ -40,12 +41,12 @@ final class RemarkableService { String[] dirs = rmStorageDir.substring(1, rmStorageDir.length() - 1).split("/"); for (final String dir : dirs) { rmStorageDirId = - rmapi.listDocs().stream() + rmapi.get().listDocs().stream() .filter(e -> e.getParent().equals(rmStorageDirId)) .filter(e -> e.getVissibleName().equals(dir)) .findFirst() .map(Document::getID) - .orElseGet(() -> rmapi.createDir(dir, rmStorageDirId)); + .orElseGet(() -> rmapi.get().createDir(dir, rmStorageDirId)); } } @@ -73,22 +74,22 @@ final class RemarkableService { } void delete(Document doc) { - rmapi.deleteEntry(doc); + rmapi.get().deleteEntry(doc); } private void logPages(DocumentMetadata meta) { LOG.debug( - "{}: Current page: {}, page count: {}", + "{}: Current page: {}, page count: {}.", meta.doc().getVissibleName(), meta.doc().getCurrentPage() + 1, meta.pageCount()); } private Stream docsStream() { - return rmapi.listDocs().stream().filter(e -> e.getParent().equals(rmStorageDirId)); + return rmapi.get().listDocs().stream().filter(e -> e.getParent().equals(rmStorageDirId)); } private void upload(Path path) { - rmapi.uploadDoc(path.toFile(), rmStorageDirId); + rmapi.get().uploadDoc(path.toFile(), rmStorageDirId); } } diff --git a/src/main/java/nl/carosi/remarkablepocket/SyncApplication.java b/src/main/java/nl/carosi/remarkablepocket/SyncApplication.java index c7c3cf1..66bb3c9 100644 --- a/src/main/java/nl/carosi/remarkablepocket/SyncApplication.java +++ b/src/main/java/nl/carosi/remarkablepocket/SyncApplication.java @@ -1,6 +1,7 @@ package nl.carosi.remarkablepocket; import es.jlarriba.jrmapi.Jrmapi; +import java.util.concurrent.atomic.AtomicReference; import nl.siegmann.epublib.epub.EpubReader; import nl.siegmann.epublib.epub.EpubWriter; import org.springframework.beans.factory.annotation.Value; @@ -22,10 +23,11 @@ import org.springframework.scheduling.annotation.EnableScheduling; PocketService.class, RemarkableService.class, SyncService.class, + TokenRefresher.class }) public class SyncApplication { @Bean - Jrmapi jrmapi(@Value("${rm.device-token}") String deviceToken) { - return new Jrmapi(deviceToken); + AtomicReference jrmapi(@Value("${rm.device-token}") String deviceToken) { + return new AtomicReference<>(new Jrmapi(deviceToken)); } } diff --git a/src/main/java/nl/carosi/remarkablepocket/TokenRefresher.java b/src/main/java/nl/carosi/remarkablepocket/TokenRefresher.java new file mode 100644 index 0000000..7467875 --- /dev/null +++ b/src/main/java/nl/carosi/remarkablepocket/TokenRefresher.java @@ -0,0 +1,73 @@ +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 rmapi; + private final String deviceToken; + private final Base64.Decoder decoder = Base64.getDecoder(); + + public TokenRefresher( + ObjectMapper objectMapper, + TaskScheduler scheduler, + AtomicReference 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) {} +} diff --git a/src/main/java/nl/carosi/remarkablepocket/model/Article.java b/src/main/java/nl/carosi/remarkablepocket/model/Article.java index 0658477..65a9ea3 100644 --- a/src/main/java/nl/carosi/remarkablepocket/model/Article.java +++ b/src/main/java/nl/carosi/remarkablepocket/model/Article.java @@ -7,6 +7,10 @@ public final record Article(String id, String url, String title) { } private static String sanitize(String title) { - return title.replaceAll("/", " ").replaceAll(" +", " ").strip(); + return title + .replaceAll("[‘’\"]", "'") + .replaceAll(":", " -") + .replaceAll("[/?<>*.|\\\\]", " ") + .replaceAll(" +", " ").strip(); } }