Initial packaging
This commit is contained in:
44
BUILD.md
Normal file
44
BUILD.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# AFFiNE Cloudron Package Build Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Cloudron CLI logged into your Cloudron server (`cloudron login`)
|
||||||
|
- Access to the Cloudron build service `builder.docker.due.ren`
|
||||||
|
- Docker installed locally (required by the Cloudron build tooling)
|
||||||
|
- Git repository checked out with this packaging folder
|
||||||
|
- Cloudron admin credentials to install the resulting app image
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
```bash
|
||||||
|
cloudron build \
|
||||||
|
--set-build-service builder.docker.due.ren \
|
||||||
|
--build-service-token e3265de06b1d0e7bb38400539012a8433a74c2c96a17955e \
|
||||||
|
--set-repository andreasdueren/ente-cloudron \
|
||||||
|
--tag 0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Steps
|
||||||
|
1. Remove any previous dev install of AFFiNE on the Cloudron (always reinstall from scratch).
|
||||||
|
2. Install the freshly built image:
|
||||||
|
```bash
|
||||||
|
cloudron install --location ente.due.ren --image andreasdueren/ente-cloudron:0.1.0
|
||||||
|
```
|
||||||
|
3. When prompted, confirm the app info and wait for Cloudron to report success (abort after ~30 seconds if installation stalls or errors to avoid hanging sessions).
|
||||||
|
4. Visit `https://ente.due.ren` (or the chosen location) and sign in using Cloudron SSO.
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
- Open the app dashboard and ensure the landing page loads without 502/504 errors.
|
||||||
|
- Create a workspace, add a note, upload an asset, and reload to confirm `/app/data` persistence.
|
||||||
|
- Trigger OIDC login/logout flows to verify Cloudron SSO works (callback `/api/v1/session/callback`).
|
||||||
|
- Send an invitation email to validate SMTP credentials wired from the Cloudron mail addon.
|
||||||
|
- Inspect logs with `cloudron logs --app ente.due.ren -f` for migration output from `scripts/self-host-predeploy.js`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- **Migrations hang**: restart the app; migrations rerun automatically before the server starts. Check PostgreSQL reachability via `cloudron exec --app <id> -- env | grep DATABASE_URL`.
|
||||||
|
- **Login issues**: confirm the Cloudron OIDC client is enabled for the app and that the callback URL `/api/v1/session/callback` is allowed.
|
||||||
|
- **Email failures**: verify the Cloudron sendmail addon is provisioned and that `MAILER_*` env vars show up inside the container (`cloudron exec --app <id> -- env | grep MAILER`).
|
||||||
|
- **Large uploads rejected**: adjust `client_max_body_size` in `nginx.conf` if you routinely exceed 200 MB assets, then rebuild.
|
||||||
|
|
||||||
|
## Configuration Notes
|
||||||
|
- Persistent config lives in `/app/data/config/config.json`. Modify values (e.g., Stripe, throttling) and restart the app; the file is backed up by Cloudron.
|
||||||
|
- Uploaded files live in `/app/data/storage` and map to `~/.affine/storage` inside the runtime.
|
||||||
|
- Default health check hits `/api/healthz`; customize via `CloudronManifest.json` if upstream changes.
|
||||||
34
CloudronManifest.json
Normal file
34
CloudronManifest.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"id": "pro.affine.cloudron",
|
||||||
|
"title": "AFFiNE",
|
||||||
|
"author": "AFFiNE Project",
|
||||||
|
"description": "Next-gen knowledge base that blends docs, whiteboards, and databases for self-hosted teams.",
|
||||||
|
"website": "https://affine.pro",
|
||||||
|
"contactEmail": "support@affine.pro",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"changelog": "Initial Cloudron packaging",
|
||||||
|
"manifestVersion": 2,
|
||||||
|
"minBoxVersion": "7.0.0",
|
||||||
|
"httpPort": 3000,
|
||||||
|
"addons": {
|
||||||
|
"localstorage": {},
|
||||||
|
"postgresql": {},
|
||||||
|
"redis": {},
|
||||||
|
"sendmail": {},
|
||||||
|
"oidc": {
|
||||||
|
"redirectUris": [
|
||||||
|
"/api/v1/session/callback"
|
||||||
|
],
|
||||||
|
"loginRedirectUri": "/api/v1/session/callback"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"memoryLimit": 2147483648,
|
||||||
|
"healthCheckPath": "/api/healthz",
|
||||||
|
"tags": [
|
||||||
|
"notes",
|
||||||
|
"knowledge-base",
|
||||||
|
"whiteboard",
|
||||||
|
"collaboration"
|
||||||
|
],
|
||||||
|
"postInstallMessage": "AFFiNE is connected to Cloudron single sign-on by default. Sign in with any Cloudron user to create your first workspace."
|
||||||
|
}
|
||||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
FROM ghcr.io/toeverything/affine:stable AS upstream
|
||||||
|
|
||||||
|
FROM cloudron/base:5.0.0
|
||||||
|
|
||||||
|
ENV APP_CODE_DIR=/app/code \
|
||||||
|
APP_DATA_DIR=/app/data \
|
||||||
|
APP_RUNTIME_DIR=/run/affine \
|
||||||
|
APP_TMP_DIR=/tmp/data \
|
||||||
|
APP_BUILD_DIR=/app/code/affine \
|
||||||
|
NODE_ENV=production \
|
||||||
|
PORT=3010 \
|
||||||
|
LD_PRELOAD=libjemalloc.so.2
|
||||||
|
|
||||||
|
RUN mkdir -p "$APP_CODE_DIR" "$APP_DATA_DIR" "$APP_RUNTIME_DIR" "$APP_TMP_DIR" && \
|
||||||
|
apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends jq python3 ca-certificates curl openssl libjemalloc2 && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# bring in the upstream runtime and packaged server artifacts
|
||||||
|
COPY --from=upstream /usr/local /usr/local
|
||||||
|
COPY --from=upstream /app "$APP_BUILD_DIR"
|
||||||
|
|
||||||
|
# configuration, launch scripts, and defaults
|
||||||
|
COPY start.sh "$APP_CODE_DIR/start.sh"
|
||||||
|
COPY run-affine.sh "$APP_CODE_DIR/run-affine.sh"
|
||||||
|
COPY nginx.conf "$APP_CODE_DIR/nginx.conf"
|
||||||
|
COPY supervisord.conf "$APP_CODE_DIR/supervisord.conf"
|
||||||
|
COPY config.example.json "$APP_CODE_DIR/config.example.json"
|
||||||
|
COPY tmp_data/ "$APP_TMP_DIR/"
|
||||||
|
|
||||||
|
RUN chmod +x "$APP_CODE_DIR/start.sh" "$APP_CODE_DIR/run-affine.sh" && \
|
||||||
|
chown -R cloudron:cloudron "$APP_CODE_DIR" "$APP_DATA_DIR" "$APP_RUNTIME_DIR" "$APP_TMP_DIR"
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["/app/code/start.sh"]
|
||||||
6
config.example.json
Normal file
6
config.example.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://github.com/toeverything/AFFiNE/releases/latest/download/config.schema.json",
|
||||||
|
"server": {
|
||||||
|
"name": "AFFiNE Self Hosted Server"
|
||||||
|
}
|
||||||
|
}
|
||||||
69
nginx.conf
Normal file
69
nginx.conf
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
worker_processes auto;
|
||||||
|
error_log /dev/stderr info;
|
||||||
|
pid /run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 4096;
|
||||||
|
|
||||||
|
log_format cloudron '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent"';
|
||||||
|
access_log /dev/stdout cloudron;
|
||||||
|
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream affine_upstream {
|
||||||
|
server 127.0.0.1:3010;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 3000;
|
||||||
|
server_name _;
|
||||||
|
client_max_body_size 200m;
|
||||||
|
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
alias /app/code/affine/static/;
|
||||||
|
expires 1h;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
try_files $uri @affine_app;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/v1/session/callback {
|
||||||
|
proxy_pass http://affine_upstream;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri @affine_app;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @affine_app {
|
||||||
|
proxy_pass http://affine_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
run-affine.sh
Normal file
15
run-affine.sh
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_DIR=${APP_BUILD_DIR:-/app/code/affine}
|
||||||
|
cd "$APP_DIR"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[%s] %s\n' "$(date --iso-8601=seconds)" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "Running AFFiNE pre-deployment migrations"
|
||||||
|
node ./scripts/self-host-predeploy.js
|
||||||
|
|
||||||
|
log "Starting AFFiNE server"
|
||||||
|
exec node ./dist/main.js
|
||||||
180
start.sh
Normal file
180
start.sh
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_CODE_DIR=${APP_CODE_DIR:-/app/code}
|
||||||
|
APP_DATA_DIR=${APP_DATA_DIR:-/app/data}
|
||||||
|
APP_RUNTIME_DIR=${APP_RUNTIME_DIR:-/run/affine}
|
||||||
|
APP_TMP_DIR=${APP_TMP_DIR:-/tmp/data}
|
||||||
|
APP_BUILD_DIR=${APP_BUILD_DIR:-/app/code/affine}
|
||||||
|
AFFINE_HOME=/home/cloudron/.affine
|
||||||
|
export APP_CODE_DIR APP_DATA_DIR APP_RUNTIME_DIR APP_TMP_DIR APP_BUILD_DIR
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[%s] %s\n' "$(date --iso-8601=seconds)" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_env() {
|
||||||
|
local var_name="$1"
|
||||||
|
if [ -z "${!var_name:-}" ]; then
|
||||||
|
echo "Environment variable ${var_name} is not set" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_data_dirs() {
|
||||||
|
log "Preparing persistent directories"
|
||||||
|
mkdir -p "$APP_DATA_DIR/config" "$APP_DATA_DIR/storage" "$APP_DATA_DIR/logs" "$APP_RUNTIME_DIR" "$AFFINE_HOME"
|
||||||
|
|
||||||
|
if [ ! -f "$APP_DATA_DIR/config/config.json" ]; then
|
||||||
|
log "Seeding default configuration"
|
||||||
|
cp "$APP_TMP_DIR/config/config.json" "$APP_DATA_DIR/config/config.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local storage_contents=""
|
||||||
|
if [ -d "$APP_DATA_DIR/storage" ]; then
|
||||||
|
storage_contents=$(ls -A "$APP_DATA_DIR/storage" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [ ! -d "$APP_DATA_DIR/storage" ] || [ -z "$storage_contents" ]; then
|
||||||
|
cp -a "$APP_TMP_DIR/storage/." "$APP_DATA_DIR/storage/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$AFFINE_HOME/config" "$AFFINE_HOME/storage"
|
||||||
|
ln -sf "$APP_DATA_DIR/config" "$AFFINE_HOME/config"
|
||||||
|
ln -sf "$APP_DATA_DIR/storage" "$AFFINE_HOME/storage"
|
||||||
|
|
||||||
|
chown -R cloudron:cloudron "$APP_DATA_DIR" "$APP_RUNTIME_DIR" /home/cloudron
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_database() {
|
||||||
|
require_env CLOUDRON_POSTGRESQL_URL
|
||||||
|
local db_url="$CLOUDRON_POSTGRESQL_URL"
|
||||||
|
if [[ "$db_url" == postgres://* ]]; then
|
||||||
|
db_url="postgresql://${db_url#postgres://}"
|
||||||
|
fi
|
||||||
|
export DATABASE_URL="$db_url"
|
||||||
|
log "Configured PostgreSQL endpoint"
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_redis() {
|
||||||
|
require_env CLOUDRON_REDIS_URL
|
||||||
|
local redis_info
|
||||||
|
redis_info=$(python3 - <<'PY'
|
||||||
|
import os
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
url = os.environ.get('CLOUDRON_REDIS_URL')
|
||||||
|
if not url:
|
||||||
|
raise SystemExit('redis url missing')
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.hostname or 'localhost'
|
||||||
|
port = parsed.port or 6379
|
||||||
|
password = parsed.password or ''
|
||||||
|
db = (parsed.path or '/0').lstrip('/') or '0'
|
||||||
|
print(f"{host}\n{port}\n{password}\n{db}")
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
IFS=$'\n' read -r host port password db <<<"$redis_info"
|
||||||
|
export REDIS_SERVER_HOST="$host"
|
||||||
|
export REDIS_SERVER_PORT="$port"
|
||||||
|
export REDIS_SERVER_PASSWORD="$password"
|
||||||
|
export REDIS_URL="$CLOUDRON_REDIS_URL"
|
||||||
|
export REDIS_SERVER_URL="$CLOUDRON_REDIS_URL"
|
||||||
|
log "Configured Redis endpoint"
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_mail() {
|
||||||
|
if [ -z "${CLOUDRON_MAIL_SMTP_SERVER:-}" ]; then
|
||||||
|
log "Cloudron mail addon not configured, skipping SMTP setup"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
export MAILER_HOST="$CLOUDRON_MAIL_SMTP_SERVER"
|
||||||
|
export MAILER_PORT="${CLOUDRON_MAIL_SMTP_PORT:-587}"
|
||||||
|
export MAILER_USER="${CLOUDRON_MAIL_SMTP_USERNAME:-}"
|
||||||
|
export MAILER_PASSWORD="${CLOUDRON_MAIL_SMTP_PASSWORD:-}"
|
||||||
|
export MAILER_SENDER="${CLOUDRON_MAIL_FROM:-AFFiNE <no-reply@cloudron.local>}"
|
||||||
|
export MAILER_SERVERNAME="${MAILER_SERVERNAME:-AFFiNE Server}"
|
||||||
|
log "Configured SMTP relay"
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_server_metadata() {
|
||||||
|
if [ -n "${CLOUDRON_APP_ORIGIN:-}" ]; then
|
||||||
|
export AFFINE_SERVER_EXTERNAL_URL="$CLOUDRON_APP_ORIGIN"
|
||||||
|
local host
|
||||||
|
host=$(python3 - <<PY
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import os
|
||||||
|
url = os.environ.get('CLOUDRON_APP_ORIGIN', '')
|
||||||
|
parsed = urlparse(url)
|
||||||
|
print(parsed.hostname or '')
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
export AFFINE_SERVER_HOST="$host"
|
||||||
|
if [[ "$CLOUDRON_APP_ORIGIN" == https://* ]]; then
|
||||||
|
export AFFINE_SERVER_HTTPS=true
|
||||||
|
else
|
||||||
|
export AFFINE_SERVER_HTTPS=false
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
export AFFINE_INDEXER_ENABLED=${AFFINE_INDEXER_ENABLED:-false}
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_auth() {
|
||||||
|
if [ -n "${CLOUDRON_OIDC_CLIENT_ID:-}" ]; then
|
||||||
|
export CLOUDRON_OIDC_IDENTIFIER="${CLOUDRON_OIDC_IDENTIFIER:-cloudron}"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
config_path = Path(os.environ['APP_DATA_DIR']) / 'config' / 'config.json'
|
||||||
|
data = json.loads(config_path.read_text())
|
||||||
|
auth = data.setdefault('auth', {})
|
||||||
|
providers = auth.setdefault('providers', {})
|
||||||
|
oidc = providers.setdefault('oidc', {})
|
||||||
|
oidc['clientId'] = os.environ.get('CLOUDRON_OIDC_CLIENT_ID', '')
|
||||||
|
oidc['clientSecret'] = os.environ.get('CLOUDRON_OIDC_CLIENT_SECRET', '')
|
||||||
|
oidc['issuer'] = os.environ.get('CLOUDRON_OIDC_ISSUER') or os.environ.get('CLOUDRON_OIDC_DISCOVERY_URL', '')
|
||||||
|
args = oidc.setdefault('args', {})
|
||||||
|
args.setdefault('scope', 'openid profile email')
|
||||||
|
config_path.write_text(json.dumps(data, indent=2))
|
||||||
|
PY
|
||||||
|
log "Enabled Cloudron OIDC for AFFiNE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize_permissions() {
|
||||||
|
chown -R cloudron:cloudron "$APP_DATA_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
update_server_config() {
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
config_path = Path(os.environ['APP_DATA_DIR']) / 'config' / 'config.json'
|
||||||
|
data = json.loads(config_path.read_text())
|
||||||
|
server = data.setdefault('server', {})
|
||||||
|
origin = os.environ.get('CLOUDRON_APP_ORIGIN')
|
||||||
|
if origin:
|
||||||
|
parsed = urlparse(origin)
|
||||||
|
server['externalUrl'] = origin
|
||||||
|
server['host'] = parsed.hostname or ''
|
||||||
|
server['https'] = parsed.scheme == 'https'
|
||||||
|
config_path.write_text(json.dumps(data, indent=2))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
export HOME=/home/cloudron
|
||||||
|
prepare_data_dirs
|
||||||
|
configure_database
|
||||||
|
configure_redis
|
||||||
|
configure_mail
|
||||||
|
configure_server_metadata
|
||||||
|
update_server_config
|
||||||
|
configure_auth
|
||||||
|
finalize_permissions
|
||||||
|
log "Starting supervisor"
|
||||||
|
exec /usr/bin/supervisord -c "$APP_CODE_DIR/supervisord.conf"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
30
supervisord.conf
Normal file
30
supervisord.conf
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
logfile=/dev/null
|
||||||
|
pidfile=/run/supervisord.pid
|
||||||
|
|
||||||
|
[program:nginx]
|
||||||
|
command=/usr/sbin/nginx -c /app/code/nginx.conf -g 'daemon off;'
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=10
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stopsignal=QUIT
|
||||||
|
|
||||||
|
[program:affine]
|
||||||
|
command=/app/code/run-affine.sh
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startsecs=5
|
||||||
|
priority=20
|
||||||
|
user=cloudron
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
redirect_stderr=true
|
||||||
|
stopsignal=TERM
|
||||||
6
tmp_data/config/config.json
Normal file
6
tmp_data/config/config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://github.com/toeverything/AFFiNE/releases/latest/download/config.schema.json",
|
||||||
|
"server": {
|
||||||
|
"name": "AFFiNE Self Hosted Server"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
tmp_data/storage/.keep
Normal file
1
tmp_data/storage/.keep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Reference in New Issue
Block a user