2024-10-21 / @syui

cloudflare , bluesky

blueskyのoauthとbbsをlivestream serviceに実装する

youtubeのlivestreamはchatのような書き込みができます。それを再現します。

まずはblueskyのoauthが動作するかの確認です。これはbluesky-social/cookbookを使います。

# https://github.com/bluesky-social/cookbook/tree/main/python-oauth-web-app
$ cd ./repos/cookbook/python-oauth-web-app
$ rye sync
$ rye run python3 -c 'import secrets; print(secrets.token_hex())'|xargs echo FLASK_SECRET_KEY|tr -d ' ' >> .env
$ rye run python3 generate_jwk.py |xargs echo FLASK_CLIENT_SECRET_JWK|tr -d ' ' >> .env
$ cat .env
$ rye run flask run

oauthはlocalhostでは動作しません。したがって、ngrok, tailscale, cloudflareなどを使用します。個人的にはcloudflareがおすすめです。

$ cloudflared tunnel --url http://localhost:5000

表示されるurlにアクセスするとoauthでloginすることができました。oauthの情報はserverに保存されており以後は承認なしにlogin可能となります。

bbsと連携する

今回はloginしている場合にbbsの書き込みシステムを表示します。

{% block content %}
{% if g.user %}
    <p>@{{ g.user['handle'] }}</p>
    <iframe src="example.com?handle={{ g.user['handle'] }}"></iframe>
{% endif %}

bbsを作ります。

[package]
name = "rust-bbs"
version = "0.1.0"
edition = "2021"

[dependencies]
actix-web = "4.0"
rusqlite = { version = "0.28", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
askama = "0.11"
use actix_web::{web, App, HttpServer, HttpResponse, Responder};
use rusqlite::{Connection, Result as SqliteResult};
use serde::{Deserialize, Serialize};
use askama::Template;

#[derive(Serialize, Deserialize)]
struct Post {
    id: i32,
    content: String,
}

#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {
    posts: Vec<Post>,
}

#[derive(Template)]
#[template(path = "post.html")]
struct PostTemplate {}

fn init_db() -> SqliteResult<()> {
    let conn = Connection::open("sqlite.db")?;
    conn.execute(
        "CREATE TABLE IF NOT EXISTS posts (
            id INTEGER PRIMARY KEY,
            content TEXT NOT NULL
        )",
        [],
    )?;
    Ok(())
}

async fn index() -> impl Responder {
    let conn = Connection::open("sqlite.db").unwrap();
    let mut stmt = conn.prepare("SELECT id, content FROM posts ORDER BY id DESC").unwrap();
    let posts = stmt.query_map([], |row| {
        Ok(Post {
            id: row.get(0)?,
            content: row.get(1)?,
        })
    }).unwrap().filter_map(Result::ok).collect::<Vec<Post>>();

    let template = IndexTemplate { posts };
    HttpResponse::Ok().body(template.render().unwrap())
}

async fn post_form() -> impl Responder {
    let template = PostTemplate {};
    HttpResponse::Ok().body(template.render().unwrap())
}

#[derive(Deserialize)]
struct FormData {
    content: String,
}

async fn submit_post(form: web::Form<FormData>) -> impl Responder {
    let conn = Connection::open("sqlite.db").unwrap();
    conn.execute(
        "INSERT INTO posts (content) VALUES (?1)",
        [&form.content],
    ).unwrap();
    
    web::Redirect::to("/").see_other()
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    init_db().unwrap();

    HttpServer::new(|| {
        App::new()
            .route("/", web::get().to(index))
            .route("/post", web::get().to(post_form))
            .route("/submit", web::post().to(submit_post))
    })
    .bind("0.0.0.0:8080")?
    .run()
    .await
}
<!DOCTYPE html>
<html>
<head>
    <title>Simple BBS</title>
</head>
<body>
    <h1>Simple BBS</h1>
    <a href="/post">New Post</a>
    <ul>
        {% for post in posts %}
            <li>{{ post.content }}</li>
        {% endfor %}
    </ul>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
    <title>New Post</title>
</head>
<body>
    <h1>New Post</h1>
    <form action="/submit" method="post">
        <textarea name="content" required></textarea>
        <br>
        <input type="submit" value="Post">
    </form>
</body>
</html>
services:
  web:
    build: .
    ports:
      - "8080:8080"
    volumes:
      - ./sqlite.db:/sqlite.db
FROM syui/aios

WORKDIR /usr/src/app
COPY . .
RUN cargo build --release
COPY ./templates /templates
CMD ["/usr/src/app/target/release/rust-bbs"]
$ cargo build
$ ./target/debug/rust-bbs

$ docker compose up
$ curl -sL localhost:8080

あとは、iframeからparamでhandleを取得するので、それを使用するようにしたり、cssで見栄えを整えたら完成です。

要点だけまとめたので適時修正してください。redirectしたときにurlを変更しないとiframeで呼び出したとき2回目からはhandleが使えません。

async fn submit_post(
    req: HttpRequest,
    form: web::Form<FormData>
) -> Result<impl Responder, Error> {
    let query = web::Query::<QueryParams>::from_query(req.query_string())
        .unwrap_or_else(|_| web::Query(QueryParams { handle: None }));
    //let handle = query.handle.clone().filter(|h| !h.is_empty());
    //println!("Debug: Extracted handle: {:?}", handle);
    let handle = if !form.handle.is_empty() {
        form.handle.clone()
    } else {
        query.handle.clone().unwrap_or_default()
    };

    println!("Debug: Using handle: {:?}", handle);

    let conn = Connection::open("sqlite.db")
        .map_err(|_| ErrorInternalServerError("Database connection failed"))?;
    let result = conn.execute(
        "INSERT INTO posts (handle, content) VALUES (?1, ?2)",
        &[&form.handle, &form.content],
    );
    match result {
        Ok(_) => {
            let redirect_url = if !handle.is_empty() {
                format!("/?handle={}", handle)
            } else {
                "/".to_string()
            };
            Ok(HttpResponse::SeeOther()
                .append_header(("Location",
                        redirect_url))
                .finish())
        },

        //Ok(_) => Ok(web::Redirect::to("/").see_other()),
        Err(_) => Err(ErrorInternalServerError("Failed to insert post")),
    }
}
<form action="/submit" method="post">
	<input type="hidden" name="handle" id="handleInput">
	<textarea name="content" required></textarea>
	<input type="submit" value="Post">
</form>

<script>
	function getHandleFromUrl() {
		const urlParams = new URLSearchParams(window.location.search);
		return urlParams.get('handle');
	}
	window.onload = function() {
		const handle = getHandleFromUrl();
		if (handle) {
			document.getElementById('handleInput').value = handle;
		} else {
			document.getElementById('handleInput').value = "anonymous";
		}
	};
</script>
{% if g.user %}
    @{{ g.user['handle'] }}
    <iframe id="livechat" title="bskychat" width="100%" height="500" src="example.com/?handle={{ session['user_handle'] }}"></iframe>
{% endif %}