Initial commit
commit
c6f6ec1bc1
@ -0,0 +1,11 @@
|
|||||||
|
name: default
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: phpstan
|
||||||
|
image: d.xr.to/php
|
||||||
|
commands:
|
||||||
|
- composer install
|
||||||
|
- ./vendor/bin/php-cs-fixer fix --dry-run .
|
||||||
|
- ./vendor/bin/phpstan analyse src
|
@ -0,0 +1,4 @@
|
|||||||
|
/vendor/
|
||||||
|
/var
|
||||||
|
/certs
|
||||||
|
/.idea/
|
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "cubistore/web",
|
||||||
|
"description": "The frontend of CubiStore website",
|
||||||
|
"type": "project",
|
||||||
|
"require": {
|
||||||
|
"php": "^7.3",
|
||||||
|
"slim/slim": "^4.3",
|
||||||
|
"php-di/php-di": "^6.0",
|
||||||
|
"doctrine/orm": "^2.7",
|
||||||
|
"php-di/slim-bridge": "^3.0",
|
||||||
|
"ext-json": "*",
|
||||||
|
"slim/psr7": "^0.6.0",
|
||||||
|
"twig/twig": "^3.0",
|
||||||
|
"doctrine/migrations": "^2.2",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"ext-openssl": "*"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"squizlabs/php_codesniffer": "3.*",
|
||||||
|
"phpstan/phpstan": "^0.11.19"
|
||||||
|
},
|
||||||
|
"license": "GPLv3",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "eater",
|
||||||
|
"email": "=@eater.me"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"CubiStore\\Web\\": "src/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
// Doctrine CLI config
|
||||||
|
use CubiStore\Web\Main;
|
||||||
|
use Doctrine\ORM\EntityManager;
|
||||||
|
use Doctrine\ORM\Tools\Console\ConsoleRunner;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
$main = Main::make('dev');
|
||||||
|
$entityManager = $main->get(EntityManager::class);
|
||||||
|
return ConsoleRunner::createHelperSet($entityManager);
|
@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use DI\Container;
|
||||||
|
use Doctrine\ORM\Configuration;
|
||||||
|
use Doctrine\ORM\EntityManager;
|
||||||
|
use Doctrine\ORM\Tools\Setup;
|
||||||
|
use Twig\Loader\FilesystemLoader;
|
||||||
|
use Twig\Loader\LoaderInterface;
|
||||||
|
use function DI\autowire;
|
||||||
|
use function DI\factory;
|
||||||
|
use function DI\get;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'app.name' => 'CubiStore',
|
||||||
|
'app.description' => 'Hello world, ya we signing',
|
||||||
|
'app.domain' => 'localhost:8888',
|
||||||
|
|
||||||
|
'doctrine.connection' => [
|
||||||
|
'driver' => 'pdo_sqlite',
|
||||||
|
'path' => __DIR__ . '/../var/db.sqlite'
|
||||||
|
],
|
||||||
|
|
||||||
|
'twig.views' => __DIR__ . '/../views',
|
||||||
|
'env.is_dev' => factory(function () {
|
||||||
|
return $_ENV['ENV'] !== 'prod';
|
||||||
|
}),
|
||||||
|
'twig.config' => factory(function (Container $container) {
|
||||||
|
return [
|
||||||
|
'debug' => $container->get('env.is_dev'),
|
||||||
|
'cache' => __DIR__ . '/../var/cache/views'
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Twig
|
||||||
|
Twig\Environment::class => autowire()->constructorParameter(1, get('twig.config')),
|
||||||
|
FilesystemLoader::class => autowire()->constructorParameter(0, get('twig.views')),
|
||||||
|
LoaderInterface::class => get(FilesystemLoader::class),
|
||||||
|
|
||||||
|
// Doctrine
|
||||||
|
Configuration::class => factory(function (Container $container) {
|
||||||
|
return Setup::createAnnotationMetadataConfiguration(
|
||||||
|
[__DIR__ . '/../src/Model'],
|
||||||
|
$container->get('env.is_dev'),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
EntityManager::class => factory([EntityManager::class, 'create'])
|
||||||
|
->parameter('connection', get('doctrine.connection'))
|
||||||
|
];
|
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use CubiStore\Web\Controller\AppManager;
|
||||||
|
use CubiStore\Web\Controller\Home;
|
||||||
|
use CubiStore\Web\Controller\Repo;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'/' => Home::class . '::index',
|
||||||
|
'/app/create' => [
|
||||||
|
'GET' => AppManager::class . '::createShow',
|
||||||
|
'POST' => AppManager::class . '::createAction',
|
||||||
|
],
|
||||||
|
'/repo/index-v1.json' => Repo::class . '::json',
|
||||||
|
'/repo/index-v1.jar' => Repo::class . '::jarJson',
|
||||||
|
];
|
@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use CubiStore\Web\Main;
|
||||||
|
|
||||||
|
include(__DIR__ . '/../vendor/autoload.php');
|
||||||
|
define('STDOUT', fopen('php://stdout', 'w'));
|
||||||
|
fprintf(STDOUT, $_SERVER['REQUEST_METHOD'] . ' ' . $_SERVER['REQUEST_URI'] . "\n");
|
||||||
|
|
||||||
|
Main::main('dev');
|
@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
namespace CubiStore\Web\Controller;
|
||||||
|
|
||||||
|
|
||||||
|
use CubiStore\Web\Service\ApkService;
|
||||||
|
use CubiStore\Web\Service\AppService;
|
||||||
|
use CubiStore\Web\Utils\Apk;
|
||||||
|
use Doctrine\ORM\EntityManager;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\UploadedFileInterface;
|
||||||
|
use Slim\Psr7\Request;
|
||||||
|
use Slim\Psr7\Response;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
class AppManager
|
||||||
|
{
|
||||||
|
function createShow(Environment $twig, ResponseInterface $response)
|
||||||
|
{
|
||||||
|
$response->getBody()->write($twig->render('app/create.html.twig'));
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAction(Request $request, Response $response, ApkService $apkService, AppService $appService, EntityManager $em)
|
||||||
|
{
|
||||||
|
$files = $request->getUploadedFiles();
|
||||||
|
|
||||||
|
if (!isset($files['apk'])) {
|
||||||
|
return $response
|
||||||
|
->withStatus(302)
|
||||||
|
->withHeader('Location', '/app/create');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var UploadedFileInterface $apkUpload */
|
||||||
|
$apkUpload = $files['apk'];
|
||||||
|
|
||||||
|
// Check if upload succeeded
|
||||||
|
if ($apkUpload->getError() !== UPLOAD_ERR_OK) {
|
||||||
|
return $response
|
||||||
|
->withStatus(302)
|
||||||
|
->withHeader('Location', '/app/create');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'apk-');
|
||||||
|
$apkUpload->moveTo($tmp);
|
||||||
|
|
||||||
|
$apk = new Apk($tmp);
|
||||||
|
|
||||||
|
if (!$apk->isValid()) {
|
||||||
|
return $response
|
||||||
|
->withStatus(302)
|
||||||
|
->withHeader('Location', '/app/create');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($appService->hasApprovedApp($apk->getPackageName())) {
|
||||||
|
return $response
|
||||||
|
->withStatus(302)
|
||||||
|
->withHeader('Location', '/app/create');
|
||||||
|
}
|
||||||
|
|
||||||
|
$app = $appService->createApp($apk);
|
||||||
|
|
||||||
|
// Flush to get id, as it's used in getStorePath
|
||||||
|
$em->persist($app);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$storePath = $appService->getStorePath($app, $apk);
|
||||||
|
if (!is_dir($storePath) && !mkdir($storePath, 0777, true)) {
|
||||||
|
return $response
|
||||||
|
->withStatus(302)
|
||||||
|
->withHeader('Location', '/app/create');
|
||||||
|
}
|
||||||
|
|
||||||
|
$newApkPath = $storePath . '/' . $apkUpload->getClientFilename();
|
||||||
|
if (!rename($tmp, $newApkPath)) {
|
||||||
|
return $response
|
||||||
|
->withStatus(302)
|
||||||
|
->withHeader('Location', '/app/create');
|
||||||
|
}
|
||||||
|
|
||||||
|
$release = $appService->createRelease($apk, $app, $newApkPath);
|
||||||
|
$em->persist($release);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $response
|
||||||
|
->withStatus(302)
|
||||||
|
->withHeader('Location', '/app/' . $app->getName() . '/' . $app->getId() . '/manage');
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
namespace CubiStore\Web\Controller;
|
||||||
|
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
|
class Home
|
||||||
|
{
|
||||||
|
public function index(ResponseInterface $response): ResponseInterface
|
||||||
|
{
|
||||||
|
$response->getBody()->write("Hello world");
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
namespace CubiStore\Web\Controller;
|
||||||
|
|
||||||
|
|
||||||
|
use CubiStore\Web\Service\FDroidRepoService;
|
||||||
|
use Slim\Psr7\Response;
|
||||||
|
|
||||||
|
class Repo
|
||||||
|
{
|
||||||
|
function json(Response $response, FDroidRepoService $repoService)
|
||||||
|
{
|
||||||
|
$jsonArr = $repoService->createIndexV1Array();
|
||||||
|
|
||||||
|
$response
|
||||||
|
->getBody()->write(json_encode($jsonArr));
|
||||||
|
|
||||||
|
return $response
|
||||||
|
->withHeader('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function jarJson(Response $response, FDroidRepoService $repoService)
|
||||||
|
{
|
||||||
|
$jsonArr = $repoService->createIndexV1Array();
|
||||||
|
$jsonFile = json_encode($jsonArr);
|
||||||
|
|
||||||
|
$jar = $repoService->createSigned('index-v1.json', $jsonFile);
|
||||||
|
$response->getBody()->write($jar);
|
||||||
|
|
||||||
|
return $response
|
||||||
|
->withHeader('Content-Type', 'application/x-jar')
|
||||||
|
->withHeader('ETag', (string)time());
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
namespace CubiStore\Web;
|
||||||
|
|
||||||
|
use CubiStore\Web\Cache\CompiledRoutes;
|
||||||
|
use CubiStore\Web\Utils\ICompiledRoutes;
|
||||||
|
use CubiStore\Web\Utils\RouteCompiler;
|
||||||
|
use DI\Bridge\Slim\Bridge;
|
||||||
|
use DI\Container;
|
||||||
|
use DI\ContainerBuilder;
|
||||||
|
use Slim\App;
|
||||||
|
use function DI\autowire;
|
||||||
|
|
||||||
|
class Main
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private $env;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Container
|
||||||
|
*/
|
||||||
|
private $container;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var App
|
||||||
|
*/
|
||||||
|
private $app;
|
||||||
|
|
||||||
|
private function __construct(string $env = 'prod')
|
||||||
|
{
|
||||||
|
$this->env = $env;
|
||||||
|
}
|
||||||
|
|
||||||
|
static function main(string $env = 'prod')
|
||||||
|
{
|
||||||
|
static::make($env)->run();
|
||||||
|
}
|
||||||
|
|
||||||
|
static function make(string $env = 'prod')
|
||||||
|
{
|
||||||
|
$_ENV['ENV'] = $env;
|
||||||
|
$main = new Main($env);
|
||||||
|
$main->setup();
|
||||||
|
return $main;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run()
|
||||||
|
{
|
||||||
|
$this->app->run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup()
|
||||||
|
{
|
||||||
|
$routes = $this->setupRoutes();
|
||||||
|
$this->setupContainer($routes);
|
||||||
|
$this->setupSlim($routes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupRoutes(): ICompiledRoutes
|
||||||
|
{
|
||||||
|
$compiledRoutesFile = __DIR__ . '/../var/cache/routes.php';
|
||||||
|
|
||||||
|
if ($this->env !== 'prod' || !file_exists($compiledRoutesFile)) {
|
||||||
|
$routes = include(__DIR__ . '/../config/routes.php');
|
||||||
|
$compiler = new RouteCompiler($compiledRoutesFile);
|
||||||
|
$compiler->addRoutes($routes);
|
||||||
|
$compiler->writeCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
include(__DIR__ . '/../var/cache/routes.php');
|
||||||
|
return new CompiledRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupContainer(ICompiledRoutes $routes)
|
||||||
|
{
|
||||||
|
$containerBuilder = new ContainerBuilder();
|
||||||
|
|
||||||
|
if ($this->env === 'prod') {
|
||||||
|
$containerBuilder->enableCompilation(__DIR__ . '/../var/cache/container');
|
||||||
|
}
|
||||||
|
|
||||||
|
$containerBuilder->addDefinitions(include(__DIR__ . '/../config/container.php'));
|
||||||
|
|
||||||
|
if ($this->env !== 'prod') {
|
||||||
|
$def = [];
|
||||||
|
foreach ($routes->getKeys() as $key) {
|
||||||
|
if (class_exists($key)) {
|
||||||
|
$def[$key] = autowire();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$containerBuilder->addDefinitions($def);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->container = $containerBuilder->build();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSlim(ICompiledRoutes $compiledRoutes)
|
||||||
|
{
|
||||||
|
$app = Bridge::create($this->container);
|
||||||
|
$compiledRoutes->configure($app);
|
||||||
|
$app->addRoutingMiddleware();
|
||||||
|
$app->addBodyParsingMiddleware();
|
||||||
|
$this->app = $app;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $class)
|
||||||
|
{
|
||||||
|
return $this->container->get($class);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
namespace CubiStore\Web\Model;
|
||||||
|
|
||||||
|
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Entity
|
||||||
|
* @ORM\Table(name="app")
|
||||||
|
*/
|
||||||
|
class App
|
||||||
|
{
|
||||||
|
const STATUS_CREATED = 'created';
|
||||||
|
const STATUS_REJECTED = 'rejected';
|
||||||
|
const STATUS_APPROVED = 'approved';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Id
|
||||||
|
* @ORM\GeneratedValue(strategy="AUTO")
|
||||||
|
* @ORM\Column(type="bigint")
|
||||||
|
* @var integer
|
||||||
|
*/
|
||||||
|
private $id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Column(type="string")
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private $name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Column(type="string")
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private $status = self::STATUS_CREATED;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Column(type="string")
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private $label;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\OneToMany(targetEntity="Release", mappedBy="app")
|
||||||
|
* @var Release[]|ArrayCollection
|
||||||
|
*/
|
||||||
|
private $releases;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->releases = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $name
|
||||||
|
*/
|
||||||
|
public function setName(string $name): void
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getLabel(): string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $label
|
||||||
|
*/
|
||||||
|
public function setLabel(string $label): void
|
||||||
|
{
|
||||||
|
$this->label = $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Release[]|ArrayCollection
|
||||||
|
*/
|
||||||
|
public function getReleases()
|
||||||
|
{
|
||||||
|
return $this->releases;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Release[]|ArrayCollection $releases
|
||||||
|
*/
|
||||||
|
public function setReleases($releases): void
|
||||||
|
{
|
||||||
|
$this->releases = $releases;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getStatus(): string
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $status
|
||||||
|
*/
|
||||||
|
public function setStatus(string $status): void
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
namespace CubiStore\Web\Model;
|
||||||
|
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Entity
|
||||||
|
* @ORM\Table(name="release")
|
||||||
|
*/
|
||||||
|
class Release
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var integer
|
||||||
|
* @ORM\Id
|
||||||
|
* @ORM\GeneratedValue(strategy="AUTO")
|
||||||
|
* @ORM\Column(type="bigint")
|
||||||
|
*/
|
||||||
|
private $id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var App
|
||||||
|
* @ORM\ManyToOne(targetEntity="App", inversedBy="releases")
|
||||||
|
*/
|
||||||
|
private $app;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
* @ORM\Column(type="string")
|
||||||
|
*/
|
||||||
|
private $versionName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var integer
|
||||||
|
* @ORM\Column(type="integer")
|
||||||
|
*/
|
||||||
|
private $versionCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
* @ORM\Column(type="string")
|
||||||
|
*/
|
||||||
|
private $apk;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return App
|
||||||
|
*/
|
||||||
|
public function getApp(): App
|
||||||
|
{
|
||||||
|
return $this->app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param App $app
|
||||||
|
*/
|
||||||
|
public function setApp(App $app): void
|
||||||
|
{
|
||||||
|
$this->app = $app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getVersionName(): string
|
||||||
|
{
|
||||||
|
return $this->versionName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $versionName
|
||||||
|
*/
|
||||||
|
public function setVersionName(string $versionName): void
|
||||||
|
{
|
||||||
|
$this->versionName = $versionName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function getVersionCode(): int
|
||||||
|
{
|
||||||
|
return $this->versionCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $versionCode
|
||||||
|
*/
|
||||||
|
public function setVersionCode(int $versionCode): void
|
||||||
|
{
|
||||||
|
$this->versionCode = $versionCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getApk(): string
|
||||||
|
{
|
||||||
|
return $this->apk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $apk
|
||||||
|
*/
|
||||||
|
public function setApk(string $apk): void
|
||||||
|
{
|
||||||
|
$this->apk = $apk;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
namespace CubiStore\Web\Service;
|
||||||
|
|
||||||
|
|
||||||
|
class ApkService
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
namespace CubiStore\Web\Service;
|
||||||
|
|
||||||
|
|
||||||
|
use ApkParser\Manifest;
|
||||||
|
use ApkParser\Parser;
|
||||||
|
use CubiStore\Web\Model\App;
|
||||||
|
use CubiStore\Web\Model\Release;
|
||||||
|
use CubiStore\Web\Utils\Apk;
|
||||||
|
use Doctrine\ORM\EntityManager;
|
||||||
|
|
||||||
|
class AppService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var EntityManager
|
||||||
|
*/
|
||||||
|
private $entityManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppService constructor.
|
||||||
|
* @param EntityManager $entityManager
|
||||||
|
*/
|
||||||
|
public function __construct(EntityManager $entityManager)
|
||||||
|
{
|
||||||
|
$this->entityManager = $entityManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasApprovedApp(string $name): bool
|
||||||
|
{
|
||||||
|
/** @var App|null $app */
|
||||||
|
$app = $this->entityManager
|
||||||
|
->getRepository(App::class)
|
||||||
|
->findOneBy(['name' => $name, 'status' => App::STATUS_APPROVED]);
|
||||||
|
|
||||||
|
return $app !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createApp(Apk $apk): App
|
||||||
|
{
|
||||||
|
$app = new App();
|
||||||
|
$app->setName($apk->getPackageName());
|
||||||
|
$app->setLabel($apk->getLabel());
|
||||||
|
|
||||||
|
return $app;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createRelease(Apk $apk, App $app, string $file): Release
|
||||||
|
{
|
||||||
|
$release = new Release();
|
||||||
|
$release->setApp($app);
|
||||||
|
$release->setVersionCode($apk->getVersionCode());
|
||||||
|
$release->setVersionName($apk->getVersionName());
|
||||||
|
$release->setApk($file);
|
||||||
|
|
||||||
|
return $release;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStorePath(App $app, Apk $apk): string
|
||||||
|
{
|
||||||
|
return __DIR__ . '/../../var/storage/apk/' . $app->getName() . '/' . $app->getId() . '/' . $apk->getVersionCode();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
namespace CubiStore\Web\Service;
|
||||||
|
|
||||||
|
|
||||||
|
use CubiStore\Web\Model\App;
|
||||||
|
use DI\Container;
|
||||||
|
use Doctrine\ORM\EntityManager;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
class FDroidRepoService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var Container
|
||||||
|
*/
|
||||||
|
private $container;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FDroidRepoService constructor.
|
||||||
|
* @param Container $container
|
||||||
|
*/
|
||||||
|
public function __construct(Container $container)
|
||||||
|
{
|
||||||
|
$this->container = $container;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createIndexV1Array()
|
||||||
|
{
|
||||||
|
/** @var EntityManager $em */
|
||||||
|
$em = $this->container->get(EntityManager::class);
|
||||||
|
|
||||||
|
$apps = $em
|
||||||
|
->getRepository(App::class)
|
||||||
|
->findBy([
|
||||||
|
'status' => App::STATUS_APPROVED
|
||||||
|
]);
|
||||||
|
|
||||||
|
$appObjects = [];
|
||||||
|
$packageObjects = [];
|
||||||
|
|
||||||
|
foreach ($apps as $app) {
|
||||||
|
/** @var $app App */
|
||||||
|
$appObjects[] = [
|
||||||
|
"authorEmail" => "example@example.com",
|
||||||
|
"authorName" => "example",
|
||||||
|
"authorWebSite" => "https://example.org",
|
||||||
|
"categories" => [
|
||||||
|
'example'
|
||||||
|
],
|
||||||
|
"suggestedVersionCode" => $app->getReleases()->first()->getVersionCode(),
|
||||||
|
"name" => $app->getLabel(),
|
||||||
|
"added" => 0,
|
||||||
|
"packageName" => $app->getName(),
|
||||||
|
"lastUpdated" => 0,
|
||||||
|
"license" => "no"
|
||||||
|
];
|
||||||
|
|
||||||
|
$packages = [];
|
||||||
|
|
||||||
|
foreach ($app->getReleases() as $release) {
|
||||||
|
$packages[] = [
|
||||||
|
"added" => 0,
|
||||||
|
"apkName" => "/apk/" . $app->getName() . "/" . $release->getVersionCode() . ".apk",
|
||||||
|
"hash" => hash('sha256', $release->getApk()),
|
||||||
|
"hashType" => "sha256",
|
||||||
|
"minSdkVersion" => 4,
|
||||||
|
"packageName" => $app->getName(),
|
||||||
|
"sig" => "deadbeef",
|
||||||
|
"signer" => "deadbeef",
|
||||||
|
"size" => filesize($release->getApk()),
|
||||||
|
"targetSdkVersion" => 28,
|
||||||
|
"versionCode" => $release->getVersionCode(),
|
||||||
|
"versionName" => $release->getVersionName()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$packageObjects[$app->getName()] = $packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'repo' => [
|
||||||
|
'timestamp' => time() * 1000,
|
||||||
|
'version' => 21,
|
||||||
|
'maxage' => 14,
|
||||||
|
'name' => $this->container->get('app.name'),
|
||||||
|
'description' => $this->container->get('app.description'),
|
||||||
|
'address' => "https://" . $this->container->get('app.domain') . '/repo'
|
||||||
|
],
|
||||||
|
'requests' => [
|
||||||
|
'install' => [],
|
||||||
|
'uninstall' => [],
|
||||||
|
],
|
||||||
|
"apps" => $appObjects,
|
||||||
|
"packages" => $packageObjects
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createSigned($file, $contents)
|
||||||
|
{
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zipPath = tempnam(sys_get_temp_dir(), 'zip');
|
||||||
|
$zip->open($zipPath, ZipArchive::CREATE);
|
||||||
|
$fileDigest = sha1($contents, true);
|
||||||
|
|
||||||
|
$fileHeader = 'Name: ' . $file . "\n";
|
||||||
|
$fileHeader .= 'SHA1-Digest: ' . base64_encode($fileDigest) . "\n\n";
|
||||||
|
|
||||||
|
$fileHeaderDigest = sha1($fileHeader, true);
|
||||||
|
|
||||||
|
$manifest = "Manifest-Version: 1.0\n";
|
||||||
|
$manifest .= "Created-By: CubiStore\n\n";
|
||||||
|
$manifestHeaderDigest = sha1($manifest, true);
|
||||||
|
|
||||||
|
$manifest .= $fileHeader;
|
||||||
|
$manifestDigest = sha1($manifest, true);
|
||||||
|
|
||||||
|
$fileManifest = "Signature-Version: 1.0\n";
|
||||||
|
$fileManifest .= "SHA1-Digest-Manifest-Main-Attributes: " . base64_encode($manifestHeaderDigest) . "\n";
|
||||||
|
$fileManifest .= "SHA1-Digest-Manifest: " . base64_encode($manifestDigest) . "\n";
|
||||||
|
$fileManifest .= "Created-By: CubiStore\n\n";
|
||||||
|
$fileManifest .= "Name: " . $file . "\n";
|
||||||
|
$fileManifest .= "SHA1-Digest: " . base64_encode($fileHeaderDigest) . "\n\n";
|
||||||
|
|
||||||
|
$zip->addFromString('META-INF/MANIFEST.MF', $manifest);
|
||||||
|
$zip->addFromString('META-INF/1.SF', $fileManifest);
|
||||||
|
|
||||||
|
$in = tempnam(sys_get_temp_dir(), 'repo');
|
||||||
|
file_put_contents($in, $fileManifest);
|
||||||
|
$out = tempnam(sys_get_temp_dir(), 'repo');
|
||||||
|
$success = openssl_pkcs7_sign(
|
||||||
|
$in,
|
||||||
|
$out,
|
||||||
|
'file://' . __DIR__ . '/../../certs/cert.pem',
|
||||||
|
'file://' . __DIR__ . '/../../certs/key.pem',
|
||||||
|
[],
|
||||||
|
PKCS7_BINARY | PKCS7_NOATTR
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$success) {
|
||||||
|
$error = '';
|
||||||
|
while ($line = openssl_error_string()) {
|
||||||
|
$error .= $line . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException($error);
|
||||||
|
}
|
||||||
|
|
||||||
|
unlink($in);
|
||||||
|
$signed = file_get_contents($out);
|
||||||
|
unlink($out);
|
||||||
|
|
||||||
|
$contentOffset = strpos($signed, "\n\n");
|
||||||
|
$content = substr($signed, $contentOffset);
|
||||||
|
$base64 = str_replace("\n", "", $content);
|
||||||
|
$zip->addFromString('META-INF/1.RSA', base64_decode($base64));
|
||||||
|
$zip->addFromString($file, $contents);
|
||||||
|
$zip->close();
|
||||||
|
$zipContent = file_get_contents($zipPath);
|
||||||
|
unlink($zipPath);
|
||||||
|
|
||||||
|
return $zipContent;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
namespace CubiStore\Web\Utils;
|
||||||
|
|
||||||
|
|
||||||
|
use SimpleXMLElement;
|
||||||
|
|
||||||
|
class Apk
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private $file;
|
||||||
|
/**
|
||||||
|
* @var SimpleXMLElement|null
|
||||||
|
*/
|
||||||
|
private $manifest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apk constructor.
|
||||||
|
* @param string $file
|
||||||
|
*/
|
||||||
|
public function __construct(string $file)
|
||||||
|
{
|
||||||
|
$this->file = $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValid(): bool
|
||||||
|
{
|
||||||
|
// Get first 4 bytes
|
||||||
|
$fh = fopen($this->file, 'r');
|
||||||
|
$header = fread($fh, 4);
|
||||||
|
fclose($fh);
|
||||||
|
|
||||||
|
// Check if uploaded file is a valid ZIP file
|
||||||
|
if ($header !== "PK\x03\x04") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip = new \ZipArchive();
|
||||||
|
$zip->open($this->file);
|
||||||
|
$valid = $zip->statName("AndroidManifest.xml") !== false && $zip->statName("resources.arsc");
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
return $valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getManifest(): SimpleXMLElement
|
||||||
|
{
|
||||||
|
if ($this->manifest === null) {
|
||||||
|
$xml = $this->cmd('androguard', 'axml', '-o', '/dev/stdout', $this->file);
|
||||||
|
$this->manifest = simplexml_load_string($xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAttribute(string $name, ?string $namespace = null): string
|
||||||
|
{
|
||||||
|
if ($namespace !== null) {
|
||||||
|
return (string)$this->getManifest()->attributes($namespace, true)[$name];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string)$this->getManifest()[$name];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPackageName(): string
|
||||||
|
{
|
||||||
|
return $this->getAttribute('package');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVersionCode(): int
|
||||||
|
{
|
||||||
|
return intval($this->getAttribute('versionCode', 'android'), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVersionName(): string
|
||||||
|
{
|
||||||
|
return $this->getAttribute('versionName', 'android');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): string
|
||||||
|
{
|
||||||
|
$code = (string)$this->getManifest()->application->attributes('android', true)['label'];
|
||||||
|
$output = $this->cmd('androguard', 'arsc', '--id', $code, $this->file);
|
||||||
|
preg_match(':\<default\> = \'(.*)\'$:m', $output, $matches);
|
||||||
|
return stripslashes($matches[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cmd(string $cmd, string... $args): string
|
||||||
|
{
|
||||||
|
$proc = popen($cmd . ' ' . implode(' ', array_map('escapeshellarg', $args)), 'r');
|
||||||
|
$data = '';
|
||||||
|
while ($text = fread($proc, 1024)) {
|
||||||
|
$data .= $text;
|
||||||
|
}
|
||||||
|
pclose($proc);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
namespace CubiStore\Web\Utils;
|
||||||
|
|
||||||
|
|
||||||
|
use Slim\App;
|
||||||
|
|
||||||
|
interface ICompiledRoutes
|
||||||
|
{
|
||||||
|
public function configure(App $app);
|
||||||
|
public function getKeys(): array;
|
||||||
|
}
|
@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
namespace CubiStore\Web\Utils;
|
||||||
|
|
||||||
|
|
||||||
|
class Route
|
||||||
|
{
|
||||||
|
const TYPE_ROOT = 'root';
|
||||||
|
const TYPE_GROUP = 'group';
|
||||||
|
const TYPE_CALL = 'call';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private $type;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private $path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private $fullPath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Route[]|null
|
||||||
|
*/
|
||||||
|
private $children;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string|null
|
||||||
|
*/
|
||||||
|
private $method;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string|null
|
||||||
|
*/
|
||||||
|
private $call;
|
||||||
|
|
||||||
|
private static function new(Route $parent, string $path, string $type): Route
|
||||||
|
{
|
||||||
|
if (strlen($path) > 0 && $path[0] !== '/') {
|
||||||
|
throw new \RuntimeException("path should start with /");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parent->type !== self::TYPE_GROUP && $parent->type !== self::TYPE_ROOT) {
|
||||||
|
throw new \RuntimeException("Can only add child routes to a group or root route");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
$route = new Route();
|
||||||
|
$parent->add($route);
|
||||||
|
$route->path = $path;
|
||||||
|
$route->fullPath = $parent->fullPath . $path;
|
||||||
|
$route->type = $type;
|
||||||
|
|
||||||
|
return $route;
|
||||||
|
}
|
||||||
|
|
||||||
|
static function group(Route $parent, string $path): Route
|
||||||
|
{
|
||||||
|
$route = static::new($parent, $path, self::TYPE_GROUP);
|
||||||
|
$route->children = [];
|
||||||
|
return $route;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Route $parent
|
||||||
|
* @param string $method
|
||||||
|
* @param string $path
|
||||||
|
* @param string $call
|
||||||
|
* @return Route
|
||||||
|
*/
|
||||||
|
static function call(Route $parent, string $method, string $path, string $call): Route
|
||||||
|
{
|
||||||
|
$route = static::new($parent, $path, self::TYPE_CALL);
|
||||||
|
$route->method = $method;
|
||||||
|
$route->call = $call;
|
||||||
|
return $route;
|
||||||
|
}
|
||||||
|
|
||||||
|
static function root(): Route
|
||||||
|
{
|
||||||
|
$route = new Route();
|
||||||
|
$route->type = self::TYPE_ROOT;
|
||||||
|
$route->fullPath = $route->path = '/';
|
||||||
|
$route->children = [];
|
||||||
|
return $route;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(Route $child): void
|
||||||
|
{
|
||||||
|
if ($this->type !== self::TYPE_GROUP && $this->type !== self::TYPE_ROOT) {
|
||||||
|
throw new \RuntimeException("Only a group route can have child routes");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->children[] = $child;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function json($value)
|
||||||
|
{
|
||||||
|
return json_encode($value, JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function compileRoute($var, $indent = 4): string
|
||||||
|
{
|
||||||
|
if ($this->type === self::TYPE_CALL) {
|
||||||
|
return str_repeat(' ', $indent) . "${var}->map([" . $this->json($this->method) . "], " . $this->json($this->path) . ", " . $this->json($this->call) . ");";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->type === self::TYPE_GROUP) {
|
||||||
|
$name = "\$group" . strlen($this->fullPath);
|
||||||
|
$code = str_repeat(' ', $indent) . "${var}->group(" . $this->json($this->path) . ", function (RouteCollectorProxy $name) {\n";
|
||||||
|
foreach ($this->children as $child) {
|
||||||
|
$code .= $child->compileRoute($name, $indent + 4) . "\n";
|
||||||
|
}
|
||||||
|
return $code . str_repeat(' ', $indent) . "});";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->type === self::TYPE_ROOT) {
|
||||||
|
$code = '';
|
||||||
|
foreach ($this->children as $child) {
|
||||||
|
$code .= $child->compileRoute($var, $indent) . "\n";
|
||||||
|
}
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException("invalid type for route");
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
namespace CubiStore\Web\Utils;
|
||||||
|
|
||||||
|
|
||||||
|
class RouteCompiler
|
||||||
|
{
|
||||||
|
private $target;
|
||||||
|
private $root;
|
||||||
|
private $keys;
|
||||||
|
|
||||||
|
public function __construct($target = __DIR__ . '/../../var/cache/routes.php')
|
||||||
|
{
|
||||||
|
$this->target = $target;
|
||||||
|
$this->root = Route::root();
|
||||||
|
$this->keys = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addRoutes(array $routes, ?Route $target = null)
|
||||||
|
{
|
||||||
|
if ($target === null) {
|
||||||
|
$target = $this->root;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($routes as $route => $details) {
|
||||||
|
if (is_array($details)) {
|
||||||
|
$route = Route::group($target, $route);
|
||||||
|
$this->addRoutes($details, $route);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_string($details)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode(':', $details, 2);
|
||||||
|
$this->keys[$parts[0]] = true;
|
||||||
|
|
||||||
|
if ($route[0] !== '/') {
|
||||||
|
Route::call($target, $route, '', $details);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Route::call($target, 'GET', $route, $details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function writeCache()
|
||||||
|
{
|
||||||
|
file_put_contents($this->target, $this->compile());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function compile()
|
||||||
|
{
|
||||||
|
$keys = array_map(function ($item) {
|
||||||
|
return json_encode($item, JSON_UNESCAPED_SLASHES);
|
||||||
|
}, array_keys($this->keys));
|
||||||
|
|
||||||
|
$head = "<?php
|
||||||
|
|
||||||
|
namespace CubiStore\\Web\\Cache;
|
||||||
|
|
||||||
|
use CubiStore\\Web\\Utils\\ICompiledRoutes;
|
||||||
|
use Slim\\App;
|
||||||
|
use Slim\\Routing\\RouteCollectorProxy;
|
||||||
|
|
||||||
|
class CompiledRoutes implements ICompiledRoutes {
|
||||||
|
function configure(App \$app) {\n";
|
||||||
|
|
||||||
|
$tail = "
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKeys(): array {
|
||||||
|
return [
|
||||||
|
" . implode(",\n" . str_repeat(' ', 12), $keys) . "
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
$configure = $this->root->compileRoute("\$app", 8);
|
||||||
|
|
||||||
|
return $head . $configure . $tail;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
<form action="/app/create" enctype="multipart/form-data" method="post">
|
||||||
|
<label for="app_first_release_apk">APK</label>
|
||||||
|
<input type="file" id="app_first_release_apk" name="apk">
|
||||||
|
|
||||||
|
<button type="submit">Upload</button>
|
||||||
|
</form>
|
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<fdroid>
|
||||||
|
<repo maxage="14" name="CubiStore" >
|
||||||
|
|
||||||
|
</repo>
|
||||||
|
</fdroid>
|
Loading…
Reference in New Issue