502 lines
18 KiB
Go
502 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case "GET":
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Write([]byte(adminLoginHTML("ru")))
|
|
case "POST":
|
|
if err := r.ParseForm(); err != nil {
|
|
jsonErr(w, 400, "bad form")
|
|
return
|
|
}
|
|
user := r.FormValue("username")
|
|
pass := r.FormValue("password")
|
|
if !s.cfg.CheckAdmin(user, pass) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(401)
|
|
w.Write([]byte("<html><body><h1>401 Unauthorized</h1><a href='/admin/login'>Try again</a></body></html>"))
|
|
return
|
|
}
|
|
tok := s.tokens.Create()
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "session", Value: tok, Path: "/admin",
|
|
HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
|
MaxAge: 86400,
|
|
})
|
|
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
|
default:
|
|
jsonErr(w, 405, "method not allowed")
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
|
|
if !s.requireAdmin(w, r) {
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
// Fetch data for dashboard.
|
|
var deviceCount, opsCount int
|
|
s.db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&deviceCount)
|
|
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
|
|
|
|
// Load SMTP config for display.
|
|
smtpHost := s.smtpGet("smtp_host")
|
|
smtpPort := s.smtpGet("smtp_port")
|
|
smtpUser := s.smtpGet("smtp_user")
|
|
smtpFrom := s.smtpGet("smtp_from")
|
|
smtpSecurity := s.smtpGet("smtp_security")
|
|
srvURL := s.smtpGet("server_url")
|
|
|
|
html := `<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Verstak Sync — Admin</title>
|
|
<style>
|
|
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:860px;margin:0 auto}
|
|
a{color:#6366f1}
|
|
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
|
|
h2{margin-top:24px;font-size:16px}
|
|
.stat{background:#1a1a28;border:1px solid #2a2a3c;padding:12px 16px;border-radius:8px;margin:8px 0}
|
|
table{width:100%%;border-collapse:collapse;margin-top:8px}
|
|
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
|
|
th{font-size:12px;color:#888;text-transform:uppercase}
|
|
.key-cell{max-width:360px;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:12px;color:#b0b0c0}
|
|
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
|
|
.btn:hover{background:#222233}
|
|
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
|
|
.btn-primary:hover{background:#4f46e5}
|
|
.btn-danger{color:#ff6b6b;border-color:#4a2222}
|
|
.btn-danger:hover{background:#3a2222}
|
|
.copy-btn{padding:2px 8px;font-size:11px;margin-left:6px}
|
|
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;margin:0;box-sizing:border-box}
|
|
input:focus{outline:none;border-color:#6366f1}
|
|
.form-row{display:flex;gap:8px;margin-bottom:8px;align-items:center}
|
|
.form-row label{font-size:12px;color:#888;min-width:80px;flex-shrink:0}
|
|
.form-row input{flex:1}
|
|
.toolbar{display:flex;gap:8px;margin:16px 0;flex-wrap:wrap}
|
|
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100}
|
|
.modal{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:24px;width:420px;max-width:90vw;position:relative;max-height:80vh;overflow-y:auto}
|
|
.modal h2{margin-top:0}
|
|
.modal-close{position:absolute;top:10px;right:14px;font-size:20px;cursor:pointer;background:none;border:none;color:#888}
|
|
.modal-close:hover{color:#e4e4ef}
|
|
pre{background:#13131f;border:1px solid #2a2a3c;border-radius:8px;padding:12px;overflow-x:auto;white-space:pre-wrap}
|
|
</style>
|
|
</head><body>
|
|
<h1>Verstak Sync Server</h1>
|
|
<div style="display:flex;gap:20px;flex-wrap:wrap">
|
|
<div class="stat" style="margin:0"><strong>Устройств:</strong> <span id="dev-count">0</span></div>
|
|
<div class="stat" style="margin:0"><strong>Операций:</strong> <span id="op-count">0</span></div>
|
|
</div>
|
|
|
|
<div class="toolbar">
|
|
<button class="btn btn-primary" onclick="openSMTP()">Настройка SMTP</button>
|
|
<a href="/admin/users" style="text-decoration:none"><button class="btn" type="button">Пользователи</button></a>
|
|
<button class="btn" onclick="openHealth()">Health check</button>
|
|
</div>
|
|
|
|
<h2>Устройства</h2>
|
|
<div id="devices"></div>
|
|
<script>
|
|
fetch('/admin/api/devices').then(r=>r.json()).then(devices=>{
|
|
const div=document.getElementById('devices')
|
|
if(!devices.length){div.innerHTML='<p>Нет устройств</p>';return}
|
|
div.innerHTML='<table><tr><th>Устройство</th><th>Пользователь</th><th>Версия</th><th>Статус</th><th>Активность</th><th></th></tr>'+
|
|
devices.map(d=>{
|
|
var status=d.revoked_at?'<span style="color:#ff6b6b">Отозвано</span>':'<span style="color:#34d399">Активно</span>'
|
|
var ls=d.last_seen||'—'
|
|
var revBtn=''
|
|
if(!d.revoked_at) revBtn='<button class="btn btn-danger" onclick="revokeDevice(\''+d.id+'\')">Отозвать</button>'
|
|
return '<tr><td>'+d.name+'</td><td>'+(d.user||'—')+'</td><td>'+(d.client_version||'—')+'</td><td>'+status+'</td><td>'+ls+'</td><td>'+revBtn+'</td></tr>'
|
|
}).join('')+'</table>'
|
|
document.getElementById('dev-count').textContent=devices.length
|
|
})
|
|
fetch('/admin/api/stats').then(r=>r.json()).then(stats=>{
|
|
document.getElementById('op-count').textContent=stats.ops||'0'
|
|
})
|
|
function revokeDevice(id){
|
|
if(!confirm('Отозвать устройство?'))return
|
|
fetch('/admin/api/keys/'+id,{method:'DELETE'}).then(()=>location.reload())
|
|
}
|
|
function openSMTP(){document.getElementById('smtp-modal').style.display='flex';document.getElementById('smtp-test-result').textContent=''}
|
|
function closeSMTP(e){if(!e||e.target.id==='smtp-modal')document.getElementById('smtp-modal').style.display='none'}
|
|
function openHealth(){var m=document.getElementById('health-modal');m.style.display='flex';document.getElementById('health-result').textContent='Загрузка...';fetch('/api/v1/health').then(function(r){return r.text()}).then(function(t){document.getElementById('health-result').textContent=t})}
|
|
function closeHealth(e){if(!e||e.target.id==='health-modal')document.getElementById('health-modal').style.display='none'}
|
|
function testSMTP(){
|
|
var f=document.querySelector('#smtp-modal form')
|
|
var fd=new FormData(f)
|
|
var obj={};for(var e of fd.entries()){obj[e[0]]=e[1]}
|
|
var r=document.getElementById('smtp-test-result')
|
|
r.textContent='⏳ Тестируем...';r.style.color='#888'
|
|
fetch('/admin/api/smtp/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(obj)}).then(function(r2){return r2.json()}).then(function(d){
|
|
r.textContent=d.ok?'✓ Тест пройден':'✗ '+d.error
|
|
r.style.color=d.ok?'#4ade80':'#ff6b6b'
|
|
}).catch(function(e){r.textContent='✗ '+e;r.style.color='#ff6b6b'})
|
|
}
|
|
</script>
|
|
|
|
<div id="smtp-modal" class="modal-overlay" style="display:none" onclick="closeSMTP(event)">
|
|
<div class="modal">
|
|
<button class="modal-close" onclick="closeSMTP()">×</button>
|
|
<h2>SMTP (для писем)</h2>
|
|
<form action="/admin/api/smtp" method="POST">
|
|
<div class="form-row"><label>Сервер</label><input name="smtp_host" value="` + smtpHost + `" placeholder="smtp.example.com"></div>
|
|
<div class="form-row"><label>Порт</label><input name="smtp_port" value="` + smtpPort + `" placeholder="587"></div>
|
|
<div class="form-row"><label>Тип</label><select name="smtp_security" style="font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;flex:1;box-sizing:border-box">
|
|
<option value="starttls"` + sel(smtpSecurity, "starttls") + `>STARTTLS</option>
|
|
<option value="tls"` + sel(smtpSecurity, "tls") + `>TLS</option>
|
|
<option value="none"` + sel(smtpSecurity, "none") + `>Без шифрования</option>
|
|
</select></div>
|
|
<div class="form-row"><label>Логин</label><input name="smtp_user" value="` + smtpUser + `" placeholder="user@example.com"></div>
|
|
<div class="form-row"><label>Пароль</label><input type="password" name="smtp_pass" placeholder="••••••••"></div>
|
|
<div class="form-row"><label>От кого</label><input name="smtp_from" value="` + smtpFrom + `" placeholder="noreply@example.com"></div>
|
|
<div class="form-row"><label>URL сервера</label><input name="server_url" value="` + srvURL + `" placeholder="https://example.com:47732"></div>
|
|
<div style="margin-top:12px;display:flex;gap:8px;align-items:center">
|
|
<button class="btn btn-primary">Сохранить SMTP</button>
|
|
<button class="btn" type="button" onclick="testSMTP()">Test</button>
|
|
<span id="smtp-test-result" style="font-size:12px"></span>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="health-modal" class="modal-overlay" style="display:none" onclick="closeHealth(event)">
|
|
<div class="modal">
|
|
<button class="modal-close" onclick="closeHealth()">×</button>
|
|
<h2>Health check</h2>
|
|
<pre id="health-result">Загрузка...</pre>
|
|
</div>
|
|
</div>
|
|
_ = smtpURL
|
|
_ = smtpUser
|
|
_ = smtpFrom
|
|
_ = smtpSecurity
|
|
_ = smtpHost
|
|
_ = smtpPort
|
|
|
|
</body></html>`
|
|
w.Write([]byte(html))
|
|
}
|
|
|
|
func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
|
if !s.requireAdmin(w, r) {
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Write([]byte(adminUsersHTML("ru")))
|
|
}
|
|
|
|
func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
|
|
if !s.requireAdmin(w, r) {
|
|
return
|
|
}
|
|
var opsCount int
|
|
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
|
|
jsonOK(w, map[string]int{"ops": opsCount})
|
|
}
|
|
|
|
func (s *Server) handleAdminSMTPTest(w http.ResponseWriter, r *http.Request) {
|
|
if !s.requireAdmin(w, r) {
|
|
return
|
|
}
|
|
var req struct {
|
|
Host string `json:"smtp_host"`
|
|
Port string `json:"smtp_port"`
|
|
User string `json:"smtp_user"`
|
|
Pass string `json:"smtp_pass"`
|
|
Security string `json:"smtp_security"`
|
|
From string `json:"smtp_from"`
|
|
To string `json:"test_to"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonErr(w, 400, "bad json")
|
|
return
|
|
}
|
|
host := req.Host
|
|
port := req.Port
|
|
user := req.User
|
|
pass := req.Pass
|
|
security := req.Security
|
|
from := req.From
|
|
to := req.To
|
|
if to == "" {
|
|
to = from
|
|
}
|
|
if host == "" || port == "" || from == "" {
|
|
jsonOK(w, map[string]interface{}{"ok": false, "error": "host, port and from required"})
|
|
return
|
|
}
|
|
if err := s.smtpTest(host, port, user, pass, security, from, to); err != nil {
|
|
jsonOK(w, map[string]interface{}{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
jsonOK(w, map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleAdminAPI(w http.ResponseWriter, r *http.Request) {
|
|
if !s.requireAdmin(w, r) {
|
|
return
|
|
}
|
|
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
|
|
|
switch {
|
|
case path == "/api/devices" && r.Method == "GET":
|
|
rows, err := s.db.Query(`
|
|
SELECT d.id, d.name, d.client_version, COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at,
|
|
COALESCE(u.username,'')
|
|
FROM server_devices d
|
|
LEFT JOIN server_users u ON u.id = d.user_id
|
|
ORDER BY d.created_at DESC`)
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
type devDTO struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
ClientVersion string `json:"client_version"`
|
|
LastSeen string `json:"last_seen"`
|
|
RevokedAt string `json:"revoked_at"`
|
|
CreatedAt string `json:"created_at"`
|
|
User string `json:"user"`
|
|
}
|
|
var out []devDTO
|
|
for rows.Next() {
|
|
var d devDTO
|
|
rows.Scan(&d.ID, &d.Name, &d.ClientVersion, &d.LastSeen, &d.RevokedAt, &d.CreatedAt, &d.User)
|
|
out = append(out, d)
|
|
}
|
|
jsonOK(w, out)
|
|
|
|
case path == "/api/keys" && r.Method == "GET":
|
|
rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at")
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
var out []map[string]string
|
|
for rows.Next() {
|
|
var id, name, key string
|
|
rows.Scan(&id, &name, &key)
|
|
out = append(out, map[string]string{"id": id, "name": name, "api_key": key})
|
|
}
|
|
jsonOK(w, out)
|
|
|
|
case path == "/api/keys" && r.Method == "POST":
|
|
if err := r.ParseForm(); err != nil {
|
|
jsonErr(w, 400, "bad form")
|
|
return
|
|
}
|
|
name := r.FormValue("name")
|
|
if name == "" {
|
|
jsonErr(w, 400, "name required")
|
|
return
|
|
}
|
|
b := make([]byte, 20)
|
|
rand.Read(b)
|
|
apiKey := hex.EncodeToString(b)
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
_, err := s.db.Exec(
|
|
"INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
apiKey[:12], name, apiKey, now, now,
|
|
)
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
|
|
|
case strings.HasPrefix(path, "/api/keys/") && r.Method == "DELETE":
|
|
id := strings.TrimPrefix(path, "/api/keys/")
|
|
_, err := s.db.Exec("DELETE FROM server_devices WHERE id=?", id)
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
s.db.Exec("DELETE FROM server_user_devices WHERE device_id=?", id)
|
|
jsonOK(w, map[string]string{"status": "deleted"})
|
|
|
|
case path == "/api/smtp" && r.Method == "POST":
|
|
if err := r.ParseForm(); err != nil {
|
|
jsonErr(w, 400, "bad form")
|
|
return
|
|
}
|
|
for _, key := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_security", "smtp_from", "server_url"} {
|
|
val := r.FormValue(key)
|
|
if val != "" {
|
|
s.smtpSet(key, val)
|
|
}
|
|
}
|
|
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
|
|
|
case path == "/api/users" && r.Method == "GET":
|
|
filter := r.URL.Query().Get("filter")
|
|
sort := r.URL.Query().Get("sort")
|
|
order := r.URL.Query().Get("order")
|
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
|
perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 || perPage > 100 {
|
|
perPage = 20
|
|
}
|
|
where := ""
|
|
var args []interface{}
|
|
if filter != "" {
|
|
where = " WHERE u.username LIKE ?"
|
|
args = append(args, "%"+filter+"%")
|
|
}
|
|
validSorts := map[string]string{
|
|
"username": "u.username",
|
|
"email": "u.email",
|
|
"confirmed": "u.confirmed",
|
|
"blocked": "u.blocked",
|
|
"created_at": "u.created_at",
|
|
"last_seen": "u.last_seen",
|
|
"devices": "devices",
|
|
}
|
|
orderClause := "u.created_at DESC"
|
|
if col, ok := validSorts[sort]; ok {
|
|
if order != "asc" {
|
|
order = "desc"
|
|
}
|
|
orderClause = col + " " + order
|
|
}
|
|
// Count total.
|
|
var total int
|
|
countSQL := "SELECT COUNT(*) FROM server_users u" + where
|
|
s.db.QueryRow(countSQL, args...).Scan(&total)
|
|
// Fetch page.
|
|
offset := (page - 1) * perPage
|
|
sql := `SELECT u.id, u.username, u.email, u.confirmed, u.blocked, u.last_seen, u.created_at,
|
|
COALESCE((SELECT COUNT(*) FROM server_user_devices ud JOIN server_devices d ON d.id=ud.device_id WHERE ud.user_id=u.id),0) AS devices
|
|
FROM server_users u` + where + ` ORDER BY ` + orderClause + ` LIMIT ? OFFSET ?`
|
|
args = append(args, perPage, offset)
|
|
rows, err := s.db.Query(sql, args...)
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
type userRow struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
Confirmed int `json:"confirmed"`
|
|
Blocked int `json:"blocked"`
|
|
LastSeen string `json:"last_seen"`
|
|
CreatedAt string `json:"created_at"`
|
|
Devices int `json:"devices"`
|
|
}
|
|
var users []userRow
|
|
for rows.Next() {
|
|
var u userRow
|
|
var lastSeen *string
|
|
rows.Scan(&u.ID, &u.Username, &u.Email, &u.Confirmed, &u.Blocked, &lastSeen, &u.CreatedAt, &u.Devices)
|
|
if lastSeen != nil {
|
|
u.LastSeen = *lastSeen
|
|
}
|
|
users = append(users, u)
|
|
}
|
|
jsonOK(w, map[string]interface{}{
|
|
"users": users,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": perPage,
|
|
})
|
|
|
|
case strings.HasPrefix(path, "/api/users/") && r.Method == "POST":
|
|
sub := strings.TrimPrefix(path, "/api/users/")
|
|
if strings.HasSuffix(sub, "/block") {
|
|
id := strings.TrimSuffix(sub, "/block")
|
|
id = strings.TrimSuffix(id, "/")
|
|
var blocked int
|
|
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked)
|
|
newVal := 1
|
|
if blocked != 0 {
|
|
newVal = 0
|
|
}
|
|
s.db.Exec("UPDATE server_users SET blocked=? WHERE id=?", newVal, id)
|
|
jsonOK(w, map[string]interface{}{"status": "ok", "blocked": newVal})
|
|
return
|
|
}
|
|
if strings.HasSuffix(sub, "/reset-password") {
|
|
id := strings.TrimSuffix(sub, "/reset-password")
|
|
id = strings.TrimSuffix(id, "/")
|
|
b := make([]byte, 12)
|
|
rand.Read(b)
|
|
newPass := hex.EncodeToString(b)
|
|
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
|
|
_, err := s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id)
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, map[string]interface{}{"status": "ok", "new_password": newPass})
|
|
return
|
|
}
|
|
if strings.HasSuffix(sub, "/edit") {
|
|
id := strings.TrimSuffix(sub, "/edit")
|
|
id = strings.TrimSuffix(id, "/")
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonErr(w, 400, "bad json")
|
|
return
|
|
}
|
|
if req.Username == "" || req.Email == "" {
|
|
jsonErr(w, 400, "username and email required")
|
|
return
|
|
}
|
|
_, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", req.Username, strings.ToLower(req.Email), id)
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, map[string]interface{}{"status": "ok"})
|
|
return
|
|
}
|
|
jsonErr(w, 404, "unknown action")
|
|
|
|
case strings.HasPrefix(path, "/api/users/") && r.Method == "DELETE":
|
|
id := strings.TrimPrefix(path, "/api/users/")
|
|
id = strings.TrimSuffix(id, "/")
|
|
// Get user devices to delete.
|
|
rows, _ := s.db.Query("SELECT device_id FROM server_user_devices WHERE user_id=?", id)
|
|
var deviceIDs []string
|
|
for rows.Next() {
|
|
var did string
|
|
rows.Scan(&did)
|
|
deviceIDs = append(deviceIDs, did)
|
|
}
|
|
rows.Close()
|
|
for _, did := range deviceIDs {
|
|
s.db.Exec("DELETE FROM server_devices WHERE id=?", did)
|
|
}
|
|
s.db.Exec("DELETE FROM server_user_devices WHERE user_id=?", id)
|
|
s.db.Exec("DELETE FROM server_email_tokens WHERE user_id=?", id)
|
|
s.db.Exec("DELETE FROM server_users WHERE id=?", id)
|
|
jsonOK(w, map[string]interface{}{"status": "deleted"})
|
|
|
|
default:
|
|
jsonErr(w, 404, "not found")
|
|
}
|
|
}
|