Consolidate all secrets into config.yaml — remove .env entirely
All configuration (including secrets) now lives in a single file: bin/config.yaml. The separate .env file has been eliminated. Changes: - config.go: Added SMTPSettings struct + AI.APIKey to Config; removed godotenv import, Environment struct, and all os.Getenv() calls - config.yaml: Added smtp section (host/port/username/password) and ai.api_key field with placeholder values - main.go: Reads SMTP and API key from cfg instead of env - smtp.go: Changed Port field from string to int - otp_test.go: Updated Port values to int - .env.example: Deleted (all config is in config.yaml) - .gitignore: Removed .env.example; kept .env for safety - go.mod/go.sum: Removed github.com/joho/godotenv dependency - install.sh: No longer creates .env or uses EnvironmentFile; warns about placeholder values in config.yaml instead
This commit is contained in:
parent
54dd30a2d6
commit
01a58156a1
11 changed files with 94 additions and 160 deletions
25
.env.example
25
.env.example
|
|
@ -1,25 +0,0 @@
|
|||
# ───────────────────────────────────────────────────────
|
||||
# inBOXER – Environment Configuration
|
||||
# ───────────────────────────────────────────────────────
|
||||
# Copy this file to .env and fill in your credentials:
|
||||
# cp .env.example .env
|
||||
# nano .env
|
||||
#
|
||||
# The application also reads these variables from the
|
||||
# systemd EnvironmentFile (if deployed via install.sh).
|
||||
# ───────────────────────────────────────────────────────
|
||||
|
||||
# DeepSeek API key – for AI-based email classification
|
||||
# Sign up at https://platform.deepseek.com/ to get one.
|
||||
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||
|
||||
# SMTP credentials – used to send one-time passwords (OTP)
|
||||
# for user login. Supports STARTTLS on port 587.
|
||||
SMTP_HOST=your.smtp.host.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@example.com
|
||||
SMTP_PASS=your-smtp-password
|
||||
|
||||
# (Optional) Override the session_secret from bin/config.yaml.
|
||||
# Leave commented out to use the value in config.yaml.
|
||||
# APP_SECRET=change-me-in-production
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,6 +1,9 @@
|
|||
# Environment
|
||||
# Environment (legacy — app no longer reads .env, but guard against accidental commit)
|
||||
.env
|
||||
|
||||
# Stale build artifact from go build . (use go build ./src/cmd instead)
|
||||
cmd
|
||||
|
||||
# Build artifacts (binary is distributed with the repo)
|
||||
bin/*.log
|
||||
bin/db.sqlite
|
||||
|
|
|
|||
|
|
@ -1,16 +1,26 @@
|
|||
# inBOXER Configuration
|
||||
# ======================
|
||||
# Single configuration file — all settings including secrets live here.
|
||||
# Replace placeholder values before deploying to production.
|
||||
|
||||
# Server configuration
|
||||
server:
|
||||
port: 8080
|
||||
host: "0.0.0.0"
|
||||
session_secret: "change-me-in-production" # Override with APP_SECRET from .env
|
||||
session_secret: "change-me-in-production"
|
||||
|
||||
# Database configuration
|
||||
database:
|
||||
path: "bin/db.sqlite"
|
||||
auto_migrate: true
|
||||
|
||||
# SMTP credentials for sending OTP login emails
|
||||
smtp:
|
||||
host: "your.smtp.host.example.com"
|
||||
port: 587
|
||||
username: "your-email@example.com"
|
||||
password: "your-smtp-password"
|
||||
|
||||
# IMAP configuration (user-specific, stored encrypted in database)
|
||||
imap_defaults:
|
||||
host: "imap.example.com"
|
||||
|
|
@ -22,6 +32,7 @@ imap_defaults:
|
|||
# AI classification configuration
|
||||
ai:
|
||||
model: "deepseek-chat"
|
||||
api_key: "your_deepseek_api_key_here"
|
||||
max_tokens: 1000
|
||||
temperature: 0.1
|
||||
prompt_file: "bin/prompt.txt"
|
||||
|
|
|
|||
BIN
bin/inboxer
BIN
bin/inboxer
Binary file not shown.
5
go.mod
5
go.mod
|
|
@ -3,9 +3,9 @@ module inboxer
|
|||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/emersion/go-imap v1.2.1
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/sessions v1.2.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.17.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/sqlite v1.5.4
|
||||
|
|
@ -13,10 +13,7 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
github.com/emersion/go-imap v1.2.1 // indirect
|
||||
github.com/emersion/go-message v0.15.0 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -1,10 +1,8 @@
|
|||
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
|
||||
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
|
||||
github.com/emersion/go-message v0.15.0 h1:urgKGqt2JAc9NFJcgncQcohHdiYb803YTH9OQwHBHIY=
|
||||
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
|
|
@ -19,8 +17,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
|||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||
|
|
|
|||
57
install.sh
57
install.sh
|
|
@ -113,40 +113,6 @@ sed -i 's|file: "bin/inboxer.log"|file: "'"${LOGS_DIR}"'/inboxer.log"|' "${BIN_D
|
|||
# prompt_file: "bin/prompt.txt" works as-is relative to the working directory,
|
||||
# since WorkingDirectory=/opt/inboxer resolves it to /opt/inboxer/bin/prompt.txt
|
||||
|
||||
# ─── Create .env from template ──────────────────────────────────────────────
|
||||
ENV_FILE="${INSTALL_DIR}/.env"
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
# Prefer .env.example from repo (single source of truth)
|
||||
if [[ -f "${REPO_DIR}/.env.example" ]]; then
|
||||
info "Copying .env.example from repository to ${ENV_FILE} ..."
|
||||
cp "${REPO_DIR}/.env.example" "${ENV_FILE}"
|
||||
else
|
||||
info "Creating .env template at ${ENV_FILE} ..."
|
||||
cat > "${ENV_FILE}" << 'ENVEOF'
|
||||
# inBOXER Environment Configuration
|
||||
# ====================================
|
||||
# Set your credentials below. The service reads these variables on startup.
|
||||
# You can also set them directly in the systemd EnvironmentFile or via
|
||||
# the shell environment (godotenv.Load() checks both the .env file and os.Getenv).
|
||||
|
||||
# DeepSeek API key – used to classify incoming emails
|
||||
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||
|
||||
# SMTP credentials – used to send one-time passwords (OTP) via email
|
||||
SMTP_HOST=your.smtp.host
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@example.com
|
||||
SMTP_PASS=your-smtp-password
|
||||
|
||||
# (Optional) Override the session_secret from config.yaml.
|
||||
# APP_SECRET=change-me-in-production
|
||||
ENVEOF
|
||||
fi
|
||||
info ".env file created."
|
||||
else
|
||||
info ".env already exists, keeping existing file."
|
||||
fi
|
||||
|
||||
# ─── Create systemd service unit ─────────────────────────────────────────────
|
||||
info "Creating systemd service unit at ${SERVICE_FILE} ..."
|
||||
cat > "${SERVICE_FILE}" << UNITEOF
|
||||
|
|
@ -161,7 +127,6 @@ Type=simple
|
|||
User=${SERVICE_USER}
|
||||
Group=${SERVICE_GROUP}
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
EnvironmentFile=${ENV_FILE}
|
||||
ExecStart=${BIN_DIR}/inboxer
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
|
@ -189,8 +154,8 @@ chmod 755 "${BIN_DIR}"
|
|||
chmod 750 "${DATA_DIR}" # database file is sensitive
|
||||
chmod 750 "${LOGS_DIR}"
|
||||
|
||||
# .env must be readable by the service user (and owner-only for secrets)
|
||||
chmod 640 "${ENV_FILE}"
|
||||
# config.yaml contains secrets so restrict access
|
||||
chmod 640 "${BIN_DIR}/config.yaml"
|
||||
|
||||
# ─── Register & start service ───────────────────────────────────────────────
|
||||
info "Reloading systemd daemon..."
|
||||
|
|
@ -227,14 +192,20 @@ info " Configuration: ${BIN_DIR}/config.yaml"
|
|||
info " Prompt file: ${BIN_DIR}/prompt.txt"
|
||||
info " Data (SQLite): ${DATA_DIR}/"
|
||||
info " Logs: ${LOGS_DIR}/"
|
||||
info " Environment: ${ENV_FILE}"
|
||||
echo ""
|
||||
info " Edit the environment file with your credentials:"
|
||||
info " sudo nano ${ENV_FILE}"
|
||||
info " ⚙ Edit config.yaml with your credentials before first start:"
|
||||
info " sudo nano ${BIN_DIR}/config.yaml"
|
||||
info ""
|
||||
info " Required settings:"
|
||||
info " - ai.api_key (DeepSeek API key)"
|
||||
info " - smtp.host/port (SMTP server for OTP emails)"
|
||||
info " - smtp.username (SMTP login)"
|
||||
info " - smtp.password (SMTP password)"
|
||||
info " - server.session_secret (change from the default)"
|
||||
echo ""
|
||||
if grep -q "your_deepseek_api_key_here" "${ENV_FILE}" 2>/dev/null; then
|
||||
warn " ⚠ The .env file still contains placeholder values!"
|
||||
warn " Edit ${ENV_FILE} before the service will function."
|
||||
if grep -q "your_deepseek_api_key_here\|your.smtp.host\|change-me-in-production" "${BIN_DIR}/config.yaml" 2>/dev/null; then
|
||||
warn " ⚠ config.yaml still contains placeholder values!"
|
||||
warn " Edit ${BIN_DIR}/config.yaml before the service will function."
|
||||
echo ""
|
||||
fi
|
||||
info " Service management:"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ func main() {
|
|||
configPath = "bin/config.yaml"
|
||||
}
|
||||
|
||||
cfg, env, err := config.LoadConfig(configPath)
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load configuration: %v", err)
|
||||
}
|
||||
|
|
@ -38,19 +38,19 @@ func main() {
|
|||
}
|
||||
defer database.Close()
|
||||
|
||||
// Initialize SMTP sender
|
||||
// Initialize SMTP sender (credentials from config.yaml)
|
||||
smtpConfig := auth.SMTPConfig{
|
||||
Host: env.SMTPHost,
|
||||
Port: env.SMTPPort,
|
||||
Username: env.SMTPUser,
|
||||
Password: env.SMTPPass,
|
||||
From: env.SMTPUser,
|
||||
Host: cfg.SMTP.Host,
|
||||
Port: cfg.SMTP.Port,
|
||||
Username: cfg.SMTP.Username,
|
||||
Password: cfg.SMTP.Password,
|
||||
From: cfg.SMTP.Username, // From address matches SMTP username
|
||||
}
|
||||
|
||||
// Validate SMTP config (but don't fail if not set - user might configure later)
|
||||
// Validate SMTP config (but don't fail if not set — user might configure later)
|
||||
if err := smtpConfig.ValidateConfig(); err != nil {
|
||||
log.Printf("Warning: SMTP configuration incomplete: %v", err)
|
||||
log.Println("OTP emails will not be sent until SMTP is configured in .env")
|
||||
log.Println("OTP emails will not be sent until SMTP is configured in config.yaml")
|
||||
}
|
||||
|
||||
smtpSender := auth.NewSMTPSender(smtpConfig)
|
||||
|
|
@ -70,7 +70,7 @@ func main() {
|
|||
|
||||
// Initialize AI classifier
|
||||
deepSeekAPI := ai.NewDeepSeekAPI(
|
||||
env.DeepSeekAPIKey,
|
||||
cfg.AI.APIKey,
|
||||
cfg.AI.Model,
|
||||
cfg.AI.MaxTokens,
|
||||
cfg.AI.Temperature,
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ func TestInMemoryOTPStore(t *testing.T) {
|
|||
func TestSMTPSenderValidation(t *testing.T) {
|
||||
config := SMTPConfig{
|
||||
Host: "smtp.example.com",
|
||||
Port: "587",
|
||||
Port: 587,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
From: "user@example.com",
|
||||
|
|
@ -123,11 +123,11 @@ func TestSMTPSenderValidation(t *testing.T) {
|
|||
|
||||
// Test invalid configs
|
||||
invalidConfigs := []SMTPConfig{
|
||||
{Host: "", Port: "587", Username: "user", Password: "pass", From: "user"},
|
||||
{Host: "smtp.example.com", Port: "", Username: "user", Password: "pass", From: "user"},
|
||||
{Host: "smtp.example.com", Port: "587", Username: "", Password: "pass", From: "user"},
|
||||
{Host: "smtp.example.com", Port: "587", Username: "user", Password: "", From: "user"},
|
||||
{Host: "smtp.example.com", Port: "587", Username: "user", Password: "pass", From: ""},
|
||||
{Host: "", Port: 587, Username: "user", Password: "pass", From: "user"},
|
||||
{Host: "smtp.example.com", Port: 0, Username: "user", Password: "pass", From: "user"},
|
||||
{Host: "smtp.example.com", Port: 587, Username: "", Password: "pass", From: "user"},
|
||||
{Host: "smtp.example.com", Port: 587, Username: "user", Password: "", From: "user"},
|
||||
{Host: "smtp.example.com", Port: 587, Username: "user", Password: "pass", From: ""},
|
||||
}
|
||||
|
||||
for _, cfg := range invalidConfigs {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
// SMTPConfig holds SMTP server configuration
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
|
|
@ -51,7 +51,7 @@ func heloHostname(from, smtpHost string) string {
|
|||
// setting the HELO/EHLO hostname explicitly. Some SMTP servers
|
||||
// reject messages when the HELO doesn't match a valid domain.
|
||||
func (s *SMTPSender) sendMail(to []string, msg []byte) error {
|
||||
addr := fmt.Sprintf("%s:%s", s.config.Host, s.config.Port)
|
||||
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
|
||||
helo := heloHostname(s.config.From, s.config.Host)
|
||||
|
||||
// Connect to the SMTP server
|
||||
|
|
@ -159,7 +159,7 @@ func (s *SMTPConfig) ValidateConfig() error {
|
|||
if s.Host == "" {
|
||||
errors = append(errors, "SMTP host is required")
|
||||
}
|
||||
if s.Port == "" {
|
||||
if s.Port == 0 {
|
||||
errors = append(errors, "SMTP port is required")
|
||||
}
|
||||
if s.Username == "" {
|
||||
|
|
|
|||
|
|
@ -5,14 +5,15 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config represents the application configuration
|
||||
// Config represents the application configuration.
|
||||
// All settings live in a single config.yaml — no separate .env file.
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
SMTP SMTPSettings `yaml:"smtp"`
|
||||
IMAP IMAPConfig `yaml:"imap_defaults"`
|
||||
AI AIConfig `yaml:"ai"`
|
||||
Worker WorkerConfig `yaml:"worker"`
|
||||
|
|
@ -33,6 +34,15 @@ type DatabaseConfig struct {
|
|||
AutoMigrate bool `yaml:"auto_migrate"`
|
||||
}
|
||||
|
||||
// SMTPSettings holds SMTP credentials for sending OTP emails.
|
||||
// Previously stored in a separate .env file; now in config.yaml.
|
||||
type SMTPSettings struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
// IMAPConfig holds default IMAP configuration
|
||||
type IMAPConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
|
|
@ -42,12 +52,14 @@ type IMAPConfig struct {
|
|||
PollIntervalMinutes int `yaml:"poll_interval_minutes"`
|
||||
}
|
||||
|
||||
// AIConfig holds AI classification configuration
|
||||
// AIConfig holds AI classification configuration.
|
||||
// api_key was previously in .env; now stored here.
|
||||
type AIConfig struct {
|
||||
Model string `yaml:"model"`
|
||||
MaxTokens int `yaml:"max_tokens"`
|
||||
Temperature float64 `yaml:"temperature"`
|
||||
PromptFile string `yaml:"prompt_file"`
|
||||
APIKey string `yaml:"api_key"`
|
||||
}
|
||||
|
||||
// WorkerConfig holds background worker configuration
|
||||
|
|
@ -78,58 +90,27 @@ type LoggingConfig struct {
|
|||
MaxAgeDays int `yaml:"max_age_days"`
|
||||
}
|
||||
|
||||
// Environment variables
|
||||
type Environment struct {
|
||||
DeepSeekAPIKey string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUser string
|
||||
SMTPPass string
|
||||
AppSecret string
|
||||
}
|
||||
|
||||
// LoadConfig loads configuration from YAML file and environment variables
|
||||
func LoadConfig(configPath string) (*Config, *Environment, error) {
|
||||
// Load .env file
|
||||
if err := godotenv.Load(); err != nil {
|
||||
// It's okay if .env doesn't exist in production
|
||||
fmt.Printf("Note: .env file not found: %v\n", err)
|
||||
}
|
||||
|
||||
// Read YAML config
|
||||
// LoadConfig loads configuration from a YAML file.
|
||||
// No .env file is consulted — everything is in config.yaml.
|
||||
func LoadConfig(configPath string) (*Config, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||
}
|
||||
|
||||
// Load environment variables
|
||||
env := &Environment{
|
||||
DeepSeekAPIKey: os.Getenv("DEEPSEEK_API_KEY"),
|
||||
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||
SMTPPort: os.Getenv("SMTP_PORT"),
|
||||
SMTPUser: os.Getenv("SMTP_USER"),
|
||||
SMTPPass: os.Getenv("SMTP_PASS"),
|
||||
AppSecret: os.Getenv("APP_SECRET"),
|
||||
}
|
||||
|
||||
// Use APP_SECRET from environment if available
|
||||
if env.AppSecret != "" {
|
||||
config.Server.SessionSecret = env.AppSecret
|
||||
}
|
||||
|
||||
return &config, env, nil
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// GetDefaultConfigPath returns the default path to config.yaml
|
||||
// GetDefaultConfigPath returns the default path to config.yaml.
|
||||
// It looks for config.yaml in the same directory as the executable.
|
||||
func GetDefaultConfigPath() (string, error) {
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
// Fallback to relative path
|
||||
return "bin/config.yaml", nil
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue