| @@ -0,0 +1,2 @@ | |||
| DATABASE_URL=postgres://postgres:MxS2p37ViXtXikeb@localhost/puffpastry_jv | |||
| JWT_SECRET="Look for the union label, you crazy diamond" | |||
| @@ -0,0 +1 @@ | |||
| /target | |||
| @@ -0,0 +1,8 @@ | |||
| # Default ignored files | |||
| /shelf/ | |||
| /workspace.xml | |||
| # Editor-based HTTP Client requests | |||
| /httpRequests/ | |||
| # Datasource local storage ignored files | |||
| /dataSources/ | |||
| /dataSources.local.xml | |||
| @@ -0,0 +1,12 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <project version="4"> | |||
| <component name="DataSourceManagerImpl" format="xml" multifile-model="true"> | |||
| <data-source source="LOCAL" name="postgres@localhost" uuid="68351c5b-378b-43b9-b884-552cec2dc79f"> | |||
| <driver-ref>postgresql</driver-ref> | |||
| <synchronize>true</synchronize> | |||
| <jdbc-driver>org.postgresql.Driver</jdbc-driver> | |||
| <jdbc-url>jdbc:postgresql://localhost:5432/postgres</jdbc-url> | |||
| <working-dir>$ProjectFileDir$</working-dir> | |||
| </data-source> | |||
| </component> | |||
| </project> | |||
| @@ -0,0 +1,8 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <project version="4"> | |||
| <component name="ProjectModuleManager"> | |||
| <modules> | |||
| <module fileurl="file://$PROJECT_DIR$/.idea/puffpastry-backend.iml" filepath="$PROJECT_DIR$/.idea/puffpastry-backend.iml" /> | |||
| </modules> | |||
| </component> | |||
| </project> | |||
| @@ -0,0 +1,11 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <module type="EMPTY_MODULE" version="4"> | |||
| <component name="NewModuleRootManager"> | |||
| <content url="file://$MODULE_DIR$"> | |||
| <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" /> | |||
| <excludeFolder url="file://$MODULE_DIR$/target" /> | |||
| </content> | |||
| <orderEntry type="inheritedJdk" /> | |||
| <orderEntry type="sourceFolder" forTests="false" /> | |||
| </component> | |||
| </module> | |||
| @@ -0,0 +1,9 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <project version="4"> | |||
| <component name="SqlDialectMappings"> | |||
| <file url="file://$PROJECT_DIR$/migrations/2025-08-04-003151_create_proposals/up.sql" dialect="PostgreSQL" /> | |||
| <file url="file://$PROJECT_DIR$/migrations/2025-08-08-035440_create_amendments/up.sql" dialect="PostgreSQL" /> | |||
| <file url="file://$PROJECT_DIR$/migrations/2025-08-13-232400_create_comments/up.sql" dialect="PostgreSQL" /> | |||
| <file url="PROJECT" dialect="PostgreSQL" /> | |||
| </component> | |||
| </project> | |||
| @@ -0,0 +1,6 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <project version="4"> | |||
| <component name="VcsDirectoryMappings"> | |||
| <mapping directory="" vcs="Git" /> | |||
| </component> | |||
| </project> | |||
| @@ -0,0 +1,21 @@ | |||
| [package] | |||
| name = "puffpastry-backend" | |||
| version = "0.1.0" | |||
| edition = "2024" | |||
| [dependencies] | |||
| actix-web = "4.11.0" | |||
| serde = { version = "1.0.219", features = ["derive"] } | |||
| serde_json = "1.0.142" | |||
| ipfs-api-backend-actix = "0.7.0" | |||
| uuid = { version = "1.17.0", features = ["v4"] } | |||
| diesel = { version = "2.2.12", features = ["postgres", "chrono", "r2d2"] } | |||
| dotenvy = "0.15.7" | |||
| chrono = { version = "0.4.41", features = ["serde"] } | |||
| futures = "0.3.31" | |||
| diesel-derive-enum = { version = "3.0.0-beta.1", features = ["postgres"] } | |||
| argon2 = "0.5" | |||
| base64 = "0.22" | |||
| ed25519-dalek = { version = "2.1", features = ["rand_core"] } | |||
| stellar-strkey = "0.0.13" | |||
| jsonwebtoken = "9" | |||
| @@ -0,0 +1,23 @@ | |||
| PuffPastry Backend OpenAPI and Frontend SDK Generation | |||
| This backend exposes an OpenAPI 3.0 document at: | |||
| GET http://localhost:7300/openapi.json | |||
| You can generate a TypeScript client for your frontend using openapi-typescript-codegen: | |||
| 1) Install the generator (once): | |||
| npm install -g openapi-typescript-codegen | |||
| 2) Run code generation (adjust output path to your frontend project): | |||
| openapi --input http://localhost:7300/openapi.json --output ../frontend/src/api --client axios | |||
| If you prefer, you can output a local file first: | |||
| curl -s http://localhost:7300/openapi.json -o openapi.json | |||
| openapi --input openapi.json --output ../frontend/src/api --client axios | |||
| Notes | |||
| - The current OpenAPI spec is minimal and focuses on request shapes and endpoints. It’s sufficient for client generation. | |||
| - We can later switch to automatic spec generation with Apistos annotations without changing the endpoint path. | |||
| - If you add new endpoints, update the spec in src/routes/openapi.rs accordingly, or let’s enhance it with Apistos once we decide on the annotation strategy. | |||
| @@ -0,0 +1,3 @@ | |||
| fn main() { | |||
| println!("cargo:rustc-link-search=native=/Library/PostgreSQL/17/lib"); | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| # For documentation on how to configure this file, | |||
| # see https://diesel.rs/guides/configuring-diesel-cli | |||
| [print_schema] | |||
| file = "src/schema.rs" | |||
| custom_type_derives = ["diesel::query_builder::QueryId", "Clone"] | |||
| [migrations_directory] | |||
| dir = "/Users/jaredbell/RustroverProjects/puffpastry-backend/migrations" | |||
| @@ -0,0 +1,6 @@ | |||
| -- This file was automatically created by Diesel to setup helper functions | |||
| -- and other internal bookkeeping. This file is safe to edit, any future | |||
| -- changes will be added to existing projects as new migrations. | |||
| DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); | |||
| DROP FUNCTION IF EXISTS diesel_set_updated_at(); | |||
| @@ -0,0 +1,36 @@ | |||
| -- This file was automatically created by Diesel to setup helper functions | |||
| -- and other internal bookkeeping. This file is safe to edit, any future | |||
| -- changes will be added to existing projects as new migrations. | |||
| -- Sets up a trigger for the given table to automatically set a column called | |||
| -- `updated_at` whenever the row is modified (unless `updated_at` was included | |||
| -- in the modified columns) | |||
| -- | |||
| -- # Example | |||
| -- | |||
| -- ```sql | |||
| -- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); | |||
| -- | |||
| -- SELECT diesel_manage_updated_at('users'); | |||
| -- ``` | |||
| CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ | |||
| BEGIN | |||
| EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s | |||
| FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); | |||
| END; | |||
| $$ LANGUAGE plpgsql; | |||
| CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ | |||
| BEGIN | |||
| IF ( | |||
| NEW IS DISTINCT FROM OLD AND | |||
| NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at | |||
| ) THEN | |||
| NEW.updated_at := current_timestamp; | |||
| END IF; | |||
| RETURN NEW; | |||
| END; | |||
| $$ LANGUAGE plpgsql; | |||
| @@ -0,0 +1 @@ | |||
| drop table proposals; | |||
| @@ -0,0 +1,11 @@ | |||
| create table proposals ( | |||
| id serial primary key, | |||
| name text not null, | |||
| cid text not null, | |||
| summary text, | |||
| creator text not null, | |||
| is_current boolean not null, -- true for latest/current version | |||
| previous_cid text, -- the CID of the previous version, if any | |||
| created_at timestamp default now(), | |||
| updated_at timestamp | |||
| ) | |||
| @@ -0,0 +1,2 @@ | |||
| drop table amendments; | |||
| drop type amendment_status; | |||
| @@ -0,0 +1,20 @@ | |||
| create type amendment_status as enum ( | |||
| 'proposed', | |||
| 'approved', | |||
| 'withdrawn', | |||
| 'rejected' | |||
| ); | |||
| create table amendments ( | |||
| id serial primary key, | |||
| name text not null, | |||
| cid text not null, | |||
| summary text, | |||
| status amendment_status not null default 'proposed', | |||
| creator text not null, | |||
| is_current boolean not null default true, | |||
| previous_cid text, | |||
| created_at timestamptz not null default now(), | |||
| updated_at timestamptz not null default now(), | |||
| proposal_id integer not null references proposals(id) | |||
| ) | |||
| @@ -0,0 +1 @@ | |||
| drop table users; | |||
| @@ -0,0 +1,17 @@ | |||
| -- Users table to hold account information | |||
| -- Postgres dialect | |||
| create table users ( | |||
| id serial primary key, | |||
| username text not null unique, | |||
| password_hash text not null, | |||
| stellar_address text not null unique, | |||
| email text unique, | |||
| is_active boolean not null default true, | |||
| roles text[] not null default '{}', | |||
| last_login_at timestamptz, | |||
| created_at timestamptz not null default now(), | |||
| updated_at timestamptz not null default now() | |||
| ); | |||
| -- Optional helpful indexes (unique already creates indexes, but adding a lower() index for case-insensitive lookups could be considered later) | |||
| @@ -0,0 +1 @@ | |||
| drop table comments; | |||
| @@ -0,0 +1,12 @@ | |||
| -- Comments schema | |||
| -- Postgres dialect | |||
| create table comments ( | |||
| id serial primary key, | |||
| cid text not null, | |||
| is_current bool not null default true, | |||
| previous_cid text, | |||
| proposal_id integer not null references proposals(id) on delete cascade, | |||
| created_at timestamptz not null default now(), | |||
| updated_at timestamptz not null default now() | |||
| ); | |||
| @@ -0,0 +1,7 @@ | |||
| drop index if exists idx_visits_method; | |||
| drop index if exists idx_visits_path; | |||
| drop index if exists idx_visits_proposal_id; | |||
| drop index if exists idx_visits_user_id; | |||
| drop index if exists idx_visits_visited_at; | |||
| drop table if exists visits; | |||
| @@ -0,0 +1,27 @@ | |||
| -- Visits tracking table | |||
| -- Postgres dialect | |||
| create table visits ( | |||
| id serial primary key, | |||
| user_id integer references users(id) on delete set null, | |||
| proposal_id integer not null, | |||
| path text not null, | |||
| method text not null, | |||
| query_string text, | |||
| status_code integer, | |||
| ip_address text, | |||
| user_agent text, | |||
| referrer text, | |||
| session_id text, | |||
| request_id text, | |||
| visited_at timestamptz not null default now(), | |||
| created_at timestamptz not null default now(), | |||
| updated_at timestamptz not null default now() | |||
| ); | |||
| -- Helpful indexes for analytics and lookups | |||
| create index idx_visits_visited_at on visits(visited_at); | |||
| create index idx_visits_user_id on visits(user_id); | |||
| create index idx_visits_proposal_id on visits(proposal_id); | |||
| create index idx_visits_path on visits(path); | |||
| create index idx_visits_method on visits(method); | |||
| @@ -0,0 +1 @@ | |||
| alter table users drop column full_name; | |||
| @@ -0,0 +1 @@ | |||
| alter table users add column full_name text; | |||
| @@ -0,0 +1,26 @@ | |||
| use diesel::r2d2::{self, ConnectionManager}; | |||
| use diesel::PgConnection; | |||
| use std::env; | |||
| use dotenvy::dotenv; | |||
| pub type DbPool = r2d2::Pool<ConnectionManager<PgConnection>>; | |||
| pub type DbConn = r2d2::PooledConnection<ConnectionManager<PgConnection>>; | |||
| pub fn establish_connection() -> Result<DbPool, Box<dyn std::error::Error>> { | |||
| dotenv().ok(); | |||
| let database_url = env::var("DATABASE_URL") | |||
| .map_err(|_| "DATABASE_URL must be set")?; | |||
| let manager = ConnectionManager::<PgConnection>::new(database_url); | |||
| let pool = r2d2::Pool::builder() | |||
| .build(manager) | |||
| .map_err(|e| format!("Failed to create pool: {}", e))?; | |||
| Ok(pool) | |||
| } | |||
| pub fn get_connection() -> Result<DbConn, Box<dyn std::error::Error>> { | |||
| let pool = establish_connection()?; | |||
| pool.get().map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) }) | |||
| } | |||
| @@ -0,0 +1 @@ | |||
| pub(crate) mod db; | |||
| @@ -0,0 +1,82 @@ | |||
| use crate::db::db::get_connection; | |||
| use crate::ipfs::ipfs::IpfsService; | |||
| use crate::schema::amendments; | |||
| use crate::types::amendment::{Amendment, AmendmentError, AmendmentFile}; | |||
| use crate::types::ipfs::IpfsResult; | |||
| use crate::utils::content::extract_summary; | |||
| use crate::utils::ipfs::{read_json_via_cat, upload_json_and_get_hash, DEFAULT_MAX_JSON_SIZE}; | |||
| use diesel::ExpressionMethods; | |||
| use diesel::QueryDsl; | |||
| use diesel::RunQueryDsl; | |||
| use ipfs_api_backend_actix::IpfsClient; | |||
| const STORAGE_DIR: &str = "/puffpastry/amendments"; | |||
| const FILE_EXTENSION: &str = "json"; | |||
| pub struct AmendmentService { | |||
| client: IpfsClient, | |||
| } | |||
| impl IpfsService<AmendmentFile> for AmendmentService { | |||
| type Err = AmendmentError; | |||
| async fn save(&mut self, item: AmendmentFile) -> IpfsResult<String, Self::Err> { | |||
| self.store_amendment_to_ipfs(item).await | |||
| } | |||
| async fn read(&mut self, hash: String) -> IpfsResult<AmendmentFile, Self::Err> { | |||
| self.read_amendment_file(&hash).await | |||
| } | |||
| } | |||
| impl AmendmentService { | |||
| pub fn new(client: IpfsClient) -> Self { | |||
| Self { client } | |||
| } | |||
| async fn store_amendment_to_ipfs(&self, amendment: AmendmentFile) -> IpfsResult<String, AmendmentError> { | |||
| let amendment_record = Amendment::new( | |||
| amendment.name.clone(), | |||
| Some(extract_summary(&amendment.content)), | |||
| amendment.creator.clone(), | |||
| amendment.proposal_id, | |||
| ); | |||
| let hash = self.upload_to_ipfs(&amendment).await?; | |||
| self.save_to_database(amendment_record.with_cid(hash.clone()).mark_as_current()).await?; | |||
| { | |||
| use crate::schema::amendments::dsl::{amendments as amendments_table, is_current as is_current_col, previous_cid as previous_cid_col}; | |||
| let mut conn = get_connection() | |||
| .map_err(|e| AmendmentError::DatabaseError(e))?; | |||
| // Ignore the count result; if no rows match, that's fine | |||
| let _ = diesel::update(amendments_table.filter(previous_cid_col.eq(hash.clone()))) | |||
| .set(is_current_col.eq(false)) | |||
| .execute(&mut conn) | |||
| .map_err(|e| AmendmentError::DatabaseError(Box::new(e)))?; | |||
| } | |||
| Ok(hash) | |||
| } | |||
| async fn upload_to_ipfs(&self, amendment: &AmendmentFile) -> IpfsResult<String, AmendmentError> { | |||
| upload_json_and_get_hash::<AmendmentFile, AmendmentError>(&self.client, STORAGE_DIR, FILE_EXTENSION, amendment).await | |||
| } | |||
| async fn save_to_database(&self, amendment: Amendment) -> IpfsResult<(), AmendmentError> { | |||
| let mut conn = get_connection() | |||
| .map_err(|e| AmendmentError::DatabaseError(e))?; | |||
| diesel::insert_into(amendments::table) | |||
| .values(&amendment) | |||
| .execute(&mut conn) | |||
| .map_err(|e| AmendmentError::DatabaseError(Box::new(e)))?; | |||
| Ok(()) | |||
| } | |||
| async fn read_amendment_file(&self, hash: &str) -> IpfsResult<AmendmentFile, AmendmentError> { | |||
| read_json_via_cat::<AmendmentFile, AmendmentError>(&self.client, hash, DEFAULT_MAX_JSON_SIZE).await | |||
| } | |||
| } | |||
| @@ -0,0 +1,106 @@ | |||
| use crate::ipfs::ipfs::IpfsService; | |||
| use crate::types::comment::{CommentError, CommentMetadata}; | |||
| use crate::types::ipfs::IpfsResult; | |||
| use crate::utils::ipfs::{ | |||
| create_file_path, | |||
| create_storage_directory, | |||
| read_json_via_cat, | |||
| retrieve_content_hash, | |||
| save_json_file, | |||
| DEFAULT_MAX_JSON_SIZE, | |||
| list_directory_file_hashes, | |||
| }; | |||
| use ipfs_api_backend_actix::IpfsClient; | |||
| const STORAGE_DIR: &str = "/puffpastry/comments"; | |||
| const FILE_EXTENSION: &str = "json"; | |||
| pub struct CommentService { | |||
| client: IpfsClient, | |||
| } | |||
| // Implement per-proposal subdirectory saving. The input is (proposal_cid, comments_batch) | |||
| impl IpfsService<(String, Vec<CommentMetadata>)> for CommentService { | |||
| type Err = CommentError; | |||
| async fn save(&mut self, item: (String, Vec<CommentMetadata>)) -> IpfsResult<String, Self::Err> { | |||
| let (proposal_cid, comments) = item; | |||
| // Allow batch save within the proposal's subdirectory. | |||
| if comments.is_empty() { | |||
| return Err(CommentError::from(std::io::Error::new( | |||
| std::io::ErrorKind::InvalidInput, | |||
| "Failed to store comment to IPFS: empty comments batch", | |||
| ))); | |||
| } | |||
| let mut last_cid: Option<String> = None; | |||
| for comment in comments { | |||
| let res = self | |||
| .store_comment_in_proposal_dir_and_publish(&proposal_cid, comment) | |||
| .await; | |||
| match res { | |||
| Ok(cid) => last_cid = Some(cid), | |||
| Err(e) => return Err(e), | |||
| } | |||
| } | |||
| match last_cid { | |||
| Some(cid) => Ok(cid), | |||
| None => Err(CommentError::from(std::io::Error::new( | |||
| std::io::ErrorKind::Other, | |||
| "Failed to store comment to IPFS", | |||
| ))), | |||
| } | |||
| } | |||
| async fn read(&mut self, hash: String) -> IpfsResult<(String, Vec<CommentMetadata>), Self::Err> { | |||
| // For reading, the caller should pass a directory hash (CID). We return only the comments vector here. | |||
| // Since IpfsService requires returning the same T, include an empty proposal id (unknown in read-by-hash). | |||
| // Alternatively, callers should not use the proposal id from this return value. | |||
| let comments = self.read_all_comments_in_dir(&hash).await?; | |||
| Ok((String::new(), comments)) | |||
| } | |||
| } | |||
| impl CommentService { | |||
| pub fn new(client: IpfsClient) -> Self { | |||
| Self { client } | |||
| } | |||
| // Writes a single comment JSON file into the MFS comments subdirectory for the proposal, | |||
| // then publishes the subdirectory snapshot (by retrieving the directory CID). Returns the comment file CID. | |||
| async fn store_comment_in_proposal_dir_and_publish( | |||
| &self, | |||
| proposal_cid: &str, | |||
| comment: CommentMetadata, | |||
| ) -> IpfsResult<String, CommentError> { | |||
| // Ensure the per-proposal storage subdirectory exists | |||
| let proposal_dir = format!("{}/{}", STORAGE_DIR, proposal_cid); | |||
| create_storage_directory::<CommentError>(&self.client, &proposal_dir).await?; | |||
| // Create a unique file path within the proposal subdirectory and save the JSON content | |||
| let file_path = create_file_path(&proposal_dir, FILE_EXTENSION); | |||
| save_json_file::<CommentMetadata, CommentError>(&self.client, &file_path, &comment).await?; | |||
| // Retrieve the file's CID to return to the caller | |||
| let file_cid = retrieve_content_hash::<CommentError>(&self.client, &file_path).await?; | |||
| // Publish a new snapshot of the proposal's comments subdirectory by retrieving its CID | |||
| let _dir_cid = retrieve_content_hash::<CommentError>(&self.client, &proposal_dir).await?; | |||
| Ok(file_cid) | |||
| } | |||
| async fn read_comment_file(&self, hash: &str) -> IpfsResult<CommentMetadata, CommentError> { | |||
| read_json_via_cat::<CommentMetadata, CommentError>(&self.client, hash, DEFAULT_MAX_JSON_SIZE).await | |||
| } | |||
| // Read all comment files within a directory identified by the given hash (directory CID) | |||
| async fn read_all_comments_in_dir(&self, dir_hash: &str) -> IpfsResult<Vec<CommentMetadata>, CommentError> { | |||
| let file_hashes = list_directory_file_hashes::<CommentError>(&self.client, dir_hash).await?; | |||
| let mut comments: Vec<CommentMetadata> = Vec::with_capacity(file_hashes.len()); | |||
| for fh in file_hashes { | |||
| let comment = self.read_comment_file(&fh).await?; | |||
| comments.push(comment); | |||
| } | |||
| Ok(comments) | |||
| } | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| use crate::types::ipfs::IpfsResult; | |||
| pub trait IpfsService<T> { | |||
| type Err; | |||
| async fn save(&mut self, item: T) -> IpfsResult<String, Self::Err>; | |||
| async fn read(&mut self, hash: String) -> IpfsResult<T, Self::Err>; | |||
| } | |||
| @@ -0,0 +1,4 @@ | |||
| pub(crate) mod ipfs; | |||
| pub(crate) mod proposal; | |||
| pub(crate) mod amendment; | |||
| pub(crate) mod comment; | |||
| @@ -0,0 +1,81 @@ | |||
| use crate::db::db::get_connection; | |||
| use crate::ipfs::ipfs::IpfsService; | |||
| use crate::schema::proposals; | |||
| use crate::types::proposal::{Proposal, ProposalError, ProposalFile}; | |||
| use crate::utils::content::extract_summary; | |||
| use crate::utils::ipfs::{upload_json_and_get_hash, read_json_via_cat as util_read_json_via_cat, DEFAULT_MAX_JSON_SIZE}; | |||
| use diesel::{RunQueryDsl, QueryDsl, ExpressionMethods}; | |||
| use ipfs_api_backend_actix::IpfsClient; | |||
| use crate::types::ipfs::IpfsResult; | |||
| const STORAGE_DIR: &str = "/puffpastry/proposals"; | |||
| const FILE_EXTENSION: &str = "json"; | |||
| pub struct ProposalService { | |||
| client: IpfsClient, | |||
| } | |||
| impl IpfsService<ProposalFile> for ProposalService { | |||
| type Err = ProposalError; | |||
| async fn save(&mut self, proposal: ProposalFile) -> IpfsResult<String, Self::Err> { | |||
| self.store_proposal_to_ipfs(proposal).await | |||
| } | |||
| async fn read(&mut self, hash: String) -> IpfsResult<ProposalFile, Self::Err> { | |||
| self.read_proposal_file(hash).await | |||
| } | |||
| } | |||
| impl ProposalService { | |||
| pub fn new(client: IpfsClient) -> Self { | |||
| Self { client } | |||
| } | |||
| async fn store_proposal_to_ipfs(&self, proposal: ProposalFile) -> IpfsResult<String, ProposalError> { | |||
| let proposal_record = Proposal::new( | |||
| proposal.name.clone(), | |||
| Some(extract_summary(&proposal.content)), | |||
| proposal.creator.clone(), | |||
| ); | |||
| let hash = self.upload_to_ipfs(&proposal).await?; | |||
| self.save_to_database(proposal_record.with_cid(hash.clone()).mark_as_current()).await?; | |||
| // After saving the new proposal, set is_current = false for any proposal | |||
| // that has previous_cid equal to this new hash | |||
| { | |||
| use crate::schema::proposals::dsl::{proposals as proposals_table, previous_cid as previous_cid_col, is_current as is_current_col}; | |||
| let mut conn = get_connection() | |||
| .map_err(|e| ProposalError::DatabaseError(e))?; | |||
| // Ignore the count result; if no rows match, that's fine | |||
| let _ = diesel::update(proposals_table.filter(previous_cid_col.eq(hash.clone()))) | |||
| .set(is_current_col.eq(false)) | |||
| .execute(&mut conn) | |||
| .map_err(|e| ProposalError::DatabaseError(Box::new(e)))?; | |||
| } | |||
| Ok(hash) | |||
| } | |||
| async fn upload_to_ipfs(&self, data: &ProposalFile) -> IpfsResult<String, ProposalError> { | |||
| upload_json_and_get_hash::<ProposalFile, ProposalError>(&self.client, STORAGE_DIR, FILE_EXTENSION, data).await | |||
| } | |||
| async fn save_to_database(&self, proposal: Proposal) -> IpfsResult<(), ProposalError> { | |||
| let mut conn = get_connection() | |||
| .map_err(|e| ProposalError::DatabaseError(e))?; | |||
| diesel::insert_into(proposals::table) | |||
| .values(&proposal) | |||
| .execute(&mut conn) | |||
| .map_err(|e| ProposalError::DatabaseError(Box::new(e)))?; | |||
| Ok(()) | |||
| } | |||
| async fn read_proposal_file(&self, hash: String) -> IpfsResult<ProposalFile, ProposalError> { | |||
| util_read_json_via_cat::<ProposalFile, ProposalError>(&self.client, &hash, DEFAULT_MAX_JSON_SIZE).await | |||
| } | |||
| } | |||
| @@ -0,0 +1,63 @@ | |||
| extern crate core; | |||
| mod routes; | |||
| mod schema; | |||
| mod db; | |||
| mod ipfs; | |||
| mod types; | |||
| mod repositories; | |||
| mod utils; | |||
| use crate::routes::proposal::{add_proposal, get_proposal, list_proposals, visit_proposal}; | |||
| use actix_web::{web, App, HttpServer}; | |||
| use crate::routes::amendment::{add_amendment, get_amendment, list_amendments}; | |||
| use crate::routes::auth::{register, login, login_freighter, get_nonce}; | |||
| use crate::routes::openapi::get_openapi; | |||
| use crate::utils::env::load_env; | |||
| #[actix_web::main] | |||
| async fn main() -> std::io::Result<()> { | |||
| // Load environment variables from .env file (if present) | |||
| load_env(); | |||
| let ip = "127.0.0.1"; | |||
| let port = 7300; | |||
| println!("Starting server on http://{}:{}", ip, port); | |||
| HttpServer::new(|| { | |||
| App::new() | |||
| .service(get_openapi) | |||
| .service( | |||
| web::scope("/api/v1") | |||
| .service( | |||
| web::scope("/proposals") | |||
| .service(list_proposals) | |||
| ) | |||
| .service( | |||
| web::scope("/proposal") | |||
| .service(add_proposal) | |||
| .service(get_proposal) | |||
| .service(visit_proposal) | |||
| ) | |||
| .service( | |||
| web::scope("/amendment") | |||
| .service(add_amendment) | |||
| .service(get_amendment) | |||
| ) | |||
| .service( | |||
| web::scope("/amendments") | |||
| .service(list_amendments) | |||
| ) | |||
| .service( | |||
| web::scope("/auth") | |||
| .service(register) | |||
| .service(login) | |||
| .service(login_freighter) | |||
| .service(get_nonce) | |||
| ) | |||
| ) | |||
| }) | |||
| .bind((ip, port))? | |||
| .run() | |||
| .await | |||
| } | |||
| @@ -0,0 +1,49 @@ | |||
| use crate::db::db::establish_connection; | |||
| use crate::schema::amendments::dsl::amendments; | |||
| use crate::types::amendment::SelectableAmendment; | |||
| use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper}; | |||
| pub fn get_amendments_for_proposal( | |||
| proposal_id: i32, | |||
| ) -> Result<Vec<SelectableAmendment>, diesel::result::Error> { | |||
| let pool = establish_connection().map_err(|_| { | |||
| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to establish database connection".to_string()), | |||
| ) | |||
| })?; | |||
| let mut conn = pool.get().map_err(|_| { | |||
| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::UnableToSendCommand, | |||
| Box::new("Failed to get connection from pool".to_string()), | |||
| ) | |||
| })?; | |||
| use crate::schema::amendments::dsl::{amendments, proposal_id as proposal_id_col}; | |||
| amendments | |||
| .filter(proposal_id_col.eq(proposal_id)) | |||
| .select(SelectableAmendment::as_select()) | |||
| .order(crate::schema::amendments::id.asc()) | |||
| .load(&mut conn) | |||
| } | |||
| pub fn get_cid_for_amendment(amendment_id: i32) -> Result<String, diesel::result::Error> { | |||
| let pool = establish_connection() | |||
| .map_err(|_| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to establish database connection".to_string()) | |||
| ))?; | |||
| let mut conn = pool.get() | |||
| .map_err(|_| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to get database connection from pool".to_string()) | |||
| ))?; | |||
| use crate::schema::amendments::dsl::{id as id_col, cid as cid_col}; | |||
| amendments | |||
| .filter(id_col.eq(amendment_id)) | |||
| .select(cid_col) | |||
| .get_result(&mut conn) | |||
| } | |||
| @@ -0,0 +1,76 @@ | |||
| use diesel::{RunQueryDsl, ExpressionMethods, QueryDsl, Connection, BoolExpressionMethods}; | |||
| use crate::db::db::establish_connection; | |||
| use crate::schema::comments::dsl::*; | |||
| // Store the new CID for the updated comment thread file on IPFS | |||
| // Instead of updating an existing record, we: | |||
| // 1) Mark the previous record (by its CID) as not current (is_current=false) | |||
| // 2) Insert a new record with is_current=true and previous_cid set to the previous CID | |||
| pub fn store_new_thread_cid_for_proposal( | |||
| pid: i32, | |||
| new_cid_value: &str, | |||
| previous_cid_value: Option<&str>, | |||
| ) -> Result<(), diesel::result::Error> { | |||
| let pool = establish_connection() | |||
| .map_err(|_| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to establish database connection".to_string()), | |||
| ))?; | |||
| let mut conn = pool.get().map_err(|_| { | |||
| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to get database connection from pool".to_string()), | |||
| ) | |||
| })?; | |||
| conn.transaction(|conn| { | |||
| if let Some(prev) = previous_cid_value { | |||
| // Best-effort mark previous as not current; it's okay if 0 rows were updated (e.g., first insert) | |||
| let _ = diesel::update(comments.filter(cid.eq(prev))) | |||
| .set(is_current.eq(false)) | |||
| .execute(conn)?; | |||
| } | |||
| diesel::insert_into(crate::schema::comments::table) | |||
| .values(( | |||
| cid.eq(new_cid_value), | |||
| proposal_id.eq(pid), | |||
| is_current.eq(true), | |||
| previous_cid.eq(previous_cid_value), | |||
| )) | |||
| .execute(conn) | |||
| .map(|_| ()) | |||
| }) | |||
| } | |||
| // Retrieve the latest/current thread CID for a proposal | |||
| pub fn latest_thread_cid_for_proposal(pid_value: i32) -> Result<String, diesel::result::Error> { | |||
| let pool = establish_connection() | |||
| .map_err(|_| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to establish database connection".to_string()), | |||
| ))?; | |||
| let mut conn = pool.get().map_err(|_| { | |||
| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to get database connection from pool".to_string()), | |||
| ) | |||
| })?; | |||
| // Prefer the record marked as current; fallback to most recent if none are marked | |||
| match comments | |||
| .filter(proposal_id.eq(pid_value).and(is_current.eq(true))) | |||
| .order(created_at.desc()) | |||
| .select(cid) | |||
| .first::<String>(&mut conn) | |||
| { | |||
| Ok(current) => Ok(current), | |||
| Err(_) => comments | |||
| .filter(proposal_id.eq(pid_value)) | |||
| .order(created_at.desc()) | |||
| .select(cid) | |||
| .first::<String>(&mut conn), | |||
| } | |||
| } | |||
| @@ -0,0 +1,5 @@ | |||
| pub(crate) mod proposal; | |||
| pub(crate) mod amendment; | |||
| pub(crate) mod user; | |||
| pub(crate) mod comment; | |||
| pub(crate) mod visit; | |||
| @@ -0,0 +1,48 @@ | |||
| use diesel::ExpressionMethods; | |||
| use diesel::{QueryDsl, RunQueryDsl}; | |||
| use crate::db::db::establish_connection; | |||
| use crate::schema::proposals::dsl::*; | |||
| use crate::types::proposal::SelectableProposal; | |||
| pub fn paginate_proposals( | |||
| offset: i64, | |||
| limit: i64 | |||
| ) -> Result<Vec<SelectableProposal>, diesel::result::Error> { | |||
| let pool = establish_connection() | |||
| .map_err(|_| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to establish database connection".to_string()) | |||
| ))?; | |||
| let mut conn = pool.get() | |||
| .map_err(|_| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to get database connection from pool".to_string()) | |||
| ))?; | |||
| proposals | |||
| .filter(is_current.eq(true)) | |||
| .offset(offset) | |||
| .limit(limit) | |||
| .order(created_at.desc()) | |||
| .load::<SelectableProposal>(&mut conn) | |||
| } | |||
| pub fn get_cid_for_proposal(proposal_id: i32) -> Result<String, diesel::result::Error> { | |||
| let pool = establish_connection() | |||
| .map_err(|_| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to establish database connection".to_string()) | |||
| ))?; | |||
| let mut conn = pool.get() | |||
| .map_err(|_| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to get database connection from pool".to_string()) | |||
| ))?; | |||
| proposals. | |||
| filter(id.eq(proposal_id)) | |||
| .select(cid) | |||
| .get_result(&mut conn) | |||
| } | |||
| @@ -0,0 +1,107 @@ | |||
| use diesel::prelude::*; | |||
| use diesel::RunQueryDsl; | |||
| use diesel::pg::PgConnection; | |||
| use diesel::r2d2; | |||
| use serde::Serialize; | |||
| use crate::db::db::establish_connection; | |||
| use crate::schema::users; | |||
| use crate::schema::users::dsl::*; | |||
| #[derive(Insertable)] | |||
| #[diesel(table_name = users)] | |||
| pub struct NewUserInsert { | |||
| pub username: String, | |||
| pub password_hash: String, | |||
| pub stellar_address: String, | |||
| pub email: Option<String>, | |||
| } | |||
| #[derive(Serialize)] | |||
| pub struct RegisteredUser { | |||
| pub id: i32, | |||
| pub username: String, | |||
| pub stellar_address: String, | |||
| pub email: Option<String>, | |||
| } | |||
| #[derive(Queryable)] | |||
| pub struct UserAuth { | |||
| pub id: i32, | |||
| pub username: String, | |||
| pub full_name: Option<String>, | |||
| pub password_hash: String, | |||
| pub stellar_address: String, | |||
| pub email: Option<String>, | |||
| pub is_active: bool, | |||
| } | |||
| #[derive(Queryable, Serialize)] | |||
| pub struct UserForAuth { | |||
| pub id: i32, | |||
| pub full_name: Option<String>, | |||
| pub username: String, | |||
| pub stellar_address: String, | |||
| pub email: Option<String>, | |||
| pub is_active: bool, | |||
| } | |||
| fn get_conn() -> Result<r2d2::PooledConnection<diesel::r2d2::ConnectionManager<PgConnection>>, diesel::result::Error> { | |||
| let pool = establish_connection().map_err(|_| { | |||
| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to establish database connection".to_string()), | |||
| ) | |||
| })?; | |||
| pool.get().map_err(|_| { | |||
| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to get database connection from pool".to_string()), | |||
| ) | |||
| }) | |||
| } | |||
| pub(crate) fn create_user( | |||
| new_user: NewUserInsert, | |||
| ) -> Result<RegisteredUser, diesel::result::Error> { | |||
| let mut conn = get_conn()?; | |||
| diesel::insert_into(users::table) | |||
| .values(&new_user) | |||
| .returning((id, username, stellar_address, email)) | |||
| .get_result::<(i32, String, String, Option<String>)>(&mut conn) | |||
| .map(|(uid, uname, saddr, mail)| RegisteredUser { | |||
| id: uid, | |||
| username: uname, | |||
| stellar_address: saddr, | |||
| email: mail, | |||
| }) | |||
| } | |||
| pub(crate) fn find_user_by_username(uname: &str) -> Result<UserAuth, diesel::result::Error> { | |||
| let mut conn = get_conn()?; | |||
| users | |||
| .filter(username.eq(uname)) | |||
| .select((id, username, full_name, password_hash, stellar_address, email, is_active)) | |||
| .first::<UserAuth>(&mut conn) | |||
| } | |||
| pub(crate) fn get_user_for_auth_by_stellar(addr: &str) -> Result<UserForAuth, diesel::result::Error> { | |||
| let mut conn = get_conn()?; | |||
| users | |||
| .filter(stellar_address.eq(addr)) | |||
| .select((id, full_name, username, stellar_address, email, is_active)) | |||
| .first::<UserForAuth>(&mut conn) | |||
| } | |||
| pub(crate) fn get_user_for_auth_by_email(mail: &str) -> Result<UserForAuth, diesel::result::Error> { | |||
| let mut conn = get_conn()?; | |||
| users | |||
| .filter(email.eq(mail)) | |||
| .select((id, full_name, username, stellar_address, email, is_active)) | |||
| .first::<UserForAuth>(&mut conn) | |||
| } | |||
| @@ -0,0 +1,94 @@ | |||
| use diesel::sql_query; | |||
| use diesel::RunQueryDsl; | |||
| use diesel::sql_types::{BigInt, Int4, Nullable, Text, Timestamptz}; | |||
| use crate::db::db::establish_connection; | |||
| use chrono::{DateTime, Duration, Utc}; | |||
| use diesel::QueryableByName; | |||
| pub fn record_visit( | |||
| proposal_id_val: i32, | |||
| user_id_val: Option<i32>, | |||
| path_val: &str, | |||
| method_val: &str, | |||
| query_string_val: Option<&str>, | |||
| status_code_val: Option<i32>, | |||
| ip_address_val: Option<&str>, | |||
| user_agent_val: Option<&str>, | |||
| referrer_val: Option<&str>, | |||
| session_id_val: Option<&str>, | |||
| request_id_val: Option<&str>, | |||
| ) -> Result<(), diesel::result::Error> { | |||
| let pool = establish_connection() | |||
| .map_err(|_| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to establish database connection".to_string()), | |||
| ))?; | |||
| let mut conn = pool.get().map_err(|_| { | |||
| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to get database connection from pool".to_string()), | |||
| ) | |||
| })?; | |||
| // Use raw SQL to avoid requiring schema.rs updates for the visits table | |||
| let query = sql_query( | |||
| "insert into visits (user_id, proposal_id, path, method, query_string, status_code, ip_address, user_agent, referrer, session_id, request_id) \ | |||
| values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)" | |||
| ) | |||
| .bind::<Nullable<Int4>, _>(user_id_val) | |||
| .bind::<Int4, _>(proposal_id_val) | |||
| .bind::<Text, _>(path_val) | |||
| .bind::<Text, _>(method_val) | |||
| .bind::<Nullable<Text>, _>(query_string_val) | |||
| .bind::<Nullable<Int4>, _>(status_code_val) | |||
| .bind::<Nullable<Text>, _>(ip_address_val) | |||
| .bind::<Nullable<Text>, _>(user_agent_val) | |||
| .bind::<Nullable<Text>, _>(referrer_val) | |||
| .bind::<Nullable<Text>, _>(session_id_val) | |||
| .bind::<Nullable<Text>, _>(request_id_val); | |||
| // Execute insert | |||
| query.execute(&mut conn).map(|_| ()) | |||
| } | |||
| #[derive(QueryableByName)] | |||
| struct CountResult { | |||
| #[diesel(sql_type = BigInt)] | |||
| cnt: i64, | |||
| } | |||
| pub fn recent_visit_count_since( | |||
| proposal_id_val: i32, | |||
| since: DateTime<Utc>, | |||
| ) -> Result<i64, diesel::result::Error> { | |||
| let pool = establish_connection() | |||
| .map_err(|_| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to establish database connection".to_string()), | |||
| ))?; | |||
| let mut conn = pool.get().map_err(|_| { | |||
| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| Box::new("Failed to get database connection from pool".to_string()), | |||
| ) | |||
| })?; | |||
| let rows: Vec<CountResult> = sql_query( | |||
| "select count(*) as cnt from visits where proposal_id = $1 and visited_at > $2" | |||
| ) | |||
| .bind::<Int4, _>(proposal_id_val) | |||
| .bind::<Timestamptz, _>(since) | |||
| .load(&mut conn)?; | |||
| Ok(rows.get(0).map(|r| r.cnt).unwrap_or(0)) | |||
| } | |||
| pub fn recent_visit_count_in_minutes( | |||
| proposal_id_val: i32, | |||
| minutes: i64, | |||
| ) -> Result<i64, diesel::result::Error> { | |||
| let since = Utc::now() - Duration::minutes(minutes); | |||
| recent_visit_count_since(proposal_id_val, since) | |||
| } | |||
| @@ -0,0 +1,102 @@ | |||
| use actix_web::{get, post, web, HttpResponse}; | |||
| use ipfs_api_backend_actix::IpfsClient; | |||
| use serde::{Deserialize, Serialize}; | |||
| use serde_json::json; | |||
| use crate::ipfs::amendment::AmendmentService; | |||
| use crate::ipfs::ipfs::IpfsService; | |||
| use crate::repositories::amendment::{get_amendments_for_proposal, get_cid_for_amendment}; | |||
| use crate::types::amendment::{AmendmentError, AmendmentFile}; | |||
| #[derive(Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct ListAmendmentsRequest { | |||
| pub proposal_id: i32, | |||
| } | |||
| #[get("list")] | |||
| async fn list_amendments(params: web::Query<ListAmendmentsRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| match get_amendments_for_proposal(params.proposal_id) { | |||
| Ok(amendments) => Ok(HttpResponse::Ok().json(json!({"amendments": amendments}))), | |||
| Err(err) => Ok(HttpResponse::InternalServerError().json( | |||
| json!({"error": err.to_string()}) | |||
| )) | |||
| } | |||
| } | |||
| #[derive(Serialize, Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct AddAmendmentRequest { | |||
| pub proposal_id: i32, | |||
| pub name: String, | |||
| pub content: String, | |||
| pub creator: String, | |||
| } | |||
| #[post("add")] | |||
| async fn add_amendment(params: web::Json<AddAmendmentRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| let req = params.into_inner(); | |||
| // Basic input validation | |||
| if req.creator.trim().is_empty() { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "Creator is required"}))); | |||
| } | |||
| if req.name.trim().is_empty() { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "Name is required"}))); | |||
| } | |||
| if req.content.trim().is_empty() { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "Content is required"}))); | |||
| } | |||
| if req.proposal_id <= 0 { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "proposalId must be a positive integer"}))); | |||
| } | |||
| match create_and_store_amendment(req).await { | |||
| Ok(cid) => Ok(HttpResponse::Ok().json(json!({"cid": cid}))), | |||
| Err(err) => { | |||
| match err { | |||
| AmendmentError::SerializationError(e) => Ok(HttpResponse::BadRequest().json( | |||
| json!({"error": format!("Invalid amendment payload: {}", e)}) | |||
| )), | |||
| _ => Ok(HttpResponse::InternalServerError().json( | |||
| json!({"error": format!("Failed to add amendment: {}", err)}) | |||
| )), | |||
| } | |||
| } | |||
| } | |||
| } | |||
| #[derive(Serialize, Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct GetAmendmentRequest { | |||
| pub amendment_id: i32, | |||
| } | |||
| #[get("get")] | |||
| async fn get_amendment(request: web::Query<GetAmendmentRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| let mut amendment_client = AmendmentService::new(IpfsClient::default()); | |||
| let cid = match get_cid_for_amendment(request.amendment_id) { | |||
| Ok(cid) => cid, | |||
| Err(err) => return Ok(HttpResponse::InternalServerError().json( | |||
| json!({"error": format!("Failed to get amendment: {}", err)}) | |||
| )) | |||
| }; | |||
| let item = amendment_client.read(cid).await; | |||
| match item { | |||
| Ok(item) => Ok(HttpResponse::Ok().json(json!({"amendment": item}))), | |||
| Err(e) => Ok(HttpResponse::InternalServerError().json(json!({"error": format!("Failed to read amendment: {}", e)}))) | |||
| } | |||
| } | |||
| async fn create_and_store_amendment(request: AddAmendmentRequest) -> Result<String, AmendmentError> { | |||
| let amendment_data = AmendmentFile { | |||
| name: request.name.to_string(), | |||
| content: request.content.to_string(), | |||
| creator: request.creator.to_string(), | |||
| created_at: Default::default(), | |||
| updated_at: Default::default(), | |||
| proposal_id: request.proposal_id, | |||
| }; | |||
| let client = IpfsClient::default(); | |||
| let mut amendment_service = AmendmentService::new(client); | |||
| let cid = amendment_service.save(amendment_data).await?; | |||
| Ok(cid) | |||
| } | |||
| @@ -0,0 +1,200 @@ | |||
| use actix_web::{get, post, web, HttpResponse}; | |||
| use serde::Deserialize; | |||
| use serde_json::json; | |||
| use crate::repositories::user::{create_user, NewUserInsert, RegisteredUser, find_user_by_username, get_user_for_auth_by_stellar}; | |||
| const JWT_EXPIRATION_TIME: i64 = 240; | |||
| use argon2::{ | |||
| password_hash::{rand_core::OsRng, PasswordHasher, SaltString, PasswordHash, PasswordVerifier}, | |||
| Argon2, | |||
| }; | |||
| use crate::utils::auth::{verify_freighter_signature, generate_jwt, generate_freighter_nonce}; | |||
| #[derive(Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct RegisterRequest { | |||
| pub username: String, | |||
| pub password: String, | |||
| pub stellar_address: String, | |||
| pub email: Option<String>, | |||
| } | |||
| #[derive(Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct LoginRequest { | |||
| pub username: String, | |||
| pub password: String, | |||
| } | |||
| #[post("register")] | |||
| pub async fn register(req: web::Json<RegisterRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| // Basic validation | |||
| if req.username.trim().is_empty() || req.password.is_empty() || req.stellar_address.trim().is_empty() { | |||
| return Ok(HttpResponse::BadRequest().json(json!({ | |||
| "error": "username, password and stellarAddress are required" | |||
| }))); | |||
| } | |||
| // Hash password with Argon2id and a random salt | |||
| let salt = SaltString::generate(&mut OsRng); | |||
| let argon2 = Argon2::default(); | |||
| let password_hash = match argon2.hash_password(req.password.as_bytes(), &salt) { | |||
| Ok(ph) => ph.to_string(), | |||
| Err(e) => return Ok(HttpResponse::InternalServerError().json(json!({ | |||
| "error": format!("Failed to hash password: {}", e) | |||
| }))), | |||
| }; | |||
| let new_user = NewUserInsert { | |||
| username: req.username.trim().to_string(), | |||
| password_hash, | |||
| stellar_address: req.stellar_address.trim().to_string(), | |||
| email: req.email.clone(), | |||
| }; | |||
| match create_user(new_user) { | |||
| Ok(user) => Ok(HttpResponse::Created().json(json!({"user": user}))), | |||
| Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, info)) => { | |||
| Ok(HttpResponse::Conflict().json(json!({ | |||
| "error": format!("Unique constraint violation: {}", info.message()) | |||
| }))) | |||
| } | |||
| Err(e) => Ok(HttpResponse::InternalServerError().json(json!({ | |||
| "error": format!("Failed to create user: {}", e) | |||
| }))), | |||
| } | |||
| } | |||
| #[derive(Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct FreighterLoginRequest { | |||
| pub stellar_address: String, | |||
| } | |||
| #[post("login")] | |||
| pub async fn login(req: web::Json<LoginRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| // Basic validation | |||
| if req.username.trim().is_empty() || req.password.is_empty() { | |||
| return Ok(HttpResponse::BadRequest().json(json!({ | |||
| "error": "Username and password are required" | |||
| }))); | |||
| } | |||
| // Fetch user by username | |||
| let user_auth = match find_user_by_username(req.username.trim()) { | |||
| Ok(u) => u, | |||
| Err(diesel::result::Error::NotFound) => { | |||
| return Ok(HttpResponse::Unauthorized().json(json!({ | |||
| "error": "Invalid credentials" | |||
| }))); | |||
| } | |||
| Err(e) => { | |||
| return Ok(HttpResponse::InternalServerError().json(json!({ | |||
| "error": format!("Failed to query user: {}", e) | |||
| }))); | |||
| } | |||
| }; | |||
| if !user_auth.is_active { | |||
| return Ok(HttpResponse::Unauthorized().json(json!({ | |||
| "error": "Account is inactive" | |||
| }))); | |||
| } | |||
| // Verify password using Argon2 | |||
| let argon2 = Argon2::default(); | |||
| let parsed_hash = match PasswordHash::new(&user_auth.password_hash) { | |||
| Ok(ph) => ph, | |||
| Err(_) => { | |||
| return Ok(HttpResponse::InternalServerError().json(json!({ | |||
| "error": "Stored password hash is invalid" | |||
| }))); | |||
| } | |||
| }; | |||
| match argon2.verify_password(req.password.as_bytes(), &parsed_hash) { | |||
| Ok(_) => { | |||
| // Build JWT token on successful password verification | |||
| let full_name_str = user_auth.full_name.as_deref().unwrap_or(""); | |||
| let token = match generate_jwt( | |||
| user_auth.id, | |||
| full_name_str, | |||
| &user_auth.username, | |||
| &user_auth.stellar_address, | |||
| Some(JWT_EXPIRATION_TIME), | |||
| ) { | |||
| Ok(t) => t, | |||
| Err(e) => { | |||
| return Ok(HttpResponse::InternalServerError().json(json!({ | |||
| "error": e | |||
| }))); | |||
| } | |||
| }; | |||
| Ok(HttpResponse::Ok().json(json!({ | |||
| "token": token | |||
| }))) | |||
| } | |||
| Err(_) => Ok(HttpResponse::Unauthorized().json(json!({ | |||
| "error": "Invalid credentials" | |||
| }))), | |||
| } | |||
| } | |||
| #[get("nonce")] | |||
| pub async fn get_nonce() -> HttpResponse { | |||
| let nonce = generate_freighter_nonce(); | |||
| HttpResponse::Ok().json(json!({"nonce": nonce})) | |||
| } | |||
| #[post("login/freighter")] | |||
| pub async fn login_freighter(req: web::Json<FreighterLoginRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| // Basic validation | |||
| if req.stellar_address.trim().is_empty() { | |||
| return Ok(HttpResponse::BadRequest().json(json!({ | |||
| "error": "stellarAddress is required" | |||
| }))); | |||
| } | |||
| // Fetch user by stellar address | |||
| let user = match get_user_for_auth_by_stellar(req.stellar_address.trim()) { | |||
| Ok(u) => u, | |||
| Err(diesel::result::Error::NotFound) => { | |||
| return Ok(HttpResponse::Unauthorized().json(json!({ | |||
| "error": "User not found" | |||
| }))); | |||
| } | |||
| Err(e) => { | |||
| return Ok(HttpResponse::InternalServerError().json(json!({ | |||
| "error": format!("Failed to query user: {}", e) | |||
| }))); | |||
| } | |||
| }; | |||
| if !user.is_active { | |||
| return Ok(HttpResponse::Unauthorized().json(json!({ | |||
| "error": "Account is inactive" | |||
| }))); | |||
| } | |||
| // Build JWT | |||
| let full_name_str = user.full_name.as_deref().unwrap_or(""); | |||
| let token = match generate_jwt(user.id, full_name_str, &user.username, &user.stellar_address, Some(JWT_EXPIRATION_TIME)) { | |||
| Ok(t) => t, | |||
| Err(e) => { | |||
| return Ok(HttpResponse::InternalServerError().json(json!({ | |||
| "error": e | |||
| }))); | |||
| } | |||
| }; | |||
| Ok(HttpResponse::Ok().json(json!({ | |||
| "token": token | |||
| }))) | |||
| } | |||
| @@ -0,0 +1,18 @@ | |||
| use actix_web::{post, web, HttpResponse}; | |||
| use chrono::Utc; | |||
| use ipfs_api_backend_actix::IpfsClient; | |||
| use serde::Deserialize; | |||
| use serde_json::json; | |||
| use crate::types::comment::{CommentError, CommentMetadata}; | |||
| #[derive(Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct AppendLocalCommentRequest { | |||
| pub thread_id: String, | |||
| pub content: String, | |||
| pub creator: String, | |||
| pub proposal_cid: Option<String>, | |||
| pub amendment_cid: Option<String>, | |||
| pub parent_comment_cid: Option<String>, | |||
| } | |||
| @@ -0,0 +1,5 @@ | |||
| pub(crate) mod proposal; | |||
| pub(crate) mod amendment; | |||
| pub(crate) mod auth; | |||
| pub(crate) mod comment; | |||
| pub(crate) mod openapi; | |||
| @@ -0,0 +1,251 @@ | |||
| use actix_web::{get, HttpResponse}; | |||
| use serde_json::{json, Value}; | |||
| // Minimal OpenAPI 3.0 spec to enable frontend code generation. | |||
| // NOTE: This is intentionally lightweight. We can later replace the manual spec | |||
| // with an automatic generator (e.g., Apistos annotations) without changing the endpoint path. | |||
| fn openapi_spec() -> Value { | |||
| // Basic schemas for known request bodies | |||
| let add_proposal_request = json!({ | |||
| "type": "object", | |||
| "properties": { | |||
| "name": {"type": "string"}, | |||
| "description": {"type": "string"}, | |||
| "creator": {"type": "string"} | |||
| }, | |||
| "required": ["name", "description", "creator"], | |||
| "additionalProperties": false | |||
| }); | |||
| let visit_proposal_request = json!({ | |||
| "type": "object", | |||
| "properties": { | |||
| "proposalId": {"type": "integer", "format": "int32"}, | |||
| "path": {"type": "string"}, | |||
| "method": {"type": "string"}, | |||
| "queryString": {"type": "string"}, | |||
| "statusCode": {"type": "integer", "format": "int32"}, | |||
| "referrer": {"type": "string"} | |||
| }, | |||
| "required": ["proposalId"], | |||
| "additionalProperties": false | |||
| }); | |||
| let register_request = json!({ | |||
| "type": "object", | |||
| "properties": { | |||
| "username": {"type": "string"}, | |||
| "password": {"type": "string"}, | |||
| "stellarAddress": {"type": "string"}, | |||
| "email": {"type": ["string", "null"]} | |||
| }, | |||
| "required": ["username", "password", "stellarAddress"], | |||
| "additionalProperties": false | |||
| }); | |||
| let login_request = json!({ | |||
| "type": "object", | |||
| "properties": { | |||
| "username": {"type": "string"}, | |||
| "password": {"type": "string"} | |||
| }, | |||
| "required": ["username", "password"], | |||
| "additionalProperties": false | |||
| }); | |||
| let freighter_login_request = json!({ | |||
| "type": "object", | |||
| "properties": { | |||
| "stellarAddress": {"type": "string"} | |||
| }, | |||
| "required": ["stellarAddress"], | |||
| "additionalProperties": false | |||
| }); | |||
| let proposal_item = json!({ | |||
| "type": "object", | |||
| "properties": { | |||
| "id": {"type": "integer", "format": "int32"}, | |||
| "name": {"type": "string"}, | |||
| "cid": {"type": "string"}, | |||
| "summary": {"type": ["string", "null"]}, | |||
| "creator": {"type": "string"}, | |||
| "isCurrent": {"type": "boolean"}, | |||
| "previousCid": {"type": ["string", "null"]}, | |||
| "createdAt": {"type": ["string", "null"]}, | |||
| "updatedAt": {"type": ["string", "null"]} | |||
| }, | |||
| "required": ["id", "name", "cid", "creator", "isCurrent"], | |||
| "additionalProperties": false | |||
| }); | |||
| let proposal_list = json!({ | |||
| "type": "array", | |||
| "items": {"$ref": "#/components/schemas/ProposalItem"} | |||
| }); | |||
| let list_proposals_response = json!({ | |||
| "type": "object", | |||
| "properties": { | |||
| "proposals": {"$ref": "#/components/schemas/ProposalList"} | |||
| } | |||
| }); | |||
| // Generic JSON response schema when we don't have strict typing | |||
| let generic_object = json!({ | |||
| "type": "object", | |||
| "additionalProperties": true | |||
| }); | |||
| json!({ | |||
| "openapi": "3.0.3", | |||
| "info": { | |||
| "title": "PuffPastry API", | |||
| "version": "1.0.0" | |||
| }, | |||
| "servers": [ | |||
| {"url": "http://localhost:7300"} | |||
| ], | |||
| "paths": { | |||
| "/api/v1/proposals/list": { | |||
| "get": { | |||
| "summary": "List proposals", | |||
| "operationId": "listProposals", | |||
| "tags": ["Proposal"], | |||
| "parameters": [ | |||
| {"name": "offset", "in": "query", "required": true, "schema": {"type": "integer", "format": "int64"}}, | |||
| {"name": "limit", "in": "query", "required": true, "schema": {"type": "integer", "format": "int64"}} | |||
| ], | |||
| "responses": { | |||
| "200": {"description": "OK", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListProposalsResponse"}}}} | |||
| } | |||
| } | |||
| }, | |||
| "/api/v1/proposal/add": { | |||
| "post": { | |||
| "summary": "Create a proposal", | |||
| "operationId": "createProposal", | |||
| "tags": ["Proposal"], | |||
| "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AddProposalRequest"}}}}, | |||
| "responses": { | |||
| "200": {"description": "OK", "content": {"application/json": {"schema": generic_object}}}, | |||
| "400": {"description": "Bad Request"} | |||
| } | |||
| } | |||
| }, | |||
| "/api/v1/proposal/get": { | |||
| "get": { | |||
| "summary": "Get a proposal by id", | |||
| "operationId": "getProposal", | |||
| "tags": ["Proposal"], | |||
| "parameters": [ | |||
| {"name": "id", "in": "query", "required": true, "schema": {"type": "integer", "format": "int32"}} | |||
| ], | |||
| "responses": { | |||
| "200": {"description": "OK", "content": {"application/json": {"schema": generic_object}}} | |||
| } | |||
| } | |||
| }, | |||
| "/api/v1/proposal/visit": { | |||
| "post": { | |||
| "summary": "Record a proposal visit", | |||
| "operationId": "recordProposalVisit", | |||
| "tags": ["Proposal"], | |||
| "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VisitProposalRequest"}}}}, | |||
| "responses": { | |||
| "201": {"description": "Created", "content": {"application/json": {"schema": generic_object}}} | |||
| } | |||
| } | |||
| }, | |||
| "/api/v1/amendment/add": { | |||
| "post": { | |||
| "summary": "Create an amendment", | |||
| "operationId": "createAmendment", | |||
| "tags": ["Amendment"], | |||
| "requestBody": {"required": true, "content": {"application/json": {"schema": generic_object}}}, | |||
| "responses": {"200": {"description": "OK"}} | |||
| } | |||
| }, | |||
| "/api/v1/amendment/get": { | |||
| "get": { | |||
| "summary": "Get an amendment", | |||
| "operationId": "getAmendment", | |||
| "tags": ["Amendment"], | |||
| "parameters": [ | |||
| {"name": "id", "in": "query", "required": true, "schema": {"type": "integer", "format": "int32"}} | |||
| ], | |||
| "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": generic_object}}}} | |||
| } | |||
| }, | |||
| "/api/v1/amendments/list": { | |||
| "get": { | |||
| "summary": "List amendments", | |||
| "operationId": "listAmendments", | |||
| "tags": ["Amendment"], | |||
| "parameters": [ | |||
| {"name": "proposalId", "in": "query", "required": true, "schema": {"type": "integer", "format": "int32"}} | |||
| ], | |||
| "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": generic_object}}}} | |||
| } | |||
| }, | |||
| "/api/v1/auth/register": { | |||
| "post": { | |||
| "summary": "Register a user", | |||
| "operationId": "registerUser", | |||
| "tags": ["Auth"], | |||
| "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RegisterRequest"}}}}, | |||
| "responses": {"201": {"description": "Created", "content": {"application/json": {"schema": generic_object}}}, | |||
| "400": {"description": "Bad Request"}, | |||
| "409": {"description": "Conflict"}} | |||
| } | |||
| }, | |||
| "/api/v1/auth/login": { | |||
| "post": { | |||
| "summary": "Login with username/password", | |||
| "operationId": "loginUser", | |||
| "tags": ["Auth"], | |||
| "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LoginRequest"}}}}, | |||
| "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": generic_object}}}, | |||
| "401": {"description": "Unauthorized"}} | |||
| } | |||
| }, | |||
| "/api/v1/auth/nonce": { | |||
| "get": { | |||
| "summary": "Get freighter login nonce", | |||
| "operationId": "getFreighterNonce", | |||
| "tags": ["Auth"], | |||
| "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": generic_object}}}} | |||
| } | |||
| }, | |||
| "/api/v1/auth/login/freighter": { | |||
| "post": { | |||
| "summary": "Login with Stellar (Freighter)", | |||
| "operationId": "loginFreighter", | |||
| "tags": ["Auth"], | |||
| "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FreighterLoginRequest"}}}}, | |||
| "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": generic_object}}}, | |||
| "401": {"description": "Unauthorized"}} | |||
| } | |||
| } | |||
| }, | |||
| "components": { | |||
| "schemas": { | |||
| "AddProposalRequest": add_proposal_request, | |||
| "VisitProposalRequest": visit_proposal_request, | |||
| "RegisterRequest": register_request, | |||
| "LoginRequest": login_request, | |||
| "FreighterLoginRequest": freighter_login_request, | |||
| "ListProposalsResponse": list_proposals_response, | |||
| "ProposalItem": proposal_item, | |||
| "ProposalList": proposal_list | |||
| } | |||
| } | |||
| }) | |||
| } | |||
| #[get("/openapi.json")] | |||
| pub async fn get_openapi() -> HttpResponse { | |||
| let spec = openapi_spec(); | |||
| HttpResponse::Ok().json(spec) | |||
| } | |||
| @@ -0,0 +1,157 @@ | |||
| use crate::ipfs::ipfs::IpfsService; | |||
| use crate::ipfs::proposal::ProposalService; | |||
| use crate::repositories::proposal::{get_cid_for_proposal, paginate_proposals}; | |||
| use crate::repositories::visit::record_visit; | |||
| use crate::types::proposal::ProposalError; | |||
| use crate::types::proposal::ProposalFile; | |||
| use actix_web::{get, post, web, HttpResponse, HttpRequest}; | |||
| use chrono::Utc; | |||
| use ipfs_api_backend_actix::IpfsClient; | |||
| use serde::{Deserialize, Serialize}; | |||
| use serde_json::json; | |||
| #[derive(Deserialize)] | |||
| struct PaginationParams { | |||
| offset: i64, | |||
| limit: i64, | |||
| } | |||
| #[get("list")] | |||
| async fn list_proposals(params: web::Query<PaginationParams>) -> Result<HttpResponse, actix_web::Error> { | |||
| match paginate_proposals(params.offset, params.limit) { | |||
| Ok(proposals) => Ok(HttpResponse::Ok().json(json!({"proposals": proposals}))), | |||
| Err(err) => Ok(HttpResponse::InternalServerError().json(json!({ | |||
| "error": format!("Failed to fetch proposals: {}", err) | |||
| }))) | |||
| } | |||
| } | |||
| #[derive(Serialize, Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct AddProposalRequest { | |||
| pub name: String, | |||
| pub description: String, | |||
| pub creator: String, | |||
| } | |||
| #[post("/add")] | |||
| async fn add_proposal( | |||
| request: web::Json<AddProposalRequest>, | |||
| ) -> Result<HttpResponse, actix_web::Error> { | |||
| let req = request.into_inner(); | |||
| // Basic input validation | |||
| if req.creator.trim().is_empty() { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "Creator is required"}))); | |||
| } | |||
| if req.name.trim().is_empty() { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "Name is required"}))); | |||
| } | |||
| if req.description.trim().is_empty() { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "Description is required"}))); | |||
| } | |||
| match create_and_store_proposal(&req).await { | |||
| Ok(cid) => Ok(HttpResponse::Ok().json(json!({ "cid": cid }))), | |||
| Err(e) => { | |||
| match e { | |||
| ProposalError::SerializationError(err) => Ok(HttpResponse::BadRequest().json(json!({ | |||
| "error": format!("Invalid proposal payload: {}", err) | |||
| }))), | |||
| _ => Ok(HttpResponse::InternalServerError().json(json!({ | |||
| "error": format!("Failed to create proposal: {}", e) | |||
| }))), | |||
| } | |||
| } | |||
| } | |||
| } | |||
| #[derive(Serialize, Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct VisitProposalRequest { | |||
| pub proposal_id: i32, | |||
| pub path: Option<String>, | |||
| pub method: Option<String>, | |||
| pub query_string: Option<String>, | |||
| pub status_code: Option<i32>, | |||
| pub referrer: Option<String>, | |||
| } | |||
| #[post("visit")] | |||
| async fn visit_proposal(req: HttpRequest, payload: web::Json<VisitProposalRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| let body = payload.into_inner(); | |||
| if body.proposal_id <= 0 { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "proposalId must be a positive integer"}))); | |||
| } | |||
| // Take analytics values from the request body (fallback to request path/method to satisfy NOT NULL columns) | |||
| let path_owned = body.path.unwrap_or_else(|| req.path().to_string()); | |||
| let method_owned = body.method.unwrap_or_else(|| req.method().to_string()); | |||
| let query_string_opt = body.query_string; // Option<String> | |||
| let status_code_val = body.status_code; // Option<i32> | |||
| let referrer_opt = body.referrer; // Option<String> | |||
| let connection_info = req.connection_info(); | |||
| let ip_address_val = connection_info.realip_remote_addr().map(|s| s.to_string()); | |||
| let user_agent_val = req.headers().get("User-Agent").and_then(|v| v.to_str().ok()).map(|s| s.to_string()); | |||
| let session_id_val = req.headers().get("X-Session-Id").and_then(|v| v.to_str().ok()).map(|s| s.to_string()); | |||
| let request_id_val = req.headers().get("X-Request-Id").and_then(|v| v.to_str().ok()).map(|s| s.to_string()); | |||
| // No authenticated user context available here; record as anonymous | |||
| let user_id_val: Option<i32> = None; | |||
| match record_visit( | |||
| body.proposal_id, | |||
| user_id_val, | |||
| &path_owned, | |||
| &method_owned, | |||
| query_string_opt.as_deref(), | |||
| status_code_val, | |||
| ip_address_val.as_deref(), | |||
| user_agent_val.as_deref(), | |||
| referrer_opt.as_deref(), | |||
| session_id_val.as_deref(), | |||
| request_id_val.as_deref(), | |||
| ) { | |||
| Ok(_) => Ok(HttpResponse::Created().json(json!({"ok": true}))), | |||
| Err(err) => Ok(HttpResponse::InternalServerError().json(json!({"error": format!("Failed to record visit: {}", err)}))), | |||
| } | |||
| } | |||
| #[derive(Deserialize)] | |||
| struct GetProposalParams { | |||
| pub id: i32, | |||
| } | |||
| #[get("get")] | |||
| async fn get_proposal(params: web::Query<GetProposalParams>) -> Result<HttpResponse, actix_web::Error> { | |||
| let mut proposal_client = ProposalService::new(IpfsClient::default()); | |||
| let cid = match get_cid_for_proposal(params.id) { | |||
| Ok(cid) => cid, | |||
| Err(err) => return Ok(HttpResponse::InternalServerError().json(json!({ | |||
| "error": format!("Failed to fetch proposal CID: {}", err) | |||
| }))) | |||
| }; | |||
| let item = proposal_client.read(cid).await; | |||
| match item { | |||
| Ok(proposal) => Ok(HttpResponse::Ok().json(json!({"proposal": proposal}))), | |||
| Err(e) => Ok(HttpResponse::InternalServerError().json(json!({ | |||
| "error": format!("Failed to read proposal: {}", e) | |||
| }))) | |||
| } | |||
| } | |||
| async fn create_and_store_proposal( | |||
| request: &AddProposalRequest, | |||
| ) -> Result<String, ProposalError> { | |||
| let proposal_data = ProposalFile { | |||
| name: request.name.to_string(), | |||
| content: request.description.to_string(), | |||
| creator: request.creator.to_string(), | |||
| created_at: Utc::now().naive_utc(), | |||
| updated_at: Utc::now().naive_utc(), | |||
| }; | |||
| let client = IpfsClient::default(); | |||
| let mut proposal_service = ProposalService::new(client); | |||
| let cid = proposal_service.save(proposal_data).await?; | |||
| Ok(cid) | |||
| } | |||
| @@ -0,0 +1,100 @@ | |||
| // @generated automatically by Diesel CLI. | |||
| pub mod sql_types { | |||
| #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] | |||
| #[diesel(postgres_type(name = "amendment_status"))] | |||
| pub struct AmendmentStatus; | |||
| } | |||
| diesel::table! { | |||
| use diesel::sql_types::*; | |||
| use super::sql_types::AmendmentStatus; | |||
| amendments (id) { | |||
| id -> Int4, | |||
| name -> Text, | |||
| cid -> Text, | |||
| summary -> Nullable<Text>, | |||
| status -> AmendmentStatus, | |||
| creator -> Text, | |||
| is_current -> Bool, | |||
| previous_cid -> Nullable<Text>, | |||
| created_at -> Timestamptz, | |||
| updated_at -> Timestamptz, | |||
| proposal_id -> Int4, | |||
| } | |||
| } | |||
| diesel::table! { | |||
| comments (id) { | |||
| id -> Int4, | |||
| cid -> Text, | |||
| is_current -> Bool, | |||
| previous_cid -> Nullable<Text>, | |||
| proposal_id -> Int4, | |||
| created_at -> Timestamptz, | |||
| updated_at -> Timestamptz, | |||
| } | |||
| } | |||
| diesel::table! { | |||
| proposals (id) { | |||
| id -> Int4, | |||
| name -> Text, | |||
| cid -> Text, | |||
| summary -> Nullable<Text>, | |||
| creator -> Text, | |||
| is_current -> Bool, | |||
| previous_cid -> Nullable<Text>, | |||
| created_at -> Nullable<Timestamp>, | |||
| updated_at -> Nullable<Timestamp>, | |||
| } | |||
| } | |||
| diesel::table! { | |||
| users (id) { | |||
| id -> Int4, | |||
| username -> Text, | |||
| password_hash -> Text, | |||
| stellar_address -> Text, | |||
| email -> Nullable<Text>, | |||
| is_active -> Bool, | |||
| roles -> Array<Nullable<Text>>, | |||
| last_login_at -> Nullable<Timestamptz>, | |||
| created_at -> Timestamptz, | |||
| updated_at -> Timestamptz, | |||
| full_name -> Nullable<Text>, | |||
| } | |||
| } | |||
| diesel::table! { | |||
| visits (id) { | |||
| id -> Int4, | |||
| user_id -> Nullable<Int4>, | |||
| proposal_id -> Int4, | |||
| path -> Text, | |||
| method -> Text, | |||
| query_string -> Nullable<Text>, | |||
| status_code -> Nullable<Int4>, | |||
| ip_address -> Nullable<Text>, | |||
| user_agent -> Nullable<Text>, | |||
| referrer -> Nullable<Text>, | |||
| session_id -> Nullable<Text>, | |||
| request_id -> Nullable<Text>, | |||
| visited_at -> Timestamptz, | |||
| created_at -> Timestamptz, | |||
| updated_at -> Timestamptz, | |||
| } | |||
| } | |||
| diesel::joinable!(amendments -> proposals (proposal_id)); | |||
| diesel::joinable!(comments -> proposals (proposal_id)); | |||
| diesel::joinable!(visits -> users (user_id)); | |||
| diesel::allow_tables_to_appear_in_same_query!( | |||
| amendments, | |||
| comments, | |||
| proposals, | |||
| users, | |||
| visits, | |||
| ); | |||
| @@ -0,0 +1,117 @@ | |||
| use core::fmt; | |||
| use crate::schema::amendments; | |||
| use chrono::NaiveDateTime; | |||
| use diesel::{Insertable, Queryable, Selectable}; | |||
| use serde::{Deserialize, Serialize}; | |||
| #[derive(Debug)] | |||
| pub enum AmendmentError { | |||
| DatabaseError(Box<dyn std::error::Error>), | |||
| IpfsError(Box<dyn std::error::Error>), | |||
| SerializationError(serde_json::Error), | |||
| } | |||
| impl fmt::Display for AmendmentError { | |||
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |||
| match self { | |||
| AmendmentError::DatabaseError(e) => write!(f, "Database error: {}", e), | |||
| AmendmentError::IpfsError(e) => write!(f, "Ipfs error: {}", e), | |||
| AmendmentError::SerializationError(e) => write!(f, "Serialization error: {}", e), | |||
| } | |||
| } | |||
| } | |||
| #[derive(Queryable, Clone, Serialize, Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct AmendmentFile { | |||
| pub name: String, | |||
| pub content: String, | |||
| pub creator: String, | |||
| pub created_at: NaiveDateTime, | |||
| pub updated_at: NaiveDateTime, | |||
| pub proposal_id: i32, | |||
| } | |||
| #[derive(Debug, Clone, Serialize, Deserialize, diesel_derive_enum::DbEnum)] | |||
| #[db_enum(existing_type_path = "crate::schema::sql_types::AmendmentStatus")] | |||
| pub enum AmendmentStatus { | |||
| Proposed, | |||
| Approved, | |||
| Withdrawn, | |||
| Rejected, | |||
| } | |||
| #[derive(Queryable, Selectable, Clone, Serialize, Deserialize)] | |||
| #[diesel(table_name = amendments)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct SelectableAmendment { | |||
| pub id: i32, | |||
| pub name: String, | |||
| pub cid: String, | |||
| pub summary: Option<String>, | |||
| pub status: AmendmentStatus, | |||
| pub creator: String, | |||
| pub is_current: bool, | |||
| pub previous_cid: Option<String>, | |||
| pub created_at: NaiveDateTime, | |||
| pub updated_at: NaiveDateTime, | |||
| pub proposal_id: i32, | |||
| } | |||
| #[derive(Insertable, Clone, Serialize, Deserialize)] | |||
| #[diesel(table_name = amendments)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct Amendment { | |||
| pub name: String, | |||
| pub cid: Option<String>, | |||
| pub summary: Option<String>, | |||
| pub status: AmendmentStatus, | |||
| pub creator: String, | |||
| pub is_current: bool, | |||
| pub previous_cid: Option<String>, | |||
| pub created_at: NaiveDateTime, | |||
| pub updated_at: NaiveDateTime, | |||
| pub proposal_id: i32, | |||
| } | |||
| impl Amendment { | |||
| pub fn new(name: String, summary: Option<String>, creator: String, proposal_id: i32) -> Self { | |||
| Amendment { | |||
| name, | |||
| summary, | |||
| status: AmendmentStatus::Proposed, | |||
| creator, | |||
| is_current: true, | |||
| previous_cid: None, | |||
| created_at: chrono::Utc::now().naive_utc(), | |||
| updated_at: chrono::Utc::now().naive_utc(), | |||
| proposal_id, | |||
| cid: None, | |||
| } | |||
| } | |||
| pub fn with_cid(mut self, cid: String) -> Self { | |||
| self.cid = Some(cid); | |||
| self | |||
| } | |||
| pub fn mark_as_current(mut self) -> Self { | |||
| self.is_current = true; | |||
| self.updated_at = chrono::Utc::now().naive_utc(); | |||
| self | |||
| } | |||
| } | |||
| impl From<serde_json::Error> for AmendmentError { | |||
| fn from(e: serde_json::Error) -> Self { | |||
| AmendmentError::SerializationError(e) | |||
| } | |||
| } | |||
| impl From<std::io::Error> for AmendmentError { | |||
| fn from(e: std::io::Error) -> Self { | |||
| AmendmentError::IpfsError(Box::new(e)) | |||
| } | |||
| } | |||
| @@ -0,0 +1,50 @@ | |||
| use chrono::NaiveDateTime; | |||
| use serde::{Deserialize, Serialize}; | |||
| use std::fmt; | |||
| #[derive(Debug)] | |||
| pub enum CommentError { | |||
| IpfsError(Box<dyn std::error::Error>), | |||
| SerializationError(serde_json::Error), | |||
| } | |||
| impl fmt::Display for CommentError { | |||
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |||
| match self { | |||
| CommentError::IpfsError(e) => write!(f, "IPFS error: {}", e), | |||
| CommentError::SerializationError(e) => write!(f, "Serialization error: {}", e), | |||
| } | |||
| } | |||
| } | |||
| #[derive(Clone, Serialize, Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct CommentMetadata { | |||
| pub content: String, | |||
| pub creator: String, | |||
| pub created_at: NaiveDateTime, | |||
| pub updated_at: NaiveDateTime, | |||
| } | |||
| #[derive(Clone, Serialize, Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct CommentFile { | |||
| pub comments: Vec<CommentMetadata>, | |||
| // Optional association fields to link this comment to a target entity | |||
| pub proposal_cid: Option<String>, | |||
| pub amendment_cid: Option<String>, | |||
| // Optional parent comment for threads | |||
| pub parent_comment_cid: Option<String>, | |||
| } | |||
| impl From<serde_json::Error> for CommentError { | |||
| fn from(e: serde_json::Error) -> Self { | |||
| CommentError::SerializationError(e) | |||
| } | |||
| } | |||
| impl From<std::io::Error> for CommentError { | |||
| fn from(e: std::io::Error) -> Self { | |||
| CommentError::IpfsError(Box::new(e)) | |||
| } | |||
| } | |||
| @@ -0,0 +1,3 @@ | |||
| // Make IpfsResult reusable by allowing a generic error type with a default of ProposalError. | |||
| pub type IpfsResult<T, E> = Result<T, E>; | |||
| @@ -0,0 +1,4 @@ | |||
| pub(crate) mod proposal; | |||
| pub(crate) mod amendment; | |||
| pub(crate) mod ipfs; | |||
| pub(crate) mod comment; | |||
| @@ -0,0 +1,127 @@ | |||
| use crate::schema::proposals; | |||
| use chrono::NaiveDateTime; | |||
| use diesel::Insertable; | |||
| use diesel::Queryable; | |||
| use diesel::Selectable; | |||
| use serde::de::StdError; | |||
| use serde::{Deserialize, Serialize}; | |||
| use std::fmt; | |||
| #[derive(Debug)] | |||
| pub enum ProposalError { | |||
| ProposalNotFound, | |||
| DatabaseError(Box<dyn std::error::Error>), | |||
| IpfsError(Box<dyn std::error::Error>), | |||
| SerializationError(serde_json::Error), | |||
| } | |||
| impl fmt::Display for ProposalError { | |||
| fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { | |||
| match self { | |||
| ProposalError::ProposalNotFound => write!(f, "Proposal not found"), | |||
| ProposalError::DatabaseError(e) => write!(f, "Database error: {}", e), | |||
| ProposalError::IpfsError(e) => write!(f, "IPFS error: {}", e), | |||
| ProposalError::SerializationError(e) => write!(f, "Serialization error: {}", e), | |||
| } | |||
| } | |||
| } | |||
| impl StdError for ProposalError { | |||
| fn source(&self) -> Option<&(dyn StdError + 'static)> { | |||
| match self { | |||
| ProposalError::ProposalNotFound => None, | |||
| ProposalError::DatabaseError(e) => Some(e.as_ref()), | |||
| ProposalError::IpfsError(e) => Some(e.as_ref()), | |||
| ProposalError::SerializationError(e) => Some(e), | |||
| } | |||
| } | |||
| } | |||
| #[derive(Serialize, Deserialize, Clone)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct ProposalFile { | |||
| pub name: String, | |||
| pub content: String, | |||
| pub creator: String, | |||
| pub created_at: NaiveDateTime, | |||
| pub updated_at: NaiveDateTime, | |||
| } | |||
| #[derive(Queryable, Selectable, Clone, Serialize, Deserialize)] | |||
| #[diesel(table_name = proposals)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct SelectableProposal { | |||
| pub id: i32, | |||
| pub name: String, | |||
| pub cid: String, | |||
| pub summary: Option<String>, | |||
| pub creator: String, | |||
| pub is_current: bool, | |||
| pub previous_cid: Option<String>, | |||
| pub created_at: Option<NaiveDateTime>, | |||
| pub updated_at: Option<NaiveDateTime>, | |||
| } | |||
| #[derive(Insertable, Clone, Serialize, Deserialize)] | |||
| #[diesel(table_name = proposals)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct Proposal { | |||
| pub name: String, | |||
| pub cid: Option<String>, | |||
| pub summary: Option<String>, | |||
| pub creator: String, | |||
| pub is_current: bool, | |||
| pub previous_cid: Option<String>, | |||
| pub created_at: NaiveDateTime, | |||
| pub updated_at: NaiveDateTime, | |||
| } | |||
| impl Proposal { | |||
| pub fn new(name: String, summary: Option<String>, creator: String) -> Self { | |||
| Self { | |||
| name, | |||
| cid: None, | |||
| creator, | |||
| summary, | |||
| is_current: false, | |||
| previous_cid: None, | |||
| created_at: chrono::Local::now().naive_local(), | |||
| updated_at: chrono::Local::now().naive_local(), | |||
| } | |||
| } | |||
| pub fn with_cid(mut self, cid: String) -> Self { | |||
| self.cid = Some(cid); | |||
| self | |||
| } | |||
| pub fn with_summary(mut self, summary: String) -> Self { | |||
| self.summary = Some(summary); | |||
| self | |||
| } | |||
| pub fn with_previous_cid(mut self, previous_cid: String) -> Self { | |||
| self.previous_cid = Some(previous_cid); | |||
| self | |||
| } | |||
| pub fn mark_as_current(mut self) -> Self { | |||
| self.is_current = true; | |||
| self.updated_at = chrono::Local::now().naive_local(); | |||
| self | |||
| } | |||
| } | |||
| impl From<serde_json::Error> for ProposalError { | |||
| fn from(e: serde_json::Error) -> Self { | |||
| ProposalError::SerializationError(e) | |||
| } | |||
| } | |||
| impl From<std::io::Error> for ProposalError { | |||
| fn from(e: std::io::Error) -> Self { | |||
| ProposalError::IpfsError(Box::new(e)) | |||
| } | |||
| } | |||
| @@ -0,0 +1,85 @@ | |||
| use base64::Engine as _; | |||
| use base64::engine::general_purpose::STANDARD as BASE64; | |||
| use ed25519_dalek::{Signature, VerifyingKey, Verifier}; | |||
| use serde::{Deserialize, Serialize}; | |||
| use stellar_strkey::ed25519::PublicKey as StellarPublicKey; | |||
| use std::env; | |||
| use chrono::{Utc, Duration}; | |||
| use jsonwebtoken::{encode, Header, EncodingKey}; | |||
| /// Verify a Freighter-provided ed25519 signature over a message using a Stellar G... address. | |||
| /// | |||
| /// Returns Ok(()) if valid; otherwise returns Err with a human-readable reason. | |||
| pub fn verify_freighter_signature(stellar_address: &str, message: &str, signature_b64: &str) -> Result<(), String> { | |||
| // Decode Stellar G... address to 32-byte ed25519 public key | |||
| let pk = StellarPublicKey::from_string(stellar_address) | |||
| .map_err(|e| format!("Invalid stellarAddress: {}", e))?; | |||
| let pk_bytes = pk.0; | |||
| // Build verifying key | |||
| let vkey = VerifyingKey::from_bytes(&pk_bytes) | |||
| .map_err(|e| format!("Invalid public key: {}", e))?; | |||
| // Decode signature from base64 | |||
| let sig_bytes = BASE64.decode(signature_b64) | |||
| .map_err(|e| format!("Invalid signature encoding: {}", e))?; | |||
| let sig = Signature::from_slice(&sig_bytes) | |||
| .map_err(|e| format!("Invalid signature: {}", e))?; | |||
| // Verify | |||
| vkey.verify(message.as_bytes(), &sig) | |||
| .map_err(|_| "Signature verification failed".to_string()) | |||
| } | |||
| /// Generate a cryptographically-random nonce for Freighter to sign on the frontend. | |||
| /// | |||
| /// Notes: | |||
| /// - This returns a UUID v4 string (e.g., 550e8400-e29b-41d4-a716-446655440000), | |||
| /// which has 122 bits of randomness and is suitable as a nonce. | |||
| /// - You should bind this nonce to a short-lived challenge server-side (e.g., with an expiry) | |||
| /// and verify the signed nonce/message using `verify_freighter_signature` before issuing JWTs. | |||
| pub fn generate_freighter_nonce() -> String { | |||
| uuid::Uuid::new_v4().to_string() | |||
| } | |||
| #[derive(Debug, Serialize, Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| struct Claims { | |||
| // Standard-like claims | |||
| exp: i64, | |||
| iat: i64, | |||
| // Custom claims | |||
| user_id: i32, | |||
| full_name: String, | |||
| username: String, | |||
| stellar_address: String, | |||
| } | |||
| /// Generate a JWT embedding the user's id, full name, username, and stellar address. | |||
| /// | |||
| /// The signing secret is read from the JWT_SECRET environment variable. | |||
| /// The token uses HS256 by default and expires in `expiration_minutes` (default: 60 minutes). | |||
| pub fn generate_jwt( | |||
| user_id: i32, | |||
| full_name: &str, | |||
| username: &str, | |||
| stellar_address: &str, | |||
| expiration_minutes: Option<i64>, | |||
| ) -> Result<String, String> { | |||
| let secret = env::var("JWT_SECRET") | |||
| .map_err(|_| "JWT_SECRET not set".to_string())?; | |||
| let now = Utc::now(); | |||
| let exp_minutes = expiration_minutes.unwrap_or(60); | |||
| let claims = Claims { | |||
| exp: (now + Duration::minutes(exp_minutes)).timestamp(), | |||
| iat: now.timestamp(), | |||
| user_id, | |||
| full_name: full_name.to_string(), | |||
| username: username.to_string(), | |||
| stellar_address: stellar_address.to_string(), | |||
| }; | |||
| encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes())) | |||
| .map_err(|e| format!("Failed to sign JWT: {}", e)) | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| pub fn extract_summary(content: &str) -> String { | |||
| content | |||
| .split('\n') | |||
| .next() | |||
| .unwrap_or_default() | |||
| .to_string() | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| /// Utilities for loading environment variables from a local .env file using dotenvy. | |||
| /// | |||
| /// Call this early in your application (e.g., at the start of main) so that | |||
| /// subsequent `std::env::var` calls can read variables defined in .env. | |||
| pub fn load_env() { | |||
| // Try to load from default .env path. It's common to ignore a missing file | |||
| // so that environments that rely on real environment variables (e.g., prod) | |||
| // don't fail to start. | |||
| if let Err(err) = dotenvy::dotenv() { | |||
| // If the file is not found, silently continue; otherwise print a warning. | |||
| // This keeps behavior non-fatal while still surfacing unexpected errors. | |||
| if !matches!(err, dotenvy::Error::Io(ref io_err) if io_err.kind() == std::io::ErrorKind::NotFound) { | |||
| eprintln!("Warning: failed to load .env: {err}"); | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,148 @@ | |||
| use ipfs_api_backend_actix::{IpfsApi, IpfsClient}; | |||
| use serde::{de::DeserializeOwned, Serialize}; | |||
| use std::io::{Cursor, Error as IoError, ErrorKind}; | |||
| use std::path::Path; | |||
| use crate::types::ipfs::IpfsResult; | |||
| pub fn create_file_path(base_dir: &str, extension: &str) -> String { | |||
| let filename = format!("{}.{}", uuid::Uuid::new_v4(), extension); | |||
| let path = Path::new(base_dir).join(filename); | |||
| path.to_string_lossy().into_owned() | |||
| } | |||
| pub async fn create_storage_directory<E>(client: &IpfsClient, dir: &str) -> IpfsResult<(), E> | |||
| where | |||
| E: From<IoError>, | |||
| { | |||
| client | |||
| .files_mkdir(dir, true) | |||
| .await | |||
| .map_err(|e| E::from(IoError::new(ErrorKind::Other, format!("IPFS mkdir error: {}", e))))?; | |||
| Ok(()) | |||
| } | |||
| pub async fn save_json_file<T, E>( | |||
| client: &IpfsClient, | |||
| path: &str, | |||
| data: &T, | |||
| ) -> IpfsResult<(), E> | |||
| where | |||
| T: Serialize, | |||
| E: From<serde_json::Error> + From<IoError>, | |||
| { | |||
| let json = serde_json::to_string::<T>(data)?; | |||
| let file_content = Cursor::new(json.into_bytes()); | |||
| client | |||
| .files_write(path, true, true, file_content) | |||
| .await | |||
| .map_err(|e| E::from(IoError::new(ErrorKind::Other, format!("IPFS write error: {}", e))))?; | |||
| Ok(()) | |||
| } | |||
| pub async fn read_json_via_cat<T, E>( | |||
| client: &IpfsClient, | |||
| hash: &str, | |||
| max_size: usize, | |||
| ) -> IpfsResult<T, E> | |||
| where | |||
| T: DeserializeOwned, | |||
| E: From<serde_json::Error> + From<IoError>, | |||
| { | |||
| let stream = client.cat(hash); | |||
| let mut content = Vec::with_capacity(1024); | |||
| let mut total_size = 0; | |||
| futures::pin_mut!(stream); | |||
| while let Some(chunk) = futures::StreamExt::next(&mut stream).await { | |||
| let chunk = chunk.map_err(|e| E::from(IoError::new( | |||
| ErrorKind::Other, | |||
| format!("Failed to read IPFS chunk: {}", e), | |||
| )))?; | |||
| total_size += chunk.len(); | |||
| if total_size > max_size { | |||
| return Err(E::from(IoError::new( | |||
| ErrorKind::Other, | |||
| "File exceeds maximum allowed size", | |||
| ))); | |||
| } | |||
| content.extend_from_slice(&chunk); | |||
| } | |||
| if content.is_empty() { | |||
| return Err(E::from(IoError::new( | |||
| ErrorKind::Other, | |||
| "Empty response from IPFS", | |||
| ))); | |||
| } | |||
| let value = serde_json::from_slice::<T>(&content)?; | |||
| Ok(value) | |||
| } | |||
| pub async fn retrieve_content_hash<E>(client: &IpfsClient, path: &str) -> IpfsResult<String, E> | |||
| where | |||
| E: From<IoError>, | |||
| { | |||
| let stat = client | |||
| .files_stat(path) | |||
| .await | |||
| .map_err(|e| E::from(IoError::new(ErrorKind::Other, format!("IPFS stat error: {}", e))))?; | |||
| Ok(stat.hash) | |||
| } | |||
| pub const DEFAULT_MAX_JSON_SIZE: usize = 10 * 1024 * 1024; | |||
| /// List the child entries of an IPFS directory given its CID/hash and | |||
| /// return the CIDs of child files. | |||
| pub async fn list_directory_file_hashes<E>(client: &IpfsClient, dir_hash: &str) -> IpfsResult<Vec<String>, E> | |||
| where | |||
| E: From<IoError>, | |||
| { | |||
| // Use `ls` which works with CIDs (not only MFS paths) | |||
| let resp = client | |||
| .ls(dir_hash) | |||
| .await | |||
| .map_err(|e| E::from(IoError::new( | |||
| ErrorKind::Other, | |||
| format!("IPFS ls error: {}", e), | |||
| )))?; | |||
| // Collect links from the first (and usually only) object | |||
| let mut file_hashes: Vec<String> = Vec::new(); | |||
| for obj in resp.objects { | |||
| for link in obj.links { | |||
| // The response type typically has `typ` as a numeric code (2 for file) or a string. | |||
| // To remain compatible without depending on exact type semantics, we accept all links | |||
| // and rely on subsequent JSON parsing to validate. Optionally, filter by name extension. | |||
| if link.name.ends_with(".json") { | |||
| file_hashes.push(link.hash.clone()); | |||
| } else { | |||
| // Still include; some JSON files may not follow extension naming in IPFS. | |||
| file_hashes.push(link.hash.clone()); | |||
| } | |||
| } | |||
| } | |||
| Ok(file_hashes) | |||
| } | |||
| pub async fn upload_json_and_get_hash<T, E>( | |||
| client: &IpfsClient, | |||
| storage_dir: &str, | |||
| file_extension: &str, | |||
| data: &T, | |||
| ) -> IpfsResult<String, E> | |||
| where | |||
| T: Serialize, | |||
| E: From<serde_json::Error> + From<IoError>, | |||
| { | |||
| let file_path = create_file_path(storage_dir, file_extension); | |||
| create_storage_directory::<E>(client, storage_dir).await?; | |||
| save_json_file::<T, E>(client, &file_path, data).await?; | |||
| retrieve_content_hash::<E>(client, &file_path).await | |||
| } | |||
| @@ -0,0 +1,4 @@ | |||
| pub(crate) mod content; | |||
| pub(crate) mod ipfs; | |||
| pub(crate) mod auth; | |||
| pub(crate) mod env; | |||