From 3f919b01eed4740066464376ba2b4c22bd88e3ea Mon Sep 17 00:00:00 2001 From: okxlin <61420215+okxlin@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:11:03 +0800 Subject: [PATCH] feat: add Snipo app --- apps/snipo/1.7.4/.env.sample | 15 ++ apps/snipo/1.7.4/data.yml | 265 +++++++++++++++++++++++++ apps/snipo/1.7.4/docker-compose.yml | 56 ++++++ apps/snipo/1.7.4/scripts/init.sh | 118 +++++++++++ apps/snipo/1.7.4/scripts/uninstall.sh | 5 + apps/snipo/1.7.4/scripts/upgrade.sh | 5 + apps/snipo/README.md | 51 +++++ apps/snipo/data.yml | 31 +++ apps/snipo/latest/.env.sample | 15 ++ apps/snipo/latest/data.yml | 265 +++++++++++++++++++++++++ apps/snipo/latest/docker-compose.yml | 56 ++++++ apps/snipo/latest/scripts/init.sh | 118 +++++++++++ apps/snipo/latest/scripts/uninstall.sh | 5 + apps/snipo/latest/scripts/upgrade.sh | 5 + apps/snipo/logo-LICENSE.txt | 9 + apps/snipo/logo.png | Bin 0 -> 6129 bytes 16 files changed, 1019 insertions(+) create mode 100644 apps/snipo/1.7.4/.env.sample create mode 100644 apps/snipo/1.7.4/data.yml create mode 100644 apps/snipo/1.7.4/docker-compose.yml create mode 100755 apps/snipo/1.7.4/scripts/init.sh create mode 100755 apps/snipo/1.7.4/scripts/uninstall.sh create mode 100755 apps/snipo/1.7.4/scripts/upgrade.sh create mode 100644 apps/snipo/README.md create mode 100644 apps/snipo/data.yml create mode 100644 apps/snipo/latest/.env.sample create mode 100644 apps/snipo/latest/data.yml create mode 100644 apps/snipo/latest/docker-compose.yml create mode 100755 apps/snipo/latest/scripts/init.sh create mode 100755 apps/snipo/latest/scripts/uninstall.sh create mode 100755 apps/snipo/latest/scripts/upgrade.sh create mode 100644 apps/snipo/logo-LICENSE.txt create mode 100644 apps/snipo/logo.png diff --git a/apps/snipo/1.7.4/.env.sample b/apps/snipo/1.7.4/.env.sample new file mode 100644 index 000000000..b67d03e22 --- /dev/null +++ b/apps/snipo/1.7.4/.env.sample @@ -0,0 +1,15 @@ +CONTAINER_NAME=snipo +PANEL_APP_BIND_ADDRESS=127.0.0.1 +PANEL_APP_PORT_HTTP=8080 +SNIPO_MASTER_PASSWORD=replace-with-a-strong-unique-password +SNIPO_SESSION_SECRET=replace-with-a-random-session-secret +SNIPO_ENCRYPTION_SALT=replace-with-a-random-encryption-salt +SNIPO_SESSION_DURATION=168h +SNIPO_TRUST_PROXY=false +SNIPO_MAX_FILES_PER_SNIPPET=10 +SNIPO_ENABLE_PUBLIC_SNIPPETS=true +SNIPO_ENABLE_API_TOKENS=true +SNIPO_ENABLE_BACKUP_RESTORE=true +SNIPO_LOG_LEVEL=info +SNIPO_LOG_FORMAT=json +APP_DATA_DIR=./data diff --git a/apps/snipo/1.7.4/data.yml b/apps/snipo/1.7.4/data.yml new file mode 100644 index 000000000..336eec900 --- /dev/null +++ b/apps/snipo/1.7.4/data.yml @@ -0,0 +1,265 @@ +additionalProperties: + formFields: + - default: 127.0.0.1 + edit: true + envKey: PANEL_APP_BIND_ADDRESS + labelEn: Bind Address + labelZh: 绑定地址 + label: + en: Bind Address + zh: 绑定地址 + zh-Hant: 綁定位址 + ja: バインドアドレス + ko: 바인드 주소 + ru: Адрес привязки + ms: Alamat ikatan + pt-br: Endereco de vinculacao + required: true + type: text + - default: 8080 + edit: true + envKey: PANEL_APP_PORT_HTTP + labelEn: HTTP Port + labelZh: HTTP 端口 + label: + en: HTTP Port + zh: HTTP 端口 + zh-Hant: HTTP 連接埠 + ja: HTTP ポート + ko: HTTP 포트 + ru: HTTP-порт + ms: Port HTTP + pt-br: Porta HTTP + required: true + rule: paramPort + type: number + - default: "" + edit: true + envKey: SNIPO_MASTER_PASSWORD + labelEn: Master Password + labelZh: 主密码 + label: + en: Master Password + zh: 主密码 + zh-Hant: 主密碼 + ja: マスターパスワード + ko: 마스터 비밀번호 + ru: Главный пароль + ms: Kata laluan induk + pt-br: Senha mestra + random: true + required: true + rule: paramComplexity + type: password + - default: "" + edit: false + envKey: SNIPO_SESSION_SECRET + labelEn: Session Secret (Auto-generated) + labelZh: 会话密钥(自动生成) + label: + en: Session Secret (Auto-generated) + zh: 会话密钥(自动生成) + zh-Hant: 工作階段密鑰(自動產生) + ja: セッションシークレット(自動生成) + ko: 세션 비밀 키(자동 생성) + ru: Секрет сеанса (создается автоматически) + ms: Rahsia sesi (dijana secara automatik) + pt-br: Segredo de sessao (gerado automaticamente) + random: true + required: true + type: password + - default: "" + edit: false + envKey: SNIPO_ENCRYPTION_SALT + labelEn: Encryption Salt (Auto-generated) + labelZh: 加密盐(自动生成) + label: + en: Encryption Salt (Auto-generated) + zh: 加密盐(自动生成) + zh-Hant: 加密鹽值(自動產生) + ja: 暗号化ソルト(自動生成) + ko: 암호화 솔트(자동 생성) + ru: Соль шифрования (создается автоматически) + ms: Garam penyulitan (dijana secara automatik) + pt-br: Sal de criptografia (gerado automaticamente) + random: true + required: true + type: password + - default: 168h + edit: true + envKey: SNIPO_SESSION_DURATION + labelEn: Session Duration + labelZh: 会话有效期 + label: + en: Session Duration + zh: 会话有效期 + zh-Hant: 工作階段有效期 + ja: セッション有効期間 + ko: 세션 유효 기간 + ru: Срок действия сеанса + ms: Tempoh sesi + pt-br: Duracao da sessao + required: true + type: text + - default: "false" + edit: true + envKey: SNIPO_TRUST_PROXY + labelEn: Trust Reverse Proxy Headers + labelZh: 信任反向代理请求头 + label: + en: Trust Reverse Proxy Headers + zh: 信任反向代理请求头 + zh-Hant: 信任反向代理請求標頭 + ja: リバースプロキシヘッダーを信頼 + ko: 리버스 프록시 헤더 신뢰 + ru: Доверять заголовкам обратного прокси + ms: Percayai pengepala proksi songsang + pt-br: Confiar nos cabecalhos do proxy reverso + required: true + type: select + values: + - label: "false" + value: "false" + - label: "true" + value: "true" + - default: 10 + edit: true + envKey: SNIPO_MAX_FILES_PER_SNIPPET + labelEn: Maximum Files per Snippet + labelZh: 每个片段的最大文件数 + label: + en: Maximum Files per Snippet + zh: 每个片段的最大文件数 + zh-Hant: 每個片段的最大檔案數 + ja: スニペットごとの最大ファイル数 + ko: 코드 조각당 최대 파일 수 + ru: Максимум файлов в одном фрагменте + ms: Fail maksimum bagi setiap cebisan + pt-br: Maximo de arquivos por trecho + required: true + type: number + - default: "true" + edit: true + envKey: SNIPO_ENABLE_PUBLIC_SNIPPETS + labelEn: Enable Public Snippets + labelZh: 启用公开片段 + label: + en: Enable Public Snippets + zh: 启用公开片段 + zh-Hant: 啟用公開片段 + ja: 公開スニペットを有効化 + ko: 공개 코드 조각 활성화 + ru: Включить публичные фрагменты + ms: Dayakan cebisan awam + pt-br: Ativar trechos publicos + required: true + type: select + values: + - label: "true" + value: "true" + - label: "false" + value: "false" + - default: "true" + edit: true + envKey: SNIPO_ENABLE_API_TOKENS + labelEn: Enable API Tokens + labelZh: 启用 API Token + label: + en: Enable API Tokens + zh: 启用 API Token + zh-Hant: 啟用 API Token + ja: API トークンを有効化 + ko: API 토큰 활성화 + ru: Включить токены API + ms: Dayakan token API + pt-br: Ativar tokens de API + required: true + type: select + values: + - label: "true" + value: "true" + - label: "false" + value: "false" + - default: "true" + edit: true + envKey: SNIPO_ENABLE_BACKUP_RESTORE + labelEn: Enable Backup and Restore + labelZh: 启用备份与恢复 + label: + en: Enable Backup and Restore + zh: 启用备份与恢复 + zh-Hant: 啟用備份與還原 + ja: バックアップと復元を有効化 + ko: 백업 및 복원 활성화 + ru: Включить резервное копирование и восстановление + ms: Dayakan sandaran dan pemulihan + pt-br: Ativar backup e restauracao + required: true + type: select + values: + - label: "true" + value: "true" + - label: "false" + value: "false" + - default: info + edit: true + envKey: SNIPO_LOG_LEVEL + labelEn: Log Level + labelZh: 日志级别 + label: + en: Log Level + zh: 日志级别 + zh-Hant: 日誌級別 + ja: ログレベル + ko: 로그 수준 + ru: Уровень журнала + ms: Tahap log + pt-br: Nivel de log + required: true + type: select + values: + - label: info + value: info + - label: warn + value: warn + - label: error + value: error + - label: debug + value: debug + - default: json + edit: true + envKey: SNIPO_LOG_FORMAT + labelEn: Log Format + labelZh: 日志格式 + label: + en: Log Format + zh: 日志格式 + zh-Hant: 日誌格式 + ja: ログ形式 + ko: 로그 형식 + ru: Формат журнала + ms: Format log + pt-br: Formato de log + required: true + type: select + values: + - label: json + value: json + - label: text + value: text + - default: ./data + edit: true + envKey: APP_DATA_DIR + labelEn: Data Directory + labelZh: 数据目录 + label: + en: Data Directory + zh: 数据目录 + zh-Hant: 資料目錄 + ja: データディレクトリ + ko: 데이터 디렉터리 + ru: Каталог данных + ms: Direktori data + pt-br: Diretorio de dados + required: true + type: text diff --git a/apps/snipo/1.7.4/docker-compose.yml b/apps/snipo/1.7.4/docker-compose.yml new file mode 100644 index 000000000..62a8bd460 --- /dev/null +++ b/apps/snipo/1.7.4/docker-compose.yml @@ -0,0 +1,56 @@ +services: + snipo: + image: "ghcr.io/mohamedelashri/snipo:1.7.4@sha256:bd9d1fa2245ad2bcdaaac0e34df3094f2005e7397de3e42c9aeb0ae9ec765e9f" + container_name: ${CONTAINER_NAME} + restart: unless-stopped + user: "1000:1000" + networks: + - 1panel-network + ports: + - "${PANEL_APP_BIND_ADDRESS}:${PANEL_APP_PORT_HTTP}:8080" + environment: + - SNIPO_MASTER_PASSWORD=${SNIPO_MASTER_PASSWORD} + - SNIPO_SESSION_SECRET=${SNIPO_SESSION_SECRET} + - SNIPO_ENCRYPTION_SALT=${SNIPO_ENCRYPTION_SALT} + - SNIPO_SESSION_DURATION=${SNIPO_SESSION_DURATION} + - SNIPO_HOST=0.0.0.0 + - SNIPO_PORT=8080 + - SNIPO_TRUST_PROXY=${SNIPO_TRUST_PROXY} + - SNIPO_MAX_FILES_PER_SNIPPET=${SNIPO_MAX_FILES_PER_SNIPPET} + - SNIPO_DB_PATH=/data/snipo.db + - SNIPO_DB_MAX_CONNS=1 + - SNIPO_DB_BUSY_TIMEOUT=5000 + - SNIPO_DB_JOURNAL=WAL + - SNIPO_DB_SYNC=NORMAL + - SNIPO_DB_MMAP_SIZE=67108864 + - SNIPO_DB_CACHE_SIZE=-1000 + - SNIPO_RATE_LIMIT=100 + - SNIPO_RATE_LIMIT_READ=1000 + - SNIPO_RATE_LIMIT_WRITE=500 + - SNIPO_RATE_LIMIT_ADMIN=100 + - SNIPO_ENABLE_PUBLIC_SNIPPETS=${SNIPO_ENABLE_PUBLIC_SNIPPETS} + - SNIPO_ENABLE_API_TOKENS=${SNIPO_ENABLE_API_TOKENS} + - SNIPO_ENABLE_BACKUP_RESTORE=${SNIPO_ENABLE_BACKUP_RESTORE} + - SNIPO_LOG_LEVEL=${SNIPO_LOG_LEVEL} + - SNIPO_LOG_FORMAT=${SNIPO_LOG_FORMAT} + volumes: + - "${APP_DATA_DIR}:/data" + read_only: true + tmpfs: + - /tmp:rw,nosuid,nodev,noexec,size=16m,uid=1000,gid=1000,mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "/snipo", "health"] + interval: 30s + timeout: 5s + retries: 8 + start_period: 10s + labels: + createdBy: "Apps" + +networks: + 1panel-network: + external: true diff --git a/apps/snipo/1.7.4/scripts/init.sh b/apps/snipo/1.7.4/scripts/init.sh new file mode 100755 index 000000000..8bfe31a89 --- /dev/null +++ b/apps/snipo/1.7.4/scripts/init.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +APP_ROOT_DIR="$(dirname "$ROOT_DIR")" +ENV_FILE="${ENV_FILE:-${ROOT_DIR}/.env}" + +fail() { + printf '%s\n' "$1" >&2 + exit 1 +} + +strip_matching_quotes() { + local value="$1" + + if [[ ${#value} -ge 2 ]]; then + if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then + value="${value:1:${#value}-2}" + elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then + value="${value:1:${#value}-2}" + fi + fi + printf '%s\n' "$value" +} + +read_env_value() { + local key="$1" + local value="" + + if [[ -f "$ENV_FILE" ]]; then + value="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 | cut -d '=' -f 2- || true)" + fi + value="${value%$'\r'}" + strip_matching_quotes "$value" +} + +set_env_value() { + local key="$1" + local value="$2" + local temp_file + + temp_file="$(mktemp "${ENV_FILE}.tmp.XXXXXX")" + awk -v key="$key" -v value="$value" ' + BEGIN { updated = 0 } + $0 ~ ("^" key "=") { + if (!updated) { + print key "=" value + updated = 1 + } + next + } + { print } + END { + if (!updated) print key "=" value + } + ' "$ENV_FILE" >"$temp_file" + chmod 600 "$temp_file" + mv -f -- "$temp_file" "$ENV_FILE" +} + +path_is_dotenv_safe() { + local value="$1" + + case "$value" in + *$'\n'* | *$'\r'* | *\\* | *'$'* | *'#'* | *'"'* | *"'"*) return 1 ;; + *) return 0 ;; + esac +} + +[[ -f "$ENV_FILE" ]] || fail "Environment file not found: ${ENV_FILE}" +[[ ! -L "$ENV_FILE" ]] || fail "Environment file must not be a symbolic link" +[[ "$(id -u)" -eq 0 ]] || fail "Snipo init must run as root" + +if [[ ${APP_DATA_DIR+x} ]]; then + DATA_DIR_RAW="$APP_DATA_DIR" +else + DATA_DIR_RAW="$(read_env_value APP_DATA_DIR)" +fi +DATA_DIR_RAW="$(strip_matching_quotes "${DATA_DIR_RAW:-./data}")" + +[[ -n "$DATA_DIR_RAW" ]] || fail "APP_DATA_DIR must not be empty" +path_is_dotenv_safe "$DATA_DIR_RAW" || fail "APP_DATA_DIR contains unsupported dotenv characters" + +if [[ "$DATA_DIR_RAW" = /* ]]; then + [[ ! -L "$DATA_DIR_RAW" ]] || fail "APP_DATA_DIR must not be a symbolic link" + DATA_DIR_ABS="$(realpath -m -- "$DATA_DIR_RAW")" + [[ "$DATA_DIR_ABS" != "/" ]] || fail "APP_DATA_DIR must not be the filesystem root" +else + CONTAINER_NAME_VALUE="$(read_env_value CONTAINER_NAME)" + [[ "$CONTAINER_NAME_VALUE" =~ ^[A-Za-z0-9._-]+$ ]] || \ + fail "CONTAINER_NAME contains unsupported characters" + + RETAINED_ROOT="${APP_ROOT_DIR}/retained-data" + [[ ! -L "$RETAINED_ROOT" ]] || fail "The retained data root must not be a symbolic link" + RETAINED_INSTANCE_ROOT="${RETAINED_ROOT}/${CONTAINER_NAME_VALUE}" + [[ ! -L "$RETAINED_INSTANCE_ROOT" ]] || fail "The retained instance root must not be a symbolic link" + DATA_DIR_PATH="${RETAINED_INSTANCE_ROOT}/${DATA_DIR_RAW#./}" + [[ ! -L "$DATA_DIR_PATH" ]] || fail "APP_DATA_DIR must not be a symbolic link" + RETAINED_INSTANCE_ABS="$(realpath -m -- "$RETAINED_INSTANCE_ROOT")" + DATA_DIR_ABS="$(realpath -m -- "$DATA_DIR_PATH")" + case "$DATA_DIR_ABS" in + "${RETAINED_INSTANCE_ABS}"/*) ;; + *) fail "Relative APP_DATA_DIR must remain inside the isolated retained-data directory" ;; + esac + + set_env_value APP_DATA_DIR "\"${DATA_DIR_ABS}\"" +fi + +if [[ -e "$DATA_DIR_ABS" && ! -d "$DATA_DIR_ABS" ]]; then + fail "APP_DATA_DIR must be a directory" +fi +if [[ ! -e "$DATA_DIR_ABS" ]]; then + install -d -m 0750 -- "$DATA_DIR_ABS" + chown 1000:1000 -- "$DATA_DIR_ABS" +fi + +[[ "$(stat -c '%u:%g' "$DATA_DIR_ABS")" == "1000:1000" ]] || \ + fail "APP_DATA_DIR must be owned by UID/GID 1000:1000" diff --git a/apps/snipo/1.7.4/scripts/uninstall.sh b/apps/snipo/1.7.4/scripts/uninstall.sh new file mode 100755 index 000000000..9365d9925 --- /dev/null +++ b/apps/snipo/1.7.4/scripts/uninstall.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Persistent data is intentionally retained for recovery or reinstallation. +exit 0 diff --git a/apps/snipo/1.7.4/scripts/upgrade.sh b/apps/snipo/1.7.4/scripts/upgrade.sh new file mode 100755 index 000000000..13092bde7 --- /dev/null +++ b/apps/snipo/1.7.4/scripts/upgrade.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Snipo applies embedded SQLite migrations when the new container starts. +exit 0 diff --git a/apps/snipo/README.md b/apps/snipo/README.md new file mode 100644 index 000000000..899689e21 --- /dev/null +++ b/apps/snipo/README.md @@ -0,0 +1,51 @@ +# Snipo + +## 产品介绍 + +Snipo 是一个面向单用户的轻量级代码片段管理器,支持在一个自托管实例中组织、搜索和分享多文件代码片段。 + +## 主要功能 + +- 多文件代码片段、语法高亮、文件夹、标签、搜索和历史记录 +- 公开链接、软删除、过期时间和备份恢复 +- 可按权限创建读、写和管理 API Token +- 使用内置 SQLite 数据库,无需外部数据库 + +## 访问说明 + +安装时设置主密码,随后通过 `http://<服务器 IP>:<端口>` 访问并登录。Snipo 只有一个共享的主密码和数据库,不提供多用户或租户隔离。默认绑定 `127.0.0.1`;远程访问应通过 1Panel 的 HTTPS 反向代理发布。 + +## 数据与安全 + +- 数据库、自动生成的加密盐和备份数据保存在 `APP_DATA_DIR` 中。相对路径会在安装时解析到应用级 `retained-data` 目录并按容器名隔离,卸载实例后仍会保留;绝对路径保持不变。升级或迁移前应备份 SQLite 数据库、WAL/SHM 文件和 `.encryption_salt`。 +- 容器以 UID/GID `1000:1000` 运行,根文件系统只读,丢弃全部 Linux capabilities,并只提供一个小型可写 `/tmp`。 +- 默认启用公开片段、API Token 和备份恢复功能,可在安装表单中关闭不需要的功能。 +- 公开片段可由持有链接的任何人访问。对公网部署时应启用 HTTPS,并避免绕过反向代理直接访问应用端口。 +- Snipo 启动时自动执行内置数据库迁移。升级后应检查 `/health` 并验证常用片段的读写和持久化。 + +## Introduction + +Snipo is a lightweight, single-user snippet manager for organizing, searching, and sharing multi-file code snippets in one self-hosted instance. + +## Features + +- Multi-file snippets, syntax highlighting, folders, tags, search, and history +- Public links, soft deletion, expiration, and backup/restore +- Read, write, and administrative API tokens +- Embedded SQLite storage without an external database + +## Usage And Security + +- Set the master password during installation, then sign in at `http://:`. Snipo has one shared password and database; it does not provide multi-user isolation. +- The service binds to `127.0.0.1` by default. Publish it remotely through a 1Panel HTTPS reverse proxy. +- `APP_DATA_DIR` stores the SQLite database, generated encryption salt, and backups. Relative paths are resolved into a per-container directory under the app-level `retained-data` directory so uninstalling an instance preserves them; absolute paths remain unchanged. +- The container runs as UID/GID `1000:1000`, uses a read-only root filesystem, drops all Linux capabilities, and receives only a small writable `/tmp`. +- Public snippets are accessible to anyone with their link. Review the public-sharing, API-token, and backup switches before exposing the service. + +## References + +- Project: +- Stable release: +- Deployment guide: +- Security model: +- License: (AGPL-3.0) diff --git a/apps/snipo/data.yml b/apps/snipo/data.yml new file mode 100644 index 000000000..7fa94aadb --- /dev/null +++ b/apps/snipo/data.yml @@ -0,0 +1,31 @@ +name: Snipo +tags: + - 工具 +title: 轻量级单用户代码片段管理器 +description: 轻量级单用户代码片段管理器 +additionalProperties: + key: snipo + name: Snipo + tags: + - Tool + shortDescZh: 轻量级单用户代码片段管理器 + shortDescEn: Lightweight single-user snippet manager + description: + en: Lightweight single-user snippet manager + zh: 轻量级单用户代码片段管理器 + zh-Hant: 輕量級單一使用者程式碼片段管理器 + ja: 軽量なシングルユーザー向けスニペット管理ツール + ko: 가벼운 단일 사용자 코드 조각 관리자 + ru: Легковесный менеджер фрагментов кода для одного пользователя + ms: Pengurus cebisan kod ringan untuk seorang pengguna + pt-br: Gerenciador leve de trechos de codigo para um unico usuario + type: website + crossVersionUpdate: true + limit: 0 + recommend: 0 + website: https://github.com/MohamedElashri/snipo + github: https://github.com/MohamedElashri/snipo + document: https://github.com/MohamedElashri/snipo/blob/main/docs/deployment.md + architectures: + - amd64 + - arm64 diff --git a/apps/snipo/latest/.env.sample b/apps/snipo/latest/.env.sample new file mode 100644 index 000000000..b67d03e22 --- /dev/null +++ b/apps/snipo/latest/.env.sample @@ -0,0 +1,15 @@ +CONTAINER_NAME=snipo +PANEL_APP_BIND_ADDRESS=127.0.0.1 +PANEL_APP_PORT_HTTP=8080 +SNIPO_MASTER_PASSWORD=replace-with-a-strong-unique-password +SNIPO_SESSION_SECRET=replace-with-a-random-session-secret +SNIPO_ENCRYPTION_SALT=replace-with-a-random-encryption-salt +SNIPO_SESSION_DURATION=168h +SNIPO_TRUST_PROXY=false +SNIPO_MAX_FILES_PER_SNIPPET=10 +SNIPO_ENABLE_PUBLIC_SNIPPETS=true +SNIPO_ENABLE_API_TOKENS=true +SNIPO_ENABLE_BACKUP_RESTORE=true +SNIPO_LOG_LEVEL=info +SNIPO_LOG_FORMAT=json +APP_DATA_DIR=./data diff --git a/apps/snipo/latest/data.yml b/apps/snipo/latest/data.yml new file mode 100644 index 000000000..336eec900 --- /dev/null +++ b/apps/snipo/latest/data.yml @@ -0,0 +1,265 @@ +additionalProperties: + formFields: + - default: 127.0.0.1 + edit: true + envKey: PANEL_APP_BIND_ADDRESS + labelEn: Bind Address + labelZh: 绑定地址 + label: + en: Bind Address + zh: 绑定地址 + zh-Hant: 綁定位址 + ja: バインドアドレス + ko: 바인드 주소 + ru: Адрес привязки + ms: Alamat ikatan + pt-br: Endereco de vinculacao + required: true + type: text + - default: 8080 + edit: true + envKey: PANEL_APP_PORT_HTTP + labelEn: HTTP Port + labelZh: HTTP 端口 + label: + en: HTTP Port + zh: HTTP 端口 + zh-Hant: HTTP 連接埠 + ja: HTTP ポート + ko: HTTP 포트 + ru: HTTP-порт + ms: Port HTTP + pt-br: Porta HTTP + required: true + rule: paramPort + type: number + - default: "" + edit: true + envKey: SNIPO_MASTER_PASSWORD + labelEn: Master Password + labelZh: 主密码 + label: + en: Master Password + zh: 主密码 + zh-Hant: 主密碼 + ja: マスターパスワード + ko: 마스터 비밀번호 + ru: Главный пароль + ms: Kata laluan induk + pt-br: Senha mestra + random: true + required: true + rule: paramComplexity + type: password + - default: "" + edit: false + envKey: SNIPO_SESSION_SECRET + labelEn: Session Secret (Auto-generated) + labelZh: 会话密钥(自动生成) + label: + en: Session Secret (Auto-generated) + zh: 会话密钥(自动生成) + zh-Hant: 工作階段密鑰(自動產生) + ja: セッションシークレット(自動生成) + ko: 세션 비밀 키(자동 생성) + ru: Секрет сеанса (создается автоматически) + ms: Rahsia sesi (dijana secara automatik) + pt-br: Segredo de sessao (gerado automaticamente) + random: true + required: true + type: password + - default: "" + edit: false + envKey: SNIPO_ENCRYPTION_SALT + labelEn: Encryption Salt (Auto-generated) + labelZh: 加密盐(自动生成) + label: + en: Encryption Salt (Auto-generated) + zh: 加密盐(自动生成) + zh-Hant: 加密鹽值(自動產生) + ja: 暗号化ソルト(自動生成) + ko: 암호화 솔트(자동 생성) + ru: Соль шифрования (создается автоматически) + ms: Garam penyulitan (dijana secara automatik) + pt-br: Sal de criptografia (gerado automaticamente) + random: true + required: true + type: password + - default: 168h + edit: true + envKey: SNIPO_SESSION_DURATION + labelEn: Session Duration + labelZh: 会话有效期 + label: + en: Session Duration + zh: 会话有效期 + zh-Hant: 工作階段有效期 + ja: セッション有効期間 + ko: 세션 유효 기간 + ru: Срок действия сеанса + ms: Tempoh sesi + pt-br: Duracao da sessao + required: true + type: text + - default: "false" + edit: true + envKey: SNIPO_TRUST_PROXY + labelEn: Trust Reverse Proxy Headers + labelZh: 信任反向代理请求头 + label: + en: Trust Reverse Proxy Headers + zh: 信任反向代理请求头 + zh-Hant: 信任反向代理請求標頭 + ja: リバースプロキシヘッダーを信頼 + ko: 리버스 프록시 헤더 신뢰 + ru: Доверять заголовкам обратного прокси + ms: Percayai pengepala proksi songsang + pt-br: Confiar nos cabecalhos do proxy reverso + required: true + type: select + values: + - label: "false" + value: "false" + - label: "true" + value: "true" + - default: 10 + edit: true + envKey: SNIPO_MAX_FILES_PER_SNIPPET + labelEn: Maximum Files per Snippet + labelZh: 每个片段的最大文件数 + label: + en: Maximum Files per Snippet + zh: 每个片段的最大文件数 + zh-Hant: 每個片段的最大檔案數 + ja: スニペットごとの最大ファイル数 + ko: 코드 조각당 최대 파일 수 + ru: Максимум файлов в одном фрагменте + ms: Fail maksimum bagi setiap cebisan + pt-br: Maximo de arquivos por trecho + required: true + type: number + - default: "true" + edit: true + envKey: SNIPO_ENABLE_PUBLIC_SNIPPETS + labelEn: Enable Public Snippets + labelZh: 启用公开片段 + label: + en: Enable Public Snippets + zh: 启用公开片段 + zh-Hant: 啟用公開片段 + ja: 公開スニペットを有効化 + ko: 공개 코드 조각 활성화 + ru: Включить публичные фрагменты + ms: Dayakan cebisan awam + pt-br: Ativar trechos publicos + required: true + type: select + values: + - label: "true" + value: "true" + - label: "false" + value: "false" + - default: "true" + edit: true + envKey: SNIPO_ENABLE_API_TOKENS + labelEn: Enable API Tokens + labelZh: 启用 API Token + label: + en: Enable API Tokens + zh: 启用 API Token + zh-Hant: 啟用 API Token + ja: API トークンを有効化 + ko: API 토큰 활성화 + ru: Включить токены API + ms: Dayakan token API + pt-br: Ativar tokens de API + required: true + type: select + values: + - label: "true" + value: "true" + - label: "false" + value: "false" + - default: "true" + edit: true + envKey: SNIPO_ENABLE_BACKUP_RESTORE + labelEn: Enable Backup and Restore + labelZh: 启用备份与恢复 + label: + en: Enable Backup and Restore + zh: 启用备份与恢复 + zh-Hant: 啟用備份與還原 + ja: バックアップと復元を有効化 + ko: 백업 및 복원 활성화 + ru: Включить резервное копирование и восстановление + ms: Dayakan sandaran dan pemulihan + pt-br: Ativar backup e restauracao + required: true + type: select + values: + - label: "true" + value: "true" + - label: "false" + value: "false" + - default: info + edit: true + envKey: SNIPO_LOG_LEVEL + labelEn: Log Level + labelZh: 日志级别 + label: + en: Log Level + zh: 日志级别 + zh-Hant: 日誌級別 + ja: ログレベル + ko: 로그 수준 + ru: Уровень журнала + ms: Tahap log + pt-br: Nivel de log + required: true + type: select + values: + - label: info + value: info + - label: warn + value: warn + - label: error + value: error + - label: debug + value: debug + - default: json + edit: true + envKey: SNIPO_LOG_FORMAT + labelEn: Log Format + labelZh: 日志格式 + label: + en: Log Format + zh: 日志格式 + zh-Hant: 日誌格式 + ja: ログ形式 + ko: 로그 형식 + ru: Формат журнала + ms: Format log + pt-br: Formato de log + required: true + type: select + values: + - label: json + value: json + - label: text + value: text + - default: ./data + edit: true + envKey: APP_DATA_DIR + labelEn: Data Directory + labelZh: 数据目录 + label: + en: Data Directory + zh: 数据目录 + zh-Hant: 資料目錄 + ja: データディレクトリ + ko: 데이터 디렉터리 + ru: Каталог данных + ms: Direktori data + pt-br: Diretorio de dados + required: true + type: text diff --git a/apps/snipo/latest/docker-compose.yml b/apps/snipo/latest/docker-compose.yml new file mode 100644 index 000000000..6a54228c1 --- /dev/null +++ b/apps/snipo/latest/docker-compose.yml @@ -0,0 +1,56 @@ +services: + snipo: + image: "ghcr.io/mohamedelashri/snipo:latest" + container_name: ${CONTAINER_NAME} + restart: unless-stopped + user: "1000:1000" + networks: + - 1panel-network + ports: + - "${PANEL_APP_BIND_ADDRESS}:${PANEL_APP_PORT_HTTP}:8080" + environment: + - SNIPO_MASTER_PASSWORD=${SNIPO_MASTER_PASSWORD} + - SNIPO_SESSION_SECRET=${SNIPO_SESSION_SECRET} + - SNIPO_ENCRYPTION_SALT=${SNIPO_ENCRYPTION_SALT} + - SNIPO_SESSION_DURATION=${SNIPO_SESSION_DURATION} + - SNIPO_HOST=0.0.0.0 + - SNIPO_PORT=8080 + - SNIPO_TRUST_PROXY=${SNIPO_TRUST_PROXY} + - SNIPO_MAX_FILES_PER_SNIPPET=${SNIPO_MAX_FILES_PER_SNIPPET} + - SNIPO_DB_PATH=/data/snipo.db + - SNIPO_DB_MAX_CONNS=1 + - SNIPO_DB_BUSY_TIMEOUT=5000 + - SNIPO_DB_JOURNAL=WAL + - SNIPO_DB_SYNC=NORMAL + - SNIPO_DB_MMAP_SIZE=67108864 + - SNIPO_DB_CACHE_SIZE=-1000 + - SNIPO_RATE_LIMIT=100 + - SNIPO_RATE_LIMIT_READ=1000 + - SNIPO_RATE_LIMIT_WRITE=500 + - SNIPO_RATE_LIMIT_ADMIN=100 + - SNIPO_ENABLE_PUBLIC_SNIPPETS=${SNIPO_ENABLE_PUBLIC_SNIPPETS} + - SNIPO_ENABLE_API_TOKENS=${SNIPO_ENABLE_API_TOKENS} + - SNIPO_ENABLE_BACKUP_RESTORE=${SNIPO_ENABLE_BACKUP_RESTORE} + - SNIPO_LOG_LEVEL=${SNIPO_LOG_LEVEL} + - SNIPO_LOG_FORMAT=${SNIPO_LOG_FORMAT} + volumes: + - "${APP_DATA_DIR}:/data" + read_only: true + tmpfs: + - /tmp:rw,nosuid,nodev,noexec,size=16m,uid=1000,gid=1000,mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "/snipo", "health"] + interval: 30s + timeout: 5s + retries: 8 + start_period: 10s + labels: + createdBy: "Apps" + +networks: + 1panel-network: + external: true diff --git a/apps/snipo/latest/scripts/init.sh b/apps/snipo/latest/scripts/init.sh new file mode 100755 index 000000000..8bfe31a89 --- /dev/null +++ b/apps/snipo/latest/scripts/init.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +APP_ROOT_DIR="$(dirname "$ROOT_DIR")" +ENV_FILE="${ENV_FILE:-${ROOT_DIR}/.env}" + +fail() { + printf '%s\n' "$1" >&2 + exit 1 +} + +strip_matching_quotes() { + local value="$1" + + if [[ ${#value} -ge 2 ]]; then + if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then + value="${value:1:${#value}-2}" + elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then + value="${value:1:${#value}-2}" + fi + fi + printf '%s\n' "$value" +} + +read_env_value() { + local key="$1" + local value="" + + if [[ -f "$ENV_FILE" ]]; then + value="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 | cut -d '=' -f 2- || true)" + fi + value="${value%$'\r'}" + strip_matching_quotes "$value" +} + +set_env_value() { + local key="$1" + local value="$2" + local temp_file + + temp_file="$(mktemp "${ENV_FILE}.tmp.XXXXXX")" + awk -v key="$key" -v value="$value" ' + BEGIN { updated = 0 } + $0 ~ ("^" key "=") { + if (!updated) { + print key "=" value + updated = 1 + } + next + } + { print } + END { + if (!updated) print key "=" value + } + ' "$ENV_FILE" >"$temp_file" + chmod 600 "$temp_file" + mv -f -- "$temp_file" "$ENV_FILE" +} + +path_is_dotenv_safe() { + local value="$1" + + case "$value" in + *$'\n'* | *$'\r'* | *\\* | *'$'* | *'#'* | *'"'* | *"'"*) return 1 ;; + *) return 0 ;; + esac +} + +[[ -f "$ENV_FILE" ]] || fail "Environment file not found: ${ENV_FILE}" +[[ ! -L "$ENV_FILE" ]] || fail "Environment file must not be a symbolic link" +[[ "$(id -u)" -eq 0 ]] || fail "Snipo init must run as root" + +if [[ ${APP_DATA_DIR+x} ]]; then + DATA_DIR_RAW="$APP_DATA_DIR" +else + DATA_DIR_RAW="$(read_env_value APP_DATA_DIR)" +fi +DATA_DIR_RAW="$(strip_matching_quotes "${DATA_DIR_RAW:-./data}")" + +[[ -n "$DATA_DIR_RAW" ]] || fail "APP_DATA_DIR must not be empty" +path_is_dotenv_safe "$DATA_DIR_RAW" || fail "APP_DATA_DIR contains unsupported dotenv characters" + +if [[ "$DATA_DIR_RAW" = /* ]]; then + [[ ! -L "$DATA_DIR_RAW" ]] || fail "APP_DATA_DIR must not be a symbolic link" + DATA_DIR_ABS="$(realpath -m -- "$DATA_DIR_RAW")" + [[ "$DATA_DIR_ABS" != "/" ]] || fail "APP_DATA_DIR must not be the filesystem root" +else + CONTAINER_NAME_VALUE="$(read_env_value CONTAINER_NAME)" + [[ "$CONTAINER_NAME_VALUE" =~ ^[A-Za-z0-9._-]+$ ]] || \ + fail "CONTAINER_NAME contains unsupported characters" + + RETAINED_ROOT="${APP_ROOT_DIR}/retained-data" + [[ ! -L "$RETAINED_ROOT" ]] || fail "The retained data root must not be a symbolic link" + RETAINED_INSTANCE_ROOT="${RETAINED_ROOT}/${CONTAINER_NAME_VALUE}" + [[ ! -L "$RETAINED_INSTANCE_ROOT" ]] || fail "The retained instance root must not be a symbolic link" + DATA_DIR_PATH="${RETAINED_INSTANCE_ROOT}/${DATA_DIR_RAW#./}" + [[ ! -L "$DATA_DIR_PATH" ]] || fail "APP_DATA_DIR must not be a symbolic link" + RETAINED_INSTANCE_ABS="$(realpath -m -- "$RETAINED_INSTANCE_ROOT")" + DATA_DIR_ABS="$(realpath -m -- "$DATA_DIR_PATH")" + case "$DATA_DIR_ABS" in + "${RETAINED_INSTANCE_ABS}"/*) ;; + *) fail "Relative APP_DATA_DIR must remain inside the isolated retained-data directory" ;; + esac + + set_env_value APP_DATA_DIR "\"${DATA_DIR_ABS}\"" +fi + +if [[ -e "$DATA_DIR_ABS" && ! -d "$DATA_DIR_ABS" ]]; then + fail "APP_DATA_DIR must be a directory" +fi +if [[ ! -e "$DATA_DIR_ABS" ]]; then + install -d -m 0750 -- "$DATA_DIR_ABS" + chown 1000:1000 -- "$DATA_DIR_ABS" +fi + +[[ "$(stat -c '%u:%g' "$DATA_DIR_ABS")" == "1000:1000" ]] || \ + fail "APP_DATA_DIR must be owned by UID/GID 1000:1000" diff --git a/apps/snipo/latest/scripts/uninstall.sh b/apps/snipo/latest/scripts/uninstall.sh new file mode 100755 index 000000000..9365d9925 --- /dev/null +++ b/apps/snipo/latest/scripts/uninstall.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Persistent data is intentionally retained for recovery or reinstallation. +exit 0 diff --git a/apps/snipo/latest/scripts/upgrade.sh b/apps/snipo/latest/scripts/upgrade.sh new file mode 100755 index 000000000..13092bde7 --- /dev/null +++ b/apps/snipo/latest/scripts/upgrade.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Snipo applies embedded SQLite migrations when the new container starts. +exit 0 diff --git a/apps/snipo/logo-LICENSE.txt b/apps/snipo/logo-LICENSE.txt new file mode 100644 index 000000000..3f68f8c52 --- /dev/null +++ b/apps/snipo/logo-LICENSE.txt @@ -0,0 +1,9 @@ +Snipo logo + +Source: https://github.com/MohamedElashri/snipo/blob/c5eab947cce8728c2a431d2ed3426d452d14c3bf/assets/logo/icon-256.png +Source commit: c5eab947cce8728c2a431d2ed3426d452d14c3bf +Source SHA-256: dae9ce2b27caa1c1dbbc27669773d8f9d229a41f5ce2550f08f77c52c7331edb +License: GNU Affero General Public License v3.0 (AGPL-3.0-only) +License text: https://github.com/MohamedElashri/snipo/blob/c5eab947cce8728c2a431d2ed3426d452d14c3bf/LICENSE + +The store asset is a proportional rendering of the official project icon. diff --git a/apps/snipo/logo.png b/apps/snipo/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d8dd565cbb42d0d950e570bdc2928df6c9f2f1ac GIT binary patch literal 6129 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 I4Ll$H4~5O>dH?_b literal 0 HcmV?d00001