mirror of
https://github.com/okxlin/appstore.git
synced 2026-09-27 08:01:06 +00:00
Merge pull request #5477 from okxlin/batch-770/snipo-20260801
feat: add Snipo app
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+118
@@ -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"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Persistent data is intentionally retained for recovery or reinstallation.
|
||||
exit 0
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Snipo applies embedded SQLite migrations when the new container starts.
|
||||
exit 0
|
||||
@@ -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://<server-ip>:<port>`. 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: <https://github.com/MohamedElashri/snipo>
|
||||
- Stable release: <https://github.com/MohamedElashri/snipo/releases/tag/v1.7.4>
|
||||
- Deployment guide: <https://github.com/MohamedElashri/snipo/blob/v1.7.4/docs/deployment.md>
|
||||
- Security model: <https://github.com/MohamedElashri/snipo/blob/v1.7.4/SECURITY.md>
|
||||
- License: <https://github.com/MohamedElashri/snipo/blob/v1.7.4/LICENSE> (AGPL-3.0)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+118
@@ -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"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Persistent data is intentionally retained for recovery or reinstallation.
|
||||
exit 0
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Snipo applies embedded SQLite migrations when the new container starts.
|
||||
exit 0
|
||||
@@ -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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
Reference in New Issue
Block a user