697 lines
33 KiB
C#
697 lines
33 KiB
C#
using System.Reflection;
|
|
using System.Text.Json.Nodes;
|
|
using Npgsql;
|
|
using NpgsqlTypes;
|
|
|
|
namespace JJServiceManagerRelay;
|
|
|
|
public sealed record UserRow(long Id, string Username, string PasswordHash, string Role, bool MustChangePassword,
|
|
bool Enabled, string Note, DateTime CreatedAt, DateTime? LastLoginAt)
|
|
{
|
|
public bool IsAdmin => Role == "admin";
|
|
}
|
|
|
|
public sealed record DeviceRow(Guid Id, string Name, string Hostname, byte[] KeyHash, bool Enabled, string Note,
|
|
DateTime EnrolledAt, DateTime? LastSeenAt, string LastPublicIp, string LanIpsJson, string ActiveListenJson,
|
|
string AppVersion, string OsVersion);
|
|
|
|
public sealed record Perm(bool View, bool Control, bool ScreenView, bool ScreenControl)
|
|
{
|
|
public static readonly Perm All = new(true, true, true, true);
|
|
public static readonly Perm None = new(false, false, false, false);
|
|
public bool Any => View || Control || ScreenView || ScreenControl;
|
|
|
|
public JsonObject ToJson() => new()
|
|
{
|
|
["view"] = View,
|
|
["control"] = Control,
|
|
["screenView"] = ScreenView,
|
|
["screenControl"] = ScreenControl
|
|
};
|
|
}
|
|
|
|
/// <summary>PostgreSQL 접근</summary>
|
|
public sealed class Db
|
|
{
|
|
private readonly NpgsqlDataSource _ds;
|
|
private readonly ServerLog _log;
|
|
|
|
public Db(string connectionString, ServerLog log)
|
|
{
|
|
_ds = NpgsqlDataSource.Create(connectionString);
|
|
_log = log;
|
|
}
|
|
|
|
public async Task<NpgsqlConnection> OpenAsync() => await _ds.OpenConnectionAsync();
|
|
|
|
// ───────────────────────── 시작: DB 기다리기 + 스키마 ─────────────────────────
|
|
|
|
public async Task InitializeAsync(CancellationToken ct)
|
|
{
|
|
// PostgreSQL 컨테이너가 늦게 뜰 수 있으므로 최대 2분 기다림
|
|
for (int i = 1; ; i++)
|
|
{
|
|
try
|
|
{
|
|
await using var c = await _ds.OpenConnectionAsync(ct);
|
|
break;
|
|
}
|
|
catch (Exception ex) when (i < 60 && !ct.IsCancellationRequested)
|
|
{
|
|
_log.Warn($"DB 연결 대기 중 ({i}/60): {ex.Message}");
|
|
await Task.Delay(2000, ct);
|
|
}
|
|
}
|
|
|
|
await using var conn = await _ds.OpenConnectionAsync(ct);
|
|
// 여러 서버가 동시에 올라와도 한 번만 적용되도록 잠금
|
|
await Exec(conn, "SELECT pg_advisory_lock(7200421)");
|
|
try
|
|
{
|
|
await Exec(conn, "CREATE TABLE IF NOT EXISTS server_settings (key text PRIMARY KEY, value text NOT NULL)");
|
|
int version = int.TryParse(await GetSetting(conn, "schema_version"), out var v) ? v : 0;
|
|
var asm = Assembly.GetExecutingAssembly();
|
|
var scripts = asm.GetManifestResourceNames()
|
|
.Where(n => n.Contains(".Schema.") && n.EndsWith(".sql"))
|
|
.OrderBy(n => n, StringComparer.Ordinal)
|
|
.ToList();
|
|
foreach (var name in scripts)
|
|
{
|
|
var file = name.Substring(name.IndexOf(".Schema.", StringComparison.Ordinal) + 8);
|
|
int num = int.Parse(file.Split('_')[0]);
|
|
if (num <= version) continue;
|
|
using var sr = new StreamReader(asm.GetManifestResourceStream(name)!);
|
|
var sql = await sr.ReadToEndAsync(ct);
|
|
await using var tx = await conn.BeginTransactionAsync(ct);
|
|
await using (var cmd = new NpgsqlCommand(sql, conn, tx)) await cmd.ExecuteNonQueryAsync(ct);
|
|
await using (var cmd = new NpgsqlCommand(
|
|
"INSERT INTO server_settings(key, value) VALUES ('schema_version', @v) ON CONFLICT (key) DO UPDATE SET value = excluded.value", conn, tx))
|
|
{
|
|
cmd.Parameters.AddWithValue("v", num.ToString());
|
|
await cmd.ExecuteNonQueryAsync(ct);
|
|
}
|
|
await tx.CommitAsync(ct);
|
|
_log.Info($"DB 스키마 적용: {file}");
|
|
version = num;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
await Exec(conn, "SELECT pg_advisory_unlock(7200421)");
|
|
}
|
|
}
|
|
|
|
private static async Task Exec(NpgsqlConnection c, string sql)
|
|
{
|
|
await using var cmd = new NpgsqlCommand(sql, c);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
private static async Task<string> GetSetting(NpgsqlConnection c, string key)
|
|
{
|
|
await using var cmd = new NpgsqlCommand("SELECT value FROM server_settings WHERE key = @k", c);
|
|
cmd.Parameters.AddWithValue("k", key);
|
|
return (string)await cmd.ExecuteScalarAsync();
|
|
}
|
|
|
|
public async Task<string> GetSettingAsync(string key)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
return await GetSetting(c, key);
|
|
}
|
|
|
|
/// <summary>없을 때만 저장하고, 실제 저장된 값을 돌려줌</summary>
|
|
public async Task<string> GetOrCreateSettingAsync(string key, Func<string> create)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using (var cmd = new NpgsqlCommand("INSERT INTO server_settings(key, value) VALUES (@k, @v) ON CONFLICT (key) DO NOTHING", c))
|
|
{
|
|
cmd.Parameters.AddWithValue("k", key);
|
|
cmd.Parameters.AddWithValue("v", create());
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
return await GetSetting(c, key);
|
|
}
|
|
|
|
public async Task<bool> PingAsync()
|
|
{
|
|
try
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand("SELECT 1", c);
|
|
await cmd.ExecuteScalarAsync();
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ───────────────────────── 사용자 ─────────────────────────
|
|
|
|
private const string UserCols = "id, username, password_hash, role, must_change_password, enabled, note, created_at, last_login_at";
|
|
|
|
private static UserRow ReadUser(NpgsqlDataReader r) => new(
|
|
r.GetInt64(0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetBoolean(4), r.GetBoolean(5),
|
|
r.GetString(6), r.GetDateTime(7), r.IsDBNull(8) ? null : r.GetDateTime(8));
|
|
|
|
public async Task<long> CountUsersAsync()
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand("SELECT count(*) FROM users", c);
|
|
return (long)await cmd.ExecuteScalarAsync();
|
|
}
|
|
|
|
public async Task<UserRow> FindUserAsync(string username)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand($"SELECT {UserCols} FROM users WHERE lower(username) = lower(@u)", c);
|
|
cmd.Parameters.AddWithValue("u", username ?? "");
|
|
await using var r = await cmd.ExecuteReaderAsync();
|
|
return await r.ReadAsync() ? ReadUser(r) : null;
|
|
}
|
|
|
|
public async Task<UserRow> GetUserAsync(long id)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand($"SELECT {UserCols} FROM users WHERE id = @id", c);
|
|
cmd.Parameters.AddWithValue("id", id);
|
|
await using var r = await cmd.ExecuteReaderAsync();
|
|
return await r.ReadAsync() ? ReadUser(r) : null;
|
|
}
|
|
|
|
public async Task<List<UserRow>> ListUsersAsync()
|
|
{
|
|
var list = new List<UserRow>();
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand($"SELECT {UserCols} FROM users ORDER BY lower(username)", c);
|
|
await using var r = await cmd.ExecuteReaderAsync();
|
|
while (await r.ReadAsync()) list.Add(ReadUser(r));
|
|
return list;
|
|
}
|
|
|
|
public async Task<long> CreateUserAsync(string username, string passwordHash, string role, bool mustChange, string note)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
"INSERT INTO users(username, password_hash, role, must_change_password, note) VALUES (@u, @p, @r, @m, @n) RETURNING id", c);
|
|
cmd.Parameters.AddWithValue("u", username);
|
|
cmd.Parameters.AddWithValue("p", passwordHash);
|
|
cmd.Parameters.AddWithValue("r", role);
|
|
cmd.Parameters.AddWithValue("m", mustChange);
|
|
cmd.Parameters.AddWithValue("n", note ?? "");
|
|
return (long)await cmd.ExecuteScalarAsync();
|
|
}
|
|
|
|
public async Task UpdateUserAsync(long id, string role, bool? enabled, string note)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
"UPDATE users SET role = coalesce(@r, role), enabled = coalesce(@e, enabled), note = coalesce(@n, note) WHERE id = @id", c);
|
|
cmd.Parameters.AddWithValue("id", id);
|
|
cmd.Parameters.Add(new NpgsqlParameter("r", NpgsqlDbType.Text) { Value = (object)role ?? DBNull.Value });
|
|
cmd.Parameters.Add(new NpgsqlParameter("e", NpgsqlDbType.Boolean) { Value = (object)enabled ?? DBNull.Value });
|
|
cmd.Parameters.Add(new NpgsqlParameter("n", NpgsqlDbType.Text) { Value = (object)note ?? DBNull.Value });
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
public async Task SetPasswordAsync(long id, string passwordHash, bool mustChange)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand("UPDATE users SET password_hash = @p, must_change_password = @m WHERE id = @id", c);
|
|
cmd.Parameters.AddWithValue("id", id);
|
|
cmd.Parameters.AddWithValue("p", passwordHash);
|
|
cmd.Parameters.AddWithValue("m", mustChange);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
public async Task TouchLoginAsync(long id)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand("UPDATE users SET last_login_at = now() WHERE id = @id", c);
|
|
cmd.Parameters.AddWithValue("id", id);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
public async Task<long> CountEnabledAdminsAsync()
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand("SELECT count(*) FROM users WHERE role = 'admin' AND enabled", c);
|
|
return (long)await cmd.ExecuteScalarAsync();
|
|
}
|
|
|
|
// ───────────────────────── 로그인 토큰 ─────────────────────────
|
|
|
|
public async Task CreateSessionAsync(byte[] tokenHash, long userId, TimeSpan life, string ip, string host)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
"INSERT INTO user_sessions(token_hash, user_id, expires_at, client_ip, client_host) VALUES (@t, @u, now() + @l, @ip, @h)", c);
|
|
cmd.Parameters.AddWithValue("t", tokenHash);
|
|
cmd.Parameters.AddWithValue("u", userId);
|
|
cmd.Parameters.AddWithValue("l", life);
|
|
cmd.Parameters.AddWithValue("ip", ip ?? "");
|
|
cmd.Parameters.AddWithValue("h", host ?? "");
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
/// <summary>유효한 토큰이면 사용자 ID (만료 시간 연장)</summary>
|
|
public async Task<long?> UseSessionAsync(byte[] tokenHash, TimeSpan life)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
"UPDATE user_sessions SET expires_at = now() + @l WHERE token_hash = @t AND expires_at > now() RETURNING user_id", c);
|
|
cmd.Parameters.AddWithValue("t", tokenHash);
|
|
cmd.Parameters.AddWithValue("l", life);
|
|
var v = await cmd.ExecuteScalarAsync();
|
|
return v == null ? null : (long)v;
|
|
}
|
|
|
|
public async Task DeleteSessionAsync(byte[] tokenHash)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand("DELETE FROM user_sessions WHERE token_hash = @t", c);
|
|
cmd.Parameters.AddWithValue("t", tokenHash);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
public async Task DeleteUserSessionsAsync(long userId, byte[] exceptTokenHash = null)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
"DELETE FROM user_sessions WHERE user_id = @u AND (@e::bytea IS NULL OR token_hash <> @e)", c);
|
|
cmd.Parameters.AddWithValue("u", userId);
|
|
cmd.Parameters.Add(new NpgsqlParameter("e", NpgsqlDbType.Bytea) { Value = (object)exceptTokenHash ?? DBNull.Value });
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
/// <summary>만료된 로그인 토큰 정리 (로그가 아니라 인증 정보이므로 정리함)</summary>
|
|
public async Task PurgeExpiredSessionsAsync()
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand("DELETE FROM user_sessions WHERE expires_at < now() - interval '1 day'", c);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
// ───────────────────────── 기기 ─────────────────────────
|
|
|
|
private const string DeviceCols = "id, name, hostname, key_hash, enabled, note, enrolled_at, last_seen_at, coalesce(last_public_ip, ''), lan_ips::text, coalesce(active_listen::text, ''), coalesce(app_version, ''), coalesce(os_version, '')";
|
|
|
|
private static DeviceRow ReadDevice(NpgsqlDataReader r) => new(
|
|
r.GetGuid(0), r.GetString(1), r.GetString(2), (byte[])r[3], r.GetBoolean(4), r.GetString(5), r.GetDateTime(6),
|
|
r.IsDBNull(7) ? null : r.GetDateTime(7), r.GetString(8), r.GetString(9), r.GetString(10), r.GetString(11), r.GetString(12));
|
|
|
|
public async Task<DeviceRow> GetDeviceAsync(Guid id)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand($"SELECT {DeviceCols} FROM devices WHERE id = @id", c);
|
|
cmd.Parameters.AddWithValue("id", id);
|
|
await using var r = await cmd.ExecuteReaderAsync();
|
|
return await r.ReadAsync() ? ReadDevice(r) : null;
|
|
}
|
|
|
|
public async Task<List<DeviceRow>> ListDevicesAsync()
|
|
{
|
|
var list = new List<DeviceRow>();
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand($"SELECT {DeviceCols} FROM devices ORDER BY lower(name)", c);
|
|
await using var r = await cmd.ExecuteReaderAsync();
|
|
while (await r.ReadAsync()) list.Add(ReadDevice(r));
|
|
return list;
|
|
}
|
|
|
|
/// <summary>등록 코드를 쓰고 기기를 만든다 (한 트랜잭션). 실패 사유 문자열 또는 null</summary>
|
|
public async Task<(Guid id, long? createdBy, string error)> EnrollAsync(byte[] codeHash, Guid newId, string hostname,
|
|
byte[] keyHash, string os, string appVersion)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var tx = await c.BeginTransactionAsync();
|
|
long codeId;
|
|
long? createdBy;
|
|
await using (var cmd = new NpgsqlCommand(
|
|
"SELECT id, created_by, expires_at > now(), used_at IS NULL, revoked FROM enrollment_codes WHERE code_hash = @h FOR UPDATE", c, tx))
|
|
{
|
|
cmd.Parameters.AddWithValue("h", codeHash);
|
|
await using var r = await cmd.ExecuteReaderAsync();
|
|
if (!await r.ReadAsync()) return (Guid.Empty, null, "등록 코드가 올바르지 않습니다.");
|
|
codeId = r.GetInt64(0);
|
|
createdBy = r.IsDBNull(1) ? null : r.GetInt64(1);
|
|
bool valid = r.GetBoolean(2), unused = r.GetBoolean(3), revoked = r.GetBoolean(4);
|
|
if (revoked) return (Guid.Empty, null, "취소된 등록 코드입니다.");
|
|
if (!unused) return (Guid.Empty, null, "이미 사용한 등록 코드입니다.");
|
|
if (!valid) return (Guid.Empty, null, "유효 시간이 지난 등록 코드입니다.");
|
|
}
|
|
await using (var cmd = new NpgsqlCommand(
|
|
"INSERT INTO devices(id, name, hostname, key_hash, enrolled_by, os_version, app_version) VALUES (@id, @n, @n, @k, @b, @os, @av)", c, tx))
|
|
{
|
|
cmd.Parameters.AddWithValue("id", newId);
|
|
cmd.Parameters.AddWithValue("n", hostname);
|
|
cmd.Parameters.AddWithValue("k", keyHash);
|
|
cmd.Parameters.Add(new NpgsqlParameter("b", NpgsqlDbType.Bigint) { Value = (object)createdBy ?? DBNull.Value });
|
|
cmd.Parameters.AddWithValue("os", os ?? "");
|
|
cmd.Parameters.AddWithValue("av", appVersion ?? "");
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
await using (var cmd = new NpgsqlCommand("UPDATE enrollment_codes SET used_at = now(), used_device = @d WHERE id = @id", c, tx))
|
|
{
|
|
cmd.Parameters.AddWithValue("id", codeId);
|
|
cmd.Parameters.AddWithValue("d", newId);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
await tx.CommitAsync();
|
|
return (newId, createdBy, null);
|
|
}
|
|
|
|
public async Task UpdateDeviceStateAsync(Guid id, string hostname, string publicIp, string lanIpsJson,
|
|
string activeListenJson, string appVersion, string os)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
@"UPDATE devices SET hostname = coalesce(nullif(@h, ''), hostname), last_seen_at = now(), last_public_ip = @ip,
|
|
lan_ips = @lan::jsonb, active_listen = @al::jsonb,
|
|
app_version = coalesce(nullif(@av, ''), app_version), os_version = coalesce(nullif(@os, ''), os_version)
|
|
WHERE id = @id", c);
|
|
cmd.Parameters.AddWithValue("id", id);
|
|
cmd.Parameters.AddWithValue("h", hostname ?? "");
|
|
cmd.Parameters.AddWithValue("ip", publicIp ?? "");
|
|
cmd.Parameters.AddWithValue("lan", lanIpsJson ?? "[]");
|
|
cmd.Parameters.Add(new NpgsqlParameter("al", NpgsqlDbType.Text) { Value = (object)activeListenJson ?? DBNull.Value });
|
|
cmd.Parameters.AddWithValue("av", appVersion ?? "");
|
|
cmd.Parameters.AddWithValue("os", os ?? "");
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
public async Task TouchDeviceAsync(Guid id)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand("UPDATE devices SET last_seen_at = now() WHERE id = @id", c);
|
|
cmd.Parameters.AddWithValue("id", id);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
public async Task RenameDeviceAsync(Guid id, string name, string note)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
"UPDATE devices SET name = coalesce(@n, name), note = coalesce(@note, note) WHERE id = @id", c);
|
|
cmd.Parameters.AddWithValue("id", id);
|
|
cmd.Parameters.Add(new NpgsqlParameter("n", NpgsqlDbType.Text) { Value = (object)name ?? DBNull.Value });
|
|
cmd.Parameters.Add(new NpgsqlParameter("note", NpgsqlDbType.Text) { Value = (object)note ?? DBNull.Value });
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
public async Task SetDeviceEnabledAsync(Guid id, bool enabled)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand("UPDATE devices SET enabled = @e WHERE id = @id", c);
|
|
cmd.Parameters.AddWithValue("id", id);
|
|
cmd.Parameters.AddWithValue("e", enabled);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
public async Task DeleteDeviceAsync(Guid id)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand("DELETE FROM devices WHERE id = @id", c);
|
|
cmd.Parameters.AddWithValue("id", id);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
// ───────────────────────── 권한 ─────────────────────────
|
|
|
|
public async Task<Perm> GetPermAsync(long userId, Guid deviceId)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
"SELECT can_view, can_control, can_screen_view, can_screen_control FROM user_device_permissions WHERE user_id = @u AND device_id = @d", c);
|
|
cmd.Parameters.AddWithValue("u", userId);
|
|
cmd.Parameters.AddWithValue("d", deviceId);
|
|
await using var r = await cmd.ExecuteReaderAsync();
|
|
return await r.ReadAsync() ? new Perm(r.GetBoolean(0), r.GetBoolean(1), r.GetBoolean(2), r.GetBoolean(3)) : Perm.None;
|
|
}
|
|
|
|
public async Task<Dictionary<Guid, Perm>> GetPermsForUserAsync(long userId)
|
|
{
|
|
var map = new Dictionary<Guid, Perm>();
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
"SELECT device_id, can_view, can_control, can_screen_view, can_screen_control FROM user_device_permissions WHERE user_id = @u", c);
|
|
cmd.Parameters.AddWithValue("u", userId);
|
|
await using var r = await cmd.ExecuteReaderAsync();
|
|
while (await r.ReadAsync())
|
|
map[r.GetGuid(0)] = new Perm(r.GetBoolean(1), r.GetBoolean(2), r.GetBoolean(3), r.GetBoolean(4));
|
|
return map;
|
|
}
|
|
|
|
public async Task SetPermAsync(long userId, Guid deviceId, Perm p)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
if (!p.Any)
|
|
{
|
|
await using var del = new NpgsqlCommand("DELETE FROM user_device_permissions WHERE user_id = @u AND device_id = @d", c);
|
|
del.Parameters.AddWithValue("u", userId);
|
|
del.Parameters.AddWithValue("d", deviceId);
|
|
await del.ExecuteNonQueryAsync();
|
|
return;
|
|
}
|
|
await using var cmd = new NpgsqlCommand(
|
|
@"INSERT INTO user_device_permissions(user_id, device_id, can_view, can_control, can_screen_view, can_screen_control)
|
|
VALUES (@u, @d, @v, @c, @sv, @sc)
|
|
ON CONFLICT (user_id, device_id) DO UPDATE SET can_view = excluded.can_view, can_control = excluded.can_control,
|
|
can_screen_view = excluded.can_screen_view, can_screen_control = excluded.can_screen_control", c);
|
|
cmd.Parameters.AddWithValue("u", userId);
|
|
cmd.Parameters.AddWithValue("d", deviceId);
|
|
cmd.Parameters.AddWithValue("v", p.View);
|
|
cmd.Parameters.AddWithValue("c", p.Control);
|
|
cmd.Parameters.AddWithValue("sv", p.ScreenView);
|
|
cmd.Parameters.AddWithValue("sc", p.ScreenControl);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
// ───────────────────────── 등록 코드 ─────────────────────────
|
|
|
|
public async Task<long> CreateCodeAsync(byte[] codeHash, long createdBy, TimeSpan valid, string note)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
"INSERT INTO enrollment_codes(code_hash, created_by, expires_at, note) VALUES (@h, @b, now() + @v, @n) RETURNING id", c);
|
|
cmd.Parameters.AddWithValue("h", codeHash);
|
|
cmd.Parameters.AddWithValue("b", createdBy);
|
|
cmd.Parameters.AddWithValue("v", valid);
|
|
cmd.Parameters.AddWithValue("n", note ?? "");
|
|
return (long)await cmd.ExecuteScalarAsync();
|
|
}
|
|
|
|
public async Task<JsonArray> ListCodesAsync()
|
|
{
|
|
var arr = new JsonArray();
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
@"SELECT e.id, coalesce(u.username, ''), e.created_at, e.expires_at, e.used_at, coalesce(d.name, ''), e.revoked, e.note,
|
|
e.expires_at > now()
|
|
FROM enrollment_codes e LEFT JOIN users u ON u.id = e.created_by LEFT JOIN devices d ON d.id = e.used_device
|
|
ORDER BY e.created_at DESC LIMIT 200", c);
|
|
await using var r = await cmd.ExecuteReaderAsync();
|
|
while (await r.ReadAsync())
|
|
{
|
|
bool used = !r.IsDBNull(4), revoked = r.GetBoolean(6), valid = r.GetBoolean(8);
|
|
arr.Add(new JsonObject
|
|
{
|
|
["id"] = r.GetInt64(0),
|
|
["createdBy"] = r.GetString(1),
|
|
["createdAt"] = Iso(r.GetDateTime(2)),
|
|
["expiresAt"] = Iso(r.GetDateTime(3)),
|
|
["usedAt"] = used ? Iso(r.GetDateTime(4)) : null,
|
|
["usedDevice"] = r.GetString(5),
|
|
["note"] = r.GetString(7),
|
|
["status"] = used ? "used" : revoked ? "revoked" : valid ? "valid" : "expired"
|
|
});
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
public async Task<bool> RevokeCodeAsync(long id)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand("UPDATE enrollment_codes SET revoked = true WHERE id = @id AND used_at IS NULL", c);
|
|
cmd.Parameters.AddWithValue("id", id);
|
|
return await cmd.ExecuteNonQueryAsync() > 0;
|
|
}
|
|
|
|
// ───────────────────────── 감사 로그 ─────────────────────────
|
|
|
|
public async Task AuditAsync(long? userId, string username, Guid? deviceId, string deviceName, string action,
|
|
string target, string result, string detail, string ip)
|
|
{
|
|
try
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
@"INSERT INTO audit_logs(user_id, username, device_id, device_name, action, target, result, detail, source_ip)
|
|
VALUES (@u, @un, @d, @dn, @a, @t, @r, @de, @ip)", c);
|
|
cmd.Parameters.Add(new NpgsqlParameter("u", NpgsqlDbType.Bigint) { Value = (object)userId ?? DBNull.Value });
|
|
cmd.Parameters.AddWithValue("un", username ?? "");
|
|
cmd.Parameters.Add(new NpgsqlParameter("d", NpgsqlDbType.Uuid) { Value = (object)deviceId ?? DBNull.Value });
|
|
cmd.Parameters.AddWithValue("dn", deviceName ?? "");
|
|
cmd.Parameters.AddWithValue("a", action ?? "");
|
|
cmd.Parameters.AddWithValue("t", Cut(target, 500));
|
|
cmd.Parameters.AddWithValue("r", result ?? "");
|
|
cmd.Parameters.AddWithValue("de", Cut(detail, 2000));
|
|
cmd.Parameters.AddWithValue("ip", ip ?? "");
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.Error("감사 로그 저장 실패: " + ex.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<JsonArray> QueryAuditAsync(DateTime? from, DateTime? to, long? userId, Guid? deviceId, string action,
|
|
long? beforeId, int limit)
|
|
{
|
|
var arr = new JsonArray();
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
@"SELECT id, at, coalesce(user_id, 0), username, device_id, device_name, action, target, result, detail, source_ip
|
|
FROM audit_logs
|
|
WHERE (@f::timestamptz IS NULL OR at >= @f) AND (@t::timestamptz IS NULL OR at < @t)
|
|
AND (@u::bigint IS NULL OR user_id = @u) AND (@d::uuid IS NULL OR device_id = @d)
|
|
AND (@a::text IS NULL OR action = @a) AND (@b::bigint IS NULL OR id < @b)
|
|
ORDER BY id DESC LIMIT @l", c);
|
|
AddFilters(cmd, from, to, userId, deviceId, action, beforeId, limit);
|
|
await using var r = await cmd.ExecuteReaderAsync();
|
|
while (await r.ReadAsync())
|
|
{
|
|
arr.Add(new JsonObject
|
|
{
|
|
["id"] = r.GetInt64(0),
|
|
["at"] = Iso(r.GetDateTime(1)),
|
|
["userId"] = r.GetInt64(2),
|
|
["username"] = r.GetString(3),
|
|
["deviceId"] = r.IsDBNull(4) ? null : r.GetGuid(4).ToString(),
|
|
["deviceName"] = r.GetString(5),
|
|
["action"] = r.GetString(6),
|
|
["target"] = r.GetString(7),
|
|
["result"] = r.GetString(8),
|
|
["detail"] = r.GetString(9),
|
|
["ip"] = r.GetString(10)
|
|
});
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
private static void AddFilters(NpgsqlCommand cmd, DateTime? from, DateTime? to, long? userId, Guid? deviceId,
|
|
string action, long? beforeId, int limit)
|
|
{
|
|
cmd.Parameters.Add(new NpgsqlParameter("f", NpgsqlDbType.TimestampTz) { Value = (object)from ?? DBNull.Value });
|
|
cmd.Parameters.Add(new NpgsqlParameter("t", NpgsqlDbType.TimestampTz) { Value = (object)to ?? DBNull.Value });
|
|
cmd.Parameters.Add(new NpgsqlParameter("u", NpgsqlDbType.Bigint) { Value = (object)userId ?? DBNull.Value });
|
|
cmd.Parameters.Add(new NpgsqlParameter("d", NpgsqlDbType.Uuid) { Value = (object)deviceId ?? DBNull.Value });
|
|
cmd.Parameters.Add(new NpgsqlParameter("a", NpgsqlDbType.Text) { Value = string.IsNullOrEmpty(action) ? DBNull.Value : action });
|
|
cmd.Parameters.Add(new NpgsqlParameter("b", NpgsqlDbType.Bigint) { Value = (object)beforeId ?? DBNull.Value });
|
|
cmd.Parameters.AddWithValue("l", Math.Clamp(limit, 1, 500));
|
|
}
|
|
|
|
// ───────────────────────── 연결 기록 ─────────────────────────
|
|
|
|
public async Task ConnectionStartAsync(Guid sessionId, long userId, string username, Guid deviceId, string deviceName, string managerIp)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
@"INSERT INTO connections(session_id, user_id, username, device_id, device_name, manager_ip)
|
|
VALUES (@s, @u, @un, @d, @dn, @ip)", c);
|
|
cmd.Parameters.AddWithValue("s", sessionId);
|
|
cmd.Parameters.AddWithValue("u", userId);
|
|
cmd.Parameters.AddWithValue("un", username ?? "");
|
|
cmd.Parameters.AddWithValue("d", deviceId);
|
|
cmd.Parameters.AddWithValue("dn", deviceName ?? "");
|
|
cmd.Parameters.AddWithValue("ip", managerIp ?? "");
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
public async Task ConnectionModeAsync(Guid sessionId, string mode)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand("UPDATE connections SET mode = @m WHERE session_id = @s AND ended_at IS NULL", c);
|
|
cmd.Parameters.AddWithValue("s", sessionId);
|
|
cmd.Parameters.AddWithValue("m", mode);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
public async Task ConnectionEndAsync(Guid sessionId, string mode, long toDevice, long fromDevice, string reason)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
@"UPDATE connections SET ended_at = now(), mode = coalesce(@m, mode), bytes_to_device = @td,
|
|
bytes_from_device = @fd, end_reason = @r
|
|
WHERE session_id = @s AND ended_at IS NULL", c);
|
|
cmd.Parameters.AddWithValue("s", sessionId);
|
|
cmd.Parameters.Add(new NpgsqlParameter("m", NpgsqlDbType.Text) { Value = (object)mode ?? DBNull.Value });
|
|
cmd.Parameters.AddWithValue("td", toDevice);
|
|
cmd.Parameters.AddWithValue("fd", fromDevice);
|
|
cmd.Parameters.AddWithValue("r", Cut(reason, 500));
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
/// <summary>서버가 재시작되어 끝을 못 적은 연결 정리</summary>
|
|
public async Task CloseDanglingConnectionsAsync()
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
"UPDATE connections SET ended_at = now(), end_reason = '서버 재시작' WHERE ended_at IS NULL", c);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
public async Task<bool> ConnectionBelongsToAsync(Guid sessionId, long userId)
|
|
{
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand("SELECT 1 FROM connections WHERE session_id = @s AND user_id = @u", c);
|
|
cmd.Parameters.AddWithValue("s", sessionId);
|
|
cmd.Parameters.AddWithValue("u", userId);
|
|
return await cmd.ExecuteScalarAsync() != null;
|
|
}
|
|
|
|
public async Task<JsonArray> QueryConnectionsAsync(DateTime? from, DateTime? to, long? userId, Guid? deviceId,
|
|
long? beforeId, int limit)
|
|
{
|
|
var arr = new JsonArray();
|
|
await using var c = await OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
@"SELECT id, session_id, coalesce(user_id, 0), username, device_id, device_name, started_at, ended_at,
|
|
coalesce(mode, ''), bytes_to_device, bytes_from_device, end_reason, manager_ip
|
|
FROM connections
|
|
WHERE (@f::timestamptz IS NULL OR started_at >= @f) AND (@t::timestamptz IS NULL OR started_at < @t)
|
|
AND (@u::bigint IS NULL OR user_id = @u) AND (@d::uuid IS NULL OR device_id = @d)
|
|
AND (@a::text IS NULL OR mode = @a) AND (@b::bigint IS NULL OR id < @b)
|
|
ORDER BY id DESC LIMIT @l", c);
|
|
AddFilters(cmd, from, to, userId, deviceId, null, beforeId, limit);
|
|
await using var r = await cmd.ExecuteReaderAsync();
|
|
while (await r.ReadAsync())
|
|
{
|
|
arr.Add(new JsonObject
|
|
{
|
|
["id"] = r.GetInt64(0),
|
|
["sessionId"] = r.GetGuid(1).ToString(),
|
|
["userId"] = r.GetInt64(2),
|
|
["username"] = r.GetString(3),
|
|
["deviceId"] = r.IsDBNull(4) ? null : r.GetGuid(4).ToString(),
|
|
["deviceName"] = r.GetString(5),
|
|
["startedAt"] = Iso(r.GetDateTime(6)),
|
|
["endedAt"] = r.IsDBNull(7) ? null : Iso(r.GetDateTime(7)),
|
|
["mode"] = r.GetString(8),
|
|
["bytesToDevice"] = r.GetInt64(9),
|
|
["bytesFromDevice"] = r.GetInt64(10),
|
|
["endReason"] = r.GetString(11),
|
|
["managerIp"] = r.GetString(12)
|
|
});
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
// ───────────────────────── 공통 ─────────────────────────
|
|
|
|
public static string Iso(DateTime t) => DateTime.SpecifyKind(t, DateTimeKind.Utc).ToString("o");
|
|
private static string Cut(string s, int max) => s == null ? "" : s.Length <= max ? s : s[..max];
|
|
}
|