Free File Storage Approach
Free File Storage Approach
最近正在思考購買 Amazon S3 服務,不過考慮到 S3 的成本和我的要求真的不多,我認為應該有更低成本的解決方案。
source code: github.com/gnitoahc/file-bot
Discord Approach
Discord 作為一個免費社交平台,提供了一個很可靠的檔案傳輸功能,並且包含幾項特性:
- 上傳後的資料會提供一個網址,可以從任何地點訪問
- 網址不容易被猜測
- 上傳總量沒有限制,並且資料不會過期或被自動刪除
- 完全免費
Discord 畢竟不是一個專門的雲端硬碟,所以還是有些限制,例如單一檔案大小上限等等。不過對於簡單的檔案傳輸或是一些 demo,已經很夠用了。
Shuttle Approach
Shuttle 是一個基於 Rust 的開源專案(Rust 雲端託管平台),提供每個人一定的免費額度可以自行部署服務。包括以下優勢:
- 開源
- 部署容易,只要
shuttle deploy
即可 - 不用擔心維護問題
- 完全免費
Overall Approach
現在我們可以綜合上述兩個方法,模擬一個免費的 Amazon S3 服務。首先,建立一個 Discord Bot 並夾到伺服器,透過 Shuttle 部署一個簡單的 API 服務。每次有檔案上傳到 Shuttle 的服務時,透過 Discord Bot 上傳到伺服器,並回傳檔案網址。
範例:
curl -X POST -F 'file=@./file.png' http://127.0.0.1:8000/discord
回傳:
{
"status": "ok",
"url": "https://cdn.discordapp.com/attachments/..."
}
Implementation
Create a new Discord Bot
首先,到 Discord Developer Portal 建立一個新的應用程式,並建立一個 Bot。進入 Bot 頁面後,選擇 OAuth2 製作 Bot Token,並將 Bot 加到伺服器。
Create a Shuttle Service
用 Shuttle 官方網站的 Sample 建立一個 Axum 服務,並撰寫 API 來處理檔案上傳。簡單來說,製作一個可以 POST
的路由,每當檔案被 POST 上來時,將檔案上傳到 Discord 伺服器,並回傳網址。
let router = Router::new()
.route("/discord", post(discord::discord::send_file));
當然,有需多東西要 handle,我們一步一步來。先用 shuttle init --template axum
建立新專案。這個 command 會在 project root 建立一個 src/main.rs
和 Cargo.toml
。
先看看我們的最終的專案架構:
project-name
├── Cargo.lock
├── Cargo.toml
├── .gitignore
├── Secrets.toml
└── src
├── discord
│ ├── discord.rs
│ └── mod.rs
└── main.rs
Cargo.lock
會自動產生,不用擔心。目前,先將需要的 dependencies 加上。
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,10 +1,12 @@
[package]
name = "file-bot"
version = "0.1.0"
edition = "2021"
[dependencies]
-axum = "0.7.4"
+reqwest = { version = "0.12.8", features = ["multipart", "json"] }
+serde_json = "1.0.128"
+axum = { version = "0.7.4", features = ["multipart"] }
shuttle-axum = "0.48.0"
shuttle-runtime = "0.48.0"
tokio = "1.28.2"
接著再把 Discord API 的部分實作。為了避免我們的 API 外流,最好把 Token 都放進 Secrets.toml
中。
DISCORD_BOT_TOKEN = "your bot token"
DISCORD_GUILD_ID= "your server's guild id"
CHANNEL_NAME= "db-storage"
Main file
首先,我們要將 API 加入到主要的檔案,讓使用者可以從外面呼叫到這個 function。在 main.rs
中加入以下內容:
use axum::{
routing::{get, post},
Router,
};
use shuttle_runtime::SecretStore;
這會引入我們需要的套件。接著加入我們的 AppState
,還有引入我們自定義的 discord module:
mod discord;
#[derive(Clone)]
struct AppState {
discord_info: discord::DiscordInfo,
}
其中,mod discord
會使用我們稍後定義在 src/discord
資料夾以下的 module。AppState
則是一個可以在整個專案中共享的資源,這裡我們放了 Discord 的資訊,稍後會在 src/discord/mod.rs
定義。
hello_world
是一個簡單的 API,用來測試是否正常運作。
async fn hello_world() -> &'static str {
"Hello, world!"
}
最後,我們定義 main function,這是整個程式的進入點。
#[shuttle_runtime::main]
async fn main(#[shuttle_runtime::Secrets] secrets: SecretStore) -> shuttle_axum::ShuttleAxum {
let info = discord::DiscordInfo {
bot_api_key: secrets.get("DISCORD_BOT_TOKEN").unwrap(),
guild_id: secrets.get("DISCORD_GUILD_ID").unwrap(),
channel_name: secrets.get("CHANNEL_NAME").unwrap(),
};
let state = AppState { discord_info: info };
let router = Router::new()
.route("/", get(hello_world))
.route("/discord", post(discord::discord::send_file))
.with_state(state);
Ok(router.into())
}
Discord Usage
現在,我們先實作 Discord API 的部分,在 src
下建立 discord
資料夾,加入 mod.rs
和 discord.rs
。在 mod.rs
加入以下內容:
// src/discord/mod.rs
pub mod discord;
#[derive(Clone, Debug)]
pub struct DiscordInfo {
pub bot_api_key: String,
pub guild_id: String,
pub channel_name: String,
}
這定義了 Discord Bot 的重要資訊,讓我們可以在其他地方使用。接著在 discord.rs
中先定義幾個 function。
// src/discord/discord.rs
const DISCORD_CHANNEL_API: &str = "https://discord.com/api/v10/channels/{channel_id}/messages";
const DISCORD_GUILD_API: &str = "https://discord.com/api/v10/guilds/{guild_id}/channels";
get_channel_id
尋找特定名字 channel 的 ID
-
定義回傳
Result<String, Box<dyn std::error::Error>>
,如果找不到 channel,回傳Err
// src/discord/discord.rs async fn get_channel_id( bot_api_key: &str, guild_id: &str, channel_name: &str, ) -> Result<String, Box<dyn std::error::Error>> { // ... }
-
使用
reqwest
套件來發送 HTTP request,取得伺服器的所有頻道let url = DISCORD_GUILD_API.replace("{guild_id}", &guild_id); let client = reqwest::Client::new(); // Create headers let mut headers = reqwest::header::HeaderMap::new(); headers.insert( "Authorization", format!("Bot {bot_api_key}", bot_api_key = bot_api_key) .parse() .unwrap(), ); headers.insert("Content-Type", "application/json".parse().unwrap()); let response = client.get(&url).headers(headers).send().await?;
-
尋找我們要的 channel ID
let channels: Vec<serde_json::Value> = response.json().await?; for channel in channels { let cur_name = channel["name"].as_str().unwrap(); if cur_name == channel_name { return Ok(channel["id"].as_str().unwrap().to_string()); } } Err("Channel not found".into())
upload
上傳特定檔案到 Discord 並回傳檔案網址
-
定義回傳
Result<String, Box<dyn std::error::Error>>
,如果上傳失敗,回傳Err
// src/discord/discord.rs async fn upload( file_name: &str, data: &[u8], channel_id: &str, bot_api_key: &str, ) -> Result<String, Box<dyn std::error::Error>> { // ... }
-
製作 multipart form data 和 client
let form = multipart::Form::new().part( "file", multipart::Part::bytes(data.to_vec()).file_name(file_name.to_string()), ); let client = reqwest::Client::new();
-
定義 Discord API 要求的 headers
let mut headers = reqwest::header::HeaderMap::new(); headers.insert( "Authorization", format!("Bot {bot_api_key}", bot_api_key = bot_api_key) .parse() .unwrap(), ); let url = DISCORD_CHANNEL_API.replace("{channel_id}", channel_id);
-
發送 request 到 Discord API
let response = client .post(&url) .headers(headers) .multipart(form) .send() .await?;
-
確定 response 成功並取得檔案網址
if !response.status().is_success() { return Err("Failed to send file".into()); } let result: Value = match response.json().await { Ok(result) => result, Err(e) => return Err(e.into()), }; let file_url = match result["attachments"][0]["url"].as_str() { Some(url) => url, None => return Err("Failed to get file URL".into()), }; return Ok(file_url.to_string());
send_file
API 的主要 function
最後,再把這些 function 都包裝起來,提供一個簡單的 API。
-
定義 API,其中
Multipart
是從axum
中提取的資料,State
是我們在main.rs
中定義的共享資源,Json
是回傳。State
可以讓整個 App 公想的資源在這裡被使用。// src/discord/discord.rs pub async fn send_file( State(state): State<AppState>, mut multipart: Multipart, ) -> Json<serde_json::Value> { // ... }
-
先呼叫已經定義好的 function 取得 channel ID
let discord_info = &state.discord_info; // Extract channel id let channel_id = match get_channel_id( &discord_info.bot_api_key, &discord_info.guild_id, &discord_info.channel_name, ) .await { Ok(channel_id) => channel_id, Err(e) => { return (json!({"status": "error", "message": e.to_string()})).into(); } };
-
取得檔案,並上傳到 Discord 伺服器,回傳網址。如果成功,回傳
ok
,否則回傳error
。if let Some(field) = multipart.next_field().await.unwrap() { if let Some(file_name) = field.file_name().map(|x| x.to_string()) { let data = field.bytes().await.unwrap(); match upload(&file_name, &data, &channel_id, &discord_info.bot_api_key).await { Ok(s) => { return (json!({"status": "ok", "url": s})).into(); } Err(e) => { return (json!({"status": "error", "message": e.to_string()})).into(); } } } } return (json!({"status": "error"})).into();
記得要在 discord.rs
最上方加上我們會用到的資源。
use axum::{
extract::{Multipart, State},
response::Json,
};
use reqwest::multipart;
use serde_json::{json, Value};
use crate::AppState;
Deploy
接下來,我們可以用 shuttle deploy
部署這個服務,並且可以透過 curl
或是 Postman 來測試 API。
curl -X POST -F 'file=@/path/to/file.png' http://your-ip/discord
如果對 Shuttle 不熟悉,可以參考 Shuttle 官方文件。注意要在 deploy 前,先用 shuttle login
登入。可以參考 Shuttle 的 Quick Start。