Compare commits

...

11 Commits

Author SHA1 Message Date
Codex
63a730a940 Enhance mail setup and team plan defaults 2025-11-14 08:05:02 -06:00
Codex
17bb921941 Add icon and wire Cloudron email addon 2025-11-13 21:09:52 -06:00
Codex
21a9ac6c86 Revert to python config update 2025-11-12 12:03:46 -06:00
Codex
1f3505f132 Update redis config via jq 2025-11-12 11:51:08 -06:00
Codex
b8d38f52a2 Override redis creds with addon provided vars 2025-11-12 11:38:27 -06:00
Codex
294a84a414 Leverage Cloudron redis addon env vars 2025-11-12 11:23:05 -06:00
Codex
0bda151abe Guard redis config defaults 2025-11-12 11:09:54 -06:00
Codex
0877db4c36 Persist redis config into config.json 2025-11-12 10:56:39 -06:00
Codex
aee56753d5 Record redis vars from local values 2025-11-12 10:44:10 -06:00
Codex
e758749bd6 Record env vars incrementally 2025-11-12 10:30:45 -06:00
Codex
7a253e0f0c Use printenv when writing runtime env 2025-11-12 10:17:13 -06:00
7 changed files with 306 additions and 66 deletions

View File

@@ -12,29 +12,29 @@
cloudron build \
--set-build-service builder.docker.due.ren \
--build-service-token e3265de06b1d0e7bb38400539012a8433a74c2c96a17955e \
--set-repository andreasdueren/ente-cloudron \
--tag 0.1.0
--set-repository andreasdueren/affine-cloudron \
--tag 0.25.3
```
## 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
cloudron install --location affine.due.ren --image andreasdueren/affine-cloudron:0.25.3
```
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.
4. Visit `https://affine.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`).
- Trigger OIDC login/logout flows to verify Cloudron SSO works (callback `/oauth/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`.
- Inspect logs with `cloudron logs --app affine.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.
- **Login issues**: confirm the Cloudron OIDC client is enabled for the app and that the callback URL `/oauth/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 200MB assets, then rebuild.

View File

@@ -5,8 +5,9 @@
"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.6",
"changelog": "Initial Cloudron packaging",
"version": "0.25.3",
"changelog": "Upgrade upstream AFFiNE runtime to v0.25.3 and keep Cloudron OIDC wiring",
"icon": "file://icon.png",
"manifestVersion": 2,
"minBoxVersion": "7.0.0",
"httpPort": 3000,
@@ -16,10 +17,9 @@
"redis": {},
"sendmail": {},
"oidc": {
"redirectUris": [
"/api/v1/session/callback"
],
"loginRedirectUri": "/api/v1/session/callback"
"loginRedirectUri": "/oauth/callback",
"logoutRedirectUri": "/",
"tokenSignatureAlgorithm": "RS256"
}
},
"memoryLimit": 2147483648,

View File

@@ -1,4 +1,5 @@
FROM ghcr.io/toeverything/affine:stable AS upstream
ARG AFFINE_VERSION=stable
FROM ghcr.io/toeverything/affine:${AFFINE_VERSION} AS upstream
FROM cloudron/base:5.0.0
@@ -30,7 +31,8 @@ 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"
chown cloudron:cloudron "$APP_CODE_DIR/start.sh" "$APP_CODE_DIR/run-affine.sh" && \
chown -R cloudron:cloudron "$APP_DATA_DIR" "$APP_RUNTIME_DIR" "$APP_TMP_DIR"
EXPOSE 3000
CMD ["/app/code/start.sh"]

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -17,6 +17,8 @@ http {
client_body_temp_path /run/nginx/body;
proxy_temp_path /run/nginx/proxy;
fastcgi_temp_path /run/nginx/fastcgi;
uwsgi_temp_path /run/nginx/uwsgi;
scgi_temp_path /run/nginx/scgi;
log_format cloudron '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
@@ -36,7 +38,7 @@ http {
server {
listen 3000;
server_name _;
client_max_body_size 200m;
client_max_body_size 600m;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

View File

@@ -99,9 +99,138 @@ ensure_runtime_envs() {
ensure_server_env
}
patch_upload_limits() {
local target="$APP_DIR/dist/main.js"
if [ ! -f "$target" ]; then
return
fi
python3 - "$target" <<'PY'
import sys
from pathlib import Path
target = Path(sys.argv[1])
data = target.read_text()
updated = data
updated = updated.replace("limit: 100 * OneMB", "limit: 512 * OneMB", 1)
updated = updated.replace("maxFileSize: 100 * OneMB", "maxFileSize: 512 * OneMB", 1)
if updated != data:
target.write_text(updated)
PY
}
grant_team_plan_features() {
log "Ensuring self-hosted workspaces have team plan features"
node <<'NODE'
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const feature = await prisma.feature.findFirst({
where: { name: 'team_plan_v1' },
orderBy: { deprecatedVersion: 'desc' },
});
if (!feature) {
console.warn('[team-plan] Feature record not found, skipping');
return;
}
const workspaces = await prisma.workspace.findMany({
select: { id: true },
});
for (const { id } of workspaces) {
const existing = await prisma.workspaceFeature.findFirst({
where: {
workspaceId: id,
name: 'team_plan_v1',
activated: true,
},
});
if (existing) continue;
await prisma.workspaceFeature.create({
data: {
workspaceId: id,
featureId: feature.id,
name: 'team_plan_v1',
type: feature.deprecatedType ?? 1,
configs: feature.configs,
reason: 'selfhost-default',
activated: true,
},
});
console.log(`[team-plan] Granted team plan to workspace ${id}`);
}
await prisma.$executeRawUnsafe(`
CREATE OR REPLACE FUNCTION grant_team_plan_feature()
RETURNS TRIGGER AS $$
DECLARE
feature_id integer;
feature_type integer;
feature_configs jsonb;
BEGIN
SELECT id, type, configs
INTO feature_id, feature_type, feature_configs
FROM features
WHERE feature = 'team_plan_v1'
ORDER BY version DESC
LIMIT 1;
IF feature_id IS NULL THEN
RETURN NEW;
END IF;
INSERT INTO workspace_features
(workspace_id, feature_id, name, type, configs, reason, activated, created_at)
SELECT
NEW.id,
feature_id,
'team_plan_v1',
feature_type,
feature_configs,
'selfhost-default',
TRUE,
NOW()
WHERE NOT EXISTS (
SELECT 1 FROM workspace_features
WHERE workspace_id = NEW.id AND name = 'team_plan_v1' AND activated = TRUE
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await prisma.$executeRawUnsafe(`
DO $$ BEGIN
CREATE TRIGGER grant_team_plan_feature_trigger
AFTER INSERT ON workspaces
FOR EACH ROW
EXECUTE FUNCTION grant_team_plan_feature();
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
}
main()
.then(() => console.log('[team-plan] Workspace quota ensured'))
.catch(err => {
console.error('[team-plan] Failed to grant features', err);
})
.finally(async () => {
await prisma.$disconnect();
});
NODE
}
log "Running AFFiNE pre-deployment migrations"
ensure_runtime_envs
node ./scripts/self-host-predeploy.js
patch_upload_limits
grant_team_plan_features
log "Starting AFFiNE server"
exec node ./dist/main.js

207
start.sh
View File

@@ -15,6 +15,14 @@ log() {
printf '[%s] %s\n' "$(date --iso-8601=seconds)" "$*"
}
record_env_var() {
local name="$1"
local value="$2"
if [ -n "$value" ]; then
printf '%s=%q\n' "$name" "$value" >> "$ENV_EXPORT_FILE"
fi
}
require_env() {
local var_name="$1"
if [ -z "${!var_name:-}" ]; then
@@ -26,7 +34,8 @@ require_env() {
prepare_data_dirs() {
log "Preparing persistent directories"
mkdir -p "$APP_DATA_DIR/config" "$APP_DATA_DIR/storage" "$APP_DATA_DIR/logs" "$APP_RUNTIME_DIR" "$APP_HOME_DIR" "$AFFINE_HOME"
mkdir -p /run/nginx/body /run/nginx/proxy /run/nginx/fastcgi
mkdir -p /run/nginx/body /run/nginx/proxy /run/nginx/fastcgi /run/nginx/uwsgi /run/nginx/scgi
: > "$ENV_EXPORT_FILE"
if [ ! -f "$APP_DATA_DIR/config/config.json" ]; then
log "Seeding default configuration"
@@ -48,6 +57,19 @@ prepare_data_dirs() {
chown -R cloudron:cloudron "$APP_DATA_DIR" "$APP_RUNTIME_DIR" "$APP_HOME_DIR"
}
prepare_runtime_build_dir() {
local source_dir="$APP_BUILD_DIR"
local runtime_build_dir="$APP_RUNTIME_DIR/affine-build"
log "Syncing AFFiNE runtime into $runtime_build_dir"
rm -rf "$runtime_build_dir"
mkdir -p "$runtime_build_dir"
cp -a "$source_dir/." "$runtime_build_dir/"
chown -R cloudron:cloudron "$runtime_build_dir"
APP_BUILD_DIR="$runtime_build_dir"
export APP_BUILD_DIR
record_env_var APP_BUILD_DIR "$APP_BUILD_DIR"
}
configure_database() {
require_env CLOUDRON_POSTGRESQL_URL
local db_url="$CLOUDRON_POSTGRESQL_URL"
@@ -55,6 +77,7 @@ configure_database() {
db_url="postgresql://${db_url#postgres://}"
fi
export DATABASE_URL="$db_url"
record_env_var DATABASE_URL "$db_url"
log "Configured PostgreSQL endpoint"
}
@@ -65,18 +88,44 @@ configure_redis() {
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'
username = parsed.username or ''
parsed = urlparse(url) if url else None
host = os.environ.get('CLOUDRON_REDIS_HOST')
port = os.environ.get('CLOUDRON_REDIS_PORT')
password = os.environ.get('CLOUDRON_REDIS_PASSWORD')
username = os.environ.get('CLOUDRON_REDIS_USERNAME')
db = os.environ.get('CLOUDRON_REDIS_DB')
if not host and parsed:
host = parsed.hostname or 'localhost'
if not port and parsed:
port = parsed.port or 6379
if not password and parsed:
password = parsed.password or ''
if not db and parsed:
db = (parsed.path or '/0').lstrip('/') or '0'
if username is None:
username = parsed.username if parsed and parsed.username else 'default'
host = host or 'localhost'
port = port or 6379
password = password or ''
db = db or '0'
print(f"{host}\n{port}\n{password}\n{db}\n{username}")
PY
)
IFS=$'\n' read -r host port password db username <<<"$redis_info"
if [ -n "${CLOUDRON_REDIS_HOST:-}" ]; then
host="$CLOUDRON_REDIS_HOST"
fi
if [ -n "${CLOUDRON_REDIS_PORT:-}" ]; then
port="$CLOUDRON_REDIS_PORT"
fi
if [ -n "${CLOUDRON_REDIS_PASSWORD:-}" ]; then
password="$CLOUDRON_REDIS_PASSWORD"
fi
if [ -n "${CLOUDRON_REDIS_USERNAME:-}" ]; then
username="$CLOUDRON_REDIS_USERNAME"
elif [ -z "$username" ]; then
username="default"
fi
export REDIS_SERVER_HOST="$host"
export REDIS_SERVER_PORT="$port"
export REDIS_SERVER_PASSWORD="$password"
@@ -84,20 +133,79 @@ PY
export REDIS_SERVER_USERNAME="$username"
export REDIS_URL="$CLOUDRON_REDIS_URL"
export REDIS_SERVER_URL="$CLOUDRON_REDIS_URL"
record_env_var REDIS_SERVER_HOST "$host"
record_env_var REDIS_SERVER_PORT "$port"
record_env_var REDIS_SERVER_PASSWORD "$password"
record_env_var REDIS_SERVER_DATABASE "$db"
record_env_var REDIS_SERVER_USERNAME "$username"
record_env_var REDIS_URL "$CLOUDRON_REDIS_URL"
record_env_var REDIS_SERVER_URL "$CLOUDRON_REDIS_URL"
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())
redis = data.setdefault('redis', {})
redis['host'] = os.environ.get('REDIS_SERVER_HOST', '')
redis['port'] = int(os.environ.get('REDIS_SERVER_PORT') or 6379)
redis['password'] = os.environ.get('REDIS_SERVER_PASSWORD', '')
redis['username'] = os.environ.get('REDIS_SERVER_USERNAME', '')
redis['db'] = int(os.environ.get('REDIS_SERVER_DATABASE') or 0)
config_path.write_text(json.dumps(data, indent=2))
PY
log "Configured Redis endpoint"
}
configure_mail() {
if [ -z "${CLOUDRON_MAIL_SMTP_SERVER:-}" ]; then
log "Cloudron mail addon not configured, skipping SMTP setup"
local host=""
local port=""
local user=""
local password=""
local sender=""
local ignore_tls="false"
if [ -n "${CLOUDRON_EMAIL_SMTP_SERVER:-}" ]; then
host="$CLOUDRON_EMAIL_SMTP_SERVER"
port="${CLOUDRON_EMAIL_SMTPS_PORT:-${CLOUDRON_EMAIL_SMTP_PORT:-587}}"
user="${CLOUDRON_EMAIL_SMTP_USERNAME:-}"
password="${CLOUDRON_EMAIL_SMTP_PASSWORD:-}"
sender="${CLOUDRON_EMAIL_FROM:-AFFiNE <no-reply@cloudron.local>}"
ignore_tls="${MAILER_IGNORE_TLS:-true}"
log "Configuring SMTP using Cloudron email addon"
elif [ -n "${CLOUDRON_MAIL_SMTP_SERVER:-}" ]; then
host="$CLOUDRON_MAIL_SMTP_SERVER"
port="${CLOUDRON_MAIL_SMTP_PORT:-587}"
user="${CLOUDRON_MAIL_SMTP_USERNAME:-}"
password="${CLOUDRON_MAIL_SMTP_PASSWORD:-}"
sender="${CLOUDRON_MAIL_FROM:-AFFiNE <no-reply@cloudron.local>}"
ignore_tls="${MAILER_IGNORE_TLS:-false}"
if [ -n "${CLOUDRON_MAIL_SMTP_SECURE:-}" ]; then
case "${CLOUDRON_MAIL_SMTP_SECURE,,}" in
true|1|yes) port="${CLOUDRON_MAIL_SMTP_PORT:-465}" ;;
esac
fi
log "Configuring SMTP using Cloudron sendmail addon"
else
log "Cloudron mail/email 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_HOST="$host"
export MAILER_PORT="$port"
export MAILER_USER="$user"
export MAILER_PASSWORD="$password"
export MAILER_SENDER="${sender:-AFFiNE <no-reply@cloudron.local>}"
export MAILER_SERVERNAME="${MAILER_SERVERNAME:-AFFiNE Server}"
export MAILER_IGNORE_TLS="$ignore_tls"
record_env_var MAILER_HOST "$MAILER_HOST"
record_env_var MAILER_PORT "$MAILER_PORT"
record_env_var MAILER_USER "$MAILER_USER"
record_env_var MAILER_PASSWORD "$MAILER_PASSWORD"
record_env_var MAILER_SENDER "$MAILER_SENDER"
record_env_var MAILER_SERVERNAME "$MAILER_SERVERNAME"
record_env_var MAILER_IGNORE_TLS "$MAILER_IGNORE_TLS"
log "Configured SMTP relay"
}
@@ -121,37 +229,10 @@ PY
fi
fi
export AFFINE_INDEXER_ENABLED=${AFFINE_INDEXER_ENABLED:-false}
}
write_runtime_env() {
: > "$ENV_EXPORT_FILE"
local vars=(
DATABASE_URL
REDIS_SERVER_HOST
REDIS_SERVER_PORT
REDIS_SERVER_PASSWORD
REDIS_SERVER_DATABASE
REDIS_SERVER_USERNAME
REDIS_URL
REDIS_SERVER_URL
MAILER_HOST
MAILER_PORT
MAILER_USER
MAILER_PASSWORD
MAILER_SENDER
MAILER_SERVERNAME
AFFINE_SERVER_EXTERNAL_URL
AFFINE_SERVER_HOST
AFFINE_SERVER_HTTPS
AFFINE_INDEXER_ENABLED
)
local var value
for var in "${vars[@]}"; do
value="${!var-}"
if [ -n "$value" ]; then
printf '%s=%q\n' "$var" "$value" >> "$ENV_EXPORT_FILE"
fi
done
record_env_var AFFINE_SERVER_EXTERNAL_URL "${AFFINE_SERVER_EXTERNAL_URL:-}"
record_env_var AFFINE_SERVER_HOST "${AFFINE_SERVER_HOST:-}"
record_env_var AFFINE_SERVER_HTTPS "${AFFINE_SERVER_HTTPS:-}"
record_env_var AFFINE_INDEXER_ENABLED "$AFFINE_INDEXER_ENABLED"
}
configure_auth() {
@@ -160,6 +241,7 @@ configure_auth() {
python3 - <<'PY'
import json
import os
import re
from pathlib import Path
config_path = Path(os.environ['APP_DATA_DIR']) / 'config' / 'config.json'
data = json.loads(config_path.read_text())
@@ -168,9 +250,34 @@ 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', '')
issuer = os.environ.get('CLOUDRON_OIDC_ISSUER') or ''
discovery = os.environ.get('CLOUDRON_OIDC_DISCOVERY_URL') or ''
resolved_issuer = issuer
if not resolved_issuer and discovery:
resolved_issuer = re.sub(r'/\.well-known.*$', '', discovery)
if not resolved_issuer:
resolved_issuer = discovery
oidc['issuer'] = resolved_issuer
default_scope = os.environ.get('AFFINE_OIDC_SCOPE', 'openid profile email')
default_claims = {
'claim_id': os.environ.get('AFFINE_OIDC_CLAIM_ID', 'preferred_username'),
'claim_email': os.environ.get('AFFINE_OIDC_CLAIM_EMAIL', 'email'),
'claim_name': os.environ.get('AFFINE_OIDC_CLAIM_NAME', 'name'),
}
args = oidc.setdefault('args', {})
args.setdefault('scope', 'openid profile email')
args['scope'] = default_scope
for key, value in default_claims.items():
args.setdefault(key, value)
oauth = data.setdefault('oauth', {})
oauth_providers = oauth.setdefault('providers', {})
oauth_oidc = oauth_providers.setdefault('oidc', {})
oauth_oidc['clientId'] = oidc['clientId']
oauth_oidc['clientSecret'] = oidc['clientSecret']
oauth_oidc['issuer'] = resolved_issuer
oauth_args = oauth_oidc.setdefault('args', {})
oauth_args['scope'] = default_scope
for key, value in default_claims.items():
oauth_args.setdefault(key, value)
config_path.write_text(json.dumps(data, indent=2))
PY
log "Enabled Cloudron OIDC for AFFiNE"
@@ -199,13 +306,13 @@ PY
main() {
export HOME="$APP_HOME_DIR"
prepare_data_dirs
prepare_runtime_build_dir
configure_database
configure_redis
configure_mail
configure_server_metadata
update_server_config
configure_auth
write_runtime_env
chown -R cloudron:cloudron "$APP_DATA_DIR" "$APP_HOME_DIR"
log "Starting supervisor"
exec /usr/bin/supervisord -c "$APP_CODE_DIR/supervisord.conf"