├── .dockerignore
├── .github
└── workflows
│ ├── build.yaml
│ └── licensing.yaml
├── .gitignore
├── .idea
└── externalDependencies.xml
├── .run
└── console.run.xml
├── .well-known
├── funding-manifest-urls
└── funding-manifest-urls.license
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── LICENSES
├── AGPL-3.0-only.txt
└── Apache-2.0.txt
├── README.md
├── SECURITY.md
├── apksparser
├── .gitignore
├── build.gradle.kts
└── src
│ └── main
│ ├── kotlin
│ └── app
│ │ └── accrescent
│ │ └── parcelo
│ │ └── apksparser
│ │ ├── AndroidManifest.kt
│ │ ├── Apk.kt
│ │ ├── ApkSet.kt
│ │ ├── AppId.kt
│ │ ├── ManifestReader.kt
│ │ └── Util.kt
│ └── proto
│ ├── commands.proto
│ ├── config.proto
│ ├── device_targeting_config.proto
│ └── targeting.proto
├── build.gradle.kts
├── console
├── .gitignore
├── build.gradle.kts
└── src
│ ├── main
│ ├── kotlin
│ │ └── app
│ │ │ └── accrescent
│ │ │ └── parcelo
│ │ │ └── console
│ │ │ ├── Application.kt
│ │ │ ├── Config.kt
│ │ │ ├── Routing.kt
│ │ │ ├── data
│ │ │ ├── AccessControlList.kt
│ │ │ ├── App.kt
│ │ │ ├── Database.kt
│ │ │ ├── Draft.kt
│ │ │ ├── Edit.kt
│ │ │ ├── File.kt
│ │ │ ├── Icon.kt
│ │ │ ├── Listing.kt
│ │ │ ├── RejectionReason.kt
│ │ │ ├── Review.kt
│ │ │ ├── ReviewIssue.kt
│ │ │ ├── ReviewIssueGroup.kt
│ │ │ ├── Reviewer.kt
│ │ │ ├── Session.kt
│ │ │ ├── ToSerializable.kt
│ │ │ ├── Update.kt
│ │ │ ├── User.kt
│ │ │ ├── WhitelistedGitHubUser.kt
│ │ │ ├── baseline
│ │ │ │ ├── BaselineAccessControlList.kt
│ │ │ │ ├── BaselineApp.kt
│ │ │ │ ├── BaselineDraft.kt
│ │ │ │ ├── BaselineEdit.kt
│ │ │ │ ├── BaselineFile.kt
│ │ │ │ ├── BaselineIcon.kt
│ │ │ │ ├── BaselineListing.kt
│ │ │ │ ├── BaselineRejectionReason.kt
│ │ │ │ ├── BaselineReview.kt
│ │ │ │ ├── BaselineReviewIssue.kt
│ │ │ │ ├── BaselineReviewIssueGroup.kt
│ │ │ │ ├── BaselineReviewer.kt
│ │ │ │ ├── BaselineSession.kt
│ │ │ │ ├── BaselineUpdate.kt
│ │ │ │ ├── BaselineUser.kt
│ │ │ │ └── BaselineWhitelistedGitHubUser.kt
│ │ │ └── net
│ │ │ │ ├── ApiError.kt
│ │ │ │ ├── App.kt
│ │ │ │ ├── Draft.kt
│ │ │ │ ├── Edit.kt
│ │ │ │ ├── Update.kt
│ │ │ │ └── User.kt
│ │ │ ├── jobs
│ │ │ ├── CleanDeletedFiles.kt
│ │ │ ├── CleanFile.kt
│ │ │ ├── JobRunr.kt
│ │ │ ├── Publish.kt
│ │ │ └── PublishEdit.kt
│ │ │ ├── publish
│ │ │ ├── PublishService.kt
│ │ │ └── S3PublishService.kt
│ │ │ ├── repo
│ │ │ └── RepoData.kt
│ │ │ ├── routes
│ │ │ ├── Apps.kt
│ │ │ ├── Drafts.kt
│ │ │ ├── Edits.kt
│ │ │ ├── Health.kt
│ │ │ ├── Session.kt
│ │ │ ├── Updates.kt
│ │ │ └── auth
│ │ │ │ ├── Auth.kt
│ │ │ │ ├── GitHub.kt
│ │ │ │ └── Session.kt
│ │ │ ├── storage
│ │ │ ├── Common.kt
│ │ │ ├── GCSObjectStorageService.kt
│ │ │ ├── ObjectStorageService.kt
│ │ │ └── S3ObjectStorageService.kt
│ │ │ ├── util
│ │ │ └── TempFile.kt
│ │ │ └── validation
│ │ │ └── Review.kt
│ └── resources
│ │ ├── application.conf
│ │ ├── db
│ │ └── migration
│ │ │ └── README.md
│ │ └── logback.xml
│ └── test
│ └── kotlin
│ └── app
│ └── accrescent
│ └── parcelo
│ └── ApplicationTest.kt
├── gradle.properties
├── gradle
├── libs.versions.toml
├── verification-metadata.xml
└── wrapper
│ ├── gradle-wrapper.jar
│ ├── gradle-wrapper.jar.license
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── renovate.json
├── renovate.json.license
├── settings.gradle.kts
└── sonar-project.properties
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Logan Magee
2 | #
3 | # SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | *
6 |
7 | !/apksparser
8 | !/build.gradle.kts
9 | !/console
10 | !/gradle
11 | !/gradle.properties
12 | !/gradlew
13 | !/settings.gradle.kts
14 |
--------------------------------------------------------------------------------
/.github/workflows/build.yaml:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Logan Magee
2 | #
3 | # SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | name: Build
6 |
7 | on: [push, pull_request]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 | - uses: actions/setup-java@v4
15 | with:
16 | distribution: temurin
17 | java-version: 21
18 | - uses: arduino/setup-protoc@v3
19 | with:
20 | version: "28.x"
21 | repo-token: ${{ secrets.GITHUB_TOKEN }}
22 | - uses: gradle/actions/setup-gradle@v4
23 | - run: ./gradlew build
24 | - run: ./gradlew dokkaHtml
25 |
--------------------------------------------------------------------------------
/.github/workflows/licensing.yaml:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Logan Magee
2 | #
3 | # SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | name: Check licensing
6 |
7 | on: [push, pull_request]
8 |
9 | jobs:
10 | license-check:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 | - uses: fsfe/reuse-action@v4
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Logan Magee
2 | #
3 | # SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | /appdata
6 | *.db
7 | .env
8 | .gradle
9 | build/
10 | !gradle/wrapper/gradle-wrapper.jar
11 | !**/src/main/**/build/
12 | !**/src/test/**/build/
13 |
14 | ### STS ###
15 | .apt_generated
16 | .classpath
17 | .factorypath
18 | .project
19 | .settings
20 | .springBeans
21 | .sts4-cache
22 | bin/
23 | !**/src/main/**/bin/
24 | !**/src/test/**/bin/
25 |
26 | ### IntelliJ IDEA ###
27 | .idea/*
28 | !.idea/externalDependencies.xml
29 | *.iws
30 | *.iml
31 | *.ipr
32 | out/
33 | !**/src/main/**/out/
34 | !**/src/test/**/out/
35 |
36 | ### NetBeans ###
37 | /nbproject/private/
38 | /nbbuild/
39 | /dist/
40 | /nbdist/
41 | /.nb-gradle/
42 |
43 | ### VS Code ###
44 | .vscode/
45 |
46 | ### Misc
47 | .kotlin/
48 |
--------------------------------------------------------------------------------
/.idea/externalDependencies.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.run/console.run.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
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 |
--------------------------------------------------------------------------------
/.well-known/funding-manifest-urls:
--------------------------------------------------------------------------------
1 | https://accrescent.app/funding.json
2 |
--------------------------------------------------------------------------------
/.well-known/funding-manifest-urls.license:
--------------------------------------------------------------------------------
1 | Copyright 2023-2024 Logan Magee
2 |
3 | SPDX-License-Identifier: AGPL-3.0-only
4 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | # Contributing guidelines
8 |
9 | Thank you for your interest in contributing to Parcelo! Please read below for development setup
10 | steps.
11 |
12 | ## Development setup
13 |
14 | The recommended and supported development environment for Parcelo is Intellij IDEA (the community
15 | edition is fine). You will also need to install the following tools:
16 |
17 | - npm
18 | - protoc
19 | - minio
20 |
21 | This repository comes with convenience IDEA run configurations to ease development. However, you will
22 | need to configure a few things yourself. After installing IDEA and cloning the repository, follow the
23 | steps below to get started.
24 |
25 | 1. Open the project in IDEA and install the required EnvFile plugin as prompted.
26 | 2. Navigate to your [GitHub developer settings] and click "Register a new application."
27 | 3. Set the "Authorization callback URL" field to `http://localhost:8080/auth/github/callback`. Fill
28 | in the other fields with whatever you like.
29 | 4. Click "Register application."
30 | 5. Copy the client ID from the resulting page into a file named `.env` with the contents
31 | `GITHUB_OAUTH2_CLIENT_ID=${client_id}`, replacing `${client_id}` with your client ID.
32 | 6. Click "Generate a new client secret" and copy the secret into a new line in `.env` with the
33 | contents `GITHUB_OAUTH2_CLIENT_SECRET=${client_secret}`, replacing `${client_secret}` with your
34 | client secret.
35 | 7. Navigate to `https://api.github.com/users/${username}` where `${username}` is your GitHub
36 | username. Copy the `id` field into a new line in `.env` as `DEBUG_USER_GITHUB_ID=${id}` where
37 | `${id}` is the ID you copied.
38 | 8. Start MinIO with `minio server`
39 | 9. Log in to the MinIO console and create a new access key. Copy the access key ID and secret access
40 | key into `.env` as `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` respectively.
41 | 10. TODO: Add secrets for private storage bucket
42 |
43 | You should now be able to run the console in IDEA by selecting the "console" run configuration and
44 | running the project.
45 |
46 | The environment variables in the included IDEA run configurations may be modified as needed.
47 | However, we don't recommend doing this unless you know what you're doing. The defaults should work
48 | fine for most use cases.
49 |
50 | ## Contributor license agreement
51 |
52 | Contributing to Parcelo requires signing a Contributor License Agreement (CLA). To sign [Parcelo's
53 | CLA], make a PR, and our CLA bot will direct you.
54 |
55 | [GitHub developer settings]: https://github.com/settings/developers
56 | [Parcelo's CLA]: https://gist.github.com/lberrymage/21603f43c6c018001e31d441125ad5de
57 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Logan Magee
2 | #
3 | # SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | FROM eclipse-temurin:21-jdk AS cache
6 |
7 | ENV GRADLE_USER_HOME=/cache
8 | COPY apksparser/build.gradle.kts /app/apksparser/
9 | COPY console/build.gradle.kts /app/console/
10 | COPY gradle/ /app/gradle/
11 | COPY build.gradle.kts gradle.properties gradlew settings.gradle.kts /app/
12 | WORKDIR /app
13 | RUN ./gradlew clean build --no-daemon
14 |
15 | FROM eclipse-temurin:21-jdk AS builder
16 |
17 | ARG DEBIAN_FRONTEND=noninteractive
18 | ARG BUILD_SYSTEM=linux-x86_64
19 | ARG PROTOBUF_VERSION=28.3
20 |
21 | RUN apt-get update \
22 | && apt-get install -y --no-install-recommends unzip \
23 | && apt-get clean \
24 | && rm -rf /var/lib/apt/lists/*
25 | RUN curl -Lo protoc.zip \
26 | https://github.com/protocolbuffers/protobuf/releases/download/v$PROTOBUF_VERSION/protoc-$PROTOBUF_VERSION-$BUILD_SYSTEM.zip \
27 | && unzip -o protoc.zip -d /usr/local bin/protoc \
28 | && unzip -o protoc.zip -d /usr/local 'include/*' \
29 | && rm -f protoc.zip
30 |
31 | WORKDIR /build
32 | COPY --from=cache /cache /root/.gradle
33 | COPY . /build
34 | RUN ./gradlew clean buildFatJar --no-daemon
35 |
36 | FROM eclipse-temurin:21-jre
37 |
38 | WORKDIR /app
39 | COPY --from=builder /build/console/build/libs/console-all.jar parcelo-console.jar
40 | RUN groupadd -r parcelo-console && useradd --no-log-init -r -g parcelo-console parcelo-console
41 | USER parcelo-console
42 |
43 | EXPOSE 8080
44 |
45 | ENTRYPOINT ["java", "-jar", "parcelo-console.jar"]
46 |
--------------------------------------------------------------------------------
/LICENSES/Apache-2.0.txt:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
10 |
11 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
12 |
13 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
14 |
15 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
16 |
17 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
18 |
19 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
20 |
21 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
22 |
23 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
24 |
25 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
26 |
27 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
28 |
29 | 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
30 |
31 | 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
32 |
33 | 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
34 |
35 | (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
36 |
37 | (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
38 |
39 | (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
40 |
41 | (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
42 |
43 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
44 |
45 | 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
46 |
47 | 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
48 |
49 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
50 |
51 | 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
52 |
53 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
54 |
55 | END OF TERMS AND CONDITIONS
56 |
57 | APPENDIX: How to apply the Apache License to your work.
58 |
59 | To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
60 |
61 | Copyright [yyyy] [name of copyright owner]
62 |
63 | Licensed under the Apache License, Version 2.0 (the "License");
64 | you may not use this file except in compliance with the License.
65 | You may obtain a copy of the License at
66 |
67 | http://www.apache.org/licenses/LICENSE-2.0
68 |
69 | Unless required by applicable law or agreed to in writing, software
70 | distributed under the License is distributed on an "AS IS" BASIS,
71 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
72 | See the License for the specific language governing permissions and
73 | limitations under the License.
74 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | # Parcelo
8 |
9 | The next-generation Accrescent developer console.
10 |
11 | ## About
12 |
13 | Parcelo is the backend server technology that powers [Accrescent]. Parcelo currently features:
14 |
15 | - GitHub account login
16 | - User registration whitelisting
17 | - New app submissions, app updates, and description edits
18 | - Conditional manual reviews for new apps and app updates
19 | - Remote repository publishing
20 |
21 | ...and more.
22 |
23 | Something missing you think you could add? If you're interested in contributing, check out our
24 | [contributing guidelines] for instructions on setting up the development environment and other
25 | helpful information.
26 |
27 | [Accrescent]: https://accrescent.app
28 | [contributing guidelines]: CONTRIBUTING.md
29 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | # Security Policy
8 |
9 | ## Supported Versions
10 |
11 | Only the latest version of parcelo is supported.
12 |
13 | ## Reporting a vulnerability
14 |
15 | If you have a vulnerability to report, please email or
16 | DM Logan Magee on Matrix @lberrymage:matrix.org if you want end-to-end
17 | encryption.
18 |
--------------------------------------------------------------------------------
/apksparser/.gitignore:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Logan Magee
2 | #
3 | # SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | /build
6 |
--------------------------------------------------------------------------------
/apksparser/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | plugins {
6 | id(libs.plugins.java.library.get().pluginId)
7 | alias(libs.plugins.kotlin.jvm)
8 | alias(libs.plugins.protobuf)
9 | }
10 |
11 | kotlin {
12 | jvmToolchain(21)
13 | }
14 |
15 | dependencies {
16 | implementation(libs.apkanalyzer)
17 | implementation(libs.apksig)
18 | implementation(libs.jackson.xml)
19 | implementation(libs.jackson.kotlin)
20 | api(libs.protobuf)
21 | }
22 |
23 | kotlin {
24 | explicitApi()
25 | }
26 |
--------------------------------------------------------------------------------
/apksparser/src/main/kotlin/app/accrescent/parcelo/apksparser/AndroidManifest.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.apksparser
6 |
7 | import com.fasterxml.jackson.annotation.JsonCreator
8 | import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty
9 |
10 | public class AndroidManifest private constructor(
11 | public val `package`: AppId,
12 | public val versionCode: Int,
13 | public val versionName: String?,
14 | public val split: String?,
15 | public val application: Application,
16 | @JacksonXmlProperty(localName = "uses-sdk")
17 | public val usesSdk: UsesSdk?,
18 | ) {
19 | /**
20 | * @throws IllegalArgumentException the app ID is not well-formed
21 | */
22 | @JsonCreator
23 | private constructor(
24 | `package`: String,
25 | versionCode: Int,
26 | versionName: String?,
27 | split: String?,
28 | application: Application,
29 | usesSdk: UsesSdk?
30 | ) : this(
31 | AppId.parseFromString(`package`) ?: throw IllegalArgumentException(),
32 | versionCode,
33 | versionName,
34 | split,
35 | application,
36 | usesSdk,
37 | )
38 |
39 | @JacksonXmlProperty(localName = "uses-permission")
40 | public var usesPermissions: List? = null
41 | // Jackson workaround to prevent permission review bypasses. See
42 | // https://github.com/FasterXML/jackson-dataformat-xml/issues/275 for more information.
43 | private set(value) {
44 | field = field.orEmpty() + value.orEmpty()
45 | }
46 | }
47 |
48 | public class Action private constructor(public val name: String)
49 |
50 | public class Application private constructor(
51 | public val debuggable: Boolean?,
52 | public val testOnly: Boolean?,
53 | ) {
54 | @JacksonXmlProperty(localName = "service")
55 | public var services: List? = null
56 | // Jackson workaround to prevent review bypasses. See
57 | // https://github.com/FasterXML/jackson-dataformat-xml/issues/275 for more information.
58 | private set(value) {
59 | field = field.orEmpty() + value.orEmpty()
60 | }
61 | }
62 |
63 | public class IntentFilter private constructor() {
64 | @JacksonXmlProperty(localName = "action")
65 | public var actions: List = emptyList()
66 | // Jackson workaround to prevent review bypasses. See
67 | // https://github.com/FasterXML/jackson-dataformat-xml/issues/275 for more information.
68 | private set(value) {
69 | field += value
70 | }
71 | }
72 |
73 | public class Service private constructor() {
74 | @JacksonXmlProperty(localName = "intent-filter")
75 | public var intentFilters: List? = null
76 | // Jackson workaround to prevent review bypasses. See
77 | // https://github.com/FasterXML/jackson-dataformat-xml/issues/275 for more information.
78 | private set(value) {
79 | field = field.orEmpty() + value.orEmpty()
80 | }
81 | }
82 |
83 | public class UsesPermission private constructor(
84 | public val name: String,
85 | public val maxSdkVersion: String?,
86 | )
87 |
88 | public class UsesSdk private constructor(
89 | public val minSdkVersion: Int = 1,
90 | public val targetSdkVersion: Int?,
91 | )
92 |
--------------------------------------------------------------------------------
/apksparser/src/main/kotlin/app/accrescent/parcelo/apksparser/Apk.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.apksparser
6 |
7 | import com.android.apksig.ApkVerifier
8 | import com.android.apksig.apk.ApkFormatException
9 | import com.android.apksig.apk.ApkUtils
10 | import com.android.apksig.util.DataSources
11 | import com.android.apksig.zip.ZipFormatException
12 | import com.android.tools.apk.analyzer.BinaryXmlParser
13 | import com.fasterxml.jackson.databind.exc.ValueInstantiationException
14 | import java.nio.ByteBuffer
15 | import java.security.cert.X509Certificate
16 |
17 | /**
18 | * An Android application package (APK).
19 | *
20 | * This class can only represent a subset of APKs. That is, there exist valid APKs which cannot be
21 | * represented by this class, as it makes some additional security checks which are not strictly
22 | * necessary for validating the package. Specifically, this class can only represent APKs which:
23 | *
24 | * - are signed with a signature scheme version greater than v1
25 | * - are signed only by a non-debug certificate or certificates
26 | * - pass signature verification
27 | */
28 | public class Apk private constructor(
29 | public val manifest: AndroidManifest,
30 | public val signerCertificates: List,
31 | ) {
32 | public companion object {
33 | /**
34 | * Parses an APK from the provided data
35 | */
36 | public fun parse(data: ByteArray): ParseApkResult {
37 | val dataSource = DataSources.asDataSource(ByteBuffer.wrap(data))
38 |
39 | // Verify the APK's signature
40 | val sigCheckResult = try {
41 | ApkVerifier.Builder(dataSource).build().verify()
42 | } catch (e: ApkFormatException) {
43 | return when (e.cause) {
44 | is ZipFormatException -> ParseApkResult.Error.ZipFormatError
45 | else -> ParseApkResult.Error.ApkFormatError
46 | }
47 | }
48 | val signerCertificates = if (sigCheckResult.isVerified) {
49 | if (!(sigCheckResult.isVerifiedUsingModernScheme())) {
50 | return ParseApkResult.Error.SignatureVersionError
51 | } else if (sigCheckResult.signerCertificates.any { it.isDebug() }) {
52 | return ParseApkResult.Error.DebugCertificateError
53 | } else {
54 | sigCheckResult.signerCertificates
55 | }
56 | } else {
57 | return ParseApkResult.Error.SignatureVerificationError
58 | }
59 |
60 | // Parse the Android manifest
61 | val manifest = try {
62 | val manifestBytes = ApkUtils.getAndroidManifest(dataSource).moveToByteArray()
63 | val decodedManifest = BinaryXmlParser.decodeXml(manifestBytes)
64 | manifestReader.readValue(decodedManifest)
65 | } catch (e: ApkFormatException) {
66 | return ParseApkResult.Error.ApkFormatError
67 | } catch (e: ValueInstantiationException) {
68 | return ParseApkResult.Error.AndroidManifestError
69 | }
70 |
71 | return ParseApkResult.Ok(Apk(manifest, signerCertificates))
72 | }
73 | }
74 | }
75 |
76 | /**
77 | * Representation of the result of attempting to parse an APK
78 | */
79 | public sealed class ParseApkResult {
80 | /**
81 | * The result of successful parsing
82 | */
83 | public data class Ok(val apk: Apk) : ParseApkResult()
84 |
85 | /**
86 | * The result of failed parsing
87 | */
88 | public sealed class Error : ParseApkResult() {
89 | /**
90 | * A message describing the error
91 | */
92 | public abstract val message: String
93 |
94 | /**
95 | * The APK is not a well-formed ZIP archive
96 | */
97 | public data object ZipFormatError : Error() {
98 | override val message: String = "APK is not a well-formed ZIP archive"
99 | }
100 |
101 | /**
102 | * The APK is not well-formed. The specific reason is not specified.
103 | */
104 | public data object ApkFormatError : Error() {
105 | override val message: String = "APK is not well-formed"
106 | }
107 |
108 | /**
109 | * The APK was not signed with a required signature version
110 | */
111 | public data object SignatureVersionError : Error() {
112 | override val message: String = "APK not signed with a required signature version"
113 | }
114 |
115 | /**
116 | * The APK is signed with a debug certificate
117 | */
118 | public data object DebugCertificateError : Error() {
119 | override val message: String = "APK is signed with a debug certificate"
120 | }
121 |
122 | /**
123 | * Signature verification failed. In other words, the signature is invalid for this APK.
124 | */
125 | public data object SignatureVerificationError : Error() {
126 | override val message: String = "signature verification failed"
127 | }
128 |
129 | /**
130 | * The Android manifest is not valid
131 | */
132 | public data object AndroidManifestError : Error() {
133 | override val message: String = "invalid Android manifest"
134 | }
135 | }
136 | }
137 |
138 | /**
139 | * Returns whether the APK was signed with a "modern" signature scheme, i.e., a scheme of version
140 | * greater than v1.
141 | *
142 | * We require this because v1 signatures are
143 | * [insecure](https://source.android.com/docs/security/features/apksigning#v1) and because signature
144 | * scheme v2 or greater is
145 | * [required](https://developer.android.com/about/versions/11/behavior-changes-11#minimum-signature-scheme)
146 | * as of Android 11.
147 | */
148 | private fun ApkVerifier.Result.isVerifiedUsingModernScheme(): Boolean {
149 | return isVerifiedUsingV2Scheme || isVerifiedUsingV3Scheme || isVerifiedUsingV31Scheme
150 | }
151 |
152 | /**
153 | * Returns whether this is a debug certificate generated by the Android SDK tools
154 | */
155 | private fun X509Certificate.isDebug(): Boolean {
156 | return subjectX500Principal.name == "C=US,O=Android,CN=Android Debug"
157 | }
158 |
--------------------------------------------------------------------------------
/apksparser/src/main/kotlin/app/accrescent/parcelo/apksparser/AppId.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.apksparser
6 |
7 | /**
8 | * A well-formed Android application ID. Its [value] property is guaranteed to contain a valid
9 | * Android app ID.
10 | */
11 | public class AppId private constructor(public val value: String) {
12 | public companion object {
13 | /**
14 | * Parses the given string into an Android application ID according to
15 | * https://developer.android.com/studio/build/configure-app-module. Specifically, it
16 | * verifies:
17 | *
18 | * 1. The string contains two segments (one or more dots).
19 | * 2. Each segment starts with a letter.
20 | * 3. All characters are alphanumeric or an underscore.
21 | *
22 | * If any of these conditions are not met, this function returns null.
23 | */
24 | public fun parseFromString(s: String): AppId? {
25 | val segments = s.split(".")
26 | if (segments.size < 2) {
27 | return null
28 | }
29 |
30 | for (segment in segments) {
31 | when {
32 | segment.isEmpty() -> return null
33 | !segment[0].isLetter() -> return null
34 | !alphanumericUnderscoreRegex.matches(segment) -> return null
35 | }
36 | }
37 |
38 | return AppId(s)
39 | }
40 | }
41 |
42 | override fun equals(other: Any?): Boolean {
43 | return other is AppId && this.value == other.value
44 | }
45 |
46 | override fun hashCode(): Int {
47 | return value.hashCode()
48 | }
49 | }
50 |
51 | private val alphanumericUnderscoreRegex = Regex("""^[a-zA-Z0-9_]+$""")
52 |
--------------------------------------------------------------------------------
/apksparser/src/main/kotlin/app/accrescent/parcelo/apksparser/ManifestReader.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.apksparser
6 |
7 | import com.fasterxml.jackson.databind.DeserializationFeature
8 | import com.fasterxml.jackson.databind.ObjectReader
9 | import com.fasterxml.jackson.dataformat.xml.XmlMapper
10 | import com.fasterxml.jackson.module.kotlin.registerKotlinModule
11 |
12 | internal val manifestReader: ObjectReader = XmlMapper.builder()
13 | .defaultUseWrapper(false)
14 | .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
15 | .build()
16 | .registerKotlinModule()
17 | .readerFor(AndroidManifest::class.java)
18 |
--------------------------------------------------------------------------------
/apksparser/src/main/kotlin/app/accrescent/parcelo/apksparser/Util.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.apksparser
6 |
7 | import java.nio.ByteBuffer
8 |
9 | internal fun ByteBuffer.moveToByteArray(): ByteArray {
10 | val array = ByteArray(remaining())
11 | get(array)
12 | return array
13 | }
14 |
--------------------------------------------------------------------------------
/apksparser/src/main/proto/commands.proto:
--------------------------------------------------------------------------------
1 | // Copyright (C) The Android Open Source Project
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 |
5 | syntax = "proto3";
6 |
7 | package android.bundle;
8 |
9 | import "config.proto";
10 | import "device_targeting_config.proto";
11 | import "targeting.proto";
12 |
13 | option java_package = "com.android.bundle";
14 |
15 | // Describes the output of the "build-apks" command.
16 | message BuildApksResult {
17 | // The package name of this app.
18 | string package_name = 4;
19 |
20 | // List of the created variants.
21 | repeated Variant variant = 1;
22 |
23 | // Metadata about BundleTool used to build the APKs.
24 | Bundletool bundletool = 2;
25 |
26 | // List of the created asset slices.
27 | repeated AssetSliceSet asset_slice_set = 3;
28 |
29 | // Information about local testing mode.
30 | LocalTestingInfo local_testing_info = 5;
31 |
32 | // Asset modules metadata for asset only bundles.
33 | AssetModulesInfo asset_modules_info = 6;
34 |
35 | // Default values for targeting dimensions, as specified in the BundleConfig.
36 | // Only set for dimensions that have a default suffix specified.
37 | repeated DefaultTargetingValue default_targeting_value = 7;
38 |
39 | // Information about permanently fused install-time modules, which were
40 | // presented in original bundle but fused into base in all variants.
41 | repeated PermanentlyFusedModule permanently_fused_modules = 8;
42 |
43 | // Definitions for device groups and user country sets.
44 | // Only set if DeviceGroupConfig is supplied as bundle metadata.
45 | DeviceGroupConfig device_group_config = 9;
46 | }
47 |
48 | message BuildSdkApksResult {
49 | // The package name of the SDK.
50 | //
51 | // For instance, for SDK “com.foo.bar” with major version “15”,
52 | // the package name stored here is simply “com.foo.bar”.
53 | // This is different from the package name that is installed in Android
54 | // PackageManager on sandbox-enabled devices (which is “com.foo.bar_15”).
55 | string package_name = 1;
56 |
57 | // Variants generated for the SDK.
58 | // At the moment, there is always a single variant.
59 | repeated Variant variant = 2;
60 |
61 | Bundletool bundletool = 3;
62 |
63 | SdkVersionInformation version = 4;
64 | }
65 |
66 | message SdkVersionInformation {
67 | // Major version of the SDK.
68 | int32 major = 1;
69 |
70 | // Minor version of the SDK.
71 | int32 minor = 2;
72 |
73 | // Patch version of the SDK.
74 | int32 patch = 3;
75 |
76 | // A unique version code assigned to the SDK by the caller of build-sdk-apks.
77 | int32 version_code = 4;
78 | }
79 |
80 | // Variant is a group of APKs that covers a part of the device configuration
81 | // space. APKs from multiple variants are never combined on one device.
82 | message Variant {
83 | // Variant-level targeting.
84 | // This targeting is fairly high-level and each APK has its own targeting as
85 | // well.
86 | VariantTargeting targeting = 1;
87 |
88 | // Set of APKs, one set per module.
89 | repeated ApkSet apk_set = 2;
90 |
91 | // Number of the variant, starting at 0 (unless overridden).
92 | // A device will receive APKs from the first variant that matches the device
93 | // configuration, with higher variant numbers having priority over lower
94 | // variant numbers.
95 | uint32 variant_number = 3;
96 |
97 | // Extra information about the variant e.g. has uncompressed dex files or
98 | // uncompressed native libraries
99 | VariantProperties variant_properties = 4;
100 | }
101 |
102 | // Describes properties of a variant
103 | message VariantProperties {
104 | // Variant has uncompressed dex files
105 | bool uncompressed_dex = 1;
106 |
107 | // Variant has uncompressed native libraries
108 | bool uncompressed_native_libraries = 2;
109 |
110 | // Variant has sparse encoded resource tables
111 | bool sparse_encoding = 3;
112 | }
113 |
114 | // Describes the output of the "extract-apks" command.
115 | message ExtractApksResult {
116 | // Set of extracted APKs.
117 | repeated ExtractedApk apks = 1;
118 |
119 | // Information about the APKs if built with local testing enabled.
120 | LocalTestingInfoForMetadata local_testing_info = 2;
121 | }
122 |
123 | message LocalTestingInfoForMetadata {
124 | // The absolute path on the device that files targeted by local testing
125 | // mode will be pushed to.
126 | string local_testing_dir = 1;
127 | }
128 |
129 | // Describes extracted APK.
130 | message ExtractedApk {
131 | // Module name.
132 | string module_name = 1;
133 |
134 | // Path
135 | string path = 2;
136 |
137 | // Indicates the delivery type (e.g. on-demand) of the APK.
138 | DeliveryType delivery_type = 3;
139 | }
140 |
141 | // Represents a module.
142 | // For pre-L devices multiple modules (possibly all) may be merged into one.
143 | message ApkSet {
144 | ModuleMetadata module_metadata = 1;
145 |
146 | // APKs.
147 | repeated ApkDescription apk_description = 2;
148 | }
149 |
150 | message ModuleMetadata {
151 | // Module name.
152 | string name = 1;
153 |
154 | // Indicates the type of this feature module.
155 | FeatureModuleType module_type = 7;
156 |
157 | // Indicates the delivery type (e.g. on-demand) of the module.
158 | DeliveryType delivery_type = 6;
159 |
160 | // Indicates whether this module is marked "instant".
161 | bool is_instant = 3;
162 |
163 | // Names of the modules that this module directly depends on.
164 | // Each module implicitly depends on the base module.
165 | repeated string dependencies = 4;
166 |
167 | // The targeting that makes a conditional module installed.
168 | // Relevant only for Split APKs.
169 | ModuleTargeting targeting = 5;
170 |
171 | // Deprecated. Please use delivery_type.
172 | bool on_demand_deprecated = 2 [deprecated = true];
173 |
174 | // Runtime-enabled SDK dependencies of this module.
175 | repeated RuntimeEnabledSdkDependency runtime_enabled_sdk_dependencies = 8;
176 |
177 | // Information about the SDK that this module was generated from.
178 | // Only set for modules with module_type FeatureModuleType.SDK_MODULE.
179 | optional SdkModuleMetadata sdk_module_metadata = 9;
180 | }
181 |
182 | // Set of asset slices belonging to a single asset module.
183 | message AssetSliceSet {
184 | // Module level metadata.
185 | AssetModuleMetadata asset_module_metadata = 1;
186 |
187 | // Asset slices.
188 | repeated ApkDescription apk_description = 2;
189 | }
190 |
191 | message AssetModuleMetadata {
192 | // Module name.
193 | string name = 1;
194 |
195 | // Indicates the delivery type for persistent install.
196 | DeliveryType delivery_type = 4;
197 |
198 | // Metadata for instant installs.
199 | InstantMetadata instant_metadata = 3;
200 |
201 | // Deprecated. Use delivery_type.
202 | bool on_demand_deprecated = 2 [deprecated = true];
203 |
204 | // Type of asset module.
205 | AssetModuleType asset_module_type = 5;
206 |
207 | // Used for conditional delivery of asset modules which controls the overall
208 | // delivery of asset modules as a single boolean.
209 | // Note that this is different from the AssetsDirectoryTargeting on the asset
210 | // slice level. That targeting decides which slice/variant of the asset module
211 | // to serve.
212 | AssetModuleTargeting targeting = 6;
213 | }
214 |
215 | message InstantMetadata {
216 | // Indicates whether this module is marked "instant".
217 | bool is_instant = 1;
218 |
219 | // Indicates the delivery type for instant install.
220 | DeliveryType delivery_type = 3;
221 |
222 | // Deprecated. Use delivery_type.
223 | bool on_demand_deprecated = 2 [deprecated = true];
224 | }
225 |
226 | enum DeliveryType {
227 | UNKNOWN_DELIVERY_TYPE = 0;
228 | INSTALL_TIME = 1;
229 | ON_DEMAND = 2;
230 | FAST_FOLLOW = 3;
231 | }
232 |
233 | enum FeatureModuleType {
234 | UNKNOWN_MODULE_TYPE = 0;
235 | FEATURE_MODULE = 1;
236 | ML_MODULE = 2;
237 | SDK_MODULE = 3;
238 | }
239 |
240 | enum AssetModuleType {
241 | UNKNOWN_ASSET_TYPE = 0;
242 | DEFAULT_ASSET_TYPE = 1;
243 | AI_PACK_TYPE = 2;
244 | }
245 |
246 | // This message name is misleading, as it's not exclusively used to describe
247 | // APKs. It's also used to describe slices of asset modules, which aren't always
248 | // APKs.
249 | message ApkDescription {
250 | ApkTargeting targeting = 1;
251 |
252 | // Path to the APK file.
253 | string path = 2;
254 |
255 | oneof apk_metadata_oneof_value {
256 | // Set only for Split APKs.
257 | SplitApkMetadata split_apk_metadata = 3;
258 | // Set only for standalone APKs.
259 | StandaloneApkMetadata standalone_apk_metadata = 4;
260 | // Set only for Instant split APKs.
261 | SplitApkMetadata instant_apk_metadata = 5;
262 | // Set only for system APKs.
263 | SystemApkMetadata system_apk_metadata = 6;
264 | // Set only for asset slices.
265 | SplitApkMetadata asset_slice_metadata = 7;
266 | // Set only for APEX APKs.
267 | ApexApkMetadata apex_apk_metadata = 8;
268 | // Set only for archived APKs.
269 | ArchivedApkMetadata archived_apk_metadata = 9;
270 | }
271 |
272 | SigningDescription signing_description = 10;
273 | }
274 |
275 | // Holds data specific to signing configuration applied on the APKs.
276 | message SigningDescription {
277 | // Denotes if the generated APK to be signed with the rotated key.
278 | bool signed_with_rotated_key = 1;
279 | }
280 |
281 | // Holds data specific to Split APKs.
282 | message SplitApkMetadata {
283 | string split_id = 1;
284 |
285 | // Indicates whether this APK is the master split of the module.
286 | bool is_master_split = 2;
287 | }
288 |
289 | // Holds data specific to Standalone APKs.
290 | message StandaloneApkMetadata {
291 | // Names of the modules fused in this standalone APK.
292 | repeated string fused_module_name = 1;
293 |
294 | reserved 2;
295 | }
296 |
297 | // Holds data specific to system APKs.
298 | message SystemApkMetadata {
299 | // Names of the modules fused in this system APK.
300 | repeated string fused_module_name = 1;
301 | // Was "system_apk_type".
302 | reserved 2;
303 | }
304 |
305 | // Holds data specific to APEX APKs.
306 | message ApexApkMetadata {
307 | // Configuration for processing of APKs embedded in an APEX image.
308 | repeated ApexEmbeddedApkConfig apex_embedded_apk_config = 1;
309 | }
310 |
311 | // Holds data specific to Archived APKs.
312 | message ArchivedApkMetadata {}
313 |
314 | message LocalTestingInfo {
315 | // Indicates if the bundle is built in local testing mode.
316 | bool enabled = 1;
317 | // The local testing path, as specified in the base manifest.
318 | // This refers to the relative path on the external directory of the app where
319 | // APKs will be pushed for local testing.
320 | // Set only if local testing is enabled.
321 | string local_testing_path = 2;
322 | }
323 |
324 | // Holds metadata for asset only bundles.
325 | message AssetModulesInfo {
326 | // App versionCodes that will be updated with these asset modules.
327 | // Only relevant for asset-only bundles.
328 | repeated int64 app_version = 1;
329 |
330 | // Version tag for the asset upload.
331 | // Only relevant for asset-only bundles.
332 | string asset_version_tag = 2;
333 | }
334 |
335 | // Default value targeted by a particular dimension.
336 | message DefaultTargetingValue {
337 | // The dimension being targeted.
338 | SplitDimension.Value dimension = 1;
339 |
340 | // The default value being targeted.
341 | string default_value = 2;
342 | }
343 |
344 | message PermanentlyFusedModule {
345 | // Module name.
346 | string name = 1;
347 | }
348 |
349 | // Describes a runtime-enabled SDK that the app depends on.
350 | message RuntimeEnabledSdkDependency {
351 | // Package name of the runtime-enabled SDK.
352 | // Required.
353 | string package_name = 1;
354 | // Major version of the runtime-enabled SDK.
355 | // Required.
356 | int32 major_version = 2;
357 | // Minor version of the runtime-enabled SDK.
358 | // Required.
359 | int32 minor_version = 3;
360 | }
361 |
362 | // Metadata of the app module generated from a runtime-enabled SDK that the app
363 | // depends on.
364 | message SdkModuleMetadata {
365 | // Version of the Runtime-enabled SDK that this module was generated from.
366 | // Required.
367 | optional SdkModuleVersion sdk_module_version = 1;
368 |
369 | // Package name of the runtime-enabled SDK that this module was generated
370 | // from.
371 | // Required.
372 | optional string sdk_package_name = 2;
373 |
374 | // Package ID of the resources of this module. Can have values between 2-255.
375 | // Required.
376 | optional int32 resources_package_id = 3;
377 | }
378 |
379 | // Versioning information about the SDK that the app module was generated from.
380 | message SdkModuleVersion {
381 | // Major version of the SDK.
382 | // Required.
383 | optional int32 major = 1;
384 | // Minor version of the SDK.
385 | // Required.
386 | optional int32 minor = 2;
387 | // Patch version of the SDK.
388 | // Required.
389 | optional int32 patch = 3;
390 | }
391 |
392 |
--------------------------------------------------------------------------------
/apksparser/src/main/proto/config.proto:
--------------------------------------------------------------------------------
1 | // Copyright (C) The Android Open Source Project
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 |
5 | syntax = "proto3";
6 |
7 | package android.bundle;
8 |
9 | option java_package = "com.android.bundle";
10 |
11 | message BundleConfig {
12 | Bundletool bundletool = 1;
13 | Optimizations optimizations = 2;
14 | Compression compression = 3;
15 | // Resources to be always kept in the master split.
16 | MasterResources master_resources = 4;
17 | ApexConfig apex_config = 5;
18 | // APKs to be signed with the same key as generated APKs.
19 | repeated UnsignedEmbeddedApkConfig unsigned_embedded_apk_config = 6;
20 | AssetModulesConfig asset_modules_config = 7;
21 |
22 | enum BundleType {
23 | REGULAR = 0;
24 | APEX = 1;
25 | ASSET_ONLY = 2;
26 | }
27 | BundleType type = 8;
28 |
29 | // Configuration for locales.
30 | Locales locales = 9;
31 | }
32 |
33 | message Bundletool {
34 | reserved 1;
35 | // Version of BundleTool used to build the Bundle.
36 | string version = 2;
37 | }
38 |
39 | message Compression {
40 | // Glob matching the list of files to leave uncompressed in the APKs.
41 | // The matching is done against the path of files in the APK, thus excluding
42 | // the name of the modules, and using forward slash ("/") as a name separator.
43 | // Examples: "res/raw/**", "assets/**/*.uncompressed", etc.
44 | repeated string uncompressed_glob = 1;
45 |
46 | enum AssetModuleCompression {
47 | UNSPECIFIED = 0;
48 | // Assets are left uncompressed in the generated asset module.
49 | UNCOMPRESSED = 1;
50 | // Assets are compressed in the generated asset module.
51 | // This option can be overridden at a finer granularity by specifying
52 | // files or folders to keep uncompressed in `uncompressed_glob`.
53 | // This option should only be used if the app is able to handle compressed
54 | // asset module content at runtime (some runtime APIs may misbehave).
55 | COMPRESSED = 2;
56 | }
57 |
58 | // Default compression strategy for install-time asset modules.
59 | // If the compression strategy indicates to compress a file and the same file
60 | // matches one of the `uncompressed_glob` values, the `uncompressed_glob`
61 | // takes precedence (the file is left uncompressed in the generated APK).
62 | //
63 | // If unspecified, asset module content is left uncompressed in the
64 | // generated asset modules.
65 | //
66 | // Note: this flag only configures the compression strategy for install-time
67 | // asset modules; the content of on-demand and fast-follow asset modules is
68 | // always kept uncompressed.
69 | AssetModuleCompression install_time_asset_module_default_compression = 2;
70 |
71 | enum ApkCompressionAlgorithm {
72 | // Default in the current version of bundletool is zlib deflate algorithm
73 | // with compression level 9 for the application's resources and compression
74 | // level 6 for other entries.
75 | //
76 | // This is a good trade-off between size of final APK and size of patches
77 | // which are used to update the application from previous to next version.
78 | DEFAULT_APK_COMPRESSION_ALGORITHM = 0;
79 |
80 | // 7zip implementation of deflate algorithm which gives smaller APK size
81 | // but size of patches required to update the application are larger.
82 | P7ZIP = 1;
83 | }
84 |
85 | // Compression algorithm which is used to compress entries in final APKs.
86 | ApkCompressionAlgorithm apk_compression_algorithm = 3;
87 | }
88 |
89 | // Resources to keep in the master split.
90 | message MasterResources {
91 | // Resource IDs to be kept in master split.
92 | repeated int32 resource_ids = 1;
93 | // Resource names to be kept in master split.
94 | repeated string resource_names = 2;
95 | }
96 |
97 | message Optimizations {
98 | SplitsConfig splits_config = 1;
99 | // This is for uncompressing native libraries on M+ devices (L+ devices on
100 | // instant apps).
101 | UncompressNativeLibraries uncompress_native_libraries = 2;
102 | // This is for uncompressing dex files.
103 | UncompressDexFiles uncompress_dex_files = 3;
104 | // Configuration for the generation of standalone APKs.
105 | // If no StandaloneConfig is set, the configuration is inherited from
106 | // splits_config.
107 | StandaloneConfig standalone_config = 4;
108 |
109 | // Optimizations that are applied to resources.
110 | ResourceOptimizations resource_optimizations = 5;
111 |
112 | // Configuration for archiving the app.
113 | StoreArchive store_archive = 6;
114 | }
115 |
116 | message ResourceOptimizations {
117 | enum SparseEncoding {
118 | // Previously 'ENFORCED'. This option is deprecated because of issues found
119 | // in Android O up to Android Sv2 and causes segfaults in
120 | // Resources#getIdentifier.
121 | reserved 1;
122 | reserved "ENFORCED";
123 |
124 | // Disables sparse encoding.
125 | UNSPECIFIED = 0;
126 | // Generates special APKs for Android SDK +32 with sparse resource tables.
127 | // Devices with Android SDK below 32 will still receive APKs with regular
128 | // resource tables.
129 | VARIANT_FOR_SDK_32 = 2;
130 | }
131 |
132 | // Pair of resource type and name, like 'layout/foo', 'string/bar'.
133 | message ResourceTypeAndName {
134 | string type = 1;
135 | string name = 2;
136 | }
137 |
138 | // Optimizations related to collapsed resource names.
139 | message CollapsedResourceNames {
140 | // Whether to collapse resource names.
141 | // Resources with collapsed resource names are only accessible by their
142 | // ids. These names are not stored inside 'resources.arsc'.
143 | bool collapse_resource_names = 1;
144 |
145 | // Instructs to not collapse resource names for specific resources which
146 | // makes certain resources to be accessible by their names.
147 | //
148 | // Applicable only if 'collapse_resource_names' is 'true'.
149 | repeated ResourceTypeAndName no_collapse_resources = 2;
150 |
151 | // Instructs to not collapse all resources of certain types.
152 | //
153 | // Applicable only if 'collapse_resource_names' is 'true'.
154 | repeated string no_collapse_resource_types = 4;
155 |
156 | // Whether to store only unique resource entries in 'resources.arsc'.
157 | //
158 | // For example if there are 3 'bool' resources defined with 'true' value
159 | // with this flag only one 'true' entry is stored and all 3 resources
160 | // are referencing this entry. By default all 3 entries are stored.
161 | //
162 | // This only works with resources where names are collapsed (either using
163 | // 'collapse_resource_names' flag or manually) because resource name is a
164 | // part of resource entry and if names are preserved - all entries are
165 | // unique.
166 | bool deduplicate_resource_entries = 3;
167 | }
168 |
169 | // Whether to use sparse encoding for resource tables.
170 | // Resources in sparse resource table are accessed using a binary search tree.
171 | // This decreases APK size at the cost of resource retrieval performance.
172 | SparseEncoding sparse_encoding = 1;
173 |
174 | // Optimizations related to collapsed resource names.
175 | CollapsedResourceNames collapsed_resource_names = 2;
176 | }
177 |
178 | message UncompressNativeLibraries {
179 | bool enabled = 1;
180 |
181 | enum PageAlignment {
182 | PAGE_ALIGNMENT_UNSPECIFIED = 0;
183 | PAGE_ALIGNMENT_4K = 1;
184 | PAGE_ALIGNMENT_16K = 2;
185 | PAGE_ALIGNMENT_64K = 3;
186 | }
187 |
188 | // This is an experimental setting. It's behavior might be changed or
189 | // completely removed.
190 | //
191 | // Alignment used for uncompressed native libraries inside APKs generated
192 | // by bundletool.
193 | //
194 | // PAGE_ALIGNMENT_4K by default.
195 | PageAlignment alignment = 2;
196 | }
197 |
198 | message UncompressDexFiles {
199 | // A new variant with uncompressed dex will be generated. The sdk targeting
200 | // of the variant is determined by 'uncompressed_dex_target_sdk'.
201 | bool enabled = 1;
202 |
203 | // If 'enabled' field is set, this will determine the sdk targeting of the
204 | // generated variant.
205 | UncompressedDexTargetSdk uncompressed_dex_target_sdk = 2;
206 |
207 | enum UncompressedDexTargetSdk {
208 | // Q+ variant will be generated.
209 | UNSPECIFIED = 0;
210 | // S+ variant will be generated.
211 | SDK_31 = 1;
212 | reserved 2;
213 | }
214 | }
215 |
216 | message StoreArchive {
217 | // Archive is an app state that allows an official app store to reclaim device
218 | // storage and disable app functionality temporarily until the user interacts
219 | // with the app again. Upon interaction the latest available version of the
220 | // app will be restored while leaving user data unaffected.
221 | // Enabled by default.
222 | bool enabled = 1;
223 | }
224 |
225 | message Locales {
226 | // Instructs bundletool to generate locale config and inject it into
227 | // AndroidManifest.xml. A locale is marked as supported by the application if
228 | // there is at least one resource value in this locale. Be very careful with
229 | // this setting because if some of your libraries expose resources in some
230 | // locales which are not actually supported by your application it will mark
231 | // this locale as supported. Disabled by default.
232 | bool inject_locale_config = 1;
233 | }
234 |
235 | // Optimization configuration used to generate Split APKs.
236 | message SplitsConfig {
237 | repeated SplitDimension split_dimension = 1;
238 | }
239 |
240 | // Optimization configuration used to generate Standalone APKs.
241 | message StandaloneConfig {
242 | // Device targeting dimensions to shard.
243 | repeated SplitDimension split_dimension = 1;
244 | // Whether 64 bit libraries should be stripped from Standalone APKs.
245 | bool strip_64_bit_libraries = 2;
246 | // Dex merging strategy that should be applied to produce Standalone APKs.
247 | DexMergingStrategy dex_merging_strategy = 3;
248 |
249 | enum DexMergingStrategy {
250 | // Strategy that does dex merging for applications that have minimum SDK
251 | // below 21 to ensure dex files from all modules are merged into one or
252 | // mainDexList is applied when merging into one dex is not possible. For
253 | // applications with minSdk >= 21 dex files from all modules are copied into
254 | // standalone APK as is because Android supports multiple dex files natively
255 | // starting from Android 5.0.
256 | MERGE_IF_NEEDED = 0;
257 | // Requires to copy dex files from all modules into standalone APK as is.
258 | // If an application supports SDKs below 21 this strategy puts
259 | // responsibility of providing dex files compatible with legacy multidex on
260 | // application developers.
261 | NEVER_MERGE = 1;
262 | }
263 |
264 | // Defines how to deal with feature modules in standalone variants (minSdk <
265 | // 21).
266 | FeatureModulesMode feature_modules_mode = 4;
267 |
268 | enum FeatureModulesMode {
269 | // Default mode which fuses feature modules with respect to its
270 | // fusing attribute into base.apk.
271 | FUSED_FEATURE_MODULES = 0;
272 | // Advanced mode, which allows to generate a single separate apk per each
273 | // feature module in variants with minSdk < 21.
274 | SEPARATE_FEATURE_MODULES = 1;
275 | }
276 | }
277 |
278 | message SplitDimension {
279 | enum Value {
280 | UNSPECIFIED_VALUE = 0;
281 | ABI = 1;
282 | SCREEN_DENSITY = 2;
283 | LANGUAGE = 3;
284 | TEXTURE_COMPRESSION_FORMAT = 4;
285 | DEVICE_TIER = 6 [deprecated = true];
286 | COUNTRY_SET = 7;
287 | AI_MODEL_VERSION = 8;
288 | DEVICE_GROUP = 9;
289 | }
290 | Value value = 1;
291 |
292 | // If set to 'true', indicates that APKs should *not* be split by this
293 | // dimension.
294 | bool negate = 2;
295 |
296 | // Optional transformation to be applied to asset directories where
297 | // the targeting is encoded in the directory name (e.g: assets/foo#tcf_etc1)
298 | SuffixStripping suffix_stripping = 3;
299 | }
300 |
301 | message SuffixStripping {
302 | // If set to 'true', indicates that the targeting suffix should be removed
303 | // from assets paths for this dimension when splits (e.g: "asset packs") or
304 | // standalone/universal APKs are generated.
305 | // This only applies to assets.
306 | // For example a folder with path "assets/level1_textures#tcf_etc1"
307 | // would be outputted to "assets/level1_textures". File contents are
308 | // unchanged.
309 | bool enabled = 1;
310 |
311 | // The default suffix to be used for the cases where separate slices can't
312 | // be generated for this dimension - typically for standalone or universal
313 | // APKs.
314 | // This default suffix defines the directories to retain. The others are
315 | // discarded: standalone/universal APKs will contain only directories
316 | // targeted at this value for the dimension.
317 | //
318 | // If not set or empty, the fallback directory in each directory group will be
319 | // used (for example, if both "assets/level1_textures#tcf_etc1" and
320 | // "assets/level1_textures" are present and the default suffix is empty,
321 | // then only "assets/level1_textures" will be used).
322 | string default_suffix = 2;
323 | }
324 |
325 | // Configuration for processing APEX bundles.
326 | // https://source.android.com/devices/tech/ota/apex
327 | message ApexConfig {
328 | // Configuration for processing of APKs embedded in an APEX image.
329 | repeated ApexEmbeddedApkConfig apex_embedded_apk_config = 1;
330 |
331 | // Explicit list of supported ABIs.
332 | // Default: See ApexBundleValidator.REQUIRED_ONE_OF_ABI_SETS
333 | repeated SupportedAbiSet supported_abi_set = 2;
334 | }
335 |
336 | // Represents a set of ABIs which must be supported by a single APEX image.
337 | message SupportedAbiSet {
338 | repeated string abi = 1;
339 | }
340 |
341 | message ApexEmbeddedApkConfig {
342 | // Android package name of the APK.
343 | string package_name = 1;
344 |
345 | // Path to the APK within the APEX system image.
346 | string path = 2;
347 | }
348 |
349 | message UnsignedEmbeddedApkConfig {
350 | // Path to the APK inside the module (e.g. if the path inside the bundle
351 | // is split/assets/example.apk, this will be assets/example.apk).
352 | string path = 1;
353 | }
354 |
355 | message AssetModulesConfig {
356 | // App versionCodes that will be updated with these asset modules.
357 | // Only relevant for asset-only bundles.
358 | repeated int64 app_version = 1;
359 |
360 | // Version tag for the asset upload.
361 | // Only relevant for asset-only bundles.
362 | string asset_version_tag = 2;
363 | }
364 |
--------------------------------------------------------------------------------
/apksparser/src/main/proto/device_targeting_config.proto:
--------------------------------------------------------------------------------
1 | // Copyright (C) The Android Open Source Project
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 |
5 | syntax = "proto3";
6 |
7 | package android.bundle;
8 |
9 | option java_package = "com.android.bundle";
10 | option java_multiple_files = true;
11 |
12 | // Configuration describing device targeting criteria for the content of an app.
13 | // Supplied in the App Bundle as bundle metadata.
14 | message DeviceGroupConfig {
15 | // Definition of device groups for the app.
16 | repeated DeviceGroup device_groups = 1;
17 | }
18 |
19 | // Configuration describing device targeting criteria for the content of an app.
20 | message DeviceTierConfig {
21 | // Definition of device groups for the app.
22 | repeated DeviceGroup device_groups = 1;
23 |
24 | // Definition of the set of device tiers for the app.
25 | DeviceTierSet device_tier_set = 2;
26 |
27 | // Definition of user country sets for the app.
28 | repeated UserCountrySet user_country_sets = 5;
29 | }
30 |
31 | // A group of devices.
32 | //
33 | // A group is defined by a set of device selectors. A device belongs to the
34 | // group if it matches any selector (logical OR).
35 | message DeviceGroup {
36 | // The name of the group.
37 | string name = 1;
38 |
39 | // Device selectors for this group. A device matching any of the selectors
40 | // is included in this group.
41 | repeated DeviceSelector device_selectors = 2;
42 | }
43 |
44 | // A set of device tiers.
45 | //
46 | // A tier set determines what variation of app content gets served to a specific
47 | // device, for device-targeted content.
48 | //
49 | // You should assign a priority level to each tier, which determines the
50 | // ordering by which they are evaluated by Play. See the documentation of
51 | // DeviceTier.level for more details.
52 | message DeviceTierSet {
53 | // Device tiers belonging to the set.
54 | repeated DeviceTier device_tiers = 1;
55 | }
56 |
57 | // A set of user countries.
58 | //
59 | // A country set determines what variation of app content gets served to a
60 | // specific location.
61 | message UserCountrySet {
62 | // Country set name.
63 | string name = 1;
64 |
65 | // List of country codes representing countries.
66 | // A Country code is represented in ISO 3166 alpha-2 format.
67 | // For Example:- "IT" for Italy, "GE" for Georgia.
68 | repeated string country_codes = 2;
69 | }
70 |
71 | // A single device tier.
72 | //
73 | // Devices matching any of the device groups in device_group_names are
74 | // considered to match the tier.
75 | message DeviceTier {
76 | // Groups of devices included in this tier.
77 | // These groups must be defined explicitly under device_groups in this
78 | // configuration.
79 | repeated string device_group_names = 1;
80 |
81 | // The priority level of the tier.
82 | //
83 | // Tiers are evaluated in descending order of level: the highest level tier
84 | // has the highest priority. The highest tier matching a given device is
85 | // selected for that device.
86 | //
87 | // You should use a contiguous range of levels for your tiers in a tier set;
88 | // tier levels in a tier set must be unique.
89 | // For instance, if your tier set has 4 tiers (including the global fallback),
90 | // you should define tiers 1, 2 and 3 in this configuration.
91 | //
92 | // Note: tier 0 is implicitly defined as a global fallback and selected for
93 | // devices that don't match any of the tiers explicitly defined here. You
94 | // mustn't define level 0 explicitly in this configuration.
95 | int32 level = 2;
96 | }
97 |
98 | // Selector for a device group.
99 | // A selector consists of a set of conditions on the device that should all
100 | // match (logical AND) to determine a device group eligibility.
101 | //
102 | // For instance, if a selector specifies RAM conditions, device model inclusion
103 | // and device model exclusion, a device is considered to match if:
104 | // device matches RAM conditions
105 | // AND
106 | // device matches one of the included device models
107 | // AND
108 | // device doesn't match excluded device models
109 | message DeviceSelector {
110 | // Conditions on the device's RAM.
111 | DeviceRam device_ram = 1;
112 |
113 | // Device models included by this selector.
114 | repeated DeviceId included_device_ids = 2;
115 |
116 | // Device models excluded by this selector, even if they match all other
117 | // conditions.
118 | repeated DeviceId excluded_device_ids = 3;
119 |
120 | // A device needs to have all these system features to be
121 | // included by the selector.
122 | repeated SystemFeature required_system_features = 4;
123 |
124 | // A device that has any of these system features is excluded by
125 | // this selector, even if it matches all other conditions.
126 | repeated SystemFeature forbidden_system_features = 5;
127 |
128 | // The SoCs included by this selector.
129 | // Only works for Android S+ devices.
130 | repeated SystemOnChip system_on_chips = 6;
131 | }
132 |
133 | // Conditions about a device's RAM capabilities.
134 | message DeviceRam {
135 | // Minimum RAM in bytes (bound included).
136 | int64 min_bytes = 1;
137 |
138 | // Maximum RAM in bytes (bound excluded).
139 | int64 max_bytes = 2;
140 | }
141 |
142 | // Identifier of a device.
143 | message DeviceId {
144 | // Value of Build.BRAND.
145 | string build_brand = 1;
146 |
147 | // Value of Build.DEVICE.
148 | string build_device = 2;
149 | }
150 |
151 | // Representation of a system feature.
152 | message SystemFeature {
153 | // The name of the feature.
154 | string name = 1;
155 | }
156 |
157 | // Representation of a System-on-Chip (SoC) of an Android device.
158 | // Can be used to target S+ devices.
159 | message SystemOnChip {
160 | // The designer of the SoC, eg. "Google"
161 | // Value of build property "ro.soc.manufacturer"
162 | // https://developer.android.com/reference/android/os/Build#SOC_MANUFACTURER
163 | // Required.
164 | string manufacturer = 1;
165 |
166 | // The model of the SoC, eg. "Tensor"
167 | // Value of build property "ro.soc.model"
168 | // https://developer.android.com/reference/android/os/Build#SOC_MODEL
169 | // Required.
170 | string model = 2;
171 | }
172 |
173 | // Properties of a particular device.
174 | message DeviceProperties {
175 | // Device RAM in bytes.
176 | int64 ram = 1;
177 |
178 | // Device ID of device.
179 | DeviceId device_id = 2;
180 |
181 | // System features in device.
182 | repeated SystemFeature system_features = 3;
183 |
184 | // SoC
185 | SystemOnChip system_on_chip = 4;
186 | }
187 |
--------------------------------------------------------------------------------
/apksparser/src/main/proto/targeting.proto:
--------------------------------------------------------------------------------
1 | // Copyright (C) The Android Open Source Project
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 |
5 | syntax = "proto3";
6 |
7 | package android.bundle;
8 |
9 | import "google/protobuf/wrappers.proto";
10 |
11 | option java_package = "com.android.bundle";
12 |
13 | // Targeting on the level of variants.
14 | message VariantTargeting {
15 | SdkVersionTargeting sdk_version_targeting = 1;
16 | AbiTargeting abi_targeting = 2;
17 | ScreenDensityTargeting screen_density_targeting = 3;
18 | MultiAbiTargeting multi_abi_targeting = 4;
19 | TextureCompressionFormatTargeting texture_compression_format_targeting = 5;
20 | SdkRuntimeTargeting sdk_runtime_targeting = 6;
21 | }
22 |
23 | // Targeting on the level of individual APKs.
24 | message ApkTargeting {
25 | AbiTargeting abi_targeting = 1;
26 | reserved 2; // was GraphicsApiTargeting
27 | LanguageTargeting language_targeting = 3;
28 | ScreenDensityTargeting screen_density_targeting = 4;
29 | SdkVersionTargeting sdk_version_targeting = 5;
30 | TextureCompressionFormatTargeting texture_compression_format_targeting = 6;
31 | MultiAbiTargeting multi_abi_targeting = 7;
32 | SanitizerTargeting sanitizer_targeting = 8;
33 | DeviceTierTargeting device_tier_targeting = 9 [deprecated = true];
34 | CountrySetTargeting country_set_targeting = 10 [deprecated = true];
35 | DeviceGroupTargeting device_group_targeting = 11;
36 | }
37 |
38 | // Targeting on the module level.
39 | // Used for conditional feature modules.
40 | // The semantic of the targeting is the "AND" rule on all immediate values.
41 | message ModuleTargeting {
42 | SdkVersionTargeting sdk_version_targeting = 1;
43 | repeated DeviceFeatureTargeting device_feature_targeting = 2;
44 | UserCountriesTargeting user_countries_targeting = 3;
45 | DeviceGroupModuleTargeting device_group_targeting = 5;
46 |
47 | reserved 4;
48 | }
49 |
50 | // Targeting for conditionally delivered AssetModules.
51 | // It's not used for variant-based targeting of AssetModules, see
52 | // AssetsDirectoryTargeting instead.
53 | // The semantic of the targeting is the "AND" rule on all immediate values.
54 | message AssetModuleTargeting {
55 | UserCountriesTargeting user_countries_targeting = 1;
56 | DeviceGroupModuleTargeting device_group_targeting = 2;
57 | }
58 |
59 | // User Countries targeting describing an inclusive/exclusive list of country
60 | // codes that module targets.
61 | message UserCountriesTargeting {
62 | // List of country codes in the two-letter CLDR territory format.
63 | repeated string country_codes = 1;
64 |
65 | // Indicates if the list above is exclusive.
66 | bool exclude = 2;
67 | }
68 |
69 | message ScreenDensity {
70 | enum DensityAlias {
71 | DENSITY_UNSPECIFIED = 0;
72 | NODPI = 1;
73 | LDPI = 2;
74 | MDPI = 3;
75 | TVDPI = 4;
76 | HDPI = 5;
77 | XHDPI = 6;
78 | XXHDPI = 7;
79 | XXXHDPI = 8;
80 | }
81 |
82 | oneof density_oneof {
83 | DensityAlias density_alias = 1;
84 | int32 density_dpi = 2;
85 | }
86 | }
87 |
88 | message SdkVersion {
89 | // Inclusive.
90 | google.protobuf.Int32Value min = 1;
91 | }
92 |
93 | message TextureCompressionFormat {
94 | enum TextureCompressionFormatAlias {
95 | UNSPECIFIED_TEXTURE_COMPRESSION_FORMAT = 0;
96 | ETC1_RGB8 = 1;
97 | PALETTED = 2;
98 | THREE_DC = 3;
99 | ATC = 4;
100 | LATC = 5;
101 | DXT1 = 6;
102 | S3TC = 7;
103 | PVRTC = 8;
104 | ASTC = 9;
105 | ETC2 = 10;
106 | }
107 | TextureCompressionFormatAlias alias = 1;
108 | }
109 |
110 | message Abi {
111 | enum AbiAlias {
112 | UNSPECIFIED_CPU_ARCHITECTURE = 0;
113 | ARMEABI = 1;
114 | ARMEABI_V7A = 2;
115 | ARM64_V8A = 3;
116 | X86 = 4;
117 | X86_64 = 5;
118 | MIPS = 6;
119 | MIPS64 = 7;
120 | RISCV64 = 8;
121 | }
122 | AbiAlias alias = 1;
123 | }
124 |
125 | message MultiAbi {
126 | repeated Abi abi = 1;
127 | }
128 |
129 | message Sanitizer {
130 | enum SanitizerAlias {
131 | NONE = 0;
132 | HWADDRESS = 1;
133 | }
134 | SanitizerAlias alias = 1;
135 | }
136 |
137 | message DeviceFeature {
138 | string feature_name = 1;
139 | // Equivalent of android:glEsVersion or android:version in .
140 | int32 feature_version = 2;
141 | }
142 |
143 | // Targeting specific for directories under assets/.
144 | message AssetsDirectoryTargeting {
145 | AbiTargeting abi = 1;
146 | reserved 2; // was GraphicsApiTargeting
147 | TextureCompressionFormatTargeting texture_compression_format = 3;
148 | LanguageTargeting language = 4;
149 | DeviceTierTargeting device_tier = 5 [deprecated = true];
150 | CountrySetTargeting country_set = 6 [deprecated = true];
151 | DeviceGroupTargeting device_group = 7;
152 | }
153 |
154 | // Targeting specific for directories under lib/.
155 | message NativeDirectoryTargeting {
156 | Abi abi = 1;
157 | reserved 2; // was GraphicsApi
158 | TextureCompressionFormat texture_compression_format = 3;
159 | Sanitizer sanitizer = 4;
160 | }
161 |
162 | // Targeting specific for image files under apex/.
163 | message ApexImageTargeting {
164 | MultiAbiTargeting multi_abi = 1;
165 | }
166 |
167 | message AbiTargeting {
168 | repeated Abi value = 1;
169 | // Targeting of other sibling directories that were in the Bundle.
170 | // For master splits this is targeting of other master splits.
171 | repeated Abi alternatives = 2;
172 | }
173 |
174 | message MultiAbiTargeting {
175 | repeated MultiAbi value = 1;
176 | // Targeting of other sibling directories that were in the Bundle.
177 | // For master splits this is targeting of other master splits.
178 | repeated MultiAbi alternatives = 2;
179 | }
180 |
181 | message ScreenDensityTargeting {
182 | repeated ScreenDensity value = 1;
183 | // Targeting of other sibling directories that were in the Bundle.
184 | // For master splits this is targeting of other master splits.
185 | repeated ScreenDensity alternatives = 2;
186 | }
187 |
188 | message LanguageTargeting {
189 | // ISO-639: 2 or 3 letter language code.
190 | repeated string value = 1;
191 | // Targeting of other sibling directories that were in the Bundle.
192 | // For master splits this is targeting of other master splits.
193 | repeated string alternatives = 2;
194 | }
195 |
196 | message SdkVersionTargeting {
197 | repeated SdkVersion value = 1;
198 | // Targeting of other sibling directories that were in the Bundle.
199 | // For master splits this is targeting of other master splits.
200 | repeated SdkVersion alternatives = 2;
201 | }
202 |
203 | message TextureCompressionFormatTargeting {
204 | repeated TextureCompressionFormat value = 1;
205 | // Targeting of other sibling directories that were in the Bundle.
206 | // For master splits this is targeting of other master splits.
207 | repeated TextureCompressionFormat alternatives = 2;
208 | }
209 |
210 | message SanitizerTargeting {
211 | repeated Sanitizer value = 1;
212 | }
213 |
214 | // Since other atom targeting messages have the "OR" semantic on values
215 | // the DeviceFeatureTargeting represents only one device feature to retain
216 | // that convention.
217 | message DeviceFeatureTargeting {
218 | DeviceFeature required_feature = 1;
219 | }
220 |
221 | // Targets assets and APKs to a concrete device tier.
222 | message DeviceTierTargeting {
223 | repeated google.protobuf.Int32Value value = 3;
224 | repeated google.protobuf.Int32Value alternatives = 4;
225 |
226 | reserved 1, 2;
227 | }
228 |
229 | // Targets assets and APKs to a specific country set.
230 | // For Example:-
231 | // The values and alternatives for the following files in assets directory
232 | // targeting would be as follows:
233 | // assetpack1/assets/foo#countries_latam/bar.txt ->
234 | // { value: [latam], alternatives: [sea] }
235 | // assetpack1/assets/foo#countries_sea/bar.txt ->
236 | // { value: [sea], alternatives: [latam] }
237 | // assetpack1/assets/foo/bar.txt ->
238 | // { value: [], alternatives: [sea, latam] }
239 | // The values and alternatives for the following targeted split apks would be as
240 | // follows:
241 | // splits/base-countries_latam.apk ->
242 | // { value: [latam], alternatives: [sea] }
243 | // splits/base-countries_sea.apk ->
244 | // { value: [sea], alternatives: [latam] }
245 | // splits/base-other_countries.apk ->
246 | // { value: [], alternatives: [sea, latam] }
247 | message CountrySetTargeting {
248 | // Country set name defined in device tier config.
249 | repeated string value = 1;
250 | // Targeting of other sibling directories that were in the Bundle.
251 | repeated string alternatives = 2;
252 | }
253 |
254 | // Targets assets and APKs to a concrete device group.
255 | message DeviceGroupTargeting {
256 | // Device group name defined in device tier config.
257 | repeated string value = 1;
258 | // Targeting of other sibling directories that are in the Bundle.
259 | repeated string alternatives = 2;
260 | }
261 |
262 | // Targets conditional modules to a set of device groups.
263 | message DeviceGroupModuleTargeting {
264 | repeated string value = 1;
265 | }
266 |
267 | // Variant targeting based on SDK Runtime availability on device.
268 | message SdkRuntimeTargeting {
269 | // Whether the variant requires SDK Runtime to be available on the device.
270 | bool requires_sdk_runtime = 1;
271 | }
272 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | plugins {
6 | alias(libs.plugins.dokka) apply false
7 | alias(libs.plugins.kotlin.jvm) apply false
8 | alias(libs.plugins.kotlin.serialization) apply false
9 | alias(libs.plugins.ksp) apply false
10 | alias(libs.plugins.ktor) apply false
11 | alias(libs.plugins.protobuf) apply false
12 | }
13 |
--------------------------------------------------------------------------------
/console/.gitignore:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Logan Magee
2 | #
3 | # SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | /build
6 |
--------------------------------------------------------------------------------
/console/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | import org.jetbrains.dokka.gradle.DokkaTask
6 |
7 | plugins {
8 | alias(libs.plugins.kotlin.jvm)
9 | alias(libs.plugins.kotlin.serialization)
10 | alias(libs.plugins.ksp)
11 | alias(libs.plugins.ktor)
12 | alias(libs.plugins.dokka)
13 | }
14 |
15 | group = "app.accrescent"
16 | version = "0.11.0"
17 |
18 | application {
19 | mainClass.set("app.accrescent.parcelo.console.ApplicationKt")
20 |
21 | val isDevelopment: Boolean = project.ext.has("development")
22 | applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
23 | }
24 |
25 | kotlin {
26 | jvmToolchain(21)
27 | }
28 |
29 | ksp {
30 | arg("KOIN_CONFIG_CHECK", "true")
31 | }
32 |
33 | dependencies {
34 | // Workaround for regressed Jackson 2.18.0 being pulled in by GCS library
35 | implementation("com.fasterxml.jackson:jackson-bom") {
36 | version { strictly("2.17.2") }
37 | }
38 | implementation("com.fasterxml.jackson.core:jackson-annotations") {
39 | version { strictly("2.17.2") }
40 | }
41 | implementation("com.fasterxml.jackson.core:jackson-core") {
42 | version { strictly("2.17.2") }
43 | }
44 | implementation("com.fasterxml.jackson.core:jackson-databind") {
45 | version { strictly("2.17.2") }
46 | }
47 | implementation("com.fasterxml.jackson.dataformat:jackson-dataformats-text") {
48 | version { strictly("2.17.2") }
49 | }
50 | implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-toml") {
51 | version { strictly("2.17.2") }
52 | }
53 | implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml") {
54 | version { strictly("2.17.2") }
55 | }
56 | implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") {
57 | version { strictly("2.17.2") }
58 | }
59 | implementation("com.fasterxml.jackson.module:jackson-modules-java8") {
60 | version { strictly("2.17.2") }
61 | }
62 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin") {
63 | version { strictly("2.17.2") }
64 | }
65 | implementation(project(":apksparser"))
66 | implementation(libs.s3)
67 | implementation(libs.exposed.core)
68 | implementation(libs.exposed.dao)
69 | implementation(libs.exposed.jdbc)
70 | implementation(libs.flyway)
71 | implementation(libs.flyway.postgresql)
72 | implementation(libs.github)
73 | implementation(platform(libs.google.cloud.bom))
74 | implementation(libs.google.cloud.storage)
75 | implementation(libs.jobrunr)
76 | implementation(libs.koin.ktor)
77 | implementation(libs.koin.logger)
78 | implementation(libs.ktor.client)
79 | implementation(libs.ktor.serialization)
80 | implementation(libs.ktor.server.auth)
81 | implementation(libs.ktor.server.core)
82 | implementation(libs.ktor.server.cors)
83 | implementation(libs.ktor.server.negotiation)
84 | implementation(libs.ktor.server.netty)
85 | implementation(libs.ktor.server.resources)
86 | implementation(libs.logback)
87 | implementation(libs.postgresql)
88 | testImplementation(libs.ktor.server.tests)
89 | testImplementation(libs.kotlin.test)
90 | }
91 |
92 | tasks.withType().configureEach {
93 | failOnWarning.set(true)
94 |
95 | dokkaSourceSets {
96 | configureEach {
97 | reportUndocumented.set(true)
98 |
99 | perPackageOption {
100 | // FIXME(#494): Document console and remove this exclusion
101 | matchingRegex.set("""app\.accrescent\.parcelo\.console(?:\.(?:data(?:\.baseline)?(?:\.net)?|routes))?""")
102 | reportUndocumented.set(false)
103 | }
104 | }
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/Application.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console
6 |
7 | import app.accrescent.parcelo.console.data.configureDatabase
8 | import app.accrescent.parcelo.console.jobs.configureJobRunr
9 | import app.accrescent.parcelo.console.publish.PublishService
10 | import app.accrescent.parcelo.console.publish.S3PublishService
11 | import app.accrescent.parcelo.console.routes.auth.configureAuthentication
12 | import app.accrescent.parcelo.console.storage.GCSObjectStorageService
13 | import app.accrescent.parcelo.console.storage.ObjectStorageService
14 | import app.accrescent.parcelo.console.storage.S3ObjectStorageService
15 | import aws.smithy.kotlin.runtime.net.url.Url
16 | import io.ktor.client.HttpClient
17 | import io.ktor.client.plugins.HttpTimeout
18 | import io.ktor.http.HttpHeaders
19 | import io.ktor.http.HttpMethod
20 | import io.ktor.serialization.kotlinx.json.json
21 | import io.ktor.server.application.Application
22 | import io.ktor.server.application.install
23 | import io.ktor.server.application.log
24 | import io.ktor.server.netty.EngineMain
25 | import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
26 | import io.ktor.server.plugins.cors.routing.CORS
27 | import kotlinx.serialization.ExperimentalSerializationApi
28 | import kotlinx.serialization.json.Json
29 | import org.koin.dsl.module
30 | import org.koin.ktor.ext.inject
31 | import org.koin.ktor.plugin.Koin
32 | import org.koin.logger.slf4jLogger
33 |
34 | private const val POSTGRESQL_DEFAULT_SERVER_NAME = "localhost"
35 | private const val POSTGRESQL_DEFAULT_DATABASE_NAME = "postgres"
36 | private const val POSTGRESQL_DEFAULT_PORT = 5432
37 | private const val POSTGRESQL_DEFAULT_USER = "postgres"
38 | private const val POSTGRESQL_DEFAULT_SSL_MODE = "verify-full"
39 |
40 | fun main(args: Array) = EngineMain.main(args)
41 |
42 | @OptIn(ExperimentalSerializationApi::class)
43 | fun Application.module() {
44 | log.info("Starting Parcelo console 0.11.0")
45 |
46 | val config = Config(
47 | application = Config.Application(
48 | baseUrl = System.getenv("BASE_URL"),
49 | ),
50 | cors = Config.Cors(
51 | allowedHost = System.getenv("CORS_ALLOWED_HOST"),
52 | allowedScheme = System.getenv("CORS_ALLOWED_SCHEME"),
53 | ),
54 | postgresql = Config.Postgresql(
55 | serverName = System.getenv("POSTGRESQL_SERVER_NAME")
56 | ?: POSTGRESQL_DEFAULT_SERVER_NAME,
57 | databaseName = System.getenv("POSTGRESQL_DATABASE_NAME")
58 | ?: POSTGRESQL_DEFAULT_DATABASE_NAME,
59 | portNumber = System.getenv("POSTGRESQL_PORT_NUMBER")?.toInt()
60 | ?: POSTGRESQL_DEFAULT_PORT,
61 | user = System.getenv("POSTGRESQL_USER") ?: POSTGRESQL_DEFAULT_USER,
62 | password = System.getenv("POSTGRESQL_PASSWORD"),
63 | sslMode = System.getenv("POSTGRESQL_SSL_MODE") ?: POSTGRESQL_DEFAULT_SSL_MODE,
64 | ),
65 | privateStorage = System.getenv("PRIVATE_STORAGE_BACKEND")?.let {
66 | when (it) {
67 | "GCS" -> Config.ObjectStorage.GCS(
68 | projectId = System.getenv("GCS_PROJECT_ID"),
69 | bucket = System.getenv("PRIVATE_STORAGE_BUCKET"),
70 | )
71 |
72 | "S3" -> Config.ObjectStorage.S3(
73 | endpointUrl = System.getenv("PRIVATE_STORAGE_ENDPOINT_URL"),
74 | region = System.getenv("PRIVATE_STORAGE_REGION"),
75 | bucket = System.getenv("PRIVATE_STORAGE_BUCKET"),
76 | accessKeyId = System.getenv("PRIVATE_STORAGE_ACCESS_KEY_ID"),
77 | secretAccessKey = System.getenv("PRIVATE_STORAGE_SECRET_ACCESS_KEY"),
78 | )
79 |
80 | else ->
81 | throw Exception("invalid private storage backend $it; must be one of [GCS, S3]")
82 | }
83 | } ?: throw Exception("PRIVATE_STORAGE_BACKEND is not specified in the environment"),
84 | s3 = Config.S3(
85 | endpointUrl = System.getenv("S3_ENDPOINT_URL"),
86 | region = System.getenv("S3_REGION"),
87 | bucket = System.getenv("S3_BUCKET"),
88 | accessKeyId = System.getenv("S3_ACCESS_KEY_ID"),
89 | secretAccessKey = System.getenv("S3_SECRET_ACCESS_KEY"),
90 | ),
91 | github = Config.GitHub(
92 | clientId = System.getenv("GITHUB_OAUTH2_CLIENT_ID")
93 | ?: throw Exception("GITHUB_OAUTH2_CLIENT_ID not specified in environment"),
94 | clientSecret = System.getenv("GITHUB_OAUTH2_CLIENT_SECRET")
95 | ?: throw Exception("GITHUB_OAUTH2_CLIENT_SECRET not specified in environment"),
96 | redirectUrl = System.getenv("GITHUB_OAUTH2_REDIRECT_URL"),
97 | ),
98 | )
99 |
100 | install(Koin) {
101 | slf4jLogger()
102 |
103 | val mainModule = module {
104 | single { config }
105 | single {
106 | when (config.privateStorage) {
107 | is Config.ObjectStorage.GCS -> GCSObjectStorageService(
108 | projectId = config.privateStorage.projectId,
109 | bucket = config.privateStorage.bucket,
110 | )
111 |
112 | is Config.ObjectStorage.S3 -> S3ObjectStorageService(
113 | Url.parse(config.privateStorage.endpointUrl),
114 | config.privateStorage.region,
115 | config.privateStorage.bucket,
116 | config.privateStorage.accessKeyId,
117 | config.privateStorage.secretAccessKey,
118 | )
119 | }
120 | }
121 | single { HttpClient { install(HttpTimeout) } }
122 | single {
123 | S3PublishService(
124 | Url.parse(config.s3.endpointUrl),
125 | config.s3.region,
126 | config.s3.bucket,
127 | config.s3.accessKeyId,
128 | config.s3.secretAccessKey,
129 | )
130 | }
131 | }
132 |
133 | modules(mainModule)
134 | }
135 | val httpClient: HttpClient by inject()
136 |
137 | install(ContentNegotiation) {
138 | json(Json {
139 | explicitNulls = false
140 | })
141 | }
142 | install(CORS) {
143 | allowCredentials = true
144 |
145 | allowHost(config.cors.allowedHost, schemes = listOf(config.cors.allowedScheme))
146 | allowHeader(HttpHeaders.ContentType)
147 | allowMethod(HttpMethod.Delete)
148 | allowMethod(HttpMethod.Patch)
149 | allowMethod(HttpMethod.Post)
150 | }
151 | configureJobRunr(configureDatabase())
152 | configureAuthentication(
153 | githubClientId = config.github.clientId,
154 | githubClientSecret = config.github.clientSecret,
155 | githubRedirectUrl = config.github.redirectUrl,
156 | httpClient = httpClient,
157 | )
158 | configureRouting()
159 | }
160 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/Config.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console
6 |
7 | data class Config(
8 | val application: Application,
9 | val cors: Cors,
10 | val postgresql: Postgresql,
11 | val privateStorage: ObjectStorage,
12 | val s3: S3,
13 | val github: GitHub,
14 | ) {
15 | data class Application(val baseUrl: String)
16 |
17 | data class Cors(val allowedHost: String, val allowedScheme: String)
18 |
19 | data class Postgresql(
20 | val serverName: String,
21 | val databaseName: String,
22 | val portNumber: Int,
23 | val user: String,
24 | val password: String,
25 | val sslMode: String,
26 | )
27 |
28 | sealed class ObjectStorage {
29 | data class GCS(val projectId: String, val bucket: String) : ObjectStorage()
30 | data class S3(
31 | val endpointUrl: String,
32 | val region: String,
33 | val bucket: String,
34 | val accessKeyId: String,
35 | val secretAccessKey: String,
36 | ) : ObjectStorage()
37 | }
38 |
39 | data class S3(
40 | val endpointUrl: String,
41 | val region: String,
42 | val bucket: String,
43 | val accessKeyId: String,
44 | val secretAccessKey: String,
45 | )
46 |
47 | data class GitHub(
48 | val clientId: String,
49 | val clientSecret: String,
50 | val redirectUrl: String,
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/Routing.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console
6 |
7 | import app.accrescent.parcelo.console.routes.appRoutes
8 | import app.accrescent.parcelo.console.routes.auth.authRoutes
9 | import app.accrescent.parcelo.console.routes.draftRoutes
10 | import app.accrescent.parcelo.console.routes.editRoutes
11 | import app.accrescent.parcelo.console.routes.healthRoutes
12 | import app.accrescent.parcelo.console.routes.sessionRoutes
13 | import app.accrescent.parcelo.console.routes.updateRoutes
14 | import io.ktor.server.application.Application
15 | import io.ktor.server.application.install
16 | import io.ktor.server.resources.Resources
17 | import io.ktor.server.routing.route
18 | import io.ktor.server.routing.routing
19 |
20 | fun Application.configureRouting() {
21 | install(Resources)
22 |
23 | routing {
24 | authRoutes()
25 | healthRoutes()
26 |
27 | route("/api/v1") {
28 | sessionRoutes()
29 |
30 | appRoutes()
31 | draftRoutes()
32 | editRoutes()
33 | updateRoutes()
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/AccessControlList.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import org.jetbrains.exposed.dao.IntEntity
8 | import org.jetbrains.exposed.dao.IntEntityClass
9 | import org.jetbrains.exposed.dao.id.EntityID
10 | import org.jetbrains.exposed.dao.id.IntIdTable
11 | import org.jetbrains.exposed.sql.ReferenceOption
12 |
13 | object AccessControlLists : IntIdTable("access_control_lists") {
14 | val userId = reference("user_id", Users, ReferenceOption.CASCADE)
15 | val appId = reference("app_id", Apps, ReferenceOption.CASCADE)
16 | val update = bool("update").default(false)
17 | val editMetadata = bool("edit_metadata").default(false)
18 |
19 | init {
20 | uniqueIndex(userId, appId)
21 | }
22 | }
23 |
24 | class AccessControlList(id: EntityID) : IntEntity(id) {
25 | companion object : IntEntityClass(AccessControlLists)
26 |
27 | var userId by AccessControlLists.userId
28 | var appId by AccessControlLists.appId
29 | var update by AccessControlLists.update
30 | var editMetadata by AccessControlLists.editMetadata
31 | }
32 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/App.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import app.accrescent.parcelo.console.data.net.App as SerializableApp
8 | import org.jetbrains.exposed.dao.Entity
9 | import org.jetbrains.exposed.dao.EntityClass
10 | import org.jetbrains.exposed.dao.id.EntityID
11 | import org.jetbrains.exposed.dao.id.IdTable
12 | import org.jetbrains.exposed.sql.ReferenceOption
13 | import org.jetbrains.exposed.sql.and
14 |
15 | object Apps : IdTable("apps") {
16 | override val id = text("id").entityId()
17 | val versionCode = integer("version_code")
18 | val versionName = text("version_name")
19 | val fileId = reference("file_id", Files, ReferenceOption.NO_ACTION)
20 | val reviewIssueGroupId =
21 | reference("review_issue_group_id", ReviewIssueGroups, ReferenceOption.NO_ACTION).nullable()
22 | val updating = bool("updating").default(false)
23 | val repositoryMetadata = blob("repository_metadata")
24 | override val primaryKey = PrimaryKey(id)
25 | }
26 |
27 | class App(id: EntityID) : Entity(id), ToSerializable {
28 | companion object : EntityClass(Apps)
29 |
30 | var versionCode by Apps.versionCode
31 | var versionName by Apps.versionName
32 | var fileId by Apps.fileId
33 | var reviewIssueGroupId by Apps.reviewIssueGroupId
34 | var updating by Apps.updating
35 | var repositoryMetadata by Apps.repositoryMetadata
36 |
37 | override fun serializable(): SerializableApp {
38 | // Use en-US locale by default
39 | val listing =
40 | Listing.find { Listings.appId eq id and (Listings.locale eq "en-US") }.single()
41 |
42 | return SerializableApp(
43 | id.value,
44 | listing.label,
45 | versionCode,
46 | versionName,
47 | listing.shortDescription,
48 | )
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/Database.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import app.accrescent.parcelo.console.Config
8 | import app.accrescent.parcelo.console.data.baseline.BaselineAccessControlLists
9 | import app.accrescent.parcelo.console.data.baseline.BaselineApps
10 | import app.accrescent.parcelo.console.data.baseline.BaselineDrafts
11 | import app.accrescent.parcelo.console.data.baseline.BaselineEdits
12 | import app.accrescent.parcelo.console.data.baseline.BaselineFiles
13 | import app.accrescent.parcelo.console.data.baseline.BaselineIcons
14 | import app.accrescent.parcelo.console.data.baseline.BaselineListings
15 | import app.accrescent.parcelo.console.data.baseline.BaselineRejectionReasons
16 | import app.accrescent.parcelo.console.data.baseline.BaselineReviewIssueGroups
17 | import app.accrescent.parcelo.console.data.baseline.BaselineReviewIssues
18 | import app.accrescent.parcelo.console.data.baseline.BaselineReviewers
19 | import app.accrescent.parcelo.console.data.baseline.BaselineReviews
20 | import app.accrescent.parcelo.console.data.baseline.BaselineSessions
21 | import app.accrescent.parcelo.console.data.baseline.BaselineUpdates
22 | import app.accrescent.parcelo.console.data.baseline.BaselineUsers
23 | import app.accrescent.parcelo.console.data.baseline.BaselineWhitelistedGitHubUsers
24 | import io.ktor.server.application.Application
25 | import org.flywaydb.core.Flyway
26 | import org.jetbrains.exposed.sql.Database
27 | import org.jetbrains.exposed.sql.SchemaUtils
28 | import org.jetbrains.exposed.sql.insertIgnore
29 | import org.jetbrains.exposed.sql.insertIgnoreAndGetId
30 | import org.jetbrains.exposed.sql.transactions.transaction
31 | import org.koin.ktor.ext.inject
32 | import org.postgresql.ds.PGSimpleDataSource
33 | import javax.sql.DataSource
34 |
35 | fun Application.configureDatabase(): DataSource {
36 | val config: Config by inject()
37 |
38 | val dataSource = PGSimpleDataSource().apply {
39 | serverNames = arrayOf(config.postgresql.serverName)
40 | databaseName = config.postgresql.databaseName
41 | portNumbers = intArrayOf(config.postgresql.portNumber)
42 | user = config.postgresql.user
43 | password = config.postgresql.password
44 | sslMode = config.postgresql.sslMode
45 | }
46 | Database.connect(dataSource)
47 |
48 | transaction {
49 | SchemaUtils.create(
50 | BaselineAccessControlLists,
51 | BaselineApps,
52 | BaselineDrafts,
53 | BaselineEdits,
54 | BaselineFiles,
55 | BaselineIcons,
56 | BaselineListings,
57 | BaselineRejectionReasons,
58 | BaselineReviewers,
59 | BaselineReviewIssues,
60 | BaselineReviewIssueGroups,
61 | BaselineReviews,
62 | BaselineSessions,
63 | BaselineUpdates,
64 | BaselineUsers,
65 | BaselineWhitelistedGitHubUsers,
66 | )
67 |
68 | if (environment.developmentMode) {
69 | // Create a default superuser
70 | val debugUserGitHubId = (System.getenv("DEBUG_USER_GITHUB_ID")
71 | ?: throw Exception("DEBUG_USER_GITHUB_ID not specified in environment")).toLong()
72 | val userId = Users.insertIgnoreAndGetId {
73 | it[githubUserId] = debugUserGitHubId
74 | it[email] = System.getenv("DEBUG_USER_EMAIL")
75 | it[publisher] = true
76 | } ?: User.find { Users.githubUserId eq debugUserGitHubId }.singleOrNull()!!.id
77 |
78 | Reviewers.insertIgnore {
79 | it[this.userId] = userId
80 | it[email] = System.getenv("DEBUG_USER_REVIEWER_EMAIL")
81 | }
82 | WhitelistedGitHubUsers.insertIgnore { it[id] = debugUserGitHubId }
83 | }
84 | }
85 |
86 | Flyway
87 | .configure()
88 | .dataSource(dataSource)
89 | .baselineOnMigrate(true)
90 | .mixed(true)
91 | .validateMigrationNaming(true)
92 | .load()
93 | .migrate()
94 |
95 | return dataSource
96 | }
97 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/Draft.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import app.accrescent.parcelo.console.data.net.Draft as SerializableDraft
8 | import app.accrescent.parcelo.console.data.net.DraftStatus
9 | import org.jetbrains.exposed.dao.UUIDEntity
10 | import org.jetbrains.exposed.dao.UUIDEntityClass
11 | import org.jetbrains.exposed.dao.id.EntityID
12 | import org.jetbrains.exposed.dao.id.UUIDTable
13 | import org.jetbrains.exposed.sql.ReferenceOption
14 | import org.jetbrains.exposed.sql.and
15 | import org.jetbrains.exposed.sql.not
16 | import org.jetbrains.exposed.sql.transactions.transaction
17 | import java.util.UUID
18 |
19 | // This is a UUID table because the ID is exposed to unprivileged API consumers. We don't want to
20 | // leak e.g. the total number of drafts.
21 | object Drafts : UUIDTable("drafts") {
22 | val appId = text("app_id")
23 | val label = text("label")
24 | val versionCode = integer("version_code")
25 | val versionName = text("version_name")
26 | val shortDescription = text("short_description").default("")
27 | val creatorId = reference("creator_id", Users, ReferenceOption.CASCADE)
28 | val creationTime = long("creation_time").clientDefault { System.currentTimeMillis() / 1000 }
29 | val fileId = reference("file_id", Files, ReferenceOption.NO_ACTION)
30 | val iconId = reference("icon_id", Icons, ReferenceOption.NO_ACTION)
31 | val reviewerId = reference("reviewer_id", Reviewers).nullable()
32 | val reviewIssueGroupId =
33 | reference("review_issue_group_id", ReviewIssueGroups, ReferenceOption.NO_ACTION).nullable()
34 | val reviewId = reference("review_id", Reviews, ReferenceOption.NO_ACTION).nullable()
35 | val publishing = bool("publishing").default(false)
36 |
37 | init {
38 | // Drafts can't be reviewed without being submitted (which is equivalent to having a
39 | // reviewer assigned) first
40 | check { not(reviewId.isNotNull() and reviewerId.isNull()) }
41 | }
42 | }
43 |
44 | class Draft(id: EntityID) : UUIDEntity(id), ToSerializable {
45 | companion object : UUIDEntityClass(Drafts)
46 |
47 | var appId by Drafts.appId
48 | var label by Drafts.label
49 | var versionCode by Drafts.versionCode
50 | var versionName by Drafts.versionName
51 | var shortDescription by Drafts.shortDescription
52 | var creatorId by Drafts.creatorId
53 | val creationTime by Drafts.creationTime
54 | var fileId by Drafts.fileId
55 | var iconId by Drafts.iconId
56 | var reviewerId by Drafts.reviewerId
57 | var reviewIssueGroupId by Drafts.reviewIssueGroupId
58 | var reviewId by Drafts.reviewId
59 | var publishing by Drafts.publishing
60 |
61 | override fun serializable(): SerializableDraft {
62 | val status = if (reviewerId == null) {
63 | DraftStatus.UNSUBMITTED
64 | } else if (reviewId == null) {
65 | DraftStatus.SUBMITTED
66 | } else {
67 | if (publishing) {
68 | DraftStatus.PUBLISHING
69 | } else {
70 | val review = transaction { Review.findById(reviewId!!)!! }
71 | if (review.approved) {
72 | DraftStatus.APPROVED
73 | } else {
74 | DraftStatus.REJECTED
75 | }
76 | }
77 | }
78 |
79 | return SerializableDraft(
80 | id.value.toString(),
81 | appId,
82 | label,
83 | versionCode,
84 | versionName,
85 | shortDescription,
86 | creationTime,
87 | status,
88 | )
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/Edit.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import app.accrescent.parcelo.console.data.net.Edit as SerializableEdit
8 | import app.accrescent.parcelo.console.data.net.EditStatus
9 | import org.jetbrains.exposed.dao.UUIDEntity
10 | import org.jetbrains.exposed.dao.UUIDEntityClass
11 | import org.jetbrains.exposed.dao.id.EntityID
12 | import org.jetbrains.exposed.dao.id.UUIDTable
13 | import org.jetbrains.exposed.sql.ReferenceOption
14 | import org.jetbrains.exposed.sql.transactions.transaction
15 | import java.util.UUID
16 |
17 | object Edits : UUIDTable("edits") {
18 | val appId = reference("app_id", Apps, ReferenceOption.CASCADE)
19 | val shortDescription = text("short_description").nullable()
20 | val creationTime = long("creation_time").clientDefault { System.currentTimeMillis() / 1000 }
21 | val reviewerId = reference("reviewer_id", Reviewers, ReferenceOption.NO_ACTION).nullable()
22 | val reviewId = reference("review_id", Reviews, ReferenceOption.NO_ACTION).nullable()
23 | val published = bool("published").default(false)
24 |
25 | init {
26 | check {
27 | // At least one metadata field must be non-null
28 | shortDescription.isNotNull()
29 | }
30 | }
31 | }
32 |
33 | class Edit(id: EntityID) : UUIDEntity(id), ToSerializable {
34 | companion object : UUIDEntityClass(Edits)
35 |
36 | var appId by Edits.appId
37 | var shortDescription by Edits.shortDescription
38 | val creationTime by Edits.creationTime
39 | var reviewerId by Edits.reviewerId
40 | var reviewId by Edits.reviewId
41 | var published by Edits.published
42 |
43 | override fun serializable(): SerializableEdit {
44 | val status = when {
45 | reviewerId == null -> EditStatus.UNSUBMITTED
46 | reviewId == null -> EditStatus.SUBMITTED
47 | else -> {
48 | val review = transaction { Review.findById(reviewId!!)!! }
49 | if (review.approved) {
50 | if (published) {
51 | EditStatus.PUBLISHED
52 | } else {
53 | EditStatus.PUBLISHING
54 | }
55 | } else {
56 | EditStatus.REJECTED
57 | }
58 | }
59 | }
60 |
61 | return SerializableEdit(
62 | id.value.toString(),
63 | appId.value,
64 | shortDescription,
65 | creationTime,
66 | status,
67 | )
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/File.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import org.jetbrains.exposed.dao.IntEntity
8 | import org.jetbrains.exposed.dao.IntEntityClass
9 | import org.jetbrains.exposed.dao.id.EntityID
10 | import org.jetbrains.exposed.dao.id.IntIdTable
11 |
12 | object Files : IntIdTable("files") {
13 | val deleted = bool("deleted").default(false)
14 | val s3ObjectKey = text("s3_object_key").nullable()
15 | }
16 |
17 | class File(id: EntityID) : IntEntity(id) {
18 | companion object : IntEntityClass(Files)
19 |
20 | var deleted by Files.deleted
21 | var s3ObjectKey by Files.s3ObjectKey
22 | }
23 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/Icon.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import org.jetbrains.exposed.dao.IntEntity
8 | import org.jetbrains.exposed.dao.IntEntityClass
9 | import org.jetbrains.exposed.dao.id.EntityID
10 | import org.jetbrains.exposed.dao.id.IntIdTable
11 | import org.jetbrains.exposed.sql.ReferenceOption
12 |
13 | object Icons : IntIdTable("icons") {
14 | val fileId = reference("file_id", Files, ReferenceOption.NO_ACTION)
15 | }
16 |
17 | class Icon(id: EntityID) : IntEntity(id) {
18 | companion object : IntEntityClass(Icons)
19 |
20 | var fileId by Icons.fileId
21 | }
22 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/Listing.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import org.jetbrains.exposed.dao.IntEntity
8 | import org.jetbrains.exposed.dao.IntEntityClass
9 | import org.jetbrains.exposed.dao.id.EntityID
10 | import org.jetbrains.exposed.dao.id.IntIdTable
11 | import org.jetbrains.exposed.sql.ReferenceOption
12 |
13 | object Listings : IntIdTable("listings") {
14 | val appId = reference("app_id", Apps, ReferenceOption.CASCADE)
15 | val locale = text("locale")
16 | val iconId = reference("icon_id", Icons, ReferenceOption.NO_ACTION)
17 | val label = text("label")
18 | val shortDescription = text("short_description")
19 |
20 | init {
21 | uniqueIndex(appId, locale)
22 | }
23 | }
24 |
25 | class Listing(id: EntityID) : IntEntity(id) {
26 | companion object : IntEntityClass(Listings)
27 |
28 | var appId by Listings.appId
29 | var locale by Listings.locale
30 | var iconId by Listings.iconId
31 | var label by Listings.label
32 | var shortDescription by Listings.shortDescription
33 | }
34 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/RejectionReason.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import org.jetbrains.exposed.dao.IntEntity
8 | import org.jetbrains.exposed.dao.IntEntityClass
9 | import org.jetbrains.exposed.dao.id.EntityID
10 | import org.jetbrains.exposed.dao.id.IntIdTable
11 | import org.jetbrains.exposed.sql.ReferenceOption
12 |
13 | object RejectionReasons : IntIdTable("rejection_reasons") {
14 | val reviewId = reference("review_id", Reviews, ReferenceOption.CASCADE)
15 | val reason = text("reason")
16 | }
17 |
18 | class RejectionReason(id: EntityID) : IntEntity(id) {
19 | companion object : IntEntityClass(RejectionReasons)
20 |
21 | var reviewId by RejectionReasons.reviewId
22 | var reason by RejectionReasons.reason
23 | }
24 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/Review.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import org.jetbrains.exposed.dao.IntEntity
8 | import org.jetbrains.exposed.dao.IntEntityClass
9 | import org.jetbrains.exposed.dao.id.EntityID
10 | import org.jetbrains.exposed.dao.id.IntIdTable
11 |
12 | object Reviews : IntIdTable("reviews") {
13 | val approved = bool("approved")
14 | val additionalNotes = text("additional_notes").nullable()
15 | }
16 |
17 | class Review(id: EntityID) : IntEntity(id) {
18 | companion object : IntEntityClass(Reviews)
19 |
20 | var approved by Reviews.approved
21 | var additionalNotes by Reviews.additionalNotes
22 | }
23 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/ReviewIssue.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import org.jetbrains.exposed.dao.IntEntity
8 | import org.jetbrains.exposed.dao.IntEntityClass
9 | import org.jetbrains.exposed.dao.id.EntityID
10 | import org.jetbrains.exposed.dao.id.IntIdTable
11 | import org.jetbrains.exposed.sql.ReferenceOption
12 |
13 | object ReviewIssues : IntIdTable("review_issues") {
14 | val reviewIssueGroupId =
15 | reference("review_issue_group_id", ReviewIssueGroups, ReferenceOption.CASCADE)
16 | val rawValue = text("raw_value")
17 |
18 | init {
19 | uniqueIndex(reviewIssueGroupId, rawValue)
20 | }
21 | }
22 |
23 | class ReviewIssue(id: EntityID) : IntEntity(id) {
24 | companion object : IntEntityClass(ReviewIssues)
25 |
26 | var reviewIssueGroupId by ReviewIssues.reviewIssueGroupId
27 | var rawValue by ReviewIssues.rawValue
28 | }
29 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/ReviewIssueGroup.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import org.jetbrains.exposed.dao.IntEntity
8 | import org.jetbrains.exposed.dao.IntEntityClass
9 | import org.jetbrains.exposed.dao.id.EntityID
10 | import org.jetbrains.exposed.dao.id.IntIdTable
11 |
12 | object ReviewIssueGroups : IntIdTable("review_issue_groups")
13 |
14 | class ReviewIssueGroup(id: EntityID) : IntEntity(id) {
15 | companion object : IntEntityClass(ReviewIssueGroups)
16 | }
17 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/Reviewer.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import org.jetbrains.exposed.dao.IntEntity
8 | import org.jetbrains.exposed.dao.IntEntityClass
9 | import org.jetbrains.exposed.dao.id.EntityID
10 | import org.jetbrains.exposed.dao.id.IntIdTable
11 | import org.jetbrains.exposed.sql.ReferenceOption
12 |
13 | object Reviewers : IntIdTable("reviewers") {
14 | val userId = reference("user_id", Users, onDelete = ReferenceOption.CASCADE).uniqueIndex()
15 | val email = text("email")
16 | }
17 |
18 | class Reviewer(id: EntityID) : IntEntity(id) {
19 | companion object : IntEntityClass(Reviewers)
20 |
21 | var userId by Reviewers.userId
22 | var email by Reviewers.email
23 | }
24 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/Session.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import io.ktor.server.auth.Principal
8 | import org.jetbrains.exposed.dao.Entity
9 | import org.jetbrains.exposed.dao.EntityClass
10 | import org.jetbrains.exposed.dao.id.EntityID
11 | import org.jetbrains.exposed.dao.id.IdTable
12 | import org.jetbrains.exposed.sql.ReferenceOption
13 |
14 | object Sessions : IdTable("sessions") {
15 | override val id = text("id").entityId()
16 | val userId = reference("user_id", Users, onDelete = ReferenceOption.CASCADE)
17 | val expiryTime = long("expiry_time")
18 | override val primaryKey = PrimaryKey(id)
19 | }
20 |
21 | class Session(id: EntityID) : Entity(id), Principal {
22 | companion object : EntityClass(Sessions)
23 |
24 | var userId by Sessions.userId
25 | var expiryTime by Sessions.expiryTime
26 | }
27 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/ToSerializable.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | /**
8 | * Utility interface to convert database objects into serializable objects
9 | *
10 | * T must be a class annotated with @Serializable
11 | */
12 | fun interface ToSerializable {
13 | /**
14 | * Returns a serializable representation of the database object
15 | */
16 | fun serializable(): T
17 | }
18 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/Update.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import app.accrescent.parcelo.console.data.net.Update as SerializableUpdate
8 | import app.accrescent.parcelo.console.data.net.UpdateStatus
9 | import org.jetbrains.exposed.dao.UUIDEntity
10 | import org.jetbrains.exposed.dao.UUIDEntityClass
11 | import org.jetbrains.exposed.dao.id.EntityID
12 | import org.jetbrains.exposed.dao.id.UUIDTable
13 | import org.jetbrains.exposed.sql.ReferenceOption
14 | import org.jetbrains.exposed.sql.and
15 | import org.jetbrains.exposed.sql.not
16 | import org.jetbrains.exposed.sql.transactions.transaction
17 | import java.util.UUID
18 |
19 | object Updates : UUIDTable("updates") {
20 | val appId = reference("app_id", Apps, ReferenceOption.CASCADE)
21 | val versionCode = integer("version_code")
22 | val versionName = text("version_name")
23 | val creatorId = reference("creator_id", Users, ReferenceOption.CASCADE)
24 | val creationTime = long("creation_time").clientDefault { System.currentTimeMillis() / 1000 }
25 | val fileId = reference("file_id", Files, ReferenceOption.SET_NULL).nullable()
26 | val reviewerId = reference("reviewer_id", Reviewers).nullable()
27 | val reviewIssueGroupId =
28 | reference("review_issue_group_id", ReviewIssueGroups, ReferenceOption.NO_ACTION).nullable()
29 | val submitted = bool("submitted").default(false)
30 | val reviewId = reference("review_id", Reviews, ReferenceOption.NO_ACTION).nullable()
31 | val published = bool("published").default(false)
32 |
33 | init {
34 | check {
35 | // A reviewer may not be assigned if there isn't a set of review issues
36 | not(reviewerId.isNotNull() and reviewIssueGroupId.isNull())
37 | }
38 | }
39 | }
40 |
41 | class Update(id: EntityID) : UUIDEntity(id), ToSerializable {
42 | companion object : UUIDEntityClass(Updates)
43 |
44 | var appId by Updates.appId
45 | var versionCode by Updates.versionCode
46 | var versionName by Updates.versionName
47 | var creatorId by Updates.creatorId
48 | val creationTime by Updates.creationTime
49 | var fileId by Updates.fileId
50 | var reviewerId by Updates.reviewerId
51 | var reviewIssueGroupId by Updates.reviewIssueGroupId
52 | var submitted by Updates.submitted
53 | var reviewId by Updates.reviewId
54 | var published by Updates.published
55 |
56 | override fun serializable(): SerializableUpdate {
57 | val status = when {
58 | !submitted -> UpdateStatus.UNSUBMITTED
59 | reviewerId == null -> if (published) UpdateStatus.PUBLISHED else UpdateStatus.PUBLISHING
60 | reviewId == null -> UpdateStatus.PENDING_REVIEW
61 | else -> {
62 | val review = transaction { Review.findById(reviewId!!)!! }
63 | if (review.approved) {
64 | if (published) {
65 | UpdateStatus.PUBLISHED
66 | } else {
67 | UpdateStatus.PUBLISHING
68 | }
69 | } else {
70 | UpdateStatus.REJECTED
71 | }
72 | }
73 | }
74 |
75 | return SerializableUpdate(
76 | id.value.toString(),
77 | appId.value,
78 | versionCode,
79 | versionName,
80 | creationTime,
81 | reviewIssueGroupId != null,
82 | status,
83 | )
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/User.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import app.accrescent.parcelo.console.data.net.User as SerializableUser
8 | import org.jetbrains.exposed.dao.UUIDEntity
9 | import org.jetbrains.exposed.dao.UUIDEntityClass
10 | import org.jetbrains.exposed.dao.id.EntityID
11 | import org.jetbrains.exposed.dao.id.UUIDTable
12 | import java.util.UUID
13 |
14 | object Users : UUIDTable("users") {
15 | val githubUserId = long("gh_id").uniqueIndex()
16 | val email = text("email")
17 | val publisher = bool("publisher").default(false)
18 | }
19 |
20 | class User(id: EntityID) : UUIDEntity(id), ToSerializable {
21 | companion object : UUIDEntityClass(Users)
22 |
23 | var githubUserId by Users.githubUserId
24 | var email by Users.email
25 | var publisher by Users.publisher
26 |
27 | override fun serializable() = SerializableUser(id.value.toString(), githubUserId, email)
28 | }
29 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/WhitelistedGitHubUser.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data
6 |
7 | import org.jetbrains.exposed.dao.Entity
8 | import org.jetbrains.exposed.dao.EntityClass
9 | import org.jetbrains.exposed.dao.id.EntityID
10 | import org.jetbrains.exposed.dao.id.IdTable
11 |
12 | object WhitelistedGitHubUsers : IdTable("whitelisted_github_users") {
13 | override val id = long("gh_id").entityId()
14 | override val primaryKey = PrimaryKey(id)
15 | }
16 |
17 | class WhitelistedGitHubUser(id: EntityID) : Entity(id) {
18 | companion object : EntityClass(WhitelistedGitHubUsers)
19 | }
20 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineAccessControlList.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.IntIdTable
10 | import org.jetbrains.exposed.sql.ReferenceOption
11 |
12 | object BaselineAccessControlLists : IntIdTable("access_control_lists") {
13 | val userId = reference("user_id", BaselineUsers, ReferenceOption.CASCADE)
14 | val appId = reference("app_id", BaselineApps, ReferenceOption.CASCADE)
15 | val update = bool("update").default(false)
16 | val editMetadata = bool("edit_metadata").default(false)
17 |
18 | init {
19 | uniqueIndex(userId, appId)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineApp.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.IdTable
10 | import org.jetbrains.exposed.sql.ReferenceOption
11 |
12 | object BaselineApps : IdTable("apps") {
13 | override val id = text("id").entityId()
14 | val versionCode = integer("version_code")
15 | val versionName = text("version_name")
16 | val fileId = reference("file_id", BaselineFiles, ReferenceOption.NO_ACTION)
17 | val reviewIssueGroupId =
18 | reference(
19 | "review_issue_group_id",
20 | BaselineReviewIssueGroups,
21 | ReferenceOption.NO_ACTION
22 | ).nullable()
23 | val updating = bool("updating").default(false)
24 | val repositoryMetadata = blob("repository_metadata")
25 | override val primaryKey = PrimaryKey(id)
26 | }
27 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineDraft.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.UUIDTable
10 | import org.jetbrains.exposed.sql.ReferenceOption
11 | import org.jetbrains.exposed.sql.and
12 | import org.jetbrains.exposed.sql.not
13 |
14 | // This is a UUID table because the ID is exposed to unprivileged API consumers. We don't want to
15 | // leak e.g. the total number of drafts.
16 | object BaselineDrafts : UUIDTable("drafts") {
17 | val appId = text("app_id")
18 | val label = text("label")
19 | val versionCode = integer("version_code")
20 | val versionName = text("version_name")
21 | val shortDescription = text("short_description").default("")
22 | val creatorId = reference("creator_id", BaselineUsers, ReferenceOption.CASCADE)
23 | val creationTime = long("creation_time").clientDefault { System.currentTimeMillis() / 1000 }
24 | val fileId = reference("file_id", BaselineFiles, ReferenceOption.NO_ACTION)
25 | val iconId = reference("icon_id", BaselineIcons, ReferenceOption.NO_ACTION)
26 | val reviewerId = reference("reviewer_id", BaselineReviewers).nullable()
27 | val reviewIssueGroupId =
28 | reference(
29 | "review_issue_group_id",
30 | BaselineReviewIssueGroups,
31 | ReferenceOption.NO_ACTION
32 | ).nullable()
33 | val reviewId = reference("review_id", BaselineReviews, ReferenceOption.NO_ACTION).nullable()
34 | val publishing = bool("publishing").default(false)
35 |
36 | init {
37 | // Drafts can't be reviewed without being submitted (which is equivalent to having a
38 | // reviewer assigned) first
39 | check { not(reviewId.isNotNull() and reviewerId.isNull()) }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineEdit.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.UUIDTable
10 | import org.jetbrains.exposed.sql.ReferenceOption
11 |
12 | object BaselineEdits : UUIDTable("edits") {
13 | val appId = reference("app_id", BaselineApps, ReferenceOption.CASCADE)
14 | val shortDescription = text("short_description").nullable()
15 | val creationTime = long("creation_time").clientDefault { System.currentTimeMillis() / 1000 }
16 | val reviewerId =
17 | reference("reviewer_id", BaselineReviewers, ReferenceOption.NO_ACTION).nullable()
18 | val reviewId = reference("review_id", BaselineReviews, ReferenceOption.NO_ACTION).nullable()
19 | val published = bool("published").default(false)
20 |
21 | init {
22 | check {
23 | // At least one metadata field must be non-null
24 | shortDescription.isNotNull()
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineFile.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.IntIdTable
10 |
11 | object BaselineFiles : IntIdTable("files") {
12 | val deleted = bool("deleted").default(false)
13 | val s3ObjectKey = text("s3_object_key").nullable()
14 | }
15 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineIcon.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.IntIdTable
10 | import org.jetbrains.exposed.sql.ReferenceOption
11 |
12 | object BaselineIcons : IntIdTable("icons") {
13 | val fileId = reference("file_id", BaselineFiles, ReferenceOption.NO_ACTION)
14 | }
15 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineListing.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.IntIdTable
10 | import org.jetbrains.exposed.sql.ReferenceOption
11 |
12 | object BaselineListings : IntIdTable("listings") {
13 | val appId = reference("app_id", BaselineApps, ReferenceOption.CASCADE)
14 | val locale = text("locale")
15 | val iconId = reference("icon_id", BaselineIcons, ReferenceOption.NO_ACTION)
16 | val label = text("label")
17 | val shortDescription = text("short_description")
18 |
19 | init {
20 | uniqueIndex(appId, locale)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineRejectionReason.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.IntIdTable
10 | import org.jetbrains.exposed.sql.ReferenceOption
11 |
12 | object BaselineRejectionReasons : IntIdTable("rejection_reasons") {
13 | val reviewId = reference("review_id", BaselineReviews, ReferenceOption.CASCADE)
14 | val reason = text("reason")
15 | }
16 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineReview.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.IntIdTable
10 |
11 | object BaselineReviews : IntIdTable("reviews") {
12 | val approved = bool("approved")
13 | val additionalNotes = text("additional_notes").nullable()
14 | }
15 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineReviewIssue.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.IntIdTable
10 | import org.jetbrains.exposed.sql.ReferenceOption
11 |
12 | object BaselineReviewIssues : IntIdTable("review_issues") {
13 | val reviewIssueGroupId =
14 | reference("review_issue_group_id", BaselineReviewIssueGroups, ReferenceOption.CASCADE)
15 | val rawValue = text("raw_value")
16 |
17 | init {
18 | uniqueIndex(reviewIssueGroupId, rawValue)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineReviewIssueGroup.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.IntIdTable
10 |
11 | object BaselineReviewIssueGroups : IntIdTable("review_issue_groups")
12 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineReviewer.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.IntIdTable
10 | import org.jetbrains.exposed.sql.ReferenceOption
11 |
12 | object BaselineReviewers : IntIdTable("reviewers") {
13 | val userId =
14 | reference("user_id", BaselineUsers, onDelete = ReferenceOption.CASCADE).uniqueIndex()
15 | val email = text("email")
16 | }
17 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineSession.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.IdTable
10 | import org.jetbrains.exposed.sql.ReferenceOption
11 |
12 | object BaselineSessions : IdTable("sessions") {
13 | override val id = text("id").entityId()
14 | val userId = reference("user_id", BaselineUsers, onDelete = ReferenceOption.CASCADE)
15 | val expiryTime = long("expiry_time")
16 | override val primaryKey = PrimaryKey(id)
17 | }
18 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineUpdate.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.UUIDTable
10 | import org.jetbrains.exposed.sql.ReferenceOption
11 | import org.jetbrains.exposed.sql.and
12 | import org.jetbrains.exposed.sql.not
13 |
14 | object BaselineUpdates : UUIDTable("updates") {
15 | val appId = reference("app_id", BaselineApps, ReferenceOption.CASCADE)
16 | val versionCode = integer("version_code")
17 | val versionName = text("version_name")
18 | val creatorId = reference("creator_id", BaselineUsers, ReferenceOption.CASCADE)
19 | val creationTime = long("creation_time").clientDefault { System.currentTimeMillis() / 1000 }
20 | val fileId = reference("file_id", BaselineFiles, ReferenceOption.SET_NULL).nullable()
21 | val reviewerId = reference("reviewer_id", BaselineReviewers).nullable()
22 | val reviewIssueGroupId =
23 | reference(
24 | "review_issue_group_id",
25 | BaselineReviewIssueGroups,
26 | ReferenceOption.NO_ACTION
27 | ).nullable()
28 | val submitted = bool("submitted").default(false)
29 | val reviewId = reference("review_id", BaselineReviews, ReferenceOption.NO_ACTION).nullable()
30 | val published = bool("published").default(false)
31 |
32 | init {
33 | check {
34 | // A reviewer may not be assigned if there isn't a set of review issues
35 | not(reviewerId.isNotNull() and reviewIssueGroupId.isNull())
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineUser.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.UUIDTable
10 |
11 | object BaselineUsers : UUIDTable("users") {
12 | val githubUserId = long("gh_id").uniqueIndex()
13 | val email = text("email")
14 | val publisher = bool("publisher").default(false)
15 | }
16 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/baseline/BaselineWhitelistedGitHubUser.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 | //
5 | // DO NOT MODIFY - DATABASE BASELINE
6 |
7 | package app.accrescent.parcelo.console.data.baseline
8 |
9 | import org.jetbrains.exposed.dao.id.IdTable
10 |
11 | object BaselineWhitelistedGitHubUsers : IdTable("whitelisted_github_users") {
12 | override val id = long("gh_id").entityId()
13 | override val primaryKey = PrimaryKey(id)
14 | }
15 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/net/ApiError.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data.net
6 |
7 | import app.accrescent.parcelo.apksparser.ParseApkResult
8 | import app.accrescent.parcelo.apksparser.ParseApkSetResult
9 | import kotlinx.serialization.SerialName
10 | import kotlinx.serialization.Serializable
11 | import java.util.UUID
12 |
13 | @Serializable
14 | class ApiError private constructor(
15 | @SerialName("error_code") val errorCode: Int,
16 | val title: String,
17 | val message: String,
18 | ) {
19 | companion object {
20 | fun androidManifest(message: String) = ApiError(1, "Invalid Android manifest", message)
21 | fun apkFormat(message: String) = ApiError(2, "APK parsing failed", message)
22 | fun debugCertificate(message: String) = ApiError(3, "Debug certificate detected", message)
23 | fun signatureVerification(message: String) =
24 | ApiError(4, "Signature verification failed", message)
25 |
26 | fun signatureVersion(message: String) = ApiError(5, "Signature version(s) invalid", message)
27 | fun zipFormat(message: String) = ApiError(6, "ZIP format error", message)
28 | fun debuggable(message: String) = ApiError(14, "Application is debuggable", message)
29 | fun manifestInfoInconsistent(message: String) =
30 | ApiError(17, "Inconsistent manifest info", message)
31 |
32 | fun signingCertMismatch(message: String) =
33 | ApiError(18, "Signing certificates don't match", message)
34 |
35 | fun targetSdkNotFound(message: String) = ApiError(19, "Target SDK not found", message)
36 | fun testOnly(message: String) = ApiError(20, "Application is test only", message)
37 | fun versionNameNotFound(message: String) = ApiError(21, "Version name not found", message)
38 | fun iconImageFormat() =
39 | ApiError(22, "Image is not in an acceptable format", "Icon must be a PNG")
40 |
41 | fun imageResolution() = ApiError(
42 | 23,
43 | "Image is not in an acceptable resolution",
44 | "Image resolution must be 512 x 512",
45 | )
46 |
47 | fun labelLength() = ApiError(
48 | 24,
49 | "Label has invalid length",
50 | "Label length must be between 3 and 30 characters long (inclusive)",
51 | )
52 |
53 | fun unknownPartName(partName: String?) =
54 | ApiError(25, "Unknown part name encountered", "Unknown part name \"$partName\"")
55 |
56 | fun appAlreadyExists() =
57 | ApiError(26, "App already exists", "An app with this ID already exists")
58 |
59 | fun minTargetSdk(min: Int, actual: Int) = ApiError(
60 | 27,
61 | "Target SDK is too small",
62 | "Target SDK $actual is smaller than the minimum $min",
63 | )
64 |
65 | fun missingPartName() = ApiError(
66 | 29,
67 | "Missing request part names",
68 | "An expected part or parts is missing from the request",
69 | )
70 |
71 | fun invalidUuid(id: String) = ApiError(
72 | 30,
73 | "Invalid UUID",
74 | "The ID \"$id\" supplied in the request is not a valid UUID",
75 | )
76 |
77 | fun draftNotFound(id: UUID) =
78 | ApiError(31, "Draft not found", "A draft with id \"$id\" does not exist")
79 |
80 | fun reviewerAlreadyAssigned() = ApiError(
81 | 32,
82 | "Reviewer already assigned",
83 | "A reviewer has already been assigned to this object",
84 | )
85 |
86 | fun alreadyReviewed() =
87 | ApiError(33, "Already reviewed", "This object has already been reviewed")
88 |
89 | fun reviewForbidden() = ApiError(
90 | 34,
91 | "Review forbidden",
92 | "This user does not have sufficient access rights to review this object",
93 | )
94 |
95 | fun publishForbidden() = ApiError(
96 | 35,
97 | "Publish forbidden",
98 | "This user does not have sufficient access rights to publish this object",
99 | )
100 |
101 | fun updateCreationForbidden() = ApiError(
102 | 36,
103 | "Update forbidden",
104 | "This user does not have sufficient permissions to create an update for this app",
105 | )
106 |
107 | fun updateNotFound(id: UUID) =
108 | ApiError(37, "Update not found", "An update with id \"$id\" does not exist")
109 |
110 | fun appNotFound(id: String) =
111 | ApiError(38, "App not found", "An app with id \"$id\" does not exist")
112 |
113 | fun readForbidden() = ApiError(
114 | 39,
115 | "Read forbidden",
116 | "This user does not have sufficient access rights to read this object",
117 | )
118 |
119 | fun updateVersionTooLow(updateVersion: Int, appVersion: Int) = ApiError(
120 | 40,
121 | "Update version is too low",
122 | "Update version code \"$updateVersion\" is less than published app version \"$appVersion\"",
123 | )
124 |
125 | fun updateAppIdDoesntMatch(appId: String, updateAppId: String) = ApiError(
126 | 41,
127 | "Update app ID doesn't match",
128 | "The update's app ID \"$updateAppId\" doesn't match the published app ID \"$appId\"",
129 | )
130 |
131 | fun downloadForbidden() = ApiError(
132 | 42,
133 | "Download forbidden",
134 | "This user does not have sufficient access rights to download this object",
135 | )
136 |
137 | fun deleteForbidden() = ApiError(
138 | 43,
139 | "Deletion forbidden",
140 | "This user does not have sufficient access rights to delete this object",
141 | )
142 |
143 | fun alreadyUpdating(appId: String) = ApiError(
144 | 44,
145 | "Already updating",
146 | "The app \"$appId\" is currently updating. Please try again later.",
147 | )
148 |
149 | fun editCreationForbidden() = ApiError(
150 | 45,
151 | "Edit forbidden",
152 | "This user does not have sufficient permissions to create an edit for this app",
153 | )
154 |
155 | fun editNotFound(id: UUID) =
156 | ApiError(46, "Edit not found", "An edit with id \"$id\" does not exist")
157 |
158 | fun submissionConflict() = ApiError(
159 | 47,
160 | "Conflict with another submission",
161 | "This object cannot be submitted since another is already submitted",
162 | )
163 |
164 | fun shortDescriptionLength() = ApiError(
165 | 48,
166 | "Short description has invalid length",
167 | "Short description must be between 3 and 80 characters long (inclusive)",
168 | )
169 |
170 | fun ioError(message: String) = ApiError(49, "I/O error", message)
171 | fun apkSetFormat(message: String) = ApiError(50, "Invalid APK set format", message)
172 | fun missing64BitLibraries(message: String) =
173 | ApiError(51, "Missing 64-bit libraries", message)
174 |
175 | fun noSupportedArchitectures(message: String) =
176 | ApiError(52, "No supported architectures", message)
177 | }
178 | }
179 |
180 | fun toApiError(error: ParseApkResult.Error): ApiError = with(error) {
181 | when (this) {
182 | ParseApkResult.Error.AndroidManifestError -> ApiError.androidManifest(message)
183 | ParseApkResult.Error.ApkFormatError -> ApiError.apkFormat(message)
184 | ParseApkResult.Error.DebugCertificateError -> ApiError.debugCertificate(message)
185 | ParseApkResult.Error.SignatureVerificationError -> ApiError.signatureVerification(message)
186 | ParseApkResult.Error.SignatureVersionError -> ApiError.signatureVersion(message)
187 | ParseApkResult.Error.ZipFormatError -> ApiError.zipFormat(message)
188 | }
189 | }
190 |
191 | fun toApiError(error: ParseApkSetResult.Error): ApiError = with(error) {
192 | when (this) {
193 | is ParseApkSetResult.Error.ApkParseError -> toApiError(this.error)
194 | ParseApkSetResult.Error.DebuggableError -> ApiError.debuggable(message)
195 | is ParseApkSetResult.Error.IoError -> ApiError.ioError(message)
196 | is ParseApkSetResult.Error.MismatchedAppIdError -> ApiError.manifestInfoInconsistent(message)
197 | is ParseApkSetResult.Error.MismatchedVersionCodeError -> ApiError.manifestInfoInconsistent(
198 | message
199 | )
200 |
201 | is ParseApkSetResult.Error.Missing64BitLibraries -> ApiError.missing64BitLibraries(message)
202 |
203 | is ParseApkSetResult.Error.MissingApkError -> ApiError.apkSetFormat(message)
204 | ParseApkSetResult.Error.MissingPathError -> ApiError.apkSetFormat(message)
205 | ParseApkSetResult.Error.MissingVersionCodeError -> ApiError.apkSetFormat(message)
206 | is ParseApkSetResult.Error.NoSupportedArchitectures -> ApiError.noSupportedArchitectures(
207 | message,
208 | )
209 |
210 | ParseApkSetResult.Error.SigningCertMismatchError -> ApiError.signingCertMismatch(message)
211 | ParseApkSetResult.Error.TargetSdkNotFoundError -> ApiError.targetSdkNotFound(message)
212 | ParseApkSetResult.Error.TestOnlyError -> ApiError.testOnly(message)
213 | ParseApkSetResult.Error.TocNotFound -> ApiError.apkSetFormat(message)
214 | ParseApkSetResult.Error.VersionNameNotFoundError -> ApiError.versionNameNotFound(message)
215 | ParseApkSetResult.Error.ZipFormatError -> ApiError.zipFormat(message)
216 | }
217 | }
218 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/net/App.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data.net
6 |
7 | import kotlinx.serialization.SerialName
8 | import kotlinx.serialization.Serializable
9 |
10 | @Serializable
11 | data class App(
12 | val id: String,
13 | val label: String,
14 | @SerialName("version_code")
15 | val versionCode: Int,
16 | @SerialName("version_name")
17 | val versionName: String,
18 | @SerialName("short_description")
19 | val shortDescription: String,
20 | )
21 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/net/Draft.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data.net
6 |
7 | import kotlinx.serialization.SerialName
8 | import kotlinx.serialization.Serializable
9 |
10 | @Serializable
11 | data class Draft(
12 | val id: String,
13 | @SerialName("app_id")
14 | val appId: String,
15 | val label: String,
16 | @SerialName("version_code")
17 | val versionCode: Int,
18 | @SerialName("version_name")
19 | val versionName: String,
20 | @SerialName("short_description")
21 | val shortDescription: String,
22 | @SerialName("creation_time")
23 | val creationTime: Long,
24 | val status: DraftStatus,
25 | )
26 |
27 | @Serializable
28 | enum class DraftStatus {
29 | @SerialName("unsubmitted")
30 | UNSUBMITTED,
31 |
32 | @SerialName("submitted")
33 | SUBMITTED,
34 |
35 | @SerialName("approved")
36 | APPROVED,
37 |
38 | @SerialName("rejected")
39 | REJECTED,
40 |
41 | @SerialName("publishing")
42 | PUBLISHING,
43 | }
44 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/net/Edit.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data.net
6 |
7 | import kotlinx.serialization.SerialName
8 | import kotlinx.serialization.Serializable
9 |
10 | @Serializable
11 | data class Edit(
12 | val id: String,
13 | @SerialName("app_id")
14 | val appId: String,
15 | @SerialName("short_description")
16 | val shortDescription: String?,
17 | @SerialName("creation_time")
18 | val creationTime: Long,
19 | val status: EditStatus,
20 | )
21 |
22 | @Serializable
23 | enum class EditStatus {
24 | @SerialName("unsubmitted")
25 | UNSUBMITTED,
26 |
27 | @SerialName("submitted")
28 | SUBMITTED,
29 |
30 | @SerialName("rejected")
31 | REJECTED,
32 |
33 | @SerialName("publishing")
34 | PUBLISHING,
35 |
36 | @SerialName("published")
37 | PUBLISHED,
38 | }
39 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/net/Update.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data.net
6 |
7 | import kotlinx.serialization.SerialName
8 | import kotlinx.serialization.Serializable
9 |
10 | @Serializable
11 | data class Update(
12 | val id: String,
13 | @SerialName("app_id")
14 | val appId: String,
15 | @SerialName("version_code")
16 | val versionCode: Int,
17 | @SerialName("version_name")
18 | val versionName: String,
19 | @SerialName("creation_time")
20 | val creationTime: Long,
21 | @SerialName("requires_review")
22 | val requiresReview: Boolean,
23 | val status: UpdateStatus,
24 | )
25 |
26 | @Serializable
27 | enum class UpdateStatus {
28 | @SerialName("unsubmitted")
29 | UNSUBMITTED,
30 |
31 | @SerialName("pending-review")
32 | PENDING_REVIEW,
33 |
34 | @SerialName("rejected")
35 | REJECTED,
36 |
37 | @SerialName("publishing")
38 | PUBLISHING,
39 |
40 | @SerialName("published")
41 | PUBLISHED,
42 | }
43 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/data/net/User.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.data.net
6 |
7 | import kotlinx.serialization.SerialName
8 | import kotlinx.serialization.Serializable
9 |
10 | @Serializable
11 | data class User(val id: String, @SerialName("gh_id") val githubUserId: Long, val email: String)
12 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/jobs/CleanDeletedFiles.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.jobs
6 |
7 | import app.accrescent.parcelo.console.storage.ObjectStorageService
8 | import kotlinx.coroutines.runBlocking
9 | import org.koin.java.KoinJavaComponent.inject
10 | import kotlin.getValue
11 |
12 | /**
13 | * Removes all files marked deleted
14 | */
15 | fun cleanDeletedFiles() {
16 | val storageService: ObjectStorageService by inject(ObjectStorageService::class.java)
17 |
18 | runBlocking {
19 | storageService.cleanAllObjects()
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/jobs/CleanFile.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.jobs
6 |
7 | import app.accrescent.parcelo.console.storage.ObjectStorageService
8 | import kotlinx.coroutines.runBlocking
9 | import org.koin.java.KoinJavaComponent.inject
10 | import kotlin.getValue
11 |
12 | /**
13 | * Removes the file with the given ID if it's marked deleted
14 | */
15 | fun cleanFile(fileId: Int) {
16 | val storageService: ObjectStorageService by inject(ObjectStorageService::class.java)
17 |
18 | runBlocking {
19 | storageService.cleanObject(fileId)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/jobs/JobRunr.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.jobs
6 |
7 | import org.jobrunr.configuration.JobRunr
8 | import org.jobrunr.scheduling.BackgroundJob
9 | import org.jobrunr.storage.sql.postgres.PostgresStorageProvider
10 | import java.time.Duration
11 | import javax.sql.DataSource
12 |
13 | private const val FILE_CLEANING_LABEL = "CLEAN_FILES"
14 | private val FILE_CLEANING_PERIOD = Duration.ofHours(6)
15 |
16 | /**
17 | * Configures JobRunr with the given [DataSource]
18 | */
19 | fun configureJobRunr(dataSource: DataSource) {
20 | JobRunr
21 | .configure()
22 | .useStorageProvider(PostgresStorageProvider(dataSource))
23 | .useBackgroundJobServer()
24 | .initialize()
25 |
26 | BackgroundJob.scheduleRecurrently(FILE_CLEANING_LABEL, FILE_CLEANING_PERIOD) {
27 | cleanDeletedFiles()
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/jobs/Publish.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.jobs
6 |
7 | import app.accrescent.parcelo.console.data.Draft as DraftDao
8 | import app.accrescent.parcelo.console.data.Update as UpdateDao
9 | import app.accrescent.parcelo.console.data.AccessControlList
10 | import app.accrescent.parcelo.console.data.App
11 | import app.accrescent.parcelo.console.data.Icon
12 | import app.accrescent.parcelo.console.data.Listing
13 | import app.accrescent.parcelo.console.publish.PublishService
14 | import app.accrescent.parcelo.console.storage.ObjectStorageService
15 | import kotlinx.coroutines.runBlocking
16 | import org.jetbrains.exposed.sql.statements.api.ExposedBlob
17 | import org.jetbrains.exposed.sql.transactions.transaction
18 | import org.jobrunr.scheduling.BackgroundJob
19 | import org.koin.java.KoinJavaComponent.inject
20 | import java.util.UUID
21 |
22 | /**
23 | * Publishes the draft with the given ID, making it available for download
24 | */
25 | fun registerPublishAppJob(draftId: UUID) {
26 | val storageService: ObjectStorageService by inject(ObjectStorageService::class.java)
27 | val publishService: PublishService by inject(PublishService::class.java)
28 |
29 | val draft = transaction { DraftDao.findById(draftId) } ?: return
30 | val iconFileId =
31 | transaction { Icon.findById(draft.iconId)?.fileId } ?: throw IllegalStateException()
32 |
33 | // Publish to the repository
34 | val metadata = runBlocking {
35 | storageService.loadObject(draft.fileId) { draftStream ->
36 | storageService.loadObject(iconFileId) { iconStream ->
37 | publishService.publishDraft(draftStream, iconStream, draft.shortDescription)
38 | }
39 | }
40 | }
41 |
42 | // Account for publication
43 | transaction {
44 | draft.delete()
45 | val app = App.new(draft.appId) {
46 | versionCode = draft.versionCode
47 | versionName = draft.versionName
48 | fileId = draft.fileId
49 | reviewIssueGroupId = draft.reviewIssueGroupId
50 | repositoryMetadata = ExposedBlob(metadata)
51 | }
52 | Listing.new {
53 | appId = app.id
54 | locale = "en-US"
55 | iconId = draft.iconId
56 | label = draft.label
57 | shortDescription = draft.shortDescription
58 | }
59 | AccessControlList.new {
60 | this.userId = draft.creatorId
61 | appId = app.id
62 | update = true
63 | editMetadata = true
64 | }
65 | }
66 | }
67 |
68 | /**
69 | * Publishes the update with the given ID, making it available for download
70 | */
71 | fun registerPublishUpdateJob(updateId: UUID) {
72 | val storageService: ObjectStorageService by inject(ObjectStorageService::class.java)
73 | val publishService: PublishService by inject(PublishService::class.java)
74 |
75 | val update = transaction { UpdateDao.findById(updateId) } ?: return
76 |
77 | // Publish to the repository
78 | val updatedMetadata = runBlocking {
79 | storageService.loadObject(update.fileId!!) {
80 | runBlocking { publishService.publishUpdate(it, update.appId.value) }
81 | }
82 | }
83 |
84 | // Account for publication
85 | val oldAppFileId = transaction {
86 | App.findById(update.appId)?.run {
87 | versionCode = update.versionCode
88 | versionName = update.versionName
89 | repositoryMetadata = ExposedBlob(updatedMetadata)
90 |
91 | val oldAppFileId = fileId
92 | fileId = update.fileId!!
93 |
94 | update.published = true
95 | updating = false
96 |
97 | oldAppFileId
98 | }
99 | }
100 |
101 | // Delete old app file
102 | if (oldAppFileId != null) {
103 | runBlocking { storageService.markDeleted(oldAppFileId.value) }
104 | BackgroundJob.enqueue { cleanFile(oldAppFileId.value) }
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/jobs/PublishEdit.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.jobs
6 |
7 | import app.accrescent.parcelo.console.data.Edit as EditDao
8 | import app.accrescent.parcelo.console.data.App
9 | import app.accrescent.parcelo.console.data.Listing
10 | import app.accrescent.parcelo.console.data.Listings
11 | import app.accrescent.parcelo.console.publish.PublishService
12 | import kotlinx.coroutines.runBlocking
13 | import org.jetbrains.exposed.sql.and
14 | import org.jetbrains.exposed.sql.statements.api.ExposedBlob
15 | import org.jetbrains.exposed.sql.transactions.transaction
16 | import org.koin.java.KoinJavaComponent.inject
17 | import java.util.UUID
18 |
19 | /**
20 | * Publishes the app metadata changes in the edit with the given ID
21 | */
22 | fun publishEdit(editId: UUID) {
23 | val publishService: PublishService by inject(PublishService::class.java)
24 |
25 | val edit = transaction { EditDao.findById(editId) } ?: return
26 |
27 | // Publish to the repository
28 | val updatedMetadata =
29 | runBlocking { publishService.publishEdit(edit.appId.value, edit.shortDescription) }
30 |
31 | // Account for publication
32 | transaction {
33 | App.findById(edit.appId)?.run {
34 | repositoryMetadata = ExposedBlob(updatedMetadata)
35 | updating = false
36 | }
37 | Listing
38 | .find { Listings.appId eq edit.appId and (Listings.locale eq "en-US") }
39 | .singleOrNull()
40 | ?.run {
41 | if (edit.shortDescription != null) {
42 | shortDescription = edit.shortDescription!!
43 | }
44 | }
45 |
46 | edit.published = true
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/publish/PublishService.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.publish
6 |
7 | import java.io.InputStream
8 |
9 | /**
10 | * An abstraction over the implementation which publishes app data, i.e., makes it available for
11 | * download to the client
12 | */
13 | interface PublishService {
14 | /**
15 | * Publishes a draft
16 | *
17 | * @return the repository metadata
18 | */
19 | suspend fun publishDraft(
20 | apkSet: InputStream,
21 | icon: InputStream,
22 | shortDescription: String,
23 | ): ByteArray
24 |
25 | /**
26 | * Publishes an app update
27 | *
28 | * @return the updated repository metadata
29 | */
30 | suspend fun publishUpdate(apkSet: InputStream, appId: String): ByteArray
31 |
32 | /**
33 | * Publishes an edit
34 | *
35 | * @return the updated repository metadata
36 | */
37 | suspend fun publishEdit(appId: String, shortDescription: String?): ByteArray
38 | }
39 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/repo/RepoData.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.repo
6 |
7 | import kotlinx.serialization.SerialName
8 | import kotlinx.serialization.Serializable
9 |
10 | /**
11 | * App-specific repository metadata
12 | *
13 | * @property version the app's version name
14 | * @property versionCode the app's version code
15 | * @property abiSplits the set of ABI split APKs the app provides
16 | * @property densitySplits the set of screen density split APKs the app provides
17 | * @property langSplits the set of language split APKs the app provides
18 | * @property shortDescription the default short description of the app
19 | */
20 | @Serializable
21 | data class RepoData(
22 | val version: String,
23 | @SerialName("version_code")
24 | val versionCode: Int,
25 | @SerialName("abi_splits")
26 | val abiSplits: Set,
27 | @SerialName("density_splits")
28 | val densitySplits: Set,
29 | @SerialName("lang_splits")
30 | val langSplits: Set,
31 | @SerialName("short_description")
32 | val shortDescription: String? = null,
33 | )
34 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/routes/Apps.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.routes
6 |
7 | import app.accrescent.parcelo.console.data.Apps as DbApps
8 | import app.accrescent.parcelo.console.data.AccessControlLists
9 | import app.accrescent.parcelo.console.data.App
10 | import app.accrescent.parcelo.console.data.Draft
11 | import app.accrescent.parcelo.console.data.Review
12 | import app.accrescent.parcelo.console.data.Session
13 | import app.accrescent.parcelo.console.data.User
14 | import app.accrescent.parcelo.console.data.net.ApiError
15 | import app.accrescent.parcelo.console.jobs.registerPublishAppJob
16 | import io.ktor.http.HttpStatusCode
17 | import io.ktor.resources.Resource
18 | import io.ktor.server.application.call
19 | import io.ktor.server.auth.authenticate
20 | import io.ktor.server.auth.principal
21 | import io.ktor.server.request.receive
22 | import io.ktor.server.resources.get
23 | import io.ktor.server.resources.post
24 | import io.ktor.server.response.respond
25 | import io.ktor.server.routing.Route
26 | import kotlinx.serialization.SerialName
27 | import kotlinx.serialization.Serializable
28 | import org.jetbrains.exposed.sql.and
29 | import org.jetbrains.exposed.sql.selectAll
30 | import org.jetbrains.exposed.sql.transactions.transaction
31 | import org.jobrunr.scheduling.BackgroundJob
32 | import java.util.UUID
33 |
34 | @Resource("/apps")
35 | class Apps {
36 | @Resource("{id}")
37 | class Id(val parent: Apps = Apps(), val id: String) {
38 | @Resource("edits")
39 | class Edits(val parent: Id)
40 |
41 | @Resource("updates")
42 | class Updates(val parent: Id)
43 | }
44 | }
45 |
46 | fun Route.appRoutes() {
47 | authenticate("cookie") {
48 | createAppRoute()
49 | getAppRoute()
50 | getAppsRoute()
51 | }
52 | }
53 |
54 | @Serializable
55 | data class CreateAppRequest(@SerialName("draft_id") val draftId: String)
56 |
57 | fun Route.createAppRoute() {
58 | post {
59 | val userId = call.principal()!!.userId
60 |
61 | // Only allow publishers to publish apps
62 | val isPublisher = transaction { User.findById(userId)?.publisher }
63 | if (isPublisher != true) {
64 | call.respond(HttpStatusCode.Forbidden, ApiError.publishForbidden())
65 | return@post
66 | }
67 |
68 | val request = call.receive()
69 | val draftId = try {
70 | UUID.fromString(request.draftId)
71 | } catch (e: IllegalArgumentException) {
72 | // The draft ID isn't a valid UUID
73 | call.respond(HttpStatusCode.BadRequest, ApiError.invalidUuid(request.draftId))
74 | return@post
75 | }
76 |
77 | // Only allow publishing of approved apps which are not already being published
78 | val draft = transaction {
79 | Draft
80 | .findById(draftId)
81 | .takeIf { draft -> draft?.reviewId?.let { Review.findById(it)?.approved } == true }
82 | ?.takeIf { draft -> !draft.publishing }
83 | // Update publishing status if found
84 | ?.apply { publishing = true }
85 | }
86 |
87 | if (draft != null) {
88 | // A draft with this ID exists, so register a job to publish it as an app
89 | BackgroundJob.enqueue { registerPublishAppJob(draft.id.value) }
90 | call.respond(HttpStatusCode.Accepted)
91 | } else {
92 | // No draft with this ID exists
93 | call.respond(HttpStatusCode.NotFound, ApiError.draftNotFound(draftId))
94 | }
95 | }
96 | }
97 |
98 | fun Route.getAppRoute() {
99 | get { route ->
100 | val userId = call.principal()!!.userId
101 | val appId = route.id
102 |
103 | val app = transaction {
104 | DbApps
105 | .innerJoin(AccessControlLists)
106 | .selectAll()
107 | .where {
108 | AccessControlLists.userId.eq(userId)
109 | .and(DbApps.id eq AccessControlLists.appId)
110 | .and(DbApps.id eq appId)
111 | }
112 | .singleOrNull()
113 | ?.let { App.wrapRow(it) }
114 | ?.serializable()
115 | }
116 |
117 | if (app == null) {
118 | call.respond(HttpStatusCode.NotFound, ApiError.appNotFound(appId))
119 | } else {
120 | call.respond(HttpStatusCode.OK, app)
121 | }
122 | }
123 | }
124 |
125 | fun Route.getAppsRoute() {
126 | get {
127 | val userId = call.principal()!!.userId
128 |
129 | val apps = transaction {
130 | DbApps
131 | .innerJoin(AccessControlLists)
132 | .selectAll()
133 | .where {
134 | AccessControlLists.userId eq userId and (DbApps.id eq AccessControlLists.appId)
135 | }
136 | .map { App.wrapRow(it).serializable() }
137 | }
138 |
139 | call.respond(apps)
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/routes/Health.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.routes
6 |
7 | import io.ktor.http.HttpStatusCode
8 | import io.ktor.resources.Resource
9 | import io.ktor.server.application.call
10 | import io.ktor.server.resources.get
11 | import io.ktor.server.response.respond
12 | import io.ktor.server.routing.Route
13 |
14 | @Resource("/")
15 | class Health
16 |
17 | fun Route.healthRoutes() {
18 | basicHealthRoute()
19 | }
20 |
21 | fun Route.basicHealthRoute() {
22 | get {
23 | call.respond(HttpStatusCode.OK)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/routes/Session.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.routes
6 |
7 | import app.accrescent.parcelo.console.routes.auth.Session as CookieSession
8 | import app.accrescent.parcelo.console.data.Session
9 | import app.accrescent.parcelo.console.data.Sessions
10 | import io.ktor.http.HttpStatusCode
11 | import io.ktor.server.application.call
12 | import io.ktor.server.auth.principal
13 | import io.ktor.server.response.respond
14 | import io.ktor.server.routing.Route
15 | import io.ktor.server.routing.delete
16 | import io.ktor.server.sessions.clear
17 | import io.ktor.server.sessions.sessions
18 | import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
19 | import org.jetbrains.exposed.sql.deleteWhere
20 | import org.jetbrains.exposed.sql.transactions.transaction
21 |
22 | fun Route.sessionRoutes() {
23 | deleteSessionRoute()
24 | }
25 |
26 | fun Route.deleteSessionRoute() {
27 | delete("/session") {
28 | val sessionId = call.principal()?.id ?: run {
29 | call.respond(HttpStatusCode.OK)
30 | return@delete
31 | }
32 |
33 | transaction { Sessions.deleteWhere { id eq sessionId } }
34 | call.sessions.clear()
35 |
36 | call.respond(HttpStatusCode.OK)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/routes/auth/Auth.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.routes.auth
6 |
7 | import app.accrescent.parcelo.console.data.Session as SessionDao
8 | import app.accrescent.parcelo.console.data.Sessions as DbSessions
9 | import io.ktor.client.HttpClient
10 | import io.ktor.http.HttpStatusCode
11 | import io.ktor.server.application.Application
12 | import io.ktor.server.application.call
13 | import io.ktor.server.application.install
14 | import io.ktor.server.auth.Authentication
15 | import io.ktor.server.auth.authenticate
16 | import io.ktor.server.auth.session
17 | import io.ktor.server.response.respond
18 | import io.ktor.server.response.respondRedirect
19 | import io.ktor.server.routing.Route
20 | import io.ktor.server.routing.get
21 | import io.ktor.server.routing.route
22 | import io.ktor.server.sessions.Sessions
23 | import io.ktor.server.sessions.cookie
24 | import io.ktor.server.sessions.maxAge
25 | import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
26 | import org.jetbrains.exposed.sql.deleteWhere
27 | import org.jetbrains.exposed.sql.transactions.transaction
28 | import kotlin.time.Duration
29 | import kotlin.time.Duration.Companion.days
30 |
31 | /**
32 | * The maximum duration of a console login session
33 | */
34 | val SESSION_LIFETIME = 1.days
35 |
36 | /**
37 | * Configures console authentication
38 | */
39 | fun Application.configureAuthentication(
40 | githubClientId: String,
41 | githubClientSecret: String,
42 | githubRedirectUrl: String,
43 | httpClient: HttpClient = HttpClient(),
44 | ) {
45 | val developmentMode = environment.developmentMode
46 |
47 | install(Sessions) {
48 | cookie(if (!developmentMode) "__Host-session" else "session") {
49 | cookie.maxAge = if (!developmentMode) SESSION_LIFETIME else Duration.INFINITE
50 | cookie.path = "/"
51 | cookie.secure = true
52 | cookie.httpOnly = true
53 | cookie.extensions["SameSite"] = "Strict"
54 | }
55 | }
56 |
57 | install(Authentication) {
58 | session("cookie") {
59 | validate { session ->
60 | val currentTime = System.currentTimeMillis()
61 |
62 | transaction {
63 | // We should delete _all_ expired sessions somewhere to prevent accumulating
64 | // dead sessions, so we might as well do it here, eliminating the need to
65 | // directly check whether the expiryTime has passed.
66 | //
67 | // If we ever reach a point where this causes performance issues, we can
68 | // instead delete all expired sessions whenever a new session is created,
69 | // which happens much less frequently than session validation.
70 | DbSessions.deleteWhere { expiryTime less currentTime }
71 | SessionDao.findById(session.id)
72 | }
73 | }
74 |
75 | challenge { call.respond(HttpStatusCode.Unauthorized) }
76 | }
77 |
78 | github(githubClientId, githubClientSecret, githubRedirectUrl, httpClient)
79 | }
80 | }
81 |
82 | /**
83 | * Registers all authentication routes
84 | */
85 | fun Route.authRoutes() {
86 | authenticate("cookie") {
87 | get("/login/session") {
88 | call.respondRedirect("/apps")
89 | }
90 | }
91 |
92 | route("/auth") {
93 | get("/login") {
94 | call.respondRedirect("/auth/github/login")
95 | }
96 |
97 | githubRoutes()
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/routes/auth/GitHub.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.routes.auth
6 |
7 | import app.accrescent.parcelo.console.data.Reviewer
8 | import app.accrescent.parcelo.console.data.Reviewers
9 | import app.accrescent.parcelo.console.data.Session
10 | import app.accrescent.parcelo.console.data.User
11 | import app.accrescent.parcelo.console.data.Users
12 | import app.accrescent.parcelo.console.data.WhitelistedGitHubUser
13 | import app.accrescent.parcelo.console.data.WhitelistedGitHubUsers
14 | import io.ktor.client.HttpClient
15 | import io.ktor.http.Cookie
16 | import io.ktor.http.HttpMethod
17 | import io.ktor.http.HttpStatusCode
18 | import io.ktor.server.application.ApplicationEnvironment
19 | import io.ktor.server.application.call
20 | import io.ktor.server.auth.AuthenticationConfig
21 | import io.ktor.server.auth.OAuthAccessTokenResponse
22 | import io.ktor.server.auth.OAuthServerSettings
23 | import io.ktor.server.auth.authenticate
24 | import io.ktor.server.auth.oauth
25 | import io.ktor.server.auth.principal
26 | import io.ktor.server.response.respond
27 | import io.ktor.server.routing.Route
28 | import io.ktor.server.routing.get
29 | import io.ktor.server.routing.route
30 | import io.ktor.server.sessions.generateSessionId
31 | import io.ktor.server.sessions.sessions
32 | import io.ktor.server.sessions.set
33 | import kotlinx.serialization.Serializable
34 | import org.jetbrains.exposed.sql.transactions.transaction
35 | import org.kohsuke.github.GitHubBuilder
36 |
37 | /**
38 | * The name of the OAuth2 state cookie for production mode
39 | */
40 | const val COOKIE_OAUTH_STATE_PROD = "__Host-oauth-state"
41 |
42 | /**
43 | * The name of the OAuth2 state cookie for development mode
44 | */
45 | const val COOKIE_OAUTH_STATE_DEVEL = "oauth-state"
46 |
47 | /**
48 | * The duration of the OAuth2 state cookie's lifetime
49 | */
50 | const val COOKIE_OAUTH_STATE_LIFETIME = 60 * 60 // 1 hour
51 |
52 | /**
53 | * A helper method for getting the name of the OAuth2 state cookie name in the current mode
54 | */
55 | val ApplicationEnvironment.oauthStateCookieName
56 | get() =
57 | if (!developmentMode) COOKIE_OAUTH_STATE_PROD else COOKIE_OAUTH_STATE_DEVEL
58 |
59 | /**
60 | * The result of successful authentication
61 | *
62 | * @property reviewer whether the logged-in user has the reviewer role
63 | * @property publisher whether the logged-in user has the publisher role
64 | */
65 | @Serializable
66 | data class AuthResult(val reviewer: Boolean, val publisher: Boolean)
67 |
68 | /**
69 | * Registers GitHub OAuth2 authentication configuration
70 | */
71 | fun AuthenticationConfig.github(
72 | clientId: String,
73 | clientSecret: String,
74 | redirectUrl: String,
75 | httpClient: HttpClient = HttpClient(),
76 | ) {
77 | oauth("oauth2-github") {
78 | urlProvider = { redirectUrl }
79 | providerLookup = {
80 | OAuthServerSettings.OAuth2ServerSettings(
81 | name = "github",
82 | authorizeUrl = "https://github.com/login/oauth/authorize",
83 | accessTokenUrl = "https://github.com/login/oauth/access_token",
84 | requestMethod = HttpMethod.Post,
85 | clientId = clientId,
86 | clientSecret = clientSecret,
87 | defaultScopes = listOf("user:email"),
88 | onStateCreated = { call, state ->
89 | // Cross-site request forgery (CSRF) protection.
90 | // See https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-30#section-10.12
91 | call.response.cookies.append(
92 | Cookie(
93 | name = call.application.environment.oauthStateCookieName,
94 | value = state,
95 | maxAge = COOKIE_OAUTH_STATE_LIFETIME,
96 | path = "/",
97 | secure = true,
98 | httpOnly = true,
99 | extensions = mapOf("SameSite" to "Lax")
100 | )
101 | )
102 | }
103 | )
104 | }
105 | client = httpClient
106 | }
107 | }
108 |
109 | /**
110 | * Registers all GitHub authentication routes
111 | */
112 | fun Route.githubRoutes() {
113 | authenticate("oauth2-github") {
114 | route("/github") {
115 | get("/login") {}
116 |
117 | get("/callback2") {
118 | // Cross-site request forgery (CSRF) protection.
119 | // See https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-30#section-10.12
120 | val oauthCookie =
121 | call.request.cookies[call.application.environment.oauthStateCookieName]
122 | if (oauthCookie == null) {
123 | call.respond(HttpStatusCode.Forbidden)
124 | return@get
125 | }
126 |
127 | val principal: OAuthAccessTokenResponse.OAuth2 = call.principal() ?: return@get
128 | if (principal.state != oauthCookie) {
129 | call.respond(HttpStatusCode.Forbidden)
130 | return@get
131 | }
132 |
133 | val githubUser = GitHubBuilder().withOAuthToken(principal.accessToken).build()
134 |
135 | val githubUserId = githubUser.myself.id
136 |
137 | // Register if not already registered
138 | val user = transaction {
139 | User.find { Users.githubUserId eq githubUserId }.firstOrNull()
140 | } ?: run {
141 | val email = githubUser.myself
142 | .listEmails()
143 | .find { it.isPrimary && it.isVerified }
144 | ?.email
145 | ?: run {
146 | call.respond(HttpStatusCode.Forbidden)
147 | return@get
148 | }
149 |
150 | transaction {
151 | User.new {
152 | this.githubUserId = githubUserId
153 | this.email = email
154 | }
155 | }
156 | }
157 | val userNotWhitelisted = transaction {
158 | WhitelistedGitHubUser
159 | .find { WhitelistedGitHubUsers.id eq user.githubUserId }
160 | .empty()
161 | }
162 | if (userNotWhitelisted) {
163 | call.respond(HttpStatusCode.Forbidden)
164 | return@get
165 | }
166 |
167 | val sessionId = transaction {
168 | Session.new(generateSessionId()) {
169 | userId = user.id
170 | expiryTime =
171 | System.currentTimeMillis() + SESSION_LIFETIME.inWholeMilliseconds
172 | }.id.value
173 | }
174 |
175 | call.sessions.set(Session(sessionId))
176 |
177 | // Determine whether the user is a reviewer
178 | val reviewer = transaction {
179 | Reviewer.find { Reviewers.userId eq user.id }.singleOrNull()
180 | } != null
181 |
182 | call.respond(HttpStatusCode.OK, AuthResult(reviewer, user.publisher))
183 | }
184 | }
185 | }
186 | }
187 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/routes/auth/Session.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.routes.auth
6 |
7 | import io.ktor.server.auth.Principal
8 |
9 | /**
10 | * An authenticated session
11 | *
12 | * @property id the session ID
13 | */
14 | data class Session(val id: String) : Principal
15 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/storage/Common.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.storage
6 |
7 | import app.accrescent.parcelo.console.data.File
8 | import app.accrescent.parcelo.console.data.Files
9 | import app.accrescent.parcelo.console.data.Files.deleted
10 | import org.jetbrains.exposed.sql.and
11 | import org.jetbrains.exposed.sql.not
12 | import java.util.UUID
13 |
14 | /**
15 | * Finds the given non-deleted file
16 | *
17 | * If the file is marked deleted, returns null.
18 | *
19 | * Note: This function requires being wrapped in a transaction context.
20 | */
21 | internal fun findFile(id: Int): File? {
22 | return File.find { Files.id eq id and not(deleted) }.singleOrNull()
23 | }
24 |
25 | /**
26 | * Generates a unique object ID for new objects
27 | *
28 | * @return a new, unique object ID
29 | */
30 | internal fun generateObjectId(): String {
31 | return UUID.randomUUID().toString()
32 | }
33 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/storage/GCSObjectStorageService.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.storage
6 |
7 | import app.accrescent.parcelo.console.data.File
8 | import app.accrescent.parcelo.console.data.Files
9 | import app.accrescent.parcelo.console.data.Files.deleted
10 | import com.google.cloud.storage.BlobId
11 | import com.google.cloud.storage.BlobInfo
12 | import com.google.cloud.storage.StorageOptions
13 | import org.jetbrains.exposed.dao.id.EntityID
14 | import org.jetbrains.exposed.sql.and
15 | import org.jetbrains.exposed.sql.transactions.transaction
16 | import java.io.FileNotFoundException
17 | import java.io.InputStream
18 | import java.nio.channels.Channels
19 | import java.nio.file.Path
20 |
21 | /**
22 | * Implementation of [ObjectStorageService] using a Google Cloud Storage backend
23 | */
24 | class GCSObjectStorageService(
25 | projectId: String,
26 | private val bucket: String,
27 | ) : ObjectStorageService {
28 | private val storage = StorageOptions.newBuilder().setProjectId(projectId).build().service
29 |
30 | override suspend fun uploadFile(path: Path): EntityID {
31 | val objectKey = generateObjectId()
32 |
33 | val blobId = BlobId.of(bucket, objectKey)
34 | val blobInfo = BlobInfo.newBuilder(blobId).build()
35 | storage.createFrom(blobInfo, path)
36 |
37 | val fileId = transaction { File.new { s3ObjectKey = objectKey }.id }
38 |
39 | return fileId
40 | }
41 |
42 | override suspend fun uploadBytes(bytes: ByteArray): EntityID {
43 | val objectKey = generateObjectId()
44 |
45 | val blobId = BlobId.of(bucket, objectKey)
46 | val blobInfo = BlobInfo.newBuilder(blobId).build()
47 | storage.create(blobInfo, bytes)
48 |
49 | val fileId = transaction { File.new { s3ObjectKey = objectKey }.id }
50 |
51 | return fileId
52 | }
53 |
54 | override suspend fun markDeleted(id: Int) {
55 | transaction { findFile(id)?.apply { deleted = true } } ?: return
56 | }
57 |
58 | override suspend fun cleanObject(id: Int) {
59 | val file = transaction {
60 | File.find { Files.id eq id and (deleted eq true) }.singleOrNull()
61 | } ?: return
62 | val s3ObjectKey = file.s3ObjectKey
63 |
64 | val blobId = BlobId.of(bucket, s3ObjectKey)
65 | storage.delete(blobId)
66 |
67 | transaction { file.delete() }
68 | }
69 |
70 | override suspend fun cleanAllObjects() {
71 | val files = transaction { File.find { deleted eq true } }
72 |
73 | val blobsToDelete = transaction { files.map { BlobId.of(bucket, it.s3ObjectKey) } }
74 |
75 | storage.delete(blobsToDelete)
76 |
77 | transaction { files.forEach { it.delete() } }
78 | }
79 |
80 | override suspend fun loadObject(id: EntityID, block: suspend (InputStream) -> T): T {
81 | val s3ObjectKey =
82 | transaction { findFile(id.value)?.s3ObjectKey } ?: throw FileNotFoundException()
83 |
84 | val blobId = BlobId.of(bucket, s3ObjectKey)
85 | val result = storage.reader(blobId).use { readChannel ->
86 | Channels.newInputStream(readChannel).use { block(it) }
87 | }
88 |
89 | return result
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/storage/ObjectStorageService.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.storage
6 |
7 | import org.jetbrains.exposed.dao.id.EntityID
8 | import java.io.InputStream
9 | import java.nio.file.Path
10 |
11 | /**
12 | * Abstraction of object storage
13 | */
14 | interface ObjectStorageService {
15 | /**
16 | * Upload an object from a file to the object storage service
17 | *
18 | * @param path the path of the file to upload
19 | * @return the database ID of the new object
20 | */
21 | suspend fun uploadFile(path: Path): EntityID
22 |
23 | /**
24 | * Upload an object from memory to the object storage service
25 | *
26 | * @param bytes the object data to upload
27 | * @return the database ID of the new object
28 | */
29 | suspend fun uploadBytes(bytes: ByteArray): EntityID
30 |
31 | /**
32 | * Marks the given file deleted
33 | *
34 | * This method does not guarantee that the file has been deleted from the underlying storage
35 | * medium. However, all future calls to [loadObject] for the same file with throw
36 | * [NoSuchFileException], and the file should be considered deleted for all purposes besides
37 | * cleaning.
38 | */
39 | suspend fun markDeleted(id: Int)
40 |
41 | /**
42 | * Delete the given file from persistent storage
43 | *
44 | * The file must be previously marked deleted by [markDeleted], otherwise this function does
45 | * nothing.
46 | */
47 | suspend fun cleanObject(id: Int)
48 |
49 | /**
50 | * Deletes all files marked deleted from persistent storage
51 | */
52 | suspend fun cleanAllObjects()
53 |
54 | /**
55 | * Load the file with the given ID
56 | *
57 | * @return the file's data stream
58 | */
59 | suspend fun loadObject(id: EntityID, block: suspend (InputStream) -> T): T
60 | }
61 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/storage/S3ObjectStorageService.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.storage
6 |
7 | import app.accrescent.parcelo.console.data.File
8 | import app.accrescent.parcelo.console.data.Files
9 | import app.accrescent.parcelo.console.data.Files.deleted
10 | import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider
11 | import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider.Companion.invoke
12 | import aws.sdk.kotlin.services.s3.S3Client
13 | import aws.sdk.kotlin.services.s3.model.Delete
14 | import aws.sdk.kotlin.services.s3.model.DeleteObjectRequest
15 | import aws.sdk.kotlin.services.s3.model.DeleteObjectsRequest
16 | import aws.sdk.kotlin.services.s3.model.GetObjectRequest
17 | import aws.sdk.kotlin.services.s3.model.ObjectIdentifier
18 | import aws.sdk.kotlin.services.s3.model.PutObjectRequest
19 | import aws.smithy.kotlin.runtime.content.ByteStream
20 | import aws.smithy.kotlin.runtime.content.asByteStream
21 | import aws.smithy.kotlin.runtime.content.toInputStream
22 | import aws.smithy.kotlin.runtime.net.url.Url
23 | import io.ktor.utils.io.core.use
24 | import org.jetbrains.exposed.dao.id.EntityID
25 | import org.jetbrains.exposed.sql.and
26 | import org.jetbrains.exposed.sql.transactions.transaction
27 | import java.io.FileNotFoundException
28 | import java.io.IOException
29 | import java.io.InputStream
30 | import java.nio.file.Path
31 |
32 | /**
33 | * Implementation of [ObjectStorageService] using S3-compatible storage as the backing store
34 | */
35 | class S3ObjectStorageService(
36 | private val s3EndpointUrl: Url,
37 | private val s3Region: String,
38 | private val s3Bucket: String,
39 | private val s3AccessKeyId: String,
40 | private val s3SecretAccessKey: String,
41 | ) : ObjectStorageService {
42 | override suspend fun uploadFile(path: Path): EntityID {
43 | S3Client {
44 | endpointUrl = s3EndpointUrl
45 | region = s3Region
46 | credentialsProvider = StaticCredentialsProvider {
47 | accessKeyId = s3AccessKeyId
48 | secretAccessKey = s3SecretAccessKey
49 | }
50 | }.use { s3Client ->
51 | val objectKey = generateObjectId()
52 |
53 | val req = PutObjectRequest {
54 | bucket = s3Bucket
55 | key = objectKey
56 | body = path.asByteStream()
57 | }
58 | s3Client.putObject(req)
59 |
60 | val fileId = transaction { File.new { s3ObjectKey = objectKey }.id }
61 |
62 | return fileId
63 | }
64 | }
65 |
66 | override suspend fun uploadBytes(bytes: ByteArray): EntityID {
67 | S3Client {
68 | endpointUrl = s3EndpointUrl
69 | region = s3Region
70 | credentialsProvider = StaticCredentialsProvider {
71 | accessKeyId = s3AccessKeyId
72 | secretAccessKey = s3SecretAccessKey
73 | }
74 | }.use { s3Client ->
75 | val objectKey = generateObjectId()
76 |
77 | val req = PutObjectRequest {
78 | bucket = s3Bucket
79 | key = objectKey
80 | body = ByteStream.fromBytes(bytes)
81 | }
82 | s3Client.putObject(req)
83 |
84 | val fileId = transaction { File.new { s3ObjectKey = objectKey }.id }
85 |
86 | return fileId
87 | }
88 | }
89 |
90 | override suspend fun markDeleted(id: Int) {
91 | transaction { findFile(id)?.apply { deleted = true } } ?: return
92 | }
93 |
94 | override suspend fun cleanObject(id: Int) {
95 | val file = transaction {
96 | File.find { Files.id eq id and (deleted eq true) }.singleOrNull()
97 | } ?: return
98 | val s3ObjectKey = file.s3ObjectKey
99 |
100 | S3Client {
101 | endpointUrl = s3EndpointUrl
102 | region = s3Region
103 | credentialsProvider = StaticCredentialsProvider {
104 | accessKeyId = s3AccessKeyId
105 | secretAccessKey = s3SecretAccessKey
106 | }
107 | }.use { s3Client ->
108 | val req = DeleteObjectRequest {
109 | bucket = s3Bucket
110 | key = s3ObjectKey
111 | }
112 | s3Client.deleteObject(req)
113 | }
114 |
115 | transaction { file.delete() }
116 | }
117 |
118 | override suspend fun cleanAllObjects() {
119 | val files = transaction { File.find { deleted eq true } }
120 |
121 | val deleteObjectsRequest = transaction {
122 | files
123 | .map { ObjectIdentifier { key = it.s3ObjectKey } }
124 | .let { Delete { objects = it } }
125 | .let {
126 | DeleteObjectsRequest {
127 | bucket = s3Bucket
128 | delete = it
129 | }
130 | }
131 | }
132 |
133 | S3Client {
134 | endpointUrl = s3EndpointUrl
135 | region = s3Region
136 | credentialsProvider = StaticCredentialsProvider {
137 | accessKeyId = s3AccessKeyId
138 | secretAccessKey = s3SecretAccessKey
139 | }
140 | }.use { s3Client ->
141 | s3Client.deleteObjects(deleteObjectsRequest)
142 |
143 | transaction { files.forEach { it.delete() } }
144 | }
145 | }
146 |
147 | override suspend fun loadObject(id: EntityID, block: suspend (InputStream) -> T): T {
148 | val s3ObjectKey =
149 | transaction { findFile(id.value)?.s3ObjectKey } ?: throw FileNotFoundException()
150 |
151 | S3Client {
152 | endpointUrl = s3EndpointUrl
153 | region = s3Region
154 | credentialsProvider = StaticCredentialsProvider {
155 | accessKeyId = s3AccessKeyId
156 | secretAccessKey = s3SecretAccessKey
157 | }
158 | }.use { s3Client ->
159 | val req = GetObjectRequest {
160 | bucket = s3Bucket
161 | key = s3ObjectKey
162 | }
163 | val result = s3Client.getObject(req) { response ->
164 | response.body?.toInputStream()?.use { block(it) } ?: throw IOException()
165 | }
166 |
167 | return result
168 | }
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/util/TempFile.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.util
6 |
7 | import java.io.FileInputStream
8 | import java.io.FileOutputStream
9 | import java.lang.AutoCloseable
10 | import java.nio.file.Path
11 | import java.nio.file.attribute.PosixFilePermission
12 | import java.nio.file.attribute.PosixFilePermissions
13 | import kotlin.io.path.createTempFile
14 | import kotlin.io.path.deleteExisting
15 |
16 | /**
17 | * A temporary file which can close after its scope via the [AutoCloseable] interface
18 | *
19 | * @property path the path of the underlying file
20 | */
21 | class TempFile : AutoCloseable {
22 | val path: Path
23 |
24 | init {
25 | val fileAttributes = PosixFilePermissions.asFileAttribute(
26 | setOf(
27 | PosixFilePermission.OWNER_READ,
28 | PosixFilePermission.OWNER_WRITE,
29 | )
30 | )
31 | path = createTempFile(attributes = arrayOf(fileAttributes))
32 | }
33 |
34 | /**
35 | * Constructs a new [FileInputStream] of this file and returns it as a result
36 | */
37 | fun inputStream(): FileInputStream {
38 | return path.toFile().inputStream()
39 | }
40 |
41 | /**
42 | * Constructs a new [FileOutputStream] of this file and returns it as a result
43 | */
44 | fun outputStream(): FileOutputStream {
45 | return path.toFile().outputStream()
46 | }
47 |
48 | /**
49 | * Returns the size of this file in bytes
50 | */
51 | fun size(): Long {
52 | return path.toFile().length()
53 | }
54 |
55 | /**
56 | * Deletes the underlying file
57 | */
58 | override fun close() {
59 | path.deleteExisting()
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/console/src/main/kotlin/app/accrescent/parcelo/console/validation/Review.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo.console.validation
6 |
7 | import kotlinx.serialization.SerialName
8 | import kotlinx.serialization.Serializable
9 |
10 | /**
11 | * The minimum target SDK accepted for both drafts and updates
12 | */
13 | const val MIN_TARGET_SDK = 34
14 |
15 | /**
16 | * The blacklist of permissions which, when requested by an update for the first time, trigger a
17 | * review which must pass before the update can be published.
18 | */
19 | val PERMISSION_REVIEW_BLACKLIST = setOf(
20 | "android.permission.ACCESS_BACKGROUND_LOCATION",
21 | "android.permission.ACCESS_BACKGROUND_LOCATION",
22 | "android.permission.ACCESS_COARSE_LOCATION",
23 | "android.permission.ACCESS_FINE_LOCATION",
24 | "android.permission.BLUETOOTH_SCAN",
25 | "android.permission.CAMERA",
26 | "android.permission.MANAGE_EXTERNAL_STORAGE",
27 | "android.permission.NEARBY_WIFI_DEVICES",
28 | "android.permission.PROCESS_OUTGOING_CALLS",
29 | "android.permission.QUERY_ALL_PACKAGES",
30 | "android.permission.READ_CALL_LOG",
31 | "android.permission.READ_CONTACTS",
32 | "android.permission.READ_EXTERNAL_STORAGE",
33 | "android.permission.READ_MEDIA_AUDIO",
34 | "android.permission.READ_MEDIA_IMAGES",
35 | "android.permission.READ_MEDIA_VIDEO",
36 | "android.permission.READ_PHONE_NUMBERS",
37 | "android.permission.READ_PHONE_STATE",
38 | "android.permission.READ_SMS",
39 | "android.permission.RECEIVE_MMS",
40 | "android.permission.RECEIVE_SMS",
41 | "android.permission.RECEIVE_WAP_PUSH",
42 | "android.permission.RECORD_AUDIO",
43 | "android.permission.REQUEST_DELETE_PACKAGES",
44 | "android.permission.REQUEST_INSTALL_PACKAGES",
45 | "android.permission.SEND_SMS",
46 | "android.permission.WRITE_CALL_LOG",
47 | "android.permission.WRITE_CONTACTS",
48 | "android.permission.SYSTEM_ALERT_WINDOW",
49 | )
50 |
51 | /**
52 | * Similar to the permission review blacklist, this list contains service intent filter actions
53 | * which trigger a review when requested for the first time by an update.
54 | */
55 | val SERVICE_INTENT_FILTER_REVIEW_BLACKLIST = setOf(
56 | "android.accessibilityservice.AccessibilityService",
57 | "android.net.VpnService",
58 | "android.view.InputMethod"
59 | )
60 |
61 | /**
62 | * A convenience union of the permission review blacklist and service intent filter action blacklist
63 | */
64 | val REVIEW_ISSUE_BLACKLIST =
65 | PERMISSION_REVIEW_BLACKLIST union SERVICE_INTENT_FILTER_REVIEW_BLACKLIST
66 |
67 | /**
68 | * The possible results of a review
69 | */
70 | @Serializable
71 | enum class ReviewResult {
72 | /**
73 | * The review expresses approval
74 | */
75 | @SerialName("approved")
76 | APPROVED,
77 |
78 | /**
79 | * The review expresses rejection
80 | */
81 | @SerialName("rejected")
82 | REJECTED,
83 | }
84 |
85 | /**
86 | * A review object
87 | */
88 | @Serializable
89 | data class ReviewRequest(
90 | /**
91 | * The result of the review
92 | */
93 | val result: ReviewResult,
94 | /**
95 | * A list of reasons for rejection
96 | *
97 | * Present if and only if [result] is [ReviewResult.REJECTED].
98 | */
99 | val reasons: List?,
100 | /**
101 | * Additional notes pertaining to the review
102 | *
103 | * This field is general-purpose, but is intended for supplying helpful information to the user
104 | * requesting review. For example, it can be used for detailing reasons for rejection or adding
105 | * tips for relevant upcoming policy changes.
106 | */
107 | @SerialName("additional_notes")
108 | val additionalNotes: String?,
109 | ) {
110 | // FIXME(#114): Handle this validation automatically via kotlinx.serialization instead
111 | init {
112 | require(
113 | result == ReviewResult.APPROVED && reasons == null ||
114 | result == ReviewResult.REJECTED && reasons != null
115 | )
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/console/src/main/resources/application.conf:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | ktor {
6 | application {
7 | modules = [ app.accrescent.parcelo.console.ApplicationKt.module ]
8 | }
9 | deployment {
10 | host = ${HOST}
11 | port = ${PORT}
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/console/src/main/resources/db/migration/README.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | # Migrations
8 |
9 | ## Making changes
10 |
11 | When adding a versioned migration, please reference [Flyway's documentation].
12 |
13 | Existing migration scripts can be modified before Parcelo includes them in a release. However, once
14 | a script is included in a release, it MUST NOT be modified at all from that point forward. Parcelo
15 | verifies migration checksums on startup, so even slight, non-functional modifications (such as
16 | modifying a code comment) will result in verification failure and Parcelo will not start.
17 |
18 | [Flyway's documentation]: https://documentation.red-gate.com/fd/migrations-184127470.html
19 |
--------------------------------------------------------------------------------
/console/src/main/resources/logback.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/console/src/test/kotlin/app/accrescent/parcelo/ApplicationTest.kt:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | package app.accrescent.parcelo
6 |
7 |
8 | class ApplicationTest
9 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Copyright 2023-2024 Logan Magee
2 | #
3 | # SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | kotlin.code.style=official
6 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Logan Magee
2 | #
3 | # SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | [versions]
6 | apkanalyzer = "31.7.2"
7 | apksig = "8.7.2"
8 | dokka = "1.9.20"
9 | exposed = "0.55.0"
10 | flyway = "10.21.0"
11 | github = "1.326"
12 | google-cloud = "26.49.0"
13 | jackson = "2.17.2"
14 | jobrunr = "7.3.1"
15 | koin = "4.0.0"
16 | kotlin = "2.0.21"
17 | ksp = "2.0.21-1.0.27"
18 | ktor = "2.3.12"
19 | logback = "1.5.12"
20 | postgresql = "42.7.4"
21 | protobuf = "4.28.3"
22 | protobuf-plugin = "0.9.4"
23 | s3 = "1.3.72"
24 |
25 | [libraries]
26 | apkanalyzer = { module = "com.android.tools.apkparser:apkanalyzer", version.ref = "apkanalyzer" }
27 | apksig = { module = "com.android.tools.build:apksig", version.ref = "apksig" }
28 | exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
29 | exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" }
30 | exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
31 | flyway = { module = "org.flywaydb:flyway-core", version.ref = "flyway" }
32 | flyway-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" }
33 | github = { module = "org.kohsuke:github-api", version.ref = "github" }
34 | google-cloud-bom = { module = "com.google.cloud:libraries-bom", version.ref = "google-cloud" }
35 | google-cloud-storage = { module = "com.google.cloud:google-cloud-storage" }
36 | jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
37 | jackson-xml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-xml", version.ref = "jackson" }
38 | jobrunr = { module = "org.jobrunr:jobrunr", version.ref = "jobrunr" }
39 | koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" }
40 | koin-logger = { module = "io.insert-koin:koin-logger-slf4j", version.ref = "koin" }
41 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
42 | ktor-client = { module = "io.ktor:ktor-client-apache5", version.ref = "ktor" }
43 | ktor-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
44 | ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" }
45 | ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
46 | ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
47 | ktor-server-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
48 | ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
49 | ktor-server-resources = { module = "io.ktor:ktor-server-resources", version.ref = "ktor" }
50 | ktor-server-tests = { module = "io.ktor:ktor-server-tests", version.ref = "ktor" }
51 | logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
52 | postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
53 | protobuf = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" }
54 | s3 = { module = "aws.sdk.kotlin:s3", version.ref = "s3" }
55 |
56 | [plugins]
57 | dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
58 | java-library = { id = "java-library" }
59 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
60 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
61 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
62 | ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
63 | protobuf = { id = "com.google.protobuf", version.ref = "protobuf-plugin" }
64 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/accrescent/parcelo/db29bcdd88be6cd76c7af949b3f9e45c96c1c008/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar.license:
--------------------------------------------------------------------------------
1 | Copyright the original author or authors
2 |
3 | SPDX-License-Identifier: Apache-2.0
4 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | # Copyright 2023-2024 Logan Magee
2 | #
3 | # SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | distributionBase=GRADLE_USER_HOME
6 | distributionPath=wrapper/dists
7 | distributionSha256Sum=57dafb5c2622c6cc08b993c85b7c06956a2f53536432a30ead46166dbca0f1e9
8 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip
9 | networkTimeout=10000
10 | validateDistributionUrl=true
11 | zipStoreBase=GRADLE_USER_HOME
12 | zipStorePath=wrapper/dists
13 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "config:base",
5 | ":disableDependencyDashboard"
6 | ],
7 | "lockFileMaintenance": {
8 | "enabled": true,
9 | "commitMessageAction": "Update lock file"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/renovate.json.license:
--------------------------------------------------------------------------------
1 | Copyright 2023-2024 Logan Magee
2 |
3 | SPDX-License-Identifier: AGPL-3.0-only
4 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | // Copyright 2023-2024 Logan Magee
2 | //
3 | // SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | rootProject.name = "parcelo"
6 |
7 | include("apksparser", "console")
8 |
9 | dependencyResolutionManagement {
10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
11 | repositories {
12 | google()
13 | mavenCentral()
14 | maven { url = uri("https://jitpack.io") }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/sonar-project.properties:
--------------------------------------------------------------------------------
1 | # Copyright 2023-2024 Logan Magee
2 | #
3 | # SPDX-License-Identifier: AGPL-3.0-only
4 |
5 | sonar.projectVersion=0.11.0
6 |
--------------------------------------------------------------------------------