├── .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 | 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 | --------------------------------------------------------------------------------