├── .gitignore
├── LICENSE.txt
├── Procfile
├── README.md
├── bot.js
├── package.json
└── public
├── favicon.ico
├── index.html
├── style.css
├── tv-bg-squiggle-matte.png
└── tv-bg-squiggle.png
/.gitignore:
--------------------------------------------------------------------------------
1 | # Configs
2 | nodemon.json
3 |
4 | # Logs
5 | logs
6 | *.log
7 | npm-debug.log*
8 |
9 | # Runtime data
10 | pids
11 | *.pid
12 | *.seed
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
21 | .grunt
22 |
23 | # node-waf configuration
24 | .lock-wscript
25 |
26 | # Compiled binary addons (http://nodejs.org/api/addons.html)
27 | build/Release
28 |
29 | # Dependency directory
30 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
31 | node_modules
32 |
33 | # Optional npm cache directory
34 | .npm
35 |
36 | # Optional REPL history
37 | .node_repl_history
38 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Totally Viable
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: node bot.js
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # snitch
2 | Spy on our Slack room
3 |
4 | 
5 |
--------------------------------------------------------------------------------
/bot.js:
--------------------------------------------------------------------------------
1 | var os = require('os');
2 | var util = require('util');
3 | var _ = require('underscore');
4 | var s = require("underscore.string");
5 |
6 | var Botkit = require('botkit');
7 |
8 | var express = require('express');
9 | var app = express();
10 | var http = require('http').Server(app);
11 | var request = require('request');
12 | var io = require('socket.io')(http);
13 |
14 | var redis = require('redis'),
15 | redis_client = redis.createClient(process.env.REDIS_URL);
16 |
17 | // logs
18 |
19 | function _log(){
20 | console.log.apply(console, arguments);
21 | }
22 |
23 | function _error(){
24 | console.error.apply(console, arguments);
25 | }
26 |
27 | function _dump(){
28 | _.each(arguments, function(arg){
29 | _log(util.inspect(arg));
30 | });
31 | }
32 |
33 | // web server
34 |
35 | if (! process.env.PORT) {
36 | process.env.PORT = 3000;
37 | }
38 |
39 | app.use(express.static('public'));
40 |
41 | http.listen(process.env.PORT, function(){
42 | _log('listening on port ' + process.env.PORT);
43 | });
44 |
45 | app.get("/file/:file_id/:variant.:ext?", function(req, res){
46 | get_file(req.params.file_id, function(err, file){
47 | file = JSON.parse(file);
48 |
49 | if (err || ! file) {
50 | return request('https://slack-imgs.com/?url=null&width=360&height=250').pipe(res);
51 | }
52 |
53 | // TODO: check if file mode is hosted or external
54 |
55 | var url = file[req.params.variant];
56 |
57 | if (! url) {
58 | return request('https://slack-imgs.com/?url=null&width=360&height=250').pipe(res);
59 | }
60 |
61 | request({
62 | url: url,
63 | headers: {
64 | 'Authorization': 'Bearer ' + bot.config.token
65 | }
66 | }).pipe(res);
67 | });
68 | });
69 |
70 | var cache = {
71 | users: {},
72 | bots: {},
73 | channels: {},
74 | };
75 |
76 | // slackbot
77 |
78 | if (! process.env.SLACK_BOT_TOKEN) {
79 | _error('Error: Specify SLACK_BOT_TOKEN in environment');
80 | process.exit(1);
81 | }
82 |
83 | var controller = Botkit.slackbot({
84 | debug: true,
85 | });
86 |
87 | var bot = controller.spawn({
88 | simple_latest: true,
89 | no_unreads: true,
90 | token: process.env.SLACK_BOT_TOKEN
91 | }).startRTM(function(err, bot, res){
92 | if (err || ! res.ok) {
93 | _error("Error with startRTM, crashing...");
94 | process.exit(1);
95 | }
96 |
97 | cache.users = {};
98 | _.each(res.users, function(item){
99 | cache.users[item.id] = item;
100 | });
101 |
102 | _.each(res.bots, function(item){
103 | cache.bots[item.id] = item;
104 | });
105 |
106 | cache.channels = {};
107 | _.each(res.channels, function(item){
108 | cache.channels[item.id] = item;
109 | });
110 | });
111 |
112 | // integrations
113 |
114 | controller.on('channel_created', function(bot, message){
115 | cache_list('channels');
116 |
117 | // TODO: update clients
118 | });
119 |
120 |
121 | controller.on('channel_deleted', function(bot, message){
122 | cache_list('channels');
123 |
124 | // TODO: update clients
125 | });
126 |
127 |
128 | controller.on('channel_rename', function(bot, message){
129 | cache_list('channels');
130 |
131 | // TODO: update clients
132 | });
133 |
134 |
135 | controller.on('channel_archive', function(bot, message){
136 | cache_list('channels');
137 |
138 | // TODO: update clients
139 | });
140 |
141 |
142 | controller.on('channel_unarchive', function(bot, message){
143 | cache_list('channels');
144 |
145 | // TODO: update clients
146 | });
147 |
148 |
149 | controller.on('user_channel_join', function(bot, message){
150 | cache_list('channels');
151 |
152 | // TODO: update clients
153 | // _log(util.inspect(message));
154 | });
155 |
156 |
157 | controller.on('channel_leave', function(bot, message){
158 | cache_list('channels');
159 |
160 | // TODO: update clients
161 | // _log(util.inspect(message));
162 | });
163 |
164 |
165 | controller.on('team_join', function(bot, message){
166 | bot.botkit.log("[TEAM JOIN] " + message.user.name);
167 |
168 | cache.users[message.user.id] = message.user;
169 |
170 | // TODO: update clients
171 | });
172 |
173 |
174 | controller.on('user_change', function(bot, message){
175 | bot.botkit.log("[USER CHANGE] " + message.user.name);
176 |
177 | cache.users[message.user.id] = message.user;
178 |
179 | // TODO: update clients
180 | });
181 |
182 |
183 | controller.on('bot_added', function(bot, message){
184 | bot.botkit.log("[BOT ADDED] " + message.bot.name);
185 |
186 | cache.bots[message.bot.id] = message.bot;
187 |
188 | // TODO: update clients
189 | });
190 |
191 |
192 | controller.on('bot_changed', function(bot, message){
193 | bot.botkit.log("[BOT UPDATED] " + message.bot.name);
194 |
195 | cache.bots[message.bot.id] = message.bot;
196 |
197 | // TODO: update clients
198 | });
199 |
200 |
201 | controller.on('ambient', function(bot, message){
202 | bot.botkit.log("[AMBIENT] " + message.text);
203 |
204 | io.emit('message', sanitized_message(message));
205 |
206 | save_message(message.channel, message.ts, message);
207 |
208 | bot.api.reactions.add({
209 | timestamp: message.ts,
210 | channel: message.channel,
211 | name: 'white_small_square',
212 | }, emoji_reaction_error_callback);
213 | });
214 |
215 |
216 | controller.on('bot_message', function(bot, message){
217 | bot.botkit.log("[BOT MESSAGE] " + message.text);
218 |
219 | io.emit('message', sanitized_message(message));
220 |
221 | save_message(message.channel, message.ts, message);
222 |
223 | bot.api.reactions.add({
224 | timestamp: message.ts,
225 | channel: message.channel,
226 | name: 'white_small_square',
227 | }, emoji_reaction_error_callback);
228 | });
229 |
230 |
231 | controller.on('me_message', function(bot, message){
232 | message.text = "/me " + message.text;
233 |
234 | bot.botkit.log("[ME MESSAGE] " + message.text);
235 |
236 | io.emit('message', sanitized_message(message));
237 |
238 | save_message(message.channel, message.ts, message);
239 |
240 | bot.api.reactions.add({
241 | timestamp: message.ts,
242 | channel: message.channel,
243 | name: 'white_small_square',
244 | }, emoji_reaction_error_callback);
245 | });
246 |
247 |
248 | controller.on('message_changed', function(bot, message){
249 | bot.botkit.log("[MESSAGE CHANGED] " + util.inspect(message));
250 |
251 | // copy channel to actual message, so it won't get lost on edit
252 | message.message.channel = message.channel;
253 |
254 | // TODO: update clients
255 |
256 | update_message(message.channel, message.message.ts, message.message);
257 |
258 | if (message.file) {
259 | update_file(message.file);
260 | }
261 |
262 | bot.api.reactions.add({
263 | timestamp: message.message.ts,
264 | channel: message.channel,
265 | name: 'small_orange_diamond',
266 | }, emoji_reaction_error_callback);
267 | });
268 |
269 |
270 | controller.on('message_deleted', function(bot, message){
271 | bot.botkit.log("[MESSAGE DELETED] " + util.inspect(message));
272 |
273 | // TODO: update clients
274 |
275 | delete_message(message.channel, message.deleted_ts);
276 |
277 | bot.startPrivateConversation(message.previous_message, function(err, dm){
278 | dm.say("I removed the message you deleted from the public record.");
279 | });
280 | });
281 |
282 |
283 | controller.on('user_typing', function(bot, message){
284 | bot.botkit.log('[TYPING] ' + message.user);
285 |
286 | io.emit('typing', {
287 | channel: sanitized_channel(message.channel),
288 | user: sanitized_user(message.user)
289 | });
290 | });
291 |
292 |
293 | controller.on('file_share', function(bot, message){
294 | _dump("[file_share]", message);
295 |
296 | io.emit('message', sanitized_message(message));
297 |
298 | save_message(message.channel, message.ts, message);
299 |
300 | save_file(message.file);
301 |
302 | bot.api.reactions.add({
303 | file: message.file.id,
304 | name: 'white_small_square',
305 | }, emoji_reaction_error_callback);
306 | });
307 |
308 |
309 | controller.on('file_change', function(bot, message){
310 | _dump("[file_change]", message);
311 |
312 | update_file(message.file);
313 |
314 | bot.api.reactions.add({
315 | file: message.file.id,
316 | name: 'small_orange_diamond',
317 | }, emoji_reaction_error_callback);
318 | });
319 |
320 |
321 | controller.on('file_deleted', function(bot, message){
322 | _dump("[file_deleted]", message);
323 |
324 | delete_file(message.file_id);
325 | });
326 |
327 |
328 | io.on('connection', function (socket) {
329 | socket.on('request_backfill', function(data){
330 | _.each(_.keys(cache.channels), function(channel_id){
331 | get_recent_messages(channel_id, 300, function(err, response){
332 | if (err) throw err;
333 |
334 | _.each(response.reverse(), function(message){
335 | var message = JSON.parse(message);
336 |
337 | message = sanitized_message(message);
338 |
339 | message.is_backfill = true;
340 |
341 | socket.emit('message', message);
342 | });
343 | });
344 | });
345 |
346 | socket.emit('backfill_complete', true);
347 | });
348 | });
349 |
350 | controller.on('tick', function(bot, message){});
351 |
352 |
353 | controller.hears(['shutdown'],'direct_message,direct_mention,mention',function(bot, message){
354 |
355 | bot.startConversation(message, function(err, convo){
356 | convo.ask('Are you sure you want me to shutdown?',[
357 | {
358 | pattern: bot.utterances.yes,
359 | callback: function(response, convo) {
360 | convo.say('Bye!');
361 | convo.next();
362 | setTimeout(function() {
363 | process.exit();
364 | },3000);
365 | }
366 | },
367 | {
368 | pattern: bot.utterances.no,
369 | default: true,
370 | callback: function(response, convo) {
371 | convo.say('*Phew!*');
372 | convo.next();
373 | }
374 | }
375 | ]);
376 | });
377 | });
378 |
379 |
380 | controller.hears(['uptime'],'direct_message,direct_mention,mention',function(bot, message) {
381 |
382 | var hostname = os.hostname();
383 | var uptime = process.uptime();
384 | var unit = 'second';
385 |
386 | if (uptime > 60) {
387 | uptime = uptime / 60;
388 | unit = 'minute';
389 | }
390 |
391 | if (uptime > 60) {
392 | uptime = uptime / 60;
393 | unit = 'hour';
394 | }
395 |
396 | if (uptime != 1) {
397 | unit = unit + 's';
398 | }
399 |
400 | uptime = uptime + ' ' + unit;
401 |
402 | bot.reply(message,':robot_face: I am a bot named <@' + bot.identity.name + '>. I have been running for ' + uptime + ' on ' + hostname + '.');
403 | });
404 |
405 | // helpers
406 |
407 | function save_message(channel_id, ts, message) {
408 | redis_client.zadd("channels." + channel_id, ts, JSON.stringify(message), redis.print);
409 |
410 | // redis_client.zremrangebyscore("channels." + channel_id, ts, ts - (60 * 5)); // 86400
411 | }
412 |
413 | function delete_message(channel_id, ts) {
414 | redis_client.zremrangebyscore("channels." + channel_id, ts, ts, redis.print);
415 | }
416 |
417 | function update_message(channel_id, ts, message) {
418 | delete_message(channel_id, ts);
419 | save_message(channel_id, ts, message);
420 | }
421 |
422 | function get_recent_messages(channel_id, count, callback) {
423 | redis_client.zrevrangebyscore(["channels." + channel_id, "+inf", "-inf", "LIMIT", 0, count], callback)
424 | }
425 |
426 | function save_file(file) {
427 | redis_client.set("files." + file.id, JSON.stringify(file), redis.print);
428 | }
429 |
430 | function delete_file(file_id) {
431 | redis_client.del("files." + file_id, redis.print);
432 | }
433 |
434 | function update_file(file) {
435 | delete_file(file.id);
436 | save_file(file);
437 | }
438 |
439 | function get_file(file_id, callback) {
440 | redis_client.get("files." + file_id, callback)
441 | }
442 |
443 | function cache_list(variant) {
444 | var options = {
445 | api_method: variant,
446 | response_wrapper: variant
447 | };
448 |
449 | // allow overriding abnormal param names
450 |
451 | if (variant == 'users') {
452 | options.response_wrapper = 'members';
453 | }
454 |
455 | bot.api[options.api_method].list({}, function(err, res){
456 | if (err || ! res.ok) bot.botkit.log("Error calling bot.api." + options.api_method + ".list");
457 |
458 | cache[options.api_method] = {};
459 |
460 | _.each(res[options.response_wrapper], function(item){
461 | cache[options.api_method][item.id] = item;
462 | });
463 |
464 | // _log(util.inspect(cache[options.api_method]));
465 | });
466 | }
467 |
468 | function sanitized_message(message){
469 | var example = {
470 | user: { }, // sanitized_user(message.channel)
471 | channel: { }, // sanitized_channel(message.user)
472 |
473 | ts: 1234567890.12345,
474 |
475 | attachments: {
476 | file: {
477 | byline: "",
478 | name: "",
479 | low_res: "",
480 | high_res: "",
481 | initial_comment: ""
482 | },
483 | inline: [
484 | {
485 | color: "red",
486 |
487 | pretext: "",
488 |
489 | author: {
490 | name: "",
491 | subname: "",
492 | icon: ""
493 | },
494 |
495 | inline_title: "",
496 | inline_title_link: "",
497 |
498 | inline_text: "",
499 |
500 | fields: [
501 | {
502 | field_title: "",
503 | field_value: "",
504 | short: true
505 | }
506 | ],
507 |
508 | image_url: "",
509 | thumb_url: ""
510 | }
511 | ]
512 | }
513 | };
514 |
515 |
516 | var alt_payload = undefined;
517 | if (message.bot_id) alt_payload = message;
518 |
519 | var response = {
520 | ts: message.ts,
521 | user: sanitized_user(message.user || message.bot_id, alt_payload),
522 | channel: sanitized_channel(message.channel),
523 | text: reformat_message_text(message.text)
524 | };
525 |
526 | if (message.file) {
527 | response.attachments = response.attachments || {};
528 | response.attachments.file = sanitized_message_attachment_file(message.file);
529 |
530 | delete response.text;
531 | }
532 |
533 | if (_.size(message.attachments) > 0) {
534 | response.attachments = response.attachments || {};
535 | response.attachments.inline = response.attachments.inline || [];
536 |
537 | _.each(message.attachments, function(attachment){
538 | response.attachments.inline.push(sanitized_message_attachment_inline(attachment));
539 | });
540 | }
541 |
542 | return response;
543 | }
544 |
545 | function sanitized_user(user, alt_payload){
546 | var user = cache.users[user] || cache.bots[user];
547 |
548 | if (! user) {
549 | bot.botkit.log("Could not find cached user: " + user);
550 | return { name: 'Anonymous', profile: {} };
551 | }
552 |
553 | user.is_bot = !! cache.bots[user.id];
554 |
555 | user = _.pick(user, 'name', 'color', 'profile', 'icons', 'is_bot');
556 |
557 | user.profile = _.pick(user.profile, 'first_name', 'last_name', 'real_name', 'image_72');
558 |
559 | if (user.icons) {
560 | user.profile.image = user.icons.image_72;
561 | } else {
562 | user.profile.image = user.profile.image_72;
563 | }
564 |
565 | // allow overriding cache with alt payload (e.g. from bot_message)
566 | if (alt_payload && alt_payload.username) {
567 | user.name = alt_payload.username;
568 | }
569 |
570 | if (alt_payload && _.size(_.omit(alt_payload.icons, "emoji")) > 0) {
571 | user.profile.image = _.last(_.values(_.omit(alt_payload.icons, "emoji")));
572 | }
573 |
574 | return user;
575 | }
576 |
577 | function sanitized_channel(channel){
578 | var channel = cache.channels[channel];
579 |
580 | if (! channel) {
581 | bot.botkit.log("Could not find cached channel: " + channel);
582 | return { name: 'channel', members: {} };
583 | }
584 |
585 | channel = _.pick(channel, 'name', 'topic', 'members');
586 |
587 | if (channel.topic) channel.topic = channel.topic.value;
588 |
589 | var members = {};
590 | _.each(channel.members, function(id){
591 | var user = sanitized_user(id);
592 | members[user.name] = user;
593 | });
594 | channel.members = members;
595 |
596 | return channel;
597 | }
598 |
599 | function sanitized_message_attachment_file(file){
600 | if (! file) {
601 | return false;
602 | }
603 |
604 | var response = {
605 | name: file.title,
606 | full_res: "/file/" + file.id + "/url_private." + file.filetype
607 | };
608 |
609 | if (file.mode == "hosted" && s.startsWith(file.mimetype, "image/")) {
610 | response.low_res = "/file/" + file.id + "/thumb_360." + file.filetype;
611 | } else {
612 | response.download_url = "/file/" + file.id + "/url_private_download." + file.filetype;
613 | }
614 |
615 | var byline = ["uploaded"];
616 |
617 | if (file.initial_comment) {
618 | byline.push("and commented on");
619 | }
620 |
621 | if (file.mode == "hosted") {
622 | if (s.startsWith(file.mimetype, "image/")) {
623 | byline.push("an image");
624 | } else {
625 | byline.push("a file");
626 | }
627 | } else {
628 | // TODO: proper indefinite article
629 | byline.push("a " + file.pretty_type + " file");
630 | }
631 |
632 | response.byline = byline.join(" ");
633 |
634 | if (file.initial_comment) {
635 | response.initial_comment = reformat_message_text(file.initial_comment.comment);
636 | }
637 |
638 | return response;
639 | }
640 |
641 | function sanitized_message_attachment_inline(attachment){
642 | var response = _.pick(attachment, "color", "pretext", "fields", "image_url", "thumb_url");
643 |
644 | // TODO: prefer video_html (over thumb_url)
645 |
646 | response.inline_title = attachment.title;
647 | response.inline_title_link = attachment.title_link;
648 |
649 | if (attachment.author_name || attachment.author_subname || attachment.author_icon) {
650 | response.author = {
651 | name: attachment.author_name,
652 | subname: attachment.author_subname,
653 | icon: attachment.author_icon
654 | };
655 | }
656 |
657 | if (attachment.pretext) {
658 | response.pretext = reformat_message_text(attachment.pretext);
659 | }
660 |
661 | if (attachment.text) {
662 | response.inline_text = reformat_message_text(attachment.text);
663 | }
664 |
665 | if (response.color && ! s.startsWith(response.color, "#")) {
666 | response.color = "#" + response.color;
667 | }
668 |
669 | return response;
670 | }
671 |
672 | function reformat_message_text(text) {
673 | // https://api.slack.com/docs/formatting
674 | text = text.replace(/<([@#!])?([^>|]+)(?:\|([^>]+))?>/g, (function(_this) {
675 | return function(m, type, link, label) {
676 | var channel, user;
677 |
678 | switch (type) {
679 | case '@':
680 | if (label) return label;
681 |
682 | user = cache.users[link];
683 |
684 | if (user) return "@" + user.name;
685 |
686 | break;
687 | case '#':
688 | if (label) return label;
689 |
690 | channel = cache.channels[link];
691 |
692 | if (channel) return "\#" + channel.name;
693 |
694 | break;
695 | case '!':
696 | if (['channel','group','everyone','here'].indexOf(link) >= 0) {
697 | return "@" + link;
698 | }
699 |
700 | break;
701 | default:
702 | if (label) {
703 | return "" + label + "";
704 | } else {
705 | return "" + link.replace(/^mailto:/, '') + "";
706 | }
707 | }
708 | };
709 | })(this));
710 |
711 | // nl2br
712 | text = text.replace(/\n/g, "
");
713 |
714 | // me_message
715 | if (text.indexOf("/me") === 0) {
716 | text = " ";
717 | }
718 |
719 | return text;
720 | }
721 |
722 | function emoji_reaction_error_callback(err, res) {
723 | if (err) {
724 | bot.botkit.log('Failed to add emoji reaction :( ', err);
725 | }
726 | }
727 |
728 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "snitch",
3 | "version": "1.0.0",
4 | "description": "Let people spy on your Slack",
5 | "repository": {
6 | "type": "git",
7 | "url": "git://github.com/totallyviable/snitch.git"
8 | },
9 | "keywords": [
10 | "slack",
11 | "bot",
12 | "slackbot"
13 | ],
14 | "bugs": {
15 | "url": "https://github.com/totallyviable/snitch/issues"
16 | },
17 | "main": "bot.js",
18 | "scripts": {
19 | "test": "echo \"Error: no test specified\" && exit 1"
20 | },
21 | "author": "Totally Viable",
22 | "license": "MIT",
23 | "dependencies": {
24 | "botkit": "0.0.5",
25 | "express": "^4.13.3",
26 | "mustache": "^2.2.1",
27 | "redis": "^2.4.2",
28 | "request": "^2.69.0",
29 | "socket.io": "^1.4.3",
30 | "underscore": "^1.8.3",
31 | "underscore.string": "^3.2.3"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/totallyviable/snitch/ab6594b494b1d135c3c7983fe1f12a830874dfc8/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |