├── web
├── .env
├── src
│ ├── modules
│ │ ├── App
│ │ │ ├── styles.less
│ │ │ ├── Layout
│ │ │ │ ├── styles.less
│ │ │ │ ├── Footer
│ │ │ │ │ ├── styles.less
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── Header
│ │ │ │ │ ├── styles.less
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ └── Manager.tsx
│ │ │ ├── index.tsx
│ │ │ ├── routes.ts
│ │ │ └── RouteSwitch.tsx
│ │ ├── Help
│ │ │ ├── Layout
│ │ │ │ ├── styles.less
│ │ │ │ ├── Menu
│ │ │ │ │ ├── styles.less
│ │ │ │ │ └── index.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── index.ts
│ │ │ └── pages
│ │ │ │ ├── Overview
│ │ │ │ ├── index.tsx
│ │ │ │ └── overview.md
│ │ │ │ └── Upload
│ │ │ │ ├── upload.md
│ │ │ │ └── index.tsx
│ │ ├── Home
│ │ │ ├── pages
│ │ │ │ └── Main
│ │ │ │ │ ├── PackageList
│ │ │ │ │ ├── SearchBar
│ │ │ │ │ │ ├── styles.less
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── hooks.ts
│ │ │ │ │ ├── ResultTable
│ │ │ │ │ │ ├── styles.less
│ │ │ │ │ │ ├── Link.tsx
│ │ │ │ │ │ ├── Tag.tsx
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── styles.less
│ │ │ │ │ └── index.tsx
│ │ │ └── index.tsx
│ │ ├── Package
│ │ │ ├── pages
│ │ │ │ ├── PackageDetail
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── Files
│ │ │ │ │ │ ├── types.ts
│ │ │ │ │ │ ├── Table
│ │ │ │ │ │ │ ├── types.ts
│ │ │ │ │ │ │ ├── styles.less
│ │ │ │ │ │ │ ├── DeleteAction.tsx
│ │ │ │ │ │ │ └── index.tsx
│ │ │ │ │ │ ├── Filters
│ │ │ │ │ │ │ ├── styles.less
│ │ │ │ │ │ │ └── index.tsx
│ │ │ │ │ │ ├── hooks.ts
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── PackageInfo
│ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ ├── styles.less
│ │ │ │ │ │ ├── InstallationGuide
│ │ │ │ │ │ │ ├── styles.less
│ │ │ │ │ │ │ ├── CommandGuide.tsx
│ │ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ │ └── PlatformGuide.tsx
│ │ │ │ │ │ └── TopCard.tsx
│ │ │ │ │ ├── hooks.ts
│ │ │ │ │ ├── Header
│ │ │ │ │ │ ├── styles.less
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── styles.less
│ │ │ │ │ └── index.tsx
│ │ │ │ └── ChannelDetail
│ │ │ │ │ ├── styles.less
│ │ │ │ │ ├── Profile.tsx
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ └── PackageList.tsx
│ │ │ └── index.tsx
│ │ ├── Account
│ │ │ ├── pages
│ │ │ │ └── RegisterLogin
│ │ │ │ │ ├── tessellation.png
│ │ │ │ │ ├── Description
│ │ │ │ │ ├── logo.png
│ │ │ │ │ ├── styles.less
│ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── styles.less
│ │ │ │ │ ├── Registration
│ │ │ │ │ ├── Register
│ │ │ │ │ │ ├── Errors.tsx
│ │ │ │ │ │ ├── styles.less
│ │ │ │ │ │ ├── Submit.tsx
│ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ ├── EmailInput.tsx
│ │ │ │ │ │ ├── ConfirmInput.tsx
│ │ │ │ │ │ ├── PasswordInput.tsx
│ │ │ │ │ │ ├── utils.ts
│ │ │ │ │ │ └── ChannelInput.tsx
│ │ │ │ │ ├── Login
│ │ │ │ │ │ ├── styles.less
│ │ │ │ │ │ ├── Errors.tsx
│ │ │ │ │ │ ├── Submit.tsx
│ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ ├── PasswordInput.tsx
│ │ │ │ │ │ ├── UsernameInput.tsx
│ │ │ │ │ │ ├── hooks.ts
│ │ │ │ │ │ └── reducer.ts
│ │ │ │ │ ├── styles.less
│ │ │ │ │ └── index.tsx
│ │ │ │ │ └── index.tsx
│ │ │ └── index.ts
│ │ └── UploadPage
│ │ │ ├── index.tsx
│ │ │ └── pages
│ │ │ └── UploadForm
│ │ │ ├── styles.less
│ │ │ └── hooks.ts
│ ├── resource
│ │ └── conda.svg
│ ├── components
│ │ ├── Markdown
│ │ │ ├── styles.less
│ │ │ └── index.tsx
│ │ └── ErrorBoundary
│ │ │ ├── NotFound.png
│ │ │ ├── styles.less
│ │ │ ├── ErrorPage.tsx
│ │ │ └── index.tsx
│ ├── react-app-env.d.ts
│ ├── features
│ │ ├── meta
│ │ │ ├── selectors.ts
│ │ │ ├── types.ts
│ │ │ ├── index.ts
│ │ │ ├── actions.ts
│ │ │ ├── api.ts
│ │ │ └── reducer.ts
│ │ ├── channel
│ │ │ ├── selectors.ts
│ │ │ ├── index.ts
│ │ │ ├── localstorage.ts
│ │ │ ├── types.ts
│ │ │ ├── actions.ts
│ │ │ └── api.ts
│ │ └── package
│ │ │ ├── index.ts
│ │ │ ├── selectors.ts
│ │ │ ├── actions.ts
│ │ │ └── types.ts
│ ├── infrastructure
│ │ ├── hooks.ts
│ │ ├── rootState.ts
│ │ ├── rootAction.ts
│ │ ├── rootReducer.ts
│ │ ├── api
│ │ │ ├── types.ts
│ │ │ └── transformers.ts
│ │ └── store.ts
│ ├── index.tsx
│ └── libs
│ │ └── date.ts
├── .dockerignore
├── public
│ ├── robots.txt
│ ├── favicon.ico
│ ├── logo192.png
│ ├── logo512.png
│ ├── manifest.json
│ └── index.html
├── tsconfig.path.json
├── Makefile
├── README.md
├── .gitignore
├── Dockerfile
├── ssl.Dockerfile
├── nginx.conf
├── tsconfig.json
├── nginx-ssl.conf
├── types
│ ├── global.d.ts
│ └── markdown-to-jsx.d.ts
├── package.json
└── craco.config.js
├── _example
├── .gitignore
├── docker-compose.ssl.yml
├── Makefile
├── docker-compose.yml
└── README.md
├── cli
├── .gitignore
├── Makefile
├── config
│ ├── keys.go
│ ├── channel.go
│ ├── root.go
│ ├── get.go
│ ├── list.go
│ ├── set.go
│ └── config.go
├── upload
│ └── package.go
├── registry
│ ├── logout.go
│ ├── root.go
│ ├── set.go
│ └── login.go
├── go.mod
├── request
│ └── client.go
├── main.go
└── README.md
├── server
├── .gitignore
├── testutils
│ └── test-packages
│ │ └── .gitignore
├── infrastructure
│ ├── database
│ │ ├── migrations
│ │ │ ├── 01_create_channel_table.down.sql
│ │ │ ├── 02_create_package_count.down.sql
│ │ │ ├── 01_create_channel_table.up.sql
│ │ │ ├── 02_create_package_count.up.sql
│ │ │ ├── 03_alter_channel_tables.down.sql
│ │ │ └── 03_alter_channel_tables.up.sql
│ │ └── postgres
│ │ │ ├── utils.go
│ │ │ ├── migrate_test.go
│ │ │ ├── postgres.go
│ │ │ ├── channel.go
│ │ │ ├── package_count_test.go
│ │ │ ├── channel_test.go
│ │ │ ├── postgres_test.go
│ │ │ ├── migrate.go
│ │ │ └── package_count.go
│ ├── conda
│ │ ├── filesys
│ │ │ ├── errors.go
│ │ │ ├── channel_test.go
│ │ │ └── filesys_test.go
│ │ └── index
│ │ │ ├── docker_test.go
│ │ │ ├── repofix_test.go
│ │ │ └── shell.go
│ └── decompressor
│ │ ├── metadata.go
│ │ └── tarbz2_test.go
├── api
│ ├── dto
│ │ ├── index.go
│ │ ├── channel_test.go
│ │ └── package.go
│ ├── errors.go
│ ├── http.go
│ ├── index.go
│ ├── index_test.go
│ ├── server.go
│ └── interfaces
│ │ └── interfaces.go
├── libs
│ ├── path.go
│ └── closer.go
├── conda.Dockerfile
├── docker-compose.yaml
├── main.go
├── Makefile
├── domain
│ ├── entity
│ │ ├── channel_test.go
│ │ ├── package_count.go
│ │ └── channel.go
│ ├── enum
│ │ ├── platforms_test.go
│ │ └── platforms.go
│ └── condatypes
│ │ ├── repodata.go
│ │ └── channeldata.go
├── config
│ ├── tls.go
│ ├── conda.go
│ ├── database.go
│ ├── defaults.go
│ └── config.go
├── go.mod
├── Dockerfile
├── fileserver
│ ├── server.go
│ └── handler.go
└── config.yaml
├── .gitattributes
├── RELEASES.md
└── LICENSE
/web/.env:
--------------------------------------------------------------------------------
1 | BROWSER=none
2 |
--------------------------------------------------------------------------------
/_example/.gitignore:
--------------------------------------------------------------------------------
1 | certs/
2 |
--------------------------------------------------------------------------------
/web/src/modules/App/styles.less:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/cli/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | pcr.exe
3 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | *.bat
3 | *.exe
4 |
--------------------------------------------------------------------------------
/cli/Makefile:
--------------------------------------------------------------------------------
1 | build:
2 | go build -o pcr.exe .
3 |
--------------------------------------------------------------------------------
/server/testutils/test-packages/.gitignore:
--------------------------------------------------------------------------------
1 | *.tar.bz2
--------------------------------------------------------------------------------
/web/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | build/
3 | .idea/
4 | .env.local
5 |
--------------------------------------------------------------------------------
/web/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 |
--------------------------------------------------------------------------------
/cli/config/keys.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | const (
4 | sslVerify = "ssl_verify"
5 | )
6 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.png filter=lfs diff=lfs merge=lfs -text
2 | *.svg filter=lfs diff=lfs merge=lfs -text
3 |
--------------------------------------------------------------------------------
/server/infrastructure/database/migrations/01_create_channel_table.down.sql:
--------------------------------------------------------------------------------
1 | drop table if exists USERS;
2 |
--------------------------------------------------------------------------------
/web/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DanielBok/private-conda-repo/HEAD/web/public/favicon.ico
--------------------------------------------------------------------------------
/server/infrastructure/database/migrations/02_create_package_count.down.sql:
--------------------------------------------------------------------------------
1 | drop table if exists PACKAGE_COUNTS;
2 |
--------------------------------------------------------------------------------
/web/src/modules/Help/Layout/styles.less:
--------------------------------------------------------------------------------
1 | .main {
2 | padding: 40px 60px;
3 | background-color: #fafafa;
4 | }
5 |
--------------------------------------------------------------------------------
/web/src/modules/Home/pages/Main/PackageList/SearchBar/styles.less:
--------------------------------------------------------------------------------
1 | .search-box {
2 | margin-bottom: 40px;
3 | }
4 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/types.ts:
--------------------------------------------------------------------------------
1 | export type MatchParams = {
2 | channel: string;
3 | pkg: string;
4 | };
5 |
--------------------------------------------------------------------------------
/web/public/logo192.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:aef7569e6ee38949bb8bb4bc3560259241107eb4efb25b1bb495920ee2278729
3 | size 18064
4 |
--------------------------------------------------------------------------------
/web/public/logo512.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:440c665f623bba9fdb27d3be46311fd6cfd01bbf012b10ba1d7ae4f30923a5ca
3 | size 71497
4 |
--------------------------------------------------------------------------------
/web/src/resource/conda.svg:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:9a25401177cb33def472e4f1c960e13b5d238e39f41a985b5565a722f3e35042
3 | size 1805
4 |
--------------------------------------------------------------------------------
/web/src/components/Markdown/styles.less:
--------------------------------------------------------------------------------
1 | .default-container {
2 | display: flex;
3 | justify-content: flex-start;
4 |
5 | div {
6 | max-width: 1200px;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/web/src/modules/Home/pages/Main/styles.less:
--------------------------------------------------------------------------------
1 | @import (reference) "../../../App/styles";
2 |
3 | .container {
4 | min-height: @min-height;
5 | padding: 24px 40px;
6 | }
7 |
--------------------------------------------------------------------------------
/server/infrastructure/conda/filesys/errors.go:
--------------------------------------------------------------------------------
1 | package filesys
2 |
3 | import "errors"
4 |
5 | var (
6 | ErrPackageNotFound = errors.New("package specified does not exist")
7 | )
8 |
--------------------------------------------------------------------------------
/web/src/components/ErrorBoundary/NotFound.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:1cdea5359913771a25bde22e156a636173571f5f0133dc983d4373b8861d29a9
3 | size 266273
4 |
--------------------------------------------------------------------------------
/web/tsconfig.path.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "@/*": ["./src/*"]
6 | },
7 | "downlevelIteration": true
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/cli/config/channel.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | type Channel struct {
4 | Channel string `mapstructure:"channel" yaml:"channel"`
5 | Password string `mapstructure:"password" yaml:"password"`
6 | }
7 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/tessellation.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:84620dcf03b39e7f8141309f9f9b9b27330720ba1ae33cbcd2c5bdc12488b7ae
3 | size 218854
4 |
--------------------------------------------------------------------------------
/web/Makefile:
--------------------------------------------------------------------------------
1 | build: build-normal build-ssl
2 |
3 | build-normal:
4 | docker image build -t danielbok/pcr-web .
5 |
6 | build-ssl:
7 | docker image build -t danielbok/pcr-web-ssl -f ssl.Dockerfile .
8 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Description/logo.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:a37a0f63e363403c43d95de2cb1837b9d8050dd6a2d358f63c20c245d89a4bee
3 | size 35253
4 |
--------------------------------------------------------------------------------
/web/src/modules/App/Layout/styles.less:
--------------------------------------------------------------------------------
1 | .content {
2 | display: flex;
3 | justify-content: center;
4 |
5 | .body {
6 | min-height: @min-height;
7 |
8 | width: 100%;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/web/src/modules/Home/index.tsx:
--------------------------------------------------------------------------------
1 | import Main from "./pages/Main";
2 |
3 | export default [
4 | {
5 | component: Main,
6 | path: "/",
7 | title: "Homepage",
8 | },
9 | ] as ModuleRoute[];
10 |
--------------------------------------------------------------------------------
/web/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | declare namespace NodeJS {
3 | interface ProcessEnv {
4 | NODE_ENV: "development" | "production";
5 | REACT_APP_API_URL: string;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/web/src/features/meta/selectors.ts:
--------------------------------------------------------------------------------
1 | import { RootState } from "@/infrastructure/rootState";
2 | import { MetaInfo } from "./types";
3 |
4 | export const metaInfo = ({ meta: { loading, ...rest } }: RootState): MetaInfo =>
5 | rest;
6 |
--------------------------------------------------------------------------------
/_example/docker-compose.ssl.yml:
--------------------------------------------------------------------------------
1 | version: "3.7"
2 |
3 | services:
4 | web:
5 | image: danielbok/pcr-web-ssl:3.0
6 | ports:
7 | - "80:80"
8 | - "443:443"
9 | volumes:
10 | - ./certs:/etc/nginx/certs
11 |
--------------------------------------------------------------------------------
/server/api/dto/index.go:
--------------------------------------------------------------------------------
1 | package dto
2 |
3 | type ApiMetaInfo struct {
4 | Indexer string `json:"indexer"`
5 | Image string `json:"image"`
6 | Registry string `json:"registry"`
7 | Repository string `json:"repository"`
8 | }
9 |
--------------------------------------------------------------------------------
/web/src/modules/UploadPage/index.tsx:
--------------------------------------------------------------------------------
1 | import UploadForm from "./pages/UploadForm";
2 |
3 | export default [
4 | {
5 | component: UploadForm,
6 | path: "/",
7 | title: "UploadForm",
8 | },
9 | ] as ModuleRoute[];
10 |
--------------------------------------------------------------------------------
/web/src/features/channel/selectors.ts:
--------------------------------------------------------------------------------
1 | import { RootState } from "@/infrastructure/rootState";
2 |
3 | export const channelInfo = (state: RootState) => state.channel;
4 | export const channelValidated = (state: RootState) => state.channel.validated;
5 |
--------------------------------------------------------------------------------
/web/src/modules/Account/index.ts:
--------------------------------------------------------------------------------
1 | import RegisterLogin from "./pages/RegisterLogin";
2 |
3 | export default [
4 | {
5 | component: RegisterLogin,
6 | path: "/",
7 | title: "Register or Login",
8 | },
9 | ] as ModuleRoute[];
10 |
--------------------------------------------------------------------------------
/web/src/features/channel/index.ts:
--------------------------------------------------------------------------------
1 | import * as ChnAction from "./actions";
2 | import * as ChnApi from "./api";
3 | import * as ChnSelector from "./selectors";
4 | import * as ChnType from "./types";
5 |
6 | export { ChnAction, ChnApi, ChnSelector, ChnType };
7 |
--------------------------------------------------------------------------------
/web/src/features/meta/types.ts:
--------------------------------------------------------------------------------
1 | export type Store = MetaInfo & {
2 | loading: LoadingState;
3 | };
4 |
5 | export type MetaInfo = {
6 | indexer: "shell" | "docker";
7 | image: string;
8 | registry: string;
9 | repository: string;
10 | };
11 |
--------------------------------------------------------------------------------
/web/src/features/package/index.ts:
--------------------------------------------------------------------------------
1 | import * as PkgAction from "./actions";
2 | import * as PkgApi from "./api";
3 | import * as PkgSelector from "./selectors";
4 | import * as PkgType from "./types";
5 |
6 | export { PkgAction, PkgApi, PkgSelector, PkgType };
7 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/styles.less:
--------------------------------------------------------------------------------
1 | .container {
2 | background-color: white;
3 | }
4 |
5 | .background {
6 | background-image: url("./tessellation.png");
7 | min-height: @min-height;
8 | background-repeat: repeat;
9 | }
10 |
--------------------------------------------------------------------------------
/web/src/features/meta/index.ts:
--------------------------------------------------------------------------------
1 | import * as MetaAction from "./actions";
2 | import * as MetaApi from "./api";
3 | import * as MetaSelector from "./selectors";
4 | import * as MetaType from "./types";
5 |
6 | export { MetaAction, MetaApi, MetaSelector, MetaType };
7 |
--------------------------------------------------------------------------------
/web/src/modules/Home/pages/Main/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PackageList from "./PackageList";
3 | import styles from "./styles.less";
4 |
5 | export default () => (
6 |
9 | );
10 |
--------------------------------------------------------------------------------
/web/README.md:
--------------------------------------------------------------------------------
1 | # PCR Web
2 |
3 | This is the web interface for the PCR app. To build this application, run
4 |
5 | ```bash
6 | npm run build
7 | ```
8 |
9 | 2 Dockerfiles have been provided for you. One with SSL and one without. Feel free
10 | to extend the application as you like.
11 |
--------------------------------------------------------------------------------
/server/libs/path.go:
--------------------------------------------------------------------------------
1 | package libs
2 |
3 | import (
4 | "os"
5 | )
6 |
7 | // Check that path or file exists. This is a simple check and should suffice
8 | // for most use cases.
9 | func PathExists(path string) bool {
10 | _, err := os.Stat(path)
11 | return !os.IsNotExist(err)
12 | }
13 |
--------------------------------------------------------------------------------
/web/src/modules/App/Layout/Footer/styles.less:
--------------------------------------------------------------------------------
1 | .footer {
2 | height: @footer-height;
3 | background-color: #191919 !important;
4 | text-align: center;
5 |
6 | .subtitle {
7 | color: #ffffff;
8 | }
9 |
10 | .text {
11 | color: rgba(255, 255, 255, 0.7);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/Files/types.ts:
--------------------------------------------------------------------------------
1 | export type ContextType = {
2 | isAdmin: boolean;
3 | filters: Filter;
4 | setFilters: (f: Partial) => void;
5 | };
6 |
7 | export type Filter = {
8 | platform: "All" | string;
9 | version: "All" | string;
10 | };
11 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/PackageInfo/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import InstallationGuide from "./InstallationGuide";
3 |
4 | import TopCard from "./TopCard";
5 |
6 | export default () => (
7 | <>
8 |
9 |
10 | >
11 | );
12 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/PackageInfo/styles.less:
--------------------------------------------------------------------------------
1 | .main-card {
2 | margin-bottom: 16px !important;
3 | }
4 |
5 | .top-card {
6 | font-size: 16px;
7 | margin-bottom: 4px;
8 |
9 | > i {
10 | margin-right: 6px;
11 | }
12 | }
13 |
14 | .icon {
15 | margin-right: 6px;
16 | }
17 |
--------------------------------------------------------------------------------
/web/src/features/meta/actions.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncAction } from "typesafe-actions";
2 | import * as MetaType from "./types";
3 |
4 | export const fetchMetaInfoAsync = createAsyncAction(
5 | "FETCH_META_INFO_REQUEST",
6 | "FETCH_META_INFO_SUCCESS",
7 | "FETCH_META_INFO_FAILURE"
8 | )();
9 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/Files/Table/types.ts:
--------------------------------------------------------------------------------
1 | import { PkgType } from "@/features/package";
2 |
3 | export type DataRow = {
4 | key: number;
5 | name: string;
6 | uploaded: string;
7 | downloads: number;
8 | channel: string;
9 | package: PkgType.RemovePackagePayload["package"];
10 | };
11 |
--------------------------------------------------------------------------------
/server/infrastructure/database/migrations/01_create_channel_table.up.sql:
--------------------------------------------------------------------------------
1 | create table if not exists USERS
2 | (
3 | ID serial primary key,
4 | CHANNEL varchar(50) not null unique,
5 | PASSWORD varchar(64) not null,
6 | EMAIL varchar(254) not null,
7 | JOIN_DATE timestamp not null default now()
8 | );
9 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/Files/Filters/styles.less:
--------------------------------------------------------------------------------
1 | .filter-fieldset {
2 | border: 1px solid @primary-color;
3 | margin: 24px 0;
4 | padding: 0 24px;
5 |
6 | > legend {
7 | width: inherit;
8 | margin: 0;
9 | padding: 0 12px;
10 | }
11 |
12 | .select {
13 | width: 100%;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/hooks.ts:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 | import * as Types from "./types";
3 |
4 | export const PackageContext = React.createContext({
5 | channel: "",
6 | pkg: "",
7 | });
8 |
9 | export const usePackageContext = () => useContext(PackageContext);
10 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Register/Errors.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC } from "react";
2 |
3 | type Props = {
4 | errors: string;
5 | };
6 |
7 | const Errors: FC = ({ errors }) => {
8 | if (errors === "") return null;
9 |
10 | return {errors};
11 | };
12 |
13 | export default Errors;
14 |
--------------------------------------------------------------------------------
/web/src/modules/Help/index.ts:
--------------------------------------------------------------------------------
1 | import Overview from "./pages/Overview";
2 | import Upload from "./pages/Upload";
3 |
4 | export default [
5 | {
6 | component: Overview,
7 | path: "/",
8 | title: "Overview",
9 | },
10 | {
11 | component: Upload,
12 | path: "/upload",
13 | title: "Upload",
14 | },
15 | ];
16 |
--------------------------------------------------------------------------------
/web/src/modules/App/index.tsx:
--------------------------------------------------------------------------------
1 | import ErrorBoundary from "@/components/ErrorBoundary";
2 | import React from "react";
3 | import Layout from "./Layout";
4 | import RouteSwitch from "./RouteSwitch";
5 |
6 | export default () => (
7 |
8 |
9 |
10 |
11 |
12 | );
13 |
--------------------------------------------------------------------------------
/web/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/web/src/modules/Home/pages/Main/PackageList/hooks.ts:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 |
3 | export const SearchContext = React.createContext<{
4 | search: string;
5 | setSearch: (v: string) => void;
6 | }>({
7 | search: "",
8 | setSearch: () => {},
9 | });
10 |
11 | export const useSearchContext = () => useContext(SearchContext);
12 |
--------------------------------------------------------------------------------
/_example/Makefile:
--------------------------------------------------------------------------------
1 | PROJECT_NAME = "PCR"
2 |
3 | start: stop
4 | docker-compose -p $(PROJECT_NAME) up -d
5 |
6 | stop:
7 | docker-compose -p $(PROJECT_NAME) down
8 |
9 | start-ssl: stop-ssl
10 | docker-compose -p $(PROJECT_NAME) -f docker-compose.ssl.yml up -d
11 |
12 | stop-ssl:
13 | docker-compose -p $(PROJECT_NAME) -f docker-compose.ssl.yml down
14 |
--------------------------------------------------------------------------------
/cli/config/root.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "github.com/spf13/cobra"
5 | )
6 |
7 | // RootCmd represents the base command when called without any subcommands
8 | var RootCmd = &cobra.Command{
9 | Use: "config",
10 | Short: "PCR cli configuration",
11 | }
12 |
13 | func init() {
14 | RootCmd.AddCommand(setConfCmd, getConfCmd, listConfCmd)
15 | }
16 |
--------------------------------------------------------------------------------
/server/infrastructure/database/postgres/utils.go:
--------------------------------------------------------------------------------
1 | package postgres
2 |
3 | import (
4 | "github.com/hashicorp/go-multierror"
5 | )
6 |
7 | func joinErrors(errors []error) error {
8 | if len(errors) == 1 {
9 | return errors[0]
10 | }
11 |
12 | var err error
13 |
14 | for _, e := range errors {
15 | err = multierror.Append(err, e)
16 | }
17 | return err
18 | }
19 |
--------------------------------------------------------------------------------
/web/src/modules/UploadPage/pages/UploadForm/styles.less:
--------------------------------------------------------------------------------
1 | .container {
2 | min-height: @min-height;
3 | padding: 12px 40px 0;
4 | }
5 |
6 | .submit-box {
7 | margin-top: 12px !important;
8 | }
9 |
10 | .success-message {
11 | display: flex;
12 | flex-direction: column;
13 |
14 | div {
15 | display: flex;
16 | justify-content: space-between;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/web/src/infrastructure/hooks.ts:
--------------------------------------------------------------------------------
1 | import { isEqual } from "lodash";
2 | import { useSelector } from "react-redux";
3 | import { RootState } from "./rootState";
4 |
5 | export const useRouter = () => useSelector((state: RootState) => state.router);
6 |
7 | export const useRootSelector = (
8 | selector: (state: RootState) => T
9 | ) => useSelector(selector, isEqual);
10 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/Header/styles.less:
--------------------------------------------------------------------------------
1 | .title {
2 | font-size: 28px;
3 | font-weight: 600;
4 |
5 | .channel {
6 | color: @primary-color;
7 |
8 | &:hover {
9 | color: @primary-color-dark;
10 | }
11 | }
12 |
13 | .subtitle {
14 | margin: 24px 0 32px 0;
15 | font-size: 18px;
16 | font-weight: normal;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/server/conda.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM continuumio/miniconda3:latest
2 |
3 | # Update and clears cache (to reduce image size)
4 | RUN apt-get update && \
5 | apt-get upgrade -y && \
6 | apt-get clean -y && \
7 | conda install conda-build conda-verify -y && \
8 | conda update --all -y && \
9 | conda clean --all -y
10 |
11 | WORKDIR /var/condapkg
12 |
13 | ENTRYPOINT ["conda"]
14 |
--------------------------------------------------------------------------------
/web/src/infrastructure/rootState.ts:
--------------------------------------------------------------------------------
1 | import { MetaType } from "@/features/meta";
2 | import { PkgType } from "@/features/package";
3 | import { ChnType } from "@/features/channel";
4 | import { RouterState } from "connected-react-router";
5 |
6 | export type RootState = {
7 | meta: MetaType.Store;
8 | pkg: PkgType.Store;
9 | channel: ChnType.Store;
10 | router: RouterState;
11 | };
12 |
--------------------------------------------------------------------------------
/web/src/infrastructure/rootAction.ts:
--------------------------------------------------------------------------------
1 | import { MetaAction } from "@/features/meta";
2 | import { PkgAction } from "@/features/package";
3 | import { ChnAction } from "@/features/channel";
4 |
5 | import { ActionType } from "typesafe-actions";
6 |
7 | type AllActions =
8 | | ActionType
9 | | ActionType
10 | | ActionType;
11 |
12 | export default AllActions;
13 |
--------------------------------------------------------------------------------
/web/src/modules/Package/index.tsx:
--------------------------------------------------------------------------------
1 | import ChannelDetail from "./pages/ChannelDetail";
2 | import PackageDetail from "./pages/PackageDetail";
3 |
4 | export default [
5 | {
6 | component: ChannelDetail,
7 | path: "/:channel",
8 | title: "Channel Detail",
9 | },
10 | {
11 | component: PackageDetail,
12 | path: "/:channel/:pkg",
13 | title: "Package Detail",
14 | },
15 | ] as ModuleRoute[];
16 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/Files/hooks.ts:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 | import { ContextType } from "./types";
3 |
4 | export const FileContext = React.createContext({
5 | filters: {
6 | platform: "All",
7 | version: "All,",
8 | },
9 | setFilters: () => {},
10 | isAdmin: false,
11 | });
12 |
13 | export const useFileContext = () => useContext(FileContext);
14 |
--------------------------------------------------------------------------------
/web/src/components/ErrorBoundary/styles.less:
--------------------------------------------------------------------------------
1 | @import "~antd/lib/style/color/colors";
2 |
3 | .error-page {
4 | background-color: white;
5 | min-height: @min-height;
6 | font-family: "Gotham Rounded SSm A", "Gotham Rounded SSm B", Helvetica, Arial,
7 | sans-serif;
8 | margin: 20px auto;
9 | padding-top: 40px;
10 | display: flex;
11 | flex-direction: column;
12 | align-items: center;
13 | color: #46575e;
14 | }
15 |
--------------------------------------------------------------------------------
/web/src/modules/Help/Layout/Menu/styles.less:
--------------------------------------------------------------------------------
1 | .title {
2 | color: @primary-color;
3 | font-size: 22px;
4 | margin-bottom: 6px;
5 | width: 100%;
6 | }
7 |
8 | .link {
9 | font-size: 18px;
10 | color: #797979;
11 | }
12 |
13 | .selected {
14 | color: #1890ff;
15 | }
16 |
17 | .group-container {
18 | display: flex;
19 | flex-direction: column;
20 |
21 | .link-container {
22 | margin-bottom: 6px;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/server/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | # This compose file is for development purposes!
2 |
3 | version: "3.7"
4 |
5 | services:
6 | db:
7 | image: postgres:12-alpine
8 | restart: always
9 | environment:
10 | POSTGRES_USER: user
11 | POSTGRES_PASSWORD: password
12 | POSTGRES_DB: pcrdb
13 | ports:
14 | - "5432:5432"
15 | # volumes:
16 | # - pcrdb:/var/lib/postgresql/data
17 | #
18 | #volumes:
19 | # pcrdb:
20 |
--------------------------------------------------------------------------------
/server/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | log "github.com/sirupsen/logrus"
5 | )
6 |
7 | func main() {
8 | setLogger()
9 |
10 | log.Info("Initalizing application")
11 | app, err := NewApp()
12 | if err != nil {
13 | log.Fatal("Fatal error encountered during application startup", err)
14 | }
15 |
16 | <-app.RunServers()
17 | }
18 |
19 | func setLogger() {
20 | log.SetFormatter(&log.TextFormatter{
21 | FullTimestamp: true,
22 | })
23 | }
24 |
--------------------------------------------------------------------------------
/web/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:12-stretch as builder
2 |
3 | WORKDIR /app
4 |
5 | RUN apt-get update && apt-get upgrade -y
6 |
7 | COPY ["package.json", "package-lock.json", "./"]
8 |
9 | RUN npm ci
10 |
11 | COPY . .
12 |
13 | RUN npm run build
14 |
15 | FROM nginx:1.17
16 |
17 | RUN apt-get update && apt-get upgrade -y
18 | COPY --from=builder /app/build /usr/share/nginx/html
19 | COPY --from=builder /app/nginx.conf /etc/nginx/conf.d/app.conf
20 |
--------------------------------------------------------------------------------
/server/Makefile:
--------------------------------------------------------------------------------
1 | PROJECT_NAME = "PCR"
2 |
3 | server-image:
4 | docker image build -t danielbok/pcr-server .
5 |
6 | conda-image:
7 | docker image build -t danielbok/conda-repo-mgr -f conda.Dockerfile .
8 | docker image prune -f
9 |
10 | start: stop
11 | docker-compose -p $(PROJECT_NAME) up -d
12 |
13 | stop:
14 | docker-compose -p $(PROJECT_NAME) down
15 |
16 | create-migration:
17 | migrate.exe create -dir store/migrations -ext sql -seq -digits 2
18 |
--------------------------------------------------------------------------------
/server/api/errors.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "errors"
5 | )
6 |
7 | var (
8 | ErrInvalidCredential = errors.New("invalid credential")
9 | ErrOpeningCondaPackage = errors.New("could not open compressed package archive")
10 | ErrSavingPackageToDisk = errors.New("could not save conda package to disk")
11 | ErrParsingFormFile = errors.New("could not parse uploaded file. Please ensure that you have uploaded a valid file with 'file' as the form key")
12 | )
13 |
--------------------------------------------------------------------------------
/web/src/modules/Help/Layout/index.tsx:
--------------------------------------------------------------------------------
1 | import { Col, Row } from "antd";
2 | import React, { FC } from "react";
3 |
4 | import Menu from "./Menu";
5 | import styles from "./styles.less";
6 |
7 | const Layout: FC = ({ children }) => (
8 |
9 |
10 |
11 |
12 |
13 | {children}
14 |
15 |
16 | );
17 |
18 | export default Layout;
19 |
--------------------------------------------------------------------------------
/web/ssl.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:12-stretch as builder
2 |
3 | WORKDIR /app
4 |
5 | RUN apt-get update && apt-get upgrade -y
6 |
7 | COPY ["package.json", "package-lock.json", "./"]
8 |
9 | RUN npm ci
10 |
11 | COPY . .
12 |
13 | RUN npm run build
14 |
15 | FROM nginx:1.17
16 |
17 | RUN apt-get update && apt-get upgrade -y
18 | COPY --from=builder /app/build /usr/share/nginx/html
19 | COPY --from=builder /app/nginx-ssl.conf /etc/nginx/conf.d/app.conf
20 |
--------------------------------------------------------------------------------
/web/src/modules/Home/pages/Main/PackageList/ResultTable/styles.less:
--------------------------------------------------------------------------------
1 | .header {
2 | font-weight: 600;
3 | font-size: 18px;
4 | }
5 |
6 | .list-main {
7 | background-color: white;
8 |
9 | :global(.ant-list-header) {
10 | background: #797979;
11 | color: white;
12 | }
13 |
14 | .alternate {
15 | background-color: #f8f8f8;
16 | }
17 | }
18 |
19 | .link {
20 | color: @primary-color;
21 |
22 | &:hover {
23 | color: @primary-color-dark;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/Files/Table/styles.less:
--------------------------------------------------------------------------------
1 | .download-link {
2 | color: @primary-color;
3 |
4 | &:hover {
5 | color: @primary-color-dark;
6 | }
7 | }
8 |
9 | .table {
10 | background-color: white;
11 | }
12 |
13 | .remove-button {
14 | color: #1890ff;
15 | text-decoration: none;
16 | background-color: rgba(0, 0, 0, 0);
17 | outline: none;
18 | cursor: pointer;
19 | -webkit-transition: color 0.3s;
20 | transition: color 0.3s;
21 | }
22 |
--------------------------------------------------------------------------------
/server/domain/entity/channel_test.go:
--------------------------------------------------------------------------------
1 | package entity_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 |
8 | . "private-conda-repo/domain/entity"
9 | )
10 |
11 | func TestChannel_HasValidPassword(t *testing.T) {
12 | c := NewChannel("daniel", "good-password", "daniel@gmail.com")
13 | valid := c.HasValidPassword("bad-password")
14 | require.False(t, valid)
15 |
16 | valid = c.HasValidPassword("good-password")
17 | require.True(t, valid)
18 | }
19 |
--------------------------------------------------------------------------------
/web/src/features/package/selectors.ts:
--------------------------------------------------------------------------------
1 | import { RootState } from "@/infrastructure/rootState";
2 |
3 | export const packageMeta = (state: RootState) => state.pkg.packages;
4 | export const packageDetail = (state: RootState) => state.pkg.packageDetail;
5 | export const isAdmin = (state: RootState) =>
6 | state.channel.validated &&
7 | state.channel.channel === state.pkg.packageDetail.channel;
8 |
9 | export const channelPackages = (state: RootState) =>
10 | state.pkg.channelPackages;
11 |
--------------------------------------------------------------------------------
/web/src/modules/Home/pages/Main/PackageList/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { SearchContext } from "./hooks";
3 | import ResultTable from "./ResultTable";
4 | import SearchBar from "./SearchBar";
5 |
6 | export default () => {
7 | const [search, setSearch] = useState("");
8 |
9 | return (
10 |
11 |
12 |
13 |
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/server/domain/entity/package_count.go:
--------------------------------------------------------------------------------
1 | package entity
2 |
3 | import "time"
4 |
5 | type PackageCount struct {
6 | Id int `json:"-"`
7 | ChannelId int `json:"-"`
8 | Package string `json:"package"`
9 | BuildString string `json:"buildString"`
10 | BuildNumber int `json:"buildNumber"`
11 | Version string `json:"version"`
12 | Platform string `json:"platform"`
13 | Count int `json:"count"`
14 | UploadDate time.Time `json:"uploadDate"`
15 | }
16 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Register/styles.less:
--------------------------------------------------------------------------------
1 | .welcome {
2 | font-size: 16px;
3 | }
4 |
5 | .submit-button {
6 | background-color: @primary-color !important;
7 | border-color: @primary-color !important;
8 |
9 | &:hover {
10 | background-color: @primary-color-dark !important;
11 | border-color: @primary-color-dark !important;
12 | }
13 |
14 | &:disabled {
15 | background-color: #f5f5f5 !important;
16 | border-color: #d9d9d9 !important;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/cli/upload/package.go:
--------------------------------------------------------------------------------
1 | package upload
2 |
3 | import "fmt"
4 |
5 | type Package struct {
6 | Name string `json:"name"`
7 | Version string `json:"version"`
8 | BuildString string `json:"buildString"`
9 | BuildNumber int `json:"buildNumber"`
10 | Platform string `json:"platform"`
11 | }
12 |
13 | // Returns the package's full filename (i.e. perfana-0.0.6-py_0.tar.bz2)
14 | func (p *Package) Filename() string {
15 | return fmt.Sprintf("%s-%s-%s_%d.tar.bz2", p.Name, p.Version, p.BuildString, p.BuildNumber)
16 | }
17 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Login/styles.less:
--------------------------------------------------------------------------------
1 | .submit-button {
2 | background-color: @primary-color !important;
3 | border-color: @primary-color !important;
4 |
5 | &:hover {
6 | background-color: @primary-color-dark !important;
7 | border-color: @primary-color-dark !important;
8 | }
9 |
10 | &:disabled {
11 | background-color: #f5f5f5 !important;
12 | border-color: #d9d9d9 !important;
13 | }
14 | }
15 |
16 | .error {
17 | margin-top: 4px;
18 | color: #ff2f31;
19 | }
20 |
--------------------------------------------------------------------------------
/web/nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | root /usr/share/nginx/html;
4 | index index.html index.htm;
5 |
6 | location / {
7 | try_files $uri $uri/ /index.html =404;
8 | add_header Cache-Control "no-store, no-cache, must-revalidate";
9 | }
10 |
11 | location /static {
12 | expires 1y;
13 | add_header Cache-Control "public";
14 | access_log off;
15 | }
16 |
17 | error_page 500 502 503 504 /50x.html;
18 | location = /50x.html {
19 | root /usr/share/nginx/html;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Description/styles.less:
--------------------------------------------------------------------------------
1 | .container {
2 | display: flex;
3 | flex-direction: column;
4 | align-items: center;
5 | text-align: center;
6 | padding-top: 40px;
7 |
8 | .icon {
9 | width: 112px;
10 | }
11 |
12 | .title {
13 | font-size: 36px;
14 | font-weight: bold;
15 | color: #43b02a;
16 |
17 | > span {
18 | font-weight: normal;
19 | }
20 | }
21 |
22 | .subtitle {
23 | margin-top: 16px;
24 | font-size: 20px;
25 | color: black;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Login/Errors.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC } from "react";
2 | import { useLoginContext } from "./hooks";
3 | import { Credential } from "./reducer";
4 |
5 | type Props = {
6 | field: keyof Credential;
7 | };
8 |
9 | const Errors: FC = ({ field }) => {
10 | const { errors } = useLoginContext().state;
11 | if (errors[field].length === 0) return null;
12 |
13 | const messages = errors[field].join(" ");
14 | return {messages};
15 | };
16 |
17 | export default Errors;
18 |
--------------------------------------------------------------------------------
/web/src/modules/App/Layout/index.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from "antd";
2 | import React, { FC } from "react";
3 | import Footer from "./Footer";
4 | import Header from "./Header";
5 |
6 | import styles from "./styles.less";
7 |
8 | const { Content } = Layout;
9 |
10 | const AppLayout: FC = ({ children }) => (
11 |
12 |
13 |
14 | {children}
15 |
16 |
17 |
18 | );
19 |
20 | export default AppLayout;
21 |
--------------------------------------------------------------------------------
/web/src/modules/Home/pages/Main/PackageList/ResultTable/Link.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link } from "react-router-dom";
3 | import styles from "./styles.less";
4 |
5 | type Props = {
6 | channel: string;
7 | name: string;
8 | };
9 |
10 | export default ({ name, channel }: Props) => (
11 |
12 |
13 | {channel}
14 |
15 | {" / "}
16 |
17 | {name}
18 |
19 |
20 | );
21 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Description/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Logo from "./logo.png";
3 | import styles from "./styles.less";
4 |
5 | export default () => (
6 |
7 |

8 |
9 | Private Conda
10 | Repository
11 |
12 |
13 |
14 | Where your in-house packages are shared
15 |
16 |
17 | );
18 |
--------------------------------------------------------------------------------
/web/src/modules/Help/pages/Overview/index.tsx:
--------------------------------------------------------------------------------
1 | import Markdown from "@/components/Markdown";
2 | import { useRootSelector } from "@/infrastructure/hooks";
3 | import Layout from "@/modules/Help/Layout";
4 | import React from "react";
5 | import Content from "./overview.md";
6 |
7 | const Overview = () => {
8 | const registry = useRootSelector((s) => s.meta.registry);
9 | const content = Content.replace("@registry", registry);
10 |
11 | return (
12 |
13 |
14 |
15 | );
16 | };
17 |
18 | export default Overview;
19 |
--------------------------------------------------------------------------------
/server/config/tls.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "strings"
5 |
6 | "private-conda-repo/libs"
7 | )
8 |
9 | type TLSConfig struct {
10 | Cert string `mapstructure:"cert"`
11 | Key string `mapstructure:"key"`
12 | }
13 |
14 | func (t *TLSConfig) HasCert() bool {
15 | t.Cert = strings.TrimSpace(t.Cert)
16 | t.Key = strings.TrimSpace(t.Key)
17 | if t.Cert == "" || t.Key == "" {
18 | return false
19 | }
20 |
21 | if !libs.PathExists(t.Cert) {
22 | return false
23 | }
24 |
25 | if !libs.PathExists(t.Key) {
26 | return false
27 | }
28 |
29 | return true
30 | }
31 |
--------------------------------------------------------------------------------
/server/go.mod:
--------------------------------------------------------------------------------
1 | module private-conda-repo
2 |
3 | go 1.14
4 |
5 | require (
6 | github.com/dhui/dktest v0.3.0
7 | github.com/go-chi/chi v4.0.2+incompatible
8 | github.com/golang-migrate/migrate/v4 v4.7.0
9 | github.com/hashicorp/go-multierror v1.0.0
10 | github.com/jinzhu/gorm v1.9.11
11 | github.com/lib/pq v1.1.1
12 | github.com/mholt/archiver/v3 v3.3.0
13 | github.com/pkg/errors v0.8.1
14 | github.com/rs/cors v1.7.0
15 | github.com/sirupsen/logrus v1.4.2
16 | github.com/spf13/viper v1.6.1
17 | github.com/stretchr/testify v1.4.0
18 | gopkg.in/yaml.v2 v2.2.7 // indirect
19 | )
20 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Login/Submit.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "antd";
2 | import React from "react";
3 | import { useLoginContext, useSubmit } from "./hooks";
4 | import styles from "./styles.less";
5 |
6 | export default () => {
7 | const { disabled } = useLoginContext().state;
8 | const submit = useSubmit();
9 |
10 | return (
11 |
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/web/src/infrastructure/rootReducer.ts:
--------------------------------------------------------------------------------
1 | import MetaReducer from "@/features/meta/reducer";
2 | import PackageReducer from "@/features/package/reducer";
3 | import ChannelReducer from "@/features/channel/reducer";
4 | import { connectRouter } from "connected-react-router";
5 | import { History } from "history";
6 | import { combineReducers } from "redux";
7 | import { RootState } from "./rootState";
8 |
9 | export default (history: History) =>
10 | combineReducers({
11 | meta: MetaReducer,
12 | pkg: PackageReducer,
13 | router: connectRouter(history),
14 | channel: ChannelReducer,
15 | });
16 |
--------------------------------------------------------------------------------
/web/src/features/channel/localstorage.ts:
--------------------------------------------------------------------------------
1 | import * as T from "./types";
2 |
3 | class ChannelLocalStorage {
4 | private itemKey = "PCR_CHANNEL_INFO";
5 |
6 | public load() {
7 | const item = localStorage.getItem(this.itemKey);
8 | if (item !== null) {
9 | return JSON.parse(item) as T.Channel;
10 | }
11 | return null;
12 | }
13 |
14 | public save(channel: T.Channel) {
15 | localStorage.setItem(this.itemKey, JSON.stringify(channel));
16 | }
17 |
18 | public clear() {
19 | localStorage.removeItem(this.itemKey);
20 | }
21 | }
22 |
23 | export const ChannelStorage = new ChannelLocalStorage();
24 |
--------------------------------------------------------------------------------
/web/src/index.tsx:
--------------------------------------------------------------------------------
1 | import store, { history } from "@/infrastructure/store";
2 | import App from "@/modules/App";
3 |
4 | import "antd/dist/antd.min.css";
5 |
6 | import { ConnectedRouter } from "connected-react-router";
7 | import React from "react";
8 | import ReactDOM from "react-dom";
9 | import { Provider } from "react-redux";
10 | import * as serviceWorker from "./serviceWorker";
11 |
12 | ReactDOM.render(
13 |
14 |
15 |
16 |
17 | ,
18 | document.getElementById("root")
19 | );
20 |
21 | serviceWorker.unregister();
22 |
--------------------------------------------------------------------------------
/server/infrastructure/database/postgres/migrate_test.go:
--------------------------------------------------------------------------------
1 | package postgres_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/dhui/dktest"
7 | _ "github.com/lib/pq"
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func TestPostgres_Migrate(t *testing.T) {
12 | dktest.Run(t, imageName, postgresImageOptions, func(t *testing.T, info dktest.ContainerInfo) {
13 | db, err := newTestDb(info)
14 | require.NoError(t, err)
15 |
16 | for i := 0; i < 3; i++ {
17 | err = db.Migrate()
18 | require.NoError(t, err, "migration should not raise errors even when migrating database which is at latest revision")
19 | }
20 | })
21 | }
22 |
--------------------------------------------------------------------------------
/web/src/infrastructure/api/types.ts:
--------------------------------------------------------------------------------
1 | import { AxiosRequestConfig, AxiosResponse } from "axios";
2 |
3 | export type BeforeRequestFunction = () => void;
4 | export type AfterResponseFunction = BeforeRequestFunction;
5 | export type SuccessFunction = (data: R) => void;
6 | export type ErrorFunction = (e: AxiosResponse) => void;
7 |
8 | export interface RequestConfig extends AxiosRequestConfig {
9 | afterResponse?: AfterResponseFunction | AfterResponseFunction[];
10 | beforeRequest?: BeforeRequestFunction | BeforeRequestFunction[];
11 | onError?: ErrorFunction | ErrorFunction[];
12 | onSuccess?: SuccessFunction;
13 | returnErrorResponse?: boolean;
14 | }
15 |
--------------------------------------------------------------------------------
/cli/registry/logout.go:
--------------------------------------------------------------------------------
1 | package registry
2 |
3 | import (
4 | "github.com/spf13/cobra"
5 |
6 | "cli/config"
7 | )
8 |
9 | var logoutCmd = &cobra.Command{
10 | Use: "logout",
11 | Short: "Log out of the registry",
12 | Long: `Removes the user's credentials from the cli tool.`,
13 | Args: cobra.NoArgs,
14 | Run: func(cmd *cobra.Command, _ []string) {
15 | conf := config.New()
16 | channel := conf.Channel.Channel
17 | conf.Channel.Channel = ""
18 | conf.Channel.Password = ""
19 |
20 | conf.Save()
21 | if channel == "" {
22 | cmd.PrintErr("You're not logged in")
23 | } else {
24 | cmd.Printf("logged out of '%s'", channel)
25 | }
26 |
27 | },
28 | }
29 |
--------------------------------------------------------------------------------
/cli/go.mod:
--------------------------------------------------------------------------------
1 | module cli
2 |
3 | go 1.13
4 |
5 | require (
6 | github.com/manifoldco/promptui v0.6.0
7 | github.com/mitchellh/go-homedir v1.1.0
8 | github.com/pelletier/go-toml v1.6.0 // indirect
9 | github.com/pkg/errors v0.8.1
10 | github.com/spf13/afero v1.2.2 // indirect
11 | github.com/spf13/cast v1.3.1 // indirect
12 | github.com/spf13/cobra v0.0.5
13 | github.com/spf13/jwalterweatherman v1.1.0 // indirect
14 | github.com/spf13/pflag v1.0.5 // indirect
15 | github.com/spf13/viper v1.6.1
16 | golang.org/x/sys v0.0.0-20191224085550-c709ea063b76 // indirect
17 | golang.org/x/text v0.3.2 // indirect
18 | gopkg.in/ini.v1 v1.51.1 // indirect
19 | gopkg.in/yaml.v2 v2.2.7
20 | )
21 |
--------------------------------------------------------------------------------
/web/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Private Conda Repo",
3 | "name": "Private Conda Repository - Hosting your conda packages in an enterprise environment",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/server/config/conda.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/pkg/errors"
7 | )
8 |
9 | type IndexerConfig struct {
10 | Type string `mapstructure:"type"`
11 | ImageName string `mapstructure:"image_name"`
12 | MountFolder string `mapstructure:"mount_folder"`
13 | Update bool `mapstructure:"update"`
14 | }
15 |
16 | func (c *IndexerConfig) Init() error {
17 | c.Type = strings.TrimSpace(strings.ToLower(c.Type))
18 |
19 | if !(c.Type == "docker" || c.Type == "shell") {
20 | return errors.Errorf("Unsupported conda indexer: %s", c.Type)
21 | }
22 |
23 | if c.Type == "shell" {
24 | c.ImageName = ""
25 | }
26 |
27 | return nil
28 | }
29 |
--------------------------------------------------------------------------------
/server/infrastructure/decompressor/metadata.go:
--------------------------------------------------------------------------------
1 | package decompressor
2 |
3 | import (
4 | "os"
5 |
6 | "private-conda-repo/api/dto"
7 | "private-conda-repo/libs"
8 | )
9 |
10 | // Meta data that is derived when unzipping the tar.bz2 package
11 | type MetaData struct {
12 | Package *dto.PackageDto
13 | Filepath string
14 | file *os.File
15 | }
16 |
17 | func (m *MetaData) Open() (*os.File, error) {
18 | var err error
19 | m.file, err = os.Open(m.Filepath)
20 |
21 | return m.file, err
22 | }
23 |
24 | func (m *MetaData) Close() {
25 | if m.file != nil {
26 | _ = m.file.Close()
27 | }
28 |
29 | if libs.PathExists(m.Filepath) {
30 | _ = os.Remove(m.Filepath)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/web/src/modules/Home/pages/Main/PackageList/ResultTable/Tag.tsx:
--------------------------------------------------------------------------------
1 | import { PkgType } from "@/features/package";
2 | import { Tag } from "antd";
3 | import React from "react";
4 |
5 | type Props = {
6 | platform: PkgType.Platform;
7 | };
8 |
9 | export default ({ platform }: Props) => {
10 | return {platform};
11 |
12 | function color() {
13 | switch (platform) {
14 | case "linux-64":
15 | return "volcano";
16 | case "noarch":
17 | return "gold";
18 | case "osx-64":
19 | return "magenta";
20 | case "win-64":
21 | return "blue";
22 | default:
23 | return "grey";
24 | }
25 | }
26 | };
27 |
--------------------------------------------------------------------------------
/cli/request/client.go:
--------------------------------------------------------------------------------
1 | package request
2 |
3 | import (
4 | "crypto/tls"
5 | "io"
6 | "net/http"
7 |
8 | "cli/config"
9 | )
10 |
11 | func NewClient() *http.Client {
12 | conf := config.New()
13 | if conf.SslVerify {
14 | return http.DefaultClient
15 | } else {
16 | tr := http.DefaultTransport.(*http.Transport).Clone()
17 | tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
18 |
19 | return &http.Client{Transport: tr}
20 | }
21 | }
22 |
23 | func Get(url string) (*http.Response, error) {
24 | return NewClient().Get(url)
25 | }
26 |
27 | func Post(url, contentType string, body io.Reader) (*http.Response, error) {
28 | return NewClient().Post(url, contentType, body)
29 | }
30 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/styles.less:
--------------------------------------------------------------------------------
1 | .radioGroup {
2 | padding: 30px 20px 0 20px !important;
3 | width: 100%;
4 |
5 | .button {
6 | color: @primary-color;
7 | width: 50%;
8 | text-align: center;
9 |
10 | &:hover {
11 | color: @primary-color;
12 | }
13 | }
14 |
15 | :global(.ant-radio-button-wrapper-checked) {
16 | background-color: @primary-color !important;
17 | border-color: @primary-color !important;
18 | color: white;
19 |
20 | &:not(.ant-radio-button-wrapper-disabled):before {
21 | background-color: @primary-color !important;
22 | }
23 | }
24 | }
25 |
26 | .form {
27 | padding: 20px 20px 0 20px;
28 | }
29 |
--------------------------------------------------------------------------------
/web/src/modules/Home/pages/Main/PackageList/SearchBar/index.tsx:
--------------------------------------------------------------------------------
1 | import SearchOutlined from "@ant-design/icons/SearchOutlined";
2 | import { Form, Input } from "antd";
3 | import React from "react";
4 | import { useSearchContext } from "../hooks";
5 | import styles from "./styles.less";
6 |
7 | export default () => {
8 | const { search, setSearch } = useSearchContext();
9 |
10 | return (
11 |
12 | setSearch(e.target.value)}
15 | placeholder="Search Private Conda Repo"
16 | size="large"
17 | addonAfter={}
18 | />
19 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/web/src/features/channel/types.ts:
--------------------------------------------------------------------------------
1 | export type Store = {
2 | channel: string;
3 | password: string;
4 | validated: boolean;
5 | form: RegistrationForm;
6 | loading: {
7 | availableCheck: LoadingState;
8 | validation: LoadingState;
9 | };
10 | };
11 |
12 | export type Channel = {
13 | channel: string;
14 | password: string;
15 | };
16 |
17 | export type RegistrationForm = {
18 | channel: string;
19 | password: string;
20 | confirm: string;
21 | email: string;
22 |
23 | errors: Record<
24 | Exclude,
25 | string
26 | >;
27 | pristine: Record<
28 | Exclude,
29 | boolean
30 | >;
31 | };
32 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/styles.less:
--------------------------------------------------------------------------------
1 | .container {
2 | min-height: @min-height;
3 | padding: 40px;
4 |
5 | .tab-bar {
6 | margin: 16px 0;
7 |
8 | :global(.ant-tabs-tab) {
9 | margin: 0;
10 | width: 300px;
11 | justify-content: center;
12 | color: white;
13 |
14 | &:hover {
15 | background-color: rgba(63, 165, 39, 0.5);
16 | }
17 | }
18 |
19 | :global(.ant-tabs-tab-active) {
20 | color: white;
21 | background-color: @primary-color;
22 |
23 | &:hover {
24 | background-color: @primary-color;
25 | }
26 | }
27 |
28 | :global(.ant-tabs-ink-bar) {
29 | background-color: @primary-color-light;
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/web/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 | Private Conda Repo
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/PackageInfo/InstallationGuide/styles.less:
--------------------------------------------------------------------------------
1 | .install-guide {
2 | .title {
3 | font-size: 28px;
4 | font-weight: 600;
5 | }
6 |
7 | .subtitle {
8 | font-size: 20px;
9 |
10 | > i:hover {
11 | color: @primary-color;
12 | cursor: pointer;
13 | }
14 | }
15 |
16 | .tooltip {
17 | min-width: 800px;
18 | }
19 |
20 | .platform {
21 | font-size: 12px;
22 | margin: 14px 0 18px 0;
23 |
24 | .tags {
25 | background-color: @primary-color;
26 | color: white;
27 | padding: 1px 5px;
28 | margin-right: 8px;
29 | }
30 | }
31 |
32 | .command {
33 | font-size: 16px;
34 |
35 | > span {
36 | margin-top: 60px;
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/server/infrastructure/database/migrations/02_create_package_count.up.sql:
--------------------------------------------------------------------------------
1 | create table if not exists PACKAGE_COUNTS
2 | (
3 | ID serial primary key,
4 | CHANNEL varchar(50) not null,
5 | PACKAGE varchar(64) not null,
6 | PLATFORM varchar(10) not null,
7 | BUILD_STRING varchar(64) not null,
8 | BUILD_NUMBER int not null default 0,
9 | VERSION varchar(30) not null,
10 | COUNT int not null default 0,
11 | UPLOAD_DATE timestamp not null default NOW()
12 | );
13 |
14 | create index IDX_PACKAGE_COUNTS__CHANNEL_PACKAGE on PACKAGE_COUNTS
15 | (CHANNEL, PACKAGE);
16 |
17 | create index IDX_PACKAGE_COUNTS__CHANNEL_PACKAGE_PLATFORM on PACKAGE_COUNTS
18 | (CHANNEL, PACKAGE, PLATFORM);
19 |
--------------------------------------------------------------------------------
/server/api/http.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | )
8 |
9 | func toJson(w http.ResponseWriter, object interface{}) {
10 | w.Header().Set("Content-Type", "api/json")
11 | w.WriteHeader(http.StatusOK)
12 | if err := json.NewEncoder(w).Encode(object); err != nil {
13 | http.Error(w, err.Error(), http.StatusBadRequest)
14 | }
15 | }
16 |
17 | func readJson(r *http.Request, object interface{}) error {
18 | if err := json.NewDecoder(r.Body).Decode(object); err != nil {
19 | return err
20 | }
21 | return nil
22 | }
23 |
24 | func ok(w http.ResponseWriter) {
25 | w.WriteHeader(http.StatusOK)
26 | if _, err := fmt.Fprint(w, "Okay"); err != nil {
27 | http.Error(w, err.Error(), http.StatusInternalServerError)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/web/src/infrastructure/api/transformers.ts:
--------------------------------------------------------------------------------
1 | import { AxiosTransformer } from "axios";
2 | import camelCaseKeys from "camelcase-keys";
3 |
4 | export const CamelCaseKeysTransformer: AxiosTransformer = (data, headers) => {
5 | if (!isJson(headers)) return data;
6 |
7 | if (typeof data === "object" && !(data instanceof FormData)) {
8 | if (Array.isArray(data) && typeof data[0] === "string") return data;
9 | return camelCaseKeys(data, { deep: true });
10 | }
11 | return data;
12 | };
13 |
14 | const isJson = (headers: Record) => {
15 | for (const key of Object.keys(headers)) {
16 | if (key.toLowerCase() === "content-type") {
17 | return headers[key].toLowerCase().includes("application/json");
18 | }
19 | }
20 | return false;
21 | };
22 |
--------------------------------------------------------------------------------
/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "react",
21 | "plugins": [
22 | {
23 | "transform": "ts-optchain/transform"
24 | }
25 | ]
26 | },
27 | "include": [
28 | "src",
29 | "types/*.d.ts"
30 | ],
31 | "extends": "./tsconfig.path"
32 | }
33 |
--------------------------------------------------------------------------------
/server/config/database.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import "fmt"
4 |
5 | type DbConfig struct {
6 | Host string `mapstructure:"host"`
7 | Port int `mapstructure:"port"`
8 | User string `mapstructure:"user"`
9 | Password string `mapstructure:"password"`
10 | DbName string `mapstructure:"dbname"`
11 | AutoMigrate bool `mapstructure:"auto_migrate"`
12 | }
13 |
14 | func (d *DbConfig) ConnectionString() string {
15 | return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
16 | d.Host, d.Port, d.User, d.Password, d.DbName)
17 | }
18 |
19 | func (d *DbConfig) MaskedConnectionString() string {
20 | return fmt.Sprintf("host=%s port=%d user=%s password=XXXXXXXXX dbname=%s sslmode=disable",
21 | d.Host, d.Port, d.User, d.DbName)
22 | }
23 |
--------------------------------------------------------------------------------
/web/src/modules/App/Layout/Footer/index.tsx:
--------------------------------------------------------------------------------
1 | import { Layout, Typography } from "antd";
2 | import React from "react";
3 |
4 | import styles from "./styles.less";
5 |
6 | const { Title, Paragraph } = Typography;
7 |
8 | export default () => {
9 | return (
10 |
11 |
12 | Private Conda Repository
13 |
14 |
15 | Private Conda Repository © {getCopyRightYear()}
16 |
17 |
18 | );
19 | };
20 |
21 | function getCopyRightYear() {
22 | const currentYear = new Date().getFullYear();
23 |
24 | return currentYear === 2019
25 | ? currentYear.toString()
26 | : `2019 - ${currentYear}`;
27 | }
28 |
--------------------------------------------------------------------------------
/server/infrastructure/conda/index/docker_test.go:
--------------------------------------------------------------------------------
1 | package index_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 |
8 | . "private-conda-repo/infrastructure/conda/index"
9 | )
10 |
11 | // This is mostly a smoke test. Because if the image already exists
12 | // the pullLatestImage method will not be executed
13 | func TestIndexImage_UpdateImage(t *testing.T) {
14 | assert := require.New(t)
15 |
16 | mgr, err := NewDockerIndex("danielbok/conda-repo-mgr")
17 | assert.NoError(err)
18 | err = mgr.Update()
19 | assert.NoError(err)
20 | }
21 |
22 | func TestManager_CheckDockerVersion(t *testing.T) {
23 | assert := require.New(t)
24 |
25 | mgr, err := NewDockerIndex("danielbok/conda-repo-mgr")
26 | assert.NoError(err)
27 | assert.IsType(&DockerIndex{}, mgr)
28 | }
29 |
--------------------------------------------------------------------------------
/web/src/modules/App/routes.ts:
--------------------------------------------------------------------------------
1 | import accountRoutes from "@/modules/Account";
2 | import helpRoutes from "@/modules/Help";
3 | import homeRoutes from "@/modules/Home";
4 | import packageRoutes from "@/modules/Package";
5 | import uploadRoutes from "@/modules/UploadPage";
6 |
7 | const routeMap: Record = {
8 | "/": {
9 | clusterName: "Home",
10 | routes: homeRoutes,
11 | },
12 | "/account": {
13 | clusterName: "Account",
14 | routes: accountRoutes,
15 | },
16 | "/help": {
17 | clusterName: "Help",
18 | routes: helpRoutes,
19 | },
20 | "/p": {
21 | clusterName: "Package",
22 | routes: packageRoutes,
23 | },
24 | "/upload": {
25 | clusterName: "Upload",
26 | routes: uploadRoutes,
27 | },
28 | };
29 |
30 | export default routeMap;
31 |
--------------------------------------------------------------------------------
/web/src/modules/App/Layout/Header/styles.less:
--------------------------------------------------------------------------------
1 | .header {
2 | height: @header-height;
3 | background-color: white !important;
4 | display: flex;
5 | align-items: center;
6 | justify-content: space-between;
7 | box-shadow: 0 5px 5px #cecece;
8 | margin-bottom: @header-margin;
9 |
10 | .title {
11 | display: flex;
12 | align-items: center;
13 | cursor: pointer;
14 |
15 | .logo {
16 | width: 24px;
17 | }
18 |
19 | .text {
20 | color: #43b02a;
21 | margin-left: 8px;
22 | font-size: 18px;
23 | font-weight: 700;
24 |
25 | .non-bold {
26 | font-weight: normal;
27 | }
28 | }
29 | }
30 | }
31 |
32 | .user {
33 | font-size: 16px;
34 | font-weight: 500;
35 |
36 | .user-logo {
37 | font-size: 18px !important;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/PackageInfo/InstallationGuide/CommandGuide.tsx:
--------------------------------------------------------------------------------
1 | import { MetaSelector } from "@/features/meta";
2 | import { PkgSelector } from "@/features/package";
3 | import { Typography } from "antd";
4 | import React from "react";
5 | import { useSelector } from "react-redux";
6 | import styles from "./styles.less";
7 |
8 | export default () => {
9 | const { channel, package: pkg } = useSelector(PkgSelector.packageDetail);
10 | const { repository } = useSelector(MetaSelector.metaInfo);
11 |
12 | return (
13 |
14 |
To install this package with conda run:
15 |
16 | conda install -c {repository}/{channel} {pkg}
17 |
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/web/src/features/channel/actions.ts:
--------------------------------------------------------------------------------
1 | import { createAction, createAsyncAction } from "typesafe-actions";
2 | import * as T from "./types";
3 |
4 | export const createChannelAsync = createAsyncAction(
5 | "CREATE_CHANNEL_REQUEST",
6 | "CREATE_CHANNEL_SUCCESS",
7 | "CREATE_CHANNEL_FAILURE"
8 | )();
9 |
10 | export const fetchChannelCredentialsAsync = createAsyncAction(
11 | "FETCH_CHANNEL_CREDENTIALS_REQUEST",
12 | "FETCH_CHANNEL_CREDENTIALS_SUCCESS",
13 | "FETCH_CHANNEL_CREDENTIALS_FAILURE"
14 | )();
15 |
16 | export const updateForm = createAction("UPDATE_CHANNEL_FORM")<
17 | DeepPartial
18 | >();
19 |
20 | export const resetForm = createAction("RESET_CHANNEL_FORM")();
21 |
22 | export const logout = createAction("LOGOUT_CHANNEL")();
23 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/Files/Table/DeleteAction.tsx:
--------------------------------------------------------------------------------
1 | import { PkgApi } from "@/features/package";
2 | import { Popconfirm } from "antd";
3 | import React from "react";
4 | import { useDispatch } from "react-redux";
5 | import styles from "./styles.less";
6 | import { DataRow } from "./types";
7 |
8 | type Props = Pick;
9 |
10 | export default ({ channel, package: detail }: Props) => {
11 | const dispatch = useDispatch();
12 |
13 | return (
14 | dispatch(PkgApi.removePackage(channel, detail))}
19 | >
20 | Remove
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/Header/index.tsx:
--------------------------------------------------------------------------------
1 | import { PkgSelector } from "@/features/package";
2 | import React from "react";
3 | import { useSelector } from "react-redux";
4 | import { Link } from "react-router-dom";
5 | import { usePackageContext } from "../hooks";
6 | import styles from "./styles.less";
7 |
8 | export default () => {
9 | const { channel, pkg } = usePackageContext();
10 | const { summary } = useSelector(PkgSelector.packageDetail).latest;
11 |
12 | return (
13 |
14 |
15 |
16 | {channel}
17 | {" "}
18 | / {pkg}
19 |
20 | {summary &&
{summary}
}
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/web/src/modules/App/Layout/Header/index.tsx:
--------------------------------------------------------------------------------
1 | import Logo from "@/resource/conda.svg";
2 | import { Layout } from "antd";
3 | import { push } from "connected-react-router";
4 | import React from "react";
5 | import { useDispatch } from "react-redux";
6 | import UserManager from "./Manager";
7 |
8 | import styles from "./styles.less";
9 |
10 | export default () => {
11 | const dispatch = useDispatch();
12 |
13 | return (
14 |
15 | dispatch(push("/"))}>
16 |

17 |
18 | Private Conda Repository
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.14-stretch as builder
2 |
3 | WORKDIR /app
4 |
5 | COPY . .
6 | RUN go build -o pcr .
7 |
8 | FROM continuumio/miniconda3:latest
9 | RUN conda config --set always_yes true && \
10 | conda update --all && \
11 | conda install conda-build conda-verify && \
12 | conda clean --all
13 |
14 | RUN mkdir -p /var/condapkg
15 |
16 | WORKDIR /app
17 |
18 | # this migration line is tied to /infrastructure/database/postgres/migrate.go implementation.
19 | # it enables users to use the latest migration source without needing to download it from github
20 | # more of a convenience
21 | COPY infrastructure/database/migrations infrastructure/database/migrations
22 | COPY --from=builder /app/pcr pcr
23 | COPY --from=builder /app/config.yaml /var/private-conda-repo/config.yaml
24 |
25 | ENTRYPOINT ["./pcr"]
26 |
--------------------------------------------------------------------------------
/server/libs/closer.go:
--------------------------------------------------------------------------------
1 | package libs
2 |
3 | import (
4 | "io"
5 |
6 | log "github.com/sirupsen/logrus"
7 | )
8 |
9 | // Closes the stream and logs a message at level Error on the standard logger
10 | // if an error is encountered while closing the stream.
11 | func IOCloser(closer io.Closer) {
12 | err := closer.Close()
13 | if err != nil {
14 | log.Error(err)
15 | }
16 | }
17 |
18 | // Closes the stream and logs a message at level Error on the standard logger
19 | // if an error is encountered while closing the stream. The error is appended
20 | // to the back of the formatted string so there is no need to add a "%v" verb
21 | // for the error
22 | func IOCloserf(closer io.Closer, format string, args ...interface{}) {
23 | err := closer.Close()
24 | if err != nil {
25 | args = append(args, err)
26 | log.Errorf(format+": %v", args...)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/index.tsx:
--------------------------------------------------------------------------------
1 | import { ChnSelector } from "@/features/channel";
2 | import { Col, Row } from "antd";
3 | import React from "react";
4 | import { useSelector } from "react-redux";
5 | import { Redirect } from "react-router-dom";
6 |
7 | import Description from "./Description";
8 | import Registration from "./Registration";
9 | import styles from "./styles.less";
10 |
11 | export default () => {
12 | if (useSelector(ChnSelector.channelValidated)) {
13 | return ;
14 | }
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/_example/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.7"
2 |
3 | services:
4 | web:
5 | image: danielbok/pcr-web:3.0
6 | ports:
7 | - "80:80"
8 | depends_on:
9 | - server
10 |
11 | postgres:
12 | image: postgres:12-alpine
13 | restart: always
14 | environment:
15 | POSTGRES_USER: user
16 | POSTGRES_PASSWORD: password
17 | POSTGRES_DB: pcrdb
18 | volumes:
19 | - pcrdb:/var/lib/postgresql/data
20 |
21 | server:
22 | image: danielbok/pcr-server:3.0
23 | ports:
24 | - "5050:5050"
25 | - "5060:5060"
26 | environment:
27 | PCR_INDEXER.TYPE: shell
28 | volumes:
29 | - C:/temp/condapkg:/var/condapkg
30 | # if overriding default config file
31 | # - C:/temp/my-config.yaml:/var/private-conda-repo/config.yaml
32 | depends_on:
33 | - postgres
34 |
35 | volumes:
36 | pcrdb:
37 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/Files/index.tsx:
--------------------------------------------------------------------------------
1 | import { PkgSelector } from "@/features/package";
2 | import React, { useState } from "react";
3 | import { useSelector } from "react-redux";
4 | import Filters from "./Filters";
5 | import { FileContext } from "./hooks";
6 | import Table from "./Table";
7 | import { Filter } from "./types";
8 |
9 | export default () => {
10 | const [filters, setFilters] = useState({
11 | platform: "All",
12 | version: "All",
13 | });
14 | const isAdmin = useSelector(PkgSelector.isAdmin);
15 |
16 | return (
17 | ) =>
22 | setFilters((prev) => ({ ...prev, ...f })),
23 | }}
24 | >
25 |
26 |
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/server/config/defaults.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "runtime"
5 |
6 | "github.com/pkg/errors"
7 | "github.com/spf13/viper"
8 | )
9 |
10 | var defaultKeyValue = map[string]string{}
11 |
12 | var defaultKeyValueMap = map[string]map[string]interface{}{
13 | "indexer.mount_folder": {
14 | "win": "C:/temp/condapkg",
15 | "lin": "/var/condapkg",
16 | },
17 | }
18 |
19 | func setDefaults() error {
20 | var os string
21 | switch runtime.GOOS {
22 | case "windows":
23 | os = "win"
24 | case "linux":
25 | os = "lin"
26 | default:
27 | return errors.Errorf("Unsupported platform: %s", runtime.GOOS)
28 | }
29 |
30 | for key, valueMap := range defaultKeyValueMap {
31 | if val := viper.GetString(key); val == "" {
32 | viper.Set(key, valueMap[os])
33 | }
34 | }
35 |
36 | for key, value := range defaultKeyValue {
37 | viper.Set(key, value)
38 | }
39 |
40 | return nil
41 | }
42 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/PackageInfo/InstallationGuide/index.tsx:
--------------------------------------------------------------------------------
1 | import QuestionCircleFilled from "@ant-design/icons/QuestionCircleFilled";
2 | import { Card, Tooltip } from "antd";
3 | import React from "react";
4 | import CommandGuide from "./CommandGuide";
5 | import PlatformGuide from "./PlatformGuide";
6 | import styles from "./styles.less";
7 |
8 | export default () => (
9 |
10 | Installers
11 |
12 | conda install{" "}
13 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Register/Submit.tsx:
--------------------------------------------------------------------------------
1 | import { ChnApi } from "@/features/channel";
2 | import { useRootSelector } from "@/infrastructure/hooks";
3 | import { Button } from "antd";
4 | import React from "react";
5 | import { useDispatch } from "react-redux";
6 | import styles from "./styles.less";
7 | import { useDisabled } from "./utils";
8 |
9 | export default () => {
10 | const dispatch = useDispatch();
11 | const disabled = useDisabled();
12 | const isLoading = useRootSelector(
13 | (s) => s.channel.loading.validation === "REQUEST"
14 | );
15 |
16 | return (
17 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/_example/README.md:
--------------------------------------------------------------------------------
1 | Sample Application
2 | ==================
3 |
4 | A sample docker-compose file is provided here for you.
5 |
6 | ## Quick start
7 |
8 | ### Without SSL
9 |
10 | ```
11 | docker-compose -p PCR -f up -d
12 | ```
13 |
14 | ### With SSL
15 |
16 | If you'd like to run the application with SSL, execute the following
17 | commands first
18 |
19 | ```bash
20 | mkdir -p certs
21 | cd certs
22 |
23 | # create private key
24 | openssl ecparam -genkey -name secp384r1 -out server.key
25 |
26 | # create public key
27 | openssl req -new -x509 -sha256 -key server.key -out server.crt -days 3650
28 |
29 | docker-compose -p PCR -f docker-compose.ssl.yml up -d
30 | ```
31 |
32 | ## Things to Note
33 |
34 | Note that if you run the server in a dockerized environment, you must
35 | set the environment variable `PCR_INDEXER.TYPE` to `shell`. Do note
36 | that you must use upper-case names for environment variable keys.
37 |
--------------------------------------------------------------------------------
/server/infrastructure/decompressor/tarbz2_test.go:
--------------------------------------------------------------------------------
1 | package decompressor_test
2 |
3 | import (
4 | "os"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/require"
8 |
9 | . "private-conda-repo/infrastructure/decompressor"
10 | "private-conda-repo/libs"
11 | "private-conda-repo/testutils"
12 | )
13 |
14 | func TestTarBz2Decompressor_RetrieveMetadata(t *testing.T) {
15 | t.Parallel()
16 | assert := require.New(t)
17 | dcp := TarBz2Decompressor{}
18 |
19 | test := func(details testutils.TestPackage) {
20 | f, err := os.Open(details.Path)
21 | assert.NoError(err)
22 | defer libs.IOCloser(f)
23 |
24 | pkg, err := dcp.RetrieveMetadata(f)
25 | assert.NoError(err)
26 | assert.Equal(details.Filename, pkg.Package.Filename())
27 | assert.Equal(details.Platform, pkg.Package.Platform)
28 | pkg.Close()
29 | }
30 |
31 | for _, details := range testutils.GetTestPackages() {
32 | test(details)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/web/src/features/meta/api.ts:
--------------------------------------------------------------------------------
1 | import api, { ThunkFunctionAsync } from "@/infrastructure/api";
2 | import { notification } from "antd";
3 | import * as MetaAction from "./actions";
4 | import * as MetaType from "./types";
5 |
6 | /**
7 | * Fetches application meta information
8 | */
9 | export const fetchMetaInfo = (): ThunkFunctionAsync => async (
10 | dispatch,
11 | getState
12 | ) => {
13 | if (getState().meta.loading === "REQUEST") return;
14 |
15 | const { data, status } = await api.Get("/meta", {
16 | beforeRequest: () => dispatch(MetaAction.fetchMetaInfoAsync.request()),
17 | onError: (e) => {
18 | notification.error({
19 | message: `Could not retrieve meta information data. Reason: ${e.data}`,
20 | duration: 8,
21 | });
22 | },
23 | });
24 |
25 | if (status === 200) {
26 | dispatch(MetaAction.fetchMetaInfoAsync.success(data));
27 | }
28 | };
29 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Login/index.tsx:
--------------------------------------------------------------------------------
1 | import { Typography } from "antd";
2 | import React, { FC } from "react";
3 | import { LoginContext } from "./hooks";
4 | import PasswordInput from "./PasswordInput";
5 | import { useLoginReducer } from "./reducer";
6 | import styles from "./styles.less";
7 | import Submit from "./Submit";
8 | import UsernameInput from "./UsernameInput";
9 |
10 | const LoginForm: FC = () => {
11 | const [state, dispatch] = useLoginReducer();
12 |
13 | return (
14 |
15 | Already a member? Sign in!
16 |
17 |
18 |
19 | {!state.valid && (
20 | User credentials are invalid
21 | )}
22 |
23 | );
24 | };
25 |
26 | export default LoginForm;
27 |
--------------------------------------------------------------------------------
/web/nginx-ssl.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | listen [::]:80;
4 | server_tokens off;
5 |
6 | return 301 https://$host$request_uri;
7 | }
8 |
9 | server {
10 | listen 443 http2 ssl;
11 | listen [::]:443 http2 ssl;
12 | ssl_certificate /etc/nginx/certs/server.crt;
13 | ssl_certificate_key /etc/nginx/certs/server.key;
14 | root /usr/share/nginx/html;
15 |
16 | location / {
17 | index index.html index.htm;
18 | try_files $uri $uri/ /index.html =404;
19 | add_header Cache-Control "no-store, no-cache, must-revalidate";
20 | }
21 |
22 | location /static {
23 | expires 1y;
24 | add_header Cache-Control "public";
25 | access_log off;
26 | }
27 |
28 | error_page 500 502 503 504 /50x.html;
29 | location = /50x.html {
30 | root /usr/share/nginx/html;
31 | }
32 | }
--------------------------------------------------------------------------------
/server/infrastructure/database/postgres/postgres.go:
--------------------------------------------------------------------------------
1 | package postgres
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/jinzhu/gorm"
7 | _ "github.com/jinzhu/gorm/dialects/postgres"
8 | "github.com/pkg/errors"
9 | log "github.com/sirupsen/logrus"
10 |
11 | "private-conda-repo/config"
12 | )
13 |
14 | type Postgres struct {
15 | db *gorm.DB
16 | }
17 |
18 | func New(config *config.DbConfig) (*Postgres, error) {
19 | var err error
20 |
21 | // waiting for db to be ready
22 | wait := 1
23 | for i := 1; i < 10; i++ {
24 | db, err := gorm.Open("postgres", config.ConnectionString())
25 | if err == nil {
26 | db.SingularTable(true)
27 | return &Postgres{db: db}, nil
28 | }
29 | wait += i
30 | log.Infof("waiting %d seconds to retry connection with database at %s", wait, config.Host)
31 | time.Sleep(time.Duration(wait) * time.Second)
32 | }
33 |
34 | return nil, errors.Wrapf(err, "could not connect to database with '%s'", config.MaskedConnectionString())
35 | }
36 |
--------------------------------------------------------------------------------
/server/domain/enum/platforms_test.go:
--------------------------------------------------------------------------------
1 | package enum_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 |
8 | . "private-conda-repo/domain/enum"
9 | )
10 |
11 | func TestMapPlatform(t *testing.T) {
12 | tests := []struct {
13 | input string
14 | expected Platform
15 | hasError bool
16 | }{
17 | {"linux32", LINUX32, false},
18 | {"linux-32", LINUX32, false},
19 | {"linux64", LINUX64, false},
20 | {"linux-64", LINUX64, false},
21 | {"win32", WIN32, false},
22 | {"win-32", WIN32, false},
23 | {"win64", WIN64, false},
24 | {"win-64", WIN64, false},
25 | {"osx64", OSX64, false},
26 | {"osx-64", OSX64, false},
27 | {"noarch", NOARCH, false},
28 | {"bad-value", "", true},
29 | }
30 |
31 | for _, test := range tests {
32 | p, err := MapPlatform(test.input)
33 | if test.hasError {
34 | require.Error(t, err)
35 | } else {
36 | require.NoError(t, err)
37 | require.EqualValues(t, test.expected, p)
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/ChannelDetail/styles.less:
--------------------------------------------------------------------------------
1 | .container {
2 | min-height: @min-height;
3 | padding: 40px;
4 | }
5 |
6 | .card {
7 | :global(.ant-card-head) {
8 | background-color: #1c1c1c;
9 | }
10 |
11 | .title {
12 | color: white;
13 | font-size: 20px;
14 | }
15 |
16 | .profile-div {
17 | margin-bottom: 12px;
18 |
19 | .channel-name {
20 | font-size: 24px;
21 | font-weight: 500;
22 | }
23 | }
24 |
25 | .icon-link {
26 | color: @primary-color;
27 | margin-right: 8px;
28 |
29 | &:hover {
30 | color: @primary-color-light;
31 | }
32 | }
33 | }
34 |
35 | .list-item {
36 | @size: 18px;
37 |
38 | img {
39 | width: @size;
40 | margin-right: 8px;
41 | }
42 |
43 | a {
44 | color: @primary-color;
45 | font-size: @size;
46 | margin-right: 8px;
47 |
48 | &:hover {
49 | color: @primary-color-dark;
50 | }
51 | }
52 |
53 | span {
54 | font-size: 10px;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/server/domain/enum/platforms.go:
--------------------------------------------------------------------------------
1 | package enum
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/pkg/errors"
7 | )
8 |
9 | type Platform string
10 |
11 | const (
12 | LINUX32 Platform = "linux-64"
13 | LINUX64 Platform = "linux-32"
14 | WIN32 Platform = "win-32"
15 | WIN64 Platform = "win-64"
16 | OSX64 Platform = "osx-64"
17 | NOARCH Platform = "noarch"
18 | )
19 |
20 | var Platforms = []Platform{LINUX32, LINUX64, WIN32, WIN64, OSX64, NOARCH}
21 |
22 | func MapPlatform(platform string) (Platform, error) {
23 | switch strings.TrimSpace(strings.ToLower(platform)) {
24 | case "linux32", "linux-32":
25 | return LINUX32, nil
26 | case "linux64", "linux-64":
27 | return LINUX64, nil
28 | case "win32", "win-32":
29 | return WIN32, nil
30 | case "win64", "win-64":
31 | return WIN64, nil
32 | case "osx64", "osx-64":
33 | return OSX64, nil
34 | case "noarch":
35 | return NOARCH, nil
36 | default:
37 | return "", errors.Errorf("Invalid platform: '%s'", platform)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/web/types/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.less";
2 | declare module "*.png";
3 | declare module "*.svg";
4 | declare module "*.md" {
5 | /**
6 | * Replaces text in a string, using a regular expression or search string.
7 | * @param searchValue A string to search for.
8 | * @param replaceValue A string containing the text to replace for every successful match of searchValue in this string.
9 | */
10 | function replace(searchValue: string | RegExp, replaceValue: string): this;
11 | }
12 |
13 | type LoadingState = "REQUEST" | "SUCCESS" | "FAILURE";
14 |
15 | type ModuleRoute = {
16 | component:
17 | | React.ComponentType>
18 | | React.ComponentType;
19 | path: string;
20 | title: string;
21 | exact?: boolean;
22 | };
23 |
24 | type ModuleRoutes = {
25 | clusterName: string;
26 | routes: ModuleRoute[];
27 | };
28 |
29 | type DeepPartial = {
30 | [K in keyof T]?: T[K] extends Array
31 | ? Array>
32 | : DeepPartial;
33 | };
34 |
--------------------------------------------------------------------------------
/web/src/modules/Help/pages/Upload/upload.md:
--------------------------------------------------------------------------------
1 | # Uploading Packages
2 |
3 | You can upload your packages using a "multipart/form" POST request via curl, postman or
4 | using the tools built in PCR.
5 |
6 |
7 |
8 | ## The basic request
9 |
10 | Read this section if you want to know how it works or if you want to upload "raw".
11 | A sample post request using javascript and **axios** is listed below.
12 |
13 |
14 |
15 |
16 | ## Using the CLI
17 |
18 | If you're using the cli, follow the steps written
19 | [here](https://github.com/DanielBok/private-conda-repo/tree/master/cli). Remember to
20 | set the `--no-abi` flag if you need it as it is not there by default.
21 |
22 | ## Using the web interface
23 |
24 | Assuming you've already created a channel and is logged onto the channel, you can fill
25 | up the form at this [link](@link) to upload a package. Remember that the package has
26 | to have the `.tar.bz2` suffix (which shouldn't be a problem if you did your
27 | `conda build` properly).
28 |
--------------------------------------------------------------------------------
/RELEASES.md:
--------------------------------------------------------------------------------
1 | # Releases
2 |
3 | A document containing some important information about the major changes I made and how to migrate over.
4 |
5 | ## Version 2
6 |
7 | ### Changes
8 | 1) Reorganized and rewrote the internals of the server to give it a cleaner architecture.
9 | 2) Some changes to api endpoints. The effects are generally the same.
10 | 3) CLI tool has been updated to follow this new structure.
11 | 4) Web frontend tool has also been updated to use new api.
12 |
13 | ### Bugs
14 |
15 | The Web UI is definitely buggy. But since you can register accounts,
16 | upload (and overwrite existing) packages with the CLI just fine, I'll
17 | postpone updating the bugs till I have more time.
18 |
19 | ### How to migrate
20 |
21 | Just run the new `docker-compose.yaml` in the `_examples` folder with the same volume. Fingers-crossed,
22 | it *should* be fine. :|
23 |
24 | The SQL script updates the database accordingly. For the server's config, check out the `config.yaml` file.
25 | In general, the configuration has been simplified.
26 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/index.tsx:
--------------------------------------------------------------------------------
1 | import { Radio } from "antd";
2 | import React, { useState } from "react";
3 |
4 | import LoginForm from "./Login";
5 | import RegistrationForm from "./Register";
6 | import styles from "./styles.less";
7 |
8 | export default () => {
9 | const [action, setAction] = useState<"Register" | "Login">("Register");
10 |
11 | return (
12 |
13 |
setAction(e.target.value)}
19 | >
20 |
21 | Register
22 |
23 |
24 | Login
25 |
26 |
27 |
28 |
29 | {action === "Login" ? : }
30 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/cli/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/spf13/cobra"
7 |
8 | "cli/config"
9 | "cli/registry"
10 | "cli/upload"
11 | )
12 |
13 | var rootCmd = &cobra.Command{
14 | Use: "pcr",
15 | Short: "Private Conda Repository Command Line Tool",
16 | Long: `Private Conda Repository command line tool.
17 | Aids in various aspect of using the Private Conda Repository
18 | application. This tool is catered for package contributors.
19 | `,
20 | Version: "3.0",
21 | }
22 |
23 | func main() {
24 | log.SetFlags(log.Flags() &^ (log.Ldate | log.Ltime))
25 |
26 | rootCmd.AddCommand(config.RootCmd, registry.RootCmd, upload.RootCmd, versionCmd)
27 |
28 | if err := rootCmd.Execute(); err != nil {
29 | log.Fatalln(err)
30 | }
31 | }
32 |
33 | var versionCmd = &cobra.Command{
34 | Use: "version",
35 | Short: "Print the version number",
36 | Long: `All software has versions. This is Private Conda Repo CLI's`,
37 | Run: func(cmd *cobra.Command, _ []string) {
38 | cmd.Printf("Private Conda Repo CLI: %s", rootCmd.Version)
39 | },
40 | }
41 |
--------------------------------------------------------------------------------
/web/src/components/Markdown/index.tsx:
--------------------------------------------------------------------------------
1 | import { Typography } from "antd";
2 | import cx from "classnames";
3 | import MarkdownToJS, { MarkdownOptions } from "markdown-to-jsx";
4 | import React, { FC } from "react";
5 |
6 | import styles from "./styles.less";
7 |
8 | interface IProps extends MarkdownOptions {
9 | className?: string;
10 | }
11 |
12 | const Markdown: FC = ({
13 | className,
14 | overrides = {},
15 | children,
16 | ...options
17 | }) => {
18 | overrides = {
19 | ...{
20 | h1: {
21 | component: Typography.Title,
22 | props: { level: 2 },
23 | },
24 | h2: {
25 | component: Typography.Title,
26 | props: { level: 3 },
27 | },
28 | div: {
29 | component: Typography.Paragraph,
30 | },
31 | },
32 | ...overrides,
33 | };
34 |
35 | return (
36 |
37 |
38 |
39 | );
40 | };
41 |
42 | export default Markdown;
43 |
--------------------------------------------------------------------------------
/web/src/modules/App/RouteSwitch.tsx:
--------------------------------------------------------------------------------
1 | import ErrorPage from "@/components/ErrorBoundary/ErrorPage";
2 | import React from "react";
3 | import { Route, Switch } from "react-router";
4 | import { oc } from "ts-optchain";
5 |
6 | import routeMap from "./routes";
7 |
8 | const moduleMap = Object.entries(routeMap).reduce(
9 | (acc, [prefix, { routes, ...rest }]) => ({
10 | ...acc,
11 | [prefix]: {
12 | ...rest,
13 | routes: routes.map(({ path, exact, ...r }) => ({
14 | ...r,
15 | exact: oc(exact)(true),
16 | path: (prefix + path).replace("//{2,}/g", "'/"),
17 | })),
18 | },
19 | }),
20 | {} as Record
21 | );
22 |
23 | const routeList = Object.values(moduleMap).flatMap((module) =>
24 | module.routes.map((r, i) => (
25 |
26 | ))
27 | );
28 |
29 | export default () => (
30 |
31 | {routeList}
32 | } />
33 | } />
34 |
35 | );
36 |
--------------------------------------------------------------------------------
/cli/config/get.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/pkg/errors"
7 | "github.com/spf13/cobra"
8 | )
9 |
10 | var getConfCmd = &cobra.Command{
11 | Use: "get",
12 | Short: "Gets the configuration value",
13 | Args: cobra.MinimumNArgs(1),
14 | Example: "pcr config get ssl_verify",
15 | Run: func(cmd *cobra.Command, args []string) {
16 | handler := GetHandler{cmd: cmd}
17 |
18 | for _, arg := range args {
19 | handler.Get(arg)
20 | }
21 | },
22 | }
23 |
24 | type GetHandler struct {
25 | cmd *cobra.Command
26 | }
27 |
28 | func (g *GetHandler) Get(key string) {
29 | value, err := getValue(key)
30 | if err != nil {
31 | g.cmd.PrintErrf("Invalid option: %s\n", key)
32 | return
33 | }
34 | g.cmd.Printf("%s: %v\n", sslVerify, value)
35 | }
36 |
37 | func getValue(key string) (interface{}, error) {
38 | key = strings.ToLower(strings.TrimSpace(key))
39 | conf := New()
40 |
41 | switch key {
42 | case sslVerify:
43 | return conf.SslVerify, nil
44 | default:
45 | return nil, errors.Errorf("Invalid option: %s\n", key)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/ChannelDetail/Profile.tsx:
--------------------------------------------------------------------------------
1 | import { PkgSelector } from "@/features/package";
2 | import { Card } from "antd";
3 | import React from "react";
4 | import { useSelector } from "react-redux";
5 | import styles from "./styles.less";
6 |
7 | export default () => {
8 | const { channel, email, joinDate } = useSelector(
9 | PkgSelector.channelPackages
10 | );
11 |
12 | const subject = encodeURI(`Hello ${channel}`);
13 |
14 | return (
15 | Profile}
17 | className={styles.card}
18 | >
19 |
20 | {channel}
21 |
22 |
23 | Contributor since {joinDate.format("MMM DD, YYYY")}
24 |
25 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Register/index.tsx:
--------------------------------------------------------------------------------
1 | import { ChnAction } from "@/features/channel";
2 | import { Typography } from "antd";
3 | import React, { useEffect } from "react";
4 | import { useDispatch } from "react-redux";
5 | import ChannelInput from "./ChannelInput";
6 | import ConfirmInput from "./ConfirmInput";
7 | import EmailInput from "./EmailInput";
8 | import PasswordInput from "./PasswordInput";
9 | import styles from "./styles.less";
10 | import Submit from "./Submit";
11 |
12 | const RegistrationForm = () => {
13 | const dispatch = useDispatch();
14 |
15 | useEffect(() => {
16 | dispatch(ChnAction.resetForm());
17 | // eslint-disable-next-line
18 | }, []);
19 |
20 | return (
21 | <>
22 |
23 | New to Private Conda Repo? Register a channel for yourself!
24 |
25 |
26 |
27 |
28 |
29 |
30 | >
31 | );
32 | };
33 |
34 | export default RegistrationForm;
35 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Login/PasswordInput.tsx:
--------------------------------------------------------------------------------
1 | import { Form, Input } from "antd";
2 | import React, { FC } from "react";
3 | import { useLoginContext, useStatus, useSubmit } from "./hooks";
4 | import Errors from "./Errors";
5 |
6 | const PasswordInput: FC = () => {
7 | const {
8 | state: { password, disabled },
9 | dispatch,
10 | } = useLoginContext();
11 | const submit = useSubmit();
12 | const status = useStatus("password");
13 |
14 | return (
15 | }
19 | >
20 | {
25 | dispatch({
26 | type: "SET_PASSWORD",
27 | payload: { password: e.target.value },
28 | });
29 | }}
30 | onKeyPress={(e) => {
31 | if (e.key === "Enter" && !disabled) submit();
32 | }}
33 | />
34 |
35 | );
36 | };
37 |
38 | export default PasswordInput;
39 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Login/UsernameInput.tsx:
--------------------------------------------------------------------------------
1 | import Errors from "./Errors";
2 | import { Form, Input } from "antd";
3 | import React, { FC } from "react";
4 | import { useLoginContext, useStatus, useSubmit } from "./hooks";
5 |
6 | const UsernameInput: FC = () => {
7 | const {
8 | state: { username, disabled },
9 | dispatch,
10 | } = useLoginContext();
11 | const submit = useSubmit();
12 | const status = useStatus("username");
13 |
14 | return (
15 | }
19 | >
20 |
25 | dispatch({
26 | type: "SET_USERNAME",
27 | payload: { username: e.target.value },
28 | })
29 | }
30 | onKeyPress={(e) => {
31 | if (e.key === "Enter" && !disabled) submit();
32 | }}
33 | />
34 |
35 | );
36 | };
37 |
38 | export default UsernameInput;
39 |
--------------------------------------------------------------------------------
/web/src/features/package/actions.ts:
--------------------------------------------------------------------------------
1 | import { createAction, createAsyncAction } from "typesafe-actions";
2 | import * as T from "./types";
3 |
4 | export const fetchAllPackagesAsync = createAsyncAction(
5 | "FETCH_ALL_PACKAGES_REQUEST",
6 | "FETCH_ALL_PACKAGES_SUCCESS",
7 | "FETCH_ALL_PACKAGES_FAILURE"
8 | )();
9 |
10 | export const fetchPackageDetail = createAsyncAction(
11 | "FETCH_PACKAGE_DETAIL_REQUEST",
12 | "FETCH_PACKAGE_DETAIL_SUCCESS",
13 | "FETCH_PACKAGE_DETAIL_FAILURE"
14 | ), void>();
15 |
16 | export const fetchChannelPackages = createAsyncAction(
17 | "FETCH_USER_PACKAGES_REQUEST",
18 | "FETCH_USER_PACKAGES_SUCCESS",
19 | "FETCH_USER_PACKAGES_FAILURE"
20 | ), void>();
21 |
22 | export const removePackageDetail = createAsyncAction(
23 | "REMOVE_PACKAGE_DETAIL_REQUEST",
24 | "REMOVE_PACKAGE_DETAIL_SUCCESS",
25 | "REMOVE_PACKAGE_DETAIL_FAILURE"
26 | )();
27 |
28 | export const resetLoadingStore = createAction("RESET_PACKAGE_LOADING_STORE")<
29 | void
30 | >();
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Daniel Bok
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Register/EmailInput.tsx:
--------------------------------------------------------------------------------
1 | import { ChnAction, ChnApi } from "@/features/channel";
2 | import { Form, Input } from "antd";
3 | import React from "react";
4 | import { useDispatch } from "react-redux";
5 | import Errors from "./Errors";
6 | import { useDetails, useDisabled } from "./utils";
7 |
8 | const EmailInput = () => {
9 | const dispatch = useDispatch();
10 | const disabled = useDisabled();
11 | const [email, errors, status] = useDetails("email");
12 |
13 | return (
14 | }
18 | >
19 | {
24 | const email = e.target.value.trim().toLowerCase();
25 | dispatch(ChnAction.updateForm({ email }));
26 | }}
27 | onKeyPress={(e) => {
28 | if (e.key === "Enter" && !disabled) dispatch(ChnApi.createChannel());
29 | }}
30 | />
31 |
32 | );
33 | };
34 |
35 | export default EmailInput;
36 |
--------------------------------------------------------------------------------
/server/infrastructure/database/migrations/03_alter_channel_tables.down.sql:
--------------------------------------------------------------------------------
1 | drop index IDX_PACKAGE_COUNT__PACKAGE_PLATFORM;
2 | drop index IDX_PACKAGE_COUNT__PACKAGE;
3 |
4 |
5 | alter table PACKAGE_COUNT
6 | add column CHANNEL varchar(50) not null default '';
7 |
8 | alter table PACKAGE_COUNT
9 | rename to PACKAGE_COUNTS;
10 |
11 | update PACKAGE_COUNTS
12 | set CHANNEL = Q.CHANNEL
13 | from (
14 | select ID, CHANNEL
15 | from CHANNEL
16 | ) as Q
17 | where PACKAGE_COUNTS.CHANNEL_ID = Q.ID;
18 |
19 | alter table PACKAGE_COUNTS
20 | alter column CHANNEL drop default;
21 |
22 | alter table PACKAGE_COUNTS
23 | drop column CHANNEL_ID;
24 |
25 | create unique index IDX_PACKAGE_COUNTS__CHANNEL_PACKAGE on PACKAGE_COUNTS (CHANNEL, PACKAGE);
26 | create unique index IDX_PACKAGE_COUNTS__CHANNEL_PACKAGE_PLATFORM on PACKAGE_COUNTS (CHANNEL, PACKAGE, PLATFORM);
27 |
28 |
29 | drop index CHANNEL_CHANNEL_KEY;
30 |
31 | alter table CHANNEL
32 | rename column CREATED_ON to JOIN_DATE;
33 |
34 | alter table CHANNEL
35 | rename to USERS;
36 |
37 | alter table USERS
38 | add constraint USERS_CHANNEL_KEY unique (CHANNEL);
39 |
--------------------------------------------------------------------------------
/web/src/features/meta/reducer.ts:
--------------------------------------------------------------------------------
1 | import AllActions from "@/infrastructure/rootAction";
2 | import produce from "immer";
3 | import { getType } from "typesafe-actions";
4 | import * as MetaAction from "./actions";
5 | import * as MetaType from "./types";
6 |
7 | const defaultState: MetaType.Store = {
8 | indexer: "shell",
9 | image: "",
10 | loading: "SUCCESS",
11 | registry: "",
12 | repository: "",
13 | };
14 |
15 | export default (state = defaultState, action: AllActions) =>
16 | produce(state, (draft) => {
17 | switch (action.type) {
18 | case getType(MetaAction.fetchMetaInfoAsync.request):
19 | draft.loading = "REQUEST";
20 | break;
21 |
22 | case getType(MetaAction.fetchMetaInfoAsync.failure):
23 | draft.loading = "FAILURE";
24 | break;
25 |
26 | case getType(MetaAction.fetchMetaInfoAsync.success):
27 | draft.loading = "SUCCESS";
28 | draft.indexer = action.payload.indexer;
29 | draft.image = action.payload.image;
30 | draft.registry = action.payload.registry;
31 | draft.repository = action.payload.repository;
32 | break;
33 | }
34 | });
35 |
--------------------------------------------------------------------------------
/web/src/components/ErrorBoundary/ErrorPage.tsx:
--------------------------------------------------------------------------------
1 | import { PkgAction } from "@/features/package";
2 | import { Button, Typography } from "antd";
3 | import { push } from "connected-react-router";
4 | import React, { useEffect } from "react";
5 | import { useDispatch } from "react-redux";
6 | import { withRouter } from "react-router-dom";
7 | import NotFound from "./NotFound.png";
8 | import styles from "./styles.less";
9 |
10 | const ErrorPage = () => {
11 | const dispatch = useDispatch();
12 |
13 | useEffect(resets);
14 |
15 | return (
16 |
17 |

18 |
The page you're looking for does not exist
19 |
20 | Do you have permissions to view this page?
21 |
22 |
23 |
26 |
27 | );
28 |
29 | function goto(path: string) {
30 | dispatch(push(path));
31 | }
32 |
33 | function resets() {
34 | dispatch(PkgAction.resetLoadingStore());
35 | }
36 | };
37 |
38 | export default withRouter(ErrorPage);
39 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Register/ConfirmInput.tsx:
--------------------------------------------------------------------------------
1 | import { ChnAction, ChnApi } from "@/features/channel";
2 | import { Form, Input } from "antd";
3 | import React from "react";
4 | import { useDispatch } from "react-redux";
5 | import Errors from "./Errors";
6 | import { useDetails, useDisabled } from "./utils";
7 |
8 | const ConfirmInput = () => {
9 | const dispatch = useDispatch();
10 | const disabled = useDisabled();
11 | const [confirm, errors, status] = useDetails("confirm");
12 |
13 | return (
14 | }
18 | >
19 | {
24 | const confirm = e.target.value.trim().toLowerCase();
25 | dispatch(ChnAction.updateForm({ confirm }));
26 | }}
27 | onKeyPress={(e) => {
28 | if (e.key === "Enter" && !disabled) dispatch(ChnApi.createChannel());
29 | }}
30 | />
31 |
32 | );
33 | };
34 |
35 | export default ConfirmInput;
36 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Register/PasswordInput.tsx:
--------------------------------------------------------------------------------
1 | import { ChnAction, ChnApi } from "@/features/channel";
2 | import { Form, Input } from "antd";
3 | import React from "react";
4 | import { useDispatch } from "react-redux";
5 | import Errors from "./Errors";
6 | import { useDetails, useDisabled } from "./utils";
7 |
8 | const PasswordInput = () => {
9 | const dispatch = useDispatch();
10 | const disabled = useDisabled();
11 | const [password, errors, status] = useDetails("password");
12 |
13 | return (
14 | }
18 | >
19 | {
24 | const password = e.target.value.trim().toLowerCase();
25 | dispatch(ChnAction.updateForm({ password }));
26 | }}
27 | onKeyPress={(e) => {
28 | if (e.key === "Enter" && !disabled) dispatch(ChnApi.createChannel());
29 | }}
30 | />
31 |
32 | );
33 | };
34 |
35 | export default PasswordInput;
36 |
--------------------------------------------------------------------------------
/server/domain/condatypes/repodata.go:
--------------------------------------------------------------------------------
1 | package condatypes
2 |
3 | type RepoData struct {
4 | Info struct {
5 | Platform string `json:"platform,omitempty"`
6 | Subdir string `json:"subdir"`
7 | DefaultPythonVersion string `json:"default_python_version,omitempty"`
8 | Arch string `json:"arch,omitempty"`
9 | DefaultNumpyVersion string `json:"default_numpy_version"`
10 | } `json:"info"`
11 | Packages map[string]*PackageDetail `json:"packages"`
12 | }
13 |
14 | type PackageDetail struct {
15 | Arch string `json:"arch,omitempty"`
16 | Build string `json:"build"`
17 | BuildNumber int `json:"build_number"`
18 | Depends []string `json:"depends"`
19 | License string `json:"license"`
20 | LicenseFamily string `json:"license_family,omitempty"`
21 | MD5 string `json:"md5"`
22 | Name string `json:"name"`
23 | NoArch string `json:"noarch,omitempty"`
24 | SHA256 string `json:"sha256"`
25 | Size int `json:"size"`
26 | Subdir string `json:"subdir"`
27 | Timestamp uint64 `json:"timestamp"`
28 | Version string `json:"version"`
29 | }
30 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Register/utils.ts:
--------------------------------------------------------------------------------
1 | import { RegistrationForm } from "@/features/channel/types";
2 | import { useRootSelector } from "@/infrastructure/hooks";
3 | import { ValidateStatus } from "antd/es/form/FormItem";
4 |
5 | export const useDetails = (
6 | key: keyof Pick<
7 | RegistrationForm,
8 | "channel" | "email" | "password" | "confirm"
9 | >
10 | ): [string, string, ValidateStatus] =>
11 | useRootSelector(({ channel: { form } }) => {
12 | const errors = form.errors[key];
13 | const status = form.pristine[key]
14 | ? ""
15 | : errors.length
16 | ? "error"
17 | : "success";
18 |
19 | return [form[key], form.errors[key], status];
20 | });
21 |
22 | export const useDisabled = () =>
23 | useRootSelector(
24 | ({
25 | channel: {
26 | form: { pristine, errors, confirm, password },
27 | },
28 | }) =>
29 | // disabled if
30 | // any fields have not been touched (edited)
31 | Object.values(pristine).some((pristine) => pristine) ||
32 | // or any fields have error
33 | Object.values(errors).some((e) => e.length > 0) ||
34 | password !== confirm
35 | );
36 |
--------------------------------------------------------------------------------
/server/fileserver/server.go:
--------------------------------------------------------------------------------
1 | package fileserver
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 |
7 | "github.com/go-chi/chi"
8 | "github.com/go-chi/chi/middleware"
9 | log "github.com/sirupsen/logrus"
10 |
11 | "private-conda-repo/api/interfaces"
12 | "private-conda-repo/config"
13 | )
14 |
15 | type MasterHandler struct {
16 | DB interfaces.DataAccessLayer
17 | *chi.Mux
18 | }
19 |
20 | func New(conf *config.AppConfig, db interfaces.DataAccessLayer) (*http.Server, error) {
21 | addr := fmt.Sprintf(":%d", conf.FileServer.Port)
22 | log.WithField("Address", addr).Info("Server details")
23 |
24 | m := MasterHandler{
25 | Mux: chi.NewRouter(),
26 | DB: db,
27 | }
28 |
29 | m.attachMiddleware()
30 | m.addFileServer(conf)
31 |
32 | return &http.Server{
33 | Addr: addr,
34 | Handler: m,
35 | }, nil
36 | }
37 |
38 | func (m *MasterHandler) attachMiddleware() {
39 | m.Use(middleware.RequestID)
40 | m.Use(middleware.RealIP)
41 | m.Use(middleware.Logger)
42 | m.Use(middleware.Recoverer)
43 | }
44 |
45 | func (m *MasterHandler) addFileServer(conf *config.AppConfig) {
46 | handler := &FileHandler{
47 | DB: m.DB,
48 | }
49 |
50 | m.Get("/*", handler.Server(conf.Indexer.MountFolder))
51 | }
52 |
--------------------------------------------------------------------------------
/web/src/infrastructure/store.ts:
--------------------------------------------------------------------------------
1 | import { MetaApi } from "@/features/meta";
2 | import { PkgApi } from "@/features/package";
3 | import { ChnApi } from "@/features/channel";
4 | import { routerMiddleware } from "connected-react-router";
5 | import { createBrowserHistory } from "history";
6 | import { applyMiddleware, createStore, Store } from "redux";
7 | import { composeWithDevTools } from "redux-devtools-extension";
8 | import thunk from "redux-thunk";
9 |
10 | import createReducer from "./rootReducer";
11 |
12 | export const history = createBrowserHistory();
13 |
14 | function configureStore() {
15 | const middleware = composeWithDevTools(
16 | applyMiddleware(thunk, routerMiddleware(history))
17 | );
18 |
19 | const store = createStore(createReducer(history), middleware);
20 | initializeStore(store).catch((e) => console.error(e));
21 |
22 | return store;
23 | }
24 |
25 | async function initializeStore(store: Store) {
26 | const dispatch = (action: any) => store.dispatch(action);
27 |
28 | await Promise.all([
29 | dispatch(PkgApi.fetchAllPackages()),
30 | dispatch(ChnApi.loadChannel()),
31 | dispatch(MetaApi.fetchMetaInfo()),
32 | ]);
33 | }
34 |
35 | export default configureStore();
36 |
--------------------------------------------------------------------------------
/web/src/modules/UploadPage/pages/UploadForm/hooks.ts:
--------------------------------------------------------------------------------
1 | import { useRootSelector } from "@/infrastructure/hooks";
2 | import { UploadFile } from "antd/es/upload/interface";
3 | import axios, { AxiosResponse } from "axios";
4 |
5 | const url =
6 | (process.env.REACT_APP_API_URL ??
7 | `${window.location.protocol}//${window.location.hostname}:5060`) + "/p";
8 |
9 | type Response = {
10 | buildNumber: number;
11 | buildString: string;
12 | name: string;
13 | platform: string;
14 | version: string;
15 | };
16 |
17 | export const useUpload = () => {
18 | const [
19 | channel,
20 | password,
21 | ] = useRootSelector(({ channel: { channel, password } }) => [
22 | channel,
23 | password,
24 | ]);
25 |
26 | return async (
27 | { originFileObj: file }: UploadFile,
28 | noAbi: boolean
29 | ): Promise> => {
30 | const data = new FormData();
31 | data.append("channel", channel);
32 | data.append("password", password);
33 | data.append("file", file as File);
34 |
35 | const fixes = [noAbi && "no-abi"].filter((e) => e).join(",");
36 | if (fixes) data.append("fixes", fixes);
37 |
38 | return await axios.post(url, data);
39 | };
40 | };
41 |
--------------------------------------------------------------------------------
/server/infrastructure/database/migrations/03_alter_channel_tables.up.sql:
--------------------------------------------------------------------------------
1 | -- USERS -> CHANNEL
2 |
3 | alter table USERS
4 | drop constraint USERS_CHANNEL_KEY;
5 |
6 | alter table USERS
7 | rename to CHANNEL;
8 |
9 | alter table CHANNEL
10 | rename column JOIN_DATE to CREATED_ON;
11 |
12 | create unique index CHANNEL_CHANNEL_KEY on CHANNEL (lower(CHANNEL));
13 |
14 |
15 | -- PACKAGE_COUNTS -> PACKAGE_COUNT
16 |
17 | drop index IDX_PACKAGE_COUNTS__CHANNEL_PACKAGE;
18 | drop index IDX_PACKAGE_COUNTS__CHANNEL_PACKAGE_PLATFORM;
19 |
20 |
21 | alter table PACKAGE_COUNTS
22 | add column CHANNEL_ID serial not null references CHANNEL (ID) on delete cascade;
23 |
24 | update PACKAGE_COUNTS
25 | set CHANNEL_ID = Q.ID
26 | from (
27 | select ID,
28 | CHANNEL
29 | from CHANNEL
30 | ) as Q
31 | where PACKAGE_COUNTS.CHANNEL = Q.CHANNEL;
32 |
33 | alter table PACKAGE_COUNTS
34 | rename to PACKAGE_COUNT;
35 |
36 | alter table PACKAGE_COUNT
37 | drop column CHANNEL;
38 |
39 | create index IDX_PACKAGE_COUNT__PACKAGE on PACKAGE_COUNT
40 | (CHANNEL_ID, PACKAGE);
41 |
42 | create index IDX_PACKAGE_COUNT__PACKAGE_PLATFORM on PACKAGE_COUNT
43 | (CHANNEL_ID, PACKAGE, PLATFORM);
44 |
--------------------------------------------------------------------------------
/server/api/index.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "strings"
7 |
8 | "private-conda-repo/api/dto"
9 | "private-conda-repo/config"
10 | )
11 |
12 | type IndexHandler struct {
13 | Conf *config.AppConfig
14 | }
15 |
16 | func (h *IndexHandler) HealthCheck() http.HandlerFunc {
17 | return func(w http.ResponseWriter, r *http.Request) {
18 | ok(w)
19 | }
20 | }
21 |
22 | func (h *IndexHandler) MetaInfo() http.HandlerFunc {
23 | return func(w http.ResponseWriter, r *http.Request) {
24 | domain := strings.Split(r.Host, ":")[0]
25 | schema := "http"
26 | if r.TLS != nil {
27 | schema = "https"
28 | }
29 |
30 | meta := dto.ApiMetaInfo{
31 | Indexer: h.Conf.Indexer.Type,
32 | Image: h.Conf.Indexer.ImageName,
33 | Registry: fmt.Sprintf("%s://%s:%d", schema, domain, h.Conf.AppServer.Port),
34 | Repository: fmt.Sprintf("%s://%s:%d", schema, domain, h.Conf.FileServer.Port),
35 | }
36 |
37 | toJson(w, &meta)
38 | }
39 | }
40 |
41 | func (h *IndexHandler) NotFound() http.HandlerFunc {
42 | return func(w http.ResponseWriter, r *http.Request) {
43 | http.Error(w, fmt.Sprintf("'%s' request for '%s' does not exist", r.Method, r.URL.String()), http.StatusNotFound)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/web/src/modules/Help/Layout/Menu/index.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from "@/infrastructure/hooks";
2 | import cx from "classnames";
3 | import React from "react";
4 | import { Link } from "react-router-dom";
5 |
6 | import styles from "./styles.less";
7 |
8 | const routes = {
9 | "Getting Started": {
10 | Overview: "/",
11 | Upload: "/upload",
12 | },
13 | };
14 |
15 | const Menu = () => {
16 | const pathname = useRouter().location.pathname.replace(/^\/help/, "");
17 |
18 | return (
19 |
20 | {Object.entries(routes).map(([title, links], i) => (
21 |
22 |
{title}
23 | {Object.entries(links).map(([name, link], j) => (
24 |
25 |
32 | {name}
33 |
34 |
35 | ))}
36 |
37 | ))}
38 |
39 | );
40 | };
41 |
42 | export default Menu;
43 |
--------------------------------------------------------------------------------
/web/src/libs/date.ts:
--------------------------------------------------------------------------------
1 | import moment, { Moment } from "moment";
2 |
3 | /**
4 | * Pretty formats the time since upload
5 | * @param timestamp milliseconds from unix epoch or Moment instance
6 | */
7 | export const timeSinceUpload = (timestamp: number | Moment) => {
8 | if (typeof timestamp === "number") timestamp = moment.unix(timestamp);
9 |
10 | const now = moment.utc();
11 | const duration = moment.duration(now.diff(timestamp));
12 |
13 | const values = [
14 | ["years", duration.years()],
15 | ["months", duration.months()],
16 | ["days", duration.days()],
17 | ["hours", duration.hours()],
18 | ]
19 | .filter(([_, v]) => v !== 0)
20 | .slice(0, 2) as [string, number][];
21 |
22 | switch (values.length) {
23 | case 0:
24 | return "Just now";
25 | case 1:
26 | const v = values[0];
27 | return `${formString(v[0], v[1])} ago`;
28 | default:
29 | const [first, second] = values,
30 | s1 = formString(first[0], first[1]),
31 | s2 = formString(second[0], second[1]);
32 | return `${s1} and ${s2} ago`;
33 | }
34 |
35 | function formString(unit: string, value: number) {
36 | if (value === 1) unit = unit.slice(0, -1);
37 | return `${value} ${unit}`;
38 | }
39 | };
40 |
--------------------------------------------------------------------------------
/cli/config/list.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/spf13/cobra"
8 | )
9 |
10 | func init() {
11 | listConfCmd.Flags().Bool("show", false, "Shows values of the keys when listing all keys")
12 | }
13 |
14 | var listConfCmd = &cobra.Command{
15 | Use: "list",
16 | Short: "List all configuration keys (and values)",
17 | Args: cobra.NoArgs,
18 | Example: "pcr config get ssl_verify",
19 | Run: func(cmd *cobra.Command, args []string) {
20 | handler := ListHandler{cmd: cmd}
21 |
22 | show, err := handler.cmd.Flags().GetBool("show")
23 | if err != nil {
24 | cmd.PrintErr(err)
25 | return
26 | }
27 | handler.ListAllKeys(show)
28 | },
29 | }
30 |
31 | type ListHandler struct {
32 | cmd *cobra.Command
33 | }
34 |
35 | func (h *ListHandler) ListAllKeys(show bool) {
36 | options := []string{
37 | sslVerify,
38 | }
39 |
40 | for i, key := range options {
41 | value, err := getValue(key)
42 | if err != nil {
43 | h.cmd.PrintErr(err)
44 | return
45 | }
46 |
47 | if show {
48 | options[i] = fmt.Sprintf("%-15s: %v", key, value)
49 | } else {
50 | options[i] = fmt.Sprintf("%-15s", key)
51 | }
52 |
53 | }
54 |
55 | h.cmd.Printf("Available Keys\n%s", strings.Join(options, "\n"))
56 | }
57 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/ChannelDetail/index.tsx:
--------------------------------------------------------------------------------
1 | import { PkgApi } from "@/features/package";
2 | import { RootState } from "@/infrastructure/rootState";
3 | import { Col, Row } from "antd";
4 | import React, { useEffect } from "react";
5 | import { useDispatch, useSelector } from "react-redux";
6 | import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
7 | import PackageList from "./PackageList";
8 | import Profile from "./Profile";
9 | import styles from "./styles.less";
10 |
11 | type Props = RouteComponentProps<{ channel: string }>;
12 |
13 | const ChannelDetail = ({
14 | match: {
15 | params: { channel },
16 | },
17 | }: Props) => {
18 | const dispatch = useDispatch();
19 |
20 | useEffect(() => {
21 | dispatch(PkgApi.fetchChannelPackages(channel));
22 | }, [channel, dispatch]);
23 |
24 | const failure = useSelector(
25 | (s: RootState) => s.pkg.loading.channelPackages === "FAILURE"
26 | );
27 |
28 | if (failure) {
29 | return ;
30 | }
31 |
32 | return (
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 | };
45 |
46 | export default withRouter(ChannelDetail);
47 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Login/hooks.ts:
--------------------------------------------------------------------------------
1 | import { ChnApi } from "@/features/channel";
2 | import { ThunkDispatchAsync } from "@/infrastructure/api";
3 | import { push } from "connected-react-router";
4 | import React, { useContext } from "react";
5 | import { useDispatch } from "react-redux";
6 | import { Credential, State, useLoginReducer } from "./reducer";
7 |
8 | export const LoginContext = React.createContext<{
9 | state: State;
10 | dispatch: ReturnType[1];
11 | }>({
12 | state: {} as any,
13 | dispatch: (_) => {},
14 | });
15 |
16 | export const useLoginContext = () => useContext(LoginContext);
17 |
18 | export const useStatus = (field: keyof Credential) => {
19 | const { pristine, errors, valid } = useLoginContext().state;
20 |
21 | if (pristine[field]) return "";
22 | if (!valid || errors[field].length > 0) return "error";
23 | return "success";
24 | };
25 |
26 | export const useSubmit = () => {
27 | const thunkDispatch = useDispatch() as ThunkDispatchAsync;
28 | const {
29 | state: { username, password },
30 | dispatch,
31 | } = useLoginContext();
32 |
33 | return async () => {
34 | const valid = await thunkDispatch(ChnApi.validateChannel(username, password));
35 | if (valid) {
36 | thunkDispatch(push("/"));
37 | } else {
38 | dispatch({ type: "SET_VALID", payload: { valid: false } });
39 | }
40 | };
41 | };
42 |
--------------------------------------------------------------------------------
/server/infrastructure/database/postgres/channel.go:
--------------------------------------------------------------------------------
1 | package postgres
2 |
3 | import (
4 | "errors"
5 | "strings"
6 |
7 | "private-conda-repo/domain/entity"
8 | )
9 |
10 | func (p *Postgres) CreateChannel(channel, password, email string) (*entity.Channel, error) {
11 | chn := entity.NewChannel(channel, password, email)
12 |
13 | if errs := p.db.Create(chn).GetErrors(); len(errs) > 0 {
14 | return nil, joinErrors(errs)
15 | }
16 |
17 | return chn, nil
18 | }
19 |
20 | func (p *Postgres) GetAllChannels() ([]*entity.Channel, error) {
21 | var channels []*entity.Channel
22 | if errs := p.db.Find(&channels).GetErrors(); len(errs) > 0 {
23 | return nil, joinErrors(errs)
24 | }
25 | return channels, nil
26 | }
27 |
28 | func (p *Postgres) GetChannel(channel string) (*entity.Channel, error) {
29 | var chn entity.Channel
30 | if errs := p.db.
31 | Where("channel = ?", strings.ToLower(channel)).
32 | First(&chn).
33 | GetErrors(); len(errs) > 0 {
34 | return nil, joinErrors(errs)
35 | }
36 |
37 | return &chn, nil
38 | }
39 |
40 | func (p *Postgres) RemoveChannel(id int) error {
41 | if id <= 0 {
42 | return errors.New("invalid channel id")
43 | }
44 |
45 | var channel entity.Channel
46 | err := p.db.First(&channel, id).Error
47 | if err != nil {
48 | return err
49 | }
50 |
51 | errs := p.db.Delete(&channel).GetErrors()
52 | if len(errs) > 0 {
53 | return joinErrors(errs)
54 | }
55 |
56 | return nil
57 | }
58 |
--------------------------------------------------------------------------------
/server/infrastructure/database/postgres/package_count_test.go:
--------------------------------------------------------------------------------
1 | package postgres_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/dhui/dktest"
7 | "github.com/stretchr/testify/require"
8 |
9 | "private-conda-repo/domain/entity"
10 | )
11 |
12 | func TestPostgres_PackageCountOperations(t *testing.T) {
13 | assert := require.New(t)
14 | packageName := "perfana"
15 | platform := "noarch"
16 |
17 | dktest.Run(t, imageName, postgresImageOptions, func(t *testing.T, info dktest.ContainerInfo) {
18 | store, err := newTestDb(info)
19 | assert.NoError(err)
20 |
21 | chn, err := store.CreateChannel("counts-channel", "password", "salt")
22 | assert.NoError(err)
23 |
24 | counts, err := store.GetPackageCounts(chn.Id, packageName)
25 | assert.NoError(err)
26 | assert.Len(counts, 0)
27 |
28 | pkg, err := store.CreatePackageCount(&entity.PackageCount{
29 | ChannelId: chn.Id,
30 | Package: packageName,
31 | BuildString: "py",
32 | BuildNumber: 0,
33 | Version: "0.0.1",
34 | Platform: platform,
35 | })
36 | assert.NoError(err)
37 | assert.Equal(0, pkg.Count)
38 |
39 | pkg, err = store.IncreasePackageCount(pkg)
40 | assert.NoError(err)
41 | assert.EqualValues(1, pkg.Count)
42 |
43 | counts, err = store.GetPackageCounts(chn.Id, packageName)
44 | assert.NoError(err)
45 | assert.Len(counts, 1)
46 |
47 | err = store.RemovePackageCount(pkg)
48 | assert.NoError(err)
49 | })
50 | }
51 |
--------------------------------------------------------------------------------
/web/src/modules/App/Layout/Header/Manager.tsx:
--------------------------------------------------------------------------------
1 | import { ChnApi, ChnSelector } from "@/features/channel";
2 | import UserOutlined from "@ant-design/icons/UserOutlined";
3 | import { Menu } from "antd";
4 | import React from "react";
5 | import { useDispatch, useSelector } from "react-redux";
6 | import { Link } from "react-router-dom";
7 | import styles from "./styles.less";
8 |
9 | export default () => {
10 | const user = useSelector(ChnSelector.channelInfo);
11 | const dispatch = useDispatch();
12 | const validated = user.validated;
13 |
14 | return (
15 |
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Register/ChannelInput.tsx:
--------------------------------------------------------------------------------
1 | import { ChnAction, ChnApi } from "@/features/channel";
2 | import { Form, Input } from "antd";
3 | import React from "react";
4 | import { useDispatch } from "react-redux";
5 | import { useDebouncedCallback } from "use-debounce";
6 | import Errors from "./Errors";
7 | import { useDetails, useDisabled } from "./utils";
8 |
9 | const ChannelInput = () => {
10 | const dispatch = useDispatch();
11 | const disabled = useDisabled();
12 | const [channel, errors, status] = useDetails("channel");
13 | const [checkAvailability] = useDebouncedCallback((name: string) => {
14 | if (name.length < 2) return;
15 | dispatch(ChnApi.isChannelAvailable(name));
16 | }, 500);
17 |
18 | return (
19 | }
23 | >
24 | {
29 | channel = channel.trim().toLowerCase();
30 | dispatch(ChnAction.updateForm({ channel }));
31 | checkAvailability(channel);
32 | }}
33 | onKeyPress={(e) => {
34 | if (e.key === "Enter" && !disabled) dispatch(ChnApi.createChannel());
35 | }}
36 | />
37 |
38 | );
39 | };
40 |
41 | export default ChannelInput;
42 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/ChannelDetail/PackageList.tsx:
--------------------------------------------------------------------------------
1 | import { PkgSelector } from "@/features/package";
2 | import { timeSinceUpload } from "@/libs/date";
3 | import Logo from "@/resource/conda.svg";
4 | import QuestionCircleOutlined from "@ant-design/icons/QuestionCircleOutlined";
5 | import { Card, List } from "antd";
6 | import React from "react";
7 | import { useSelector } from "react-redux";
8 | import { Link } from "react-router-dom";
9 | import styles from "./styles.less";
10 |
11 | export default () => {
12 | const { channel, packages } = useSelector(PkgSelector.channelPackages);
13 |
14 | return (
15 |
18 |
22 |
23 |
24 | Packages
25 |
26 | }
27 | className={styles.card}
28 | >
29 | (
33 |
34 |
35 | {item.name}
36 | Updated {timeSinceUpload(item.timestamp)}
37 |
38 | )}
39 | />
40 |
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/server/domain/condatypes/channeldata.go:
--------------------------------------------------------------------------------
1 | package condatypes
2 |
3 | type ChannelData struct {
4 | ChannelDataVersion int `json:"channeldata_version"`
5 | Packages map[string]PackageData `json:"packages"`
6 | Subdirs []string `json:"subdirs"`
7 | }
8 |
9 | type PackageData struct {
10 | Subdirs []string `json:"subdirs"`
11 | Version *string `json:"version"`
12 | ActivateD bool `json:"activate.d"`
13 | BinaryPrefix bool `json:"binary_prefix"`
14 | DeactivateD bool `json:"deactivate.d"`
15 | Description *string `json:"description"`
16 | DevUrl *string `json:"dev_url"`
17 | DocSourceUrl *string `json:"doc_source_url"`
18 | DocUrl *string `json:"doc_url"`
19 | Home *string `json:"home"`
20 | IconHash *string `json:"icon_hash"`
21 | IconUrl *string `json:"icon_url"`
22 | Identifiers *string `json:"identifiers"`
23 | Keywords []string `json:"keywords"`
24 | License *string `json:"license"`
25 | PostLink bool `json:"post_link"`
26 | PreLink bool `json:"pre_link"`
27 | PreUnlink bool `json:"pre_unlink"`
28 | RecipeOrigin *string `json:"recipe_origin"`
29 | SourceGitUrl *string `json:"source_git_url"`
30 | SourceUrl *string `json:"source_url"`
31 | Summary *string `json:"summary"`
32 | Tags []string `json:"tags"`
33 | TextPrefix bool `json:"text_prefix"`
34 | Timestamp uint64 `json:"timestamp"`
35 | }
36 |
--------------------------------------------------------------------------------
/server/infrastructure/conda/index/repofix_test.go:
--------------------------------------------------------------------------------
1 | package index_test
2 |
3 | import (
4 | "path/filepath"
5 | "runtime"
6 | "strings"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/require"
10 |
11 | "private-conda-repo/infrastructure/conda/index"
12 | )
13 |
14 | func TestFixRepoData(t *testing.T) {
15 | assert := require.New(t)
16 |
17 | _, file, _, _ := runtime.Caller(0)
18 | file = filepath.Join(filepath.Dir(file), "repodata.json")
19 |
20 | data, hasChanges, err := index.FixRepoData(file, []string{"abi"})
21 | assert.NoError(err)
22 | assert.True(hasChanges)
23 |
24 | for _, p := range data.Packages {
25 | for _, d := range p.Depends {
26 | // check that abi is removed
27 | assert.False(strings.HasPrefix(strings.ToLower(d), "python_abi"))
28 | }
29 | }
30 | }
31 |
32 | func TestFixRepoData_NoFixes(t *testing.T) {
33 | assert := require.New(t)
34 |
35 | _, file, _, _ := runtime.Caller(0)
36 | file = filepath.Join(filepath.Dir(file), "repodata.json")
37 |
38 | data, hasChanges, err := index.FixRepoData(file, nil)
39 | assert.NoError(err)
40 | assert.False(hasChanges)
41 |
42 | atLeastOnePackageHasPythonAbi := false
43 | for _, p := range data.Packages {
44 | for _, d := range p.Depends {
45 | // check that at least one package hsa python abi
46 |
47 | if strings.HasPrefix(strings.ToLower(d), "python_abi") {
48 | atLeastOnePackageHasPythonAbi = true
49 | break
50 | }
51 | }
52 | }
53 | assert.True(atLeastOnePackageHasPythonAbi)
54 | }
55 |
--------------------------------------------------------------------------------
/cli/registry/root.go:
--------------------------------------------------------------------------------
1 | package registry
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/spf13/cobra"
8 |
9 | "cli/config"
10 | )
11 |
12 | // RootCmd represents the base command when called without any subcommands
13 | var RootCmd = &cobra.Command{
14 | Use: "registry",
15 | Short: "Logs the user into the system",
16 | Long: `Logs the user into the system. This will raise an
17 | error if the private conda repository's url is not set.`,
18 | Run: func(cmd *cobra.Command, args []string) {
19 | conf := config.New()
20 | registry := conf.Registry
21 |
22 | if len(args) > 0 {
23 | c := strings.Join(args, " ")
24 | cmd.Printf("%s is not a valid command", c)
25 | return
26 | }
27 |
28 | if registry == "" {
29 | registry = ""
30 | }
31 |
32 | channel := conf.Channel.Channel
33 | if channel == "" {
34 | channel = ""
35 | }
36 |
37 | cmd.Println(strings.TrimSpace(fmt.Sprintf(`
38 | CLI Registry details:
39 | Registry: %s
40 | Channel : %s
41 |
42 | Use "%s registry --help" for more information.
43 |
44 | Registry should be the api server that is used to create and add channel.
45 | Usually this is https://:5060. Remember to set it with the "set"
46 | command.
47 | `, registry, channel, strings.Split(cmd.CommandPath(), " ")[0])))
48 | },
49 | }
50 |
51 | func init() {
52 | RootCmd.AddCommand(setCmd, loginCmd, logoutCmd, registerCmd)
53 | }
54 |
--------------------------------------------------------------------------------
/server/infrastructure/database/postgres/channel_test.go:
--------------------------------------------------------------------------------
1 | package postgres_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/dhui/dktest"
7 | "github.com/jinzhu/gorm"
8 | "github.com/stretchr/testify/require"
9 |
10 | "private-conda-repo/domain/entity"
11 | )
12 |
13 | func TestPostgres_ChannelOperations(t *testing.T) {
14 | name := "daniel"
15 | password := "Password123"
16 | email := "daniel@gmail.com"
17 |
18 | dktest.Run(t, imageName, postgresImageOptions, func(t *testing.T, info dktest.ContainerInfo) {
19 | assert := require.New(t)
20 | store, err := newTestDb(info)
21 | assert.NoError(err)
22 |
23 | chn, err := store.GetChannel(name)
24 | assert.EqualError(err, gorm.ErrRecordNotFound.Error())
25 |
26 | chn, err = store.CreateChannel(name, password, email)
27 | assert.NoError(err)
28 | assert.IsType(*chn, entity.Channel{})
29 | assert.Equal(chn.Channel, name)
30 | assert.NotEqual(chn.Password, password)
31 | assert.True(chn.HasValidPassword(password))
32 | assert.False(chn.HasValidPassword(password + "abc"))
33 |
34 | chn2, err := store.GetChannel(name)
35 | assert.NoError(err)
36 | assert.Equal(chn.Id, chn2.Id)
37 |
38 | err = store.RemoveChannel(0)
39 | assert.Error(err, "invalid name id")
40 |
41 | err = store.RemoveChannel(99999)
42 | assert.Error(err, "name with id does not exist")
43 |
44 | err = store.RemoveChannel(chn.Id)
45 | assert.NoError(err)
46 |
47 | chn, err = store.GetChannel(name)
48 | assert.Error(err)
49 | assert.Nil(chn)
50 | })
51 | }
52 |
--------------------------------------------------------------------------------
/cli/config/set.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/pkg/errors"
7 | "github.com/spf13/cobra"
8 | )
9 |
10 | var setConfCmd = &cobra.Command{
11 | Use: "set",
12 | Short: "Sets configuration values",
13 | Args: cobra.RangeArgs(1, 2),
14 | Example: "pcr config set ssl_verify true",
15 | Run: func(cmd *cobra.Command, args []string) {
16 | if len(args) == 1 {
17 | args = strings.SplitN(args[0], "=", 2)
18 | }
19 |
20 | if len(args) != 2 {
21 | cmd.PrintErr("set values must be pairs or separated by '='. Example, 'ssl_verify=true' or 'ssl_verify true'")
22 | return
23 | }
24 |
25 | handler := SetHandler{cmd: cmd}
26 | conf, err := handler.Set(args[0], args[1])
27 | if err != nil {
28 | cmd.PrintErr(err)
29 | return
30 | }
31 | conf.Save()
32 | },
33 | }
34 |
35 | type SetHandler struct {
36 | cmd *cobra.Command
37 | }
38 |
39 | func (s *SetHandler) Set(key, value string) (*Config, error) {
40 | key = strings.ToLower(strings.TrimSpace(key))
41 | value = strings.ToLower(strings.TrimSpace(value))
42 | conf := New()
43 |
44 | err := func() (*Config, error) {
45 | return nil, errors.Errorf("Invalid option for %s: %s", key, value)
46 | }
47 |
48 | switch key {
49 | case sslVerify:
50 | switch value {
51 | case "true", "t", "1":
52 | conf.SslVerify = true
53 | case "false", "f", "0":
54 | conf.SslVerify = false
55 | default:
56 | return err()
57 | }
58 | default:
59 | return nil, errors.Errorf("Invalid option: %s", key)
60 | }
61 | return conf, nil
62 | }
63 |
--------------------------------------------------------------------------------
/web/src/modules/Help/pages/Overview/overview.md:
--------------------------------------------------------------------------------
1 | # Overview
2 |
3 | The Private Conda Repository is a place to host your fantastic conda packages.
4 |
5 | ## Register an account
6 |
7 | To get started, register an account with the GUI. The account name will also be your
8 | channel name. Once you're done, you can start uploading and sharing your packages.
9 |
10 | ## CLI tool
11 |
12 | A CLI tool will be available for each release. You can download the CLI
13 | [here](https://github.com/DanielBok/private-conda-repo/releases).
14 |
15 | Assuming you saved your tool as `PCR` in your path
16 |
17 | ### Using the CLI tool
18 |
19 | To start using the CLI tool, you must first set the registry and login.
20 | To register your console with the API server:
21 |
22 | ```sh
23 | pcr registry set @registry
24 | ```
25 |
26 | Subsequently, login to your console via
27 |
28 | ```bash
29 | pcr registry login # you will be prompted for your username and password here
30 | ```
31 |
32 | ### Uploading packages
33 |
34 | ```sh
35 | pcr upload path/to/your/package.tar.bz2
36 | ```
37 |
38 | Please note that your package must be in the `.tar.bz2` format. If you built your
39 | package via `conda-build`, this will be the default format.
40 |
41 | ## More documentation
42 |
43 | Underneath the hood, the CLI tool makes a couple of API calls to the server.
44 |
45 | The full list of the available API is documented [here](https://github.com/DanielBok/private-conda-repo/tree/master/server).
46 | These APIs can be used in the event that you want to build your own CI/CD process
47 | for package publishing.
48 |
--------------------------------------------------------------------------------
/server/config.yaml:
--------------------------------------------------------------------------------
1 | admin:
2 | username: "admin"
3 | password: "password"
4 |
5 | indexer:
6 | # 'type' can only be 'shell' or 'docker'. Using 'shell' means that PCR will use a shell
7 | # version of conda to index the channels, using 'docker' will use a dockerized-conda instance
8 | # to index the channels. Use 'shell' when running the server application in a dockerized
9 | # container, like in docker-compose. This is because it is not easy to have a docker container
10 | # run another docker container whilst setting up the volumes for the second docker container.
11 | type: shell
12 | # if using docker, the image name and mount folder options can be specified
13 | image_name: danielbok/conda-repo-mgr
14 | # This is the folder path on the host where the conda channels and packages are stored
15 | # leave empty for the application to automatically set path based on OS
16 | mount_folder: ""
17 | # if True, the application will attempt to update the conda-build version on the shell or pull
18 | # the latest indexer docker image on application startup
19 | update: False
20 |
21 | db:
22 | host: postgres
23 | port: 5432
24 | user: user
25 | password: password
26 | dbname: pcrdb
27 | # if true, automatically migrates the database to the latest schema. Otherwise, migration has to
28 | # be handled by the user manually
29 | auto_migrate: True
30 |
31 | fileserver:
32 | port: 5050
33 |
34 | api:
35 | port: 5060
36 |
37 | # if valid key and cert files are provided, server runs in HTTPS mode automatically
38 | tls:
39 | cert: "" # path to TLS certificate (public key)
40 | key: "" # path to TLS key (private key)
41 |
--------------------------------------------------------------------------------
/server/api/index_test.go:
--------------------------------------------------------------------------------
1 | package api_test
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "net/http/httptest"
8 | "testing"
9 |
10 | "github.com/stretchr/testify/require"
11 |
12 | . "private-conda-repo/api"
13 | "private-conda-repo/api/dto"
14 | "private-conda-repo/config"
15 | )
16 |
17 | func NewIndexHandler() *IndexHandler {
18 | return &IndexHandler{
19 | Conf: &config.AppConfig{
20 | Admin: nil,
21 | Indexer: &config.IndexerConfig{
22 | Type: "shell",
23 | ImageName: "",
24 | },
25 | DB: nil,
26 | FileServer: &config.ServerConfig{Port: 5050},
27 | AppServer: &config.ServerConfig{Port: 5060},
28 | },
29 | }
30 | }
31 |
32 | func TestIndexHandler_HealthCheck(t *testing.T) {
33 | assert := require.New(t)
34 | handler := NewIndexHandler()
35 |
36 | w := httptest.NewRecorder()
37 | r := httptest.NewRequest("GET", "/healthcheck", nil)
38 |
39 | handler.HealthCheck()(w, r)
40 | assert.Equal(http.StatusOK, w.Code)
41 | }
42 |
43 | func TestIndexHandler_MetaInfo(t *testing.T) {
44 | assert := require.New(t)
45 | handler := NewIndexHandler()
46 |
47 | w := httptest.NewRecorder()
48 | r := httptest.NewRequest("GET", "/meta", nil)
49 |
50 | handler.MetaInfo()(w, r)
51 | assert.Equal(http.StatusOK, w.Code)
52 |
53 | var meta dto.ApiMetaInfo
54 | err := json.NewDecoder(w.Body).Decode(&meta)
55 | assert.NoError(err)
56 |
57 | formURL := func(port int) string {
58 | return fmt.Sprintf("http://%s:%d", r.Host, port)
59 | }
60 |
61 | assert.Equal(dto.ApiMetaInfo{
62 | Indexer: "shell",
63 | Image: "",
64 | Registry: formURL(5060),
65 | Repository: formURL(5050),
66 | }, meta)
67 | }
68 |
--------------------------------------------------------------------------------
/web/src/features/package/types.ts:
--------------------------------------------------------------------------------
1 | import { Moment } from "moment";
2 |
3 | export type Store = {
4 | packages: PackageMetaInfo[];
5 | loading: {
6 | packages: LoadingState;
7 | details: LoadingState;
8 | channelPackages: LoadingState;
9 | };
10 | packageDetail: PackageDetail;
11 | channelPackages: ChannelPackages;
12 | };
13 |
14 | export type Platform = "noarch" | "win-64" | "osx-64" | "linux-64";
15 |
16 | export type ChannelPackages = {
17 | channel: string;
18 | email: string;
19 | joinDate: T;
20 | packages: PackageMetaInfo[];
21 | };
22 |
23 | export type PackageMetaInfo = {
24 | channel: string;
25 | platforms: Platform[];
26 | version: string | null;
27 | description: string | null;
28 | devUrl: string | null;
29 | docUrl: string | null;
30 | home: string | null;
31 | license: string | null;
32 | summary: string | null;
33 | timestamp: number;
34 | name: string;
35 | };
36 |
37 | export type PackageDetail = {
38 | channel: string;
39 | package: string;
40 | details: PackageCountInfo[];
41 | latest: PackageMetaInfo;
42 | };
43 |
44 | export type PackageCountInfo = {
45 | channelId: number;
46 | package: string;
47 | buildString: string;
48 | buildNumber: number;
49 | version: string;
50 | platform: string;
51 | count: number;
52 | uploadDate: T;
53 | };
54 |
55 | export type RemovePackagePayload = {
56 | channel: string;
57 | password: string;
58 | package: {
59 | name: string;
60 | version: string;
61 | buildString: string;
62 | buildNumber: number;
63 | platform: string;
64 | };
65 | };
66 |
--------------------------------------------------------------------------------
/web/src/components/ErrorBoundary/index.tsx:
--------------------------------------------------------------------------------
1 | import { push } from "connected-react-router";
2 | import React, { Component } from "react";
3 | import { connect } from "react-redux";
4 | import { RouteComponentProps, withRouter } from "react-router";
5 | import { Dispatch } from "redux";
6 | import ErrorPage from "./ErrorPage";
7 |
8 | type State = {
9 | hasError: boolean;
10 | };
11 |
12 | type Props = RouteComponentProps & {
13 | errorRoute?: string;
14 | goto: (path: string) => void;
15 | children: React.ReactNode;
16 | };
17 |
18 | class ErrorBoundary extends Component {
19 | constructor(props: Props) {
20 | super(props);
21 |
22 | this.state = {
23 | hasError: false,
24 | };
25 |
26 | props.history.listen(() => {
27 | if (this.state.hasError) {
28 | this.setState({ hasError: false });
29 | }
30 | });
31 | }
32 |
33 | static getDerivedStateFromError(error: Error) {
34 | return { hasError: typeof error === "object" };
35 | }
36 |
37 | componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
38 | if (process.env.NODE_ENV === "development") {
39 | console.error(error);
40 | console.error(errorInfo);
41 | }
42 |
43 | if (typeof this.props.errorRoute === "string") {
44 | this.props.goto(this.props.errorRoute);
45 | }
46 | }
47 |
48 | render() {
49 | if (this.state.hasError) {
50 | return ;
51 | }
52 | return this.props.children;
53 | }
54 | }
55 |
56 | const mapDispatchToProps = (dispatch: Dispatch) => ({
57 | goto: (path: string) => dispatch(push(path)),
58 | });
59 |
60 | export default connect(null, mapDispatchToProps)(withRouter(ErrorBoundary));
61 |
--------------------------------------------------------------------------------
/server/infrastructure/conda/index/shell.go:
--------------------------------------------------------------------------------
1 | package index
2 |
3 | import (
4 | "os/exec"
5 | "path/filepath"
6 | "regexp"
7 | "strings"
8 |
9 | "github.com/pkg/errors"
10 | log "github.com/sirupsen/logrus"
11 | )
12 |
13 | type ShellIndex struct {
14 | }
15 |
16 | func NewShellIndex() (*ShellIndex, error) {
17 | if version, err := exec.Command("conda", "--version").Output(); err != nil {
18 | return nil, errors.Wrapf(err, "conda not installed")
19 | } else {
20 | log.Printf("Using: %s", version)
21 | }
22 |
23 | return &ShellIndex{}, nil
24 | }
25 |
26 | func (s *ShellIndex) Index(dir string) error {
27 | cmd := []string{"index", dir}
28 |
29 | if _, err := exec.Command("conda", cmd...).Output(); err != nil {
30 | return errors.Wrapf(err, "could not index channel '%s'", filepath.Base(dir))
31 | }
32 |
33 | return nil
34 | }
35 | func (s *ShellIndex) Update() error {
36 | exists, err := s.condaBuildExist()
37 | if err != nil {
38 | return errors.Wrap(err, " could not install conda build")
39 | }
40 |
41 | if !exists {
42 | return s.installCondaBuild()
43 | }
44 |
45 | return nil
46 | }
47 |
48 | func (s *ShellIndex) condaBuildExist() (bool, error) {
49 | cmd := []string{"list", "-f", "conda-build"}
50 | output, err := exec.Command("conda", cmd...).CombinedOutput()
51 | if err != nil {
52 | return false, errors.Wrapf(err, "could not execute conda command. Is it installed?")
53 | }
54 |
55 | return regexp.MatchString("conda-build", strings.TrimSpace(string(output)))
56 | }
57 |
58 | func (s *ShellIndex) installCondaBuild() error {
59 | cmd := []string{"install", "-y", "conda-build"}
60 | if _, err := exec.Command("conda", cmd...).Output(); err != nil {
61 | return errors.Wrap(err, "could not install conda-build package")
62 | }
63 | return nil
64 | }
65 |
--------------------------------------------------------------------------------
/server/api/dto/channel_test.go:
--------------------------------------------------------------------------------
1 | package dto_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 |
8 | . "private-conda-repo/api/dto"
9 | )
10 |
11 | func TestChannelDto_IsValid(t *testing.T) {
12 | t.Parallel()
13 |
14 | goodEmail := "daniel@gmail.com"
15 | goodPassword := "good" // Must be >= 2 characters
16 | goodUsername := "good" // Must be >= 4 characters
17 |
18 | tests := []struct {
19 | Channel ChannelDto
20 | HasError bool
21 | Message string
22 | }{
23 | {
24 | ChannelDto{
25 | Channel: "b", // too short a name
26 | Password: goodPassword,
27 | Email: goodEmail,
28 | },
29 | true,
30 | "channel name is too short",
31 | },
32 | {
33 | ChannelDto{
34 | Channel: "A-really-long-name-with-valid-characters-but-is-more-than-the-limit-of-50-characters",
35 | Password: goodPassword,
36 | Email: goodEmail,
37 | },
38 | true,
39 | "channel name is more than 50 characters (too long)",
40 | },
41 | {
42 | ChannelDto{
43 | Channel: goodUsername,
44 | Password: "bad",
45 | Email: goodEmail,
46 | },
47 | true,
48 | "password is too short",
49 | },
50 | {
51 | ChannelDto{
52 | Channel: goodUsername,
53 | Password: goodPassword,
54 | Email: "badEmail",
55 | },
56 | true,
57 | "email is invalid",
58 | },
59 | {
60 | ChannelDto{
61 | Channel: goodUsername,
62 | Password: goodPassword,
63 | Email: goodEmail,
64 | },
65 | false,
66 | "should not fail as DTO is valid",
67 | },
68 | }
69 |
70 | for _, test := range tests {
71 | err := test.Channel.IsValid()
72 | if test.HasError {
73 | require.Error(t, err, test.Message)
74 | } else {
75 | require.NoError(t, err, test.Message)
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/web/src/modules/Help/pages/Upload/index.tsx:
--------------------------------------------------------------------------------
1 | import Markdown from "@/components/Markdown";
2 | import { useRootSelector } from "@/infrastructure/hooks";
3 | import Layout from "@/modules/Help/Layout";
4 | import { Divider } from "antd";
5 | import { reduce } from "lodash";
6 | import React from "react";
7 | import { CopyBlock, dracula } from "react-code-blocks";
8 | import Content from "./upload.md";
9 |
10 | const useOverrides = () => {
11 | const registry = useRootSelector((s) => s.meta.registry);
12 |
13 | return {
14 | Divider,
15 | BasicRequest: function BasicRequest() {
16 | return (
17 |
18 | alert(resp.data))
29 | .catch((e) => console.error(e));
30 | `
31 | .trim()
32 | .replace("@url", registry)}
33 | showLineNumbers={true}
34 | theme={dracula}
35 | wrapLines={true}
36 | codeBlock
37 | />
38 |
39 | );
40 | },
41 | };
42 | };
43 |
44 | const Upload = () => {
45 | const overrides = useOverrides();
46 | const { protocol, host } = window.location;
47 |
48 | const content = reduce(
49 | {
50 | "@link": `${protocol}//${host}/upload`,
51 | },
52 | (acc, search, replace) => acc.replace(search, replace),
53 | Content
54 | );
55 |
56 | return (
57 |
58 |
59 |
60 | );
61 | };
62 |
63 | export default Upload;
64 |
--------------------------------------------------------------------------------
/web/types/markdown-to-jsx.d.ts:
--------------------------------------------------------------------------------
1 | declare module "markdown-to-jsx" {
2 | export interface MarkdownOptions {
3 | // Forces all string to be "blocks" - header like objects
4 | forceBlock?: boolean;
5 |
6 | // The inverse of forceBlock. Forces all strings to be rendered as strings, no indents
7 | forceInline?: boolean;
8 |
9 | // Overrides any React JSX like objects in the markdown with the actual component
10 | overrides?: {
11 | [component: string]:
12 | | {
13 | component: React.ReactNode;
14 | props?: object;
15 | }
16 | | React.ReactNode;
17 | };
18 |
19 | // By default, a lightweight deburring function is used to generate an HTML id from headings.
20 | // You can override this by passing a function to options.slugify. This is helpful when you are
21 | // using non-alphanumeric characters (e.g. Chinese or Japanese characters) in headings.
22 | slugify?: (id: string) => string;
23 |
24 | // By default only a couple of named html codes are converted to unicode characters. These include
25 | // characters such as &, ', >, <,   and " which have special meaning in HTML or other context.
26 | // Some projects require to extend this map of named codes and unicode characters.
27 | // To customize this list with additional html codes pass the option namedCodesToUnicode
28 | // as object with the code names needed as in the example below:
29 | namedCodesToUnicode?: {
30 | [character: string]: string;
31 | };
32 | }
33 |
34 | export interface MarkdownProps {
35 | options?: MarkdownOptions;
36 | }
37 |
38 | export function compiler(
39 | markdown: string,
40 | options?: MarkdownOptions
41 | ): React.ReactNode;
42 |
43 | export default class Markdown extends React.PureComponent {}
44 | }
45 |
--------------------------------------------------------------------------------
/server/domain/entity/channel.go:
--------------------------------------------------------------------------------
1 | package entity
2 |
3 | import (
4 | "crypto/rand"
5 | "crypto/sha256"
6 | "fmt"
7 | "strings"
8 | "time"
9 |
10 | "github.com/pkg/errors"
11 | )
12 |
13 | type Channel struct {
14 | Id int `json:"id"`
15 | Channel string `json:"channel"`
16 | Password string `json:"password,omitempty"`
17 | Email string `json:"email"`
18 | CreatedOn time.Time `json:"createdOn"`
19 |
20 | PackageCounts []PackageCount `json:"package_counts"`
21 | }
22 |
23 | const (
24 | saltLen = 8
25 | joinKey = ";;;;"
26 | )
27 |
28 | func (c *Channel) HasValidPassword(password string) bool {
29 | salt := strings.Split(c.Password, joinKey)[1]
30 | return hashPassword(password, salt) == c.Password
31 | }
32 |
33 | // This is the proper way to change the password. Setting the password directly on the struct field
34 | // does not hash it. Meaning that subsequently, when checking if the password is valid, the check
35 | // will fail since the check will hash the incoming password.
36 | func (c *Channel) SetPassword(password string) {
37 | salt := generateSalt()
38 | c.Password = hashPassword(password, salt)
39 | }
40 |
41 | func NewChannel(name, password, email string) *Channel {
42 | c := &Channel{
43 | Channel: strings.ToLower(strings.TrimSpace(name)),
44 | Email: strings.TrimSpace(strings.ToLower(email)),
45 | CreatedOn: time.Now().UTC(),
46 | }
47 | c.SetPassword(password)
48 |
49 | return c
50 | }
51 |
52 | func hashPassword(plainPassword, salt string) string {
53 | h := sha256.New()
54 | h.Write([]byte(plainPassword + salt))
55 | p := fmt.Sprintf("%x", h.Sum(nil))[:52]
56 |
57 | return p + joinKey + salt
58 | }
59 |
60 | func generateSalt() string {
61 | b := make([]byte, saltLen)
62 | _, err := rand.Read(b)
63 | if err != nil {
64 | panic(errors.Wrap(err, "This should not happen!"))
65 | }
66 |
67 | str := fmt.Sprintf("%x", b)
68 | return str[:saltLen]
69 | }
70 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/index.tsx:
--------------------------------------------------------------------------------
1 | import { PkgApi } from "@/features/package";
2 | import { RootState } from "@/infrastructure/rootState";
3 | import { Tabs } from "antd";
4 | import React, { useEffect, useState } from "react";
5 | import { useDispatch, useSelector } from "react-redux";
6 | import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
7 | import Files from "./Files";
8 | import Header from "./Header";
9 | import { PackageContext } from "./hooks";
10 | import PackageInfo from "./PackageInfo";
11 | import styles from "./styles.less";
12 | import { MatchParams } from "./types";
13 |
14 | const { TabPane } = Tabs;
15 |
16 | type Props = RouteComponentProps;
17 |
18 | const PackageDetail = ({
19 | match: {
20 | params: { channel, pkg },
21 | },
22 | }: Props) => {
23 | const dispatch = useDispatch();
24 | const [tab, setTab] = useState<"conda" | "files">("conda");
25 |
26 | useEffect(() => {
27 | dispatch(PkgApi.fetchPackageDetail(channel, pkg));
28 | }, [channel, pkg, dispatch]);
29 |
30 | const failure = useSelector(
31 | (s: RootState) => s.pkg.loading.details === "FAILURE"
32 | );
33 |
34 | if (failure) {
35 | return ;
36 | }
37 |
38 | return (
39 |
40 |
41 |
42 |
setTab(e as typeof tab)}
45 | className={styles.tabBar}
46 | tabBarStyle={{
47 | backgroundColor: "rgba(63,165,39,.3)",
48 | }}
49 | >
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | );
60 | };
61 |
62 | export default withRouter(PackageDetail);
63 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/PackageInfo/InstallationGuide/PlatformGuide.tsx:
--------------------------------------------------------------------------------
1 | import { PkgSelector } from "@/features/package";
2 | import AndroidOutlined from "@ant-design/icons/AndroidOutlined";
3 | import AppleOutlined from "@ant-design/icons/AppleOutlined";
4 | import WindowsOutlined from "@ant-design/icons/WindowsOutlined";
5 | import React from "react";
6 | import { useSelector } from "react-redux";
7 | import styles from "./styles.less";
8 |
9 | export default () => {
10 | const { latest } = useSelector(PkgSelector.packageDetail);
11 |
12 | return (
13 |
14 |
15 | {latest.version}
16 |
17 | );
18 | };
19 |
20 | const PlatformTags = () => {
21 | const order = {
22 | windows: 2,
23 | apple: 1,
24 | android: 3,
25 | noarch: 4,
26 | };
27 |
28 | const platforms = usePlatforms()
29 | .map((e) => {
30 | switch (e) {
31 | case "win-64":
32 | return "windows";
33 | case "osx-64":
34 | return "apple";
35 | case "linux-64":
36 | return "android";
37 | default:
38 | return "noarch";
39 | }
40 | })
41 | .sort((x, y) => order[x] - order[y])
42 | .map((e, i) => {
43 | switch (e) {
44 | case "windows":
45 | return ;
46 | case "apple":
47 | return ;
48 | case "android":
49 | return ;
50 | default:
51 | return noarch;
52 | }
53 | });
54 |
55 | return {platforms};
56 | };
57 |
58 | const usePlatforms = () => {
59 | const { details, latest } = useSelector(PkgSelector.packageDetail);
60 | const platforms = details
61 | .filter((d) => d.version === latest.version)
62 | .map((e) => e.platform);
63 |
64 | if (platforms.length === 0) return latest.platforms;
65 | return platforms;
66 | };
67 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/Files/Filters/index.tsx:
--------------------------------------------------------------------------------
1 | import { PkgSelector } from "@/features/package";
2 | import FilterOutlined from "@ant-design/icons/FilterOutlined";
3 | import { Col, Form, Row, Select } from "antd";
4 | import React from "react";
5 | import { useSelector } from "react-redux";
6 | import { useFileContext } from "../hooks";
7 | import styles from "./styles.less";
8 |
9 | const { Option } = Select;
10 |
11 | export default () => {
12 | const { filters, setFilters } = useFileContext();
13 | const { details } = useSelector(PkgSelector.packageDetail);
14 |
15 | const versions = ["All", ...new Set(details.map((e) => e.version))];
16 | const platforms = ["All", ...new Set(details.map((e) => e.platform))];
17 |
18 | return (
19 |
56 | );
57 | };
58 |
--------------------------------------------------------------------------------
/server/api/dto/package.go:
--------------------------------------------------------------------------------
1 | package dto
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "time"
7 |
8 | "github.com/pkg/errors"
9 |
10 | "private-conda-repo/domain/entity"
11 | "private-conda-repo/domain/enum"
12 | )
13 |
14 | // Package details that is received from
15 | type PackageDto struct {
16 | Name string `json:"name"`
17 | Version string `json:"version"`
18 | BuildString string `json:"buildString"`
19 | BuildNumber int `json:"buildNumber"`
20 | Platform string `json:"platform"`
21 | }
22 |
23 | // Returns the package's full filename (i.e. perfana-0.0.6-py_0.tar.bz2)
24 | func (p *PackageDto) Filename() string {
25 | return fmt.Sprintf("%s-%s-%s_%d.tar.bz2", p.Name, p.Version, p.BuildString, p.BuildNumber)
26 | }
27 |
28 | func (p *PackageDto) GetPlatform() enum.Platform {
29 | platform, _ := enum.MapPlatform(p.Platform)
30 | return platform
31 | }
32 |
33 | func (p *PackageDto) Validate() error {
34 | p.Name = strings.TrimSpace(p.Name)
35 | if p.Name == "" {
36 | return errors.New("name cannot be empty")
37 | }
38 |
39 | _, err := enum.MapPlatform(p.Platform)
40 | if err != nil {
41 | return err
42 | }
43 |
44 | return nil
45 | }
46 |
47 | func (p *PackageDto) ToPackageCount(channelId int) *entity.PackageCount {
48 | return &entity.PackageCount{
49 | ChannelId: channelId,
50 | Package: p.Name,
51 | BuildString: p.BuildString,
52 | BuildNumber: p.BuildNumber,
53 | Version: p.Version,
54 | Platform: p.Platform,
55 | UploadDate: time.Now(),
56 | }
57 | }
58 |
59 | // A DTO giving information about the package in the channel. For example, the channel can be
60 | // 'EISR' and the package can be 'numpy'. This DTO will then provide all the details for this
61 | // specification for all versions of the package.
62 | type PackageDetails struct {
63 | Channel string `json:"channel"`
64 | Package string `json:"package"`
65 | Details []*entity.PackageCount `json:"details"`
66 | Latest *ChannelData `json:"latest"`
67 | }
68 |
--------------------------------------------------------------------------------
/cli/README.md:
--------------------------------------------------------------------------------
1 | PCR CLI
2 | =======
3 |
4 | The PCR cli tool is a tool used to help you manage the packages.
5 |
6 | ## Building the tool
7 |
8 | The Makefile shows an example of how to build the tool for your computer.
9 | The command is for a Windows computer, you can adapt it to whatever you're
10 | using. Note that you need to have
11 | [Go version >= 13](https://golang.org/dl/) to build the cli.
12 |
13 | You can use Docker to build the CLI tool too. Just specify the GOOS and GOARCH
14 | variables before the build and share the build folder volume with your host.
15 | Here's a [reference](https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63)
16 | for all valid GOOS and GOARCH values.
17 |
18 | ## Using the CLI
19 |
20 | Assuming your built binary is called `pcr`, you can get help by calling
21 |
22 | ```bash
23 | pcr help
24 | ```
25 |
26 | ### Set the Repository
27 |
28 | To get started, you'll need to set your registry. If your registry is running
29 | at `http://localhost:5050`, then run the following command
30 |
31 | ```bash
32 | pcr registry set http://localhost:5060
33 | ```
34 |
35 | Now that the CLI knows where to communicate, you can either login or create
36 | a account / channel. If you've already created an account via the web
37 | interface, you can proceed to login straightaway.
38 |
39 | ### Signup or Login
40 |
41 | ```bash
42 | # if creating an account
43 | pcr registry register -c -p -e
44 |
45 | # if logging in
46 | pcr registry login -c -p
47 | ```
48 |
49 | If you don't want to expose any of the values in shell, you can just run
50 | `pcr registry login` and you'll be prompted for the channel and password.
51 | In this instance, **the password will be masked**.
52 |
53 | ### Uploading package
54 |
55 | Assuming you went through all the steps to build your pacakge via
56 | `conda build ...`, you can upload your package by
57 |
58 | ```bash
59 | pcr upload path/to/pkg
60 |
61 | # example
62 | pcr upload dist/noarch/numpy-0.1.1-py_0.tar.bz
63 | ```
64 |
--------------------------------------------------------------------------------
/server/infrastructure/database/postgres/postgres_test.go:
--------------------------------------------------------------------------------
1 | package postgres_test
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "strconv"
7 | "time"
8 |
9 | "github.com/dhui/dktest"
10 | "github.com/pkg/errors"
11 |
12 | "private-conda-repo/config"
13 | . "private-conda-repo/infrastructure/database/postgres"
14 | "private-conda-repo/libs"
15 | )
16 |
17 | const (
18 | dbUid = "user"
19 | dbPwd = "password"
20 | dbName = "pcrdb"
21 | )
22 |
23 | var (
24 | imageName = "postgres:12-alpine"
25 | postgresImageOptions = dktest.Options{
26 | ReadyFunc: dbReady,
27 | PortRequired: true,
28 | ReadyTimeout: 5 * time.Minute,
29 | Env: map[string]string{
30 | "POSTGRES_USER": dbUid,
31 | "POSTGRES_PASSWORD": dbPwd,
32 | "POSTGRES_DB": dbName,
33 | },
34 | PullTimeout: 7.5 * 60 * time.Second,
35 | }
36 | )
37 |
38 | func newDbConfig(c dktest.ContainerInfo) (*config.DbConfig, error) {
39 | host, port, err := c.FirstPort()
40 | if err != nil {
41 | return nil, err
42 | }
43 |
44 | portNo, err := strconv.Atoi(port)
45 | if err != nil {
46 | return nil, err
47 | }
48 |
49 | return &config.DbConfig{
50 | Host: host,
51 | Port: portNo,
52 | User: dbUid,
53 | Password: dbPwd,
54 | DbName: dbName,
55 | }, nil
56 | }
57 |
58 | func dbReady(ctx context.Context, c dktest.ContainerInfo) bool {
59 | conf, err := newDbConfig(c)
60 | if err != nil {
61 | return false
62 | }
63 |
64 | db, err := sql.Open("postgres", conf.ConnectionString())
65 | if err != nil {
66 | return false
67 | }
68 | defer libs.IOCloser(db)
69 |
70 | return db.PingContext(ctx) == nil
71 | }
72 |
73 | func newTestDb(c dktest.ContainerInfo) (*Postgres, error) {
74 | conf, err := newDbConfig(c)
75 | if err != nil {
76 | return nil, errors.Wrap(err, "could not create connection string from docker info")
77 | }
78 |
79 | store, err := New(conf)
80 | if err != nil {
81 | return nil, err
82 | }
83 |
84 | if err = store.Migrate(); err != nil {
85 | return nil, err
86 | }
87 |
88 | return store, nil
89 | }
90 |
--------------------------------------------------------------------------------
/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "private-conda-repo",
3 | "version": "1.0.0",
4 | "private": true,
5 | "dependencies": {
6 | "@ant-design/icons": "^4.2.1",
7 | "antd": "^4.5.0",
8 | "axios": "^0.21.2",
9 | "camelcase-keys": "^6.2.2",
10 | "classnames": "^2.2.6",
11 | "connected-react-router": "^6.8.0",
12 | "fuse.js": "^6.4.1",
13 | "history": "^4.10.1",
14 | "immer": "^9.0.6",
15 | "lodash": "^4.17.19",
16 | "markdown-to-jsx": "^6.11.4",
17 | "moment": "^2.29.2",
18 | "react": "^16.13.1",
19 | "react-code-blocks": "0.0.8",
20 | "react-dom": "^16.13.1",
21 | "react-redux": "^7.2.1",
22 | "react-router-dom": "^5.2.0",
23 | "react-scripts": "3.4.1",
24 | "react-use": "^15.3.3",
25 | "redux": "^4.0.5",
26 | "redux-thunk": "^2.3.0",
27 | "ts-optchain": "^0.1.8",
28 | "typesafe-actions": "^5.1.0",
29 | "typescript": "^3.9.7",
30 | "use-debounce": "^3.4.3",
31 | "use-immer": "^0.4.1"
32 | },
33 | "devDependencies": {
34 | "@baristalabs/craco-raw-loader": "^1.2.0",
35 | "@craco/craco": "^5.6.4",
36 | "@types/classnames": "^2.2.10",
37 | "@types/history": "^4.7.6",
38 | "@types/lodash": "^4.14.158",
39 | "@types/node": "^14.0.26",
40 | "@types/react": "^16.9.43",
41 | "@types/react-dom": "^16.9.8",
42 | "@types/react-redux": "^7.1.9",
43 | "@types/react-router-dom": "^5.1.5",
44 | "circular-dependency-plugin": "^5.2.0",
45 | "craco-antd": "^1.18.1",
46 | "lint-staged": "^10.2.11",
47 | "prettier": "^2.0.5",
48 | "redux-devtools-extension": "^2.13.8",
49 | "webpackbar": "^4.0.0"
50 | },
51 | "scripts": {
52 | "start": "craco start",
53 | "build": "craco build",
54 | "eject": "craco eject"
55 | },
56 | "eslintConfig": {
57 | "extends": "react-app"
58 | },
59 | "browserslist": {
60 | "production": [
61 | ">0.2%",
62 | "not dead",
63 | "not op_mini all"
64 | ],
65 | "development": [
66 | "last 1 chrome version",
67 | "last 1 firefox version",
68 | "last 1 safari version"
69 | ]
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/cli/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "io/ioutil"
5 | "log"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 |
10 | "github.com/mitchellh/go-homedir"
11 | "github.com/pkg/errors"
12 | "github.com/spf13/viper"
13 | "gopkg.in/yaml.v2"
14 | )
15 |
16 | type Config struct {
17 | Registry string `mapstructure:"registry" yaml:"registry"`
18 | Channel Channel `mapstructure:"channel" yaml:"channel"`
19 | PackageRepository string `mapstructure:"package_repository" yaml:"package_repository"`
20 | SslVerify bool `mapstructure:"ssl_verify" yaml:"ssl_verify"`
21 | }
22 |
23 | var (
24 | configFilePath string
25 | )
26 |
27 | func New() *Config {
28 | home, err := homedir.Dir()
29 | if err != nil {
30 | log.Fatal(err)
31 | }
32 |
33 | viper.AddConfigPath(home)
34 | viper.SetConfigType("yaml")
35 | viper.SetConfigName(".pcrrc")
36 |
37 | if err := viper.ReadInConfig(); err != nil {
38 | log.Fatal(errors.Wrap(err, "could not read in config"))
39 | }
40 |
41 | var conf Config
42 | if err := viper.Unmarshal(&conf); err != nil {
43 | log.Fatal(errors.Wrap(err, "could not unmarshal config"))
44 | }
45 | conf.Registry = strings.TrimSpace(strings.TrimRight(conf.Registry, "/"))
46 | return &conf
47 | }
48 |
49 | func init() {
50 | home, err := homedir.Dir()
51 | if err != nil {
52 | log.Fatal(err)
53 | }
54 |
55 | configFilePath = filepath.Join(home, ".pcrrc.yaml")
56 | if _, err := os.Stat(configFilePath); os.IsNotExist(err) {
57 | file, err := os.Create(configFilePath)
58 | if err != nil {
59 | log.Fatal(errors.Wrap(err, "could not write config"))
60 | }
61 | _ = file.Close()
62 | }
63 | }
64 |
65 | func (c *Config) HasRegistry() bool {
66 | if c.Registry == "" {
67 | log.Print("Registry location not set. Please use 'pcr registry set' to specify your private conda repo registry")
68 | return false
69 | }
70 | return true
71 | }
72 |
73 | // Saves configuration to the config file
74 | func (c *Config) Save() {
75 | out, err := yaml.Marshal(c)
76 | if err != nil {
77 | log.Fatal(err)
78 | }
79 |
80 | if err := ioutil.WriteFile(configFilePath, out, 0666); err != nil {
81 | log.Fatal(errors.Wrap(err, "error encountered when saving configurations"))
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/cli/registry/set.go:
--------------------------------------------------------------------------------
1 | package registry
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "regexp"
7 | "strings"
8 |
9 | "github.com/pkg/errors"
10 | "github.com/spf13/cobra"
11 |
12 | "cli/config"
13 | "cli/request"
14 | )
15 |
16 | var setCmd = &cobra.Command{
17 | Use: "set",
18 | Short: "Sets the package registry",
19 | Long: `Verifies the package registry and sets it if successful. The registry needs to be specified
20 | for the cli to work correctly`,
21 | Example: "pcr registry set http://localhost:5060",
22 | Args: cobra.ExactArgs(1),
23 | Run: func(cmd *cobra.Command, args []string) {
24 | handler := SetHandler{cmd: cmd}
25 | url := args[0]
26 |
27 | err := handler.verifyUrl(url)
28 | if err != nil {
29 | cmd.PrintErr(err)
30 | return
31 | }
32 |
33 | err = handler.fetchMeta(url)
34 | if err != nil {
35 | cmd.PrintErr(err)
36 | return
37 | }
38 |
39 | conf := config.New()
40 | conf.Registry = handler.Registry
41 | conf.PackageRepository = handler.Repository
42 |
43 | conf.Save()
44 | cmd.Printf(strings.TrimSpace(fmt.Sprintf(`Set registry target to:
45 | Registry: %s
46 | Repository: %s
47 | `, conf.Registry, conf.PackageRepository)))
48 | },
49 | }
50 |
51 | type SetHandler struct {
52 | cmd *cobra.Command
53 | registryMeta
54 | }
55 |
56 | type registryMeta struct {
57 | Registry string `json:"registry"`
58 | Repository string `json:"repository"`
59 | }
60 |
61 | func (h *SetHandler) verifyUrl(host string) error {
62 | re, err := regexp.Compile(`https?://\w+`)
63 | if err != nil {
64 | return err
65 | }
66 |
67 | if !re.MatchString(host) {
68 | return errors.New("invalid registry address")
69 | }
70 |
71 | return nil
72 | }
73 |
74 | func (h *SetHandler) fetchMeta(host string) error {
75 | resp, err := request.Get(host + "/meta")
76 | if err != nil {
77 | return errors.Wrap(err, "could not fetch meta information from registry. Is this a valid Private Conda Repo?")
78 | }
79 | defer func() { _ = resp.Body.Close() }()
80 |
81 | var meta registryMeta
82 | if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
83 | return errors.Wrap(err, "could not parse meta information from registry")
84 | }
85 |
86 | h.Registry = meta.Registry
87 | h.Repository = meta.Repository
88 |
89 | return nil
90 | }
91 |
--------------------------------------------------------------------------------
/server/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "os/user"
5 | "path/filepath"
6 | "runtime"
7 |
8 | "github.com/pkg/errors"
9 | "github.com/spf13/viper"
10 | )
11 |
12 | type AppConfig struct {
13 | Admin *AdminProfile `mapstructure:"admin"`
14 | Indexer *IndexerConfig `mapstructure:"indexer"`
15 | DB *DbConfig `mapstructure:"db"`
16 | FileServer *ServerConfig `mapstructure:"fileserver"`
17 | AppServer *ServerConfig `mapstructure:"api"`
18 | TLS *TLSConfig `mapstructure:"tls"`
19 | }
20 |
21 | type IConfig interface {
22 | Init() error
23 | }
24 |
25 | type AdminProfile struct {
26 | Username string `mapstructure:"username"`
27 | Password string `mapstructure:"password"`
28 | }
29 |
30 | type ServerConfig struct {
31 | Port int `mapstructure:"port"`
32 | }
33 |
34 | const prefix = "pcr"
35 |
36 | func New() (*AppConfig, error) {
37 | if err := setConfigDirectory(); err != nil {
38 | return nil, err
39 | }
40 |
41 | viper.SetConfigName("config")
42 | viper.SetConfigType("yaml")
43 |
44 | viper.SetEnvPrefix(prefix)
45 | viper.AutomaticEnv()
46 |
47 | if err := viper.ReadInConfig(); err != nil {
48 | return nil, errors.Wrap(err, "could not read in configuration")
49 | }
50 |
51 | if err := setDefaults(); err != nil {
52 | return nil, err
53 | }
54 |
55 | var config AppConfig
56 | if err := viper.Unmarshal(&config); err != nil {
57 | return nil, errors.Wrap(err, "could not unmarshal config")
58 | }
59 |
60 | // Check all configurations are valid
61 | for _, c := range []IConfig{
62 | config.Indexer,
63 | } {
64 | err := c.Init()
65 | if err != nil {
66 | return nil, err
67 | }
68 | }
69 |
70 | return &config, nil
71 | }
72 |
73 | func setConfigDirectory() error {
74 | usr, err := user.Current()
75 | if err != nil {
76 | return errors.Wrap(err, "could not get user directory")
77 | }
78 |
79 | switch runtime.GOOS {
80 | case "windows":
81 | viper.AddConfigPath("C:/Projects/private-conda-repo")
82 | viper.AddConfigPath("C:/Projects/private-conda-repo/server")
83 | case "linux":
84 | viper.AddConfigPath("/var/private-conda-repo")
85 |
86 | default:
87 | return errors.Errorf("Unsupported platform: %s", runtime.GOOS)
88 | }
89 | viper.AddConfigPath(filepath.Join(usr.HomeDir, "private-conda-repo"))
90 |
91 | return nil
92 | }
93 |
--------------------------------------------------------------------------------
/server/infrastructure/database/postgres/migrate.go:
--------------------------------------------------------------------------------
1 | package postgres
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "path/filepath"
7 | "runtime"
8 | "strings"
9 |
10 | "github.com/golang-migrate/migrate/v4"
11 | "github.com/golang-migrate/migrate/v4/database/postgres"
12 | _ "github.com/golang-migrate/migrate/v4/source/file"
13 | _ "github.com/golang-migrate/migrate/v4/source/github"
14 | "github.com/pkg/errors"
15 |
16 | "private-conda-repo/libs"
17 | )
18 |
19 | func (p *Postgres) Migrate() error {
20 | driver, err := postgres.WithInstance(p.db.DB(), &postgres.Config{})
21 | if err != nil {
22 | return errors.Wrap(err, "could not create database driver")
23 | }
24 | sourceUrl, err := getMigrationSourceUrl()
25 | if err != nil {
26 | return err
27 | }
28 |
29 | m, err := migrate.NewWithDatabaseInstance(sourceUrl, "postgres", driver)
30 | if err != nil {
31 | return errors.Wrap(err, "could not create migration instance")
32 | }
33 |
34 | if err := m.Up(); err != nil && err != migrate.ErrNoChange {
35 | return errors.Wrap(err, "could not apply migrations")
36 | }
37 | return nil
38 | }
39 |
40 | func getMigrationSourceUrl() (string, error) {
41 | formatFolderPath := func(folder string) string {
42 | sourceUrl := "file://" + folder
43 | if runtime.GOOS == "windows" {
44 | cwd, _ := os.Getwd()
45 | sourceUrl = strings.Replace(strings.Replace(sourceUrl, cwd, ".", 1), `\`, "/", -1)
46 | }
47 | return sourceUrl
48 | }
49 |
50 | // search from source executable (which is the case for Docker images
51 | root, err := os.Executable()
52 | if err != nil {
53 | return "", err
54 | }
55 | mgDir := filepath.Join(filepath.Dir(root), "infrastructure", "database", "migrations")
56 | if libs.PathExists(mgDir) {
57 | return formatFolderPath(mgDir), nil
58 | }
59 |
60 | // search from local file path, (which is usually the case during development)
61 | _, file, _, _ := runtime.Caller(0)
62 | mgDir = filepath.Join(filepath.Dir(file), "..", "migrations")
63 | if libs.PathExists(mgDir) {
64 | return formatFolderPath(mgDir), nil
65 | }
66 |
67 | username := "danielbok"
68 | publicRepoReadonlyToken := ""
69 | repo := "private-conda-repo"
70 | folderPath := "server/infrastructure/database/migrations"
71 | return fmt.Sprintf("github://%s:%s@%s/%s/%s", username, publicRepoReadonlyToken, username, repo, folderPath), nil
72 | }
73 |
--------------------------------------------------------------------------------
/server/infrastructure/conda/filesys/channel_test.go:
--------------------------------------------------------------------------------
1 | package filesys_test
2 |
3 | import (
4 | "os"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/require"
8 |
9 | "private-conda-repo/api/dto"
10 | "private-conda-repo/libs"
11 | "private-conda-repo/testutils"
12 | )
13 |
14 | func TestChannel_CRUDPackage(t *testing.T) {
15 | t.Parallel()
16 |
17 | var assert = require.New(t)
18 | repo := NewFileSys()
19 |
20 | chn, err := repo.CreateChannel("crud-channel-packages")
21 | assert.NoError(err)
22 |
23 | testPkg := testutils.GetTestPackages()["perfana-0.0.6-py_0.tar.bz2"]
24 |
25 | file, err := os.Open(testPkg.Path)
26 | assert.NoError(err)
27 | defer libs.IOCloser(file)
28 |
29 | pkg, err := chn.AddPackage(file, testPkg.ToPackageDto(), nil)
30 | assert.NoError(err)
31 |
32 | channelData, err := chn.GetChannelData()
33 | assert.NoError(err)
34 |
35 | assert.Len(channelData.Packages, 1)
36 | assert.NotNil(channelData.Packages["perfana"])
37 |
38 | err = chn.RemoveSinglePackage(pkg)
39 | assert.NoError(err)
40 | }
41 |
42 | func TestChannel_GetChannelData(t *testing.T) {
43 | t.Parallel()
44 |
45 | var assert = require.New(t)
46 | chn, err := newPreloadedChannel("get-channel-data")
47 | assert.NoError(err)
48 |
49 | // both packages (copulae and perfana) are registered
50 | channelData, err := chn.GetChannelData()
51 | assert.NoError(err)
52 | assert.Len(channelData.Packages, 2)
53 | assert.EqualValues("0.4.3", *channelData.Packages["copulae"].Version)
54 | assert.EqualValues("0.0.6", *channelData.Packages["perfana"].Version)
55 |
56 | // Remove package updates indices correctly
57 | err = chn.RemoveSinglePackage(&dto.PackageDto{
58 | Name: "perfana",
59 | Version: "0.0.6",
60 | BuildString: "py",
61 | Platform: "noarch",
62 | })
63 |
64 | assert.NoError(err)
65 | channelData, err = chn.GetChannelData()
66 | assert.NoError(err)
67 | assert.EqualValues("0.0.5", *channelData.Packages["perfana"].Version)
68 | }
69 |
70 | func TestChannel_RemovePackageAllVersions(t *testing.T) {
71 | t.Parallel()
72 |
73 | var assert = require.New(t)
74 | chn, err := newPreloadedChannel("remove-package-all-versions-channel")
75 | assert.NoError(err)
76 |
77 | n, err := chn.RemovePackageAllVersions("copulae")
78 | assert.NoError(err)
79 | assert.EqualValues(6, n)
80 |
81 | channelData, err := chn.GetChannelData()
82 | assert.NoError(err)
83 | assert.Len(channelData.Packages, 1)
84 | }
85 |
--------------------------------------------------------------------------------
/server/infrastructure/database/postgres/package_count.go:
--------------------------------------------------------------------------------
1 | package postgres
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/pkg/errors"
7 |
8 | "private-conda-repo/domain/entity"
9 | )
10 |
11 | func (p *Postgres) GetPackageCounts(channelId int, name string) ([]*entity.PackageCount, error) {
12 | var counts []*entity.PackageCount
13 | if errs := p.db.
14 | Where("channel_id = ? AND package = ?", channelId, name).
15 | Find(&counts).
16 | GetErrors(); len(errs) > 0 {
17 | return nil, errors.Wrapf(joinErrors(errs), "could not get count data for channel '%d' for package '%s'", channelId, name)
18 | }
19 | return counts, nil
20 | }
21 |
22 | func (p *Postgres) CreatePackageCount(pkg *entity.PackageCount) (*entity.PackageCount, error) {
23 | if errs := p.db.
24 | Where(entity.PackageCount{
25 | ChannelId: pkg.ChannelId,
26 | Package: pkg.Package,
27 | BuildString: pkg.BuildString,
28 | BuildNumber: pkg.BuildNumber,
29 | Version: pkg.Version,
30 | Platform: pkg.Platform,
31 | }).Assign(entity.PackageCount{
32 | Count: 0,
33 | UploadDate: time.Now().UTC(),
34 | }).FirstOrCreate(pkg).
35 | GetErrors(); len(errs) > 0 {
36 | return nil, joinErrors(errs)
37 | }
38 | return pkg, nil
39 | }
40 |
41 | func (p *Postgres) IncreasePackageCount(pkg *entity.PackageCount) (*entity.PackageCount, error) {
42 | var count entity.PackageCount
43 | if errs := p.db.
44 | Where(entity.PackageCount{
45 | ChannelId: pkg.ChannelId,
46 | Package: pkg.Package,
47 | BuildString: pkg.BuildString,
48 | BuildNumber: pkg.BuildNumber,
49 | Version: pkg.Version,
50 | Platform: pkg.Platform,
51 | }).
52 | First(&count).
53 | GetErrors(); len(errs) > 0 {
54 | return nil, errors.Wrap(joinErrors(errs), "could not update count")
55 | }
56 |
57 | p.db.Model(&count).Update("count", count.Count+1)
58 | return &count, nil
59 | }
60 |
61 | func (p *Postgres) RemovePackageCount(pkg *entity.PackageCount) error {
62 | var record entity.PackageCount
63 | if errs := p.db.Where(entity.PackageCount{
64 | ChannelId: pkg.ChannelId,
65 | Package: pkg.Package,
66 | BuildString: pkg.BuildString,
67 | BuildNumber: pkg.BuildNumber,
68 | Version: pkg.Version,
69 | Platform: pkg.Platform,
70 | }).First(&record).GetErrors(); len(errs) > 0 {
71 | return joinErrors(errs)
72 | }
73 |
74 | if errs := p.db.Delete(record).GetErrors(); len(errs) > 0 {
75 | return joinErrors(errs)
76 | }
77 |
78 | return nil
79 | }
80 |
--------------------------------------------------------------------------------
/server/fileserver/handler.go:
--------------------------------------------------------------------------------
1 | package fileserver
2 |
3 | import (
4 | "net/http"
5 | "regexp"
6 | "strconv"
7 | "strings"
8 |
9 | "github.com/pkg/errors"
10 | log "github.com/sirupsen/logrus"
11 |
12 | "private-conda-repo/api/interfaces"
13 | "private-conda-repo/domain/entity"
14 | "private-conda-repo/domain/enum"
15 | )
16 |
17 | var nameRegex = regexp.MustCompile(`([\w\-_]+)-([\w.]+)-(\w+)_(\d+).tar.bz2`)
18 |
19 | type FileHandler struct {
20 | DB interfaces.DataAccessLayer
21 | }
22 |
23 | func (h *FileHandler) Server(mountFolder string) http.HandlerFunc {
24 | root := http.Dir(mountFolder)
25 | fs := http.FileServer(root)
26 |
27 | return func(w http.ResponseWriter, r *http.Request) {
28 | components := strings.Split(strings.Trim(r.RequestURI, "/"), "/")
29 | if len(components) != 3 {
30 | http.Error(w, "invalid request", http.StatusBadRequest)
31 | return
32 | }
33 |
34 | channel := components[0]
35 | platform := components[1]
36 | file := components[2]
37 | if _, err := enum.MapPlatform(platform); err != nil {
38 | http.Error(w, err.Error(), http.StatusBadRequest)
39 | return
40 | }
41 |
42 | switch {
43 | case file == "current_repodata.json" || file == "repodata.json":
44 | log.Infof("requesting repodata from '%s/%s'", channel, platform)
45 | case strings.HasSuffix(file, ".tar.bz2"):
46 | err := h.updateCount(channel, file, platform)
47 | if err != nil {
48 | http.Error(w, "could not update package count", http.StatusBadRequest)
49 | return
50 | }
51 | log.Infof("serving '%s' to remote '%s'", file, r.RemoteAddr)
52 |
53 | default:
54 | http.Error(w, "request is not made by a conda agent", http.StatusBadRequest)
55 | return
56 | }
57 | fs.ServeHTTP(w, r)
58 | }
59 | }
60 |
61 | func (h *FileHandler) updateCount(channel, file, platform string) error {
62 | chn, err := h.DB.GetChannel(channel)
63 | if err != nil {
64 | return err
65 | }
66 |
67 | m := nameRegex.FindStringSubmatch(file)
68 | if len(m) != 5 {
69 | return errors.Errorf("could not parse package name from '%s'", file)
70 | }
71 |
72 | buildNo, err := strconv.Atoi(m[4])
73 | if err != nil {
74 | return errors.Errorf("could not parse build number from '%s'", file)
75 | }
76 |
77 | _, err = h.DB.IncreasePackageCount(&entity.PackageCount{
78 | ChannelId: chn.Id,
79 | Package: m[1],
80 | Version: m[2],
81 | BuildString: m[3],
82 | BuildNumber: buildNo,
83 | Platform: platform,
84 | })
85 | return err
86 | }
87 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/PackageInfo/TopCard.tsx:
--------------------------------------------------------------------------------
1 | import { PkgSelector } from "@/features/package";
2 | import { timeSinceUpload } from "@/libs/date";
3 | import { Card } from "antd";
4 | import ProfileOutlined from "@ant-design/icons/ProfileOutlined";
5 | import DownloadOutlined from "@ant-design/icons/DownloadOutlined";
6 | import CalendarOutlined from "@ant-design/icons/CalendarOutlined";
7 | import HomeOutlined from "@ant-design/icons/HomeOutlined";
8 | import CodeOutlined from "@ant-design/icons/CodeOutlined";
9 | import FileWordOutlined from "@ant-design/icons/FileWordOutlined";
10 | import React from "react";
11 | import { useSelector } from "react-redux";
12 | import styles from "./styles.less";
13 |
14 | export default () => {
15 | const details = useSelector(PkgSelector.packageDetail);
16 | const { devUrl, docUrl, home, license, timestamp } = details.latest;
17 | const downloads = details.details.reduce((acc, e) => acc + e.count, 0);
18 |
19 | return (
20 |
21 | {license && (
22 |
33 | )}
34 |
35 | {([
36 | [home, "Home", ],
37 | [devUrl, "Development", ],
38 | [docUrl, "Documentation", ],
39 | ] as [string | null, string, JSX.Element][]).map(
40 | ([link, title, icon]) =>
41 | link && (
42 |
49 | )
50 | )}
51 |
52 |
53 |
54 | {downloads} total downloads
55 |
56 |
57 |
58 |
59 | Last Upload: {timeSinceUpload(timestamp)}
60 |
61 |
62 | );
63 | };
64 |
--------------------------------------------------------------------------------
/web/src/modules/Home/pages/Main/PackageList/ResultTable/index.tsx:
--------------------------------------------------------------------------------
1 | import { PkgSelector, PkgType } from "@/features/package";
2 | import { Col, List, Row } from "antd";
3 | import Fuse from "fuse.js";
4 | import sortBy from "lodash/sortBy";
5 | import React from "react";
6 | import { useSelector } from "react-redux";
7 | import { useSearchContext } from "../hooks";
8 | import Link from "./Link";
9 | import styles from "./styles.less";
10 | import Tag from "./Tag";
11 |
12 | export default () => {
13 | const list = useResultList();
14 |
15 | const pagination =
16 | list.length <= 10
17 | ? false
18 | : {
19 | pageSize: 10,
20 | };
21 |
22 | return (
23 |
}
29 | bordered={true}
30 | className={styles.listMain}
31 | renderItem={(item, i) => {
32 | const className = i % 2 === 1 ? styles.alternate : undefined;
33 |
34 | return (
35 |
36 |
37 |
38 | }
40 | description={item.description}
41 | />
42 |
43 |
44 | {item.platforms.map((p) => (
45 |
46 | ))}
47 |
48 |
49 |
50 | );
51 | }}
52 | />
53 | );
54 | };
55 |
56 | const Header = () => (
57 |
58 | Package (owner / package)
59 | Platforms
60 |
61 | );
62 |
63 | const useResultList = () => {
64 | const { search } = useSearchContext();
65 | const packages = useSelector(PkgSelector.packageMeta);
66 |
67 | if (search.length > 0) {
68 | const keys: {
69 | name: keyof PkgType.PackageMetaInfo;
70 | weight: number;
71 | }[] = [
72 | { name: "name", weight: 0.6 },
73 | { name: "description", weight: 0.25 },
74 | { name: "channel", weight: 0.1 },
75 | { name: "summary", weight: 0.05 },
76 | ];
77 |
78 | return new Fuse(packages, { threshold: 0.2, keys })
79 | .search(search)
80 | .map((e) => e.item);
81 | } else {
82 | return sortBy(packages, (e) => [e.channel, e.name]);
83 | }
84 | };
85 |
--------------------------------------------------------------------------------
/web/craco.config.js:
--------------------------------------------------------------------------------
1 | const CircularDependencyPlugin = require("circular-dependency-plugin");
2 | const CracoAntDesignPlugin = require("craco-antd");
3 | const CracoRawLoaderPlugin = require("@baristalabs/craco-raw-loader");
4 | const WebpackBar = require("webpackbar");
5 | const getCSSModuleLocalIdent = require("react-dev-utils/getCSSModuleLocalIdent");
6 |
7 | const path = require("path");
8 |
9 | const isDev = process.env.NODE_ENV === "development";
10 |
11 | const extraWebpackPlugins = isDev
12 | ? [
13 | new CircularDependencyPlugin({
14 | exclude: /node_modules/,
15 | failOnError: true,
16 | allowAsyncCycles: false,
17 | cwd: process.cwd(),
18 | }),
19 | ]
20 | : []; // prod plugins
21 |
22 | module.exports = {
23 | webpack: {
24 | performance: {
25 | hints: true,
26 | },
27 | alias: {
28 | "@": path.resolve(__dirname, "src/"),
29 | },
30 | devServer: {
31 | historyApiFallback: true, // * to handle react-router-dom browserHistory
32 | inline: true,
33 | compress: true,
34 | open: false,
35 | port: 3000,
36 | },
37 | plugins: [new WebpackBar({ profile: true }), ...extraWebpackPlugins],
38 | },
39 | plugins: [
40 | {
41 | plugin: CracoAntDesignPlugin,
42 | options: {
43 | customizeTheme: {
44 | "@primary-color": "#43b02a",
45 | "@primary-color-light": "#46d42a",
46 | "@primary-color-dark": "#025c02",
47 | },
48 | lessLoaderOptions: {
49 | lessOptions: {
50 | modifyVars: {
51 | "@footer-height": "140px",
52 | "@header-height": "64px",
53 | "@header-margin": "5px",
54 | "@min-height":
55 | "calc(100vh - @header-height - @footer-height - @header-margin)",
56 | },
57 | },
58 | },
59 | cssLoaderOptions: {
60 | modules: {
61 | localIdentName: isDev ? "[path][name]_[local]" : "[hash:base64]",
62 | getLocalIdent: (context, localIdentName, localName, options) =>
63 | context.resourcePath.includes("node_modules")
64 | ? localName
65 | : getCSSModuleLocalIdent(
66 | context,
67 | localIdentName,
68 | localName,
69 | options
70 | ),
71 | },
72 | localsConvention: "camelCase",
73 | },
74 | },
75 | },
76 | {
77 | plugin: CracoRawLoaderPlugin,
78 | options: {
79 | test: /\.md$/,
80 | },
81 | },
82 | ],
83 | };
84 |
--------------------------------------------------------------------------------
/web/src/modules/Account/pages/RegisterLogin/Registration/Login/reducer.ts:
--------------------------------------------------------------------------------
1 | import { some } from "lodash";
2 | import { useImmerReducer } from "use-immer";
3 |
4 | export type Credential = {
5 | username: string;
6 | password: string;
7 | };
8 |
9 | export type State = Credential & {
10 | valid: boolean;
11 | pristine: Record;
12 | errors: Record;
13 | disabled: boolean;
14 | };
15 |
16 | export type Action =
17 | | {
18 | type: "SET_USERNAME";
19 | payload: {
20 | username: string;
21 | };
22 | }
23 | | {
24 | type: "SET_PASSWORD";
25 | payload: {
26 | password: string;
27 | };
28 | }
29 | | {
30 | type: "SET_VALID";
31 | payload: {
32 | valid: boolean;
33 | };
34 | };
35 |
36 | const initialState: State = {
37 | username: "",
38 | password: "",
39 | valid: true,
40 | pristine: {
41 | username: true,
42 | password: true,
43 | },
44 | errors: {
45 | username: [],
46 | password: [],
47 | },
48 | disabled: true,
49 | };
50 |
51 | export const useLoginReducer = () => {
52 | return useImmerReducer((draft, action: Action) => {
53 | switch (action.type) {
54 | case "SET_USERNAME": {
55 | const { username } = action.payload;
56 | draft.username = action.payload.username;
57 | draft.pristine.username = false;
58 |
59 | draft.errors.username = hasError({ username }).username;
60 | break;
61 | }
62 | case "SET_PASSWORD": {
63 | const { password } = action.payload;
64 | draft.password = password;
65 | draft.pristine.password = false;
66 |
67 | draft.errors.password = hasError({ password }).password;
68 | break;
69 | }
70 | case "SET_VALID":
71 | draft.valid = action.payload.valid;
72 | break;
73 | }
74 |
75 | const pristine = some(draft.pristine);
76 | draft.disabled = pristine || some(draft.errors, (e) => e.length > 0);
77 | }, initialState);
78 | };
79 |
80 | const hasError = ({ username, password }: Partial) => {
81 | const errors: State["errors"] = {
82 | username: [],
83 | password: [],
84 | };
85 |
86 | if (username === undefined) {
87 | errors.username.push("Username is required.");
88 | } else if (username.trim().length < 2) {
89 | errors.username.push("Username must be at least 2 characters long.");
90 | }
91 |
92 | if (!password) {
93 | errors.password.push("Password is required.");
94 | } else if (password.length < 4) {
95 | errors.password.push("Password must be at least 4 characters long.");
96 | }
97 |
98 | return errors;
99 | };
100 |
--------------------------------------------------------------------------------
/server/api/server.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 |
7 | "github.com/go-chi/chi"
8 | "github.com/go-chi/chi/middleware"
9 | log "github.com/sirupsen/logrus"
10 |
11 | "github.com/rs/cors"
12 |
13 | "private-conda-repo/api/interfaces"
14 | "private-conda-repo/config"
15 | _ "private-conda-repo/infrastructure/database/postgres"
16 | )
17 |
18 | type MasterHandler struct {
19 | *chi.Mux
20 | Config *config.AppConfig
21 | db interfaces.DataAccessLayer
22 | decompressor interfaces.Decompressor
23 | fileSys interfaces.FileSys
24 | }
25 |
26 | func New(conf *config.AppConfig, db interfaces.DataAccessLayer, decompressor interfaces.Decompressor, fileSys interfaces.FileSys) (*http.Server, error) {
27 | addr := fmt.Sprintf(":%d", conf.AppServer.Port)
28 | log.WithField("Address", addr).Info("Server details")
29 |
30 | r := MasterHandler{
31 | chi.NewRouter(),
32 | conf,
33 | db,
34 | decompressor,
35 | fileSys,
36 | }
37 |
38 | r.attachMiddleware()
39 | r.registerRoutes()
40 |
41 | return &http.Server{
42 | Addr: addr,
43 | Handler: r,
44 | }, nil
45 | }
46 |
47 | func (m *MasterHandler) attachMiddleware() {
48 | m.Use(middleware.RequestID)
49 | m.Use(middleware.RealIP)
50 | m.Use(middleware.Logger)
51 | m.Use(middleware.Recoverer)
52 |
53 | m.Use(cors.New(cors.Options{
54 | AllowedOrigins: []string{"*"},
55 | AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
56 | AllowedHeaders: []string{"*"},
57 | AllowCredentials: true,
58 | }).Handler)
59 | }
60 |
61 | func (m *MasterHandler) registerRoutes() {
62 | // channel routes
63 | m.Route("/channel", func(r chi.Router) {
64 | h := ChannelHandler{
65 | DB: m.db,
66 | FileSys: m.fileSys,
67 | }
68 |
69 | r.Get("/", h.ListChannels())
70 | r.Get("/{channel}", h.GetChannelInfo())
71 |
72 | r.Post("/", h.CreateChannel())
73 | r.Post("/check", h.CheckChannel())
74 |
75 | r.Delete("/", h.RemoveChannel())
76 | })
77 |
78 | // package routes
79 | m.Route("/p", func(r chi.Router) {
80 | h := PackageHandler{
81 | DB: m.db,
82 | Decompressor: m.decompressor,
83 | FileSys: m.fileSys,
84 | }
85 |
86 | r.Get("/", h.ListAllPackages())
87 | r.Get("/{channel}", h.ListPackagesInChannel())
88 | r.Get("/{channel}/{pkg}", h.FetchPackageDetails())
89 |
90 | r.Post("/", h.UploadPackage())
91 | r.Delete("/", h.RemovePackage())
92 | r.Delete("/{pkg}", h.RemoveAllPackages())
93 | })
94 |
95 | // index level routes, these are the least specific
96 | m.Route("/", func(r chi.Router) {
97 | h := IndexHandler{Conf: m.Config}
98 |
99 | r.Get("/healthcheck", h.HealthCheck())
100 | r.Get("/meta", h.MetaInfo())
101 |
102 | r.HandleFunc("/*", h.NotFound())
103 | })
104 | }
105 |
--------------------------------------------------------------------------------
/server/infrastructure/conda/filesys/filesys_test.go:
--------------------------------------------------------------------------------
1 | package filesys_test
2 |
3 | import (
4 | "fmt"
5 | "io/ioutil"
6 | "os"
7 | "path/filepath"
8 | "testing"
9 | "time"
10 |
11 | log "github.com/sirupsen/logrus"
12 | "github.com/stretchr/testify/require"
13 |
14 | "private-conda-repo/api/interfaces"
15 | . "private-conda-repo/infrastructure/conda/filesys"
16 | "private-conda-repo/infrastructure/conda/index"
17 | "private-conda-repo/testutils"
18 | )
19 |
20 | var tmpDir string
21 |
22 | func TestMain(m *testing.M) {
23 | dir, err := ioutil.TempDir("", "conda")
24 | if err != nil {
25 | log.Fatal(err)
26 | }
27 | tmpDir = dir
28 | code := m.Run()
29 |
30 | time.Sleep(1 * time.Second)
31 | err = os.RemoveAll(tmpDir)
32 | if err != nil {
33 | log.Fatal(err)
34 | }
35 |
36 | os.Exit(code)
37 | }
38 |
39 | func NewFileSys() *FileSys {
40 | return New(tmpDir, &index.DockerIndex{Image: "danielbok/conda-repo-mgr:latest"})
41 | }
42 |
43 | func newPreloadedChannel(name string) (interfaces.Channel, error) {
44 | repo := NewFileSys()
45 |
46 | chn, err := repo.CreateChannel(name)
47 | if err != nil {
48 | return nil, err
49 | }
50 |
51 | for _, details := range testutils.GetTestPackages() {
52 | f, err := os.Open(details.Path)
53 | if err != nil {
54 | return nil, err
55 | }
56 |
57 | _, err = chn.AddPackage(f, details.ToPackageDto(), nil)
58 | if err != nil {
59 | return nil, err
60 | }
61 | _ = f.Close()
62 | }
63 |
64 | return chn, err
65 | }
66 |
67 | func TestFileSys_CRUDChannel(t *testing.T) {
68 | t.Parallel()
69 | assert := require.New(t)
70 | channelName := "crud-conda-channel"
71 | channelNewName := "crud-conda-channel-new-channel-name"
72 |
73 | repo := NewFileSys()
74 |
75 | _, err := repo.GetChannel(channelName)
76 | assert.Error(err)
77 |
78 | chn, err := repo.CreateChannel(channelName)
79 | assert.NoError(err)
80 |
81 | chn, err = repo.GetChannel(channelName)
82 | assert.NoError(err)
83 | assert.EqualValues(channelName, filepath.Base(chn.Directory()))
84 |
85 | _, err = repo.RenameChannel(channelName, channelName)
86 | assert.Error(err, "should not be able to replace existing channel")
87 |
88 | chn, err = repo.RenameChannel(channelName, channelNewName)
89 | assert.NoError(err)
90 | assert.EqualValues(channelNewName, filepath.Base(chn.Directory()))
91 |
92 | err = repo.RemoveChannel(channelName)
93 | assert.Error(err, "channel does not exist and should raise error if trying to remove")
94 |
95 | err = repo.RemoveChannel(channelNewName)
96 | assert.NoError(err)
97 |
98 | // Test listing all channels
99 | numChannels := 10
100 | for i := 0; i < numChannels; i++ {
101 | _, err := repo.CreateChannel(fmt.Sprintf("test-channel-%d", i))
102 | assert.NoError(err)
103 | }
104 |
105 | allChannels, err := repo.ListAllChannels()
106 | assert.NoError(err)
107 | assert.GreaterOrEqual(len(allChannels), numChannels)
108 | }
109 |
--------------------------------------------------------------------------------
/server/api/interfaces/interfaces.go:
--------------------------------------------------------------------------------
1 | package interfaces
2 |
3 | import (
4 | "io"
5 |
6 | "private-conda-repo/api/dto"
7 | "private-conda-repo/domain/condatypes"
8 | "private-conda-repo/domain/entity"
9 | "private-conda-repo/infrastructure/decompressor"
10 | )
11 |
12 | type DataAccessLayer interface {
13 | Migrate() error
14 |
15 | CreateChannel(channel, password, email string) (*entity.Channel, error)
16 | GetChannel(channel string) (*entity.Channel, error)
17 | RemoveChannel(id int) error
18 | GetAllChannels() ([]*entity.Channel, error)
19 |
20 | GetPackageCounts(channelId int, name string) ([]*entity.PackageCount, error)
21 | CreatePackageCount(pkg *entity.PackageCount) (*entity.PackageCount, error)
22 | IncreasePackageCount(pkg *entity.PackageCount) (*entity.PackageCount, error)
23 | RemovePackageCount(pkg *entity.PackageCount) error
24 | }
25 |
26 | type Decompressor interface {
27 | // Retrieves MetaData from the .tar.bz2 file
28 | RetrieveMetadata(file io.ReadCloser) (*decompressor.MetaData, error)
29 | }
30 |
31 | type Indexer interface {
32 | // Indexes the directory via `conda index`. This should be run whenever a package is added,
33 | // removed or updated. It will update the current_repodata.json and repodata.json files in
34 | // the repository so that when we `conda install`, the dependency solver will know how to
35 | // look for files
36 | Index(dir string) error
37 |
38 | // Applies a series of fixes to repodata.json and current_repodata.json such as removal of
39 | // `python_abi` from the dependency lists. A list of instructions (fixes) must be provided
40 | // to determine which fixes to apply. Presently the supported values are
41 | //
42 | // - no-abi : Removes "python_abi *" dependencies from the uploaded package
43 | FixRepoData(dir string, fixes []string) error
44 |
45 | // Updates the indexer. This should not need to be called most times
46 | Update() error
47 | }
48 |
49 | type Channel interface {
50 | // Adds a package to the channel
51 | AddPackage(file io.Reader, pkg *dto.PackageDto, fixes []string) (*dto.PackageDto, error)
52 |
53 | // Returns the absolute path of channel
54 | Directory() string
55 |
56 | // Returns channel's channeldata.json file. This is useful for debugging the current state of the
57 | // channel
58 | GetChannelData() (*condatypes.ChannelData, error)
59 |
60 | // Reindex the channel folder. This should be called whenever there are changes to the packages
61 | // in the channel.
62 | Index(fixes []string) error
63 |
64 | // Returns the name of the channel
65 | Name() string
66 |
67 | // Removes a single package from the channel
68 | RemoveSinglePackage(pkg *dto.PackageDto) error
69 |
70 | // Removes all packages of the same name from the channel That is if you have a package called
71 | // numpy with different versions, this method will remove all versions of 'numpy'. Other packages
72 | // like 'scipy' will remain intact. Returns the number of packages removed
73 | RemovePackageAllVersions(name string) (int, error)
74 | }
75 |
76 | type FileSys interface {
77 | CreateChannel(channel string) (Channel, error)
78 | RenameChannel(oldName, newName string) (Channel, error)
79 | GetChannel(name string) (Channel, error)
80 | ListAllChannels() ([]Channel, error)
81 | RemoveChannel(name string) error
82 | }
83 |
--------------------------------------------------------------------------------
/web/src/modules/Package/pages/PackageDetail/Files/Table/index.tsx:
--------------------------------------------------------------------------------
1 | import { MetaSelector } from "@/features/meta";
2 | import { PkgSelector } from "@/features/package";
3 | import { timeSinceUpload } from "@/libs/date";
4 | import CalendarOutlined from "@ant-design/icons/CalendarOutlined";
5 | import { Table } from "antd";
6 | import { ColumnProps } from "antd/es/table";
7 | import React from "react";
8 | import { useSelector } from "react-redux";
9 | import { useFileContext } from "../hooks";
10 | import DeleteAction from "./DeleteAction";
11 | import styles from "./styles.less";
12 | import { DataRow } from "./types";
13 |
14 | const pageSize = 20;
15 |
16 | export default () => {
17 | const columns = useColumns();
18 | const data = useDataSource();
19 |
20 | const pagination =
21 | data.length > pageSize
22 | ? {
23 | style: { marginRight: 20 },
24 | pageSize,
25 | }
26 | : false;
27 |
28 | return (
29 |
35 | );
36 | };
37 |
38 | const useColumns = (): ColumnProps[] => {
39 | const { repository } = useSelector(MetaSelector.metaInfo);
40 |
41 | return [
42 | {
43 | title: "Name",
44 | dataIndex: "name",
45 | render: (text: string) => {
46 | const [, ...fileParts] = text.split("/");
47 | const filename = fileParts.join("/");
48 |
49 | const link = `${repository}/${text}`;
50 | return (
51 |
52 | {filename}
53 |
54 | );
55 | },
56 | },
57 | {
58 | title: "Uploaded",
59 | dataIndex: "uploaded",
60 | render: (text) => (
61 | <>
62 |
63 | {text}
64 | >
65 | ),
66 | },
67 | {
68 | title: "Downloads",
69 | dataIndex: "downloads",
70 | render: (text) => {text},
71 | },
72 | {
73 | title: "Action",
74 | key: "action",
75 | render: (_, r) => (
76 |
77 | ),
78 | },
79 | ];
80 | };
81 |
82 | const useDataSource = () => {
83 | const { filters } = useFileContext();
84 | const { details, channel } = useSelector(PkgSelector.packageDetail);
85 | return details
86 | .filter((d) => {
87 | if (filters.version !== "All" && d.version !== filters.version)
88 | return false;
89 | return !(filters.platform !== "All" && d.platform !== filters.platform);
90 | })
91 | .map(
92 | (d, i) =>
93 | ({
94 | key: i,
95 | name: `${channel}/${d.platform}/${d.package}-${d.version}-${d.buildString}_${d.buildNumber}.tar.bz2`,
96 | uploaded: timeSinceUpload(d.uploadDate),
97 | downloads: d.count,
98 | channel,
99 | package: {
100 | name: d.package,
101 | version: d.version,
102 | platform: d.platform,
103 | buildNumber: d.buildNumber,
104 | buildString: d.buildString,
105 | },
106 | } as DataRow)
107 | );
108 | };
109 |
--------------------------------------------------------------------------------
/web/src/features/channel/api.ts:
--------------------------------------------------------------------------------
1 | import api, { ThunkFunction, ThunkFunctionAsync } from "@/infrastructure/api";
2 | import { notification } from "antd";
3 | import * as A from "./actions";
4 | import { ChannelStorage } from "./localstorage";
5 | import * as T from "./types";
6 |
7 | /**
8 | * Creates channel in the backend server
9 | */
10 | export const createChannel = (): ThunkFunctionAsync => async (
11 | dispatch,
12 | getState
13 | ) => {
14 | const {
15 | form: { errors, pristine, channel, password, confirm, email },
16 | loading: { validation },
17 | } = getState().channel;
18 |
19 | const invalid =
20 | confirm !== password ||
21 | Object.values(pristine).some((e) => e) ||
22 | Object.values(errors).some((e) => e.length > 0);
23 | if (validation === "REQUEST" || invalid) return;
24 |
25 | const payload: T.Channel = { channel, password };
26 |
27 | const { status } = await api.Post(
28 | "/channel",
29 | { ...payload, email },
30 | {
31 | beforeRequest: () => dispatch(A.createChannelAsync.request()),
32 | }
33 | );
34 |
35 | if (status === 200) {
36 | dispatch(A.createChannelAsync.success(payload));
37 | ChannelStorage.save(payload);
38 | notification.success({
39 | message: `Channel: ${payload.channel} created. Looking forward to your contributions!`,
40 | });
41 | } else {
42 | dispatch(A.createChannelAsync.failure());
43 | ChannelStorage.clear();
44 | }
45 | };
46 |
47 | /**
48 | * Loads channel details from local storage
49 | */
50 | export const loadChannel = (): ThunkFunctionAsync => async (dispatch) => {
51 | const channel = ChannelStorage.load();
52 | if (channel) {
53 | await dispatch(validateChannel(channel.channel, channel.password));
54 | }
55 | };
56 |
57 | /**
58 | * Checks if the channel is valid. Used for logging in
59 | */
60 | export const validateChannel = (
61 | channel: string,
62 | password: string
63 | ): ThunkFunctionAsync => async (dispatch, getState) => {
64 | if (getState().channel.loading.validation === "REQUEST") return false;
65 | const payload: T.Channel = { channel, password };
66 |
67 | const { status } = await api.Post("/channel/check", payload, {
68 | beforeRequest: () => dispatch(A.fetchChannelCredentialsAsync.request()),
69 | });
70 |
71 | if (status === 200) {
72 | dispatch(A.fetchChannelCredentialsAsync.success(payload));
73 | ChannelStorage.save(payload);
74 | return true;
75 | } else {
76 | dispatch(A.fetchChannelCredentialsAsync.failure());
77 | return false;
78 | }
79 | };
80 |
81 | /**
82 | * Logs the channel out
83 | */
84 | export const logout = (): ThunkFunction => (dispatch) => {
85 | ChannelStorage.clear();
86 | dispatch(A.logout());
87 | };
88 |
89 | /**
90 | * Checks if channel is available from the backend
91 | * @param channel name to check
92 | */
93 | export const isChannelAvailable = (
94 | channel: string
95 | ): ThunkFunctionAsync => async (dispatch) => {
96 | const { status, data } = await api.Post("/channel/check", {
97 | channel,
98 | password: "",
99 | });
100 |
101 | const available = status === 400 && data.trim() === "record not found";
102 | if (!available) {
103 | dispatch(A.updateForm({ errors: { channel: "channel is not available" } }));
104 | } else {
105 | }
106 | };
107 |
--------------------------------------------------------------------------------
/cli/registry/login.go:
--------------------------------------------------------------------------------
1 | package registry
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "strings"
7 |
8 | "github.com/manifoldco/promptui"
9 | "github.com/pkg/errors"
10 | "github.com/spf13/cobra"
11 |
12 | "cli/config"
13 | "cli/request"
14 | )
15 |
16 | func init() {
17 | loginCmd.Flags().StringP("channel", "c", "", "Channel name")
18 | loginCmd.Flags().StringP("password", "p", "", "Registry password")
19 | }
20 |
21 | var loginCmd = &cobra.Command{
22 | Use: "login",
23 | Short: "Log into the registry",
24 | Long: `Logs the cli tool with the channel's credentials. The channel's credentials will be verified
25 | against the server. `,
26 | Args: cobra.NoArgs,
27 | Run: func(cmd *cobra.Command, _ []string) {
28 | handler := loginHandler{cmd: cmd}
29 | channel, err := handler.getValue("channel", 0)
30 | if err != nil {
31 | cmd.PrintErr(err)
32 | return
33 | }
34 |
35 | password, err := handler.getValue("password", '*')
36 | if err != nil {
37 | cmd.PrintErr(err)
38 | return
39 | }
40 |
41 | err = handler.validateChannelCredentials(channel, password)
42 | if err != nil {
43 | cmd.PrintErr(err)
44 | return
45 | }
46 |
47 | conf := config.New()
48 | conf.Channel.Channel = channel
49 | conf.Channel.Password = password
50 |
51 | conf.Save()
52 | cmd.Printf("Logged into '%s'", channel)
53 | },
54 | }
55 |
56 | type loginHandler struct {
57 | cmd *cobra.Command
58 | }
59 |
60 | func (h *loginHandler) getValue(flag string, mask rune) (string, error) {
61 | value, err := h.getFlag(flag)
62 | if err != nil {
63 | return "", err
64 | } else if value != "" {
65 | return value, nil
66 | }
67 |
68 | return h.promptValue(strings.Title(flag), mask)
69 | }
70 |
71 | func (h *loginHandler) getFlag(flag string) (string, error) {
72 | value, err := h.cmd.Flags().GetString(flag)
73 | if err != nil {
74 | return "", errors.Wrapf(err, "could not get '%s' flag value", flag)
75 | }
76 |
77 | return strings.TrimSpace(value), nil
78 | }
79 |
80 | func (h *loginHandler) promptValue(label string, mask rune) (string, error) {
81 | prompt := promptui.Prompt{
82 | Label: label,
83 | Validate: func(input string) error {
84 | input = strings.TrimSpace(input)
85 | if input == "" {
86 | return errors.Errorf("%s cannot be empty", label)
87 | }
88 | if len(input) < 4 {
89 | return errors.Errorf("%s length must be >= 4 characters", label)
90 | }
91 | return nil
92 | },
93 | Mask: mask,
94 | }
95 |
96 | value, err := prompt.Run()
97 | if err != nil {
98 | return "", err
99 | }
100 | return value, nil
101 | }
102 |
103 | func (h *loginHandler) validateChannelCredentials(channel, password string) error {
104 | conf := config.New()
105 | if !conf.HasRegistry() {
106 | return nil
107 | }
108 |
109 | payload := strings.NewReader(fmt.Sprintf(`{
110 | "channel": "%s",
111 | "password": "%s"
112 | }`, channel, password))
113 |
114 | resp, err := request.Post(conf.Registry+"/channel/check", "application/json", payload)
115 | if err != nil {
116 | return errors.Wrap(err, "could not check user information against server")
117 | }
118 | defer func() { _ = resp.Body.Close() }()
119 |
120 | if resp.StatusCode != http.StatusOK {
121 | return errors.Errorf("channel credentials for '%s' is incorrect. Did you create the account created or have you forgotten the password?", channel)
122 | }
123 |
124 | return nil
125 | }
126 |
--------------------------------------------------------------------------------