├── .gitignore ├── LICENSE.GPL2 ├── README.md ├── _config.json ├── app ├── admin.go ├── app.go ├── middleware.go ├── setup.go ├── static.go ├── streams.go ├── template.go ├── ui │ ├── html │ │ ├── add.page.tmpl │ │ ├── admin.page.tmpl │ │ ├── base.layout.tmpl │ │ ├── footer.partial.tmpl │ │ ├── home.page.tmpl │ │ ├── setup.page.tmpl │ │ ├── signin.page.tmpl │ │ └── stream.page.tmpl │ └── static │ │ ├── app.min.js │ │ ├── compiled.min.css │ │ ├── uplot.min.css │ │ └── uplot.min.js └── websocket.go ├── bin ├── skeef ├── skeef-mac └── skeef.exe ├── db ├── actions.go ├── checks.go ├── create.go ├── streams.go ├── tokens.go └── users.go ├── front-end ├── app.js └── tailwind.css ├── go.mod ├── go.sum ├── graph ├── edges.go ├── graph.go └── nodes.go ├── main.go ├── makefile ├── package-lock.json ├── package.json ├── postcss.config.js ├── stream └── stream.go └── tailwind.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | node_modules 3 | skeef.db 4 | -------------------------------------------------------------------------------- /LICENSE.GPL2: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | Visualise Twitter networks in near real-time. 6 | 7 | [Documentation](https://skeef.io/) 8 | 9 |
10 | 11 | --- 12 | 13 | Get the binary for your OS 14 | [here](https://github.com/devOpifex/skeef/tree/master/bin) 15 | then run it. 16 | 17 | ```bash 18 | ./skeef 19 | ``` 20 | 21 | See the [get started guide](https://skeef.io/docs/run) for more 22 | information. 23 | -------------------------------------------------------------------------------- /_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "twitterConsumerKey": "", 3 | "twitterConsumerSecret": "", 4 | "TwitterAccessToken": "", 5 | "TwitterAccessSecret": "", 6 | "port": "8080" 7 | } 8 | -------------------------------------------------------------------------------- /app/admin.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "unicode/utf8" 7 | 8 | "golang.org/x/crypto/bcrypt" 9 | ) 10 | 11 | func (app *Application) signinPage(w http.ResponseWriter, r *http.Request) { 12 | tmpls := []string{ 13 | "ui/html/signin.page.tmpl", 14 | } 15 | 16 | app.render(w, r, tmpls, templateData{}) 17 | } 18 | 19 | func (app *Application) signinForm(w http.ResponseWriter, r *http.Request) { 20 | tmpls := []string{ 21 | "ui/html/signin.page.tmpl", 22 | } 23 | 24 | err := r.ParseForm() 25 | 26 | if err != nil { 27 | http.Error(w, "Bad Request", http.StatusBadRequest) 28 | return 29 | } 30 | 31 | var tmplData templateData 32 | tmplData.Errors = make(map[string]string) 33 | 34 | email, err := app.Database.Authenticate(r.PostForm.Get("email"), r.PostForm.Get("password")) 35 | 36 | if err != nil { 37 | tmplData.Errors["credentials"] = "Invalid credentials" 38 | app.render(w, r, tmpls, tmplData) 39 | return 40 | } 41 | 42 | app.Session.Put(r, "authenticatedUserID", email) 43 | http.Redirect(w, r, "/admin", http.StatusSeeOther) 44 | } 45 | 46 | func (app *Application) isAuthenticated(r *http.Request) bool { 47 | return app.Session.Exists(r, "authenticatedUserID") 48 | } 49 | 50 | // will eventually be useful 51 | func (app *Application) GetAuthenticated(r *http.Request) string { 52 | auth := app.Session.Get(r, "authenticatedUserID") 53 | return fmt.Sprintf("%v", auth) 54 | } 55 | 56 | func (app *Application) adminPage(w http.ResponseWriter, r *http.Request) { 57 | if !app.isAuthenticated(r) { 58 | http.Redirect(w, r, "/admin/signin", http.StatusSeeOther) 59 | return 60 | } 61 | 62 | hasTokens := app.Database.TokensExist() 63 | tmplData := templateData{} 64 | tmplData.HasTokens = hasTokens 65 | tmplData.Email = app.GetAuthenticated(r) 66 | tmplData.Connected = app.Connected 67 | 68 | if hasTokens { 69 | streams, err := app.Database.GetStreams() 70 | 71 | if err != nil { 72 | tmplData.Errors["existingStreams"] = "Could not retrieve streams" 73 | } 74 | 75 | tmplData.Streams = streams 76 | } 77 | 78 | app.render(w, r, []string{"ui/html/admin.page.tmpl"}, tmplData) 79 | } 80 | 81 | func (app *Application) adminForm(w http.ResponseWriter, r *http.Request) { 82 | err := r.ParseForm() 83 | 84 | if err != nil { 85 | http.Error(w, "Bad Request", http.StatusBadRequest) 86 | return 87 | } 88 | 89 | var tmplData templateData 90 | tmplData.Errors = make(map[string]string) 91 | tmplData.Flash = make(map[string]string) 92 | tmplData.Email = app.GetAuthenticated(r) 93 | 94 | hasTokens := app.Database.TokensExist() 95 | 96 | action := r.Form.Get("action") 97 | if action == "twitter" { 98 | apiKey := r.Form.Get("apiKey") 99 | apiSecret := r.Form.Get("apiSecret") 100 | accessToken := r.Form.Get("accessToken") 101 | accessSecret := r.Form.Get("accessSecret") 102 | 103 | // UPDATE OR INSERT TOKENS 104 | if hasTokens { 105 | err = app.Database.UpdateTokens(apiKey, apiSecret, accessToken, accessSecret) 106 | 107 | if err != nil { 108 | app.ErrorLog.Println(err) 109 | tmplData.Errors["any"] = "Could not store data" 110 | } 111 | } else { 112 | err = app.Database.InsertTokens(apiKey, apiSecret, accessToken, accessSecret) 113 | 114 | if err != nil { 115 | app.ErrorLog.Println(err) 116 | tmplData.Errors["any"] = "Could not store data" 117 | } 118 | } 119 | } 120 | 121 | if action == "newPassword" { 122 | password := r.PostForm.Get("password") 123 | password2 := r.PostForm.Get("password2") 124 | 125 | if password == "" || password2 == "" { 126 | tmplData.Errors["password"] = "Empty password" 127 | } 128 | 129 | if password != password2 { 130 | tmplData.Errors["password"] = "Passwords do not match" 131 | } 132 | 133 | if utf8.RuneCountInString(password) < 5 { 134 | tmplData.Errors["password"] = "Password must be at least 5 characters" 135 | } 136 | 137 | if len(tmplData.Errors) > 0 { 138 | app.render(w, r, []string{"ui/html/profile.page.tmpl"}, tmplData) 139 | return 140 | } 141 | 142 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12) 143 | if err != nil { 144 | http.Error(w, "Could not hash password", http.StatusInternalServerError) 145 | return 146 | } 147 | 148 | err = app.Database.ChangePassword(tmplData.Email, string(hashedPassword)) 149 | 150 | if err != nil { 151 | http.Error(w, "Could not change password", http.StatusInternalServerError) 152 | return 153 | } 154 | 155 | tmplData.Flash["password"] = "Password changed!" 156 | } 157 | 158 | if action == "message" { 159 | app.NotStreaming = r.PostForm.Get("notStreaming") 160 | } 161 | 162 | if action == "deleteStream" { 163 | err = app.Database.DeleteStream(r.Form.Get("streamName")) 164 | 165 | if err != nil { 166 | tmplData.Errors["existingStreams"] = "Failed to delete the stream from the database" 167 | } else { 168 | tmplData.Flash["existingStreams"] = "Deleted stream from the database" 169 | } 170 | } 171 | 172 | if action == "startStream" { 173 | 174 | if app.Database.StreamOnGoing() { 175 | 176 | tmplData.Errors["existingStreams"] = "There is already one stream active" 177 | 178 | } else { 179 | err = app.Database.StartStream(r.Form.Get("streamName")) 180 | 181 | if err != nil { 182 | tmplData.Errors["existingStreams"] = "Failed to start stream" 183 | } else { 184 | app.InfoLog.Println("Starting stream") 185 | go func() { 186 | for { 187 | select { 188 | case <-app.Quit: 189 | return 190 | default: 191 | app.StartStream() 192 | } 193 | } 194 | }() 195 | tmplData.Flash["existingStreams"] = "Stream Started" 196 | } 197 | } 198 | } 199 | 200 | if action == "stopStream" { 201 | 202 | if !app.Database.StreamOnGoing() { 203 | tmplData.Errors["existingStreams"] = "There is no active stream to pause" 204 | } else { 205 | err = app.Database.PauseStream(r.Form.Get("streamName")) 206 | 207 | if err != nil { 208 | tmplData.Errors["existingStreams"] = "Failed to pause stream" 209 | } else { 210 | app.StopStream() 211 | tmplData.Flash["existingStreams"] = "Stream Paused" 212 | } 213 | 214 | } 215 | 216 | } 217 | 218 | // get stored streams 219 | streams, err := app.Database.GetStreams() 220 | if err != nil { 221 | tmplData.Errors["existingStreams"] = "Could not retrieve streams" 222 | } 223 | tmplData.Streams = streams 224 | 225 | tmplData.HasTokens = app.Database.TokensExist() 226 | tmplData.Connected = app.Connected 227 | 228 | app.render(w, r, []string{"ui/html/admin.page.tmpl"}, tmplData) 229 | } 230 | 231 | func (app *Application) signout(w http.ResponseWriter, r *http.Request) { 232 | app.Session.Remove(r, "authenticatedUserID") 233 | http.Redirect(w, r, "/", http.StatusSeeOther) 234 | } 235 | -------------------------------------------------------------------------------- /app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/bmizerany/pat" 8 | "github.com/devOpifex/skeef/db" 9 | "github.com/devOpifex/skeef/graph" 10 | "github.com/devOpifex/skeef/stream" 11 | "github.com/dghubble/go-twitter/twitter" 12 | "github.com/golangcollege/sessions" 13 | "github.com/justinas/alice" 14 | ) 15 | 16 | // Application Application object 17 | type Application struct { 18 | InfoLog *log.Logger 19 | ErrorLog *log.Logger 20 | Database db.Database 21 | Session *sessions.Session 22 | Addr string 23 | Count int64 24 | Stream *twitter.Stream 25 | Valid bool 26 | Pool *Pool 27 | Quit chan struct{} 28 | Streaming bool 29 | Graph graph.Graph 30 | Connected int 31 | Trend map[int64]int 32 | Exclusion map[string]bool 33 | MaxEdges int 34 | StreamActive stream.Stream 35 | NotStreaming string 36 | TweetsUsers []tweetsUsers 37 | } 38 | 39 | type Setup struct { 40 | Tables bool 41 | Admin bool 42 | } 43 | 44 | func (app *Application) home(w http.ResponseWriter, r *http.Request) { 45 | 46 | if !app.Database.AdminExists() { 47 | http.Redirect(w, r, "/setup", http.StatusSeeOther) 48 | return 49 | } 50 | 51 | var tmplData templateData 52 | tmplData.Flash = make(map[string]string) 53 | 54 | tmplData.Authenticated = app.isAuthenticated(r) 55 | 56 | tmplData.Streaming = app.Database.StreamOnGoing() 57 | 58 | // default message 59 | msg := app.NotStreaming 60 | if msg == "" { 61 | msg = "

Currently not streaming

" 62 | } 63 | tmplData.Flash["message"] = msg 64 | 65 | app.render(w, r, []string{"ui/html/home.page.tmpl"}, tmplData) 66 | } 67 | 68 | // Handlers Returns all routes 69 | func (app *Application) Handlers() http.Handler { 70 | 71 | standardMiddleware := alice.New(secureHeaders) 72 | dynamicMiddleware := alice.New(app.Session.Enable, noSurf) 73 | 74 | mux := pat.New() 75 | mux.Get("/", http.HandlerFunc(app.home)) 76 | mux.Get("/setup", dynamicMiddleware.Then(http.HandlerFunc(app.setupPage))) 77 | mux.Post("/setup", dynamicMiddleware.Then(http.HandlerFunc(app.setupForm))) 78 | mux.Get("/admin/signin", dynamicMiddleware.Then(http.HandlerFunc(app.signinPage))) 79 | mux.Post("/admin/signin", dynamicMiddleware.Then(http.HandlerFunc(app.signinForm))) 80 | mux.Get("/admin", dynamicMiddleware.Then(http.HandlerFunc(app.adminPage))) 81 | mux.Post("/admin", dynamicMiddleware.Then(http.HandlerFunc(app.adminForm))) 82 | mux.Get("/admin/signout", dynamicMiddleware.ThenFunc(app.signout)) 83 | mux.Get("/ws", dynamicMiddleware.ThenFunc(app.socket)) 84 | mux.Get("/admin/edit/:stream", dynamicMiddleware.Then(http.HandlerFunc(app.streamEditPage))) 85 | mux.Post("/admin/edit", dynamicMiddleware.Then(http.HandlerFunc(app.streamEditForm))) 86 | mux.Get("/admin/add", dynamicMiddleware.Then(http.HandlerFunc(app.streamAddPage))) 87 | mux.Post("/admin/add", dynamicMiddleware.Then(http.HandlerFunc(app.streamAddForm))) 88 | 89 | mux.Get("/static/", app.static()) 90 | 91 | return app.Session.Enable(standardMiddleware.Then(mux)) 92 | } 93 | -------------------------------------------------------------------------------- /app/middleware.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/justinas/nosurf" 7 | ) 8 | 9 | func secureHeaders(next http.Handler) http.Handler { 10 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 | w.Header().Set("X-XSS-Protection", "1; mode=block") 12 | w.Header().Set("X-Frame-Options", "deny") 13 | 14 | next.ServeHTTP(w, r) 15 | }) 16 | } 17 | 18 | func noSurf(next http.Handler) http.Handler { 19 | csrfHandler := nosurf.New(next) 20 | csrfHandler.SetBaseCookie(http.Cookie{ 21 | HttpOnly: true, 22 | Path: "/", 23 | Secure: true, 24 | }) 25 | 26 | return csrfHandler 27 | } 28 | -------------------------------------------------------------------------------- /app/setup.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "net/http" 5 | "regexp" 6 | "unicode/utf8" 7 | 8 | "golang.org/x/crypto/bcrypt" 9 | ) 10 | 11 | var EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") 12 | 13 | func (app *Application) setupPage(w http.ResponseWriter, r *http.Request) { 14 | 15 | if app.Database.AdminExists() { 16 | http.Redirect(w, r, "/setup/validate", http.StatusSeeOther) 17 | return 18 | } 19 | 20 | app.render(w, r, []string{"ui/html/setup.page.tmpl"}, templateData{}) 21 | } 22 | 23 | func (app *Application) setupForm(w http.ResponseWriter, r *http.Request) { 24 | err := r.ParseForm() 25 | if err != nil { 26 | http.Error(w, "Failed to parse form", http.StatusInternalServerError) 27 | return 28 | } 29 | 30 | email := r.PostForm.Get("email") 31 | password := r.PostForm.Get("password") 32 | password2 := r.PostForm.Get("password2") 33 | 34 | var tmplData templateData 35 | tmplData.Errors = make(map[string]string) 36 | 37 | if email == "" { 38 | tmplData.Errors["exists"] = "Empty email" 39 | } 40 | 41 | if password == "" || password2 == "" { 42 | tmplData.Errors["password"] = "Empty password" 43 | } 44 | 45 | if password != password2 { 46 | tmplData.Errors["password"] = "Passwords do not match" 47 | } 48 | 49 | if utf8.RuneCountInString(password) < 5 { 50 | tmplData.Errors["password"] = "Password must be at least 5 characters long" 51 | } 52 | 53 | if !EmailRX.MatchString(email) { 54 | tmplData.Errors["exists"] = "Invalid email address" 55 | } 56 | 57 | if len(tmplData.Errors) > 0 { 58 | app.render(w, r, []string{"ui/html/setup.page.tmpl"}, tmplData) 59 | return 60 | } 61 | 62 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12) 63 | if err != nil { 64 | http.Error(w, "Could not hash password", http.StatusInternalServerError) 65 | return 66 | } 67 | 68 | err = app.Database.InsertUser(email, string(hashedPassword), 1) 69 | 70 | if err != nil { 71 | http.Error(w, "Could not create the user", http.StatusInternalServerError) 72 | } 73 | 74 | http.Redirect(w, r, "/admin", http.StatusSeeOther) 75 | } 76 | -------------------------------------------------------------------------------- /app/static.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | "net/http" 7 | ) 8 | 9 | //go:embed ui/static 10 | var embededStatic embed.FS 11 | 12 | func (app *Application) static() http.Handler { 13 | fsys, err := fs.Sub(embededStatic, "ui/static") 14 | 15 | if err != nil { 16 | app.ErrorLog.Panic("Internal error code: e-1") 17 | return nil 18 | } 19 | 20 | return http.StripPrefix("/static", http.FileServer(http.FS(fsys))) 21 | } 22 | -------------------------------------------------------------------------------- /app/streams.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/devOpifex/skeef/stream" 9 | ) 10 | 11 | func (app *Application) streamEditPage(w http.ResponseWriter, r *http.Request) { 12 | if !app.isAuthenticated(r) { 13 | http.Redirect(w, r, "/admin/signin", http.StatusSeeOther) 14 | return 15 | } 16 | 17 | name := r.URL.Query().Get(":stream") 18 | stream, err := app.Database.GetStream(name) 19 | 20 | if err != nil { 21 | http.Error(w, "Failed to fetch stream", http.StatusInternalServerError) 22 | return 23 | } 24 | 25 | var tmplData templateData 26 | tmplData.Stream = stream 27 | 28 | app.render(w, r, []string{"ui/html/stream.page.tmpl"}, tmplData) 29 | } 30 | 31 | func (app *Application) streamEditForm(w http.ResponseWriter, r *http.Request) { 32 | err := r.ParseForm() 33 | 34 | if err != nil { 35 | http.Error(w, "Bad Request", http.StatusBadRequest) 36 | return 37 | } 38 | 39 | var tmplData templateData 40 | tmplData.Errors = make(map[string]string) 41 | tmplData.Flash = make(map[string]string) 42 | 43 | maxEdges, _ := strconv.Atoi(r.Form.Get("maxEdges")) 44 | 45 | ok := networkTypesOk( 46 | checkboxToInt(r.Form.Get("retweetsNet")), 47 | checkboxToInt(r.Form.Get("mentionsNet")), 48 | checkboxToInt(r.Form.Get("hashtagsNet")), 49 | checkboxToInt(r.Form.Get("replyNet")), 50 | ) 51 | 52 | minFollowers, _ := strconv.Atoi(r.Form.Get("minFollowerCount")) 53 | minFavorites, _ := strconv.Atoi(r.Form.Get("minFavouriteCount")) 54 | onlyVerified := onlyVerifiedToInt(r.Form.Get("onlyVerified")) 55 | maxHashtags, _ := strconv.Atoi(r.Form.Get("maxHashtags")) 56 | maxMentions, _ := strconv.Atoi(r.Form.Get("maxMentions")) 57 | 58 | if ok { 59 | err = app.Database.UpdateStream( 60 | r.Form.Get("track"), 61 | r.Form.Get("follow"), 62 | r.Form.Get("locations"), 63 | r.Form.Get("name"), 64 | r.Form.Get("currentName"), 65 | r.Form.Get("exclude"), 66 | r.Form.Get("desc"), 67 | maxEdges, 68 | checkboxToInt(r.Form.Get("retweetsNet")), 69 | checkboxToInt(r.Form.Get("mentionsNet")), 70 | checkboxToInt(r.Form.Get("hashtagsNet")), 71 | r.Form.Get("filterLevel"), 72 | minFollowers, 73 | minFavorites, 74 | onlyVerified, 75 | maxHashtags, 76 | maxMentions, 77 | checkboxToInt(r.Form.Get("replyNet")), 78 | ) 79 | 80 | if err != nil { 81 | tmplData.Errors["failure"] = "Failed to update stream" 82 | } else { 83 | tmplData.Flash["success"] = "Successfully updated stream" 84 | } 85 | } else { 86 | tmplData.Errors["failure"] = "Must check at least one of retweets, mentions, or hashtags." 87 | } 88 | 89 | tmplData.Stream = stream.Stream{ 90 | Follow: r.Form.Get("follow"), 91 | Track: r.Form.Get("track"), 92 | Locations: r.Form.Get("locations"), 93 | Name: r.Form.Get("name"), 94 | MaxEdges: maxEdges, 95 | } 96 | 97 | app.render(w, r, []string{"ui/html/stream.page.tmpl"}, tmplData) 98 | http.Redirect(w, r, "/admin/edit/"+r.Form.Get("name"), http.StatusSeeOther) 99 | } 100 | 101 | func checkboxToInt(value string) int { 102 | return len(value) 103 | } 104 | 105 | func (app *Application) streamAddPage(w http.ResponseWriter, r *http.Request) { 106 | if !app.isAuthenticated(r) { 107 | http.Redirect(w, r, "/admin/signin", http.StatusSeeOther) 108 | return 109 | } 110 | 111 | app.render(w, r, []string{"ui/html/add.page.tmpl"}, templateData{}) 112 | } 113 | 114 | func (app *Application) streamAddForm(w http.ResponseWriter, r *http.Request) { 115 | err := r.ParseForm() 116 | 117 | if err != nil { 118 | http.Error(w, "Bad Request", http.StatusBadRequest) 119 | return 120 | } 121 | 122 | var tmplData templateData 123 | tmplData.Errors = make(map[string]string) 124 | tmplData.Flash = make(map[string]string) 125 | 126 | // form inputs 127 | name := r.Form.Get("name") 128 | follow := r.Form.Get("follow") 129 | track := r.Form.Get("track") 130 | locations := r.Form.Get("locations") 131 | exclude := r.Form.Get("exclude") 132 | maxEdges := r.Form.Get("maxEdges") 133 | desc := r.Form.Get("desc") 134 | retweetsNet := checkboxToInt(r.Form.Get("retweetsNet")) 135 | mentionsNet := checkboxToInt(r.Form.Get("mentionsNet")) 136 | hashtagsNet := checkboxToInt(r.Form.Get("hashtagsNet")) 137 | replyNet := checkboxToInt(r.Form.Get("replyNet")) 138 | filterLevel := r.Form.Get("filterLevel") 139 | minFollowers, _ := strconv.Atoi(r.Form.Get("minFollowerCount")) 140 | minFavorites, _ := strconv.Atoi(r.Form.Get("minFavouriteCount")) 141 | onlyVerified := onlyVerifiedToInt(r.Form.Get("onlyVerified")) 142 | maxHashtags, _ := strconv.Atoi(r.Form.Get("maxHashtags")) 143 | maxMentions, _ := strconv.Atoi(r.Form.Get("maxMentions")) 144 | 145 | ok := networkTypesOk(retweetsNet, mentionsNet, hashtagsNet, replyNet) 146 | 147 | if !ok { 148 | tmplData.Errors["stream"] = "Must check at least one of retweets, mentions, or hashtags" 149 | } 150 | 151 | if name == "" { 152 | tmplData.Errors["stream"] = "Must specify a name" 153 | } 154 | 155 | if follow == "" && track == "" { 156 | tmplData.Errors["stream"] = "Must use 'follow' or 'track' (or both)" 157 | } 158 | 159 | streamExists, err := app.Database.StreamExists(name) 160 | 161 | if err != nil { 162 | tmplData.Errors["stream"] = "Failed to check if stream exists" 163 | } 164 | 165 | if streamExists { 166 | tmplData.Errors["stream"] = "A stream under that name already exists" 167 | } 168 | 169 | if len(tmplData.Errors) == 0 { 170 | err = app.Database.InsertStream( 171 | name, follow, track, locations, exclude, 172 | maxEdges, desc, retweetsNet, mentionsNet, 173 | hashtagsNet, filterLevel, minFollowers, 174 | minFavorites, onlyVerified, 175 | maxHashtags, maxMentions, replyNet) 176 | 177 | if err != nil { 178 | app.ErrorLog.Println(err) 179 | tmplData.Errors["stream"] = "Failed to add the stream to the database" 180 | } else { 181 | tmplData.Flash["stream"] = "Stream added to the database" 182 | } 183 | } 184 | 185 | app.render(w, r, []string{"ui/html/add.page.tmpl"}, tmplData) 186 | } 187 | 188 | func exclusionMap(exclusion string) map[string]bool { 189 | mp := make(map[string]bool) 190 | list := strings.Split(exclusion, ",") 191 | 192 | for _, str := range list { 193 | key := strings.TrimSpace(str) 194 | mp[key] = true 195 | } 196 | 197 | return mp 198 | } 199 | 200 | func networkTypesOk(retweetsNet, mentionsNet, hashtagsNet, replyNet int) bool { 201 | total := retweetsNet + mentionsNet + hashtagsNet + replyNet 202 | return total != 0 203 | } 204 | 205 | func onlyVerifiedToInt(str string) int { 206 | if str == "on" { 207 | return 1 208 | } 209 | return 0 210 | } 211 | -------------------------------------------------------------------------------- /app/template.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "embed" 5 | "net/http" 6 | "text/template" 7 | 8 | "github.com/devOpifex/skeef/stream" 9 | "github.com/justinas/nosurf" 10 | ) 11 | 12 | type templateData struct { 13 | Errors map[string]string 14 | Authenticated bool 15 | CSRFToken string 16 | HasTokens bool 17 | Flash map[string]string 18 | Streams []stream.Stream 19 | Stream stream.Stream 20 | Addr string 21 | Email string 22 | Streaming bool 23 | Connected int 24 | NotStreaming string 25 | StreamActive stream.Stream 26 | HasDescription bool 27 | } 28 | 29 | //go:embed ui/html 30 | var embededTemplates embed.FS 31 | 32 | func (app *Application) render(w http.ResponseWriter, r *http.Request, files []string, data templateData) { 33 | 34 | data.Addr = app.Addr 35 | data.NotStreaming = template.HTMLEscapeString(app.NotStreaming) 36 | data.HasDescription = app.StreamActive.Description != "" 37 | data.StreamActive = app.StreamActive 38 | 39 | tmpls := []string{ 40 | "ui/html/base.layout.tmpl", 41 | "ui/html/footer.partial.tmpl", 42 | } 43 | 44 | tmpls = append(files, tmpls...) 45 | 46 | ts, err := template.ParseFS(embededTemplates, tmpls...) 47 | if err != nil { 48 | app.ErrorLog.Println(err.Error()) 49 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 50 | return 51 | } 52 | 53 | data.CSRFToken = nosurf.Token(r) 54 | 55 | err = ts.Execute(w, data) 56 | if err != nil { 57 | app.ErrorLog.Println(err.Error()) 58 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 59 | return 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/ui/html/add.page.tmpl: -------------------------------------------------------------------------------- 1 | {{template "base" .}} 2 | 3 | {{define "title"}}Add Stream{{end}} 4 | 5 | {{define "main"}} 6 |
7 |
8 |

Add Stream

9 | {{ with .Flash.stream }} 10 |

{{ . }}

11 | {{ end }} 12 |

13 | Add a stream, you can always edit this later. 14 |

15 |
16 | {{ with .Errors.stream }} 17 |

{{ . }}

18 | {{ end }} 19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 |
28 |
29 | 30 | 31 |
32 |
33 | 34 | 35 |
36 |
37 | 38 | 39 |

40 |
41 |
42 |

Edges types

43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 |
52 |
53 |

Filter

54 | 55 | 56 | 57 | 58 | 59 | 60 |
61 |
62 | 63 |
64 | 65 |
66 |
67 |
68 |
69 | 70 | 71 |
72 |
73 | 74 | 75 |
76 |
77 |

Verified Users

78 | 79 | 80 |
81 |
82 |
83 |
84 |
85 |
86 | 87 |
88 | 89 |
90 |
91 | 92 |
93 | 94 |
95 |
96 |
97 |
98 | 99 |
100 | 101 |
102 |
103 | 104 | 105 |
106 |
107 |
108 |

Go back to the admin area.

109 |
110 | 135 |
136 | {{end}} 137 | -------------------------------------------------------------------------------- /app/ui/html/admin.page.tmpl: -------------------------------------------------------------------------------- 1 | {{template "base" .}} 2 | 3 | {{define "title"}}Admin Area{{end}} 4 | 5 | {{define "main"}} 6 |
7 |

Admin Area

8 |

9 | View stream 10 | 11 | {{ .Email }} | 12 | signout 13 | 14 |

15 | {{ if .HasTokens }} 16 |

Streaming

17 |

18 | Users watching the stream: 19 | {{ .Connected }} 20 |

21 |
22 |

Manage Streams

23 |

24 | Manage your streams 25 | - only one can be actively streaming at any one time. 26 | Add a stream 27 |

28 | {{ with .Errors.existingStreams }} 29 |

{{ . }}

30 | {{ end }} 31 | {{ with .Flash.existingStreams }} 32 | {{.}} 33 |
34 | {{ end }} 35 | {{ if not .Streams }} 36 |

No streams, add one to get started.

37 | {{ else }} 38 | {{ range $index, $stream := .Streams}} 39 |
40 |
41 |

{{ $stream.Name }}

42 |
43 |
44 |

45 | {{ if $stream.RetweetsNet }} 46 | 47 | 48 | 49 | {{ else }} 50 | 51 | 52 | 53 | {{ end }} 54 | {{ if $stream.MentionsNet }} 55 | 56 | 57 | 58 | {{ else }} 59 | 60 | 61 | 62 | {{ end }} 63 | {{ if $stream.HashtagsNet }} 64 | 65 | 66 | 67 | {{ else }} 68 | 69 | 70 | 71 | {{ end }} 72 | {{ if $stream.ReplyNet }} 73 | 74 | 75 | 76 | {{ else }} 77 | 78 | 79 | 80 | {{ end }} 81 |

82 |
83 |
84 |

85 | {{ if $stream.Track }} 86 | 87 | 88 | 89 | {{ else }} 90 | 91 | 92 | 93 | {{ end }} 94 | track 95 |

96 |
97 |
98 |

99 | {{ if $stream.Follow }} 100 | 101 | 102 | 103 | {{ else }} 104 | 105 | 106 | 107 | {{ end }} 108 | follow 109 |

110 |
111 |
112 |

113 | {{ if $stream.Locations }} 114 | 115 | 116 | 117 | {{ else }} 118 | 119 | 120 | 121 | {{ end }} 122 | locations 123 |

124 |
125 |
126 |

127 | 128 | 129 | 130 | {{ $stream.MaxEdges }} edges 131 |

132 |
133 |
134 |
135 | 136 | 137 | {{ if $stream.Active }} 138 | 145 | {{ else }} 146 | 152 | {{ end }} 153 |
154 |
155 | 163 |
164 |
165 | 166 | 167 | 173 |
174 |
175 |
176 | {{ end }} 177 | {{ end }} 178 |
179 | {{ end }} 180 |

Settings

181 |
182 |
183 |
184 |

Twitter application credentials

185 | {{ if .HasTokens}} 186 |

You have already setup your credentials.

187 | {{ else }} 188 |

You have not yet setup your credentials.

189 | {{ end }} 190 |
191 |
192 |
193 | {{with .Errors.any}} 194 | {{.}} 195 |
196 | {{end}} 197 |
198 | 199 | 200 |
201 |
202 | 203 | 204 |
205 |
206 | 207 | 208 |
209 |
210 | 211 | 212 |
213 |
214 | 215 | {{ if .HasTokens }} 216 | 217 | {{ else }} 218 | 219 | {{ end }} 220 |
221 |
222 |
223 |
224 | {{ if .HasTokens }} 225 | {{ else }} 226 |

227 | Don't have a Twitter application? Obtain one for free on 228 | twitter's developer portal. 229 |

230 | {{ end }} 231 |
232 |
233 |

Not Streaming Message

234 |

Message to display when not streaming.

235 |
236 |
237 | 238 | 239 | 240 |
241 |
242 |
243 |
244 |
245 |
246 |

Security

247 |

Change your password {{ .Email }}

248 | {{with .Flash.password}} 249 |
250 | {{.}} 251 |
252 | {{end}} 253 |
254 | {{with .Errors.password}} 255 | {{.}} 256 |
257 | {{end}} 258 |
259 | 260 | 261 |
262 |
263 | 264 | 265 |
266 |
267 | 268 | 271 |
272 |
273 |
274 |
275 |
276 |
277 | {{end}} -------------------------------------------------------------------------------- /app/ui/html/base.layout.tmpl: -------------------------------------------------------------------------------- 1 | {{define "base"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {{template "title" .}} - Skeef 15 | 16 | 17 |
18 | {{template "main" .}} 19 |
20 | 21 | {{template "footer" .}} 22 | 23 | 24 | {{end}} 25 | -------------------------------------------------------------------------------- /app/ui/html/footer.partial.tmpl: -------------------------------------------------------------------------------- 1 | {{define "footer"}} 2 |
3 |
4 | {{end}} -------------------------------------------------------------------------------- /app/ui/html/home.page.tmpl: -------------------------------------------------------------------------------- 1 | {{template "base" .}} 2 | 3 | {{define "title"}}Home{{end}} 4 | 5 | {{define "main"}} 6 |
7 | {{ if .Streaming }} 8 |

Number of tweets over time

9 |
10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |
18 |
19 |

20 | 0 edges 21 |
22 | 0 nodes 23 |

24 |

25 | ? 26 |

27 |
28 |
29 |

Stream

30 | {{ if .Authenticated }} 31 | Admin area 32 | {{ end }} 33 | {{ if .HasDescription }} 34 |
35 | {{ .StreamActive.Description}} 36 |
37 | {{ end }} 38 |

39 | Use the w, a, s, d key to navigate the graph. 40 | and the arrow keys to rotate the camera: , , , . 41 |

42 |

43 | You can click on nodes to reveal the relevant tweet(s). 44 |

45 | 46 |
47 |
48 | {{ else }} 49 |
50 |
51 | {{ with .Flash.message }} 52 |
{{.}}
53 | {{ end }} 54 | {{ if .Authenticated }} 55 |

56 | It looks like you are the admin, 57 | start streaming. 58 |

59 | {{ end }} 60 |
61 |
62 | {{ end }} 63 |
64 | 65 | 71 | 72 | 78 | {{end}} -------------------------------------------------------------------------------- /app/ui/html/setup.page.tmpl: -------------------------------------------------------------------------------- 1 | {{template "base" .}} 2 | 3 | {{define "title"}}Setup{{end}} 4 | 5 | {{define "main"}} 6 |
7 |
8 |

First time setup

9 |

Create your admin account

10 |
11 |
12 |
13 | {{with .Errors.exists}} 14 | {{.}} 15 |
16 | {{end}} 17 | 18 | 19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 |
28 | {{with .Errors.password}} 29 | {{.}} 30 |
31 | {{end}} 32 |
33 | 34 | 35 |
36 |
37 |
38 |

39 | Once this account created you will be able to log back 40 | in the admin area using these credentials. 41 |

42 |
43 |
44 | {{end}} 45 | -------------------------------------------------------------------------------- /app/ui/html/signin.page.tmpl: -------------------------------------------------------------------------------- 1 | {{template "base" .}} 2 | 3 | {{define "title"}}Signin{{end}} 4 | 5 | {{define "main"}} 6 |
7 |
8 |

Signin

9 |

Sign into the admin area

10 |
11 | {{with .Errors.credentials}} 12 | {{.}} 13 |
14 | {{end}} 15 |
16 | 17 | 18 |
19 |
20 | 21 | 22 |
23 |
24 | 25 | 26 |
27 |
28 |
29 |
30 | {{end}} 31 | -------------------------------------------------------------------------------- /app/ui/html/stream.page.tmpl: -------------------------------------------------------------------------------- 1 | {{template "base" .}} 2 | 3 | {{define "title"}}Stream{{end}} 4 | 5 | {{define "main"}} 6 |
7 |
8 |

Edit Stream

9 | {{ with .Flash.success }} 10 |

{{ . }}

11 | {{ end }} 12 | {{ with .Errors.failure }} 13 |

{{ . }}

14 | {{ end }} 15 |

16 | The stream is currently 17 | {{ if .Stream.Active }} 18 | running 19 | {{ else }} 20 | paused 21 | {{ end }} 22 |

23 |
24 |
25 |
26 | 27 | 28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 | 40 |
41 |
42 | 43 | 44 |

45 |
46 |
47 |

Edges types

48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 |
57 |
58 |

Filter

59 | 60 | 61 | 62 | 63 | 64 | 65 |
66 |
67 | 68 |
69 | 70 |
71 |
72 |
73 |
74 | 75 | 76 |
77 |
78 | 79 | 80 |
81 |
82 |

Verified Users

83 | 84 | 85 |
86 |
87 |
88 |
89 |
90 |
91 | 92 |
93 | 94 |
95 |
96 | 97 |
98 | 99 |
100 |
101 |
102 |
103 | 104 |
105 | 106 |
107 |
108 | 109 | 110 | 111 |
112 |
113 |
114 |

Go back to the admin area

115 |
116 | 141 |
142 | {{end}} 143 | -------------------------------------------------------------------------------- /app/ui/static/uplot.min.css: -------------------------------------------------------------------------------- 1 | .uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: content-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;z-index: 100;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;pointer-events: none;will-change: transform;z-index: 100;/*this has to be !important since we set inline "background" shorthand */background-clip: content-box !important;}.u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;} -------------------------------------------------------------------------------- /app/ui/static/uplot.min.js: -------------------------------------------------------------------------------- 1 | /*! https://github.com/leeoniya/uPlot (v1.6.7) */ 2 | var uPlot=function(){"use strict";function n(n,e,r,t){var l;r=r||0;for(var i=2147483647>=(t=t||e.length-1);t-r>1;)n>e[l=i?r+t>>1:m((r+t)/2)]?r=l:t=l;return n-e[r]>e[t]-n?t:r}function e(n,e,r,t){for(var l=1==t?e:r;l>=e&&r>=l;l+=t)if(null!=n[l])return l;return-1}var r=[0,0];function t(n,e,t,l){return r[0]=0>t?C(n,-t):n,r[1]=0>l?C(e,-l):e,r}function l(n,e,r,l){var i,a,o,s=10==r?k:y;return n==e&&(n/=r,e*=r),l?(i=m(s(n)),a=g(s(e)),n=(o=t(b(r,i),b(r,a),i,a))[0],e=o[1]):(i=m(s(d(n))),a=m(s(d(e))),n=Y(n,(o=t(b(r,i),b(r,a),i,a))[0]),e=W(e,o[1])),[n,e]}function i(n,e,r,t){var i=l(n,e,r,t);return 0==n&&(i[0]=0),0==e&&(i[1]=0),i}var a={pad:0,soft:null,mode:0},o={min:a,max:a};function s(n,e,r,t){return V(r)?f(n,e,r):(a.pad=r,a.soft=t?0:null,a.mode=t?3:0,f(n,e,o))}function u(n,e){return null==n?e:n}function f(n,e,r){var t=r.min,l=r.max,i=u(t.pad,0),a=u(l.pad,0),o=u(t.hard,-S),s=u(l.hard,S),f=u(t.soft,S),c=u(l.soft,-S),v=u(t.mode,0),h=u(l.mode,0),p=e-n,g=p||d(e)||1e3,_=k(g),y=b(10,m(_)),M=C(Y(n-g*(0==p?0==n?.1:1:i),y/10),6),z=f>n||1!=v&&(3!=v||M>f)&&(2!=v||f>M)?S:f,D=w(o,z>M&&n>=z?z:x(z,M)),T=C(W(e+g*(0==p?0==e?.1:1:a),y/10),6),E=e>c||1!=h&&(3!=h||c>T)&&(2!=h||T>c)?-S:c,P=x(s,T>E&&E>=e?E:w(E,T));return D==P&&0==D&&(P=100),[D,P]}var c=new Intl.NumberFormat(navigator.language).format,v=Math,h=v.PI,d=v.abs,m=v.floor,p=v.round,g=v.ceil,x=v.min,w=v.max,b=v.pow,_=v.sqrt,k=v.log10,y=v.log2,M=(n,e)=>(void 0===e&&(e=1),v.asinh(n/e)),S=1/0;function z(n,e){return p(n/e)*e}function D(n,e,r){return x(w(n,e),r)}function T(n){return"function"==typeof n?n:()=>n}var E=(n,e)=>e,P=()=>null,A=()=>!0;function W(n,e){return g(n/e)*e}function Y(n,e){return m(n/e)*e}function C(n,e){return p(n*(e=Math.pow(10,e)))/e}var H=new Map;function F(n){return((""+n).split(".")[1]||"").length}function L(n,e,r,t){for(var l=[],i=t.map(F),a=e;r>a;a++)for(var o=d(a),s=C(b(n,a),o),u=0;t.length>u;u++){var f=t[u]*s,c=(0>f||0>a?o:0)+(i[u]>a?i[u]:0),v=C(f,c);l.push(v),H.set(v,c)}return l}var N={},I=Array.isArray;function O(n){return"string"==typeof n}function V(n){var e=!1;if(null!=n){var r=n.constructor;e=null==r||r==Object}return e}function j(n){return null!=n&&"object"==typeof n}function G(n,e){var r;if(e=e||V,I(n))r=n.map((n=>G(n,e)));else if(e(n))for(var t in r={},n)r[t]=G(n[t],e);else r=n;return r}function R(n){for(var e=arguments,r=1;e.length>r;r++){var t=e[r];for(var l in t)V(n[l])?R(n[l],G(t[l])):n[l]=G(t[l])}return n}function U(n,e,r){for(var t=0,l=void 0,i=-1;e.length>t;t++){var a=e[t];if(a>i){for(l=a-1;l>=0&&null==n[l];)n[l--]=null;for(l=a+1;r>l&&null==n[l];)n[i=l++]=null}}}var B="undefined"==typeof queueMicrotask?n=>Promise.resolve().then(n):queueMicrotask,J="width",q="height",Z="top",X="bottom",K="left",Q="right",$="#000",nn="#0000",en="mousemove",rn="mousedown",tn="mouseup",ln="mouseenter",an="mouseleave",on="dblclick",sn="u-off",un="u-label",fn=document,cn=window,vn=devicePixelRatio;function hn(n,e){if(null!=e){var r=n.classList;!r.contains(e)&&r.add(e)}}function dn(n,e){var r=n.classList;r.contains(e)&&r.remove(e)}function mn(n,e,r){n.style[e]=r+"px"}function pn(n,e,r,t){var l=fn.createElement(n);return null!=e&&hn(l,e),null!=r&&r.insertBefore(l,t),l}function gn(n,e){return pn("div",n,e)}function xn(n,e,r,t,l){n.style.transform="translate("+e+"px,"+r+"px)",0>e||0>r||e>t||r>l?hn(n,sn):dn(n,sn)}var wn={passive:!0};function bn(n,e,r){e.addEventListener(n,r,wn)}function _n(n,e,r){e.removeEventListener(n,r,wn)}var kn=["January","February","March","April","May","June","July","August","September","October","November","December"],yn=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];function Mn(n){return n.slice(0,3)}var Sn=yn.map(Mn),zn=kn.map(Mn),Dn={MMMM:kn,MMM:zn,WWWW:yn,WWW:Sn};function Tn(n){return(10>n?"0":"")+n}var En={YYYY:n=>n.getFullYear(),YY:n=>(n.getFullYear()+"").slice(2),MMMM:(n,e)=>e.MMMM[n.getMonth()],MMM:(n,e)=>e.MMM[n.getMonth()],MM:n=>Tn(n.getMonth()+1),M:n=>n.getMonth()+1,DD:n=>Tn(n.getDate()),D:n=>n.getDate(),WWWW:(n,e)=>e.WWWW[n.getDay()],WWW:(n,e)=>e.WWW[n.getDay()],HH:n=>Tn(n.getHours()),H:n=>n.getHours(),h:n=>{var e=n.getHours();return 0==e?12:e>12?e-12:e},AA:n=>12>n.getHours()?"AM":"PM",aa:n=>12>n.getHours()?"am":"pm",a:n=>12>n.getHours()?"a":"p",mm:n=>Tn(n.getMinutes()),m:n=>n.getMinutes(),ss:n=>Tn(n.getSeconds()),s:n=>n.getSeconds(),fff:n=>function(n){return(10>n?"00":100>n?"0":"")+n}(n.getMilliseconds())};function Pn(n,e){e=e||Dn;for(var r,t=[],l=/\{([a-z]+)\}|[^{]+/gi;r=l.exec(n);)t.push("{"==r[0][0]?En[r[1]]:r[0]);return n=>{for(var r="",l=0;t.length>l;l++)r+="string"==typeof t[l]?t[l]:t[l](n,e);return r}}var An=(new Intl.DateTimeFormat).resolvedOptions().timeZone,Wn=n=>n%1==0,Yn=[1,2,2.5,5],Cn=L(10,-16,0,Yn),Hn=L(10,0,16,Yn),Fn=Hn.filter(Wn),Ln=Cn.concat(Hn),Nn="{YYYY}",In="\n"+Nn,On="{M}/{D}",Vn="\n"+On,jn=Vn+"/{YY}",Gn="{aa}",Rn="{h}:{mm}"+Gn,Un="\n"+Rn,Bn=":{ss}",Jn=null;function qn(n){var e=1e3*n,r=60*e,t=60*r,l=24*t,i=30*l,a=365*l;return[(1==n?L(10,0,3,Yn).filter(Wn):L(10,-3,0,Yn)).concat([e,5*e,10*e,15*e,30*e,r,5*r,10*r,15*r,30*r,t,2*t,3*t,4*t,6*t,8*t,12*t,l,2*l,3*l,4*l,5*l,6*l,7*l,8*l,9*l,10*l,15*l,i,2*i,3*i,4*i,6*i,a,2*a,5*a,10*a,25*a,50*a,100*a]),[[a,Nn,Jn,Jn,Jn,Jn,Jn,Jn,1],[28*l,"{MMM}",In,Jn,Jn,Jn,Jn,Jn,1],[l,On,In,Jn,Jn,Jn,Jn,Jn,1],[t,"{h}"+Gn,jn,Jn,Vn,Jn,Jn,Jn,1],[r,Rn,jn,Jn,Vn,Jn,Jn,Jn,1],[e,Bn,jn+" "+Rn,Jn,Vn+" "+Rn,Jn,Un,Jn,1],[n,Bn+".{fff}",jn+" "+Rn,Jn,Vn+" "+Rn,Jn,Un,Jn,1]],function(e){return(o,s,u,f,c,v)=>{var h=[],d=c>=a,g=c>=i&&a>c,x=e(u),w=C(x*n,3),b=ie(x.getFullYear(),d?0:x.getMonth(),g||d?1:x.getDate()),_=C(b*n,3);if(g||d)for(var k=g?c/i:0,y=d?c/a:0,M=w==_?w:C(ie(b.getFullYear()+y,b.getMonth()+k,1)*n,3),S=new Date(p(M/n)),z=S.getFullYear(),D=S.getMonth(),T=0;f>=M;T++){var E=ie(z+y*T,D+k*T,1),P=E-e(C(E*n,3));(M=C((+E+P)*n,3))>f||h.push(M)}else{var A=l>c?c:l,Y=_+(m(u)-m(w))+W(w-_,A);h.push(Y);for(var H=e(Y),F=H.getHours()+H.getMinutes()/r+H.getSeconds()/t,L=c/t,N=v/o.axes[s]._space;(Y=C(Y+c,1==n?0:3))<=f;)if(L>1){var I=m(C(F+L,6))%24,O=e(Y).getHours()-I;O>1&&(O=-1),F=(F+L)%24,.7>C(((Y-=O*t)-h[h.length-1])/c,3)*N||h.push(Y)}else h.push(Y)}return h}}]}var Zn=qn(1),Xn=Zn[0],Kn=Zn[1],Qn=Zn[2],$n=qn(.001),ne=$n[0],ee=$n[1],re=$n[2];function te(n,e){return n.map((n=>n.map(((r,t)=>0==t||8==t||null==r?r:e(1==t||0==n[8]?r:n[1]+r)))))}function le(n,e){return(r,t,l,i,a)=>{var o,s,u,f,c,v,h=e.find((n=>a>=n[0]))||e[e.length-1];return t.map((e=>{var r=n(e),t=r.getFullYear(),l=r.getMonth(),i=r.getDate(),a=r.getHours(),d=r.getMinutes(),m=r.getSeconds(),p=t!=o&&h[2]||l!=s&&h[3]||i!=u&&h[4]||a!=f&&h[5]||d!=c&&h[6]||m!=v&&h[7]||h[1];return o=t,s=l,u=i,f=a,c=d,v=m,p(r)}))}}function ie(n,e,r){return new Date(n,e,r)}function ae(n,e){return e(n)}function oe(n,e){return(r,t)=>e(n(t))}function se(n,e){var r=n.series[e];return r.width?r.stroke(n,e):r.points.width?r.points.stroke(n,e):null}function ue(n,e){return n.series[e].fill(n,e)}L(2,-53,53,[1]);var fe=[0,0];function ce(n,e,r){return n=>{0==n.button&&r(n)}}function ve(n,e,r){return r}var he={show:!0,x:!0,y:!0,lock:!1,move:function(n,e,r){return fe[0]=e,fe[1]=r,fe},points:{show:function(n,e){var r=n.cursor.points,t=gn(),l=r.stroke(n,e),i=r.fill(n,e);t.style.background=i||l;var a=r.size(n,e),o=r.width(n,e,a);o&&(t.style.border=o+"px solid "+l);var s=a/-2;return mn(t,J,a),mn(t,q,a),mn(t,"marginLeft",s),mn(t,"marginTop",s),t},size:function(n,e){return Ae(n.series[e].width,1)},width:0,stroke:function(n,e){return n.series[e].stroke(n,e)},fill:function(n,e){return n.series[e].stroke(n,e)}},bind:{mousedown:ce,mouseup:ce,click:ce,dblclick:ce,mousemove:ve,mouseleave:ve,mouseenter:ve},drag:{setScale:!0,x:!0,y:!1,dist:0,uni:null,_x:!1,_y:!1},focus:{prox:-1},left:-10,top:-10,idx:null,dataIdx:function(n,e,r){return r}},de={show:!0,stroke:"rgba(0,0,0,0.07)",width:2,filter:E},me=R({},de,{size:10}),pe='12px system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"',ge="bold "+pe,xe={show:!0,scale:"x",stroke:$,space:50,gap:5,size:50,labelSize:30,labelFont:ge,side:2,grid:de,ticks:me,font:pe,rotate:0},we={show:!0,scale:"x",auto:!1,sorted:1,min:S,max:-S,idxs:[]};function be(n,e){return e.map((n=>null==n?"":c(n)))}function _e(n,e,r,t,l,i,a){for(var o=[],s=H.get(l)||0,u=r=a?r:C(W(r,l),s);t>=u;u=C(u+l,s))o.push(Object.is(u,-0)?0:u);return o}function ke(n,e,r,t,l){var i=[],a=n.scales[n.axes[e].scale].log,o=m((10==a?k:y)(r));l=b(a,o),0>o&&(l=C(l,-o));var s=r;do{i.push(s),l*a>(s=C(s+l,H.get(l)))||(l=s)}while(t>=s);return i}function ye(n,e,r,t,l){var i=n.scales[n.axes[e].scale].asinh,a=t>i?ke(n,e,w(i,r),t,l):[i],o=0>t||r>0?[]:[0];return(-i>r?ke(n,e,w(i,-t),-r,l):[i]).reverse().map((n=>-n)).concat(o,a)}var Me=/./,Se=/[12357]/,ze=/[125]/,De=/1/;function Te(n,e,r){var t=n.axes[r],l=t.scale,i=n.scales[l];if(3==i.distr&&2==i.log)return e;var a=n.valToPos,o=t._space,s=a(10,l),u=a(9,l)-s4==i.distr&&0==n||u.test(n)?n:null))}function Ee(n,e){return null==e?"":c(e)}var Pe={show:!0,scale:"y",stroke:$,space:30,gap:5,size:50,labelSize:30,labelFont:ge,side:3,grid:de,ticks:me,font:pe,rotate:0};function Ae(n,e){return C((3+2*(n||1))*e,3)}function We(n,e){var r=n.scales[n.series[e].scale],t=n.bands&&n.bands.some((n=>n.series[0]==e));return 3==r.distr||t?r.min:0}var Ye={scale:"y",auto:!0,sorted:0,show:!0,band:!1,spanGaps:!1,alpha:1,points:{show:function(n,e){var r=n.series[0].idxs;return(0==n.scales[n.series[0].scale].ori?n.bbox.width:n.bbox.height)/(n.series[e].points.space*vn)>=r[1]-r[0]}},values:null,min:S,max:-S,idxs:[],path:null,clip:null};function Ce(n,e,r){return r/10}var He={time:!0,auto:!0,distr:1,log:10,asinh:1,min:null,max:null,dir:1,ori:0},Fe=R({},He,{time:!1,ori:1}),Le={};function Ne(n){var e=Le[n];if(!e){var r=[];e={key:n,sub:function(n){r.push(n)},unsub:function(n){r=r.filter((e=>e!=n))},pub:function(n,e,t,l,i,a){for(var o=0;r.length>o;o++)r[o]!=e&&r[o].pub(n,e,t,l,i,a,o)}},null!=n&&(Le[n]=e)}return e}function Ie(n,e,r){var t=n.series[e],l=n.scales,i=n.bbox,a=n._data[0],o=n._data[e],s=l[n.series[0].scale],u=l[t.scale],f=i.left,c=i.top,v=i.width,h=i.height,d=n.valToPosH,m=n.valToPosV;return 0==s.ori?r(t,a,o,s,u,d,m,f,c,v,h,Ge,Ue,Je,Ze,Ke):r(t,a,o,s,u,m,d,c,f,h,v,Re,Be,qe,Xe,Qe)}function Oe(n,e,r,t,l){return Ie(n,e,((n,e,i,a,o,s,u,f,c,v,h)=>{var d,m,p=0==a.ori?Ue:Be;1==a.dir*(0==a.ori?1:-1)?(d=r,m=t):(d=t,m=r);var g=z(s(e[d],a,v,f),.5),x=z(u(i[d],o,h,c),.5),w=z(s(e[m],a,v,f),.5),b=z(u(o.max,o,h,c),.5),_=new Path2D(l);return p(_,w,b),p(_,g,b),p(_,g,x),_}))}function Ve(n,e,r,t,l,i){var a=null;if(n.length>0){a=new Path2D;for(var o=0==e?Je:qe,s=r,u=0;n.length>u;u++){var f=n[u];o(a,s,t,f[0]-s,t+i),s=f[1]}o(a,s,t,r+l-s,t+i)}return a}function je(n,e,r){if(r>e){var t=n[n.length-1];t&&t[0]==e?t[1]=r:n.push([e,r])}}function Ge(n,e,r){n.moveTo(e,r)}function Re(n,e,r){n.moveTo(r,e)}function Ue(n,e,r){n.lineTo(e,r)}function Be(n,e,r){n.lineTo(r,e)}function Je(n,e,r,t,l){n.rect(e,r,t,l)}function qe(n,e,r,t,l){n.rect(r,e,l,t)}function Ze(n,e,r,t,l,i){n.arc(e,r,t,l,i)}function Xe(n,e,r,t,l,i){n.arc(r,e,t,l,i)}function Ke(n,e,r,t,l,i,a){n.bezierCurveTo(e,r,t,l,i,a)}function Qe(n,e,r,t,l,i,a){n.bezierCurveTo(r,e,l,t,a,i)}function $e(n){return(e,r,t,l,i)=>{t!=l&&(n(e,r,t),n(e,r,l),n(e,r,i))}}var nr=$e(Ue),er=$e(Be);function rr(){return(n,r,t,l)=>Ie(n,r,((i,a,o,s,u,f,c,v,h,d,m)=>{var g,b;0==s.ori?(g=Ue,b=nr):(g=Be,b=er);var _,k,y,M=s.dir*(0==s.ori?1:-1),D={stroke:new Path2D,fill:null,clip:null,band:null},T=D.stroke,E=S,P=-S,A=[],W=p(f(a[1==M?t:l],s,d,v)),Y=!1,C=e(o,t,l,1*M),H=e(o,t,l,-1*M),F=z(f(a[C],s,d,v),.5),L=z(f(a[H],s,d,v),.5);F>v&&je(A,v,F);for(var N=1==M?t:l;N>=t&&l>=N;N+=M){var I=p(f(a[N],s,d,v));if(I==W)null!=o[N]?(_=p(c(o[N],u,m,h)),E==S&&g(T,I,_),E=x(_,E),P=w(_,P)):Y||null!==o[N]||(Y=!0);else{var O=!1;E!=S?(b(T,W,E,P,_),k=y=W):Y&&(O=!0,Y=!1),null!=o[N]?(g(T,I,_=p(c(o[N],u,m,h))),E=P=_,I-W>1&&null===o[N-M]&&(O=!0)):(E=S,P=-S,Y||null!==o[N]||(Y=!0)),O&&je(A,k,I),W=I}}if(E!=S&&E!=P&&y!=W&&b(T,W,E,P,_),v+d>L&&je(A,L,v+d),null!=i.fill){var V=D.fill=new Path2D(T),j=p(c(i.fillTo(n,r,i.min,i.max),u,m,h));g(V,L,j),g(V,F,j)}return i.spanGaps||(D.clip=Ve(A,s.ori,v,h,d,m)),n.bands.length>0&&(D.band=Oe(n,r,t,l,T)),D}))}function tr(n,e,r,t,l){var i,a,o,s,u,f,c,v,h,d,m,g,x,w,k,y,M,S,z,D,T,E,P,A,W,Y=new Path2D,C=n.length;t(Y,p(n[0]),p(e[0]));for(var H=0;C-1>H;H++){var F=0==H?0:H-1;a=e[F],s=e[H],u=n[H+1],f=e[H+1],C>H+2?(c=n[H+2],v=e[H+2]):(c=u,v=f),x=_(b((i=n[F])-(o=n[H]),2)+b(a-s,2)),w=_(b(o-u,2)+b(s-f,2)),k=_(b(u-c,2)+b(f-v,2)),D=b(k,r),E=b(k,2*r),T=b(w,r),P=b(w,2*r),(S=3*(W=b(x,r))*(W+T))>0&&(S=1/S),(z=3*D*(D+T))>0&&(z=1/z),d=(-P*a+(y=2*(A=b(x,2*r))+3*W*T+P)*s+A*f)*S,g=(E*s+(M=2*E+3*D*T+P)*f-P*v)*z,0==(h=(-P*i+y*o+A*u)*S)&&0==d&&(h=o,d=s),0==(m=(E*o+M*u-P*c)*z)&&0==g&&(m=u,g=f),l(Y,h,d,m,g,u,f)}return Y}var lr=new Set;function ir(){lr.forEach((n=>{n.syncRect(!0)}))}bn("resize",cn,ir),bn("scroll",cn,ir);var ar=rr();function or(n,e,r,t){return(t?[n[0],n[1]].concat(n.slice(2)):[n[0]].concat(n.slice(1))).map(((n,t)=>sr(n,t,e,r)))}function sr(n,e,r,t){return R({},0==e?r:t,n)}var ur=[null,null];function fr(n,e,r){return null==e?ur:[e,r]}var cr=fr;function vr(n,e,r){return null==e?ur:s(e,r,.1,!0)}function hr(n,e,r,t){return null==e?ur:l(e,r,n.scales[t].log,!1)}var dr=hr;function mr(n,e,r,t){return null==e?ur:i(e,r,n.scales[t].log,!1)}var pr=mr;function gr(n){var e;return[n=n.replace(/(\d+)px/,((n,r)=>(e=p(r*vn))+"px")),e]}function xr(e,r,t){var a={};function o(n,e){return((3==e.distr?k(n>0?n:e.clamp(a,n,e.min,e.max,e.key)):4==e.distr?M(n,e.asinh):n)-e._min)/(e._max-e._min)}function f(n,e,r,t){var l=o(n,e);return t+r*(-1==e.dir?1-l:l)}function c(n,e,r,t){var l=o(n,e);return t+r*(-1==e.dir?l:1-l)}function _(n,e,r,t){return 0==e.ori?f(n,e,r,t):c(n,e,r,t)}a.valToPosH=f,a.valToPosV=c;var y=!1;a.status=0;var Y=a.root=gn("uplot");null!=e.id&&(Y.id=e.id),hn(Y,e.class),e.title&&(gn("u-title",Y).textContent=e.title);var F=pn("canvas"),L=a.ctx=F.getContext("2d"),U=gn("u-wrap",Y),$=gn("u-under",U);U.appendChild(F);var cn=gn("u-over",U),wn=u((e=G(e)).pxAlign,!0);(e.plugins||[]).forEach((n=>{n.opts&&(e=n.opts(a,e)||e)}));var kn=e.ms||.001,yn=a.series=or(e.series||[],we,Ye,!1),Mn=a.axes=or(e.axes||[],xe,Pe,!0),Sn=a.scales={},zn=a.bands=e.bands||[];zn.forEach((n=>{n.fill=T(n.fill||null)}));var Dn=yn[0].scale,Tn={axes:function(){Mn.forEach(((n,e)=>{if(n.show&&n._show){var r=Sn[n.scale],t=n.side,l=t%2,i=0==l?qe:Ke,o=0==l?Be:Je,s=p(n.gap*vn),u=n.ticks,f=u.show?p(u.size*vn):0,c=n._found,v=c[0],d=c[1],m=n._splits,g=2==r.distr?m.map((n=>Wr[n])):m,x=2==r.distr?Wr[m[1]]-Wr[m[0]]:v,w=n._rotate*-h/180,b=p(n._pos*vn),k=b+(f+s)*(0==l&&0==t||1==l&&3==t?-1:1),y=0==l?k:0,M=1==l?k:0;L.font=n.font[0],L.fillStyle=n.stroke(a,e),L.textAlign=1==n.align?K:2==n.align?Q:w>0?K:0>w?Q:0==l?"center":3==t?Q:K,L.textBaseline=w||1==l?"middle":2==t?Z:X;var S=1.5*n.font[1],z=m.map((n=>p(_(n,r,i,o))));if(n._values.forEach(((n,e)=>{null!=n&&(0==l?M=z[e]:y=z[e],(""+n).split(/\n/gm).forEach(((n,e)=>{w?(L.save(),L.translate(M,y+e*S),L.rotate(w),L.fillText(n,0,0),L.restore()):L.fillText(n,M,y+e*S)})))})),n.label){L.save();var D=p(n._lpos*vn);1==l?(M=y=0,L.translate(D,p(Je+Ke/2)),L.rotate((3==t?-h:h)/2)):(M=p(Be+qe/2),y=D),L.font=n.labelFont[0],L.textAlign="center",L.textBaseline=2==t?Z:X,L.fillText(n.label,M,y),L.restore()}u.show&&Nr(z,u.filter(a,g,e,d,x),l,t,b,f,C(u.width*vn,3),u.stroke(a,e),u.dash,u.cap);var T=n.grid;T.show&&Nr(z,T.filter(a,g,e,d,x),l,0==l?2:1,0==l?Je:Be,0==l?Ke:qe,C(T.width*vn,3),T.stroke(a,e),T.dash,T.cap)}})),Ft("drawAxes")},series:function(){zr>0&&(yn.forEach(((n,e)=>{if(e>0&&n.show&&null==n._paths){var t=function(n){for(var e=D(Er-1,0,zr-1),r=D(Pr+1,0,zr-1);null==n[e]&&e>0;)e--;for(;null==n[r]&&zr-1>r;)r++;return[e,r]}(r[e]);n._paths=n.paths(a,e,t[0],t[1])}})),yn.forEach(((n,e)=>{e>0&&n.show&&(n._paths&&function(n){var e=yn[n],r=e._paths,t=r.stroke,l=r.fill,i=r.clip,o=C(e.width*vn,3),s=o%2/2,u=e._stroke=e.stroke(a,n),f=e._fill=e.fill(a,n);L.globalAlpha=e.alpha;var c=wn&&e.pxAlign;c&&L.translate(s,s),L.save();var v=Be,h=Je,d=qe,m=Ke,p=o*vn/2;0==e.min&&(m+=p),0==e.max&&(h-=p,m+=p),L.beginPath(),L.rect(v,h,d,m),L.clip(),i&&L.clip(i),function(n,e,r,t,l,i,o,s){var u=!1;zn.forEach(((f,c)=>{if(f.series[0]==n){var v=yn[f.series[1]],h=(v._paths||N).band;L.save();var d=null;v.show&&h&&(d=f.fill(a,c)||i,L.clip(h)),Lr(e,r,t,l,d,o,s),L.restore(),u=!0}})),u||Lr(e,r,t,l,i,o,s)}(n,u,o,e.dash,e.cap,f,t,l),L.restore(),c&&L.translate(-s,-s),L.globalAlpha=1}(e),n.points.show(a,e,Er,Pr)&&function(n){var e=yn[n],t=e.points,l=C(t.width*vn,3),i=l%2/2,o=t.width>0,s=(t.size-t.width)/2*vn,u=C(2*s,3),f=wn&&e.pxAlign;f&&L.translate(i,i),L.save(),L.beginPath(),L.rect(Be-u,Je-u,qe+2*u,Ke+2*u),L.clip(),L.globalAlpha=e.alpha;var c,v,d,m,g=new Path2D,x=Sn[e.scale];0==In.ori?(c=qe,v=Be,d=Ke,m=Je):(c=Ke,v=Je,d=qe,m=Be);for(var w=Er;Pr>=w;w++)if(null!=r[n][w]){var b=p(Yn(r[0][w],In,c,v)),_=p(Cn(r[n][w],x,d,m));Hn(g,b+s,_),Nn(g,b,_,s,0,2*h)}var k=t._stroke=t.stroke(a,n),y=t._fill=t.fill(a,n);Fr(k,l,t.dash,t.cap,y||(o?"#fff":e._stroke)),L.fill(g),o&&L.stroke(g),L.globalAlpha=1,L.restore(),f&&L.translate(-i,-i)}(e),Ft("drawSeries",e))})))}},En=(e.drawOrder||["axes","series"]).map((n=>Tn[n]));function An(n){var r=Sn[n];if(null==r){var t=(e.scales||N)[n]||N;if(null!=t.from)An(t.from),Sn[n]=R({},Sn[t.from],t);else{(r=Sn[n]=R({},n==Dn?He:Fe,t)).key=n;var l=r.time,i=r.range,a=I(i);if(n!=Dn&&!a&&V(i)){var o=i;i=(n,e,r)=>null==e?ur:s(e,r,o)}r.range=T(i||(l?cr:n==Dn?3==r.distr?dr:4==r.distr?pr:fr:3==r.distr?hr:4==r.distr?mr:vr)),r.auto=T(!a&&r.auto),r.clamp=T(r.clamp||Ce),r._min=r._max=null}}}for(var Wn in An("x"),An("y"),yn.forEach((n=>{An(n.scale)})),Mn.forEach((n=>{An(n.scale)})),e.scales)An(Wn);var Yn,Cn,Hn,Nn,In=Sn[Dn],On=In.distr;0==In.ori?(hn(Y,"u-hz"),Yn=f,Cn=c,Hn=Ge,Nn=Ze):(hn(Y,"u-vt"),Yn=c,Cn=f,Hn=Re,Nn=Xe);var Vn={};for(var jn in Sn){var Gn=Sn[jn];null==Gn.min&&null==Gn.max||(Vn[jn]={min:Gn.min,max:Gn.max},Gn.min=Gn.max=null)}var Rn,Un=e.tzDate||(n=>new Date(p(n/kn))),Bn=e.fmtDate||Pn,Jn=1==kn?Qn(Un):re(Un),qn=le(Un,te(1==kn?Kn:ee,Bn)),Zn=oe(Un,ae("{YYYY}-{MM}-{DD} {h}:{mm}{aa}",Bn)),$n=a.legend=R({show:!0,live:!0,idx:null,values:[]},e.legend),ie=$n.show;$n.width=T(u($n.width,2)),$n.dash=T($n.dash||"solid"),$n.stroke=T($n.stroke||se),$n.fill=T($n.fill||ue);var fe,ce=[],ve=!1,de={};if($n.live){var me=yn[1]?yn[1].values:null;for(var pe in fe=(ve=null!=me)?me(a,1,0):{_:0})de[pe]="--"}if(ie)if(Rn=pn("table","u-legend",Y),ve){var ge=pn("tr","u-thead",Rn);for(var Me in pn("th",null,ge),fe)pn("th",un,ge).textContent=Me}else hn(Rn,"u-inline"),$n.live&&hn(Rn,"u-live");var Se=new Map;function ze(n,e,r){var t=Se.get(e)||{},l=xr.bind[n](a,e,r);l&&(bn(n,e,t[n]=l),Se.set(e,t))}function De(n,e){var r=Se.get(e)||{};for(var t in r)null!=n&&t!=n||(_n(t,e,r[t]),delete r[t]);null==n&&Se.delete(e)}var Le=0,Ie=0,Oe=0,Ve=0,je=0,Ue=0,Be=0,Je=0,qe=0,Ke=0;a.bbox={};var Qe=!1,$e=!1,nr=!1,er=!1,rr=!1;function tr(n,e){n==a.width&&e==a.height||ir(n,e),Vr(!1),nr=!0,$e=!0,er=!0,rr=!0,et()}function ir(n,e){a.width=Le=Oe=n,a.height=Ie=Ve=e,je=Ue=0,function(){var n=!1,e=!1,r=!1,t=!1;Mn.forEach((l=>{if(l.show&&l._show){var i=l.side,a=i%2,o=l._size+(l.labelSize=null!=l.label?l.labelSize||30:0);o>0&&(a?(Oe-=o,3==i?(je+=o,t=!0):r=!0):(Ve-=o,0==i?(Ue+=o,n=!0):e=!0))}})),Mr[0]=n,Mr[1]=r,Mr[2]=e,Mr[3]=t,Oe-=Tr[1]+Tr[3],je+=Tr[3],Ve-=Tr[2]+Tr[0],Ue+=Tr[0]}(),function(){var n=je+Oe,e=Ue+Ve,r=je,t=Ue;function l(l,i){switch(l){case 1:return(n+=i)-i;case 2:return(e+=i)-i;case 3:return(r-=i)+i;case 0:return(t-=i)+i}}Mn.forEach((n=>{if(n.show&&n._show){var e=n.side;n._pos=l(e,n._size),null!=n.label&&(n._lpos=l(e,n.labelSize))}}))}();var r=a.bbox;Be=r.left=z(je*vn,.5),Je=r.top=z(Ue*vn,.5),qe=r.width=z(Oe*vn,.5),Ke=r.height=z(Ve*vn,.5)}a.setSize=function(n){tr(n.width,n.height)};var xr=a.cursor=R({},he,e.cursor);xr._lock=!1;var wr=xr.points;wr.show=T(wr.show),wr.size=T(wr.size),wr.stroke=T(wr.stroke),wr.width=T(wr.width),wr.fill=T(wr.fill);var br=a.focus=R({},e.focus||{alpha:.3},xr.focus),_r=br.prox>=0,kr=[null];function yr(n,e){var r=Sn[n.scale].time,t=n.value;if(n.value=r?O(t)?oe(Un,ae(t,Bn)):t||Zn:t||Ee,n.label=n.label||(r?"Time":"Value"),e>0){n.width=null==n.width?1:n.width,n.paths=n.paths||ar||P,n.fillTo=T(n.fillTo||We),n.pxAlign=u(n.pxAlign,!0),n.stroke=T(n.stroke||null),n.fill=T(n.fill||null),n._stroke=n._fill=n._paths=n._focus=null;var l=Ae(n.width,1),i=n.points=R({},{size:l,width:w(1,.2*l),stroke:n.stroke,space:2*l,_stroke:null,_fill:null},n.points);i.show=T(i.show),i.fill=T(i.fill),i.stroke=T(i.stroke)}if(ie&&(ce.splice(e,0,function(n,e){if(0==e&&(ve||!$n.live))return null;var r=[],t=pn("tr","u-series",Rn,Rn.childNodes[e]);hn(t,n.class),n.show||hn(t,sn);var l=pn("th",null,t),i=gn("u-marker",l);if(e>0){var o=$n.width(a,e);o&&(i.style.border=o+"px "+$n.dash(a,e)+" "+$n.stroke(a,e)),i.style.background=$n.fill(a,e)}var s=gn(un,l);for(var u in s.textContent=n.label,e>0&&(ze("click",l,(()=>{xr._lock||mt(yn.indexOf(n),{show:!n.show},Lt.setSeries)})),_r&&ze(ln,l,(()=>{xr._lock||mt(yn.indexOf(n),pt,Lt.setSeries)}))),fe){var f=pn("td","u-value",t);f.textContent="--",r.push(f)}return r}(n,e)),$n.values.push(null)),xr.show){var o=function(n,e){if(e>0){var r=xr.points.show(a,e);if(r)return hn(r,"u-cursor-pt"),hn(r,n.class),xn(r,-10,-10,Oe,Ve),cn.insertBefore(r,kr[e]),r}}(n,e);o&&kr.splice(e,0,o)}}a.addSeries=function(n,e){n=sr(n,e=null==e?yn.length:e,we,Ye),yn.splice(e,0,n),yr(yn[e],e)},a.delSeries=function(n){if(yn.splice(n,1),ie){$n.values.splice(n,1);var e=ce.splice(n,1)[0][0].parentNode;De(null,e.firstChild),e.remove()}kr.length>1&&kr.splice(n,1)[0].remove()},yn.forEach(yr);var Mr=[!1,!1,!1,!1];function Sr(n,e,r){var t=r[0],l=r[1],i=r[2],a=r[3],o=e%2,s=0;return 0==o&&(a||l)&&(s=0==e&&!t||2==e&&!i?p(xe.size/3):0),1==o&&(t||i)&&(s=1==e&&!l||3==e&&!a?p(Pe.size/2):0),s}Mn.forEach((function(n,e){if(n._show=n.show,n.show){var r=Sn[n.scale];null==r&&(n.scale=n.side%2?yn[1].scale:Dn,r=Sn[n.scale]);var t=r.time;n.size=T(n.size),n.space=T(n.space),n.rotate=T(n.rotate),n.incrs=T(n.incrs||(2==r.distr?Fn:t?1==kn?Xn:ne:Ln)),n.splits=T(n.splits||(t&&1==r.distr?Jn:3==r.distr?ke:4==r.distr?ye:_e)),n.stroke=T(n.stroke),n.grid.stroke=T(n.grid.stroke),n.ticks.stroke=T(n.ticks.stroke);var l=n.values;n.values=I(l)&&!I(l[0])?T(l):t?I(l)?le(Un,te(l,Bn)):O(l)?function(n,e){var r=Pn(e);return(e,t)=>t.map((e=>r(n(e))))}(Un,l):l||qn:l||be,n.filter=T(n.filter||(3>r.distr?E:Te)),n.font=gr(n.font),n.labelFont=gr(n.labelFont),n._size=n.size(a,null,e,0),n._space=n._rotate=n._incrs=n._found=n._splits=n._values=null,n._size>0&&(Mr[e]=!0)}}));var zr,Dr=a.padding=(e.padding||[Sr,Sr,Sr,Sr]).map((n=>T(u(n,Sr)))),Tr=a._padding=Dr.map(((n,e)=>n(a,e,Mr,0))),Er=null,Pr=null,Ar=yn[0].idxs,Wr=null,Yr=!1;function Cr(n,e){if((n=n||[])[0]=n[0]||[],a.data=n,r=n.slice(),zr=(Wr=r[0]).length,2==On&&(r[0]=Wr.map(((n,e)=>e))),a._data=r,Vr(!0),Ft("setData"),!1!==e){var t=In;t.auto(a,Yr)?Hr():dt(Dn,t.min,t.max),er=xr.left>=0,rr=!0,et()}}function Hr(){var n,e,t,a,o;Yr=!0,zr>0?(Er=Ar[0]=0,Pr=Ar[1]=zr-1,a=r[0][Er],o=r[0][Pr],2==On?(a=Er,o=Pr):1==zr&&(3==On?(a=(n=l(a,a,In.log,!1))[0],o=n[1]):4==On?(a=(e=i(a,a,In.log,!1))[0],o=e[1]):In.time?o=a+p(86400/kn):(a=(t=s(a,o,.1,!0))[0],o=t[1]))):(Er=Ar[0]=a=null,Pr=Ar[1]=o=null),dt(Dn,a,o)}function Fr(n,e,r,t,l){L.strokeStyle=n||nn,L.lineWidth=e,L.lineJoin="round",L.lineCap=t||"butt",L.setLineDash(r||[]),L.fillStyle=l||nn}function Lr(n,e,r,t,l,i,a){Fr(n,e,r,t,l),l&&a&&L.fill(a),n&&i&&e&&L.stroke(i)}function Nr(n,e,r,t,l,i,a,o,s,u){var f=a%2/2;wn&&L.translate(f,f),Fr(o,a,s,u),L.beginPath();var c,v,h,d,m=l+(0==t||3==t?-i:i);0==r?(v=l,d=m):(c=l,h=m),n.forEach(((n,t)=>{null!=e[t]&&(0==r?c=h=n:v=d=n,L.moveTo(c,v),L.lineTo(h,d))})),L.stroke(),wn&&L.translate(-f,-f)}function Ir(n){var e=!0;return Mn.forEach(((r,t)=>{if(r.show){var l=Sn[r.scale];if(null!=l.min){r._show||(e=!1,r._show=!0,Vr(!1));var i=r.side,o=l.min,s=l.max,u=function(n,e,r,t){var l,i=Mn[n];if(t>0){var o=i._space=i.space(a,n,e,r,t),s=i._incrs=i.incrs(a,n,e,r,t,o);l=i._found=function(n,e,r,t,l){for(var i=t/(e-n),a=(""+m(n)).length,o=0;r.length>o;o++){var s=r[o]*i,u=10>r[o]?H.get(r[o]):0;if(s>=l&&17>a+u)return[r[o],s]}return[0,0]}(e,r,s,t,o)}else l=[0,0];return l}(t,o,s,0==i%2?Oe:Ve),f=u[0],c=u[1];if(0!=c){var v=r._splits=r.splits(a,t,o,s,f,c,2==l.distr),h=2==l.distr?v.map((n=>Wr[n])):v,d=2==l.distr?Wr[v[1]]-Wr[v[0]]:f,p=r._values=r.values(a,r.filter(a,h,t,c,d),t,c,d);r._rotate=2==i?r.rotate(a,p,t,c):0;var x=r._size;r._size=g(r.size(a,p,t,n)),null!=x&&r._size!=x&&(e=!1)}}else r._show&&(e=!1,r._show=!1,Vr(!1))}})),e}function Or(n){var e=!0;return Dr.forEach(((r,t)=>{var l=r(a,t,Mr,n);l!=Tr[t]&&(e=!1),Tr[t]=l})),e}function Vr(n){yn.forEach(((e,r)=>{r>0&&(e._paths=null,n&&(e.min=null,e.max=null))}))}a.setData=Cr;var jr,Gr,Rr,Ur,Br,Jr,qr,Zr,Xr,Kr,Qr,$r,nt=!1;function et(){nt||(B(rt),nt=!0)}function rt(){Qe&&(function(){var e=G(Sn,j);for(var t in e){var l=e[t],i=Vn[t];if(null!=i&&null!=i.min)R(l,i),t==Dn&&Vr(!0);else if(t!=Dn)if(0==zr&&null==l.from){var o=l.range(a,null,null,t);l.min=o[0],l.max=o[1]}else l.min=S,l.max=-S}if(zr>0)for(var s in yn.forEach(((t,l)=>{var i=t.scale,o=e[i],s=Vn[i];if(0==l){var u=o.range(a,o.min,o.max,i);o.min=u[0],o.max=u[1],Er=n(o.min,r[0]),Pr=n(o.max,r[0]),o.min>r[0][Er]&&Er++,r[0][Pr]>o.max&&Pr--,t.min=Wr[Er],t.max=Wr[Pr]}else if(t.show&&t.auto&&o.auto(a,Yr)&&(null==s||null==s.min)){var f=null==t.min?3==o.distr?function(n,e,r){for(var t=S,l=-S,i=e;r>=i;i++)n[i]>0&&(t=x(t,n[i]),l=w(l,n[i]));return[t==S?1:t,l==-S?10:l]}(r[l],Er,Pr):function(n,e,r,t){var l=S,i=-S;if(1==t)l=n[e],i=n[r];else if(-1==t)l=n[r],i=n[e];else for(var a=e;r>=a;a++)null!=n[a]&&(l=x(l,n[a]),i=w(i,n[a]));return[l,i]}(r[l],Er,Pr,t.sorted):[t.min,t.max];o.min=x(o.min,t.min=f[0]),o.max=w(o.max,t.max=f[1])}t.idxs[0]=Er,t.idxs[1]=Pr})),e){var u=e[s],f=Vn[s];if(null==u.from&&(null==f||null==f.min)){var c=u.range(a,u.min==S?null:u.min,u.max==-S?null:u.max,s);u.min=c[0],u.max=c[1]}}for(var v in e){var h=e[v];if(null!=h.from){var d=e[h.from],m=h.range(a,d.min,d.max,v);h.min=m[0],h.max=m[1]}}var p={},g=!1;for(var b in e){var _=e[b],y=Sn[b];if(y.min!=_.min||y.max!=_.max){y.min=_.min,y.max=_.max;var z=y.distr;y._min=3==z?k(y.min):4==z?M(y.min,y.asinh):y.min,y._max=3==z?k(y.max):4==z?M(y.max,y.asinh):y.max,p[b]=g=!0}}if(g){for(var D in yn.forEach((n=>{p[n.scale]&&(n._paths=null)})),p)nr=!0,Ft("setScale",D);xr.show&&(er=xr.left>=0)}for(var T in Vn)Vn[T]=null}(),Qe=!1),nr&&(function(){for(var n=!1,e=0;!n;){var r=Ir(++e),t=Or(e);(n=r&&t)||(ir(a.width,a.height),$e=!0)}}(),nr=!1),$e&&(mn($,K,je),mn($,Z,Ue),mn($,J,Oe),mn($,q,Ve),mn(cn,K,je),mn(cn,Z,Ue),mn(cn,J,Oe),mn(cn,q,Ve),mn(U,J,Le),mn(U,q,Ie),F.width=p(Le*vn),F.height=p(Ie*vn),Dt(!1),Ft("setSize"),$e=!1),Le>0&&Ie>0&&(L.clearRect(0,0,F.width,F.height),Ft("drawClear"),En.forEach((n=>n())),Ft("draw")),xr.show&&er&&(St(),er=!1),y||(y=!0,a.status=1,Ft("ready")),Yr=!1,nt=!1}function tt(e,t){var l=Sn[e];if(null==l.from){if(0==zr){var i=l.range(a,t.min,t.max,e);t.min=i[0],t.max=i[1]}if(t.min>t.max){var o=t.min;t.min=t.max,t.max=o}if(zr>1&&null!=t.min&&null!=t.max&&1e-16>t.max-t.min)return;e==Dn&&2==l.distr&&zr>0&&(t.min=n(t.min,r[0]),t.max=n(t.max,r[0])),Vn[e]=t,Qe=!0,et()}}a.redraw=(n,e)=>{nr=e||!1,!1!==n?dt(Dn,In.min,In.max):et()},a.setScale=tt;var lt=!1,it=xr.drag,at=it.x,ot=it.y;xr.show&&(xr.x&&(jr=gn("u-cursor-x",cn)),xr.y&&(Gr=gn("u-cursor-y",cn)),0==In.ori?(Rr=jr,Ur=Gr):(Rr=Gr,Ur=jr),Qr=xr.left,$r=xr.top);var st,ut,ft,ct=a.select=R({show:!0,over:!0,left:0,width:0,top:0,height:0},e.select),vt=ct.show?gn("u-select",ct.over?cn:$):null;function ht(n,e){if(ct.show){for(var r in n)mn(vt,r,ct[r]=n[r]);!1!==e&&Ft("setSelect")}}function dt(n,e,r){tt(n,{min:e,max:r})}function mt(n,e,r){var t=yn[n];null!=e.focus&&function(n){if(n!=ft){var e=null==n,r=1!=br.alpha;yn.forEach(((t,l)=>{var i=e||0==l||l==n;t._focus=e?null:i,r&&function(n,e){yn[n].alpha=e,xr.show&&kr[n]&&(kr[n].style.opacity=e),ie&&ce[n]&&(ce[n][0].parentNode.style.opacity=e)}(l,i?1:br.alpha)})),ft=n,r&&et()}}(n),null!=e.show&&(t.show=e.show,function(n){var e=ie?ce[n][0].parentNode:null;yn[n].show?e&&dn(e,sn):(e&&hn(e,sn),kr.length>1&&xn(kr[n],-10,-10,Oe,Ve))}(n),dt(t.scale,null,null),et()),Ft("setSeries",n,e),r&&It("setSeries",a,n,e)}a.setSelect=ht,a.setSeries=mt;var pt={focus:!0},gt={focus:!1};function xt(n,e){var r=Sn[e],t=Oe;1==r.ori&&(n=(t=Ve)-n),-1==r.dir&&(n=t-n);var l=r._min,i=l+n/t*(r._max-l),a=r.distr;return 3==a?b(10,i):4==a?((n,e)=>(void 0===e&&(e=1),v.sinh(n/e)))(i,r.asinh):i}function wt(n,e){mn(vt,K,ct.left=n),mn(vt,J,ct.width=e)}function bt(n,e){mn(vt,Z,ct.top=n),mn(vt,q,ct.height=e)}ie&&_r&&bn(an,Rn,(()=>{xr._lock||(mt(null,gt,Lt.setSeries),St())})),a.valToIdx=e=>n(e,r[0]),a.posToIdx=function(e){return n(xt(e,Dn),r[0],Er,Pr)},a.posToVal=xt,a.valToPos=(n,e,r)=>0==Sn[e].ori?f(n,Sn[e],r?qe:Oe,r?Be:0):c(n,Sn[e],r?Ke:Ve,r?Je:0),a.batch=function(n){n(a),et()},a.setCursor=n=>{Qr=n.left,$r=n.top,St()};var _t=0==In.ori?wt:bt,kt=1==In.ori?wt:bt;function yt(n,e){if(null!=n){var r=n.idx;$n.idx=r,yn.forEach(((n,e)=>{(e>0||!ve)&&Mt(e,r)}))}ie&&$n.live&&function(){if(ie&&$n.live)for(var n=0;yn.length>n;n++)if(0!=n||!ve){var e=$n.values[n],r=0;for(var t in e)ce[n][r++].firstChild.nodeValue=e[t]}}(),rr=!1,!1!==e&&Ft("setLegend")}function Mt(n,e){var t;if(null==e)t=de;else{var l=yn[n],i=0==n&&2==On?Wr:r[n];t=ve?l.values(a,n,e):{_:l.value(a,i[e],n,e)}}$n.values[n]=t}function St(e,t){var l,i;Xr=Qr,Kr=$r,l=xr.move(a,Qr,$r),Qr=l[0],$r=l[1],xr.show&&(Rr&&xn(Rr,p(Qr),0,Oe,Ve),Ur&&xn(Ur,0,p($r),Oe,Ve));var o=!1;st=S;var s=0==In.ori?Oe:Ve,u=1==In.ori?Oe:Ve;if(0>Qr||0==zr||Er>Pr){i=null;for(var f=0;yn.length>f;f++)f>0&&kr.length>1&&xn(kr[f],-10,-10,Oe,Ve);if(_r&&mt(null,pt,Lt.setSeries),$n.live){o=!0;for(var c=0;yn.length>c;c++)$n.values[c]=de}}else{var v=xt(0==In.ori?Qr:$r,Dn);i=n(v,r[0],Er,Pr);for(var h=W(Yn(r[0][i],In,s,0),.5),m=0;yn.length>m;m++){var g=yn[m],w=xr.dataIdx(a,m,i,v),b=w==i?h:W(Yn(r[0][w],In,s,0),.5);if(m>0&&g.show){var _=r[m][w],k=null==_?-10:W(Cn(_,Sn[g.scale],u,0),.5);if(k>0){var M=d(k-$r);M>st||(st=M,ut=m)}var z=void 0,D=void 0;0==In.ori?(z=b,D=k):(z=k,D=b),kr.length>1&&xn(kr[m],z,D,Oe,Ve)}if($n.live){if(w==xr.idx&&!rr||0==m&&ve)continue;o=!0,Mt(m,w)}}}if(o&&($n.idx=i,yt()),ct.show&<)if(null!=t){var T=Lt.scales,E=T[0],P=T[1],A=t.cursor.drag;at=A._x,ot=A._y;var Y,C,H,F,L,N=t.select,I=N.left,O=N.top,V=N.width,j=N.height,G=t.scales[E].ori,R=t.posToVal;E&&(0==G?(Y=I,C=V):(Y=O,C=j),H=Sn[E],F=Yn(R(Y,E),H,s,0),L=Yn(R(Y+C,E),H,s,0),_t(x(F,L),d(L-F)),P||kt(0,u)),P&&(1==G?(Y=I,C=V):(Y=O,C=j),H=Sn[P],F=Cn(R(Y,P),H,u,0),L=Cn(R(Y+C,P),H,u,0),kt(x(F,L),d(L-F)),E||_t(0,s))}else{var U=d(Xr-Br),B=d(Kr-Jr);if(1==In.ori){var J=U;U=B,B=J}at=it.x&&U>=it.dist,ot=it.y&&B>=it.dist;var q,Z,X=it.uni;null!=X?at&&ot&&(ot=B>=X,(at=U>=X)||ot||(B>U?ot=!0:at=!0)):it.x&&it.y&&(at||ot)&&(at=ot=!0),at&&(0==In.ori?(q=qr,Z=Qr):(q=Zr,Z=$r),_t(x(q,Z),d(Z-q)),ot||kt(0,u)),ot&&(1==In.ori?(q=qr,Z=Qr):(q=Zr,Z=$r),kt(x(q,Z),d(Z-q)),at||_t(0,s)),at||ot||(_t(0,0),kt(0,0))}if(xr.idx=i,xr.left=Qr,xr.top=$r,it._x=at,it._y=ot,null!=e&&(It(en,a,Qr,$r,s,u,i),_r)){var K=Lt.setSeries,Q=br.prox;null==ft?st>Q||mt(ut,pt,K):st>Q?mt(null,pt,K):ut!=ft&&mt(ut,pt,K)}y&&Ft("setCursor")}a.setLegend=yt;var zt=null;function Dt(n){zt=n?null:cn.getBoundingClientRect()}function Tt(n,e,r,t,l,i){xr._lock||(Et(n,e,r,t,l,i,0,!1,null!=n),null!=n?St(1):St(null,e))}function Et(n,e,r,t,l,i,o,s,u){var f;if(null==zt&&Dt(!1),null!=n)r=n.clientX-zt.left,t=n.clientY-zt.top;else{if(0>r||0>t)return Qr=-10,void($r=-10);var c=Oe,v=Ve,h=l,d=i,m=r,p=t;1==In.ori&&(c=Ve,v=Oe);var g=Lt.scales,x=g[0],w=g[1];if(1==e.scales[x].ori&&(h=i,d=l,m=t,p=r),r=null!=x?_(e.posToVal(m,x),Sn[x],c,0):c*(m/h),t=null!=w?_(e.posToVal(p,w),Sn[w],v,0):v*(p/d),1==In.ori){var b=r;r=t,t=b}}u&&(r>1&&Oe-1>r||(r=z(r,Oe)),t>1&&Ve-1>t||(t=z(t,Ve))),s?(Br=r,Jr=t,f=xr.move(a,r,t),qr=f[0],Zr=f[1]):(Qr=r,$r=t)}function Pt(){ht({width:0,height:0},!1)}function At(n,e,r,t,l,i){lt=!0,at=ot=it._x=it._y=!1,Et(n,e,r,t,l,i,0,!0,!1),null!=n&&(ze(tn,fn,Wt),It(rn,a,qr,Zr,Oe,Ve,null))}function Wt(n,e,r,t,l,i){lt=it._x=it._y=!1,Et(n,e,r,t,l,i,0,!1,!0);var o=ct.left,s=ct.top,u=ct.width,f=ct.height,c=u>0||f>0;if(c&&ht(ct),it.setScale&&c){var v=o,h=u,d=s,m=f;if(1==In.ori&&(v=s,h=f,d=o,m=u),at&&dt(Dn,xt(v,Dn),xt(v+h,Dn)),ot)for(var p in Sn){var g=Sn[p];p!=Dn&&null==g.from&&g.min!=S&&dt(p,xt(d+m,p),xt(d,p))}Pt()}else xr.lock&&(xr._lock=!xr._lock,xr._lock||St());null!=n&&(De(tn,fn),It(tn,a,Qr,$r,Oe,Ve,null))}function Yt(n){Hr(),Pt(),null!=n&&It(on,a,Qr,$r,Oe,Ve,null)}var Ct={};Ct.mousedown=At,Ct.mousemove=Tt,Ct.mouseup=Wt,Ct.dblclick=Yt,Ct.setSeries=(n,e,r,t)=>{mt(r,t)},xr.show&&(ze(rn,cn,At),ze(en,cn,Tt),ze(ln,cn,Dt),ze(an,cn,(function(){if(!xr._lock){var n=lt;if(lt){var e,r,t=!0,l=!0;0==In.ori?(e=at,r=ot):(e=ot,r=at),e&&r&&(t=10>=Qr||Qr>=Oe-10,l=10>=$r||$r>=Ve-10),e&&t&&(Qr=qr>Qr?0:Oe),r&&l&&($r=Zr>$r?0:Ve),St(1),lt=!1}Qr=-10,$r=-10,St(1),n&&(lt=n)}})),ze(on,cn,Yt),lr.add(a),a.syncRect=Dt);var Ht=a.hooks=e.hooks||{};function Ft(n,e,r){n in Ht&&Ht[n].forEach((n=>{n.call(null,a,e,r)}))}(e.plugins||[]).forEach((n=>{for(var e in n.hooks)Ht[e]=(Ht[e]||[]).concat(n.hooks[e])}));var Lt=R({key:null,setSeries:!1,filters:{pub:A,sub:A},scales:[Dn,null]},xr.sync),Nt=Ne(Lt.key);function It(n,e,r,t,l,i,a){Lt.filters.pub(n,e,r,t,l,i,a)&&Nt.pub(n,e,r,t,l,i,a)}function Ot(){Ft("init",e,r),Cr(r||e.data,!1),Vn[Dn]?tt(Dn,Vn[Dn]):Hr(),tr(e.width,e.height),St(),ht(ct,!1)}return Nt.sub(a),a.pub=function(n,e,r,t,l,i,a){Lt.filters.sub(n,e,r,t,l,i,a)&&Ct[n](null,e,r,t,l,i,a)},a.destroy=function(){Nt.unsub(a),lr.delete(a),Se.clear(),Y.remove(),Ft("destroy")},t?t instanceof HTMLElement?(t.appendChild(Y),Ot()):t(a,Ot):Ot(),a}xr.assign=R,xr.fmtNum=c,xr.rangeNum=s,xr.rangeLog=l,xr.rangeAsinh=i,xr.orient=Ie,xr.join=function(n,e){for(var r=new Set,t=0;n.length>t;t++)for(var l=n[t][0],i=l.length,a=0;i>a;a++)r.add(l[a]);for(var o=[Array.from(r).sort(((n,e)=>n-e))],s=o[0].length,u=new Map,f=0;s>f;f++)u.set(o[0][f],f);for(var c=0;n.length>c;c++)for(var v=n[c],h=v[0],d=1;v.length>d;d++){for(var m=v[d],p=Array(s).fill(void 0),g=e?e[c][d]:1,x=[],w=0;m.length>w;w++){var b=m[w],_=u.get(h[w]);null==b?0!=g&&(p[_]=b,2==g&&x.push(_)):p[_]=b}U(p,x,s),o.push(p)}return o},xr.fmtDate=Pn,xr.tzDate=function(n,e){var r;return"UTC"==e||"Etc/UTC"==e?r=new Date(+n+6e4*n.getTimezoneOffset()):e==An?r=n:(r=new Date(n.toLocaleString("en-US",{timeZone:e}))).setMilliseconds(n.getMilliseconds()),r},xr.sync=Ne,xr.addGap=je,xr.clipGaps=Ve;var wr=xr.paths={};return wr.linear=rr,wr.spline=function(){return(n,r,t,l)=>Ie(n,r,((i,a,o,s,u,f,c,v,h,d,m)=>{var g,x,w;0==s.ori?(g=Ge,w=Ue,x=Ke):(g=Re,w=Be,x=Qe);var b=1*s.dir*(0==s.ori?1:-1);t=e(o,t,l,1),l=e(o,t,l,-1);for(var _=[],k=!1,y=p(f(a[1==b?t:l],s,d,v)),M=y,S=[],z=[],D=1==b?t:l;D>=t&&l>=D;D+=b){var T=o[D],E=f(a[D],s,d,v);null!=T?(k&&(je(_,M,E),k=!1),S.push(M=E),z.push(c(o[D],u,m,h))):null===T&&(je(_,M,E),k=!0)}var P={stroke:tr(S,z,.5,g,x),fill:null,clip:null,band:null},A=P.stroke;if(null!=i.fill){var W=P.fill=new Path2D(A),Y=i.fillTo(n,r,i.min,i.max),C=p(c(Y,u,m,h));w(W,M,C),w(W,y,C)}return i.spanGaps||(P.clip=Ve(_,s.ori,v,h,d,m)),n.bands.length>0&&(P.band=Oe(n,r,t,l,A)),P}))},wr.stepped=function(n){var r=u(n.align,1),t=u(n.ascDesc,!1);return(n,l,i,a)=>Ie(n,l,((o,s,u,f,c,v,h,d,m,g,x)=>{var w=0==f.ori?Ue:Be,b={stroke:new Path2D,fill:null,clip:null,band:null},_=b.stroke,k=1*f.dir*(0==f.ori?1:-1);i=e(u,i,a,1),a=e(u,i,a,-1);var y=[],M=!1,S=p(h(u[1==k?i:a],c,x,m)),z=p(v(s[1==k?i:a],f,g,d)),D=z;w(_,z,S);for(var T=1==k?i:a;T>=i&&a>=T;T+=k){var E=u[T],P=p(v(s[T],f,g,d));if(null!=E){var A=p(h(E,c,x,m));if(M){if(je(y,D,P),S!=A){var W=o.width*vn/2,Y=y[y.length-1];Y[0]+=t||1==r?W:-W,Y[1]-=t||-1==r?W:-W}M=!1}1==r?w(_,P,S):w(_,D,A),w(_,P,A),S=A,D=P}else null===E&&(je(y,D,P),M=!0)}if(null!=o.fill){var C=b.fill=new Path2D(_),H=o.fillTo(n,l,o.min,o.max),F=p(h(H,c,x,m));w(C,D,F),w(C,z,F)}return o.spanGaps||(b.clip=Ve(y,f.ori,d,m,g,x)),n.bands.length>0&&(b.band=Oe(n,l,i,a,_)),b}))},wr.bars=function(n){var r=u((n=n||N).size,[.6,S]),t=n.align||0,l=1-r[0],i=u(r[1],S)*vn;return(n,r,a,o)=>Ie(n,r,((s,u,f,c,v,h,d,m,g,b,_)=>{var k,y=0==c.ori?Je:qe,M=h(u[1],c,b,m)-h(u[0],c,b,m),S=M*l,D=d(s.fillTo(n,r,s.min,s.max),v,_,g),T=p(s.width*vn),E=p(x(i,M-S)-T),P=1==t?0:-1==t?E:E/2,A={stroke:new Path2D,fill:null,clip:null,band:null},W=n.bands.length>0;W&&(A.band=new Path2D,k=z(d(v.max,v,_,g),.5));for(var Y=A.stroke,C=A.band,H=c.dir*(0==c.ori?1:-1),F=1==H?a:o;F>=a&&o>=F;F+=H){var L=f[F];if(null==L){if(!W)continue;var N=e(f,1==H?a:o,F,-H),I=e(f,F,1==H?o:a,H),O=f[N];L=O+(F-N)/(I-N)*(f[I]-O)}var V=h(2==c.distr?F:u[F],c,b,m),j=d(L,v,_,g),G=p(V-P),R=p(w(j,D)),U=p(x(j,D)),B=R-U;null!=f[F]&&y(Y,G,U,E,B),W&&(R=U,y(C,G,U=k,E,B=R-U))}return null!=s.fill&&(A.fill=new Path2D(Y)),A}))},xr}(); -------------------------------------------------------------------------------- /app/websocket.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/devOpifex/skeef/graph" 11 | "github.com/dghubble/go-twitter/twitter" 12 | "github.com/dghubble/oauth1" 13 | "github.com/gorilla/websocket" 14 | ) 15 | 16 | type message struct { 17 | Graph graph.Graph `json:"graph"` 18 | Trend map[int64]int `json:"trend"` 19 | RemoveEdges []graph.Edge `json:"removeEdges"` 20 | RemoveNodes []graph.Node `json:"removeNodes"` 21 | } 22 | 23 | type messageEmbed struct { 24 | Embeds []string `json:"embeds"` 25 | } 26 | 27 | type connectionMessage struct { 28 | Graph graph.Graph `json:"graph"` 29 | Trend map[int64]int `json:"trend"` 30 | } 31 | 32 | type Client struct { 33 | ID string 34 | Conn *websocket.Conn 35 | Pool *Pool 36 | } 37 | 38 | type Pool struct { 39 | Register chan *Client 40 | Unregister chan *Client 41 | Clients map[*Client]bool 42 | Broadcast chan message 43 | } 44 | 45 | func NewPool() *Pool { 46 | return &Pool{ 47 | Register: make(chan *Client), 48 | Unregister: make(chan *Client), 49 | Clients: make(map[*Client]bool), 50 | Broadcast: make(chan message), 51 | } 52 | } 53 | 54 | const maxMessageSize = 512 55 | 56 | func (c *Client) Read(app *Application) { 57 | defer func() { 58 | c.Pool.Unregister <- c 59 | c.Conn.Close() 60 | }() 61 | 62 | c.Conn.SetReadLimit(maxMessageSize) 63 | 64 | for { 65 | _, p, err := c.Conn.ReadMessage() 66 | if err != nil { 67 | log.Println(err) 68 | return 69 | } 70 | 71 | msg := string(p) 72 | embeds := app.getMentions(msg) 73 | c.Conn.WriteJSON(messageEmbed{embeds}) 74 | 75 | } 76 | } 77 | 78 | func (app *Application) StartPool() { 79 | for { 80 | select { 81 | case client := <-app.Pool.Register: 82 | app.Pool.Clients[client] = true 83 | app.Connected++ 84 | for client := range app.Pool.Clients { 85 | // send current state of the graph on connect 86 | client.Conn.WriteJSON( 87 | connectionMessage{ 88 | Graph: app.Graph, 89 | Trend: app.Trend, 90 | }) 91 | } 92 | case client := <-app.Pool.Unregister: 93 | delete(app.Pool.Clients, client) 94 | app.Connected-- 95 | // for client := range app.Pool.Clients { 96 | // client.Conn.WriteJSON(message{}) 97 | // } 98 | case message := <-app.Pool.Broadcast: 99 | for client := range app.Pool.Clients { 100 | if err := client.Conn.WriteJSON(message); err != nil { 101 | fmt.Println(err) 102 | return 103 | } 104 | } 105 | } 106 | } 107 | } 108 | 109 | var upgrader = websocket.Upgrader{ 110 | ReadBufferSize: 2048, 111 | WriteBufferSize: 2048, 112 | CheckOrigin: func(r *http.Request) bool { return true }, 113 | } 114 | 115 | // wsUpgrade Upgrades the websocket 116 | func (app *Application) wsUpgrade(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) { 117 | ws, err := upgrader.Upgrade(w, r, nil) 118 | if err != nil { 119 | app.ErrorLog.Println(err) 120 | return ws, err 121 | } 122 | return ws, nil 123 | } 124 | 125 | // socket Upgrade the websocket and app it to the pool 126 | func (app *Application) socket(w http.ResponseWriter, r *http.Request) { 127 | ws, err := app.wsUpgrade(w, r) 128 | 129 | if err != nil { 130 | log.Println(err) 131 | } 132 | 133 | client := &Client{ 134 | Conn: ws, 135 | Pool: app.Pool, 136 | } 137 | 138 | app.Pool.Register <- client 139 | client.Read(app) 140 | 141 | } 142 | 143 | // StopStream stops the stream 144 | func (app *Application) StopStream() { 145 | 146 | if app.Stream != nil { 147 | app.Stream.Stop() 148 | } 149 | 150 | app.Quit <- struct{}{} 151 | app.Database.PauseAllStreams() 152 | } 153 | 154 | // StartStream starts the stream 155 | func (app *Application) StartStream() { 156 | 157 | app.Trend = make(map[int64]int) 158 | app.Count = 0 159 | app.Graph = graph.Graph{} 160 | 161 | tokens, err := app.Database.GetTokens() 162 | 163 | if err != nil { 164 | return 165 | } 166 | 167 | app.StreamActive = app.Database.GetActiveStream() 168 | app.Exclusion = exclusionMap(app.StreamActive.Exclusion) 169 | app.MaxEdges = app.StreamActive.MaxEdges 170 | 171 | var twitterConfig = oauth1.NewConfig(tokens.ApiKey, tokens.ApiSecret) 172 | var token = oauth1.NewToken(tokens.AccessToken, tokens.AccessSecret) 173 | 174 | var httpClient = twitterConfig.Client(oauth1.NoContext, token) 175 | 176 | var client = twitter.NewClient(httpClient) 177 | 178 | var params = &twitter.StreamFilterParams{ 179 | Track: splitTerm(app.StreamActive.Track), 180 | Follow: splitTerm(app.StreamActive.Follow), 181 | StallWarnings: twitter.Bool(true), 182 | } 183 | app.Stream, _ = client.Streams.Filter(params) 184 | 185 | var demux = twitter.NewSwitchDemux() 186 | demux.Tweet = app.demux() 187 | 188 | for message := range app.Stream.Messages { 189 | demux.Handle(message) 190 | } 191 | } 192 | 193 | // demux Demux tweets and broadcast to websocket clients 194 | func (app *Application) demux() func(tweet *twitter.Tweet) { 195 | return func(tweet *twitter.Tweet) { 196 | app.Count++ 197 | app.Trend[parseTime(tweet.CreatedAt)]++ 198 | app.truncateTrend() 199 | app.InfoLog.Printf("Tweet #%v\n", app.Count) 200 | 201 | // declare variables 202 | var nodes []graph.Node 203 | var edges []graph.Edge 204 | 205 | // selectively create graph 206 | if app.StreamActive.MentionsNet > 0 { 207 | nodesMentions, edgesMentions := graph.GetMentionNet( 208 | *tweet, 209 | app.Exclusion, 210 | app.StreamActive.MinFollowerCount, 211 | app.StreamActive.MinFavoriteCount, 212 | app.StreamActive.OnlyVerified, 213 | app.StreamActive.MaxHashtags, 214 | app.StreamActive.MaxMentions, 215 | ) 216 | nodes = append(nodes, nodesMentions...) 217 | edges = append(edges, edgesMentions...) 218 | } 219 | if app.StreamActive.HashtagsNet > 0 { 220 | nodesHash, edgesHash := graph.GetHashtagNet( 221 | *tweet, 222 | app.Exclusion, 223 | app.StreamActive.MinFollowerCount, 224 | app.StreamActive.MinFavoriteCount, 225 | app.StreamActive.OnlyVerified, 226 | app.StreamActive.MaxHashtags, 227 | app.StreamActive.MaxMentions, 228 | ) 229 | nodes = append(nodes, nodesHash...) 230 | edges = append(edges, edgesHash...) 231 | } 232 | if app.StreamActive.RetweetsNet > 0 { 233 | ok, nodesRetweet, edgesRetweet := graph.GetRetweetNet( 234 | *tweet, 235 | app.Exclusion, 236 | app.StreamActive.MinFollowerCount, 237 | app.StreamActive.MinFavoriteCount, 238 | app.StreamActive.OnlyVerified, 239 | app.StreamActive.MaxHashtags, 240 | app.StreamActive.MaxMentions, 241 | ) 242 | if ok { 243 | nodes = append(nodes, nodesRetweet...) 244 | edges = append(edges, edgesRetweet) 245 | } 246 | } 247 | 248 | // track tweets 249 | app.trackTweets(tweet, edges) 250 | //truncate tweets 251 | app.truncateTweetsUser() 252 | 253 | app.Graph.UpsertEdges(edges...) 254 | app.Graph.UpsertNodes(nodes...) 255 | 256 | removeNodes, removeEdges := app.Graph.Truncate(app.MaxEdges) 257 | 258 | g := graph.Graph{ 259 | Edges: edges, 260 | Nodes: nodes, 261 | } 262 | 263 | m := message{ 264 | Graph: g, 265 | Trend: app.Trend, 266 | RemoveNodes: removeNodes, 267 | RemoveEdges: removeEdges, 268 | } 269 | app.Pool.Broadcast <- m 270 | } 271 | } 272 | 273 | // parseTime Parse the ruby date and round to nearest 15 seconds 274 | func parseTime(date string) int64 { 275 | toRound, _ := time.Parse(time.RubyDate, date) 276 | minute := toRound.Round(15 * time.Second) 277 | 278 | return minute.Unix() 279 | } 280 | 281 | // splitTerm splits comma separate string into 282 | // slice of strings 283 | func splitTerm(track string) []string { 284 | splat := strings.Split(track, ",") 285 | 286 | for i, s := range splat { 287 | splat[i] = strings.TrimSpace(s) 288 | } 289 | 290 | return splat 291 | } 292 | 293 | // truncateTrend Truncates the trend to ensure it 294 | // does not grow infinitely 295 | func (app *Application) truncateTrend() { 296 | if len(app.Trend) < 50 { 297 | return 298 | } 299 | 300 | var min int64 301 | 302 | for key := range app.Trend { 303 | if min == 0 { 304 | min = key 305 | continue 306 | } 307 | 308 | if min < key { 309 | continue 310 | } 311 | 312 | min = key 313 | } 314 | 315 | delete(app.Trend, min) 316 | } 317 | 318 | type tweetsUsers struct { 319 | id string 320 | nodes []string 321 | } 322 | 323 | // trackTweets Keep track of tweets and nodes involved 324 | func (app *Application) trackTweets(tweet *twitter.Tweet, edges []graph.Edge) { 325 | var nodes []string 326 | 327 | if len(edges) == 0 { 328 | return 329 | } 330 | 331 | for _, v := range edges { 332 | nodes = append(nodes, v.Source) 333 | nodes = append(nodes, v.Target) 334 | } 335 | 336 | new := tweetsUsers{ 337 | id: tweet.IDStr, 338 | nodes: nodes, 339 | } 340 | 341 | app.TweetsUsers = append(app.TweetsUsers, new) 342 | } 343 | 344 | func (app *Application) truncateTweetsUser() { 345 | 346 | n := len(app.TweetsUsers) 347 | 348 | if n < 1000 { 349 | return 350 | } 351 | 352 | app.TweetsUsers[0] = tweetsUsers{} 353 | app.TweetsUsers = app.TweetsUsers[1:] 354 | 355 | } 356 | 357 | type tweetEmbeds map[string]bool 358 | 359 | func (app *Application) getMentions(id string) []string { 360 | var results = make(tweetEmbeds) 361 | 362 | for _, tweet := range app.TweetsUsers { 363 | for _, node := range tweet.nodes { 364 | if node == id { 365 | results[tweet.id] = true 366 | } 367 | } 368 | } 369 | 370 | var ids []string 371 | for k := range results { 372 | if len(ids) > 4 { 373 | break 374 | } 375 | ids = append(ids, k) 376 | } 377 | 378 | return ids 379 | } 380 | -------------------------------------------------------------------------------- /bin/skeef: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devOpifex/skeef/269c29dc4c3d6f4fec8ca2c89ef0c9dc556b66bd/bin/skeef -------------------------------------------------------------------------------- /bin/skeef-mac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devOpifex/skeef/269c29dc4c3d6f4fec8ca2c89ef0c9dc556b66bd/bin/skeef-mac -------------------------------------------------------------------------------- /bin/skeef.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devOpifex/skeef/269c29dc4c3d6f4fec8ca2c89ef0c9dc556b66bd/bin/skeef.exe -------------------------------------------------------------------------------- /db/actions.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | "os" 7 | ) 8 | 9 | type Database struct { 10 | Con *sql.DB 11 | } 12 | 13 | // DBExists Whether the database exists 14 | func Exists() bool { 15 | _, err := os.Stat("skeef.db") 16 | return err == nil 17 | } 18 | 19 | // DBCreate Create Database 20 | func Create() error { 21 | _, err := os.Create("skeef.db") 22 | 23 | if err != nil { 24 | return err 25 | } 26 | 27 | return nil 28 | } 29 | 30 | // DBRemove Remove Database 31 | func Remove() error { 32 | return os.Remove("skeef.db") 33 | } 34 | 35 | // DBConnect Connect to database 36 | func Connect() *sql.DB { 37 | db, err := sql.Open("sqlite3", "skeef.db") 38 | 39 | if err != nil { 40 | log.Fatal("Could not connect to local database") 41 | } 42 | 43 | return db 44 | } 45 | -------------------------------------------------------------------------------- /db/checks.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | func (db Database) AdminExists() bool { 4 | rows := db.Con.QueryRow("SELECT COUNT(1) FROM users;") 5 | 6 | var count int 7 | rows.Scan(&count) 8 | 9 | return count > 0 10 | } 11 | 12 | func (db Database) TokensExist() bool { 13 | rows := db.Con.QueryRow("SELECT COUNT(1) FROM twitter_app;") 14 | 15 | var count int 16 | rows.Scan(&count) 17 | 18 | return count > 0 19 | } 20 | -------------------------------------------------------------------------------- /db/create.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | // CreateUserTable Create user table 4 | func (DB *Database) CreateTableUser() error { 5 | 6 | _, err := DB.Con.Exec(`CREATE TABLE users 7 | ( 8 | email VARCHAR(50) NOT NULL PRIMARY KEY, 9 | hashed_password CHAR(60) NOT NULL, 10 | admin INTEGER 11 | );`) 12 | 13 | if err != nil { 14 | return err 15 | } 16 | 17 | return nil 18 | } 19 | 20 | func (DB *Database) CreateTableTwitterApp() error { 21 | 22 | _, err := DB.Con.Exec(`CREATE TABLE twitter_app 23 | ( 24 | api_key VARCHAR(255) NOT NULL, 25 | api_secret VARCHAR(255) NOT NULL, 26 | access_token VARCHAR(255) NOT NULL, 27 | access_secret VARCHAR(255) NOT NULL, 28 | id INTEGER 29 | );`) 30 | 31 | if err != nil { 32 | return err 33 | } 34 | 35 | return nil 36 | } 37 | 38 | func (DB *Database) CreateTableStreams() error { 39 | 40 | _, err := DB.Con.Exec(`CREATE TABLE streams 41 | ( 42 | name VARCHAR(50) NOT NULL PRIMARY KEY, 43 | follow VARCHAR(400), track VARCHAR(400), 44 | locations VARCHAR(400), 45 | active INTEGER, 46 | max_edges INTEGER, 47 | exclude VARCHAR(254), 48 | description VARCHAR(1000), 49 | retweets_net INTEGER, 50 | mentions_net INTEGER, 51 | hashtags_net INTEGER, 52 | reply_net INTEGER, 53 | filter_level VARCHAR(10), 54 | min_follower_count INTEGER, 55 | min_favorite_count INTEGER, 56 | only_verified INTERGER, 57 | max_hashtags INTEGER, 58 | max_mentions INTEGER 59 | );`) 60 | 61 | if err != nil { 62 | return err 63 | } 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /db/streams.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/devOpifex/skeef/stream" 5 | ) 6 | 7 | // InsertStream Insert a new stream 8 | func (DB *Database) InsertStream( 9 | name, follow, track, locations, exclude, maxEdges, description string, 10 | retweetsNet, mentionsNet, hashtagsNet int, 11 | filterLevel string, 12 | min_follower_count, min_favorite_count, only_verified, 13 | max_hashtags, max_mentions, replyNet int) error { 14 | 15 | stmt, err := DB.Con.Prepare(`INSERT INTO streams 16 | ( 17 | name, follow, track, locations, active, 18 | exclude, max_edges, description, retweets_net, 19 | mentions_net, hashtags_net, filter_level, 20 | min_follower_count, min_favorite_count, only_verified, 21 | max_hashtags, max_mentions, reply_net 22 | ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);`) 23 | 24 | if err != nil { 25 | return err 26 | } 27 | 28 | defer stmt.Close() 29 | 30 | _, err = stmt.Exec( 31 | name, 32 | follow, 33 | track, 34 | locations, 35 | 0, 36 | exclude, 37 | maxEdges, 38 | description, 39 | retweetsNet, 40 | mentionsNet, 41 | hashtagsNet, 42 | filterLevel, 43 | min_follower_count, 44 | min_favorite_count, 45 | only_verified, 46 | max_hashtags, 47 | max_mentions, 48 | replyNet, 49 | ) 50 | 51 | if err != nil { 52 | return err 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func (DB *Database) StreamExists(name string) (bool, error) { 59 | 60 | stmt, err := DB.Con.Prepare("SELECT COUNT(1) FROM streams WHERE name = ?;") 61 | 62 | if err != nil { 63 | return false, err 64 | } 65 | 66 | row := stmt.QueryRow(name) 67 | 68 | res := 0 69 | row.Scan(&res) 70 | 71 | return res > 0, nil 72 | } 73 | 74 | func (DB *Database) GetStreams() ([]stream.Stream, error) { 75 | var streams []stream.Stream 76 | 77 | rows, err := DB.Con.Query(`SELECT name, follow, 78 | track, locations, active, max_edges, description, 79 | retweets_net, mentions_net, hashtags_net, filter_level, 80 | min_follower_count, min_favorite_count, only_verified, 81 | max_hashtags, max_mentions, reply_net 82 | FROM streams;`) 83 | 84 | if err != nil { 85 | return streams, err 86 | } 87 | 88 | for rows.Next() { 89 | var stream stream.Stream 90 | 91 | err := rows.Scan( 92 | &stream.Name, 93 | &stream.Follow, 94 | &stream.Track, 95 | &stream.Locations, 96 | &stream.Active, 97 | &stream.MaxEdges, 98 | &stream.Description, 99 | &stream.RetweetsNet, 100 | &stream.MentionsNet, 101 | &stream.HashtagsNet, 102 | &stream.FilterLevel, 103 | &stream.MinFollowerCount, 104 | &stream.MinFavoriteCount, 105 | &stream.OnlyVerified, 106 | &stream.MaxHashtags, 107 | &stream.MaxMentions, 108 | &stream.ReplyNet, 109 | ) 110 | 111 | if err != nil { 112 | continue 113 | } 114 | 115 | streams = append(streams, stream) 116 | } 117 | 118 | return streams, nil 119 | } 120 | 121 | func (DB *Database) DeleteStream(name string) error { 122 | stmt, err := DB.Con.Prepare("DELETE FROM streams WHERE name = ?") 123 | 124 | if err != nil { 125 | return err 126 | } 127 | 128 | _, err = stmt.Exec(&name) 129 | 130 | if err != nil { 131 | return err 132 | } 133 | 134 | return nil 135 | } 136 | 137 | func (DB *Database) StartStream(name string) error { 138 | 139 | stmt, err := DB.Con.Prepare("UPDATE streams SET active = 1 WHERE name = ?") 140 | 141 | if err != nil { 142 | return err 143 | } 144 | 145 | _, err = stmt.Exec(&name) 146 | 147 | if err != nil { 148 | return err 149 | } 150 | 151 | return nil 152 | } 153 | 154 | func (DB *Database) PauseStream(name string) error { 155 | 156 | stmt, err := DB.Con.Prepare("UPDATE streams SET active = 0 WHERE name = ?") 157 | 158 | if err != nil { 159 | return err 160 | } 161 | 162 | _, err = stmt.Exec(&name) 163 | 164 | if err != nil { 165 | return err 166 | } 167 | 168 | return nil 169 | } 170 | 171 | func (DB *Database) PauseAllStreams() error { 172 | 173 | stmt, err := DB.Con.Prepare("UPDATE streams SET active = 0") 174 | 175 | if err != nil { 176 | return err 177 | } 178 | 179 | _, err = stmt.Exec() 180 | 181 | if err != nil { 182 | return err 183 | } 184 | 185 | return nil 186 | } 187 | 188 | func (DB *Database) GetStream(name string) (stream.Stream, error) { 189 | var stream stream.Stream 190 | 191 | stmt, err := DB.Con.Prepare(`SELECT name, follow, 192 | track, locations, active, max_edges, exclude, 193 | description, retweets_net, mentions_net, hashtags_net, 194 | filter_level, min_follower_count, min_favorite_count, only_verified, 195 | max_hashtags, max_mentions, reply_net 196 | FROM streams WHERE name = ?`) 197 | 198 | if err != nil { 199 | return stream, err 200 | } 201 | 202 | row := stmt.QueryRow(name) 203 | 204 | row.Scan( 205 | &stream.Name, 206 | &stream.Follow, 207 | &stream.Track, 208 | &stream.Locations, 209 | &stream.Active, 210 | &stream.MaxEdges, 211 | &stream.Exclusion, 212 | &stream.Description, 213 | &stream.RetweetsNet, 214 | &stream.MentionsNet, 215 | &stream.HashtagsNet, 216 | &stream.FilterLevel, 217 | &stream.MinFollowerCount, 218 | &stream.MinFavoriteCount, 219 | &stream.OnlyVerified, 220 | &stream.MaxHashtags, 221 | &stream.MaxMentions, 222 | &stream.ReplyNet, 223 | ) 224 | 225 | return stream, nil 226 | } 227 | 228 | func (DB *Database) UpdateStream( 229 | track, follow, locations, newName, currentName, exclusion, description string, 230 | maxEdges, retweetsNet, mentionsNet, hashtagsNet int, 231 | filterLevel string, 232 | min_follower_count, min_favorite_count, only_verified, 233 | max_hashtags, max_mentions, replyNet int) error { 234 | 235 | stmt, err := DB.Con.Prepare(`UPDATE streams SET 236 | track = ?, 237 | follow = ?, 238 | locations = ?, 239 | name = ?, 240 | max_edges = ?, 241 | description = ?, 242 | exclude = ?, 243 | retweets_net = ?, 244 | mentions_net = ?, 245 | hashtags_net = ?, 246 | filter_level = ?, 247 | min_follower_count = ?, 248 | min_favorite_count = ?, 249 | only_verified = ?, 250 | max_hashtags = ?, 251 | max_mentions = ? 252 | WHERE name = ?`) 253 | 254 | if err != nil { 255 | return err 256 | } 257 | 258 | _, err = stmt.Exec( 259 | track, 260 | follow, 261 | locations, 262 | newName, 263 | maxEdges, 264 | description, 265 | exclusion, 266 | retweetsNet, 267 | mentionsNet, 268 | hashtagsNet, 269 | filterLevel, 270 | min_follower_count, 271 | min_favorite_count, 272 | only_verified, 273 | max_hashtags, 274 | max_mentions, 275 | replyNet, 276 | currentName, 277 | ) 278 | 279 | if err != nil { 280 | return err 281 | } 282 | 283 | return nil 284 | } 285 | 286 | func (DB *Database) GetActiveStream() stream.Stream { 287 | var stream stream.Stream 288 | 289 | row := DB.Con.QueryRow(`SELECT 290 | name, 291 | follow, 292 | track, 293 | locations, 294 | active, 295 | max_edges, 296 | exclude, 297 | description, 298 | retweets_net, 299 | mentions_net, 300 | hashtags_net, 301 | filter_level, 302 | min_follower_count, 303 | min_favorite_count, 304 | only_verified, 305 | max_hashtags, 306 | max_mentions, 307 | reply_net 308 | FROM streams 309 | WHERE active = 1;`) 310 | 311 | var onlyVerified int 312 | 313 | row.Scan( 314 | &stream.Name, 315 | &stream.Follow, 316 | &stream.Track, 317 | &stream.Locations, 318 | &stream.Active, 319 | &stream.MaxEdges, 320 | &stream.Exclusion, 321 | &stream.Description, 322 | &stream.RetweetsNet, 323 | &stream.MentionsNet, 324 | &stream.HashtagsNet, 325 | &stream.FilterLevel, 326 | &stream.MinFollowerCount, 327 | &stream.MinFavoriteCount, 328 | &onlyVerified, 329 | &stream.MaxHashtags, 330 | &stream.MaxMentions, 331 | &stream.ReplyNet, 332 | ) 333 | 334 | stream.OnlyVerified = intToBool(onlyVerified) 335 | 336 | return stream 337 | } 338 | 339 | func (DB *Database) StreamOnGoing() bool { 340 | rows := DB.Con.QueryRow("SELECT COUNT(1) FROM streams WHERE active = 1;") 341 | 342 | var count int 343 | rows.Scan(&count) 344 | 345 | return count > 0 346 | } 347 | 348 | func intToBool(i int) bool { 349 | return i > 0 350 | } 351 | -------------------------------------------------------------------------------- /db/tokens.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | type Tokens struct { 4 | ApiKey string 5 | ApiSecret string 6 | AccessToken string 7 | AccessSecret string 8 | } 9 | 10 | func (DB *Database) InsertTokens(apiKey, apiSecret, accessToken, accessSecret string) error { 11 | 12 | stmt, err := DB.Con.Prepare("INSERT INTO twitter_app (api_key, api_secret, access_token, access_secret, id) VALUES (?,?,?,?,?);") 13 | 14 | if err != nil { 15 | return err 16 | } 17 | 18 | defer stmt.Close() 19 | 20 | _, err = stmt.Exec(apiKey, apiSecret, accessToken, accessSecret, 1) 21 | 22 | if err != nil { 23 | return err 24 | } 25 | 26 | return nil 27 | } 28 | 29 | func (DB *Database) UpdateTokens(apiKey, apiSecret, accessToken, accessSecret string) error { 30 | 31 | stmt, err := DB.Con.Prepare("UPDATE twitter_app SET api_key = ?, api_secret = ?, access_token = ?, access_secret = ? WHERE id = 1") 32 | 33 | if err != nil { 34 | return err 35 | } 36 | 37 | _, err = stmt.Exec(apiKey, apiSecret, accessToken, accessSecret) 38 | 39 | if err != nil { 40 | return err 41 | } 42 | 43 | return nil 44 | } 45 | 46 | func (DB *Database) GetTokens() (Tokens, error) { 47 | res := DB.Con.QueryRow("SELECT api_key, api_secret, access_token, access_secret FROM twitter_app;") 48 | 49 | tk := Tokens{} 50 | res.Scan(&tk.ApiKey, &tk.ApiSecret, &tk.AccessToken, &tk.AccessSecret) 51 | 52 | return tk, nil 53 | } 54 | -------------------------------------------------------------------------------- /db/users.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | 7 | "golang.org/x/crypto/bcrypt" 8 | ) 9 | 10 | // InsertUser Inserts a new user in the database 11 | func (DB *Database) InsertUser(email, password string, admin int) error { 12 | 13 | stmt, err := DB.Con.Prepare("INSERT INTO users (email, hashed_password, admin) VALUES (?,?,?);") 14 | 15 | if err != nil { 16 | return err 17 | } 18 | 19 | defer stmt.Close() 20 | 21 | _, err = stmt.Exec(email, password, admin) 22 | 23 | if err != nil { 24 | return err 25 | } 26 | 27 | return nil 28 | } 29 | 30 | func (DB *Database) ChangePassword(email, password string) error { 31 | stmt, err := DB.Con.Prepare("UPDATE users SET hashed_password = ? WHERE email = ?;") 32 | 33 | if err != nil { 34 | return err 35 | } 36 | 37 | _, err = stmt.Exec(password, email) 38 | 39 | if err != nil { 40 | return err 41 | } 42 | 43 | return nil 44 | } 45 | 46 | func (DB *Database) Authenticate(email, password string) (string, error) { 47 | var hashedPassword []byte 48 | stmt := "SELECT hashed_password FROM users WHERE email = ?" 49 | row := DB.Con.QueryRow(stmt, email) 50 | err := row.Scan(&hashedPassword) 51 | 52 | if err != nil { 53 | if errors.Is(err, sql.ErrNoRows) { 54 | return "", errors.New("invalid credentials") 55 | } else { 56 | return "", err 57 | } 58 | } 59 | 60 | err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(password)) 61 | if err != nil { 62 | if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { 63 | return "", errors.New("invalid credentials") 64 | } else { 65 | return "", err 66 | } 67 | } 68 | 69 | // Otherwise, the password is correct. Return the user ID. 70 | return email, nil 71 | } 72 | -------------------------------------------------------------------------------- /front-end/app.js: -------------------------------------------------------------------------------- 1 | var createGraph = require('ngraph.graph'); 2 | var pixel = require('ngraph.pixel'); 3 | 4 | let trendPlot, 5 | graphContainer, 6 | clickedNode; 7 | 8 | document.addEventListener("DOMContentLoaded",function(){ 9 | 10 | // warning on admin inputs 11 | let locationsInput = document.getElementById("locationsStream"); 12 | 13 | if(locationsInput != null){ 14 | let locationsWarning = document.getElementById("locationsWarning"); 15 | locationsInput.addEventListener("input", (event) => { 16 | if(event.data == null){ 17 | locationsWarning.innerHTML = ""; 18 | return ; 19 | } 20 | locationsWarning.innerHTML = "Few tweets are geo-tagged; this dramatically reduces the number of tweets streamed." 21 | }); 22 | } 23 | 24 | var g = createGraph(); 25 | g.addNode(1, {type: 'hidden'}); 26 | g.addNode(2, {type: 'hidden'}); 27 | graphContainer = document.getElementById("graph"); 28 | 29 | if(graphContainer != null){ 30 | var renderer = pixel(g, { 31 | is3d: true, 32 | container: graphContainer, 33 | clearAlpha: 0.0, 34 | autoFit: true, 35 | physics: { 36 | springLength : 50, 37 | springCoeff : 0.0002, 38 | gravity: -2, 39 | theta : 0.4, 40 | dragCoeff : 0.02 41 | }, 42 | node: function (n) { 43 | if(n.data.type == 'user'){ 44 | return { 45 | color: 0xff9e00, 46 | size: sizeNode(n.data.count + 1) 47 | } 48 | } else if(n.data.type == 'hashtag'){ 49 | return { 50 | color: 0x9d4edd, 51 | size: sizeNode(n.data.count + 1) 52 | } 53 | } else if (n.data.type == 'hidden') { 54 | return { 55 | color: 0x262b36, 56 | size: 0 57 | } 58 | } 59 | }, 60 | link: function(l){ 61 | return { 62 | fromColor: 0xbfbfbf, 63 | toColor: 0xbfbfbf 64 | } 65 | } 66 | }); 67 | 68 | renderer.on('nodeclick', function(node){ 69 | clickedNode = node.id; 70 | socket.send(node.id); 71 | }) 72 | 73 | resizeGraph(); 74 | } 75 | 76 | // websocket 77 | window.socket.onopen = () => { 78 | socket.send("Client Connected") 79 | } 80 | 81 | window.socket.onclose = () => { 82 | console.log("Websocket closed") 83 | } 84 | 85 | window.socket.onerror = (error) => { 86 | console.log(error); 87 | } 88 | 89 | let firstrun = true, 90 | nnodes = 0, 91 | nedges = 0; 92 | let nedgesEl = document.getElementById("nedges"); 93 | let nnodesEl = document.getElementById("nnodes"); 94 | trendPlot = initPlot(); 95 | let tweetsWrapper = document.getElementById("tweets"); 96 | 97 | window.socket.onmessage = (data) => { 98 | if(nedgesEl == null) 99 | return; 100 | 101 | let parsed = JSON.parse(data.data); 102 | 103 | if(parsed != null && parsed.hasOwnProperty("embeds")){ 104 | // empty the wrapper 105 | tweetsWrapper.innerHTML = ""; 106 | 107 | if(parsed.embeds == null){ 108 | return ; 109 | } 110 | 111 | let embeds = parsed.embeds.filter(uniques); 112 | 113 | for(let i = 0; i < embeds.length; i++){ 114 | 115 | // create div 116 | let el = document.createElement("DIV"); 117 | tweetsWrapper.appendChild(el); 118 | 119 | // embed tweet 120 | twttr.widgets.createTweet( 121 | embeds[i], el, { 122 | theme: 'dark', 123 | conversation: 'none', 124 | cards: 'hidden' 125 | } 126 | ) 127 | 128 | setTimeout(function(){ 129 | document.getElementById('hideTweets').style.display = 'block'; 130 | }, 500); 131 | } 132 | return; 133 | } 134 | 135 | updatePlot(parsed.trend); 136 | 137 | // Initial load, all at once 138 | if(firstrun){ 139 | g.beginUpdate(); 140 | for(let i = 0; i < parsed.graph.nodes.length; i++) { 141 | nnodes++ 142 | g.addNode( 143 | parsed.graph.nodes[i].name, 144 | {type: parsed.graph.nodes[i].type, count: parsed.graph.nodes[i].count} 145 | ); 146 | } 147 | for(let i = 0; i < parsed.graph.edges.length; i++) { 148 | nedges++ 149 | g.addLink(parsed.graph.edges[i].source, parsed.graph.edges[i].target); 150 | } 151 | g.endUpdate(); 152 | firstrun = false 153 | return ; 154 | } 155 | 156 | g.beginUpdate(); 157 | if(parsed.graph.nodes){ 158 | for(let i = 0; i < parsed.graph.nodes.length; i++) { 159 | 160 | if(parsed.graph.nodes[i].action == "update"){ 161 | let n = renderer.getNode(parsed.graph.nodes[i].name); 162 | 163 | // if undefined then we have an issue and we must add 164 | // the node instead 165 | if(n == undefined) { 166 | g.addNode( 167 | parsed.graph.nodes[i].name, 168 | { 169 | type: parsed.graph.nodes[i].type, 170 | count: parsed.graph.nodes[i].count 171 | } 172 | ); 173 | continue 174 | } 175 | n.size = parsed.graph.nodes[i].count; 176 | } 177 | 178 | if(parsed.graph.nodes[i].action == "add"){ 179 | nnodes++ 180 | g.addNode( 181 | parsed.graph.nodes[i].name, 182 | {type: parsed.graph.nodes[i].type, count: parsed.graph.nodes[i].count} 183 | ); 184 | } 185 | 186 | } 187 | } 188 | 189 | if(parsed.graph.edges){ 190 | for(let i = 0; i < parsed.graph.edges.length; i++){ 191 | if(parsed.graph.edges[i].action == "update"){ 192 | continue; 193 | } 194 | 195 | if(parsed.graph.edges[i].action == "add"){ 196 | nedges++ 197 | g.addLink(parsed.graph.edges[i].source, parsed.graph.edges[i].target); 198 | } 199 | 200 | } 201 | } 202 | g.endUpdate(); 203 | 204 | let lnk; 205 | if(parsed.removeEdges != null){ 206 | g.beginUpdate(); 207 | for( let i = 0; i < parsed.removeEdges.length; i++){ 208 | nedges-- 209 | lnk = g.getLink( 210 | parsed.removeEdges[i].source, 211 | parsed.removeEdges[i].target 212 | ) 213 | g.removeLink(lnk); 214 | } 215 | g.endUpdate(); 216 | } 217 | 218 | if(parsed.removeNodes != null){ 219 | g.beginUpdate(); 220 | for( let i = 0; i < parsed.removeNodes.length; i++){ 221 | nnodes-- 222 | g.removeNode(parsed.removeNodes[i].name); 223 | } 224 | g.endUpdate(); 225 | } 226 | 227 | if(parsed.graph.nodes != null){ 228 | nnodesEl.innerText = nnodes; 229 | } 230 | 231 | if(parsed.graph.edges != null){ 232 | nedgesEl.innerText = nedges; 233 | } 234 | 235 | } 236 | 237 | }); 238 | 239 | function initPlot(){ 240 | let xs = []; 241 | let vals = []; 242 | 243 | let data = [ 244 | xs, 245 | vals, 246 | ]; 247 | 248 | const opts = { 249 | zDate: ts => uPlot.tzDate(new Date(ts * 1e3), tz), 250 | width: window.innerWidth - 40, 251 | height: 120, 252 | title: "", 253 | axes: [ 254 | { 255 | stroke: "#c7d0d9", 256 | // font: `12px 'Roboto'`, 257 | // labelFont: `12px 'Roboto'`, 258 | grid: { 259 | show: false 260 | }, 261 | ticks: { 262 | width: 1 / devicePixelRatio, 263 | stroke: "#2c3235", 264 | } 265 | }, 266 | { 267 | stroke: "#c7d0d9", 268 | // font: `12px 'Roboto'`, 269 | // labelFont: `12px 'Roboto'`, 270 | grid: { 271 | width: 1 / devicePixelRatio, 272 | stroke: "#2c3235", 273 | }, 274 | ticks: { 275 | width: 1 / devicePixelRatio, 276 | stroke: "#2c3235", 277 | } 278 | }, 279 | ], 280 | scales: { 281 | x: { 282 | time: true, 283 | }, 284 | }, 285 | series: [ 286 | { 287 | label: 'Date time' 288 | }, 289 | { 290 | label: 'Tweets', 291 | stroke: "#ff9e00", 292 | fill: "rgba(255,158,0,0.1)", 293 | width: 1/devicePixelRatio, 294 | } 295 | ], 296 | }; 297 | 298 | let u = new uPlot(opts, data, document.getElementById("trend")); 299 | 300 | return u; 301 | } 302 | 303 | function updatePlot(data){ 304 | if(data == undefined) 305 | return ; 306 | 307 | if(data == null){ 308 | return ; 309 | } 310 | 311 | if(Object.keys(data).length < 2){ 312 | return ; 313 | } 314 | 315 | let result = [ 316 | Object.keys(data), 317 | Object.values(data) 318 | ] 319 | 320 | trendPlot.setData(result); 321 | } 322 | 323 | function getSize() { 324 | return { 325 | width: window.innerWidth - 40, 326 | height: 120, 327 | } 328 | } 329 | 330 | window.addEventListener("resize", e => { 331 | trendPlot.setSize(getSize()); 332 | resizeGraph(); 333 | }); 334 | 335 | function resizeGraph(){ 336 | if(graphContainer == null) 337 | return ; 338 | 339 | graphContainer.childNodes[0].style.width = graphContainer.offsetWidth + 'px'; 340 | } 341 | 342 | function sizeNode(n){ 343 | return Math.round(Math.log10(n + 1) * 25); 344 | } 345 | 346 | function uniques(value, index, self) { 347 | return self.indexOf(value) === index; 348 | } -------------------------------------------------------------------------------- /front-end/tailwind.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | .manage-streams{ 6 | cursor: pointer; 7 | } 8 | 9 | #graph{ 10 | width: 100%; 11 | height: 88vh; 12 | } 13 | 14 | canvas:focus { 15 | outline: none; 16 | max-width: 100%; 17 | } 18 | 19 | .u-legend { 20 | visibility: hidden; 21 | height: 0px; 22 | } 23 | 24 | kbd { 25 | background-color: #eee; 26 | border-radius: 3px; 27 | border: 1px solid #b4b4b4; 28 | box-shadow: 0 1px 1px rgba(0, 0, 0, .2), 0 2px 0 0 rgba(255, 255, 255, .7) inset; 29 | color: #333; 30 | display: inline-block; 31 | font-size: .85em; 32 | font-weight: 700; 33 | line-height: 1; 34 | padding: 2px 4px; 35 | white-space: nowrap; 36 | } 37 | 38 | /* Hide scrollbar for Chrome, Safari and Opera */ 39 | #tweets::-webkit-scrollbar { 40 | display: none; 41 | } 42 | 43 | /* Hide scrollbar for IE, Edge and Firefox */ 44 | #tweets { 45 | -ms-overflow-style: none; /* IE and Edge */ 46 | scrollbar-width: none; /* Firefox */ 47 | } 48 | 49 | #hideTweets { 50 | display: none; 51 | cursor: pointer; 52 | } 53 | 54 | #tweetsWrapper::-webkit-scrollbar { 55 | display: none; 56 | } 57 | 58 | /* Hide scrollbar for IE, Edge and Firefox */ 59 | #tweetsWrapper { 60 | -ms-overflow-style: none; /* IE and Edge */ 61 | scrollbar-width: none; /* Firefox */ 62 | } 63 | 64 | .left-18 { 65 | left: 4.9em; 66 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/devOpifex/skeef 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40 7 | github.com/dghubble/go-twitter v0.0.0-20201011215211-4b180d0cc78d 8 | github.com/dghubble/oauth1 v0.7.0 9 | github.com/golangcollege/sessions v1.2.0 10 | github.com/gorilla/websocket v1.4.2 11 | github.com/justinas/alice v1.2.0 12 | github.com/justinas/nosurf v1.1.1 13 | github.com/mattn/go-sqlite3 v1.14.6 14 | golang.org/x/crypto v0.0.0-20210317152858-513c2a44f670 15 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40 h1:y4B3+GPxKlrigF1ha5FFErxK+sr6sWxQovRMzwMhejo= 2 | github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c= 3 | github.com/cenkalti/backoff v2.1.1+incompatible h1:tKJnvO2kl0zmb/jA5UKAt4VoEVw1qxKWjE/Bpp46npY= 4 | github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= 5 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/dghubble/go-twitter v0.0.0-20201011215211-4b180d0cc78d h1:sBKr0A8iQ1qAOozedZ8Aox+Jpv+TeP1Qv7dcQyW8V+M= 8 | github.com/dghubble/go-twitter v0.0.0-20201011215211-4b180d0cc78d/go.mod h1:xfg4uS5LEzOj8PgZV7SQYRHbG7jPUnelEiaAVJxmhJE= 9 | github.com/dghubble/oauth1 v0.7.0 h1:AlpZdbRiJM4XGHIlQ8BuJ/wlpGwFEJNnB4Mc+78tA/w= 10 | github.com/dghubble/oauth1 v0.7.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4Tk1ujxk= 11 | github.com/dghubble/sling v1.3.0 h1:pZHjCJq4zJvc6qVQ5wN1jo5oNZlNE0+8T/h0XeXBUKU= 12 | github.com/dghubble/sling v1.3.0/go.mod h1:XXShWaBWKzNLhu2OxikSNFrlsvowtz4kyRuXUG7oQKY= 13 | github.com/golangcollege/sessions v1.2.0 h1:2aD9jac/N8NC/y+NEoirYMGlYymzS0ZQN6ASudm4P0s= 14 | github.com/golangcollege/sessions v1.2.0/go.mod h1:7iTf/FrZku0hWyjV95lES7abH89WBlyBjPyA1htnuks= 15 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 16 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 17 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 18 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 19 | github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo= 20 | github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA= 21 | github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk= 22 | github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= 23 | github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= 24 | github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 25 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 26 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 27 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 28 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 29 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 30 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 31 | golang.org/x/crypto v0.0.0-20200317142112-1b76d66859c6/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 32 | golang.org/x/crypto v0.0.0-20210317152858-513c2a44f670 h1:gzMM0EjIYiRmJI3+jBdFuoynZlpxa2JQZsolKu09BXo= 33 | golang.org/x/crypto v0.0.0-20210317152858-513c2a44f670/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 34 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 35 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 36 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 37 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 38 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 39 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk= 40 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 41 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 42 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 43 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 44 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 45 | -------------------------------------------------------------------------------- /graph/edges.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | // HasNode Does the node exist in the graph 4 | func (g *Graph) HasEdge(edge Edge) bool { 5 | 6 | for _, e := range g.Edges { 7 | if e.Source == edge.Source && e.Target == edge.Target { 8 | return true 9 | } 10 | } 11 | 12 | return false 13 | } 14 | 15 | // UpdateEdge Update the edge count 16 | func (g *Graph) UpsertEdge(edge *Edge) { 17 | 18 | for index := range g.Edges { 19 | if g.Edges[index].Source == edge.Source && g.Edges[index].Target == edge.Target { 20 | g.Edges[index].Weight++ 21 | edge.Weight = g.Edges[index].Weight 22 | edge.Action = "update" 23 | return 24 | } 25 | } 26 | 27 | edge.Action = "add" 28 | g.Edges = append(g.Edges, *edge) 29 | 30 | } 31 | 32 | // UpsertEdges Variadic for convenience 33 | func (g *Graph) UpsertEdges(edges ...Edge) { 34 | for key := range edges { 35 | g.UpsertEdge(&edges[key]) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /graph/graph.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "github.com/dghubble/go-twitter/twitter" 5 | ) 6 | 7 | // Node defines nodes 8 | type Node struct { 9 | Name string `json:"name"` 10 | Type string `json:"type"` 11 | Count int `json:"count"` 12 | Action string `json:"action"` 13 | } 14 | 15 | // Edge edges 16 | type Edge struct { 17 | Source string `json:"source"` 18 | Target string `json:"target"` 19 | Weight int `json:"weight"` 20 | Action string `json:"action"` 21 | } 22 | 23 | // Graph defines a graph 24 | type Graph struct { 25 | Nodes []Node `json:"nodes"` 26 | Edges []Edge `json:"edges"` 27 | } 28 | 29 | // GetUserNet builds the network of users, where one user 30 | // mentions another 31 | func GetMentionNet(tweet twitter.Tweet, exclusion map[string]bool, minFollowerCount, minFavoriteCount int, onlyVerified bool, maxHashtags, maxMentions int) ([]Node, []Edge) { 32 | 33 | var edges []Edge 34 | var nodes []Node 35 | 36 | for _, m := range tweet.Entities.UserMentions { 37 | 38 | // filters 39 | _, ok := exclusion[tweet.User.ScreenName] 40 | 41 | if ok { 42 | continue 43 | } 44 | 45 | if tweet.User.FollowersCount < minFollowerCount { 46 | continue 47 | } 48 | 49 | if tweet.FavoriteCount < minFavoriteCount { 50 | continue 51 | } 52 | 53 | if onlyVerified && !tweet.User.Verified { 54 | continue 55 | } 56 | 57 | if len(tweet.Entities.Hashtags) > maxHashtags { 58 | continue 59 | } 60 | 61 | if len(tweet.Entities.UserMentions) > maxMentions { 62 | continue 63 | } 64 | 65 | _, ok = exclusion[m.ScreenName] 66 | 67 | if ok { 68 | continue 69 | } 70 | 71 | edge := Edge{tweet.User.ScreenName, m.ScreenName, 1, "add"} 72 | 73 | edges = append(edges, edge) 74 | } 75 | 76 | for _, n := range edges { 77 | src := Node{n.Source, "user", 1, "add"} 78 | tgt := Node{n.Target, "user", 1, "add"} 79 | nodes = append(nodes, src, tgt) 80 | } 81 | 82 | return nodes, edges 83 | } 84 | 85 | // GetHashNet builds the network of users to hashtags they use in tweets 86 | func GetHashtagNet(tweet twitter.Tweet, exclusion map[string]bool, minFollowerCount, minFavoriteCount int, onlyVerified bool, maxHashtags, maxMentions int) ([]Node, []Edge) { 87 | 88 | var edges []Edge 89 | var nodes []Node 90 | 91 | for _, h := range tweet.Entities.Hashtags { 92 | 93 | _, ok := exclusion[tweet.User.ScreenName] 94 | 95 | if ok { 96 | continue 97 | } 98 | 99 | _, ok = exclusion["#"+h.Text] 100 | 101 | if ok { 102 | continue 103 | } 104 | 105 | if tweet.User.FollowersCount <= minFollowerCount { 106 | continue 107 | } 108 | 109 | if tweet.FavoriteCount <= minFavoriteCount { 110 | continue 111 | } 112 | 113 | if onlyVerified && !tweet.User.Verified { 114 | continue 115 | } 116 | 117 | if len(tweet.Entities.Hashtags) >= maxHashtags { 118 | continue 119 | } 120 | 121 | if len(tweet.Entities.UserMentions) >= maxMentions { 122 | continue 123 | } 124 | 125 | edge := Edge{tweet.User.ScreenName, "#" + h.Text, 1, "add"} 126 | edges = append(edges, edge) 127 | } 128 | 129 | for _, e := range edges { 130 | src := Node{e.Source, "user", 1, "add"} 131 | tgt := Node{e.Target, "hashtag", 1, "add"} 132 | 133 | nodes = append(nodes, src, tgt) 134 | } 135 | 136 | return nodes, edges 137 | } 138 | 139 | // GetUserNet builds the network of users, where one user 140 | // mentions another 141 | func GetRetweetNet(tweet twitter.Tweet, exclusion map[string]bool, minFollowerCount, minFavoriteCount int, onlyVerified bool, maxHashtags, maxMentions int) (bool, []Node, Edge) { 142 | 143 | var edge Edge 144 | var nodes []Node 145 | 146 | if tweet.InReplyToScreenName == "" { 147 | return false, nodes, edge 148 | } 149 | 150 | _, ok := exclusion[tweet.InReplyToScreenName] 151 | 152 | if ok { 153 | return false, nodes, edge 154 | } 155 | 156 | _, ok = exclusion[tweet.User.ScreenName] 157 | 158 | if ok { 159 | return false, nodes, edge 160 | } 161 | 162 | if tweet.User.FollowersCount <= minFollowerCount { 163 | return false, nodes, edge 164 | } 165 | 166 | if tweet.FavoriteCount <= minFavoriteCount { 167 | return false, nodes, edge 168 | } 169 | 170 | if onlyVerified && !tweet.User.Verified { 171 | return false, nodes, edge 172 | } 173 | 174 | if len(tweet.Entities.Hashtags) >= maxHashtags { 175 | return false, nodes, edge 176 | } 177 | 178 | if len(tweet.Entities.UserMentions) >= maxMentions { 179 | return false, nodes, edge 180 | } 181 | 182 | edge = Edge{tweet.User.ScreenName, tweet.InReplyToScreenName, 1, "add"} 183 | from := Node{tweet.User.ScreenName, "user", 1, "add"} 184 | to := Node{tweet.InReplyToScreenName, "user", 1, "add"} 185 | 186 | nodes = append(nodes, from, to) 187 | 188 | return true, nodes, edge 189 | } 190 | 191 | // Truncate truncates the graph to ensure we limit the number of 192 | // edges (and subsequently nodes) present on the screen 193 | // at any one time. 194 | func (g *Graph) Truncate(max int) ([]Node, []Edge) { 195 | 196 | var nodesToRemove []Node 197 | var edgesToRemove []Edge 198 | 199 | // no need to truncate we're below the threshold 200 | if len(g.Edges) < max { 201 | return nodesToRemove, edgesToRemove 202 | } 203 | 204 | // keep track of edges that we should remove 205 | // a map is much more efficient 206 | edgesToRemoveMap := make(map[string]bool) 207 | 208 | diff := len(g.Edges) - max 209 | edgesToRemove = g.Edges[0:diff] 210 | 211 | // no edges to remove 212 | if len(edgesToRemove) < 1 { 213 | return nodesToRemove, edgesToRemove 214 | } 215 | 216 | // convert the struct of edges into a map 217 | for _, e := range edgesToRemove { 218 | edgesToRemoveMap[e.Source] = true 219 | edgesToRemoveMap[e.Target] = true 220 | } 221 | 222 | // we only keep those edges 223 | g.Edges = g.Edges[diff:len(g.Edges)] 224 | 225 | // we cannot delete a node that is part 226 | // of an edge that remains on the graph 227 | for _, e := range g.Edges { 228 | edgesToRemoveMap[e.Source] = false 229 | edgesToRemoveMap[e.Target] = false 230 | } 231 | 232 | var nodesKeep []Node 233 | for _, n := range g.Nodes { 234 | ok := edgesToRemoveMap[n.Name] 235 | 236 | if ok { 237 | nodesToRemove = append(nodesToRemove, n) 238 | } else { 239 | nodesKeep = append(nodesKeep, n) 240 | } 241 | } 242 | 243 | g.Nodes = nodesKeep 244 | 245 | return nodesToRemove, edgesToRemove 246 | } 247 | 248 | // GetReplyNet builds an edge that connects the author of a tweet 249 | // with the user the author responds to 250 | func GetReplyNet(tweet twitter.Tweet, exclusion map[string]bool, minFollowerCount, minFavoriteCount int, onlyVerified bool, maxHashtags, maxMentions int) ([]Node, Edge) { 251 | 252 | var edge Edge 253 | var nodes []Node 254 | 255 | // filters 256 | _, ok := exclusion[tweet.InReplyToScreenName] 257 | 258 | if ok { 259 | return nodes, edge 260 | } 261 | 262 | if tweet.User.FollowersCount < minFollowerCount { 263 | return nodes, edge 264 | } 265 | 266 | if tweet.FavoriteCount < minFavoriteCount { 267 | return nodes, edge 268 | } 269 | 270 | if onlyVerified && !tweet.User.Verified { 271 | return nodes, edge 272 | } 273 | 274 | if len(tweet.Entities.Hashtags) > maxHashtags { 275 | return nodes, edge 276 | } 277 | 278 | if len(tweet.Entities.UserMentions) > maxMentions { 279 | return nodes, edge 280 | } 281 | 282 | edge = Edge{tweet.User.ScreenName, tweet.InReplyToScreenName, 1, "add"} 283 | 284 | src := Node{edge.Source, "user", 1, "add"} 285 | tgt := Node{edge.Target, "user", 1, "add"} 286 | nodes = append(nodes, src, tgt) 287 | 288 | return nodes, edge 289 | } 290 | -------------------------------------------------------------------------------- /graph/nodes.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | // HasNode Does the node exist in the graph 4 | func (g *Graph) HasNode(node Node) bool { 5 | 6 | for index := range g.Nodes { 7 | if g.Nodes[index].Name == node.Name { 8 | return true 9 | } 10 | } 11 | 12 | return false 13 | } 14 | 15 | // UpdateNode Update the node count 16 | func (g *Graph) UpsertNode(node *Node) { 17 | 18 | for index := range g.Nodes { 19 | if g.Nodes[index].Name == node.Name { 20 | g.Nodes[index].Count++ 21 | node.Count = g.Nodes[index].Count 22 | node.Action = "update" 23 | return 24 | } 25 | } 26 | 27 | node.Action = "add" 28 | g.Nodes = append(g.Nodes, *node) 29 | 30 | } 31 | 32 | func (g *Graph) UpsertNodes(nodes ...Node) { 33 | for key := range nodes { 34 | g.UpsertNode(&nodes[key]) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "time" 10 | 11 | "github.com/devOpifex/skeef/app" 12 | "github.com/devOpifex/skeef/db" 13 | "github.com/golangcollege/sessions" 14 | 15 | // sqlite3 driver 16 | _ "github.com/mattn/go-sqlite3" 17 | ) 18 | 19 | var session *sessions.Session 20 | var secret = []byte("u46IpCV8y5Vlur8YvODJEhgOY8m9JVE5") 21 | 22 | func main() { 23 | 24 | reset := flag.Bool("reset", false, "Reset the application and redo the first time setup.") 25 | addr := flag.String("addr", ":8080", "Address on which to serve the skeef.") 26 | flag.Parse() 27 | 28 | if *reset { 29 | db.Remove() 30 | return 31 | } 32 | 33 | infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime) 34 | errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime) 35 | 36 | session = sessions.New(secret) 37 | session.Lifetime = 12 * time.Hour 38 | session.SameSite = http.SameSiteStrictMode 39 | 40 | pool := app.NewPool() 41 | 42 | app := &app.Application{ 43 | InfoLog: infoLog, 44 | ErrorLog: errorLog, 45 | Session: session, 46 | Addr: *addr, 47 | Pool: pool, 48 | } 49 | 50 | app.Quit = make(chan struct{}) 51 | 52 | // websocket pool 53 | go app.StartPool() 54 | 55 | firstrun := false 56 | if !db.Exists() { 57 | firstrun = true 58 | err := db.Create() 59 | fmt.Println("Visit the homepage to setup skeef!") 60 | 61 | if err != nil { 62 | errorLog.Fatal("Could not create database") 63 | return 64 | } 65 | 66 | } 67 | 68 | // connect 69 | app.Database.Con = db.Connect() 70 | defer app.Database.Con.Close() 71 | 72 | if firstrun { 73 | err := app.Database.CreateTableUser() 74 | if err != nil { 75 | db.Remove() 76 | errorLog.Fatal("Could not create users table") 77 | return 78 | } 79 | 80 | err = app.Database.CreateTableTwitterApp() 81 | if err != nil { 82 | db.Remove() 83 | errorLog.Fatal("Could not create twitter app table") 84 | return 85 | } 86 | 87 | err = app.Database.CreateTableStreams() 88 | if err != nil { 89 | db.Remove() 90 | errorLog.Fatal("Could not create streams table") 91 | return 92 | } 93 | } 94 | 95 | srv := &http.Server{ 96 | Addr: *addr, 97 | ErrorLog: errorLog, 98 | Handler: app.Handlers(), 99 | } 100 | 101 | infoLog.Printf("Listening on port%s", *addr) 102 | err := srv.ListenAndServe() 103 | log.Fatal(err) 104 | } 105 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | default: npm-install 2 | go build 3 | 4 | all: darwin 5 | GOOS=linux GOARCH=amd64 go build -o bin/skeef *.go 6 | 7 | darwin: windows 8 | GOOS=darwin GOARCH=amd64 go build -o bin/skeef-mac *.go 9 | 10 | windows: npm-install 11 | GOOS=windows GOARCH=amd64 go build -o bin/skeef.exe *.go 12 | 13 | npm-install: css 14 | npm install 15 | 16 | css: build 17 | npm run dev-css 18 | 19 | build: 20 | npm run build 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "skeef", 3 | "version": "1.0.0", 4 | "description": "Copy configuration file.", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev-css": "postcss build front-end/tailwind.css -o app/ui/static/compiled.min.css", 8 | "build-css": "NODE_ENV=production postcss build front-end/tailwind.css -o app/ui/static/compiled.min.css", 9 | "watch-css": "postcss -w build front-end/tailwind.css -o app/ui/static/compiled.min.css", 10 | "build": "esbuild front-end/app.js --bundle --minify --outfile=app/ui/static/app.min.js", 11 | "build-dev": "esbuild front-end/app.js --bundle --outfile=app/ui/static/app.min.js" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/devOpifex/skeef.git" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "GPL2", 20 | "bugs": { 21 | "url": "https://github.com/devOpifex/skeef/issues" 22 | }, 23 | "homepage": "https://github.com/devOpifex/skeef#readme", 24 | "dependencies": { 25 | "esbuild": "^0.8.56", 26 | "ngraph.graph": "^19.1.0", 27 | "ngraph.pixel": "^2.4.1" 28 | }, 29 | "devDependencies": { 30 | "autoprefixer": "^10.2.5", 31 | "cssnano": "^4.1.10", 32 | "postcss": "^8.2.7", 33 | "postcss-cli": "^8.3.1", 34 | "tailwindcss": "^2.0.3" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | cssnano: { 6 | preset: 'default' 7 | } 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /stream/stream.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | type Stream struct { 4 | Name string 5 | Follow string 6 | Track string 7 | Locations string 8 | Exclusion string 9 | MaxEdges int 10 | Active int 11 | Description string 12 | RetweetsNet int 13 | MentionsNet int 14 | HashtagsNet int 15 | ReplyNet int 16 | FilterLevel string 17 | MinFollowerCount int 18 | MinFavoriteCount int 19 | OnlyVerified bool 20 | MaxHashtags int 21 | MaxMentions int 22 | } 23 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: [ 3 | "app/ui/html/*.html", 4 | "app/ui/html/*.js" 5 | ], 6 | darkMode: false, // or 'media' or 'class' 7 | theme: { 8 | extend: { 9 | colors: { 10 | background: "#262b36" 11 | } 12 | }, 13 | }, 14 | variants: { 15 | extend: {}, 16 | }, 17 | plugins: [], 18 | } 19 | --------------------------------------------------------------------------------