diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 305f0e6a..e2c4b46c 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -1,13 +1,13 @@ name: Bug Report description: File a bug report -labels: [ Bug ] +type: "Bug" +assignees: [ "illyrius666" ] body: - type: markdown attributes: value: | Thank you for taking the time to fill this out! - type: textarea - id: how attributes: label: Problem description: Please give a text description of how you reached the problem @@ -19,7 +19,6 @@ body: validations: required: true - type: textarea - id: what attributes: label: Solution (if any) description: Explain where you think the problem comes from (optional) @@ -27,7 +26,6 @@ body: validations: required: false - type: input - id: version attributes: label: Version description: What version are you running? @@ -35,7 +33,6 @@ body: validations: required: true - type: input - id: logs attributes: label: Log description: Paste a full log. Always use [Pastebin](https://pastebin.com/). Must not be a crash report. Must be a full log. Must not be a screenshot of a log. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 9888262e..6300bb34 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false -contact_links: - - name: Community Support - url: https://discord.gg/CWy6JxNXP4 - about: Please ask and answer questions here. +# contact_links: +# - name: Community Support +# url: https://discord.gg/CWy6JxNXP4 +# about: Please ask and answer questions here. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index 1198e89e..1475a79a 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -1,32 +1,23 @@ name: Feature Request description: File a feature request. -labels: [ Feature ] +type: "Feature" +assignees: [ "illyrius666" ] body: - type: markdown attributes: value: | Thank you for taking the time to fill this out! - type: dropdown - id: arc attributes: - label: Adding, Removing, or Changing - description: What are you doing + label: Type of Modification + description: What are you doing? options: - Adding - Removing - Changing validations: required: true - - type: input - id: type - attributes: - label: Type of Modification - description: What is it for? - placeholder: I want to ... - validations: - required: true - type: textarea - id: desc attributes: label: What are you trying to modify description: Give as detailed of a description as you can for the skill that you want (include pictures/Videos if applicable) @@ -34,7 +25,6 @@ body: validations: required: true - type: textarea - id: alternative attributes: label: Alternatives description: What alternatives have you considered? diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6e23bab6..7424ad4c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,14 +1,12 @@ ## Description -Please include a summary of the changes and the related issue. Explain the motivation behind these changes. - -## Checklist: - -- [ ] My code follows the style guidelines of this project -- [ ] I have performed a self-review of my own code -- [ ] I have commented my code, particularly in hard-to-understand areas -- [ ] I have made corresponding changes to the documentation -- [ ] My changes generate no new warnings -- [ ] I have added tests that prove my fix is effective or that my feature works -- [ ] New and existing unit tests pass locally with my changes -- [ ] Any dependent changes have been merged and published in downstream modules + + +## How Has This Been Tested? + + + +- [ ] Unit tests +- [ ] Integration tests +- [ ] Manual testing +- [ ] Other (please describe) diff --git a/.github/renovate.json b/.github/renovate.json index ceb699b7..24c5eaa8 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -10,8 +10,6 @@ "extends": [ "config:recommended" ], - "labels": [ - "Dependencies" - ], - "prHeader": "Xodium Dependencies Updater" + "prHeader": "Dependencies Updater", + "commitMessagePrefix": "[ci-skip]" } diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 90fec3e5..9be39e93 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -1,38 +1,52 @@ name: Xodium CI/CD -run-name: "Xodium CI/CD" -on: { push: { branches: [ main ], paths: [ "../../server/src/**" ] }, workflow_dispatch } +on: + push: + branches: [ main ] + paths: [ "src/**" ] -permissions: { contents: write, packages: write } +permissions: + contents: read + packages: write concurrency: - { - group: "${{ github.workflow }}-${{ github.ref }}", - cancel-in-progress: true, - } + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true jobs: build: runs-on: ubuntu-latest environment: - { - name: "${{ github.ref_name }}", - url: "${{ steps.upload_artifact.outputs.artifact-url }}", - } + name: "${{ github.ref_name }}" + url: "${{ steps.upload_artifact.outputs.artifact-url }}" outputs: { VERSION: "${{ steps.get_version.outputs.VERSION }}" } steps: - id: checkout name: Checkout - uses: actions/checkout@main + uses: actions/checkout@v6.0.1 + + - id: cache_deps + name: Cache dependencies + uses: actions/cache@v5.0.1 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - id: setup_rust name: Setup Rust - uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 - with: { toolchain: stable } + uses: actions-rust-lang/setup-rust-toolchain@806aa7ddf5d59f36fb30048411f6bde29364a53f + with: + toolchain: stable + components: rustfmt, clippy - id: build_artifact name: Build Artefact - run: cargo build --release + run: cargo build --release --locked --timings + shell: bash - id: install_toml_cli name: Install toml-cli @@ -41,55 +55,66 @@ jobs: - id: get_version name: Get Version run: echo "VERSION=$(toml get Cargo.toml package.version | tr -d '\"')" >> $GITHUB_OUTPUT + shell: bash - id: upload_artifact name: Upload Artifact - uses: actions/upload-artifact@main - with: { name: xbim, path: target/release/xBIM } + uses: actions/upload-artifact@v6.0.0 + with: + name: xbim + path: target/release/xbim + retention-days: 7 test: needs: [ build ] runs-on: ubuntu-latest environment: - { - name: "${{ github.ref_name }}", - url: "${{ steps.upload_artifact.outputs.artifact-url }}", - } + name: "${{ github.ref_name }}" steps: - id: checkout name: Checkout - uses: actions/checkout@main + uses: actions/checkout@v6.0.1 - id: setup_rust name: Setup Rust - uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 - with: { toolchain: stable } + uses: actions-rust-lang/setup-rust-toolchain@806aa7ddf5d59f36fb30048411f6bde29364a53f + with: + toolchain: stable - id: run_tests name: Run Tests - run: cargo test --all + run: | + cargo test --all --no-fail-fast + cargo clippy --all-targets --all-features -- -D warnings + cargo fmt --all -- --check + shell: bash release: needs: [ build, test ] runs-on: ubuntu-latest environment: - { - name: "${{ github.ref_name }}", - url: "${{ steps.create_release.outputs.url }}", - } + name: "${{ github.ref_name }}" + url: "${{ steps.create_release.outputs.url }}" steps: - id: download_artifact name: Download Artefact - uses: actions/download-artifact@main - with: { name: xbim } + uses: actions/download-artifact@v7.0.0 + with: + name: xbim + + - id: verify_binary + name: Verify Binary + run: ./xbim --version + shell: bash - id: create_release name: Create Release - uses: softprops/action-gh-release@f37a2f9143791b88da06f2c143d376e00fce81dc - env: { GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" } + uses: softprops/action-gh-release@5122b4edc95f85501a71628a57dc180a03ec7588 + env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" with: draft: ${{ contains(github.event.head_commit.message, '.draft') }} generate_release_notes: true prerelease: ${{ contains(github.event.head_commit.message, '.pre') }} tag_name: v${{ needs.build.outputs.VERSION }} - files: xBIM + files: xbim diff --git a/.github/workflows/enforce_branch.yml b/.github/workflows/enforce_branch.yml new file mode 100644 index 00000000..4f8190ce --- /dev/null +++ b/.github/workflows/enforce_branch.yml @@ -0,0 +1,50 @@ +name: Xodium CI/CD - Enforce Target Branch + +on: + pull_request_target: + types: [ opened, reopened, synchronize, edited, ready_for_review ] + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + enforce-branch: + runs-on: ubuntu-latest + steps: + - id: enforce_branch + name: Enforce and Auto-fix Target Branch + env: + HEAD_REF: ${{ github.head_ref }} + BASE_REF: ${{ github.base_ref }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + if [ "$BASE_REF" == "main" ] && [ "$HEAD_REF" != "dev" ]; then + echo "❌ PR is targeting 'main' but not from 'dev'. Changing target to 'dev'..." + + gh pr edit "$PR_NUMBER" --repo "$REPO" --base dev + + echo "changed=true" >> $GITHUB_OUTPUT + echo "✅ Target branch automatically changed to 'dev'" + echo "⚠️ To merge to 'main', please create a PR from 'dev' branch" + else + echo "changed=false" >> $GITHUB_OUTPUT + echo "✅ Target branch is correct" + fi + + - id: notify_user + name: Notify User + if: steps.enforce_branch.outputs.changed == true + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + gh pr comment "$PR_NUMBER" --repo "$REPO" --body \ + "🤖 The target branch has been automatically changed from \`main\` to \`dev\`.\n\nPRs to \`main\` are only allowed from the \`dev\` branch. Please merge to \`dev\` first." \ No newline at end of file diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml new file mode 100644 index 00000000..4ea72a91 --- /dev/null +++ b/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml new file mode 100644 index 00000000..7ef04e2e --- /dev/null +++ b/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 00000000..1f2ea11e --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml new file mode 100644 index 00000000..8648f940 --- /dev/null +++ b/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml index 9a3472c3..211aa6bd 100644 --- a/.idea/dictionaries/project.xml +++ b/.idea/dictionaries/project.xml @@ -2,8 +2,18 @@ authguard + clippy + deps + eframe + egui + pkce ratelimitguard + rustfmt + softprops + ssrf testuser + xbim + xodium \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index e775b510..347b55aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,17 +19,18 @@ publish = false [package.metadata.docs.rs] all-features = true +targets = ["x86_64-unknown-linux-gnu"] [dependencies] -chrono = "0.4.40" -colored = "3.0.0" -reqwest = { version = "0.12.15", features = ["json"] } -rocket = { version = "0.5.1", features = ["json", "uuid", "tls"] } -rocket_async_compression = "0.6.1" -rocket_cors = "0.6.0" -rocket-governor = "0.2.0-rc.4" -rocket_oauth2 = "0.5.0" -surrealdb = { version = "2.2.1", features = ["http"] } -figment = { version = "0.10.19", features = ["toml"] } -toml = "0.9.0" -serde_json = "1.0.140" +egui = "0.33.2" +eframe = { version = "0.33.2", default-features = false, features = [ + "default_fonts", + "glow", + "persistence", + "wayland", +] } +env_logger = "0.11.8" +serde = { version = "1.0.228", features = ["derive"] } +oauth2 = { version = "5.0.0", features = ["reqwest"] } +reqwest = { version = "0.13.0", features = ["json"] } +surrealdb = "2.4.0" diff --git a/README.md b/README.md index 37865069..c9a79894 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ ## About The Project -xbim is a project aimed to provide a complete solution for working with BIM models. It is written in Rust, which +`xbim` is a project aimed to provide a complete solution for working with BIM models. It is written in Rust, which provides a high level of performance and safety. The project is still in its early stages, but it is already capable of reading and writing IFC files. @@ -42,9 +42,9 @@ reading and writing IFC files. 1. Download the latest version of xbim from the [release][release_latest] page. 2. Place it in a directory of your choice. 3. Run the executable. It will return an error that it cannot connect to the database. This is expected, as the - database is not yet set up in the config.toml which will generate on first time run. + database is not yet set up in the `config.toml` which will generate on first time run. 4. Replace the default values in the config with yours. -5. Rerun the executable and voila! +5. Rerun the executable and voilà! ## Built With diff --git a/SECURITY.md b/SECURITY.md index 34199a08..233bed7e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,8 @@ ## Reporting a Vulnerability -We take security vulnerabilities seriously. If you discover a security issue, please report it to us responsibly through GitHub's private vulnerability reporting system. +We take security vulnerabilities seriously. If you discover a security issue, +please report it to us responsibly through GitHub's private vulnerability reporting system. ### How to Report diff --git a/scripts/gen_certs_for_dev_LINUX.sh b/scripts/gen_certs_for_dev_LINUX.sh deleted file mode 100644 index b3a07a57..00000000 --- a/scripts/gen_certs_for_dev_LINUX.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -# Gen Certs for Dev - Shell Script Version - -# Create certs directory -mkdir -p ../target/debug/certs - -# Generate private key -openssl genrsa -out ../target/debug/certs/key.pem 2048 - -# Generate self-signed certificate -openssl req -new -x509 -key ../target/debug/certs/key.pem -out ../target/debug/certs/cert.pem -days 365 -subj "/CN=localhost" - -# Set appropriate permissions -chmod 600 ../target/debug/certs/key.pem -chmod 644 ../target/debug/certs/cert.pem - -echo "Self-signed certificates generated in target/debug/certs/" \ No newline at end of file diff --git a/scripts/gen_certs_for_dev_WINDOWS.ps1 b/scripts/gen_certs_for_dev_WINDOWS.ps1 deleted file mode 100644 index ee527a12..00000000 --- a/scripts/gen_certs_for_dev_WINDOWS.ps1 +++ /dev/null @@ -1,30 +0,0 @@ -# Gen Certs for Dev - PowerShell Version - -# Create certs directory -$certsDir = "..\target\debug\certs" -New-Item -ItemType Directory -Force -Path $certsDir | Out-Null - -# Generate self-signed certificate -$cert = New-SelfSignedCertificate ` - -Subject "CN=localhost" ` - -KeyAlgorithm RSA ` - -KeyLength 2048 ` - -CertStoreLocation "Cert:\CurrentUser\My" ` - -NotAfter (Get-Date).AddDays(365) - -# Export certificate and private key -Export-PfxCertificate ` - -Cert $cert ` - -FilePath "$certsDir\cert.pfx" ` - -Password (ConvertTo-SecureString -String "password" -Force -AsPlainText) | Out-Null - -# Export just the public key -Export-Certificate ` - -Cert $cert ` - -FilePath "$certsDir\cert.pem" -Type CERT | Out-Null - -# Clean up - remove from certificate store -Remove-Item -Path $cert.PSPath - -Write-Host "Self-signed certificates generated in $certsDir" -Write-Host "Note: The PFX file contains both certificate and private key (password: 'password')" \ No newline at end of file diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 00000000..eb055000 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,79 @@ +use crate::footer::footer; +use crate::login_form::LoginForm; +use crate::menu::menu; +use crate::oauth::OAuthService; +use crate::user_profile::UserProfile; + +#[derive(Clone, Default, serde::Deserialize, serde::Serialize)] +#[serde(default)] +pub struct App { + login_form: LoginForm, + #[serde(skip)] + is_logged_in: bool, + #[serde(skip)] + current_username: String, + #[serde(skip)] + oauth_service: Option, +} + +impl App { + pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + let mut app: App = if let Some(storage) = cc.storage { + eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default() + } else { + Default::default() + }; + + if !app.login_form.persist_username { + app.login_form.username.clear(); + } + + app.oauth_service = Some(OAuthService::new( + "client_id", + "client_secret", + "https://github.com/login/oauth/authorize", + "https://github.com/login/oauth/access_token", + "http://127.0.0.1:8080", + )); + + app + } + + fn handle_login(&mut self) {} + + fn handle_logout(&mut self) { + self.is_logged_in = false; + self.current_username.clear(); + self.login_form.clear(); + } +} + +impl eframe::App for App { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { + egui::MenuBar::new().ui(ui, menu); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + if self.is_logged_in { + if UserProfile::show(ui, &self.current_username) { + self.handle_logout(); + } + } else if self.login_form.show(ui) { + self.handle_login(); + } + + ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), footer); + }); + } + + fn save(&mut self, storage: &mut dyn eframe::Storage) { + let mut snapshot = self.clone(); + if !snapshot.login_form.persist_username { + snapshot.login_form.username.clear(); + } + snapshot.is_logged_in = false; + snapshot.current_username.clear(); + eframe::set_value(storage, eframe::APP_KEY, &snapshot); + } +} diff --git a/src/components/footer.rs b/src/components/footer.rs new file mode 100644 index 00000000..48787064 --- /dev/null +++ b/src/components/footer.rs @@ -0,0 +1,19 @@ +/// Renders the application footer with copyright and attribution links. +/// # Arguments +/// * `ui` — The egui UI context to render the footer into. +pub fn footer(ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.label("© 2025 "); + ui.hyperlink_to("Xodium", "https://github.com/XodiumSoftware"); + ui.label(". Powered by "); + ui.hyperlink_to("egui", "https://github.com/emilk/egui"); + ui.label(" and "); + ui.hyperlink_to( + "eframe", + "https://github.com/emilk/egui/tree/master/crates/eframe", + ); + ui.label("."); + }); + egui::warn_if_debug_build(ui); +} diff --git a/src/components/login_form.rs b/src/components/login_form.rs new file mode 100644 index 00000000..10b37bcf --- /dev/null +++ b/src/components/login_form.rs @@ -0,0 +1,65 @@ +/// Login form state and UI component. +#[derive(Clone, Default, serde::Deserialize, serde::Serialize)] +pub struct LoginForm { + pub username: String, + pub password: String, + pub persist_username: bool, + pub error: Option, +} + +impl LoginForm { + /// Renders the login form UI. + /// # Parameters + /// * `ui`: Mutable reference to the current `egui::Ui`. + /// # Returns + /// `true` if "Log in" was clicked or Enter was pressed in this frame, otherwise `false`. + /// # Notes + /// If `error` is set, it is shown in red below the controls. + pub fn show(&mut self, ui: &mut egui::Ui) -> bool { + let mut login_attempted = false; + + ui.heading("Login"); + + ui.horizontal(|ui| { + ui.label("Username:"); + ui.add(egui::TextEdit::singleline(&mut self.username).desired_width(200.0)); + }); + + ui.horizontal(|ui| { + ui.label("Password:"); + ui.add( + egui::TextEdit::singleline(&mut self.password) + .password(true) + .desired_width(200.0), + ); + }); + + ui.checkbox(&mut self.persist_username, "Remember username"); + + if ui.button("Log in").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + login_attempted = true; + } + + if let Some(error) = &self.error { + ui.colored_label(egui::Color32::RED, error); + } + + login_attempted + } + + /// Clears sensitive and transient state. + pub fn clear(&mut self) { + self.password.clear(); + self.error = None; + if !self.persist_username { + self.username.clear(); + } + } + + /// Sets the current error message to be displayed below the form. + /// # Parameters + /// * `error`: The message to show in the UI. + pub fn set_error(&mut self, error: String) { + self.error = Some(error); + } +} diff --git a/src/components/menu.rs b/src/components/menu.rs new file mode 100644 index 00000000..db2a5218 --- /dev/null +++ b/src/components/menu.rs @@ -0,0 +1,13 @@ +/// A simple menu component with a "File" menu, and a "Quit" option, +/// as well as a global theme preference switch. +/// # Arguments +/// * `ui` — The egui UI context to render the menu in. +pub fn menu(ui: &mut egui::Ui) { + ui.menu_button("File", |ui| { + if ui.button("Quit").clicked() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + ui.add_space(16.0); + egui::widgets::global_theme_preference_switch(ui); +} diff --git a/src/components/user_profile.rs b/src/components/user_profile.rs new file mode 100644 index 00000000..a781a8aa --- /dev/null +++ b/src/components/user_profile.rs @@ -0,0 +1,21 @@ +/// User profile UI component for displaying a greeting, and a logout button. +pub struct UserProfile; + +impl UserProfile { + /// Shows the user profile UI. + /// # Parameters + /// * `ui`: Mutable reference to the current `egui::Ui`. + /// * `username`: The name to display in the greeting. + /// # Returns + /// `true` if the "Log out" button was clicked in this frame, otherwise `false`. + pub fn show(ui: &mut egui::Ui, username: &str) -> bool { + let mut logout_clicked = false; + + ui.colored_label(egui::Color32::GREEN, format!("Welcome, {}!", username)); + if ui.button("Log out").clicked() { + logout_clicked = true; + } + + logout_clicked + } +} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 6cf7eefa..00000000 --- a/src/config.rs +++ /dev/null @@ -1,78 +0,0 @@ -#![warn(clippy::all)] -#![forbid(unsafe_code)] - -use crate::utils::Utils; -use figment::Figment; -use figment::providers::{Format, Serialized, Toml}; -use rocket::serde::{Deserialize, Serialize}; -use std::fs; -use std::fs::File; -use std::io::Write; -use std::path::PathBuf; - -/// Configuration settings for the application. -#[derive(Clone, Default, Debug, Serialize, Deserialize)] -#[serde(crate = "rocket::serde")] -pub struct Config { - pub secret_key: String, - pub database_url: String, - pub database_username: String, - pub database_password: String, - pub github_client_id: String, - pub github_client_secret: String, - pub github_redirect_url: String, - pub tls_cert_path: String, - pub tls_key_path: String, -} - -impl Config { - /// Creates a new instance of `AppConfig` with default values. - /// - /// # Returns - /// A `Self` instance containing the default configuration. - pub fn new() -> Self { - Self::load_or_create(&Utils::get_exec_path("config.toml")) - } - - /// Loads the configuration from a file, creating a default one if it doesn't exist. - /// - /// # Arguments - /// * `path` - The path to the configuration file. - /// - /// # Returns - /// A `Self` instance containing the loaded or default configuration. - pub fn load_or_create(path: &PathBuf) -> Self { - if !path.exists() { - println!("Creating default config at: {}", path.display()); - Self::default() - .save_to_file(path) - .unwrap_or_else(|err| eprintln!("Failed to create config: {err}")); - } - - Figment::from(Serialized::defaults(Self::default())) - .merge(Toml::file(path)) - .extract::() - .unwrap_or_else(|err| { - eprintln!("Configuration error (using defaults): {err}"); - Self::default() - }) - } - - /// Saves the current configuration to a file. - /// - /// # Arguments - /// * `path` - The path to the configuration file. - /// - /// # Returns - /// A `std::io::Result<()>` indicating success or failure. - pub fn save_to_file(&self, path: &PathBuf) -> std::io::Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - File::create(path)?.write_all( - toml::to_string_pretty(self) - .expect("Failed to serialize config to TOML") - .as_bytes(), - ) - } -} diff --git a/src/database.rs b/src/database.rs deleted file mode 100644 index 0730f513..00000000 --- a/src/database.rs +++ /dev/null @@ -1,136 +0,0 @@ -#![warn(clippy::all)] -#![forbid(unsafe_code)] - -use crate::config::Config; -use crate::utils::Utils; -use rocket::serde::{Deserialize, Serialize}; -use surrealdb::{ - Error, Surreal, - engine::remote::ws::{Client, Ws}, - error::Api, - opt::auth::Root, - sql::Uuid, -}; - -pub struct Database { - pub client: Surreal, - pub session_token: Uuid, -} - -impl Database { - /// Creates a new `Database` instance. - /// - /// # Arguments - /// * `config` - The application configuration. - /// - /// # Returns - /// A new `Database` instance. - pub async fn new(config: &Config) -> Self { - match Self::connect(config).await { - Ok(db) => db, - Err(e) => { - Utils::database_err_msg(&e, config); - std::process::exit(1); - } - } - } - - /// Connects to the database using the provided configuration. - /// - /// # Arguments - /// * `config` - The application configuration. - /// - /// # Returns - /// A `Result` containing the connected `Database` instance. - async fn connect(config: &Config) -> Result { - Ok(Self { - client: { - let client = Surreal::new::(&config.database_url).await?; - client - .signin(Root { - username: &config.database_username, - password: &config.database_password, - }) - .await?; - client - }, - session_token: Uuid::new(), - }) - } - - /// Creates a new record in the specified table. - /// - /// # Arguments - /// * `table` - The table name to create the record in. - /// * `data` - The data to create. - /// - /// # Returns - /// A `Result` containing the created record with its ID. - pub async fn create(&self, table: &str, data: T) -> Result - where - T: Serialize + for<'a> Deserialize<'a> + 'static, - { - self.client - .create(table) - .content(data) - .await? - .take() - .ok_or_else(|| Error::Api(Api::ParseError(String::from("Failed to create record")))) - } - - /// Retrieves a record from the specified table by its ID. - /// - /// # Arguments - /// * `table` - The table name to retrieve from. - /// * `id` - The ID of the record to retrieve. - /// - /// # Returns - /// A `Result` containing the retrieved record. - pub async fn read(&self, table: &str, id: &str) -> Result - where - T: for<'a> Deserialize<'a> + 'static, - { - self.client - .select((table, id)) - .await? - .take() - .ok_or_else(|| Error::Api(Api::ParseError(String::from("Failed to retrieve record")))) - } - - /// Updates a record in the specified table. - /// - /// # Arguments - /// * `table` - The table name where the record is stored. - /// * `id` - The ID of the record to update. - /// * `data` - The updated data. - /// - /// # Returns - /// A `Result` containing the updated record. - pub async fn update(&self, table: &str, id: &str, data: T) -> Result - where - T: Serialize + for<'a> Deserialize<'a> + 'static, - { - self.client - .update((table, id)) - .content(data) - .await? - .take() - .ok_or_else(|| Error::Api(Api::ParseError(String::from("Failed to update record")))) - } - - /// Deletes a record from the specified table. - /// - /// # Arguments - /// * `table` - The table name where the record is stored. - /// * `id` - The ID of the record to delete. - /// - /// # Returns - /// A `Result` indicating whether the deletion was successful. - pub async fn delete(&self, table: &str, id: &str) -> Result - where - T: for<'a> Deserialize<'a> + 'static, - { - let result: Option = self.client.delete((table, id)).await?.take(); - Ok(result.is_some()) - } -} diff --git a/src/errors.rs b/src/errors.rs deleted file mode 100644 index a47026e7..00000000 --- a/src/errors.rs +++ /dev/null @@ -1,85 +0,0 @@ -#![warn(clippy::all)] -#![forbid(unsafe_code)] - -use rocket::{Catcher, catch, catchers, http::Status, serde::Serialize, serde::json::Json}; -use rocket_governor::rocket_governor_catcher; - -#[derive(Serialize)] -#[serde(crate = "rocket::serde")] -struct Response { - status: Status, - message: &'static str, -} - -/// Returns a list of catchers for the application. -/// -/// # Returns -/// A vector of catchers. -pub fn catchers() -> Vec { - catchers![ - err_400, - err_401, - err_403, - err_404, - err_405, - rocket_governor_catcher, - err_500, - err_503 - ] -} - -#[catch(400)] -fn err_400() -> Json { - Json(Response { - status: Status::BadRequest, - message: "Bad request format or invalid parameters", - }) -} - -#[catch(401)] -fn err_401() -> Json { - Json(Response { - status: Status::Unauthorized, - message: "Authentication required", - }) -} - -#[catch(403)] -fn err_403() -> Json { - Json(Response { - status: Status::Forbidden, - message: "Access forbidden - You don't have permission to access this resource", - }) -} - -#[catch(404)] -fn err_404() -> Json { - Json(Response { - status: Status::NotFound, - message: "Resource not found", - }) -} - -#[catch(405)] -fn err_405() -> Json { - Json(Response { - status: Status::MethodNotAllowed, - message: "Method not allowed for this resource", - }) -} - -#[catch(500)] -fn err_500() -> Json { - Json(Response { - status: Status::InternalServerError, - message: "Internal server error", - }) -} - -#[catch(503)] -fn err_503() -> Json { - Json(Response { - status: Status::ServiceUnavailable, - message: "Service temporarily unavailable", - }) -} diff --git a/src/guards/auth.rs b/src/guards/auth.rs deleted file mode 100644 index da86622b..00000000 --- a/src/guards/auth.rs +++ /dev/null @@ -1,101 +0,0 @@ -#![warn(clippy::all)] -#![forbid(unsafe_code)] - -use crate::routes::github::GitHubUser; -use rocket::{ - Request, async_trait, - http::Status, - request::{FromRequest, Outcome}, - serde::json::from_str, -}; - -/// Authentication Guard -pub struct AuthGuard; - -#[async_trait] -impl<'r> FromRequest<'r> for AuthGuard { - type Error = (); - - async fn from_request(request: &'r Request<'_>) -> Outcome { - request - .cookies() - .get_private("user_session") - .and_then(|cookie| { - from_str::(cookie.value()) - .map(|_| AuthGuard) - .ok() - }) - .map(Outcome::Success) - .unwrap_or(Outcome::Error((Status::Unauthorized, ()))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rocket::http::Cookie; - use rocket::local::asynchronous::Client; - use rocket::{Build, Rocket, get, routes, tokio}; - use serde_json::json; - - #[get("/protected")] - fn test_endpoint(_auth: AuthGuard) -> &'static str { - "Authenticated!" - } - - fn rocket_test() -> Rocket { - rocket::build().mount("/", routes![test_endpoint]) - } - - #[tokio::test] - async fn test_auth_guard_success() { - let client = Client::tracked(rocket_test()) - .await - .expect("valid rocket instance"); - - let user_json = json!({ - "login": "testuser", - "id": 12345, - "name": "Test User" - }) - .to_string(); - - let cookie = Cookie::new("user_session", user_json); - - let response = client - .get("/protected") - .private_cookie(cookie) - .dispatch() - .await; - - assert_eq!(response.status(), Status::Ok); - } - - #[tokio::test] - async fn test_auth_guard_unauthorized_no_cookie() { - let client = Client::tracked(rocket_test()) - .await - .expect("valid rocket instance"); - - let response = client.get("/protected").dispatch().await; - - assert_eq!(response.status(), Status::Unauthorized); - } - - #[tokio::test] - async fn test_auth_guard_invalid_cookie_value() { - let client = Client::tracked(rocket_test()) - .await - .expect("valid rocket instance"); - - let invalid_cookie = Cookie::new("user_session", "not_valid_json"); - - let response = client - .get("/protected") - .private_cookie(invalid_cookie) - .dispatch() - .await; - - assert_eq!(response.status(), Status::Unauthorized); - } -} diff --git a/src/guards/ratelimit.rs b/src/guards/ratelimit.rs deleted file mode 100644 index 3a0f7dd3..00000000 --- a/src/guards/ratelimit.rs +++ /dev/null @@ -1,52 +0,0 @@ -#![warn(clippy::all)] -#![forbid(unsafe_code)] - -use rocket_governor::{Method, Quota, RocketGovernable}; - -/// RateLimit Guard. -pub struct RateLimitGuard; - -impl RocketGovernable<'_> for RateLimitGuard { - fn quota(_method: Method, _route_name: &str) -> Quota { - Quota::per_second(Self::nonzero(1u32)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rocket_governor::{Method, Quota}; - use std::num::NonZeroU32; - - #[test] - fn test_rate_limit_quota_get() { - let quota = RateLimitGuard::quota(Method::Get, "test_route"); - let expected = Quota::per_second(NonZeroU32::new(1).unwrap()); - - assert_eq!(quota, expected); - } - - #[test] - fn test_rate_limit_quota_different_methods() { - let get_quota = RateLimitGuard::quota(Method::Get, "test_route"); - let post_quota = RateLimitGuard::quota(Method::Post, "test_route"); - let put_quota = RateLimitGuard::quota(Method::Put, "test_route"); - - assert_eq!(get_quota, post_quota); - assert_eq!(post_quota, put_quota); - } - - #[test] - fn test_rate_limit_quota_different_routes() { - let route1_quota = RateLimitGuard::quota(Method::Get, "route1"); - let route2_quota = RateLimitGuard::quota(Method::Get, "route2"); - - assert_eq!(route1_quota, route2_quota); - } - - #[test] - fn test_nonzero_conversion() { - assert_eq!(RateLimitGuard::nonzero(5u32), NonZeroU32::new(5).unwrap()); - assert_eq!(RateLimitGuard::nonzero(1u32), NonZeroU32::new(1).unwrap()); - } -} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..30b02ba7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,14 @@ +mod app; +mod components { + pub mod footer; + pub mod login_form; + pub mod menu; + pub mod user_profile; +} +mod services { + pub mod oauth; +} + +pub use app::*; +pub use components::*; +pub use services::*; diff --git a/src/main.rs b/src/main.rs index 28b8a630..0ecdef43 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,99 +1,18 @@ -#![warn(clippy::all)] -#![forbid(unsafe_code)] - -pub mod guards { - pub mod auth; - pub mod ratelimit; -} - -pub mod models { - pub mod card; - pub mod user; -} - -pub mod routes { - pub mod data; - pub mod github; - pub mod health; -} - -pub mod config; -pub mod database; -pub mod errors; -mod utils; - -use crate::config::Config; -use crate::routes::data::{data_delete, data_get, data_update, data_upload}; -use crate::routes::github::{GitHubUser, github_callback, github_login}; -use crate::routes::health::health; -use database::Database; -use errors::catchers; -use rocket::config::SecretKey; -use rocket::routes; -use rocket::{ - Build, Rocket, build, config::TlsConfig, launch, shield::ExpectCt, shield::Feature, - shield::Frame, shield::Hsts, shield::NoSniff, shield::Permission, shield::Prefetch, - shield::Referrer, shield::Shield, shield::XssFilter, time::Duration, -}; -use rocket_async_compression::{Compression, Level as CompressionLevel}; -use rocket_cors::{AllowedOrigins, CorsOptions}; -use rocket_oauth2::{HyperRustlsAdapter, OAuth2, OAuthConfig, StaticProvider}; - -#[launch] -async fn rocket() -> Rocket { - let config = Config::new(); - build() - .configure(rocket::Config { - tls: (!config.tls_cert_path.is_empty() && !config.tls_key_path.is_empty()) - .then(|| TlsConfig::from_paths(&config.tls_cert_path, &config.tls_key_path)), - secret_key: SecretKey::derive_from(config.secret_key.as_bytes()), - ..rocket::Config::default() - }) - .manage(config.clone()) - .manage(Database::new(&config).await) - .mount( - "/", - routes![ - github_login, - github_callback, - health, - data_upload, - data_get, - data_update, - data_delete, - ], - ) - .attach( - Shield::new() - .enable(ExpectCt::Enforce(Duration::days(30))) - .enable( - Permission::default() - .block(Feature::Camera) - .block(Feature::Geolocation) - .block(Feature::Microphone), - ) - .enable(Frame::SameOrigin) - .enable(Hsts::IncludeSubDomains(Duration::days(365))) - .enable(NoSniff::Enable) - .enable(Prefetch::On) - .enable(Referrer::StrictOriginWhenCrossOrigin) - .enable(XssFilter::EnableBlock), - ) - .attach( - CorsOptions::default() - .allowed_origins(AllowedOrigins::all()) - .to_cors() - .expect("Failed to build CORS"), - ) - .attach(Compression::with_level(CompressionLevel::Default)) - .attach(OAuth2::::custom( - HyperRustlsAdapter::default(), - OAuthConfig::new( - StaticProvider::GitHub, - config.github_client_id.clone(), - config.github_client_secret.clone(), - Some(config.github_redirect_url.clone()), - ), - )) - .register("/", catchers()) +/// Entry point for the xbim application. +/// # Returns +/// An `eframe::Result` indicating the success or failure of the application startup. +/// # Errors +/// Returns an error if the application fails to start or encounters a runtime error. +fn main() -> eframe::Result { + env_logger::init(); + eframe::run_native( + "xbim", + eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([400.0, 300.0]) + .with_min_inner_size([300.0, 220.0]), + ..Default::default() + }, + Box::new(|cc| Ok(Box::new(xbim::App::new(cc)))), + ) } diff --git a/src/models/card.rs b/src/models/card.rs deleted file mode 100644 index b0d8ce7b..00000000 --- a/src/models/card.rs +++ /dev/null @@ -1,20 +0,0 @@ -#![warn(clippy::all)] -#![forbid(unsafe_code)] - -use crate::models::user::User; -use rocket::serde::{Deserialize, Serialize}; -use surrealdb::sql::Thing; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(crate = "rocket::serde")] -pub struct Card { - pub id: Option, - pub thumbnail: Option, - pub title: String, - pub author: User, - pub description: String, - pub platform: String, - pub downloads: u32, - pub rating: f32, - pub last_updated: f64, -} diff --git a/src/models/user.rs b/src/models/user.rs deleted file mode 100644 index f9ec7105..00000000 --- a/src/models/user.rs +++ /dev/null @@ -1,30 +0,0 @@ -#![warn(clippy::all)] -#![forbid(unsafe_code)] - -use crate::routes::github::GitHubUser; -use rocket::serde::{Deserialize, Serialize}; -use surrealdb::sql::Thing; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(crate = "rocket::serde")] -pub struct User { - pub id: Option, - pub github_id: u64, - pub login: String, - pub name: Option, - pub email: Option, - pub avatar_url: Option, -} - -impl From for User { - fn from(github_user: GitHubUser) -> Self { - Self { - id: None, - github_id: github_user.id, - login: github_user.login, - name: github_user.name, - email: github_user.email, - avatar_url: github_user.avatar_url, - } - } -} diff --git a/src/routes/data.rs b/src/routes/data.rs deleted file mode 100644 index 1f9956eb..00000000 --- a/src/routes/data.rs +++ /dev/null @@ -1,154 +0,0 @@ -#![warn(clippy::all)] -#![forbid(unsafe_code)] - -use crate::guards::ratelimit::RateLimitGuard; -use crate::{database::Database, guards::auth::AuthGuard}; -use chrono::{DateTime, Utc}; -use rocket::{ - State, delete, get, - http::Status, - post, put, - serde::json::Json, - serde::{Deserialize, Serialize}, -}; -use rocket_governor::RocketGovernor; -use std::collections::HashMap; - -#[derive(Debug, Serialize, Deserialize)] -#[serde(crate = "rocket::serde")] -pub struct StoredIFC { - pub id: Option, - pub name: String, - pub version: String, - pub description: Option, - pub created_at: DateTime, - pub updated_at: DateTime, - pub metadata: HashMap, - pub file_content: Option, -} - -/// Upload a new IFC model to the database. -/// -/// # Arguments -/// * `database` - The database instance. -/// * `_authguard` - Authentication Guard. -/// * `_ratelimitguard` - Rate Limit Guard. -/// * `model` - The IFC model to upload. -/// -/// # Returns -/// The saved IFC model with its ID. -#[post("/ifc", data = "")] -pub async fn data_upload( - database: &State, - _authguard: AuthGuard, - _ratelimitguard: RocketGovernor<'_, RateLimitGuard>, - model: Json, -) -> Result, Status> { - println!("Processing IFC upload"); - match database.create("ifc_models", model.into_inner()).await { - Ok(saved_model) => { - println!("Successfully saved IFC model"); - Ok(Json(saved_model)) - } - Err(e) => { - println!("Error saving IFC model: {e:?}"); - Err(Status::InternalServerError) - } - } -} - -/// Get an IFC model by ID. -/// -/// # Arguments -/// * `database` - The database instance. -/// * `_authguard` - Authentication Guard. -/// * `_ratelimitguard` - Rate Limit Guard. -/// * `id` - The ID of the IFC model to retrieve. -/// -/// # Returns -/// The retrieved IFC model. -#[get("/ifc/")] -pub async fn data_get( - database: &State, - _authguard: AuthGuard, - _ratelimitguard: RocketGovernor<'_, RateLimitGuard>, - id: String, -) -> Result, Status> { - println!("Retrieving IFC model {id}"); - match database.read::("ifc_models", &id).await { - Ok(model) => { - println!("Successfully retrieved IFC model {id}"); - Ok(Json(model)) - } - Err(e) => { - println!("Error retrieving IFC model {id}: {e:?}"); - Err(Status::NotFound) - } - } -} - -/// Update an existing IFC model. -/// -/// # Arguments -/// * `database` - The database instance. -/// * `_authguard` - Authentication Guard. -/// * `_ratelimitguard` - Rate Limit Guard. -/// * `id` - The ID of the IFC model to update. -/// * `model` - The updated IFC model data. -/// -/// # Returns -/// The updated IFC model. -#[put("/ifc/", data = "")] -pub async fn data_update( - database: &State, - _authguard: AuthGuard, - _ratelimitguard: RocketGovernor<'_, RateLimitGuard>, - id: String, - model: Json, -) -> Result, Status> { - println!("Updating IFC model {id}"); - match database.update("ifc_models", &id, model.into_inner()).await { - Ok(updated_model) => { - println!("Successfully updated IFC model {id}"); - Ok(Json(updated_model)) - } - Err(e) => { - println!("Error updating IFC model {id}: {e:?}"); - Err(Status::InternalServerError) - } - } -} - -/// Delete an IFC model by ID. -/// -/// # Arguments -/// * `database` - The database instance. -/// * `_authguard` - Authentication Guard. -/// * `_ratelimitguard` - Rate Limit Guard. -/// * `id` - The ID of the IFC model to delete. -/// -/// # Returns -/// 204 No Content on success, error status otherwise. -#[delete("/ifc/")] -pub async fn data_delete( - database: &State, - _authguard: AuthGuard, - _ratelimitguard: RocketGovernor<'_, RateLimitGuard>, - id: String, -) -> Status { - println!("Deleting IFC model {id}"); - match database.delete::("ifc_models", &id).await { - Ok(true) => { - println!("Successfully deleted IFC model {id}"); - Status::NoContent - } - Ok(false) => { - println!("IFC model {id} not found for deletion"); - Status::NotFound - } - Err(e) => { - println!("Error deleting IFC model {id}: {e:?}"); - Status::InternalServerError - } - } -} diff --git a/src/routes/github.rs b/src/routes/github.rs deleted file mode 100644 index 490192f6..00000000 --- a/src/routes/github.rs +++ /dev/null @@ -1,73 +0,0 @@ -#![warn(clippy::all)] -#![forbid(unsafe_code)] - -use crate::database::Database; -use crate::models::user::User; -use reqwest::Client as HttpClient; -use rocket::http::{Cookie, CookieJar, SameSite}; -use rocket::response::{Flash, Redirect}; -use rocket::serde::{Deserialize, Serialize}; -use rocket::{State, get}; -use rocket_oauth2::{OAuth2, TokenResponse}; -use std::time::Duration; - -#[derive(Debug, Serialize, Deserialize)] -#[serde(crate = "rocket::serde")] -pub struct GitHubUser { - pub id: u64, - pub login: String, - pub name: Option, - pub email: Option, - pub avatar_url: Option, -} - -#[get("/auth/github/login")] -pub fn github_login(oauth2: OAuth2, cookies: &CookieJar<'_>) -> Redirect { - oauth2 - .get_redirect(cookies, &["user:email", "read:user"]) - .unwrap() -} - -#[get("/auth/github/callback")] -pub async fn github_callback( - token: TokenResponse, - cookies: &CookieJar<'_>, - db: &State, -) -> Result> { - // Get GitHub user data - let github_user: GitHubUser = HttpClient::new() - .get("https://api.github.com/user") - .header("User-Agent", "xBIM-App") - .bearer_auth(token.access_token()) - .send() - .await - .map_err(|_| Flash::error(Redirect::to("/"), "Failed to get GitHub user data"))? - .json() - .await - .map_err(|_| Flash::error(Redirect::to("/"), "Failed to parse GitHub user data"))?; - - let github_id = github_user.id; - - // Create or update user record - let user = User::from(github_user); - let user_clone = user.clone(); - let saved_user = match db.create("users", user).await { - Ok(user) => user, - Err(_) => db - .update("users", &format!("github_id:{github_id}"), user_clone) - .await - .map_err(|_| Flash::error(Redirect::to("/"), "Failed to save user data"))?, - }; - - // Store just the session ID in cookie - let user_id = saved_user.id.unwrap().to_string(); - cookies.add_private( - Cookie::build(("user_session", user_id)) - .same_site(SameSite::Lax) - .http_only(true) - .max_age(Duration::from_secs(86400).try_into().unwrap()) - .build(), - ); - - Ok(Redirect::to("/")) -} diff --git a/src/routes/health.rs b/src/routes/health.rs deleted file mode 100644 index 93045767..00000000 --- a/src/routes/health.rs +++ /dev/null @@ -1,35 +0,0 @@ -#![warn(clippy::all)] -#![forbid(unsafe_code)] - -use crate::{guards::auth::AuthGuard, guards::ratelimit::RateLimitGuard}; -use chrono::{DateTime, Utc}; -use rocket::{get, http::Status, serde::Serialize, serde::json::Json}; -use rocket_governor::RocketGovernor; - -#[derive(Serialize)] -#[serde(crate = "rocket::serde")] -pub struct Response { - status: Status, - version: &'static str, - timestamp: DateTime, -} - -/// Health check endpoint to confirm the service is running. -/// -/// # Arguments -/// * `_authguard`: An instance of `AuthGuard` to handle authentication. -/// * `_ratelimitguard`: An instance of `RateLimitGuard` to handle rate limiting. -/// -/// # Returns -/// A JSON response with the status, request ID, version, and timestamp. -#[get("/health")] -pub fn health( - _authguard: AuthGuard, - _ratelimitguard: RocketGovernor<'_, RateLimitGuard>, -) -> Json { - Json(Response { - status: Status::Ok, - version: env!("CARGO_PKG_VERSION"), - timestamp: Utc::now(), - }) -} diff --git a/src/services/oauth.rs b/src/services/oauth.rs new file mode 100644 index 00000000..79a630b0 --- /dev/null +++ b/src/services/oauth.rs @@ -0,0 +1,115 @@ +use oauth2::basic::{ + BasicClient, BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse, + BasicTokenResponse, +}; +use oauth2::http::header::{AUTHORIZATION, USER_AGENT}; +use oauth2::url::Url; +use oauth2::{ + AuthUrl, AuthorizationCode, Client, ClientId, ClientSecret, CsrfToken, EndpointNotSet, + EndpointSet, HttpClientError, RedirectUrl, Scope, StandardRevocableToken, TokenUrl, +}; +use serde::de::DeserializeOwned; + +/// OAuthService handles OAuth2 authentication flow. +/// It uses the `oauth2` crate to manage the OAuth2 process. +#[derive(Clone, Debug)] +pub struct OAuthService { + pub client: Client< + BasicErrorResponse, + BasicTokenResponse, + BasicTokenIntrospectionResponse, + StandardRevocableToken, + BasicRevocationErrorResponse, + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointSet, + >, +} + +impl OAuthService { + /// Creates a new OAuthService instance. + /// # Arguments + /// * `client_id` - The OAuth2 client ID. + /// * `client_secret` - The OAuth2 client secret. + /// * `auth_url` - The authorization endpoint URL. + /// * `token_url` - The token endpoint URL. + /// * `redirect_url` - The redirect URL after authorization. + /// # Returns + /// A new instance of OAuthService. + pub fn new( + client_id: impl Into, + client_secret: impl Into, + auth_url: impl Into, + token_url: impl Into, + redirect_url: impl Into, + ) -> Self { + Self { + client: BasicClient::new(ClientId::new(client_id.into())) + .set_client_secret(ClientSecret::new(client_secret.into())) + .set_auth_uri( + AuthUrl::new(auth_url.into()).expect("Invalid authorization endpoint URL"), + ) + .set_token_uri(TokenUrl::new(token_url.into()).expect("Invalid token endpoint URL")) + .set_redirect_uri( + RedirectUrl::new(redirect_url.into()).expect("Invalid redirect URL"), + ), + } + } + + /// Builds the authorization URL to redirect the user to. + /// # Returns + /// A tuple containing the authorization URL and the CSRF token. + pub fn build_authorize_url(&self) -> (Url, CsrfToken) { + self.client + .authorize_url(CsrfToken::new_random) + .add_scope(Scope::new("public_repo".to_string())) + .add_scope(Scope::new("user:email".to_string())) + .url() + } + + /// Exchanges the authorization code for an access token. + /// # Arguments + /// * `code` - The authorization code received from the OAuth2 provider. + /// * `http_client` - The HTTP client to use for the request. + /// # Returns + /// A result containing the token response or an error. + pub async fn exchange_code( + &self, + code: AuthorizationCode, + http_client: &reqwest::Client, + ) -> Result< + BasicTokenResponse, + oauth2::RequestTokenError, BasicErrorResponse>, + > { + self.client + .exchange_code(code) + .request_async(http_client) + .await + } + + /// Fetches user info from a generic OAuth2 provider. + /// # Arguments + /// * `access_token` - The OAuth2 access token. + /// * `http_client` - The HTTP client to use for the request. + /// * `user_info_url` - The endpoint returning the user's info (e.g., `/me`). + /// # Returns + /// A result containing the deserialized user info. + pub async fn get_user_info( + &self, + access_token: &str, + http_client: &reqwest::Client, + user_info_url: &str, + ) -> Result { + let user: T = http_client + .get(user_info_url) + .header(USER_AGENT, "rust-oauth2-client") + .header(AUTHORIZATION, format!("Bearer {}", access_token)) + .send() + .await? + .json() + .await?; + Ok(user) + } +} diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index 30d26121..00000000 --- a/src/utils.rs +++ /dev/null @@ -1,93 +0,0 @@ -#![warn(clippy::all)] -#![forbid(unsafe_code)] - -use crate::config::Config; -use colored::*; -use std::env; -use std::path::PathBuf; -use surrealdb::Error; - -/// A utility struct for common helper functions. -pub struct Utils; - -impl Utils { - /// Returns a path to a file in the same directory as the current executable. - /// - /// # Arguments - /// * `filename` - The name of the file to locate (e.g., "config.toml") - /// - /// # Returns - /// A `PathBuf` pointing to the specified file in the executable's directory - pub fn get_exec_path(filename: &str) -> PathBuf { - env::current_exe() - .expect("Failed to get executable path") - .parent() - .expect("Failed to get executable directory") - .join(filename) - } - - /// Displays a formatted error message for database connection issues. - /// - /// # Arguments - /// * `error` - The error encountered during database connection. - /// * `config` - The application configuration containing the database URL. - pub fn database_err_msg(error: &Error, config: &Config) { - const ERROR_TITLE: &str = "DATABASE ERROR"; - const PADDING: usize = 6; - const BULLET: &str = "● "; - const LABELS: [&str; 3] = ["URL:", "Error:", "Note:"]; - - let total_width = ERROR_TITLE.len() + (PADDING * 2); - let border_line = "─".repeat(total_width); - let box_parts = [ - format!("╭{border_line}╮"), - format!( - "│{}{}{}│", - " ".repeat(PADDING), - ERROR_TITLE, - " ".repeat(PADDING) - ), - format!("╰{border_line}╯"), - ]; - - for (i, part) in box_parts.iter().enumerate() { - eprintln!( - "{}", - if i == 1 { - part.bright_red().bold() - } else { - part.bright_red() - } - ); - } - - eprintln!( - "{} {}", - format!("{BULLET} {}", LABELS[0]).yellow().bold(), - config.database_url - ); - - let (problem, note) = if error.to_string().contains("authentication") { - ( - "Authentication failed", - "Check your database username and password in config.toml", - ) - } else { - ( - "Connection failed", - "Check if SurrealDB is running and network connectivity", - ) - }; - - eprintln!( - "{} {}", - format!("{BULLET} {}", LABELS[1]).yellow().bold(), - problem.red() - ); - eprintln!( - "{} {}", - format!("{BULLET} {}", LABELS[2]).yellow().bold(), - note.bright_white() - ); - } -}