├── 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 |
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 |
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 |
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 |
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 |
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 |
34 |
35 |
36 |
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 |
--------------------------------------------------------------------------------