···11+-- This file was automatically created by Diesel to setup helper functions
22+-- and other internal bookkeeping. This file is safe to edit, any future
33+-- changes will be added to existing projects as new migrations.
44+55+DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
66+DROP FUNCTION IF EXISTS diesel_set_updated_at();
···11+-- This file was automatically created by Diesel to setup helper functions
22+-- and other internal bookkeeping. This file is safe to edit, any future
33+-- changes will be added to existing projects as new migrations.
44+55+66+77+88+-- Sets up a trigger for the given table to automatically set a column called
99+-- `updated_at` whenever the row is modified (unless `updated_at` was included
1010+-- in the modified columns)
1111+--
1212+-- # Example
1313+--
1414+-- ```sql
1515+-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
1616+--
1717+-- SELECT diesel_manage_updated_at('users');
1818+-- ```
1919+CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
2020+BEGIN
2121+ EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
2222+ FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
2323+END;
2424+$$ LANGUAGE plpgsql;
2525+2626+CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
2727+BEGIN
2828+ IF (
2929+ NEW IS DISTINCT FROM OLD AND
3030+ NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
3131+ ) THEN
3232+ NEW.updated_at := current_timestamp;
3333+ END IF;
3434+ RETURN NEW;
3535+END;
3636+$$ LANGUAGE plpgsql;
···11+-- This file should undo anything in `up.sql`
22+drop table if exists profile_pronouns;
33+44+drop table if exists profile_links;
55+66+drop table if exists profile;
77+88+drop table if exists emails;
99+1010+drop table if exists _jetstream;
1111+1212+drop table if exists follows;
1313+1414+drop table if exists public_keys;
1515+1616+drop table if exists registrations;
1717+1818+drop table if exists oauth_sessions;
1919+2020+drop table if exists oauth_requests;
···11+create table if not exists registrations (
22+ id serial primary key,
33+ domain text not null unique,
44+ did text not null,
55+ secret text not null,
66+ created timestamp
77+ with
88+ time zone not null default (now () at time zone 'utc'),
99+ registered text
1010+);
1111+1212+create table if not exists public_keys (
1313+ id serial primary key,
1414+ did text not null,
1515+ name text not null,
1616+ key_contents text not null,
1717+ rkey text not null,
1818+ created timestamp
1919+ with
2020+ time zone not null default (now () at time zone 'utc'),
2121+ unique (did, name, key_contents)
2222+);
2323+2424+create table if not exists follows (
2525+ user_did text not null,
2626+ subject_did text not null,
2727+ rkey text not null,
2828+ followed_at timestamp
2929+ with
3030+ time zone not null default (now () at time zone 'utc'),
3131+ primary key (user_did, subject_did),
3232+ check (user_did <> subject_did)
3333+);
3434+3535+create table if not exists _jetstream (
3636+ id serial primary key,
3737+ last_time_us integer not null
3838+);
3939+4040+create table if not exists emails (
4141+ id serial primary key,
4242+ did text not null,
4343+ email text not null,
4444+ verified integer not null default 0,
4545+ verification_code text not null,
4646+ last_sent timestamp
4747+ with
4848+ time zone not null default (now () at time zone 'utc'),
4949+ is_primary integer not null default 0,
5050+ created timestamp
5151+ with
5252+ time zone not null default (now () at time zone 'utc'),
5353+ unique (did, email)
5454+);
5555+5656+create table if not exists profile (
5757+ -- id
5858+ id serial primary key,
5959+ did text not null,
6060+ -- data
6161+ avatar text,
6262+ description text not null,
6363+ include_bluesky boolean not null default false,
6464+ include_tangled boolean not null default false,
6565+ location text,
6666+ pinned_post jsonb,
6767+ created_at timestamp
6868+ with
6969+ time zone default (now () at time zone 'utc'),
7070+ -- constraints
7171+ unique (did)
7272+);
7373+7474+create table if not exists profile_links (
7575+ -- id
7676+ id serial primary key,
7777+ did text not null,
7878+ -- data
7979+ link text not null,
8080+ -- constraints
8181+ foreign key (did) references profile (did) on delete cascade
8282+);
8383+8484+create table if not exists profile_pronouns (
8585+ -- id
8686+ id serial primary key,
8787+ did text not null,
8888+ -- data
8989+ pronoun text not null,
9090+ -- constraints
9191+ foreign key (did) references profile (did) on delete cascade
9292+);
9393+9494+create table if not exists oauth_requests (
9595+ id serial primary key,
9696+ auth_server_iss text not null,
9797+ state text,
9898+ did text not null,
9999+ pkce_verifier text not null,
100100+ dpop_key jsonb not null
101101+);
102102+103103+create table if not exists oauth_sessions (
104104+ id serial primary key,
105105+ did text not null,
106106+ pds_url text not null,
107107+ session jsonb not null,
108108+ expiry text
109109+);
+90
crates/weaver-appview/src/api_error.rs
···11+use axum::{
22+ Json,
33+ extract::rejection::JsonRejection,
44+ response::{IntoResponse, Response},
55+};
66+use hyper::StatusCode;
77+use miette::Diagnostic;
88+use serde::{Deserialize, Serialize};
99+use thiserror::Error;
1010+use tracing::error;
1111+1212+/// Custom error type for the API.
1313+/// The `#[from]` attribute allows for easy conversion from other error types.
1414+#[derive(Error, Debug, Diagnostic)]
1515+pub enum ApiError {
1616+ /// Converts from an Axum built-in extractor error.
1717+ #[diagnostic_source]
1818+ #[error("Invalid payload.")]
1919+ InvalidJsonBody(#[from] JsonRejection),
2020+2121+ /// For errors that occur during manual validation.
2222+ #[error("Invalid request: {0}")]
2323+ #[diagnostic()]
2424+ InvalidRequest(String),
2525+2626+ /// Converts from `sqlx::Error`.
2727+ #[error("A database error has occurred.")]
2828+ #[diagnostic_source]
2929+ DatabaseError(#[from] diesel::result::Error),
3030+3131+ #[error("A Weaver error has occurred.")]
3232+ #[diagnostic(transparent)]
3333+ WeaverError(#[from] weaver_common::error::Error),
3434+ /// Converts from any `anyhow::Error`.
3535+ #[error("An internal server error has occurred.")]
3636+ #[diagnostic(transparent)]
3737+ InternalError(miette::Report),
3838+}
3939+4040+impl From<miette::Report> for ApiError {
4141+ fn from(err: miette::Report) -> Self {
4242+ ApiError::InternalError(err)
4343+ }
4444+}
4545+4646+#[derive(Serialize, Deserialize)]
4747+pub struct ApiErrorResp {
4848+ pub message: String,
4949+}
5050+5151+// The IntoResponse implementation for ApiError logs the error message.
5252+//
5353+// To avoid exposing implementation details to API consumers, we separate
5454+// the message that we log from the API response message.
5555+impl IntoResponse for ApiError {
5656+ fn into_response(self) -> Response {
5757+ // Log detailed error for telemetry.
5858+ let error_to_log = match &self {
5959+ ApiError::InvalidJsonBody(err) => match err {
6060+ JsonRejection::JsonDataError(e) => e.body_text(),
6161+ JsonRejection::JsonSyntaxError(e) => e.body_text(),
6262+ JsonRejection::MissingJsonContentType(_) => {
6363+ "Missing `Content-Type: application/json` header".to_string()
6464+ }
6565+ JsonRejection::BytesRejection(_) => "Failed to buffer request body".to_string(),
6666+ _ => "Unknown error".to_string(),
6767+ },
6868+ ApiError::InvalidRequest(_) => format!("{}", self),
6969+ ApiError::WeaverError(err) => format!("{}", err),
7070+ ApiError::DatabaseError(err) => format!("{}", err),
7171+ ApiError::InternalError(err) => format!("{}", err),
7272+ };
7373+ error!("{}", error_to_log);
7474+7575+ // Create a generic response to hide specific implementation details.
7676+ let resp = ApiErrorResp {
7777+ message: self.to_string(),
7878+ };
7979+8080+ // Determine the appropriate status code.
8181+ let status = match self {
8282+ ApiError::InvalidJsonBody(_) | ApiError::InvalidRequest(_) => StatusCode::BAD_REQUEST,
8383+ ApiError::WeaverError(_) | ApiError::DatabaseError(_) | ApiError::InternalError(_) => {
8484+ StatusCode::INTERNAL_SERVER_ERROR
8585+ }
8686+ };
8787+8888+ (status, Json(resp)).into_response()
8989+ }
9090+}
···11+use diesel::prelude::*;
22+use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
33+pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!();
44+use diesel_async::RunQueryDsl;
55+use diesel_async::pooled_connection::AsyncDieselConnectionManager;
66+use diesel_async::pooled_connection::deadpool::Pool;
77+88+#[derive(Clone)]
99+pub struct Db {
1010+ pub pool: Pool<diesel_async::AsyncPgConnection>,
1111+}
1212+1313+impl Db {
1414+ /// Yes, this fuction can and WILL panic if it can't create the connection pool
1515+ /// for some reason. We just want to bail because the appview
1616+ /// does not work without a database.
1717+ pub async fn new(db_path: Option<String>) -> Self {
1818+ let database_url = if let Some(db_path) = db_path {
1919+ db_path
2020+ } else {
2121+ std::env::var("DATABASE_URL").expect("DATABASE_URL must be set")
2222+ };
2323+ let config =
2424+ AsyncDieselConnectionManager::<diesel_async::AsyncPgConnection>::new(database_url);
2525+ let pool = Pool::builder(config)
2626+ .build()
2727+ .expect("Failed to create pool");
2828+ Self { pool }
2929+ }
3030+}
3131+3232+pub fn run_migrations(
3333+ db_path: Option<String>,
3434+) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
3535+ let database_url = if let Some(db_path) = db_path {
3636+ db_path
3737+ } else {
3838+ std::env::var("DATABASE_URL").expect("DATABASE_URL must be set")
3939+ };
4040+ let mut connection = PgConnection::establish(&database_url)
4141+ .unwrap_or_else(|_| panic!("Error connecting to {}", database_url));
4242+ // This will run the necessary migrations.
4343+ //
4444+ // See the documentation for `MigrationHarness` for
4545+ // all available methods.
4646+ println!("Attempting migrations...");
4747+ let result = connection.run_pending_migrations(MIGRATIONS);
4848+ println!("{:?}", result);
4949+ if result.is_err() {
5050+ println!("Failed to run migrations");
5151+ return result.map(|_| ());
5252+ }
5353+ println!("Migrations Applied:");
5454+ let applied_migrations = connection.applied_migrations()?;
5555+ for migration in applied_migrations {
5656+ println!(" * {}", migration);
5757+ }
5858+ Ok(())
5959+}
+134-2
crates/weaver-appview/src/main.rs
···11-mod oauth;
11+pub mod api_error;
22+33+pub mod config;
44+pub mod db;
55+pub mod middleware;
66+pub mod models;
77+pub mod oauth;
88+pub mod routes;
99+pub mod schema;
1010+pub mod state;
1111+pub mod telemetry;
1212+1313+use axum::Router;
1414+use clap::Parser;
1515+use config::*;
1616+use db::*;
1717+use diesel::prelude::*;
1818+use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl};
1919+use dotenvy::dotenv;
2020+use miette::IntoDiagnostic;
2121+use miette::miette;
2222+use state::*;
2323+use std::env;
2424+2525+use tokio::net::TcpListener;
2626+use tracing::{debug, error, info};
2727+2828+#[derive(Parser)]
2929+#[command(author, version, about, long_about = None)]
3030+struct Cli {
3131+ #[arg(
3232+ short,
3333+ long,
3434+ value_name = "FILE",
3535+ default_value = "appview-config.toml"
3636+ )]
3737+ config: String,
3838+}
239340#[tokio::main]
44-async fn main() {}
4141+async fn main() -> miette::Result<()> {
4242+ let config = initialize()?;
4343+ // Run any migrations before we do anything else.
4444+ let db_path = config.core.db_path.clone();
4545+ let _ = tokio::task::spawn_blocking(|| db::run_migrations(Some(db_path)))
4646+ .await
4747+ .into_diagnostic()?;
4848+ let db = Db::new(Some(config.core.db_path.clone())).await;
4949+ debug!("Connected to database");
5050+ // Spin up our server.
5151+ info!("Starting server on {}", config.core.listen_addr);
5252+ let listener = TcpListener::bind(&config.core.listen_addr)
5353+ .await
5454+ .expect("Failed to bind address");
5555+ let router = router(config, db);
5656+ axum::serve(listener, router)
5757+ .await
5858+ .expect("Failed to start server");
5959+ Ok(())
6060+}
6161+6262+pub fn router(cfg: Config, db: Db) -> Router {
6363+ let app_state = AppState::new(cfg, db);
6464+6565+ // Middleware that adds high level tracing to a Service.
6666+ // Trace comes with good defaults but also supports customizing many aspects of the output:
6767+ // https://docs.rs/tower-http/latest/tower_http/trace/index.html
6868+ let trace_layer = telemetry::trace_layer();
6969+7070+ // Sets 'x-request-id' header with randomly generated uuid v7.
7171+ let request_id_layer = middleware::request_id_layer();
7272+7373+ // Propagates 'x-request-id' header from the request to the response.
7474+ let propagate_request_id_layer = middleware::propagate_request_id_layer();
7575+7676+ // Layer that applies the Cors middleware which adds headers for CORS.
7777+ let cors_layer = middleware::cors_layer();
7878+7979+ // Layer that applies the Timeout middleware, which sets a timeout for requests.
8080+ // The default value is 15 seconds.
8181+ let timeout_layer = middleware::timeout_layer();
8282+8383+ // Any trailing slashes from request paths will be removed. For example, a request with `/foo/`
8484+ // will be changed to `/foo` before reaching the internal service.
8585+ let normalize_path_layer = middleware::normalize_path_layer();
8686+8787+ // Create the router with the routes.
8888+ let router = routes::router();
8989+9090+ // Combine all the routes and apply the middleware layers.
9191+ // The order of the layers is important. The first layer is the outermost layer.
9292+ Router::new()
9393+ .merge(router)
9494+ .layer(normalize_path_layer)
9595+ .layer(cors_layer)
9696+ .layer(timeout_layer)
9797+ .layer(propagate_request_id_layer)
9898+ .layer(trace_layer)
9999+ .layer(request_id_layer)
100100+ .with_state(app_state)
101101+}
102102+103103+pub fn initialize() -> miette::Result<Config> {
104104+ miette::set_hook(Box::new(|_| {
105105+ Box::new(
106106+ miette::MietteHandlerOpts::new()
107107+ .terminal_links(true)
108108+ //.rgb_colors(miette::RgbColors::)
109109+ .with_cause_chain()
110110+ .with_syntax_highlighting(miette::highlighters::SyntectHighlighter::default())
111111+ .color(true)
112112+ .context_lines(5)
113113+ .tab_width(2)
114114+ .break_words(true)
115115+ .build(),
116116+ )
117117+ }))
118118+ .map_err(|e| miette!("Failed to set miette hook: {}", e))?;
119119+ miette::set_panic_hook();
120120+ dotenv().ok();
121121+ let cli = Cli::parse();
122122+ let config = config::Config::load(&cli.config);
123123+ let config = if let Err(e) = config {
124124+ error!("{}", e);
125125+ config::Config::load(
126126+ &env::var("APPVIEW_CONFIG").expect("Either set APPVIEW_CONFIG to the path to your config file, pass --config FILE to specify the path, or create a file called appview-config.toml in the directory where you are running the binary from."),
127127+ )
128128+ .map_err(|e| miette!(e))
129129+ } else {
130130+ config
131131+ }?;
132132+ let log_dir = env::var("LOG_DIR").unwrap_or_else(|_| "/tmp/appview".to_string());
133133+ std::fs::create_dir_all(&log_dir).unwrap();
134134+ let _guard = telemetry::setup_tracing(&log_dir);
135135+ Ok(config)
136136+}
+61
crates/weaver-appview/src/middleware.rs
···11+use std::time::Duration;
22+33+use axum::http::HeaderName;
44+use hyper::Request;
55+use tower_http::{
66+ cors::{AllowHeaders, Any, CorsLayer},
77+ normalize_path::NormalizePathLayer,
88+ request_id::{MakeRequestId, PropagateRequestIdLayer, RequestId, SetRequestIdLayer},
99+ timeout::TimeoutLayer,
1010+};
1111+1212+#[derive(Clone, Default)]
1313+pub struct Id;
1414+1515+impl MakeRequestId for Id {
1616+ fn make_request_id<B>(&mut self, _: &Request<B>) -> Option<RequestId> {
1717+ let id = uuid::Uuid::now_v7().to_string().parse().unwrap();
1818+ Some(RequestId::new(id))
1919+ }
2020+}
2121+2222+/// Sets the 'x-request-id' header with a randomly generated UUID v7.
2323+///
2424+/// SetRequestId will not override request IDs if they are already present
2525+/// on requests or responses.
2626+pub fn request_id_layer() -> SetRequestIdLayer<Id> {
2727+ let x_request_id = HeaderName::from_static("x-request-id");
2828+ SetRequestIdLayer::new(x_request_id.clone(), Id)
2929+}
3030+3131+// Propagates 'x-request-id' header from the request to the response.
3232+///
3333+/// PropagateRequestId wont override request ids if its already
3434+/// present on requests or responses.
3535+pub fn propagate_request_id_layer() -> PropagateRequestIdLayer {
3636+ let x_request_id = HeaderName::from_static("x-request-id");
3737+ PropagateRequestIdLayer::new(x_request_id)
3838+}
3939+4040+/// Layer that applies the Cors middleware which adds headers for CORS.
4141+pub fn cors_layer() -> CorsLayer {
4242+ CorsLayer::new()
4343+ .allow_origin(Any)
4444+ .allow_methods(Any)
4545+ .allow_headers(AllowHeaders::mirror_request())
4646+ .max_age(Duration::from_secs(600))
4747+}
4848+4949+/// Layer that applies the Timeout middleware which apply a timeout to requests.
5050+/// The default timeout value is set to 15 seconds.
5151+pub fn timeout_layer() -> TimeoutLayer {
5252+ TimeoutLayer::new(Duration::from_secs(15))
5353+}
5454+5555+/// Middleware that normalizes paths.
5656+///
5757+/// Any trailing slashes from request paths will be removed. For example, a request with `/foo/`
5858+/// will be changed to `/foo` before reaching the inner service.
5959+pub fn normalize_path_layer() -> NormalizePathLayer {
6060+ NormalizePathLayer::trim_trailing_slash()
6161+}
···11+use tower_http::{
22+ classify::{ServerErrorsAsFailures, SharedClassifier},
33+ trace::{DefaultMakeSpan, DefaultOnRequest, DefaultOnResponse, TraceLayer},
44+};
55+use tracing::Level;
66+use tracing_appender::{self, non_blocking, non_blocking::WorkerGuard, rolling::daily};
77+use tracing_subscriber::{
88+ EnvFilter,
99+ fmt::{self, layer, writer::MakeWriterExt},
1010+ layer::SubscriberExt,
1111+ registry,
1212+ util::SubscriberInitExt,
1313+};
1414+/// The `EnvFilter` type is used to filter log events based on the value of an environment variable.
1515+/// In this case, we are using the `try_from_default_env` method to attempt to read the `RUST_LOG` environment variable,
1616+/// which is used to set the log level for the application.
1717+/// If the environment variable is not set, we default to the log level of `debug`.
1818+/// The `RUST_LOG` environment variable is set in the Dockerfile and .env files.
1919+pub fn setup_tracing<S: AsRef<str>>(logdir: S) -> WorkerGuard {
2020+ let (non_blocking_appender, guard) = non_blocking(daily(logdir.as_ref(), "general.log"));
2121+ let env_filter_layer = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
2222+ format!(
2323+ "debug,{}=debug,tower_http=debug,axum=debug,hyper=debug,axum::rejection=trace,markdown=info",
2424+ env!("CARGO_PKG_NAME"),
2525+ ).into()
2626+ });
2727+ let formatting_layer = fmt::layer().json();
2828+ tracing_subscriber::registry()
2929+ .with(env_filter_layer)
3030+ .with(formatting_layer)
3131+ .with(
3232+ layer()
3333+ .with_writer(std::io::stdout.with_max_level(Level::DEBUG))
3434+ .event_format(tracing_subscriber::fmt::format().pretty()),
3535+ )
3636+ .with(layer().with_writer(non_blocking_appender.with_max_level(Level::INFO)))
3737+ .init();
3838+ guard
3939+}
4040+4141+/// Returns a `TraceLayer` for HTTP requests and responses.
4242+/// The `TraceLayer` is used to trace requests and responses in the application.
4343+pub fn trace_layer() -> TraceLayer<SharedClassifier<ServerErrorsAsFailures>> {
4444+ TraceLayer::new_for_http()
4545+ .make_span_with(DefaultMakeSpan::new().level(Level::INFO))
4646+ .on_request(DefaultOnRequest::new().level(Level::INFO))
4747+ .on_response(DefaultOnResponse::new().level(Level::INFO))
4848+}
+1
crates/weaver-cli/Cargo.toml
···26262727# temp for testing
2828tokio = { version = "1.45.0", features = ["full"] }
2929+rouille = { version = "3.6.2", features = ["rustls"] }
+36-14
crates/weaver-cli/src/main.rs
···11use atrium_api::agent::Agent;
22-use atrium_api::xrpc::http::Uri;
32use atrium_oauth::AuthorizeOptions;
33+use atrium_oauth::CallbackParams;
44use atrium_oauth::KnownScope;
55use atrium_oauth::Scope;
66-use std::{
77- error,
88- io::{BufRead, Write, stdin, stdout},
99-};
66+use rouille::Server;
77+use std::error;
88+use tokio::sync::mpsc;
1091110#[tokio::main]
1211async fn main() -> Result<(), Box<dyn error::Error>> {
1313- let client = weaver_common::oauth::default_oauth_client("https://appview.weaver.sh")?;
1212+ let (tx, mut rx) = mpsc::channel(5);
1313+ let server = Server::new("0.0.0.0:4000", move |request| {
1414+ create_callback_router(request, tx.clone())
1515+ })
1616+ .expect("Could not start server");
1717+ let (server_handle, server_stop) = server.stoppable();
1818+ let client = weaver_common::oauth::default_native_oauth_client()?;
1419 println!(
1515- "Authorization url: {}",
2020+ "To authenticate with your PDS, visit:\r\n\t {}",
1621 client
1722 .authorize(
1823 std::env::var("HANDLE").unwrap_or(String::from("https://atproto.systems")),
···2732 .await?
2833 );
29343030- print!("Redirected url: ");
3131- stdout().lock().flush()?;
3232- let mut url = String::new();
3333- stdin().lock().read_line(&mut url)?;
3434-3535- let uri = url.trim().parse::<Uri>()?;
3636- let params = serde_html_form::from_str(uri.query().unwrap())?;
3535+ let params = rx.recv().await.unwrap();
3736 let (session, _) = client.callback(params).await?;
3737+ server_stop.send(()).expect("Failed to stop callbackserver");
3838 let agent = Agent::new(session);
3939 let output = agent
4040 .api
···5353 for feed in &output.feed {
5454 println!("{feed:?}");
5555 }
5656+ server_handle.join().unwrap();
5657 Ok(())
5758}
5959+6060+pub fn create_callback_router(
6161+ request: &rouille::Request,
6262+ tx: mpsc::Sender<CallbackParams>,
6363+) -> rouille::Response {
6464+ rouille::router!(request,
6565+ (GET) (/oauth/callback) => {
6666+ let state = request.get_param("state").unwrap();
6767+ let code = request.get_param("code").unwrap();
6868+ let iss = request.get_param("iss").unwrap();
6969+ let callback_params = CallbackParams {
7070+ state: Some(state),
7171+ code,
7272+ iss: Some(iss),
7373+ };
7474+ tx.try_send(callback_params).unwrap();
7575+ rouille::Response::text("Logged in!")
7676+ },
7777+ _ => rouille::Response::empty_404()
7878+ )
7979+}
···11pub mod client;
22+pub mod compat;
23pub mod config;
34pub mod error;
45pub mod lexicons;
···89910pub use crate::error::{Error, IoError, ParseError, SerDeError};
10111111-/// Canonical Cow for us, thanks Amos
1212pub use merde::CowStr;
1313-1413/// too many cows, so we have conversions
1514pub fn mcow_to_cow(cow: CowStr<'_>) -> std::borrow::Cow<'_, str> {
1615 match cow {