diff --git a/documentation/setup.md b/documentation/setup.md index 3d7e1e9..0fb0476 100644 --- a/documentation/setup.md +++ b/documentation/setup.md @@ -142,4 +142,27 @@ Otherwise, if you want to upload files to a specific folder, do the following st However, if desired, you can also create another subfolder there. 3. Navigate to the exact folder where you want the files to be uploaded. 4. Look at the URL bar of your browser and copy the value of the `folder=` parameter - into the `PCLOUD_FOLDER_ID` environment variable. \ No newline at end of file + into the `PCLOUD_FOLDER_ID` environment variable. + +### AWS S3 + +1. Create an AWS account if you don't have one already +2. Create an S3 bucket where you want to store your Notion backups +3. Create an IAM user with programmatic access: + - Go to the [IAM Console](https://console.aws.amazon.com/iam/) + - Click on "Users" and then "Add user" + - Give the user a name (e.g., "notion-backup") + - Select "Access key - Programmatic access" + - Click "Next: Permissions" + - Click "Attach existing policies directly" + - Search for and select `AmazonS3FullAccess` (or create a more restricted policy if desired) + - Complete the user creation process +4. After creating the user, you'll see the access key and secret key. Add these to your `.env` file: + ``` + AWS_ACCESS_KEY=your_access_key + AWS_SECRET_KEY=your_secret_key + AWS_REGION=your_region (e.g., us-east-1) + AWS_BUCKET_NAME=your_bucket_name + ``` + +Note: Make sure your S3 bucket exists before running the application. The application will create a new file in the bucket with the same name as the exported Notion file. \ No newline at end of file diff --git a/pom.xml b/pom.xml index bffc2a0..a7803b5 100644 --- a/pom.xml +++ b/pom.xml @@ -108,6 +108,13 @@ 1.8.1 + + + com.amazonaws + aws-java-sdk-s3 + 1.12.261 + + org.projectlombok diff --git a/src/main/java/com/greydev/notionbackup/NotionBackup.java b/src/main/java/com/greydev/notionbackup/NotionBackup.java index 8f1dd52..5ecb746 100644 --- a/src/main/java/com/greydev/notionbackup/NotionBackup.java +++ b/src/main/java/com/greydev/notionbackup/NotionBackup.java @@ -29,6 +29,9 @@ import io.github.cdimascio.dotenv.Dotenv; import lombok.extern.slf4j.Slf4j; +import com.amazonaws.services.s3.AmazonS3; +import com.greydev.notionbackup.cloudstorage.aws.S3Client; +import com.greydev.notionbackup.cloudstorage.aws.S3ServiceFactory; @Slf4j @@ -51,6 +54,11 @@ public class NotionBackup { private static final String KEY_PCLOUD_API_HOST = "PCLOUD_API_HOST"; private static final String KEY_PCLOUD_FOLDER_ID = "PCLOUD_FOLDER_ID"; + private static final String KEY_AWS_ACCESS_KEY = "AWS_ACCESS_KEY"; + private static final String KEY_AWS_SECRET_KEY = "AWS_SECRET_KEY"; + private static final String KEY_AWS_REGION = "AWS_REGION"; + private static final String KEY_AWS_BUCKET_NAME = "AWS_BUCKET_NAME"; + private static final Dotenv dotenv; static { @@ -110,7 +118,17 @@ public static void main(String[] args) { return null; }); - CompletableFuture.allOf(futureGoogleDrive, futureDropbox, futureNextcloud, futurePCloud).join(); + CompletableFuture futureS3 = CompletableFuture + .runAsync(() -> NotionBackup.startS3Backup(exportedFile)) + .handle((result, ex) -> { + if (ex != null) { + hasErrorOccurred.set(true); + log.error("Exception while S3 upload", ex); + } + return null; + }); + + CompletableFuture.allOf(futureGoogleDrive, futureDropbox, futureNextcloud, futurePCloud, futureS3).join(); if (hasErrorOccurred.get()) { log.error("Not all backups were completed successfully. See the logs above to get more information about the errors."); @@ -245,6 +263,32 @@ public static void startPCloudBackup(File fileToUpload) { } } + public static void startS3Backup(File fileToUpload) { + String accessKey = dotenv.get(KEY_AWS_ACCESS_KEY); + String secretKey = dotenv.get(KEY_AWS_SECRET_KEY); + String region = dotenv.get(KEY_AWS_REGION); + String bucketName = dotenv.get(KEY_AWS_BUCKET_NAME); + + if (StringUtils.isAnyBlank(accessKey, secretKey, region, bucketName)) { + log.info("Skipping S3 upload. {}, {}, {} or {} is blank.", + KEY_AWS_ACCESS_KEY, KEY_AWS_SECRET_KEY, KEY_AWS_REGION, KEY_AWS_BUCKET_NAME); + return; + } + + Optional s3Client = S3ServiceFactory.create(accessKey, secretKey, region); + if (s3Client.isEmpty()) { + log.warn("Could not create S3 client. Skipping S3 upload."); + return; + } + + S3Client client = new S3Client(s3Client.get(), bucketName); + boolean isSuccess = client.upload(fileToUpload); + + if (!isSuccess) { + throw new IllegalStateException("Backup was not successful"); + } + } + private static Optional extractGoogleServiceAccountSecret() { String serviceAccountSecret = dotenv.get(KEY_GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_JSON); String serviceAccountSecretFilePath = dotenv.get(KEY_GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_FILE_PATH); diff --git a/src/main/java/com/greydev/notionbackup/cloudstorage/aws/S3Client.java b/src/main/java/com/greydev/notionbackup/cloudstorage/aws/S3Client.java new file mode 100644 index 0000000..c8914d5 --- /dev/null +++ b/src/main/java/com/greydev/notionbackup/cloudstorage/aws/S3Client.java @@ -0,0 +1,40 @@ +package com.greydev.notionbackup.cloudstorage.aws; + +import java.io.File; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.greydev.notionbackup.cloudstorage.CloudStorageClient; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class S3Client implements CloudStorageClient { + + private final AmazonS3 s3Client; + private final String bucketName; + + public S3Client(AmazonS3 s3Client, String bucketName) { + this.s3Client = s3Client; + this.bucketName = bucketName; + } + + @Override + public boolean upload(File fileToUpload) { + log.info("S3: uploading file '{}' ...", fileToUpload.getName()); + if (!(fileToUpload.exists() && fileToUpload.isFile())) { + log.error("S3: could not find {} in project root directory", fileToUpload.getName()); + return false; + } + + try { + PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, fileToUpload.getName(), fileToUpload); + s3Client.putObject(putObjectRequest); + log.info("S3: successfully uploaded '{}'", fileToUpload.getName()); + return true; + } catch (Exception e) { + log.error("S3: Error uploading file", e); + return false; + } + } +} diff --git a/src/main/java/com/greydev/notionbackup/cloudstorage/aws/S3ServiceFactory.java b/src/main/java/com/greydev/notionbackup/cloudstorage/aws/S3ServiceFactory.java new file mode 100644 index 0000000..87ee2fa --- /dev/null +++ b/src/main/java/com/greydev/notionbackup/cloudstorage/aws/S3ServiceFactory.java @@ -0,0 +1,28 @@ +package com.greydev.notionbackup.cloudstorage.aws; + +import java.util.Optional; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class S3ServiceFactory { + + public static Optional create(String accessKey, String secretKey, String region) { + try { + BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + AmazonS3 s3Client = AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + return Optional.of(s3Client); + } catch (Exception e) { + log.error("Error creating S3 client", e); + return Optional.empty(); + } + } +} diff --git a/src/test/java/com/greydev/notionbackup/cloudstorage/aws/S3ClientTest.java b/src/test/java/com/greydev/notionbackup/cloudstorage/aws/S3ClientTest.java new file mode 100644 index 0000000..2f95240 --- /dev/null +++ b/src/test/java/com/greydev/notionbackup/cloudstorage/aws/S3ClientTest.java @@ -0,0 +1,72 @@ +package com.greydev.notionbackup.cloudstorage.aws; + +import java.io.File; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.PutObjectRequest; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class S3ClientTest { + + @Mock + private AmazonS3 s3Client; + + @Test + void testUpload() { + // given + File fileToUpload = new File("src/test/resources/testFileToUpload.txt"); + String bucketName = "test-bucket"; + S3Client client = new S3Client(s3Client, bucketName); + + // when + boolean result = client.upload(fileToUpload); + + // then + assertTrue(result); + verify(s3Client).putObject(any(PutObjectRequest.class)); + } + + @Test + void testUpload_Exception() { + // given + File fileToUpload = new File("src/test/resources/testFileToUpload.txt"); + String bucketName = "test-bucket"; + S3Client client = new S3Client(s3Client, bucketName); + + doThrow(new RuntimeException("S3 error")).when(s3Client).putObject(any(PutObjectRequest.class)); + + // when + boolean result = client.upload(fileToUpload); + + // then + assertFalse(result); + verify(s3Client).putObject(any(PutObjectRequest.class)); + } + + @Test + void testUpload_invalidFile() { + // given + File fileToUpload = new File("thisFileDoesNotExist.txt"); + String bucketName = "test-bucket"; + S3Client client = new S3Client(s3Client, bucketName); + + // when + boolean result = client.upload(fileToUpload); + + // then + assertFalse(result); + verifyNoInteractions(s3Client); + } +} \ No newline at end of file diff --git a/src/test/java/com/greydev/notionbackup/cloudstorage/aws/S3ServiceFactoryTest.java b/src/test/java/com/greydev/notionbackup/cloudstorage/aws/S3ServiceFactoryTest.java new file mode 100644 index 0000000..27ec53c --- /dev/null +++ b/src/test/java/com/greydev/notionbackup/cloudstorage/aws/S3ServiceFactoryTest.java @@ -0,0 +1,36 @@ +package com.greydev.notionbackup.cloudstorage.aws; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class S3ServiceFactoryTest { + + @Test + void testCreate_ValidCredentials() { + // given + String accessKey = "test-access-key"; + String secretKey = "test-secret-key"; + String region = "us-east-1"; + + // when + var result = S3ServiceFactory.create(accessKey, secretKey, region); + + // then + assertTrue(result.isPresent()); + } + + @Test + void testCreate_InvalidCredentials() { + // given + String accessKey = null; + String secretKey = null; + String region = "invalid-region"; + + // when + var result = S3ServiceFactory.create(accessKey, secretKey, region); + + // then + assertFalse(result.isPresent()); + } +} \ No newline at end of file