added Malcolm
This commit is contained in:
		
							
								
								
									
										523
									
								
								Vagrant/resources/malcolm/name-map-ui/site/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										523
									
								
								Vagrant/resources/malcolm/name-map-ui/site/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,523 @@ | ||||
| <!doctype html> | ||||
|  | ||||
| <!-- Copyright (c) 2021 Battelle Energy Alliance, LLC.  All rights reserved. --> | ||||
|  | ||||
| <!--[if lt IE 7 ]> <html lang="en" class="no-js ie6"> <![endif]--> | ||||
| <!--[if IE 7 ]>    <html lang="en" class="no-js ie7"> <![endif]--> | ||||
| <!--[if IE 8 ]>    <html lang="en" class="no-js ie8"> <![endif]--> | ||||
| <!--[if IE 9 ]>    <html lang="en" class="no-js ie9"> <![endif]--> | ||||
| <!--[if (gt IE 9)|!(IE)]><!--> <html lang="en" class="no-js"> <!--<![endif]--> | ||||
|  | ||||
| <head> | ||||
|   <meta charset="utf-8"> | ||||
|   <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> | ||||
|   <link rel="stylesheet" href="name-map-ui/mapping.css"> | ||||
|   <script src="name-map-ui/jquery.min.js"></script> | ||||
|   <title>Host and Network Segment Name Mapping</title> | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
|  | ||||
|   <div id="container" class="mapping-page"> | ||||
|     <div id="main"> | ||||
|       <div class="c1"> | ||||
|         <img class="center" src="name-map-ui/Malcolm_banner.png" alt=""> | ||||
|         <h1>Host and Network Segment Name Mapping</h1> | ||||
|         <div id="mapping"> | ||||
|           <table> | ||||
|             <thead> | ||||
|               <tr> | ||||
|                 <th class="sort" title="Mapping type (host or network segment)" data-sort="type">Type</th> | ||||
|                 <th class="sort" title="Host address (IP or MAC) or CIDR-formatted IP range" data-sort="address">Address</th> | ||||
|                 <th class="sort" title="Host or network segment name" data-sort="name">Name</th> | ||||
|                 <th class="sort" title="Assign name only if the event contains this tag (optional)" data-sort="tag">Tag</th> | ||||
|                 <th colspan="2"> | ||||
|                   <input type="text" class="search" placeholder="🔎 Search mappings" /> | ||||
|                 </th> | ||||
|               </tr> | ||||
|             </thead> | ||||
|             <tbody class="list"/> | ||||
|             <tfoot> | ||||
|               <tr> | ||||
|                 <td class="type"> | ||||
|                   <input type="hidden" id="id-field" /> | ||||
|                   <select id="type-field"> | ||||
|                     <option value="host">🖳 host</option> | ||||
|                     <option value="segment">🖧 segment</option> | ||||
|                   </select> | ||||
|                 </td> | ||||
|                 <td class="address"> | ||||
|                   <input type="text" id="address-field" placeholder="Address" /> | ||||
|                 </td> | ||||
|                 <td class="name"> | ||||
|                   <input type="text" id="name-field" placeholder="Name" /> | ||||
|                 </td> | ||||
|                 <td class="tag"> | ||||
|                   <input type="text" id="tag-field" placeholder="Tag (optional)" /> | ||||
|                 </td> | ||||
|                 <td class="add" colspan="2"> | ||||
|                   <button title="Create new mapping" class="add-btn" id="add-btn">💾</button> | ||||
|                   <button title="Modify mapping" class="update-btn" id="update-btn">💾</button> | ||||
|                   <button title="Cancel changes" class="cancel-btn" id="cancel-btn">❌</button> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td class="foot"> | ||||
|                   <input type="file" id="import-file" name="import-file" style="display:none;"/> | ||||
|                   <button title="Import mappings from a JSON file" class="import-btn" id="import-btn">📥 Import</button> | ||||
|                 </td> | ||||
|                 <td class="foot"> | ||||
|                   <button title="Download mappings as a JSON file" class="export-btn" id="export-btn">📤 Export</button> | ||||
|                 </td> | ||||
|                 <td class="foot" colspan="2"> | ||||
|                   <button title="Write mappings to Malcolm's net-map.json" class="save-btn" id="save-btn">💾 Save Mappings</button> | ||||
|                   <input type="hidden" value=0 id="save-state"/> | ||||
|                 </td> | ||||
|                 <td class="foot" colspan="2"> | ||||
|                   <button title="Restart log ingestion, parsing and enrichment" class="restart-btn" id="restart-btn">🔁 Restart Logstash</button> | ||||
|                 </td> | ||||
|               </tr> | ||||
|             </tfoot> | ||||
|           </table> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> <!-- end of #container .mapping-page --> | ||||
|  | ||||
|   <script src="name-map-ui/list.min.js"></script> | ||||
|   <script type="text/javascript"> | ||||
|  | ||||
|     // define value names and template for new list items | ||||
|     var options = { | ||||
|       valueNames: [ 'id', 'type', 'address', 'name', 'tag' ], | ||||
|       item: '<tr><td class="id" style="display:none;"><td class="type"></td></td><td class="address"></td><td class="name"></td><td class="tag"></td><td class="update"><button title="Modify this mapping" class="edit-item-btn">📝</button></td><td class="remove"><button title="Remove this entry" class="remove-item-btn">🚫</button></td></tr>' | ||||
|     }; | ||||
|  | ||||
|     // initialize list and other elements | ||||
|     var mappingList = new List('mapping', options); | ||||
|  | ||||
|     var idField = $('#id-field'), | ||||
|         typeField = $('#type-field'), | ||||
|         addressField = $('#address-field'), | ||||
|         nameField = $('#name-field'), | ||||
|         tagField = $('#tag-field'), | ||||
|         addBtn = $('#add-btn'), | ||||
|         updateBtn = $('#update-btn').hide(), | ||||
|         cancelBtn = $('#cancel-btn').hide(), | ||||
|         saveBtn = $('#save-btn'), | ||||
|         saveState = $('#save-state'), | ||||
|         exportBtn = $('#export-btn'), | ||||
|         importBtn = $('#import-btn'), | ||||
|         importFile = $('#import-file'), | ||||
|         restartBtn = $('#restart-btn'), | ||||
|         removeBtns = $('.remove-item-btn'), | ||||
|         editBtns = $('.edit-item-btn'); | ||||
|  | ||||
|     // sets callbacks to the buttons in the list | ||||
|     refreshCallbacks(); | ||||
|  | ||||
|     function focusItem(itemId) { | ||||
|       // scroll back up to item that was updated | ||||
|       // todo: is there a more efficient way to do this? there's got to be with list.js | ||||
|       for (const editBtnKey in editBtns) { | ||||
|         if (editBtnKey && (editBtns[editBtnKey])) { | ||||
|           entry = (editBtns[editBtnKey].closest) ? editBtns[editBtnKey].closest('tr').firstChild : null; | ||||
|           if (entry && entry.firstChild && entry.firstChild.data && (String(entry.firstChild.data) === String(itemId))) { | ||||
|             editBtns[editBtnKey].focus(); | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } // focusItem | ||||
|  | ||||
|     // store a new value from the edit inputs into the list | ||||
|     addBtn.click(function() { | ||||
|       const type = typeField.val(); | ||||
|       const address = addressField.val().trim(); | ||||
|       const name = nameField.val().trim(); | ||||
|       const tag = tagField.val().trim(); | ||||
|       if ((file_types_valid.has(type)) && | ||||
|           (address) && (address.length > 0) && | ||||
|           (name) && (name.length > 0)) { | ||||
|         mappingList.add({ | ||||
|           // todo: better random ID generator (if necessary) | ||||
|           id: Math.floor(Math.random()*110000), | ||||
|           type: type, | ||||
|           address: address, | ||||
|           name: name, | ||||
|           tag: tag | ||||
|         }); | ||||
|         saveState.val(parseInt(saveState.val()) + 1); | ||||
|         clearFields(); | ||||
|         refreshCallbacks(); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     // update an item being edited back into the list | ||||
|     updateBtn.click(function() { | ||||
|       const itemId = idField.val(); | ||||
|       const type = typeField.val(); | ||||
|       const address = addressField.val().trim(); | ||||
|       const name = nameField.val().trim(); | ||||
|       const tag = tagField.val().trim(); | ||||
|       if ((file_types_valid.has(type)) && | ||||
|           (address) && (address.length > 0) && | ||||
|           (name) && (name.length > 0)) { | ||||
|         var item = mappingList.get('id', itemId)[0]; | ||||
|         item.values({ | ||||
|           id: itemId, | ||||
|           type: type, | ||||
|           address: addressField.val(), | ||||
|           name: nameField.val(), | ||||
|           tag: tagField.val() | ||||
|         }); | ||||
|         saveState.val(parseInt(saveState.val()) + 1); | ||||
|         clearFields(); | ||||
|         updateBtn.hide(); | ||||
|         cancelBtn.hide(); | ||||
|         addBtn.show(); | ||||
|         focusItem(itemId); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     // revert edits without updating | ||||
|     cancelBtn.click(function() { | ||||
|       const itemId = idField.val(); | ||||
|       clearFields(); | ||||
|       updateBtn.hide(); | ||||
|       cancelBtn.hide(); | ||||
|       addBtn.show(); | ||||
|       focusItem(itemId); | ||||
|     }); | ||||
|  | ||||
|     function hasJsonStructure(str) { | ||||
|       if (typeof str !== 'string') return false; | ||||
|       try { | ||||
|         const result = JSON.parse(str); | ||||
|         const type = Object.prototype.toString.call(result); | ||||
|         return type === '[object Object]' | ||||
|             || type === '[object Array]'; | ||||
|       } catch (err) { | ||||
|         return false; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     function download(filename, text, contentType="text/plain") { | ||||
|       var element = document.createElement('a'); | ||||
|       element.setAttribute('href', 'data:' + contentType + ';charset=utf-8,' + encodeURIComponent(text)); | ||||
|       element.setAttribute('download', filename); | ||||
|       element.style.display = 'none'; | ||||
|       document.body.appendChild(element); | ||||
|       element.click(); | ||||
|       document.body.removeChild(element); | ||||
|     } | ||||
|  | ||||
|     async function uploadNameMap(jsonStr) { | ||||
|       let formData = new FormData(); | ||||
|       let upBlob = new Blob([jsonStr], { type: 'application/json' }); | ||||
|       formData.append("upfile", upBlob); | ||||
|       try { | ||||
|         let r = await fetch('name-map-ui/upload.php', {method: "POST", body: formData}); | ||||
|       } catch(e) { | ||||
|         console.log('uploadNameMap error: ', e); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     async function restartLogstash() { | ||||
|       let formData = new FormData(); | ||||
|       formData.append("save-state", saveState.val()); | ||||
|       try { | ||||
|         let r = await fetch('name-map-ui/restart-logstash.php', {method: "POST", body: formData}); | ||||
|       } catch(e) { | ||||
|         console.log('restartLogstash error: ', e); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     saveBtn.click(function() { | ||||
|       if ((mappingList) && (confirm("Save name mappings?") === true )) { | ||||
|         // create list of all items (minus the random "id" index field) | ||||
|         let items = mappingList.items.map( | ||||
|           function(item) { return (({ id, ...o }) => o)(item.values()); } | ||||
|         ); | ||||
|         uploadNameMap(JSON.stringify(items, null, 2)); | ||||
|         saveState.val(0); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     exportBtn.click(function() { | ||||
|       if (mappingList) { | ||||
|         // create list of all items (minus the random "id" index field) | ||||
|         let items = mappingList.items.map( | ||||
|           function(item) { return (({ id, ...o }) => o)(item.values()); } | ||||
|         ); | ||||
|         download('net-map.json', JSON.stringify(items, null, 2), "application/json"); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     importFile.change(function (event) { | ||||
|       if ((event) && (event.target) && (event.target.files) && (event.target.files.length > 0)) { | ||||
|         let f = event.target.files[0]; | ||||
|         if (((f.type === 'application/json') || (f.type === 'text/plain')) && (f.size <= 67108864)) { | ||||
|           let reader = new FileReader(); | ||||
|           reader.onload = function(e) { | ||||
|             if (hasJsonStructure(e.target.result)) { | ||||
|               // replace current map list with that from imported file | ||||
|               populateMapList([{fileType: null, | ||||
|                                 fileSrc: file_spec_in_memory, | ||||
|                                 filePath: e.target.result}], -1); | ||||
|  | ||||
|             } else { | ||||
|               alert('Invalid file format: ' + f.name + ', ' + f.type + ', ' + f.size + ' bytes'); | ||||
|             } | ||||
|           }; | ||||
|           reader.readAsText(f); | ||||
|         } else { | ||||
|           alert('Invalid file: ' + f.name + ', ' + f.type + ', ' + f.size + ' bytes'); | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     importBtn.click(function() { | ||||
|       if ((mappingList) && (confirm("Clear and replace current name mappings with those from a file?") === true )) { | ||||
|         importFile.click(); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     restartBtn.click(function() { | ||||
|       let confirmMsg = ''; | ||||
|       if (parseInt(saveState.val()) > 0) { | ||||
|         saveBtn.click(); | ||||
|       } | ||||
|       confirmMsg = confirmMsg.concat("Apply the saved name mappings and restart Logstash?"); | ||||
|       if (confirm(confirmMsg) == true ) { | ||||
|          restartLogstash(); | ||||
|          alert("Logstash is restarting in the background.\nLog ingestion will be resumed in a few minutes."); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     // apply callbacks for the buttons on the list items | ||||
|     function refreshCallbacks() { | ||||
|  | ||||
|       removeBtns = $(removeBtns.selector); | ||||
|       editBtns = $(editBtns.selector); | ||||
|  | ||||
|       // delete item | ||||
|       removeBtns.click(function() { | ||||
|         var itemId = $(this).closest('tr').find('.id').text(); | ||||
|         mappingList.remove('id', itemId); | ||||
|         saveState.val(parseInt(saveState.val()) + 1); | ||||
|       }); | ||||
|  | ||||
|       // populate the edit inputs from the values of row where Edit was clicked | ||||
|       editBtns.click(function() { | ||||
|         var itemId = $(this).closest('tr').find('.id').text(); | ||||
|         var itemValues = mappingList.get('id', itemId)[0].values(); | ||||
|         idField.val(itemValues.id); | ||||
|         typeField.val(itemValues.type); | ||||
|         addressField.val(itemValues.address); | ||||
|         nameField.val(itemValues.name); | ||||
|         tagField.val(itemValues.tag); | ||||
|  | ||||
|         updateBtn.show(); | ||||
|         cancelBtn.show(); | ||||
|         addBtn.hide(); | ||||
|         window.scrollTo(0,document.body.scrollHeight); | ||||
|         // focus and scroll to editing fields | ||||
|         addressField.focus(); | ||||
|         addressField.select(); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     // clear edit inputs | ||||
|     function clearFields() { | ||||
|       typeField.val('host'); | ||||
|       addressField.val(''); | ||||
|       nameField.val(''); | ||||
|       tagField.val(''); | ||||
|     } | ||||
|  | ||||
|     const file_type_txt_cidr  = 'segment'; | ||||
|     const file_type_txt_host  = 'host'; | ||||
|     const file_types_valid    = new Set([file_type_txt_cidr, file_type_txt_host]); | ||||
|  | ||||
|     const file_spec_on_server = 'SERVER'; | ||||
|     const file_spec_in_memory = 'LOCALSTR'; | ||||
|     const file_specs_valid    = new Set([file_spec_on_server, file_spec_in_memory]); | ||||
|  | ||||
|  | ||||
|     // given the text of a mapping file (eg., net-map.json, cidr-map.txt, host-map.txt) | ||||
|     // populate an array containing the mapping entries from that file | ||||
|     function parseMapFileText(fileTxt, mapType=null) { | ||||
|       let result; | ||||
|  | ||||
|       if (hasJsonStructure(fileTxt)) { | ||||
|         // is already JSON text, should be the format we want | ||||
|         result = JSON.parse(fileTxt); | ||||
|  | ||||
|       } else { | ||||
|         // parse the lines from the cidr-map.txt/host-map.txt format | ||||
|         result = []; | ||||
|         const lines = fileTxt.split(/\r?\n/); | ||||
|         for (lineIdx in lines) { | ||||
|           let line = lines[lineIdx]; | ||||
|           if (!line.startsWith("#")) { | ||||
|             const vals = line.split("|"); | ||||
|             const valsLen = vals.length; | ||||
|             if ((valsLen >= 2) && (valsLen < 4)) { | ||||
|               const name = vals[1].trim(); | ||||
|               const tag = (valsLen > 2) ? vals[2].trim() : ""; | ||||
|               const addrs = vals[0].trim().split(","); | ||||
|               for (addrIdx in addrs) { | ||||
|                 result.push({ | ||||
|                   type: mapType, | ||||
|                   address: addrs[addrIdx], | ||||
|                   name: name, | ||||
|                   tag: tag | ||||
|                 }); | ||||
|               } | ||||
|             } // line has the right number of delimited fields | ||||
|           } // line is not a # comment | ||||
|         } // for (lineIdx in lines) | ||||
|       } // if/else hasJsonStructure | ||||
|  | ||||
|       if (!Array.isArray(result)) { | ||||
|         result = null; | ||||
|       } | ||||
|       return result; | ||||
|  | ||||
|     } // parseMapFileText | ||||
|  | ||||
|  | ||||
|     // given an array of filespecs (eg., | ||||
|     // [{fileType: file_type_txt_cidr, filePath: 'maps/cidr-map.txt'}, | ||||
|     //  {fileType: file_type_txt_host, filePath: 'maps/host-map.txt'}, | ||||
|     //  {fileType: null, filePath: 'maps/net-map.json'}] | ||||
|     // read and return an array of the mappings within those files | ||||
|  | ||||
|     function loadMapsFromFiles(fileSpecs, cb) { | ||||
|  | ||||
|       let result = [] | ||||
|  | ||||
|       if ((fileSpecs.length > 0) && (fileSpecs[0]) && (file_specs_valid.has(fileSpecs[0].fileSrc))) { | ||||
|  | ||||
|         // if this is a delimited file (not JSON) mark the type | ||||
|         let mapType = null; | ||||
|         if (fileSpecs[0].fileType === file_type_txt_cidr) { | ||||
|           mapType = "segment"; | ||||
|         } else if (fileSpecs[0].fileType === file_type_txt_host) { | ||||
|           mapType = "host"; | ||||
|         } | ||||
|         // else the type is per-item in the JSON | ||||
|  | ||||
|         if (fileSpecs[0].fileSrc === file_spec_in_memory) { | ||||
|           // the text of the file is already in memory | ||||
|           result = parseMapFileText(fileSpecs[0].filePath, mapType); | ||||
|           return cb(result); | ||||
|  | ||||
|         } else { | ||||
|           // GET the file from the server | ||||
|           var txtFile = new XMLHttpRequest(); | ||||
|           txtFile.open("GET", fileSpecs[0].filePath, true); | ||||
|           txtFile.send(); | ||||
|           txtFile.onreadystatechange = function() { | ||||
|             if (txtFile.status === 200) { | ||||
|               if (txtFile.readyState === 4) { | ||||
|                 result = parseMapFileText(txtFile.responseText, mapType); | ||||
|                 if (fileSpecs.length > 1) { | ||||
|                   // we have processed this fileSpec, process the next | ||||
|                   loadMapsFromFiles(fileSpecs.slice(1), function(nextFileResult) { | ||||
|                     return cb(result.concat(nextFileResult)); | ||||
|                   }); | ||||
|                 } else { | ||||
|                   // we have processed this fileSpec, and there are no more to process | ||||
|                   return cb(result); | ||||
|                 } | ||||
|               } // txtFile.readyState is ready | ||||
|  | ||||
|             } else if (fileSpecs.length > 1) { | ||||
|               // the GET returned an error, process the next fileSpec | ||||
|               loadMapsFromFiles(fileSpecs.slice(1), function(nextFileResult) { | ||||
|                 return cb(result.concat(nextFileResult)); | ||||
|               }); | ||||
|  | ||||
|             } else { | ||||
|               // the GET returned an error, and there are no more fileSpecs to process | ||||
|               return cb(result); | ||||
|             } | ||||
|  | ||||
|           } // txtFile.onreadystatechange | ||||
|         } | ||||
|  | ||||
|       } else if (fileSpecs.length > 1) { | ||||
|         // the first fileSpec is invalid, process the next | ||||
|         loadMapsFromFiles(fileSpecs.slice(1), function(nextFileResult) { | ||||
|           return cb(result.concat(nextFileResult)); | ||||
|         }); | ||||
|  | ||||
|       } else { | ||||
|         // the first fileSpec is missing or invalid, and there are no more to process | ||||
|         return cb(result); | ||||
|       } | ||||
|  | ||||
|     } // loadMapsFromFiles | ||||
|  | ||||
|     function populateMapList(fileSpecs, newSaveState=0) { | ||||
|       // load old delimited plain text format | ||||
|       //   IP or MAC address to host name map: | ||||
|       //     address|host name|required tag | ||||
|       //   CIDR to network segment format: | ||||
|       //     IP(s)|segment name|required tag | ||||
|       // and JSON-formatted native format: | ||||
|       // [ | ||||
|       //   { | ||||
|       //     "type": "segment", | ||||
|       //     "address": "172.16.0.0/24", | ||||
|       //     "name": "home", | ||||
|       //     "tag": "" | ||||
|       //   }, ... | ||||
|       mappingList.clear(); | ||||
|       loadMapsFromFiles(fileSpecs, function (mapsArray) { | ||||
|  | ||||
|         // convert to a hash to resolve any duplicates | ||||
|         var mapsHash = mapsArray.reduce(function(acc, cur) { | ||||
|           acc[cur.type + '|' + cur.address] = cur; | ||||
|           return acc; | ||||
|         }, {}); | ||||
|  | ||||
|         // populate the list.js List object | ||||
|         for (mapKey in mapsHash) { | ||||
|           let map = mapsHash[mapKey]; | ||||
|           if ((file_types_valid.has(map.type)) && | ||||
|               (map.address) && (map.address.length > 0) && | ||||
|               (map.name) && (map.name.length > 0)) { | ||||
|             mappingList.add({ | ||||
|               // todo: better random ID generator (if necessary) | ||||
|               id: Math.floor(Math.random()*110000), | ||||
|               type: map.type, | ||||
|               address: map.address, | ||||
|               name: map.name, | ||||
|               tag: map.tag ? map.tag : "" | ||||
|             }); | ||||
|           } | ||||
|         } | ||||
|         mappingList.sort('address'); | ||||
|         refreshCallbacks(); | ||||
|         saveState.val((newSaveState >= 0) ? newSaveState : Math.max(Object.keys(mapsHash).length, 1)); | ||||
|       }); | ||||
|     } // populateMapList | ||||
|  | ||||
|     // initial page load from cidr-map.txt, host-map.txt, and/or net-map.json | ||||
|     populateMapList([{fileType: file_type_txt_cidr, | ||||
|                       fileSrc: file_spec_on_server, | ||||
|                       filePath: 'name-map-ui/maps/cidr-map.txt',}, | ||||
|                      {fileType: file_type_txt_host, | ||||
|                       fileSrc: file_spec_on_server, | ||||
|                       filePath: 'name-map-ui/maps/host-map.txt'}, | ||||
|                      {fileType: null, | ||||
|                       fileSrc: file_spec_on_server, | ||||
|                       filePath: 'name-map-ui/maps/net-map.json'}]); | ||||
|  | ||||
|   </script> <!-- end of ListJS-related scripts --> | ||||
|  | ||||
| </body> | ||||
| </html> | ||||
		Reference in New Issue
	
	Block a user