initial commit
This commit is contained in:
commit
58fa65fb6b
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
1619
Cargo.lock
generated
Normal file
1619
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
26
Cargo.toml
Normal file
26
Cargo.toml
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
[package]
|
||||||
|
name = "registry"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = "0.1.56"
|
||||||
|
axum = "0.5.13"
|
||||||
|
futures-core = "0.3.21"
|
||||||
|
futures-util = "0.3.21"
|
||||||
|
hmac-sha256 = "1.1.4"
|
||||||
|
once_cell = "1.13.0"
|
||||||
|
semver = { version = "1.0.12", features = ["serde"] }
|
||||||
|
serde = { version = "1.0.140", features = ["derive"] }
|
||||||
|
serde_json = "1.0.82"
|
||||||
|
sqlx = { version = "0.6.0", features = ["runtime-tokio-rustls", "postgres"] }
|
||||||
|
tokio = { version = "1.20.1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7.3", features = ["io"] }
|
||||||
|
toml = "0.5.9"
|
||||||
|
tower = "0.4.13"
|
||||||
|
tower-http = { version = "0.3.4", features = ["trace"] }
|
||||||
|
tracing-subscriber = "0.3.15"
|
||||||
|
|
||||||
|
[features]
|
51
README.md
Normal file
51
README.md
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
# warehouse
|
||||||
|
|
||||||
|
A ~~simple~~ cargo registry.
|
||||||
|
|
||||||
|
To run this, you need to:
|
||||||
|
- Create a `registry.toml` file in the current directory with the following contents:
|
||||||
|
```toml
|
||||||
|
working_dir = "/path/to/some/directory"
|
||||||
|
postgres_uri = "postgres://1.2.3.4/db_name"
|
||||||
|
listen_uri = "0.0.0.0:1234"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Run a PostgreSQL database at the specified URI.
|
||||||
|
|
||||||
|
- Initialize a (not bare) git repository at `the_working_dir/index`
|
||||||
|
|
||||||
|
- Create a `index/config.json` file, with the following contents:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dl": "http://your.website:1234/api/v1/crates",
|
||||||
|
"api": "http://your.website:1234"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
HTTPS is currently broken for an unknown reason.
|
||||||
|
|
||||||
|
- Write the following to `index/.git/hooks/post-commit`:
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
exec git update-server-info
|
||||||
|
```
|
||||||
|
|
||||||
|
- Commit the `config.json` to git.
|
||||||
|
|
||||||
|
- Run any webserver at `index/.git`. (another method of serving git would also work)
|
||||||
|
|
||||||
|
Note: you may need to set `net.git-fetch-with-cli = true` in your `~/.cargo/config.toml`
|
||||||
|
|
||||||
|
The URL of that webserver/git repository is the URL of the registry.
|
||||||
|
|
||||||
|
Users must add the following to their `~/.cargo/config.toml`:
|
||||||
|
```toml
|
||||||
|
[registries]
|
||||||
|
foobarbaz = { index = "http://the.index.url" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Creating accounts is currently not managed by this code. You will have to run the following query to add a user:
|
||||||
|
```sql
|
||||||
|
INSERT INTO users (login, credential) VALUES ('username', 'account token')
|
||||||
|
```
|
||||||
|
You can then give out the token for someone else to use with `cargo login`.
|
60
src/create.sql
Normal file
60
src/create.sql
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id int NOT NULL UNIQUE GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
login text NOT NULL UNIQUE,
|
||||||
|
credential text NOT NULL UNIQUE,
|
||||||
|
name text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS crates (
|
||||||
|
id int NOT NULL UNIQUE GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
publisher int NOT NULL REFERENCES users (id),
|
||||||
|
owners int[] NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE version_feature AS (
|
||||||
|
feature text,
|
||||||
|
enables text[]
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS versions (
|
||||||
|
id int NOT NULL UNIQUE GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
vers text NOT NULL,
|
||||||
|
cksum char(64) NOT NULL,
|
||||||
|
yanked boolean NOT NULL,
|
||||||
|
links text,
|
||||||
|
crate_id int NOT NULL REFERENCES crates (id),
|
||||||
|
features version_feature[] NOT NULL,
|
||||||
|
authors text[] NOT NULL,
|
||||||
|
description text,
|
||||||
|
documentation text,
|
||||||
|
homepage text,
|
||||||
|
readme text,
|
||||||
|
readme_file text,
|
||||||
|
keywords text[] NOT NULL,
|
||||||
|
categories text[] NOT NULL,
|
||||||
|
license text,
|
||||||
|
license_file text,
|
||||||
|
repository text,
|
||||||
|
badges jsonb NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE dependency_kind AS ENUM (
|
||||||
|
'dev',
|
||||||
|
'build',
|
||||||
|
'normal'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS deps (
|
||||||
|
id int NOT NULL UNIQUE GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL REFERENCES crates (name),
|
||||||
|
version_req text NOT NULL,
|
||||||
|
optional boolean NOT NULL,
|
||||||
|
default_features boolean NOT NULL,
|
||||||
|
target text,
|
||||||
|
kind dependency_kind NOT NULL,
|
||||||
|
registry text,
|
||||||
|
package text,
|
||||||
|
features text[] NOT NULL,
|
||||||
|
version_id int NOT NULL REFERENCES versions (id)
|
||||||
|
);
|
147
src/db.rs
Normal file
147
src/db.rs
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use once_cell::sync::OnceCell;
|
||||||
|
use sqlx::{
|
||||||
|
error::BoxDynError,
|
||||||
|
postgres::{types::Oid, PgHasArrayType, PgTypeInfo},
|
||||||
|
query_as,
|
||||||
|
types::Json,
|
||||||
|
Decode, Encode, Executor, FromRow, Postgres, Type,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Default, Debug)]
|
||||||
|
#[repr(transparent)]
|
||||||
|
pub struct PgU32(pub u32);
|
||||||
|
|
||||||
|
impl PgU32 {
|
||||||
|
fn to_i32(self) -> i32 {
|
||||||
|
i32::from_ne_bytes(self.0.to_ne_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_i32(v: i32) -> Self {
|
||||||
|
Self(u32::from_ne_bytes(v.to_ne_bytes()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'r> Decode<'r, Postgres> for PgU32 {
|
||||||
|
fn decode(
|
||||||
|
value: <Postgres as sqlx::database::HasValueRef<'r>>::ValueRef,
|
||||||
|
) -> Result<Self, sqlx::error::BoxDynError> {
|
||||||
|
i32::decode(value).map(Self::from_i32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'q> Encode<'q, Postgres> for PgU32 {
|
||||||
|
fn encode_by_ref(
|
||||||
|
&self,
|
||||||
|
buf: &mut <Postgres as sqlx::database::HasArguments<'q>>::ArgumentBuffer,
|
||||||
|
) -> sqlx::encode::IsNull {
|
||||||
|
self.to_i32().encode(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn produces(&self) -> Option<<Postgres as sqlx::Database>::TypeInfo> {
|
||||||
|
self.to_i32().produces()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size_hint(&self) -> usize {
|
||||||
|
self.to_i32().size_hint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Type<Postgres> for PgU32 {
|
||||||
|
fn type_info() -> <Postgres as sqlx::Database>::TypeInfo {
|
||||||
|
i32::type_info()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PgHasArrayType for PgU32 {
|
||||||
|
fn array_type_info() -> PgTypeInfo {
|
||||||
|
i32::array_type_info()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
pub struct DbUser {
|
||||||
|
pub id: PgU32,
|
||||||
|
pub login: String,
|
||||||
|
pub credential: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
pub struct DbCrate {
|
||||||
|
pub id: PgU32,
|
||||||
|
pub name: String,
|
||||||
|
pub publisher: PgU32,
|
||||||
|
pub owners: Vec<PgU32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
pub struct DbVersion {
|
||||||
|
pub id: PgU32,
|
||||||
|
pub vers: String,
|
||||||
|
pub cksum: String,
|
||||||
|
pub yanked: bool,
|
||||||
|
pub links: Option<String>,
|
||||||
|
pub crate_id: PgU32,
|
||||||
|
pub features: Vec<DbVersionFeature>,
|
||||||
|
pub authors: Vec<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub documentation: Option<String>,
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
pub readme: Option<String>,
|
||||||
|
pub readme_file: Option<String>,
|
||||||
|
pub keywords: Vec<String>,
|
||||||
|
pub categories: Vec<String>,
|
||||||
|
pub license: Option<String>,
|
||||||
|
pub license_file: Option<String>,
|
||||||
|
pub repository: Option<String>,
|
||||||
|
pub badges: Json<HashMap<String, HashMap<String, String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
pub struct DbDep {
|
||||||
|
pub id: PgU32,
|
||||||
|
pub name: String,
|
||||||
|
pub version_req: String,
|
||||||
|
pub optional: bool,
|
||||||
|
pub default_features: bool,
|
||||||
|
pub target: Option<String>,
|
||||||
|
pub kind: String,
|
||||||
|
pub registry: Option<String>,
|
||||||
|
pub package: Option<String>,
|
||||||
|
pub features: Vec<String>,
|
||||||
|
pub version_id: PgU32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Type)]
|
||||||
|
#[sqlx(type_name = "version_feature")]
|
||||||
|
pub struct DbVersionFeature {
|
||||||
|
pub feature: String,
|
||||||
|
pub enables: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static VERSION_FEATURE_ARRAY_OID: OnceCell<Oid> = OnceCell::new();
|
||||||
|
|
||||||
|
pub async fn init<'c, E: Executor<'c, Database = Postgres> + Copy>(
|
||||||
|
e: E,
|
||||||
|
) -> Result<(), BoxDynError> {
|
||||||
|
// explicitly ignore the result, since it currently throws an error if the type already exists
|
||||||
|
let _ = e.execute(include_str!("create.sql")).await;
|
||||||
|
|
||||||
|
let (oid,): (Oid,) = query_as("SELECT typarray FROM pg_type WHERE typname = 'version_feature'")
|
||||||
|
.fetch_one(e)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
VERSION_FEATURE_ARRAY_OID
|
||||||
|
.set(oid)
|
||||||
|
.expect("db::init called multiple times");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PgHasArrayType for DbVersionFeature {
|
||||||
|
fn array_type_info() -> PgTypeInfo {
|
||||||
|
PgTypeInfo::with_oid(*VERSION_FEATURE_ARRAY_OID.get().unwrap())
|
||||||
|
}
|
||||||
|
}
|
29
src/download.rs
Normal file
29
src/download.rs
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::{BoxBody, StreamBody},
|
||||||
|
extract::Path,
|
||||||
|
http::StatusCode,
|
||||||
|
Extension,
|
||||||
|
};
|
||||||
|
use semver::Version;
|
||||||
|
use tokio::fs::File;
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
|
use crate::State;
|
||||||
|
|
||||||
|
pub async fn download(
|
||||||
|
Path((crate_name, version)): Path<(String, Version)>,
|
||||||
|
Extension(state): Extension<Arc<State>>,
|
||||||
|
) -> Result<BoxBody, StatusCode> {
|
||||||
|
let mut path = state.crate_dir.clone();
|
||||||
|
path.push(&crate_name);
|
||||||
|
path.push(version.to_string());
|
||||||
|
path.push("crate.crate");
|
||||||
|
let file = match File::open(path).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return Err(StatusCode::NOT_FOUND),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(BoxBody::new(StreamBody::new(ReaderStream::new(file))))
|
||||||
|
}
|
188
src/index.rs
Normal file
188
src/index.rs
Normal file
|
@ -0,0 +1,188 @@
|
||||||
|
use std::{collections::HashMap, process::Stdio, str::FromStr};
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use semver::{Version, VersionReq};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::query_as;
|
||||||
|
use tokio::{
|
||||||
|
fs::{self, OpenOptions},
|
||||||
|
io::AsyncWriteExt,
|
||||||
|
process::Command,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{DbDep, DbVersion, PgU32},
|
||||||
|
db_error, get_crate_prefix, internal_error, Errors, State, INDEX_LOCK,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct CrateVersion {
|
||||||
|
pub name: String,
|
||||||
|
pub vers: Version,
|
||||||
|
pub deps: Vec<Dependency>,
|
||||||
|
pub cksum: String,
|
||||||
|
pub features: HashMap<String, Vec<String>>,
|
||||||
|
pub yanked: bool,
|
||||||
|
pub links: Option<String>,
|
||||||
|
/// Should always be `1` for maximum compatability
|
||||||
|
pub v: u32,
|
||||||
|
// `features2` exists, but for out purposes, it can be ignored. See https://doc.rust-lang.org/cargo/reference/registries.html
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Dependency {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub base: BaseDependency,
|
||||||
|
pub package: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct BaseDependency {
|
||||||
|
pub name: String,
|
||||||
|
pub version_req: VersionReq,
|
||||||
|
pub features: Vec<String>,
|
||||||
|
pub optional: bool,
|
||||||
|
pub default_features: bool,
|
||||||
|
pub target: Option<String>,
|
||||||
|
pub kind: DependencyKind,
|
||||||
|
pub registry: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Copy)]
|
||||||
|
pub enum DependencyKind {
|
||||||
|
Dev,
|
||||||
|
Build,
|
||||||
|
Normal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DependencyKind {
|
||||||
|
pub fn to_db_repr(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
DependencyKind::Dev => "dev",
|
||||||
|
DependencyKind::Build => "build",
|
||||||
|
DependencyKind::Normal => "normal",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for DependencyKind {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"dev" => Ok(DependencyKind::Dev),
|
||||||
|
"build" => Ok(DependencyKind::Build),
|
||||||
|
"normal" => Ok(DependencyKind::Normal),
|
||||||
|
_ => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_crate_from_db(
|
||||||
|
crate_id: PgU32,
|
||||||
|
state: &State,
|
||||||
|
message: &str,
|
||||||
|
) -> Result<(), Errors> {
|
||||||
|
let lock = INDEX_LOCK.lock().await;
|
||||||
|
|
||||||
|
let mut db = state.db.acquire().await.map_err(db_error)?;
|
||||||
|
|
||||||
|
let (mut crate_name,): (String,) = query_as("SELECT name FROM crates WHERE id = $1")
|
||||||
|
.bind(crate_id)
|
||||||
|
.fetch_one(&mut db)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
|
||||||
|
// we use `fetch_all` here since we cant use `fetch` because of conflicting mutable borrows
|
||||||
|
let versions = query_as::<_, DbVersion>("SELECT * FROM versions WHERE crate_id = $1")
|
||||||
|
.bind(crate_id)
|
||||||
|
.fetch_all(&mut db)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
|
||||||
|
let mut versions2 = Vec::new();
|
||||||
|
for version in versions {
|
||||||
|
let mut deps = query_as::<_, DbDep>("SELECT * FROM deps WHERE version_id = $1")
|
||||||
|
.bind(version.id)
|
||||||
|
.fetch(&mut db);
|
||||||
|
|
||||||
|
let mut deps2 = Vec::new();
|
||||||
|
while let Some(dep) = deps.next().await.transpose().map_err(db_error)? {
|
||||||
|
deps2.push(Dependency {
|
||||||
|
base: BaseDependency {
|
||||||
|
name: dep.name,
|
||||||
|
version_req: dep.version_req.parse().unwrap(),
|
||||||
|
features: dep.features,
|
||||||
|
optional: dep.optional,
|
||||||
|
default_features: dep.default_features,
|
||||||
|
target: dep.target,
|
||||||
|
kind: dep.kind.parse().unwrap(),
|
||||||
|
registry: dep.registry,
|
||||||
|
},
|
||||||
|
package: dep.package,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
versions2.push(CrateVersion {
|
||||||
|
name: crate_name.clone(),
|
||||||
|
vers: version.vers.parse().unwrap(),
|
||||||
|
deps: deps2,
|
||||||
|
cksum: version.cksum,
|
||||||
|
features: version
|
||||||
|
.features
|
||||||
|
.into_iter()
|
||||||
|
.map(|v| (v.feature, v.enables))
|
||||||
|
.collect(),
|
||||||
|
yanked: version.yanked,
|
||||||
|
links: version.links,
|
||||||
|
v: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
crate_name.make_ascii_lowercase();
|
||||||
|
|
||||||
|
let mut path = state.index_dir.clone();
|
||||||
|
path.push(&get_crate_prefix(&crate_name).unwrap());
|
||||||
|
fs::create_dir_all(&path).await.map_err(internal_error)?;
|
||||||
|
path.push(&crate_name);
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(&path)
|
||||||
|
.await
|
||||||
|
.map_err(internal_error)?;
|
||||||
|
|
||||||
|
for version in versions2 {
|
||||||
|
let mut buf = serde_json::to_vec(&version).map_err(internal_error)?;
|
||||||
|
buf.push(b'\n');
|
||||||
|
file.write_all(&buf).await.map_err(internal_error)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::new("git")
|
||||||
|
.arg("add")
|
||||||
|
.arg(&path)
|
||||||
|
.current_dir(&state.index_dir)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.map_err(internal_error)?;
|
||||||
|
|
||||||
|
Command::new("git")
|
||||||
|
.arg("commit")
|
||||||
|
.arg("-m")
|
||||||
|
.arg(message)
|
||||||
|
.current_dir(&state.index_dir)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.map_err(internal_error)?;
|
||||||
|
|
||||||
|
drop(lock);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
256
src/main.rs
Normal file
256
src/main.rs
Normal file
|
@ -0,0 +1,256 @@
|
||||||
|
mod db;
|
||||||
|
mod download;
|
||||||
|
mod index;
|
||||||
|
mod new_crate;
|
||||||
|
mod owners;
|
||||||
|
mod search;
|
||||||
|
mod yank;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{FromRequest, RequestParts},
|
||||||
|
http::{header::AUTHORIZATION, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{delete, get, put},
|
||||||
|
Extension, Json, Router,
|
||||||
|
};
|
||||||
|
use db::DbUser;
|
||||||
|
use download::download;
|
||||||
|
use owners::{add_owners, list_owners, remove_owners};
|
||||||
|
use search::search;
|
||||||
|
use serde::{ser::SerializeStruct, Deserialize, Serialize};
|
||||||
|
use sqlx::{error::BoxDynError, postgres::PgPoolOptions, query_as, Pool, Postgres};
|
||||||
|
use std::{fmt::Display, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||||
|
use tokio::{fs, sync::Mutex};
|
||||||
|
use tower::ServiceBuilder;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
use tracing_subscriber::filter::LevelFilter;
|
||||||
|
use yank::{unyank, yank};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Errors {
|
||||||
|
errors: Vec<SingleError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Errors {
|
||||||
|
fn new(v: impl ToString) -> Self {
|
||||||
|
Self {
|
||||||
|
errors: vec![SingleError {
|
||||||
|
detail: v.to_string(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_many(v: impl IntoIterator<Item = impl ToString>) -> Errors {
|
||||||
|
Self {
|
||||||
|
errors: v
|
||||||
|
.into_iter()
|
||||||
|
.map(|v| SingleError {
|
||||||
|
detail: v.to_string(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: ToString> From<T> for Errors {
|
||||||
|
fn from(v: T) -> Self {
|
||||||
|
Self::new(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for Errors {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
Json(self).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Errors> for Response {
|
||||||
|
fn from(v: Errors) -> Self {
|
||||||
|
v.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type RespResult<T = Success> = std::result::Result<T, Response>;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SingleError {
|
||||||
|
detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_crate_prefix(s: &str) -> Result<PathBuf, &'static str> {
|
||||||
|
if s.is_empty() {
|
||||||
|
return Err("Crate name must be non-empty");
|
||||||
|
} else if !s.is_ascii() {
|
||||||
|
return Err("Crate name must be ASCII");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buf = PathBuf::new();
|
||||||
|
if s.len() == 1 {
|
||||||
|
buf.push("1");
|
||||||
|
} else if s.len() == 2 {
|
||||||
|
buf.push("2");
|
||||||
|
} else if s.len() == 3 {
|
||||||
|
buf.push("3");
|
||||||
|
let c = s.chars().next().unwrap().to_ascii_lowercase();
|
||||||
|
let mut b = [0];
|
||||||
|
buf.push(c.encode_utf8(&mut b));
|
||||||
|
} else {
|
||||||
|
let mut b: [u8; 4] = s.as_bytes()[0..4].try_into().unwrap();
|
||||||
|
let s = std::str::from_utf8_mut(&mut b).unwrap();
|
||||||
|
s.make_ascii_lowercase();
|
||||||
|
buf.push(&s[0..2]);
|
||||||
|
buf.push(&s[2..4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct State {
|
||||||
|
index_dir: PathBuf,
|
||||||
|
crate_dir: PathBuf,
|
||||||
|
db: Pool<Postgres>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static INDEX_LOCK: Mutex<()> = Mutex::const_new(());
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
working_dir: PathBuf,
|
||||||
|
postgres_uri: String,
|
||||||
|
listen_uri: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), BoxDynError> {
|
||||||
|
let config = toml::from_str::<Config>(
|
||||||
|
&fs::read_to_string("registry.toml")
|
||||||
|
.await
|
||||||
|
.expect("Config file missing"),
|
||||||
|
)
|
||||||
|
.expect("Invalid config file");
|
||||||
|
|
||||||
|
let mut index_dir = config.working_dir.clone();
|
||||||
|
index_dir.push("index");
|
||||||
|
let mut crate_dir = config.working_dir;
|
||||||
|
crate_dir.push("crates");
|
||||||
|
let state = Arc::new(State {
|
||||||
|
index_dir,
|
||||||
|
crate_dir,
|
||||||
|
db: PgPoolOptions::new().connect(&config.postgres_uri).await?,
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_max_level(LevelFilter::TRACE)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
db::init(&state.db).await?;
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/api/v1/crates/new", put(new_crate::new_crate))
|
||||||
|
.route("/api/v1/crates/:crate_name/:version/yank", delete(yank))
|
||||||
|
.route("/api/v1/crates/:crate_name/:version/unyank", put(unyank))
|
||||||
|
.route(
|
||||||
|
"/api/v1/crates/:crate_name/owners",
|
||||||
|
get(list_owners).put(add_owners).delete(remove_owners),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/crates/:crate_name/:version/download",
|
||||||
|
get(download),
|
||||||
|
)
|
||||||
|
.route("/api/v1/crates", get(search))
|
||||||
|
.layer(
|
||||||
|
ServiceBuilder::new()
|
||||||
|
.layer(Extension(state.clone()))
|
||||||
|
.layer(TraceLayer::new_for_http()),
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::create_dir_all(&state.index_dir).await?;
|
||||||
|
fs::create_dir_all(&state.crate_dir).await?;
|
||||||
|
|
||||||
|
axum::Server::bind(&config.listen_uri)
|
||||||
|
.serve(app.into_make_service())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Auth(DbUser);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FromRequest<Body> for Auth {
|
||||||
|
type Rejection = Response;
|
||||||
|
|
||||||
|
async fn from_request(req: &mut RequestParts<Body>) -> Result<Self, Self::Rejection> {
|
||||||
|
let Extension(state): Extension<Arc<State>> = Extension::from_request(req)
|
||||||
|
.await
|
||||||
|
.map_err(IntoResponse::into_response)?;
|
||||||
|
|
||||||
|
if let Some(auth) = req
|
||||||
|
.headers()
|
||||||
|
.get(AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
{
|
||||||
|
if let Some(db_user) =
|
||||||
|
sqlx::query_as::<_, DbUser>("SELECT * FROM users WHERE credential = $1 LIMIT 1")
|
||||||
|
.bind(auth)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)
|
||||||
|
.map_err(IntoResponse::into_response)?
|
||||||
|
{
|
||||||
|
Ok(Self(db_user))
|
||||||
|
} else {
|
||||||
|
Err(StatusCode::FORBIDDEN.into_response())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(StatusCode::FORBIDDEN.into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn auth(crate_name: &str, auth_user: &DbUser, state: &State) -> Result<(), Response> {
|
||||||
|
let (is_authenticated,): (bool,) =
|
||||||
|
query_as("SELECT $1 = ANY (crates.owners) FROM crates WHERE name = $2")
|
||||||
|
.bind(auth_user.id)
|
||||||
|
.bind(&crate_name)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)
|
||||||
|
.map_err(IntoResponse::into_response)?;
|
||||||
|
|
||||||
|
if !is_authenticated {
|
||||||
|
return Err(StatusCode::FORBIDDEN.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Success;
|
||||||
|
|
||||||
|
impl Serialize for Success {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
let mut serialize_struct = serializer.serialize_struct("Struct", 1)?;
|
||||||
|
serialize_struct.serialize_field("ok", &true)?;
|
||||||
|
serialize_struct.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for Success {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
Json(self).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_error<E: Display>(e: E) -> Errors {
|
||||||
|
Errors::new(format_args!("Internal server error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn db_error<E: Display>(e: E) -> Errors {
|
||||||
|
Errors::new(format_args!("Database error: {e}"))
|
||||||
|
}
|
251
src/new_crate.rs
Normal file
251
src/new_crate.rs
Normal file
|
@ -0,0 +1,251 @@
|
||||||
|
use crate::{
|
||||||
|
auth,
|
||||||
|
db::{DbVersionFeature, PgU32},
|
||||||
|
db_error,
|
||||||
|
index::{update_crate_from_db, BaseDependency},
|
||||||
|
internal_error, Auth, Errors, RespResult, State,
|
||||||
|
};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::{
|
||||||
|
body::{Body, Bytes},
|
||||||
|
extract::{FromRequest, RequestParts},
|
||||||
|
Extension, Json,
|
||||||
|
};
|
||||||
|
use semver::Version;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{query, query_as, types::Json as SqlxJson, Connection};
|
||||||
|
use std::{collections::HashMap, fmt::Write, sync::Arc};
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
pub struct NewCrateRequest {
|
||||||
|
json_data: NewCrateJsonData,
|
||||||
|
crate_file: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FromRequest<Body> for NewCrateRequest {
|
||||||
|
type Rejection = Errors;
|
||||||
|
|
||||||
|
async fn from_request(req: &mut RequestParts<Body>) -> Result<Self, Self::Rejection> {
|
||||||
|
let body = Bytes::from_request(req).await.unwrap();
|
||||||
|
|
||||||
|
let mut offset = 0;
|
||||||
|
|
||||||
|
let json_len_bytes = body
|
||||||
|
.get(offset..offset + 4)
|
||||||
|
.ok_or_else(|| Errors::new("Not enough bytes when reading length of JSON data"))?;
|
||||||
|
let json_len = u32::from_le_bytes(json_len_bytes.try_into().unwrap()) as usize;
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
let json_bytes = body
|
||||||
|
.get(offset..offset + json_len)
|
||||||
|
.ok_or_else(|| Errors::new("Not enough bytes when reading JSON data"))?;
|
||||||
|
let json_data = serde_json::from_slice::<NewCrateJsonData>(json_bytes)
|
||||||
|
.map_err(|e| Errors::new(format_args!("Error while parsing JSON data: {e}")))?;
|
||||||
|
offset += json_len;
|
||||||
|
|
||||||
|
let crate_len_bytes = body
|
||||||
|
.get(offset..offset + 4)
|
||||||
|
.ok_or_else(|| Errors::new("Not enough bytes when reading length of .crate file"))?;
|
||||||
|
let crate_len = u32::from_le_bytes(crate_len_bytes.try_into().unwrap()) as usize;
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
let crate_file = body
|
||||||
|
.get(offset..offset + crate_len)
|
||||||
|
.ok_or_else(|| Errors::new("Not enough bytes when reading .crate file"))?
|
||||||
|
.to_vec();
|
||||||
|
offset += crate_len;
|
||||||
|
|
||||||
|
if body.len() != offset {
|
||||||
|
return Err(Errors::new("Too much data provided"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
json_data,
|
||||||
|
crate_file,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NewCrateJsonData {
|
||||||
|
name: String,
|
||||||
|
vers: Version,
|
||||||
|
deps: Vec<Dependency2>,
|
||||||
|
features: HashMap<String, Vec<String>>,
|
||||||
|
authors: Vec<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
documentation: Option<String>,
|
||||||
|
homepage: Option<String>,
|
||||||
|
readme: Option<String>,
|
||||||
|
readme_file: Option<String>,
|
||||||
|
keywords: Vec<String>,
|
||||||
|
categories: Vec<String>,
|
||||||
|
license: Option<String>,
|
||||||
|
license_file: Option<String>,
|
||||||
|
repository: Option<String>,
|
||||||
|
badges: HashMap<String, HashMap<String, String>>,
|
||||||
|
links: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Dependency2 {
|
||||||
|
#[serde(flatten)]
|
||||||
|
base: BaseDependency,
|
||||||
|
explicit_name_in_toml: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct NewCrateResponse {
|
||||||
|
warnings: Warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Warnings {
|
||||||
|
invalid_categories: Vec<String>,
|
||||||
|
invalid_badges: Vec<String>,
|
||||||
|
other: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn new_crate(
|
||||||
|
mut request: NewCrateRequest,
|
||||||
|
Auth(auth_user): Auth,
|
||||||
|
Extension(state): Extension<Arc<State>>,
|
||||||
|
) -> RespResult<Json<NewCrateResponse>> {
|
||||||
|
if !request.json_data.name.is_ascii() {
|
||||||
|
return Err(Errors::new("Crate name must be ASCII").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
request.json_data.name.make_ascii_lowercase();
|
||||||
|
|
||||||
|
let mut db = state.db.acquire().await.map_err(db_error)?;
|
||||||
|
|
||||||
|
let crate_id = {
|
||||||
|
let mut trans = db.begin().await.map_err(db_error)?;
|
||||||
|
|
||||||
|
let (crate_id,): (PgU32,) = match query_as("SELECT id FROM crates WHERE name = $1 LIMIT 1")
|
||||||
|
.bind(&request.json_data.name)
|
||||||
|
.fetch_optional(&mut *trans)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?
|
||||||
|
{
|
||||||
|
Some(v) => {
|
||||||
|
auth(&request.json_data.name, &auth_user, &state).await?;
|
||||||
|
v
|
||||||
|
}
|
||||||
|
None => query_as(
|
||||||
|
"INSERT INTO crates (name, publisher, owners) VALUES ($1, $2, $3) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(&request.json_data.name)
|
||||||
|
.bind(auth_user.id)
|
||||||
|
.bind(&[auth_user.id][..])
|
||||||
|
.fetch_one(&mut *trans)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let hash = hmac_sha256::Hash::hash(&request.crate_file);
|
||||||
|
let mut cksum = String::new();
|
||||||
|
for byte in hash {
|
||||||
|
write!(cksum, "{byte:02x}").expect("Formatting to a String failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
let (version_id,): (PgU32,) = query_as(
|
||||||
|
r"INSERT INTO versions (
|
||||||
|
vers, cksum, yanked, links,
|
||||||
|
crate_id, features,
|
||||||
|
authors, description, documentation,
|
||||||
|
homepage, readme, readme_file,
|
||||||
|
keywords, categories,
|
||||||
|
license, license_file,
|
||||||
|
repository, badges
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(&request.json_data.vers.to_string())
|
||||||
|
.bind(&cksum)
|
||||||
|
.bind(false)
|
||||||
|
.bind(&request.json_data.links)
|
||||||
|
.bind(crate_id)
|
||||||
|
.bind(
|
||||||
|
&request
|
||||||
|
.json_data
|
||||||
|
.features
|
||||||
|
.iter()
|
||||||
|
.map(|v| DbVersionFeature {
|
||||||
|
feature: v.0.clone(),
|
||||||
|
enables: v.1.clone(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.bind(&request.json_data.authors)
|
||||||
|
.bind(&request.json_data.description)
|
||||||
|
.bind(&request.json_data.documentation)
|
||||||
|
.bind(&request.json_data.homepage)
|
||||||
|
.bind(&request.json_data.readme)
|
||||||
|
.bind(&request.json_data.readme_file)
|
||||||
|
.bind(&request.json_data.keywords)
|
||||||
|
.bind(&request.json_data.categories)
|
||||||
|
.bind(&request.json_data.license)
|
||||||
|
.bind(&request.json_data.license_file)
|
||||||
|
.bind(&request.json_data.repository)
|
||||||
|
.bind(SqlxJson(&request.json_data.badges))
|
||||||
|
.fetch_one(&mut *trans)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
|
||||||
|
for dep in &request.json_data.deps {
|
||||||
|
query(
|
||||||
|
r"INSERT INTO deps (
|
||||||
|
name, version_req, optional, default_features,
|
||||||
|
target, kind, registry, package, feature, version_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||||
|
)
|
||||||
|
.bind(&dep.base.name)
|
||||||
|
.bind(&dep.base.version_req.to_string())
|
||||||
|
.bind(&dep.base.optional)
|
||||||
|
.bind(&dep.base.default_features)
|
||||||
|
.bind(&dep.base.target)
|
||||||
|
.bind(&dep.base.kind.to_db_repr())
|
||||||
|
.bind(&dep.base.registry)
|
||||||
|
.bind(&dep.explicit_name_in_toml)
|
||||||
|
.bind(&dep.base.features)
|
||||||
|
.bind(version_id)
|
||||||
|
.execute(&mut *trans)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
trans.commit().await.map_err(db_error)?;
|
||||||
|
|
||||||
|
crate_id
|
||||||
|
};
|
||||||
|
|
||||||
|
update_crate_from_db(
|
||||||
|
crate_id,
|
||||||
|
&state,
|
||||||
|
&format!(
|
||||||
|
"Add version {} of '{}'",
|
||||||
|
request.json_data.vers, request.json_data.name
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut path = state.crate_dir.clone();
|
||||||
|
path.push(&request.json_data.name);
|
||||||
|
path.push(request.json_data.vers.to_string());
|
||||||
|
fs::create_dir_all(&path).await.map_err(internal_error)?;
|
||||||
|
path.push("crate.crate");
|
||||||
|
fs::write(path, request.crate_file)
|
||||||
|
.await
|
||||||
|
.map_err(internal_error)?;
|
||||||
|
|
||||||
|
Ok(Json(NewCrateResponse {
|
||||||
|
warnings: Warnings {
|
||||||
|
invalid_categories: Vec::new(),
|
||||||
|
invalid_badges: Vec::new(),
|
||||||
|
other: Vec::new(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
135
src/owners.rs
Normal file
135
src/owners.rs
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::{
|
||||||
|
body::HttpBody,
|
||||||
|
extract::{FromRequest, Path, RequestParts},
|
||||||
|
BoxError, Extension, Json as AxumJson,
|
||||||
|
};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||||
|
use sqlx::{query, query_as};
|
||||||
|
|
||||||
|
use crate::{auth, db::PgU32, db_error, Auth, Errors, RespResult, State, Success};
|
||||||
|
|
||||||
|
// TODO: custom `Json` errors
|
||||||
|
|
||||||
|
pub struct Json<T>(T);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T, B> FromRequest<B> for Json<T>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
B: HttpBody + Send,
|
||||||
|
B::Data: Send,
|
||||||
|
B::Error: Into<BoxError>,
|
||||||
|
{
|
||||||
|
type Rejection = Errors;
|
||||||
|
|
||||||
|
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
|
||||||
|
let res = AxumJson::<T>::from_request(req).await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(AxumJson(v)) => Ok(Json(v)),
|
||||||
|
Err(e) => Err(Errors::new(format_args!("Invalid json data: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ListOwnersResponse {
|
||||||
|
users: Vec<User>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct User {
|
||||||
|
id: u32,
|
||||||
|
login: String,
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_owners(
|
||||||
|
Path(crate_name): Path<String>,
|
||||||
|
Auth(_auth_user): Auth,
|
||||||
|
Extension(state): Extension<Arc<State>>,
|
||||||
|
) -> RespResult<AxumJson<ListOwnersResponse>> {
|
||||||
|
let mut users = query_as("SELECT users.id, users.login, users.name FROM crates INNER JOIN users ON users.id = ANY (crates.owners) WHERE crates.name = $1")
|
||||||
|
.bind(&crate_name)
|
||||||
|
.fetch(&state.db);
|
||||||
|
|
||||||
|
let mut users2 = Vec::new();
|
||||||
|
while let Some(user) = users.next().await.transpose().map_err(db_error)? {
|
||||||
|
let _: (PgU32, _, _) = user;
|
||||||
|
users2.push(User {
|
||||||
|
id: user.0 .0,
|
||||||
|
login: user.1,
|
||||||
|
name: user.2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(AxumJson(ListOwnersResponse { users: users2 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AddRemoveOwnersRequest {
|
||||||
|
users: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct AddRemoveOwnersResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
success: Success,
|
||||||
|
msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_owners(
|
||||||
|
Json(request): Json<AddRemoveOwnersRequest>,
|
||||||
|
Path(crate_name): Path<String>,
|
||||||
|
Auth(auth_user): Auth,
|
||||||
|
Extension(state): Extension<Arc<State>>,
|
||||||
|
) -> RespResult<AxumJson<AddRemoveOwnersResponse>> {
|
||||||
|
auth(&crate_name, &auth_user, &state).await?;
|
||||||
|
|
||||||
|
query(
|
||||||
|
r"UPDATE crates SET owners = owners || (
|
||||||
|
SELECT array_agg(users.id) FROM users INNER JOIN unnest($1) ON users.login = unnest
|
||||||
|
) WHERE name = $2",
|
||||||
|
)
|
||||||
|
.bind(&request.users)
|
||||||
|
.bind(&crate_name)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
|
||||||
|
Ok(AxumJson(AddRemoveOwnersResponse {
|
||||||
|
success: Success,
|
||||||
|
msg: format!("adding {:?} to crate {}", request.users, crate_name),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_owners(
|
||||||
|
Json(request): Json<AddRemoveOwnersRequest>,
|
||||||
|
Path(crate_name): Path<String>,
|
||||||
|
Auth(auth_user): Auth,
|
||||||
|
Extension(state): Extension<Arc<State>>,
|
||||||
|
) -> RespResult<AxumJson<AddRemoveOwnersResponse>> {
|
||||||
|
auth(&crate_name, &auth_user, &state).await?;
|
||||||
|
|
||||||
|
query(
|
||||||
|
r"UPDATE crates SET owners = (
|
||||||
|
SELECT array_agg(unnest) FROM (
|
||||||
|
SELECT unnest(owners) EXCEPT (SELECT users.id FROM users INNER JOIN unnest($1) ON users.login = unnest)
|
||||||
|
) t (unnest)
|
||||||
|
) WHERE name = $2",
|
||||||
|
)
|
||||||
|
.bind(&request.users)
|
||||||
|
.bind(&crate_name)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
|
||||||
|
Ok(AxumJson(AddRemoveOwnersResponse {
|
||||||
|
success: Success,
|
||||||
|
msg: format!("removed {:?} from crate {}", request.users, crate_name),
|
||||||
|
}))
|
||||||
|
}
|
95
src/search.rs
Normal file
95
src/search.rs
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::{
|
||||||
|
extract::{FromRequest, Query as AxumQuery, RequestParts},
|
||||||
|
Extension, Json,
|
||||||
|
};
|
||||||
|
use semver::Version;
|
||||||
|
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||||
|
use sqlx::query_as;
|
||||||
|
|
||||||
|
use crate::{db::PgU32, db_error, Errors, RespResult, State};
|
||||||
|
|
||||||
|
pub struct Query<T>(T);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T, B> FromRequest<B> for Query<T>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
B: Send,
|
||||||
|
{
|
||||||
|
type Rejection = Errors;
|
||||||
|
|
||||||
|
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
|
||||||
|
let res = AxumQuery::<T>::from_request(req).await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(AxumQuery(v)) => Ok(Query(v)),
|
||||||
|
Err(e) => Err(Errors::new(format_args!("Invalid query options: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SearchQueryParams {
|
||||||
|
q: String,
|
||||||
|
per_page: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SearchResponse {
|
||||||
|
crates: Vec<Crate>,
|
||||||
|
meta: Meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Crate {
|
||||||
|
name: String,
|
||||||
|
max_version: Version,
|
||||||
|
description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Meta {
|
||||||
|
total: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search(
|
||||||
|
Query(params): Query<SearchQueryParams>,
|
||||||
|
Extension(state): Extension<Arc<State>>,
|
||||||
|
) -> RespResult<Json<SearchResponse>> {
|
||||||
|
let crates = query_as::<_, (PgU32, String)>(
|
||||||
|
"SELECT id, name FROM crates ORDER BY SIMILARITY(name, $1) DESC LIMIT $2;",
|
||||||
|
)
|
||||||
|
.bind(¶ms.q)
|
||||||
|
.bind(i16::from(params.per_page.unwrap_or(10)))
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
|
||||||
|
let mut crates2 = Vec::new();
|
||||||
|
for craet in crates {
|
||||||
|
let v: Option<(String, Option<String>)> = query_as(
|
||||||
|
"SELECT vers, description FROM versions WHERE crate_id = $1 ORDER BY vers DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(craet.0)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
|
||||||
|
if let Some((vers, desc)) = v {
|
||||||
|
crates2.push(Crate {
|
||||||
|
name: craet.1,
|
||||||
|
max_version: vers.parse().unwrap(),
|
||||||
|
description: desc.unwrap_or_else(|| "".to_owned()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(SearchResponse {
|
||||||
|
crates: crates2,
|
||||||
|
// TODO: total
|
||||||
|
meta: Meta { total: 0 },
|
||||||
|
}))
|
||||||
|
}
|
86
src/yank.rs
Normal file
86
src/yank.rs
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::{
|
||||||
|
extract::{FromRequest, Path as AxumPath, RequestParts},
|
||||||
|
Extension,
|
||||||
|
};
|
||||||
|
use semver::Version;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use sqlx::query_as;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth, db::PgU32, db_error, index::update_crate_from_db, Auth, Errors, RespResult, State,
|
||||||
|
Success,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Path<T>(T);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T, B> FromRequest<B> for Path<T>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned + Send,
|
||||||
|
B: Send,
|
||||||
|
{
|
||||||
|
type Rejection = Errors;
|
||||||
|
|
||||||
|
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
|
||||||
|
let res = AxumPath::<T>::from_request(req).await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(AxumPath(v)) => Ok(Path(v)),
|
||||||
|
Err(e) => Err(Errors::new(format_args!("Invalid path fragments: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn yank(
|
||||||
|
Path((crate_name, version)): Path<(String, Version)>,
|
||||||
|
Auth(auth_user): Auth,
|
||||||
|
Extension(state): Extension<Arc<State>>,
|
||||||
|
) -> RespResult {
|
||||||
|
auth(&crate_name, &auth_user, &state).await?;
|
||||||
|
|
||||||
|
do_yank(&crate_name, &version, &state, true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unyank(
|
||||||
|
Path((crate_name, version)): Path<(String, Version)>,
|
||||||
|
Auth(auth_user): Auth,
|
||||||
|
Extension(state): Extension<Arc<State>>,
|
||||||
|
) -> RespResult {
|
||||||
|
auth(&crate_name, &auth_user, &state).await?;
|
||||||
|
|
||||||
|
do_yank(&crate_name, &version, &state, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_yank(crate_name: &str, version: &Version, state: &State, yank: bool) -> RespResult {
|
||||||
|
let (crate_id,): (PgU32,) = query_as(
|
||||||
|
r"UPDATE versions SET yanked = $1
|
||||||
|
FROM crates
|
||||||
|
WHERE crates.id = versions.crate_id
|
||||||
|
and crates.name = $2
|
||||||
|
and versions.vers = $3
|
||||||
|
RETURNING crates.id",
|
||||||
|
)
|
||||||
|
.bind(yank)
|
||||||
|
.bind(crate_name)
|
||||||
|
.bind(&version.to_string())
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
|
||||||
|
update_crate_from_db(
|
||||||
|
crate_id,
|
||||||
|
state,
|
||||||
|
&format!(
|
||||||
|
"{} version {} of `{}`",
|
||||||
|
if yank { "Yank" } else { "Unyank" },
|
||||||
|
version,
|
||||||
|
crate_name
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Success)
|
||||||
|
}
|
Loading…
Reference in a new issue