This commit is contained in:
		@@ -4,8 +4,10 @@ VITE_OAUTH_CLIENT_ID=https://log.syui.ai/client-metadata.json
 | 
			
		||||
VITE_OAUTH_REDIRECT_URI=https://log.syui.ai/oauth/callback
 | 
			
		||||
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
 | 
			
		||||
 | 
			
		||||
# Optional: Override collection names (if not set, auto-generated from host)
 | 
			
		||||
# VITE_COLLECTION_COMMENT=ai.syui.log
 | 
			
		||||
# VITE_COLLECTION_USER=ai.syui.log.user
 | 
			
		||||
# Collection names for OAuth app
 | 
			
		||||
VITE_COLLECTION_COMMENT=ai.syui.log
 | 
			
		||||
VITE_COLLECTION_USER=ai.syui.log.user
 | 
			
		||||
 | 
			
		||||
# Collection names for ailog (backward compatibility)
 | 
			
		||||
AILOG_COLLECTION_COMMENT=ai.syui.log
 | 
			
		||||
AILOG_COLLECTION_USER=ai.syui.log.user
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								run.zsh
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								run.zsh
									
									
									
									
									
								
							@@ -6,6 +6,7 @@ function _env() {
 | 
			
		||||
	oauth=$d/oauth
 | 
			
		||||
	myblog=$d/my-blog
 | 
			
		||||
	port=4173
 | 
			
		||||
	source $oauth/.env.production
 | 
			
		||||
	case $OSTYPE in
 | 
			
		||||
		darwin*)
 | 
			
		||||
			export NVM_DIR="$HOME/.nvm"
 | 
			
		||||
@@ -34,7 +35,6 @@ function _oauth_build() {
 | 
			
		||||
	cd $oauth
 | 
			
		||||
	nvm use 21
 | 
			
		||||
	npm i
 | 
			
		||||
	source .env.production
 | 
			
		||||
	npm run build
 | 
			
		||||
	rm -rf $myblog/static/assets
 | 
			
		||||
	cp -rf dist/* $myblog/static/
 | 
			
		||||
 
 | 
			
		||||
@@ -6,3 +6,4 @@ pub mod clean;
 | 
			
		||||
pub mod doc;
 | 
			
		||||
pub mod auth;
 | 
			
		||||
pub mod stream;
 | 
			
		||||
pub mod oauth;
 | 
			
		||||
							
								
								
									
										190
									
								
								src/commands/oauth.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								src/commands/oauth.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,190 @@
 | 
			
		||||
use anyhow::{Result, Context};
 | 
			
		||||
use std::path::{Path, PathBuf};
 | 
			
		||||
use std::fs;
 | 
			
		||||
use std::process::Command;
 | 
			
		||||
use toml::Value;
 | 
			
		||||
 | 
			
		||||
pub async fn build(project_dir: PathBuf) -> Result<()> {
 | 
			
		||||
    println!("Building OAuth app for project: {}", project_dir.display());
 | 
			
		||||
 | 
			
		||||
    // 1. Read config.toml from project directory
 | 
			
		||||
    let config_path = project_dir.join("config.toml");
 | 
			
		||||
    if !config_path.exists() {
 | 
			
		||||
        anyhow::bail!("config.toml not found in {}", project_dir.display());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let config_content = fs::read_to_string(&config_path)
 | 
			
		||||
        .with_context(|| format!("Failed to read config.toml from {}", config_path.display()))?;
 | 
			
		||||
    
 | 
			
		||||
    let config: Value = config_content.parse()
 | 
			
		||||
        .with_context(|| "Failed to parse config.toml")?;
 | 
			
		||||
 | 
			
		||||
    // 2. Extract [oauth] section
 | 
			
		||||
    let oauth_config = config.get("oauth")
 | 
			
		||||
        .and_then(|v| v.as_table())
 | 
			
		||||
        .ok_or_else(|| anyhow::anyhow!("No [oauth] section found in config.toml"))?;
 | 
			
		||||
 | 
			
		||||
    let site_config = config.get("site")
 | 
			
		||||
        .and_then(|v| v.as_table())
 | 
			
		||||
        .ok_or_else(|| anyhow::anyhow!("No [site] section found in config.toml"))?;
 | 
			
		||||
 | 
			
		||||
    // 3. Generate environment variables
 | 
			
		||||
    let base_url = site_config.get("base_url")
 | 
			
		||||
        .and_then(|v| v.as_str())
 | 
			
		||||
        .ok_or_else(|| anyhow::anyhow!("No base_url found in [site] section"))?;
 | 
			
		||||
 | 
			
		||||
    let client_id_path = oauth_config.get("json")
 | 
			
		||||
        .and_then(|v| v.as_str())
 | 
			
		||||
        .unwrap_or("client-metadata.json");
 | 
			
		||||
 | 
			
		||||
    let redirect_path = oauth_config.get("redirect")
 | 
			
		||||
        .and_then(|v| v.as_str())
 | 
			
		||||
        .unwrap_or("oauth/callback");
 | 
			
		||||
 | 
			
		||||
    let admin_did = oauth_config.get("admin")
 | 
			
		||||
        .and_then(|v| v.as_str())
 | 
			
		||||
        .ok_or_else(|| anyhow::anyhow!("No admin DID found in [oauth] section"))?;
 | 
			
		||||
 | 
			
		||||
    let collection_comment = oauth_config.get("collection_comment")
 | 
			
		||||
        .and_then(|v| v.as_str())
 | 
			
		||||
        .unwrap_or("ai.syui.log");
 | 
			
		||||
 | 
			
		||||
    let collection_user = oauth_config.get("collection_user")
 | 
			
		||||
        .and_then(|v| v.as_str())
 | 
			
		||||
        .unwrap_or("ai.syui.log.user");
 | 
			
		||||
 | 
			
		||||
    // 4. Create .env.production content
 | 
			
		||||
    let env_content = format!(
 | 
			
		||||
        r#"# Production environment variables
 | 
			
		||||
VITE_APP_HOST={}
 | 
			
		||||
VITE_OAUTH_CLIENT_ID={}/{}
 | 
			
		||||
VITE_OAUTH_REDIRECT_URI={}/{}
 | 
			
		||||
VITE_ADMIN_DID={}
 | 
			
		||||
 | 
			
		||||
# Collection names for OAuth app
 | 
			
		||||
VITE_COLLECTION_COMMENT={}
 | 
			
		||||
VITE_COLLECTION_USER={}
 | 
			
		||||
 | 
			
		||||
# Collection names for ailog (backward compatibility)
 | 
			
		||||
AILOG_COLLECTION_COMMENT={}
 | 
			
		||||
AILOG_COLLECTION_USER={}
 | 
			
		||||
"#,
 | 
			
		||||
        base_url,
 | 
			
		||||
        base_url, client_id_path,
 | 
			
		||||
        base_url, redirect_path,
 | 
			
		||||
        admin_did,
 | 
			
		||||
        collection_comment,
 | 
			
		||||
        collection_user,
 | 
			
		||||
        collection_comment,
 | 
			
		||||
        collection_user
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // 5. Find oauth directory (relative to current working directory)
 | 
			
		||||
    let oauth_dir = Path::new("oauth");
 | 
			
		||||
    if !oauth_dir.exists() {
 | 
			
		||||
        anyhow::bail!("oauth directory not found in current working directory");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let env_path = oauth_dir.join(".env.production");
 | 
			
		||||
    fs::write(&env_path, env_content)
 | 
			
		||||
        .with_context(|| format!("Failed to write .env.production to {}", env_path.display()))?;
 | 
			
		||||
 | 
			
		||||
    println!("Generated .env.production");
 | 
			
		||||
 | 
			
		||||
    // 6. Build OAuth app
 | 
			
		||||
    build_oauth_app(&oauth_dir).await?;
 | 
			
		||||
 | 
			
		||||
    // 7. Copy build artifacts to project directory
 | 
			
		||||
    copy_build_artifacts(&oauth_dir, &project_dir).await?;
 | 
			
		||||
 | 
			
		||||
    println!("OAuth app built successfully!");
 | 
			
		||||
    Ok(())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async fn build_oauth_app(oauth_dir: &Path) -> Result<()> {
 | 
			
		||||
    println!("Installing dependencies...");
 | 
			
		||||
    
 | 
			
		||||
    // Check if node is available
 | 
			
		||||
    let node_check = Command::new("node")
 | 
			
		||||
        .arg("--version")
 | 
			
		||||
        .output();
 | 
			
		||||
    
 | 
			
		||||
    if node_check.is_err() {
 | 
			
		||||
        anyhow::bail!("Node.js not found. Please install Node.js or ensure it's in PATH");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Install dependencies
 | 
			
		||||
    let npm_install = Command::new("npm")
 | 
			
		||||
        .arg("install")
 | 
			
		||||
        .current_dir(oauth_dir)
 | 
			
		||||
        .status()
 | 
			
		||||
        .with_context(|| "Failed to run npm install")?;
 | 
			
		||||
 | 
			
		||||
    if !npm_install.success() {
 | 
			
		||||
        anyhow::bail!("npm install failed");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    println!("Building OAuth app...");
 | 
			
		||||
    
 | 
			
		||||
    // Build the app
 | 
			
		||||
    let npm_build = Command::new("npm")
 | 
			
		||||
        .arg("run")
 | 
			
		||||
        .arg("build")
 | 
			
		||||
        .current_dir(oauth_dir)
 | 
			
		||||
        .status()
 | 
			
		||||
        .with_context(|| "Failed to run npm run build")?;
 | 
			
		||||
 | 
			
		||||
    if !npm_build.success() {
 | 
			
		||||
        anyhow::bail!("npm run build failed");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    println!("OAuth app build completed");
 | 
			
		||||
    Ok(())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async fn copy_build_artifacts(oauth_dir: &Path, project_dir: &Path) -> Result<()> {
 | 
			
		||||
    let dist_dir = oauth_dir.join("dist");
 | 
			
		||||
    let static_dir = project_dir.join("static");
 | 
			
		||||
    let templates_dir = project_dir.join("templates");
 | 
			
		||||
 | 
			
		||||
    // Remove old assets
 | 
			
		||||
    let assets_dir = static_dir.join("assets");
 | 
			
		||||
    if assets_dir.exists() {
 | 
			
		||||
        fs::remove_dir_all(&assets_dir)
 | 
			
		||||
            .with_context(|| format!("Failed to remove old assets directory: {}", assets_dir.display()))?;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Copy all files from dist to static
 | 
			
		||||
    copy_dir_recursive(&dist_dir, &static_dir)
 | 
			
		||||
        .with_context(|| "Failed to copy dist files to static directory")?;
 | 
			
		||||
 | 
			
		||||
    // Copy index.html to oauth-assets.html template
 | 
			
		||||
    let index_html = dist_dir.join("index.html");
 | 
			
		||||
    let oauth_assets = templates_dir.join("oauth-assets.html");
 | 
			
		||||
    
 | 
			
		||||
    fs::copy(&index_html, &oauth_assets)
 | 
			
		||||
        .with_context(|| "Failed to copy index.html to oauth-assets.html")?;
 | 
			
		||||
 | 
			
		||||
    println!("Copied build artifacts to project directory");
 | 
			
		||||
    Ok(())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
 | 
			
		||||
    if !dst.exists() {
 | 
			
		||||
        fs::create_dir_all(dst)?;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for entry in fs::read_dir(src)? {
 | 
			
		||||
        let entry = entry?;
 | 
			
		||||
        let path = entry.path();
 | 
			
		||||
        let dst_path = dst.join(entry.file_name());
 | 
			
		||||
 | 
			
		||||
        if path.is_dir() {
 | 
			
		||||
            copy_dir_recursive(&path, &dst_path)?;
 | 
			
		||||
        } else {
 | 
			
		||||
            fs::copy(&path, &dst_path)?;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Ok(())
 | 
			
		||||
}
 | 
			
		||||
@@ -5,13 +5,74 @@ use serde::{Deserialize, Serialize};
 | 
			
		||||
use serde_json::{json, Value};
 | 
			
		||||
use std::collections::HashSet;
 | 
			
		||||
use std::fs;
 | 
			
		||||
use std::path::PathBuf;
 | 
			
		||||
use std::path::{Path, PathBuf};
 | 
			
		||||
use std::process::{Command, Stdio};
 | 
			
		||||
use tokio::time::{sleep, Duration, interval};
 | 
			
		||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
 | 
			
		||||
use toml;
 | 
			
		||||
 | 
			
		||||
use super::auth::{load_config, load_config_with_refresh, AuthConfig};
 | 
			
		||||
 | 
			
		||||
// Load collection config with priority: env vars > project config.toml > defaults
 | 
			
		||||
fn load_collection_config(project_dir: Option<&Path>) -> Result<(String, String)> {
 | 
			
		||||
    // 1. Check environment variables first (highest priority)
 | 
			
		||||
    if let (Ok(comment), Ok(user)) = (
 | 
			
		||||
        std::env::var("AILOG_COLLECTION_COMMENT"),
 | 
			
		||||
        std::env::var("AILOG_COLLECTION_USER")
 | 
			
		||||
    ) {
 | 
			
		||||
        println!("{}", "📂 Using collection config from environment variables".cyan());
 | 
			
		||||
        return Ok((comment, user));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 2. Try to load from project config.toml (second priority)
 | 
			
		||||
    if let Some(project_path) = project_dir {
 | 
			
		||||
        match load_collection_config_from_project(project_path) {
 | 
			
		||||
            Ok(config) => {
 | 
			
		||||
                println!("{}", format!("📂 Using collection config from: {}", project_path.display()).cyan());
 | 
			
		||||
                return Ok(config);
 | 
			
		||||
            }
 | 
			
		||||
            Err(e) => {
 | 
			
		||||
                println!("{}", format!("⚠️  Failed to load project config: {}", e).yellow());
 | 
			
		||||
                println!("{}", "📂 Falling back to default collections".cyan());
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 3. Use defaults (lowest priority)
 | 
			
		||||
    println!("{}", "📂 Using default collection configuration".cyan());
 | 
			
		||||
    Ok(("ai.syui.log".to_string(), "ai.syui.log.user".to_string()))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Load collection config from project's config.toml
 | 
			
		||||
fn load_collection_config_from_project(project_dir: &Path) -> Result<(String, String)> {
 | 
			
		||||
    let config_path = project_dir.join("config.toml");
 | 
			
		||||
    if !config_path.exists() {
 | 
			
		||||
        return Err(anyhow::anyhow!("config.toml not found in {}", project_dir.display()));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let config_content = fs::read_to_string(&config_path)
 | 
			
		||||
        .with_context(|| format!("Failed to read config.toml from {}", config_path.display()))?;
 | 
			
		||||
    
 | 
			
		||||
    let config: toml::Value = config_content.parse()
 | 
			
		||||
        .with_context(|| "Failed to parse config.toml")?;
 | 
			
		||||
 | 
			
		||||
    let oauth_config = config.get("oauth")
 | 
			
		||||
        .and_then(|v| v.as_table())
 | 
			
		||||
        .ok_or_else(|| anyhow::anyhow!("No [oauth] section found in config.toml"))?;
 | 
			
		||||
 | 
			
		||||
    let collection_comment = oauth_config.get("collection_comment")
 | 
			
		||||
        .and_then(|v| v.as_str())
 | 
			
		||||
        .unwrap_or("ai.syui.log")
 | 
			
		||||
        .to_string();
 | 
			
		||||
 | 
			
		||||
    let collection_user = oauth_config.get("collection_user")
 | 
			
		||||
        .and_then(|v| v.as_str())
 | 
			
		||||
        .unwrap_or("ai.syui.log.user")
 | 
			
		||||
        .to_string();
 | 
			
		||||
 | 
			
		||||
    Ok((collection_comment, collection_user))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Debug, Serialize, Deserialize)]
 | 
			
		||||
struct JetstreamMessage {
 | 
			
		||||
    collection: Option<String>,
 | 
			
		||||
@@ -57,8 +118,17 @@ fn get_pid_file() -> Result<PathBuf> {
 | 
			
		||||
    Ok(pid_dir.join("stream.pid"))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub async fn start(daemon: bool) -> Result<()> {
 | 
			
		||||
    let config = load_config_with_refresh().await?;
 | 
			
		||||
pub async fn start(project_dir: Option<PathBuf>, daemon: bool) -> Result<()> {
 | 
			
		||||
    let mut config = load_config_with_refresh().await?;
 | 
			
		||||
    
 | 
			
		||||
    // Load collection config with priority: env vars > project config > defaults
 | 
			
		||||
    let (collection_comment, collection_user) = load_collection_config(project_dir.as_deref())?;
 | 
			
		||||
    
 | 
			
		||||
    // Update config with loaded collections
 | 
			
		||||
    config.collections.comment = collection_comment.clone();
 | 
			
		||||
    config.collections.user = collection_user;
 | 
			
		||||
    config.jetstream.collections = vec![collection_comment];
 | 
			
		||||
    
 | 
			
		||||
    let pid_file = get_pid_file()?;
 | 
			
		||||
    
 | 
			
		||||
    // Check if already running
 | 
			
		||||
@@ -74,8 +144,15 @@ pub async fn start(daemon: bool) -> Result<()> {
 | 
			
		||||
        
 | 
			
		||||
        // Fork process for daemon mode
 | 
			
		||||
        let current_exe = std::env::current_exe()?;
 | 
			
		||||
        let mut args = vec!["stream".to_string(), "start".to_string()];
 | 
			
		||||
        
 | 
			
		||||
        // Add project_dir argument if provided
 | 
			
		||||
        if let Some(project_path) = &project_dir {
 | 
			
		||||
            args.push(project_path.to_string_lossy().to_string());
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        let child = Command::new(current_exe)
 | 
			
		||||
            .args(&["stream", "start"])
 | 
			
		||||
            .args(&args)
 | 
			
		||||
            .stdin(Stdio::null())
 | 
			
		||||
            .stdout(Stdio::null())
 | 
			
		||||
            .stderr(Stdio::null())
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										27
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								src/main.rs
									
									
									
									
									
								
							@@ -85,6 +85,11 @@ enum Commands {
 | 
			
		||||
        #[command(subcommand)]
 | 
			
		||||
        command: StreamCommands,
 | 
			
		||||
    },
 | 
			
		||||
    /// OAuth app management
 | 
			
		||||
    Oauth {
 | 
			
		||||
        #[command(subcommand)]
 | 
			
		||||
        command: OauthCommands,
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Subcommand)]
 | 
			
		||||
@@ -101,6 +106,8 @@ enum AuthCommands {
 | 
			
		||||
enum StreamCommands {
 | 
			
		||||
    /// Start monitoring ATProto streams
 | 
			
		||||
    Start {
 | 
			
		||||
        /// Path to the blog project directory
 | 
			
		||||
        project_dir: Option<PathBuf>,
 | 
			
		||||
        /// Run as daemon
 | 
			
		||||
        #[arg(short, long)]
 | 
			
		||||
        daemon: bool,
 | 
			
		||||
@@ -113,6 +120,15 @@ enum StreamCommands {
 | 
			
		||||
    Test,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Subcommand)]
 | 
			
		||||
enum OauthCommands {
 | 
			
		||||
    /// Build OAuth app
 | 
			
		||||
    Build {
 | 
			
		||||
        /// Path to the blog project directory
 | 
			
		||||
        project_dir: PathBuf,
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[tokio::main]
 | 
			
		||||
async fn main() -> Result<()> {
 | 
			
		||||
    let cli = Cli::parse();
 | 
			
		||||
@@ -159,8 +175,8 @@ async fn main() -> Result<()> {
 | 
			
		||||
        }
 | 
			
		||||
        Commands::Stream { command } => {
 | 
			
		||||
            match command {
 | 
			
		||||
                StreamCommands::Start { daemon } => {
 | 
			
		||||
                    commands::stream::start(daemon).await?;
 | 
			
		||||
                StreamCommands::Start { project_dir, daemon } => {
 | 
			
		||||
                    commands::stream::start(project_dir, daemon).await?;
 | 
			
		||||
                }
 | 
			
		||||
                StreamCommands::Stop => {
 | 
			
		||||
                    commands::stream::stop().await?;
 | 
			
		||||
@@ -173,6 +189,13 @@ async fn main() -> Result<()> {
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        Commands::Oauth { command } => {
 | 
			
		||||
            match command {
 | 
			
		||||
                OauthCommands::Build { project_dir } => {
 | 
			
		||||
                    commands::oauth::build(project_dir).await?;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Ok(())
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user