Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .maint
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
backend
41 changes: 36 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,47 @@
## About The Project
<div align="center">
<a href="https://github.com/metakgp/maintos">
<img width="80%" alt="image" src="https://user-images.githubusercontent.com/86282911/206632547-a3b34b47-e7ae-4186-a1e6-ecda7ddb38e6.png">
<img width="80%" alt="image" src="https://gist.github.com/user-attachments/assets/e5f7e679-b7d4-413f-a874-2de638461780">
</a>
</div>

_Detailed explaination of the project goes here_
_Maintos_ is a maintainer's dashboard which gives maintainers access to information and control on their projects, without requiring explicit access to the server. Maintainers of a project can start/stop the running containers/services, read logs for the project, as well as see and update environment variables.

<p align="right">(<a href="#top">back to top</a>)</p>

## Development
[WIP]

1. Clone this repository.
2. Backend:
- Copy `.env.template` to `.env` and update the values as per [Environment Variables](#environment-variables).
- For the backend to run, Docker must be installed and running.
- Run the backend:
```bash
cargo run
```
3. Frontend:
- Set the environment variables in `.env.local`:
- `VITE_BACKEND_URL`: URL of the backend
- `VITE_GH_OAUTH_CLIENT_ID`: Client ID of the GitHub OAuth App.
- Run the frontend:
```bash
npm install
npm run dev
```


### Environment Variables

This project needs a [GitHub OAuth app](https://github.com/settings/developers) and a [Personal Access Token](https://github.com/settings/personal-access-tokens) of an admin of the GitHub org.

- `GH_CLIENT_ID`, `GH_CLIENT_SECRET`: Client ID and Client Secret for the GitHub OAuth application.
- `GH_ORG_NAME`: Name of the GitHub organisation
- `GH_ORG_ADMIN_TOKEN`: A GitHub PAT of an org admin
- `JWT_SECRET`: A secure string (for signing JWTs)
- `DEPLOYMENTS_DIR`: Absolute path to directory containing all the project git repos (deployed)
- `SERVER_PORT`: Port where the backend server listens to
- `CORS_ALLOWED_ORIGINS`: Frontend URLs


## Deployment
[WIP]
Expand Down Expand Up @@ -98,12 +129,12 @@ See https://wiki.metakgp.org/w/Metakgp:Project_Maintainer.
- [Harsh Khandeparkar](https://github.com/harshkhandeparkar)
- [Devansh Gupta](https://github.com/Devansh-bit)

### Past Maintainer(s)
<!-- ### Past Maintainer(s)

Previous maintainer(s) of this project.
See https://wiki.metakgp.org/w/Metakgp:Project_Maintainer.

<p align="right">(<a href="#top">back to top</a>)</p>
<p align="right">(<a href="#top">back to top</a>)</p> -->

## Additional documentation

Expand Down
1 change: 1 addition & 0 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ http = "1.3.1"
jwt = "0.16.0"
reqwest = { version = "0.12.24", features = ["json"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
serde_json = { version = "1.0.145", features = ["preserve_order"] }
sha2 = "0.10.9"
tokio = { version = "1.48.0", features = ["full"] }
tower-http = { version = "0.6.6", features = ["cors", "trace"] }
Expand Down
29 changes: 28 additions & 1 deletion backend/src/routing/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ use axum::extract::State;
use axum::{Extension, extract::Json, http::StatusCode};
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;

use crate::auth::{self, Auth};
use crate::utils::{Deployment, get_deployments};
use crate::utils::{Deployment, get_deployments, get_env};

use super::{AppError, BackendResponse, RouterState};

Expand Down Expand Up @@ -87,3 +88,29 @@ pub async fn deployments(
get_deployments(&state.env_vars, &auth.username).await?,
))
}

#[derive(Deserialize)]
/// The request format for the get environment variables endpoint
pub struct EnvVarsReq {
project_name: String,
}

/// Gets the environment variables for a project if the user has access to it
pub async fn get_env_vars(
State(state): HandlerState,
Extension(auth): Extension<Auth>,
Json(body): Json<EnvVarsReq>,
) -> HandlerReturn<Value> {
let project_name = body.project_name.as_str();
if let Ok(env_vars) = get_env(&state.env_vars, &auth.username, project_name).await {
return Ok(BackendResponse::ok(
"Successfully fetched environment variables.".into(),
env_vars,
));
} else {
return Ok(BackendResponse::error(
"Error: Project not found or access denied.".into(),
StatusCode::NOT_FOUND,
));
}
}
1 change: 1 addition & 0 deletions backend/src/routing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub fn get_router(env_vars: &EnvVars, docker: Arc<Docker>) -> axum::Router {
axum::Router::new()
.route("/profile", axum::routing::get(handlers::profile))
.route("/deployments", axum::routing::get(handlers::deployments))
.route("/get_env", axum::routing::post(handlers::get_env_vars))
.route_layer(axum::middleware::from_fn_with_state(
state.clone(),
middleware::verify_jwt_middleware,
Expand Down
160 changes: 102 additions & 58 deletions backend/src/utils.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use std::str::FromStr;
use std::{path::PathBuf, str::FromStr};

use anyhow::anyhow;
use git2::Repository;
use reqwest::Url;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tokio::fs;

use crate::{env::EnvVars, github};
Expand All @@ -19,76 +20,119 @@ pub struct Deployment {
repo_name: String,
}

/// Check if a user has permission to access a project
pub async fn check_access(env_vars: &EnvVars, username: &str, project_name: &str) -> Res<Deployment> {
let deployments_dir = &env_vars.deployments_dir;

let client = reqwest::Client::new();
let repo_path = format!("{}/{}", deployments_dir.display(), project_name);
let repo = Repository::open(repo_path)?;
let repo_url = repo
.find_remote("origin")?
.url()
.ok_or(anyhow!(
"Error: Origin remote URL not found for repo {project_name}."
))?
.to_string();
let parsed_url = Url::from_str(&repo_url)?;
let mut url_paths = parsed_url
.path_segments()
.ok_or(anyhow!("Error parsing repository remote URL."))?;
let repo_owner = url_paths
.next()
.ok_or(anyhow!(
"Error parsing repository remote URL: Repo owner not found."
))?
.to_string();
let repo_name = url_paths
.next()
.ok_or(anyhow!(
"Error parsing repository remote URL: Repo name not found."
))?
.to_string();
if repo_owner == env_vars.gh_org_name {
let collab_role = github::get_collaborator_role(
&client,
&env_vars.gh_org_admin_token,
&repo_owner,
&repo_name,
username,
)
.await?;

// `None` means the user is not a collaborator
if let Some(role) = collab_role.as_deref()
&& (role == "maintain" || role == "admin")
{
return Ok(Deployment {
name: project_name.to_string(),
repo_url,
repo_owner,
repo_name,
});
}
}
Err(anyhow!("User does not have permission to access this project."))
}

/// Get a list of deployments
pub async fn get_deployments(env_vars: &EnvVars, username: &str) -> Res<Vec<Deployment>> {
let deployments_dir = &env_vars.deployments_dir;

let mut deployments = Vec::new();

// To be reused for collaborator permission checking requests
let client = reqwest::Client::new();

let mut dir_iter = fs::read_dir(deployments_dir).await?;
while let Some(path) = dir_iter.next_entry().await? {
if path.file_type().await?.is_dir()
&& let Ok(repo) = Repository::open(path.path())
{
let name = path
.file_name()
.into_string()
.map_err(|err| anyhow!("{}", err.display()))?;

let repo_url = repo
.find_remote("origin")?
.url()
.ok_or(anyhow!(
"Error: Origin remote URL not found for repo {name}."
))?
.to_string();

let parsed_url = Url::from_str(&repo_url)?;
let mut url_paths = parsed_url
.path_segments()
.ok_or(anyhow!("Error parsing repository remote URL."))?;

let repo_owner = url_paths
.next()
.ok_or(anyhow!(
"Error parsing repository remote URL: Repo owner not found."
))?
.to_string();
let repo_name = url_paths
.next()
.ok_or(anyhow!(
"Error parsing repository remote URL: Repo name not found."
))?
.to_string();

// Only include repositories owned by the organization
if repo_owner == env_vars.gh_org_name {
let collab_role = github::get_collaborator_role(
&client,
&env_vars.gh_org_admin_token,
&repo_owner,
&repo_name,
username,
)
.await?;

// `None` means the user is not a collaborator
if let Some(role) = collab_role.as_deref()
&& (role == "maintain" || role == "admin")
{
deployments.push(Deployment {
name,
repo_url,
repo_owner,
repo_name,
});
}
let project_name = path.file_name().into_string().map_err(|_| anyhow!("Invalid project name"))?;
if let Some(deployment) = check_access(env_vars, username, &project_name).await.ok() {
deployments.push(deployment);
}
}
}

Ok(deployments)
}

#[derive(Deserialize, Serialize)]
/// Settings for a project
pub struct ProjectSettings {
/// Subdirectory which is deployed (relative to the project root)
pub deploy_dir: String,
}

/// Get the project settings (stored in .maint on the top level of the project directory)
pub async fn get_project_settings(env_vars: &EnvVars, project_name: &str) -> Res<ProjectSettings> {
let maint_file_path = format!(
"{}/{}/.maint",
env_vars.deployments_dir.display(),
project_name
);

if let Ok(maint_file_contents) = fs::read_to_string(maint_file_path).await {
Ok(ProjectSettings { deploy_dir: maint_file_contents.trim().into() })
} else {
Ok(ProjectSettings { deploy_dir: ".".into() } )
}
}

/// Get the environment variables for a project
pub async fn get_env(env_vars: &EnvVars, username: &str, project_name: &str) -> Res<Value> {
check_access(env_vars, username, project_name).await?;

let project_settings = get_project_settings(env_vars, project_name).await?;

let env_file_path = PathBuf::from(&env_vars.deployments_dir)
.join(project_name)
.join(&project_settings.deploy_dir)
.join(".env");

let mut map = Map::new();
for item in dotenvy::from_path_iter(env_file_path)? {
let (key, value) = item?;
map.insert(key, Value::String(value));
}

Ok(Value::Object(map))
}
2 changes: 1 addition & 1 deletion frontend/.env.development
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
VITE_BACKEND_URL=http://localhost:8080
VITE_GH_OAUTH_CLIENT_ID=Ov23liSSsyTFMsm1CT09
VITE_GH_OAUTH_CLIENT_ID=Ov23liN3sRyCG1lEbuyU
Loading