├── .editorconfig
├── .env.example
├── .gitattributes
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ ├── config.yml
│ └── feature_request.yml
├── config.yml
└── workflows
│ ├── build_pull_request.yml
│ ├── open_pull_request.yml
│ └── release.yml
├── .gitignore
├── .releaserc
├── CHANGELOG.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── about.example.json
├── assets
├── revanced-headline
│ ├── revanced-headline-vertical-dark.svg
│ └── revanced-headline-vertical-light.svg
└── revanced-logo
│ └── revanced-logo.svg
├── build.gradle.kts
├── configuration.example.toml
├── docker-compose.example.yml
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── package-lock.json
├── package.json
├── settings.gradle.kts
└── src
├── main
├── kotlin
│ └── app
│ │ └── revanced
│ │ └── api
│ │ ├── command
│ │ ├── MainCommand.kt
│ │ └── StartAPICommand.kt
│ │ └── configuration
│ │ ├── APISchema.kt
│ │ ├── Dependencies.kt
│ │ ├── Extensions.kt
│ │ ├── HTTP.kt
│ │ ├── Logging.kt
│ │ ├── OpenAPI.kt
│ │ ├── Routing.kt
│ │ ├── Security.kt
│ │ ├── Serialization.kt
│ │ ├── repository
│ │ ├── AnnouncementRepository.kt
│ │ ├── BackendRepository.kt
│ │ ├── ConfigurationRepository.kt
│ │ └── GitHubBackendRepository.kt
│ │ ├── routes
│ │ ├── Announcements.kt
│ │ ├── ApiRoute.kt
│ │ ├── ManagerRoute.kt
│ │ └── PatchesRoute.kt
│ │ └── services
│ │ ├── AnnouncementService.kt
│ │ ├── ApiService.kt
│ │ ├── AuthenticationService.kt
│ │ ├── ManagerService.kt
│ │ ├── PatchesService.kt
│ │ └── SignatureService.kt
└── resources
│ ├── app
│ └── revanced
│ │ └── api
│ │ └── version.properties
│ └── logback.xml
└── test
└── kotlin
└── app
└── revanced
└── api
└── configuration
└── services
└── AnnouncementServiceTest.kt
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*.{kt,kts}]
2 | ktlint_code_style = intellij_idea
3 | ktlint_standard_no-wildcard-imports = disabled
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Optional token for API calls to the backend
2 | BACKEND_API_TOKEN=
3 |
4 | # Database connection details
5 | DB_URL=jdbc:h2:./persistence/revanced-api
6 | DB_USER=
7 | DB_PASSWORD=
8 |
9 | # Digest auth to issue JWT tokens in the format SHA256("username:ReVanced:password")
10 | AUTH_SHA256_DIGEST=
11 |
12 | # JWT configuration for authenticated API endpoints
13 | JWT_SECRET=
14 | JWT_ISSUER=
15 | JWT_VALIDITY_IN_MIN=
16 |
17 | # Logging level for the application
18 | LOG_LEVEL=INFO
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | #
2 | # https://help.github.com/articles/dealing-with-line-endings/
3 | #
4 | # Linux start script should use lf
5 | /gradlew text eol=lf
6 |
7 | # These are Windows script files and should use crlf
8 | *.bat text eol=crlf
9 |
10 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: 🐞 Bug report
2 | description: Report a bug or an issue.
3 | title: 'bug: '
4 | labels: ['Bug report']
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 |
10 |
11 |
16 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | Continuing the legacy of Vanced
67 |
68 |
69 | # ReVanced API bug report
70 |
71 | Before creating a new bug report, please keep the following in mind:
72 |
73 | - **Do not submit a duplicate bug report**: Search for existing bug reports [here](https://github.com/ReVanced/revanced-api/issues?q=label%3A%22Bug+report%22).
74 | - **Review the contribution guidelines**: Make sure your bug report adheres to it. You can find the guidelines [here](https://github.com/ReVanced/revanced-api/blob/main/CONTRIBUTING.md).
75 | - **Do not use the issue page for support**: If you need help or have questions, check out other platforms on [revanced.app](https://revanced.app).
76 | - type: textarea
77 | attributes:
78 | label: Bug description
79 | description: |
80 | - Describe your bug in detail
81 | - Add steps to reproduce the bug if possible (Step 1. ... Step 2. ...)
82 | - Add images and videos if possible
83 | validations:
84 | required: true
85 | - type: textarea
86 | attributes:
87 | label: Error logs
88 | description: Exceptions can be captured by running `logcat | grep AndroidRuntime` in a shell.
89 | render: shell
90 | - type: textarea
91 | attributes:
92 | label: Solution
93 | description: If applicable, add a possible solution to the bug.
94 | - type: textarea
95 | attributes:
96 | label: Additional context
97 | description: Add additional context here.
98 | - type: checkboxes
99 | id: acknowledgements
100 | attributes:
101 | label: Acknowledgements
102 | description: Your bug report will be closed if you don't follow the checklist below.
103 | options:
104 | - label: I have checked all open and closed bug reports and this is not a duplicate.
105 | required: true
106 | - label: I have chosen an appropriate title.
107 | required: true
108 | - label: All requested information has been provided properly.
109 | required: true
110 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: 🗨 Discussions
4 | url: https://github.com/revanced/revanced-suggestions/discussions
5 | about: Have something unspecific to ReVanced APi in mind? Search for or start a new discussion!
6 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: ⭐ Feature request
2 | description: Create a detailed request for a new feature.
3 | title: 'feat: '
4 | labels: ['Feature request']
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 |
10 |
11 |
16 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | Continuing the legacy of Vanced
67 |
68 |
69 | # ReVanced APi feature request
70 |
71 | Before creating a new feature request, please keep the following in mind:
72 |
73 | - **Do not submit a duplicate feature request**: Search for existing feature requests [here](https://github.com/ReVanced/revanced-api/issues?q=label%3A%22Feature+request%22).
74 | - **Review the contribution guidelines**: Make sure your feature request adheres to it. You can find the guidelines [here](https://github.com/ReVanced/revanced-api/blob/main/CONTRIBUTING.md).
75 | - **Do not use the issue page for support**: If you need help or have questions, check out other platforms on [revanced.app](https://revanced.app).
76 | - type: textarea
77 | attributes:
78 | label: Feature description
79 | description: |
80 | - Describe your feature in detail
81 | - Add images, videos, links, examples, references, etc. if possible
82 | - type: textarea
83 | attributes:
84 | label: Motivation
85 | description: |
86 | A strong motivation is necessary for a feature request to be considered.
87 |
88 | - Why should this feature be implemented?
89 | - What is the explicit use case?
90 | - What are the benefits?
91 | - What makes this feature important?
92 | validations:
93 | required: true
94 | - type: checkboxes
95 | id: acknowledgements
96 | attributes:
97 | label: Acknowledgements
98 | description: Your feature request will be closed if you don't follow the checklist below.
99 | options:
100 | - label: I have checked all open and closed feature requests and this is not a duplicate
101 | required: true
102 | - label: I have chosen an appropriate title.
103 | required: true
104 | - label: All requested information has been provided properly.
105 | required: true
106 |
--------------------------------------------------------------------------------
/.github/config.yml:
--------------------------------------------------------------------------------
1 | firstPRMergeComment: >
2 | Thank you for contributing to ReVanced. Join us on [Discord](https://revanced.app/discord) to receive a role for your contribution.
3 |
--------------------------------------------------------------------------------
/.github/workflows/build_pull_request.yml:
--------------------------------------------------------------------------------
1 | name: Build pull request
2 |
3 | on:
4 | workflow_dispatch:
5 | pull_request:
6 | branches:
7 | - dev
8 |
9 | jobs:
10 | release:
11 | name: Build
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v4
16 | with:
17 | fetch-depth: 0
18 |
19 | - name: Cache Gradle
20 | uses: burrunan/gradle-cache-action@v1
21 |
22 | - name: Build
23 | env:
24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
25 | run: ./gradlew build --no-daemon
26 |
--------------------------------------------------------------------------------
/.github/workflows/open_pull_request.yml:
--------------------------------------------------------------------------------
1 | name: Open a PR to main
2 |
3 | on:
4 | push:
5 | branches:
6 | - dev
7 | workflow_dispatch:
8 |
9 | env:
10 | MESSAGE: Merge branch `${{ github.head_ref || github.ref_name }}` to `main`
11 |
12 | jobs:
13 | pull-request:
14 | name: Open pull request
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Checkout
18 | uses: actions/checkout@v4
19 |
20 | - name: Open pull request
21 | uses: repo-sync/pull-request@v2
22 | with:
23 | destination_branch: 'main'
24 | pr_title: 'chore: ${{ env.MESSAGE }}'
25 | pr_body: 'This pull request will ${{ env.MESSAGE }}.'
26 | pr_draft: true
27 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches:
7 | - main
8 | - dev
9 |
10 | jobs:
11 | release:
12 | name: Release
13 | runs-on: ubuntu-latest
14 | permissions:
15 | contents: write
16 | packages: write
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v4
20 | with:
21 | # Make sure the release step uses its own credentials:
22 | # https://github.com/cycjimmy/semantic-release-action#private-packages
23 | persist-credentials: false
24 | fetch-depth: 0
25 |
26 | - name: Cache Gradle
27 | uses: burrunan/gradle-cache-action@v1
28 |
29 | - name: Build
30 | env:
31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32 | run: ./gradlew startShadowScripts clean
33 |
34 | - name: Setup Node.js
35 | uses: actions/setup-node@v4
36 | with:
37 | node-version: "lts/*"
38 | cache: "npm"
39 |
40 | - name: Install dependencies
41 | run: npm install
42 |
43 | - name: Import GPG key
44 | uses: crazy-max/ghaction-import-gpg@v6
45 | with:
46 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
47 | passphrase: ${{ secrets.GPG_PASSPHRASE }}
48 | fingerprint: ${{ vars.GPG_FINGERPRINT }}
49 |
50 | - name: Setup QEMU
51 | uses: docker/setup-qemu-action@v3
52 | with:
53 | platforms: amd64, arm64
54 |
55 | - name: Setup Docker Buildx
56 | uses: docker/setup-buildx-action@v3
57 |
58 | - name: Release
59 | env:
60 | DOCKER_REGISTRY_USER: ${{ github.actor }}
61 | DOCKER_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
62 | GITHUB_ACTOR: ${{ github.actor }}
63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
64 | run: npm exec semantic-release
65 |
66 | - name: Set Portainer stack webhook URL based on branch
67 | run: |
68 | if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
69 | PORTAINER_WEBHOOK_URL=${{ secrets.PORTAINER_WEBHOOK_MAIN_URL }}
70 | else
71 | PORTAINER_WEBHOOK_URL=${{ secrets.PORTAINER_WEBHOOK_DEV_URL }}
72 | fi
73 | echo "PORTAINER_WEBHOOK_URL=$PORTAINER_WEBHOOK_URL" >> $GITHUB_ENV
74 |
75 | - name: Trigger Portainer stack update
76 | uses: newarifrh/portainer-service-webhook@v1
77 | with:
78 | webhook_url: ${{ env.PORTAINER_WEBHOOK_URL }}
79 |
80 | - name: Purge outdated images
81 | uses: snok/container-retention-policy@v3.0.0
82 | with:
83 | account: ${{ github.repository_owner }}
84 | token: ${{ secrets.GITHUB_TOKEN }}
85 | image-names: revanced-api
86 | keep-n-most-recent: 5
87 | cut-off: 1w
88 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .gradle
2 | build/
3 | !gradle/wrapper/gradle-wrapper.jar
4 | !**/src/main/**/build/
5 | !**/src/test/**/build/
6 |
7 | ### STS ###
8 | .apt_generated
9 | .classpath
10 | .factorypath
11 | .project
12 | .settings
13 | .springBeans
14 | .sts4-cache
15 | bin/
16 | !**/src/main/**/bin/
17 | !**/src/test/**/bin/
18 |
19 | ### IntelliJ IDEA ###
20 | .idea
21 | *.iws
22 | *.iml
23 | *.ipr
24 | out/
25 | !**/src/main/**/out/
26 | !**/src/test/**/out/
27 |
28 | ### NetBeans ###
29 | /nbproject/private/
30 | /nbbuild/
31 | /dist/
32 | /nbdist/
33 | /.nb-gradle/
34 |
35 | ### VS Code ###
36 | .vscode/
37 |
38 | ### Project ###
39 | .env
40 | persistence/
41 | configuration.toml
42 | docker-compose.yml
43 | patches-public-key.asc
44 | node_modules/
45 | static/
46 | about.json
--------------------------------------------------------------------------------
/.releaserc:
--------------------------------------------------------------------------------
1 | {
2 | "branches": [
3 | "main",
4 | {
5 | "name": "dev",
6 | "prerelease": true
7 | }
8 | ],
9 | "plugins": [
10 | [
11 | "@semantic-release/commit-analyzer", {
12 | "releaseRules": [
13 | { "type": "build", "scope": "Needs bump", "release": "patch" }
14 | ]
15 | }
16 | ],
17 | "@semantic-release/release-notes-generator",
18 | "@semantic-release/changelog",
19 | "gradle-semantic-release-plugin",
20 | [
21 | "@semantic-release/git",
22 | {
23 | "assets": [
24 | "CHANGELOG.md",
25 | "gradle.properties"
26 | ],
27 | "message": "chore: Release v${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
28 | }
29 | ],
30 | [
31 | "@semantic-release/github",
32 | {
33 | "assets": [
34 | {
35 | "path": "build/libs/*"
36 | }
37 | ],
38 | "successComment": false
39 | }
40 | ],
41 | [
42 | "@codedependant/semantic-release-docker",
43 | {
44 | "dockerImage": "revanced-api",
45 | "dockerTags": [
46 | "{{#if prerelease.[0]}}dev{{else}}main{{/if}}",
47 | "{{#unless prerelease.[0]}}latest{{/unless}}",
48 | "{{version}}"
49 | ],
50 | "dockerRegistry": "ghcr.io",
51 | "dockerProject": "revanced",
52 | "dockerPlatform": [
53 | "linux/amd64",
54 | "linux/arm64"
55 | ],
56 | "dockerArgs": {
57 | "GITHUB_ACTOR": null,
58 | "GITHUB_TOKEN": null,
59 | }
60 | }
61 | ],
62 | [
63 | "@saithodev/semantic-release-backmerge",
64 | {
65 | "backmergeBranches": [{"from": "main", "to": "dev"}],
66 | "clearWorkspace": true
67 | }
68 | ]
69 | ]
70 | }
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | Continuing the legacy of Vanced
59 |
60 |
61 | # 👋 Contribution guidelines
62 |
63 | This document describes how to contribute to ReVanced API.
64 |
65 | ## 📖 Resources to help you get started
66 |
67 | * [Our backlog](https://github.com/orgs/ReVanced/projects/12) is where we keep track of what we're working on
68 | * [Issues](https://github.com/ReVanced/revanced-api/issues) are where we keep track of bugs and feature requests
69 |
70 | ## 🙏 Submitting a feature request
71 |
72 | Features can be requested by opening an issue using the
73 | [Feature request issue template](https://github.com/ReVanced/revanced-api/issues/new?assignees=&labels=Feature+request&projects=&template=feature_request.yml&title=feat%3A+).
74 |
75 | > **Note**
76 | > Requests can be accepted or rejected at the discretion of maintainers of ReVanced API.
77 | > Good motivation has to be provided for a request to be accepted.
78 |
79 | ## 🐞 Submitting a bug report
80 |
81 | If you encounter a bug while using ReVanced API, open an issue using the
82 | [Bug report issue template](https://github.com/ReVanced/revanced-api/issues/new?assignees=&labels=Bug+report&projects=&template=bug_report.yml&title=bug%3A+).
83 |
84 | ## 📝 How to contribute
85 |
86 | 1. Before contributing, it is recommended to open an issue to discuss your change
87 | with the maintainers of ReVanced API. This will help you determine whether your change is acceptable
88 | and whether it is worth your time to implement it
89 | 2. Development happens on the `dev` branch. Fork the repository and create your branch from `dev`
90 | 3. Commit your changes
91 | 4. Submit a pull request to the `dev` branch of the repository and reference issues
92 | that your pull request closes in the description of your pull request
93 | 5. Our team will review your pull request and provide feedback. Once your pull request is approved,
94 | it will be merged into the `dev` branch and will be included in the next release of ReVanced API
95 |
96 | ❤️ Thank you for considering contributing to ReVanced API,
97 | ReVanced
98 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Build the application
2 | FROM gradle:latest AS build
3 |
4 | ARG GITHUB_ACTOR
5 | ARG GITHUB_TOKEN
6 |
7 | ENV GITHUB_ACTOR=$GITHUB_ACTOR
8 | ENV GITHUB_TOKEN=$GITHUB_TOKEN
9 |
10 | WORKDIR /app
11 | COPY . .
12 | RUN gradle startShadowScript --no-daemon
13 |
14 | # Build the runtime container
15 | FROM eclipse-temurin:latest
16 |
17 | WORKDIR /app
18 | COPY --from=build /app/build/libs/revanced-api-*.jar revanced-api.jar
19 | CMD java -jar revanced-api.jar $COMMAND
20 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU Affero General Public License is a free, copyleft license for
11 | software and other kinds of works, specifically designed to ensure
12 | cooperation with the community in the case of network server software.
13 |
14 | The licenses for most software and other practical works are designed
15 | to take away your freedom to share and change the works. By contrast,
16 | our General Public Licenses are intended to guarantee your freedom to
17 | share and change all versions of a program--to make sure it remains free
18 | software for all its users.
19 |
20 | When we speak of free software, we are referring to freedom, not
21 | price. Our General Public Licenses are designed to make sure that you
22 | have the freedom to distribute copies of free software (and charge for
23 | them if you wish), that you receive source code or can get it if you
24 | want it, that you can change the software or use pieces of it in new
25 | free programs, and that you know you can do these things.
26 |
27 | Developers that use our General Public Licenses protect your rights
28 | with two steps: (1) assert copyright on the software, and (2) offer
29 | you this License which gives you legal permission to copy, distribute
30 | and/or modify the software.
31 |
32 | A secondary benefit of defending all users' freedom is that
33 | improvements made in alternate versions of the program, if they
34 | receive widespread use, become available for other developers to
35 | incorporate. Many developers of free software are heartened and
36 | encouraged by the resulting cooperation. However, in the case of
37 | software used on network servers, this result may fail to come about.
38 | The GNU General Public License permits making a modified version and
39 | letting the public access it on a server without ever releasing its
40 | source code to the public.
41 |
42 | The GNU Affero General Public License is designed specifically to
43 | ensure that, in such cases, the modified source code becomes available
44 | to the community. It requires the operator of a network server to
45 | provide the source code of the modified version running there to the
46 | users of that server. Therefore, public use of a modified version, on
47 | a publicly accessible server, gives the public access to the source
48 | code of the modified version.
49 |
50 | An older license, called the Affero General Public License and
51 | published by Affero, was designed to accomplish similar goals. This is
52 | a different license, not a version of the Affero GPL, but Affero has
53 | released a new version of the Affero GPL which permits relicensing under
54 | this license.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | TERMS AND CONDITIONS
60 |
61 | 0. Definitions.
62 |
63 | "This License" refers to version 3 of the GNU Affero General Public License.
64 |
65 | "Copyright" also means copyright-like laws that apply to other kinds of
66 | works, such as semiconductor masks.
67 |
68 | "The Program" refers to any copyrightable work licensed under this
69 | License. Each licensee is addressed as "you". "Licensees" and
70 | "recipients" may be individuals or organizations.
71 |
72 | To "modify" a work means to copy from or adapt all or part of the work
73 | in a fashion requiring copyright permission, other than the making of an
74 | exact copy. The resulting work is called a "modified version" of the
75 | earlier work or a work "based on" the earlier work.
76 |
77 | A "covered work" means either the unmodified Program or a work based
78 | on the Program.
79 |
80 | To "propagate" a work means to do anything with it that, without
81 | permission, would make you directly or secondarily liable for
82 | infringement under applicable copyright law, except executing it on a
83 | computer or modifying a private copy. Propagation includes copying,
84 | distribution (with or without modification), making available to the
85 | public, and in some countries other activities as well.
86 |
87 | To "convey" a work means any kind of propagation that enables other
88 | parties to make or receive copies. Mere interaction with a user through
89 | a computer network, with no transfer of a copy, is not conveying.
90 |
91 | An interactive user interface displays "Appropriate Legal Notices"
92 | to the extent that it includes a convenient and prominently visible
93 | feature that (1) displays an appropriate copyright notice, and (2)
94 | tells the user that there is no warranty for the work (except to the
95 | extent that warranties are provided), that licensees may convey the
96 | work under this License, and how to view a copy of this License. If
97 | the interface presents a list of user commands or options, such as a
98 | menu, a prominent item in the list meets this criterion.
99 |
100 | 1. Source Code.
101 |
102 | The "source code" for a work means the preferred form of the work
103 | for making modifications to it. "Object code" means any non-source
104 | form of a work.
105 |
106 | A "Standard Interface" means an interface that either is an official
107 | standard defined by a recognized standards body, or, in the case of
108 | interfaces specified for a particular programming language, one that
109 | is widely used among developers working in that language.
110 |
111 | The "System Libraries" of an executable work include anything, other
112 | than the work as a whole, that (a) is included in the normal form of
113 | packaging a Major Component, but which is not part of that Major
114 | Component, and (b) serves only to enable use of the work with that
115 | Major Component, or to implement a Standard Interface for which an
116 | implementation is available to the public in source code form. A
117 | "Major Component", in this context, means a major essential component
118 | (kernel, window system, and so on) of the specific operating system
119 | (if any) on which the executable work runs, or a compiler used to
120 | produce the work, or an object code interpreter used to run it.
121 |
122 | The "Corresponding Source" for a work in object code form means all
123 | the source code needed to generate, install, and (for an executable
124 | work) run the object code and to modify the work, including scripts to
125 | control those activities. However, it does not include the work's
126 | System Libraries, or general-purpose tools or generally available free
127 | programs which are used unmodified in performing those activities but
128 | which are not part of the work. For example, Corresponding Source
129 | includes interface definition files associated with source files for
130 | the work, and the source code for shared libraries and dynamically
131 | linked subprograms that the work is specifically designed to require,
132 | such as by intimate data communication or control flow between those
133 | subprograms and other parts of the work.
134 |
135 | The Corresponding Source need not include anything that users
136 | can regenerate automatically from other parts of the Corresponding
137 | Source.
138 |
139 | The Corresponding Source for a work in source code form is that
140 | same work.
141 |
142 | 2. Basic Permissions.
143 |
144 | All rights granted under this License are granted for the term of
145 | copyright on the Program, and are irrevocable provided the stated
146 | conditions are met. This License explicitly affirms your unlimited
147 | permission to run the unmodified Program. The output from running a
148 | covered work is covered by this License only if the output, given its
149 | content, constitutes a covered work. This License acknowledges your
150 | rights of fair use or other equivalent, as provided by copyright law.
151 |
152 | You may make, run and propagate covered works that you do not
153 | convey, without conditions so long as your license otherwise remains
154 | in force. You may convey covered works to others for the sole purpose
155 | of having them make modifications exclusively for you, or provide you
156 | with facilities for running those works, provided that you comply with
157 | the terms of this License in conveying all material for which you do
158 | not control copyright. Those thus making or running the covered works
159 | for you must do so exclusively on your behalf, under your direction
160 | and control, on terms that prohibit them from making any copies of
161 | your copyrighted material outside their relationship with you.
162 |
163 | Conveying under any other circumstances is permitted solely under
164 | the conditions stated below. Sublicensing is not allowed; section 10
165 | makes it unnecessary.
166 |
167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 |
169 | No covered work shall be deemed part of an effective technological
170 | measure under any applicable law fulfilling obligations under article
171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 | similar laws prohibiting or restricting circumvention of such
173 | measures.
174 |
175 | When you convey a covered work, you waive any legal power to forbid
176 | circumvention of technological measures to the extent such circumvention
177 | is effected by exercising rights under this License with respect to
178 | the covered work, and you disclaim any intention to limit operation or
179 | modification of the work as a means of enforcing, against the work's
180 | users, your or third parties' legal rights to forbid circumvention of
181 | technological measures.
182 |
183 | 4. Conveying Verbatim Copies.
184 |
185 | You may convey verbatim copies of the Program's source code as you
186 | receive it, in any medium, provided that you conspicuously and
187 | appropriately publish on each copy an appropriate copyright notice;
188 | keep intact all notices stating that this License and any
189 | non-permissive terms added in accord with section 7 apply to the code;
190 | keep intact all notices of the absence of any warranty; and give all
191 | recipients a copy of this License along with the Program.
192 |
193 | You may charge any price or no price for each copy that you convey,
194 | and you may offer support or warranty protection for a fee.
195 |
196 | 5. Conveying Modified Source Versions.
197 |
198 | You may convey a work based on the Program, or the modifications to
199 | produce it from the Program, in the form of source code under the
200 | terms of section 4, provided that you also meet all of these conditions:
201 |
202 | a) The work must carry prominent notices stating that you modified
203 | it, and giving a relevant date.
204 |
205 | b) The work must carry prominent notices stating that it is
206 | released under this License and any conditions added under section
207 | 7. This requirement modifies the requirement in section 4 to
208 | "keep intact all notices".
209 |
210 | c) You must license the entire work, as a whole, under this
211 | License to anyone who comes into possession of a copy. This
212 | License will therefore apply, along with any applicable section 7
213 | additional terms, to the whole of the work, and all its parts,
214 | regardless of how they are packaged. This License gives no
215 | permission to license the work in any other way, but it does not
216 | invalidate such permission if you have separately received it.
217 |
218 | d) If the work has interactive user interfaces, each must display
219 | Appropriate Legal Notices; however, if the Program has interactive
220 | interfaces that do not display Appropriate Legal Notices, your
221 | work need not make them do so.
222 |
223 | A compilation of a covered work with other separate and independent
224 | works, which are not by their nature extensions of the covered work,
225 | and which are not combined with it such as to form a larger program,
226 | in or on a volume of a storage or distribution medium, is called an
227 | "aggregate" if the compilation and its resulting copyright are not
228 | used to limit the access or legal rights of the compilation's users
229 | beyond what the individual works permit. Inclusion of a covered work
230 | in an aggregate does not cause this License to apply to the other
231 | parts of the aggregate.
232 |
233 | 6. Conveying Non-Source Forms.
234 |
235 | You may convey a covered work in object code form under the terms
236 | of sections 4 and 5, provided that you also convey the
237 | machine-readable Corresponding Source under the terms of this License,
238 | in one of these ways:
239 |
240 | a) Convey the object code in, or embodied in, a physical product
241 | (including a physical distribution medium), accompanied by the
242 | Corresponding Source fixed on a durable physical medium
243 | customarily used for software interchange.
244 |
245 | b) Convey the object code in, or embodied in, a physical product
246 | (including a physical distribution medium), accompanied by a
247 | written offer, valid for at least three years and valid for as
248 | long as you offer spare parts or customer support for that product
249 | model, to give anyone who possesses the object code either (1) a
250 | copy of the Corresponding Source for all the software in the
251 | product that is covered by this License, on a durable physical
252 | medium customarily used for software interchange, for a price no
253 | more than your reasonable cost of physically performing this
254 | conveying of source, or (2) access to copy the
255 | Corresponding Source from a network server at no charge.
256 |
257 | c) Convey individual copies of the object code with a copy of the
258 | written offer to provide the Corresponding Source. This
259 | alternative is allowed only occasionally and noncommercially, and
260 | only if you received the object code with such an offer, in accord
261 | with subsection 6b.
262 |
263 | d) Convey the object code by offering access from a designated
264 | place (gratis or for a charge), and offer equivalent access to the
265 | Corresponding Source in the same way through the same place at no
266 | further charge. You need not require recipients to copy the
267 | Corresponding Source along with the object code. If the place to
268 | copy the object code is a network server, the Corresponding Source
269 | may be on a different server (operated by you or a third party)
270 | that supports equivalent copying facilities, provided you maintain
271 | clear directions next to the object code saying where to find the
272 | Corresponding Source. Regardless of what server hosts the
273 | Corresponding Source, you remain obligated to ensure that it is
274 | available for as long as needed to satisfy these requirements.
275 |
276 | e) Convey the object code using peer-to-peer transmission, provided
277 | you inform other peers where the object code and Corresponding
278 | Source of the work are being offered to the general public at no
279 | charge under subsection 6d.
280 |
281 | A separable portion of the object code, whose source code is excluded
282 | from the Corresponding Source as a System Library, need not be
283 | included in conveying the object code work.
284 |
285 | A "User Product" is either (1) a "consumer product", which means any
286 | tangible personal property which is normally used for personal, family,
287 | or household purposes, or (2) anything designed or sold for incorporation
288 | into a dwelling. In determining whether a product is a consumer product,
289 | doubtful cases shall be resolved in favor of coverage. For a particular
290 | product received by a particular user, "normally used" refers to a
291 | typical or common use of that class of product, regardless of the status
292 | of the particular user or of the way in which the particular user
293 | actually uses, or expects or is expected to use, the product. A product
294 | is a consumer product regardless of whether the product has substantial
295 | commercial, industrial or non-consumer uses, unless such uses represent
296 | the only significant mode of use of the product.
297 |
298 | "Installation Information" for a User Product means any methods,
299 | procedures, authorization keys, or other information required to install
300 | and execute modified versions of a covered work in that User Product from
301 | a modified version of its Corresponding Source. The information must
302 | suffice to ensure that the continued functioning of the modified object
303 | code is in no case prevented or interfered with solely because
304 | modification has been made.
305 |
306 | If you convey an object code work under this section in, or with, or
307 | specifically for use in, a User Product, and the conveying occurs as
308 | part of a transaction in which the right of possession and use of the
309 | User Product is transferred to the recipient in perpetuity or for a
310 | fixed term (regardless of how the transaction is characterized), the
311 | Corresponding Source conveyed under this section must be accompanied
312 | by the Installation Information. But this requirement does not apply
313 | if neither you nor any third party retains the ability to install
314 | modified object code on the User Product (for example, the work has
315 | been installed in ROM).
316 |
317 | The requirement to provide Installation Information does not include a
318 | requirement to continue to provide support service, warranty, or updates
319 | for a work that has been modified or installed by the recipient, or for
320 | the User Product in which it has been modified or installed. Access to a
321 | network may be denied when the modification itself materially and
322 | adversely affects the operation of the network or violates the rules and
323 | protocols for communication across the network.
324 |
325 | Corresponding Source conveyed, and Installation Information provided,
326 | in accord with this section must be in a format that is publicly
327 | documented (and with an implementation available to the public in
328 | source code form), and must require no special password or key for
329 | unpacking, reading or copying.
330 |
331 | 7. Additional Terms.
332 |
333 | "Additional permissions" are terms that supplement the terms of this
334 | License by making exceptions from one or more of its conditions.
335 | Additional permissions that are applicable to the entire Program shall
336 | be treated as though they were included in this License, to the extent
337 | that they are valid under applicable law. If additional permissions
338 | apply only to part of the Program, that part may be used separately
339 | under those permissions, but the entire Program remains governed by
340 | this License without regard to the additional permissions.
341 |
342 | When you convey a copy of a covered work, you may at your option
343 | remove any additional permissions from that copy, or from any part of
344 | it. (Additional permissions may be written to require their own
345 | removal in certain cases when you modify the work.) You may place
346 | additional permissions on material, added by you to a covered work,
347 | for which you have or can give appropriate copyright permission.
348 |
349 | Notwithstanding any other provision of this License, for material you
350 | add to a covered work, you may (if authorized by the copyright holders of
351 | that material) supplement the terms of this License with terms:
352 |
353 | a) Disclaiming warranty or limiting liability differently from the
354 | terms of sections 15 and 16 of this License; or
355 |
356 | b) Requiring preservation of specified reasonable legal notices or
357 | author attributions in that material or in the Appropriate Legal
358 | Notices displayed by works containing it; or
359 |
360 | c) Prohibiting misrepresentation of the origin of that material, or
361 | requiring that modified versions of such material be marked in
362 | reasonable ways as different from the original version; or
363 |
364 | d) Limiting the use for publicity purposes of names of licensors or
365 | authors of the material; or
366 |
367 | e) Declining to grant rights under trademark law for use of some
368 | trade names, trademarks, or service marks; or
369 |
370 | f) Requiring indemnification of licensors and authors of that
371 | material by anyone who conveys the material (or modified versions of
372 | it) with contractual assumptions of liability to the recipient, for
373 | any liability that these contractual assumptions directly impose on
374 | those licensors and authors.
375 |
376 | All other non-permissive additional terms are considered "further
377 | restrictions" within the meaning of section 10. If the Program as you
378 | received it, or any part of it, contains a notice stating that it is
379 | governed by this License along with a term that is a further
380 | restriction, you may remove that term. If a license document contains
381 | a further restriction but permits relicensing or conveying under this
382 | License, you may add to a covered work material governed by the terms
383 | of that license document, provided that the further restriction does
384 | not survive such relicensing or conveying.
385 |
386 | If you add terms to a covered work in accord with this section, you
387 | must place, in the relevant source files, a statement of the
388 | additional terms that apply to those files, or a notice indicating
389 | where to find the applicable terms.
390 |
391 | Additional terms, permissive or non-permissive, may be stated in the
392 | form of a separately written license, or stated as exceptions;
393 | the above requirements apply either way.
394 |
395 | 8. Termination.
396 |
397 | You may not propagate or modify a covered work except as expressly
398 | provided under this License. Any attempt otherwise to propagate or
399 | modify it is void, and will automatically terminate your rights under
400 | this License (including any patent licenses granted under the third
401 | paragraph of section 11).
402 |
403 | However, if you cease all violation of this License, then your
404 | license from a particular copyright holder is reinstated (a)
405 | provisionally, unless and until the copyright holder explicitly and
406 | finally terminates your license, and (b) permanently, if the copyright
407 | holder fails to notify you of the violation by some reasonable means
408 | prior to 60 days after the cessation.
409 |
410 | Moreover, your license from a particular copyright holder is
411 | reinstated permanently if the copyright holder notifies you of the
412 | violation by some reasonable means, this is the first time you have
413 | received notice of violation of this License (for any work) from that
414 | copyright holder, and you cure the violation prior to 30 days after
415 | your receipt of the notice.
416 |
417 | Termination of your rights under this section does not terminate the
418 | licenses of parties who have received copies or rights from you under
419 | this License. If your rights have been terminated and not permanently
420 | reinstated, you do not qualify to receive new licenses for the same
421 | material under section 10.
422 |
423 | 9. Acceptance Not Required for Having Copies.
424 |
425 | You are not required to accept this License in order to receive or
426 | run a copy of the Program. Ancillary propagation of a covered work
427 | occurring solely as a consequence of using peer-to-peer transmission
428 | to receive a copy likewise does not require acceptance. However,
429 | nothing other than this License grants you permission to propagate or
430 | modify any covered work. These actions infringe copyright if you do
431 | not accept this License. Therefore, by modifying or propagating a
432 | covered work, you indicate your acceptance of this License to do so.
433 |
434 | 10. Automatic Licensing of Downstream Recipients.
435 |
436 | Each time you convey a covered work, the recipient automatically
437 | receives a license from the original licensors, to run, modify and
438 | propagate that work, subject to this License. You are not responsible
439 | for enforcing compliance by third parties with this License.
440 |
441 | An "entity transaction" is a transaction transferring control of an
442 | organization, or substantially all assets of one, or subdividing an
443 | organization, or merging organizations. If propagation of a covered
444 | work results from an entity transaction, each party to that
445 | transaction who receives a copy of the work also receives whatever
446 | licenses to the work the party's predecessor in interest had or could
447 | give under the previous paragraph, plus a right to possession of the
448 | Corresponding Source of the work from the predecessor in interest, if
449 | the predecessor has it or can get it with reasonable efforts.
450 |
451 | You may not impose any further restrictions on the exercise of the
452 | rights granted or affirmed under this License. For example, you may
453 | not impose a license fee, royalty, or other charge for exercise of
454 | rights granted under this License, and you may not initiate litigation
455 | (including a cross-claim or counterclaim in a lawsuit) alleging that
456 | any patent claim is infringed by making, using, selling, offering for
457 | sale, or importing the Program or any portion of it.
458 |
459 | 11. Patents.
460 |
461 | A "contributor" is a copyright holder who authorizes use under this
462 | License of the Program or a work on which the Program is based. The
463 | work thus licensed is called the contributor's "contributor version".
464 |
465 | A contributor's "essential patent claims" are all patent claims
466 | owned or controlled by the contributor, whether already acquired or
467 | hereafter acquired, that would be infringed by some manner, permitted
468 | by this License, of making, using, or selling its contributor version,
469 | but do not include claims that would be infringed only as a
470 | consequence of further modification of the contributor version. For
471 | purposes of this definition, "control" includes the right to grant
472 | patent sublicenses in a manner consistent with the requirements of
473 | this License.
474 |
475 | Each contributor grants you a non-exclusive, worldwide, royalty-free
476 | patent license under the contributor's essential patent claims, to
477 | make, use, sell, offer for sale, import and otherwise run, modify and
478 | propagate the contents of its contributor version.
479 |
480 | In the following three paragraphs, a "patent license" is any express
481 | agreement or commitment, however denominated, not to enforce a patent
482 | (such as an express permission to practice a patent or covenant not to
483 | sue for patent infringement). To "grant" such a patent license to a
484 | party means to make such an agreement or commitment not to enforce a
485 | patent against the party.
486 |
487 | If you convey a covered work, knowingly relying on a patent license,
488 | and the Corresponding Source of the work is not available for anyone
489 | to copy, free of charge and under the terms of this License, through a
490 | publicly available network server or other readily accessible means,
491 | then you must either (1) cause the Corresponding Source to be so
492 | available, or (2) arrange to deprive yourself of the benefit of the
493 | patent license for this particular work, or (3) arrange, in a manner
494 | consistent with the requirements of this License, to extend the patent
495 | license to downstream recipients. "Knowingly relying" means you have
496 | actual knowledge that, but for the patent license, your conveying the
497 | covered work in a country, or your recipient's use of the covered work
498 | in a country, would infringe one or more identifiable patents in that
499 | country that you have reason to believe are valid.
500 |
501 | If, pursuant to or in connection with a single transaction or
502 | arrangement, you convey, or propagate by procuring conveyance of, a
503 | covered work, and grant a patent license to some of the parties
504 | receiving the covered work authorizing them to use, propagate, modify
505 | or convey a specific copy of the covered work, then the patent license
506 | you grant is automatically extended to all recipients of the covered
507 | work and works based on it.
508 |
509 | A patent license is "discriminatory" if it does not include within
510 | the scope of its coverage, prohibits the exercise of, or is
511 | conditioned on the non-exercise of one or more of the rights that are
512 | specifically granted under this License. You may not convey a covered
513 | work if you are a party to an arrangement with a third party that is
514 | in the business of distributing software, under which you make payment
515 | to the third party based on the extent of your activity of conveying
516 | the work, and under which the third party grants, to any of the
517 | parties who would receive the covered work from you, a discriminatory
518 | patent license (a) in connection with copies of the covered work
519 | conveyed by you (or copies made from those copies), or (b) primarily
520 | for and in connection with specific products or compilations that
521 | contain the covered work, unless you entered into that arrangement,
522 | or that patent license was granted, prior to 28 March 2007.
523 |
524 | Nothing in this License shall be construed as excluding or limiting
525 | any implied license or other defenses to infringement that may
526 | otherwise be available to you under applicable patent law.
527 |
528 | 12. No Surrender of Others' Freedom.
529 |
530 | If conditions are imposed on you (whether by court order, agreement or
531 | otherwise) that contradict the conditions of this License, they do not
532 | excuse you from the conditions of this License. If you cannot convey a
533 | covered work so as to satisfy simultaneously your obligations under this
534 | License and any other pertinent obligations, then as a consequence you may
535 | not convey it at all. For example, if you agree to terms that obligate you
536 | to collect a royalty for further conveying from those to whom you convey
537 | the Program, the only way you could satisfy both those terms and this
538 | License would be to refrain entirely from conveying the Program.
539 |
540 | 13. Remote Network Interaction; Use with the GNU General Public License.
541 |
542 | Notwithstanding any other provision of this License, if you modify the
543 | Program, your modified version must prominently offer all users
544 | interacting with it remotely through a computer network (if your version
545 | supports such interaction) an opportunity to receive the Corresponding
546 | Source of your version by providing access to the Corresponding Source
547 | from a network server at no charge, through some standard or customary
548 | means of facilitating copying of software. This Corresponding Source
549 | shall include the Corresponding Source for any work covered by version 3
550 | of the GNU General Public License that is incorporated pursuant to the
551 | following paragraph.
552 |
553 | Notwithstanding any other provision of this License, you have
554 | permission to link or combine any covered work with a work licensed
555 | under version 3 of the GNU General Public License into a single
556 | combined work, and to convey the resulting work. The terms of this
557 | License will continue to apply to the part which is the covered work,
558 | but the work with which it is combined will remain governed by version
559 | 3 of the GNU General Public License.
560 |
561 | 14. Revised Versions of this License.
562 |
563 | The Free Software Foundation may publish revised and/or new versions of
564 | the GNU Affero General Public License from time to time. Such new versions
565 | will be similar in spirit to the present version, but may differ in detail to
566 | address new problems or concerns.
567 |
568 | Each version is given a distinguishing version number. If the
569 | Program specifies that a certain numbered version of the GNU Affero General
570 | Public License "or any later version" applies to it, you have the
571 | option of following the terms and conditions either of that numbered
572 | version or of any later version published by the Free Software
573 | Foundation. If the Program does not specify a version number of the
574 | GNU Affero General Public License, you may choose any version ever published
575 | by the Free Software Foundation.
576 |
577 | If the Program specifies that a proxy can decide which future
578 | versions of the GNU Affero General Public License can be used, that proxy's
579 | public statement of acceptance of a version permanently authorizes you
580 | to choose that version for the Program.
581 |
582 | Later license versions may give you additional or different
583 | permissions. However, no additional obligations are imposed on any
584 | author or copyright holder as a result of your choosing to follow a
585 | later version.
586 |
587 | 15. Disclaimer of Warranty.
588 |
589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 |
598 | 16. Limitation of Liability.
599 |
600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 | SUCH DAMAGES.
609 |
610 | 17. Interpretation of Sections 15 and 16.
611 |
612 | If the disclaimer of warranty and limitation of liability provided
613 | above cannot be given local legal effect according to their terms,
614 | reviewing courts shall apply local law that most closely approximates
615 | an absolute waiver of all civil liability in connection with the
616 | Program, unless a warranty or assumption of liability accompanies a
617 | copy of the Program in return for a fee.
618 |
619 | END OF TERMS AND CONDITIONS
620 |
621 | How to Apply These Terms to Your New Programs
622 |
623 | If you develop a new program, and you want it to be of the greatest
624 | possible use to the public, the best way to achieve this is to make it
625 | free software which everyone can redistribute and change under these terms.
626 |
627 | To do so, attach the following notices to the program. It is safest
628 | to attach them to the start of each source file to most effectively
629 | state the exclusion of warranty; and each file should have at least
630 | the "copyright" line and a pointer to where the full notice is found.
631 |
632 |
633 | Copyright (C)
634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published
637 | by the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | Continuing the legacy of Vanced
59 |
60 |
61 | # 🚀 ReVanced API
62 |
63 | 
64 | 
65 |
66 | API server for ReVanced.
67 |
68 | ## ❓ About
69 |
70 | ReVanced API is a server that is used as the backend for ReVanced.
71 | ReVanced API acts as the data source for [ReVanced Website](https://github.com/ReVanced/revanced-website) and
72 | powers [ReVanced Manager](https://github.com/ReVanced/revanced-manager)
73 | with updates and ReVanced Patches.
74 |
75 | ## 💪 Features
76 |
77 | Some of the features ReVanced API include:
78 |
79 | - 📢 **Announcements**: Post and get announcements
80 | - ℹ️ **About**: Get more information such as a description, ways to donate to,
81 | and links of the hoster of ReVanced API
82 | - 🧩 **Patches**: Get the latest updates of ReVanced Patches, directly from ReVanced API
83 | - 👥 **Contributors**: List all contributors involved in the project
84 |
85 | ## 🚀 How to get started
86 |
87 | ReVanced API can be deployed as a Docker container or used standalone.
88 |
89 | ## 🐳 Docker
90 |
91 | To deploy ReVanced API as a Docker container, you can use Docker Compose or Docker CLI.
92 | The Docker image is published on GitHub Container registry,
93 | so before you can pull the image, you need
94 | to [authenticate to the Container registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry).
95 |
96 | ### 🗄️ Docker Compose
97 |
98 | 1. Create an `.env` file using [.env.example](.env.example) as a template
99 | 2. Create a `configuration.toml` file using [configuration.example.toml](configuration.example.toml) as a template
100 | 3. Create an `about.json` file using [about.example.json](about.example.json) as a template
101 | 4. Create a `docker-compose.yml` file using [docker-compose.example.yml](docker-compose.example.yml) as a template
102 | 5. Run `docker-compose up -d` to start the server
103 |
104 | ### 💻 Docker CLI
105 |
106 | 1. Create an `.env` file using [.env.example](.env.example) as a template
107 | 2. Create a `configuration.toml` file using [configuration.example.toml](configuration.example.toml) as a template
108 | 3. Create an `about.json` file using [about.example.json](about.example.json) as a template
109 | 4. Start the container using the following command:
110 | ```shell
111 | docker run -d --name revanced-api \
112 | # Mount the .env file
113 | -v $(pwd)/.env:/app/.env \
114 | # Mount the configuration.toml file
115 | -v $(pwd)/configuration.toml:/app/configuration.toml \
116 | # Mount the patches public key
117 | -v $(pwd)/patches-public-key.asc:/app/patches-public-key.asc \
118 | # Mount the static folder
119 | -v $(pwd)/static:/app/static \
120 | # Mount the about.json file
121 | -v $(pwd)/about.json:/app/about.json \
122 | # Mount the persistence folder
123 | -v $(pwd)/persistence:/app/persistence \
124 | # Expose the port 8888
125 | -p 8888:8888 \
126 | # Use the start command to start the server
127 | -e COMMAND=start \
128 | # Pull the image from the GitHub Container registry
129 | ghcr.io/revanced/revanced-api:latest
130 | ```
131 |
132 | ## 🖥️ Standalone
133 |
134 | To deploy ReVanced API standalone, you can either use the pre-built executable or build it from source.
135 |
136 | ### 📦 Pre-built executable
137 |
138 | A Java Runtime Environment (JRE) must be installed.
139 |
140 | 1. [Download](https://github.com/ReVanced/revanced-api/releases/latest) ReVanced API to a folder
141 | 2. In the same folder, create an `.env` file using [.env.example](.env.example) as a template
142 | 3. In the same folder, create a `configuration.toml` file
143 | using [configuration.example.toml](configuration.example.toml) as a template
144 | 4. In the same folder, create an `about.json` file using [about.example.json](about.example.json) as a template
145 | 5. Run `java -jar revanced-api.jar start` to start the server
146 |
147 | ### 🛠️ From source
148 |
149 | A Java Development Kit (JDK) and Git must be installed.
150 |
151 | 1. Run `git clone git@github.com:ReVanced/revanced-api.git` to clone the repository
152 | 2. Copy [.env.example](.env.example) to `.env` and fill in the required values
153 | 3. Copy [configuration.example.toml](configuration.example.toml) to `configuration.toml` and fill in the required values
154 | 4. Copy [about.example.json](about.example.json) to `about.json` and fill in the required values
155 | 5. Run `gradlew run --args=start` to start the server
156 |
157 | ## 📚 Everything else
158 |
159 | ### 📙 Contributing
160 |
161 | Thank you for considering contributing to ReVanced API. You can find the contribution
162 | guidelines [here](CONTRIBUTING.md).
163 |
164 | ### 🛠️ Building
165 |
166 | To build ReVanced API, a Java Development Kit (JDK) and Git must be installed.
167 | Follow the steps below to build ReVanced API:
168 |
169 | 1. Run `git clone git@github.com:ReVanced/revanced-api.git` to clone the repository
170 | 2. Run `gradlew build` to build the project
171 |
172 | ## 📜 Licence
173 |
174 | ReVanced API is licensed under the AGPLv3 licence. Please see the [licence file](LICENSE) for more information.
175 | [tl;dr](https://www.tldrlegal.com/license/gnu-affero-general-public-license-v3-agpl-3-0) you may copy, distribute and
176 | modify ReVanced API as long as you track changes/dates in source files.
177 | Any modifications to ReVanced API must also be made available under the GPL along with build & install instructions.
178 |
--------------------------------------------------------------------------------
/about.example.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ReVanced",
3 | "about": "ReVanced was born out of Vanced's discontinuation and it is our goal to continue the legacy of what Vanced left behind. Thanks to ReVanced Patcher, it's possible to create long-lasting patches for nearly any Android app. ReVanced's patching system is designed to allow patches to work on new versions of the apps automatically with bare minimum maintenance.",
4 | "keys": "https://api.revanced.app/keys",
5 | "branding": {
6 | "logo": "https://raw.githubusercontent.com/ReVanced/revanced-branding/main/assets/revanced-logo/revanced-logo.svg"
7 | },
8 | "status": "https://status.revanced.app",
9 | "contact": {
10 | "email": "contact@revanced.app"
11 | },
12 | "socials": [
13 | {
14 | "name": "Website",
15 | "url": "https://revanced.app",
16 | "preferred": true
17 | },
18 | {
19 | "name": "GitHub",
20 | "url": "https://github.com/revanced"
21 | },
22 | {
23 | "name": "Twitter",
24 | "url": "https://twitter.com/revancedapp"
25 | },
26 | {
27 | "name": "Discord",
28 | "url": "https://revanced.app/discord",
29 | "preferred": true
30 | },
31 | {
32 | "name": "Reddit",
33 | "url": "https://www.reddit.com/r/revancedapp"
34 | },
35 | {
36 | "name": "Telegram",
37 | "url": "https://t.me/app_revanced"
38 | },
39 | {
40 | "name": "YouTube",
41 | "url": "https://www.youtube.com/@ReVanced"
42 | }
43 | ],
44 | "donations": {
45 | "wallets": [
46 | {
47 | "network": "Bitcoin",
48 | "currency_code": "BTC",
49 | "address": "bc1q4x8j6mt27y5gv0q625t8wkr87ruy8fprpy4v3f"
50 | },
51 | {
52 | "network": "Dogecoin",
53 | "currency_code": "DOGE",
54 | "address": "D8GH73rNjudgi6bS2krrXWEsU9KShedLXp",
55 | "preferred": true
56 | },
57 | {
58 | "network": "Ethereum",
59 | "currency_code": "ETH",
60 | "address": "0x7ab4091e00363654bf84B34151225742cd92FCE5"
61 | },
62 | {
63 | "network": "Litecoin",
64 | "currency_code": "LTC",
65 | "address": "LbJi8EuoDcwaZvykcKmcrM74jpjde23qJ2"
66 | },
67 | {
68 | "network": "Monero",
69 | "currency_code": "XMR",
70 | "address": "46YwWDbZD6jVptuk5mLHsuAmh1BnUMSjSNYacozQQEraWSQ93nb2yYVRHoMR6PmFYWEHsLHg9tr1cH5M8Rtn7YaaGQPCjSh"
71 | }
72 | ],
73 | "links": [
74 | {
75 | "name": "Open Collective",
76 | "url": "https://opencollective.com/revanced",
77 | "preferred": true
78 | },
79 | {
80 | "name": "GitHub Sponsors",
81 | "url": "https://github.com/sponsors/ReVanced"
82 | }
83 | ]
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/assets/revanced-headline/revanced-headline-vertical-dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/revanced-headline/revanced-headline-vertical-light.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/revanced-logo/revanced-logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2 |
3 | plugins {
4 | alias(libs.plugins.kotlin)
5 | alias(libs.plugins.ktor)
6 | alias(libs.plugins.serilization)
7 | `maven-publish`
8 | signing
9 | }
10 |
11 | group = "app.revanced"
12 |
13 | tasks {
14 | processResources {
15 | expand("projectVersion" to project.version)
16 | }
17 |
18 | // Used by gradle-semantic-release-plugin.
19 | // Tracking: https://github.com/KengoTODA/gradle-semantic-release-plugin/issues/435.
20 | publish {
21 | dependsOn(shadowJar)
22 | }
23 |
24 | shadowJar {
25 | // Needed for Jetty to work.
26 | mergeServiceFiles()
27 | }
28 | }
29 |
30 | application {
31 | mainClass.set("app.revanced.api.command.MainCommandKt")
32 | }
33 |
34 | ktor {
35 | fatJar {
36 | archiveFileName.set("${project.name}-${project.version}.jar")
37 | }
38 | }
39 |
40 | java {
41 | sourceCompatibility = JavaVersion.VERSION_21
42 | targetCompatibility = JavaVersion.VERSION_21
43 | }
44 |
45 | kotlin {
46 | compilerOptions {
47 | jvmTarget = JvmTarget.JVM_21
48 | }
49 | }
50 |
51 | tasks {
52 | test {
53 | useJUnitPlatform()
54 | }
55 | }
56 |
57 | repositories {
58 | mavenCentral()
59 | google()
60 | mavenLocal()
61 | maven {
62 | // A repository must be specified for some reason. "registry" is a dummy.
63 | url = uri("https://maven.pkg.github.com/revanced/registry")
64 | credentials {
65 | username = project.findProperty("gpr.user") as String? ?: System.getenv("GITHUB_ACTOR")
66 | password = project.findProperty("gpr.key") as String? ?: System.getenv("GITHUB_TOKEN")
67 | }
68 | }
69 | }
70 |
71 | dependencies {
72 | implementation(libs.ktor.client.core)
73 | implementation(libs.ktor.client.cio)
74 | implementation(libs.ktor.client.okhttp)
75 | implementation(libs.ktor.client.auth)
76 | implementation(libs.ktor.client.resources)
77 | implementation(libs.ktor.client.content.negotiation)
78 | implementation(libs.ktor.server.core)
79 | implementation(libs.ktor.server.content.negotiation)
80 | implementation(libs.ktor.server.auth)
81 | implementation(libs.ktor.server.auth.jwt)
82 | implementation(libs.ktor.server.cors)
83 | implementation(libs.ktor.server.caching.headers)
84 | implementation(libs.ktor.server.rate.limit)
85 | implementation(libs.ktor.server.host.common)
86 | implementation(libs.ktor.server.jetty)
87 | implementation(libs.ktor.server.call.logging)
88 | implementation(libs.ktor.serialization.kotlinx.json)
89 | implementation(libs.koin.ktor)
90 | implementation(libs.kompendium.core)
91 | implementation(libs.h2)
92 | implementation(libs.logback.classic)
93 | implementation(libs.exposed.core)
94 | implementation(libs.exposed.jdbc)
95 | implementation(libs.exposed.dao)
96 | implementation(libs.exposed.kotlin.datetime)
97 | implementation(libs.dotenv.kotlin)
98 | implementation(libs.ktoml.core)
99 | implementation(libs.ktoml.file)
100 | implementation(libs.picocli)
101 | implementation(libs.kotlinx.datetime)
102 | implementation(libs.revanced.patcher)
103 | implementation(libs.revanced.library)
104 | implementation(libs.caffeine)
105 | implementation(libs.bouncy.castle.provider)
106 | implementation(libs.bouncy.castle.pgp)
107 |
108 | testImplementation(kotlin("test"))
109 | }
110 |
111 | // The maven-publish plugin is necessary to make signing work.
112 | publishing {
113 | repositories {
114 | mavenLocal()
115 | }
116 |
117 | publications {
118 | create("revanced-api-publication") {
119 | from(components["java"])
120 | }
121 | }
122 | }
123 |
124 | signing {
125 | useGpgCmd()
126 |
127 | sign(publishing.publications["revanced-api-publication"])
128 | }
129 |
--------------------------------------------------------------------------------
/configuration.example.toml:
--------------------------------------------------------------------------------
1 | api-version = "v1"
2 | cors-allowed-hosts = [
3 | "revanced.app",
4 | "*.revanced.app"
5 | ]
6 | endpoint = "https://api.revanced.app"
7 | static-files-path = "static/root"
8 | versioned-static-files-path = "static/versioned"
9 | backend-service-name = "GitHub"
10 | about-json-file-path = "about.json"
11 | organization = "revanced"
12 |
13 | [patches]
14 | repository = "revanced-patches"
15 | asset-regex = "rvp$"
16 | signature-asset-regex = "asc$"
17 | public-key-file = "static/root/keys.asc"
18 | public-key-id = 3897925568445097277
19 |
20 | [manager]
21 | repository = "revanced-manager"
22 | asset-regex = "apk$"
23 |
24 | [contributors-repositories]
25 | revanced-patcher = "ReVanced Patcher"
26 | revanced-patches = "ReVanced Patches"
27 | revanced-website = "ReVanced Website"
28 | revanced-cli = "ReVanced CLI"
29 | revanced-manager = "ReVanced Manager"
--------------------------------------------------------------------------------
/docker-compose.example.yml:
--------------------------------------------------------------------------------
1 | services:
2 | revanced-api:
3 | container_name: revanced-api
4 | image: ghcr.io/revanced/revanced-api:latest
5 | volumes:
6 | - /data/revanced-api/persistence:/app/persistence
7 | - /data/revanced-api/.env:/app/.env
8 | - /data/revanced-api/configuration.toml:/app/configuration.toml
9 | - /data/revanced-api/patches-public-key.asc:/app/patches-public-key.asc
10 | - /data/revanced-api/static:/app/static
11 | - /data/revanced-api/about.json:/app/about.json
12 | environment:
13 | - COMMAND=start
14 | ports:
15 | - "8888:8888"
16 | restart: unless-stopped
17 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | org.gradle.parallel = true
2 | org.gradle.caching = true
3 | kotlin.code.style = official
4 | version = 1.6.1
5 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | kompendium-core = "3.14.4"
3 | kotlin = "2.0.20"
4 | logback = "1.5.6"
5 | exposed = "0.52.0"
6 | h2 = "2.2.224"
7 | koin = "3.5.3"
8 | dotenv = "6.4.1"
9 | ktor = "2.3.7"
10 | ktoml = "0.5.2"
11 | picocli = "4.7.6"
12 | datetime = "0.6.0"
13 | revanced-patcher = "21.0.0"
14 | revanced-library = "3.0.2"
15 | caffeine = "3.1.8"
16 | bouncy-castle = "1.78.1"
17 |
18 | [libraries]
19 | kompendium-core = { module = "io.bkbn:kompendium-core", version.ref = "kompendium-core" }
20 | ktor-client-core = { module = "io.ktor:ktor-client-core" }
21 | ktor-client-cio = { module = "io.ktor:ktor-client-cio" }
22 | ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp" }
23 | ktor-client-resources = { module = "io.ktor:ktor-client-resources" }
24 | ktor-client-auth = { module = "io.ktor:ktor-client-auth" }
25 | ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation" }
26 | ktor-server-core = { module = "io.ktor:ktor-server-core" }
27 | ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation" }
28 | ktor-server-auth = { module = "io.ktor:ktor-server-auth" }
29 | ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt" }
30 | ktor-server-cors = { module = "io.ktor:ktor-server-cors" }
31 | ktor-server-caching-headers = { module = "io.ktor:ktor-server-caching-headers" }
32 | ktor-server-rate-limit = { module = "io.ktor:ktor-server-rate-limit" }
33 | ktor-server-host-common = { module = "io.ktor:ktor-server-host-common" }
34 | ktor-server-jetty = { module = "io.ktor:ktor-server-jetty" }
35 | ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging" }
36 | ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json" }
37 | koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" }
38 | h2 = { module = "com.h2database:h2", version.ref = "h2" }
39 | logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
40 | exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
41 | exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
42 | exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" }
43 | exposed-kotlin-datetime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" }
44 | dotenv-kotlin = { module = "io.github.cdimascio:dotenv-kotlin", version.ref = "dotenv" }
45 | ktoml-core = { module = "com.akuleshov7:ktoml-core", version.ref = "ktoml" }
46 | ktoml-file = { module = "com.akuleshov7:ktoml-file", version.ref = "ktoml" }
47 | picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
48 | kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
49 | revanced-patcher = { module = "app.revanced:revanced-patcher", version.ref = "revanced-patcher" }
50 | revanced-library = { module = "app.revanced:revanced-library", version.ref = "revanced-library" }
51 | caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" }
52 | bouncy-castle-provider = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncy-castle" }
53 | bouncy-castle-pgp = { module = "org.bouncycastle:bcpg-jdk18on", version.ref = "bouncy-castle" }
54 |
55 | [plugins]
56 | serilization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
57 | ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
58 | kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
59 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ReVanced/revanced-api/c71cbd04665f0270a86734c7d2071eab76271c86/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
5 | networkTimeout=10000
6 | validateDistributionUrl=true
7 | zipStoreBase=GRADLE_USER_HOME
8 | zipStorePath=wrapper/dists
9 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 | # SPDX-License-Identifier: Apache-2.0
19 | #
20 |
21 | ##############################################################################
22 | #
23 | # Gradle start up script for POSIX generated by Gradle.
24 | #
25 | # Important for running:
26 | #
27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28 | # noncompliant, but you have some other compliant shell such as ksh or
29 | # bash, then to run this script, type that shell name before the whole
30 | # command line, like:
31 | #
32 | # ksh Gradle
33 | #
34 | # Busybox and similar reduced shells will NOT work, because this script
35 | # requires all of these POSIX shell features:
36 | # * functions;
37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39 | # * compound commands having a testable exit status, especially «case»;
40 | # * various built-in commands including «command», «set», and «ulimit».
41 | #
42 | # Important for patching:
43 | #
44 | # (2) This script targets any POSIX shell, so it avoids extensions provided
45 | # by Bash, Ksh, etc; in particular arrays are avoided.
46 | #
47 | # The "traditional" practice of packing multiple parameters into a
48 | # space-separated string is a well documented source of bugs and security
49 | # problems, so this is (mostly) avoided, by progressively accumulating
50 | # options in "$@", and eventually passing that to Java.
51 | #
52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54 | # see the in-line comments for details.
55 | #
56 | # There are tweaks for specific operating systems such as AIX, CygWin,
57 | # Darwin, MinGW, and NonStop.
58 | #
59 | # (3) This script is generated from the Groovy template
60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61 | # within the Gradle project.
62 | #
63 | # You can find Gradle at https://github.com/gradle/gradle/.
64 | #
65 | ##############################################################################
66 |
67 | # Attempt to set APP_HOME
68 |
69 | # Resolve links: $0 may be a link
70 | app_path=$0
71 |
72 | # Need this for daisy-chained symlinks.
73 | while
74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75 | [ -h "$app_path" ]
76 | do
77 | ls=$( ls -ld "$app_path" )
78 | link=${ls#*' -> '}
79 | case $link in #(
80 | /*) app_path=$link ;; #(
81 | *) app_path=$APP_HOME$link ;;
82 | esac
83 | done
84 |
85 | # This is normally unused
86 | # shellcheck disable=SC2034
87 | APP_BASE_NAME=${0##*/}
88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
90 | ' "$PWD" ) || exit
91 |
92 | # Use the maximum available, or set MAX_FD != -1 to use that value.
93 | MAX_FD=maximum
94 |
95 | warn () {
96 | echo "$*"
97 | } >&2
98 |
99 | die () {
100 | echo
101 | echo "$*"
102 | echo
103 | exit 1
104 | } >&2
105 |
106 | # OS specific support (must be 'true' or 'false').
107 | cygwin=false
108 | msys=false
109 | darwin=false
110 | nonstop=false
111 | case "$( uname )" in #(
112 | CYGWIN* ) cygwin=true ;; #(
113 | Darwin* ) darwin=true ;; #(
114 | MSYS* | MINGW* ) msys=true ;; #(
115 | NONSTOP* ) nonstop=true ;;
116 | esac
117 |
118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
119 |
120 |
121 | # Determine the Java command to use to start the JVM.
122 | if [ -n "$JAVA_HOME" ] ; then
123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
124 | # IBM's JDK on AIX uses strange locations for the executables
125 | JAVACMD=$JAVA_HOME/jre/sh/java
126 | else
127 | JAVACMD=$JAVA_HOME/bin/java
128 | fi
129 | if [ ! -x "$JAVACMD" ] ; then
130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
131 |
132 | Please set the JAVA_HOME variable in your environment to match the
133 | location of your Java installation."
134 | fi
135 | else
136 | JAVACMD=java
137 | if ! command -v java >/dev/null 2>&1
138 | then
139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
140 |
141 | Please set the JAVA_HOME variable in your environment to match the
142 | location of your Java installation."
143 | fi
144 | fi
145 |
146 | # Increase the maximum file descriptors if we can.
147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
148 | case $MAX_FD in #(
149 | max*)
150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
151 | # shellcheck disable=SC2039,SC3045
152 | MAX_FD=$( ulimit -H -n ) ||
153 | warn "Could not query maximum file descriptor limit"
154 | esac
155 | case $MAX_FD in #(
156 | '' | soft) :;; #(
157 | *)
158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
159 | # shellcheck disable=SC2039,SC3045
160 | ulimit -n "$MAX_FD" ||
161 | warn "Could not set maximum file descriptor limit to $MAX_FD"
162 | esac
163 | fi
164 |
165 | # Collect all arguments for the java command, stacking in reverse order:
166 | # * args from the command line
167 | # * the main class name
168 | # * -classpath
169 | # * -D...appname settings
170 | # * --module-path (only if needed)
171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
172 |
173 | # For Cygwin or MSYS, switch paths to Windows format before running java
174 | if "$cygwin" || "$msys" ; then
175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
177 |
178 | JAVACMD=$( cygpath --unix "$JAVACMD" )
179 |
180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
181 | for arg do
182 | if
183 | case $arg in #(
184 | -*) false ;; # don't mess with options #(
185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
186 | [ -e "$t" ] ;; #(
187 | *) false ;;
188 | esac
189 | then
190 | arg=$( cygpath --path --ignore --mixed "$arg" )
191 | fi
192 | # Roll the args list around exactly as many times as the number of
193 | # args, so each arg winds up back in the position where it started, but
194 | # possibly modified.
195 | #
196 | # NB: a `for` loop captures its iteration list before it begins, so
197 | # changing the positional parameters here affects neither the number of
198 | # iterations, nor the values presented in `arg`.
199 | shift # remove old arg
200 | set -- "$@" "$arg" # push replacement arg
201 | done
202 | fi
203 |
204 |
205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
207 |
208 | # Collect all arguments for the java command:
209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
210 | # and any embedded shellness will be escaped.
211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
212 | # treated as '${Hostname}' itself on the command line.
213 |
214 | set -- \
215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
216 | -classpath "$CLASSPATH" \
217 | org.gradle.wrapper.GradleWrapperMain \
218 | "$@"
219 |
220 | # Stop when "xargs" is not available.
221 | if ! command -v xargs >/dev/null 2>&1
222 | then
223 | die "xargs is not available"
224 | fi
225 |
226 | # Use "xargs" to parse quoted args.
227 | #
228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
229 | #
230 | # In Bash we could simply go:
231 | #
232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
233 | # set -- "${ARGS[@]}" "$@"
234 | #
235 | # but POSIX shell has neither arrays nor command substitution, so instead we
236 | # post-process each arg (as a line of input to sed) to backslash-escape any
237 | # character that might be a shell metacharacter, then use eval to reverse
238 | # that process (while maintaining the separation between arguments), and wrap
239 | # the whole thing up as a single "set" statement.
240 | #
241 | # This will of course break if any of these variables contains a newline or
242 | # an unmatched quote.
243 | #
244 |
245 | eval "set -- $(
246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
247 | xargs -n1 |
248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
249 | tr '\n' ' '
250 | )" '"$@"'
251 |
252 | exec "$JAVACMD" "$@"
253 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "devDependencies": {
3 | "@codedependant/semantic-release-docker": "^5.0.3",
4 | "@saithodev/semantic-release-backmerge": "^4.0.1",
5 | "@semantic-release/changelog": "^6.0.3",
6 | "@semantic-release/git": "^10.0.1",
7 | "gradle-semantic-release-plugin": "^1.10.1",
8 | "semantic-release": "^24.1.2"
9 | }
10 | }
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | rootProject.name = "revanced-api"
2 |
3 | buildCache {
4 | local {
5 | isEnabled = "CI" !in System.getenv()
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/command/MainCommand.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.command
2 |
3 | import picocli.CommandLine
4 | import java.util.*
5 |
6 | internal val applicationVersion = MainCommand::class.java.getResourceAsStream(
7 | "/app/revanced/api/version.properties",
8 | )?.use { stream ->
9 | Properties().apply {
10 | load(stream)
11 | }.getProperty("version")
12 | } ?: "v0.0.0"
13 |
14 | fun main(args: Array) {
15 | CommandLine(MainCommand).execute(*args).let(System::exit)
16 | }
17 |
18 | private object CLIVersionProvider : CommandLine.IVersionProvider {
19 | override fun getVersion() =
20 | arrayOf(
21 | "ReVanced API $applicationVersion",
22 | )
23 | }
24 |
25 | @CommandLine.Command(
26 | name = "revanced-api",
27 | description = ["API server for ReVanced"],
28 | mixinStandardHelpOptions = true,
29 | versionProvider = CLIVersionProvider::class,
30 | subcommands = [
31 | StartAPICommand::class,
32 | ],
33 | )
34 | private object MainCommand
35 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/command/StartAPICommand.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.command
2 |
3 | import app.revanced.api.configuration.*
4 | import io.github.cdimascio.dotenv.Dotenv
5 | import io.ktor.server.engine.*
6 | import io.ktor.server.jetty.*
7 | import picocli.CommandLine
8 | import java.io.File
9 |
10 | @CommandLine.Command(
11 | name = "start",
12 | description = ["Start the API server"],
13 | )
14 | internal object StartAPICommand : Runnable {
15 | @CommandLine.Option(
16 | names = ["-h", "--host"],
17 | description = ["The host address to bind to."],
18 | showDefaultValue = CommandLine.Help.Visibility.ALWAYS,
19 | )
20 | private var host: String = "0.0.0.0"
21 |
22 | @CommandLine.Option(
23 | names = ["-p", "--port"],
24 | description = ["The port to listen on."],
25 | showDefaultValue = CommandLine.Help.Visibility.ALWAYS,
26 | )
27 | private var port: Int = 8888
28 |
29 | @CommandLine.Option(
30 | names = ["-c", "--config"],
31 | description = ["The path to the configuration file."],
32 | showDefaultValue = CommandLine.Help.Visibility.ALWAYS,
33 | )
34 | private var configFile = File("configuration.toml")
35 |
36 | override fun run() {
37 | Dotenv.configure().systemProperties().load()
38 |
39 | embeddedServer(Jetty, port, host) {
40 | configureDependencies(configFile)
41 | configureHTTP()
42 | configureSerialization()
43 | configureSecurity()
44 | configureOpenAPI()
45 | configureLogging()
46 | configureRouting()
47 | }.start(wait = true)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/APISchema.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration
2 |
3 | import kotlinx.datetime.Clock
4 | import kotlinx.datetime.LocalDateTime
5 | import kotlinx.datetime.TimeZone
6 | import kotlinx.datetime.toLocalDateTime
7 | import kotlinx.serialization.Serializable
8 |
9 | interface ApiUser {
10 | val name: String
11 | val avatarUrl: String
12 | val url: String
13 | }
14 |
15 | @Serializable
16 | class ApiMember(
17 | override val name: String,
18 | override val avatarUrl: String,
19 | override val url: String,
20 | val bio: String?,
21 | val gpgKey: ApiGpgKey?,
22 | ) : ApiUser
23 |
24 | @Serializable
25 | class ApiGpgKey(
26 | val id: String,
27 | val url: String,
28 | )
29 |
30 | @Serializable
31 | class ApiContributor(
32 | override val name: String,
33 | override val avatarUrl: String,
34 | override val url: String,
35 | val contributions: Int,
36 | ) : ApiUser
37 |
38 | @Serializable
39 | class APIContributable(
40 | val name: String,
41 | val url: String,
42 | // Using a list instead of a set because set semantics are unnecessary here.
43 | val contributors: List,
44 | )
45 |
46 | @Serializable
47 | class ApiRelease(
48 | val version: String,
49 | val createdAt: LocalDateTime,
50 | val description: String,
51 | val downloadUrl: String,
52 | val signatureDownloadUrl: String? = null,
53 | )
54 |
55 | @Serializable
56 | class ApiReleaseVersion(
57 | val version: String,
58 | )
59 |
60 | @Serializable
61 | class ApiAnnouncement(
62 | val author: String? = null,
63 | val title: String,
64 | val content: String? = null,
65 | // Using a list instead of a set because set semantics are unnecessary here.
66 | val attachments: List? = null,
67 | // Using a list instead of a set because set semantics are unnecessary here.
68 | val tags: List? = null,
69 | val createdAt: LocalDateTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()),
70 | val archivedAt: LocalDateTime? = null,
71 | val level: Int = 0,
72 | )
73 |
74 | @Serializable
75 | class ApiResponseAnnouncement(
76 | val id: Int,
77 | val author: String? = null,
78 | val title: String,
79 | val content: String? = null,
80 | // Using a list instead of a set because set semantics are unnecessary here.
81 | val attachments: List? = null,
82 | // Using a list instead of a set because set semantics are unnecessary here.
83 | val tags: List? = null,
84 | val createdAt: LocalDateTime,
85 | val archivedAt: LocalDateTime? = null,
86 | val level: Int = 0,
87 | )
88 |
89 | @Serializable
90 | class ApiResponseAnnouncementId(
91 | val id: Int,
92 | )
93 |
94 | @Serializable
95 | class ApiAnnouncementArchivedAt(
96 | val archivedAt: LocalDateTime,
97 | )
98 |
99 | @Serializable
100 | class ApiAnnouncementTag(
101 | val name: String,
102 | )
103 |
104 | @Serializable
105 | class ApiRateLimit(
106 | val limit: Int,
107 | val remaining: Int,
108 | val reset: LocalDateTime,
109 | )
110 |
111 | @Serializable
112 | class ApiAssetPublicKey(
113 | val patchesPublicKey: String,
114 | )
115 |
116 | @Serializable
117 | class APIAbout(
118 | val name: String,
119 | val about: String,
120 | val keys: String,
121 | val branding: Branding?,
122 | val contact: Contact?,
123 | // Using a list instead of a set because set semantics are unnecessary here.
124 | val socials: List?,
125 | val donations: Donations?,
126 | val status: String,
127 | ) {
128 | @Serializable
129 | class Branding(
130 | val logo: String,
131 | )
132 |
133 | @Serializable
134 | class Contact(
135 | val email: String,
136 | )
137 |
138 | @Serializable
139 | class Social(
140 | val name: String,
141 | val url: String,
142 | val preferred: Boolean? = false,
143 | )
144 |
145 | @Serializable
146 | class Wallet(
147 | val network: String,
148 | val currencyCode: String,
149 | val address: String,
150 | val preferred: Boolean? = false,
151 | )
152 |
153 | @Serializable
154 | class Link(
155 | val name: String,
156 | val url: String,
157 | val preferred: Boolean? = false,
158 | )
159 |
160 | @Serializable
161 | class Donations(
162 | // Using a list instead of a set because set semantics are unnecessary here.
163 | val wallets: List?,
164 | // Using a list instead of a set because set semantics are unnecessary here.
165 | val links: List ?,
166 | )
167 | }
168 |
169 | @Serializable
170 | class ApiToken(val token: String)
171 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/Dependencies.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration
2 |
3 | import app.revanced.api.configuration.repository.AnnouncementRepository
4 | import app.revanced.api.configuration.repository.BackendRepository
5 | import app.revanced.api.configuration.repository.ConfigurationRepository
6 | import app.revanced.api.configuration.repository.GitHubBackendRepository
7 | import app.revanced.api.configuration.services.*
8 | import com.akuleshov7.ktoml.Toml
9 | import com.akuleshov7.ktoml.source.decodeFromStream
10 | import io.ktor.server.application.*
11 | import org.jetbrains.exposed.sql.Database
12 | import org.koin.core.module.dsl.singleOf
13 | import org.koin.dsl.module
14 | import org.koin.ktor.plugin.Koin
15 | import java.io.File
16 |
17 | fun Application.configureDependencies(
18 | configFile: File,
19 | ) {
20 | val repositoryModule = module {
21 | single { Toml.decodeFromStream(configFile.inputStream()) }
22 | single {
23 | Database.connect(
24 | url = System.getProperty("DB_URL"),
25 | user = System.getProperty("DB_USER"),
26 | password = System.getProperty("DB_PASSWORD"),
27 | )
28 | }
29 | singleOf(::AnnouncementRepository)
30 | singleOf(::GitHubBackendRepository)
31 | single {
32 | val backendServices = mapOf(
33 | GitHubBackendRepository.SERVICE_NAME to { get() },
34 | // Implement more backend services here.
35 | )
36 |
37 | val configuration = get()
38 | val backendFactory = backendServices[configuration.backendServiceName]!!
39 |
40 | backendFactory()
41 | }
42 | }
43 |
44 | val serviceModule = module {
45 | single {
46 | val jwtSecret = System.getProperty("JWT_SECRET")
47 | val issuer = System.getProperty("JWT_ISSUER")
48 | val validityInMin = System.getProperty("JWT_VALIDITY_IN_MIN").toLong()
49 |
50 | val authSHA256DigestString = System.getProperty("AUTH_SHA256_DIGEST")
51 |
52 | AuthenticationService(issuer, validityInMin, jwtSecret, authSHA256DigestString)
53 | }
54 | singleOf(::AnnouncementService)
55 | singleOf(::SignatureService)
56 | singleOf(::PatchesService)
57 | singleOf(::ManagerService)
58 | singleOf(::ApiService)
59 | }
60 |
61 | install(Koin) {
62 | modules(
63 | repositoryModule,
64 | serviceModule,
65 | )
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/Extensions.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration
2 |
3 | import io.bkbn.kompendium.core.metadata.MethodInfo
4 | import io.bkbn.kompendium.core.plugin.NotarizedRoute
5 | import io.ktor.http.*
6 | import io.ktor.http.content.*
7 | import io.ktor.server.application.*
8 | import io.ktor.server.http.content.*
9 | import io.ktor.server.plugins.cachingheaders.*
10 | import io.ktor.server.response.*
11 | import io.ktor.server.routing.*
12 | import java.io.File
13 | import java.nio.file.Path
14 | import kotlin.time.Duration
15 |
16 | internal suspend fun ApplicationCall.respondOrNotFound(value: Any?) = respond(value ?: HttpStatusCode.NotFound)
17 |
18 | internal fun ApplicationCallPipeline.installCache(maxAge: Duration) =
19 | installCache(CacheControl.MaxAge(maxAgeSeconds = maxAge.inWholeSeconds.toInt()))
20 |
21 | internal fun ApplicationCallPipeline.installNoCache() =
22 | installCache(CacheControl.NoCache(null))
23 |
24 | internal fun ApplicationCallPipeline.installCache(cacheControl: CacheControl) =
25 | install(CachingHeaders) {
26 | options { _, _ ->
27 | CachingOptions(cacheControl)
28 | }
29 | }
30 |
31 | internal fun ApplicationCallPipeline.installNotarizedRoute(configure: NotarizedRoute.Config.() -> Unit = {}) =
32 | install(NotarizedRoute(), configure)
33 |
34 | internal fun Route.staticFiles(
35 | remotePath: String,
36 | dir: Path,
37 | block: StaticContentConfig.() -> Unit = {
38 | contentType {
39 | ContentType.Application.Json
40 | }
41 | extensions("json")
42 | },
43 | ) = staticFiles(remotePath, dir.toFile(), null, block)
44 |
45 | internal fun MethodInfo.Builder<*>.canRespondUnauthorized() {
46 | canRespond {
47 | responseCode(HttpStatusCode.Unauthorized)
48 | description("Unauthorized")
49 | responseType()
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/HTTP.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration
2 |
3 | import app.revanced.api.configuration.repository.ConfigurationRepository
4 | import io.ktor.http.*
5 | import io.ktor.server.application.*
6 | import io.ktor.server.plugins.*
7 | import io.ktor.server.plugins.cors.routing.*
8 | import io.ktor.server.plugins.ratelimit.*
9 | import io.ktor.server.request.*
10 | import org.koin.ktor.ext.get
11 | import kotlin.time.Duration.Companion.minutes
12 |
13 | fun Application.configureHTTP() {
14 | val configuration = get()
15 |
16 | install(CORS) {
17 | HttpMethod.DefaultMethods.minus(HttpMethod.Options).forEach(::allowMethod)
18 |
19 | allowHeader(HttpHeaders.ContentType)
20 | allowHeader(HttpHeaders.Authorization)
21 | exposeHeader(HttpHeaders.WWWAuthenticate)
22 |
23 | allowCredentials = true
24 |
25 | configuration.corsAllowedHosts.forEach { host ->
26 | allowHost(host = host, schemes = listOf("https"))
27 | }
28 | }
29 |
30 | install(RateLimit) {
31 | fun rateLimit(name: String, block: RateLimitProviderConfig.() -> Unit) = register(RateLimitName(name)) {
32 | requestKey {
33 | it.request.uri + it.request.origin.remoteAddress
34 | }
35 |
36 | block()
37 | }
38 |
39 | rateLimit("weak") {
40 | rateLimiter(limit = 30, refillPeriod = 2.minutes)
41 | }
42 | rateLimit("strong") {
43 | rateLimiter(limit = 5, refillPeriod = 1.minutes)
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/Logging.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration
2 |
3 | import io.ktor.server.application.*
4 | import io.ktor.server.plugins.callloging.*
5 | import io.ktor.server.request.*
6 |
7 | internal fun Application.configureLogging() {
8 | install(CallLogging) {
9 | format { call ->
10 | val status = call.response.status()
11 | val httpMethod = call.request.httpMethod.value
12 | val uri = call.request.uri
13 | "$status $httpMethod $uri"
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/OpenAPI.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration
2 |
3 | import app.revanced.api.command.applicationVersion
4 | import app.revanced.api.configuration.repository.ConfigurationRepository
5 | import io.bkbn.kompendium.core.attribute.KompendiumAttributes
6 | import io.bkbn.kompendium.core.plugin.NotarizedApplication
7 | import io.bkbn.kompendium.json.schema.KotlinXSchemaConfigurator
8 | import io.bkbn.kompendium.oas.OpenApiSpec
9 | import io.bkbn.kompendium.oas.component.Components
10 | import io.bkbn.kompendium.oas.info.Contact
11 | import io.bkbn.kompendium.oas.info.Info
12 | import io.bkbn.kompendium.oas.info.License
13 | import io.bkbn.kompendium.oas.security.BearerAuth
14 | import io.bkbn.kompendium.oas.server.Server
15 | import io.ktor.server.application.*
16 | import io.ktor.server.response.*
17 | import io.ktor.server.routing.*
18 | import java.net.URI
19 | import org.koin.ktor.ext.get as koinGet
20 |
21 | internal fun Application.configureOpenAPI() {
22 | val configuration = koinGet()
23 |
24 | install(NotarizedApplication()) {
25 | openApiJson = {
26 | route("/${configuration.apiVersion}/openapi.json") {
27 | get {
28 | call.respond(application.attributes[KompendiumAttributes.openApiSpec])
29 | }
30 | }
31 | }
32 | spec = OpenApiSpec(
33 | info = Info(
34 | title = "ReVanced API",
35 | version = applicationVersion,
36 | description = "API server for ReVanced.",
37 | contact = Contact(
38 | name = "ReVanced",
39 | url = URI("https://revanced.app"),
40 | email = "contact@revanced.app",
41 | ),
42 | license = License(
43 | name = "AGPLv3",
44 | url = URI("https://github.com/ReVanced/revanced-api/blob/main/LICENSE"),
45 | ),
46 | ),
47 | components = Components(
48 | securitySchemes = mutableMapOf(
49 | "bearer" to BearerAuth(),
50 | ),
51 | ),
52 | ).apply {
53 | servers += Server(
54 | url = URI(configuration.endpoint),
55 | description = "ReVanced API server",
56 | )
57 | }
58 |
59 | schemaConfigurator = KotlinXSchemaConfigurator()
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/Routing.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration
2 |
3 | import app.revanced.api.configuration.repository.ConfigurationRepository
4 | import app.revanced.api.configuration.routes.*
5 | import app.revanced.api.configuration.routes.announcementsRoute
6 | import app.revanced.api.configuration.routes.apiRoute
7 | import app.revanced.api.configuration.routes.patchesRoute
8 | import io.bkbn.kompendium.core.routes.redoc
9 | import io.bkbn.kompendium.core.routes.swagger
10 | import io.ktor.http.*
11 | import io.ktor.server.application.*
12 | import io.ktor.server.routing.*
13 | import kotlin.time.Duration.Companion.minutes
14 | import org.koin.ktor.ext.get as koinGet
15 |
16 | internal fun Application.configureRouting() = routing {
17 | val configuration = koinGet()
18 |
19 | installCache(5.minutes)
20 |
21 | route("/${configuration.apiVersion}") {
22 | announcementsRoute()
23 | patchesRoute()
24 | managerRoute()
25 | apiRoute()
26 | }
27 |
28 | staticFiles("/", configuration.staticFilesPath) {
29 | contentType {
30 | when (it.extension) {
31 | "json" -> ContentType.Application.Json
32 | "asc" -> ContentType.Text.Plain
33 | "ico" -> ContentType.Image.XIcon
34 | "svg" -> ContentType.Image.SVG
35 | "jpg", "jpeg" -> ContentType.Image.JPEG
36 | "png" -> ContentType.Image.PNG
37 | "gif" -> ContentType.Image.GIF
38 | "mp4" -> ContentType.Video.MP4
39 | "ogg" -> ContentType.Video.OGG
40 | "mp3" -> ContentType.Audio.MPEG
41 | "css" -> ContentType.Text.CSS
42 | "js" -> ContentType.Application.JavaScript
43 | "html" -> ContentType.Text.Html
44 | "xml" -> ContentType.Application.Xml
45 | "pdf" -> ContentType.Application.Pdf
46 | "zip" -> ContentType.Application.Zip
47 | "gz" -> ContentType.Application.GZip
48 | else -> ContentType.Application.OctetStream
49 | }
50 | }
51 |
52 | extensions("json", "asc")
53 | }
54 |
55 | val specUrl = "/${configuration.apiVersion}/openapi.json"
56 | swagger(pageTitle = "ReVanced API", path = "/", specUrl = specUrl)
57 | redoc(pageTitle = "ReVanced API", path = "/redoc", specUrl = specUrl)
58 | }
59 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/Security.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration
2 |
3 | import app.revanced.api.configuration.services.AuthenticationService
4 | import io.ktor.server.application.*
5 | import io.ktor.server.auth.*
6 | import org.koin.ktor.ext.get
7 |
8 | fun Application.configureSecurity() {
9 | val authenticationService = get()
10 |
11 | install(Authentication) {
12 | with(authenticationService) {
13 | jwt()
14 | digest()
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/Serialization.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration
2 |
3 | import io.bkbn.kompendium.oas.serialization.KompendiumSerializersModule
4 | import io.ktor.serialization.kotlinx.json.*
5 | import io.ktor.server.application.*
6 | import io.ktor.server.plugins.contentnegotiation.*
7 | import kotlinx.serialization.ExperimentalSerializationApi
8 | import kotlinx.serialization.json.Json
9 | import kotlinx.serialization.json.JsonNamingStrategy
10 |
11 | @OptIn(ExperimentalSerializationApi::class)
12 | fun Application.configureSerialization() {
13 | install(ContentNegotiation) {
14 | json(
15 | Json {
16 | serializersModule = KompendiumSerializersModule.module
17 | namingStrategy = JsonNamingStrategy.SnakeCase
18 | explicitNulls = false
19 | encodeDefaults = true
20 | },
21 | )
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/repository/AnnouncementRepository.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration.repository
2 |
3 | import app.revanced.api.configuration.ApiAnnouncement
4 | import app.revanced.api.configuration.ApiAnnouncementTag
5 | import app.revanced.api.configuration.ApiResponseAnnouncement
6 | import app.revanced.api.configuration.ApiResponseAnnouncementId
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlinx.coroutines.runBlocking
9 | import org.jetbrains.exposed.dao.IntEntity
10 | import org.jetbrains.exposed.dao.IntEntityClass
11 | import org.jetbrains.exposed.dao.id.EntityID
12 | import org.jetbrains.exposed.dao.id.IntIdTable
13 | import org.jetbrains.exposed.sql.*
14 | import org.jetbrains.exposed.sql.kotlin.datetime.CurrentDateTime
15 | import org.jetbrains.exposed.sql.kotlin.datetime.datetime
16 | import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
17 |
18 | internal class AnnouncementRepository(private val database: Database) {
19 | // This is better than doing a maxByOrNull { it.id } on every request.
20 | private var latestAnnouncement: Announcement? = null
21 | private val latestAnnouncementByTag = mutableMapOf()
22 |
23 | init {
24 | runBlocking {
25 | transaction {
26 | SchemaUtils.create(
27 | Announcements,
28 | Attachments,
29 | Tags,
30 | AnnouncementTags,
31 | )
32 |
33 | initializeLatestAnnouncements()
34 | }
35 | }
36 | }
37 |
38 | private fun initializeLatestAnnouncements() {
39 | latestAnnouncement = Announcement.all().orderBy(Announcements.id to SortOrder.DESC).firstOrNull()
40 |
41 | Tag.all().map { it.name }.forEach(::updateLatestAnnouncementForTag)
42 | }
43 |
44 | private fun updateLatestAnnouncement(new: Announcement) {
45 | if (latestAnnouncement == null || latestAnnouncement!!.id.value <= new.id.value) {
46 | latestAnnouncement = new
47 | new.tags.forEach { tag -> latestAnnouncementByTag[tag.name] = new }
48 | }
49 | }
50 |
51 | private fun updateLatestAnnouncementForTag(tag: String) {
52 | val latestAnnouncementForTag = Tags.innerJoin(AnnouncementTags)
53 | .select(AnnouncementTags.announcement)
54 | .where { Tags.name eq tag }
55 | .orderBy(AnnouncementTags.announcement to SortOrder.DESC)
56 | .limit(1)
57 | .firstNotNullOfOrNull { Announcement.findById(it[AnnouncementTags.announcement]) }
58 |
59 | latestAnnouncementForTag?.let { latestAnnouncementByTag[tag] = it }
60 | }
61 |
62 | suspend fun latest() = transaction {
63 | latestAnnouncement.toApiResponseAnnouncement()
64 | }
65 |
66 | suspend fun latest(tags: Set) = transaction {
67 | tags.mapNotNull { tag -> latestAnnouncementByTag[tag] }.toApiAnnouncement()
68 | }
69 |
70 | fun latestId() = latestAnnouncement?.id?.value.toApiResponseAnnouncementId()
71 |
72 | fun latestId(tags: Set) = tags.map { tag -> latestAnnouncementByTag[tag]?.id?.value }.toApiResponseAnnouncementId()
73 |
74 | suspend fun paged(cursor: Int, count: Int, tags: Set?) = transaction {
75 | Announcement.find {
76 | fun idLessEq() = Announcements.id lessEq cursor
77 |
78 | if (tags == null) {
79 | idLessEq()
80 | } else {
81 | fun hasTags() = Announcements.id inSubQuery (
82 | AnnouncementTags.innerJoin(Tags)
83 | .select(AnnouncementTags.announcement)
84 | .withDistinct()
85 | .where { Tags.name inList tags }
86 | )
87 |
88 | idLessEq() and hasTags()
89 | }
90 | }.orderBy(Announcements.id to SortOrder.DESC).limit(count).toApiAnnouncement()
91 | }
92 |
93 | suspend fun get(id: Int) = transaction {
94 | Announcement.findById(id).toApiResponseAnnouncement()
95 | }
96 |
97 | suspend fun new(new: ApiAnnouncement) = transaction {
98 | Announcement.new {
99 | author = new.author
100 | title = new.title
101 | content = new.content
102 | createdAt = new.createdAt
103 | archivedAt = new.archivedAt
104 | level = new.level
105 | if (new.tags != null) {
106 | tags = SizedCollection(
107 | new.tags.map { tag -> Tag.find { Tags.name eq tag }.firstOrNull() ?: Tag.new { name = tag } },
108 | )
109 | }
110 | }.apply {
111 | new.attachments?.map { attachmentUrl ->
112 | Attachment.new {
113 | url = attachmentUrl
114 | announcement = this@apply
115 | }
116 | }
117 | }.let(::updateLatestAnnouncement)
118 | }
119 |
120 | suspend fun update(id: Int, new: ApiAnnouncement) = transaction {
121 | Announcement.findByIdAndUpdate(id) {
122 | it.author = new.author
123 | it.title = new.title
124 | it.content = new.content
125 | it.createdAt = new.createdAt
126 | it.archivedAt = new.archivedAt
127 | it.level = new.level
128 |
129 | if (new.tags != null) {
130 | // Get the old tags, create new tags if they don't exist,
131 | // and delete tags that are not in the new tags, after updating the announcement.
132 | val oldTags = it.tags.toList()
133 | val updatedTags = new.tags.map { name ->
134 | Tag.find { Tags.name eq name }.firstOrNull() ?: Tag.new { this.name = name }
135 | }
136 | it.tags = SizedCollection(updatedTags)
137 | oldTags.forEach { tag ->
138 | if (tag in updatedTags || !tag.announcements.empty()) return@forEach
139 | tag.delete()
140 | }
141 | }
142 |
143 | // Delete old attachments and create new attachments.
144 | if (new.attachments != null) {
145 | it.attachments.forEach { attachment -> attachment.delete() }
146 | new.attachments.map { attachment ->
147 | Attachment.new {
148 | url = attachment
149 | announcement = it
150 | }
151 | }
152 | }
153 | }?.let(::updateLatestAnnouncement) ?: Unit
154 | }
155 |
156 | suspend fun delete(id: Int) = transaction {
157 | val announcement = Announcement.findById(id) ?: return@transaction
158 |
159 | // Delete the tag if no other announcements are referencing it.
160 | // One count means that the announcement is the only one referencing the tag.
161 | announcement.tags.filter { tag -> tag.announcements.count() == 1L }.forEach { tag ->
162 | latestAnnouncementByTag -= tag.name
163 | tag.delete()
164 | }
165 |
166 | announcement.delete()
167 |
168 | // If the deleted announcement is the latest announcement, set the new latest announcement.
169 | if (latestAnnouncement?.id?.value == id) {
170 | latestAnnouncement = Announcement.all().orderBy(Announcements.id to SortOrder.DESC).firstOrNull()
171 | }
172 |
173 | // The new announcement may be the latest for a specific tag. Set the new latest announcement for that tag.
174 | latestAnnouncementByTag.keys.forEach { tag ->
175 | updateLatestAnnouncementForTag(tag)
176 | }
177 | }
178 |
179 | suspend fun tags() = transaction {
180 | Tag.all().toList().toApiTag()
181 | }
182 |
183 | private suspend fun transaction(statement: suspend Transaction.() -> T) = newSuspendedTransaction(Dispatchers.IO, database, statement = statement)
184 |
185 | private object Announcements : IntIdTable() {
186 | val author = varchar("author", 32).nullable()
187 | val title = varchar("title", 64)
188 | val content = text("content").nullable()
189 | val createdAt = datetime("createdAt").defaultExpression(CurrentDateTime)
190 | val archivedAt = datetime("archivedAt").nullable()
191 | val level = integer("level")
192 | }
193 |
194 | private object Attachments : IntIdTable() {
195 | val url = varchar("url", 256)
196 | val announcement = reference("announcement", Announcements, onDelete = ReferenceOption.CASCADE)
197 | }
198 |
199 | private object Tags : IntIdTable() {
200 | val name = varchar("name", 16).uniqueIndex()
201 | }
202 |
203 | private object AnnouncementTags : Table() {
204 | val tag = reference("tag", Tags, onDelete = ReferenceOption.CASCADE)
205 | val announcement = reference("announcement", Announcements, onDelete = ReferenceOption.CASCADE)
206 |
207 | init {
208 | uniqueIndex(tag, announcement)
209 | }
210 | }
211 |
212 | class Announcement(id: EntityID) : IntEntity(id) {
213 | companion object : IntEntityClass(Announcements)
214 |
215 | var author by Announcements.author
216 | var title by Announcements.title
217 | var content by Announcements.content
218 | val attachments by Attachment referrersOn Attachments.announcement
219 | var tags by Tag via AnnouncementTags
220 | var createdAt by Announcements.createdAt
221 | var archivedAt by Announcements.archivedAt
222 | var level by Announcements.level
223 | }
224 |
225 | class Attachment(id: EntityID) : IntEntity(id) {
226 | companion object : IntEntityClass(Attachments)
227 |
228 | var url by Attachments.url
229 | var announcement by Announcement referencedOn Attachments.announcement
230 | }
231 |
232 | class Tag(id: EntityID) : IntEntity(id) {
233 | companion object : IntEntityClass(Tags)
234 |
235 | var name by Tags.name
236 | var announcements by Announcement via AnnouncementTags
237 | }
238 |
239 | private fun Announcement?.toApiResponseAnnouncement() = this?.let {
240 | ApiResponseAnnouncement(
241 | id.value,
242 | author,
243 | title,
244 | content,
245 | attachments.map { it.url },
246 | tags.map { it.name },
247 | createdAt,
248 | archivedAt,
249 | level,
250 | )
251 | }
252 |
253 | private fun Iterable.toApiAnnouncement() = map { it.toApiResponseAnnouncement()!! }
254 |
255 | private fun Iterable.toApiTag() = map { ApiAnnouncementTag(it.name) }
256 |
257 | private fun Int?.toApiResponseAnnouncementId() = this?.let { ApiResponseAnnouncementId(this) }
258 |
259 | private fun Iterable.toApiResponseAnnouncementId() = map { it.toApiResponseAnnouncementId() }
260 | }
261 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/repository/BackendRepository.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration.repository
2 |
3 | import io.ktor.client.*
4 | import io.ktor.client.engine.okhttp.*
5 | import io.ktor.client.plugins.*
6 | import io.ktor.client.plugins.auth.*
7 | import io.ktor.client.plugins.auth.providers.*
8 | import io.ktor.client.plugins.cache.*
9 | import io.ktor.client.plugins.contentnegotiation.*
10 | import io.ktor.client.plugins.resources.*
11 | import io.ktor.serialization.kotlinx.json.*
12 | import kotlinx.datetime.LocalDateTime
13 | import kotlinx.serialization.json.Json
14 | import kotlinx.serialization.json.JsonNamingStrategy
15 |
16 | /**
17 | * The backend of the API used to get data.
18 | *
19 | * @param defaultRequestUri The URI to use for requests.
20 | * @param website The site of the backend users can visit.
21 | */
22 | abstract class BackendRepository internal constructor(
23 | defaultRequestUri: String,
24 | internal val website: String,
25 | ) {
26 | protected val client: HttpClient = HttpClient(OkHttp) {
27 | defaultRequest { url(defaultRequestUri) }
28 |
29 | install(HttpCache)
30 | install(Resources)
31 | install(ContentNegotiation) {
32 | json(
33 | Json {
34 | ignoreUnknownKeys = true
35 | @Suppress("OPT_IN_USAGE")
36 | namingStrategy = JsonNamingStrategy.SnakeCase
37 | },
38 | )
39 | }
40 |
41 | System.getProperty("BACKEND_API_TOKEN")?.let {
42 | install(Auth) {
43 | bearer {
44 | loadTokens {
45 | BearerTokens(
46 | accessToken = it,
47 | refreshToken = "", // Required dummy value
48 | )
49 | }
50 |
51 | sendWithoutRequest { true }
52 | }
53 | }
54 | }
55 | }
56 |
57 | /**
58 | * A user.
59 | *
60 | * @property name The name of the user.
61 | * @property avatarUrl The URL to the avatar of the user.
62 | * @property url The URL to the profile of the user.
63 | */
64 | interface BackendUser {
65 | val name: String
66 | val avatarUrl: String
67 | val url: String
68 | }
69 |
70 | /**
71 | * An organization.
72 | *
73 | * @property members The members of the organization.
74 | */
75 | class BackendOrganization(
76 | // Using a list instead of a set because set semantics are unnecessary here.
77 | val members: List,
78 | ) {
79 | /**
80 | * A member of an organization.
81 | *
82 | * @property name The name of the member.
83 | * @property avatarUrl The URL to the avatar of the member.
84 | * @property url The URL to the profile of the member.
85 | * @property bio The bio of the member.
86 | * @property gpgKeys The GPG key of the member.
87 | */
88 | class BackendMember(
89 | override val name: String,
90 | override val avatarUrl: String,
91 | override val url: String,
92 | val bio: String?,
93 | val gpgKeys: GpgKeys,
94 | ) : BackendUser {
95 | /**
96 | * The GPG keys of a member.
97 | *
98 | * @property ids The IDs of the GPG keys.
99 | * @property url The URL to the GPG master key.
100 | */
101 | class GpgKeys(
102 | // Using a list instead of a set because set semantics are unnecessary here.
103 | val ids: List,
104 | val url: String,
105 | )
106 | }
107 |
108 | /**
109 | * A repository of an organization.
110 | *
111 | * @property contributors The contributors of the repository.
112 | */
113 | class BackendRepository(
114 | // Using a list instead of a set because set semantics are unnecessary here.
115 | val contributors: List,
116 | ) {
117 | /**
118 | * A contributor of a repository.
119 | *
120 | * @property name The name of the contributor.
121 | * @property avatarUrl The URL to the avatar of the contributor.
122 | * @property url The URL to the profile of the contributor.
123 | * @property contributions The number of contributions of the contributor.
124 | */
125 | class BackendContributor(
126 | override val name: String,
127 | override val avatarUrl: String,
128 | override val url: String,
129 | val contributions: Int,
130 | ) : BackendUser
131 |
132 | /**
133 | * A release of a repository.
134 | *
135 | * @property tag The tag of the release.
136 | * @property assets The assets of the release.
137 | * @property createdAt The date and time the release was created.
138 | * @property prerelease Whether the release is a prerelease.
139 | * @property releaseNote The release note of the release.
140 | */
141 | class BackendRelease(
142 | val tag: String,
143 | val releaseNote: String,
144 | val createdAt: LocalDateTime,
145 | val prerelease: Boolean,
146 | // Using a list instead of a set because set semantics are unnecessary here.
147 | val assets: List,
148 | ) {
149 | companion object {
150 | fun List.first(assetRegex: Regex) = first { assetRegex.containsMatchIn(it.name) }
151 | }
152 |
153 | /**
154 | * An asset of a release.
155 | *
156 | * @property name The name of the asset.
157 | * @property downloadUrl The URL to download the asset.
158 | */
159 | class BackendAsset(
160 | val name: String,
161 | val downloadUrl: String,
162 | )
163 | }
164 | }
165 | }
166 |
167 | /**
168 | * The rate limit of the backend.
169 | *
170 | * @property limit The limit of the rate limit.
171 | * @property remaining The remaining requests of the rate limit.
172 | * @property reset The date and time the rate limit resets.
173 | */
174 | class BackendRateLimit(
175 | val limit: Int,
176 | val remaining: Int,
177 | val reset: LocalDateTime,
178 | )
179 |
180 | /**
181 | * Get a release of a repository.
182 | *
183 | * @param owner The owner of the repository.
184 | * @param repository The name of the repository.
185 | * @param prerelease Whether to get a prerelease.
186 | * @return The release.
187 | */
188 | abstract suspend fun release(
189 | owner: String,
190 | repository: String,
191 | prerelease: Boolean,
192 | ): BackendOrganization.BackendRepository.BackendRelease
193 |
194 | /**
195 | * Get the contributors of a repository.
196 | *
197 | * @param owner The owner of the repository.
198 | * @param repository The name of the repository.
199 | * @return The contributors.
200 | */
201 | abstract suspend fun contributors(
202 | owner: String,
203 | repository: String,
204 | ): List
205 |
206 | /**
207 | * Get the members of an organization.
208 | *
209 | * @param organization The name of the organization.
210 | * @return The members.
211 | */
212 | abstract suspend fun members(organization: String): List
213 |
214 | /**
215 | * Get the rate limit of the backend.
216 | *
217 | * @return The rate limit.
218 | */
219 | abstract suspend fun rateLimit(): BackendRateLimit?
220 | }
221 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/repository/ConfigurationRepository.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration.repository
2 |
3 | import app.revanced.api.configuration.APIAbout
4 | import app.revanced.api.configuration.services.ManagerService
5 | import app.revanced.api.configuration.services.PatchesService
6 | import kotlinx.serialization.ExperimentalSerializationApi
7 | import kotlinx.serialization.KSerializer
8 | import kotlinx.serialization.SerialName
9 | import kotlinx.serialization.Serializable
10 | import kotlinx.serialization.descriptors.PrimitiveKind
11 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
12 | import kotlinx.serialization.descriptors.SerialDescriptor
13 | import kotlinx.serialization.encoding.Decoder
14 | import kotlinx.serialization.encoding.Encoder
15 | import kotlinx.serialization.json.Json
16 | import kotlinx.serialization.json.JsonNamingStrategy
17 | import kotlinx.serialization.json.decodeFromStream
18 | import java.io.File
19 | import java.nio.file.Path
20 | import kotlin.io.path.createDirectories
21 |
22 | /**
23 | * The repository storing the configuration for the API.
24 | *
25 | * @property organization The API backends organization name where the repositories are.
26 | * @property patches The source of the patches.
27 | * @property manager The source of the manager.
28 | * @property contributorsRepositoryNames The friendly name of repos mapped to the repository names to get contributors from.
29 | * @property backendServiceName The name of the backend service to use for the repositories, contributors, etc.
30 | * @property apiVersion The version to use for the API.
31 | * @property corsAllowedHosts The hosts allowed to make requests to the API.
32 | * @property endpoint The endpoint of the API.
33 | * @property staticFilesPath The path to the static files to be served under the root path.
34 | * @property versionedStaticFilesPath The path to the static files to be served under a versioned path.
35 | * @property about The path to the json file deserialized to [APIAbout]
36 | * (because com.akuleshov7.ktoml.Toml does not support nested tables).
37 | */
38 | @Serializable
39 | internal class ConfigurationRepository(
40 | val organization: String,
41 | val patches: SignedAssetConfiguration,
42 | val manager: AssetConfiguration,
43 | @SerialName("contributors-repositories")
44 | val contributorsRepositoryNames: Map,
45 | @SerialName("backend-service-name")
46 | val backendServiceName: String,
47 | @SerialName("api-version")
48 | val apiVersion: String = "v1",
49 | @SerialName("cors-allowed-hosts")
50 | val corsAllowedHosts: Set,
51 | val endpoint: String,
52 | @Serializable(with = PathSerializer::class)
53 | @SerialName("static-files-path")
54 | val staticFilesPath: Path,
55 | @Serializable(with = PathSerializer::class)
56 | @SerialName("versioned-static-files-path")
57 | val versionedStaticFilesPath: Path,
58 | @Serializable(with = AboutSerializer::class)
59 | @SerialName("about-json-file-path")
60 | val about: APIAbout,
61 | ) {
62 | init {
63 | staticFilesPath.createDirectories()
64 | versionedStaticFilesPath.createDirectories()
65 | }
66 |
67 | /**
68 | * Am asset configuration whose asset is signed.
69 | *
70 | * [PatchesService] for example uses [BackendRepository] to get assets from its releases.
71 | * A release contains multiple assets.
72 | *
73 | * This configuration is used in [ConfigurationRepository]
74 | * to determine which release assets from repositories to get and to verify them.
75 | *
76 | * @property repository The repository in which releases are made to get an asset.
77 | * @property assetRegex The regex matching the asset name.
78 | * @property signatureAssetRegex The regex matching the signature asset name to verify the asset.
79 | * @property publicKeyFile The public key file to verify the signature of the asset.
80 | * @property publicKeyId The ID of the public key to verify the signature of the asset.
81 | */
82 | @Serializable
83 | internal class SignedAssetConfiguration(
84 | val repository: String,
85 | @Serializable(with = RegexSerializer::class)
86 | @SerialName("asset-regex")
87 | val assetRegex: Regex,
88 | @Serializable(with = RegexSerializer::class)
89 | @SerialName("signature-asset-regex")
90 | val signatureAssetRegex: Regex,
91 | @Serializable(with = FileSerializer::class)
92 | @SerialName("public-key-file")
93 | val publicKeyFile: File,
94 | @SerialName("public-key-id")
95 | val publicKeyId: Long,
96 | )
97 |
98 | /**
99 | * Am asset configuration.
100 | *
101 | * [ManagerService] for example uses [BackendRepository] to get assets from its releases.
102 | * A release contains multiple assets.
103 | *
104 | * This configuration is used in [ConfigurationRepository]
105 | * to determine which release assets from repositories to get and to verify them.
106 | *
107 | * @property repository The repository in which releases are made to get an asset.
108 | * @property assetRegex The regex matching the asset name.
109 | */
110 | @Serializable
111 | internal class AssetConfiguration(
112 | val repository: String,
113 | @Serializable(with = RegexSerializer::class)
114 | @SerialName("asset-regex")
115 | val assetRegex: Regex,
116 | )
117 | }
118 |
119 | private object RegexSerializer : KSerializer {
120 | override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Regex", PrimitiveKind.STRING)
121 |
122 | override fun serialize(encoder: Encoder, value: Regex) = encoder.encodeString(value.pattern)
123 |
124 | override fun deserialize(decoder: Decoder) = Regex(decoder.decodeString())
125 | }
126 |
127 | private object FileSerializer : KSerializer {
128 | override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("File", PrimitiveKind.STRING)
129 |
130 | override fun serialize(encoder: Encoder, value: File) = encoder.encodeString(value.path)
131 |
132 | override fun deserialize(decoder: Decoder) = File(decoder.decodeString())
133 | }
134 |
135 | private object PathSerializer : KSerializer {
136 | override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Path", PrimitiveKind.STRING)
137 |
138 | override fun serialize(encoder: Encoder, value: Path) = encoder.encodeString(value.toString())
139 |
140 | override fun deserialize(decoder: Decoder): Path = Path.of(decoder.decodeString())
141 | }
142 |
143 | private object AboutSerializer : KSerializer {
144 | override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("APIAbout", PrimitiveKind.STRING)
145 |
146 | override fun serialize(encoder: Encoder, value: APIAbout) = error("Serializing APIAbout is not supported")
147 |
148 | @OptIn(ExperimentalSerializationApi::class)
149 | val json = Json { namingStrategy = JsonNamingStrategy.SnakeCase }
150 |
151 | override fun deserialize(decoder: Decoder): APIAbout =
152 | json.decodeFromStream(File(decoder.decodeString()).inputStream())
153 | }
154 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/repository/GitHubBackendRepository.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration.repository
2 |
3 | import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendMember
4 | import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendContributor
5 | import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendRelease
6 | import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendRelease.BackendAsset
7 | import app.revanced.api.configuration.repository.GitHubOrganization.GitHubRepository.GitHubContributor
8 | import app.revanced.api.configuration.repository.GitHubOrganization.GitHubRepository.GitHubRelease
9 | import app.revanced.api.configuration.repository.Organization.Repository.Contributors
10 | import app.revanced.api.configuration.repository.Organization.Repository.Releases
11 | import io.ktor.client.call.*
12 | import io.ktor.client.plugins.resources.*
13 | import io.ktor.resources.*
14 | import kotlinx.coroutines.async
15 | import kotlinx.coroutines.awaitAll
16 | import kotlinx.coroutines.coroutineScope
17 | import kotlinx.datetime.Instant
18 | import kotlinx.datetime.TimeZone
19 | import kotlinx.datetime.toLocalDateTime
20 | import kotlinx.serialization.SerialName
21 | import kotlinx.serialization.Serializable
22 |
23 | class GitHubBackendRepository : BackendRepository("https://api.github.com", "https://github.com") {
24 | override suspend fun release(
25 | owner: String,
26 | repository: String,
27 | prerelease: Boolean,
28 | ): BackendRelease {
29 | val release: GitHubRelease = if (prerelease) {
30 | client.get(Releases(owner, repository)).body>().first()
31 | } else {
32 | client.get(Releases.Latest(owner, repository)).body()
33 | }
34 |
35 | return BackendRelease(
36 | tag = release.tagName,
37 | releaseNote = release.body,
38 | createdAt = release.createdAt.toLocalDateTime(TimeZone.UTC),
39 | prerelease = release.prerelease,
40 | assets = release.assets.map {
41 | BackendAsset(
42 | name = it.name,
43 | downloadUrl = it.browserDownloadUrl,
44 | )
45 | },
46 | )
47 | }
48 |
49 | override suspend fun contributors(
50 | owner: String,
51 | repository: String,
52 | ): List {
53 | val contributors: List = client.get(
54 | Contributors(
55 | owner,
56 | repository,
57 | ),
58 | ).body()
59 |
60 | return contributors.map {
61 | BackendContributor(
62 | name = it.login,
63 | avatarUrl = it.avatarUrl,
64 | url = it.htmlUrl,
65 | contributions = it.contributions,
66 | )
67 | }
68 | }
69 |
70 | override suspend fun members(organization: String): List {
71 | // Get the list of members of the organization.
72 | val publicMembers: List =
73 | client.get(Organization.PublicMembers(organization)).body()
74 |
75 | return coroutineScope {
76 | publicMembers.map { member ->
77 | async {
78 | awaitAll(
79 | async {
80 | // Get the user.
81 | client.get(User(member.login)).body()
82 | },
83 | async {
84 | // Get the GPG key of the user.
85 | client.get(User.GpgKeys(member.login)).body>()
86 | },
87 | )
88 | }
89 | }
90 | }.awaitAll().map { responses ->
91 | val user = responses[0] as GitHubUser
92 |
93 | @Suppress("UNCHECKED_CAST")
94 | val gpgKeys = responses[1] as List
95 |
96 | BackendMember(
97 | name = user.login,
98 | avatarUrl = user.avatarUrl,
99 | url = user.htmlUrl,
100 | bio = user.bio,
101 | gpgKeys =
102 | BackendMember.GpgKeys(
103 | ids = gpgKeys.map { it.keyId },
104 | url = "https://github.com/${user.login}.gpg",
105 | ),
106 | )
107 | }
108 | }
109 |
110 | override suspend fun rateLimit(): BackendRateLimit {
111 | val rateLimit: GitHubRateLimit = client.get(RateLimit()).body()
112 |
113 | return BackendRateLimit(
114 | limit = rateLimit.rate.limit,
115 | remaining = rateLimit.rate.remaining,
116 | reset = Instant.fromEpochSeconds(rateLimit.rate.reset).toLocalDateTime(TimeZone.UTC),
117 | )
118 | }
119 |
120 | companion object {
121 | const val SERVICE_NAME = "GitHub"
122 | }
123 | }
124 |
125 | interface IGitHubUser {
126 | val login: String
127 | val avatarUrl: String
128 | val htmlUrl: String
129 | }
130 |
131 | @Serializable
132 | class GitHubUser(
133 | override val login: String,
134 | override val avatarUrl: String,
135 | override val htmlUrl: String,
136 | val bio: String?,
137 | ) : IGitHubUser {
138 | @Serializable
139 | class GitHubGpgKey(
140 | val keyId: String,
141 | )
142 | }
143 |
144 | class GitHubOrganization {
145 | @Serializable
146 | class GitHubMember(
147 | override val login: String,
148 | override val avatarUrl: String,
149 | override val htmlUrl: String,
150 | ) : IGitHubUser
151 |
152 | class GitHubRepository {
153 | @Serializable
154 | class GitHubContributor(
155 | override val login: String,
156 | override val avatarUrl: String,
157 | override val htmlUrl: String,
158 | val contributions: Int,
159 | ) : IGitHubUser
160 |
161 | @Serializable
162 | class GitHubRelease(
163 | val tagName: String,
164 | // Using a list instead of a set because set semantics are unnecessary here.
165 | val assets: List,
166 | val createdAt: Instant,
167 | val prerelease: Boolean,
168 | val body: String,
169 | ) {
170 | @Serializable
171 | class GitHubAsset(
172 | val name: String,
173 | val browserDownloadUrl: String,
174 | )
175 | }
176 | }
177 | }
178 |
179 | @Serializable
180 | class GitHubRateLimit(
181 | val rate: Rate,
182 | ) {
183 | @Serializable
184 | class Rate(
185 | val limit: Int,
186 | val remaining: Int,
187 | val reset: Long,
188 | )
189 | }
190 |
191 | @Resource("/users/{login}")
192 | class User(val login: String) {
193 | @Resource("/users/{login}/gpg_keys")
194 | class GpgKeys(val login: String)
195 | }
196 |
197 | class Organization {
198 | @Resource("/orgs/{org}/public_members")
199 | class PublicMembers(val org: String)
200 |
201 | class Repository {
202 | @Resource("/repos/{owner}/{repo}/contributors")
203 | class Contributors(val owner: String, val repo: String, @SerialName("per_page") val perPage: Int = 100)
204 |
205 | @Resource("/repos/{owner}/{repo}/releases")
206 | class Releases(val owner: String, val repo: String, @SerialName("per_page") val perPage: Int = 1) {
207 | @Resource("/repos/{owner}/{repo}/releases/latest")
208 | class Latest(val owner: String, val repo: String)
209 | }
210 | }
211 | }
212 |
213 | @Resource("/rate_limit")
214 | class RateLimit
215 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/routes/Announcements.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration.routes
2 |
3 | import app.revanced.api.configuration.ApiAnnouncement
4 | import app.revanced.api.configuration.ApiResponseAnnouncement
5 | import app.revanced.api.configuration.ApiResponseAnnouncementId
6 | import app.revanced.api.configuration.canRespondUnauthorized
7 | import app.revanced.api.configuration.installCache
8 | import app.revanced.api.configuration.installNotarizedRoute
9 | import app.revanced.api.configuration.respondOrNotFound
10 | import app.revanced.api.configuration.services.AnnouncementService
11 | import io.bkbn.kompendium.core.metadata.DeleteInfo
12 | import io.bkbn.kompendium.core.metadata.GetInfo
13 | import io.bkbn.kompendium.core.metadata.PatchInfo
14 | import io.bkbn.kompendium.core.metadata.PostInfo
15 | import io.bkbn.kompendium.json.schema.definition.TypeDefinition
16 | import io.bkbn.kompendium.oas.payload.Parameter
17 | import io.ktor.http.*
18 | import io.ktor.server.application.*
19 | import io.ktor.server.auth.*
20 | import io.ktor.server.plugins.ratelimit.*
21 | import io.ktor.server.response.*
22 | import io.ktor.server.routing.*
23 | import io.ktor.server.util.*
24 | import kotlin.time.Duration.Companion.minutes
25 | import org.koin.ktor.ext.get as koinGet
26 |
27 | internal fun Route.announcementsRoute() = route("announcements") {
28 | val announcementService = koinGet()
29 |
30 | installCache(5.minutes)
31 |
32 | installAnnouncementsRouteDocumentation()
33 |
34 | rateLimit(RateLimitName("strong")) {
35 | get {
36 | val cursor = call.parameters["cursor"]?.toInt() ?: Int.MAX_VALUE
37 | val count = call.parameters["count"]?.toInt() ?: 16
38 | val tags = call.parameters.getAll("tag")
39 |
40 | call.respond(announcementService.paged(cursor, count, tags?.toSet()))
41 | }
42 | }
43 |
44 | rateLimit(RateLimitName("weak")) {
45 | authenticate("jwt") {
46 | post { announcement ->
47 | announcementService.new(announcement)
48 |
49 | call.respond(HttpStatusCode.OK)
50 | }
51 | }
52 |
53 | route("latest") {
54 | installAnnouncementsLatestRouteDocumentation()
55 |
56 | get {
57 | val tags = call.parameters.getAll("tag")
58 |
59 | if (tags?.isNotEmpty() == true) {
60 | call.respond(announcementService.latest(tags.toSet()))
61 | } else {
62 | call.respondOrNotFound(announcementService.latest())
63 | }
64 | }
65 |
66 | route("id") {
67 | installAnnouncementsLatestIdRouteDocumentation()
68 |
69 | get {
70 | val tags = call.parameters.getAll("tag")
71 |
72 | if (tags?.isNotEmpty() == true) {
73 | call.respond(announcementService.latestId(tags.toSet()))
74 | } else {
75 | call.respondOrNotFound(announcementService.latestId())
76 | }
77 | }
78 | }
79 | }
80 |
81 | route("{id}") {
82 | installAnnouncementsIdRouteDocumentation()
83 |
84 | get {
85 | val id: Int by call.parameters
86 |
87 | call.respondOrNotFound(announcementService.get(id))
88 | }
89 |
90 | authenticate("jwt") {
91 | patch { announcement ->
92 | val id: Int by call.parameters
93 |
94 | announcementService.update(id, announcement)
95 |
96 | call.respond(HttpStatusCode.OK)
97 | }
98 |
99 | delete {
100 | val id: Int by call.parameters
101 |
102 | announcementService.delete(id)
103 |
104 | call.respond(HttpStatusCode.OK)
105 | }
106 | }
107 | }
108 |
109 | route("tags") {
110 | installAnnouncementsTagsRouteDocumentation()
111 |
112 | get {
113 | call.respond(announcementService.tags())
114 | }
115 | }
116 | }
117 | }
118 |
119 | private val authHeaderParameter = Parameter(
120 | name = "Authorization",
121 | `in` = Parameter.Location.header,
122 | schema = TypeDefinition.STRING,
123 | required = true,
124 | examples = mapOf("Bearer authentication" to Parameter.Example("Bearer abc123")),
125 | )
126 |
127 | private fun Route.installAnnouncementsRouteDocumentation() = installNotarizedRoute {
128 | tags = setOf("Announcements")
129 |
130 | get = GetInfo.builder {
131 | description("Get a page of announcements")
132 | summary("Get announcements")
133 | parameters(
134 | Parameter(
135 | name = "cursor",
136 | `in` = Parameter.Location.query,
137 | schema = TypeDefinition.INT,
138 | description = "The offset of the announcements. Default is Int.MAX_VALUE (Newest first)",
139 | required = false,
140 | ),
141 | Parameter(
142 | name = "count",
143 | `in` = Parameter.Location.query,
144 | schema = TypeDefinition.INT,
145 | description = "The count of the announcements. Default is 16",
146 | required = false,
147 | ),
148 | Parameter(
149 | name = "tag",
150 | `in` = Parameter.Location.query,
151 | schema = TypeDefinition.STRING,
152 | description = "The tags to filter the announcements by. Default is all tags",
153 | required = false,
154 | ),
155 | )
156 | response {
157 | responseCode(HttpStatusCode.OK)
158 | mediaTypes("application/json")
159 | description("The announcements")
160 | responseType>()
161 | }
162 | }
163 |
164 | post = PostInfo.builder {
165 | description("Create a new announcement")
166 | summary("Create announcement")
167 | parameters(authHeaderParameter)
168 | request {
169 | requestType()
170 | description("The new announcement")
171 | }
172 | response {
173 | description("The announcement is created")
174 | responseCode(HttpStatusCode.OK)
175 | responseType()
176 | }
177 | canRespondUnauthorized()
178 | }
179 | }
180 |
181 | private fun Route.installAnnouncementsLatestRouteDocumentation() = installNotarizedRoute {
182 | tags = setOf("Announcements")
183 |
184 | get = GetInfo.builder {
185 | description("Get the latest announcement")
186 | summary("Get latest announcement")
187 | parameters(
188 | Parameter(
189 | name = "tag",
190 | `in` = Parameter.Location.query,
191 | schema = TypeDefinition.STRING,
192 | description = "The tags to filter the latest announcements by",
193 | required = false,
194 | ),
195 | )
196 | response {
197 | responseCode(HttpStatusCode.OK)
198 | mediaTypes("application/json")
199 | description("The latest announcement")
200 | responseType()
201 | }
202 | canRespond {
203 | responseCode(HttpStatusCode.OK)
204 | mediaTypes("application/json")
205 | description("The latest announcements")
206 | responseType>()
207 | }
208 | canRespond {
209 | responseCode(HttpStatusCode.NotFound)
210 | description("No announcement exists")
211 | responseType()
212 | }
213 | }
214 | }
215 |
216 | private fun Route.installAnnouncementsLatestIdRouteDocumentation() = installNotarizedRoute {
217 | tags = setOf("Announcements")
218 |
219 | get = GetInfo.builder {
220 | description("Get the ID of the latest announcement")
221 | summary("Get ID of latest announcement")
222 | parameters(
223 | Parameter(
224 | name = "tag",
225 | `in` = Parameter.Location.query,
226 | schema = TypeDefinition.STRING,
227 | description = "The tags to filter the latest announcements by",
228 | required = false,
229 | ),
230 | )
231 | response {
232 | responseCode(HttpStatusCode.OK)
233 | mediaTypes("application/json")
234 | description("The ID of the latest announcement")
235 | responseType()
236 | }
237 | canRespond {
238 | responseCode(HttpStatusCode.OK)
239 | mediaTypes("application/json")
240 | description("The IDs of the latest announcements")
241 | responseType>()
242 | }
243 | canRespond {
244 | responseCode(HttpStatusCode.NotFound)
245 | description("No announcement exists")
246 | responseType()
247 | }
248 | }
249 | }
250 |
251 | private fun Route.installAnnouncementsIdRouteDocumentation() = installNotarizedRoute {
252 | tags = setOf("Announcements")
253 |
254 | parameters = listOf(
255 | Parameter(
256 | name = "id",
257 | `in` = Parameter.Location.path,
258 | schema = TypeDefinition.INT,
259 | description = "The ID of the announcement to update",
260 | required = true,
261 | ),
262 | authHeaderParameter,
263 | )
264 |
265 | get = GetInfo.builder {
266 | description("Get an announcement")
267 | summary("Get announcement")
268 | response {
269 | description("The announcement")
270 | responseCode(HttpStatusCode.OK)
271 | responseType()
272 | }
273 | canRespond {
274 | responseCode(HttpStatusCode.NotFound)
275 | description("The announcement does not exist")
276 | responseType()
277 | }
278 | }
279 |
280 | patch = PatchInfo.builder {
281 | description("Update an announcement")
282 | summary("Update announcement")
283 | request {
284 | requestType()
285 | description("The new announcement")
286 | }
287 | response {
288 | description("The announcement is updated")
289 | responseCode(HttpStatusCode.OK)
290 | responseType()
291 | }
292 | canRespondUnauthorized()
293 | }
294 |
295 | delete = DeleteInfo.builder {
296 | description("Delete an announcement")
297 | summary("Delete announcement")
298 | response {
299 | description("The announcement is deleted")
300 | responseCode(HttpStatusCode.OK)
301 | responseType()
302 | }
303 | canRespondUnauthorized()
304 | }
305 | }
306 |
307 | private fun Route.installAnnouncementsTagsRouteDocumentation() = installNotarizedRoute {
308 | tags = setOf("Announcements")
309 |
310 | get = GetInfo.builder {
311 | description("Get all announcement tags")
312 | summary("Get announcement tags")
313 | response {
314 | responseCode(HttpStatusCode.OK)
315 | mediaTypes("application/json")
316 | description("The announcement tags")
317 | responseType>()
318 | }
319 | }
320 | }
321 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/routes/ApiRoute.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration.routes
2 |
3 | import app.revanced.api.configuration.*
4 | import app.revanced.api.configuration.installCache
5 | import app.revanced.api.configuration.installNoCache
6 | import app.revanced.api.configuration.installNotarizedRoute
7 | import app.revanced.api.configuration.repository.ConfigurationRepository
8 | import app.revanced.api.configuration.respondOrNotFound
9 | import app.revanced.api.configuration.services.ApiService
10 | import app.revanced.api.configuration.services.AuthenticationService
11 | import io.bkbn.kompendium.core.metadata.*
12 | import io.bkbn.kompendium.json.schema.definition.TypeDefinition
13 | import io.bkbn.kompendium.oas.payload.Parameter
14 | import io.ktor.http.*
15 | import io.ktor.server.application.*
16 | import io.ktor.server.auth.*
17 | import io.ktor.server.plugins.ratelimit.*
18 | import io.ktor.server.response.*
19 | import io.ktor.server.routing.*
20 | import kotlin.time.Duration.Companion.days
21 | import org.koin.ktor.ext.get as koinGet
22 |
23 | internal fun Route.apiRoute() {
24 | val apiService = koinGet()
25 | val authenticationService = koinGet()
26 |
27 | rateLimit(RateLimitName("strong")) {
28 | authenticate("auth-digest") {
29 | route("token") {
30 | installTokenRouteDocumentation()
31 |
32 | get {
33 | call.respond(authenticationService.newToken())
34 | }
35 | }
36 | }
37 |
38 | route("contributors") {
39 | installCache(1.days)
40 |
41 | installContributorsRouteDocumentation()
42 |
43 | get {
44 | call.respond(apiService.contributors())
45 | }
46 | }
47 |
48 | route("team") {
49 | installCache(1.days)
50 |
51 | installTeamRouteDocumentation()
52 |
53 | get {
54 | call.respond(apiService.team())
55 | }
56 | }
57 | }
58 |
59 | route("about") {
60 | installCache(1.days)
61 |
62 | installAboutRouteDocumentation()
63 |
64 | get {
65 | call.respond(apiService.about)
66 | }
67 | }
68 |
69 | route("ping") {
70 | installNoCache()
71 |
72 | installPingRouteDocumentation()
73 |
74 | handle {
75 | call.respond(HttpStatusCode.NoContent)
76 | }
77 | }
78 |
79 | rateLimit(RateLimitName("weak")) {
80 | route("backend/rate_limit") {
81 | installRateLimitRouteDocumentation()
82 |
83 | get {
84 | call.respondOrNotFound(apiService.rateLimit())
85 | }
86 | }
87 |
88 | staticFiles("/", apiService.versionedStaticFilesPath)
89 | }
90 | }
91 |
92 | private fun Route.installAboutRouteDocumentation() = installNotarizedRoute {
93 | tags = setOf("API")
94 |
95 | get = GetInfo.builder {
96 | description("Get information about the API")
97 | summary("Get about")
98 | response {
99 | description("Information about the API")
100 | mediaTypes("application/json")
101 | responseCode(HttpStatusCode.OK)
102 | responseType()
103 | }
104 | }
105 | }
106 |
107 | private fun Route.installRateLimitRouteDocumentation() = installNotarizedRoute {
108 | tags = setOf("API")
109 |
110 | get = GetInfo.builder {
111 | description("Get the rate limit of the backend")
112 | summary("Get rate limit of backend")
113 | response {
114 | description("The rate limit of the backend")
115 | mediaTypes("application/json")
116 | responseCode(HttpStatusCode.OK)
117 | responseType()
118 | }
119 | }
120 | }
121 |
122 | private fun Route.installPingRouteDocumentation() = installNotarizedRoute {
123 | tags = setOf("API")
124 |
125 | head = HeadInfo.builder {
126 | description("Ping the server")
127 | summary("Ping")
128 | response {
129 | description("The server is reachable")
130 | responseCode(HttpStatusCode.NoContent)
131 | responseType()
132 | }
133 | }
134 | }
135 |
136 | private fun Route.installTeamRouteDocumentation() = installNotarizedRoute {
137 | tags = setOf("API")
138 |
139 | get = GetInfo.builder {
140 | description("Get the list of team members")
141 | summary("Get team members")
142 | response {
143 | description("The list of team members")
144 | mediaTypes("application/json")
145 | responseCode(HttpStatusCode.OK)
146 | responseType>()
147 | }
148 | }
149 | }
150 |
151 | private fun Route.installContributorsRouteDocumentation() = installNotarizedRoute {
152 | tags = setOf("API")
153 |
154 | get = GetInfo.builder {
155 | description("Get the list of contributors")
156 | summary("Get contributors")
157 | response {
158 | description("The list of contributors")
159 | mediaTypes("application/json")
160 | responseCode(HttpStatusCode.OK)
161 | responseType>()
162 | }
163 | }
164 | }
165 |
166 | private fun Route.installTokenRouteDocumentation() = installNotarizedRoute {
167 | val configuration = koinGet()
168 |
169 | tags = setOf("API")
170 |
171 | get = GetInfo.builder {
172 | description("Get a new authorization token")
173 | summary("Get authorization token")
174 | parameters(
175 | Parameter(
176 | name = "Authorization",
177 | `in` = Parameter.Location.header,
178 | schema = TypeDefinition.STRING,
179 | required = true,
180 | examples = mapOf(
181 | "Digest access authentication" to Parameter.Example(
182 | value = "Digest " +
183 | "username=\"ReVanced\", " +
184 | "realm=\"ReVanced\", " +
185 | "nonce=\"abc123\", " +
186 | "uri=\"/${configuration.apiVersion}/token\", " +
187 | "algorithm=SHA-256, " +
188 | "response=\"yxz456\"",
189 | ),
190 | ), // Provide an example for the header
191 | ),
192 | )
193 | response {
194 | description("The authorization token")
195 | mediaTypes("application/json")
196 | responseCode(HttpStatusCode.OK)
197 | responseType()
198 | }
199 | canRespondUnauthorized()
200 | }
201 | }
202 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/routes/ManagerRoute.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration.routes
2 |
3 | import app.revanced.api.configuration.ApiRelease
4 | import app.revanced.api.configuration.ApiReleaseVersion
5 | import app.revanced.api.configuration.installNotarizedRoute
6 | import app.revanced.api.configuration.services.ManagerService
7 | import io.bkbn.kompendium.core.metadata.GetInfo
8 | import io.bkbn.kompendium.json.schema.definition.TypeDefinition
9 | import io.bkbn.kompendium.oas.payload.Parameter
10 | import io.ktor.http.*
11 | import io.ktor.server.application.*
12 | import io.ktor.server.plugins.ratelimit.*
13 | import io.ktor.server.response.*
14 | import io.ktor.server.routing.*
15 | import org.koin.ktor.ext.get as koinGet
16 |
17 | internal fun Route.managerRoute() = route("manager") {
18 | val managerService = koinGet()
19 |
20 | installManagerRouteDocumentation()
21 |
22 | rateLimit(RateLimitName("weak")) {
23 | get {
24 | val prerelease = call.parameters["prerelease"]?.toBoolean() ?: false
25 |
26 | call.respond(managerService.latestRelease(prerelease))
27 | }
28 |
29 | route("version") {
30 | installManagerVersionRouteDocumentation()
31 |
32 | get {
33 | val prerelease = call.parameters["prerelease"]?.toBoolean() ?: false
34 |
35 | call.respond(managerService.latestVersion(prerelease))
36 | }
37 | }
38 | }
39 | }
40 |
41 | private val prereleaseParameter = Parameter(
42 | name = "prerelease",
43 | `in` = Parameter.Location.query,
44 | schema = TypeDefinition.STRING,
45 | description = "Whether to get the current manager prerelease",
46 | required = false,
47 | )
48 |
49 | private fun Route.installManagerRouteDocumentation() = installNotarizedRoute {
50 | tags = setOf("Manager")
51 |
52 | get = GetInfo.builder {
53 | description("Get the current manager release")
54 | summary("Get current manager release")
55 | parameters(prereleaseParameter)
56 | response {
57 | description("The latest manager release")
58 | mediaTypes("application/json")
59 | responseCode(HttpStatusCode.OK)
60 | responseType()
61 | }
62 | }
63 | }
64 |
65 | private fun Route.installManagerVersionRouteDocumentation() = installNotarizedRoute {
66 | tags = setOf("Manager")
67 |
68 | get = GetInfo.builder {
69 | description("Get the current manager release version")
70 | summary("Get current manager release version")
71 | parameters(prereleaseParameter)
72 | response {
73 | description("The current manager release version")
74 | mediaTypes("application/json")
75 | responseCode(HttpStatusCode.OK)
76 | responseType()
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/routes/PatchesRoute.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration.routes
2 |
3 | import app.revanced.api.configuration.ApiAssetPublicKey
4 | import app.revanced.api.configuration.ApiRelease
5 | import app.revanced.api.configuration.ApiReleaseVersion
6 | import app.revanced.api.configuration.installCache
7 | import app.revanced.api.configuration.installNotarizedRoute
8 | import app.revanced.api.configuration.services.PatchesService
9 | import io.bkbn.kompendium.core.metadata.GetInfo
10 | import io.bkbn.kompendium.json.schema.definition.TypeDefinition
11 | import io.bkbn.kompendium.oas.payload.Parameter
12 | import io.ktor.http.*
13 | import io.ktor.server.application.*
14 | import io.ktor.server.plugins.ratelimit.*
15 | import io.ktor.server.response.*
16 | import io.ktor.server.routing.*
17 | import kotlin.time.Duration.Companion.days
18 | import org.koin.ktor.ext.get as koinGet
19 |
20 | internal fun Route.patchesRoute() = route("patches") {
21 | val patchesService = koinGet()
22 |
23 | installPatchesRouteDocumentation()
24 |
25 | rateLimit(RateLimitName("weak")) {
26 | get {
27 | val prerelease = call.parameters["prerelease"]?.toBoolean() ?: false
28 |
29 | call.respond(patchesService.latestRelease(prerelease))
30 | }
31 |
32 | route("version") {
33 | installPatchesVersionRouteDocumentation()
34 |
35 | get {
36 | val prerelease = call.parameters["prerelease"]?.toBoolean() ?: false
37 |
38 | call.respond(patchesService.latestVersion(prerelease))
39 | }
40 | }
41 | }
42 |
43 | rateLimit(RateLimitName("strong")) {
44 | route("list") {
45 | installPatchesListRouteDocumentation()
46 |
47 | get {
48 | val prerelease = call.parameters["prerelease"]?.toBoolean() ?: false
49 |
50 | call.respondBytes(ContentType.Application.Json) { patchesService.list(prerelease) }
51 | }
52 | }
53 | }
54 |
55 | rateLimit(RateLimitName("strong")) {
56 | route("keys") {
57 | installCache(356.days)
58 |
59 | installPatchesPublicKeyRouteDocumentation()
60 |
61 | get {
62 | call.respond(patchesService.publicKey())
63 | }
64 | }
65 | }
66 | }
67 |
68 | private val prereleaseParameter = Parameter(
69 | name = "prerelease",
70 | `in` = Parameter.Location.query,
71 | schema = TypeDefinition.STRING,
72 | description = "Whether to get the current patches prerelease",
73 | required = false,
74 | )
75 |
76 | private fun Route.installPatchesRouteDocumentation() = installNotarizedRoute {
77 | tags = setOf("Patches")
78 |
79 | get = GetInfo.builder {
80 | description("Get the current patches release")
81 | summary("Get current patches release")
82 | parameters(prereleaseParameter)
83 | response {
84 | description("The current patches release")
85 | mediaTypes("application/json")
86 | responseCode(HttpStatusCode.OK)
87 | responseType()
88 | }
89 | }
90 | }
91 |
92 | private fun Route.installPatchesVersionRouteDocumentation() = installNotarizedRoute {
93 | tags = setOf("Patches")
94 |
95 | get = GetInfo.builder {
96 | description("Get the current patches release version")
97 | summary("Get current patches release version")
98 | parameters(prereleaseParameter)
99 | response {
100 | description("The current patches release version")
101 | mediaTypes("application/json")
102 | responseCode(HttpStatusCode.OK)
103 | responseType()
104 | }
105 | }
106 | }
107 |
108 | private fun Route.installPatchesListRouteDocumentation() = installNotarizedRoute {
109 | tags = setOf("Patches")
110 |
111 | get = GetInfo.builder {
112 | description("Get the list of patches from the current patches release")
113 | summary("Get list of patches from current patches release")
114 | parameters(prereleaseParameter)
115 | response {
116 | description("The list of patches")
117 | mediaTypes("application/json")
118 | responseCode(HttpStatusCode.OK)
119 | responseType()
120 | }
121 | }
122 | }
123 |
124 | private fun Route.installPatchesPublicKeyRouteDocumentation() = installNotarizedRoute {
125 | tags = setOf("Patches")
126 |
127 | get = GetInfo.builder {
128 | description("Get the public keys for verifying patches assets")
129 | summary("Get patches public keys")
130 | response {
131 | description("The public keys")
132 | mediaTypes("application/json")
133 | responseCode(HttpStatusCode.OK)
134 | responseType()
135 | }
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/services/AnnouncementService.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration.services
2 |
3 | import app.revanced.api.configuration.ApiAnnouncement
4 | import app.revanced.api.configuration.repository.AnnouncementRepository
5 |
6 | internal class AnnouncementService(
7 | private val announcementRepository: AnnouncementRepository,
8 | ) {
9 | suspend fun latest(tags: Set) = announcementRepository.latest(tags)
10 |
11 | suspend fun latest() = announcementRepository.latest()
12 |
13 | fun latestId(tags: Set) = announcementRepository.latestId(tags)
14 |
15 | fun latestId() = announcementRepository.latestId()
16 |
17 | suspend fun paged(cursor: Int, limit: Int, tags: Set?) =
18 | announcementRepository.paged(cursor, limit, tags)
19 |
20 | suspend fun get(id: Int) = announcementRepository.get(id)
21 |
22 | suspend fun update(id: Int, new: ApiAnnouncement) = announcementRepository.update(id, new)
23 |
24 | suspend fun delete(id: Int) = announcementRepository.delete(id)
25 |
26 | suspend fun new(new: ApiAnnouncement) = announcementRepository.new(new)
27 |
28 | suspend fun tags() = announcementRepository.tags()
29 | }
30 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/services/ApiService.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration.services
2 |
3 | import app.revanced.api.configuration.*
4 | import app.revanced.api.configuration.repository.BackendRepository
5 | import app.revanced.api.configuration.repository.ConfigurationRepository
6 | import io.ktor.http.*
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlinx.coroutines.async
9 | import kotlinx.coroutines.awaitAll
10 | import kotlinx.coroutines.withContext
11 |
12 | internal class ApiService(
13 | private val backendRepository: BackendRepository,
14 | private val configurationRepository: ConfigurationRepository,
15 | ) {
16 | val versionedStaticFilesPath = configurationRepository.versionedStaticFilesPath
17 | val about = configurationRepository.about
18 |
19 | suspend fun contributors() = withContext(Dispatchers.IO) {
20 | configurationRepository.contributorsRepositoryNames.map { (repository, name) ->
21 | async {
22 | APIContributable(
23 | name,
24 | URLBuilder().apply {
25 | takeFrom(backendRepository.website)
26 | path(configurationRepository.organization, repository)
27 | }.buildString(),
28 | backendRepository.contributors(configurationRepository.organization, repository).map {
29 | ApiContributor(it.name, it.avatarUrl, it.url, it.contributions)
30 | },
31 | )
32 | }
33 | }
34 | }.awaitAll()
35 |
36 | suspend fun team() = backendRepository.members(configurationRepository.organization).map { member ->
37 | ApiMember(
38 | member.name,
39 | member.avatarUrl,
40 | member.url,
41 | member.bio,
42 | if (member.gpgKeys.ids.isNotEmpty()) {
43 | ApiGpgKey(
44 | // Must choose one of the GPG keys, because it does not make sense to have multiple GPG keys for the API.
45 | member.gpgKeys.ids.first(),
46 | member.gpgKeys.url,
47 | )
48 | } else {
49 | null
50 | },
51 | )
52 | }
53 |
54 | suspend fun rateLimit() = backendRepository.rateLimit()?.let {
55 | ApiRateLimit(it.limit, it.remaining, it.reset)
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/services/AuthenticationService.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration.services
2 |
3 | import app.revanced.api.configuration.ApiToken
4 | import com.auth0.jwt.JWT
5 | import com.auth0.jwt.algorithms.Algorithm
6 | import io.ktor.server.auth.*
7 | import io.ktor.server.auth.jwt.*
8 | import java.time.Instant
9 | import java.time.temporal.ChronoUnit
10 | import kotlin.text.HexFormat
11 |
12 | internal class AuthenticationService private constructor(
13 | private val issuer: String,
14 | private val validityInMin: Long,
15 | private val jwtSecret: String,
16 | private val authSHA256Digest: ByteArray,
17 | ) {
18 | @OptIn(ExperimentalStdlibApi::class)
19 | constructor(issuer: String, validityInMin: Long, jwtSecret: String, authSHA256DigestString: String) : this(
20 | issuer,
21 | validityInMin,
22 | jwtSecret,
23 | authSHA256DigestString.hexToByteArray(HexFormat.Default),
24 | )
25 |
26 | fun AuthenticationConfig.jwt() {
27 | jwt("jwt") {
28 | realm = "ReVanced"
29 | verifier(JWT.require(Algorithm.HMAC256(jwtSecret)).withIssuer(issuer).build())
30 | // This is required and not optional. Authentication will fail if this is not present.
31 | validate { JWTPrincipal(it.payload) }
32 | }
33 | }
34 |
35 | fun AuthenticationConfig.digest() {
36 | digest("auth-digest") {
37 | realm = "ReVanced"
38 | algorithmName = "SHA-256"
39 |
40 | digestProvider { _, _ ->
41 | authSHA256Digest
42 | }
43 | }
44 | }
45 |
46 | fun newToken() = ApiToken(
47 | JWT.create()
48 | .withIssuer(issuer)
49 | .withExpiresAt(Instant.now().plus(validityInMin, ChronoUnit.MINUTES))
50 | .sign(Algorithm.HMAC256(jwtSecret)),
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/services/ManagerService.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration.services
2 |
3 | import app.revanced.api.configuration.ApiRelease
4 | import app.revanced.api.configuration.ApiReleaseVersion
5 | import app.revanced.api.configuration.repository.BackendRepository
6 | import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendRelease.Companion.first
7 | import app.revanced.api.configuration.repository.ConfigurationRepository
8 |
9 | internal class ManagerService(
10 | private val backendRepository: BackendRepository,
11 | private val configurationRepository: ConfigurationRepository,
12 | ) {
13 | suspend fun latestRelease(prerelease: Boolean): ApiRelease {
14 | val managerRelease = backendRepository.release(
15 | configurationRepository.organization,
16 | configurationRepository.manager.repository,
17 | prerelease,
18 | )
19 |
20 | return ApiRelease(
21 | managerRelease.tag,
22 | managerRelease.createdAt,
23 | managerRelease.releaseNote,
24 | managerRelease.assets.first(configurationRepository.manager.assetRegex).downloadUrl,
25 | )
26 | }
27 |
28 | suspend fun latestVersion(prerelease: Boolean): ApiReleaseVersion {
29 | val managerRelease = backendRepository.release(
30 | configurationRepository.organization,
31 | configurationRepository.manager.repository,
32 | prerelease,
33 | )
34 |
35 | return ApiReleaseVersion(managerRelease.tag)
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/services/PatchesService.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration.services
2 |
3 | import app.revanced.api.configuration.ApiAssetPublicKey
4 | import app.revanced.api.configuration.ApiRelease
5 | import app.revanced.api.configuration.ApiReleaseVersion
6 | import app.revanced.api.configuration.repository.BackendRepository
7 | import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendRelease.Companion.first
8 | import app.revanced.api.configuration.repository.ConfigurationRepository
9 | import app.revanced.library.serializeTo
10 | import app.revanced.patcher.patch.loadPatchesFromJar
11 | import com.github.benmanes.caffeine.cache.Caffeine
12 | import kotlinx.coroutines.Dispatchers
13 | import kotlinx.coroutines.withContext
14 | import java.io.ByteArrayOutputStream
15 | import java.net.URL
16 |
17 | internal class PatchesService(
18 | private val signatureService: SignatureService,
19 | private val backendRepository: BackendRepository,
20 | private val configurationRepository: ConfigurationRepository,
21 | ) {
22 | suspend fun latestRelease(prerelease: Boolean): ApiRelease {
23 | val patchesRelease = backendRepository.release(
24 | configurationRepository.organization,
25 | configurationRepository.patches.repository,
26 | prerelease,
27 | )
28 |
29 | return ApiRelease(
30 | patchesRelease.tag,
31 | patchesRelease.createdAt,
32 | patchesRelease.releaseNote,
33 | patchesRelease.assets.first(configurationRepository.patches.assetRegex).downloadUrl,
34 | patchesRelease.assets.first(configurationRepository.patches.signatureAssetRegex).downloadUrl,
35 | )
36 | }
37 |
38 | suspend fun latestVersion(prerelease: Boolean): ApiReleaseVersion {
39 | val patchesRelease = backendRepository.release(
40 | configurationRepository.organization,
41 | configurationRepository.patches.repository,
42 | prerelease,
43 | )
44 |
45 | return ApiReleaseVersion(patchesRelease.tag)
46 | }
47 |
48 | private val patchesListCache = Caffeine
49 | .newBuilder()
50 | .maximumSize(1)
51 | .build()
52 |
53 | suspend fun list(prerelease: Boolean): ByteArray {
54 | val patchesRelease = backendRepository.release(
55 | configurationRepository.organization,
56 | configurationRepository.patches.repository,
57 | prerelease,
58 | )
59 |
60 | return withContext(Dispatchers.IO) {
61 | patchesListCache.get(patchesRelease.tag) {
62 | val patchesDownloadUrl = patchesRelease.assets
63 | .first(configurationRepository.patches.assetRegex).downloadUrl
64 |
65 | val signatureDownloadUrl = patchesRelease.assets
66 | .first(configurationRepository.patches.signatureAssetRegex).downloadUrl
67 |
68 | val patchesFile = kotlin.io.path.createTempFile().toFile().apply {
69 | outputStream().use { URL(patchesDownloadUrl).openStream().copyTo(it) }
70 | }
71 |
72 | val patches = if (
73 | signatureService.verify(
74 | patchesFile,
75 | signatureDownloadUrl,
76 | configurationRepository.patches.publicKeyFile,
77 | configurationRepository.patches.publicKeyId,
78 | )
79 | ) {
80 | loadPatchesFromJar(setOf(patchesFile))
81 | } else {
82 | // Use an empty set of patches if the signature is invalid.
83 | emptySet()
84 | }
85 |
86 | patchesFile.delete()
87 |
88 | ByteArrayOutputStream().use { stream ->
89 | patches.serializeTo(outputStream = stream)
90 |
91 | stream.toByteArray()
92 | }
93 | }
94 | }
95 | }
96 |
97 | fun publicKey() = ApiAssetPublicKey(configurationRepository.patches.publicKeyFile.readText())
98 | }
99 |
--------------------------------------------------------------------------------
/src/main/kotlin/app/revanced/api/configuration/services/SignatureService.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration.services
2 |
3 | import com.github.benmanes.caffeine.cache.Caffeine
4 | import org.bouncycastle.openpgp.*
5 | import org.bouncycastle.openpgp.operator.bc.BcKeyFingerprintCalculator
6 | import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider
7 | import java.io.File
8 | import java.io.InputStream
9 | import java.net.URL
10 | import java.security.MessageDigest
11 |
12 | internal class SignatureService {
13 | private val signatureCache = Caffeine
14 | .newBuilder()
15 | .maximumSize(2) // 2 because currently only the latest release and prerelease patches are needed.
16 | .build() // Hash -> Verified.
17 |
18 | fun verify(
19 | file: File,
20 | signatureDownloadUrl: String,
21 | publicKeyFile: File,
22 | publicKeyId: Long,
23 | ): Boolean {
24 | val fileBytes = file.readBytes()
25 |
26 | return signatureCache.get(MessageDigest.getInstance("SHA-256").digest(fileBytes)) {
27 | verify(
28 | fileBytes = fileBytes,
29 | signatureInputStream = URL(signatureDownloadUrl).openStream(),
30 | publicKeyFileInputStream = publicKeyFile.inputStream(),
31 | publicKeyId = publicKeyId,
32 | )
33 | }
34 | }
35 |
36 | private fun verify(
37 | fileBytes: ByteArray,
38 | signatureInputStream: InputStream,
39 | publicKeyFileInputStream: InputStream,
40 | publicKeyId: Long,
41 | ) = getSignature(signatureInputStream).apply {
42 | init(BcPGPContentVerifierBuilderProvider(), getPublicKey(publicKeyFileInputStream, publicKeyId))
43 | update(fileBytes)
44 | }.verify()
45 |
46 | private fun getPublicKey(
47 | publicKeyFileInputStream: InputStream,
48 | publicKeyId: Long,
49 | ): PGPPublicKey {
50 | val decoderStream = PGPUtil.getDecoderStream(publicKeyFileInputStream)
51 | val pgpPublicKeyRingCollection = PGPPublicKeyRingCollection(decoderStream, BcKeyFingerprintCalculator())
52 | val publicKeyRing = pgpPublicKeyRingCollection.getPublicKeyRing(publicKeyId)
53 | ?: throw IllegalArgumentException("Can't find public key ring with ID $publicKeyId.")
54 |
55 | return publicKeyRing.getPublicKey(publicKeyId)
56 | ?: throw IllegalArgumentException("Can't find public key with ID $publicKeyId.")
57 | }
58 |
59 | private fun getSignature(inputStream: InputStream): PGPSignature {
60 | val decoderStream = PGPUtil.getDecoderStream(inputStream)
61 | val pgpSignatureList = PGPObjectFactory(decoderStream, BcKeyFingerprintCalculator()).first {
62 | it is PGPSignatureList
63 | } as PGPSignatureList
64 |
65 | return pgpSignatureList.first()
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/main/resources/app/revanced/api/version.properties:
--------------------------------------------------------------------------------
1 | version=${projectVersion}
--------------------------------------------------------------------------------
/src/main/resources/logback.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | %d{YYYY-MM-dd HH:mm:ss.SSS} %-5level %msg%n
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/test/kotlin/app/revanced/api/configuration/services/AnnouncementServiceTest.kt:
--------------------------------------------------------------------------------
1 | package app.revanced.api.configuration.services
2 |
3 | import app.revanced.api.configuration.ApiAnnouncement
4 | import app.revanced.api.configuration.repository.AnnouncementRepository
5 | import kotlinx.coroutines.runBlocking
6 | import kotlinx.datetime.toKotlinLocalDateTime
7 | import org.jetbrains.exposed.sql.Database
8 | import org.junit.jupiter.api.Assertions.assertNull
9 | import org.junit.jupiter.api.BeforeAll
10 | import org.junit.jupiter.api.BeforeEach
11 | import org.junit.jupiter.api.Test
12 | import java.time.LocalDateTime
13 | import kotlin.test.assertEquals
14 | import kotlin.test.assertNotNull
15 |
16 | private object AnnouncementServiceTest {
17 | private lateinit var announcementService: AnnouncementService
18 |
19 | @JvmStatic
20 | @BeforeAll
21 | fun setUp() {
22 | val database = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false")
23 |
24 | announcementService = AnnouncementService(AnnouncementRepository(database))
25 | }
26 |
27 | @BeforeEach
28 | fun clear() {
29 | runBlocking {
30 | while (true) {
31 | val latestId = announcementService.latestId() ?: break
32 | announcementService.delete(latestId.id)
33 | }
34 | }
35 | }
36 |
37 | @Test
38 | fun `can do basic crud`(): Unit = runBlocking {
39 | announcementService.new(ApiAnnouncement(title = "title"))
40 |
41 | val latestId = announcementService.latestId()!!.id
42 |
43 | announcementService.update(latestId, ApiAnnouncement(title = "new title"))
44 | assert(announcementService.get(latestId)?.title == "new title")
45 |
46 | announcementService.delete(latestId)
47 | assertNull(announcementService.get(latestId))
48 | assertNull(announcementService.latestId())
49 | }
50 |
51 | @Test
52 | fun `archiving works properly`() = runBlocking {
53 | announcementService.new(ApiAnnouncement(title = "title"))
54 |
55 | val latest = announcementService.latest()!!
56 | assertNull(announcementService.get(latest.id)?.archivedAt)
57 |
58 | val updated = ApiAnnouncement(
59 | title = latest.title,
60 | archivedAt = LocalDateTime.now().toKotlinLocalDateTime(),
61 | )
62 |
63 | announcementService.update(latest.id, updated)
64 | assertNotNull(announcementService.get(latest.id)?.archivedAt)
65 |
66 | return@runBlocking
67 | }
68 |
69 | @Test
70 | fun `latest works properly`() = runBlocking {
71 | announcementService.new(ApiAnnouncement(title = "title"))
72 | announcementService.new(ApiAnnouncement(title = "title2"))
73 |
74 | var latest = announcementService.latest()
75 | assert(latest?.title == "title2")
76 |
77 | announcementService.delete(latest!!.id)
78 |
79 | latest = announcementService.latest()
80 | assert(latest?.title == "title")
81 |
82 | announcementService.delete(latest!!.id)
83 | assertNull(announcementService.latest())
84 |
85 | announcementService.new(ApiAnnouncement(title = "1", tags = listOf("tag1", "tag2")))
86 | announcementService.new(ApiAnnouncement(title = "2", tags = listOf("tag1", "tag3")))
87 | announcementService.new(ApiAnnouncement(title = "3", tags = listOf("tag1", "tag4")))
88 |
89 | assert(announcementService.latest(setOf("tag2")).first().title == "1")
90 | assert(announcementService.latest(setOf("tag3")).last().title == "2")
91 |
92 | val announcement2and3 = announcementService.latest(setOf("tag1", "tag3"))
93 | assert(announcement2and3.size == 2)
94 | assert(announcement2and3.any { it.title == "2" })
95 | assert(announcement2and3.any { it.title == "3" })
96 |
97 | announcementService.delete(announcementService.latestId()!!.id)
98 | assert(announcementService.latest(setOf("tag1", "tag3")).first().title == "2")
99 |
100 | announcementService.delete(announcementService.latestId()!!.id)
101 | assert(announcementService.latest(setOf("tag1", "tag3")).first().title == "1")
102 |
103 | announcementService.delete(announcementService.latestId()!!.id)
104 | assert(announcementService.latest(setOf("tag1", "tag3")).isEmpty())
105 | assert(announcementService.tags().isEmpty())
106 | }
107 |
108 | @Test
109 | fun `tags work properly`() = runBlocking {
110 | announcementService.new(ApiAnnouncement(title = "title", tags = listOf("tag1", "tag2")))
111 | announcementService.new(ApiAnnouncement(title = "title2", tags = listOf("tag1", "tag3")))
112 |
113 | val tags = announcementService.tags()
114 | assertEquals(3, tags.size)
115 | assert(tags.any { it.name == "tag1" })
116 | assert(tags.any { it.name == "tag2" })
117 | assert(tags.any { it.name == "tag3" })
118 |
119 | announcementService.delete(announcementService.latestId()!!.id)
120 | assertEquals(2, announcementService.tags().size)
121 |
122 | announcementService.update(
123 | announcementService.latestId()!!.id,
124 | ApiAnnouncement(title = "title", tags = listOf("tag1", "tag3")),
125 | )
126 |
127 | assertEquals(2, announcementService.tags().size)
128 | assert(announcementService.tags().any { it.name == "tag3" })
129 | }
130 |
131 | @Test
132 | fun `attachments work properly`() = runBlocking {
133 | announcementService.new(ApiAnnouncement(title = "title", attachments = listOf("attachment1", "attachment2")))
134 |
135 | val latestAnnouncement = announcementService.latest()!!
136 | val latestId = latestAnnouncement.id
137 |
138 | val attachments = latestAnnouncement.attachments!!
139 | assertEquals(2, attachments.size)
140 | assert(attachments.any { it == "attachment1" })
141 | assert(attachments.any { it == "attachment2" })
142 |
143 | announcementService.update(
144 | latestId,
145 | ApiAnnouncement(title = "title", attachments = listOf("attachment1", "attachment3")),
146 | )
147 | assert(announcementService.get(latestId)!!.attachments!!.any { it == "attachment3" })
148 | }
149 |
150 | @Test
151 | fun `paging works correctly`() = runBlocking {
152 | repeat(10) {
153 | announcementService.new(ApiAnnouncement(title = "title$it"))
154 | }
155 |
156 | val announcements = announcementService.paged(Int.MAX_VALUE, 5, null)
157 | assertEquals(5, announcements.size, "Returns correct number of announcements")
158 | assertEquals("title9", announcements.first().title, "Starts from the latest announcement")
159 |
160 | val announcements2 = announcementService.paged(5, 5, null)
161 | assertEquals(5, announcements2.size, "Returns correct number of announcements when starting from the cursor")
162 | assertEquals("title4", announcements2.first().title, "Starts from the cursor")
163 |
164 | (0..4).forEach { id ->
165 | announcementService.update(
166 | id,
167 | ApiAnnouncement(
168 | title = "title$id",
169 | tags = (0..id).map { "tag$it" },
170 | archivedAt = if (id % 2 == 0) {
171 | // Only two announcements will be archived.
172 | LocalDateTime.now().plusDays(2).minusDays(id.toLong()).toKotlinLocalDateTime()
173 | } else {
174 | null
175 | },
176 | ),
177 | )
178 | }
179 |
180 | val tags = announcementService.tags()
181 | assertEquals(5, tags.size, "Returns correct number of newly created tags")
182 |
183 | val announcements3 = announcementService.paged(5, 5, setOf(tags[1].name))
184 | assertEquals(4, announcements3.size, "Filters announcements by tag")
185 | }
186 | }
187 |
--------------------------------------------------------------------------------