securedgram 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.env.example +40 -0
- data/CONTRIBUTORS +22 -0
- data/DESIGN.md +289 -0
- data/LICENSE +12 -0
- data/README.md +754 -0
- data/SECURITY.md +161 -0
- data/data/schema.sql +48 -0
- data/exe/securedgram +176 -0
- data/exe/sg-clean +337 -0
- data/exe/sg-recv +287 -0
- data/exe/sg-send +178 -0
- data/lib/securedgram/crypto.rb +64 -0
- data/lib/securedgram/daemon_utils.rb +420 -0
- data/lib/securedgram/env_loader.rb +44 -0
- data/lib/securedgram/udp_server.rb +623 -0
- data/lib/securedgram/version.rb +5 -0
- data/lib/securedgram.rb +21 -0
- metadata +135 -0
data/exe/sg-clean
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
#
|
|
4
|
+
# sg-clean -- SecureDGram CLI tool to purge old messages from the database
|
|
5
|
+
#
|
|
6
|
+
# Deletes terminal-state messages older than the specified age.
|
|
7
|
+
# By default purges:
|
|
8
|
+
# - Outbound: acknowledged, send_failed
|
|
9
|
+
# - Inbound: ack_sent, ack_failed (only where read_count > 0)
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# sg-clean <age> # e.g., 7d, 24h, 4w
|
|
13
|
+
# sg-clean --before <datetime> # ISO 8601: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS
|
|
14
|
+
# sg-clean --dry-run 30d # preview what would be deleted
|
|
15
|
+
#
|
|
16
|
+
# Age suffixes (GNU sleep / systemd convention):
|
|
17
|
+
# s = seconds, m = minutes, h = hours, d = days, w = weeks
|
|
18
|
+
|
|
19
|
+
require 'securedgram/version'
|
|
20
|
+
require 'securedgram/env_loader'
|
|
21
|
+
|
|
22
|
+
## Load .env:
|
|
23
|
+
SecureDGram::EnvLoader.load_dotenv
|
|
24
|
+
|
|
25
|
+
require 'optparse'
|
|
26
|
+
require 'json'
|
|
27
|
+
require 'sqlite3'
|
|
28
|
+
require 'time'
|
|
29
|
+
|
|
30
|
+
## Duration suffix multipliers (GNU sleep / systemd convention):
|
|
31
|
+
DURATION_MULTIPLIERS = {
|
|
32
|
+
's' => 1,
|
|
33
|
+
'm' => 60,
|
|
34
|
+
'h' => 3600,
|
|
35
|
+
'd' => 86400,
|
|
36
|
+
'w' => 604800,
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
## Parse a duration string like "7d", "24h", "30m", "3600s", "4w"
|
|
41
|
+
## Returns seconds as an integer, or nil if invalid.
|
|
42
|
+
##
|
|
43
|
+
def parse_duration(str)
|
|
44
|
+
str = str.strip
|
|
45
|
+
if m = /\A(\d+)([smhdw])\z/.match(str)
|
|
46
|
+
return m[1].to_i * DURATION_MULTIPLIERS[m[2]]
|
|
47
|
+
end
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
##
|
|
52
|
+
## Parse an age argument: either a duration suffix or an ISO 8601 datetime.
|
|
53
|
+
## Returns a Time object representing the cutoff.
|
|
54
|
+
##
|
|
55
|
+
def parse_cutoff(str)
|
|
56
|
+
## Try duration first:
|
|
57
|
+
seconds = parse_duration(str)
|
|
58
|
+
if seconds
|
|
59
|
+
return Time.now - seconds
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
## Try ISO 8601 datetime (Time.parse handles YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS, etc.):
|
|
63
|
+
begin
|
|
64
|
+
return Time.parse(str)
|
|
65
|
+
rescue ArgumentError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
## Defaults:
|
|
71
|
+
db_path = ENV.fetch('SECUREDGRAM_DB', 'securedgram.db')
|
|
72
|
+
dry_run = false
|
|
73
|
+
auto_yes = false
|
|
74
|
+
no_vacuum = false
|
|
75
|
+
outbound_only = false
|
|
76
|
+
inbound_only = false
|
|
77
|
+
include_unread = false
|
|
78
|
+
all_states = false
|
|
79
|
+
target_state = nil
|
|
80
|
+
before_str = nil
|
|
81
|
+
|
|
82
|
+
optparser = OptionParser.new do |opts|
|
|
83
|
+
opts.banner = "Usage: #{File.basename($0)} [options] <age>"
|
|
84
|
+
opts.separator ""
|
|
85
|
+
opts.separator "SecureDGram v#{SecureDGram::VERSION} -- Purge old messages from the database."
|
|
86
|
+
opts.separator ""
|
|
87
|
+
opts.separator "Age format: <number><suffix> where suffix is:"
|
|
88
|
+
opts.separator " s = seconds, m = minutes, h = hours, d = days, w = weeks"
|
|
89
|
+
opts.separator " Examples: 7d (7 days), 24h (24 hours), 4w (4 weeks), 3600s (1 hour)"
|
|
90
|
+
opts.separator ""
|
|
91
|
+
opts.separator "Options:"
|
|
92
|
+
|
|
93
|
+
opts.on("--db PATH", "SQLite3 database path (default: from .env)") do |path|
|
|
94
|
+
db_path = path
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
opts.on("--before DATETIME", "Purge messages before this date (ISO 8601: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)") do |dt|
|
|
98
|
+
before_str = dt
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
opts.on("--dry-run", "Show what would be deleted without actually deleting") do
|
|
102
|
+
dry_run = true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
opts.on("-y", "--yes", "Skip confirmation prompt (for scripting)") do
|
|
106
|
+
auto_yes = true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
opts.on("--no-vacuum", "Skip VACUUM after deletion") do
|
|
110
|
+
no_vacuum = true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
opts.on("--outbound-only", "Only purge outbound messages") do
|
|
114
|
+
outbound_only = true
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
opts.on("--inbound-only", "Only purge inbound messages") do
|
|
118
|
+
inbound_only = true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
opts.on("--include-unread", "Also purge inbound messages with read_count = 0") do
|
|
122
|
+
include_unread = true
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
opts.on("--all-states", "Purge ALL matching rows regardless of state (DANGEROUS)") do
|
|
126
|
+
all_states = true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
opts.on("-s", "--state STATE", "Target a specific state only (e.g., send_failed, acknowledged)") do |s|
|
|
130
|
+
target_state = s
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
opts.on("-V", "--version", "Show version and exit") do
|
|
134
|
+
puts "sg-clean (SecureDGram) #{SecureDGram::VERSION}"
|
|
135
|
+
exit
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
opts.on("-h", "--help", "Show this help") do
|
|
139
|
+
puts opts
|
|
140
|
+
exit
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
begin
|
|
145
|
+
optparser.parse!(ARGV)
|
|
146
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
|
|
147
|
+
STDERR.puts "ERROR: #{e.message}"
|
|
148
|
+
STDERR.puts optparser
|
|
149
|
+
exit 1
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
## Conflicting flags:
|
|
153
|
+
if outbound_only && inbound_only
|
|
154
|
+
STDERR.puts "ERROR: --outbound-only and --inbound-only are mutually exclusive."
|
|
155
|
+
exit 1
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
## Parse cutoff time:
|
|
159
|
+
cutoff = nil
|
|
160
|
+
|
|
161
|
+
if before_str
|
|
162
|
+
cutoff = parse_cutoff(before_str)
|
|
163
|
+
unless cutoff
|
|
164
|
+
STDERR.puts "ERROR: Cannot parse --before value: #{before_str.inspect}"
|
|
165
|
+
STDERR.puts "Expected ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS"
|
|
166
|
+
exit 1
|
|
167
|
+
end
|
|
168
|
+
elsif ARGV.size >= 1
|
|
169
|
+
cutoff = parse_cutoff(ARGV[0])
|
|
170
|
+
unless cutoff
|
|
171
|
+
STDERR.puts "ERROR: Cannot parse age: #{ARGV[0].inspect}"
|
|
172
|
+
STDERR.puts "Expected format: <number><suffix> where suffix is s/m/h/d/w"
|
|
173
|
+
STDERR.puts " Examples: 7d, 24h, 30m, 3600s, 4w"
|
|
174
|
+
STDERR.puts "Or ISO 8601: YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS"
|
|
175
|
+
exit 1
|
|
176
|
+
end
|
|
177
|
+
else
|
|
178
|
+
STDERR.puts "ERROR: Missing required age argument."
|
|
179
|
+
STDERR.puts optparser
|
|
180
|
+
exit 1
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
cutoff_str = cutoff.strftime('%Y-%m-%dT%H:%M:%S.%6N')
|
|
184
|
+
|
|
185
|
+
## Verify database exists:
|
|
186
|
+
unless File.file?(db_path)
|
|
187
|
+
STDERR.puts "ERROR: Database not found at #{db_path}"
|
|
188
|
+
exit 1
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
## Terminal states for each table:
|
|
192
|
+
OUTBOUND_TERMINAL = ['acknowledged', 'send_failed'].freeze
|
|
193
|
+
INBOUND_TERMINAL = ['ack_sent', 'ack_failed'].freeze
|
|
194
|
+
|
|
195
|
+
##
|
|
196
|
+
## Build DELETE conditions for a table.
|
|
197
|
+
## Returns [where_clause, params] or nil if this table should be skipped.
|
|
198
|
+
##
|
|
199
|
+
def build_conditions(table, cutoff_str, target_state, all_states, include_unread)
|
|
200
|
+
conditions = []
|
|
201
|
+
params = []
|
|
202
|
+
|
|
203
|
+
conditions << "updated_at < ?"
|
|
204
|
+
params << cutoff_str
|
|
205
|
+
|
|
206
|
+
## State filter:
|
|
207
|
+
if target_state
|
|
208
|
+
conditions << "state = ?"
|
|
209
|
+
params << target_state
|
|
210
|
+
elsif !all_states
|
|
211
|
+
if table == 'outbound_messages'
|
|
212
|
+
placeholders = OUTBOUND_TERMINAL.map { '?' }.join(', ')
|
|
213
|
+
conditions << "state IN (#{placeholders})"
|
|
214
|
+
params.concat(OUTBOUND_TERMINAL)
|
|
215
|
+
else
|
|
216
|
+
placeholders = INBOUND_TERMINAL.map { '?' }.join(', ')
|
|
217
|
+
conditions << "state IN (#{placeholders})"
|
|
218
|
+
params.concat(INBOUND_TERMINAL)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
## For inbound: skip unread unless --include-unread:
|
|
223
|
+
if table == 'inbound_messages' && !include_unread && !all_states
|
|
224
|
+
conditions << "read_count > 0"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
where = conditions.join(" AND ")
|
|
228
|
+
return where, params
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
begin
|
|
232
|
+
db = SQLite3::Database.new(db_path)
|
|
233
|
+
db.results_as_hash = true
|
|
234
|
+
db.execute("PRAGMA journal_mode = WAL")
|
|
235
|
+
db.busy_timeout = 5000
|
|
236
|
+
|
|
237
|
+
tables = []
|
|
238
|
+
tables << 'outbound_messages' unless inbound_only
|
|
239
|
+
tables << 'inbound_messages' unless outbound_only
|
|
240
|
+
|
|
241
|
+
total_to_delete = 0
|
|
242
|
+
table_counts = {}
|
|
243
|
+
|
|
244
|
+
## Count phase:
|
|
245
|
+
tables.each do |table|
|
|
246
|
+
where, params = build_conditions(table, cutoff_str, target_state, all_states, include_unread)
|
|
247
|
+
count_query = "SELECT COUNT(*) as cnt FROM #{table} WHERE #{where}"
|
|
248
|
+
row = db.get_first_row(count_query, params)
|
|
249
|
+
count = row ? row['cnt'] : 0
|
|
250
|
+
table_counts[table] = { count: count, where: where, params: params }
|
|
251
|
+
total_to_delete += count
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
## Report:
|
|
255
|
+
puts "Cutoff: #{cutoff.strftime('%Y-%m-%d %H:%M:%S')} (messages last updated before this time)"
|
|
256
|
+
puts ""
|
|
257
|
+
|
|
258
|
+
tables.each do |table|
|
|
259
|
+
info = table_counts[table]
|
|
260
|
+
label = table == 'outbound_messages' ? 'Outbound' : 'Inbound'
|
|
261
|
+
|
|
262
|
+
if info[:count] > 0
|
|
263
|
+
## Show state breakdown:
|
|
264
|
+
where = info[:where]
|
|
265
|
+
params = info[:params]
|
|
266
|
+
breakdown_query = "SELECT state, COUNT(*) as cnt FROM #{table} WHERE #{where} GROUP BY state ORDER BY cnt DESC"
|
|
267
|
+
breakdown = db.execute(breakdown_query, params)
|
|
268
|
+
puts " #{label}: #{info[:count]} rows to delete"
|
|
269
|
+
breakdown.each do |brow|
|
|
270
|
+
puts " #{brow['state']}: #{brow['cnt']}"
|
|
271
|
+
end
|
|
272
|
+
else
|
|
273
|
+
puts " #{label}: 0 rows to delete"
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
puts ""
|
|
278
|
+
|
|
279
|
+
if total_to_delete == 0
|
|
280
|
+
puts "Nothing to delete."
|
|
281
|
+
db.close
|
|
282
|
+
exit 0
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
if dry_run
|
|
286
|
+
puts "(dry run — no rows were deleted)"
|
|
287
|
+
db.close
|
|
288
|
+
exit 0
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
## Confirmation:
|
|
292
|
+
unless auto_yes
|
|
293
|
+
STDERR.print "Delete #{total_to_delete} rows? [y/N] "
|
|
294
|
+
STDERR.flush
|
|
295
|
+
answer = STDIN.gets
|
|
296
|
+
unless answer && answer.strip.downcase == 'y'
|
|
297
|
+
puts "Cancelled."
|
|
298
|
+
db.close
|
|
299
|
+
exit 0
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
## Delete phase:
|
|
304
|
+
total_deleted = 0
|
|
305
|
+
tables.each do |table|
|
|
306
|
+
info = table_counts[table]
|
|
307
|
+
next if info[:count] == 0
|
|
308
|
+
|
|
309
|
+
delete_query = "DELETE FROM #{table} WHERE #{info[:where]}"
|
|
310
|
+
db.execute(delete_query, info[:params])
|
|
311
|
+
deleted = db.changes
|
|
312
|
+
total_deleted += deleted
|
|
313
|
+
|
|
314
|
+
label = table == 'outbound_messages' ? 'Outbound' : 'Inbound'
|
|
315
|
+
puts " #{label}: #{deleted} rows deleted"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
puts ""
|
|
319
|
+
puts "Total: #{total_deleted} rows deleted."
|
|
320
|
+
|
|
321
|
+
## VACUUM (default unless --no-vacuum):
|
|
322
|
+
unless no_vacuum
|
|
323
|
+
puts "Running VACUUM to reclaim disk space..."
|
|
324
|
+
db.execute("VACUUM")
|
|
325
|
+
puts "VACUUM complete."
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
db.close
|
|
329
|
+
|
|
330
|
+
rescue SQLite3::BusyException => e
|
|
331
|
+
STDERR.puts "ERROR: Database is busy: #{e.message}"
|
|
332
|
+
STDERR.puts "The daemon or another process is holding the lock. Try again."
|
|
333
|
+
exit 1
|
|
334
|
+
rescue SQLite3::Exception => e
|
|
335
|
+
STDERR.puts "ERROR: Database error: #{e.class}: #{e.message}"
|
|
336
|
+
exit 1
|
|
337
|
+
end
|
data/exe/sg-recv
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
#
|
|
4
|
+
# sg-recv -- SecureDGram CLI tool to read received (inbound) messages
|
|
5
|
+
#
|
|
6
|
+
# Queries the inbound_messages table and outputs results as JSON.
|
|
7
|
+
# Can also query outbound message status.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# sg-recv [options] # show recent inbound messages
|
|
11
|
+
# sg-recv --outbound [options] # show outbound message status
|
|
12
|
+
# sg-recv --follow # poll for new messages continuously
|
|
13
|
+
|
|
14
|
+
require 'securedgram/version'
|
|
15
|
+
require 'securedgram/env_loader'
|
|
16
|
+
|
|
17
|
+
## Load .env:
|
|
18
|
+
SecureDGram::EnvLoader.load_dotenv
|
|
19
|
+
|
|
20
|
+
require 'optparse'
|
|
21
|
+
require 'json'
|
|
22
|
+
require 'sqlite3'
|
|
23
|
+
|
|
24
|
+
## Defaults:
|
|
25
|
+
db_path = ENV.fetch('SECUREDGRAM_DB', 'securedgram.db')
|
|
26
|
+
limit = 20
|
|
27
|
+
outbound = false
|
|
28
|
+
state = nil
|
|
29
|
+
from_addr = nil
|
|
30
|
+
msg_id = nil
|
|
31
|
+
follow = false
|
|
32
|
+
interval = 1.0
|
|
33
|
+
since_id = nil
|
|
34
|
+
compact = false
|
|
35
|
+
payload_only = false
|
|
36
|
+
read_included = false
|
|
37
|
+
no_mark = false
|
|
38
|
+
|
|
39
|
+
optparser = OptionParser.new do |opts|
|
|
40
|
+
opts.banner = "Usage: #{File.basename($0)} [options]"
|
|
41
|
+
opts.separator ""
|
|
42
|
+
opts.separator "SecureDGram v#{SecureDGram::VERSION} -- Read messages from the database."
|
|
43
|
+
opts.separator ""
|
|
44
|
+
opts.separator "Options:"
|
|
45
|
+
|
|
46
|
+
opts.on("--db PATH", "SQLite3 database path (default: from .env)") do |path|
|
|
47
|
+
db_path = path
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
opts.on("-n", "--limit N", Integer, "Number of messages to return (default: 20, 0 = all)") do |n|
|
|
51
|
+
limit = n
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
opts.on("-o", "--outbound", "Query outbound messages instead of inbound") do
|
|
55
|
+
outbound = true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
opts.on("-s", "--state STATE", "Filter by message state (e.g., ack_sent, received, pending, sent, acknowledged)") do |s|
|
|
59
|
+
state = s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
opts.on("--from ADDR", "Filter inbound by sender IP address") do |addr|
|
|
63
|
+
from_addr = addr
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
opts.on("--to ADDR", "Filter outbound by destination IP address") do |addr|
|
|
67
|
+
from_addr = addr ## Reuse same variable; context changes meaning
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
opts.on("-i", "--id MESSAGE_ID", "Look up a specific message by message_id") do |mid|
|
|
71
|
+
msg_id = mid.strip.downcase
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
opts.on("-f", "--follow", "Continuously poll for new messages (Ctrl+C to stop)") do
|
|
75
|
+
follow = true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
opts.on("--interval SECS", Float, "Poll interval in seconds for --follow mode (default: 1.0)") do |secs|
|
|
79
|
+
interval = secs
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
opts.on("--since-id ID", Integer, "Only show messages with row id > ID") do |id|
|
|
83
|
+
since_id = id
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
opts.on("-c", "--compact", "Output one JSON object per line (JSONL) instead of pretty-printed") do
|
|
87
|
+
compact = true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
opts.on("-p", "--payload-only", "Output only the payload field of each message") do
|
|
91
|
+
payload_only = true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
opts.on("-r", "--read-included", "Include already-read messages (default: unread only for inbound)") do
|
|
95
|
+
read_included = true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
opts.on("--no-mark", "Don't increment read_count (peek without marking as read)") do
|
|
99
|
+
no_mark = true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
opts.on("-V", "--version", "Show version and exit") do
|
|
103
|
+
puts "sg-recv (SecureDGram) #{SecureDGram::VERSION}"
|
|
104
|
+
exit
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
opts.on("-h", "--help", "Show this help") do
|
|
108
|
+
puts opts
|
|
109
|
+
exit
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
begin
|
|
114
|
+
optparser.parse!(ARGV)
|
|
115
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
|
|
116
|
+
STDERR.puts "ERROR: #{e.message}"
|
|
117
|
+
STDERR.puts optparser
|
|
118
|
+
exit 1
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
## Verify database exists:
|
|
122
|
+
unless File.file?(db_path)
|
|
123
|
+
STDERR.puts "ERROR: Database not found at #{db_path}"
|
|
124
|
+
STDERR.puts "Has the daemon been started at least once? (It creates the DB on first run.)"
|
|
125
|
+
exit 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
## Build query:
|
|
129
|
+
def build_query(outbound, msg_id, state, from_addr, since_id, limit, read_included)
|
|
130
|
+
table = outbound ? "outbound_messages" : "inbound_messages"
|
|
131
|
+
conditions = []
|
|
132
|
+
params = []
|
|
133
|
+
|
|
134
|
+
if msg_id
|
|
135
|
+
conditions << "message_id = ?"
|
|
136
|
+
params << msg_id
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if state
|
|
140
|
+
conditions << "state = ?"
|
|
141
|
+
params << state
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
if from_addr
|
|
145
|
+
if outbound
|
|
146
|
+
conditions << "dst_addr = ?"
|
|
147
|
+
else
|
|
148
|
+
conditions << "src_addr = ?"
|
|
149
|
+
end
|
|
150
|
+
params << from_addr
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if since_id
|
|
154
|
+
conditions << "id > ?"
|
|
155
|
+
params << since_id
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
## For inbound queries, default to unread only (read_count = 0):
|
|
159
|
+
if !outbound && !read_included && !msg_id
|
|
160
|
+
conditions << "read_count = 0"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
where = conditions.empty? ? "" : " WHERE " + conditions.join(" AND ")
|
|
164
|
+
limit_clause = (limit > 0) ? " LIMIT #{limit}" : ""
|
|
165
|
+
|
|
166
|
+
query = "SELECT * FROM #{table}#{where} ORDER BY id DESC#{limit_clause}"
|
|
167
|
+
return query, params
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
## Format a row for output:
|
|
171
|
+
def format_row(row, payload_only)
|
|
172
|
+
if payload_only
|
|
173
|
+
begin
|
|
174
|
+
return JSON.parse(row['payload'])
|
|
175
|
+
rescue
|
|
176
|
+
return row['payload']
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
## Convert the Hash to a clean structure:
|
|
180
|
+
result = {}
|
|
181
|
+
row.each do |key, value|
|
|
182
|
+
next if key.is_a?(Integer) ## Skip numeric index keys from results_as_hash
|
|
183
|
+
result[key] = value
|
|
184
|
+
end
|
|
185
|
+
## Parse payload JSON for nested output:
|
|
186
|
+
if result['payload']
|
|
187
|
+
begin
|
|
188
|
+
result['payload'] = JSON.parse(result['payload'])
|
|
189
|
+
rescue
|
|
190
|
+
## Leave as string if not valid JSON
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
result
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
begin
|
|
197
|
+
db = SQLite3::Database.new(db_path)
|
|
198
|
+
db.results_as_hash = true
|
|
199
|
+
db.execute("PRAGMA journal_mode = WAL")
|
|
200
|
+
db.busy_timeout = 5000
|
|
201
|
+
|
|
202
|
+
if follow
|
|
203
|
+
## Follow mode: continuously poll for new messages
|
|
204
|
+
last_id = since_id || 0
|
|
205
|
+
|
|
206
|
+
## Get the current max id as starting point if no since_id given:
|
|
207
|
+
if last_id == 0
|
|
208
|
+
table = outbound ? "outbound_messages" : "inbound_messages"
|
|
209
|
+
max_row = db.get_first_row("SELECT MAX(id) as max_id FROM #{table}")
|
|
210
|
+
last_id = (max_row && max_row['max_id']) ? max_row['max_id'] : 0
|
|
211
|
+
STDERR.puts "Following new messages (since id #{last_id}). Press Ctrl+C to stop."
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
begin
|
|
215
|
+
loop do
|
|
216
|
+
query, params = build_query(outbound, msg_id, state, from_addr, last_id, 0, read_included)
|
|
217
|
+
## Override ORDER to ASC for follow mode:
|
|
218
|
+
query = query.sub("ORDER BY id DESC", "ORDER BY id ASC")
|
|
219
|
+
rows = db.execute(query, params)
|
|
220
|
+
|
|
221
|
+
rows.each do |row|
|
|
222
|
+
formatted = format_row(row, payload_only)
|
|
223
|
+
if compact
|
|
224
|
+
puts JSON.generate(formatted)
|
|
225
|
+
else
|
|
226
|
+
puts JSON.pretty_generate(formatted)
|
|
227
|
+
end
|
|
228
|
+
STDOUT.flush
|
|
229
|
+
last_id = row['id'] if row['id'] > last_id
|
|
230
|
+
|
|
231
|
+
## Mark as read (increment read_count) for inbound messages:
|
|
232
|
+
if !outbound && !no_mark
|
|
233
|
+
db.execute(
|
|
234
|
+
"UPDATE inbound_messages SET read_count = read_count + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE id = ?",
|
|
235
|
+
[row['id']]
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
sleep interval
|
|
241
|
+
end
|
|
242
|
+
rescue Interrupt
|
|
243
|
+
STDERR.puts "\nStopped."
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
else
|
|
247
|
+
## One-shot query:
|
|
248
|
+
query, params = build_query(outbound, msg_id, state, from_addr, since_id, limit, read_included)
|
|
249
|
+
rows = db.execute(query, params)
|
|
250
|
+
|
|
251
|
+
results = rows.map { |row| format_row(row, payload_only) }
|
|
252
|
+
|
|
253
|
+
## Mark as read (increment read_count) for inbound messages:
|
|
254
|
+
if !outbound && !no_mark
|
|
255
|
+
rows.each do |row|
|
|
256
|
+
db.execute(
|
|
257
|
+
"UPDATE inbound_messages SET read_count = read_count + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE id = ?",
|
|
258
|
+
[row['id']]
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
if msg_id && results.size == 1
|
|
264
|
+
## Single lookup: output the object directly:
|
|
265
|
+
if compact
|
|
266
|
+
puts JSON.generate(results.first)
|
|
267
|
+
else
|
|
268
|
+
puts JSON.pretty_generate(results.first)
|
|
269
|
+
end
|
|
270
|
+
else
|
|
271
|
+
if compact
|
|
272
|
+
results.each { |r| puts JSON.generate(r) }
|
|
273
|
+
else
|
|
274
|
+
puts JSON.pretty_generate(results)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
db.close
|
|
280
|
+
|
|
281
|
+
rescue SQLite3::BusyException => e
|
|
282
|
+
STDERR.puts "ERROR: Database is busy: #{e.message}"
|
|
283
|
+
exit 1
|
|
284
|
+
rescue SQLite3::Exception => e
|
|
285
|
+
STDERR.puts "ERROR: Database error: #{e.class}: #{e.message}"
|
|
286
|
+
exit 1
|
|
287
|
+
end
|