280 lines
7.3 KiB
Markdown
280 lines
7.3 KiB
Markdown
|
+++
|
||
|
date = "2024-10-21"
|
||
|
tags = ["cloudflare", "bluesky"]
|
||
|
title = "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)を使います。
|
||
|
|
||
|
```sh
|
||
|
# 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がおすすめです。
|
||
|
|
||
|
```sh
|
||
|
$ cloudflared tunnel --url http://localhost:5000
|
||
|
```
|
||
|
|
||
|
表示されるurlにアクセスするとoauthでloginすることができました。oauthの情報はserverに保存されており以後は承認なしにlogin可能となります。
|
||
|
|
||
|
## bbsと連携する
|
||
|
|
||
|
今回はloginしている場合にbbsの書き込みシステムを表示します。
|
||
|
|
||
|
<video src="https://raw.githubusercontent.com/syui/img/master/movie/bluesky_oauth_livestream_gameplay.mp4" width="100%" height="500px" controls> </video>
|
||
|
|
||
|
```html:template/home.html
|
||
|
{% block content %}
|
||
|
{% if g.user %}
|
||
|
<p>@{{ g.user['handle'] }}</p>
|
||
|
<iframe src="example.com?handle={{ g.user['handle'] }}"></iframe>
|
||
|
{% endif %}
|
||
|
```
|
||
|
|
||
|
bbsを作ります。
|
||
|
|
||
|
```toml:Cargo.toml
|
||
|
[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"
|
||
|
```
|
||
|
|
||
|
```rust:src/main.rs
|
||
|
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
|
||
|
}
|
||
|
```
|
||
|
|
||
|
```html:template/index.html
|
||
|
<!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>
|
||
|
```
|
||
|
|
||
|
```html:template/post.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>
|
||
|
```
|
||
|
|
||
|
```yml:compose.yml
|
||
|
services:
|
||
|
web:
|
||
|
build: .
|
||
|
ports:
|
||
|
- "8080:8080"
|
||
|
volumes:
|
||
|
- ./sqlite.db:/sqlite.db
|
||
|
```
|
||
|
|
||
|
```sh:Dockerfile.txt
|
||
|
FROM syui/aios
|
||
|
|
||
|
WORKDIR /usr/src/app
|
||
|
COPY . .
|
||
|
RUN cargo build --release
|
||
|
COPY ./templates /templates
|
||
|
CMD ["/usr/src/app/target/release/rust-bbs"]
|
||
|
```
|
||
|
|
||
|
```sh
|
||
|
$ cargo build
|
||
|
$ ./target/debug/rust-bbs
|
||
|
|
||
|
$ docker compose up
|
||
|
$ curl -sL localhost:8080
|
||
|
```
|
||
|
|
||
|
あとは、iframeからparamでhandleを取得するので、それを使用するようにしたり、cssで見栄えを整えたら完成です。
|
||
|
|
||
|
要点だけまとめたので適時修正してください。redirectしたときにurlを変更しないとiframeで呼び出したとき2回目からはhandleが使えません。
|
||
|
|
||
|
```rust:src/main.rs
|
||
|
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")),
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
```html:templates/index.html
|
||
|
<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>
|
||
|
```
|
||
|
|
||
|
```html:templates/home.htmll
|
||
|
{% if g.user %}
|
||
|
@{{ g.user['handle'] }}
|
||
|
<iframe id="livechat" title="bskychat" width="100%" height="500" src="example.com/?handle={{ session['user_handle'] }}"></iframe>
|
||
|
{% endif %}
|
||
|
```
|