Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
9 changes: 6 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# lol
.vscode
ShareMii-main/
storage
*copy.*
.logs/*
__pycache__/
*.pyc
venv/
7 changes: 7 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"python.languageServer": "Pylance",
"python.analysis.diagnosticSeverityOverrides": {
"reportMissingModuleSource": "none",
"reportShadowedImports": "none"
}
}
129 changes: 129 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# ShareMii Upload API

Public FastAPI API for sharing Mii files with optional metadata and up to 5 compressed preview images.

## Features

- Public endpoints (no API token)
- `.ltd` upload only
- Mii file size limit: `20 KB`
- Optional metadata on upload:
- `name`
- `author`
- `description`
- Optional image uploads (`images`, up to 5 files)
- Uploaded images are compressed to JPEG and stored in `storage/images`
- MySQL persistence for Mii metadata and image paths
- 6-character manual-friendly share codes (`file_id`)
- In-memory rate limiting by client hash for upload and download traffic

## Endpoints

- `GET /`
- Service info and rate-limit config.
- `POST /upload`
- Upload `.ltd` file with optional metadata and images.
- `GET /files/{file_id}`
- Download an `.ltd` file.
- `GET /images/{image_name}`
- Download one stored compressed image.
- `GET /miis/{file_id}`
- Retrieve metadata + image URLs for one Mii.

## Database

This project expects MySQL credentials in `config.json`:

```json
{
"mysql": {
"host": "localhost",
"port": 3306,
"user": "root",
"password": "password",
"database": "sharemii_upload"
}
}
```

On startup, the API initializes tables from `src/utils/sql/init_db.sql`.

## Run

```bash
pip install -r requirements.txt
python main.py
```

Default URLs:

```text
FastAPI: http://127.0.0.1:3000
Django (ShareMii UI): http://127.0.0.1:8000
```

## Curl

### 1) Upload only the Mii file

```bash
curl -X POST "http://127.0.0.1:3000/upload" \
-F "file=@sample.ltd"
```

### 2) Upload with metadata and up to 5 images

```bash
curl -X POST "http://127.0.0.1:3000/upload" \
-F "file=@sample.ltd" \
-F "name=My Mii" \
-F "author=MyUsername" \
-F "description=Created on Switch" \
-F "images=@preview1.png" \
-F "images=@preview2.jpg"
```

Example response:

```json
{
"file_id": "A7K2Q9",
"name": "My Mii",
"author": "MyUsername",
"description": "Created on Switch",
"original_filename": "sample.ltd",
"stored_filename": "A7K2Q9.ltd",
"size_bytes": 436,
"download_url": "/files/A7K2Q9",
"images": [
"/images/A7K2Q9_1.jpg",
"/images/A7K2Q9_2.jpg"
]
}
```

### 3) Download Mii file by code

```bash
curl "http://127.0.0.1:3000/files/A7K2Q9" --output downloaded.ltd
```

### 4) Get metadata by code

```bash
curl "http://127.0.0.1:3000/miis/A7K2Q9"
```

### 5) Download one preview image

```bash
curl "http://127.0.0.1:3000/images/A7K2Q9_1.jpg" --output preview.jpg
```

## Notes

- Rate limits are memory-based and reset when the server restarts.
- Invalid `.ltd` upload extension returns `400`.
- Invalid share code (`file_id`) format returns `400`.
- Missing file/image/metadata entries return `404`.
- Upload/download over rate budget returns `429`.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Dump your save data using **Checkpoint** or **JKSV** on your Switch, (or your ga

https://sharemii.qwkuns.me/

![Preview](assets/preview.png)
![Preview](src/webui/static/webui/assets/preview.png)

*Currently, `.ltd` files are compatible with the original python version of [ShareMii](https://github.com/Star-F0rce/ShareMii), created by [@Star-F0rce](https://github.com/Star-F0rce). As much as possible, we want to keep them compatible with newer versions of `.ltd` files.*

Expand Down
35 changes: 35 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"api": {
"host": "0.0.0.0",
"port": 3000,
"base_url": "",
"allowed_extension": ".ltd",
"max_file_size": 20480,
"max_images": 5,
"max_single_image_upload_size": 10485760,
"upload_window_seconds": 600,
"upload_byte_limit": 25600,
"download_window_seconds": 60,
"download_byte_limit": 256000,
"file_id_length": 6,
"file_id_alphabet": "23456789ABCDEFGHJKLMNPQRSTUVWXYZ",
"file_id_max_attempts": 50
},
"django": {
"host": "127.0.0.1",
"port": 8000,
"debug": true,
"secret_key": "dev-only-change-me",
"allowed_hosts": [
"127.0.0.1",
"localhost"
]
},
"mysql": {
"host": "localhost",
"port": 3306,
"user": "root",
"password": "password",
"database": "sharemii_upload"
}
}
Binary file added db.sqlite3
Binary file not shown.
41 changes: 41 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import os
import signal
import subprocess
import sys

from src.app.main import app
from src.utils.config import config


if __name__ == "__main__":
import uvicorn

api_config = config.get("api", {})
django_config = config.get("django", {})

api_host = str(api_config.get("host", "0.0.0.0"))
api_port = int(api_config.get("port", 3000))
django_host = str(django_config.get("host", "127.0.0.1"))
django_port = int(django_config.get("port", 8000))

django_env = os.environ.copy()
django_env["DJANGO_SETTINGS_MODULE"] = "src.sharemii_server.settings"
django_cmd = [
sys.executable,
"manage.py",
"runserver",
f"{django_host}:{django_port}",
"--noreload",
]

django_process = subprocess.Popen(django_cmd, env=django_env)

try:
uvicorn.run(app, host=api_host, port=api_port)
finally:
if django_process.poll() is None:
django_process.send_signal(signal.SIGTERM)
try:
django_process.wait(timeout=5)
except subprocess.TimeoutExpired:
django_process.kill()
20 changes: 20 additions & 0 deletions manage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env python
import os
import sys


def main() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "src.sharemii_server.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable?"
) from exc

execute_from_command_line(sys.argv)


if __name__ == "__main__":
main()
8 changes: 8 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
fastapi
uvicorn
django
requests
python-multipart
mysql-connector-python
Pillow
colorama
Empty file added src/__init__.py
Empty file.
1 change: 1 addition & 0 deletions src/app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from src.app.main import app
25 changes: 25 additions & 0 deletions src/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from fastapi import FastAPI

from src.app.routers.download import router as download_router
from src.app.routers.index import router as index_router
from src.app.routers.miis import router as miis_router
from src.app.routers.upload import router as upload_router
from src.app.settings import ensure_directories
from src.utils.sql import init as init_db

app = FastAPI(title="ShareMii Upload API")


@app.on_event("startup")
def startup() -> None:
ensure_directories()
try:
init_db()
except Exception as exc:
raise RuntimeError("Database initialization failed.") from exc


app.include_router(index_router)
app.include_router(upload_router)
app.include_router(download_router)
app.include_router(miis_router)
62 changes: 62 additions & 0 deletions src/app/rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import hashlib
import time
from collections import defaultdict, deque
from threading import Lock

from fastapi import HTTPException, Request


class ByteRateLimiter:
def __init__(self) -> None:
self._events: dict[str, deque[tuple[float, int]]] = defaultdict(deque)
self._lock = Lock()

def allow(self, key: str, cost: int, window_seconds: int, byte_limit: int) -> int | None:
now = time.monotonic()

with self._lock:
events = self._events[key]
while events and now - events[0][0] >= window_seconds:
events.popleft()

used_bytes = sum(size for _, size in events)
if used_bytes + cost > byte_limit:
if not events:
return window_seconds
return max(1, int(window_seconds - (now - events[0][0])))

events.append((now, cost))
return None


def get_client_key(request: Request) -> str:
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
client_host = forwarded_for or (request.client.host if request.client else "unknown")
return hashlib.sha256(client_host.encode("utf-8")).hexdigest()


def enforce_rate_limit(
limiter: ByteRateLimiter,
request: Request,
*,
cost: int,
window_seconds: int,
byte_limit: int,
detail: str,
) -> None:
retry_after = limiter.allow(
key=get_client_key(request),
cost=cost,
window_seconds=window_seconds,
byte_limit=byte_limit,
)
if retry_after is not None:
raise HTTPException(
status_code=429,
detail=detail,
headers={"Retry-After": str(retry_after)},
)


upload_limiter = ByteRateLimiter()
download_limiter = ByteRateLimiter()
Empty file added src/app/routers/__init__.py
Empty file.
Loading