mailcatcher-ng 1.3.2 → 1.4.6
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 +4 -4
- data/lib/mail_catcher/mail.rb +94 -7
- data/lib/mail_catcher/smtp.rb +8 -6
- data/lib/mail_catcher/version.rb +1 -1
- data/lib/mail_catcher/web/application.rb +71 -3
- data/lib/mail_catcher.rb +12 -7
- data/public/assets/atom-one-light.min.css +1 -1
- data/public/assets/favcount.js +102 -0
- data/public/assets/highlight.min.js +370 -401
- data/public/assets/jquery.min.js +2 -0
- data/public/assets/jquery.min.map +1 -0
- data/public/assets/mailcatcher-ui.js +3 -0
- data/public/assets/mailcatcher.css +43 -0
- data/public/assets/mailcatcher.js +1185 -22
- data/public/assets/popper.min.js +6 -0
- data/public/assets/popper.min.js.map +1 -0
- data/public/assets/tippy-light.min.css +1 -0
- data/public/assets/tippy.min.js +2 -0
- data/views/index.erb +20 -7
- data/views/server_info.erb +594 -94
- metadata +8 -21
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2238cc35e17eb1bca222b4adf177bd093f9eacf291f5b179c3c1db7cc6ccfc53
|
|
4
|
+
data.tar.gz: 6d7487a8ef8f6488afa6f83e850c2c3f928424cd870ed313c15849c849873672
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f784c55cf0eb84d166a48ba99e36e60b3fd4028facf5e2b30fa92c3c6f17814f71434fb6e5be2f68a82c4c3f3b93c7cc2c617c5100a8515ba0e9d2a3331e3272
|
|
7
|
+
data.tar.gz: 776268ce8b6c14dcb67c5387e03611015c1dab6afc8527ef6a1bd67d42bd510d634eec220df5363625de4536f576e26fd53605140a00a2aaddac2e4190c1fe55
|
data/lib/mail_catcher/mail.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "eventmachine"
|
|
4
|
+
require "fileutils"
|
|
4
5
|
require "json"
|
|
5
6
|
require "mail"
|
|
6
7
|
require "sqlite3"
|
|
@@ -8,9 +9,10 @@ require "sqlite3"
|
|
|
8
9
|
module MailCatcher::Mail extend self
|
|
9
10
|
def db
|
|
10
11
|
@__db ||= begin
|
|
11
|
-
|
|
12
|
+
db_path = determine_db_path
|
|
13
|
+
SQLite3::Database.new(db_path, :type_translation => true).tap do |db|
|
|
12
14
|
db.execute(<<-SQL)
|
|
13
|
-
CREATE TABLE message (
|
|
15
|
+
CREATE TABLE IF NOT EXISTS message (
|
|
14
16
|
id INTEGER PRIMARY KEY ASC,
|
|
15
17
|
sender TEXT,
|
|
16
18
|
recipients TEXT,
|
|
@@ -22,7 +24,7 @@ module MailCatcher::Mail extend self
|
|
|
22
24
|
)
|
|
23
25
|
SQL
|
|
24
26
|
db.execute(<<-SQL)
|
|
25
|
-
CREATE TABLE message_part (
|
|
27
|
+
CREATE TABLE IF NOT EXISTS message_part (
|
|
26
28
|
id INTEGER PRIMARY KEY ASC,
|
|
27
29
|
message_id INTEGER NOT NULL,
|
|
28
30
|
cid TEXT,
|
|
@@ -37,7 +39,7 @@ module MailCatcher::Mail extend self
|
|
|
37
39
|
)
|
|
38
40
|
SQL
|
|
39
41
|
db.execute(<<-SQL)
|
|
40
|
-
CREATE TABLE smtp_transcript (
|
|
42
|
+
CREATE TABLE IF NOT EXISTS smtp_transcript (
|
|
41
43
|
id INTEGER PRIMARY KEY ASC,
|
|
42
44
|
message_id INTEGER,
|
|
43
45
|
session_id TEXT NOT NULL,
|
|
@@ -55,13 +57,41 @@ module MailCatcher::Mail extend self
|
|
|
55
57
|
FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE
|
|
56
58
|
)
|
|
57
59
|
SQL
|
|
58
|
-
db.execute(
|
|
59
|
-
|
|
60
|
+
db.execute(<<-SQL)
|
|
61
|
+
CREATE TABLE IF NOT EXISTS websocket_connection (
|
|
62
|
+
id INTEGER PRIMARY KEY ASC,
|
|
63
|
+
session_id TEXT NOT NULL,
|
|
64
|
+
client_ip TEXT,
|
|
65
|
+
opened_at DATETIME,
|
|
66
|
+
closed_at DATETIME,
|
|
67
|
+
last_ping_at DATETIME,
|
|
68
|
+
last_pong_at DATETIME,
|
|
69
|
+
ping_count INTEGER DEFAULT 0,
|
|
70
|
+
pong_count INTEGER DEFAULT 0,
|
|
71
|
+
created_at DATETIME DEFAULT CURRENT_DATETIME,
|
|
72
|
+
updated_at DATETIME DEFAULT CURRENT_DATETIME
|
|
73
|
+
)
|
|
74
|
+
SQL
|
|
75
|
+
db.execute("CREATE INDEX IF NOT EXISTS idx_smtp_transcript_message_id ON smtp_transcript(message_id)")
|
|
76
|
+
db.execute("CREATE INDEX IF NOT EXISTS idx_smtp_transcript_session_id ON smtp_transcript(session_id)")
|
|
77
|
+
db.execute("CREATE INDEX IF NOT EXISTS idx_websocket_connection_session_id ON websocket_connection(session_id)")
|
|
60
78
|
db.execute("PRAGMA foreign_keys = ON")
|
|
61
79
|
end
|
|
62
80
|
end
|
|
63
81
|
end
|
|
64
82
|
|
|
83
|
+
def determine_db_path
|
|
84
|
+
if MailCatcher.options && MailCatcher.options[:persistence]
|
|
85
|
+
# Use a persistent SQLite file in the user's home directory
|
|
86
|
+
db_dir = File.expand_path('~/.mailcatcher')
|
|
87
|
+
FileUtils.mkdir_p(db_dir) unless Dir.exist?(db_dir)
|
|
88
|
+
File.join(db_dir, 'mailcatcher.db')
|
|
89
|
+
else
|
|
90
|
+
# Use in-memory database
|
|
91
|
+
':memory:'
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
65
95
|
def add_message(message)
|
|
66
96
|
@add_message_query ||= db.prepare("INSERT INTO message (sender, recipients, subject, source, type, size, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))")
|
|
67
97
|
|
|
@@ -311,7 +341,10 @@ module MailCatcher::Mail extend self
|
|
|
311
341
|
|
|
312
342
|
def delete!
|
|
313
343
|
@delete_all_messages_query ||= db.prepare "DELETE FROM message"
|
|
344
|
+
@delete_all_transcripts_query ||= db.prepare "DELETE FROM smtp_transcript"
|
|
345
|
+
|
|
314
346
|
@delete_all_messages_query.execute
|
|
347
|
+
@delete_all_transcripts_query.execute
|
|
315
348
|
|
|
316
349
|
EventMachine.next_tick do
|
|
317
350
|
MailCatcher::Bus.push(type: "clear")
|
|
@@ -438,7 +471,7 @@ module MailCatcher::Mail extend self
|
|
|
438
471
|
|
|
439
472
|
def message_transcript(message_id)
|
|
440
473
|
@message_transcript_query ||= db.prepare(<<-SQL)
|
|
441
|
-
SELECT id, session_id, client_ip, client_port,
|
|
474
|
+
SELECT id, message_id, session_id, client_ip, client_port,
|
|
442
475
|
server_ip, server_port, tls_enabled, tls_protocol,
|
|
443
476
|
tls_cipher, connection_started_at, connection_ended_at,
|
|
444
477
|
entries, created_at
|
|
@@ -470,6 +503,60 @@ module MailCatcher::Mail extend self
|
|
|
470
503
|
end
|
|
471
504
|
end
|
|
472
505
|
|
|
506
|
+
def all_transcript_entries
|
|
507
|
+
@all_transcript_entries_query ||= db.prepare(<<-SQL)
|
|
508
|
+
SELECT id, message_id, session_id, client_ip, client_port,
|
|
509
|
+
server_ip, server_port, tls_enabled, tls_protocol,
|
|
510
|
+
tls_cipher, connection_started_at, connection_ended_at,
|
|
511
|
+
entries, created_at
|
|
512
|
+
FROM smtp_transcript
|
|
513
|
+
ORDER BY created_at DESC
|
|
514
|
+
SQL
|
|
515
|
+
|
|
516
|
+
@all_transcript_entries_query.execute.map do |row|
|
|
517
|
+
result = Hash[@all_transcript_entries_query.columns.zip(row)]
|
|
518
|
+
result['entries'] = JSON.parse(result['entries']) if result['entries']
|
|
519
|
+
result['tls_enabled'] = result['tls_enabled'] == 1
|
|
520
|
+
result
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def create_websocket_connection(session_id, client_ip)
|
|
525
|
+
@create_ws_connection_query ||= db.prepare(<<-SQL)
|
|
526
|
+
INSERT INTO websocket_connection (session_id, client_ip, opened_at, created_at, updated_at)
|
|
527
|
+
VALUES (?, ?, datetime('now'), datetime('now'), datetime('now'))
|
|
528
|
+
SQL
|
|
529
|
+
@create_ws_connection_query.execute(session_id, client_ip)
|
|
530
|
+
db.last_insert_row_id
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def close_websocket_connection(session_id)
|
|
534
|
+
@close_ws_connection_query ||= db.prepare(<<-SQL)
|
|
535
|
+
UPDATE websocket_connection
|
|
536
|
+
SET closed_at = datetime('now'), updated_at = datetime('now')
|
|
537
|
+
WHERE session_id = ? AND closed_at IS NULL
|
|
538
|
+
SQL
|
|
539
|
+
@close_ws_connection_query.execute(session_id)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def record_websocket_ping(session_id)
|
|
543
|
+
@record_ping_query ||= db.prepare(<<-SQL)
|
|
544
|
+
UPDATE websocket_connection
|
|
545
|
+
SET last_ping_at = datetime('now'), ping_count = ping_count + 1, updated_at = datetime('now')
|
|
546
|
+
WHERE session_id = ? AND closed_at IS NULL
|
|
547
|
+
SQL
|
|
548
|
+
@record_ping_query.execute(session_id)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def record_websocket_pong(session_id)
|
|
552
|
+
@record_pong_query ||= db.prepare(<<-SQL)
|
|
553
|
+
UPDATE websocket_connection
|
|
554
|
+
SET last_pong_at = datetime('now'), pong_count = pong_count + 1, updated_at = datetime('now')
|
|
555
|
+
WHERE session_id = ? AND closed_at IS NULL
|
|
556
|
+
SQL
|
|
557
|
+
@record_pong_query.execute(session_id)
|
|
558
|
+
end
|
|
559
|
+
|
|
473
560
|
private
|
|
474
561
|
|
|
475
562
|
def parse_authentication_results(auth_header)
|
data/lib/mail_catcher/smtp.rb
CHANGED
|
@@ -18,6 +18,7 @@ class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
|
|
|
18
18
|
@session_id = SecureRandom.uuid
|
|
19
19
|
@connection_started_at = Time.now
|
|
20
20
|
@data_started = false
|
|
21
|
+
@last_message_id = nil
|
|
21
22
|
super
|
|
22
23
|
end
|
|
23
24
|
|
|
@@ -58,8 +59,9 @@ class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
|
|
|
58
59
|
@connection_ended_at = Time.now
|
|
59
60
|
log_transcript('connection', 'server', "Connection closed")
|
|
60
61
|
|
|
61
|
-
# Save transcript
|
|
62
|
-
|
|
62
|
+
# Save transcript with the last message if available, otherwise without message_id
|
|
63
|
+
# This ensures "Connection closed" is included in the transcript
|
|
64
|
+
save_transcript(@last_message_id) if @transcript_entries.any?
|
|
63
65
|
|
|
64
66
|
super
|
|
65
67
|
end
|
|
@@ -205,6 +207,7 @@ class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
|
|
|
205
207
|
|
|
206
208
|
begin
|
|
207
209
|
message_id = MailCatcher::Mail.add_message current_message
|
|
210
|
+
@last_message_id = message_id
|
|
208
211
|
rescue => e
|
|
209
212
|
$stderr.puts "Error in add_message: #{e.message}"
|
|
210
213
|
$stderr.puts e.backtrace.join("\n")
|
|
@@ -213,16 +216,15 @@ class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
|
|
|
213
216
|
|
|
214
217
|
MailCatcher::Mail.delete_older_messages!
|
|
215
218
|
|
|
216
|
-
#
|
|
217
|
-
|
|
219
|
+
# Don't save transcript here - save it when connection closes (in unbind)
|
|
220
|
+
# This ensures "Connection closed" entry is included
|
|
218
221
|
|
|
219
222
|
true
|
|
220
223
|
rescue => exception
|
|
221
224
|
log_transcript('error', 'server', "Exception: #{exception.class} - #{exception.message}")
|
|
222
225
|
MailCatcher.log_exception("Error receiving message", @current_message, exception)
|
|
223
226
|
|
|
224
|
-
#
|
|
225
|
-
save_transcript(nil)
|
|
227
|
+
# Don't save transcript here - save it when connection closes (in unbind)
|
|
226
228
|
|
|
227
229
|
false
|
|
228
230
|
ensure
|
data/lib/mail_catcher/version.rb
CHANGED
|
@@ -73,6 +73,13 @@ module MailCatcher
|
|
|
73
73
|
erb :websocket_test
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
get "/version.json" do
|
|
77
|
+
content_type :json
|
|
78
|
+
JSON.generate({
|
|
79
|
+
version: MailCatcher::VERSION
|
|
80
|
+
})
|
|
81
|
+
end
|
|
82
|
+
|
|
76
83
|
get "/server-info" do
|
|
77
84
|
@version = MailCatcher::VERSION
|
|
78
85
|
@smtp_ip = MailCatcher.options[:smtp_ip]
|
|
@@ -101,9 +108,39 @@ module MailCatcher
|
|
|
101
108
|
@fqdn = fqdn
|
|
102
109
|
end
|
|
103
110
|
|
|
111
|
+
# Fetch all SMTP transcripts and flatten entries for display
|
|
112
|
+
transcripts = Mail.all_transcript_entries
|
|
113
|
+
@log_entries = transcripts.flat_map do |transcript|
|
|
114
|
+
transcript['entries'].map do |entry|
|
|
115
|
+
entry.merge({
|
|
116
|
+
'session_id' => transcript['session_id'],
|
|
117
|
+
'client_ip' => transcript['client_ip'],
|
|
118
|
+
'server_port' => transcript['server_port'],
|
|
119
|
+
'tls_enabled' => transcript['tls_enabled']
|
|
120
|
+
})
|
|
121
|
+
end
|
|
122
|
+
end.sort_by { |e| e['timestamp'] }
|
|
123
|
+
|
|
104
124
|
erb :server_info
|
|
105
125
|
end
|
|
106
126
|
|
|
127
|
+
get "/logs.json" do
|
|
128
|
+
content_type :json
|
|
129
|
+
transcripts = Mail.all_transcript_entries
|
|
130
|
+
log_entries = transcripts.flat_map do |transcript|
|
|
131
|
+
transcript['entries'].map do |entry|
|
|
132
|
+
entry.merge({
|
|
133
|
+
'session_id' => transcript['session_id'],
|
|
134
|
+
'client_ip' => transcript['client_ip'],
|
|
135
|
+
'server_port' => transcript['server_port'],
|
|
136
|
+
'tls_enabled' => transcript['tls_enabled']
|
|
137
|
+
})
|
|
138
|
+
end
|
|
139
|
+
end.sort_by { |e| e['timestamp'] }
|
|
140
|
+
|
|
141
|
+
JSON.generate(entries: log_entries)
|
|
142
|
+
end
|
|
143
|
+
|
|
107
144
|
delete "/" do
|
|
108
145
|
if MailCatcher.quittable?
|
|
109
146
|
MailCatcher.quit!
|
|
@@ -116,11 +153,16 @@ module MailCatcher
|
|
|
116
153
|
get "/messages" do
|
|
117
154
|
if request.websocket?
|
|
118
155
|
bus_subscription = nil
|
|
156
|
+
ping_timer = nil
|
|
157
|
+
session_id = SecureRandom.uuid
|
|
158
|
+
client_ip = request.ip
|
|
119
159
|
|
|
120
160
|
ws = Faye::WebSocket.new(request.env)
|
|
121
161
|
|
|
122
162
|
ws.on(:open) do |_|
|
|
123
|
-
$stderr.puts "[WebSocket] Connection opened"
|
|
163
|
+
$stderr.puts "[WebSocket] Connection opened (session: #{session_id}, ip: #{client_ip})"
|
|
164
|
+
MailCatcher::Mail.create_websocket_connection(session_id, client_ip)
|
|
165
|
+
|
|
124
166
|
bus_subscription = MailCatcher::Bus.subscribe do |message|
|
|
125
167
|
begin
|
|
126
168
|
$stderr.puts "[WebSocket] Sending message: #{message.inspect}"
|
|
@@ -130,15 +172,41 @@ module MailCatcher
|
|
|
130
172
|
MailCatcher.log_exception("Error sending message through websocket", message, exception)
|
|
131
173
|
end
|
|
132
174
|
end
|
|
175
|
+
|
|
176
|
+
# Send initial ping and set up periodic ping timer (every 30 seconds)
|
|
177
|
+
ping_interval = 30
|
|
178
|
+
ping_timer = EventMachine.add_periodic_timer(ping_interval) do
|
|
179
|
+
begin
|
|
180
|
+
$stderr.puts "[WebSocket] Sending ping (session: #{session_id})"
|
|
181
|
+
MailCatcher::Mail.record_websocket_ping(session_id)
|
|
182
|
+
ws.send(JSON.generate({ type: "ping" }))
|
|
183
|
+
rescue => exception
|
|
184
|
+
$stderr.puts "[WebSocket] Error sending ping: #{exception.message}"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
ws.on(:message) do |event|
|
|
190
|
+
begin
|
|
191
|
+
data = JSON.parse(event.data)
|
|
192
|
+
if data["type"] == "pong"
|
|
193
|
+
$stderr.puts "[WebSocket] Received pong (session: #{session_id})"
|
|
194
|
+
MailCatcher::Mail.record_websocket_pong(session_id)
|
|
195
|
+
end
|
|
196
|
+
rescue => exception
|
|
197
|
+
$stderr.puts "[WebSocket] Error processing message: #{exception.message}"
|
|
198
|
+
end
|
|
133
199
|
end
|
|
134
200
|
|
|
135
201
|
ws.on(:close) do |_|
|
|
136
|
-
$stderr.puts "[WebSocket] Connection closed"
|
|
202
|
+
$stderr.puts "[WebSocket] Connection closed (session: #{session_id})"
|
|
203
|
+
EventMachine.cancel_timer(ping_timer) if ping_timer
|
|
137
204
|
MailCatcher::Bus.unsubscribe(bus_subscription) if bus_subscription
|
|
205
|
+
MailCatcher::Mail.close_websocket_connection(session_id)
|
|
138
206
|
end
|
|
139
207
|
|
|
140
208
|
ws.on(:error) do |event|
|
|
141
|
-
$stderr.puts "[WebSocket] WebSocket error: #{event}"
|
|
209
|
+
$stderr.puts "[WebSocket] WebSocket error: #{event} (session: #{session_id})"
|
|
142
210
|
end
|
|
143
211
|
|
|
144
212
|
ws.rack_response
|
data/lib/mail_catcher.rb
CHANGED
|
@@ -85,6 +85,7 @@ module MailCatcher
|
|
|
85
85
|
http_port: '1080',
|
|
86
86
|
http_path: '/',
|
|
87
87
|
messages_limit: nil,
|
|
88
|
+
persistence: false,
|
|
88
89
|
verbose: false,
|
|
89
90
|
daemon: !windows?,
|
|
90
91
|
browse: false,
|
|
@@ -109,7 +110,7 @@ module MailCatcher
|
|
|
109
110
|
parser.banner = 'Usage: mailcatcher [options]'
|
|
110
111
|
parser.version = VERSION
|
|
111
112
|
parser.separator ''
|
|
112
|
-
parser.separator "MailCatcher v#{VERSION}"
|
|
113
|
+
parser.separator "MailCatcher NG v#{VERSION}"
|
|
113
114
|
parser.separator ''
|
|
114
115
|
|
|
115
116
|
parser.on('--ip IP', 'Set the ip address of both servers') do |ip|
|
|
@@ -157,6 +158,10 @@ module MailCatcher
|
|
|
157
158
|
options[:messages_limit] = count
|
|
158
159
|
end
|
|
159
160
|
|
|
161
|
+
parser.on('--persistence', 'Store messages in a persistent SQLite database file') do
|
|
162
|
+
options[:persistence] = true
|
|
163
|
+
end
|
|
164
|
+
|
|
160
165
|
parser.on('--http-path PATH', String, 'Add a prefix to all HTTP paths') do |path|
|
|
161
166
|
clean_path = Rack::Utils.clean_path_info("/#{path}")
|
|
162
167
|
|
|
@@ -342,15 +347,15 @@ module MailCatcher
|
|
|
342
347
|
|
|
343
348
|
# Configure the STARTTLS Smtp class
|
|
344
349
|
Smtp.class_variable_set(:@@parms, {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
350
|
+
starttls: :required,
|
|
351
|
+
starttls_options: ssl_options
|
|
352
|
+
})
|
|
348
353
|
|
|
349
354
|
# Configure the Direct TLS SmtpTls class
|
|
350
355
|
SmtpTls.class_variable_set(:@@parms, {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
356
|
+
starttls: :required,
|
|
357
|
+
starttls_options: ssl_options
|
|
358
|
+
})
|
|
354
359
|
|
|
355
360
|
@ssl_options = ssl_options
|
|
356
361
|
end
|
|
@@ -1 +1 @@
|
|
|
1
|
-
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#383a42;background:#fafafa}.hljs-comment,.hljs-quote{color:#a0a1a7;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#a626a4}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e45649}.hljs-literal{color:#0184bb}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#50a14f}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#986801}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#4078f2}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#c18401}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
|
|
1
|
+
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#383a42;background:#fafafa}.hljs-comment,.hljs-quote{color:#a0a1a7;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#a626a4}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e45649}.hljs-literal{color:#0184bb}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#50a14f}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#986801}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#4078f2}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#c18401}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* favcount.js v1.5.0
|
|
3
|
+
* http://chrishunt.co/favcount
|
|
4
|
+
* Dynamically updates the favicon with a number.
|
|
5
|
+
*
|
|
6
|
+
* Copyright 2013, Chris Hunt
|
|
7
|
+
* Released under the MIT license
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
(function(){
|
|
11
|
+
function Favcount(icon) {
|
|
12
|
+
this.icon = icon;
|
|
13
|
+
this.opacity = 0.4;
|
|
14
|
+
this.canvas = document.createElement('canvas');
|
|
15
|
+
this.font = "Helvetica, Arial, sans-serif";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Favcount.prototype.set = function(count) {
|
|
19
|
+
var self = this,
|
|
20
|
+
img = document.createElement('img');
|
|
21
|
+
|
|
22
|
+
if (self.canvas.getContext) {
|
|
23
|
+
img.crossOrigin = "anonymous";
|
|
24
|
+
|
|
25
|
+
img.onload = function() {
|
|
26
|
+
drawCanvas(self.canvas, self.opacity, self.font, img, normalize(count));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
img.src = this.icon;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function normalize(count) {
|
|
34
|
+
count = Math.round(count);
|
|
35
|
+
|
|
36
|
+
if (isNaN(count) || count < 1) {
|
|
37
|
+
return '';
|
|
38
|
+
} else if (count < 10) {
|
|
39
|
+
return ' ' + count;
|
|
40
|
+
} else if (count > 99) {
|
|
41
|
+
return '99';
|
|
42
|
+
} else {
|
|
43
|
+
return count;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function drawCanvas(canvas, opacity, font, img, count) {
|
|
48
|
+
var head = document.getElementsByTagName('head')[0],
|
|
49
|
+
favicon = document.createElement('link'),
|
|
50
|
+
multiplier, fontSize, context, xOffset, yOffset, border, shadow;
|
|
51
|
+
|
|
52
|
+
favicon.rel = 'icon';
|
|
53
|
+
|
|
54
|
+
// Scale canvas elements based on favicon size
|
|
55
|
+
multiplier = img.width / 16;
|
|
56
|
+
fontSize = multiplier * 11;
|
|
57
|
+
xOffset = multiplier;
|
|
58
|
+
yOffset = multiplier * 11;
|
|
59
|
+
border = multiplier;
|
|
60
|
+
shadow = multiplier * 2;
|
|
61
|
+
|
|
62
|
+
canvas.height = canvas.width = img.width;
|
|
63
|
+
context = canvas.getContext('2d');
|
|
64
|
+
context.font = 'bold ' + fontSize + 'px ' + font;
|
|
65
|
+
|
|
66
|
+
// Draw faded favicon background
|
|
67
|
+
if (count) { context.globalAlpha = opacity; }
|
|
68
|
+
context.drawImage(img, 0, 0);
|
|
69
|
+
context.globalAlpha = 1.0;
|
|
70
|
+
|
|
71
|
+
// Draw white drop shadow
|
|
72
|
+
context.shadowColor = '#FFF';
|
|
73
|
+
context.shadowBlur = shadow;
|
|
74
|
+
context.shadowOffsetX = 0;
|
|
75
|
+
context.shadowOffsetY = 0;
|
|
76
|
+
|
|
77
|
+
// Draw white border
|
|
78
|
+
context.fillStyle = '#FFF';
|
|
79
|
+
context.fillText(count, xOffset, yOffset);
|
|
80
|
+
context.fillText(count, xOffset + border, yOffset);
|
|
81
|
+
context.fillText(count, xOffset, yOffset + border);
|
|
82
|
+
context.fillText(count, xOffset + border, yOffset + border);
|
|
83
|
+
|
|
84
|
+
// Draw black count
|
|
85
|
+
context.fillStyle = '#000';
|
|
86
|
+
context.fillText(count,
|
|
87
|
+
xOffset + (border / 2.0),
|
|
88
|
+
yOffset + (border / 2.0)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Replace favicon with new favicon
|
|
92
|
+
favicon.href = canvas.toDataURL('image/png');
|
|
93
|
+
head.removeChild(document.querySelector('link[rel=icon]'));
|
|
94
|
+
head.appendChild(favicon);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.Favcount = Favcount;
|
|
98
|
+
}).call(this);
|
|
99
|
+
|
|
100
|
+
(function(){
|
|
101
|
+
Favcount.VERSION = '1.5.0';
|
|
102
|
+
}).call(this);
|