├── .gitattributes ├── .github └── workflows │ ├── apple.yaml │ ├── infra.yaml │ └── web.yaml ├── .gitignore ├── .prettierrc.cjs ├── LICENSE.md ├── README.md ├── SEQUENCE.md ├── apple ├── README.md ├── TRssReader.xcodeproj │ ├── project.pbxproj │ └── xcshareddata │ │ └── xcschemes │ │ ├── TRssReader.xcscheme │ │ ├── TRssReaderTests.xcscheme │ │ └── TRssReaderUITests.xcscheme ├── TRssReader │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── app-icon-ios-1024.png │ │ │ ├── app-icon-ios-114.png │ │ │ ├── app-icon-ios-120.png │ │ │ ├── app-icon-ios-128.png │ │ │ ├── app-icon-ios-136.png │ │ │ ├── app-icon-ios-152.png │ │ │ ├── app-icon-ios-167.png │ │ │ ├── app-icon-ios-180.png │ │ │ ├── app-icon-ios-192.png │ │ │ ├── app-icon-ios-40.png │ │ │ ├── app-icon-ios-58.png │ │ │ ├── app-icon-ios-60.png │ │ │ ├── app-icon-ios-76.png │ │ │ ├── app-icon-ios-80.png │ │ │ ├── app-icon-ios-87.png │ │ │ ├── app-icon-macos-1024.png │ │ │ ├── app-icon-macos-128.png │ │ │ ├── app-icon-macos-16.png │ │ │ ├── app-icon-macos-256.png │ │ │ ├── app-icon-macos-32.png │ │ │ ├── app-icon-macos-512.png │ │ │ └── app-icon-macos-64.png │ │ └── Contents.json │ ├── Constants.swift │ ├── EnvExample.xcconfig │ ├── Extensions │ │ ├── Date.swift │ │ └── URLResponse.swift │ ├── Info.plist │ ├── Models │ │ ├── Entry.swift │ │ ├── Feed.swift │ │ └── Token.swift │ ├── Preview Content │ │ ├── Preview Assets.xcassets │ │ │ └── Contents.json │ │ └── StatefulPreview.swift │ ├── Services │ │ ├── AuthorizedService.swift │ │ ├── EntriesService.swift │ │ ├── FeedsService.swift │ │ ├── LastAccessService.swift │ │ ├── LoginService.swift │ │ └── ServiceError.swift │ ├── Stores │ │ ├── FeedsStore.swift │ │ ├── ModalStore.swift │ │ ├── SelectedFeedStore.swift │ │ └── TokenStore.swift │ ├── TRssReader.entitlements │ ├── TRssReaderApp.swift │ ├── Utils │ │ ├── Env.swift │ │ ├── KeychainKey.swift │ │ └── PreviewError.swift │ └── Views │ │ ├── Details │ │ ├── DetailsItemView.swift │ │ └── DetailsView.swift │ │ ├── List │ │ ├── ListActionsView.swift │ │ ├── ListItemView.swift │ │ └── ListView.swift │ │ ├── Modal │ │ └── UpsertFeedModal.swift │ │ ├── Screens │ │ ├── ListDetailsView.swift │ │ ├── LoginView.swift │ │ └── LoginViewModel.swift │ │ └── Shared │ │ ├── ActionButtonView.swift │ │ ├── ResultMessageView.swift │ │ └── ValidationMessageView.swift └── TRssReaderTests │ ├── Services │ └── AuthorizedServiceTests.swift │ └── XCTestCase+XCTAssertErrorType.swift ├── design ├── README.md └── t-rss-reader-design.webp ├── infra ├── .terraform.lock.hcl ├── Makefile ├── README.md ├── entries-api-gateway.tf ├── entries-handler │ ├── Makefile │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── lib │ │ │ ├── get-atom-entries.ts │ │ │ ├── get-feed-format.ts │ │ │ ├── get-rss-entries.ts │ │ │ ├── get-unix-time.ts │ │ │ ├── last-access-table.ts │ │ │ ├── parse-feed.ts │ │ │ ├── sort-entries.ts │ │ │ ├── types.ts │ │ │ └── verify-token.ts │ ├── test │ │ ├── fixture │ │ │ ├── atom.xml │ │ │ ├── create-event.ts │ │ │ └── rss.xml │ │ ├── index.test.ts │ │ └── lib │ │ │ ├── get-atom-entries.test.ts │ │ │ ├── get-feed-format.test.ts │ │ │ ├── get-rss-entries.test.ts │ │ │ ├── parse-feed.test.ts │ │ │ └── sort-entries.test.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── entries-lambda.tf ├── feeds-api-gateway.tf ├── feeds-dynamo-db.tf ├── feeds-handler │ ├── Makefile │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── lib │ │ │ ├── feeds-table.ts │ │ │ └── verify-token.ts │ ├── test │ │ ├── fixture │ │ │ └── create-event.ts │ │ └── index.test.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── feeds-lambda.tf ├── last-access-api-gateway.tf ├── last-access-dynamo-db.tf ├── last-access-handler │ ├── Makefile │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── lib │ │ │ ├── last-access-table.ts │ │ │ └── verify-token.ts │ ├── test │ │ ├── fixture │ │ │ └── create-event.ts │ │ └── index.test.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── last-access-lambda.tf ├── login-api-gateway.tf ├── login-handler │ ├── Makefile │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── lib │ │ │ ├── get-timestamp.ts │ │ │ └── sign-token.ts │ ├── test │ │ ├── fixture │ │ │ └── create-event.ts │ │ └── index.test.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── login-lambda.tf ├── main.tf ├── outputs.tf ├── terraform-example.tfvars └── variables.tf └── web ├── .env.example ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.cjs ├── README.md ├── firebase.json ├── package-lock.json ├── package.json ├── src ├── app.css ├── app.html ├── lib │ ├── components │ │ ├── Button.svelte │ │ ├── DetailsItem.svelte │ │ ├── FormResultMessage.svelte │ │ ├── FormValidationMessage.svelte │ │ ├── Header.svelte │ │ ├── ListItem.svelte │ │ ├── Loading.svelte │ │ └── Modal.svelte │ ├── constants.ts │ ├── services │ │ ├── authorized-service.ts │ │ ├── entries-service.ts │ │ ├── feeds-service.ts │ │ ├── last-access-service.ts │ │ └── login-service.ts │ ├── stores │ │ ├── feeds-store.ts │ │ ├── modal-store.ts │ │ ├── selected-feed-store.ts │ │ └── token-store.ts │ ├── types.ts │ ├── utils │ │ ├── get-access-token-with-check.ts │ │ ├── get-access-token.ts │ │ ├── get-random-number.ts │ │ ├── handle-jump-keyboard-events.ts │ │ ├── sort-feeds.ts │ │ └── token-maybe-valid.ts │ ├── widgets │ │ ├── Details.svelte │ │ ├── List.svelte │ │ ├── LoginForm.svelte │ │ └── UpsertFeedModal.svelte │ └── workers │ │ └── background-request-entries.ts └── routes │ ├── +layout.svelte │ ├── +layout.ts │ ├── +page.svelte │ └── login │ └── +page.svelte ├── static ├── favicon-16.jpg ├── favicon-180.jpg ├── favicon-32.jpg ├── favicon-48.jpg ├── favicon-512.jpg ├── favicon.ico ├── manifest.json └── open-graph.jpg ├── svelte.config.js ├── test └── lib │ ├── services │ ├── entries-service.test.ts │ ├── feeds-service.test.ts │ ├── last-access-service.test.ts │ └── login-service.test.ts │ └── utils │ ├── get-access-token-with-check.test.ts │ ├── get-random-number.test.ts │ ├── sort-feeds.test.ts │ └── token-maybe-valid.test.ts ├── tsconfig.json └── vite.config.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.png binary 3 | *.jpg binary 4 | *.jpeg binaryg 5 | *.gif binary 6 | *.ico binary 7 | *.mov binary 8 | *.mp4 binary 9 | *.mp3 binary 10 | *.ttf binary 11 | *.otf binary 12 | *.eot binary 13 | *.woff binary 14 | *.woff2 binary 15 | *.pdf binary 16 | *.tar.gz binary 17 | *.zip binary 18 | *.7z binary -------------------------------------------------------------------------------- /.github/workflows/apple.yaml: -------------------------------------------------------------------------------- 1 | name: apple 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | 7 | jobs: 8 | test: 9 | name: test 10 | runs-on: macos-latest 11 | 12 | steps: 13 | - name: Checkout repo 14 | uses: actions/checkout@v3 15 | 16 | - name: Install dependencies 17 | run: brew install xcbeautify 18 | 19 | - name: Run apple client tests 20 | working-directory: ./apple 21 | run: >- 22 | set -o pipefail && xcodebuild test \ 23 | -project TRssReader.xcodeproj \ 24 | -scheme TRssReaderTests \ 25 | -configuration Test \ 26 | -sdk iphonesimulator \ 27 | -destination 'platform=iOS Simulator,name=iPhone 14' | xcbeautify 28 | -------------------------------------------------------------------------------- /.github/workflows/infra.yaml: -------------------------------------------------------------------------------- 1 | name: infra 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | 7 | jobs: 8 | test: 9 | name: test 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repo 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up node 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: 18 20 | 21 | - name: Install infra handler dependencies 22 | working-directory: ./infra 23 | run: make install 24 | 25 | - name: Run infra handler tests 26 | working-directory: ./infra 27 | run: make test 28 | -------------------------------------------------------------------------------- /.github/workflows/web.yaml: -------------------------------------------------------------------------------- 1 | name: web 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | 7 | jobs: 8 | test: 9 | name: test 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repo 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up node 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: 18 20 | 21 | - name: Install web client dependencies 22 | working-directory: ./web 23 | run: npm install 24 | 25 | - name: Run web client tests 26 | working-directory: ./web 27 | run: npm run test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # infra 2 | .terraform/ 3 | *.out 4 | *.tfstate 5 | *.tfstate.backup 6 | *.tfstate.lock.info 7 | *.zip 8 | terraform.tfvars 9 | 10 | # web 11 | node_modules/ 12 | dist/ 13 | .firebase/ 14 | 15 | # macOS 16 | .DS_Store 17 | 18 | # Xcode per-user config 19 | *.mode1 20 | *.mode1v3 21 | *.mode2v3 22 | *.perspective 23 | *.perspectivev3 24 | *.pbxuser 25 | *.xcworkspace 26 | xcuserdata 27 | 28 | # Xcode build products 29 | build/ 30 | *.o 31 | *.LinkFileList 32 | *.hmap 33 | 34 | # Xcode env vars 35 | Env.xcconfig -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: true, 6 | singleQuote: true, 7 | quoteProps: 'as-needed', 8 | jsxSingleQuote: false, 9 | trailingComma: 'none', 10 | bracketSpacing: true, 11 | jsxBracketSameLine: false, 12 | arrowParens: 'always' 13 | }; 14 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ty Hopp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # t-rss-reader 2 | 3 | A personal [RSS](https://en.wikipedia.org/wiki/RSS) reader you can self-host at no cost. 4 | 5 | ## Project 6 | 7 | The project is arranged in these parts: 8 | 9 | - Backend [infrastructure](./infra/README.md) built with Terraform and AWS 10 | - Frontend clients that run natively on each platform: 11 | 12 | - [Web](./web/README.md) 13 | - [iOS, iPadOS, and macOS](./apple/README.md) (WIP) 14 | - Android (TBD) 15 | 16 | - A [Figma design file](./design/README.md) that you can view or duplicate 17 | 18 | ## Usage 19 | 20 | 1. Set up the backend [infrastructure](./infra/README.md) so there are endpoints your clients can call. 21 | 2. Set up one or more of the clients following their README files. 22 | 3. Run clients locally, host, or install them wherever you want. 23 | 24 | ## Reference 25 | 26 | - [Sequence diagrams](./SEQUENCE.md) 27 | -------------------------------------------------------------------------------- /SEQUENCE.md: -------------------------------------------------------------------------------- 1 | # Sequence 2 | 3 | Sequence diagrams that describe the patterns every client implements. 4 | 5 | ## App start sequence 6 | 7 | ```mermaid 8 | sequenceDiagram 9 | autonumber 10 | 11 | actor Client 12 | participant LoginService 13 | participant FeedsService 14 | participant EntriesService 15 | participant LastAccessService 16 | 17 | Client->>LoginService: POST /login 18 | LoginService-->>Client: Token 19 | 20 | Client->>FeedsService: GET /feeds 21 | FeedsService-->>Client: Feed[] 22 | par for each feed url 23 | Client->>EntriesService: GET /entries?url=[Feed.url] 24 | EntriesService-->>Client: Entry[] 25 | end 26 | 27 | Client->>LastAccessService: PUT /last-access 28 | ``` 29 | 30 | ## Upsert feed sequence 31 | 32 | ```mermaid 33 | sequenceDiagram 34 | autonumber 35 | 36 | actor Client 37 | participant FeedsService 38 | 39 | Client->>FeedsService: PUT /feeds 40 | FeedsService-->>Client: Feed 41 | ``` 42 | 43 | ## Delete feed sequence 44 | 45 | ```mermaid 46 | sequenceDiagram 47 | autonumber 48 | 49 | actor Client 50 | participant FeedsService 51 | 52 | Client->>FeedsService: DELETE /feeds 53 | ``` 54 | 55 | ## Type reference 56 | 57 | ```ts 58 | interface Token { 59 | accessToken: string; 60 | tokenType: string; 61 | expiresIn: number; 62 | } 63 | 64 | interface Feed { 65 | name: string; 66 | url: string; 67 | hasNew?: boolean; 68 | } 69 | 70 | interface Entry { 71 | url?: string; 72 | title?: string; 73 | published?: string; 74 | isNew?: boolean; 75 | } 76 | ``` 77 | -------------------------------------------------------------------------------- /apple/README.md: -------------------------------------------------------------------------------- 1 | # t-rss-reader iOS, iPadOS and macOS client (WIP) 2 | 3 | [Multiplatform client](https://developer.apple.com/documentation/xcode/configuring-a-multiplatform-app-target) targeting three Apple platforms: iOS, iPadOS and macOS. 4 | 5 | ## Prerequisites 6 | 7 | - Backend [infrastructure](../../infra/README.md) is created and you have the invoke URLs in hand 8 | - A macOS system with [Xcode](https://developer.apple.com/xcode/) installed 9 | 10 | ## Setup 11 | 12 | 1. Create a `Env.xcconfig` file from the `EnvExample.xcconfig` file 13 | 2. Add the env vars 14 | 15 | ## Usage 16 | 17 | - Open `TRssReader.xcodeproj` in Xcode 18 | - Make sure the `TRssReader` scheme is selected in the toolbar 19 | - Select a device destination (e.g. "My Mac", "iPhone 14") 20 | - Run the project 21 | 22 | ## Deployment 23 | 24 | TBD 25 | -------------------------------------------------------------------------------- /apple/TRssReader.xcodeproj/xcshareddata/xcschemes/TRssReader.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 59 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /apple/TRssReader.xcodeproj/xcshareddata/xcschemes/TRssReaderTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 16 | 19 | 25 | 26 | 27 | 28 | 29 | 39 | 41 | 47 | 48 | 49 | 50 | 56 | 57 | 59 | 60 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /apple/TRssReader.xcodeproj/xcshareddata/xcschemes/TRssReaderUITests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 16 | 19 | 25 | 26 | 27 | 28 | 29 | 39 | 40 | 46 | 47 | 49 | 50 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-1024.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-114.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-120.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-128.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-136.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-136.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-152.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-167.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-180.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-192.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-40.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-58.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-60.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-76.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-80.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-ios-87.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-macos-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-macos-1024.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-macos-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-macos-128.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-macos-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-macos-16.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-macos-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-macos-256.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-macos-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-macos-32.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-macos-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-macos-512.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-macos-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/apple/TRssReader/Assets.xcassets/AppIcon.appiconset/app-icon-macos-64.png -------------------------------------------------------------------------------- /apple/TRssReader/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /apple/TRssReader/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 3/4/23. 6 | // 7 | 8 | import Foundation 9 | 10 | let ACCESS_TOKEN_KEY = "t-rss-reader-access-token" 11 | -------------------------------------------------------------------------------- /apple/TRssReader/EnvExample.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // EnvExample.xcconfig 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 14/4/23. 6 | // 7 | 8 | // Configuration settings file format documentation can be found at: 9 | // https://help.apple.com/xcode/#/dev745c5c974 10 | 11 | // Separate sequential forward slashes with $() (e.g. https:// should be https:/$()/) 12 | // See https://stackoverflow.com/a/36297483/13530088 13 | 14 | // Values should not have quotes around them. 15 | // 16 | // Incorrect: 17 | // FEEDS_API = "https:/$()/example.com/api" 18 | // 19 | // Correct: 20 | // FEEDS_API = https:/$()/example.com/api 21 | 22 | FEEDS_API = 23 | LOGIN_API = 24 | ENTRIES_API = 25 | LAST_ACCESS_API = 26 | -------------------------------------------------------------------------------- /apple/TRssReader/Extensions/Date.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+nowInt.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 13/4/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Date { 11 | var nowInMs: Int { 12 | return Int((Date().timeIntervalSince1970) * 1000) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apple/TRssReader/Extensions/URLResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLResponse+statusCode.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 7/4/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension URLResponse { 11 | func statusCode() -> Int { 12 | if let code = (self as? HTTPURLResponse)?.statusCode { 13 | return code 14 | } else { 15 | return 500 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apple/TRssReader/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ENTRIES_API 6 | $(ENTRIES_API) 7 | FEEDS_API 8 | $(FEEDS_API) 9 | LAST_ACCESS_API 10 | $(LAST_ACCESS_API) 11 | LOGIN_API 12 | $(LOGIN_API) 13 | Scene Configuration 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /apple/TRssReader/Models/Entry.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Entry.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 18/4/23. 6 | // 7 | 8 | struct Entry: Codable { 9 | var url: String 10 | var title: String 11 | var published: String 12 | var isNew: Bool 13 | 14 | enum CodingKeys: String, CodingKey { 15 | case url, title, published, isNew 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apple/TRssReader/Models/Feed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Feed.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 3/4/23. 6 | // 7 | 8 | struct Feed: Codable { 9 | var name: String 10 | var url: String 11 | var createdAt: Int 12 | 13 | enum CodingKeys: String, CodingKey { 14 | case name, url, createdAt 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apple/TRssReader/Models/Token.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Token.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 3/4/23. 6 | // 7 | 8 | struct Token: Codable { 9 | var accessToken: String 10 | var tokenType: String 11 | var expiresIn: Int 12 | 13 | enum CodingKeys: String, CodingKey { 14 | case accessToken, tokenType, expiresIn 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apple/TRssReader/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /apple/TRssReader/Preview Content/StatefulPreview.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatefulPreview.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 14/4/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StatefulPreview: View { 11 | @State var stateVariable: StateVariable 12 | 13 | var content: (Binding) -> Content 14 | 15 | var body: some View { 16 | content($stateVariable) 17 | } 18 | 19 | init(stateVariable: StateVariable, content: @escaping (Binding) -> Content) { 20 | self._stateVariable = State(wrappedValue: stateVariable) 21 | self.content = content 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apple/TRssReader/Services/AuthorizedService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorizedService.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 3/4/23. 6 | // 7 | 8 | import Foundation 9 | import Keychain 10 | 11 | class AuthorizedService { 12 | let defaultHeaders: [String: String] = ["Content-Type": "application/json"] 13 | let tokenStore: TokenStorable 14 | 15 | init(tokenStore: TokenStorable = TokenStore.shared) { 16 | self.tokenStore = tokenStore 17 | } 18 | 19 | func url(api: String) throws -> URL { 20 | guard let url = URL(string: api) else { 21 | throw ServiceError.url 22 | } 23 | 24 | return url 25 | } 26 | 27 | func headers() throws -> [String: String] { 28 | var headersInstance = defaultHeaders 29 | 30 | if let token = tokenStore.store.token { 31 | headersInstance.merge(["Authorization": token.accessToken]) { (current, _) in current } 32 | } else { 33 | throw ServiceError.accessToken 34 | } 35 | 36 | return headersInstance 37 | } 38 | 39 | func request(api: String, queryItems: [URLQueryItem] = []) throws -> URLRequest { 40 | var url = try self.url(api: api) 41 | 42 | if !queryItems.isEmpty { 43 | url.append(queryItems: queryItems) 44 | } 45 | 46 | var request = URLRequest(url: url) 47 | 48 | do { 49 | for (key, value) in try self.headers() { 50 | request.setValue(value, forHTTPHeaderField: key) 51 | } 52 | } catch { 53 | throw ServiceError.headers 54 | } 55 | 56 | return request 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /apple/TRssReader/Services/EntriesService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EntriesService.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 7/4/23. 6 | // 7 | 8 | import Foundation 9 | 10 | class EntriesService: AuthorizedService { 11 | @Sendable func getEntries(url: String) async throws -> (Data, URLResponse) { 12 | var request = try self.request(api: Env.ENTRIES_API, queryItems: [URLQueryItem(name: "url", value: url)]) 13 | 14 | request.httpMethod = "GET" 15 | 16 | return try await URLSession.shared.data(for: request) 17 | } 18 | } 19 | 20 | #if DEBUG 21 | class MockEntriesService: EntriesService { 22 | @Sendable override func getEntries(url: String) async throws -> (Data, URLResponse) { 23 | let sampleEntries: [Entry] = [ 24 | Entry(url: "https://example.com/entry1", title: "Entry 1", published: Date().ISO8601Format(), isNew: false), 25 | Entry(url: "https://example.com/entry2", title: "Entry 2", published: Date().ISO8601Format(), isNew: true), 26 | Entry(url: "https://example.com/entry3", title: "Entry 3", published: Date().ISO8601Format(), isNew: false) 27 | ] 28 | 29 | let data = try JSONEncoder().encode(sampleEntries) 30 | 31 | let response = HTTPURLResponse( 32 | url: URL(string: url)!, 33 | statusCode: 200, 34 | httpVersion: nil, 35 | headerFields: nil 36 | )! 37 | 38 | return (data, response) 39 | } 40 | } 41 | #endif 42 | -------------------------------------------------------------------------------- /apple/TRssReader/Services/FeedsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedsService.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 3/4/23. 6 | // 7 | 8 | import Foundation 9 | 10 | class FeedsService: AuthorizedService { 11 | @Sendable func deleteFeed(url: String) async throws -> (Data, URLResponse) { 12 | var request = try self.request(api: Env.FEEDS_API) 13 | 14 | request.httpMethod = "DELETE" 15 | 16 | do { 17 | request.httpBody = try JSONEncoder().encode(["url": url]) 18 | } catch { 19 | throw ServiceError.encodeBody 20 | } 21 | 22 | return try await URLSession.shared.data(for: request) 23 | } 24 | 25 | @Sendable func getFeeds() async throws -> (Data, URLResponse) { 26 | var request = try self.request(api: Env.FEEDS_API) 27 | 28 | request.httpMethod = "GET" 29 | 30 | return try await URLSession.shared.data(for: request) 31 | } 32 | 33 | @Sendable func putFeed(url: String, name: String) async throws -> (Data, URLResponse) { 34 | var request = try self.request(api: Env.FEEDS_API) 35 | 36 | request.httpMethod = "PUT" 37 | 38 | do { 39 | request.httpBody = try JSONEncoder().encode(["url": url, "name": name]) 40 | } catch { 41 | throw ServiceError.encodeBody 42 | } 43 | 44 | return try await URLSession.shared.data(for: request) 45 | } 46 | } 47 | 48 | #if DEBUG 49 | class MockFeedsService: FeedsService { 50 | @Sendable override func deleteFeed(url: String) async throws -> (Data, URLResponse) { 51 | let data = try JSONEncoder().encode(["success": true]) 52 | let response = HTTPURLResponse(url: URL(string: Env.FEEDS_API)!, statusCode: 200, httpVersion: nil, headerFields: nil)! 53 | return (data, response) 54 | } 55 | 56 | @Sendable override func getFeeds() async throws -> (Data, URLResponse) { 57 | let sampleFeeds = [ 58 | Feed(name: "Feed 1", url: "https://example.com/feed1", createdAt: Date().nowInMs), 59 | Feed(name: "Feed 2", url: "https://example.com/feed2", createdAt: Date().nowInMs) 60 | ] 61 | 62 | let data = try JSONEncoder().encode(sampleFeeds) 63 | let response = HTTPURLResponse(url: URL(string: Env.FEEDS_API)!, statusCode: 200, httpVersion: nil, headerFields: nil)! 64 | return (data, response) 65 | } 66 | 67 | @Sendable override func putFeed(url: String, name: String) async throws -> (Data, URLResponse) { 68 | let feed = Feed(name: name, url: url, createdAt: Date().nowInMs) 69 | let data = try JSONEncoder().encode(feed) 70 | let response = HTTPURLResponse(url: URL(string: Env.FEEDS_API)!, statusCode: 200, httpVersion: nil, headerFields: nil)! 71 | return (data, response) 72 | } 73 | } 74 | #endif 75 | -------------------------------------------------------------------------------- /apple/TRssReader/Services/LastAccessService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LastAccessService.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 7/4/23. 6 | // 7 | 8 | import Foundation 9 | 10 | class LastAccessService: AuthorizedService { 11 | @Sendable func putLastAccess() async throws -> (Data, URLResponse) { 12 | var request = try self.request(api: Env.LAST_ACCESS_API) 13 | 14 | request.httpMethod = "PUT" 15 | 16 | return try await URLSession.shared.data(for: request) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apple/TRssReader/Services/LoginService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginService.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 6/4/23. 6 | // 7 | 8 | import Foundation 9 | 10 | class LoginService { 11 | @Sendable func login(password: String) async throws -> (Data, URLResponse) { 12 | guard let url = URL(string: Env.LOGIN_API) else { 13 | throw ServiceError.url 14 | } 15 | 16 | var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) 17 | 18 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 19 | request.setValue(password, forHTTPHeaderField: "Authorization") 20 | request.httpMethod = "POST" 21 | 22 | return try await URLSession.shared.data(for: request) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apple/TRssReader/Services/ServiceError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceError.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 7/4/23. 6 | // 7 | 8 | enum ServiceError: Error { 9 | case url 10 | case accessToken 11 | case headers 12 | case encodeBody 13 | } 14 | -------------------------------------------------------------------------------- /apple/TRssReader/Stores/FeedsStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedsStore.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 17/4/23. 6 | // 7 | 8 | import Foundation 9 | 10 | final class FeedsStore: ObservableObject { 11 | @Published var feeds: [Feed]? 12 | 13 | func getFeedsUrlIndex() -> [String: Feed] { 14 | var feedsByUrl = [String: Feed]() 15 | 16 | if let feeds = feeds { 17 | for feed in feeds { 18 | feedsByUrl[feed.url] = feed 19 | } 20 | } 21 | 22 | return feedsByUrl 23 | } 24 | 25 | func getFeedByUrl(url: String) -> Feed? { 26 | let feedsUrlIndex = getFeedsUrlIndex() 27 | return feedsUrlIndex[url] 28 | } 29 | 30 | func deleteFeedByUrl(url: String) { 31 | feeds = feeds?.filter { $0.url != url } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apple/TRssReader/Stores/ModalStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModalStore.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 21/4/23. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | final class ModalStore: ObservableObject { 12 | enum Mode { 13 | case add 14 | case edit 15 | } 16 | 17 | @Published var isOpen: Bool = false 18 | @Published var mode: Mode = .add 19 | @Published var name: String = "" 20 | @Published var url: String = "" 21 | 22 | func open(mode: Mode = .add, name: String = "", url: String = "") { 23 | self.isOpen = true 24 | self.mode = mode 25 | self.name = name 26 | self.url = url 27 | } 28 | 29 | func close() { 30 | self.isOpen = false 31 | self.mode = .add 32 | self.name = "" 33 | self.url = "" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /apple/TRssReader/Stores/SelectedFeedStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SelectedFeedStore.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 20/4/23. 6 | // 7 | 8 | import Foundation 9 | 10 | final class SelectedFeedStore: ObservableObject { 11 | @Published var feedUrl: String? 12 | } 13 | -------------------------------------------------------------------------------- /apple/TRssReader/Stores/TokenStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TokenStore.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 13/4/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct TokenModelStore { 11 | var maybeValid: Bool = false 12 | var token: Token? 13 | } 14 | 15 | protocol TokenStorable { 16 | var store: TokenModelStore { get set } 17 | 18 | func getTokenFromKeychain() -> Token? 19 | 20 | func setToken(token: Token) throws 21 | } 22 | 23 | final class TokenStore: TokenStorable, ObservableObject { 24 | static let shared = TokenStore() 25 | 26 | @Published var store = TokenModelStore() 27 | 28 | private var date: Date = Date() 29 | 30 | private init() { 31 | #if DEBUG 32 | KeychainKey.token = nil 33 | #endif 34 | 35 | if let tokenInKeychain = getTokenFromKeychain() { 36 | if (!tokenInKeychain.accessToken.isEmpty && tokenInKeychain.expiresIn > date.nowInMs) { 37 | store = TokenModelStore(maybeValid: true, token: tokenInKeychain) 38 | } 39 | } 40 | } 41 | 42 | enum TokenModelError: Error { 43 | case jsonEncodeFailure 44 | } 45 | 46 | func getTokenFromKeychain() -> Token? { 47 | guard let tokenString = KeychainKey.token?.utf8 else { 48 | return nil 49 | } 50 | 51 | return try? JSONDecoder().decode(Token.self, from: Data(tokenString)) 52 | } 53 | 54 | func setToken(token: Token) throws { 55 | guard let jsonData = try? JSONEncoder().encode(token) else { 56 | throw TokenModelError.jsonEncodeFailure 57 | } 58 | 59 | let jsonString = String(data: jsonData, encoding: .utf8) 60 | 61 | KeychainKey.token = jsonString 62 | 63 | store = TokenModelStore(maybeValid: true, token: token) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /apple/TRssReader/TRssReader.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /apple/TRssReader/TRssReaderApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TRssReaderApp.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 29/3/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct TRssReaderApp: App { 12 | @StateObject private var tokenStore = TokenStore.shared 13 | 14 | var body: some Scene { 15 | WindowGroup { 16 | TRssReaderContentView() 17 | .environmentObject(tokenStore) 18 | } 19 | } 20 | } 21 | 22 | struct TRssReaderContentView: View { 23 | @EnvironmentObject var tokenStore: TokenStore 24 | 25 | var body: some View { 26 | Group { 27 | if tokenStore.store.maybeValid { 28 | ListDetailsView() 29 | } else { 30 | LoginView(viewModel: LoginViewModel(tokenStore: tokenStore)) 31 | } 32 | } 33 | } 34 | } 35 | 36 | #Preview("Unauthenticated") { 37 | let tokenStore = TokenStore.shared 38 | tokenStore.store.maybeValid = false 39 | return TRssReaderContentView() 40 | .environmentObject(tokenStore) 41 | } 42 | 43 | #Preview("Authenticated") { 44 | let tokenStore = TokenStore.shared 45 | tokenStore.store.maybeValid = true 46 | return TRssReaderContentView() 47 | .environmentObject(tokenStore) 48 | } 49 | -------------------------------------------------------------------------------- /apple/TRssReader/Utils/Env.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Env.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 3/4/23. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Env { 11 | private static let infoDictionary: [String: Any] = { 12 | guard let dict = Bundle.main.infoDictionary else { 13 | fatalError("Plist file not found") 14 | } 15 | return dict 16 | }() 17 | 18 | static let FEEDS_API: String = { 19 | guard let apiKey = Env.infoDictionary["FEEDS_API"] as? String else { 20 | fatalError("FEEDS_API key not set in plist") 21 | } 22 | return apiKey 23 | }() 24 | 25 | static let LOGIN_API: String = { 26 | guard let apiKey = Env.infoDictionary["LOGIN_API"] as? String else { 27 | fatalError("LOGIN_API key not set in plist") 28 | } 29 | return apiKey 30 | }() 31 | 32 | static let ENTRIES_API: String = { 33 | guard let apiKey = Env.infoDictionary["ENTRIES_API"] as? String else { 34 | fatalError("ENTRIES_API key not set in plist") 35 | } 36 | return apiKey 37 | }() 38 | 39 | static let LAST_ACCESS_API: String = { 40 | guard let apiKey = Env.infoDictionary["LAST_ACCESS_API"] as? String else { 41 | fatalError("LAST_ACCESS_API key not set in plist") 42 | } 43 | return apiKey 44 | }() 45 | } 46 | -------------------------------------------------------------------------------- /apple/TRssReader/Utils/KeychainKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeychainKey.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 4/4/23. 6 | // 7 | 8 | import Foundation 9 | import Keychain 10 | 11 | enum KeychainKey { 12 | @Keychain(service: "t-rss-reader-access-token", account: "t-rss-reader") static public var token: String? 13 | } 14 | -------------------------------------------------------------------------------- /apple/TRssReader/Utils/PreviewError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewError.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 4/12/24. 6 | // 7 | 8 | 9 | enum PreviewError: Error { 10 | case unknown 11 | } 12 | -------------------------------------------------------------------------------- /apple/TRssReader/Views/Details/DetailsItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailsItemView.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 19/4/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DetailsItemView: View { 11 | let entry: Entry 12 | 13 | var body: some View { 14 | Link(destination: URL(string: entry.url)!) { 15 | VStack(alignment: .leading) { 16 | Text(entry.title) 17 | .font(.headline) 18 | .padding([.bottom], 1) 19 | 20 | // TODO: Format date 21 | Text(entry.published) 22 | .font(.subheadline) 23 | .opacity(0.75) 24 | } 25 | } 26 | .foregroundColor(.primary) 27 | .padding(4) 28 | } 29 | } 30 | 31 | #Preview { 32 | DetailsItemView(entry: Entry(url: "https://a.com", title: "An entry", published: "2023-01-11T01:00:00.000Z", isNew: false)) 33 | } 34 | -------------------------------------------------------------------------------- /apple/TRssReader/Views/Details/DetailsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailsView.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 13/4/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct DetailsView: View { 12 | @EnvironmentObject var feedsStore: FeedsStore 13 | @EnvironmentObject var selectedFeedStore: SelectedFeedStore 14 | 15 | @State private var task: Task? 16 | @State private var result: Result<[Entry], Error>? 17 | 18 | var entriesService: EntriesService 19 | 20 | enum DetailsViewError: Error { 21 | case entriesRequest 22 | case entriesDecode 23 | } 24 | 25 | init(entriesService: EntriesService = EntriesService()) { 26 | self.entriesService = entriesService 27 | } 28 | 29 | func getNavigationTitle() -> String { 30 | guard let selectedFeedUrl = selectedFeedStore.feedUrl else { 31 | return "Entries" 32 | } 33 | 34 | let selectedFeed = feedsStore.getFeedByUrl(url: selectedFeedUrl) 35 | 36 | if let name = selectedFeed?.name { 37 | return name 38 | } 39 | 40 | return "Entries" 41 | } 42 | 43 | func getEntries() async { 44 | task = Task { 45 | if let selectedFeedUrl = selectedFeedStore.feedUrl { 46 | let (entriesData, entriesResponse) = try await entriesService.getEntries(url: selectedFeedUrl) 47 | 48 | guard let entries = try? JSONDecoder().decode([Entry].self, from: entriesData) else { 49 | result = .failure(DetailsViewError.entriesDecode) 50 | return 51 | } 52 | 53 | guard entriesResponse.statusCode() == 200 else { 54 | result = .failure(DetailsViewError.entriesRequest) 55 | return 56 | } 57 | 58 | result = .success(entries) 59 | } 60 | } 61 | } 62 | 63 | @ViewBuilder var body: some View { 64 | Group { 65 | switch result { 66 | case .none: 67 | if selectedFeedStore.feedUrl != nil { 68 | ProgressView() 69 | } 70 | case .failure(_): 71 | Text("Failed to get entries") 72 | case .success(let entries): 73 | if entries.isEmpty { 74 | Text("Select a feed to view entries") 75 | // TODO: Select random button 76 | } else { 77 | #if os(iOS) 78 | Spacer() 79 | .frame(height: 10) 80 | #endif 81 | List(entries, id: \.url) { entry in 82 | DetailsItemView(entry: entry) 83 | } 84 | } 85 | } 86 | } 87 | .navigationTitle(getNavigationTitle()) 88 | .onAppear { 89 | Task { @MainActor in 90 | await getEntries() 91 | } 92 | } 93 | .onChange(of: selectedFeedStore.feedUrl) { _, _ in 94 | result = .none 95 | task?.cancel() 96 | 97 | Task { @MainActor in 98 | await getEntries() 99 | } 100 | } 101 | } 102 | } 103 | 104 | #Preview { 105 | let feedsStore = FeedsStore() 106 | let selectedFeedStore = SelectedFeedStore() 107 | 108 | feedsStore.feeds = [ 109 | Feed(name: "Feed 1", url: "https://example.com/feed1", createdAt: Date().nowInMs), 110 | Feed(name: "Feed 2", url: "https://example.com/feed2", createdAt: Date().nowInMs) 111 | ] 112 | 113 | selectedFeedStore.feedUrl = "https://example.com/feed1" 114 | 115 | return DetailsView(entriesService: MockEntriesService()) 116 | .environmentObject(feedsStore) 117 | .environmentObject(selectedFeedStore) 118 | } 119 | -------------------------------------------------------------------------------- /apple/TRssReader/Views/List/ListActionsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListActionsView.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 20/4/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ListActionsView: View { 11 | @EnvironmentObject var feedsStore: FeedsStore 12 | @EnvironmentObject var selectedFeedStore: SelectedFeedStore 13 | @EnvironmentObject var modalStore: ModalStore 14 | 15 | @State private var actionInFlight: Bool = false 16 | @State private var actionFailed: Bool = false 17 | 18 | let feedsService: FeedsService 19 | 20 | init(feedsService: FeedsService = FeedsService()) { 21 | self.feedsService = feedsService 22 | } 23 | 24 | func deleteFeed(url: String) { 25 | Task { 26 | do { 27 | actionInFlight = true 28 | 29 | let (_, deletedFeedResponse) = try await feedsService.deleteFeed(url: url) 30 | 31 | guard deletedFeedResponse.statusCode() == 200 else { 32 | actionInFlight = false 33 | actionFailed = true 34 | return 35 | } 36 | 37 | feedsStore.deleteFeedByUrl(url: url) 38 | selectedFeedStore.feedUrl = nil 39 | actionInFlight = false 40 | } catch { 41 | actionInFlight = false 42 | actionFailed = true 43 | } 44 | } 45 | } 46 | 47 | var body: some View { 48 | if let feeds = feedsStore.feeds { 49 | if feeds.isEmpty { 50 | Text("No feeds yet") 51 | } else { 52 | List(selection: $selectedFeedStore.feedUrl) { 53 | ForEach(feeds, id: \.url) { feed in 54 | ListItemView(feed: feed) 55 | .swipeActions(allowsFullSwipe: false) { 56 | ActionButtonView(type: .edit) { 57 | modalStore.open(mode: .edit, name: feed.name, url: feed.url) 58 | } 59 | ActionButtonView(type: .delete) { 60 | deleteFeed(url: feed.url) 61 | } 62 | } 63 | .contextMenu { 64 | ActionButtonView(type: .edit) { 65 | modalStore.open(mode: .edit, name: feed.name, url: feed.url) 66 | } 67 | ActionButtonView(type: .delete) { 68 | deleteFeed(url: feed.url) 69 | } 70 | } 71 | } 72 | .deleteDisabled(actionInFlight) 73 | } 74 | .toolbar { 75 | ToolbarItemGroup { 76 | Spacer() 77 | Button { 78 | modalStore.open() 79 | } label: { 80 | Label("Add", systemImage: "plus.circle") 81 | } 82 | } 83 | } 84 | .alert("Request failed", isPresented: $actionFailed, actions: { 85 | Button("Close") { 86 | actionFailed = false 87 | } 88 | }) 89 | } 90 | } else { 91 | Text("No feeds yet") 92 | } 93 | } 94 | } 95 | 96 | #Preview("Feeds") { 97 | let feedsStore = FeedsStore() 98 | let selectedFeedStore = SelectedFeedStore() 99 | 100 | feedsStore.feeds = [ 101 | Feed(name: "Feed 1", url: "https://example.com/feed1", createdAt: Date().nowInMs), 102 | Feed(name: "Feed 2", url: "https://example.com/feed2", createdAt: Date().nowInMs) 103 | ] 104 | 105 | selectedFeedStore.feedUrl = "https://example.com/feed1" 106 | 107 | return ListActionsView() 108 | .environmentObject(feedsStore) 109 | .environmentObject(selectedFeedStore) 110 | .environmentObject(ModalStore()) 111 | } 112 | 113 | #Preview("No feeds") { 114 | ListActionsView() 115 | .environmentObject(FeedsStore()) 116 | .environmentObject(SelectedFeedStore()) 117 | .environmentObject(ModalStore()) 118 | } 119 | -------------------------------------------------------------------------------- /apple/TRssReader/Views/List/ListItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListItemView.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 19/4/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ListItemView: View { 11 | let feed: Feed 12 | 13 | var body: some View { 14 | NavigationLink(value: feed.url) { 15 | VStack(alignment: .leading) { 16 | Text(feed.name) 17 | .font(.headline) 18 | .padding([.bottom], 1) 19 | .lineLimit(1) 20 | .truncationMode(.tail) 21 | 22 | Text(feed.url) 23 | .font(.subheadline) 24 | .opacity(0.75) 25 | .lineLimit(1) 26 | .truncationMode(.tail) 27 | } 28 | } 29 | .padding(4) 30 | } 31 | } 32 | 33 | #Preview { 34 | ListItemView(feed: Feed(name: "A feed", url: "https://a.com", createdAt: Date().nowInMs)) 35 | } 36 | -------------------------------------------------------------------------------- /apple/TRssReader/Views/List/ListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListView.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 13/4/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct ListView: View { 12 | @EnvironmentObject var feedsStore: FeedsStore 13 | @EnvironmentObject var selectedFeedStore: SelectedFeedStore 14 | 15 | @State private var feedsResult: Result<[Feed], Error>? 16 | 17 | let feedsService: FeedsService 18 | 19 | init(feedsService: FeedsService = FeedsService()) { 20 | self.feedsService = feedsService 21 | } 22 | 23 | enum FeedsError: Error { 24 | case feedsRequest 25 | case feedsDecode 26 | } 27 | 28 | func getFeeds() async { 29 | do { 30 | let (feedsData, feedsResponse) = try await feedsService.getFeeds() 31 | 32 | guard let feeds = try? JSONDecoder().decode([Feed].self, from: feedsData) else { 33 | feedsResult = .failure(FeedsError.feedsDecode) 34 | return 35 | } 36 | 37 | guard feedsResponse.statusCode() == 200 else { 38 | feedsResult = .failure(FeedsError.feedsRequest) 39 | return 40 | } 41 | 42 | feedsStore.feeds = feeds 43 | 44 | feedsResult = .success(feeds) 45 | } catch { 46 | feedsResult = .failure(FeedsError.feedsRequest) 47 | } 48 | } 49 | 50 | @ViewBuilder var body: some View { 51 | Group { 52 | switch feedsResult { 53 | case .none: 54 | ProgressView() 55 | case .failure(_): 56 | Text("Failed to get feeds") 57 | // TODO: Retry logic 58 | case .success(_): 59 | if let feeds = feedsStore.feeds { 60 | if feeds.isEmpty { 61 | Text("No feeds yet") 62 | } else { 63 | #if os(iOS) 64 | Spacer() 65 | .frame(height: 10) 66 | #endif 67 | ListActionsView() 68 | } 69 | } else { 70 | Text("No feeds yet") 71 | } 72 | } 73 | } 74 | .task { 75 | if case .none = feedsResult { 76 | await getFeeds() 77 | } 78 | } 79 | } 80 | } 81 | 82 | #Preview { 83 | let feedsStore = FeedsStore() 84 | let selectedFeedStore = SelectedFeedStore() 85 | 86 | feedsStore.feeds = [ 87 | Feed(name: "Feed 1", url: "https://example.com/feed1", createdAt: Date().nowInMs), 88 | Feed(name: "Feed 2", url: "https://example.com/feed2", createdAt: Date().nowInMs) 89 | ] 90 | 91 | selectedFeedStore.feedUrl = "https://example.com/feed1" 92 | 93 | return ListView(feedsService: MockFeedsService()) 94 | .environmentObject(feedsStore) 95 | .environmentObject(selectedFeedStore) 96 | } 97 | -------------------------------------------------------------------------------- /apple/TRssReader/Views/Screens/ListDetailsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListDetailsView.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 13/4/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct ListDetailsView: View { 12 | @StateObject var feedsStore = FeedsStore() 13 | @StateObject var selectedFeedStore = SelectedFeedStore() 14 | @StateObject var modalStore = ModalStore() 15 | 16 | @State var columnVisibility: NavigationSplitViewVisibility = .all 17 | 18 | var body: some View { 19 | NavigationSplitView(columnVisibility: $columnVisibility) { 20 | ListView() 21 | .navigationTitle("Feeds") 22 | #if os(macOS) 23 | .navigationSplitViewColumnWidth( 24 | min: 250, ideal: 300) 25 | #endif 26 | } detail: { 27 | DetailsView() 28 | } 29 | .sheet(isPresented: $modalStore.isOpen) { 30 | NavigationStack { 31 | UpsertFeedModal() 32 | } 33 | } 34 | .environmentObject(feedsStore) 35 | .environmentObject(selectedFeedStore) 36 | .environmentObject(modalStore) 37 | } 38 | } 39 | 40 | #Preview { 41 | ListDetailsView() 42 | } 43 | -------------------------------------------------------------------------------- /apple/TRssReader/Views/Screens/LoginView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginView.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 13/4/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum LoginError: Error { 11 | case tokenDecode 12 | case tokenNotReceived 13 | case tokenNotSet 14 | case unknown 15 | } 16 | 17 | struct LoginView: View { 18 | @StateObject private var viewModel: LoginViewModel 19 | 20 | init(viewModel: LoginViewModel) { 21 | _viewModel = StateObject(wrappedValue: viewModel) 22 | } 23 | 24 | var body: some View { 25 | NavigationStack { 26 | VStack { 27 | Spacer() 28 | .frame(height: 50) 29 | 30 | Text("Enter your password") 31 | .frame(maxWidth: .infinity, alignment: .leading) 32 | 33 | ResultMessageView(result: $viewModel.result) 34 | .frame(maxWidth: .infinity, alignment: .leading) 35 | .padding([.top], 4) 36 | 37 | SecureField("Password", text: $viewModel.password) { 38 | Task { 39 | await viewModel.submit() 40 | } 41 | } 42 | .padding([.top, .bottom], 10) 43 | .textFieldStyle(.roundedBorder) 44 | .disabled(viewModel.loading) 45 | 46 | Button(viewModel.loading ? "Authorizing..." : "Log In", action: { 47 | Task { 48 | await viewModel.submit() 49 | } 50 | }) 51 | .padding([.top], 6) 52 | .frame(alignment: .trailing) 53 | .buttonStyle(.borderedProminent) 54 | .disabled(viewModel.password.isEmpty || viewModel.loading) 55 | 56 | Spacer() 57 | } 58 | .frame( 59 | idealWidth: 300, 60 | maxWidth: 300 61 | ) 62 | .padding() 63 | .navigationTitle("Log In") 64 | } 65 | } 66 | } 67 | 68 | #Preview { 69 | LoginView(viewModel: LoginViewModel()) 70 | } 71 | -------------------------------------------------------------------------------- /apple/TRssReader/Views/Screens/LoginViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewModel.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 4/12/24. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | final class LoginViewModel: ObservableObject { 12 | @Published var password: String = "" 13 | @Published var loading: Bool = false 14 | @Published var result: Result? 15 | 16 | private let loginService: LoginService 17 | private let tokenStore: TokenStore 18 | 19 | init(loginService: LoginService = LoginService(), tokenStore: TokenStore = TokenStore.shared) { 20 | self.loginService = loginService 21 | self.tokenStore = tokenStore 22 | } 23 | 24 | func login() async { 25 | do { 26 | loading = true 27 | 28 | let (loginData, loginResponse) = try await loginService.login(password: password) 29 | 30 | loading = false 31 | 32 | guard let token = try? JSONDecoder().decode(Token.self, from: loginData) else { 33 | result = .failure(LoginError.tokenDecode) 34 | return 35 | } 36 | 37 | guard loginResponse.statusCode() == 200 && !token.accessToken.isEmpty else { 38 | result = .failure(LoginError.tokenNotReceived) 39 | return 40 | } 41 | 42 | do { 43 | try tokenStore.setToken(token: token) 44 | result = .success(true) 45 | } catch { 46 | result = .failure(LoginError.tokenNotSet) 47 | } 48 | } catch { 49 | result = .failure(LoginError.unknown) 50 | } 51 | } 52 | 53 | func submit() async { 54 | await login() 55 | try? await Task.sleep(for: .seconds(3)) 56 | result = nil 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /apple/TRssReader/Views/Shared/ActionButtonView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActionButtonView.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 21/4/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ActionButtonView: View { 11 | enum ButtonType { 12 | case edit 13 | case delete 14 | } 15 | 16 | let role: [ButtonType: ButtonRole?] = [ 17 | .edit: nil, 18 | .delete: .destructive 19 | ] 20 | 21 | let label: [ButtonType: Label] = [ 22 | .edit: Label("Edit", systemImage: "pencil.circle"), 23 | .delete: Label("Delete", systemImage: "trash") 24 | ] 25 | 26 | let tint: [ButtonType: Color] = [ 27 | .edit: .blue, 28 | .delete: .red 29 | ] 30 | 31 | var type: ButtonType 32 | var action: (() -> Void) 33 | 34 | var body: some View { 35 | Button( 36 | role: role[type] as? ButtonRole, 37 | action: action, 38 | label: { 39 | label[type] 40 | } 41 | ) 42 | .tint(tint[type]) 43 | } 44 | } 45 | 46 | #Preview("Edit") { 47 | ActionButtonView(type: .edit, action: {}) 48 | } 49 | 50 | #Preview("Delete") { 51 | ActionButtonView(type: .delete, action: {}) 52 | } 53 | -------------------------------------------------------------------------------- /apple/TRssReader/Views/Shared/ResultMessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResultMessageView.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 14/4/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum ResultMessage: String { 11 | case success = "Request successful" 12 | case failure = "Request failed" 13 | } 14 | 15 | struct ResultMessageView: View { 16 | @Binding var result: Result? 17 | 18 | var body: some View { 19 | switch result { 20 | case .success: 21 | Label(ResultMessage.success.rawValue, systemImage: "checkmark.circle.fill") 22 | .foregroundColor(.green) 23 | case .failure: 24 | Label(ResultMessage.failure.rawValue, systemImage: "xmark.circle.fill") 25 | .foregroundColor(.red) 26 | case .none: 27 | EmptyView() 28 | } 29 | } 30 | } 31 | 32 | #Preview("Success") { 33 | ResultMessageView(result: .constant(.success(true))) 34 | } 35 | 36 | #Preview("Failure") { 37 | ResultMessageView(result: .constant(.failure(PreviewError.unknown))) 38 | } 39 | 40 | #Preview("Nil") { 41 | ResultMessageView(result: .constant(nil)) 42 | } 43 | -------------------------------------------------------------------------------- /apple/TRssReader/Views/Shared/ValidationMessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ValidationMessageView.swift 3 | // TRssReader 4 | // 5 | // Created by Ty Hopp on 25/4/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ValidationMessageView: View { 11 | var message: String? 12 | 13 | var body: some View { 14 | if let message = message { 15 | Text(message) 16 | .foregroundColor(.red) 17 | } else { 18 | EmptyView() 19 | } 20 | } 21 | } 22 | 23 | #Preview { 24 | ValidationMessageView(message: "URL must be unique") 25 | } 26 | -------------------------------------------------------------------------------- /apple/TRssReaderTests/Services/AuthorizedServiceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorizedServiceTests.swift 3 | // TRssReaderTests 4 | // 5 | // Created by Ty Hopp on 3/4/23. 6 | // 7 | 8 | import XCTest 9 | @testable import TRssReader 10 | 11 | let mockToken: Token = Token(accessToken: "token", tokenType: "jwt", expiresIn: 0) 12 | 13 | class AuthorizedServiceTests: XCTestCase { 14 | let api = "https://example.com" 15 | 16 | func testUrlFailure() { 17 | let service = AuthorizedService() 18 | 19 | XCTAssertErrorType(try service.url(api: ""), throws: ServiceError.url) 20 | } 21 | 22 | func testUrlSuccess() { 23 | let service = AuthorizedService() 24 | 25 | let url: URL 26 | 27 | do { 28 | url = try service.url(api: api) 29 | 30 | XCTAssertEqual(url.absoluteString, api) 31 | } catch { 32 | XCTFail("Failed to construct url") 33 | } 34 | } 35 | 36 | func testHeadersNoToken() { 37 | let service = AuthorizedService(tokenStore: MockedTokenStoreNoToken()) 38 | 39 | XCTAssertErrorType(try service.headers(), throws: ServiceError.accessToken) 40 | } 41 | 42 | func testHeadersWithToken() { 43 | let service = AuthorizedService(tokenStore: MockedTokenStoreWithToken()) 44 | 45 | var headers: [String: String] 46 | 47 | do { 48 | headers = try service.headers() 49 | 50 | if let authorization = headers.removeValue(forKey: "Authorization") { 51 | XCTAssertEqual(authorization, mockToken.accessToken) 52 | XCTAssertEqual(headers, service.defaultHeaders) 53 | } 54 | } catch { 55 | XCTFail("Failed to get headers with token") 56 | } 57 | } 58 | 59 | func testRequestNoToken() { 60 | let service = AuthorizedService(tokenStore: MockedTokenStoreNoToken()) 61 | 62 | XCTAssertErrorType(try service.request(api: api), throws: ServiceError.headers) 63 | } 64 | 65 | func testRequestWithToken() { 66 | let service = AuthorizedService(tokenStore: MockedTokenStoreWithToken()) 67 | 68 | let request: URLRequest 69 | 70 | do { 71 | request = try service.request(api: api) 72 | 73 | var headers = request.allHTTPHeaderFields 74 | 75 | XCTAssertEqual(request.url?.absoluteString, api) 76 | 77 | if let authorization = headers?.removeValue(forKey: "Authorization") { 78 | XCTAssertEqual(authorization, mockToken.accessToken) 79 | XCTAssertEqual(headers, service.defaultHeaders) 80 | } 81 | } catch { 82 | XCTFail("Failed to get request with token") 83 | } 84 | } 85 | 86 | func testRequestWithQueryItems() { 87 | let service = AuthorizedService(tokenStore: MockedTokenStoreWithToken()) 88 | let queryItems = URLQueryItem(name: "a", value: "b") 89 | 90 | let request: URLRequest 91 | 92 | do { 93 | request = try service.request(api: api, queryItems: [queryItems]) 94 | XCTAssertEqual(request.url?.absoluteString, "\(api)?a=b") 95 | } catch { 96 | XCTFail("Failed to get request with query items") 97 | } 98 | } 99 | } 100 | 101 | private class MockedTokenStoreNoToken: TokenStorable { 102 | var store = TokenModelStore() 103 | 104 | func getTokenFromKeychain() -> Token? { 105 | return nil 106 | } 107 | 108 | func setToken(token: Token) throws {} 109 | } 110 | 111 | private class MockedTokenStoreWithToken: TokenStorable { 112 | var store = TokenModelStore(maybeValid: true, token: mockToken) 113 | 114 | func getTokenFromKeychain() -> Token? { 115 | return mockToken 116 | } 117 | 118 | func setToken(token: Token) throws {} 119 | } 120 | -------------------------------------------------------------------------------- /apple/TRssReaderTests/XCTestCase+XCTAssertErrorType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCTestCase+XCTAssertErrorType.swift 3 | // TRssReaderTests 4 | // 5 | // Created by Ty Hopp on 7/4/23. 6 | // 7 | 8 | import XCTest 9 | 10 | extension XCTestCase { 11 | func XCTAssertErrorType( 12 | _ expression: @autoclosure () throws -> T, 13 | throws error: E, 14 | in file: StaticString = #file, 15 | line: UInt = #line 16 | ) { 17 | var thrownError: Error? 18 | 19 | XCTAssertThrowsError(try expression(), 20 | file: file, line: line) { 21 | thrownError = $0 22 | } 23 | 24 | XCTAssertTrue( 25 | thrownError is E, 26 | "Unexpected error type: \(type(of: thrownError))", 27 | file: file, line: line 28 | ) 29 | 30 | XCTAssertEqual( 31 | thrownError as? E, error, 32 | file: file, line: line 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /design/README.md: -------------------------------------------------------------------------------- 1 | # t-rss-reader design 2 | 3 | The [Figma design file](https://www.figma.com/community/file/1212600217282894177) is published and available to view or duplicate. 4 | 5 | ![Screenshot of the t-rss-reader design file](./t-rss-reader-design.webp) 6 | 7 | It will not stay in sync with changes in the clients, but is a reasonably accurate representation of what they look like. 8 | -------------------------------------------------------------------------------- /design/t-rss-reader-design.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyhopp/t-rss-reader/0e611dbdc75b0f6bc0e6a93a8424a302d2a0dd30/design/t-rss-reader-design.webp -------------------------------------------------------------------------------- /infra/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/aws" { 5 | version = "4.54.0" 6 | constraints = "~> 4.16" 7 | hashes = [ 8 | "h1:eiPVhVhAawhvnZIwtRRM4y7UpfWSJpYXFHwml1M2LB0=", 9 | "h1:j/L01+hlHVM2X2VrkQC2WtMZyu4ZLhDMw+HDJ7k0Y2Q=", 10 | "zh:24358aefc06b3f38878680fe606dab2570cb58ab952750c47e90b81d3b05e606", 11 | "zh:3fc0ef459d6bb4fbb0e4eb7b8adadddd636efa6d975be6e70de7327d83e15729", 12 | "zh:67e765119726f47b1916316ac95c3cd32ac074b454f2a67b6127120b476bc483", 13 | "zh:71aed1300debac24f11263a6f8a231c6432497b25e623e8f34e27121af65f523", 14 | "zh:722043077e63713d4e458f3228be30c21fcff5b6660c6de8b96967337cdc604a", 15 | "zh:76d67be4220b93cfaca0882f46db9a42b4ca48285a64fe304f108dde85f4d611", 16 | "zh:81534c18d9f02648b1644a7937e7bea56e91caef13b41de121ee51168faad680", 17 | "zh:89983ab2596846d5f3413ff1b5b9b21424c3c757a54dcc5a4604d3ac34fea1a6", 18 | "zh:8a603ac6884de5dc51c372f641f9613aefd87059ff6e6a74b671f6864226e06f", 19 | "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", 20 | "zh:b6fae6c1cda6d842406066dac7803d24a597b62da5fae33bcd50c5dae70140c2", 21 | "zh:bc4c3b4bfb715beecf5186dfeb91173ef1a9c0b68e8c45cbeee180195bbfa37f", 22 | "zh:c741a3fe7d085593a160e79596bd237afc9503c836abcc95fd627554cdf16ec0", 23 | "zh:f6763e96485e1ea5b67a33bbd04042e412508b2b06946acf957fb68a314d893e", 24 | "zh:fc7144577ea7d6e05c276b54a9f8f8609be7b4d0a128aa45f233a4b0e5cbf090", 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /infra/Makefile: -------------------------------------------------------------------------------- 1 | targets = install test init validate create delete 2 | login_handler_path = ./login-handler 3 | feeds_handler_path = ./feeds-handler 4 | entries_handler_path = ./entries-handler 5 | last_access_handler_path = ./last-access-handler 6 | 7 | .PHONY: help $(targets) 8 | 9 | help: 10 | @echo "Available targets: $(targets)" 11 | 12 | install: 13 | $(MAKE) -C $(login_handler_path) install 14 | $(MAKE) -C $(feeds_handler_path) install 15 | $(MAKE) -C $(entries_handler_path) install 16 | $(MAKE) -C $(last_access_handler_path) install 17 | 18 | test: 19 | $(MAKE) -C $(login_handler_path) test 20 | $(MAKE) -C $(feeds_handler_path) test 21 | $(MAKE) -C $(entries_handler_path) test 22 | $(MAKE) -C $(last_access_handler_path) test 23 | 24 | init: 25 | $(MAKE) install 26 | terraform init 27 | 28 | validate: 29 | terraform fmt 30 | terraform validate 31 | 32 | create: 33 | $(MAKE) -C $(login_handler_path) prepare 34 | $(MAKE) -C $(feeds_handler_path) prepare 35 | $(MAKE) -C $(entries_handler_path) prepare 36 | $(MAKE) -C $(last_access_handler_path) prepare 37 | terraform apply -var-file="terraform.tfvars" 38 | 39 | delete: 40 | terraform apply -var-file="terraform.tfvars" -destroy 41 | $(MAKE) -C $(login_handler_path) clean 42 | $(MAKE) -C $(feeds_handler_path) clean 43 | $(MAKE) -C $(entries_handler_path) clean 44 | $(MAKE) -C $(last_access_handler_path) clean -------------------------------------------------------------------------------- /infra/README.md: -------------------------------------------------------------------------------- 1 | # t-rss-reader infra 2 | 3 | Set up and tear down these resources in AWS via Terraform: 4 | 5 | - DynamoDB tables 6 | - Lambda functions 7 | - API Gateways 8 | - IAM roles and policies 9 | 10 | ## Endpoints 11 | 12 | After creating all the resources you end up with these endpoints: 13 | 14 | - `/login` 15 | - `POST` takes your password and returns a [JSON Web Token](https://jwt.io/) 16 | - `/feeds` 17 | - `GET` gets all feeds 18 | - `PUT` upserts a feed 19 | - `DELETE` deletes a feed 20 | - `/entries?url=[FEED_URL]` 21 | - `GET` gets a feed's entries 22 | - `/last-access` 23 | - `PUT` upserts a timestamp of the last time the application was accessed 24 | 25 | See lambda function handler code for the exact interfaces. 26 | 27 | ## Prerequisites 28 | 29 | Assumes a Unix-like system (e.g. Linux, macOS) with [make](https://www.gnu.org/software/make/) and [zip](https://linux.die.net/man/1/zip) installed. 30 | 31 | - [Terraform CLI](https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli) 32 | - [Terraform Cloud account](https://cloud.hashicorp.com/products/terraform) 33 | - [AWS account](https://aws.amazon.com/) with [access keys](https://aws.amazon.com/premiumsupport/knowledge-center/create-access-key/) created 34 | 35 | ## Setup 36 | 37 | 1. Change the Terraform Cloud organization in [`main.tf`](./main.tf) to yours (this value is not possible to declare as a variable) 38 | 2. `terraform login` 39 | 3. Generate a password to use for authenticating client requests in the application 40 | 4. Create a `terraform.tfvars` file from the `terraform-example.tfvars` file 41 | 5. Add your generated password and AWS access keys to the `terraform.tfvars` file 42 | 6. `make init` 43 | 44 | ## Create and delete resources 45 | 46 | - `make create` to create the resource creation plan and apply it 47 | - `make delete` to create the resource deletion plan and apply it 48 | 49 | ## Cost 50 | 51 | All resources have generous limits within the [AWS Free Tier](https://aws.amazon.com/free/). 52 | 53 | In most cases it costs nothing to run this infrastructure. 54 | 55 | Use your own discretion when evaluating costs. It goes without saying, but I am not responsible for any unintended costs incurred by your use of these templates. 56 | 57 | ## Gotchas 58 | 59 | - Terraform always updates the lambda functions despite the hash of the output files not changing. It's a [known issue](https://github.com/hashicorp/terraform-provider-aws/issues/17989) that's not worth fixing at this scale. 60 | - Terraform sometimes throws a concurrent modification error when creating routes. It looks like this: `Error: creating API Gateway v2 route: ConflictException: Unable to complete operation due to concurrent modification. Please try again later`. Re-run `make create` and the routes not created from the last run will be created. This only happens when all routes are created at once so it doesn't happen enough to make it worth fixing. 61 | - Terraform sometimes doesn't pick up certain changes when diffing, such as DyanmoDB attributes. Run `make destroy` and `make create` to have them applied. This will erase all database items, so beware. Also doesn't happen enough to make it worth fixing. 62 | -------------------------------------------------------------------------------- /infra/entries-api-gateway.tf: -------------------------------------------------------------------------------- 1 | resource "aws_apigatewayv2_api" "t-rss-reader-entries-handler-api" { 2 | name = "t-rss-reader-entries-handler-api" 3 | protocol_type = "HTTP" 4 | 5 | cors_configuration { 6 | allow_origins = var.t-rss-reader-allow-origins 7 | allow_methods = ["GET"] 8 | allow_headers = ["content-type", "authorization"] 9 | allow_credentials = true 10 | max_age = 7200 11 | } 12 | } 13 | 14 | resource "aws_cloudwatch_log_group" "t-rss-reader-entries-handler-api-log-group" { 15 | name = "/aws/t-rss-reader-entries-handler-api-log-group/${aws_apigatewayv2_api.t-rss-reader-entries-handler-api.name}" 16 | retention_in_days = 30 17 | } 18 | 19 | resource "aws_apigatewayv2_stage" "t-rss-reader-entries-handler-api-stage" { 20 | api_id = aws_apigatewayv2_api.t-rss-reader-entries-handler-api.id 21 | name = "default" 22 | auto_deploy = true 23 | 24 | access_log_settings { 25 | destination_arn = aws_cloudwatch_log_group.t-rss-reader-entries-handler-api-log-group.arn 26 | format = jsonencode({ 27 | requestId = "$context.requestId" 28 | sourceIp = "$context.identity.sourceIp" 29 | requestTime = "$context.requestTime" 30 | protocol = "$context.protocol" 31 | httpMethod = "$context.httpMethod" 32 | resourcePath = "$context.resourcePath" 33 | routeKey = "$context.routeKey" 34 | status = "$context.status" 35 | responseLength = "$context.responseLength" 36 | integrationErrorMessage = "$context.integrationErrorMessage" 37 | } 38 | ) 39 | } 40 | } 41 | 42 | resource "aws_apigatewayv2_integration" "t-rss-reader-entries-handler-api-integration" { 43 | api_id = aws_apigatewayv2_api.t-rss-reader-entries-handler-api.id 44 | integration_type = "AWS_PROXY" 45 | integration_method = "POST" 46 | integration_uri = aws_lambda_function.t-rss-reader-entries-handler.invoke_arn 47 | } 48 | 49 | resource "aws_apigatewayv2_route" "t-rss-reader-entries-handler-api-route-get" { 50 | api_id = aws_apigatewayv2_api.t-rss-reader-entries-handler-api.id 51 | route_key = "GET /entries" 52 | target = "integrations/${aws_apigatewayv2_integration.t-rss-reader-entries-handler-api-integration.id}" 53 | } 54 | -------------------------------------------------------------------------------- /infra/entries-handler/Makefile: -------------------------------------------------------------------------------- 1 | targets = install prepare test clean 2 | 3 | .PHONY: help $(targets) 4 | 5 | help: 6 | @echo "Available targets: $(targets)" 7 | 8 | install: 9 | npm install 10 | 11 | prepare: 12 | npm run build 13 | npm run zip 14 | 15 | test: 16 | npm run test 17 | 18 | clean: 19 | npm run clean -------------------------------------------------------------------------------- /infra/entries-handler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": "true", 3 | "name": "entries-handler", 4 | "description": "Lambda function that fetches an RSS feed and returns its entries as JSON", 5 | "type": "module", 6 | "engines": { 7 | "node": ">=18.0.0" 8 | }, 9 | "scripts": { 10 | "clean": "del dist entries-handler.zip", 11 | "build": "ncc build ./src/index.ts --out dist --external @aws-sdk/client-dynamodb --external @aws-sdk/lib-dynamodb --minify", 12 | "zip": "zip -r entries-handler.zip ./dist", 13 | "test": "vitest" 14 | }, 15 | "dependencies": { 16 | "@aws-sdk/client-dynamodb": "^3.284.0", 17 | "@aws-sdk/lib-dynamodb": "^3.284.0", 18 | "jsonwebtoken": "^9.0.0", 19 | "linkedom": "^0.14.22" 20 | }, 21 | "devDependencies": { 22 | "@types/aws-lambda": "^8.10.110", 23 | "@types/jsonwebtoken": "^9.0.1", 24 | "@vercel/ncc": "^0.36.1", 25 | "del-cli": "^5.0.0", 26 | "typescript": "^4.9.5", 27 | "vitest": "^0.28.5" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /infra/entries-handler/src/index.ts: -------------------------------------------------------------------------------- 1 | import { verifyToken } from './lib/verify-token'; 2 | import { parseFeed } from './lib/parse-feed'; 3 | import type { APIGatewayEvent } from 'aws-lambda'; 4 | import { sortEntries } from './lib/sort-entries'; 5 | 6 | const headers = { 7 | 'Content-Type': 'application/json' 8 | }; 9 | 10 | export const handler = async (event: APIGatewayEvent) => { 11 | const verified = verifyToken(event?.headers?.authorization); 12 | 13 | if (!verified) { 14 | return { 15 | statusCode: 403, 16 | body: JSON.stringify({ message: 'Unauthorized' }), 17 | headers 18 | }; 19 | } 20 | 21 | let url: string; 22 | 23 | try { 24 | url = decodeURI(event.queryStringParameters.url); 25 | } catch (error) { 26 | return { 27 | statusCode: 400, 28 | body: JSON.stringify({ message: 'Malformed url query parameter' }), 29 | headers 30 | }; 31 | } 32 | 33 | let responseBody = {}; 34 | let responseHeaders = headers; 35 | 36 | try { 37 | switch (event.httpMethod) { 38 | case 'GET': 39 | const response = await fetch(url); 40 | 41 | if (response.status >= 400) { 42 | return { 43 | statusCode: 400, 44 | body: JSON.stringify({ message: `Failed to fetch feed ${url}` }) 45 | }; 46 | } 47 | 48 | const xml = await response.text(); 49 | const unsortedEntries = parseFeed(url, xml); 50 | responseBody = await sortEntries(unsortedEntries); 51 | 52 | responseHeaders['Cache-Control'] = `max-age=300`; 53 | break; 54 | default: 55 | return { 56 | statusCode: 405, 57 | body: JSON.stringify({ message: `Unsupported method ${event.httpMethod}` }), 58 | headers 59 | }; 60 | } 61 | 62 | return { 63 | statusCode: 200, 64 | body: JSON.stringify(responseBody), 65 | headers: responseHeaders 66 | }; 67 | } catch (err) { 68 | console.error(err.message); 69 | 70 | return { 71 | statusCode: 500, 72 | body: JSON.stringify({ message: 'Operation failed' }), 73 | headers: responseHeaders 74 | }; 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /infra/entries-handler/src/lib/get-atom-entries.ts: -------------------------------------------------------------------------------- 1 | import type { RssFeedEntries } from './types'; 2 | import type { XMLDocument } from 'linkedom/types/xml/document'; 3 | 4 | export function getAtomEntries(doc: XMLDocument): RssFeedEntries { 5 | const entries = []; 6 | 7 | const entryElements = doc.querySelectorAll('entry') || []; 8 | 9 | for (const entryElement of entryElements) { 10 | const url = entryElement.querySelector('link')?.getAttribute('href') ?? entryElement.querySelector('id')?.textContent; 11 | const title = entryElement.querySelector('title')?.textContent; 12 | const published = entryElement.querySelector('published, updated')?.textContent; 13 | 14 | const entry = { 15 | url, 16 | title, 17 | published 18 | }; 19 | 20 | entries.push(entry); 21 | } 22 | 23 | return entries; 24 | } -------------------------------------------------------------------------------- /infra/entries-handler/src/lib/get-feed-format.ts: -------------------------------------------------------------------------------- 1 | import { RssFeedFormat } from './types'; 2 | import type { XMLDocument } from 'linkedom/types/xml/document'; 3 | 4 | export function getFeedFormat(doc: XMLDocument): RssFeedFormat | undefined { 5 | const tag = doc.firstElementChild?.tagName?.toLowerCase(); 6 | 7 | switch (tag) { 8 | case 'feed': 9 | return RssFeedFormat.atom; 10 | case 'rss': 11 | return RssFeedFormat.rss; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /infra/entries-handler/src/lib/get-rss-entries.ts: -------------------------------------------------------------------------------- 1 | import type { RssFeedEntries } from './types'; 2 | import type { XMLDocument } from 'linkedom/types/xml/document'; 3 | 4 | export function getRssEntries(doc: XMLDocument): RssFeedEntries { 5 | const entries = []; 6 | 7 | const entryElements = doc.querySelectorAll('item') || []; 8 | 9 | for (const entryElement of entryElements) { 10 | const url = entryElement.querySelector('link')?.textContent; 11 | const title = entryElement.querySelector('title')?.textContent; 12 | const published = entryElement.querySelector('pubDate')?.textContent; 13 | 14 | const entry = { 15 | url, 16 | title, 17 | published 18 | }; 19 | 20 | entries.push(entry); 21 | } 22 | 23 | return entries; 24 | } -------------------------------------------------------------------------------- /infra/entries-handler/src/lib/get-unix-time.ts: -------------------------------------------------------------------------------- 1 | export function getUnixTime(dateString: string): number { 2 | const date = new Date(dateString); 3 | return date.getTime(); 4 | } 5 | -------------------------------------------------------------------------------- /infra/entries-handler/src/lib/last-access-table.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 2 | import { DynamoDBDocumentClient, GetCommand, GetCommandOutput } from '@aws-sdk/lib-dynamodb'; 3 | 4 | export class LastAccessTable { 5 | private tableName: string = 't-rss-reader-last-access-table'; 6 | private dbClientInstance: DynamoDBClient; 7 | private dbDocClientInstance: DynamoDBDocumentClient; 8 | private id: number = 0; 9 | 10 | constructor() { 11 | this.dbClientInstance = new DynamoDBClient({}); 12 | this.dbDocClientInstance = DynamoDBDocumentClient.from(this.dbClientInstance); 13 | } 14 | 15 | async getLastAccess(): Promise { 16 | let lastAccess: number = Date.now(); 17 | let response: GetCommandOutput; 18 | 19 | try { 20 | response = await this.dbDocClientInstance.send( 21 | new GetCommand({ 22 | TableName: this.tableName, 23 | Key: { 24 | id: this.id 25 | } 26 | }) 27 | ); 28 | } catch (error) { 29 | console.error('Failed to get last access', error); 30 | } 31 | 32 | if (response?.Item?.lastAccess) { 33 | lastAccess = response?.Item?.lastAccess; 34 | } 35 | 36 | return lastAccess; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /infra/entries-handler/src/lib/parse-feed.ts: -------------------------------------------------------------------------------- 1 | import { DOMParser } from 'linkedom'; 2 | import { getFeedFormat } from './get-feed-format'; 3 | import { getRssEntries } from './get-rss-entries'; 4 | import { getAtomEntries } from './get-atom-entries'; 5 | import { RssFeedFormat, type RssFeedEntries } from './types'; 6 | 7 | export function parseFeed(url: string, xml: string): RssFeedEntries { 8 | const parser = new DOMParser(); 9 | const doc = parser.parseFromString(xml, 'text/xml'); 10 | 11 | /** 12 | * Linkedom does not appear to support parsererror injection on error, so skip that check. 13 | * @link https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString#error_handling 14 | */ 15 | 16 | const format = getFeedFormat(doc); 17 | 18 | if (!format) { 19 | throw new Error(`Unable to determine feed format of RSS feed '${url}'.`); 20 | } 21 | 22 | const entries: RssFeedEntries = format === RssFeedFormat.rss 23 | ? getRssEntries(doc) 24 | : getAtomEntries(doc); 25 | 26 | return entries; 27 | } 28 | -------------------------------------------------------------------------------- /infra/entries-handler/src/lib/sort-entries.ts: -------------------------------------------------------------------------------- 1 | import { LastAccessTable } from './last-access-table'; 2 | import { getUnixTime } from './get-unix-time'; 3 | import type { RssFeedEntries } from './types'; 4 | 5 | export async function sortEntries(unsortedEntries: RssFeedEntries) { 6 | let sortedEntries: RssFeedEntries = unsortedEntries; 7 | 8 | try { 9 | const lastAccessTableInstance = new LastAccessTable(); 10 | const lastAccessUnixTime = await lastAccessTableInstance.getLastAccess(); 11 | 12 | sortedEntries = sortedEntries.map((entry) => { 13 | const publishedUnixTime = getUnixTime(entry.published); 14 | entry.isNew = publishedUnixTime > lastAccessUnixTime; 15 | return entry; 16 | }); 17 | 18 | sortedEntries = unsortedEntries.sort((a, b) => { 19 | if (a.isNew && b.isNew) { 20 | return 0; 21 | } 22 | 23 | if (a.isNew) { 24 | return -1; 25 | } 26 | 27 | if (b.isNew) { 28 | return 1; 29 | } 30 | 31 | return 0; 32 | }); 33 | } catch (error) { 34 | console.error('Failed to sort entries', error); 35 | } 36 | 37 | return sortedEntries; 38 | } 39 | -------------------------------------------------------------------------------- /infra/entries-handler/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export enum RssFeedFormat { 2 | rss = 'rss', 3 | atom = 'atom' 4 | } 5 | 6 | export interface RssFeedEntry { 7 | url: string | null | undefined; 8 | title: string | null | undefined; 9 | published: string | null | undefined; 10 | isNew?: boolean; 11 | } 12 | 13 | export type RssFeedEntries = Array; 14 | -------------------------------------------------------------------------------- /infra/entries-handler/src/lib/verify-token.ts: -------------------------------------------------------------------------------- 1 | import { verify } from 'jsonwebtoken'; 2 | 3 | export function verifyToken(authorization: string): boolean { 4 | try { 5 | verify(authorization, process.env.T_RSS_READER_PASSWORD); 6 | return true; 7 | } catch (error) { 8 | console.error(error.message); 9 | return false; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /infra/entries-handler/test/fixture/atom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | atom-title 4 | atom-published 5 | 6 | 7 | 8 | atom-url 9 | atom-title 10 | atom-published 11 | 12 | -------------------------------------------------------------------------------- /infra/entries-handler/test/fixture/create-event.ts: -------------------------------------------------------------------------------- 1 | import type { APIGatewayEvent, APIGatewayEventRequestContext } from 'aws-lambda'; 2 | 3 | const event: APIGatewayEvent = { 4 | path: 'entries', 5 | resource: 'entries', 6 | queryStringParameters: { 7 | url: 'https%3A%2F%2Ftyhopp.com%2Frss.xml' 8 | }, 9 | httpMethod: 'GET', 10 | body: '{}', 11 | headers: { 12 | 'Content-Type': 'application/json' 13 | }, 14 | multiValueHeaders: {}, 15 | isBase64Encoded: false, 16 | pathParameters: null, 17 | multiValueQueryStringParameters: null, 18 | stageVariables: null, 19 | requestContext: {} as APIGatewayEventRequestContext 20 | }; 21 | 22 | export function createEvent( 23 | queryStringParameters: Record = event.queryStringParameters, 24 | httpMethod: string = 'GET' 25 | ): APIGatewayEvent { 26 | return { ...event, queryStringParameters, httpMethod }; 27 | } 28 | -------------------------------------------------------------------------------- /infra/entries-handler/test/fixture/rss.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | rss-url 4 | rss-title 5 | rss-published 6 | 7 | -------------------------------------------------------------------------------- /infra/entries-handler/test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { test, vi, expect } from 'vitest'; 2 | import { createEvent } from './fixture/create-event'; 3 | import { verifyToken } from '../src/lib/verify-token'; 4 | import { handler } from '../src/index'; 5 | import { sortEntries } from '../src/lib/sort-entries'; 6 | import type { RssFeedEntry } from '../src/lib/types'; 7 | 8 | const entry: RssFeedEntry = { 9 | url: 'url', 10 | title: 'title', 11 | published: 'published' 12 | }; 13 | 14 | vi.stubGlobal('fetch', async () => ({ 15 | status: 200, 16 | text: async () => '' 17 | })); 18 | 19 | vi.mock('../src/lib/verify-token', () => ({ 20 | verifyToken: vi.fn() 21 | })); 22 | 23 | vi.mock('../src/lib/parse-feed', () => ({ 24 | parseFeed: vi.fn() 25 | })); 26 | 27 | vi.mock('../src/lib/sort-entries', () => ({ 28 | sortEntries: vi.fn() 29 | })); 30 | 31 | test('should return early if user is not authorized', async () => { 32 | vi.mocked(verifyToken).mockReturnValueOnce(false); 33 | const event = createEvent(); 34 | 35 | const { statusCode, body } = await handler(event); 36 | 37 | expect(statusCode).toEqual(403); 38 | expect(body).toMatch('Unauthorized'); 39 | }); 40 | 41 | test('should return early if url query param is malformed', async () => { 42 | vi.mocked(verifyToken).mockReturnValueOnce(true); 43 | const event = createEvent({ url: '%' }); 44 | 45 | const { statusCode, body } = await handler(event); 46 | 47 | expect(statusCode).toEqual(400); 48 | expect(body).toMatch('Malformed url query parameter'); 49 | }); 50 | 51 | test('should handle GET requests', async () => { 52 | vi.mocked(verifyToken).mockReturnValueOnce(true); 53 | vi.mocked(sortEntries).mockResolvedValueOnce([entry]); 54 | const event = createEvent(); 55 | 56 | const { statusCode, body } = await handler(event); 57 | const parsedBody = JSON.parse(body); 58 | 59 | expect(statusCode).toEqual(200); 60 | expect(parsedBody).toEqual([entry]); 61 | }); 62 | 63 | test('should reject unsupported methods', async () => { 64 | vi.mocked(verifyToken).mockReturnValueOnce(true); 65 | const event = createEvent(undefined, 'PATCH'); 66 | 67 | const { statusCode, body } = await handler(event); 68 | 69 | expect(statusCode).toEqual(405); 70 | expect(body).toMatch(`Unsupported method PATCH`); 71 | }); 72 | -------------------------------------------------------------------------------- /infra/entries-handler/test/lib/get-atom-entries.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import { test, expect } from 'vitest'; 4 | import { DOMParser } from 'linkedom'; 5 | import { getAtomEntries } from '../../src/lib/get-atom-entries'; 6 | 7 | const parser = new DOMParser(); 8 | 9 | const atomPath = path.resolve(__dirname, '../fixture/atom.xml'); 10 | const atomXml = fs.readFileSync(atomPath, 'utf-8'); 11 | const atomDoc = parser.parseFromString(atomXml, 'text/xml'); 12 | const emptyAtomDoc = parser.parseFromString('', 'text/xml'); 13 | const emptyRandomDoc = parser.parseFromString('', 'text/xml'); 14 | 15 | const atomEntry = { 16 | url: 'atom-url', 17 | title: 'atom-title', 18 | published: 'atom-published' 19 | }; 20 | 21 | test('should handle atom feeds', () => { 22 | const entries = getAtomEntries(atomDoc); 23 | expect(entries).toEqual([atomEntry, atomEntry]); 24 | }); 25 | 26 | test('should return no atom feed entries if there are none', () => { 27 | const entries = getAtomEntries(emptyAtomDoc); 28 | expect(entries).toEqual([]); 29 | }); 30 | 31 | test('should return no atom feed entries if the document is not an atom document', () => { 32 | const entries = getAtomEntries(emptyRandomDoc); 33 | expect(entries).toEqual([]); 34 | }); 35 | -------------------------------------------------------------------------------- /infra/entries-handler/test/lib/get-feed-format.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import { test, expect } from 'vitest'; 4 | import { DOMParser } from 'linkedom'; 5 | import { getFeedFormat } from '../../src/lib/get-feed-format'; 6 | import { RssFeedFormat } from '../../src/lib/types'; 7 | 8 | const parser = new DOMParser(); 9 | 10 | const atomPath = path.resolve(__dirname, '../fixture/atom.xml'); 11 | const rssPath = path.resolve(__dirname, '../fixture/rss.xml'); 12 | 13 | const atomXml = fs.readFileSync(atomPath, 'utf-8'); 14 | const rssXml = fs.readFileSync(rssPath, 'utf-8'); 15 | 16 | const atomDoc = parser.parseFromString(atomXml, 'text/xml'); 17 | const rssDoc = parser.parseFromString(rssXml, 'text/xml'); 18 | 19 | const randomDoc = parser.parseFromString('', 'text/xml'); 20 | 21 | test('should identify atom feeds', () => { 22 | const format = getFeedFormat(atomDoc); 23 | expect(format).toEqual(RssFeedFormat.atom); 24 | }); 25 | 26 | test('should identify rss feeds', () => { 27 | const format = getFeedFormat(rssDoc); 28 | expect(format).toEqual(RssFeedFormat.rss); 29 | }); 30 | 31 | test('should not identify unknown feeds', () => { 32 | const format = getFeedFormat(randomDoc); 33 | expect(format).toEqual(undefined); 34 | }); 35 | -------------------------------------------------------------------------------- /infra/entries-handler/test/lib/get-rss-entries.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import { test, expect } from 'vitest'; 4 | import { DOMParser } from 'linkedom'; 5 | import { getRssEntries } from '../../src/lib/get-rss-entries'; 6 | 7 | const parser = new DOMParser(); 8 | 9 | const rssPath = path.resolve(__dirname, '../fixture/rss.xml'); 10 | const rssXml = fs.readFileSync(rssPath, 'utf-8'); 11 | const rssDoc = parser.parseFromString(rssXml, 'text/xml'); 12 | const emptyRssDoc = parser.parseFromString('', 'text/xml'); 13 | const emptyRandomDoc = parser.parseFromString('', 'text/xml'); 14 | 15 | const rssEntry = { 16 | url: 'rss-url', 17 | title: 'rss-title', 18 | published: 'rss-published' 19 | }; 20 | 21 | test('should handle rss feeds', () => { 22 | const entries = getRssEntries(rssDoc); 23 | expect(entries).toEqual([rssEntry]); 24 | }); 25 | 26 | test('should return no rss feed entries if there are none', () => { 27 | const entries = getRssEntries(emptyRssDoc); 28 | expect(entries).toEqual([]); 29 | }); 30 | 31 | test('should return no rss feed entries if the document is not an rss document', () => { 32 | const entries = getRssEntries(emptyRandomDoc); 33 | expect(entries).toEqual([]); 34 | }); 35 | -------------------------------------------------------------------------------- /infra/entries-handler/test/lib/parse-feed.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, vi } from 'vitest'; 2 | import { parseFeed } from '../../src/lib/parse-feed'; 3 | import { getRssEntries } from '../../src/lib/get-rss-entries'; 4 | import { getAtomEntries } from '../../src/lib/get-atom-entries'; 5 | import { getFeedFormat } from '../../src/lib/get-feed-format'; 6 | import { RssFeedFormat } from '../../src/lib/types'; 7 | 8 | const url = 'some-url'; 9 | const xml = ''; 10 | const entry = { 11 | url: 'url', 12 | title: 'title', 13 | published: 'published' 14 | }; 15 | 16 | vi.mock('../../src/lib/get-feed-format', () => ({ 17 | getFeedFormat: vi.fn() 18 | })); 19 | 20 | vi.mock('../../src/lib/get-rss-entries', () => ({ 21 | getRssEntries: vi.fn() 22 | })); 23 | 24 | vi.mock('../../src/lib/get-atom-entries', () => ({ 25 | getAtomEntries: vi.fn() 26 | })); 27 | 28 | test('should error on feeds that are not a known format', () => { 29 | expect(() => parseFeed(url, xml)).toThrowError( 30 | `Unable to determine feed format of RSS feed '${url}'.` 31 | ); 32 | }); 33 | 34 | test('should return rss entries', () => { 35 | vi.mocked(getFeedFormat).mockReturnValueOnce(RssFeedFormat.rss); 36 | vi.mocked(getRssEntries).mockReturnValueOnce([entry]); 37 | 38 | const entries = parseFeed(url, xml); 39 | 40 | expect(entries).toEqual([entry]); 41 | }); 42 | 43 | test('should return atom entries', () => { 44 | vi.mocked(getFeedFormat).mockReturnValueOnce(RssFeedFormat.atom); 45 | vi.mocked(getAtomEntries).mockReturnValueOnce([entry]); 46 | 47 | const entries = parseFeed(url, xml); 48 | 49 | expect(entries).toEqual([entry]); 50 | }); 51 | -------------------------------------------------------------------------------- /infra/entries-handler/test/lib/sort-entries.test.ts: -------------------------------------------------------------------------------- 1 | import { test, vi, expect } from 'vitest'; 2 | import { sortEntries } from '../../src/lib/sort-entries'; 3 | import { getUnixTime } from '../../src/lib/get-unix-time'; 4 | import type { RssFeedEntries } from '../../src/lib/types'; 5 | 6 | const lastAccess = 1; 7 | 8 | const unsortedEntries: RssFeedEntries = [ 9 | { 10 | url: 'a-url', 11 | title: 'a-title', 12 | published: '' 13 | }, 14 | { 15 | url: 'b-url', 16 | title: 'b-title', 17 | published: '' 18 | } 19 | ]; 20 | 21 | vi.mock('../../src/lib/get-unix-time', () => { 22 | return { 23 | getUnixTime: vi.fn() 24 | }; 25 | }); 26 | 27 | vi.mock('../../src/lib/last-access-table', () => { 28 | return { 29 | LastAccessTable: class LastAccessTable { 30 | async getLastAccess() { 31 | return lastAccess; 32 | } 33 | } 34 | }; 35 | }); 36 | 37 | test('should sort entries with different publish times by new first', async () => { 38 | vi.mocked(getUnixTime).mockReturnValueOnce(0); // First entry published key mock 39 | vi.mocked(getUnixTime).mockReturnValueOnce(2); // Second entry published key mock 40 | 41 | const [firstEntry, secondEntry] = await sortEntries(unsortedEntries); 42 | 43 | expect(firstEntry.url).toEqual('b-url'); 44 | expect(firstEntry.isNew).toEqual(true); 45 | expect(secondEntry.url).toEqual('a-url'); 46 | expect(secondEntry.isNew).toEqual(false); 47 | }); 48 | 49 | test('should leave the same entry order on error', async () => { 50 | // Not mocking `getUnixTime` will throw an error since the published key is an empty string for entries 51 | 52 | const [firstEntry, secondEntry] = await sortEntries(unsortedEntries); 53 | 54 | expect(firstEntry).toEqual(unsortedEntries[0]); 55 | expect(secondEntry).toEqual(unsortedEntries[1]); 56 | }); 57 | -------------------------------------------------------------------------------- /infra/entries-handler/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "moduleResolution": "node" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /infra/entries-handler/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['./test/**/*.test.ts'], 6 | environment: 'node', 7 | watch: false, 8 | mockReset: true 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /infra/entries-lambda.tf: -------------------------------------------------------------------------------- 1 | resource "aws_lambda_function" "t-rss-reader-entries-handler" { 2 | filename = "./entries-handler/entries-handler.zip" 3 | function_name = "t-rss-reader-entries-handler" 4 | memory_size = 7076 # 1,769 per vCPU, so ~4 vCPUs 5 | role = aws_iam_role.t-rss-reader-entries-handler-iam-role.arn 6 | handler = "./dist/index.handler" 7 | source_code_hash = filebase64sha256("./entries-handler/dist/index.js") 8 | runtime = "nodejs18.x" 9 | environment { 10 | variables = { 11 | T_RSS_READER_PASSWORD = var.t-rss-reader-password 12 | } 13 | } 14 | } 15 | 16 | resource "aws_cloudwatch_log_group" "t-rss-reader-entries-handler-log-group" { 17 | name = "/aws/lambda/${aws_lambda_function.t-rss-reader-entries-handler.function_name}" 18 | retention_in_days = 30 19 | } 20 | 21 | resource "aws_iam_role" "t-rss-reader-entries-handler-iam-role" { 22 | name = "t-rss-reader-entries-handler-iam" 23 | assume_role_policy = jsonencode({ 24 | "Version" = "2012-10-17" 25 | "Statement" = [ 26 | { 27 | "Action" = "sts:AssumeRole" 28 | "Effect" = "Allow" 29 | "Sid" = "" 30 | "Principal" = { 31 | "Service" = "lambda.amazonaws.com" 32 | } 33 | } 34 | ] 35 | }) 36 | } 37 | 38 | resource "aws_iam_role_policy" "t-rss-reader-entries-handler-iam-policy" { 39 | name = "t-rss-reader-entries-handler-iam-policy" 40 | role = aws_iam_role.t-rss-reader-entries-handler-iam-role.id 41 | policy = jsonencode({ 42 | "Version" = "2012-10-17" 43 | "Statement" = [ 44 | { 45 | "Effect" = "Allow", 46 | "Action" = [ 47 | "dynamodb:GetItem", 48 | ], 49 | "Resource" = "arn:aws:dynamodb:${var.aws-region}:${data.aws_caller_identity.current.account_id}:table/*" 50 | }, 51 | { 52 | "Effect" = "Allow", 53 | "Action" = "logs:CreateLogGroup", 54 | "Resource" = "*" 55 | }, 56 | { 57 | "Effect" = "Allow", 58 | "Action" = [ 59 | "logs:CreateLogStream", 60 | "logs:PutLogEvents" 61 | ], 62 | "Resource" = [ 63 | "arn:aws:logs:${var.aws-region}:${data.aws_caller_identity.current.account_id}:*" 64 | ] 65 | } 66 | ] 67 | }) 68 | } 69 | 70 | resource "aws_lambda_permission" "t-rss-reader-entries-handler-permission-api" { 71 | statement_id = "AllowExecutionFromAPIGateway" 72 | action = "lambda:InvokeFunction" 73 | function_name = aws_lambda_function.t-rss-reader-entries-handler.function_name 74 | principal = "apigateway.amazonaws.com" 75 | source_arn = "${aws_apigatewayv2_api.t-rss-reader-entries-handler-api.execution_arn}/*/*" 76 | } 77 | -------------------------------------------------------------------------------- /infra/feeds-api-gateway.tf: -------------------------------------------------------------------------------- 1 | resource "aws_apigatewayv2_api" "t-rss-reader-feeds-handler-api" { 2 | name = "t-rss-reader-feeds-handler-api" 3 | protocol_type = "HTTP" 4 | 5 | cors_configuration { 6 | allow_origins = var.t-rss-reader-allow-origins 7 | allow_methods = ["DELETE", "GET", "PUT"] 8 | allow_headers = ["content-type", "authorization"] 9 | allow_credentials = true 10 | max_age = 7200 11 | } 12 | } 13 | 14 | resource "aws_cloudwatch_log_group" "t-rss-reader-feeds-handler-api-log-group" { 15 | name = "/aws/t-rss-reader-feeds-handler-api-log-group/${aws_apigatewayv2_api.t-rss-reader-feeds-handler-api.name}" 16 | retention_in_days = 30 17 | } 18 | 19 | resource "aws_apigatewayv2_stage" "t-rss-reader-feeds-handler-api-stage" { 20 | api_id = aws_apigatewayv2_api.t-rss-reader-feeds-handler-api.id 21 | name = "default" 22 | auto_deploy = true 23 | 24 | access_log_settings { 25 | destination_arn = aws_cloudwatch_log_group.t-rss-reader-feeds-handler-api-log-group.arn 26 | format = jsonencode({ 27 | requestId = "$context.requestId" 28 | sourceIp = "$context.identity.sourceIp" 29 | requestTime = "$context.requestTime" 30 | protocol = "$context.protocol" 31 | httpMethod = "$context.httpMethod" 32 | resourcePath = "$context.resourcePath" 33 | routeKey = "$context.routeKey" 34 | status = "$context.status" 35 | responseLength = "$context.responseLength" 36 | integrationErrorMessage = "$context.integrationErrorMessage" 37 | } 38 | ) 39 | } 40 | } 41 | 42 | resource "aws_apigatewayv2_integration" "t-rss-reader-feeds-handler-api-integration" { 43 | api_id = aws_apigatewayv2_api.t-rss-reader-feeds-handler-api.id 44 | integration_type = "AWS_PROXY" 45 | integration_method = "POST" 46 | integration_uri = aws_lambda_function.t-rss-reader-feeds-handler.invoke_arn 47 | } 48 | 49 | resource "aws_apigatewayv2_route" "t-rss-reader-feeds-handler-api-route-delete" { 50 | api_id = aws_apigatewayv2_api.t-rss-reader-feeds-handler-api.id 51 | route_key = "DELETE /feeds" 52 | target = "integrations/${aws_apigatewayv2_integration.t-rss-reader-feeds-handler-api-integration.id}" 53 | } 54 | 55 | resource "aws_apigatewayv2_route" "t-rss-reader-feeds-handler-api-route-get" { 56 | api_id = aws_apigatewayv2_api.t-rss-reader-feeds-handler-api.id 57 | route_key = "GET /feeds" 58 | target = "integrations/${aws_apigatewayv2_integration.t-rss-reader-feeds-handler-api-integration.id}" 59 | } 60 | 61 | resource "aws_apigatewayv2_route" "t-rss-reader-feeds-handler-api-route-put" { 62 | api_id = aws_apigatewayv2_api.t-rss-reader-feeds-handler-api.id 63 | route_key = "PUT /feeds" 64 | target = "integrations/${aws_apigatewayv2_integration.t-rss-reader-feeds-handler-api-integration.id}" 65 | } 66 | -------------------------------------------------------------------------------- /infra/feeds-dynamo-db.tf: -------------------------------------------------------------------------------- 1 | resource "aws_dynamodb_table" "t-rss-reader-feeds-table" { 2 | name = "t-rss-reader-feeds-table" 3 | billing_mode = "PAY_PER_REQUEST" 4 | hash_key = "url" 5 | 6 | attribute { 7 | name = "url" 8 | type = "S" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /infra/feeds-handler/Makefile: -------------------------------------------------------------------------------- 1 | targets = install prepare test clean 2 | 3 | .PHONY: help $(targets) 4 | 5 | help: 6 | @echo "Available targets: $(targets)" 7 | 8 | install: 9 | npm install 10 | 11 | prepare: 12 | npm run build 13 | npm run zip 14 | 15 | test: 16 | npm run test 17 | 18 | clean: 19 | npm run clean -------------------------------------------------------------------------------- /infra/feeds-handler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": "true", 3 | "name": "feeds-handler", 4 | "description": "Lambda function that handles CRUD operations for the feeds DynamoDB table", 5 | "type": "module", 6 | "engines": { 7 | "node": ">=18.0.0" 8 | }, 9 | "scripts": { 10 | "clean": "del dist feeds-handler.zip", 11 | "build": "ncc build ./src/index.ts --out dist --external @aws-sdk/client-dynamodb --external @aws-sdk/lib-dynamodb --minify", 12 | "zip": "zip -r feeds-handler.zip ./dist", 13 | "test": "vitest" 14 | }, 15 | "dependencies": { 16 | "@aws-sdk/client-dynamodb": "^3.272.0", 17 | "@aws-sdk/lib-dynamodb": "^3.272.0", 18 | "jsonwebtoken": "^9.0.0" 19 | }, 20 | "devDependencies": { 21 | "@types/aws-lambda": "^8.10.110", 22 | "@types/jsonwebtoken": "^9.0.1", 23 | "@vercel/ncc": "^0.36.1", 24 | "del-cli": "^5.0.0", 25 | "typescript": "^4.9.5", 26 | "vitest": "^0.28.5" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /infra/feeds-handler/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { APIGatewayEvent } from 'aws-lambda'; 2 | import { verifyToken } from './lib/verify-token'; 3 | import { FeedsTable } from './lib/feeds-table'; 4 | 5 | interface RequestBody { 6 | url?: string; 7 | name?: string; 8 | } 9 | 10 | const headers = { 11 | 'Content-Type': 'application/json' 12 | }; 13 | 14 | export const handler = async (event: APIGatewayEvent) => { 15 | const verified = verifyToken(event?.headers?.authorization); 16 | 17 | if (!verified) { 18 | return { 19 | statusCode: 403, 20 | body: JSON.stringify({ message: 'Unauthorized' }), 21 | headers 22 | }; 23 | } 24 | 25 | let requestBody: RequestBody; 26 | 27 | try { 28 | requestBody = JSON.parse(event.body); 29 | } catch (error) { 30 | return { 31 | statusCode: 400, 32 | body: JSON.stringify({ message: 'Malformed request body' }), 33 | headers 34 | }; 35 | } 36 | 37 | let responseBody = {}; 38 | let responseHeaders = headers; 39 | 40 | try { 41 | const feedsTableInstance = new FeedsTable(); 42 | 43 | switch (event.httpMethod) { 44 | case 'DELETE': 45 | responseBody = await feedsTableInstance.deleteFeed(requestBody.url); 46 | break; 47 | case 'GET': 48 | responseBody = await feedsTableInstance.getFeeds(); 49 | responseHeaders['Cache-Control'] = `max-age=5`; 50 | break; 51 | case 'PUT': 52 | responseBody = await feedsTableInstance.putFeed(requestBody.url, requestBody.name); 53 | break; 54 | default: 55 | return { 56 | statusCode: 405, 57 | body: JSON.stringify({ message: `Unsupported method ${event.httpMethod}` }), 58 | headers: responseHeaders 59 | }; 60 | } 61 | 62 | return { 63 | statusCode: 200, 64 | body: JSON.stringify(responseBody), 65 | headers: responseHeaders 66 | }; 67 | } catch (err) { 68 | console.error(err.message); 69 | 70 | return { 71 | statusCode: 500, 72 | body: JSON.stringify({ message: 'Operation failed' }), 73 | headers: responseHeaders 74 | }; 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /infra/feeds-handler/src/lib/feeds-table.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 2 | import { 3 | DynamoDBDocumentClient, 4 | ScanCommand, 5 | PutCommand, 6 | DeleteCommand 7 | } from '@aws-sdk/lib-dynamodb'; 8 | 9 | interface FeedItem { 10 | url: string; 11 | name: string; 12 | createdAt: number; 13 | } 14 | 15 | type FeedItems = Array; 16 | 17 | export class FeedsTable { 18 | private tableName: string = 't-rss-reader-feeds-table'; 19 | private dbClientInstance: DynamoDBClient; 20 | private dbDocClientInstance: DynamoDBDocumentClient; 21 | 22 | constructor() { 23 | this.dbClientInstance = new DynamoDBClient({}); 24 | this.dbDocClientInstance = DynamoDBDocumentClient.from(this.dbClientInstance); 25 | } 26 | 27 | async deleteFeed(feedUrl: string): Promise<{ message: string }> { 28 | await this.dbDocClientInstance.send( 29 | new DeleteCommand({ 30 | TableName: this.tableName, 31 | Key: { 32 | url: feedUrl 33 | } 34 | }) 35 | ); 36 | 37 | return { message: `Successfully deleted ${feedUrl}` }; 38 | } 39 | 40 | async getFeeds(): Promise { 41 | const response = await this.dbDocClientInstance.send( 42 | new ScanCommand({ TableName: this.tableName }) 43 | ); 44 | 45 | return (response?.Items as FeedItems).sort((a, b) => a.name.localeCompare(b.name)); 46 | } 47 | 48 | async putFeed(feedUrl: string, feedName: string): Promise<{ message: string; feed: FeedItem }> { 49 | const item: FeedItem = { 50 | url: feedUrl, 51 | name: feedName, 52 | createdAt: Date.now() 53 | }; 54 | 55 | await this.dbDocClientInstance.send( 56 | new PutCommand({ 57 | TableName: this.tableName, 58 | Item: item 59 | }) 60 | ); 61 | 62 | return { 63 | message: `Successfully put ${item.url}`, 64 | feed: item 65 | }; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /infra/feeds-handler/src/lib/verify-token.ts: -------------------------------------------------------------------------------- 1 | import { verify } from 'jsonwebtoken'; 2 | 3 | export function verifyToken(authorization: string): boolean { 4 | try { 5 | verify(authorization, process.env.T_RSS_READER_PASSWORD); 6 | return true; 7 | } catch (error) { 8 | console.error(error.message); 9 | return false; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /infra/feeds-handler/test/fixture/create-event.ts: -------------------------------------------------------------------------------- 1 | import type { APIGatewayEvent, APIGatewayEventRequestContext } from 'aws-lambda'; 2 | 3 | const event: Omit = { 4 | body: '{}', 5 | headers: { 6 | 'Content-Type': 'application/json' 7 | }, 8 | multiValueHeaders: {}, 9 | isBase64Encoded: false, 10 | pathParameters: null, 11 | queryStringParameters: null, 12 | multiValueQueryStringParameters: null, 13 | stageVariables: null, 14 | requestContext: {} as APIGatewayEventRequestContext 15 | }; 16 | 17 | export function createEvent(resource: string, httpMethod: string): APIGatewayEvent { 18 | return { ...event, resource, path: resource, httpMethod }; 19 | } 20 | -------------------------------------------------------------------------------- /infra/feeds-handler/test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { test, vi, expect } from 'vitest'; 2 | import { createEvent } from './fixture/create-event'; 3 | import { verifyToken } from '../src/lib/verify-token'; 4 | import { handler } from '../src/index'; 5 | 6 | const feedUrl = 'https://tyhopp.com/rss.xml'; 7 | const feedName = `Ty Hopp's feed`; 8 | const feed = { 9 | name: feedName, 10 | url: feedUrl, 11 | createdAt: 123 12 | }; 13 | 14 | vi.mock('../src/lib/verify-token', () => ({ 15 | verifyToken: vi.fn() 16 | })); 17 | 18 | vi.mock('../src/lib/feeds-table', () => { 19 | return { 20 | FeedsTable: class FeedsTable { 21 | async deleteFeed() { 22 | return { 23 | message: `Successfully deleted ${feedUrl}` 24 | }; 25 | } 26 | async getFeeds() { 27 | return [feed]; 28 | } 29 | async putFeed() { 30 | return { 31 | message: `Successfully put ${feedUrl}`, 32 | feed 33 | }; 34 | } 35 | } 36 | }; 37 | }); 38 | 39 | test('should return early if user is not authorized', async () => { 40 | vi.mocked(verifyToken).mockReturnValueOnce(false); 41 | const event = createEvent('feeds', ''); 42 | 43 | const { statusCode, body } = await handler(event); 44 | 45 | expect(statusCode).toEqual(403); 46 | expect(body).toMatch('Unauthorized'); 47 | }); 48 | 49 | test('should return early if request body is malformed', async () => { 50 | vi.mocked(verifyToken).mockReturnValueOnce(true); 51 | const event = createEvent('feeds', ''); 52 | event.body = 'invalid-json'; 53 | 54 | const { statusCode, body } = await handler(event); 55 | 56 | expect(statusCode).toEqual(400); 57 | expect(body).toMatch('Malformed request body'); 58 | }); 59 | 60 | test('should handle DELETE requests', async () => { 61 | vi.mocked(verifyToken).mockReturnValueOnce(true); 62 | const event = createEvent('feeds', 'DELETE'); 63 | event.body = JSON.stringify({ url: feedUrl }); 64 | 65 | const { statusCode, body } = await handler(event); 66 | const { message } = JSON.parse(body); 67 | 68 | expect(statusCode).toEqual(200); 69 | expect(message).toMatch(`Successfully deleted ${feedUrl}`); 70 | }); 71 | 72 | test('should handle GET requests', async () => { 73 | vi.mocked(verifyToken).mockReturnValueOnce(true); 74 | const event = createEvent('feeds', 'GET'); 75 | 76 | const { statusCode, body } = await handler(event); 77 | const parsedBody = JSON.parse(body); 78 | 79 | expect(statusCode).toEqual(200); 80 | expect(parsedBody).toEqual([feed]); 81 | }); 82 | 83 | test('should handle PUT requests', async () => { 84 | vi.mocked(verifyToken).mockReturnValueOnce(true); 85 | const event = createEvent('feeds', 'PUT'); 86 | event.body = JSON.stringify({ 87 | url: feedUrl, 88 | name: feedName 89 | }); 90 | 91 | const { statusCode, body } = await handler(event); 92 | const parsedBody = JSON.parse(body); 93 | 94 | expect(statusCode).toEqual(200); 95 | expect(parsedBody.message).toMatch(`Successfully put ${feedUrl}`); 96 | expect(parsedBody.feed).toEqual(feed); 97 | }); 98 | 99 | test('should reject unsupported methods', async () => { 100 | vi.mocked(verifyToken).mockReturnValueOnce(true); 101 | const event = createEvent('feeds', 'PATCH'); 102 | 103 | const { statusCode, body } = await handler(event); 104 | 105 | expect(statusCode).toEqual(405); 106 | expect(body).toMatch(`Unsupported method PATCH`); 107 | }); 108 | -------------------------------------------------------------------------------- /infra/feeds-handler/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "moduleResolution": "node" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /infra/feeds-handler/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['./test/**/*.test.ts'], 6 | environment: 'node', 7 | watch: false, 8 | mockReset: true 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /infra/feeds-lambda.tf: -------------------------------------------------------------------------------- 1 | resource "aws_lambda_function" "t-rss-reader-feeds-handler" { 2 | filename = "./feeds-handler/feeds-handler.zip" 3 | function_name = "t-rss-reader-feeds-handler" 4 | memory_size = 7076 # 1,769 per vCPU, so ~4 vCPUs 5 | role = aws_iam_role.t-rss-reader-feeds-handler-iam-role.arn 6 | handler = "./dist/index.handler" 7 | source_code_hash = filebase64sha256("./feeds-handler/dist/index.js") 8 | runtime = "nodejs18.x" 9 | environment { 10 | variables = { 11 | T_RSS_READER_PASSWORD = var.t-rss-reader-password 12 | } 13 | } 14 | } 15 | 16 | resource "aws_cloudwatch_log_group" "t-rss-reader-feeds-handler-log-group" { 17 | name = "/aws/lambda/${aws_lambda_function.t-rss-reader-feeds-handler.function_name}" 18 | retention_in_days = 30 19 | } 20 | 21 | resource "aws_iam_role" "t-rss-reader-feeds-handler-iam-role" { 22 | name = "t-rss-reader-feeds-handler-iam" 23 | assume_role_policy = jsonencode({ 24 | "Version" = "2012-10-17" 25 | "Statement" = [ 26 | { 27 | "Action" = "sts:AssumeRole" 28 | "Effect" = "Allow" 29 | "Sid" = "" 30 | "Principal" = { 31 | "Service" = "lambda.amazonaws.com" 32 | } 33 | } 34 | ] 35 | }) 36 | } 37 | 38 | resource "aws_iam_role_policy" "t-rss-reader-feeds-handler-iam-policy" { 39 | name = "t-rss-reader-feeds-handler-iam-policy" 40 | role = aws_iam_role.t-rss-reader-feeds-handler-iam-role.id 41 | policy = jsonencode({ 42 | "Version" = "2012-10-17" 43 | "Statement" = [ 44 | { 45 | "Effect" = "Allow", 46 | "Action" = [ 47 | "dynamodb:DeleteItem", 48 | "dynamodb:PutItem", 49 | "dynamodb:Scan" 50 | ], 51 | "Resource" = "arn:aws:dynamodb:${var.aws-region}:${data.aws_caller_identity.current.account_id}:table/*" 52 | }, 53 | { 54 | "Effect" = "Allow", 55 | "Action" = "logs:CreateLogGroup", 56 | "Resource" = "*" 57 | }, 58 | { 59 | "Effect" = "Allow", 60 | "Action" = [ 61 | "logs:CreateLogStream", 62 | "logs:PutLogEvents" 63 | ], 64 | "Resource" = [ 65 | "arn:aws:logs:${var.aws-region}:${data.aws_caller_identity.current.account_id}:*" 66 | ] 67 | } 68 | ] 69 | }) 70 | } 71 | 72 | resource "aws_lambda_permission" "t-rss-reader-feeds-handler-permission-api" { 73 | statement_id = "AllowExecutionFromAPIGateway" 74 | action = "lambda:InvokeFunction" 75 | function_name = aws_lambda_function.t-rss-reader-feeds-handler.function_name 76 | principal = "apigateway.amazonaws.com" 77 | source_arn = "${aws_apigatewayv2_api.t-rss-reader-feeds-handler-api.execution_arn}/*/*" 78 | } 79 | -------------------------------------------------------------------------------- /infra/last-access-api-gateway.tf: -------------------------------------------------------------------------------- 1 | resource "aws_apigatewayv2_api" "t-rss-reader-last-access-handler-api" { 2 | name = "t-rss-reader-last-access-handler-api" 3 | protocol_type = "HTTP" 4 | 5 | cors_configuration { 6 | allow_origins = var.t-rss-reader-allow-origins 7 | allow_methods = ["PUT"] 8 | allow_headers = ["content-type", "authorization"] 9 | allow_credentials = true 10 | max_age = 7200 11 | } 12 | } 13 | 14 | resource "aws_cloudwatch_log_group" "t-rss-reader-last-access-handler-api-log-group" { 15 | name = "/aws/t-rss-reader-last-access-handler-api-log-group/${aws_apigatewayv2_api.t-rss-reader-last-access-handler-api.name}" 16 | retention_in_days = 30 17 | } 18 | 19 | resource "aws_apigatewayv2_stage" "t-rss-reader-last-access-handler-api-stage" { 20 | api_id = aws_apigatewayv2_api.t-rss-reader-last-access-handler-api.id 21 | name = "default" 22 | auto_deploy = true 23 | 24 | access_log_settings { 25 | destination_arn = aws_cloudwatch_log_group.t-rss-reader-last-access-handler-api-log-group.arn 26 | format = jsonencode({ 27 | requestId = "$context.requestId" 28 | sourceIp = "$context.identity.sourceIp" 29 | requestTime = "$context.requestTime" 30 | protocol = "$context.protocol" 31 | httpMethod = "$context.httpMethod" 32 | resourcePath = "$context.resourcePath" 33 | routeKey = "$context.routeKey" 34 | status = "$context.status" 35 | responseLength = "$context.responseLength" 36 | integrationErrorMessage = "$context.integrationErrorMessage" 37 | } 38 | ) 39 | } 40 | } 41 | 42 | resource "aws_apigatewayv2_integration" "t-rss-reader-last-access-handler-api-integration" { 43 | api_id = aws_apigatewayv2_api.t-rss-reader-last-access-handler-api.id 44 | integration_type = "AWS_PROXY" 45 | integration_method = "POST" 46 | integration_uri = aws_lambda_function.t-rss-reader-last-access-handler.invoke_arn 47 | } 48 | 49 | resource "aws_apigatewayv2_route" "t-rss-reader-last-access-handler-api-route-put" { 50 | api_id = aws_apigatewayv2_api.t-rss-reader-last-access-handler-api.id 51 | route_key = "PUT /last-access" 52 | target = "integrations/${aws_apigatewayv2_integration.t-rss-reader-last-access-handler-api-integration.id}" 53 | } 54 | -------------------------------------------------------------------------------- /infra/last-access-dynamo-db.tf: -------------------------------------------------------------------------------- 1 | resource "aws_dynamodb_table" "t-rss-reader-last-access-table" { 2 | name = "t-rss-reader-last-access-table" 3 | billing_mode = "PAY_PER_REQUEST" 4 | hash_key = "id" 5 | 6 | attribute { 7 | name = "id" 8 | type = "N" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /infra/last-access-handler/Makefile: -------------------------------------------------------------------------------- 1 | targets = install prepare test clean 2 | 3 | .PHONY: help $(targets) 4 | 5 | help: 6 | @echo "Available targets: $(targets)" 7 | 8 | install: 9 | npm install 10 | 11 | prepare: 12 | npm run build 13 | npm run zip 14 | 15 | test: 16 | npm run test 17 | 18 | clean: 19 | npm run clean -------------------------------------------------------------------------------- /infra/last-access-handler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": "true", 3 | "name": "last-access-handler", 4 | "description": "Lambda function used to store the last access time of the application in DynamoDB", 5 | "type": "module", 6 | "engines": { 7 | "node": ">=18.0.0" 8 | }, 9 | "scripts": { 10 | "clean": "del dist last-access-handler.zip", 11 | "build": "ncc build ./src/index.ts --out dist --external @aws-sdk/client-dynamodb --external @aws-sdk/lib-dynamodb --minify", 12 | "zip": "zip -r last-access-handler.zip ./dist", 13 | "test": "vitest" 14 | }, 15 | "dependencies": { 16 | "@aws-sdk/client-dynamodb": "^3.284.0", 17 | "@aws-sdk/lib-dynamodb": "^3.284.0", 18 | "jsonwebtoken": "^9.0.0" 19 | }, 20 | "devDependencies": { 21 | "@types/aws-lambda": "^8.10.110", 22 | "@types/jsonwebtoken": "^9.0.1", 23 | "@vercel/ncc": "^0.36.1", 24 | "del-cli": "^5.0.0", 25 | "prettier": "^2.8.4", 26 | "typescript": "^4.9.5", 27 | "vitest": "^0.28.5" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /infra/last-access-handler/src/index.ts: -------------------------------------------------------------------------------- 1 | import { verifyToken } from './lib/verify-token'; 2 | import { LastAccessTable } from './lib/last-access-table'; 3 | import type { APIGatewayEvent } from 'aws-lambda'; 4 | 5 | const headers = { 6 | 'Content-Type': 'application/json' 7 | }; 8 | 9 | export const handler = async (event: APIGatewayEvent) => { 10 | const verified = verifyToken(event?.headers?.authorization); 11 | 12 | if (!verified) { 13 | return { 14 | statusCode: 403, 15 | body: JSON.stringify({ message: 'Unauthorized' }), 16 | headers 17 | }; 18 | } 19 | 20 | let responseBody: { message: string }; 21 | 22 | try { 23 | const lastAccessTableInstance = new LastAccessTable(); 24 | 25 | switch (event.httpMethod) { 26 | case 'PUT': 27 | responseBody = await lastAccessTableInstance.putLastAccess(); 28 | break; 29 | default: 30 | return { 31 | statusCode: 405, 32 | body: JSON.stringify({ message: `Unsupported method ${event.httpMethod}` }), 33 | headers 34 | }; 35 | } 36 | 37 | return { 38 | statusCode: 200, 39 | body: JSON.stringify(responseBody), 40 | headers 41 | }; 42 | } catch (err) { 43 | console.error(err.message); 44 | 45 | return { 46 | statusCode: 500, 47 | body: JSON.stringify({ message: 'Operation failed' }), 48 | headers 49 | }; 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /infra/last-access-handler/src/lib/last-access-table.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 2 | import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; 3 | 4 | export class LastAccessTable { 5 | private tableName: string = 't-rss-reader-last-access-table'; 6 | private dbClientInstance: DynamoDBClient; 7 | private dbDocClientInstance: DynamoDBDocumentClient; 8 | private id: number = 0; 9 | 10 | constructor() { 11 | this.dbClientInstance = new DynamoDBClient({}); 12 | this.dbDocClientInstance = DynamoDBDocumentClient.from(this.dbClientInstance); 13 | } 14 | 15 | async putLastAccess(): Promise<{ message: string }> { 16 | const lastAccess: number = Date.now(); 17 | 18 | await this.dbDocClientInstance.send( 19 | new PutCommand({ 20 | TableName: this.tableName, 21 | Item: { 22 | id: this.id, 23 | lastAccess 24 | } 25 | }) 26 | ); 27 | 28 | return { 29 | message: `Successfully put last access: ${lastAccess}` 30 | }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /infra/last-access-handler/src/lib/verify-token.ts: -------------------------------------------------------------------------------- 1 | import { verify } from 'jsonwebtoken'; 2 | 3 | export function verifyToken(authorization: string): boolean { 4 | try { 5 | verify(authorization, process.env.T_RSS_READER_PASSWORD); 6 | return true; 7 | } catch (error) { 8 | console.error(error.message); 9 | return false; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /infra/last-access-handler/test/fixture/create-event.ts: -------------------------------------------------------------------------------- 1 | import type { APIGatewayEvent, APIGatewayEventRequestContext } from 'aws-lambda'; 2 | 3 | const event: Omit = { 4 | resource: 'last-access', 5 | path: 'last-access', 6 | body: '{}', 7 | headers: { 8 | 'Content-Type': 'application/json' 9 | }, 10 | multiValueHeaders: {}, 11 | isBase64Encoded: false, 12 | pathParameters: null, 13 | queryStringParameters: null, 14 | multiValueQueryStringParameters: null, 15 | stageVariables: null, 16 | requestContext: {} as APIGatewayEventRequestContext 17 | }; 18 | 19 | export function createEvent(httpMethod): APIGatewayEvent { 20 | return { ...event, httpMethod }; 21 | } 22 | -------------------------------------------------------------------------------- /infra/last-access-handler/test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { test, vi, expect } from 'vitest'; 2 | import { createEvent } from './fixture/create-event'; 3 | import { verifyToken } from '../src/lib/verify-token'; 4 | import { handler } from '../src/index'; 5 | 6 | const lastAccess = 123; 7 | 8 | vi.mock('../src/lib/verify-token', () => ({ 9 | verifyToken: vi.fn() 10 | })); 11 | 12 | vi.mock('../src/lib/last-access-table', () => { 13 | return { 14 | LastAccessTable: class LastAccessTable { 15 | async putLastAccess() { 16 | return { 17 | message: `Successfully put last access: ${lastAccess}` 18 | }; 19 | } 20 | } 21 | }; 22 | }); 23 | 24 | test('should return early if user is not authorized', async () => { 25 | vi.mocked(verifyToken).mockReturnValueOnce(false); 26 | const event = createEvent('PUT'); 27 | 28 | const { statusCode, body } = await handler(event); 29 | 30 | expect(statusCode).toEqual(403); 31 | expect(body).toMatch('Unauthorized'); 32 | }); 33 | 34 | test('should handle PUT requests', async () => { 35 | vi.mocked(verifyToken).mockReturnValueOnce(true); 36 | const event = createEvent('PUT'); 37 | 38 | const { statusCode, body } = await handler(event); 39 | const parsedBody = JSON.parse(body); 40 | 41 | expect(statusCode).toEqual(200); 42 | expect(parsedBody.message).toMatch(`Successfully put last access: ${lastAccess}`); 43 | }); 44 | 45 | test('should reject unsupported methods', async () => { 46 | vi.mocked(verifyToken).mockReturnValueOnce(true); 47 | const event = createEvent('PATCH'); 48 | 49 | const { statusCode, body } = await handler(event); 50 | 51 | expect(statusCode).toEqual(405); 52 | expect(body).toMatch(`Unsupported method PATCH`); 53 | }); 54 | -------------------------------------------------------------------------------- /infra/last-access-handler/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "moduleResolution": "node" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /infra/last-access-handler/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['./test/**/*.test.ts'], 6 | environment: 'node', 7 | watch: false 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /infra/last-access-lambda.tf: -------------------------------------------------------------------------------- 1 | resource "aws_lambda_function" "t-rss-reader-last-access-handler" { 2 | filename = "./last-access-handler/last-access-handler.zip" 3 | function_name = "t-rss-reader-last-access-handler" 4 | memory_size = 7076 # 1,769 per vCPU, so ~4 vCPUs 5 | role = aws_iam_role.t-rss-reader-last-access-handler-iam-role.arn 6 | handler = "./dist/index.handler" 7 | source_code_hash = filebase64sha256("./last-access-handler/dist/index.js") 8 | runtime = "nodejs18.x" 9 | environment { 10 | variables = { 11 | T_RSS_READER_PASSWORD = var.t-rss-reader-password 12 | } 13 | } 14 | } 15 | 16 | resource "aws_cloudwatch_log_group" "t-rss-reader-last-access-handler-log-group" { 17 | name = "/aws/lambda/${aws_lambda_function.t-rss-reader-last-access-handler.function_name}" 18 | retention_in_days = 30 19 | } 20 | 21 | resource "aws_iam_role" "t-rss-reader-last-access-handler-iam-role" { 22 | name = "t-rss-reader-last-access-handler-iam" 23 | assume_role_policy = jsonencode({ 24 | "Version" = "2012-10-17" 25 | "Statement" = [ 26 | { 27 | "Action" = "sts:AssumeRole" 28 | "Effect" = "Allow" 29 | "Sid" = "" 30 | "Principal" = { 31 | "Service" = "lambda.amazonaws.com" 32 | } 33 | } 34 | ] 35 | }) 36 | } 37 | 38 | resource "aws_iam_role_policy" "t-rss-reader-last-access-handler-iam-policy" { 39 | name = "t-rss-reader-last-access-handler-iam-policy" 40 | role = aws_iam_role.t-rss-reader-last-access-handler-iam-role.id 41 | policy = jsonencode({ 42 | "Version" = "2012-10-17" 43 | "Statement" = [ 44 | { 45 | "Effect" = "Allow", 46 | "Action" = [ 47 | "dynamodb:PutItem", 48 | ], 49 | "Resource" = "arn:aws:dynamodb:${var.aws-region}:${data.aws_caller_identity.current.account_id}:table/*" 50 | }, 51 | { 52 | "Effect" = "Allow", 53 | "Action" = "logs:CreateLogGroup", 54 | "Resource" = "*" 55 | }, 56 | { 57 | "Effect" = "Allow", 58 | "Action" = [ 59 | "logs:CreateLogStream", 60 | "logs:PutLogEvents" 61 | ], 62 | "Resource" = [ 63 | "arn:aws:logs:${var.aws-region}:${data.aws_caller_identity.current.account_id}:*" 64 | ] 65 | } 66 | ] 67 | }) 68 | } 69 | 70 | resource "aws_lambda_permission" "t-rss-reader-last-access-handler-permission-api" { 71 | statement_id = "AllowExecutionFromAPIGateway" 72 | action = "lambda:InvokeFunction" 73 | function_name = aws_lambda_function.t-rss-reader-last-access-handler.function_name 74 | principal = "apigateway.amazonaws.com" 75 | source_arn = "${aws_apigatewayv2_api.t-rss-reader-last-access-handler-api.execution_arn}/*/*" 76 | } 77 | -------------------------------------------------------------------------------- /infra/login-api-gateway.tf: -------------------------------------------------------------------------------- 1 | resource "aws_apigatewayv2_api" "t-rss-reader-login-handler-api" { 2 | name = "t-rss-reader-login-handler-api" 3 | protocol_type = "HTTP" 4 | 5 | cors_configuration { 6 | allow_origins = var.t-rss-reader-allow-origins 7 | allow_methods = ["POST"] 8 | allow_headers = ["content-type", "authorization"] 9 | allow_credentials = true 10 | max_age = 7200 11 | } 12 | } 13 | 14 | resource "aws_cloudwatch_log_group" "t-rss-reader-login-handler-api-log-group" { 15 | name = "/aws/t-rss-reader-login-handler-api-log-group/${aws_apigatewayv2_api.t-rss-reader-login-handler-api.name}" 16 | retention_in_days = 30 17 | } 18 | 19 | resource "aws_apigatewayv2_stage" "t-rss-reader-login-handler-api-stage" { 20 | api_id = aws_apigatewayv2_api.t-rss-reader-login-handler-api.id 21 | name = "default" 22 | auto_deploy = true 23 | 24 | access_log_settings { 25 | destination_arn = aws_cloudwatch_log_group.t-rss-reader-login-handler-api-log-group.arn 26 | format = jsonencode({ 27 | requestId = "$context.requestId" 28 | sourceIp = "$context.identity.sourceIp" 29 | requestTime = "$context.requestTime" 30 | protocol = "$context.protocol" 31 | httpMethod = "$context.httpMethod" 32 | resourcePath = "$context.resourcePath" 33 | routeKey = "$context.routeKey" 34 | status = "$context.status" 35 | responseLength = "$context.responseLength" 36 | integrationErrorMessage = "$context.integrationErrorMessage" 37 | } 38 | ) 39 | } 40 | } 41 | 42 | resource "aws_apigatewayv2_integration" "t-rss-reader-login-handler-api-integration" { 43 | api_id = aws_apigatewayv2_api.t-rss-reader-login-handler-api.id 44 | integration_type = "AWS_PROXY" 45 | integration_method = "POST" 46 | integration_uri = aws_lambda_function.t-rss-reader-login-handler.invoke_arn 47 | } 48 | 49 | resource "aws_apigatewayv2_route" "t-rss-reader-login-handler-api-route" { 50 | api_id = aws_apigatewayv2_api.t-rss-reader-login-handler-api.id 51 | route_key = "POST /login" 52 | target = "integrations/${aws_apigatewayv2_integration.t-rss-reader-login-handler-api-integration.id}" 53 | } 54 | -------------------------------------------------------------------------------- /infra/login-handler/Makefile: -------------------------------------------------------------------------------- 1 | targets = install prepare test clean 2 | 3 | .PHONY: help $(targets) 4 | 5 | help: 6 | @echo "Available targets: $(targets)" 7 | 8 | install: 9 | npm install 10 | 11 | prepare: 12 | npm run build 13 | npm run zip 14 | 15 | test: 16 | npm run test 17 | 18 | clean: 19 | npm run clean -------------------------------------------------------------------------------- /infra/login-handler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": "true", 3 | "name": "login-handler", 4 | "description": "Lambda function that handles authentication", 5 | "type": "module", 6 | "engines": { 7 | "node": ">=18.0.0" 8 | }, 9 | "scripts": { 10 | "clean": "del dist login-handler.zip", 11 | "build": "ncc build ./src/index.ts --out dist --minify", 12 | "zip": "zip -r login-handler.zip ./dist", 13 | "test": "vitest" 14 | }, 15 | "dependencies": { 16 | "dayjs": "^1.11.7", 17 | "jsonwebtoken": "^9.0.0" 18 | }, 19 | "devDependencies": { 20 | "@types/aws-lambda": "^8.10.110", 21 | "@types/jsonwebtoken": "^9.0.1", 22 | "@vercel/ncc": "^0.36.1", 23 | "del-cli": "^5.0.0", 24 | "prettier": "^2.8.4", 25 | "typescript": "^4.9.5", 26 | "vitest": "^0.28.5" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /infra/login-handler/src/index.ts: -------------------------------------------------------------------------------- 1 | import { signToken } from './lib/sign-token.js'; 2 | import type { APIGatewayEvent } from 'aws-lambda'; 3 | 4 | export const handler = async (event: APIGatewayEvent) => { 5 | if ( 6 | !event?.headers?.authorization || 7 | event?.headers?.authorization !== process.env.T_RSS_READER_PASSWORD 8 | ) { 9 | return { 10 | statusCode: 401, 11 | body: JSON.stringify({ message: 'Failed to authorize' }), 12 | headers: { 13 | 'Content-Type': 'application/json' 14 | } 15 | }; 16 | } 17 | 18 | try { 19 | const { token, expiry } = signToken(); 20 | 21 | return { 22 | statusCode: 200, 23 | body: JSON.stringify({ 24 | accessToken: token, 25 | tokenType: 'Bearer', 26 | expiresIn: expiry 27 | }), 28 | headers: { 29 | 'Content-Type': 'application/json', 30 | 'Cache-control': 'no-store' 31 | } 32 | }; 33 | } catch (error) { 34 | console.error(error.message); 35 | 36 | return { 37 | statusCode: 500, 38 | body: JSON.stringify({ message: 'Failed to create token' }), 39 | headers: { 40 | 'Content-Type': 'application/json' 41 | } 42 | }; 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /infra/login-handler/src/lib/get-timestamp.ts: -------------------------------------------------------------------------------- 1 | import * as dayjs from 'dayjs'; 2 | 3 | export interface TimeStamps { 4 | now: number; 5 | expiry: number; 6 | } 7 | 8 | export function getTimestamp(): TimeStamps { 9 | const now = dayjs(); 10 | 11 | /** 12 | * Disclamer - A long-lived access token is sent. Reasons: 13 | * 14 | * - Data stored for this app is not particularly sensitive 15 | * - Straightforward to revoke access tokens by changing the password 16 | * - HTTP-only cookies are not convenient for localhost development and non-web based clients 17 | * - Prefer to keep infra implementation as simple as possible 18 | * 19 | * Of course, make your own judgement based on your own requirements. This is acceptable for me for this use case. 20 | */ 21 | const expiry = now.add(1, 'month'); 22 | 23 | return { 24 | now: now.valueOf(), 25 | expiry: expiry.valueOf() 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /infra/login-handler/src/lib/sign-token.ts: -------------------------------------------------------------------------------- 1 | import { getTimestamp } from './get-timestamp.js'; 2 | import jwt from 'jsonwebtoken'; 3 | 4 | export interface TokenWithExpiry { 5 | token: string; 6 | expiry: number; 7 | } 8 | 9 | export function signToken(): TokenWithExpiry { 10 | const { now, expiry } = getTimestamp(); 11 | 12 | const token = jwt.sign( 13 | { 14 | iss: 't-rss-reader', 15 | sub: 'owner', 16 | iat: now, 17 | exp: expiry 18 | }, 19 | process.env.T_RSS_READER_PASSWORD 20 | ); 21 | 22 | return { 23 | token, 24 | expiry 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /infra/login-handler/test/fixture/create-event.ts: -------------------------------------------------------------------------------- 1 | import type { APIGatewayEvent, APIGatewayEventRequestContext } from 'aws-lambda'; 2 | 3 | const event: Omit = { 4 | body: '{}', 5 | headers: { 6 | 'Content-Type': 'application/json' 7 | }, 8 | multiValueHeaders: {}, 9 | isBase64Encoded: false, 10 | pathParameters: null, 11 | queryStringParameters: null, 12 | multiValueQueryStringParameters: null, 13 | stageVariables: null, 14 | requestContext: {} as APIGatewayEventRequestContext 15 | }; 16 | 17 | export function createEvent(resource: string, httpMethod: string): APIGatewayEvent { 18 | return { ...event, resource, path: resource, httpMethod }; 19 | } 20 | -------------------------------------------------------------------------------- /infra/login-handler/test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { test, beforeEach, afterEach, vi, expect } from 'vitest'; 2 | import { createEvent } from './fixture/create-event'; 3 | import { handler } from '../src/index'; 4 | 5 | vi.mock('../src/lib/get-timestamp', () => { 6 | return { 7 | getTimestamp: () => ({ 8 | now: 1, 9 | expiry: 2 10 | }) 11 | }; 12 | }); 13 | 14 | vi.mock('../src/lib/sign-token', () => { 15 | return { 16 | signToken: () => ({ 17 | token: 'a', 18 | expiry: 2 19 | }) 20 | }; 21 | }); 22 | 23 | const password = 'abc'; 24 | 25 | beforeEach(() => { 26 | process.env.T_RSS_READER_PASSWORD = password; 27 | }); 28 | 29 | afterEach(() => { 30 | delete process.env.T_RSS_READER_PASSWORD; 31 | }); 32 | 33 | test('should fail to authorize with no authorization header', async () => { 34 | const event = createEvent('login', 'POST'); 35 | const { statusCode, body } = await handler(event); 36 | 37 | expect(statusCode).to.equal(401); 38 | expect(body).to.include('Failed to authorize'); 39 | }); 40 | 41 | test('should fail to authorize with an incorrect authorization header', async () => { 42 | const event = createEvent('login', 'POST'); 43 | event.headers.authorization = 'incorrect'; 44 | 45 | const { statusCode, body } = await handler(event); 46 | 47 | expect(statusCode).to.equal(401); 48 | expect(body).to.include('Failed to authorize'); 49 | }); 50 | 51 | test('should authorize with a correct authorization header', async () => { 52 | const event = createEvent('login', 'POST'); 53 | event.headers.authorization = password; 54 | 55 | const { statusCode, body, headers } = await handler(event); 56 | const { accessToken, tokenType, expiresIn } = JSON.parse(body); 57 | 58 | expect(statusCode).to.equal(200); 59 | expect(accessToken).to.equal('a'); 60 | expect(tokenType).to.equal('Bearer'); 61 | expect(expiresIn).to.equal(2); 62 | expect(headers['Cache-control']).to.equal('no-store'); 63 | }); 64 | -------------------------------------------------------------------------------- /infra/login-handler/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "moduleResolution": "node" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /infra/login-handler/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['./test/**/*.test.ts'], 6 | environment: 'node', 7 | watch: false 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /infra/login-lambda.tf: -------------------------------------------------------------------------------- 1 | resource "aws_lambda_function" "t-rss-reader-login-handler" { 2 | filename = "./login-handler/login-handler.zip" 3 | function_name = "t-rss-reader-login-handler" 4 | memory_size = 7076 # 1,769 per vCPU, so ~4 vCPUs 5 | role = aws_iam_role.t-rss-reader-login-handler-iam-role.arn 6 | handler = "./dist/index.handler" 7 | source_code_hash = filebase64sha256("./login-handler/dist/index.js") 8 | runtime = "nodejs18.x" 9 | environment { 10 | variables = { 11 | T_RSS_READER_PASSWORD = var.t-rss-reader-password 12 | } 13 | } 14 | } 15 | 16 | resource "aws_cloudwatch_log_group" "t-rss-reader-login-handler-log-group" { 17 | name = "/aws/lambda/${aws_lambda_function.t-rss-reader-login-handler.function_name}" 18 | retention_in_days = 30 19 | } 20 | 21 | resource "aws_iam_role" "t-rss-reader-login-handler-iam-role" { 22 | name = "t-rss-reader-login-handler-iam" 23 | assume_role_policy = jsonencode({ 24 | "Version" = "2012-10-17" 25 | "Statement" = [ 26 | { 27 | "Action" = "sts:AssumeRole" 28 | "Effect" = "Allow" 29 | "Sid" = "" 30 | "Principal" = { 31 | "Service" = "lambda.amazonaws.com" 32 | } 33 | } 34 | ] 35 | }) 36 | } 37 | 38 | resource "aws_iam_role_policy" "t-rss-reader-login-handler-iam-policy" { 39 | name = "t-rss-reader-login-handler-iam-policy" 40 | role = aws_iam_role.t-rss-reader-login-handler-iam-role.id 41 | policy = jsonencode({ 42 | "Version" = "2012-10-17" 43 | "Statement" = [ 44 | { 45 | "Effect" = "Allow", 46 | "Action" = "logs:CreateLogGroup", 47 | "Resource" = "*" 48 | }, 49 | { 50 | "Effect" = "Allow", 51 | "Action" = [ 52 | "logs:CreateLogStream", 53 | "logs:PutLogEvents" 54 | ], 55 | "Resource" = [ 56 | "arn:aws:logs:${var.aws-region}:${data.aws_caller_identity.current.account_id}:*" 57 | ] 58 | } 59 | ] 60 | }) 61 | } 62 | 63 | resource "aws_lambda_permission" "t-rss-reader-login-handler-permission-api" { 64 | statement_id = "AllowExecutionFromAPIGateway" 65 | action = "lambda:InvokeFunction" 66 | function_name = aws_lambda_function.t-rss-reader-login-handler.function_name 67 | principal = "apigateway.amazonaws.com" 68 | source_arn = "${aws_apigatewayv2_api.t-rss-reader-login-handler-api.execution_arn}/*/*" 69 | } 70 | -------------------------------------------------------------------------------- /infra/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | cloud { 3 | organization = "tyhopp" 4 | 5 | workspaces { 6 | name = "t-rss-reader" 7 | } 8 | } 9 | 10 | required_providers { 11 | 12 | aws = { 13 | source = "hashicorp/aws" 14 | version = "~> 4.16" 15 | } 16 | } 17 | 18 | required_version = ">= 1.2.0" 19 | } 20 | 21 | provider "aws" { 22 | access_key = var.aws-access-key 23 | secret_key = var.aws-secret-access-key 24 | region = var.aws-region 25 | } 26 | 27 | # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity 28 | data "aws_caller_identity" "current" {} 29 | -------------------------------------------------------------------------------- /infra/outputs.tf: -------------------------------------------------------------------------------- 1 | output "t-rss-reader-login-handler-invoke-url" { 2 | description = "Login handler invoke url" 3 | value = "https://${aws_apigatewayv2_api.t-rss-reader-login-handler-api.id}.execute-api.${var.aws-region}.amazonaws.com/default/login" 4 | } 5 | 6 | output "t-rss-reader-feeds-handler-invoke-url" { 7 | description = "Feeds handler invoke url" 8 | value = "https://${aws_apigatewayv2_api.t-rss-reader-feeds-handler-api.id}.execute-api.${var.aws-region}.amazonaws.com/default/feeds" 9 | } 10 | 11 | output "t-rss-reader-entries-handler-invoke-url" { 12 | description = "Entries handler invoke url" 13 | value = "https://${aws_apigatewayv2_api.t-rss-reader-entries-handler-api.id}.execute-api.${var.aws-region}.amazonaws.com/default/entries" 14 | } 15 | 16 | output "t-rss-reader-last-access-handler-invoke-url" { 17 | description = "Last access handler invoke url" 18 | value = "https://${aws_apigatewayv2_api.t-rss-reader-last-access-handler-api.id}.execute-api.${var.aws-region}.amazonaws.com/default/last-access" 19 | } 20 | -------------------------------------------------------------------------------- /infra/terraform-example.tfvars: -------------------------------------------------------------------------------- 1 | t-rss-reader-password = "" 2 | t-rss-reader-allow-origins = ["http://localhost:8000"] 3 | 4 | aws-access-key = "" 5 | aws-secret-access-key = "" 6 | aws-region = "" 7 | -------------------------------------------------------------------------------- /infra/variables.tf: -------------------------------------------------------------------------------- 1 | variable "t-rss-reader-password" { 2 | description = "The password to use for authenticating requests" 3 | type = string 4 | sensitive = true 5 | nullable = false 6 | } 7 | 8 | variable "t-rss-reader-allow-origins" { 9 | description = "The allowed origins for CORS" 10 | type = list(string) 11 | default = ["http://localhost:8000"] 12 | } 13 | 14 | variable "aws-access-key" { 15 | description = "Public key for accessing AWS resources" 16 | type = string 17 | nullable = false 18 | } 19 | 20 | variable "aws-secret-access-key" { 21 | description = "Secret key for accessing AWS resources" 22 | type = string 23 | sensitive = true 24 | nullable = false 25 | } 26 | 27 | variable "aws-region" { 28 | description = "AWS region for all resources" 29 | type = string 30 | default = "ap-southeast-1" 31 | } 32 | -------------------------------------------------------------------------------- /web/.env.example: -------------------------------------------------------------------------------- 1 | PUBLIC_FEEDS_API = "" 2 | PUBLIC_LOGIN_API = "" 3 | PUBLIC_ENTRIES_API = "" 4 | PUBLIC_LAST_ACCESS_API = "" -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | *.woff2 12 | -------------------------------------------------------------------------------- /web/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /web/.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example -------------------------------------------------------------------------------- /web/.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | const defaultConfig = require('../.prettierrc.cjs'); 2 | 3 | module.exports = { 4 | ...defaultConfig, 5 | plugins: ['prettier-plugin-svelte'], 6 | pluginSearchDirs: ['.'], 7 | overrides: [{ files: '*.svelte', options: { parser: 'svelte' } }] 8 | }; 9 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # t-rss-reader web client 2 | 3 | Web client built with [SvelteKit](https://kit.svelte.dev/), which relies on [Svelte](https://svelte.dev/) and [Vite](https://vitejs.dev/). 4 | 5 | ## Prerequisites 6 | 7 | - Backend [infrastructure](../../infra/README.md) is created and you have the invoke URLs in hand. 8 | 9 | ## Setup 10 | 11 | 1. Create a `.env` file from the `.env.example` file 12 | 2. Add the env vars 13 | 3. `npm install` to install dependencies 14 | 15 | ## Usage 16 | 17 | - `npm run dev` to run in development mode with hot module replacement 18 | - `npm run build` to build production site 19 | - `npm run preview` to view production site 20 | 21 | ## Deployment 22 | 23 | I use [Firebase Hosting](https://firebase.google.com/docs/hosting), but you can host wherever you want. 24 | 25 | To use Firebase Hosting: 26 | 27 | 1. Install the [Firebase CLI](https://firebase.google.com/docs/cli) 28 | 2. Deploy with `npm run deploy` 29 | 30 | ## Disclaimer 31 | 32 | An access token with a 1 month expiry is stored in IndexedDB. Reasons: 33 | 34 | - Data stored for this app is not particularly sensitive 35 | - Straightforward to revoke access tokens by changing the password 36 | - HTTP-only cookies are not convenient for localhost development 37 | - Web workers have access to IndexedDB 38 | 39 | Of course, make your own judgement based on your own requirements. This is acceptable for me for this use case. 40 | -------------------------------------------------------------------------------- /web/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "build", 4 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 5 | "cleanUrls": true, 6 | "trailingSlash": false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "t-rss-reader-svelte", 3 | "version": "0.0.1", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "clean": "del .svelte-kit", 8 | "dev": "vite dev", 9 | "build": "vite build", 10 | "preview": "vite preview", 11 | "deploy": "firebase deploy --project t-rss-reader-svelte --only hosting", 12 | "test": "vitest", 13 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 14 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 15 | "lint": "prettier --plugin-search-dir . --check .", 16 | "format": "prettier --plugin-search-dir . --write ." 17 | }, 18 | "dependencies": { 19 | "idb-keyval": "^6.2.0" 20 | }, 21 | "devDependencies": { 22 | "@sveltejs/adapter-static": "^2.0.1", 23 | "@sveltejs/kit": "^1.5.0", 24 | "del-cli": "^5.0.0", 25 | "prettier": "^2.8.0", 26 | "prettier-plugin-svelte": "^2.8.1", 27 | "svelte": "^3.54.0", 28 | "svelte-check": "^3.0.1", 29 | "tslib": "^2.4.1", 30 | "typescript": "^4.9.3", 31 | "vite": "^4.0.0", 32 | "vitest": "^0.25.3" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /web/src/app.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --text: #363535; 3 | --line: #bebdbd; 4 | --background: #fffbf4; 5 | --hover: #fdf6ea; 6 | --active: #fdf3e2; 7 | --scroll: #f1ece2; 8 | --accent: #3c8ec9; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --text: #f8f9fa; 14 | --line: #424464; 15 | --background: #1f1f22; 16 | --hover: #18181a; 17 | --active: #070707; 18 | --scroll: #2a2a2e; 19 | --accent: #3c8ec9; 20 | } 21 | } 22 | 23 | html { 24 | font-family: 'Roboto Slab', sans-serif; 25 | color: var(--text); 26 | background-color: var(--background); 27 | -webkit-text-size-adjust: none; 28 | text-size-adjust: none; 29 | } 30 | 31 | body { 32 | margin: 0; 33 | position: fixed; 34 | height: 100%; 35 | width: 100%; 36 | overflow: hidden; 37 | } 38 | 39 | #sveltekit-body { 40 | display: flex; 41 | flex-direction: column; 42 | max-width: 1000px; 43 | height: 100%; 44 | margin: 0 auto; 45 | } 46 | 47 | #sveltekit-body > * { 48 | min-width: 0; 49 | min-height: 0; 50 | } 51 | 52 | a { 53 | color: var(--text); 54 | text-decoration: none; 55 | } 56 | 57 | a:visited { 58 | opacity: 85%; 59 | } 60 | 61 | ::-webkit-scrollbar { 62 | width: 14px; 63 | height: 12px; 64 | border-left: 1px dashed var(--line); 65 | } 66 | 67 | ::-webkit-scrollbar-thumb { 68 | background: var(--scroll); 69 | } 70 | 71 | input, 72 | button { 73 | font-family: 'Roboto Slab', sans-serif; 74 | color: var(--text); 75 | } 76 | 77 | input { 78 | font-size: 16px; 79 | background-color: var(--background); 80 | border: 1px solid var(--line); 81 | } 82 | 83 | input[disabled] { 84 | opacity: 50%; 85 | cursor: not-allowed; 86 | } 87 | 88 | @media (min-width: 1000px) { 89 | input { 90 | font-size: 14px; 91 | } 92 | } 93 | 94 | :focus { 95 | outline-width: 2px; 96 | outline-style: dashed; 97 | outline-color: var(--accent); 98 | outline-offset: -2px; 99 | } 100 | -------------------------------------------------------------------------------- /web/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %sveltekit.head% 7 | 8 | 9 |
%sveltekit.body%
10 | 11 | 12 | -------------------------------------------------------------------------------- /web/src/lib/components/Button.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | 13 | 60 | -------------------------------------------------------------------------------- /web/src/lib/components/DetailsItem.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
  • 16 | 17 |

    18 | 19 | {entry.title} 20 |

    21 |

    {formatDate(entry.published)}

    22 |
    23 |
  • 24 | 25 | 75 | -------------------------------------------------------------------------------- /web/src/lib/components/FormResultMessage.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | {#if result !== Result.none} 20 |
    {resultMessage}
    21 | {/if} 22 | 23 | 37 | -------------------------------------------------------------------------------- /web/src/lib/components/FormValidationMessage.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | {#if !!validationMessage} 6 |
    {validationMessage}
    7 | {/if} 8 | 9 | 16 | -------------------------------------------------------------------------------- /web/src/lib/components/Header.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 |
    26 |

    t-rss-reader

    27 |
    28 |
    30 |

    {selectedFeed?.name}

    31 |
    32 |
    34 |
    35 | 36 | 111 | -------------------------------------------------------------------------------- /web/src/lib/components/ListItem.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 33 |
  • 41 |
    42 |

    43 | 44 | {feed?.name} 45 |

    46 |

    {feed?.url}

    47 |
    48 |
    49 |
    51 |
  • 52 | 53 | 122 | -------------------------------------------------------------------------------- /web/src/lib/components/Loading.svelte: -------------------------------------------------------------------------------- 1 |
    2 | 3 |
    4 | 5 | 43 | -------------------------------------------------------------------------------- /web/src/lib/components/Modal.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | {#if $modalStore.open} 12 | 27 | {/if} 28 | 29 | 67 | -------------------------------------------------------------------------------- /web/src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const ACCESS_TOKEN_KEY = 't-rss-reader-access-token'; 2 | -------------------------------------------------------------------------------- /web/src/lib/services/authorized-service.ts: -------------------------------------------------------------------------------- 1 | import { getAccessToken } from '../utils/get-access-token'; 2 | 3 | export class AuthorizedService { 4 | protected get defaultHeaders(): HeadersInit { 5 | return { 6 | 'content-type': 'application/json' 7 | }; 8 | } 9 | 10 | protected async headers(): Promise { 11 | const token = await getAccessToken(); 12 | 13 | if (!token) { 14 | return this.defaultHeaders; 15 | } 16 | 17 | const { accessToken } = JSON.parse(token) || {}; 18 | 19 | return { 20 | ...this.defaultHeaders, 21 | authorization: accessToken 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /web/src/lib/services/entries-service.ts: -------------------------------------------------------------------------------- 1 | import { AuthorizedService } from './authorized-service'; 2 | 3 | export class EntriesService extends AuthorizedService { 4 | constructor(api: string) { 5 | super(); 6 | this.api = api; 7 | } 8 | 9 | api: string; 10 | 11 | async getEntries({ 12 | url, 13 | abortController, 14 | timeout 15 | }: { 16 | url: string; 17 | abortController?: AbortController; 18 | timeout?: number; 19 | }): Promise { 20 | const composedUrl = `${this.api}?url=${encodeURIComponent(url)}`; 21 | 22 | const options: RequestInit = { 23 | method: 'GET', 24 | headers: await this.headers() 25 | }; 26 | 27 | if (abortController) { 28 | options.signal = abortController.signal; 29 | } 30 | 31 | let timeoutCallback; 32 | 33 | if (timeout) { 34 | const timeoutController = new AbortController(); 35 | options.signal = timeoutController.signal; 36 | timeoutCallback = setTimeout(() => { 37 | timeoutController.abort(); 38 | }, timeout); 39 | } 40 | 41 | const response = await fetch(composedUrl, options); 42 | 43 | if (timeoutCallback) { 44 | clearTimeout(timeoutCallback); 45 | } 46 | 47 | return response; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /web/src/lib/services/feeds-service.ts: -------------------------------------------------------------------------------- 1 | import { AuthorizedService } from './authorized-service'; 2 | import { PUBLIC_FEEDS_API } from '$env/static/public'; 3 | 4 | export class FeedsService extends AuthorizedService { 5 | async deleteFeed(url: string): Promise { 6 | const headers = await this.headers(); 7 | 8 | return await fetch(PUBLIC_FEEDS_API, { 9 | method: 'DELETE', 10 | headers, 11 | body: JSON.stringify({ url }) 12 | }); 13 | } 14 | 15 | async getFeeds(): Promise { 16 | const headers = await this.headers(); 17 | 18 | return await fetch(PUBLIC_FEEDS_API, { 19 | method: 'GET', 20 | headers 21 | }); 22 | } 23 | 24 | async putFeed(url: string, name: string): Promise { 25 | const headers = await this.headers(); 26 | 27 | return await fetch(PUBLIC_FEEDS_API, { 28 | method: 'PUT', 29 | headers, 30 | body: JSON.stringify({ url, name }) 31 | }); 32 | } 33 | } 34 | 35 | export default new FeedsService(); 36 | -------------------------------------------------------------------------------- /web/src/lib/services/last-access-service.ts: -------------------------------------------------------------------------------- 1 | import { AuthorizedService } from './authorized-service'; 2 | 3 | export class LastAccessService extends AuthorizedService { 4 | constructor(api: string) { 5 | super(); 6 | this.api = api; 7 | } 8 | 9 | api: string; 10 | 11 | async putLastAccess(): Promise { 12 | const options: RequestInit = { 13 | method: 'PUT', 14 | headers: await this.headers() 15 | }; 16 | 17 | return await fetch(this.api, options); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /web/src/lib/services/login-service.ts: -------------------------------------------------------------------------------- 1 | import { PUBLIC_LOGIN_API } from '$env/static/public'; 2 | 3 | export class LoginService { 4 | private get headers(): HeadersInit { 5 | return { 6 | 'content-type': 'application/json' 7 | }; 8 | } 9 | 10 | async login(password: string): Promise { 11 | return await fetch(PUBLIC_LOGIN_API, { 12 | method: 'POST', 13 | cache: 'no-cache', 14 | headers: { ...this.headers, authorization: password } 15 | }); 16 | } 17 | } 18 | 19 | export default new LoginService(); 20 | -------------------------------------------------------------------------------- /web/src/lib/stores/feeds-store.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | import FeedsService from '../services/feeds-service'; 3 | import { Result } from '../types'; 4 | import type { Feeds } from '../types'; 5 | 6 | const feedsStoreInstance = writable([]); 7 | 8 | export const feedsStore = { 9 | init: async (): Promise => { 10 | const response = await FeedsService.getFeeds(); 11 | 12 | if (response.status === 200) { 13 | const nextFeeds = await response.json(); 14 | 15 | feedsStoreInstance.set(nextFeeds); 16 | 17 | return Result.success; 18 | } 19 | 20 | return Result.failure; 21 | }, 22 | subscribe: feedsStoreInstance.subscribe, 23 | update: feedsStoreInstance.update 24 | }; 25 | -------------------------------------------------------------------------------- /web/src/lib/stores/modal-store.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | export enum ModalMode { 4 | edit = 'edit', 5 | add = 'add' 6 | } 7 | 8 | interface ModalStore { 9 | open: boolean; 10 | mode: ModalMode; 11 | name: string | undefined; 12 | url: string | undefined; 13 | } 14 | 15 | const modalStoreInstance = writable({ 16 | open: false, 17 | mode: ModalMode.add, 18 | name: undefined, 19 | url: undefined 20 | }); 21 | 22 | export const modalStore = { 23 | subscribe: modalStoreInstance.subscribe, 24 | open: ({ 25 | mode = ModalMode.add, 26 | name, 27 | url 28 | }: { mode?: ModalMode; name?: string; url?: string } = {}) => 29 | modalStoreInstance.set({ 30 | open: true, 31 | mode, 32 | name, 33 | url 34 | }), 35 | close: () => 36 | modalStoreInstance.set({ 37 | open: false, 38 | mode: ModalMode.add, 39 | name: undefined, 40 | url: undefined 41 | }), 42 | set: modalStoreInstance.set 43 | }; 44 | -------------------------------------------------------------------------------- /web/src/lib/stores/selected-feed-store.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | import type { Feed } from '../types'; 3 | 4 | export const selectedFeedStore = writable(); 5 | -------------------------------------------------------------------------------- /web/src/lib/stores/token-store.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | import { getAccessTokenWithCheck } from '../utils/get-access-token-with-check'; 3 | import { tokenMaybeValid } from '../utils/token-maybe-valid'; 4 | import { ACCESS_TOKEN_KEY } from '../constants'; 5 | import { set } from 'idb-keyval'; 6 | import type { Token } from '../types'; 7 | 8 | interface TokenStore { 9 | maybeValid: boolean; 10 | token?: Token; 11 | } 12 | 13 | const tokenStoreInstance = writable(); 14 | 15 | export const tokenStore = { 16 | init: async (): Promise => { 17 | const { maybeValid, token } = await getAccessTokenWithCheck(); 18 | tokenStoreInstance.set({ maybeValid, token }); 19 | return true; 20 | }, 21 | subscribe: tokenStoreInstance.subscribe, 22 | set: async (token: Token) => { 23 | await set(ACCESS_TOKEN_KEY, JSON.stringify(token)); 24 | const maybeValid = tokenMaybeValid(token); 25 | tokenStoreInstance.set({ maybeValid, token }); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /web/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export interface Feed { 2 | name: string; 3 | url: string; 4 | hasNew?: boolean; 5 | } 6 | 7 | export type Feeds = Array; 8 | 9 | export interface Token { 10 | accessToken: string; 11 | tokenType: string; 12 | expiresIn: number; 13 | } 14 | 15 | export enum Result { 16 | none = 'none', 17 | success = 'success', 18 | failure = 'failure' 19 | } 20 | 21 | export enum RssFeedFormat { 22 | rss = 'rss', 23 | atom = 'atom' 24 | } 25 | 26 | export interface RssFeedEntry { 27 | url?: string; 28 | title?: string; 29 | published?: string; 30 | isNew?: boolean; 31 | } 32 | 33 | export type RssFeedEntries = Array; 34 | -------------------------------------------------------------------------------- /web/src/lib/utils/get-access-token-with-check.ts: -------------------------------------------------------------------------------- 1 | import { getAccessToken } from './get-access-token'; 2 | import { tokenMaybeValid } from './token-maybe-valid'; 3 | import type { Token } from '../types'; 4 | 5 | export async function getAccessTokenWithCheck(): Promise<{ 6 | maybeValid: boolean; 7 | token?: Token; 8 | }> { 9 | try { 10 | const token = await getAccessToken(); 11 | 12 | if (token) { 13 | const parsedToken: Token = JSON.parse(token) || {}; 14 | 15 | return { 16 | maybeValid: tokenMaybeValid(parsedToken), 17 | token: parsedToken 18 | }; 19 | } 20 | } catch (_) { 21 | return { 22 | maybeValid: false 23 | }; 24 | } 25 | 26 | return { 27 | maybeValid: false 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /web/src/lib/utils/get-access-token.ts: -------------------------------------------------------------------------------- 1 | import { ACCESS_TOKEN_KEY } from '../constants'; 2 | import { get } from 'idb-keyval'; 3 | 4 | export async function getAccessToken(): Promise { 5 | return await get(ACCESS_TOKEN_KEY); 6 | } 7 | -------------------------------------------------------------------------------- /web/src/lib/utils/get-random-number.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get a random number between a range inclusively. 3 | */ 4 | export function getRandomNumber(min: number, max: number): number { 5 | return Math.floor(Math.random() * (max - min + 1) + min); 6 | } 7 | -------------------------------------------------------------------------------- /web/src/lib/utils/handle-jump-keyboard-events.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Handle focus for lists when jump keys are pressed. 3 | * Allows reasonable keyboard navigation within and between the list and details widgets. 4 | * 5 | * Handled keys: 6 | * Home - focus on first item in the list 7 | * End - focus on last item in the list 8 | */ 9 | export function handleJumpKeyboardEvents(event: KeyboardEvent, selector: string): void { 10 | if (event.key === 'Home') { 11 | const firstItem = document.querySelector(selector) as HTMLElement; 12 | 13 | if (firstItem) { 14 | firstItem.focus(); 15 | } 16 | } 17 | 18 | if (event.key === 'End') { 19 | const items = document.querySelectorAll(selector); 20 | const lastItem = items?.[items.length - 1] as HTMLElement; 21 | 22 | if (lastItem) { 23 | lastItem.focus(); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /web/src/lib/utils/sort-feeds.ts: -------------------------------------------------------------------------------- 1 | import type { Feeds } from '$lib/types'; 2 | 3 | export function sortFeeds(feeds: Feeds): Feeds { 4 | return feeds.sort((a, b) => { 5 | if (a.hasNew && b.hasNew) { 6 | return a.name.localeCompare(b.name); 7 | } 8 | 9 | if (a.hasNew) { 10 | return -1; 11 | } 12 | 13 | if (b.hasNew) { 14 | return 1; 15 | } 16 | 17 | return a.name.localeCompare(b.name); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /web/src/lib/utils/token-maybe-valid.ts: -------------------------------------------------------------------------------- 1 | import type { Token } from '../types'; 2 | 3 | /** 4 | * Best effort guess that a token is valid prior to making a network request. 5 | */ 6 | export function tokenMaybeValid(token: Token): boolean { 7 | return !!token.accessToken && token.expiresIn > Date.now(); 8 | } 9 | -------------------------------------------------------------------------------- /web/src/lib/widgets/Details.svelte: -------------------------------------------------------------------------------- 1 | 87 | 88 |
    89 | {#if loading} 90 | 91 | {:else if !hasSelection && !entriesFailed && $feedsStore.length} 92 |
    93 |

    Select a feed to view entries

    94 |
    96 | {:else if hasSelection && entriesFailed} 97 |
    98 |

    Failed to get entries

    99 |
    100 | {:else if hasSelection && entries.length} 101 |
      102 | {#each entries as entry} 103 | 104 | {/each} 105 |
    106 | {/if} 107 |
    108 | 109 | 143 | -------------------------------------------------------------------------------- /web/src/lib/widgets/List.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 |
      47 | {#if initialized === Result.none} 48 | 49 | {:else if initialized === Result.failure} 50 |
      51 |

      Failed to get feeds

      52 |

      Attempts: {attempts}

      53 |
      55 | {:else if initialized === Result.success && $feedsStore?.length === 0} 56 |
      57 |

      No feeds yet

      58 |
      60 | {:else} 61 | {#each $feedsStore as feed} 62 | 63 | {/each} 64 | {/if} 65 |
    66 | 67 | 105 | -------------------------------------------------------------------------------- /web/src/lib/widgets/LoginForm.svelte: -------------------------------------------------------------------------------- 1 | 43 | 44 |
    45 | 46 | 47 | 55 |