├── devconsole ├── pb │ └── .gitkeep ├── .gitignore ├── data │ ├── sdk_exception.go │ ├── app.go │ ├── file_storage.go │ ├── emails.go │ ├── local_storage.go │ ├── db.go │ └── sqlite_test.go ├── auth │ ├── constants.go │ ├── errors.go │ ├── util.go │ └── github.go ├── config │ └── config.go ├── set_mode.go ├── set_mode_debug.go ├── .dockerignore ├── middleware │ ├── db.go │ ├── oauth2_config.go │ ├── config.go │ ├── file_storage.go │ ├── signer_required.go │ ├── reviewer_required.go │ ├── user_can_update_required.go │ └── auth_required.go ├── quality │ ├── constants.go │ ├── review_tests.go │ ├── review_blacklist.go │ └── reject_tests.go ├── load_config.go ├── Dockerfile ├── api │ ├── get_approved_apps.go │ ├── approve_app.go │ ├── get_apps.go │ ├── get_updates.go │ ├── get_pending_apps.go │ ├── get_submitted_apps.go │ ├── get_submitted_updates.go │ ├── get_emails.go │ ├── log_out.go │ ├── get_app_apks.go │ ├── reject_app.go │ ├── reject_update.go │ ├── get_update_apks.go │ ├── submit_app.go │ ├── register.go │ ├── util.go │ ├── publish_app.go │ ├── approve_update.go │ ├── submit_update.go │ ├── publish.go │ ├── new_update.go │ └── new_app.go ├── load_config_debug.go ├── main.go ├── go.mod ├── app.go └── proto │ └── targeting.proto ├── .gitignore ├── web ├── src │ ├── app │ │ ├── app.component.html │ │ ├── email.ts │ │ ├── publish │ │ │ ├── publish.component.css │ │ │ ├── publish.component.html │ │ │ ├── publish.component.spec.ts │ │ │ └── publish.component.ts │ │ ├── review │ │ │ ├── review.component.css │ │ │ ├── review.component.spec.ts │ │ │ ├── review.component.ts │ │ │ └── review.component.html │ │ ├── app-list │ │ │ ├── app-list.component.css │ │ │ ├── app-list.component.spec.ts │ │ │ ├── app-list.component.ts │ │ │ └── app-list.component.html │ │ ├── new-app │ │ │ ├── new-app.component.html │ │ │ ├── new-app.component.ts │ │ │ └── new-app.component.spec.ts │ │ ├── register │ │ │ ├── register.component.css │ │ │ ├── register.component.html │ │ │ ├── register.component.ts │ │ │ └── register.component.spec.ts │ │ ├── new-update │ │ │ ├── new-update.component.html │ │ │ ├── new-update.component.ts │ │ │ └── new-update.component.spec.ts │ │ ├── login │ │ │ ├── login.component.html │ │ │ ├── login.component.css │ │ │ ├── login.component.spec.ts │ │ │ └── login.component.ts │ │ ├── dashboard │ │ │ ├── dashboard.component.css │ │ │ ├── dashboard.component.ts │ │ │ ├── dashboard.component.html │ │ │ └── dashboard.component.spec.ts │ │ ├── app-info │ │ │ ├── app-info.component.css │ │ │ ├── app-info.component.html │ │ │ ├── app-info.component.ts │ │ │ └── app-info.component.spec.ts │ │ ├── console-layout │ │ │ ├── console-layout.component.css │ │ │ ├── console-layout.component.ts │ │ │ ├── console-layout.component.spec.ts │ │ │ └── console-layout.component.html │ │ ├── new-app-form │ │ │ ├── new-app-form.component.css │ │ │ ├── new-app-form.component.spec.ts │ │ │ ├── new-app-form.component.ts │ │ │ └── new-app-form.component.html │ │ ├── new-update-form │ │ │ ├── new-update-form.component.css │ │ │ ├── new-update-form.component.spec.ts │ │ │ ├── new-update-form.component.html │ │ │ └── new-update-form.component.ts │ │ ├── login-result.ts │ │ ├── app.ts │ │ ├── app.component.ts │ │ ├── landing │ │ │ ├── landing.component.ts │ │ │ ├── landing.component.html │ │ │ ├── landing.component.css │ │ │ └── landing.component.spec.ts │ │ ├── unauthorized-register │ │ │ ├── unauthorized-register.component.html │ │ │ ├── unauthorized-register.component.ts │ │ │ ├── unauthorized-register.component.css │ │ │ └── unauthorized-register.component.spec.ts │ │ ├── auth.guard.spec.ts │ │ ├── app.service.spec.ts │ │ ├── auth.service.spec.ts │ │ ├── reviewer.guard.spec.ts │ │ ├── publisher.guard.spec.ts │ │ ├── register.service.spec.ts │ │ ├── publisher.guard.ts │ │ ├── reviewer.guard.ts │ │ ├── register-form │ │ │ ├── register-form.component.html │ │ │ ├── register-form.component.spec.ts │ │ │ └── register-form.component.ts │ │ ├── register.service.ts │ │ ├── auth.guard.ts │ │ ├── global-error-handler.ts │ │ ├── auth-interceptor.ts │ │ ├── app.component.spec.ts │ │ ├── auth.service.ts │ │ ├── app-routing.module.ts │ │ ├── app.service.ts │ │ └── app.module.ts │ ├── favicon.ico │ ├── environments │ │ ├── environment.ts │ │ └── environment.prod.ts │ ├── styles.css │ ├── main.ts │ ├── test.ts │ ├── index.html │ ├── assets │ │ ├── invertocat.svg │ │ └── accrescent.svg │ └── polyfills.ts ├── .vscode │ ├── extensions.json │ ├── launch.json │ └── tasks.json ├── .dockerignore ├── nginx.conf ├── .editorconfig ├── tsconfig.app.json ├── Dockerfile ├── tsconfig.spec.json ├── .gitignore ├── tsconfig.json ├── .eslintrc.json ├── package.json ├── karma.conf.js └── angular.json ├── .golangci.yaml ├── reposerver ├── api │ ├── upload_type.go │ ├── publish_app.go │ ├── update_app.go │ ├── repodata.go │ └── publish.go ├── config.go ├── set_mode.go ├── set_mode_debug.go ├── Dockerfile ├── .dockerignore ├── middleware │ ├── publish_dir.go │ └── auth_required.go ├── load_config_debug.go ├── load_config.go ├── main.go ├── go.mod └── go.sum ├── sonar-project.properties ├── SECURITY.md ├── docker-compose.yaml ├── renovate.json ├── .github ├── dependabot.yaml └── workflows │ ├── frontend.yaml │ ├── sonarcloud.yaml │ └── backend.yaml ├── LICENSE ├── ansible ├── devconsole.service ├── templates │ ├── reposerver.service.j2 │ └── nginx │ │ ├── reposerver.conf.j2 │ │ └── devconsole.conf.j2 ├── nginx │ └── security.conf ├── upgrade.yaml └── main.yaml ├── nginx ├── security.conf └── dev.conf └── README.md /devconsole/pb/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | /certs 3 | -------------------------------------------------------------------------------- /devconsole/.gitignore: -------------------------------------------------------------------------------- 1 | /pb 2 | -------------------------------------------------------------------------------- /web/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /web/src/app/email.ts: -------------------------------------------------------------------------------- 1 | export interface Email { 2 | email: string; 3 | } 4 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - gofmt 4 | - usestdlibvars 5 | -------------------------------------------------------------------------------- /web/src/app/publish/publish.component.css: -------------------------------------------------------------------------------- 1 | mat-card { 2 | width: 30em; 3 | } 4 | -------------------------------------------------------------------------------- /web/src/app/review/review.component.css: -------------------------------------------------------------------------------- 1 | mat-card { 2 | width: 35em; 3 | } 4 | -------------------------------------------------------------------------------- /web/src/app/app-list/app-list.component.css: -------------------------------------------------------------------------------- 1 | mat-card { 2 | width: 30em; 3 | } 4 | -------------------------------------------------------------------------------- /web/src/app/new-app/new-app.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /web/src/app/register/register.component.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 2em; 3 | } 4 | -------------------------------------------------------------------------------- /web/src/app/new-update/new-update.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /web/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/accrescent/devconsole/HEAD/web/src/favicon.ico -------------------------------------------------------------------------------- /web/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false 3 | }; 4 | -------------------------------------------------------------------------------- /web/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /web/src/app/login/login.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /devconsole/data/sdk_exception.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | type SdkException struct { 4 | MinTargetSdk uint 5 | } 6 | -------------------------------------------------------------------------------- /web/src/app/dashboard/dashboard.component.css: -------------------------------------------------------------------------------- 1 | mat-card { 2 | width: fit-content; 3 | cursor: pointer; 4 | } 5 | -------------------------------------------------------------------------------- /reposerver/api/upload_type.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type uploadType int 4 | 5 | const ( 6 | newApp uploadType = iota 7 | appUpdate 8 | ) 9 | -------------------------------------------------------------------------------- /web/src/app/app-info/app-info.component.css: -------------------------------------------------------------------------------- 1 | mat-form-field { 2 | width: 50em; 3 | } 4 | 5 | textarea { 6 | resize: none; 7 | } 8 | -------------------------------------------------------------------------------- /web/src/app/console-layout/console-layout.component.css: -------------------------------------------------------------------------------- 1 | #main { 2 | height: 100vh; 3 | } 4 | 5 | .spacer { 6 | flex: auto; 7 | } 8 | -------------------------------------------------------------------------------- /devconsole/auth/constants.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | const ( 4 | authStateCookie = "__Host-oauth2_state" 5 | SessionCookie = "__Host-session" 6 | ) 7 | -------------------------------------------------------------------------------- /reposerver/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type config struct { 4 | APIKey string `toml:"api_key"` 5 | PublishDir string `toml:"publish_dir"` 6 | } 7 | -------------------------------------------------------------------------------- /web/src/app/new-app-form/new-app-form.component.css: -------------------------------------------------------------------------------- 1 | input[type="file"] { 2 | display: none; 3 | } 4 | 5 | .progress-bar { 6 | width: 24em; 7 | } 8 | -------------------------------------------------------------------------------- /devconsole/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Config struct { 4 | SignerGitHubID int64 5 | RepoURL string 6 | APIKey string 7 | } 8 | -------------------------------------------------------------------------------- /reposerver/api/publish_app.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | func PublishApp(c *gin.Context) { 6 | publish(c, newApp) 7 | } 8 | -------------------------------------------------------------------------------- /reposerver/api/update_app.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | func UpdateApp(c *gin.Context) { 6 | publish(c, appUpdate) 7 | } 8 | -------------------------------------------------------------------------------- /web/src/app/new-update-form/new-update-form.component.css: -------------------------------------------------------------------------------- 1 | input[type="file"] { 2 | display: none; 3 | } 4 | 5 | .progress-bar { 6 | width: 24em; 7 | } 8 | -------------------------------------------------------------------------------- /web/src/app/login/login.component.css: -------------------------------------------------------------------------------- 1 | .center { 2 | height: 100vh; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | } 7 | -------------------------------------------------------------------------------- /web/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /web/src/app/login-result.ts: -------------------------------------------------------------------------------- 1 | export interface LoginResult { 2 | logged_in: boolean; 3 | registered: boolean; 4 | reviewer: boolean; 5 | publisher: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /devconsole/set_mode.go: -------------------------------------------------------------------------------- 1 | //go:build !debug 2 | 3 | package main 4 | 5 | import "github.com/gin-gonic/gin" 6 | 7 | func setMode() { 8 | gin.SetMode(gin.ReleaseMode) 9 | } 10 | -------------------------------------------------------------------------------- /devconsole/set_mode_debug.go: -------------------------------------------------------------------------------- 1 | //go:build debug 2 | 3 | package main 4 | 5 | import "github.com/gin-gonic/gin" 6 | 7 | func setMode() { 8 | gin.SetMode(gin.DebugMode) 9 | } 10 | -------------------------------------------------------------------------------- /reposerver/set_mode.go: -------------------------------------------------------------------------------- 1 | //go:build !debug 2 | 3 | package main 4 | 5 | import "github.com/gin-gonic/gin" 6 | 7 | func setMode() { 8 | gin.SetMode(gin.ReleaseMode) 9 | } 10 | -------------------------------------------------------------------------------- /reposerver/set_mode_debug.go: -------------------------------------------------------------------------------- 1 | //go:build debug 2 | 3 | package main 4 | 5 | import "github.com/gin-gonic/gin" 6 | 7 | func setMode() { 8 | gin.SetMode(gin.DebugMode) 9 | } 10 | -------------------------------------------------------------------------------- /web/src/app/app.ts: -------------------------------------------------------------------------------- 1 | export interface App { 2 | app_id: string; 3 | label: string; 4 | version_code: number; 5 | version_name: string; 6 | issues: string[]; 7 | } 8 | -------------------------------------------------------------------------------- /web/src/app/register/register.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Sign up

3 |

Choose your preferred email

4 | 5 | 6 |
7 | -------------------------------------------------------------------------------- /reposerver/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | RUN go build -tags=debug,nomsgpack -o /reposerver 8 | 9 | EXPOSE 8080 10 | 11 | CMD [ "/reposerver" ] 12 | -------------------------------------------------------------------------------- /reposerver/.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | 3 | !/api 4 | !/config.go 5 | !/go.mod 6 | !/go.sum 7 | !/load_config.go 8 | !/load_config_debug.go 9 | !/main.go 10 | !/middleware 11 | !/set_mode.go 12 | !/set_mode_debug.go 13 | -------------------------------------------------------------------------------- /web/src/app/new-app/new-app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-new-app', 5 | templateUrl: './new-app.component.html', 6 | }) 7 | export class NewAppComponent {} 8 | -------------------------------------------------------------------------------- /web/.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | 3 | !/.browserslistrc 4 | !/angular.json 5 | !/karma.conf.js 6 | !/nginx.conf 7 | !/package-lock.json 8 | !/package.json 9 | !/src 10 | !/tsconfig.app.json 11 | !/tsconfig.json 12 | !/tsconfig.spec.json 13 | -------------------------------------------------------------------------------- /devconsole/auth/errors.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrNoStateParam = errors.New("no state param passed in OAuth2 callback") 7 | ErrNoStateMatch = errors.New("state param doesn't match expected value") 8 | ) 9 | -------------------------------------------------------------------------------- /web/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html', 6 | }) 7 | export class AppComponent { 8 | title = 'web'; 9 | } 10 | -------------------------------------------------------------------------------- /web/src/app/new-update/new-update.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-new-update', 5 | templateUrl: './new-update.component.html', 6 | }) 7 | export class NewUpdateComponent {} 8 | -------------------------------------------------------------------------------- /web/src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | html, body { height: 100%; } 4 | body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } 5 | 6 | mat-card { 7 | margin: 1em; 8 | } 9 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=accrescent_devconsole 2 | sonar.projectVersion=0.2.0 3 | sonar.organization=accrescent 4 | 5 | sonar.exclusions=**/*_test.go 6 | 7 | sonar.test.inclusions=**/*_test.go 8 | sonar.go.coverage.reportPaths=devconsole/coverage.out 9 | -------------------------------------------------------------------------------- /reposerver/middleware/publish_dir.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | func PublishDir(publishDir string) gin.HandlerFunc { 6 | return func(c *gin.Context) { 7 | c.Set("publish_dir", publishDir) 8 | c.Next() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /web/src/app/landing/landing.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-landing', 5 | templateUrl: './landing.component.html', 6 | styleUrls: ['./landing.component.css'], 7 | }) 8 | export class LandingComponent {} 9 | -------------------------------------------------------------------------------- /devconsole/auth/util.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "crypto/subtle" 4 | 5 | func ConstantTimeEqInt64(x, y int64) int { 6 | lower := subtle.ConstantTimeEq(int32(x), int32(y)) 7 | upper := subtle.ConstantTimeEq(int32(x>>32), int32(y>>32)) 8 | 9 | return lower & upper 10 | } 11 | -------------------------------------------------------------------------------- /web/src/app/register/register.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-register', 5 | templateUrl: './register.component.html', 6 | styleUrls: ['./register.component.css'], 7 | }) 8 | export class RegisterComponent {} 9 | -------------------------------------------------------------------------------- /devconsole/.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | 3 | !/api 4 | !/app.go 5 | !/auth 6 | !/config 7 | !/data 8 | !/go.mod 9 | !/go.sum 10 | !/load_config.go 11 | !/load_config_debug.go 12 | !/main.go 13 | !/middleware 14 | !/pb 15 | !/proto 16 | !/set_mode.go 17 | !/set_mode_debug.go 18 | !/quality 19 | -------------------------------------------------------------------------------- /web/src/app/dashboard/dashboard.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-dashboard', 5 | templateUrl: './dashboard.component.html', 6 | styleUrls: ['./dashboard.component.css'] 7 | }) 8 | export class DashboardComponent {} 9 | -------------------------------------------------------------------------------- /devconsole/middleware/db.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | 6 | "github.com/accrescent/devconsole/data" 7 | ) 8 | 9 | func DB(db data.DB) gin.HandlerFunc { 10 | return func(c *gin.Context) { 11 | c.Set("db", db) 12 | c.Next() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /devconsole/quality/constants.go: -------------------------------------------------------------------------------- 1 | package quality 2 | 3 | type UploadType int 4 | 5 | const ( 6 | NewApp UploadType = iota 7 | Update 8 | ) 9 | 10 | const ( 11 | MIN_TARGET_SDK_NEW_APP = 33 12 | MIN_TARGET_SDK_UPDATE = 33 13 | ) 14 | 15 | const MIN_BUNDLETOOL_VERSION = "1.11.4" 16 | -------------------------------------------------------------------------------- /reposerver/load_config_debug.go: -------------------------------------------------------------------------------- 1 | //go:build debug 2 | 3 | package main 4 | 5 | import "os" 6 | 7 | func loadConfig(path string) (*config, error) { 8 | conf := &config{ 9 | APIKey: os.Getenv("API_KEY"), 10 | PublishDir: os.Getenv("PUBLISH_DIR"), 11 | } 12 | 13 | return conf, nil 14 | } 15 | -------------------------------------------------------------------------------- /web/src/app/dashboard/dashboard.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | New app 4 | 5 | 6 |

Submit a new app to Accrescent.

7 |
8 |
9 | -------------------------------------------------------------------------------- /devconsole/middleware/oauth2_config.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "golang.org/x/oauth2" 6 | ) 7 | 8 | func OAuth2Config(conf oauth2.Config) gin.HandlerFunc { 9 | return func(c *gin.Context) { 10 | c.Set("oauth2_config", conf) 11 | c.Next() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /devconsole/middleware/config.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | 6 | "github.com/accrescent/devconsole/config" 7 | ) 8 | 9 | func Config(conf config.Config) gin.HandlerFunc { 10 | return func(c *gin.Context) { 11 | c.Set("config", conf) 12 | c.Next() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /web/nginx.conf: -------------------------------------------------------------------------------- 1 | events {} 2 | 3 | http { 4 | server { 5 | listen 80; 6 | root /usr/share/nginx/html; 7 | include mime.types; 8 | default_type application/octet-stream; 9 | 10 | location / { 11 | try_files $uri $uri/ /index.html; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /devconsole/load_config.go: -------------------------------------------------------------------------------- 1 | //go:build !debug 2 | 3 | package main 4 | 5 | import ( 6 | "golang.org/x/oauth2" 7 | 8 | "github.com/accrescent/devconsole/config" 9 | "github.com/accrescent/devconsole/data" 10 | ) 11 | 12 | func loadConfig(db data.DB) (*oauth2.Config, *config.Config, error) { 13 | return db.LoadConfig() 14 | } 15 | -------------------------------------------------------------------------------- /devconsole/middleware/file_storage.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | 6 | "github.com/accrescent/devconsole/data" 7 | ) 8 | 9 | func FileStorage(storage data.FileStorage) gin.HandlerFunc { 10 | return func(c *gin.Context) { 11 | c.Set("storage", storage) 12 | c.Next() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /web/src/app/unauthorized-register/unauthorized-register.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Registration not permitted

4 | 5 |

Accrescent developer registration is currently invite-only. Please come back 6 | when registration opens for everyone.

7 |
8 |
9 | -------------------------------------------------------------------------------- /devconsole/data/app.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | type App struct { 4 | AppID string `json:"app_id"` 5 | Label string `json:"label"` 6 | VersionCode int32 `json:"version_code"` 7 | VersionName string `json:"version_name"` 8 | } 9 | 10 | type AppWithIssues struct { 11 | App 12 | Issues []string `json:"issues,omitempty"` 13 | } 14 | -------------------------------------------------------------------------------- /web/src/app/unauthorized-register/unauthorized-register.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-unauthorized-register', 5 | templateUrl: './unauthorized-register.component.html', 6 | styleUrls: ['./unauthorized-register.component.css'], 7 | }) 8 | export class UnauthorizedRegisterComponent {} 9 | -------------------------------------------------------------------------------- /web/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Only the latest versions of all software in this repository is supported. 6 | 7 | ## Reporting a vulnerability 8 | 9 | If you have a vulnerability to report, either email Logan Magee 10 | or DM him on Matrix @lberrymage:matrix.org if you want 11 | end-to-end encryption. 12 | -------------------------------------------------------------------------------- /web/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts", 10 | "src/polyfills.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine AS build 2 | 3 | WORKDIR /app 4 | 5 | RUN npm install -g @angular/cli 6 | 7 | COPY package.json package-lock.json . 8 | 9 | RUN npm ci 10 | 11 | COPY . . 12 | 13 | RUN ng build --configuration development 14 | 15 | FROM nginx:alpine 16 | 17 | COPY nginx.conf /etc/nginx/nginx.conf 18 | 19 | COPY --from=build /app/dist/web /usr/share/nginx/html 20 | -------------------------------------------------------------------------------- /reposerver/load_config.go: -------------------------------------------------------------------------------- 1 | //go:build !debug 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | 8 | "github.com/BurntSushi/toml" 9 | ) 10 | 11 | func loadConfig(path string) (*config, error) { 12 | file, err := os.ReadFile(path) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | var conf config 18 | err = toml.Unmarshal(file, &conf) 19 | 20 | return &conf, err 21 | } 22 | -------------------------------------------------------------------------------- /web/src/app/unauthorized-register/unauthorized-register.component.css: -------------------------------------------------------------------------------- 1 | .center { 2 | height: 100vh; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | } 7 | 8 | .container { 9 | display: flex; 10 | flex-direction: column; 11 | align-items: center; 12 | background-color: #1f1f1f; 13 | max-width: 400px; 14 | padding: 2.5em; 15 | border-radius: 5vh; 16 | } 17 | -------------------------------------------------------------------------------- /web/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /web/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /web/src/app/auth.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { AuthGuard } from './auth.guard'; 4 | 5 | describe('AuthGuard', () => { 6 | let guard: AuthGuard; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | guard = TestBed.inject(AuthGuard); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(guard).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /devconsole/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21-alpine 2 | 3 | RUN apk --no-cache add libc-dev gcc protoc protobuf-dev 4 | RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest 5 | 6 | WORKDIR /app 7 | 8 | COPY go.mod go.sum . 9 | RUN go mod download 10 | RUN go install github.com/mattn/go-sqlite3 11 | 12 | COPY . . 13 | 14 | RUN go generate && go build -tags=debug,nomsgpack -o /devconsole 15 | 16 | EXPOSE 8080 17 | 18 | CMD [ "/devconsole" ] 19 | -------------------------------------------------------------------------------- /devconsole/api/get_approved_apps.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/accrescent/devconsole/data" 9 | ) 10 | 11 | func GetApprovedApps(c *gin.Context) { 12 | db := c.MustGet("db").(data.DB) 13 | 14 | apps, err := db.GetApprovedApps() 15 | if err != nil { 16 | _ = c.AbortWithError(http.StatusInternalServerError, err) 17 | return 18 | } 19 | 20 | c.JSON(http.StatusOK, apps) 21 | } 22 | -------------------------------------------------------------------------------- /web/src/app/app.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppService', () => { 6 | let service: AppService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(AppService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /web/src/app/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { AuthService } from './auth.service'; 4 | 5 | describe('AuthService', () => { 6 | let service: AuthService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(AuthService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /devconsole/api/approve_app.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/accrescent/devconsole/data" 9 | ) 10 | 11 | func ApproveApp(c *gin.Context) { 12 | db := c.MustGet("db").(data.DB) 13 | appID := c.Param("id") 14 | 15 | if err := db.ApproveApp(appID); err != nil { 16 | _ = c.AbortWithError(http.StatusInternalServerError, err) 17 | return 18 | } 19 | 20 | c.String(http.StatusOK, "") 21 | } 22 | -------------------------------------------------------------------------------- /web/src/app/reviewer.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { ReviewerGuard } from './reviewer.guard'; 4 | 5 | describe('ReviewerGuard', () => { 6 | let guard: ReviewerGuard; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | guard = TestBed.inject(ReviewerGuard); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(guard).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /devconsole/api/get_apps.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/accrescent/devconsole/data" 9 | ) 10 | 11 | func GetApps(c *gin.Context) { 12 | db := c.MustGet("db").(data.DB) 13 | ghID := c.MustGet("gh_id").(int64) 14 | 15 | apps, err := db.GetApps(ghID) 16 | if err != nil { 17 | _ = c.AbortWithError(http.StatusInternalServerError, err) 18 | return 19 | } 20 | 21 | c.JSON(http.StatusOK, apps) 22 | } 23 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | console: 4 | build: devconsole 5 | env_file: devconsole/.env 6 | web: 7 | build: web 8 | repo: 9 | build: reposerver 10 | env_file: reposerver/.env 11 | 12 | nginx: 13 | image: nginx:alpine 14 | volumes: 15 | - ./nginx/dev.conf:/etc/nginx/nginx.conf 16 | - ./nginx/security.conf:/etc/nginx/security.conf 17 | - ./certs:/etc/nginx/certs 18 | ports: 19 | - '8080:443' 20 | -------------------------------------------------------------------------------- /web/src/app/publisher.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { PublisherGuard } from './publisher.guard'; 4 | 5 | describe('PublisherGuard', () => { 6 | let guard: PublisherGuard; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | guard = TestBed.inject(PublisherGuard); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(guard).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /devconsole/api/get_updates.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/accrescent/devconsole/data" 9 | ) 10 | 11 | func GetUpdates(c *gin.Context) { 12 | db := c.MustGet("db").(data.DB) 13 | ghID := c.MustGet("gh_id").(int64) 14 | 15 | apps, err := db.GetUpdates(ghID) 16 | if err != nil { 17 | _ = c.AbortWithError(http.StatusInternalServerError, err) 18 | return 19 | } 20 | 21 | c.JSON(http.StatusOK, apps) 22 | } 23 | -------------------------------------------------------------------------------- /web/src/app/register.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { RegisterService } from './register.service'; 4 | 5 | describe('RegisterService', () => { 6 | let service: RegisterService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(RegisterService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /devconsole/api/get_pending_apps.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/accrescent/devconsole/data" 9 | ) 10 | 11 | func GetPendingApps(c *gin.Context) { 12 | db := c.MustGet("db").(data.DB) 13 | ghID := c.MustGet("gh_id").(int64) 14 | 15 | apps, err := db.GetPendingApps(ghID) 16 | if err != nil { 17 | _ = c.AbortWithError(http.StatusInternalServerError, err) 18 | return 19 | } 20 | 21 | c.JSON(http.StatusOK, apps) 22 | } 23 | -------------------------------------------------------------------------------- /devconsole/api/get_submitted_apps.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/accrescent/devconsole/data" 9 | ) 10 | 11 | func GetSubmittedApps(c *gin.Context) { 12 | db := c.MustGet("db").(data.DB) 13 | ghID := c.MustGet("gh_id").(int64) 14 | 15 | apps, err := db.GetSubmittedApps(ghID) 16 | if err != nil { 17 | _ = c.AbortWithError(http.StatusInternalServerError, err) 18 | return 19 | } 20 | 21 | c.JSON(http.StatusOK, apps) 22 | } 23 | -------------------------------------------------------------------------------- /devconsole/api/get_submitted_updates.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/accrescent/devconsole/data" 9 | ) 10 | 11 | func GetSubmittedUpdates(c *gin.Context) { 12 | db := c.MustGet("db").(data.DB) 13 | ghID := c.MustGet("gh_id").(int64) 14 | 15 | updates, err := db.GetSubmittedUpdates(ghID) 16 | if err != nil { 17 | _ = c.AbortWithError(http.StatusInternalServerError, err) 18 | return 19 | } 20 | 21 | c.JSON(http.StatusOK, updates) 22 | } 23 | -------------------------------------------------------------------------------- /web/src/app/app-info/app-info.component.html: -------------------------------------------------------------------------------- 1 |

Badge

2 | 3 | Get it on Accrescent 8 | 9 |

Use the following HTML badge to share your app.

10 | 11 | 15 | 16 | -------------------------------------------------------------------------------- /web/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | // First, initialize the Angular testing environment. 11 | getTestBed().initTestEnvironment( 12 | BrowserDynamicTestingModule, 13 | platformBrowserDynamicTesting(), 14 | ); 15 | -------------------------------------------------------------------------------- /devconsole/api/get_emails.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/google/go-github/v52/github" 8 | 9 | "github.com/accrescent/devconsole/data" 10 | ) 11 | 12 | func GetEmails(c *gin.Context) { 13 | ghClient := c.MustGet("gh_client").(*github.Client) 14 | 15 | usableEmails, err := data.GetUsableEmails(c, ghClient) 16 | if err != nil { 17 | _ = c.AbortWithError(http.StatusInternalServerError, err) 18 | return 19 | } 20 | 21 | c.JSON(http.StatusOK, usableEmails) 22 | } 23 | -------------------------------------------------------------------------------- /devconsole/data/file_storage.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "io" 5 | "mime/multipart" 6 | ) 7 | 8 | type FileStorage interface { 9 | SaveNewApp( 10 | apkSet multipart.File, 11 | icon multipart.File, 12 | ) (apkSetHandle string, iconHandle string, err error) 13 | SaveUpdate(apkSet multipart.File) (apkSetHandle string, err error) 14 | 15 | GetAPKSet(apkSetHandle string) (file io.Reader, size int64, err error) 16 | 17 | // DeleteFile takes a file handle and deletes the associated file 18 | DeleteFile(handle string) error 19 | } 20 | -------------------------------------------------------------------------------- /web/src/app/publisher.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot} from '@angular/router'; 3 | 4 | import { AuthService } from './auth.service'; 5 | 6 | @Injectable({ 7 | providedIn: 'root' 8 | }) 9 | export class PublisherGuard implements CanActivate { 10 | constructor(private authService: AuthService) {} 11 | 12 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { 13 | return this.authService.publisher; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /web/src/app/reviewer.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router'; 3 | 4 | import { AuthService } from './auth.service'; 5 | 6 | @Injectable({ 7 | providedIn: 'root' 8 | }) 9 | export class ReviewerGuard implements CanActivate { 10 | constructor(private authService: AuthService) {} 11 | 12 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { 13 | return this.authService.reviewer; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /web/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "pwa-chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /devconsole/middleware/signer_required.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/accrescent/devconsole/auth" 9 | "github.com/accrescent/devconsole/config" 10 | ) 11 | 12 | func SignerRequired() gin.HandlerFunc { 13 | return func(c *gin.Context) { 14 | conf := c.MustGet("config").(config.Config) 15 | ghID := c.MustGet("gh_id").(int64) 16 | 17 | if auth.ConstantTimeEqInt64(ghID, conf.SignerGitHubID) == 0 { 18 | c.AbortWithStatus(http.StatusForbidden) 19 | return 20 | } 21 | 22 | c.Next() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /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 | "schedule": ["before 4pm on Tuesday"] 11 | }, 12 | "postUpdateOptions": [ 13 | "gomodTidy", 14 | "gomodUpdateImportPaths" 15 | ], 16 | "packageRules": [ 17 | { 18 | "matchPackageNames": ["golang.org/x/exp"], 19 | "schedule": ["before 4pm on Tuesday"] 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /web/src/app/app-info/app-info.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'app-app-info', 6 | templateUrl: './app-info.component.html', 7 | styleUrls: ['./app-info.component.css'], 8 | }) 9 | export class AppInfoComponent implements OnInit { 10 | appId = ""; 11 | 12 | constructor(private activatedRoute: ActivatedRoute) {} 13 | 14 | ngOnInit(): void { 15 | this.activatedRoute.paramMap.subscribe(params => this.appId = params.get('id') ?? ""); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /web/src/app/register-form/register-form.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 7 | 8 | 9 |
10 |
11 |
12 |
13 | 14 |
15 | -------------------------------------------------------------------------------- /devconsole/api/log_out.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/accrescent/devconsole/auth" 9 | "github.com/accrescent/devconsole/data" 10 | ) 11 | 12 | func LogOut(c *gin.Context) { 13 | db := c.MustGet("db").(data.DB) 14 | sessionID := c.MustGet("session_id").(string) 15 | 16 | if err := db.DeleteSession(sessionID); err != nil { 17 | _ = c.AbortWithError(http.StatusInternalServerError, err) 18 | return 19 | } 20 | 21 | c.SetSameSite(http.SameSiteStrictMode) 22 | c.SetCookie(auth.SessionCookie, "", -1, "/", "", true, true) 23 | 24 | c.String(http.StatusOK, "") 25 | } 26 | -------------------------------------------------------------------------------- /devconsole/middleware/reviewer_required.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/accrescent/devconsole/data" 9 | ) 10 | 11 | func ReviewerRequired() gin.HandlerFunc { 12 | return func(c *gin.Context) { 13 | db := c.MustGet("db").(data.DB) 14 | ghID := c.MustGet("gh_id").(int64) 15 | 16 | _, reviewer, err := db.GetUserRoles(ghID) 17 | if err != nil { 18 | _ = c.AbortWithError(http.StatusInternalServerError, err) 19 | return 20 | } 21 | if !reviewer { 22 | c.AbortWithStatus(http.StatusForbidden) 23 | return 24 | } 25 | 26 | c.Next() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "16:00" 8 | - package-ecosystem: gomod 9 | directory: "/devconsole" 10 | schedule: 11 | interval: daily 12 | time: "16:00" 13 | - package-ecosystem: gomod 14 | directory: "/reposerver" 15 | schedule: 16 | interval: daily 17 | time: "16:00" 18 | open-pull-requests-limit: 20 19 | - package-ecosystem: npm 20 | directory: "/web" 21 | schedule: 22 | interval: daily 23 | time: "16:00" 24 | open-pull-requests-limit: 20 25 | -------------------------------------------------------------------------------- /web/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Accrescent Developer Console 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /web/src/app/landing/landing.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 9 |

Log in to Accrescent

10 |
11 | 12 |
13 | Invertocat 14 | Log in with GitHub 15 |
16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /web/src/app/publish/publish.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ app.label }} 4 | {{ app.app_id }} 5 | 6 | 7 |
8 |

App info

9 |
    10 |
  • Version code: {{ app.version_code }}
  • 11 |
  • Version name: {{ app.version_name }}
  • 12 |
13 |
14 | 15 | 16 | 17 |
18 | -------------------------------------------------------------------------------- /web/src/app/landing/landing.component.css: -------------------------------------------------------------------------------- 1 | .center { 2 | height: 100vh; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | } 7 | 8 | .container { 9 | display: flex; 10 | flex-direction: column; 11 | align-items: center; 12 | background-color: #1f1f1f; 13 | padding: 2.2em 3.5em 3em 3.5em; 14 | border-radius: 10%; 15 | } 16 | 17 | .logo { 18 | border-radius: 50%; 19 | margin-bottom: 1em; 20 | } 21 | 22 | .spacer { 23 | margin: 0.4em 0; 24 | } 25 | 26 | .icon { 27 | padding-right: 14px; 28 | } 29 | 30 | .btn { 31 | padding: 24px 14px 24px 14px; 32 | } 33 | 34 | .row { 35 | display: flex; 36 | align-items: center; 37 | } 38 | -------------------------------------------------------------------------------- /web/src/app/register.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | 4 | import { Observable } from 'rxjs'; 5 | 6 | import { Email } from './email'; 7 | 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class RegisterService { 12 | private readonly registerUrl = 'api/register'; 13 | private readonly emailsUrl = 'api/emails'; 14 | 15 | constructor(private http: HttpClient) {} 16 | 17 | getEmails(): Observable { 18 | return this.http.get(this.emailsUrl); 19 | } 20 | 21 | register(email: Email): Observable { 22 | return this.http.post(this.registerUrl, email); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /web/src/app/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { 3 | ActivatedRouteSnapshot, 4 | CanActivate, 5 | Router, 6 | RouterStateSnapshot, 7 | UrlTree, 8 | } from '@angular/router'; 9 | 10 | import { AuthService } from './auth.service'; 11 | 12 | @Injectable({ 13 | providedIn: 'root' 14 | }) 15 | export class AuthGuard implements CanActivate { 16 | constructor(private authService: AuthService, private router: Router) {} 17 | 18 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree { 19 | if (this.authService.loggedIn) { 20 | return true; 21 | } else { 22 | return this.router.parseUrl('/'); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /web/src/app/login/login.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LoginComponent } from './login.component'; 4 | 5 | describe('LoginComponent', () => { 6 | let component: LoginComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ LoginComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(LoginComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /web/src/app/review/review.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ReviewComponent } from './review.component'; 4 | 5 | describe('ReviewComponent', () => { 6 | let component: ReviewComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ ReviewComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(ReviewComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /web/src/app/new-app/new-app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { NewAppComponent } from './new-app.component'; 4 | 5 | describe('NewAppComponent', () => { 6 | let component: NewAppComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ NewAppComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(NewAppComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /web/src/app/landing/landing.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LandingComponent } from './landing.component'; 4 | 5 | describe('LandingComponent', () => { 6 | let component: LandingComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ LandingComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(LandingComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /web/src/app/publish/publish.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PublishComponent } from './publish.component'; 4 | 5 | describe('PublishComponent', () => { 6 | let component: PublishComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ PublishComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(PublishComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /web/src/app/app-info/app-info.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AppInfoComponent } from './app-info.component'; 4 | 5 | describe('AppInfoComponent', () => { 6 | let component: AppInfoComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ AppInfoComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(AppInfoComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /web/src/app/app-list/app-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AppListComponent } from './app-list.component'; 4 | 5 | describe('AppListComponent', () => { 6 | let component: AppListComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ AppListComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(AppListComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /web/src/app/register/register.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { RegisterComponent } from './register.component'; 4 | 5 | describe('RegisterComponent', () => { 6 | let component: RegisterComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ RegisterComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(RegisterComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2022-2023 Logan Magee 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /web/src/app/dashboard/dashboard.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { DashboardComponent } from './dashboard.component'; 4 | 5 | describe('DashboardComponent', () => { 6 | let component: DashboardComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ DashboardComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(DashboardComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /web/src/app/new-update/new-update.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { NewUpdateComponent } from './new-update.component'; 4 | 5 | describe('NewUpdateComponent', () => { 6 | let component: NewUpdateComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ NewUpdateComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(NewUpdateComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /web/src/app/new-app-form/new-app-form.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { NewAppFormComponent } from './new-app-form.component'; 4 | 5 | describe('NewAppFormComponent', () => { 6 | let component: NewAppFormComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ NewAppFormComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(NewAppFormComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /.github/workflows/frontend.yaml: -------------------------------------------------------------------------------- 1 | name: Frontend 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 10 | - name: Set up Node 11 | uses: actions/setup-node@v3 12 | with: 13 | node-version: 18 14 | - run: cd web && npm ci 15 | - name: Build frontend 16 | run: cd web && npm run build 17 | lint: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 21 | - name: Set up Node 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: 18 25 | - run: cd web && npm ci 26 | - name: Lint frontend 27 | run: cd web && npm run lint 28 | -------------------------------------------------------------------------------- /web/src/app/console-layout/console-layout.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | 4 | import { AuthService } from '../auth.service'; 5 | 6 | @Component({ 7 | selector: 'app-console-layout', 8 | templateUrl: './console-layout.component.html', 9 | styleUrls: ['./console-layout.component.css'] 10 | }) 11 | export class ConsoleLayoutComponent { 12 | constructor(private authService: AuthService, private router: Router) {} 13 | 14 | get reviewer(): boolean { 15 | return this.authService.reviewer; 16 | } 17 | 18 | get publisher(): boolean { 19 | return this.authService.publisher; 20 | } 21 | 22 | logOut(): void { 23 | this.authService.logOut().subscribe(); 24 | this.router.navigate(['/']); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /web/src/app/register-form/register-form.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { RegisterFormComponent } from './register-form.component'; 4 | 5 | describe('RegisterFormComponent', () => { 6 | let component: RegisterFormComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ RegisterFormComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(RegisterFormComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /web/src/app/console-layout/console-layout.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ConsoleLayoutComponent } from './console-layout.component'; 4 | 5 | describe('ConsoleLayoutComponent', () => { 6 | let component: ConsoleLayoutComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ ConsoleLayoutComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(ConsoleLayoutComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /web/src/app/new-update-form/new-update-form.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { NewUpdateFormComponent } from './new-update-form.component'; 4 | 5 | describe('NewUpdateFormComponent', () => { 6 | let component: NewUpdateFormComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ NewUpdateFormComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(NewUpdateFormComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /web/src/app/global-error-handler.ts: -------------------------------------------------------------------------------- 1 | import { ErrorHandler, Injectable, NgZone } from '@angular/core'; 2 | import { HttpErrorResponse } from '@angular/common/http'; 3 | import { MatSnackBar } from '@angular/material/snack-bar'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | }) 8 | export class GlobalErrorHandler implements ErrorHandler { 9 | constructor(private snackbar: MatSnackBar, private zone: NgZone) {} 10 | 11 | handleError(error: any): void { 12 | if ( 13 | error instanceof HttpErrorResponse && 14 | error.error !== null && 15 | Object.hasOwn(error.error, 'error') 16 | ) { 17 | this.zone.run(() => { 18 | this.snackbar.open(error.error.error); 19 | }); 20 | } else { 21 | console.error(error); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /web/src/app/new-update-form/new-update-form.component.html: -------------------------------------------------------------------------------- 1 | 2 | 9 |

{{ fileinput.value.replace("C:\\fakepath\\", "") }}

10 | 11 | 12 | 13 |
14 |

Update info

15 |

App ID: {{ app.app_id }}

16 |

Display name: {{ app.label }}

17 |

Version code: {{ app.version_code }}

18 |

Version name: {{ app.version_name }}

19 | 20 | 21 |
22 | -------------------------------------------------------------------------------- /devconsole/middleware/user_can_update_required.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "net/http" 7 | 8 | "github.com/gin-gonic/gin" 9 | 10 | "github.com/accrescent/devconsole/data" 11 | ) 12 | 13 | func UserCanUpdateRequired() gin.HandlerFunc { 14 | return func(c *gin.Context) { 15 | db := c.MustGet("db").(data.DB) 16 | ghID := c.MustGet("gh_id").(int64) 17 | appID := c.Param("id") 18 | 19 | userCanUpdate, err := db.GetUserPermissions(appID, ghID) 20 | if err != nil { 21 | if errors.Is(err, sql.ErrNoRows) { 22 | _ = c.AbortWithError(http.StatusForbidden, err) 23 | } else { 24 | _ = c.AbortWithError(http.StatusInternalServerError, err) 25 | } 26 | return 27 | } 28 | if !userCanUpdate { 29 | c.AbortWithStatus(http.StatusForbidden) 30 | return 31 | } 32 | 33 | c.Next() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /web/src/app/app-list/app-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { App } from '../app'; 4 | import { AppService } from '../app.service'; 5 | 6 | @Component({ 7 | selector: 'app-app-list', 8 | templateUrl: './app-list.component.html', 9 | styleUrls: ['./app-list.component.css'] 10 | }) 11 | export class AppListComponent implements OnInit { 12 | apps: App[] = []; 13 | submittedApps: App[] = []; 14 | submittedUpdates: App[] = []; 15 | 16 | constructor(private appService: AppService) {} 17 | 18 | ngOnInit(): void { 19 | this.appService.getApps().subscribe(apps => this.apps = apps); 20 | this.appService.getSubmittedApps().subscribe(apps => this.submittedApps = apps); 21 | this.appService.getSubmittedUpdates().subscribe(updates => this.submittedUpdates = updates); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ansible/devconsole.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Accrescent developer console 3 | 4 | [Service] 5 | ExecStart=/opt/devconsole/devconsole 6 | WorkingDirectory=/var/lib/devconsole 7 | 8 | CapabilityBoundingSet= 9 | LockPersonality=yes 10 | MemoryDenyWriteExecute=yes 11 | NoNewPrivileges=yes 12 | PrivateDevices=yes 13 | PrivateTmp=yes 14 | PrivateUsers=yes 15 | ProtectClock=yes 16 | ProtectControlGroups=yes 17 | ProtectHome=yes 18 | ProtectHostname=yes 19 | ProtectKernelLogs=yes 20 | ProtectKernelModules=yes 21 | ProtectKernelTunables=yes 22 | ProtectProc=invisible 23 | ProtectSystem=strict 24 | ReadWritePaths=/var/lib/devconsole 25 | RemoveIPC=yes 26 | RestrictAddressFamilies=AF_INET AF_INET6 27 | RestrictNamespaces=yes 28 | RestrictRealtime=yes 29 | RestrictSUIDSGID=yes 30 | SystemCallArchitectures=native 31 | UMask=0077 32 | User=devconsole 33 | 34 | [Install] 35 | WantedBy=multi-user.target 36 | -------------------------------------------------------------------------------- /reposerver/middleware/auth_required.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "crypto/subtle" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | func AuthRequired(apiKey string) gin.HandlerFunc { 12 | return func(c *gin.Context) { 13 | auth := c.GetHeader("Authorization") 14 | if auth == "" { 15 | c.AbortWithStatus(http.StatusUnauthorized) 16 | return 17 | } 18 | headerParts := strings.Split(auth, " ") 19 | if len(headerParts) != 2 { 20 | c.AbortWithStatus(http.StatusBadRequest) 21 | return 22 | } 23 | 24 | authType := headerParts[0] 25 | if authType != "token" { 26 | c.AbortWithStatus(http.StatusBadRequest) 27 | return 28 | } 29 | 30 | token := headerParts[1] 31 | if subtle.ConstantTimeCompare([]byte(token), []byte(apiKey)) == 0 { 32 | c.AbortWithStatus(http.StatusUnauthorized) 33 | return 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /web/src/app/unauthorized-register/unauthorized-register.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { UnauthorizedRegisterComponent } from './unauthorized-register.component'; 4 | 5 | describe('UnauthorizedRegisterComponent', () => { 6 | let component: UnauthorizedRegisterComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ UnauthorizedRegisterComponent ] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(UnauthorizedRegisterComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /devconsole/api/get_app_apks.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/accrescent/devconsole/data" 9 | ) 10 | 11 | func GetAppAPKs(c *gin.Context) { 12 | db := c.MustGet("db").(data.DB) 13 | storage := c.MustGet("storage").(data.FileStorage) 14 | appID := c.Param("id") 15 | 16 | _, _, _, _, appHandle, _, err := db.GetSubmittedAppInfo(appID) 17 | if err != nil { 18 | _ = c.AbortWithError(http.StatusInternalServerError, err) 19 | return 20 | } 21 | file, size, err := storage.GetAPKSet(appHandle) 22 | if err != nil { 23 | _ = c.AbortWithError(http.StatusInternalServerError, err) 24 | return 25 | } 26 | 27 | filename := appID + ".apks" 28 | headers := map[string]string{"Content-Disposition": `attachment; filename="` + filename + `"`} 29 | 30 | c.DataFromReader(http.StatusOK, size, "application/octet-stream", file, headers) 31 | } 32 | -------------------------------------------------------------------------------- /web/src/app/console-layout/console-layout.component.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | Accrescent Developer Console 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Dashboard 16 | My apps 17 | Review 18 | Publish 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /web/src/app/publish/publish.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { App } from '../app'; 4 | import { AppService } from '../app.service'; 5 | 6 | @Component({ 7 | selector: 'app-publish', 8 | templateUrl: './publish.component.html', 9 | styleUrls: ['./publish.component.css'], 10 | }) 11 | export class PublishComponent implements OnInit { 12 | apps: App[] = []; 13 | 14 | constructor(private appService: AppService) {} 15 | 16 | ngOnInit(): void { 17 | this.appService.getApprovedApps().subscribe(apps => this.apps = apps); 18 | } 19 | 20 | publishApp(appId: string): void { 21 | this.appService.publishApp(appId).subscribe(_ => { 22 | const i = this.apps.findIndex(a => a.app_id === appId); 23 | if (i > -1) { 24 | this.apps.splice(i, 1); 25 | } 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ansible/templates/reposerver.service.j2: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Accrescent repo server 3 | 4 | [Service] 5 | ExecStart=/opt/reposerver/reposerver 6 | WorkingDirectory=/srv/{{ inventory_hostname }} 7 | 8 | CapabilityBoundingSet= 9 | LockPersonality=yes 10 | MemoryDenyWriteExecute=yes 11 | NoNewPrivileges=yes 12 | PrivateDevices=yes 13 | PrivateTmp=yes 14 | PrivateUsers=yes 15 | ProtectClock=yes 16 | ProtectControlGroups=yes 17 | ProtectHome=yes 18 | ProtectHostname=yes 19 | ProtectKernelLogs=yes 20 | ProtectKernelModules=yes 21 | ProtectKernelTunables=yes 22 | ProtectProc=invisible 23 | ProtectSystem=strict 24 | ReadWritePaths=/srv/{{ inventory_hostname }} 25 | RemoveIPC=yes 26 | RestrictAddressFamilies=AF_INET AF_INET6 27 | RestrictNamespaces=yes 28 | RestrictRealtime=yes 29 | RestrictSUIDSGID=yes 30 | SystemCallArchitectures=native 31 | UMask=0022 32 | User=reposerver 33 | 34 | [Install] 35 | WantedBy=multi-user.target 36 | -------------------------------------------------------------------------------- /devconsole/data/emails.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "context" 5 | "regexp" 6 | 7 | "github.com/google/go-github/v52/github" 8 | ) 9 | 10 | // GitHub's noreply email format is documented at 11 | // https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/setting-your-commit-email-address 12 | var noReplyEmail = regexp.MustCompile(`^([0-9]{7}\+)?.*@users\.noreply\.github\.com$`) 13 | 14 | func GetUsableEmails(ctx context.Context, client *github.Client) ([]string, error) { 15 | var usableEmails []string 16 | 17 | emails, _, err := client.Users.ListEmails(ctx, nil) 18 | if err != nil { 19 | return []string{}, err 20 | } 21 | for _, email := range emails { 22 | address := email.GetEmail() 23 | if email.GetVerified() && !noReplyEmail.MatchString(address) { 24 | usableEmails = append(usableEmails, address) 25 | } 26 | } 27 | 28 | return usableEmails, nil 29 | } 30 | -------------------------------------------------------------------------------- /devconsole/load_config_debug.go: -------------------------------------------------------------------------------- 1 | //go:build debug 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "strconv" 8 | 9 | "golang.org/x/oauth2" 10 | "golang.org/x/oauth2/endpoints" 11 | 12 | "github.com/accrescent/devconsole/config" 13 | "github.com/accrescent/devconsole/data" 14 | ) 15 | 16 | func loadConfig(db data.DB) (*oauth2.Config, *config.Config, error) { 17 | oauth2Conf := &oauth2.Config{ 18 | ClientID: os.Getenv("GH_CLIENT_ID"), 19 | ClientSecret: os.Getenv("GH_CLIENT_SECRET"), 20 | Endpoint: endpoints.GitHub, 21 | RedirectURL: os.Getenv("OAUTH2_REDIRECT_URL"), 22 | Scopes: []string{"user:email"}, 23 | } 24 | signerGitHubID, err := strconv.ParseInt(os.Getenv("SIGNER_GH_ID"), 10, 64) 25 | if err != nil { 26 | return nil, nil, err 27 | } 28 | conf := &config.Config{ 29 | SignerGitHubID: signerGitHubID, 30 | RepoURL: os.Getenv("REPO_URL"), 31 | APIKey: os.Getenv("API_KEY"), 32 | } 33 | 34 | return oauth2Conf, conf, nil 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/sonarcloud.yaml: -------------------------------------------------------------------------------- 1 | name: SonarCloud 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize, reopened] 9 | 10 | jobs: 11 | sonarcloud: 12 | name: SonarCloud 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 16 | with: 17 | fetch-depth: 0 18 | - uses: actions/setup-go@v4 19 | with: 20 | go-version: 1.19 21 | - name: Install protoc 22 | run: | 23 | sudo apt-get install protobuf-compiler 24 | go install google.golang.org/protobuf/cmd/protoc-gen-go@latest 25 | - run: cd devconsole && go generate && go test -v -coverprofile=coverage.out ./... 26 | - name: SonarCloud Scan 27 | uses: SonarSource/sonarcloud-github-action@master 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 31 | -------------------------------------------------------------------------------- /devconsole/api/reject_app.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/accrescent/devconsole/data" 9 | ) 10 | 11 | func RejectApp(c *gin.Context) { 12 | db := c.MustGet("db").(data.DB) 13 | storage := c.MustGet("storage").(data.FileStorage) 14 | appID := c.Param("id") 15 | 16 | _, _, _, _, appHandle, iconHandle, err := db.GetSubmittedAppInfo(appID) 17 | if err != nil { 18 | _ = c.AbortWithError(http.StatusInternalServerError, err) 19 | return 20 | } 21 | if err := db.DeleteSubmittedApp(appID); err != nil { 22 | _ = c.AbortWithError(http.StatusInternalServerError, err) 23 | return 24 | } 25 | if err := storage.DeleteFile(appHandle); err != nil { 26 | _ = c.AbortWithError(http.StatusInternalServerError, err) 27 | return 28 | } 29 | if err := storage.DeleteFile(iconHandle); err != nil { 30 | _ = c.AbortWithError(http.StatusInternalServerError, err) 31 | return 32 | } 33 | 34 | c.String(http.StatusOK, "") 35 | } 36 | -------------------------------------------------------------------------------- /devconsole/quality/review_tests.go: -------------------------------------------------------------------------------- 1 | package quality 2 | 3 | import ( 4 | "github.com/accrescent/apkstat" 5 | "golang.org/x/exp/slices" 6 | ) 7 | 8 | func RunReviewTests(apk *apk.APK) (issues []string) { 9 | permissions := apk.Manifest().UsesPermissions 10 | if permissions != nil { 11 | for _, permission := range *permissions { 12 | if slices.Contains(permissionReviewBlacklist, permission.Name) { 13 | issues = append(issues, permission.Name) 14 | } 15 | } 16 | } 17 | 18 | services := apk.Manifest().Application.Services 19 | if services != nil { 20 | for _, service := range *services { 21 | filters := service.IntentFilters 22 | if filters != nil { 23 | for _, filter := range *filters { 24 | for _, action := range filter.Actions { 25 | if slices.Contains(serviceIntentFilterActions, action.Name) && 26 | !slices.Contains(issues, action.Name) { 27 | issues = append(issues, action.Name) 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | 35 | return issues 36 | } 37 | -------------------------------------------------------------------------------- /devconsole/api/reject_update.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/accrescent/devconsole/data" 10 | ) 11 | 12 | func RejectUpdate(c *gin.Context) { 13 | db := c.MustGet("db").(data.DB) 14 | storage := c.MustGet("storage").(data.FileStorage) 15 | appID := c.Param("id") 16 | versionCode, err := strconv.Atoi(c.Param("version")) 17 | if err != nil { 18 | _ = c.AbortWithError(http.StatusBadRequest, err) 19 | return 20 | } 21 | 22 | _, _, appHandle, _, err := db.GetUpdateInfo(appID, versionCode) 23 | if err != nil { 24 | _ = c.AbortWithError(http.StatusInternalServerError, err) 25 | return 26 | } 27 | if err := db.DeleteSubmittedUpdate(appID, versionCode); err != nil { 28 | _ = c.AbortWithError(http.StatusInternalServerError, err) 29 | return 30 | } 31 | if err := storage.DeleteFile(appHandle); err != nil { 32 | _ = c.AbortWithError(http.StatusInternalServerError, err) 33 | return 34 | } 35 | 36 | c.String(http.StatusOK, "") 37 | } 38 | -------------------------------------------------------------------------------- /ansible/nginx/security.conf: -------------------------------------------------------------------------------- 1 | add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; 2 | add_header X-Content-Type-Options "nosniff" always; 3 | add_header Referrer-Policy "no-referrer" always; 4 | add_header Cross-Origin-Opener-Policy "same-origin" always; 5 | add_header Cross-Origin-Embedder-Policy "require-corp" always; 6 | add_header Expect-CT "enforce, max-age=63072000" always; 7 | add_header X-Frame-Options "DENY" always; 8 | add_header X-XSS-Protection "0" always; 9 | add_header Permissions-Policy "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), clipboard-read=(), clipboard-write=(), display-capture=(), document-domain=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), interest-cohort=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), serial=(), sync-xhr=(), usb=(), xr-spatial-tracking=()" always; 10 | add_header Cross-Origin-Resource-Policy "same-origin" always; 11 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "downlevelIteration": true, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "node", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "es2020", 21 | "lib": [ 22 | "es2022", 23 | "dom" 24 | ], 25 | "useDefineForClassFields": false 26 | }, 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /nginx/security.conf: -------------------------------------------------------------------------------- 1 | add_header Content-Security-Policy "trusted-types angular; require-trusted-types-for 'script';" always; 2 | add_header X-Content-Type-Options "nosniff" always; 3 | add_header Referrer-Policy "no-referrer" always; 4 | add_header Cross-Origin-Opener-Policy "same-origin" always; 5 | add_header Cross-Origin-Embedder-Policy "require-corp" always; 6 | add_header Expect-CT "enforce, max-age=63072000" always; 7 | add_header X-Frame-Options "DENY" always; 8 | add_header X-XSS-Protection "0" always; 9 | add_header Permissions-Policy "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), clipboard-read=(), clipboard-write=(), display-capture=(), document-domain=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), interest-cohort=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), serial=(), sync-xhr=(), usb=(), xr-spatial-tracking=()" always; 10 | add_header Cross-Origin-Resource-Policy "same-origin" always; 11 | -------------------------------------------------------------------------------- /web/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /devconsole/api/get_update_apks.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/accrescent/devconsole/data" 10 | ) 11 | 12 | func GetUpdateAPKs(c *gin.Context) { 13 | db := c.MustGet("db").(data.DB) 14 | storage := c.MustGet("storage").(data.FileStorage) 15 | appID := c.Param("id") 16 | versionCodeStr := c.Param("version") 17 | versionCode, err := strconv.Atoi(versionCodeStr) 18 | if err != nil { 19 | _ = c.AbortWithError(http.StatusBadRequest, err) 20 | return 21 | } 22 | 23 | _, _, handle, _, err := db.GetUpdateInfo(appID, versionCode) 24 | if err != nil { 25 | _ = c.AbortWithError(http.StatusInternalServerError, err) 26 | return 27 | } 28 | file, size, err := storage.GetAPKSet(handle) 29 | if err != nil { 30 | _ = c.AbortWithError(http.StatusInternalServerError, err) 31 | return 32 | } 33 | 34 | filename := appID + "-" + versionCodeStr + ".apks" 35 | headers := map[string]string{"Content-Disposition": `attachment; filename="` + filename + `"`} 36 | 37 | c.DataFromReader(http.StatusOK, size, "application/octet-stream", file, headers) 38 | } 39 | -------------------------------------------------------------------------------- /web/src/assets/invertocat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /web/src/app/auth-interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { 3 | HttpErrorResponse, HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, 4 | } from '@angular/common/http'; 5 | import { Router } from '@angular/router'; 6 | import { MatSnackBar } from '@angular/material/snack-bar'; 7 | 8 | import { Observable, tap } from 'rxjs'; 9 | 10 | import { AuthService } from './auth.service'; 11 | 12 | @Injectable() 13 | export class AuthInterceptor implements HttpInterceptor { 14 | constructor( 15 | private authService: AuthService, 16 | private router: Router, 17 | private snackbar: MatSnackBar, 18 | ) {} 19 | 20 | intercept(req: HttpRequest, next: HttpHandler): Observable> { 21 | return next.handle(req).pipe(tap( 22 | () => {}, 23 | (error: any) => { 24 | if (error instanceof HttpErrorResponse && error.status === 401) { 25 | this.authService.logOut(); 26 | this.router.navigate(['/']); 27 | this.snackbar.open('You must be logged in to access that resource'); 28 | } 29 | } 30 | )); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /web/src/app/register-form/register-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { NonNullableFormBuilder, Validators } from '@angular/forms'; 3 | import { Router } from '@angular/router'; 4 | 5 | import { RegisterService } from '../register.service'; 6 | 7 | @Component({ 8 | selector: 'app-register-form', 9 | templateUrl: './register-form.component.html', 10 | }) 11 | export class RegisterFormComponent implements OnInit { 12 | form = this.fb.group({ 13 | email: this.fb.control('', [Validators.required]) 14 | }); 15 | emails: string[] = []; 16 | 17 | constructor( 18 | private fb: NonNullableFormBuilder, 19 | private registerService: RegisterService, 20 | private router: Router, 21 | ) {} 22 | 23 | ngOnInit(): void { 24 | this.registerService.getEmails().subscribe(emails => 25 | emails.forEach((email, _) => this.emails.push(email)) 26 | ); 27 | } 28 | 29 | onSubmit(): void { 30 | const email: string = this.form.getRawValue().email; 31 | this.registerService.register({ email }).subscribe(_ => 32 | this.router.navigate(['dashboard']) 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /devconsole/middleware/auth_required.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "net/http" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/google/go-github/v52/github" 10 | "golang.org/x/oauth2" 11 | 12 | "github.com/accrescent/devconsole/auth" 13 | "github.com/accrescent/devconsole/data" 14 | ) 15 | 16 | func AuthRequired() gin.HandlerFunc { 17 | return func(c *gin.Context) { 18 | db := c.MustGet("db").(data.DB) 19 | conf := c.MustGet("oauth2_config").(oauth2.Config) 20 | 21 | sessionID, err := c.Cookie(auth.SessionCookie) 22 | if err != nil { 23 | _ = c.AbortWithError(http.StatusUnauthorized, err) 24 | return 25 | } 26 | 27 | ghID, token, err := db.GetSessionInfo(sessionID) 28 | if err != nil { 29 | if errors.Is(err, sql.ErrNoRows) { 30 | _ = c.AbortWithError(http.StatusUnauthorized, err) 31 | } else { 32 | _ = c.AbortWithError(http.StatusInternalServerError, err) 33 | } 34 | return 35 | } 36 | 37 | httpClient := conf.Client(c, &oauth2.Token{AccessToken: token}) 38 | client := github.NewClient(httpClient) 39 | 40 | c.Set("session_id", sessionID) 41 | c.Set("gh_id", ghID) 42 | c.Set("gh_client", client) 43 | 44 | c.Next() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /web/src/assets/accrescent.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /devconsole/api/submit_app.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "net/http" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/mattn/go-sqlite3" 10 | 11 | "github.com/accrescent/devconsole/data" 12 | ) 13 | 14 | func SubmitApp(c *gin.Context) { 15 | db := c.MustGet("db").(data.DB) 16 | ghID := c.MustGet("gh_id").(int64) 17 | appID := c.Param("id") 18 | 19 | var input struct { 20 | Label string `json:"label" binding:"required"` 21 | } 22 | if err := c.BindJSON(&input); err != nil { 23 | return 24 | } 25 | if len(input.Label) < 3 || len(input.Label) > 30 { 26 | c.AbortWithStatus(http.StatusUnprocessableEntity) 27 | return 28 | } 29 | 30 | if err := db.SubmitApp(appID, input.Label, ghID); err != nil { 31 | if errors.Is(err, sql.ErrNoRows) { 32 | msg := "Nothing to submit. Try uploading and submitting again" 33 | c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": msg}) 34 | } else if errors.Is(err.(sqlite3.Error).ExtendedCode, sqlite3.ErrConstraintPrimaryKey) { 35 | msg := "You've already submitted an app with this ID" 36 | c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": msg}) 37 | } else { 38 | _ = c.AbortWithError(http.StatusInternalServerError, err) 39 | } 40 | return 41 | } 42 | 43 | c.String(http.StatusOK, "") 44 | } 45 | -------------------------------------------------------------------------------- /web/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { AppComponent } from './app.component'; 4 | 5 | describe('AppComponent', () => { 6 | beforeEach(async () => { 7 | await TestBed.configureTestingModule({ 8 | imports: [ 9 | RouterTestingModule 10 | ], 11 | declarations: [ 12 | AppComponent 13 | ], 14 | }).compileComponents(); 15 | }); 16 | 17 | it('should create the app', () => { 18 | const fixture = TestBed.createComponent(AppComponent); 19 | const app = fixture.componentInstance; 20 | expect(app).toBeTruthy(); 21 | }); 22 | 23 | it(`should have as title 'web'`, () => { 24 | const fixture = TestBed.createComponent(AppComponent); 25 | const app = fixture.componentInstance; 26 | expect(app.title).toEqual('web'); 27 | }); 28 | 29 | it('should render title', () => { 30 | const fixture = TestBed.createComponent(AppComponent); 31 | fixture.detectChanges(); 32 | const compiled = fixture.nativeElement as HTMLElement; 33 | expect(compiled.querySelector('.content span')?.textContent).toContain('web app is running!'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /devconsole/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/accrescent/devconsole/data" 14 | ) 15 | 16 | //go:generate protoc -I proto --go_out pb proto/commands.proto proto/config.proto proto/targeting.proto 17 | 18 | func main() { 19 | setMode() 20 | 21 | db := new(data.SQLite) 22 | if err := db.Open("devconsole.db?_fk=yes&_journal=WAL"); err != nil { 23 | log.Fatal(err) 24 | } 25 | if err := db.Initialize(); err != nil { 26 | log.Fatal(err) 27 | } 28 | 29 | fileStorage := data.NewLocalStorage(".") 30 | 31 | oauth2Conf, conf, err := loadConfig(db) 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | 36 | app, err := NewApp(db, fileStorage, *oauth2Conf, *conf) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | 41 | go func() { 42 | if err := app.Start(); err != nil && errors.Is(http.ErrServerClosed, err) { 43 | log.Println(err) 44 | } 45 | }() 46 | 47 | quit := make(chan os.Signal, 1) 48 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 49 | <-quit 50 | log.Println("Shutting down...") 51 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 52 | defer cancel() 53 | 54 | if err := app.Stop(ctx); err != nil { 55 | log.Fatal("Shutting down forcefully:", err) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": [ 4 | "projects/**/*" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*.ts" 10 | ], 11 | "parserOptions": { 12 | "project": [ 13 | "tsconfig.json" 14 | ], 15 | "createDefaultProgram": true 16 | }, 17 | "extends": [ 18 | "plugin:@angular-eslint/recommended", 19 | "plugin:@angular-eslint/template/process-inline-templates" 20 | ], 21 | "rules": { 22 | "@angular-eslint/directive-selector": [ 23 | "error", 24 | { 25 | "type": "attribute", 26 | "prefix": "app", 27 | "style": "camelCase" 28 | } 29 | ], 30 | "@angular-eslint/component-selector": [ 31 | "error", 32 | { 33 | "type": "element", 34 | "prefix": "app", 35 | "style": "kebab-case" 36 | } 37 | ], 38 | "@typescript-eslint/explicit-function-return-type": "error", 39 | "indent": ["error", 4], 40 | "semi": ["error", "always"] 41 | } 42 | }, 43 | { 44 | "files": [ 45 | "*.html" 46 | ], 47 | "extends": [ 48 | "plugin:@angular-eslint/template/recommended" 49 | ], 50 | "rules": {} 51 | } 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /reposerver/api/repodata.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "strings" 5 | 6 | "golang.org/x/exp/slices" 7 | ) 8 | 9 | var abis = []string{ 10 | "arm64_v8a", 11 | "armeabi_v7a", 12 | "x86_64", 13 | "x86", 14 | } 15 | 16 | func getSplitInfo(s string) (name string, t splitType, typeName string) { 17 | typeName = strings.TrimSuffix(strings.TrimPrefix(s, "base-"), ".apk") 18 | // Detect and use APKs with uncompressed native libraries for minSdk < 23 apps. 19 | // https://developer.android.com/topic/performance/reduce-apk-size#extract-false 20 | typeName = strings.TrimSuffix(typeName, "_2") 21 | 22 | switch { 23 | case typeName == "master": 24 | t = master 25 | case strings.HasSuffix(typeName, "dpi"): 26 | t = density 27 | case slices.Contains(abis, typeName): 28 | t = abi 29 | typeName = strings.Replace(typeName, "_", "-", 1) 30 | default: 31 | t = lang 32 | } 33 | 34 | name = "split." + typeName + ".apk" 35 | name = strings.Replace(name, "split.master.apk", "base.apk", 1) 36 | 37 | return 38 | } 39 | 40 | type splitType int 41 | 42 | const ( 43 | abi splitType = iota 44 | density 45 | lang 46 | 47 | master 48 | ) 49 | 50 | type repoData struct { 51 | Version string `json:"version"` 52 | VersionCode int `json:"version_code"` 53 | ABISplits []string `json:"abi_splits"` 54 | DensitySplits []string `json:"density_splits"` 55 | LangSplits []string `json:"lang_splits"` 56 | } 57 | -------------------------------------------------------------------------------- /devconsole/api/register.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/google/go-github/v52/github" 9 | "github.com/mattn/go-sqlite3" 10 | "golang.org/x/exp/slices" 11 | 12 | "github.com/accrescent/devconsole/data" 13 | ) 14 | 15 | func Register(c *gin.Context) { 16 | db := c.MustGet("db").(data.DB) 17 | ghID := c.MustGet("gh_id").(int64) 18 | ghClient := c.MustGet("gh_client").(*github.Client) 19 | 20 | var input struct { 21 | Email string `json:"email" binding:"required"` 22 | } 23 | if err := c.BindJSON(&input); err != nil { 24 | return 25 | } 26 | 27 | // Verify user is allowed to register with the submitted email 28 | usableEmails, err := data.GetUsableEmails(c, ghClient) 29 | if err != nil { 30 | _ = c.AbortWithError(http.StatusInternalServerError, err) 31 | return 32 | } 33 | if !slices.Contains(usableEmails, input.Email) { 34 | _ = c.AbortWithError(http.StatusForbidden, errors.New("email not usable")) 35 | return 36 | } 37 | 38 | // Register user 39 | if err := db.CreateUser(ghID, input.Email); err != nil { 40 | if errors.Is(err.(sqlite3.Error).ExtendedCode, sqlite3.ErrConstraintPrimaryKey) { 41 | msg := "You are already registered" 42 | c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": msg}) 43 | } else { 44 | _ = c.AbortWithError(http.StatusInternalServerError, err) 45 | } 46 | return 47 | } 48 | 49 | c.String(http.StatusOK, "") 50 | } 51 | -------------------------------------------------------------------------------- /devconsole/quality/review_blacklist.go: -------------------------------------------------------------------------------- 1 | package quality 2 | 3 | var permissionReviewBlacklist = []string{ 4 | "android.permission.ACCESS_BACKGROUND_LOCATION", 5 | "android.permission.ACCESS_COARSE_LOCATION", 6 | "android.permission.ACCESS_FINE_LOCATION", 7 | "android.permission.BLUETOOTH_SCAN", 8 | "android.permission.CAMERA", 9 | "android.permission.MANAGE_EXTERNAL_STORAGE", 10 | "android.permission.NEARBY_WIFI_DEVICES", 11 | "android.permission.PROCESS_OUTGOING_CALLS", 12 | "android.permission.QUERY_ALL_PACKAGES", 13 | "android.permission.READ_CALL_LOG", 14 | "android.permission.READ_CONTACTS", 15 | "android.permission.READ_EXTERNAL_STORAGE", 16 | "android.permission.READ_MEDIA_AUDIO", 17 | "android.permission.READ_MEDIA_IMAGES", 18 | "android.permission.READ_MEDIA_VIDEO", 19 | "android.permission.READ_PHONE_NUMBERS", 20 | "android.permission.READ_PHONE_STATE", 21 | "android.permission.READ_SMS", 22 | "android.permission.RECEIVE_MMS", 23 | "android.permission.RECEIVE_SMS", 24 | "android.permission.RECEIVE_WAP_PUSH", 25 | "android.permission.RECORD_AUDIO", 26 | "android.permission.REQUEST_INSTALL_PACKAGES", 27 | "android.permission.SEND_SMS", 28 | "android.permission.WRITE_CALL_LOG", 29 | "android.permission.WRITE_CONTACTS", 30 | "android.permission.SYSTEM_ALERT_WINDOW", 31 | } 32 | 33 | var serviceIntentFilterActions = []string{ 34 | "android.accessibilityservice.AccessibilityService", 35 | "android.net.VpnService", 36 | "android.view.InputMethod", 37 | } 38 | -------------------------------------------------------------------------------- /reposerver/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/gin-gonic/gin" 14 | 15 | "github.com/accrescent/reposerver/api" 16 | "github.com/accrescent/reposerver/middleware" 17 | ) 18 | 19 | func main() { 20 | setMode() 21 | 22 | router := gin.New() 23 | router.Use(gin.Logger()) 24 | err := router.SetTrustedProxies(nil) 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | 29 | conf, err := loadConfig("/etc/reposerver/config.toml") 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | 34 | auth := router.Group("/", middleware.AuthRequired(conf.APIKey)) 35 | auth.Use(middleware.PublishDir(conf.PublishDir)) 36 | auth.POST("/api/apps/:id/:versionCode/:version", api.PublishApp) 37 | auth.PUT("/api/apps/:id/:versionCode/:version", api.UpdateApp) 38 | 39 | srv := &http.Server{ 40 | Addr: ":8080", 41 | Handler: router, 42 | } 43 | 44 | go func() { 45 | if err := srv.ListenAndServe(); err != nil && errors.Is(http.ErrServerClosed, err) { 46 | log.Println(err) 47 | } 48 | }() 49 | 50 | quit := make(chan os.Signal, 1) 51 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 52 | <-quit 53 | log.Println("Shutting down...") 54 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 55 | defer cancel() 56 | 57 | if err := srv.Shutdown(ctx); err != nil { 58 | log.Fatal("Shutting down forcefully:", err) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /ansible/upgrade.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Upgrade devconsole 3 | hosts: consoles 4 | become: yes 5 | gather_facts: no 6 | tags: console 7 | tasks: 8 | - name: Upgrade application 9 | ansible.builtin.copy: 10 | src: ./devconsole 11 | dest: /opt/devconsole/ 12 | mode: 0755 13 | - name: Upgrade static web files 14 | ansible.builtin.copy: 15 | src: ./dist/web/ 16 | dest: '/srv/{{ inventory_hostname }}/' 17 | tags: static 18 | - name: Install systemd service 19 | ansible.builtin.copy: 20 | src: ./devconsole.service 21 | dest: /usr/lib/systemd/system/devconsole.service 22 | - name: Restart systemd service 23 | ansible.builtin.systemd: 24 | name: devconsole 25 | daemon_reload: yes 26 | enabled: yes 27 | state: restarted 28 | 29 | - name: Upgrade reposerver 30 | hosts: repos 31 | become: yes 32 | gather_facts: no 33 | tags: repo 34 | tasks: 35 | - name: Upgrade application 36 | ansible.builtin.copy: 37 | src: ./reposerver 38 | dest: /opt/reposerver/ 39 | mode: 0755 40 | - name: Install systemd service 41 | ansible.builtin.template: 42 | src: reposerver.service.j2 43 | dest: /usr/lib/systemd/system/reposerver.service 44 | - name: Restart systemd service 45 | ansible.builtin.systemd: 46 | name: reposerver 47 | daemon_reload: yes 48 | enabled: yes 49 | state: restarted 50 | -------------------------------------------------------------------------------- /web/src/app/login/login.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute, Router } from '@angular/router'; 3 | 4 | import { AuthService } from '../auth.service'; 5 | 6 | @Component({ 7 | selector: 'app-login', 8 | templateUrl: './login.component.html', 9 | styleUrls: ['./login.component.css'], 10 | }) 11 | export class LoginComponent implements OnInit { 12 | constructor( 13 | private authService: AuthService, 14 | private activatedRoute: ActivatedRoute, 15 | private router: Router, 16 | ) {} 17 | 18 | ngOnInit(): void { 19 | this.activatedRoute.queryParams.subscribe(params => { 20 | this.authService.logIn(params['code'], params['state']).subscribe(res => { 21 | this.authService.loggedIn = res.logged_in; 22 | this.authService.registered = res.registered; 23 | this.authService.reviewer = res.reviewer; 24 | this.authService.publisher = res.publisher; 25 | 26 | if (this.authService.loggedIn) { 27 | if (this.authService.registered) { 28 | this.router.navigate(['dashboard']); 29 | } else { 30 | this.router.navigate(['register']); 31 | } 32 | } else { 33 | this.router.navigate(['unauthorized-register']); 34 | } 35 | }); 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /devconsole/api/util.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "errors" 7 | "io" 8 | "mime/multipart" 9 | 10 | "github.com/accrescent/apkstat" 11 | "google.golang.org/protobuf/proto" 12 | 13 | pb "github.com/accrescent/devconsole/pb" 14 | ) 15 | 16 | var ErrFatalIO = errors.New("fatal IO error") 17 | 18 | func openAPKSet( 19 | formFile *multipart.FileHeader, 20 | ) (*pb.BuildApksResult, *apk.APK, multipart.File, error) { 21 | file, err := formFile.Open() 22 | if err != nil { 23 | return nil, nil, nil, err 24 | } 25 | 26 | apkSet, err := zip.NewReader(file, formFile.Size) 27 | if err != nil { 28 | return nil, nil, nil, err 29 | } 30 | 31 | rawBaseAPK, err := apkSet.Open("splits/base-master.apk") 32 | if err != nil { 33 | return nil, nil, nil, err 34 | } 35 | baseAPK, err := io.ReadAll(rawBaseAPK) 36 | if err != nil { 37 | return nil, nil, nil, ErrFatalIO 38 | } 39 | apk, err := apk.FromReader(bytes.NewReader(baseAPK), int64(len(baseAPK))) 40 | if err != nil { 41 | return nil, nil, nil, err 42 | } 43 | 44 | metadataFile, err := apkSet.Open("toc.pb") 45 | if err != nil { 46 | return nil, nil, nil, err 47 | } 48 | defer metadataFile.Close() 49 | metadataData, err := io.ReadAll(metadataFile) 50 | if err != nil { 51 | return nil, nil, nil, err 52 | } 53 | metadata := new(pb.BuildApksResult) 54 | if err := proto.Unmarshal(metadataData, metadata); err != nil { 55 | return nil, nil, nil, err 56 | } 57 | 58 | return metadata, apk, file, nil 59 | } 60 | -------------------------------------------------------------------------------- /devconsole/api/publish_app.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/mattn/go-sqlite3" 9 | 10 | "github.com/accrescent/devconsole/data" 11 | "github.com/accrescent/devconsole/quality" 12 | ) 13 | 14 | func PublishApp(c *gin.Context) { 15 | db := c.MustGet("db").(data.DB) 16 | storage := c.MustGet("storage").(data.FileStorage) 17 | appID := c.Param("id") 18 | 19 | app, _, _, _, appHandle, _, err := db.GetSubmittedAppInfo(appID) 20 | if err != nil { 21 | _ = c.AbortWithError(http.StatusInternalServerError, err) 22 | return 23 | } 24 | 25 | // Publish to repository server 26 | if err := publish( 27 | c, 28 | appID, 29 | app.VersionCode, 30 | app.VersionName, 31 | quality.NewApp, 32 | appHandle, 33 | ); err != nil { 34 | _ = c.AbortWithError(http.StatusInternalServerError, err) 35 | return 36 | } 37 | 38 | // Delete local copy of app once it's published. Note we keep the icon so we can display it 39 | // to the developer. 40 | if err := storage.DeleteFile(appHandle); err != nil { 41 | _ = c.AbortWithError(http.StatusInternalServerError, err) 42 | return 43 | } 44 | 45 | if err := db.PublishApp(appID); err != nil { 46 | if errors.Is(err.(sqlite3.Error).ExtendedCode, sqlite3.ErrConstraintPrimaryKey) { 47 | _ = c.AbortWithError(http.StatusConflict, err) 48 | } else { 49 | _ = c.AbortWithError(http.StatusInternalServerError, err) 50 | } 51 | return 52 | } 53 | 54 | c.String(http.StatusOK, "") 55 | } 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # (Old) Accrescent Developer Console 2 | 3 | **NOTE: This project is no longer maintained. If you're looking for Accrescent's 4 | current developer console, see https://github.com/accrescent/parcelo.** 5 | 6 | The Accrescent developer console - a web application for developers to upload 7 | and manage their apps in the Accrescent app store. 8 | 9 | ## Development/testing 10 | 11 | To set up the development/testing environment for the developer console, follow 12 | these steps: 13 | 14 | 1. Create an OAuth app from the developer settings of your GitHub account or 15 | organization. Set the homepage URL to `https://localhost:8080` and the 16 | authorization callback URL to `https://localhost:8080/auth/github/callback`. 17 | 2. Generate a new client secret and store it in `devconsole/.env` as 18 | `GH_CLIENT_SECRET`. Store the app's client ID as `GH_CLIENT_ID`. Store the 19 | authorization callback URL as `OAUTH2_REDIRECT_URL`. 20 | 3. Set `SIGNER_GH_ID` to the value of the `id` field from 21 | `https://api.github.com/users/`. 22 | 4. Set `REPO_URL` to `http://repo:8080`. 23 | 5. Set `API_KEY` to the same string in both `devconsole/.env` and 24 | `reposerver/.env`. 25 | 6. Set `PUBLISH_DIR` in `reposerver/.env` to a folder name such as `/apps`. This 26 | directory is internal to the container. 27 | 7. Generate a TLS certificate & key and store them as `certs/cert.pem` & 28 | `certs/key.pem` respectively. 29 | 8. Start the application by running `docker compose up` 30 | 9. The web application is now accessible at `https://localhost:8080` 31 | -------------------------------------------------------------------------------- /reposerver/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/accrescent/reposerver 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.3.2 7 | github.com/gin-gonic/gin v1.9.1 8 | golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 9 | ) 10 | 11 | require ( 12 | github.com/bytedance/sonic v1.10.0 // indirect 13 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect 14 | github.com/chenzhuoyu/iasm v0.9.0 // indirect 15 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 16 | github.com/gin-contrib/sse v0.1.0 // indirect 17 | github.com/go-playground/locales v0.14.1 // indirect 18 | github.com/go-playground/universal-translator v0.18.1 // indirect 19 | github.com/go-playground/validator/v10 v10.15.3 // indirect 20 | github.com/goccy/go-json v0.10.2 // indirect 21 | github.com/json-iterator/go v1.1.12 // indirect 22 | github.com/klauspost/cpuid/v2 v2.2.5 // indirect 23 | github.com/leodido/go-urn v1.2.4 // indirect 24 | github.com/mattn/go-isatty v0.0.19 // indirect 25 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 26 | github.com/modern-go/reflect2 v1.0.2 // indirect 27 | github.com/pelletier/go-toml/v2 v2.1.0 // indirect 28 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 29 | github.com/ugorji/go/codec v1.2.11 // indirect 30 | golang.org/x/arch v0.4.0 // indirect 31 | golang.org/x/crypto v0.12.0 // indirect 32 | golang.org/x/net v0.14.0 // indirect 33 | golang.org/x/sys v0.11.0 // indirect 34 | golang.org/x/text v0.12.0 // indirect 35 | google.golang.org/protobuf v1.31.0 // indirect 36 | gopkg.in/yaml.v3 v3.0.1 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test", 10 | "lint": "ng lint" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "^15.2.9", 15 | "@angular/cdk": "~15.2.9", 16 | "@angular/common": "^15.2.9", 17 | "@angular/compiler": "^15.2.9", 18 | "@angular/core": "^15.2.9", 19 | "@angular/forms": "^15.2.9", 20 | "@angular/material": "~15.2.9", 21 | "@angular/platform-browser": "^15.2.9", 22 | "@angular/platform-browser-dynamic": "^15.2.9", 23 | "@angular/router": "^15.2.9", 24 | "rxjs": "~7.8.1", 25 | "tslib": "^2.5.0", 26 | "zone.js": "~0.13.0" 27 | }, 28 | "devDependencies": { 29 | "@angular-devkit/build-angular": "^15.2.8", 30 | "@angular-eslint/builder": "15.2.1", 31 | "@angular-eslint/eslint-plugin": "15.2.1", 32 | "@angular-eslint/eslint-plugin-template": "15.2.1", 33 | "@angular-eslint/schematics": "15.2.1", 34 | "@angular-eslint/template-parser": "15.2.1", 35 | "@angular/cli": "~15.2.8", 36 | "@angular/compiler-cli": "^15.2.9", 37 | "@types/jasmine": "~4.3.1", 38 | "@typescript-eslint/eslint-plugin": "^5.59.8", 39 | "@typescript-eslint/parser": "^5.59.8", 40 | "eslint": "^8.41.0", 41 | "jasmine-core": "~5.1.0", 42 | "karma": "~6.4.2", 43 | "karma-chrome-launcher": "~3.2.0", 44 | "karma-coverage": "~2.2.0", 45 | "karma-jasmine": "~5.1.0", 46 | "karma-jasmine-html-reporter": "~2.1.0", 47 | "typescript": "~4.9.5" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /devconsole/api/approve_update.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "net/http" 7 | "strconv" 8 | 9 | "github.com/gin-gonic/gin" 10 | 11 | "github.com/accrescent/devconsole/data" 12 | "github.com/accrescent/devconsole/quality" 13 | ) 14 | 15 | func ApproveUpdate(c *gin.Context) { 16 | db := c.MustGet("db").(data.DB) 17 | storage := c.MustGet("storage").(data.FileStorage) 18 | appID := c.Param("id") 19 | versionCode, err := strconv.Atoi(c.Param("version")) 20 | if err != nil { 21 | _ = c.AbortWithError(http.StatusBadRequest, err) 22 | return 23 | } 24 | 25 | firstUpdate, versionName, appHandle, issueGroupID, err := db.GetUpdateInfo(appID, versionCode) 26 | if err != nil { 27 | if errors.Is(err, sql.ErrNoRows) { 28 | _ = c.AbortWithError(http.StatusNotFound, err) 29 | } else { 30 | _ = c.AbortWithError(http.StatusInternalServerError, err) 31 | } 32 | return 33 | } 34 | // Prohibit approving updates out-of-order 35 | if versionCode != firstUpdate { 36 | c.AbortWithStatus(http.StatusConflict) 37 | return 38 | } 39 | 40 | if err := publish( 41 | c, 42 | appID, 43 | int32(versionCode), 44 | versionName, 45 | quality.Update, 46 | appHandle, 47 | ); err != nil { 48 | _ = c.AbortWithError(http.StatusInternalServerError, err) 49 | return 50 | } 51 | 52 | // Delete local copy of update once it's published 53 | if err := storage.DeleteFile(appHandle); err != nil { 54 | _ = c.AbortWithError(http.StatusInternalServerError, err) 55 | return 56 | } 57 | 58 | if err := db.ApproveUpdate(appID, versionCode, versionName, issueGroupID); err != nil { 59 | _ = c.AbortWithError(http.StatusInternalServerError, err) 60 | return 61 | } 62 | 63 | c.String(http.StatusOK, "") 64 | } 65 | -------------------------------------------------------------------------------- /devconsole/data/local_storage.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "io" 5 | "mime/multipart" 6 | "os" 7 | ) 8 | 9 | type LocalStorage struct { 10 | baseDir string 11 | } 12 | 13 | func NewLocalStorage(baseDir string) *LocalStorage { 14 | return &LocalStorage{baseDir} 15 | } 16 | 17 | func (s *LocalStorage) SaveNewApp( 18 | apkSet multipart.File, 19 | icon multipart.File, 20 | ) (apkSetHandle string, iconHandle string, err error) { 21 | appFile, err := os.CreateTemp(s.baseDir, "*.apks") 22 | if err != nil { 23 | return "", "", err 24 | } 25 | defer appFile.Close() 26 | iconFile, err := os.CreateTemp(s.baseDir, "*.png") 27 | if err != nil { 28 | return "", "", err 29 | } 30 | defer iconFile.Close() 31 | 32 | if _, err := io.Copy(appFile, apkSet); err != nil { 33 | return "", "", err 34 | } 35 | if _, err := io.Copy(iconFile, icon); err != nil { 36 | return "", "", err 37 | } 38 | 39 | return appFile.Name(), iconFile.Name(), nil 40 | } 41 | 42 | func (s *LocalStorage) SaveUpdate(apkSet multipart.File) (apkSetHandle string, err error) { 43 | appFile, err := os.CreateTemp(s.baseDir, "*.apks") 44 | if err != nil { 45 | return "", err 46 | } 47 | defer appFile.Close() 48 | 49 | if _, err := io.Copy(appFile, apkSet); err != nil { 50 | return "", err 51 | } 52 | 53 | return appFile.Name(), nil 54 | } 55 | 56 | func (s *LocalStorage) GetAPKSet(apkSetHandle string) (file io.Reader, size int64, err error) { 57 | apkSet, err := os.Open(apkSetHandle) 58 | if err != nil { 59 | return nil, 0, err 60 | } 61 | apkSetInfo, err := apkSet.Stat() 62 | if err != nil { 63 | return nil, 0, err 64 | } 65 | 66 | return apkSet, apkSetInfo.Size(), nil 67 | } 68 | 69 | func (s *LocalStorage) DeleteFile(handle string) error { 70 | return os.Remove(handle) 71 | } 72 | -------------------------------------------------------------------------------- /web/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | jasmine: { 17 | // you can add configuration options for Jasmine here 18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 19 | // for example, you can disable the random execution with `random: false` 20 | // or set a specific seed with `seed: 4321` 21 | }, 22 | clearContext: false // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | jasmineHtmlReporter: { 25 | suppressAll: true // removes the duplicated traces 26 | }, 27 | coverageReporter: { 28 | dir: require('path').join(__dirname, './coverage/web'), 29 | subdir: '.', 30 | reporters: [ 31 | { type: 'html' }, 32 | { type: 'text-summary' } 33 | ] 34 | }, 35 | reporters: ['progress', 'kjhtml'], 36 | port: 9876, 37 | colors: true, 38 | logLevel: config.LOG_INFO, 39 | autoWatch: true, 40 | browsers: ['Chrome'], 41 | singleRun: false, 42 | restartOnFileChange: true, 43 | customHeaders: [{ 44 | match: '.*', 45 | name: 'Content-Security-Policy', 46 | value: "trusted-types angular; require-trusted-types-for 'script';" 47 | }] 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /web/src/app/review/review.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { App } from '../app'; 4 | import { AppService } from '../app.service'; 5 | 6 | @Component({ 7 | selector: 'app-review', 8 | templateUrl: './review.component.html', 9 | styleUrls: ['./review.component.css'] 10 | }) 11 | export class ReviewComponent implements OnInit { 12 | apps: App[] = []; 13 | updates: App[] = []; 14 | 15 | constructor(private appService: AppService) {} 16 | 17 | ngOnInit(): void { 18 | this.appService.getPendingApps().subscribe(apps => this.apps = apps); 19 | this.appService.getUpdates().subscribe(updates => this.updates = updates); 20 | } 21 | 22 | approveApp(appId: string): void { 23 | this.appService.approveApp(appId).subscribe(_ => this.removeApp(appId)); 24 | } 25 | 26 | rejectApp(appId: string): void { 27 | this.appService.rejectApp(appId).subscribe(_ => this.removeApp(appId)); 28 | } 29 | 30 | approveUpdate(appId: string, versionCode: number): void { 31 | this.appService.approveUpdate(appId, versionCode) 32 | .subscribe(_ => this.removeUpdate(appId, versionCode)); 33 | } 34 | 35 | rejectUpdate(appId: string, versionCode: number): void { 36 | this.appService.rejectUpdate(appId, versionCode) 37 | .subscribe(_ => this.removeUpdate(appId, versionCode)); 38 | } 39 | 40 | 41 | private removeApp(appId: string): void { 42 | const i = this.apps.findIndex(a => a.app_id === appId); 43 | if (i > -1) { 44 | this.apps.splice(i, 1); 45 | } 46 | } 47 | 48 | private removeUpdate(appId: string, versionCode: number): void { 49 | const i = this.updates.findIndex(u => u.app_id === appId && u.version_code === versionCode); 50 | if (i > -1) { 51 | this.updates.splice(i, 1); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /web/src/app/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient, HttpParams } from '@angular/common/http'; 3 | 4 | import { Observable } from 'rxjs'; 5 | 6 | import { LoginResult } from './login-result'; 7 | 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class AuthService { 12 | private readonly authCallbackUrl = 'api/auth/github/callback'; 13 | private readonly sessionUrl = 'api/session'; 14 | 15 | constructor(private http: HttpClient) {} 16 | 17 | logIn(code: string, state: string): Observable { 18 | const params = new HttpParams().append('code', code).append('state', state); 19 | 20 | return this.http.get(this.authCallbackUrl, { params }); 21 | } 22 | 23 | logOut(): Observable { 24 | localStorage.removeItem('loggedIn'); 25 | localStorage.removeItem('registered'); 26 | localStorage.removeItem('reviewer'); 27 | localStorage.removeItem('publisher'); 28 | 29 | return this.http.delete(this.sessionUrl); 30 | } 31 | 32 | get loggedIn(): boolean { 33 | return localStorage.getItem('loggedIn') === 'true'; 34 | } 35 | 36 | set loggedIn(loggedIn: boolean) { 37 | localStorage.setItem('loggedIn', String(loggedIn)); 38 | } 39 | 40 | get registered(): boolean { 41 | return localStorage.getItem('registered') === 'true'; 42 | } 43 | 44 | set registered(registered: boolean) { 45 | localStorage.setItem('registered', String(registered)); 46 | } 47 | 48 | get reviewer(): boolean { 49 | return localStorage.getItem('reviewer') === 'true'; 50 | } 51 | 52 | set reviewer(reviewer: boolean) { 53 | localStorage.setItem('reviewer', String(reviewer)); 54 | } 55 | 56 | get publisher(): boolean { 57 | return localStorage.getItem('publisher') === 'true'; 58 | } 59 | 60 | set publisher(publisher: boolean) { 61 | localStorage.setItem('publisher', String(publisher)); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /devconsole/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/accrescent/devconsole 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/accrescent/apkstat v0.1.4 7 | github.com/gin-gonic/gin v1.9.1 8 | github.com/google/go-github/v52 v52.0.0 9 | github.com/mattn/go-sqlite3 v1.14.17 10 | golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 11 | golang.org/x/mod v0.12.0 12 | golang.org/x/oauth2 v0.11.0 13 | google.golang.org/protobuf v1.31.0 14 | ) 15 | 16 | require ( 17 | github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect 18 | github.com/bytedance/sonic v1.10.0 // indirect 19 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect 20 | github.com/chenzhuoyu/iasm v0.9.0 // indirect 21 | github.com/cloudflare/circl v1.3.3 // indirect 22 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 23 | github.com/gin-contrib/sse v0.1.0 // indirect 24 | github.com/go-playground/locales v0.14.1 // indirect 25 | github.com/go-playground/universal-translator v0.18.1 // indirect 26 | github.com/go-playground/validator/v10 v10.15.3 // indirect 27 | github.com/goccy/go-json v0.10.2 // indirect 28 | github.com/golang/protobuf v1.5.3 // indirect 29 | github.com/google/go-querystring v1.1.0 // indirect 30 | github.com/json-iterator/go v1.1.12 // indirect 31 | github.com/klauspost/cpuid/v2 v2.2.5 // indirect 32 | github.com/leodido/go-urn v1.2.4 // indirect 33 | github.com/mattn/go-isatty v0.0.19 // indirect 34 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 35 | github.com/modern-go/reflect2 v1.0.2 // indirect 36 | github.com/pelletier/go-toml/v2 v2.1.0 // indirect 37 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 38 | github.com/ugorji/go/codec v1.2.11 // indirect 39 | golang.org/x/arch v0.4.0 // indirect 40 | golang.org/x/crypto v0.12.0 // indirect 41 | golang.org/x/net v0.14.0 // indirect 42 | golang.org/x/sys v0.11.0 // indirect 43 | golang.org/x/text v0.12.0 // indirect 44 | google.golang.org/appengine v1.6.7 // indirect 45 | gopkg.in/yaml.v3 v3.0.1 // indirect 46 | ) 47 | -------------------------------------------------------------------------------- /web/src/app/app-list/app-list.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ app.label }} 4 | {{ app.app_id }} 5 | 6 | 7 |
8 |

App info

9 |
    10 |
  • Version code: {{ app.version_code }}
  • 11 |
  • Version name: {{ app.version_name }}
  • 12 |
13 |
14 | 15 | 16 | 19 | 20 |
21 | 22 | 23 | 24 | {{ submitted_app.label }} 25 | {{ submitted_app.app_id}} 26 | 27 | 28 |
29 |

Submitted app info

30 |
    31 |
  • Version code: {{ submitted_app.version_code }}
  • 32 |
  • Version name: {{ submitted_app.version_name }}
  • 33 |
34 |
35 |
36 | 37 | 38 | 39 | {{ update.label }} 40 | {{ update.app_id}} 41 | 42 | 43 |
44 |

Review issues

45 |
    46 |
  • {{ issue }}
  • 47 |
48 |

Submitted update info

49 |
    50 |
  • Version code: {{ update.version_code }}
  • 51 |
  • Version name: {{ update.version_name }}
  • 52 |
53 |
54 |
55 | -------------------------------------------------------------------------------- /web/src/app/new-update-form/new-update-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { HttpEventType, HttpResponse } from '@angular/common/http'; 3 | import { NonNullableFormBuilder } from '@angular/forms'; 4 | import { ActivatedRoute, Router } from '@angular/router'; 5 | 6 | import { App } from '../app'; 7 | import { AppService } from '../app.service'; 8 | 9 | @Component({ 10 | selector: 'app-new-update-form', 11 | templateUrl: './new-update-form.component.html', 12 | styleUrls: ['./new-update-form.component.css'] 13 | }) 14 | export class NewUpdateFormComponent implements OnInit { 15 | private appId = ""; 16 | app: App | undefined = undefined; 17 | form = this.fb.group({}); 18 | uploadProgress = 0; 19 | 20 | constructor( 21 | private fb: NonNullableFormBuilder, 22 | private appService: AppService, 23 | private router: Router, 24 | private activatedRoute: ActivatedRoute, 25 | ) {} 26 | 27 | ngOnInit(): void { 28 | this.activatedRoute.paramMap.subscribe(params => this.appId = params.get('id') ?? ""); 29 | } 30 | 31 | onFileChange(event: Event): void { 32 | const file = (event.target as HTMLInputElement).files?.[0]; 33 | 34 | if (file !== undefined) { 35 | this.appService.uploadUpdate(file, this.appId).subscribe(event => { 36 | if (event.type === HttpEventType.UploadProgress) { 37 | this.uploadProgress = 100 * event.loaded / event.total!!; 38 | 39 | // Clear the progress bar once the upload is complete 40 | if (event.loaded === event.total!!) { 41 | this.uploadProgress = 0; 42 | } 43 | } else if (event instanceof HttpResponse) { 44 | this.app = event.body!!; 45 | } 46 | }); 47 | } 48 | } 49 | 50 | onSubmit(): void { 51 | this.appService.submitUpdate(this.app!.app_id, this.app!.version_code) 52 | .subscribe(_ => this.router.navigate(['apps'])); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/backend.yaml: -------------------------------------------------------------------------------- 1 | name: Backend 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 10 | - name: Set up Go 11 | uses: actions/setup-go@v4 12 | with: 13 | go-version: 1.19 14 | - name: Install protoc 15 | run: | 16 | sudo apt-get install protobuf-compiler 17 | go install google.golang.org/protobuf/cmd/protoc-gen-go@latest 18 | - name: Build devconsole 19 | run: cd devconsole && go generate -v && go build -v 20 | - name: Build reposerver 21 | run: cd reposerver && go generate -v && go build -v 22 | lint: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 26 | - name: Set up Go 27 | uses: actions/setup-go@v4 28 | with: 29 | go-version: 1.19 30 | - name: Install protoc 31 | run: | 32 | sudo apt-get install protobuf-compiler 33 | go install google.golang.org/protobuf/cmd/protoc-gen-go@latest 34 | - name: Generate source 35 | run: cd devconsole && go generate 36 | - name: Lint devconsole 37 | uses: golangci/golangci-lint-action@v3 38 | with: 39 | working-directory: devconsole 40 | args: --timeout 2m 41 | - name: Lint reposerver 42 | uses: golangci/golangci-lint-action@v3 43 | with: 44 | working-directory: reposerver 45 | args: --timeout 2m 46 | test: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 50 | - name: Set up Go 51 | uses: actions/setup-go@v4 52 | with: 53 | go-version: 1.19 54 | - name: Install protoc 55 | run: | 56 | sudo apt-get install protobuf-compiler 57 | go install google.golang.org/protobuf/cmd/protoc-gen-go@latest 58 | - name: Test devconsole 59 | run: cd devconsole && go generate && go test -v ./... 60 | -------------------------------------------------------------------------------- /devconsole/api/submit_update.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "net/http" 7 | "strconv" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/mattn/go-sqlite3" 11 | 12 | "github.com/accrescent/devconsole/data" 13 | "github.com/accrescent/devconsole/quality" 14 | ) 15 | 16 | func SubmitUpdate(c *gin.Context) { 17 | db := c.MustGet("db").(data.DB) 18 | storage := c.MustGet("storage").(data.FileStorage) 19 | ghID := c.MustGet("gh_id").(int64) 20 | appID := c.Param("id") 21 | versionCode, err := strconv.Atoi(c.Param("version")) 22 | if err != nil { 23 | _ = c.AbortWithError(http.StatusBadRequest, err) 24 | return 25 | } 26 | 27 | label, versionName, fileHandle, issueGroupID, needsReview, err := db.GetStagingUpdateInfo( 28 | appID, 29 | int32(versionCode), 30 | ghID, 31 | ) 32 | if err != nil { 33 | if errors.Is(err, sql.ErrNoRows) { 34 | _ = c.AbortWithError(http.StatusNotFound, err) 35 | } else { 36 | _ = c.AbortWithError(http.StatusInternalServerError, err) 37 | } 38 | return 39 | } 40 | 41 | if !needsReview { 42 | // No review necessary, so publish the update immediately. 43 | if err := publish( 44 | c, 45 | appID, 46 | int32(versionCode), 47 | versionName, 48 | quality.Update, 49 | fileHandle, 50 | ); err != nil { 51 | _ = c.AbortWithError(http.StatusInternalServerError, err) 52 | return 53 | } 54 | 55 | // Delete update locally after publishing 56 | if err := storage.DeleteFile(fileHandle); err != nil { 57 | _ = c.AbortWithError(http.StatusInternalServerError, err) 58 | return 59 | } 60 | } 61 | 62 | app := data.App{ 63 | AppID: appID, 64 | Label: label, 65 | VersionCode: int32(versionCode), 66 | VersionName: versionName, 67 | } 68 | if err := db.SubmitUpdate(app, fileHandle, issueGroupID, needsReview); err != nil { 69 | if errors.Is(err.(sqlite3.Error).ExtendedCode, sqlite3.ErrConstraintUnique) { 70 | _ = c.AbortWithError(http.StatusConflict, err) 71 | } else { 72 | _ = c.AbortWithError(http.StatusInternalServerError, err) 73 | } 74 | return 75 | } 76 | 77 | c.String(http.StatusOK, "") 78 | } 79 | -------------------------------------------------------------------------------- /devconsole/api/publish.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/gin-gonic/gin" 9 | 10 | "github.com/accrescent/devconsole/config" 11 | "github.com/accrescent/devconsole/data" 12 | "github.com/accrescent/devconsole/quality" 13 | ) 14 | 15 | func publish( 16 | c *gin.Context, appID string, versionCode int32, versionName string, 17 | uploadType quality.UploadType, appFileHandle string, 18 | ) error { 19 | storage := c.MustGet("storage").(data.FileStorage) 20 | conf := c.MustGet("config").(config.Config) 21 | 22 | var method string 23 | if uploadType == quality.NewApp { 24 | method = http.MethodPost 25 | } else if uploadType == quality.Update { 26 | method = http.MethodPut 27 | } 28 | 29 | file, size, err := storage.GetAPKSet(appFileHandle) 30 | if err != nil { 31 | _ = c.AbortWithError(http.StatusInternalServerError, err) 32 | return err 33 | } 34 | 35 | req, err := http.NewRequest( 36 | method, 37 | fmt.Sprintf("%s/api/apps/%s/%d/%s", conf.RepoURL, appID, versionCode, versionName), 38 | file, 39 | ) 40 | if err != nil { 41 | _ = c.AbortWithError(http.StatusInternalServerError, err) 42 | return err 43 | } 44 | req.Header.Add("Authorization", "token "+conf.APIKey) 45 | req.ContentLength = size 46 | resp, err := http.DefaultClient.Do(req) 47 | if err != nil { 48 | _ = c.AbortWithError(http.StatusInternalServerError, err) 49 | return err 50 | } 51 | defer resp.Body.Close() 52 | if resp.StatusCode != http.StatusOK { 53 | var err error 54 | switch resp.StatusCode { 55 | case http.StatusBadRequest: 56 | err = errors.New("bad request") 57 | c.AbortWithStatus(http.StatusInternalServerError) 58 | case http.StatusUnauthorized: 59 | err = errors.New("invalid repo server API key") 60 | _ = c.AbortWithError(http.StatusInternalServerError, err) 61 | case http.StatusConflict: 62 | err = errors.New("app already published") 63 | _ = c.AbortWithError(resp.StatusCode, err) 64 | default: 65 | err = errors.New("unknown error") 66 | _ = c.AbortWithError(http.StatusInternalServerError, err) 67 | } 68 | return err 69 | } 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /web/src/app/review/review.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ app.label }} 4 | {{ app.app_id }} 5 | 6 | 7 |
8 |

Review issues

9 |
    10 |
  • {{ issue }}
  • 11 |
  • None
  • 12 |
13 | Download 14 |
15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | {{ update.label }} 24 | {{ update.app_id }} 25 | 26 | 27 |
28 |

Review issues

29 |
    30 |
  • {{ issue }}
  • 31 |
  • None
  • 32 |
33 |

Update info

34 |
    35 |
  • Version code: {{ update.version_code }}
  • 36 |
  • Version name: {{ update.version_name }}
  • 37 |
38 | 41 | Download 42 | 43 |
44 | 45 | 48 | 54 | 55 |
56 | -------------------------------------------------------------------------------- /web/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { AppInfoComponent } from './app-info/app-info.component'; 5 | import { AppListComponent } from './app-list/app-list.component'; 6 | import { DashboardComponent } from './dashboard/dashboard.component'; 7 | import { NewAppComponent } from './new-app/new-app.component'; 8 | import { NewUpdateComponent } from './new-update/new-update.component'; 9 | import { LoginComponent } from './login/login.component'; 10 | import { RegisterComponent } from './register/register.component'; 11 | import { ReviewComponent } from './review/review.component'; 12 | import { PublishComponent } from './publish/publish.component'; 13 | import { LandingComponent } from './landing/landing.component'; 14 | import { ConsoleLayoutComponent } from './console-layout/console-layout.component'; 15 | import { UnauthorizedRegisterComponent } from './unauthorized-register/unauthorized-register.component'; 16 | import { AuthGuard } from './auth.guard'; 17 | import { ReviewerGuard } from './reviewer.guard'; 18 | import { PublisherGuard } from './publisher.guard'; 19 | 20 | const routes: Routes = [ 21 | { path: '', component: LandingComponent }, 22 | { path: 'auth/github/callback', component: LoginComponent }, 23 | { path: 'register', component: RegisterComponent }, 24 | { path: '', component: ConsoleLayoutComponent, canActivate: [AuthGuard], children: [ 25 | { path: 'dashboard', component: DashboardComponent }, 26 | { path: 'apps', component: AppListComponent, }, 27 | { path: 'apps/:id', component: AppInfoComponent }, 28 | { path: 'apps/:id/update', component: NewUpdateComponent }, 29 | { path: 'new-app', component: NewAppComponent }, 30 | { path: 'review', component: ReviewComponent, canActivate: [ReviewerGuard] }, 31 | { path: 'publish', component: PublishComponent, canActivate: [PublisherGuard] }, 32 | ] }, 33 | { path: 'unauthorized-register', component: UnauthorizedRegisterComponent }, 34 | ]; 35 | 36 | @NgModule({ 37 | imports: [RouterModule.forRoot(routes)], 38 | exports: [RouterModule] 39 | }) 40 | export class AppRoutingModule { } 41 | -------------------------------------------------------------------------------- /web/src/app/new-app-form/new-app-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { HttpEventType, HttpResponse } from '@angular/common/http'; 3 | import { NonNullableFormBuilder, Validators } from '@angular/forms'; 4 | import { Router } from '@angular/router'; 5 | 6 | import { App } from '../app'; 7 | import { AppService } from '../app.service'; 8 | 9 | @Component({ 10 | selector: 'app-new-app-form', 11 | templateUrl: './new-app-form.component.html', 12 | styleUrls: ['./new-app-form.component.css'] 13 | }) 14 | export class NewAppFormComponent { 15 | app: App | undefined = undefined; 16 | uploadForm = this.fb.group({ 17 | app: ['', Validators.required], 18 | icon: ['', Validators.required], 19 | }); 20 | uploadProgress = 0; 21 | confirmationForm = this.fb.group({ 22 | label: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(30)]], 23 | }); 24 | 25 | constructor( 26 | private fb: NonNullableFormBuilder, 27 | private appService: AppService, 28 | private router: Router, 29 | ) {} 30 | 31 | onUpload(): void { 32 | const app = (document.getElementById("app")).files?.[0]; 33 | const icon = (document.getElementById("icon")).files?.[0]; 34 | 35 | if (app !== undefined && icon !== undefined) { 36 | this.appService.uploadApp(app, icon).subscribe(event => { 37 | if (event.type === HttpEventType.UploadProgress) { 38 | this.uploadProgress = 100 * event.loaded / event.total!!; 39 | 40 | // Clear the progress bar once the upload is complete 41 | if (event.loaded === event.total!!) { 42 | this.uploadProgress = 0; 43 | } 44 | } else if (event instanceof HttpResponse) { 45 | this.app = event.body!!; 46 | this.confirmationForm.patchValue({ label: this.app.label }); 47 | } 48 | }); 49 | } 50 | } 51 | 52 | onConfirm(): void { 53 | const label = this.confirmationForm.getRawValue().label; 54 | this.appService.submitApp(this.app!.app_id, label) 55 | .subscribe(_ => this.router.navigate(['dashboard'])); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /web/src/app/new-app-form/new-app-form.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 10 |

{{ uploadForm.value.app?.replace("C:\\fakepath\\", "") }}

11 |
12 | 13 | 20 |
Icon must be a 512 x 512 PNG
21 |

{{ uploadForm.value.icon?.replace("C:\\fakepath\\", "") }}

22 | 23 | 24 |
25 | 26 | 33 |
34 |
35 | 36 |
37 |

App info

38 | 39 | App ID 40 | 41 | 42 |
43 | 44 | Display name 45 | 46 | 47 |
48 | 49 | Version code 50 | 51 | 52 |
53 | 54 | Version name 55 | 56 | 57 |
58 | 59 | 66 |
67 | -------------------------------------------------------------------------------- /devconsole/quality/reject_tests.go: -------------------------------------------------------------------------------- 1 | package quality 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "path" 7 | 8 | "github.com/accrescent/apkstat" 9 | "golang.org/x/mod/semver" 10 | 11 | "github.com/accrescent/devconsole/data" 12 | pb "github.com/accrescent/devconsole/pb" 13 | ) 14 | 15 | func RunRejectTests( 16 | metadata *pb.BuildApksResult, 17 | apk *apk.APK, 18 | sdkException *data.SdkException, 19 | uploadType UploadType, 20 | ) error { 21 | manifest := apk.Manifest() 22 | 23 | // Version name (for later URL path construction) 24 | if "/"+manifest.VersionName != path.Clean("/"+manifest.VersionName) { 25 | return errors.New("invalid version name") 26 | } 27 | 28 | // Bundletool version used to generate APK set 29 | bundletoolVersion := metadata.GetBundletool().GetVersion() 30 | if semver.Compare("v"+bundletoolVersion, "v"+MIN_BUNDLETOOL_VERSION) == -1 { 31 | return fmt.Errorf( 32 | "APK set generated with bundletool %s but mininum supported version is %s", 33 | bundletoolVersion, 34 | MIN_BUNDLETOOL_VERSION, 35 | ) 36 | } 37 | 38 | targetSDK := manifest.UsesSDK.TargetSDKVersion 39 | 40 | // Target SDK 41 | switch { 42 | case targetSDK == nil: 43 | return errors.New("Required field 'targetSdk' not found") 44 | case uploadType == NewApp && *targetSDK < MIN_TARGET_SDK_NEW_APP: 45 | return fmt.Errorf( 46 | "App target SDK is %d but the minimum is %d", 47 | *targetSDK, MIN_TARGET_SDK_NEW_APP, 48 | ) 49 | case uploadType == Update: 50 | switch { 51 | case sdkException != nil && *targetSDK >= sdkException.MinTargetSdk: 52 | break 53 | case *targetSDK < MIN_TARGET_SDK_UPDATE: 54 | return fmt.Errorf( 55 | "App target SDK is %d but the minimum is %d", 56 | *targetSDK, MIN_TARGET_SDK_UPDATE, 57 | ) 58 | } 59 | } 60 | 61 | // android:debuggable 62 | if manifest.Application.Debuggable != nil && *manifest.Application.Debuggable { 63 | return errors.New("android:debuggable should not be set to true") 64 | } 65 | 66 | // android:testOnly 67 | if manifest.Application.TestOnly != nil && *manifest.Application.TestOnly { 68 | return errors.New("android:testOnly should not be set to true") 69 | } 70 | 71 | // android:usesCleartextTraffic 72 | usesCleartextTraffic := manifest.Application.UsesCleartextTraffic 73 | if usesCleartextTraffic != nil && *usesCleartextTraffic { 74 | return errors.New("android:usesCleartextTraffic should not be set to true") 75 | } 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /web/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes recent versions of Safari, Chrome (including 12 | * Opera), Edge on the desktop, and iOS and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** 22 | * By default, zone.js will patch all possible macroTask and DomEvents 23 | * user can disable parts of macroTask/DomEvents patch by setting following flags 24 | * because those flags need to be set before `zone.js` being loaded, and webpack 25 | * will put import in the top of bundle, so user need to create a separate file 26 | * in this directory (for example: zone-flags.ts), and put the following flags 27 | * into that file, and then add the following code before importing zone.js. 28 | * import './zone-flags'; 29 | * 30 | * The flags allowed in zone-flags.ts are listed here. 31 | * 32 | * The following flags will work for all browsers. 33 | * 34 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 35 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 36 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 37 | * 38 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 39 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 40 | * 41 | * (window as any).__Zone_enable_cross_context_check = true; 42 | * 43 | */ 44 | 45 | /*************************************************************************************************** 46 | * Zone JS is required by default for Angular itself. 47 | */ 48 | import 'zone.js'; // Included with Angular CLI. 49 | 50 | 51 | /*************************************************************************************************** 52 | * APPLICATION IMPORTS 53 | */ 54 | -------------------------------------------------------------------------------- /nginx/dev.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | 4 | events { 5 | worker_connections 4096; 6 | } 7 | 8 | http { 9 | include mime.types; 10 | default_type application/octet-stream; 11 | 12 | charset utf-8; 13 | 14 | sendfile on; 15 | sendfile_max_chunk 512k; 16 | tcp_nopush on; 17 | keepalive_timeout 3m; 18 | server_tokens off; 19 | msie_padding off; 20 | 21 | client_max_body_size 1k; 22 | client_body_buffer_size 1k; 23 | client_header_buffer_size 1k; 24 | large_client_header_buffers 4 4k; 25 | http2_recv_buffer_size 128k; 26 | 27 | client_body_timeout 30s; 28 | client_header_timeout 30s; 29 | send_timeout 30s; 30 | 31 | http2_max_concurrent_streams 32; 32 | limit_conn_status 429; 33 | limit_conn_zone $binary_remote_addr zone=addr:10m; 34 | limit_conn addr 256; 35 | 36 | ssl_protocols TLSv1.2 TLSv1.3; 37 | ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256; 38 | ssl_prefer_server_ciphers on; 39 | ssl_conf_command Options PrioritizeChaCha; 40 | 41 | ssl_certificate /etc/nginx/certs/cert.pem; 42 | ssl_certificate_key /etc/nginx/certs/key.pem; 43 | 44 | ssl_session_cache shared:SSL:10m; 45 | ssl_session_timeout 1d; 46 | ssl_buffer_size 4k; 47 | 48 | log_format main '$remote_addr - $remote_user [$time_local] ' 49 | '"$request_method $scheme://$host$request_uri $server_protocol" $status $body_bytes_sent ' 50 | '"$http_referer" "$http_user_agent"'; 51 | access_log /var/log/nginx/access.log main buffer=64k flush=1m; 52 | error_log syslog:server=unix:/dev/log,nohostname; 53 | log_not_found off; 54 | 55 | gzip_proxied any; 56 | gzip_vary on; 57 | 58 | # Static files & reverse proxy to web application 59 | server { 60 | listen 443 ssl http2; 61 | listen [::]:443 ssl http2; 62 | server_name localhost; 63 | root /usr/share/nginx/html; 64 | include security.conf; 65 | 66 | index index.html; 67 | 68 | location = "/api/logout" { 69 | proxy_pass http://console:8080; 70 | } 71 | 72 | location = "/auth/github" { 73 | proxy_pass http://console:8080; 74 | } 75 | 76 | location /api/apps { 77 | client_max_body_size 128M; 78 | proxy_pass http://console:8080; 79 | } 80 | 81 | location ^~ /api/ { 82 | proxy_pass http://console:8080; 83 | } 84 | 85 | location / { 86 | proxy_pass http://web; 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /devconsole/data/db.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "golang.org/x/oauth2" 5 | 6 | "github.com/accrescent/devconsole/config" 7 | ) 8 | 9 | type DB interface { 10 | Open(dsn string) error 11 | Initialize() error 12 | LoadConfig() (*oauth2.Config, *config.Config, error) 13 | Close() error 14 | 15 | CreateSession(ghID int64, accessToken string) (id string, err error) 16 | GetSessionInfo(id string) (ghId int64, accessToken string, err error) 17 | DeleteExpiredSessions() error 18 | DeleteSession(id string) error 19 | 20 | CanUserRegister(ghID int64) (bool, error) 21 | CreateUser(ghID int64, email string) error 22 | GetUserPermissions(appID string, ghID int64) (update bool, err error) 23 | GetUserRoles(ghID int64) (registered bool, reviewer bool, err error) 24 | 25 | CreateReviewer(ghID int64, email string) error 26 | 27 | CreateApp( 28 | app AppWithIssues, 29 | ghID int64, 30 | appFileHandle string, 31 | iconFileHandle string, 32 | iconHash string, 33 | ) error 34 | GetAppInfo(appID string) (versionCode int32, err error) 35 | GetApprovedApps() ([]App, error) 36 | GetApps(ghID int64) ([]App, error) 37 | GetPendingApps(reviewerGhID int64) ([]AppWithIssues, error) 38 | GetStagingAppInfo(appID string, ghID int64) (appHandle string, iconHandle string, err error) 39 | GetSubmittedAppInfo( 40 | appID string, 41 | ) ( 42 | app App, 43 | ghID int64, 44 | iconID int, 45 | issueGroupID *int, 46 | appHandle string, 47 | iconHandle string, 48 | err error, 49 | ) 50 | GetSubmittedApps(ghID int64) ([]App, error) 51 | ApproveApp(appID string) error 52 | PublishApp(appID string) error 53 | SubmitApp(appID string, label string, ghID int64) error 54 | DeleteSubmittedApp(appID string) error 55 | 56 | CreateUpdate(app AppWithIssues, ghID int64, fileHandle string) error 57 | GetSubmittedUpdates(ghID int64) ([]AppWithIssues, error) 58 | GetUpdateInfo( 59 | appID string, 60 | versionCode int, 61 | ) (firstVersion int, versionName string, fileHandle string, issueGroupID *int, err error) 62 | GetUpdates(reviewerGhID int64) ([]AppWithIssues, error) 63 | GetStagingUpdateInfo( 64 | appID string, 65 | versionCode int32, 66 | ghID int64, 67 | ) ( 68 | label string, 69 | versionName string, 70 | fileHandle string, 71 | issueGroupID *int, 72 | needsReview bool, 73 | err error, 74 | ) 75 | ApproveUpdate(appID string, versionCode int, versionName string, issueGroupID *int) error 76 | SubmitUpdate(app App, fileHandle string, issueGroupID *int, needsReview bool) error 77 | DeleteSubmittedUpdate(appID string, versionCode int) error 78 | 79 | GetSdkException(appID string) (*SdkException, error) 80 | } 81 | -------------------------------------------------------------------------------- /ansible/templates/nginx/reposerver.conf.j2: -------------------------------------------------------------------------------- 1 | worker_processes auto; 2 | worker_rlimit_nofile 16384; 3 | 4 | events { 5 | worker_connections 4096; 6 | } 7 | 8 | http { 9 | include mime.types; 10 | default_type application/octet-stream; 11 | 12 | charset utf-8; 13 | 14 | sendfile on; 15 | sendfile_max_chunk 512k; 16 | tcp_nopush on; 17 | keepalive_timeout 3m; 18 | server_tokens off; 19 | msie_padding off; 20 | 21 | client_max_body_size 1k; 22 | client_body_buffer_size 1k; 23 | client_header_buffer_size 1k; 24 | large_client_header_buffers 4 4k; 25 | http2_recv_buffer_size 128k; 26 | 27 | client_body_timeout 30s; 28 | client_header_timeout 30s; 29 | send_timeout 30s; 30 | 31 | http2_max_concurrent_streams 32; 32 | 33 | ssl_protocols TLSv1.2 TLSv1.3; 34 | ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256; 35 | ssl_prefer_server_ciphers on; 36 | ssl_conf_command Options PrioritizeChaCha; 37 | 38 | ssl_certificate /etc/letsencrypt/live/{{ inventory_hostname }}/fullchain.pem; 39 | ssl_certificate_key /etc/letsencrypt/live/{{ inventory_hostname }}/privkey.pem; 40 | 41 | ssl_session_cache shared:SSL:10m; 42 | ssl_session_timeout 1d; 43 | ssl_buffer_size 4k; 44 | 45 | log_format main '$remote_addr - $remote_user [$time_local] ' 46 | '"$request_method $scheme://$host$request_uri $server_protocol" $status $body_bytes_sent ' 47 | '"$http_referer" "$http_user_agent"'; 48 | access_log syslog:server=unix:/dev/log,nohostname main; 49 | error_log syslog:server=unix:/dev/log,nohostname; 50 | log_not_found off; 51 | 52 | gzip_proxied any; 53 | gzip_vary on; 54 | 55 | if_modified_since before; 56 | 57 | aio threads; 58 | aio_write on; 59 | 60 | upstream backend { 61 | server [::1]:8080 max_conns=1024 fail_timeout=1s; 62 | } 63 | 64 | server { 65 | listen 80; 66 | listen [::]:80; 67 | server_name {{ inventory_hostname }}; 68 | 69 | root /var/empty; 70 | 71 | return 301 https://$host$request_uri; 72 | } 73 | 74 | server { 75 | listen 443 ssl http2; 76 | listen [::]:443 ssl http2; 77 | server_name {{ inventory_hostname }}; 78 | 79 | include security.conf; 80 | add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; sandbox; base-uri 'none';" always; 81 | 82 | gzip_static on; 83 | 84 | location /.well-known/acme-challenge/ { 85 | root /srv/certbot; 86 | } 87 | 88 | location /api/apps { 89 | client_max_body_size 128M; 90 | proxy_pass http://backend; 91 | } 92 | 93 | location / { 94 | root /srv/{{ inventory_hostname }}; 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /devconsole/app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "golang.org/x/oauth2" 9 | 10 | "github.com/accrescent/devconsole/api" 11 | "github.com/accrescent/devconsole/auth" 12 | "github.com/accrescent/devconsole/config" 13 | "github.com/accrescent/devconsole/data" 14 | "github.com/accrescent/devconsole/middleware" 15 | ) 16 | 17 | type App struct { 18 | server *http.Server 19 | db data.DB 20 | fileStorage data.FileStorage 21 | } 22 | 23 | func NewApp( 24 | db data.DB, 25 | fileStorage data.FileStorage, 26 | oauth2Conf oauth2.Config, 27 | conf config.Config, 28 | ) (*App, error) { 29 | router := gin.New() 30 | router.Use(gin.Logger()) 31 | if err := router.SetTrustedProxies(nil); err != nil { 32 | return nil, err 33 | } 34 | router.Use(middleware.DB(db)) 35 | router.Use(middleware.FileStorage(fileStorage)) 36 | router.Use(middleware.OAuth2Config(oauth2Conf)) 37 | router.Use(middleware.Config(conf)) 38 | 39 | router.GET("/auth/github", auth.GitHub) 40 | router.GET("/api/auth/github/callback", auth.GitHubCallback) 41 | 42 | auth := router.Group("/", middleware.AuthRequired()) 43 | reviewer := auth.Group("/", middleware.ReviewerRequired()) 44 | update := auth.Group("/", middleware.UserCanUpdateRequired()) 45 | auth.GET("/api/emails", api.GetEmails) 46 | reviewer.GET("/api/pending-apps", api.GetPendingApps) 47 | reviewer.GET("/api/pending-apps/:id/apks", api.GetAppAPKs) 48 | reviewer.PATCH("/api/pending-apps/:id", api.ApproveApp) 49 | reviewer.DELETE("/api/pending-apps/:id", api.RejectApp) 50 | reviewer.GET("/api/updates", api.GetUpdates) 51 | reviewer.GET("/api/updates/:id/:version/apks", api.GetUpdateAPKs) 52 | reviewer.PATCH("/api/updates/:id/:version", api.ApproveUpdate) 53 | reviewer.DELETE("/api/updates/:id/:version", api.RejectUpdate) 54 | auth.GET("/api/approved-apps", middleware.SignerRequired(), api.GetApprovedApps) 55 | auth.POST("/api/register", api.Register) 56 | auth.DELETE("/api/session", api.LogOut) 57 | auth.GET("/api/apps", api.GetApps) 58 | auth.POST("/api/apps", api.NewApp) 59 | auth.GET("/api/submitted-apps", api.GetSubmittedApps) 60 | auth.GET("/api/submitted-updates", api.GetSubmittedUpdates) 61 | auth.PATCH("/api/apps/:id", api.SubmitApp) 62 | update.POST("/api/apps/:id/updates", api.NewUpdate) 63 | update.PATCH("/api/apps/:id/:version", api.SubmitUpdate) 64 | auth.POST("/api/apps/:id", middleware.SignerRequired(), api.PublishApp) 65 | 66 | server := &http.Server{ 67 | Addr: ":8080", 68 | Handler: router, 69 | } 70 | 71 | return &App{ 72 | server, 73 | db, 74 | fileStorage, 75 | }, nil 76 | } 77 | 78 | func (a *App) Start() error { 79 | return a.server.ListenAndServe() 80 | } 81 | 82 | func (a *App) Stop(ctx context.Context) error { 83 | if err := a.server.Shutdown(ctx); err != nil { 84 | return err 85 | } 86 | 87 | return a.db.Close() 88 | } 89 | -------------------------------------------------------------------------------- /web/src/app/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient, HttpEvent, HttpRequest } from '@angular/common/http'; 3 | 4 | import { Observable } from 'rxjs'; 5 | 6 | import { App } from './app'; 7 | 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class AppService { 12 | private readonly appsUrl = 'api/apps'; 13 | private readonly submittedAppsUrl = 'api/submitted-apps'; 14 | private readonly submittedUpdatesUrl = 'api/submitted-updates'; 15 | private readonly pendingAppsUrl = 'api/pending-apps'; 16 | private readonly updatesUrl = 'api/updates'; 17 | private readonly approvedAppsUrl = 'api/approved-apps'; 18 | 19 | constructor(private http: HttpClient) {} 20 | 21 | getApps(): Observable { 22 | return this.http.get(this.appsUrl); 23 | } 24 | 25 | getSubmittedApps(): Observable { 26 | return this.http.get(this.submittedAppsUrl); 27 | } 28 | 29 | getSubmittedUpdates(): Observable { 30 | return this.http.get(this.submittedUpdatesUrl); 31 | } 32 | 33 | getPendingApps(): Observable { 34 | return this.http.get(this.pendingAppsUrl); 35 | } 36 | 37 | getUpdates(): Observable { 38 | return this.http.get(this.updatesUrl); 39 | } 40 | 41 | getApprovedApps(): Observable { 42 | return this.http.get(this.approvedAppsUrl); 43 | } 44 | 45 | approveApp(appId: string): Observable { 46 | return this.http.patch(`${this.pendingAppsUrl}/${appId}`, ''); 47 | } 48 | 49 | rejectApp(appId: string): Observable { 50 | return this.http.delete(`${this.pendingAppsUrl}/${appId}`); 51 | } 52 | 53 | uploadApp(app: File, icon: File): Observable> { 54 | const formData = new FormData(); 55 | formData.append("app", app); 56 | formData.append("icon", icon); 57 | 58 | const req = new HttpRequest('POST', this.appsUrl, formData, { reportProgress: true }); 59 | 60 | return this.http.request(req); 61 | } 62 | 63 | uploadUpdate(app: File, appId: string): Observable> { 64 | const formData = new FormData(); 65 | formData.append("app", app); 66 | 67 | const req = new HttpRequest('POST', `${this.appsUrl}/${appId}/updates`, formData, { 68 | reportProgress: true, 69 | }); 70 | 71 | return this.http.request(req); 72 | } 73 | 74 | submitApp(id: string, label: string): Observable { 75 | return this.http.patch(`${this.appsUrl}/${id}`, { label }); 76 | } 77 | 78 | submitUpdate(id: string, versionCode: number): Observable { 79 | return this.http.patch(`${this.appsUrl}/${id}/${versionCode}`, ''); 80 | } 81 | 82 | approveUpdate(id: string, versionCode: number): Observable { 83 | return this.http.patch(`${this.updatesUrl}/${id}/${versionCode}`, ''); 84 | } 85 | 86 | rejectUpdate(id: string, versionCode: number): Observable { 87 | return this.http.delete(`${this.updatesUrl}/${id}/${versionCode}`); 88 | } 89 | 90 | publishApp(id: string): Observable { 91 | return this.http.post(`${this.appsUrl}/${id}`, ''); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /ansible/templates/nginx/devconsole.conf.j2: -------------------------------------------------------------------------------- 1 | worker_processes auto; 2 | worker_rlimit_nofile 16384; 3 | 4 | events { 5 | worker_connections 4096; 6 | } 7 | 8 | http { 9 | include mime.types; 10 | default_type application/octet-stream; 11 | 12 | charset utf-8; 13 | 14 | sendfile on; 15 | sendfile_max_chunk 512k; 16 | tcp_nopush on; 17 | keepalive_timeout 3m; 18 | server_tokens off; 19 | msie_padding off; 20 | 21 | client_max_body_size 1k; 22 | client_body_buffer_size 1k; 23 | client_header_buffer_size 1k; 24 | large_client_header_buffers 4 4k; 25 | http2_recv_buffer_size 128k; 26 | 27 | client_body_timeout 30s; 28 | client_header_timeout 30s; 29 | send_timeout 30s; 30 | 31 | http2_max_concurrent_streams 32; 32 | 33 | ssl_protocols TLSv1.2 TLSv1.3; 34 | ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256; 35 | ssl_prefer_server_ciphers on; 36 | ssl_conf_command Options PrioritizeChaCha; 37 | 38 | ssl_certificate /etc/letsencrypt/live/{{ inventory_hostname }}/fullchain.pem; 39 | ssl_certificate_key /etc/letsencrypt/live/{{ inventory_hostname }}/privkey.pem; 40 | 41 | ssl_session_cache shared:SSL:10m; 42 | ssl_session_timeout 1d; 43 | ssl_buffer_size 4k; 44 | 45 | log_format main '$remote_addr - $remote_user [$time_local] ' 46 | '"$request_method $scheme://$host$request_uri $server_protocol" $status $body_bytes_sent ' 47 | '"$http_referer" "$http_user_agent"'; 48 | access_log syslog:server=unix:/dev/log,nohostname main; 49 | error_log syslog:server=unix:/dev/log,nohostname; 50 | log_not_found off; 51 | 52 | gzip_proxied any; 53 | gzip_vary on; 54 | 55 | if_modified_since before; 56 | 57 | aio threads; 58 | aio_write on; 59 | 60 | upstream backend { 61 | server [::1]:8080 max_conns=1024 fail_timeout=1s; 62 | } 63 | 64 | server { 65 | listen 80; 66 | listen [::]:80; 67 | server_name {{ inventory_hostname }}; 68 | 69 | root /var/empty; 70 | 71 | return 301 https://$host$request_uri; 72 | } 73 | 74 | server { 75 | listen 443 ssl http2; 76 | listen [::]:443 ssl http2; 77 | server_name {{ inventory_hostname }}; 78 | 79 | root /srv/{{ inventory_hostname }}; 80 | 81 | include security.conf; 82 | add_header Content-Security-Policy "default-src 'self'; font-src https://fonts.gstatic.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; sandbox allow-downloads allow-forms allow-same-origin allow-scripts; base-uri 'self'; trusted-types angular; require-trusted-types-for 'script';" always; 83 | 84 | gzip_static on; 85 | 86 | location /.well-known/acme-challenge/ { 87 | root /srv/certbot; 88 | } 89 | 90 | location = "/auth/github" { 91 | proxy_pass http://backend; 92 | } 93 | 94 | location /api/apps { 95 | client_max_body_size 128M; 96 | proxy_pass http://backend; 97 | } 98 | 99 | location ^~ /api/ { 100 | proxy_pass http://backend; 101 | } 102 | 103 | location / { 104 | try_files $uri $uri/ /index.html; 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /devconsole/data/sqlite_test.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "encoding/hex" 5 | "testing" 6 | ) 7 | 8 | func testOpenSQLite(t testing.TB) *SQLite { 9 | s := new(SQLite) 10 | if err := s.Open(":memory:"); err != nil { 11 | t.Fatal("failed to open database:", err) 12 | } 13 | 14 | return s 15 | } 16 | 17 | func testCreateSQLite(t testing.TB) *SQLite { 18 | s := testOpenSQLite(t) 19 | if err := s.Initialize(); err != nil { 20 | t.Fatal("failed to initialize database:", err) 21 | } 22 | 23 | return s 24 | } 25 | 26 | func TestSQLiteOpen(t *testing.T) { 27 | s := testOpenSQLite(t) 28 | defer s.Close() 29 | 30 | t.Run("trusted_schema value", func(t *testing.T) { 31 | var trustedSchema bool 32 | if err := s.db.QueryRow("PRAGMA trusted_schema").Scan(&trustedSchema); err != nil { 33 | t.Fatal("failed to read trusted_schema:", err) 34 | } 35 | 36 | if trustedSchema { 37 | t.Error("trusted_schema is ON") 38 | } 39 | }) 40 | } 41 | 42 | func TestSQLiteInitialize(t *testing.T) { 43 | s := testCreateSQLite(t) 44 | defer s.Close() 45 | } 46 | 47 | func TestSQLiteClose(t *testing.T) { 48 | s := testCreateSQLite(t) 49 | defer s.Close() 50 | 51 | if err := s.CreateUser(123456, "example@example.com"); err != nil { 52 | t.Fatal("failed to create user") 53 | } 54 | 55 | s.Close() 56 | 57 | if _, _, err := s.GetUserRoles(123456); err == nil { 58 | t.Error("query after close succeeded") 59 | } 60 | } 61 | 62 | func TestSQLiteSession(t *testing.T) { 63 | s := testCreateSQLite(t) 64 | defer s.Close() 65 | 66 | var testGHID int64 = 123456 67 | testToken := "token-1234" 68 | testSIDLen := 16 69 | 70 | sessionID, err := s.CreateSession(testGHID, testToken) 71 | if err != nil { 72 | t.Fatal("failed to create session:", err) 73 | } 74 | 75 | t.Run("session ID properties", func(t *testing.T) { 76 | decoded, err := hex.DecodeString(sessionID) 77 | if err != nil { 78 | t.Error("session ID is not hex encoded:", err) 79 | } 80 | decodedLen := len(decoded) 81 | if decodedLen != testSIDLen { 82 | t.Errorf("session ID length is %d but expected %d", decodedLen, testSIDLen) 83 | } 84 | }) 85 | 86 | t.Run("get", func(t *testing.T) { 87 | ghID, token, err := s.GetSessionInfo(sessionID) 88 | if err != nil { 89 | t.Fatal("failed to get session:", err) 90 | } 91 | 92 | if ghID != testGHID { 93 | t.Errorf("GitHub ID is %d but expected %d", ghID, testGHID) 94 | } 95 | if token != testToken { 96 | t.Errorf("access token is %s but expected %s", token, testToken) 97 | } 98 | }) 99 | 100 | t.Run("delete", func(t *testing.T) { 101 | if err := s.DeleteSession(sessionID); err != nil { 102 | t.Fatal("failed to delete session:", err) 103 | } 104 | 105 | if _, _, err := s.GetSessionInfo(sessionID); err == nil { 106 | t.Error("get after delete succeeded") 107 | } 108 | }) 109 | } 110 | 111 | func TestSQLiteUser(t *testing.T) { 112 | s := testCreateSQLite(t) 113 | defer s.Close() 114 | 115 | var testGHID int64 = 654321 116 | testEmail := "example@example.com" 117 | 118 | t.Run("default roles", func(t *testing.T) { 119 | registered, reviewer, err := s.GetUserRoles(testGHID) 120 | if err != nil { 121 | t.Fatal("failed to get user:", err) 122 | } 123 | if registered { 124 | t.Error("user is considered registered but has not been created") 125 | } 126 | if reviewer { 127 | t.Error("user is considered a reviewer but has not been created") 128 | } 129 | }) 130 | 131 | if err := s.CreateUser(testGHID, testEmail); err != nil { 132 | t.Fatal("failed to create user:", err) 133 | } 134 | 135 | t.Run("registered after creation", func(t *testing.T) { 136 | registered, _, err := s.GetUserRoles(testGHID) 137 | if err != nil { 138 | t.Fatal("failed to get user:", err) 139 | } 140 | if !registered { 141 | t.Error("user is created but not considered registered") 142 | } 143 | }) 144 | } 145 | -------------------------------------------------------------------------------- /web/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule, ErrorHandler } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; 4 | import { ReactiveFormsModule } from '@angular/forms'; 5 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 6 | import { MatButtonModule } from '@angular/material/button'; 7 | import { MatCardModule } from '@angular/material/card'; 8 | import { MatIconModule } from '@angular/material/icon'; 9 | import { MatInputModule } from '@angular/material/input'; 10 | import { MatListModule } from '@angular/material/list'; 11 | import { MatProgressBarModule } from '@angular/material/progress-bar'; 12 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 13 | import { MatRadioModule } from '@angular/material/radio'; 14 | import { MatSidenavModule } from '@angular/material/sidenav'; 15 | import { MatToolbarModule } from '@angular/material/toolbar'; 16 | import { MatSnackBarModule, MAT_SNACK_BAR_DEFAULT_OPTIONS } from '@angular/material/snack-bar'; 17 | 18 | import { AppRoutingModule } from './app-routing.module'; 19 | import { AppComponent } from './app.component'; 20 | import { RegisterComponent } from './register/register.component'; 21 | import { RegisterFormComponent } from './register-form/register-form.component'; 22 | import { LandingComponent } from './landing/landing.component'; 23 | import { DashboardComponent } from './dashboard/dashboard.component'; 24 | import { NewAppComponent } from './new-app/new-app.component'; 25 | import { NewAppFormComponent } from './new-app-form/new-app-form.component'; 26 | import { NewUpdateComponent } from './new-update/new-update.component'; 27 | import { NewUpdateFormComponent } from './new-update-form/new-update-form.component'; 28 | import { ConsoleLayoutComponent } from './console-layout/console-layout.component'; 29 | import { LoginComponent } from './login/login.component'; 30 | import { ReviewComponent } from './review/review.component'; 31 | import { AppListComponent } from './app-list/app-list.component'; 32 | import { PublishComponent } from './publish/publish.component'; 33 | import { GlobalErrorHandler } from './global-error-handler'; 34 | import { AuthInterceptor } from './auth-interceptor'; 35 | import { UnauthorizedRegisterComponent } from './unauthorized-register/unauthorized-register.component'; 36 | import { AppInfoComponent } from './app-info/app-info.component'; 37 | 38 | @NgModule({ 39 | declarations: [ 40 | AppComponent, 41 | RegisterComponent, 42 | RegisterFormComponent, 43 | LandingComponent, 44 | DashboardComponent, 45 | NewAppComponent, 46 | NewAppFormComponent, 47 | NewUpdateComponent, 48 | NewUpdateFormComponent, 49 | ConsoleLayoutComponent, 50 | LoginComponent, 51 | ReviewComponent, 52 | AppListComponent, 53 | PublishComponent, 54 | UnauthorizedRegisterComponent, 55 | AppInfoComponent, 56 | ], 57 | imports: [ 58 | BrowserModule, 59 | HttpClientModule, 60 | BrowserAnimationsModule, 61 | MatButtonModule, 62 | MatCardModule, 63 | MatIconModule, 64 | MatInputModule, 65 | MatListModule, 66 | MatProgressBarModule, 67 | MatProgressSpinnerModule, 68 | MatRadioModule, 69 | MatSidenavModule, 70 | MatToolbarModule, 71 | MatSnackBarModule, 72 | ReactiveFormsModule, 73 | AppRoutingModule 74 | ], 75 | providers: [{ 76 | provide: ErrorHandler, 77 | useClass: GlobalErrorHandler, 78 | }, { 79 | provide: HTTP_INTERCEPTORS, 80 | useClass: AuthInterceptor, 81 | multi: true, 82 | }, { 83 | provide: MAT_SNACK_BAR_DEFAULT_OPTIONS, 84 | useValue: { duration: 5000 }, 85 | }], 86 | bootstrap: [AppComponent] 87 | }) 88 | export class AppModule { } 89 | -------------------------------------------------------------------------------- /devconsole/api/new_update.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/gin-gonic/gin" 10 | 11 | "github.com/accrescent/devconsole/data" 12 | "github.com/accrescent/devconsole/quality" 13 | ) 14 | 15 | func NewUpdate(c *gin.Context) { 16 | db := c.MustGet("db").(data.DB) 17 | storage := c.MustGet("storage").(data.FileStorage) 18 | ghID := c.MustGet("gh_id").(int64) 19 | appID := c.Param("id") 20 | 21 | versionCode, err := db.GetAppInfo(appID) 22 | if err != nil { 23 | if errors.Is(err, sql.ErrNoRows) { 24 | _ = c.AbortWithError(http.StatusNotFound, err) 25 | } else { 26 | _ = c.AbortWithError(http.StatusInternalServerError, err) 27 | } 28 | return 29 | } 30 | 31 | file, err := c.FormFile("app") 32 | if err != nil { 33 | _ = c.AbortWithError(http.StatusBadRequest, err) 34 | return 35 | } 36 | 37 | // We've received the (supposed) APK set. Now extract the app metadata. 38 | metadata, apk, appFile, err := openAPKSet(file) 39 | if err != nil { 40 | if errors.Is(err, ErrFatalIO) { 41 | _ = c.AbortWithError(http.StatusInternalServerError, err) 42 | } else { 43 | msg := "App is in incorrect format. Make sure you upload an APK set." 44 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": msg}) 45 | } 46 | return 47 | } 48 | defer appFile.Close() 49 | 50 | m := apk.Manifest() 51 | 52 | // Run tests whose failures warrant immediate rejection 53 | if m.Package != appID { 54 | msg := fmt.Sprintf( 55 | "App ID '%s' doesn't match expected value '%s'", 56 | m.Package, 57 | appID, 58 | ) 59 | c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": msg}) 60 | return 61 | } 62 | if m.VersionCode <= versionCode { 63 | err := fmt.Sprintf( 64 | "Version %d is not more than current version %d", 65 | m.VersionCode, versionCode, 66 | ) 67 | c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": err}) 68 | return 69 | } 70 | sdkException, err := db.GetSdkException(m.Package) 71 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 72 | _ = c.AbortWithError(http.StatusInternalServerError, err) 73 | return 74 | } 75 | if err := quality.RunRejectTests(metadata, apk, sdkException, quality.Update); err != nil { 76 | c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) 77 | return 78 | } 79 | 80 | // If update already exists on disk, delete it 81 | overwrite := true 82 | _, _, appHandle, _, _, err := db.GetStagingUpdateInfo(appID, m.VersionCode, ghID) 83 | if err != nil { 84 | if errors.Is(err, sql.ErrNoRows) { 85 | overwrite = false 86 | } else { 87 | _ = c.AbortWithError(http.StatusInternalServerError, err) 88 | return 89 | } 90 | } 91 | if overwrite { 92 | if err := storage.DeleteFile(appHandle); err != nil { 93 | _ = c.AbortWithError(http.StatusInternalServerError, err) 94 | return 95 | } 96 | } 97 | 98 | // App passed all automated checks, so save it to disk 99 | apkSetHandle, err := storage.SaveUpdate(appFile) 100 | if err != nil { 101 | _ = c.AbortWithError(http.StatusInternalServerError, err) 102 | return 103 | } 104 | 105 | // Run tests whose failures warrant manual review 106 | issues := quality.RunReviewTests(apk) 107 | 108 | if err := db.CreateUpdate( 109 | data.AppWithIssues{ 110 | App: data.App{ 111 | AppID: m.Package, 112 | Label: *m.Application.Label, 113 | VersionCode: m.VersionCode, 114 | VersionName: m.VersionName, 115 | }, 116 | Issues: issues, 117 | }, 118 | ghID, 119 | apkSetHandle, 120 | ); err != nil { 121 | _ = c.AbortWithError(http.StatusInternalServerError, err) 122 | return 123 | } 124 | 125 | c.JSON(http.StatusCreated, gin.H{ 126 | "app_id": m.Package, 127 | "label": m.Application.Label, 128 | "version_code": m.VersionCode, 129 | "version_name": m.VersionName, 130 | "issues": issues, 131 | }) 132 | } 133 | -------------------------------------------------------------------------------- /devconsole/auth/github.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "errors" 7 | "net/http" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/google/go-github/v52/github" 11 | "golang.org/x/oauth2" 12 | 13 | "github.com/accrescent/devconsole/config" 14 | "github.com/accrescent/devconsole/data" 15 | ) 16 | 17 | func GitHub(c *gin.Context) { 18 | conf := c.MustGet("oauth2_config").(oauth2.Config) 19 | 20 | // CSRF protection. http://tools.ietf.org/html/rfc6749#section-10.12 21 | state := make([]byte, 16) 22 | if _, err := rand.Read(state); err != nil { 23 | _ = c.AbortWithError(http.StatusInternalServerError, err) 24 | return 25 | } 26 | 27 | stateStr := hex.EncodeToString(state) 28 | c.SetSameSite(http.SameSiteLaxMode) 29 | c.SetCookie(authStateCookie, stateStr, 30, "/", "", true, true) 30 | 31 | url := conf.AuthCodeURL(stateStr) 32 | c.Redirect(http.StatusFound, url) 33 | } 34 | 35 | func GitHubCallback(c *gin.Context) { 36 | db := c.MustGet("db").(data.DB) 37 | conf := c.MustGet("config").(config.Config) 38 | oauth2Conf := c.MustGet("oauth2_config").(oauth2.Config) 39 | 40 | // Use existing session if possible 41 | sessionID, err := c.Cookie(SessionCookie) 42 | if err == nil { 43 | ghID, _, err := db.GetSessionInfo(sessionID) 44 | if err == nil { 45 | registered, reviewer, err := db.GetUserRoles(ghID) 46 | if err != nil { 47 | _ = c.AbortWithError(http.StatusInternalServerError, err) 48 | return 49 | } 50 | publisher := ConstantTimeEqInt64(ghID, conf.SignerGitHubID) == 1 51 | 52 | c.JSON(http.StatusOK, gin.H{ 53 | "logged_in": true, 54 | "registered": registered, 55 | "reviewer": reviewer, 56 | "publisher": publisher, 57 | }) 58 | 59 | return 60 | } 61 | } 62 | 63 | stateParam, exists := c.GetQuery("state") 64 | if !exists { 65 | c.SetCookie(authStateCookie, "", -1, "/", "", true, true) 66 | _ = c.AbortWithError(http.StatusBadRequest, ErrNoStateParam) 67 | return 68 | } 69 | 70 | stateCookie, err := c.Cookie(authStateCookie) 71 | if err != nil { 72 | if err != http.ErrNoCookie { 73 | c.SetCookie(authStateCookie, "", -1, "/", "", true, true) 74 | } 75 | _ = c.AbortWithError(http.StatusForbidden, err) 76 | return 77 | } 78 | 79 | // CSRF protection. http://tools.ietf.org/html/rfc6749#section-10.12 80 | if stateParam != stateCookie { 81 | c.SetCookie(authStateCookie, "", -1, "/", "", true, true) 82 | _ = c.AbortWithError(http.StatusForbidden, ErrNoStateMatch) 83 | return 84 | } 85 | 86 | code := c.Query("code") 87 | token, err := oauth2Conf.Exchange(c, code) 88 | if err != nil { 89 | var retrieveError *oauth2.RetrieveError 90 | if errors.As(err, &retrieveError) { 91 | _ = c.AbortWithError(retrieveError.Response.StatusCode, err) 92 | } else { 93 | _ = c.AbortWithError(http.StatusInternalServerError, err) 94 | } 95 | return 96 | } 97 | 98 | // Get authenticated user 99 | httpClient := oauth2Conf.Client(c, token) 100 | client := github.NewClient(httpClient) 101 | user, _, err := client.Users.Get(c, "") 102 | if err != nil { 103 | _ = c.AbortWithError(http.StatusUnauthorized, err) 104 | return 105 | } 106 | 107 | // Check against registration whitelist 108 | canRegister, err := db.CanUserRegister(*user.ID) 109 | if err != nil { 110 | _ = c.AbortWithError(http.StatusInternalServerError, err) 111 | return 112 | } 113 | 114 | if canRegister { 115 | // Add session 116 | if err := db.DeleteExpiredSessions(); err != nil { 117 | _ = c.AbortWithError(http.StatusInternalServerError, err) 118 | return 119 | } 120 | sessionID, err := db.CreateSession(*user.ID, token.AccessToken) 121 | if err != nil { 122 | _ = c.AbortWithError(http.StatusInternalServerError, err) 123 | return 124 | } 125 | 126 | c.SetSameSite(http.SameSiteStrictMode) 127 | c.SetCookie(SessionCookie, sessionID, 24*60*60, "/", "", true, true) // Max-Age 1 day 128 | 129 | registered, reviewer, err := db.GetUserRoles(*user.ID) 130 | if err != nil { 131 | _ = c.AbortWithError(http.StatusInternalServerError, err) 132 | return 133 | } 134 | publisher := ConstantTimeEqInt64(*user.ID, conf.SignerGitHubID) == 1 135 | 136 | c.JSON(http.StatusOK, gin.H{ 137 | "logged_in": true, 138 | "registered": registered, 139 | "reviewer": reviewer, 140 | "publisher": publisher, 141 | }) 142 | } else { 143 | c.JSON(http.StatusOK, gin.H{ 144 | "logged_in": false, 145 | }) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /devconsole/api/new_app.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "crypto/sha256" 5 | "database/sql" 6 | "encoding/hex" 7 | "errors" 8 | "image" 9 | _ "image/png" 10 | "io" 11 | "net/http" 12 | 13 | "github.com/gin-gonic/gin" 14 | 15 | "github.com/accrescent/devconsole/data" 16 | "github.com/accrescent/devconsole/quality" 17 | ) 18 | 19 | func NewApp(c *gin.Context) { 20 | db := c.MustGet("db").(data.DB) 21 | storage := c.MustGet("storage").(data.FileStorage) 22 | ghID := c.MustGet("gh_id").(int64) 23 | 24 | formApp, err := c.FormFile("app") 25 | if err != nil { 26 | _ = c.AbortWithError(http.StatusBadRequest, err) 27 | return 28 | } 29 | formIcon, err := c.FormFile("icon") 30 | if err != nil { 31 | _ = c.AbortWithError(http.StatusBadRequest, err) 32 | return 33 | } 34 | 35 | // We've received the (supposed) APK set. Now extract the app metadata. 36 | metadata, apk, appFile, err := openAPKSet(formApp) 37 | if err != nil { 38 | if errors.Is(err, ErrFatalIO) { 39 | _ = c.AbortWithError(http.StatusInternalServerError, err) 40 | } else { 41 | msg := "App is in incorrect format. Make sure you upload an APK set." 42 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": msg}) 43 | } 44 | return 45 | } 46 | defer appFile.Close() 47 | 48 | // Check that image is a 512x512 PNG 49 | formIconFile, err := formIcon.Open() 50 | if err != nil { 51 | _ = c.AbortWithError(http.StatusInternalServerError, err) 52 | return 53 | } 54 | defer formIconFile.Close() 55 | iconInfo, format, err := image.DecodeConfig(formIconFile) 56 | if err != nil || format != "png" || iconInfo.Width != 512 || iconInfo.Height != 512 { 57 | msg := "Icon must be a 512x512 PNG" 58 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": msg}) 59 | return 60 | } 61 | 62 | m := apk.Manifest() 63 | 64 | // Run tests whose failures warrant immediate rejection 65 | sdkException, err := db.GetSdkException(m.Package) 66 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 67 | _ = c.AbortWithError(http.StatusInternalServerError, err) 68 | return 69 | } 70 | if err := quality.RunRejectTests(metadata, apk, sdkException, quality.NewApp); err != nil { 71 | c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) 72 | return 73 | } 74 | 75 | // If app already exists on disk, delete it 76 | overwrite := true 77 | appHandle, iconHandle, err := db.GetStagingAppInfo(m.Package, ghID) 78 | if err != nil { 79 | if errors.Is(err, sql.ErrNoRows) { 80 | overwrite = false 81 | } else { 82 | _ = c.AbortWithError(http.StatusInternalServerError, err) 83 | return 84 | } 85 | } 86 | if overwrite { 87 | if err := storage.DeleteFile(appHandle); err != nil { 88 | _ = c.AbortWithError(http.StatusInternalServerError, err) 89 | return 90 | } 91 | if err := storage.DeleteFile(iconHandle); err != nil { 92 | _ = c.AbortWithError(http.StatusInternalServerError, err) 93 | return 94 | } 95 | } 96 | 97 | // App passed all automated checks, so save it to disk 98 | if _, err := formIconFile.Seek(0, io.SeekStart); err != nil { 99 | _ = c.AbortWithError(http.StatusInternalServerError, err) 100 | return 101 | } 102 | apkSetHandle, iconHandle, err := storage.SaveNewApp(appFile, formIconFile) 103 | if err != nil { 104 | _ = c.AbortWithError(http.StatusInternalServerError, err) 105 | return 106 | } 107 | 108 | // Run tests whose failures warrant manual review 109 | issues := quality.RunReviewTests(apk) 110 | 111 | // Calculate icon hash 112 | if _, err := formIconFile.Seek(0, io.SeekStart); err != nil { 113 | _ = c.AbortWithError(http.StatusInternalServerError, err) 114 | return 115 | } 116 | hasher := sha256.New() 117 | if _, err := io.Copy(hasher, formIconFile); err != nil { 118 | _ = c.AbortWithError(http.StatusInternalServerError, err) 119 | return 120 | } 121 | iconHash := hex.EncodeToString(hasher.Sum(nil)) 122 | 123 | if err := db.CreateApp( 124 | data.AppWithIssues{ 125 | App: data.App{ 126 | AppID: m.Package, 127 | Label: *m.Application.Label, 128 | VersionCode: m.VersionCode, 129 | VersionName: m.VersionName, 130 | }, 131 | Issues: issues, 132 | }, 133 | ghID, 134 | apkSetHandle, 135 | iconHandle, 136 | iconHash, 137 | ); err != nil { 138 | _ = c.AbortWithError(http.StatusInternalServerError, err) 139 | return 140 | } 141 | 142 | c.JSON(http.StatusCreated, gin.H{ 143 | "app_id": m.Package, 144 | "label": m.Application.Label, 145 | "version_name": m.VersionName, 146 | "version_code": m.VersionCode, 147 | }) 148 | } 149 | -------------------------------------------------------------------------------- /web/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "web": { 7 | "projectType": "application", 8 | "schematics": {}, 9 | "root": "", 10 | "sourceRoot": "src", 11 | "prefix": "app", 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "outputPath": "dist/web", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": "src/polyfills.ts", 20 | "tsConfig": "tsconfig.app.json", 21 | "assets": [ 22 | "src/favicon.ico", 23 | "src/assets" 24 | ], 25 | "styles": [ 26 | "./node_modules/@angular/material/prebuilt-themes/purple-green.css", 27 | "src/styles.css" 28 | ], 29 | "scripts": [] 30 | }, 31 | "configurations": { 32 | "production": { 33 | "budgets": [ 34 | { 35 | "type": "initial", 36 | "maximumWarning": "500kb", 37 | "maximumError": "1mb" 38 | }, 39 | { 40 | "type": "anyComponentStyle", 41 | "maximumWarning": "2kb", 42 | "maximumError": "4kb" 43 | } 44 | ], 45 | "fileReplacements": [ 46 | { 47 | "replace": "src/environments/environment.ts", 48 | "with": "src/environments/environment.prod.ts" 49 | } 50 | ], 51 | "optimization": { 52 | "scripts": true, 53 | "styles": { 54 | "minify": true, 55 | "inlineCritical": false 56 | }, 57 | "fonts": true 58 | }, 59 | "outputHashing": "all" 60 | }, 61 | "development": { 62 | "buildOptimizer": false, 63 | "optimization": false, 64 | "vendorChunk": true, 65 | "extractLicenses": false, 66 | "sourceMap": true, 67 | "namedChunks": true 68 | } 69 | }, 70 | "defaultConfiguration": "production" 71 | }, 72 | "serve": { 73 | "builder": "@angular-devkit/build-angular:dev-server", 74 | "options": { 75 | "headers": { 76 | "Content-Security-Policy": "trusted-types angular; require-trusted-types-for 'script';" 77 | } 78 | }, 79 | "configurations": { 80 | "production": { 81 | "browserTarget": "web:build:production" 82 | }, 83 | "development": { 84 | "browserTarget": "web:build:development" 85 | } 86 | }, 87 | "defaultConfiguration": "development" 88 | }, 89 | "extract-i18n": { 90 | "builder": "@angular-devkit/build-angular:extract-i18n", 91 | "options": { 92 | "browserTarget": "web:build" 93 | } 94 | }, 95 | "test": { 96 | "builder": "@angular-devkit/build-angular:karma", 97 | "options": { 98 | "main": "src/test.ts", 99 | "polyfills": "src/polyfills.ts", 100 | "tsConfig": "tsconfig.spec.json", 101 | "karmaConfig": "karma.conf.js", 102 | "assets": [ 103 | "src/favicon.ico", 104 | "src/assets" 105 | ], 106 | "styles": [ 107 | "./node_modules/@angular/material/prebuilt-themes/purple-green.css", 108 | "src/styles.css" 109 | ], 110 | "scripts": [] 111 | } 112 | }, 113 | "lint": { 114 | "builder": "@angular-eslint/builder:lint", 115 | "options": { 116 | "lintFilePatterns": [ 117 | "src/**/*.ts", 118 | "src/**/*.html" 119 | ] 120 | } 121 | } 122 | } 123 | } 124 | }, 125 | "cli": { 126 | "schematicCollections": [ 127 | "@angular-eslint/schematics" 128 | ] 129 | }, 130 | "schematics": { 131 | "@angular-eslint/schematics:application": { 132 | "setParserOptionsProject": true 133 | }, 134 | "@angular-eslint/schematics:library": { 135 | "setParserOptionsProject": true 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /reposerver/api/publish.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "encoding/json" 7 | "errors" 8 | "io" 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | "strconv" 13 | "strings" 14 | 15 | "github.com/gin-gonic/gin" 16 | "golang.org/x/exp/slices" 17 | ) 18 | 19 | func publish(c *gin.Context, uploadType uploadType) { 20 | publishDir := c.MustGet("publish_dir").(string) 21 | appID := c.Param("id") 22 | versionCode := c.Param("versionCode") 23 | versionCodeInt, err := strconv.Atoi(versionCode) 24 | if err != nil { 25 | _ = c.AbortWithError(http.StatusBadRequest, err) 26 | return 27 | } 28 | version := c.Param("version") 29 | 30 | rawBody, err := io.ReadAll(c.Request.Body) 31 | if err != nil { 32 | _ = c.AbortWithError(http.StatusBadRequest, err) 33 | return 34 | } 35 | body := bytes.NewReader(rawBody) 36 | apkSet, err := zip.NewReader(body, c.Request.ContentLength) 37 | if err != nil { 38 | _ = c.AbortWithError(http.StatusBadRequest, err) 39 | return 40 | } 41 | 42 | repoData := repoData{ 43 | Version: version, 44 | VersionCode: versionCodeInt, 45 | // These slices are intentionally empty instead of nil so the serialized repository 46 | // metadata is valid. 47 | ABISplits: []string{}, 48 | DensitySplits: []string{}, 49 | LangSplits: []string{}, 50 | } 51 | 52 | // Extract APKs from APK set 53 | appDir := filepath.Join(publishDir, appID) 54 | apkOutDir := filepath.Join(appDir, versionCode) 55 | if err := os.MkdirAll(apkOutDir, 0755); err != nil { 56 | _ = c.AbortWithError(http.StatusInternalServerError, err) 57 | return 58 | } 59 | for _, file := range apkSet.File { 60 | path := filepath.Join(apkOutDir, file.Name) 61 | // zip.FileHeader.Name is not sanitized by default, so we have to sanitize it 62 | // ourselves. 63 | // See https://github.com/golang/go/issues/25849 and 64 | // https://github.com/uber/astro/pull/47. 65 | if !strings.HasPrefix(path, filepath.Clean(apkOutDir)+string(os.PathSeparator)) { 66 | // You're probably looking at the line below and asking yourself, "Why are 67 | // you using this status code?" The short answer is simple: I want to. The 68 | // long and more technical answer is that no other status code fits much 69 | // better. This is technically a client error since the developer pushed an 70 | // APK set with invalid/malicious ZIP file names, but the developer console 71 | // accepted that long ago. We still ought to sanitize the file names here as 72 | // opposed to strictly in the developer console because we don't need to 73 | // give an attacker with control over the developer console a vector toward 74 | // full control over the repository server. Labeling this as an internal 75 | // server error _technically might be correct but handling 500's in client 76 | // JS feels gross and wrong. 77 | // 78 | // So here we are. Do not extract files, do not brew coffee, do not pass go, 79 | // do not collect two hundred dollars. 80 | _ = c.AbortWithError(http.StatusTeapot, errors.New("path sanitization failed")) 81 | return 82 | } 83 | 84 | // Skip standalone APKs to save disk space. We don't need them since Accrescent only 85 | // supports OS versions that support split APKs. 86 | // 87 | // file is safe to read at this point 88 | if strings.HasPrefix(filepath.Base(file.Name), "standalone-") { 89 | continue 90 | } 91 | 92 | // Publish split APKs 93 | if filepath.Ext(file.Name) == ".apk" { 94 | f, err := file.Open() 95 | if err != nil { 96 | _ = c.AbortWithError(http.StatusInternalServerError, err) 97 | return 98 | } 99 | defer f.Close() 100 | 101 | name, typ, typeName := getSplitInfo(filepath.Base(path)) 102 | 103 | switch typ { 104 | case abi: 105 | if !slices.Contains(repoData.ABISplits, typeName) { 106 | repoData.ABISplits = append(repoData.ABISplits, typeName) 107 | } 108 | case density: 109 | repoData.DensitySplits = append(repoData.DensitySplits, typeName) 110 | case lang: 111 | repoData.LangSplits = append(repoData.LangSplits, typeName) 112 | } 113 | 114 | newPath := filepath.Join(apkOutDir, name) 115 | split, err := os.OpenFile(newPath, os.O_WRONLY|os.O_CREATE, 0644) 116 | if err != nil { 117 | _ = c.AbortWithError(http.StatusInternalServerError, err) 118 | return 119 | } 120 | if _, err := io.Copy(split, f); err != nil { 121 | _ = c.AbortWithError(http.StatusInternalServerError, err) 122 | return 123 | } 124 | } 125 | } 126 | 127 | // Publish app repodata 128 | repoDataPath := filepath.Join(appDir, "repodata.json") 129 | openFlags := os.O_WRONLY 130 | if uploadType == newApp { 131 | openFlags |= os.O_CREATE 132 | } else if uploadType == appUpdate { 133 | openFlags |= os.O_TRUNC 134 | } 135 | repoDataFile, err := os.OpenFile(repoDataPath, openFlags, 0644) 136 | if err != nil { 137 | _ = c.AbortWithError(http.StatusInternalServerError, err) 138 | return 139 | } 140 | enc := json.NewEncoder(repoDataFile) 141 | if err := enc.Encode(repoData); err != nil { 142 | _ = c.AbortWithError(http.StatusInternalServerError, err) 143 | return 144 | } 145 | 146 | if uploadType == appUpdate { 147 | // Delete old split APKs 148 | appDirPath := filepath.Join(publishDir, appID) 149 | appDir, err := os.Open(appDirPath) 150 | if err != nil { 151 | _ = c.AbortWithError(http.StatusInternalServerError, err) 152 | return 153 | } 154 | subDirs, err := appDir.Readdirnames(-1) 155 | if err != nil { 156 | _ = c.AbortWithError(http.StatusInternalServerError, err) 157 | return 158 | } 159 | for _, dir := range subDirs { 160 | if num, err := strconv.Atoi(dir); err == nil && num < versionCodeInt { 161 | // Directory presumably contains old split APKs. Delete them. 162 | if err := os.RemoveAll(filepath.Join(appDirPath, dir)); err != nil { 163 | _ = c.AbortWithError(http.StatusInternalServerError, err) 164 | return 165 | } 166 | } 167 | } 168 | } 169 | 170 | c.String(http.StatusOK, "") 171 | } 172 | -------------------------------------------------------------------------------- /ansible/main.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Enable automatic updates 3 | hosts: all 4 | become: yes 5 | tasks: 6 | - name: Install dnf-automatic 7 | ansible.builtin.dnf: 8 | name: dnf-automatic 9 | state: latest 10 | - name: Enable and start dnf-automatic-install.timer 11 | ansible.builtin.systemd: 12 | name: dnf-automatic-install.timer 13 | enabled: yes 14 | state: started 15 | - name: Install cron 16 | ansible.builtin.dnf: 17 | name: cronie 18 | state: latest 19 | - name: Enable and start crond 20 | ansible.builtin.systemd: 21 | name: crond 22 | enabled: yes 23 | state: started 24 | - name: Enable automatic reboots 25 | ansible.builtin.cron: 26 | name: autoreboot 27 | special_time: daily 28 | job: /usr/sbin/reboot 29 | 30 | - name: Set up firewall 31 | hosts: all 32 | become: yes 33 | tasks: 34 | - name: Install firewalld 35 | ansible.builtin.dnf: 36 | name: firewalld 37 | state: latest 38 | - name: Enable and start firewalld 39 | ansible.builtin.systemd: 40 | name: firewalld 41 | enabled: yes 42 | state: started 43 | - name: Permit inbound SSH 44 | ansible.posix.firewalld: 45 | immediate: yes 46 | permanent: true 47 | service: ssh 48 | state: enabled 49 | - name: Permit inbound HTTP 50 | ansible.posix.firewalld: 51 | immediate: yes 52 | permanent: true 53 | service: http 54 | state: enabled 55 | - name: Permit inbound HTTPS 56 | ansible.posix.firewalld: 57 | immediate: yes 58 | permanent: true 59 | service: https 60 | state: enabled 61 | 62 | - name: Set up devconsole 63 | hosts: consoles 64 | become: yes 65 | tasks: 66 | - name: Create application user 67 | ansible.builtin.user: 68 | name: devconsole 69 | create_home: false 70 | password: '!' 71 | shell: /usr/sbin/nologin 72 | umask: 0077 73 | - name: Install application 74 | ansible.builtin.copy: 75 | src: ./devconsole 76 | dest: /opt/devconsole/ 77 | mode: 0755 78 | - name: Allow nginx to read static web files 79 | community.general.sefcontext: 80 | target: '/srv/{{ inventory_hostname }}(/.*)?' 81 | setype: httpd_sys_content_t 82 | - name: Install static web files 83 | ansible.builtin.copy: 84 | src: ./dist/web/ 85 | dest: '/srv/{{ inventory_hostname }}/' 86 | - name: Create application data directory 87 | ansible.builtin.file: 88 | path: /var/lib/devconsole 89 | state: directory 90 | owner: devconsole 91 | group: devconsole 92 | mode: 0700 93 | - name: Install systemd service 94 | ansible.builtin.copy: 95 | src: ./devconsole.service 96 | dest: /usr/lib/systemd/system/devconsole.service 97 | - name: Enable and start systemd service 98 | ansible.builtin.systemd: 99 | name: devconsole 100 | enabled: yes 101 | state: started 102 | 103 | - name: Set up reposerver 104 | hosts: repos 105 | become: yes 106 | tasks: 107 | - name: Create application user 108 | ansible.builtin.user: 109 | name: reposerver 110 | create_home: false 111 | password: '!' 112 | shell: /usr/sbin/nologin 113 | umask: 0077 114 | - name: Install application 115 | ansible.builtin.copy: 116 | src: ./reposerver 117 | dest: /opt/reposerver/ 118 | mode: 0755 119 | - name: Allow nginx to read static files 120 | community.general.sefcontext: 121 | target: '/srv/{{ inventory_hostname }}(/.*)?' 122 | setype: httpd_sys_content_t 123 | - name: Create static file directory 124 | ansible.builtin.file: 125 | path: '/srv/{{ inventory_hostname }}' 126 | state: directory 127 | owner: reposerver 128 | group: reposerver 129 | mode: 0755 130 | - name: Install systemd service 131 | ansible.builtin.template: 132 | src: reposerver.service.j2 133 | dest: /usr/lib/systemd/system/reposerver.service 134 | - name: Enable and start systemd service 135 | ansible.builtin.systemd: 136 | name: reposerver 137 | enabled: yes 138 | state: started 139 | 140 | - name: Set up nginx 141 | hosts: all 142 | become: yes 143 | tasks: 144 | - name: Install nginx 145 | ansible.builtin.dnf: 146 | name: nginx 147 | state: latest 148 | - name: Enable and start nginx service 149 | ansible.builtin.systemd: 150 | name: nginx 151 | enabled: yes 152 | state: started 153 | - name: Allow nginx proxying 154 | ansible.posix.seboolean: 155 | name: httpd_can_network_connect 156 | persistent: yes 157 | state: yes 158 | 159 | - name: Set up nginx for devconsole 160 | hosts: consoles 161 | become: yes 162 | tasks: 163 | - name: Install root config 164 | ansible.builtin.template: 165 | src: nginx/devconsole.conf.j2 166 | dest: /etc/nginx/nginx.conf 167 | - name: Install security config 168 | ansible.builtin.copy: 169 | src: nginx/security.conf 170 | dest: /etc/nginx/security.conf 171 | - name: Reload nginx 172 | ansible.builtin.systemd: 173 | name: nginx 174 | state: reloaded 175 | 176 | - name: Set up nginx for reposerver 177 | hosts: repos 178 | become: yes 179 | tasks: 180 | - name: Install root config 181 | ansible.builtin.template: 182 | src: nginx/reposerver.conf.j2 183 | dest: /etc/nginx/nginx.conf 184 | - name: Install security config 185 | ansible.builtin.copy: 186 | src: nginx/security.conf 187 | dest: /etc/nginx/security.conf 188 | - name: Reload nginx 189 | ansible.builtin.systemd: 190 | name: nginx 191 | state: reloaded 192 | -------------------------------------------------------------------------------- /devconsole/proto/targeting.proto: -------------------------------------------------------------------------------- 1 | // Sourced from https://github.com/google/bundletool/blob/1.13.1/src/main/proto/targeting.proto 2 | 3 | syntax = "proto3"; 4 | 5 | package android.bundle; 6 | 7 | import "google/protobuf/wrappers.proto"; 8 | 9 | option go_package = "."; 10 | 11 | // Targeting on the level of variants. 12 | message VariantTargeting { 13 | SdkVersionTargeting sdk_version_targeting = 1; 14 | AbiTargeting abi_targeting = 2; 15 | ScreenDensityTargeting screen_density_targeting = 3; 16 | MultiAbiTargeting multi_abi_targeting = 4; 17 | TextureCompressionFormatTargeting texture_compression_format_targeting = 5; 18 | SdkRuntimeTargeting sdk_runtime_targeting = 6; 19 | } 20 | 21 | // Targeting on the level of individual APKs. 22 | message ApkTargeting { 23 | AbiTargeting abi_targeting = 1; 24 | reserved 2; // was GraphicsApiTargeting 25 | LanguageTargeting language_targeting = 3; 26 | ScreenDensityTargeting screen_density_targeting = 4; 27 | SdkVersionTargeting sdk_version_targeting = 5; 28 | TextureCompressionFormatTargeting texture_compression_format_targeting = 6; 29 | MultiAbiTargeting multi_abi_targeting = 7; 30 | SanitizerTargeting sanitizer_targeting = 8; 31 | DeviceTierTargeting device_tier_targeting = 9; 32 | CountrySetTargeting country_set_targeting = 10; 33 | } 34 | 35 | // Targeting on the module level. 36 | // The semantic of the targeting is the "AND" rule on all immediate values. 37 | message ModuleTargeting { 38 | SdkVersionTargeting sdk_version_targeting = 1; 39 | repeated DeviceFeatureTargeting device_feature_targeting = 2; 40 | UserCountriesTargeting user_countries_targeting = 3; 41 | DeviceGroupModuleTargeting device_group_targeting = 5; 42 | 43 | reserved 4; 44 | } 45 | 46 | // User Countries targeting describing an inclusive/exclusive list of country 47 | // codes that module targets. 48 | message UserCountriesTargeting { 49 | // List of country codes in the two-letter CLDR territory format. 50 | repeated string country_codes = 1; 51 | 52 | // Indicates if the list above is exclusive. 53 | bool exclude = 2; 54 | } 55 | 56 | message ScreenDensity { 57 | enum DensityAlias { 58 | DENSITY_UNSPECIFIED = 0; 59 | NODPI = 1; 60 | LDPI = 2; 61 | MDPI = 3; 62 | TVDPI = 4; 63 | HDPI = 5; 64 | XHDPI = 6; 65 | XXHDPI = 7; 66 | XXXHDPI = 8; 67 | } 68 | 69 | oneof density_oneof { 70 | DensityAlias density_alias = 1; 71 | int32 density_dpi = 2; 72 | } 73 | } 74 | 75 | message SdkVersion { 76 | // Inclusive. 77 | google.protobuf.Int32Value min = 1; 78 | } 79 | 80 | message TextureCompressionFormat { 81 | enum TextureCompressionFormatAlias { 82 | UNSPECIFIED_TEXTURE_COMPRESSION_FORMAT = 0; 83 | ETC1_RGB8 = 1; 84 | PALETTED = 2; 85 | THREE_DC = 3; 86 | ATC = 4; 87 | LATC = 5; 88 | DXT1 = 6; 89 | S3TC = 7; 90 | PVRTC = 8; 91 | ASTC = 9; 92 | ETC2 = 10; 93 | } 94 | TextureCompressionFormatAlias alias = 1; 95 | } 96 | 97 | message Abi { 98 | enum AbiAlias { 99 | UNSPECIFIED_CPU_ARCHITECTURE = 0; 100 | ARMEABI = 1; 101 | ARMEABI_V7A = 2; 102 | ARM64_V8A = 3; 103 | X86 = 4; 104 | X86_64 = 5; 105 | MIPS = 6; 106 | MIPS64 = 7; 107 | } 108 | AbiAlias alias = 1; 109 | } 110 | 111 | message MultiAbi { 112 | repeated Abi abi = 1; 113 | } 114 | 115 | message Sanitizer { 116 | enum SanitizerAlias { 117 | NONE = 0; 118 | HWADDRESS = 1; 119 | } 120 | SanitizerAlias alias = 1; 121 | } 122 | 123 | message DeviceFeature { 124 | string feature_name = 1; 125 | // Equivalent of android:glEsVersion or android:version in . 126 | int32 feature_version = 2; 127 | } 128 | 129 | // Targeting specific for directories under assets/. 130 | message AssetsDirectoryTargeting { 131 | AbiTargeting abi = 1; 132 | reserved 2; // was GraphicsApiTargeting 133 | TextureCompressionFormatTargeting texture_compression_format = 3; 134 | LanguageTargeting language = 4; 135 | DeviceTierTargeting device_tier = 5; 136 | CountrySetTargeting country_set = 6; 137 | } 138 | 139 | // Targeting specific for directories under lib/. 140 | message NativeDirectoryTargeting { 141 | Abi abi = 1; 142 | reserved 2; // was GraphicsApi 143 | TextureCompressionFormat texture_compression_format = 3; 144 | Sanitizer sanitizer = 4; 145 | } 146 | 147 | // Targeting specific for image files under apex/. 148 | message ApexImageTargeting { 149 | MultiAbiTargeting multi_abi = 1; 150 | } 151 | 152 | message AbiTargeting { 153 | repeated Abi value = 1; 154 | // Targeting of other sibling directories that were in the Bundle. 155 | // For master splits this is targeting of other master splits. 156 | repeated Abi alternatives = 2; 157 | } 158 | 159 | message MultiAbiTargeting { 160 | repeated MultiAbi value = 1; 161 | // Targeting of other sibling directories that were in the Bundle. 162 | // For master splits this is targeting of other master splits. 163 | repeated MultiAbi alternatives = 2; 164 | } 165 | 166 | message ScreenDensityTargeting { 167 | repeated ScreenDensity value = 1; 168 | // Targeting of other sibling directories that were in the Bundle. 169 | // For master splits this is targeting of other master splits. 170 | repeated ScreenDensity alternatives = 2; 171 | } 172 | 173 | message LanguageTargeting { 174 | // ISO-639: 2 or 3 letter language code. 175 | repeated string 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 string alternatives = 2; 179 | } 180 | 181 | message SdkVersionTargeting { 182 | repeated SdkVersion 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 SdkVersion alternatives = 2; 186 | } 187 | 188 | message TextureCompressionFormatTargeting { 189 | repeated TextureCompressionFormat value = 1; 190 | // Targeting of other sibling directories that were in the Bundle. 191 | // For master splits this is targeting of other master splits. 192 | repeated TextureCompressionFormat alternatives = 2; 193 | } 194 | 195 | message SanitizerTargeting { 196 | repeated Sanitizer value = 1; 197 | } 198 | 199 | // Since other atom targeting messages have the "OR" semantic on values 200 | // the DeviceFeatureTargeting represents only one device feature to retain 201 | // that convention. 202 | message DeviceFeatureTargeting { 203 | DeviceFeature required_feature = 1; 204 | } 205 | 206 | // Targets assets and APKs to a concrete device tier. 207 | message DeviceTierTargeting { 208 | repeated google.protobuf.Int32Value value = 3; 209 | repeated google.protobuf.Int32Value alternatives = 4; 210 | 211 | reserved 1, 2; 212 | } 213 | 214 | // Targets assets and APKs to a specific country set. 215 | // For Example:- 216 | // The values and alternatives for the following files in assets directory 217 | // targeting would be as follows: 218 | // assetpack1/assets/foo#countries_latam/bar.txt -> 219 | // { value: [latam], alternatives: [sea] } 220 | // assetpack1/assets/foo#countries_sea/bar.txt -> 221 | // { value: [sea], alternatives: [latam] } 222 | // assetpack1/assets/foo/bar.txt -> 223 | // { value: [], alternatives: [sea, latam] } 224 | // The values and alternatives for the following targeted split apks would be as 225 | // follows: 226 | // splits/base-countries_latam.apk -> 227 | // { value: [latam], alternatives: [sea] } 228 | // splits/base-countries_sea.apk -> 229 | // { value: [sea], alternatives: [latam] } 230 | // splits/base-other_countries.apk -> 231 | // { value: [], alternatives: [sea, latam] } 232 | message CountrySetTargeting { 233 | // Country set name defined in device tier config. 234 | repeated string value = 1; 235 | // Targeting of other sibling directories that were in the Bundle. 236 | repeated string alternatives = 2; 237 | } 238 | 239 | // Targets conditional modules to a set of device groups. 240 | message DeviceGroupModuleTargeting { 241 | repeated string value = 1; 242 | } 243 | 244 | // Variant targeting based on SDK Runtime availability on device. 245 | message SdkRuntimeTargeting { 246 | // Whether the variant requires SDK Runtime to be available on the device. 247 | bool requires_sdk_runtime = 1; 248 | } 249 | -------------------------------------------------------------------------------- /reposerver/go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= 2 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= 4 | github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= 5 | github.com/bytedance/sonic v1.10.0 h1:qtNZduETEIWJVIyDl01BeNxur2rW9OwTQ/yBqFRkKEk= 6 | github.com/bytedance/sonic v1.10.0/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= 7 | github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= 8 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= 9 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= 10 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= 11 | github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= 12 | github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= 17 | github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= 18 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 19 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 20 | github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= 21 | github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= 22 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 23 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 24 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 25 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 26 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 27 | github.com/go-playground/validator/v10 v10.15.3 h1:S+sSpunYjNPDuXkWbK+x+bA7iXiW296KG4dL3X7xUZo= 28 | github.com/go-playground/validator/v10 v10.15.3/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 29 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 30 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 31 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 32 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 33 | github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= 34 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 35 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 36 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 37 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 38 | github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= 39 | github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 40 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 41 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= 42 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 43 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 44 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 45 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 46 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 47 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 48 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 49 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 50 | github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= 51 | github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= 52 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 53 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 54 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 55 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 56 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 57 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 58 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 59 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 60 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 61 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 62 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 63 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 64 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 65 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 66 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 67 | github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= 68 | github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 69 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 70 | golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc= 71 | golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 72 | golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= 73 | golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 74 | golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq8U5YUhetc9cBWKS1TQ= 75 | golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8= 76 | golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= 77 | golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= 78 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 79 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 80 | golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= 81 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 82 | golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= 83 | golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 84 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 85 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 86 | google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= 87 | google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 88 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 89 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 90 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 91 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 92 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 93 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 94 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 95 | --------------------------------------------------------------------------------