Skip to content

Commit a50be1b

Browse files
committed
feat: support s3, r2, minio storage type
1 parent dbaf60a commit a50be1b

File tree

10 files changed

+192
-15
lines changed

10 files changed

+192
-15
lines changed

README.md

+28-6
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,36 @@ Response
8585
- set env `LOCAL_STORAGE_DOMAIN` to your deployment domain (e.g. `LOCAL_STORAGE_DOMAIN=http://blob-service.onrender.com`)
8686
- if you are using Docker, you need to mount volume `/static` to the host (e.g. `-v /path/to/static:/static`)
8787

88-
3. 🚀 AWS S3 Storage
88+
3. 🚀 AWS S3
8989
- [ ] **Payment Storage Cost**
9090
- [x] Support Direct URL Access
9191
- [x] China Mainland User Friendly
9292
- Config:
9393
- set env `STORAGE_TYPE` to `s3` (e.g. `STORAGE_TYPE=s3`)
94-
- set env `AWS_ACCESS_KEY_ID` to your AWS Access Key ID
95-
- set env `AWS_SECRET_ACCESS_KEY` to your AWS Secret Access Key
96-
- set env `AWS_S3_BUCKET` to your AWS S3 Bucket Name
97-
- set env `AWS_S3_REGION` to your AWS S3 Region
98-
- set env `AWS_S3_DOMAIN` to your AWS S3 Domain (e.g. `https://s3.amazonaws.com`)
94+
- set env `S3_ACCESS_KEY` to your AWS Access Key ID
95+
- set env `S3_SECRET_KEY` to your AWS Secret Access Key
96+
- set env `S3_BUCKET` to your AWS S3 Bucket Name
97+
- set env `S3_REGION` to your AWS S3 Region
98+
99+
4. 🔔 Cloudflare R2
100+
- [x] **Free Storage Quota ([10GB Storage](https://developers.cloudflare.com/r2/pricing/))**
101+
- [x] Support Direct URL Access
102+
- Config *(S3 Compatible)*:
103+
- set env `STORAGE_TYPE` to `s3` (e.g. `STORAGE_TYPE=s3`)
104+
- set env `S3_ACCESS_KEY` to your Cloudflare R2 Access Key ID
105+
- set env `S3_SECRET_KEY` to your Cloudflare R2 Secret Access Key
106+
- set env `S3_BUCKET` to your Cloudflare R2 Bucket Name
107+
- set env `S3_DOMAIN` to your Cloudflare R2 Domain Name (e.g. `https://<account-id>.r2.cloudflarestorage.com`)
108+
- set env `S3_DIRECT_URL_DOMAIN` to your Cloudflare R2 Public URL Access Domain Name ([Open Public URL Access](https://developers.cloudflare.com/r2/buckets/public-buckets/), e.g. `https://pub-xxx.r2.dev`)
109+
110+
5. 📦 Min IO
111+
- [x] **Self Hosted**
112+
- [x] Reliable & Flexible Storage
113+
- Config *(S3 Compatible)*:
114+
- set env `STORAGE_TYPE` to `s3` (e.g. `STORAGE_TYPE=s3`)
115+
- set env `S3_SIGN_VERSION` to `s3v4` (e.g. `S3_SIGN_VERSION=s3v4`)
116+
- set env `S3_ACCESS_KEY` to your Min IO Access Key ID
117+
- set env `S3_SECRET_KEY` to your Min IO Secret Access Key
118+
- set env `S3_BUCKET` to your Min IO Bucket Name
119+
- set env `S3_DOMAIN` to your Min IO Domain Name (e.g. `https://oss.example.com`)
120+
- *[Optional] If you are using CDN, you can set `S3_DIRECT_URL_DOMAIN` to your Min IO Public URL Access Domain Name (e.g. `https://cdn-hk.example.com`)*

config.py

+12
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,15 @@
1212

1313
STORAGE_TYPE = environ.get("STORAGE_TYPE", "common").lower() # Storage Type
1414
LOCAL_STORAGE_DOMAIN = environ.get("LOCAL_STORAGE_DOMAIN", "").rstrip("/") # Local Storage Domain
15+
16+
S3_BUCKET = environ.get("S3_BUCKET", "") # S3 Bucket
17+
S3_ACCESS_KEY = environ.get("S3_ACCESS_KEY", "") # S3 Access Key
18+
S3_SECRET_KEY = environ.get("S3_SECRET_KEY", "") # S3 Secret Key
19+
S3_REGION = environ.get("S3_REGION", "") # S3 Region
20+
S3_DOMAIN = environ.get("S3_DOMAIN", "").rstrip("/") # S3 Domain (Optional)
21+
S3_DIRECT_URL_DOMAIN = environ.get("S3_DIRECT_URL_DOMAIN", "").rstrip("/") # S3 Direct/Proxy URL Domain (Optional)
22+
S3_SIGN_VERSION = environ.get("S3_SIGN_VERSION", None) # S3 Sign Version
23+
24+
S3_API = S3_DOMAIN or f"https://{S3_BUCKET}.s3.{S3_REGION}.amazonaws.com" # S3 API
25+
S3_SPACE = S3_DIRECT_URL_DOMAIN or S3_API # S3 Image URL Domain
26+

favicon.ico

8.74 KB
Binary file not shown.

index.html

+78-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
<head>
44
<meta charset="UTF-8">
55
<title>Chat Nio Blob Service</title>
6+
<link rel="icon" href="/favicon.ico">
7+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
8+
<meta name="description" content="Chat Nio Blob Service">
9+
<meta name="author" content="Deeptrain Community">
10+
<meta name="keywords" content="Chat Nio Blob Service">
11+
<meta name="theme-color" content="#ffffff">
12+
<meta itemprop="image" content="/favicon.ico">
613
<link href="https://open.lightxi.com/fonts/Open-Sans" rel="stylesheet">
714
<style>
815
html, body {
@@ -13,6 +20,11 @@
1320
overflow-x: hidden;
1421
overflow-y: auto;
1522
background: hsl(var(--background));
23+
scrollbar-width: none;
24+
}
25+
26+
::-webkit-scrollbar {
27+
width: 0;
1628
}
1729

1830
* {
@@ -97,8 +109,16 @@
97109
color: hsl(var(--text-secondary));
98110
}
99111

112+
.drop-label {
113+
display: flex;
114+
flex-direction: row;
115+
align-items: center;
116+
justify-content: center;
117+
}
118+
100119
.output-text {
101120
width: 100%;
121+
min-height: 60px;
102122
border: 1px solid hsl(var(--border));
103123
border-radius: var(--radius);
104124
padding: 1rem;
@@ -108,13 +128,58 @@
108128
word-break: break-all;
109129
word-wrap: break-word;
110130
}
131+
132+
.loading {
133+
width: 0.75rem;
134+
height: 0.75rem;
135+
border-radius: 50%;
136+
border: 0.1rem solid hsl(var(--text-secondary));
137+
border-top-color: hsl(var(--background));
138+
animation: spin 1s linear infinite;
139+
margin-right: 0.5rem;
140+
display: inline-block;
141+
}
142+
143+
.link {
144+
position: absolute;
145+
top: 0;
146+
right: 0;
147+
margin: 1.5rem;
148+
cursor: pointer;
149+
outline: none;
150+
text-decoration: none;
151+
}
152+
153+
.link svg {
154+
width: 1.25rem;
155+
height: 1.25rem;
156+
color: hsl(var(--text));
157+
}
158+
159+
.hidden {
160+
display: none;
161+
}
162+
163+
@keyframes spin {
164+
to {
165+
transform: rotate(360deg);
166+
}
167+
}
111168
</style>
112169
</head>
113170
<body>
171+
<a href="https://github.com/Deeptrain-Community/chatnio-blob-service" target="_blank" class="link">
172+
<svg viewBox="0 0 438.549 438.549">
173+
<path fill="currentColor" d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"></path>
174+
</svg>
175+
</a>
114176
<div class="wrapper">
115177
<label for="file" class="drop-window">
116178
<input type="file" id="file" style="display: none">
117-
<span>Click to upload a file</span>
179+
<span class="drop-label">
180+
<span class="loading hidden"></span>
181+
Click to upload a file
182+
</span>
118183
</label>
119184
<div id="output" class="output-text"></div>
120185
</div>
@@ -140,15 +205,25 @@
140205
}
141206
}
142207

143-
input.addEventListener('change', async (e) => {
144-
const file = e.target.files[0];
208+
async function handler(file) {
209+
const loading = document.querySelector('.loading');
210+
loading.classList.remove('hidden');
145211
const content = await post(file);
146212
let raw = content.replace(/\\n/g, '\n');
147213
if (raw.startsWith("data:image") && raw.length > 1000) {
148214
raw = `${raw.slice(0, 1000)}... (truncated ${raw.length - 1000} characters)`;
149215
}
150216

151217
output.innerText = raw;
218+
loading.classList.add('hidden');
219+
}
220+
221+
input.addEventListener('change', async e => {
222+
const file = e.target.files[0];
223+
if (!file) return;
224+
225+
await handler(file);
226+
input.value = '';
152227
});
153228
</script>
154229
</body>

main.py

+5
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ def root():
2222
return FileResponse("index.html", media_type="text/html")
2323

2424

25+
@app.get("/favicon.ico")
26+
def favicon():
27+
return FileResponse("favicon.ico")
28+
29+
2530
@app.post("/upload")
2631
async def upload(file: UploadFile = File(...)):
2732
"""Accepts file and returns its contents."""

requirements.txt

+3
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,8 @@ python-pptx
88
openpyxl
99
xlrd
1010

11+
boto3
12+
botocore
1113
azure-cognitiveservices-speech
14+
1215
cryptography

store/local.py

+3-6
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
1-
from datetime import datetime
2-
31
from fastapi import UploadFile
4-
52
from config import LOCAL_STORAGE_DOMAIN
6-
from util import md5_encode
3+
from store.utils import store_filename
74

85

96
async def process_local(file: UploadFile) -> str:
10-
"""Process image and return its base64 url."""
7+
"""Process image and return its direct url."""
118

12-
filename = md5_encode(file.filename + datetime.now().isoformat())
9+
filename = store_filename(file.filename)
1310
path = f"static/{filename}"
1411

1512
with open(path, "wb") as f:

store/s3.py

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from fastapi import UploadFile
2+
import boto3
3+
from botocore.exceptions import ClientError, NoCredentialsError, PartialCredentialsError
4+
from botocore.client import Config
5+
6+
from store.utils import store_filename
7+
from config import (
8+
S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION, S3_API, S3_DOMAIN, S3_SPACE, S3_SIGN_VERSION,
9+
)
10+
11+
12+
def create_s3_client():
13+
config = Config(signature_version=S3_SIGN_VERSION) if S3_SIGN_VERSION else None
14+
if S3_DOMAIN and len(S3_DOMAIN) > 0:
15+
# Cloudflare R2 Storage
16+
return boto3.client(
17+
"s3",
18+
aws_access_key_id=S3_ACCESS_KEY,
19+
aws_secret_access_key=S3_SECRET_KEY,
20+
endpoint_url=S3_API,
21+
config=config,
22+
)
23+
24+
return boto3.client(
25+
"s3",
26+
region_name=S3_REGION,
27+
aws_access_key_id=S3_ACCESS_KEY,
28+
aws_secret_access_key=S3_SECRET_KEY,
29+
config=config,
30+
)
31+
32+
33+
async def process_s3(file: UploadFile) -> str:
34+
"""Process image and return its s3 url."""
35+
36+
filename = store_filename(file.filename)
37+
38+
try:
39+
client = create_s3_client()
40+
client.upload_fileobj(
41+
file.file,
42+
S3_BUCKET,
43+
filename,
44+
ExtraArgs={"ACL": "public-read"},
45+
)
46+
47+
return f"{S3_SPACE}/{filename}"
48+
except (NoCredentialsError, PartialCredentialsError) as e:
49+
raise ValueError(f"AWS credentials not found: {e}")
50+
except ClientError as e:
51+
raise ValueError(f"AWS S3 error: {e}")
52+
except Exception as e:
53+
raise ValueError(f"S3 error: {e}")

store/store.py

+2
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
from store.common import process_base64
66
from store.local import process_local
7+
from store.s3 import process_s3
78

89
IMAGE_HANDLERS = {
910
"common": process_base64,
1011
"local": process_local,
12+
"s3": process_s3,
1113
}
1214

1315

store/utils.py

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from datetime import datetime
2+
from util import md5_encode
3+
4+
5+
def store_filename(filename: str) -> str:
6+
"""Store filename."""
7+
suffix = filename.split(".")[-1] if "." in filename else "jpg"
8+
return md5_encode(filename + datetime.now().isoformat()) + "." + suffix

0 commit comments

Comments
 (0)