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()
- );
- }
-}