add support for SQLite as an alternative database backend
Some checks failed
Build & Publish Docker Image / build-and-push (push) Failing after 33s
Some checks failed
Build & Publish Docker Image / build-and-push (push) Failing after 33s
Implement SQLite support using the pure Go `modernc.org/sqlite` driver and update the migration system to handle driver-specific schemas. Users can now choose between MySQL and SQLite by setting the `DB_DRIVER` environment variable.
This commit is contained in:
@@ -21,6 +21,7 @@ type RateLimitConfig struct {
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
Driver string
|
||||
DSN string
|
||||
Database string
|
||||
APIKey string
|
||||
@@ -80,9 +81,19 @@ func load() *Config {
|
||||
connMaxLifetime = 5
|
||||
}
|
||||
|
||||
dbName := os.Getenv("DATABASE_NAME")
|
||||
if dbName == "" {
|
||||
panic("DATABASE_NAME environment variable is required")
|
||||
driver := os.Getenv("DB_DRIVER")
|
||||
if driver == "" {
|
||||
driver = "mysql"
|
||||
}
|
||||
|
||||
var dbName string
|
||||
if driver == "sqlite" {
|
||||
dbName = "main"
|
||||
} else {
|
||||
dbName = os.Getenv("DATABASE_NAME")
|
||||
if dbName == "" {
|
||||
panic("DATABASE_NAME environment variable is required")
|
||||
}
|
||||
}
|
||||
|
||||
if os.Getenv("DB_DSN") == "" {
|
||||
@@ -91,6 +102,7 @@ func load() *Config {
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
Driver: driver,
|
||||
DSN: os.Getenv("DB_DSN"),
|
||||
Database: dbName,
|
||||
APIKey: apiKey,
|
||||
|
||||
@@ -1,23 +1,41 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"emly-api-go/internal/config"
|
||||
)
|
||||
|
||||
func Connect(cfg *config.Config) (*sqlx.DB, error) {
|
||||
db, err := sqlx.Connect("mysql", cfg.DSN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var db *sqlx.DB
|
||||
var err error
|
||||
|
||||
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
db.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Minute)
|
||||
switch cfg.Driver {
|
||||
case "sqlite":
|
||||
db, err = sqlx.Connect("sqlite", cfg.DSN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Enable foreign key support (disabled by default in SQLite)
|
||||
if _, err = db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||
return nil, fmt.Errorf("sqlite: enable foreign_keys: %w", err)
|
||||
}
|
||||
case "mysql":
|
||||
db, err = sqlx.Connect("mysql", cfg.DSN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
db.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Minute)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported DB_DRIVER %q: must be mysql or sqlite", cfg.Driver)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
//go:embed init.sql migrations/*.json migrations/*.sql
|
||||
//go:embed mysql sqlite
|
||||
var migrationsFS embed.FS
|
||||
|
||||
type taskFile struct {
|
||||
@@ -31,62 +31,43 @@ type condition struct {
|
||||
Index string `json:"index,omitempty"`
|
||||
}
|
||||
|
||||
// Migrate reads migrations/tasks.json and executes every task whose
|
||||
// conditions are ALL satisfied (i.e. logical AND).
|
||||
func Migrate(db *sqlx.DB, dbName string) error {
|
||||
// If the database has no tables at all, bootstrap with init.sql.
|
||||
empty, err := schemaIsEmpty(db, dbName)
|
||||
// Migrate reads the driver-specific migrations and applies them.
|
||||
func Migrate(db *sqlx.DB, dbName string, driver string) error {
|
||||
empty, err := schemaIsEmpty(db, dbName, driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schema: check empty: %w", err)
|
||||
}
|
||||
if empty {
|
||||
log.Println("[migrate] empty schema detected – running init.sql")
|
||||
initSQL, err := migrationsFS.ReadFile("init.sql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("schema: read init.sql: %w", err)
|
||||
if err := runInitSQL(db, driver); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stmt := range splitStatements(string(initSQL)) {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("schema: exec init.sql: %w\nSQL: %s", err, stmt)
|
||||
}
|
||||
}
|
||||
log.Println("[migrate] init.sql applied – base schema created")
|
||||
} else {
|
||||
log.Println("[migrate] checking if tables exist")
|
||||
// Check if the tables are there or not
|
||||
var tableNames []string
|
||||
tableNames := []string{"bug_reports", "bug_report_files", "rate_limit_hwid", "user", "session"}
|
||||
var foundTables []string
|
||||
tableNames = append(tableNames, "bug_reports", "bug_report_files", "rate_limit_hwid", "user", "session")
|
||||
for _, tableName := range tableNames {
|
||||
found, err := tableExists(db, dbName, tableName)
|
||||
found, err := tableExists(db, dbName, tableName, driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schema: check table %s: %w", tableName, err)
|
||||
}
|
||||
if !found {
|
||||
log.Printf("[migrate] warning: expected table %s not found – schema may be in an inconsistent state", tableName)
|
||||
log.Printf("[migrate] warning: expected table %s not found", tableName)
|
||||
continue
|
||||
}
|
||||
foundTables = append(foundTables, tableName)
|
||||
}
|
||||
if len(foundTables) != len(tableNames) {
|
||||
log.Printf("[migrate] warning: expected %d tables, found %d", len(tableNames), len(foundTables))
|
||||
log.Printf("[migrate] info: running init.sql")
|
||||
initSQL, err := migrationsFS.ReadFile("init.sql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("schema: read init.sql: %w", err)
|
||||
log.Printf("[migrate] warning: expected %d tables, found %d – running init.sql", len(tableNames), len(foundTables))
|
||||
if err := runInitSQL(db, driver); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stmt := range splitStatements(string(initSQL)) {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("schema: exec init.sql: %w\nSQL: %s", err, stmt)
|
||||
}
|
||||
}
|
||||
log.Println("[migrate] init.sql applied – base schema created")
|
||||
} else {
|
||||
log.Println("[migrate] all expected tables found – skipping init.sql")
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := migrationsFS.ReadFile("migrations/tasks.json")
|
||||
raw, err := migrationsFS.ReadFile(driver + "/migrations/tasks.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("schema: read tasks.json: %w", err)
|
||||
}
|
||||
@@ -97,7 +78,7 @@ func Migrate(db *sqlx.DB, dbName string) error {
|
||||
}
|
||||
|
||||
for _, t := range tf.Tasks {
|
||||
needed, err := shouldRun(db, dbName, t.Conditions)
|
||||
needed, err := shouldRun(db, dbName, t.Conditions, driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schema: evaluate conditions for %s: %w", t.ID, err)
|
||||
}
|
||||
@@ -106,7 +87,7 @@ func Migrate(db *sqlx.DB, dbName string) error {
|
||||
continue
|
||||
}
|
||||
|
||||
sqlBytes, err := migrationsFS.ReadFile("migrations/" + t.SQLFile)
|
||||
sqlBytes, err := migrationsFS.ReadFile(driver + "/migrations/" + t.SQLFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schema: read %s: %w", t.SQLFile, err)
|
||||
}
|
||||
@@ -122,11 +103,25 @@ func Migrate(db *sqlx.DB, dbName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runInitSQL(db *sqlx.DB, driver string) error {
|
||||
initSQL, err := migrationsFS.ReadFile(driver + "/init.sql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("schema: read init.sql: %w", err)
|
||||
}
|
||||
for _, stmt := range splitStatements(string(initSQL)) {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("schema: exec init.sql: %w\nSQL: %s", err, stmt)
|
||||
}
|
||||
}
|
||||
log.Println("[migrate] init.sql applied – base schema created")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- Condition evaluator ----------
|
||||
|
||||
func shouldRun(db *sqlx.DB, dbName string, conds []condition) (bool, error) {
|
||||
func shouldRun(db *sqlx.DB, dbName string, conds []condition, driver string) (bool, error) {
|
||||
for _, c := range conds {
|
||||
met, err := evaluate(db, dbName, c)
|
||||
met, err := evaluate(db, dbName, c, driver)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -137,81 +132,186 @@ func shouldRun(db *sqlx.DB, dbName string, conds []condition) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func evaluate(db *sqlx.DB, dbName string, c condition) (bool, error) {
|
||||
func evaluate(db *sqlx.DB, dbName string, c condition, driver string) (bool, error) {
|
||||
switch c.Type {
|
||||
case "column_not_exists":
|
||||
exists, err := columnExists(db, dbName, c.Table, c.Column)
|
||||
exists, err := columnExists(db, dbName, c.Table, c.Column, driver)
|
||||
return !exists, err
|
||||
|
||||
case "column_exists":
|
||||
return columnExists(db, dbName, c.Table, c.Column)
|
||||
return columnExists(db, dbName, c.Table, c.Column, driver)
|
||||
|
||||
case "index_not_exists":
|
||||
exists, err := indexExists(db, dbName, c.Table, c.Index)
|
||||
exists, err := indexExists(db, dbName, c.Table, c.Index, driver)
|
||||
return !exists, err
|
||||
|
||||
case "index_exists":
|
||||
return indexExists(db, dbName, c.Table, c.Index)
|
||||
return indexExists(db, dbName, c.Table, c.Index, driver)
|
||||
|
||||
case "table_not_exists":
|
||||
exists, err := tableExists(db, dbName, c.Table)
|
||||
exists, err := tableExists(db, dbName, c.Table, driver)
|
||||
return !exists, err
|
||||
|
||||
case "table_exists":
|
||||
return tableExists(db, dbName, c.Table)
|
||||
return tableExists(db, dbName, c.Table, driver)
|
||||
|
||||
default:
|
||||
return false, fmt.Errorf("unknown condition type: %s", c.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func columnExists(db *sqlx.DB, dbName, table, column string) (bool, error) {
|
||||
// ---------- MySQL condition checks ----------
|
||||
|
||||
func columnExistsMySQL(db *sqlx.DB, dbName, table, column string) (bool, error) {
|
||||
var count int
|
||||
err := db.Get(&count,
|
||||
`SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = ?
|
||||
AND TABLE_NAME = ?
|
||||
AND COLUMN_NAME = ?`, dbName, table, column)
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
|
||||
dbName, table, column)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func indexExists(db *sqlx.DB, dbName, table, index string) (bool, error) {
|
||||
func indexExistsMySQL(db *sqlx.DB, dbName, table, index string) (bool, error) {
|
||||
var count int
|
||||
err := db.Get(&count,
|
||||
`SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = ?
|
||||
AND TABLE_NAME = ?
|
||||
AND INDEX_NAME = ?`, dbName, table, index)
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ?`,
|
||||
dbName, table, index)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func tableExists(db *sqlx.DB, dbName, table string) (bool, error) {
|
||||
func tableExistsMySQL(db *sqlx.DB, dbName, table string) (bool, error) {
|
||||
var count int
|
||||
err := db.Get(&count,
|
||||
`SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = ?
|
||||
AND TABLE_NAME = ?`, dbName, table)
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`,
|
||||
dbName, table)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func schemaIsEmpty(db *sqlx.DB, dbName string) (bool, error) {
|
||||
func schemaIsEmptyMySQL(db *sqlx.DB, dbName string) (bool, error) {
|
||||
var count int
|
||||
err := db.Get(&count,
|
||||
`SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = ?`, dbName)
|
||||
`SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?`, dbName)
|
||||
return count == 0, err
|
||||
}
|
||||
|
||||
// splitStatements splits a SQL blob on ";" respecting only top-level
|
||||
// semicolons (good enough for simple ALTER / CREATE statements).
|
||||
// ---------- SQLite condition checks ----------
|
||||
|
||||
func columnExistsSQLite(db *sqlx.DB, table, column string) (bool, error) {
|
||||
var count int
|
||||
// pragma_table_info is a table-valued function available since SQLite 3.16.0
|
||||
err := db.Get(&count,
|
||||
fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table),
|
||||
column)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func indexExistsSQLite(db *sqlx.DB, table, index string) (bool, error) {
|
||||
var count int
|
||||
err := db.Get(&count,
|
||||
`SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND tbl_name=? AND name=?`,
|
||||
table, index)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func tableExistsSQLite(db *sqlx.DB, table string) (bool, error) {
|
||||
var count int
|
||||
err := db.Get(&count,
|
||||
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func schemaIsEmptySQLite(db *sqlx.DB) (bool, error) {
|
||||
var count int
|
||||
err := db.Get(&count, `SELECT COUNT(*) FROM sqlite_master WHERE type='table'`)
|
||||
return count == 0, err
|
||||
}
|
||||
|
||||
// ---------- Driver-dispatched wrappers ----------
|
||||
|
||||
func columnExists(db *sqlx.DB, dbName, table, column, driver string) (bool, error) {
|
||||
if driver == "sqlite" {
|
||||
return columnExistsSQLite(db, table, column)
|
||||
}
|
||||
return columnExistsMySQL(db, dbName, table, column)
|
||||
}
|
||||
|
||||
func indexExists(db *sqlx.DB, dbName, table, index, driver string) (bool, error) {
|
||||
if driver == "sqlite" {
|
||||
return indexExistsSQLite(db, table, index)
|
||||
}
|
||||
return indexExistsMySQL(db, dbName, table, index)
|
||||
}
|
||||
|
||||
func tableExists(db *sqlx.DB, dbName, table, driver string) (bool, error) {
|
||||
if driver == "sqlite" {
|
||||
return tableExistsSQLite(db, table)
|
||||
}
|
||||
return tableExistsMySQL(db, dbName, table)
|
||||
}
|
||||
|
||||
func schemaIsEmpty(db *sqlx.DB, dbName, driver string) (bool, error) {
|
||||
if driver == "sqlite" {
|
||||
return schemaIsEmptySQLite(db)
|
||||
}
|
||||
return schemaIsEmptyMySQL(db, dbName)
|
||||
}
|
||||
|
||||
// splitStatements splits a SQL blob on top-level ";" only, respecting
|
||||
// BEGIN...END blocks (e.g. triggers) so their inner semicolons are not split.
|
||||
func splitStatements(sql string) []string {
|
||||
raw := strings.Split(sql, ";")
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, s := range raw {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
var out []string
|
||||
var buf strings.Builder
|
||||
depth := 0
|
||||
n := len(sql)
|
||||
|
||||
for i := 0; i < n; {
|
||||
c := sql[i]
|
||||
|
||||
// Collect whole identifier tokens to detect BEGIN / END keywords.
|
||||
if isIdentStart(c) {
|
||||
j := i
|
||||
for j < n && isIdentChar(sql[j]) {
|
||||
j++
|
||||
}
|
||||
word := strings.ToUpper(sql[i:j])
|
||||
switch word {
|
||||
case "BEGIN":
|
||||
depth++
|
||||
case "END":
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
}
|
||||
buf.WriteString(sql[i:j])
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
if c == ';' && depth == 0 {
|
||||
if stmt := strings.TrimSpace(buf.String()); stmt != "" {
|
||||
out = append(out, stmt)
|
||||
}
|
||||
buf.Reset()
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
buf.WriteByte(c)
|
||||
i++
|
||||
}
|
||||
|
||||
if stmt := strings.TrimSpace(buf.String()); stmt != "" {
|
||||
out = append(out, stmt)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isIdentStart(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
|
||||
}
|
||||
|
||||
func isIdentChar(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
|
||||
}
|
||||
|
||||
65
internal/database/schema/sqlite/init.sql
Normal file
65
internal/database/schema/sqlite/init.sql
Normal file
@@ -0,0 +1,65 @@
|
||||
CREATE TABLE IF NOT EXISTS bug_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
hwid TEXT NOT NULL DEFAULT '',
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
os_user TEXT NOT NULL DEFAULT '',
|
||||
submitter_ip TEXT NOT NULL DEFAULT '',
|
||||
system_info TEXT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'new' CHECK(status IN ('new','in_review','resolved','closed')),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_status ON bug_reports(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_hwid ON bug_reports(hwid);
|
||||
CREATE INDEX IF NOT EXISTS idx_hostname ON bug_reports(hostname);
|
||||
CREATE INDEX IF NOT EXISTS idx_os_user ON bug_reports(os_user);
|
||||
CREATE INDEX IF NOT EXISTS idx_created_at ON bug_reports(created_at);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_bug_reports_updated_at
|
||||
AFTER UPDATE ON bug_reports
|
||||
FOR EACH ROW
|
||||
WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE bug_reports SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bug_report_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
report_id INTEGER NOT NULL,
|
||||
file_role TEXT NOT NULL CHECK(file_role IN ('screenshot','mail_file','localstorage','config','system_info')),
|
||||
filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
data BLOB NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (report_id) REFERENCES bug_reports(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_report_id ON bug_report_files(report_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rate_limit_hwid (
|
||||
hwid TEXT PRIMARY KEY,
|
||||
window_start DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
displayname TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
3
internal/database/schema/sqlite/migrations/tasks.json
Normal file
3
internal/database/schema/sqlite/migrations/tasks.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"tasks": []
|
||||
}
|
||||
Reference in New Issue
Block a user