Compare commits
	
		
			13 Commits
		
	
	
		
			4a3c022958
			...
			39a682fd41
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 39a682fd41 | |||
| a0e661eca3 | |||
| db294c171d | |||
| 2c75603046 | |||
| a7b1ba584f | |||
| 89237c2b84 | |||
| 5ade1c0593 | |||
| ec6c0cc45b | |||
| 9f9e0524f5 | |||
| 13f126ccbc | |||
| 80ac8148e7 | |||
| d7f0ba7486 | |||
| 5682777bfe | 
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,3 +1,5 @@ | |||||||
| **/node_modules | **/node_modules | ||||||
| **/dist | **/dist | ||||||
| honeypot/Geoacumen-Country.mmdb | honeypot/Geoacumen-Country.mmdb | ||||||
|  | result | ||||||
|  | frontend/.parcel-cache | ||||||
|   | |||||||
| @@ -153,7 +153,7 @@ func ActionServe(c *cli.Context) error { | |||||||
| 
 | 
 | ||||||
| 	// Start ssh server | 	// Start ssh server | ||||||
| 	go func() { | 	go func() { | ||||||
| 		loggers.rootLogger.Info("Starting SSH server.") | 		loggers.rootLogger.Infow("Starting SSH server.", "addr", cfg.Honeypot.ListenAddr) | ||||||
| 		if err := hs.ListenAndServe(); err != nil && err != sshlib.ErrServerClosed { | 		if err := hs.ListenAndServe(); err != nil && err != sshlib.ErrServerClosed { | ||||||
| 			loggers.rootLogger.Warnw("SSH server returned error.", "error", err) | 			loggers.rootLogger.Warnw("SSH server returned error.", "error", err) | ||||||
| 		} | 		} | ||||||
| @@ -162,7 +162,7 @@ func ActionServe(c *cli.Context) error { | |||||||
| 
 | 
 | ||||||
| 	// Start web server | 	// Start web server | ||||||
| 	go func() { | 	go func() { | ||||||
| 		loggers.rootLogger.Info("Starting web server.") | 		loggers.rootLogger.Infow("Starting web server.", "addr", cfg.Frontend.ListenAddr) | ||||||
| 		if err := web.StartServe(); err != nil && err != http.ErrServerClosed { | 		if err := web.StartServe(); err != nil && err != http.ErrServerClosed { | ||||||
| 			loggers.rootLogger.Warnw("Web server returned error.", "error", err) | 			loggers.rootLogger.Warnw("Web server returned error.", "error", err) | ||||||
| 		} | 		} | ||||||
							
								
								
									
										27
									
								
								flake.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								flake.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | |||||||
|  | { | ||||||
|  |   "nodes": { | ||||||
|  |     "nixpkgs": { | ||||||
|  |       "locked": { | ||||||
|  |         "lastModified": 1741173522, | ||||||
|  |         "narHash": "sha256-k7VSqvv0r1r53nUI/IfPHCppkUAddeXn843YlAC5DR0=", | ||||||
|  |         "owner": "NixOS", | ||||||
|  |         "repo": "nixpkgs", | ||||||
|  |         "rev": "d69ab0d71b22fa1ce3dbeff666e6deb4917db049", | ||||||
|  |         "type": "github" | ||||||
|  |       }, | ||||||
|  |       "original": { | ||||||
|  |         "owner": "NixOS", | ||||||
|  |         "ref": "nixos-unstable", | ||||||
|  |         "repo": "nixpkgs", | ||||||
|  |         "type": "github" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "root": { | ||||||
|  |       "inputs": { | ||||||
|  |         "nixpkgs": "nixpkgs" | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "root": "root", | ||||||
|  |   "version": 7 | ||||||
|  | } | ||||||
							
								
								
									
										123
									
								
								flake.nix
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								flake.nix
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,123 @@ | |||||||
|  | { | ||||||
|  |   description = "SSH honeypot with frontend"; | ||||||
|  |  | ||||||
|  |   inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; | ||||||
|  |  | ||||||
|  |   outputs = | ||||||
|  |     { self, nixpkgs }: | ||||||
|  |     let | ||||||
|  |       allSystems = [ | ||||||
|  |         "x86_64-linux" | ||||||
|  |         "aarch64-linux" | ||||||
|  |         "x86_64-darwin" | ||||||
|  |         "aarch64-darwin" | ||||||
|  |       ]; | ||||||
|  |       forAllSystems = | ||||||
|  |         f: | ||||||
|  |         nixpkgs.lib.genAttrs allSystems ( | ||||||
|  |           system: | ||||||
|  |           f { | ||||||
|  |             pkgs = import nixpkgs { inherit system; }; | ||||||
|  |           } | ||||||
|  |         ); | ||||||
|  |     in | ||||||
|  |     { | ||||||
|  |       overlays.default = final: prev: { | ||||||
|  |         apiary = self.packages.${prev.system}.default; | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       packages = forAllSystems ( | ||||||
|  |         { pkgs }: | ||||||
|  |         { | ||||||
|  |           default = self.packages.${pkgs.system}.apiary; | ||||||
|  |           apiary = | ||||||
|  |             let | ||||||
|  |               src = pkgs.lib.sourceFilesBySuffices ./. [ | ||||||
|  |                 "go.mod" | ||||||
|  |                 "go.sum" | ||||||
|  |                 ".go" | ||||||
|  |               ]; | ||||||
|  |               version = pkgs.lib.strings.removePrefix "v" ( | ||||||
|  |                 builtins.elemAt (pkgs.lib.strings.split "\"" ( | ||||||
|  |                   pkgs.lib.lists.findFirst (x: pkgs.lib.strings.hasInfix "Version" x) null ( | ||||||
|  |                     pkgs.lib.strings.splitString "\n" (builtins.readFile ./version.go) | ||||||
|  |                   ) | ||||||
|  |                 )) 2 | ||||||
|  |               ); | ||||||
|  |               rev = self.rev or ""; | ||||||
|  |               geoDb = pkgs.fetchFromGitHub { | ||||||
|  |                 owner = "geoacumen"; | ||||||
|  |                 repo = "geoacumen-country"; | ||||||
|  |                 rev = "5f770af620465f40427c3f421446a7b7845e2699"; | ||||||
|  |                 sha256 = "sha256-t3ELkhAbJC40z6r6wJeQI4Kutfw26fRg6n7a6FmhvkA="; | ||||||
|  |               }; | ||||||
|  |  | ||||||
|  |               frontend = pkgs.buildNpmPackage { | ||||||
|  |                 inherit version; | ||||||
|  |                 name = "apiary-frontend"; | ||||||
|  |  | ||||||
|  |                 src = ./frontend; | ||||||
|  |  | ||||||
|  |                 npmDepsHash = "sha256-7gjPEiQAMOpteLwHT4AIY9QLpDozpXb7xy0bDiOpDf4="; | ||||||
|  |  | ||||||
|  |                 installPhase = '' | ||||||
|  |                   mkdir -p $out | ||||||
|  |                   cp -r dist/* $out | ||||||
|  |                 ''; | ||||||
|  |               }; | ||||||
|  |             in | ||||||
|  |             pkgs.buildGoModule { | ||||||
|  |               inherit version; | ||||||
|  |               pname = "apiary"; | ||||||
|  |               src = src; | ||||||
|  |               prePatch = '' | ||||||
|  |                 cp ${geoDb}/Geoacumen-Country.mmdb honeypot/ssh | ||||||
|  |                 mkdir -p web/frontend/dist | ||||||
|  |                 cp -r ${frontend}/* web/frontend/dist | ||||||
|  |               ''; | ||||||
|  |               vendorHash = "sha256-griWN9fQ0X2ClPDOzVXV80MpdcEFZfe/WaYm7L7fAc8="; | ||||||
|  |               ldflags = [ "-X git.t-juice.club/torjus/apiary.Build=${rev}" ]; | ||||||
|  |               tags = [ | ||||||
|  |                 "embed" | ||||||
|  |               ]; | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |           tarball = | ||||||
|  |             let | ||||||
|  |               version = self.packages.${pkgs.system}.apiary.version; | ||||||
|  |             in | ||||||
|  |             pkgs.stdenv.mkDerivation { | ||||||
|  |               name = "apiary-tarballs-${version}"; | ||||||
|  |               phases = [ "installPhase" ]; | ||||||
|  |               installPhase = '' | ||||||
|  |                 mkdir -p $out | ||||||
|  |                 mkdir apiary | ||||||
|  |                 cp ${self.packages.${pkgs.system}.apiary}/bin/apiary apiary/apiary-${pkgs.system}-${version} | ||||||
|  |                 tar cvzf $out/apiary-${pkgs.system}-${version}.tar.gz apiary | ||||||
|  |                 pushd apiary | ||||||
|  |                 sha256sum apiary-${pkgs.system}-${version} > apiary-${pkgs.system}-${version}.sha256sum | ||||||
|  |                 popd | ||||||
|  |                 cp apiary/apiary-${pkgs.system}-${version}.sha256sum $out | ||||||
|  |               ''; | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |       ); | ||||||
|  |       devShells = forAllSystems ( | ||||||
|  |         { pkgs }: | ||||||
|  |         { | ||||||
|  |           default = pkgs.mkShell { | ||||||
|  |             packages = with pkgs; [ | ||||||
|  |               go | ||||||
|  |               golangci-lint | ||||||
|  |             ]; | ||||||
|  |           }; | ||||||
|  |           frontend = pkgs.mkShell { | ||||||
|  |             packages = with pkgs; [ | ||||||
|  |               nodejs | ||||||
|  |               yarn | ||||||
|  |             ]; | ||||||
|  |           }; | ||||||
|  |         } | ||||||
|  |       ); | ||||||
|  |     }; | ||||||
|  | } | ||||||
							
								
								
									
										7
									
								
								frontend/jest.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/jest.config.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | /** @type {import('ts-jest').JestConfigWithTsJest} **/ | ||||||
|  | module.exports = { | ||||||
|  |   testEnvironment: "jsdom", | ||||||
|  |   transform: { | ||||||
|  |     "^.+\.tsx?$": ["ts-jest",{}], | ||||||
|  |   }, | ||||||
|  | }; | ||||||
							
								
								
									
										9405
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										9405
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										37
									
								
								frontend/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								frontend/package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | |||||||
|  | { | ||||||
|  |   "name": "apiary-frontend", | ||||||
|  |   "scripts": { | ||||||
|  |     "start": "parcel src/index.html", | ||||||
|  |     "build": "parcel build src/index.html", | ||||||
|  |     "test": "jest" | ||||||
|  |   }, | ||||||
|  |   "devDependencies": { | ||||||
|  |     "@ngneat/falso": "^7.3.0", | ||||||
|  |     "@parcel/transformer-css": "^2.13.3", | ||||||
|  |     "@testing-library/dom": "^10.4.0", | ||||||
|  |     "@testing-library/jest-dom": "^6.6.3", | ||||||
|  |     "@testing-library/react": "^16.2.0", | ||||||
|  |     "@testing-library/user-event": "^14.6.1", | ||||||
|  |     "@types/humanize-duration": "^3.27.4", | ||||||
|  |     "@types/jest": "^29.5.14", | ||||||
|  |     "@types/node": "^22.13.9", | ||||||
|  |     "@types/react": "^19.0.10", | ||||||
|  |     "@types/react-dom": "^19.0.4", | ||||||
|  |     "jest": "^29.7.0", | ||||||
|  |     "jest-environment-jsdom": "^29.7.0", | ||||||
|  |     "parcel": "^2.13.3", | ||||||
|  |     "process": "^0.11.10", | ||||||
|  |     "svgo": "^3.3.2", | ||||||
|  |     "ts-jest": "^29.2.6", | ||||||
|  |     "ts-loader": "^9.5.2", | ||||||
|  |     "typescript": "^5.8.2" | ||||||
|  |   }, | ||||||
|  |   "dependencies": { | ||||||
|  |     "chart.js": "^4.4.8", | ||||||
|  |     "humanize-duration": "^3.32.1", | ||||||
|  |     "react": "^19.0.0", | ||||||
|  |     "react-chartjs-2": "^5.3.0", | ||||||
|  |     "react-dom": "^19.0.0", | ||||||
|  |     "react-router": "^7.2.0" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										77
									
								
								frontend/src/assets/apiary.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								frontend/src/assets/apiary.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | |||||||
|  | <?xml version="1.0" encoding="iso-8859-1"?> | ||||||
|  | <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> | ||||||
|  | <svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"  | ||||||
|  | 	 viewBox="0 0 511.573 511.573" xml:space="preserve"> | ||||||
|  | <g transform="translate(1 1)"> | ||||||
|  | 	<polygon style="fill:#FFE100;" points="254.787,7.107 84.12,143.64 425.453,143.64 	"/> | ||||||
|  | 	<polygon style="fill:#FFFFFF;" points="84.12,143.64 254.787,7.107 41.453,143.64 	"/> | ||||||
|  | 	<polygon style="fill:#FFA800;" points="254.787,7.107 425.453,143.64 468.12,143.64 	"/> | ||||||
|  | 	<g> | ||||||
|  | 		<polygon style="fill:#FFE100;" points="41.453,502.04 92.653,502.04 92.653,450.84 41.453,450.84 		"/> | ||||||
|  | 		<polygon style="fill:#FFE100;" points="416.92,502.04 468.12,502.04 468.12,450.84 416.92,450.84 		"/> | ||||||
|  | 		<polygon style="fill:#FFE100;" points="41.453,450.84 468.12,450.84 468.12,143.64 41.453,143.64 		"/> | ||||||
|  | 	</g> | ||||||
|  | 	<polygon style="fill:#FFA800;" points="442.52,126.573 442.52,502.04 468.12,502.04 468.12,146.2 	"/> | ||||||
|  | 	<polygon style="fill:#FFFFFF;" points="41.453,143.64 41.453,502.04 67.053,502.04 67.053,126.573 	"/> | ||||||
|  | 	<path style="fill:#63D3FD;" d="M314.52,450.84H195.053V341.613c0-15.36,12.8-27.307,27.307-27.307h64 | ||||||
|  | 		c15.36,0,27.307,12.8,27.307,27.307V450.84H314.52z"/> | ||||||
|  | 	<path style="fill:#3DB9F9;" d="M287.213,314.307h-25.6c15.36,0,27.307,12.8,27.307,27.307V450.84h25.6V341.613 | ||||||
|  | 		C314.52,327.107,302.573,314.307,287.213,314.307"/> | ||||||
|  | 	<path d="M468.12,459.373H41.453c-5.12,0-8.533-3.413-8.533-8.533v-307.2c0-2.56,1.707-5.973,4.267-6.827L250.52,0.28 | ||||||
|  | 		c2.56-1.707,5.973-1.707,9.387,0L473.24,136.813c2.56,1.707,4.267,4.267,4.267,6.827v307.2 | ||||||
|  | 		C476.653,455.96,473.24,459.373,468.12,459.373z M49.987,442.307h409.6v-294.4l-204.8-130.56l-204.8,130.56V442.307z"/> | ||||||
|  | 	<path d="M92.653,510.573h-51.2c-5.12,0-8.533-3.413-8.533-8.533v-51.2c0-5.12,3.413-8.533,8.533-8.533h51.2 | ||||||
|  | 		c5.12,0,8.533,3.413,8.533,8.533v51.2C101.187,507.16,97.773,510.573,92.653,510.573z M49.987,493.507H84.12v-34.133H49.987 | ||||||
|  | 		V493.507z"/> | ||||||
|  | 	<path d="M468.12,510.573h-51.2c-5.12,0-8.533-3.413-8.533-8.533v-51.2c0-5.12,3.413-8.533,8.533-8.533h51.2 | ||||||
|  | 		c5.12,0,8.533,3.413,8.533,8.533v51.2C476.653,507.16,473.24,510.573,468.12,510.573z M425.453,493.507h34.133v-34.133h-34.133 | ||||||
|  | 		V493.507z"/> | ||||||
|  | 	<path d="M7.32,177.773c-2.56,0-5.12-0.853-6.827-3.413c-2.56-3.413-1.707-9.387,1.707-11.947l34.133-25.6 | ||||||
|  | 		c3.413-2.56,9.387-1.707,11.947,1.707c2.56,3.413,1.707,9.387-1.707,11.947l-34.133,25.6C10.733,176.92,9.027,177.773,7.32,177.773 | ||||||
|  | 		z"/> | ||||||
|  | 	<path d="M502.253,177.773c-1.707,0-3.413-0.853-5.12-1.707L463,150.467c-3.413-2.56-4.267-8.533-1.707-11.947 | ||||||
|  | 		c2.56-3.413,8.533-4.267,11.947-1.707l34.133,25.6c3.413,2.56,4.267,8.533,1.707,11.947 | ||||||
|  | 		C507.373,176.92,504.813,177.773,502.253,177.773z"/> | ||||||
|  | 	<path d="M468.12,254.573H41.453c-5.12,0-8.533-3.413-8.533-8.533v-102.4c0-5.12,3.413-8.533,8.533-8.533H468.12 | ||||||
|  | 		c5.12,0,8.533,3.413,8.533,8.533v102.4C476.653,251.16,473.24,254.573,468.12,254.573z M49.987,237.507h409.6v-85.333h-409.6 | ||||||
|  | 		V237.507z"/> | ||||||
|  | 	<path d="M314.52,459.373H195.053c-5.12,0-8.533-3.413-8.533-8.533V341.613c0-19.627,16.213-35.84,35.84-35.84h64 | ||||||
|  | 		c19.627,0,35.84,16.213,35.84,35.84V450.84C323.053,455.96,319.64,459.373,314.52,459.373z M203.587,442.307h102.4V341.613 | ||||||
|  | 		c0-10.24-8.533-18.773-18.773-18.773h-64c-11.093,0-19.627,8.533-19.627,18.773V442.307z"/> | ||||||
|  | 	<path d="M195.053,459.373h-153.6c-5.12,0-8.533-3.413-8.533-8.533v-102.4c0-5.12,3.413-8.533,8.533-8.533h153.6 | ||||||
|  | 		c5.12,0,8.533,3.413,8.533,8.533v102.4C203.587,455.96,200.173,459.373,195.053,459.373z M49.987,442.307H186.52v-85.333H49.987 | ||||||
|  | 		V442.307z"/> | ||||||
|  | 	<path d="M468.12,459.373h-153.6c-5.12,0-8.533-3.413-8.533-8.533v-102.4c0-5.12,3.413-8.533,8.533-8.533h153.6 | ||||||
|  | 		c5.12,0,8.533,3.413,8.533,8.533v102.4C476.653,455.96,473.24,459.373,468.12,459.373z M323.053,442.307h136.533v-85.333H323.053 | ||||||
|  | 		V442.307z"/> | ||||||
|  | 	<path d="M468.12,356.973h-153.6c-5.12,0-8.533-3.413-8.533-8.533v-6.827c0-10.24-8.533-18.773-18.773-18.773h-64 | ||||||
|  | 		c-11.093,0-19.627,8.533-19.627,18.773v6.827c0,5.12-3.413,8.533-8.533,8.533h-153.6c-5.12,0-8.533-3.413-8.533-8.533v-102.4 | ||||||
|  | 		c0-5.12,3.413-8.533,8.533-8.533H468.12c5.12,0,8.533,3.413,8.533,8.533v102.4C476.653,353.56,473.24,356.973,468.12,356.973z | ||||||
|  | 		 M323.053,339.907h136.533v-85.333h-409.6v85.333H186.52c0.853-18.773,17.067-34.133,35.84-34.133h64 | ||||||
|  | 		C305.987,305.773,322.2,321.133,323.053,339.907z"/> | ||||||
|  | 	<path d="M468.12,288.707h-59.733c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||||
|  | 		c5.12,0,8.533,3.413,8.533,8.533S473.24,288.707,468.12,288.707z"/> | ||||||
|  | 	<path d="M468.12,322.84h-34.133c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h34.133 | ||||||
|  | 		c5.12,0,8.533,3.413,8.533,8.533C476.653,319.427,473.24,322.84,468.12,322.84z"/> | ||||||
|  | 	<path d="M101.187,288.707H41.453c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||||
|  | 		c5.12,0,8.533,3.413,8.533,8.533S106.307,288.707,101.187,288.707z"/> | ||||||
|  | 	<path d="M75.587,322.84H41.453c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h34.133 | ||||||
|  | 		c5.12,0,8.533,3.413,8.533,8.533C84.12,319.427,80.707,322.84,75.587,322.84z"/> | ||||||
|  | 	<path d="M468.12,186.307h-59.733c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||||
|  | 		c5.12,0,8.533,3.413,8.533,8.533C476.653,182.893,473.24,186.307,468.12,186.307z"/> | ||||||
|  | 	<path d="M468.12,220.44h-34.133c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h34.133 | ||||||
|  | 		c5.12,0,8.533,3.413,8.533,8.533C476.653,217.027,473.24,220.44,468.12,220.44z"/> | ||||||
|  | 	<path d="M101.187,186.307H41.453c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||||
|  | 		c5.12,0,8.533,3.413,8.533,8.533C109.72,182.893,106.307,186.307,101.187,186.307z"/> | ||||||
|  | 	<path d="M75.587,220.44H41.453c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h34.133 | ||||||
|  | 		c5.12,0,8.533,3.413,8.533,8.533C84.12,217.027,80.707,220.44,75.587,220.44z"/> | ||||||
|  | 	<path d="M468.12,391.107h-59.733c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||||
|  | 		c5.12,0,8.533,3.413,8.533,8.533C476.653,387.693,473.24,391.107,468.12,391.107z"/> | ||||||
|  | 	<path d="M468.12,425.24h-34.133c-5.12,0-8.533-3.413-8.533-8.533s3.413-8.533,8.533-8.533h34.133c5.12,0,8.533,3.413,8.533,8.533 | ||||||
|  | 		S473.24,425.24,468.12,425.24z"/> | ||||||
|  | 	<path d="M101.187,391.107H41.453c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||||
|  | 		c5.12,0,8.533,3.413,8.533,8.533C109.72,387.693,106.307,391.107,101.187,391.107z"/> | ||||||
|  | 	<path d="M75.587,425.24H41.453c-5.12,0-8.533-3.413-8.533-8.533s3.413-8.533,8.533-8.533h34.133c5.12,0,8.533,3.413,8.533,8.533 | ||||||
|  | 		S80.707,425.24,75.587,425.24z"/> | ||||||
|  | </g> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 6.4 KiB | 
							
								
								
									
										2
									
								
								frontend/src/css/fonts.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								frontend/src/css/fonts.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | |||||||
|  | @import url("https://fonts.googleapis.com/css2?family=Prompt:wght@300;600&display=swap"); | ||||||
|  | @import url('https://fonts.googleapis.com/css2?family=Secular+One&display=swap'); | ||||||
							
								
								
									
										210
									
								
								frontend/src/css/style.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										210
									
								
								frontend/src/css/style.module.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,210 @@ | |||||||
|  | :root { | ||||||
|  |     --main-color-bg: white; | ||||||
|  |     --main-color: black; | ||||||
|  |     /* Menu */ | ||||||
|  |     --menu-color-bg: #212529; | ||||||
|  |     --menu-color-text: hsla(0,0%,100%,.5); | ||||||
|  |     --menu-color-text-hover: white; | ||||||
|  |     --menu-color-title-text: white; | ||||||
|  |  | ||||||
|  |     /* Table */ | ||||||
|  |     --table-color-header-bg: var(--menu-color-bg); | ||||||
|  |     --table-color-header-text: #dddddd; | ||||||
|  |     --table-row-odd: #dddddd; | ||||||
|  |     --table-row-even: #f3f3f3; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .dark_mode { | ||||||
|  |     --main-color-bg: #15141a; | ||||||
|  |     --main-color: white; | ||||||
|  |     /* Menu */ | ||||||
|  |     --menu-color-bg: #35393d; | ||||||
|  |     --menu-color-text: hsla(0,0%,100%,.5); | ||||||
|  |     --menu-color-text-hover: white; | ||||||
|  |     --menu-color-title-text: white; | ||||||
|  |  | ||||||
|  |     /* Table */ | ||||||
|  |     --table-color-header-bg: var(--menu-color-bg); | ||||||
|  |     --table-color-header-text: var(--menu-color-text); | ||||||
|  |     --table-row-odd: #302f36; | ||||||
|  |     --table-row-even: #232229; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #root { | ||||||
|  |     padding: 0; | ||||||
|  |     margin: 0; | ||||||
|  |     height: 100%; | ||||||
|  |     width: 100%; | ||||||
|  | } | ||||||
|  | body { | ||||||
|  |     background-color: var(--main-color-bg); | ||||||
|  |     color: var(--main-color); | ||||||
|  |     margin: 0; | ||||||
|  |     margin-top: 100px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | *, | ||||||
|  | *::before, | ||||||
|  | *::after { | ||||||
|  |     padding: 0; | ||||||
|  |     margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | a { | ||||||
|  |     text-decoration: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | canvas { | ||||||
|  |     width: 100%; | ||||||
|  |     display: block; | ||||||
|  |     padding-left: 0; | ||||||
|  |     padding-right: 0; | ||||||
|  |     margin-left: auto; | ||||||
|  |     margin-right: auto; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | form { | ||||||
|  |     width: 100%; | ||||||
|  | } | ||||||
|  | input[type=text] { | ||||||
|  |     width: 100%; | ||||||
|  |     height: 30px; | ||||||
|  |     box-sizing: border-box; | ||||||
|  |     border: 2px solid var(--menu-color-bg); | ||||||
|  |     border-radius: 5px; | ||||||
|  |     padding-left: 5px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .navbar { | ||||||
|  |     background-color: var(--menu-color-bg); | ||||||
|  |     display: flex; | ||||||
|  |     font-size: 1.2rem; | ||||||
|  |     font-family: "Prompt", sans-serif; | ||||||
|  |     height: 40px; | ||||||
|  |     position: fixed; | ||||||
|  |     top: 0; | ||||||
|  |     width: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .navbar ul { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .root .navbar { | ||||||
|  |     justify-content: space-between; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .navbar ul li { | ||||||
|  |     list-style-type: none; | ||||||
|  |     color: var(--menu-color-text); | ||||||
|  |     padding-left: 10px; | ||||||
|  |     padding-right: 10px; | ||||||
|  |     line-height: 40px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .navbar h2 { | ||||||
|  |     line-height: 40px; | ||||||
|  |     align-items: center; | ||||||
|  |     padding-right: 10px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .navbar a { | ||||||
|  |     height: 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .navbar a:hover { | ||||||
|  |     color: var(--menu-color-text-hover); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .submenu nav { | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: space-between; | ||||||
|  |     line-height: 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .totals { | ||||||
|  |     display: grid; | ||||||
|  |     grid-template-columns: repeat(2, 1fr); | ||||||
|  |     grid-template-rows: 1fr; | ||||||
|  |     grid-column-gap: 0px; | ||||||
|  |     grid-row-gap: 0px; | ||||||
|  |     margin-top: 5px; | ||||||
|  |     margin-bottom: 5px; | ||||||
|  |     font-family: "Secular One", sans-serif; | ||||||
|  |     font-size: 20px; | ||||||
|  |     font-weight: 200; | ||||||
|  | } | ||||||
|  | .totals_key {  | ||||||
|  |     grid-area: 1 / 1 / 2 / 2;  | ||||||
|  |     text-align: right; | ||||||
|  | } | ||||||
|  | .totals_value {  | ||||||
|  |     grid-area: 1 / 2 / 2 / 3; | ||||||
|  |     padding-left: 10px; | ||||||
|  |     text-align: left; | ||||||
|  | } | ||||||
|  | #menu_title { | ||||||
|  |     color: var(--menu-color-text-hover); | ||||||
|  |     font-family: "Secular One", sans-serif; | ||||||
|  |     font-size: 30px; | ||||||
|  |     font-weight: 300; | ||||||
|  | } | ||||||
|  | #menu_logo { | ||||||
|  |     padding: 5px; | ||||||
|  |     content: url("../assets/apiary.svg"); | ||||||
|  |     width: 30px; | ||||||
|  |     height: 30px; | ||||||
|  | } | ||||||
|  | .menu_link { | ||||||
|  |     color: var(--menu-color-text); | ||||||
|  | } | ||||||
|  | .menu_link:hover { | ||||||
|  |     color: var(--menu-color-text-hover); | ||||||
|  | } | ||||||
|  | .menu_link_active { | ||||||
|  |     color: var(--menu-color-text-hover); | ||||||
|  | } | ||||||
|  | .stats_pie { | ||||||
|  |     max-height: 70vh; | ||||||
|  |     text-align: left; | ||||||
|  |     color: white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .live_table { | ||||||
|  |     border-collapse: collapse; | ||||||
|  |     margin: 25px 0; | ||||||
|  |     font-size: 0.9em; | ||||||
|  |     font-family: sans-serif; | ||||||
|  |     min-width: 400px; | ||||||
|  |     width: 100%; | ||||||
|  |     box-shadow: 0 0 20px rgba(0, 0, 0, 0.15); | ||||||
|  | } | ||||||
|  | .live_table thead tr { | ||||||
|  |     background-color: var(--table-color-header-bg); | ||||||
|  |     color: var(--table-color-header-text); | ||||||
|  |     text-align: left; | ||||||
|  | } | ||||||
|  | .live_table th, | ||||||
|  | .live_table td { | ||||||
|  |     padding: 12px 15px; | ||||||
|  | } | ||||||
|  | .live_table tbody tr { | ||||||
|  |     border-bottom: 1px solid var(--table-row-odd); | ||||||
|  | } | ||||||
|  | .live_table tbody tr:nth-of-type(even) { | ||||||
|  |     background-color: var(--table-row-even); | ||||||
|  | } | ||||||
|  | .live_table tbody tr:last-of-type { | ||||||
|  |     border-bottom: 2px solid var(--table-color-bg); | ||||||
|  | } | ||||||
|  | .live_table tbody tr.active-row { | ||||||
|  |     font-weight: bold; | ||||||
|  |     color: var(--table-color-bg); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .content { | ||||||
|  |     margin-left: 10vw; | ||||||
|  |     width: 50vw; | ||||||
|  |     text-align: center; | ||||||
|  | } | ||||||
							
								
								
									
										12
									
								
								frontend/src/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/src/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | <!doctype html> | ||||||
|  | <html lang="en"> | ||||||
|  |   <head> | ||||||
|  |     <meta charset="utf-8"> | ||||||
|  |     <title></title> | ||||||
|  |     <link rel="stylesheet" href="./css/fonts.css" /> | ||||||
|  |     <script type="module" src="./js/app.tsx"></script> | ||||||
|  |   </head> | ||||||
|  |   <body> | ||||||
|  |     <div id="root"></div> | ||||||
|  |   </body> | ||||||
|  | </html> | ||||||
							
								
								
									
										154
									
								
								frontend/src/js/api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								frontend/src/js/api.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,154 @@ | |||||||
|  | import { randUserName, randRecentDate, randIp, randPassword, randUuid, randNumber } from '@ngneat/falso'; | ||||||
|  | import { r } from 'react-router/dist/development/fog-of-war-Cm1iXIp7'; | ||||||
|  |  | ||||||
|  | export interface LoginAttempt { | ||||||
|  |     readonly date: string; | ||||||
|  |     readonly remoteIP: string; | ||||||
|  |     readonly username: string; | ||||||
|  |     readonly password: string; | ||||||
|  |     readonly sshClientVersion: string; | ||||||
|  |     readonly connectionUUID: string; | ||||||
|  |     readonly country: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface TotalStats { | ||||||
|  |     readonly password: number; | ||||||
|  |     readonly username: number; | ||||||
|  |     readonly ip: number; | ||||||
|  |     readonly country: number; | ||||||
|  |     readonly attempts: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface StatsResult { | ||||||
|  |     name: string; | ||||||
|  |     count: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export type StatsType = 'password' | 'username' | 'ip' | 'country' | 'attempts'; | ||||||
|  |  | ||||||
|  | export interface ApiaryAPI { | ||||||
|  |     live(fn: (a: LoginAttempt) => void): void; | ||||||
|  |     stats(statsType: StatsType, limit: number): Promise<StatsResult[]>; | ||||||
|  |     query(queryType: string, query: string): Promise<LoginAttempt[]>; | ||||||
|  |     totals(): Promise<TotalStats>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function fakeLoginAttempt(): LoginAttempt { | ||||||
|  |     return { | ||||||
|  |         date: randRecentDate({ days: 2 }).toISOString(), | ||||||
|  |         remoteIP: randIp().toString(), | ||||||
|  |         username: randUserName().toString(), | ||||||
|  |         password: randPassword().toString(), | ||||||
|  |         sshClientVersion: '1.0', | ||||||
|  |         connectionUUID: randUuid().toString(), | ||||||
|  |         country: 'NO' | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export class DummyApiaryAPIClient implements ApiaryAPI { | ||||||
|  |     live(fn: (a: LoginAttempt) => void): () => void { | ||||||
|  |         const interval = setInterval(() => { | ||||||
|  |             let a = fakeLoginAttempt(); | ||||||
|  |             fn(a); | ||||||
|  |         }, 1000); | ||||||
|  |  | ||||||
|  |         return () => { clearInterval(interval) } | ||||||
|  |     } | ||||||
|  |     async stats(_type: StatsType, limit: number): Promise<StatsResult[]> { | ||||||
|  |         const stats = Array.from({ length: limit }, () => { | ||||||
|  |             switch (_type) { | ||||||
|  |                 case 'password': | ||||||
|  |                     return { name: randPassword().toString(), count: randNumber().valueOf() } | ||||||
|  |                 case 'username': | ||||||
|  |                     return { name: randUserName().toString(), count: randNumber().valueOf() } | ||||||
|  |                 case 'ip': | ||||||
|  |                     return { name: randIp().toString(), count: randNumber().valueOf() } | ||||||
|  |                 case 'country': | ||||||
|  |                     return { name: 'NO', count: randNumber().valueOf() } | ||||||
|  |             } | ||||||
|  |             return { name: randUserName().toString(), count: randNumber().valueOf() } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         const sorted = stats.sort((a, b) => b.count - a.count) | ||||||
|  |         return Promise.resolve(sorted); | ||||||
|  |     } | ||||||
|  |     async query(_type: string, _query: string): Promise<LoginAttempt[]> { | ||||||
|  |         const attempts = Array.from({ length: 10 }, () => { | ||||||
|  |             return fakeLoginAttempt(); | ||||||
|  |         }) | ||||||
|  |         return Promise.resolve(attempts); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async totals(): Promise<TotalStats> { | ||||||
|  |         return Promise.resolve({ password: 1, username: 1, ip: 1, country: 1, attempts: 1 }) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export class ApiaryAPIClient implements ApiaryAPI { | ||||||
|  |     live(fn: (a: LoginAttempt) => void): () => void { | ||||||
|  |         const es = new EventSource('/api/stream'); | ||||||
|  |         const updateFn = (ev: MessageEvent<string>) => { | ||||||
|  |             const attempt: LoginAttempt = JSON.parse(ev.data); | ||||||
|  |             fn(attempt); | ||||||
|  |         }; | ||||||
|  |         es.addEventListener('message', updateFn) | ||||||
|  |  | ||||||
|  |         return () => { | ||||||
|  |             es.removeEventListener('message', updateFn); | ||||||
|  |             es.close(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     async stats(statsType: StatsType, limit: number): Promise<StatsResult[]> { | ||||||
|  |         const resp = await fetch(`/api/stats?type=${statsType}&limit=${limit}`) | ||||||
|  |         if (!resp.ok) { | ||||||
|  |             throw new Error('Failed to fetch query') | ||||||
|  |         } | ||||||
|  |         const data: StatsResult[] | null = await resp.json() | ||||||
|  |         if (!data) { | ||||||
|  |             return [] | ||||||
|  |         } | ||||||
|  |         return data.sort((a, b) => b.count - a.count) | ||||||
|  |     } | ||||||
|  |     async query(queryType: string, query: string): Promise<LoginAttempt[]> { | ||||||
|  |         const resp = await fetch(`/api/query?type=${queryType}&query=${query}`) | ||||||
|  |         if (!resp.ok) { | ||||||
|  |             throw new Error('Failed to fetch query') | ||||||
|  |         } | ||||||
|  |         const data: LoginAttempt[] = await resp.json() | ||||||
|  |  | ||||||
|  |         return data | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async totals(): Promise<TotalStats> { | ||||||
|  |         let password: number = -1 | ||||||
|  |         let username: number = -1 | ||||||
|  |         let ip: number = -1 | ||||||
|  |         let attempts: number = -1 | ||||||
|  |         let country: number = -1 | ||||||
|  |  | ||||||
|  |         const resp = await fetch('/api/stats?type=total') | ||||||
|  |         const data: Array<StatsResult> = await resp.json() | ||||||
|  |  | ||||||
|  |         for (const stat of data) { | ||||||
|  |             switch (stat.name) { | ||||||
|  |                 case 'UniquePasswords': | ||||||
|  |                     password = stat.count | ||||||
|  |                     break | ||||||
|  |                 case 'UniqueUsernames': | ||||||
|  |                     username = stat.count | ||||||
|  |                     break | ||||||
|  |                 case 'UniqueIPs': | ||||||
|  |                     ip = stat.count | ||||||
|  |                     break | ||||||
|  |                 case 'TotalLoginAttempts': | ||||||
|  |                     attempts = stat.count | ||||||
|  |                     break | ||||||
|  |                 case 'UniqueCountries': | ||||||
|  |                     country = stat.count | ||||||
|  |                     break | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return { password, username, ip, attempts, country } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										467
									
								
								frontend/src/js/app.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										467
									
								
								frontend/src/js/app.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,467 @@ | |||||||
|  | import React from "react"; | ||||||
|  | import { createRoot } from "react-dom/client"; | ||||||
|  | import { useState, useEffect } from 'react'; | ||||||
|  | import { ApiaryAPI, LoginAttempt, DummyApiaryAPIClient, ApiaryAPIClient, TotalStats, StatsResult, StatsType } from "./api"; | ||||||
|  | import { BrowserRouter, NavLink, Routes, Route } from "react-router"; | ||||||
|  | import { Chart as ChartJS, Tooltip, ArcElement, Legend, ChartOptions } from "chart.js"; | ||||||
|  | import { Pie } from "react-chartjs-2"; | ||||||
|  | import humanizeDuration from "humanize-duration"; | ||||||
|  | import * as classes from "../css/style.module.css"; | ||||||
|  |  | ||||||
|  | ChartJS.register(Tooltip, ArcElement, Legend); | ||||||
|  | console.log(classes); | ||||||
|  |  | ||||||
|  | interface AppProps { | ||||||
|  |     api: ApiaryAPI | ||||||
|  | } | ||||||
|  |  | ||||||
|  | let chartColors = [ | ||||||
|  |     "#1abc9c", | ||||||
|  |     "#2ecc71", | ||||||
|  |     "#3498db", | ||||||
|  |     "#9b59b6", | ||||||
|  |     "#34495e", | ||||||
|  |     "#16a085", | ||||||
|  |     "#27ae60", | ||||||
|  |     "#2980b9", | ||||||
|  |     "#8e44ad", | ||||||
|  |     "#2c3e50", | ||||||
|  |     "#f1c40f", | ||||||
|  |     "#e67e22", | ||||||
|  |     "#e74c3c", | ||||||
|  |     "#ecf0f1", | ||||||
|  |     "#95a5a6", | ||||||
|  |     "#f39c12", | ||||||
|  |     "#d35400", | ||||||
|  |     "#c0392b", | ||||||
|  |     "#bdc3c7", | ||||||
|  |     "#7f8c8d", | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | export function App({ api }: AppProps) { | ||||||
|  |     const [mode, setMode] = useState("light"); | ||||||
|  |     const headerProps: HeaderMenuProps = { | ||||||
|  |         title: "apiary.home.2rjus.net", | ||||||
|  |         items: [ | ||||||
|  |             { | ||||||
|  |                 name: "Totals", | ||||||
|  |                 path: "/", | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 name: "Passwords", | ||||||
|  |                 path: "/stats/password", | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 name: "Usernames", | ||||||
|  |                 path: "/stats/username", | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 name: "IPs", | ||||||
|  |                 path: "/stats/ip", | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 name: "Live", | ||||||
|  |                 path: "/live", | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 name: "Query", | ||||||
|  |                 path: "/query", | ||||||
|  |             } | ||||||
|  |         ] | ||||||
|  |     } | ||||||
|  |     const onSelectMode = (mode: string) => { | ||||||
|  |         setMode(mode); | ||||||
|  |  | ||||||
|  |         if (mode === "dark") { | ||||||
|  |             document.body.classList.add(classes.dark_mode); | ||||||
|  |         } else { | ||||||
|  |             document.body.classList.remove(classes.dark_mode); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     useEffect(() => { | ||||||
|  |         window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => onSelectMode(e.matches ? "dark" : "light")); | ||||||
|  |         onSelectMode(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') | ||||||
|  |         return () => { | ||||||
|  |             window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', (e) => onSelectMode(e.matches ? "dark" : "light")); | ||||||
|  |         } | ||||||
|  |     }, []) | ||||||
|  |     return ( | ||||||
|  |         <> | ||||||
|  |             <BrowserRouter> | ||||||
|  |                 <div id="app"> | ||||||
|  |                     <HeaderMenu title={headerProps.title} items={headerProps.items} /> | ||||||
|  |                     <div className={classes.content}> | ||||||
|  |                         <Routes> | ||||||
|  |                             <Route path="/" element={<Home api={api} />} /> | ||||||
|  |                             <Route path="/stats/password" element={<Stats api={api} type="password" />} /> | ||||||
|  |                             <Route path="/stats/username" element={<Stats api={api} type="username" />} /> | ||||||
|  |                             <Route path="/stats/ip" element={<Stats api={api} type="ip" />} /> | ||||||
|  |                             <Route path="/live" element={<Live api={api} />} /> | ||||||
|  |                             <Route path="/query" element={<Query api={api} />} /> | ||||||
|  |                         </Routes> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </BrowserRouter > | ||||||
|  |         </> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface StatsProps { | ||||||
|  |     api: ApiaryAPI | ||||||
|  |     type: StatsType | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function Stats({ api, type }: StatsProps) { | ||||||
|  |     const [stats, setStats] = useState<StatsResult[] | null>(null) | ||||||
|  |     const [currentType, setCurrentType] = useState(type) | ||||||
|  |  | ||||||
|  |     useEffect(() => { | ||||||
|  |         async function getStats() { | ||||||
|  |             try { | ||||||
|  |                 let newStats = await api.stats(type, 10); | ||||||
|  |                 if (JSON.stringify(newStats) !== JSON.stringify(stats)) { | ||||||
|  |                     setStats(newStats) | ||||||
|  |                 } | ||||||
|  |             } catch (e) { | ||||||
|  |                 console.log("Error getting stats", e) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (currentType !== type) { | ||||||
|  |             setCurrentType(type) | ||||||
|  |             getStats() | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (stats === null) { | ||||||
|  |             getStats() | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const interval = setInterval(() => { | ||||||
|  |             getStats() | ||||||
|  |         }, 60000) | ||||||
|  |  | ||||||
|  |         return () => { | ||||||
|  |             clearInterval(interval); | ||||||
|  |         } | ||||||
|  |     }, [stats, type]) | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |         <> | ||||||
|  |             {(stats != null && stats.length > 0) ? <StatsPie data={stats} /> : <p>Loading...</p>} | ||||||
|  |         </> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface StatsPieProps { | ||||||
|  |     data: StatsResult[] | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function StatsPie({ data }: StatsPieProps) { | ||||||
|  |     const labels = data.map((d) => d.name); | ||||||
|  |     const values = data.map((d) => d.count); | ||||||
|  |     const piedata = { | ||||||
|  |         labels, | ||||||
|  |         datasets: [{ | ||||||
|  |             label: "# of attempts", | ||||||
|  |             data: values, | ||||||
|  |             backgroundColor: chartColors, | ||||||
|  |             borderWidth: 1 | ||||||
|  |         }] | ||||||
|  |     }; | ||||||
|  |     console.log(piedata) | ||||||
|  |     const getTextColor = () => { | ||||||
|  |         return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'white' : 'black' | ||||||
|  |     } | ||||||
|  |     const options: ChartOptions<"pie"> = { | ||||||
|  |         plugins: { | ||||||
|  |             legend: { | ||||||
|  |                 display: true, | ||||||
|  |                 align: "start", | ||||||
|  |                 labels: { | ||||||
|  |                     color: getTextColor(), | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     return ( | ||||||
|  |         <div className={classes.stats_pie}> | ||||||
|  |             <Pie data={piedata} options={options} /> | ||||||
|  |         </div> | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function Home({ api }: AppProps) { | ||||||
|  |     const [totals, setTotals] = useState<TotalStats | null>(null) | ||||||
|  |     useEffect(() => { | ||||||
|  |         async function getTotals() { | ||||||
|  |             let totals = await api.totals(); | ||||||
|  |             setTotals(totals); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!totals) { | ||||||
|  |             getTotals(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const interval = setInterval(() => { | ||||||
|  |             getTotals(); | ||||||
|  |         }, 5000) | ||||||
|  |  | ||||||
|  |         return () => { | ||||||
|  |             clearInterval(interval); | ||||||
|  |         } | ||||||
|  |     }) | ||||||
|  |     return ( | ||||||
|  |         <> | ||||||
|  |             {totals ? <Totals totals={totals} /> : <p>Loading...</p>} | ||||||
|  |         </> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface TotalsProps { | ||||||
|  |     totals: TotalStats | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function Totals({ totals }: TotalsProps) { | ||||||
|  |     return ( | ||||||
|  |         <div className={classes.totals}> | ||||||
|  |             <div className={classes.totals_key}> | ||||||
|  |                 <h2>Unique passwords</h2> | ||||||
|  |                 <h2>Unique username</h2> | ||||||
|  |                 <h2>Unique IPs</h2> | ||||||
|  |                 <h2>Total attempts</h2> | ||||||
|  |             </div> | ||||||
|  |             <div className={classes.totals_value}> | ||||||
|  |                 <h2>{totals.password}</h2> | ||||||
|  |                 <h2>{totals.username}</h2> | ||||||
|  |                 <h2>{totals.ip}</h2> | ||||||
|  |                 <h2>{totals.attempts}</h2> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function Live({ api }: AppProps) { | ||||||
|  |     let list: LoginAttempt[] = []; | ||||||
|  |     let [liveList, setLiveList] = useState(list); | ||||||
|  |  | ||||||
|  |     useEffect(() => { | ||||||
|  |         const cleanup = api.live((a) => { | ||||||
|  |             setLiveList((list) => { | ||||||
|  |                 return [a, ...list]; | ||||||
|  |             }); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         return cleanup | ||||||
|  |  | ||||||
|  |     }, [liveList, api]); | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |         <> | ||||||
|  |             <LiveList list={liveList} /> | ||||||
|  |         </> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface LiveListProps { | ||||||
|  |     list: LoginAttempt[] | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export interface DateTDProps { | ||||||
|  |     date: Date | ||||||
|  |     now: Date | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function DateTD({ date, now }: DateTDProps) { | ||||||
|  |     const [displayDate, setDisplayDate] = useState(date.toLocaleString()); | ||||||
|  |  | ||||||
|  |     useEffect(() => { | ||||||
|  |         if (now.getTime() - date.getTime() < 14400000) { | ||||||
|  |             const newDate = humanizeDuration(now.getTime() - date.getTime(), { largest: 1, round: true }) + " ago"; | ||||||
|  |             if (newDate !== displayDate) { | ||||||
|  |                 setDisplayDate(newDate); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     }, [displayDate, now]) | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |         <td className={classes.live_table_date}>{displayDate}</td> | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function LiveList({ list }: LiveListProps) { | ||||||
|  |     const [now, setNow] = useState(new Date()) | ||||||
|  |  | ||||||
|  |     let items = list.map((a) => { | ||||||
|  |         const attemptDate = new Date(a.date); | ||||||
|  |         const key = `${a.username}-${a.password}-${a.remoteIP}-${a.date}`; | ||||||
|  |         return ( | ||||||
|  |             <tr key={a.date}> | ||||||
|  |                 <DateTD date={attemptDate} now={now} /> | ||||||
|  |                 <td className={classes.live_table_username}>{a.username}</td> | ||||||
|  |                 <td className={classes.live_table_password}>{a.password}</td> | ||||||
|  |                 <td className={classes.live_table_ip}>{a.remoteIP}</td> | ||||||
|  |                 <td className={classes.live_table_country}>{a.country}</td> | ||||||
|  |             </tr> | ||||||
|  |         ) | ||||||
|  |     }) | ||||||
|  |  | ||||||
|  |     useEffect(() => { | ||||||
|  |         const interval = setInterval(() => { | ||||||
|  |             setNow(new Date()) | ||||||
|  |         }, 1000) | ||||||
|  |  | ||||||
|  |         return () => { | ||||||
|  |             clearInterval(interval) | ||||||
|  |         } | ||||||
|  |     }, [now]) | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |         <> | ||||||
|  |             <table className={classes.live_table}> | ||||||
|  |                 <thead> | ||||||
|  |                     <tr> | ||||||
|  |                         <th className={classes.live_table_date}>Date</th> | ||||||
|  |                         <th className={classes.live_table_username}>Username</th> | ||||||
|  |                         <th className={classes.live_table_password}>Password</th> | ||||||
|  |                         <th className={classes.live_table_ip}>IP</th> | ||||||
|  |                         <th className={classes.live_table_country}>Country</th> | ||||||
|  |                     </tr> | ||||||
|  |                 </thead> | ||||||
|  |                 <tbody> | ||||||
|  |                     {items} | ||||||
|  |                 </tbody> | ||||||
|  |             </table> | ||||||
|  |         </> | ||||||
|  |     ) | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export function Query({ api }: AppProps) { | ||||||
|  |     const [liveList, setLiveList] = useState<LoginAttempt[]>([]); | ||||||
|  |     const [queryErr, setQueryErr] = useState<Error | null>(null); | ||||||
|  |  | ||||||
|  |     async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { | ||||||
|  |         event.preventDefault(); | ||||||
|  |         const value = event.currentTarget.query.value; | ||||||
|  |         if (value === "") { | ||||||
|  |             setQueryErr(new Error("Query cannot be empty")); | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |         try { | ||||||
|  |             const results = await api.query("", value) | ||||||
|  |             setQueryErr(null); | ||||||
|  |             setLiveList(results); | ||||||
|  |         } catch (e) { | ||||||
|  |             if (e instanceof Error) { | ||||||
|  |                 setQueryErr(e); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     return ( | ||||||
|  |         <> | ||||||
|  |             <form onSubmit={handleSubmit}> | ||||||
|  |                 <input placeholder="Search..." name="query" type="text" /> | ||||||
|  |             </form> | ||||||
|  |             {queryErr ? <ErrorBox message={queryErr.message} /> : null} | ||||||
|  |             <LiveList list={liveList} /> | ||||||
|  |         </> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface ErrorBoxProps { | ||||||
|  |     message: string | null | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function ErrorBox({ message }: ErrorBoxProps) { | ||||||
|  |     return ( | ||||||
|  |         <div className={classes.error_box}> | ||||||
|  |             <p>Error: {message}</p> | ||||||
|  |         </div> | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function Header() { | ||||||
|  |     return ( | ||||||
|  |         <div className={classes.navbar}> | ||||||
|  |             <h2 id="menu-title">apiary.home.2rjus.net</h2> | ||||||
|  |             <nav className={classes.nav_flex}> | ||||||
|  |                 <ul> | ||||||
|  |                     <li><NavLink to="/" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Home</NavLink></li> | ||||||
|  |                     <li><NavLink to="/stats/password" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Stats</NavLink></li> | ||||||
|  |                     <li><NavLink to="/live" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Live</NavLink></li> | ||||||
|  |                     <li><NavLink to="/query" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Query</NavLink></li> | ||||||
|  |                 </ul> | ||||||
|  |             </nav> | ||||||
|  |         </div > | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface HeaderItem { | ||||||
|  |     name: string | ||||||
|  |     path: string | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface HeaderMenuProps { | ||||||
|  |     title: string | ||||||
|  |     items: Array<HeaderItem> | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function HeaderMenu({ title, items }: HeaderMenuProps) { | ||||||
|  |     const menuItems = items.map((item) => { | ||||||
|  |         return ( | ||||||
|  |             <li key={item.path}> | ||||||
|  |                 <NavLink to={item.path} className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>{item.name}</NavLink> | ||||||
|  |             </li> | ||||||
|  |         ) | ||||||
|  |     }) | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |         <div className={classes.navbar}> | ||||||
|  |             <img id={classes.menu_logo}></img> | ||||||
|  |             <h2 id={classes.menu_title}>{title}</h2> | ||||||
|  |             <nav className={classes.nav_flex}> | ||||||
|  |                 <ul> | ||||||
|  |                     {menuItems} | ||||||
|  |                 </ul> | ||||||
|  |             </nav> | ||||||
|  |         </div> | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface SubMenuProps { | ||||||
|  |     items: Array<{ name: string, active: () => boolean, onClick: () => void }> | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function SubMenu({ items }: SubMenuProps) { | ||||||
|  |     return ( | ||||||
|  |         <nav className={classes.submenu}> | ||||||
|  |             <ul> | ||||||
|  |                 {items.map((item) => { | ||||||
|  |                     return <li> | ||||||
|  |                         <a | ||||||
|  |                             href="#" | ||||||
|  |                             className={item.active() ? classes.sub_menu_active : classes.sub_menu_link} | ||||||
|  |                             onClick={item.onClick}>{item.name}</a> | ||||||
|  |                     </li> | ||||||
|  |                 })} | ||||||
|  |             </ul> | ||||||
|  |         </nav> | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const rootElement = document.getElementById('root'); | ||||||
|  | if (rootElement) { | ||||||
|  |     const root = createRoot(rootElement); | ||||||
|  |     let api: ApiaryAPI; | ||||||
|  |     if (process.env.NODE_ENV === "production") { | ||||||
|  |         api = new ApiaryAPIClient(); | ||||||
|  |     } else { | ||||||
|  |         api = new DummyApiaryAPIClient(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     root.render( | ||||||
|  |         <App api={api} /> | ||||||
|  |     ); | ||||||
|  | } | ||||||
							
								
								
									
										17
									
								
								frontend/src/tests/api.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								frontend/src/tests/api.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | import { DummyApiaryAPIClient, TotalStats } from "../js/api"; | ||||||
|  |  | ||||||
|  | describe("DummyApiaryAPIClient", () => { | ||||||
|  |     const api = new DummyApiaryAPIClient() | ||||||
|  |     test("totals returns expeced value", async () => { | ||||||
|  |         let totals = await api.totals() | ||||||
|  |         const expected: TotalStats = { | ||||||
|  |             password: 1, | ||||||
|  |             username: 1, | ||||||
|  |             ip: 1, | ||||||
|  |             attempts: 1, | ||||||
|  |             country: 1, | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         expect(totals).toEqual(expected) | ||||||
|  |     }) | ||||||
|  | }); | ||||||
							
								
								
									
										16
									
								
								frontend/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								frontend/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "compilerOptions": { | ||||||
|  |     "esModuleInterop": true, | ||||||
|  |     "jsx": "react", | ||||||
|  |     "module": "esnext", | ||||||
|  |     "moduleResolution": "node", | ||||||
|  |     "lib": ["dom", "esnext"], | ||||||
|  |     "strict": true, | ||||||
|  |     "sourceMap": true, | ||||||
|  |     "target": "esnext", | ||||||
|  |     "types": ["node", "jest"] | ||||||
|  |   }, | ||||||
|  |   "exclude": ["node_modules"], | ||||||
|  |   "include": ["src/**/*.ts", "src/**/*.tsx"] | ||||||
|  | } | ||||||
|  |  | ||||||
| @@ -6,7 +6,7 @@ import ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
| var ( | var ( | ||||||
| 	Version = "v0.1.32" | 	Version = "v0.2.1" | ||||||
| 	Build   string | 	Build   string | ||||||
| ) | ) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,3 +0,0 @@ | |||||||
| > 1% |  | ||||||
| last 2 versions |  | ||||||
| not dead |  | ||||||
| @@ -1,7 +0,0 @@ | |||||||
| [*.{js,jsx,ts,tsx,vue}] |  | ||||||
| indent_style = space |  | ||||||
| indent_size = 2 |  | ||||||
| end_of_line = lf |  | ||||||
| trim_trailing_whitespace = true |  | ||||||
| insert_final_newline = true |  | ||||||
| max_line_length = 100 |  | ||||||
| @@ -1,20 +0,0 @@ | |||||||
| module.exports = { |  | ||||||
|   root: true, |  | ||||||
|   env: { |  | ||||||
|     node: true, |  | ||||||
|   }, |  | ||||||
|   extends: [ |  | ||||||
|     'plugin:vue/essential', |  | ||||||
|     '@vue/airbnb', |  | ||||||
|     '@vue/typescript/recommended', |  | ||||||
|     'prettier' |  | ||||||
|   ], |  | ||||||
|   parserOptions: { |  | ||||||
|     ecmaVersion: 2020, |  | ||||||
|   }, |  | ||||||
|   rules: { |  | ||||||
|     'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', |  | ||||||
|     'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', |  | ||||||
|     'implicit-arrow-linebreak': 'warn', |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
							
								
								
									
										23
									
								
								web/frontend/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										23
									
								
								web/frontend/.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,23 +0,0 @@ | |||||||
| .DS_Store |  | ||||||
| node_modules |  | ||||||
| /dist |  | ||||||
|  |  | ||||||
|  |  | ||||||
| # local env files |  | ||||||
| .env.local |  | ||||||
| .env.*.local |  | ||||||
|  |  | ||||||
| # Log files |  | ||||||
| npm-debug.log* |  | ||||||
| yarn-debug.log* |  | ||||||
| yarn-error.log* |  | ||||||
| pnpm-debug.log* |  | ||||||
|  |  | ||||||
| # Editor directories and files |  | ||||||
| .idea |  | ||||||
| .vscode |  | ||||||
| *.suo |  | ||||||
| *.ntvs* |  | ||||||
| *.njsproj |  | ||||||
| *.sln |  | ||||||
| *.sw? |  | ||||||
| @@ -1,24 +0,0 @@ | |||||||
| # vue-frontend |  | ||||||
|  |  | ||||||
| ## Project setup |  | ||||||
| ``` |  | ||||||
| yarn install |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| ### Compiles and hot-reloads for development |  | ||||||
| ``` |  | ||||||
| yarn serve |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| ### Compiles and minifies for production |  | ||||||
| ``` |  | ||||||
| yarn build |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| ### Lints and fixes files |  | ||||||
| ``` |  | ||||||
| yarn lint |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| ### Customize configuration |  | ||||||
| See [Configuration Reference](https://cli.vuejs.org/config/). |  | ||||||
| @@ -1,5 +0,0 @@ | |||||||
| module.exports = { |  | ||||||
|   presets: [ |  | ||||||
|     '@vue/cli-plugin-babel/preset', |  | ||||||
|   ], |  | ||||||
| }; |  | ||||||
| @@ -1,50 +0,0 @@ | |||||||
| { |  | ||||||
|   "name": "apiary-frontend", |  | ||||||
|   "version": "0.1.0", |  | ||||||
|   "private": true, |  | ||||||
|   "scripts": { |  | ||||||
|     "serve": "vue-cli-service serve", |  | ||||||
|     "build": "vue-cli-service build", |  | ||||||
|     "lint": "vue-cli-service lint" |  | ||||||
|   }, |  | ||||||
|   "dependencies": { |  | ||||||
|     "@fontsource/rubik": "^4.2.2", |  | ||||||
|     "@fontsource/secular-one": "^4.2.2", |  | ||||||
|     "axios": "^0.21.1", |  | ||||||
|     "bootstrap": "^4.6.0", |  | ||||||
|     "bootstrap-vue": "^2.21.2", |  | ||||||
|     "chart.js": "^3.0.2", |  | ||||||
|     "core-js": "^3.6.5", |  | ||||||
|     "d3": "^6.6.2", |  | ||||||
|     "d3-svg-legend": "^2.25.6", |  | ||||||
|     "faker": "^5.5.2", |  | ||||||
|     "randomcolor": "^0.6.2", |  | ||||||
|     "s-ago": "^2.2.0", |  | ||||||
|     "vue": "^2.6.11", |  | ||||||
|     "vue-class-component": "^7.2.3", |  | ||||||
|     "vue-property-decorator": "^9.1.2", |  | ||||||
|     "vue-router": "^3.5.1" |  | ||||||
|   }, |  | ||||||
|   "devDependencies": { |  | ||||||
|     "@types/chart.js": "^2.9.32", |  | ||||||
|     "@types/d3": "^6.3.0", |  | ||||||
|     "@types/faker": "^5.5.1", |  | ||||||
|     "@types/randomcolor": "^0.5.5", |  | ||||||
|     "@typescript-eslint/eslint-plugin": "^4.18.0", |  | ||||||
|     "@typescript-eslint/parser": "^4.18.0", |  | ||||||
|     "@vue/cli-plugin-babel": "~4.5.0", |  | ||||||
|     "@vue/cli-plugin-eslint": "~4.5.0", |  | ||||||
|     "@vue/cli-plugin-typescript": "~4.5.0", |  | ||||||
|     "@vue/cli-service": "~4.5.0", |  | ||||||
|     "@vue/eslint-config-airbnb": "^5.0.2", |  | ||||||
|     "@vue/eslint-config-typescript": "^7.0.0", |  | ||||||
|     "eslint": "^6.7.2", |  | ||||||
|     "eslint-config-prettier": "^8.1.0", |  | ||||||
|     "eslint-plugin-import": "^2.20.2", |  | ||||||
|     "eslint-plugin-vue": "^6.2.2", |  | ||||||
|     "sass": "^1.26.5", |  | ||||||
|     "sass-loader": "^8.0.2", |  | ||||||
|     "typescript": "~4.1.5", |  | ||||||
|     "vue-template-compiler": "^2.6.11" |  | ||||||
|   } |  | ||||||
| } |  | ||||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 15 KiB | 
| @@ -1,22 +0,0 @@ | |||||||
| <!DOCTYPE html> |  | ||||||
| <html lang="en"> |  | ||||||
|  |  | ||||||
| <head> |  | ||||||
|   <meta charset="utf-8"> |  | ||||||
|   <meta http-equiv="X-UA-Compatible" content="IE=edge"> |  | ||||||
|   <meta name="description" content="Stats about logins, usernames and passwords received by the SSH honeypot."> |  | ||||||
|   <meta name="viewport" content="width=device-width,initial-scale=1.0"> |  | ||||||
|   <link rel="icon" href="<%= BASE_URL %>favicon.ico"> |  | ||||||
|   <title>apiary.t-juice.club</title> |  | ||||||
| </head> |  | ||||||
|  |  | ||||||
| <body> |  | ||||||
|   <noscript> |  | ||||||
|     <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. |  | ||||||
|         Please enable it to continue.</strong> |  | ||||||
|   </noscript> |  | ||||||
|   <div id="app"></div> |  | ||||||
|   <!-- built files will be auto injected --> |  | ||||||
| </body> |  | ||||||
|  |  | ||||||
| </html> |  | ||||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 13 KiB | 
| @@ -1,3 +0,0 @@ | |||||||
| # https://www.robotstxt.org/robotstxt.html |  | ||||||
| User-agent: * |  | ||||||
| Disallow: |  | ||||||
| @@ -1,75 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div id="app"> |  | ||||||
|     <div id="apiary-navbar"> |  | ||||||
|       <b-navbar toggleable="md" type="dark" variant="dark"> |  | ||||||
|         <b-navbar-brand href="#"> |  | ||||||
|           <img |  | ||||||
|             alt="logo" |  | ||||||
|             src="logo192.png" |  | ||||||
|             width="29" |  | ||||||
|             height="29" |  | ||||||
|             class="d-inline-block align-top" |  | ||||||
|           /> |  | ||||||
|           apiary.t-juice.club |  | ||||||
|         </b-navbar-brand> |  | ||||||
|         <b-navbar-toggle target="nav-collapse"></b-navbar-toggle> |  | ||||||
|         <b-collapse id="nav-collapse" is-nav> |  | ||||||
|           <b-navbar-nav class="mr-auto"> |  | ||||||
|             <b-nav-item :to="'/'">Home</b-nav-item> |  | ||||||
|             <b-nav-item :to="'/stats'">Stats</b-nav-item> |  | ||||||
|             <b-nav-item :to="'/attempts'">Attempts</b-nav-item> |  | ||||||
|             <b-nav-item :to="'/search'">Search</b-nav-item> |  | ||||||
|           </b-navbar-nav> |  | ||||||
|         </b-collapse> |  | ||||||
|       </b-navbar> |  | ||||||
|     </div> |  | ||||||
|     <b-container id="main-content-container" fluid="md"> |  | ||||||
|       <router-view></router-view> |  | ||||||
|     </b-container> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script lang="ts"> |  | ||||||
| import { Component, Vue } from 'vue-property-decorator'; |  | ||||||
| import AttemptList from '@/components/AttemptList.vue'; |  | ||||||
| import Stats from '@/components/Stats.vue'; |  | ||||||
| import Home from '@/components/Home.vue'; |  | ||||||
|  |  | ||||||
| @Component({ |  | ||||||
|   components: { |  | ||||||
|     AttemptList, |  | ||||||
|     Stats, |  | ||||||
|     Home, |  | ||||||
|   }, |  | ||||||
| }) |  | ||||||
| export default class App extends Vue {} |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <style lang="scss"> |  | ||||||
| $font-stack-header: 'Secular One', sans-serif; |  | ||||||
| $font-stack-content: 'Rubik', sans-serif; |  | ||||||
| #app { |  | ||||||
|   font-family: $font-stack-content; |  | ||||||
|   -webkit-font-smoothing: antialiased; |  | ||||||
|   -moz-osx-font-smoothing: grayscale; |  | ||||||
|   text-align: center; |  | ||||||
|   color: #2c3e50; |  | ||||||
|   margin-top: 0; |  | ||||||
| } |  | ||||||
| .navbar-brand { |  | ||||||
|   font-family: $font-stack-header; |  | ||||||
| } |  | ||||||
| .navbar-nav a { |  | ||||||
|   font-family: $font-stack-content; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| h1 h2 h3 { |  | ||||||
|   font-family: $font-stack-header; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #main-content-container { |  | ||||||
|   margin-top: 50px; |  | ||||||
|   text-align: center; |  | ||||||
|   display: inline-block; |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,8 +0,0 @@ | |||||||
| import { ApiaryAPI, LoginAttempt } from "@/apiary/apiary"; |  | ||||||
|  |  | ||||||
|  |  | ||||||
| export class ApiaryDummyClient implements ApiaryAPI { |  | ||||||
|     streamLoginAttempts(): any { |  | ||||||
|         return 'a'; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,35 +0,0 @@ | |||||||
| import FakerStatic from 'faker'; |  | ||||||
|  |  | ||||||
| export interface LoginAttempt { |  | ||||||
|   readonly date: string; |  | ||||||
|   readonly remoteIP: string; |  | ||||||
|   readonly username: string; |  | ||||||
|   readonly password: string; |  | ||||||
|   readonly sshClientVersion: string; |  | ||||||
|   readonly connectionUUID: string; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export interface StatResult { |  | ||||||
|   name: string; |  | ||||||
|   count: number; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export interface ApiaryAPI { |  | ||||||
|   streamLoginAttempts(): ReadableStream<LoginAttempt>; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export function fakeAttempt(): LoginAttempt { |  | ||||||
|   return { |  | ||||||
|     date: FakerStatic.date.recent().toLocaleDateString(), |  | ||||||
|     remoteIP: FakerStatic.internet.ip(), |  | ||||||
|     username: FakerStatic.internet.userName(), |  | ||||||
|     password: FakerStatic.internet.password(), |  | ||||||
|     sshClientVersion: FakerStatic.lorem.words(2), |  | ||||||
|     connectionUUID: FakerStatic.datatype.uuid(), |  | ||||||
|   }; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export function fakeAttemptStream(): EventSource { |  | ||||||
|   const es = new EventSource('/stream'); |  | ||||||
|   return es; |  | ||||||
| } |  | ||||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 6.7 KiB | 
| @@ -1,79 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div class="attemptlist"> |  | ||||||
|     <h1>Live Attempts</h1> |  | ||||||
|     <p> |  | ||||||
|       <b-table |  | ||||||
|         responsive="md" |  | ||||||
|         striped |  | ||||||
|         hover |  | ||||||
|         :items="attempts" |  | ||||||
|         :fields="fields" |  | ||||||
|       /> |  | ||||||
|     </p> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script lang="ts"> |  | ||||||
| import { Component, Prop, Vue } from 'vue-property-decorator'; |  | ||||||
| import { BvTableFieldArray, BvTableFormatterCallback } from 'bootstrap-vue'; |  | ||||||
| import { LoginAttempt } from '@/apiary/apiary'; |  | ||||||
|  |  | ||||||
| @Component |  | ||||||
| export default class AttemptList extends Vue { |  | ||||||
|   @Prop() private items!: LoginAttempt[]; |  | ||||||
|  |  | ||||||
|   attempts: LoginAttempt[]; |  | ||||||
|  |  | ||||||
|   fields: BvTableFieldArray = [ |  | ||||||
|     { |  | ||||||
|       key: 'date', |  | ||||||
|       sortable: true, |  | ||||||
|       formatter: (value: string): string => { |  | ||||||
|         const d = new Date(value); |  | ||||||
|         // This seems stupid... |  | ||||||
|         return d.toTimeString().split(' ')[0]; |  | ||||||
|       }, |  | ||||||
|       sortByFormatted: false, |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       key: 'username', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       key: 'password', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       key: 'remoteIP', |  | ||||||
|       sortable: true, |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       key: 'country', |  | ||||||
|     }, |  | ||||||
|   ]; |  | ||||||
|  |  | ||||||
|   constructor() { |  | ||||||
|     super(); |  | ||||||
|     this.attempts = []; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   mounted(): void { |  | ||||||
|     /** |  | ||||||
|     console.log(this); |  | ||||||
|     const at: LoginAttempt[] = []; |  | ||||||
|  |  | ||||||
|     for (let i = 0; i < 5; i += 1) { |  | ||||||
|       at.push(fakeAttempt()); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     setInterval(() => { |  | ||||||
|       at.push(fakeAttempt()); |  | ||||||
|     }, 1000); |  | ||||||
|     */ |  | ||||||
|  |  | ||||||
|     const attemptStream = new EventSource('/api/stream'); |  | ||||||
|     attemptStream.addEventListener('message', (ev: MessageEvent<string>) => { |  | ||||||
|       const parsed: LoginAttempt = JSON.parse(ev.data); |  | ||||||
|       this.attempts.unshift(parsed); |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </script> |  | ||||||
| @@ -1,145 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div class="home"> |  | ||||||
|     <b-container v-if="ready"> |  | ||||||
|       <b-row> |  | ||||||
|         <b-col class="total-title"> |  | ||||||
|           <h2>Total login attempts</h2> |  | ||||||
|         </b-col> |  | ||||||
|         <b-col class="total-count"> |  | ||||||
|           <h2>{{ totalLoginAttempts }}</h2> |  | ||||||
|         </b-col> |  | ||||||
|       </b-row> |  | ||||||
|       <b-row> |  | ||||||
|         <b-col class="total-title"> |  | ||||||
|           <h2>Unique passwords</h2> |  | ||||||
|         </b-col> |  | ||||||
|         <b-col class="total-count"> |  | ||||||
|           <h2>{{ uniquePasswords }}</h2> |  | ||||||
|         </b-col> |  | ||||||
|       </b-row> |  | ||||||
|       <b-row> |  | ||||||
|         <b-col class="total-title"> |  | ||||||
|           <h2>Unique usernames</h2> |  | ||||||
|         </b-col> |  | ||||||
|         <b-col class="total-count"> |  | ||||||
|           <h2>{{ uniqueUsernames }}</h2> |  | ||||||
|         </b-col> |  | ||||||
|       </b-row> |  | ||||||
|       <b-row> |  | ||||||
|         <b-col class="total-title"> |  | ||||||
|           <h2>Unique IPs</h2> |  | ||||||
|         </b-col> |  | ||||||
|         <b-col class="total-count"> |  | ||||||
|           <h2>{{ uniqueIPs }}</h2> |  | ||||||
|         </b-col> |  | ||||||
|       </b-row> |  | ||||||
|     </b-container> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script lang="ts"> |  | ||||||
| import { Component, Vue, Watch } from 'vue-property-decorator'; |  | ||||||
| import axios from 'axios'; |  | ||||||
|  |  | ||||||
| type TotalStatsHeaders = |  | ||||||
|   | 'UniquePasswords' |  | ||||||
|   | 'UniqueUsernames' |  | ||||||
|   | 'UniqueIPs' |  | ||||||
|   | 'UniqueCountries' |  | ||||||
|   | 'TotalLoginAttempts'; |  | ||||||
|  |  | ||||||
| interface TotalStatsResult { |  | ||||||
|   name: TotalStatsHeaders; |  | ||||||
|   count: number; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @Component |  | ||||||
| export default class Home extends Vue { |  | ||||||
|   totalLoginAttempts: number; |  | ||||||
|  |  | ||||||
|   uniquePasswords: number; |  | ||||||
|  |  | ||||||
|   uniqueUsernames: number; |  | ||||||
|  |  | ||||||
|   uniqueIPs: number; |  | ||||||
|  |  | ||||||
|   uniqueCountries: number; |  | ||||||
|  |  | ||||||
|   private updaterHandle?: number; |  | ||||||
|  |  | ||||||
|   ready = false; |  | ||||||
|  |  | ||||||
|   constructor() { |  | ||||||
|     super(); |  | ||||||
|     this.totalLoginAttempts = 0; |  | ||||||
|     this.uniquePasswords = 0; |  | ||||||
|     this.uniqueUsernames = 0; |  | ||||||
|     this.uniqueIPs = 0; |  | ||||||
|     this.uniqueCountries = 0; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   updateStats(): void { |  | ||||||
|     const url = `/api/stats?type=total`; |  | ||||||
|     axios |  | ||||||
|       .get<TotalStatsResult[]>(url) |  | ||||||
|       .then((resp) => { |  | ||||||
|         const totals = resp.data.find((e) => e.name === 'TotalLoginAttempts') |  | ||||||
|           ?.count; |  | ||||||
|         if (totals) { |  | ||||||
|           this.totalLoginAttempts = totals; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         const passwords = resp.data.find((e) => e.name === 'UniquePasswords') |  | ||||||
|           ?.count; |  | ||||||
|         if (passwords) { |  | ||||||
|           this.uniquePasswords = passwords; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         const usernames = resp.data.find((e) => e.name === 'UniqueUsernames') |  | ||||||
|           ?.count; |  | ||||||
|         if (usernames) { |  | ||||||
|           this.uniqueUsernames = usernames; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         const ips = resp.data.find((e) => e.name === 'UniqueIPs')?.count; |  | ||||||
|         if (ips) { |  | ||||||
|           this.uniqueIPs = ips; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         const countries = resp.data.find((e) => e.name === 'UniqueCountries') |  | ||||||
|           ?.count; |  | ||||||
|         if (countries) { |  | ||||||
|           this.uniqueCountries = countries; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         this.ready = true; |  | ||||||
|       }) |  | ||||||
|       .catch((e) => { |  | ||||||
|         console.log(e); |  | ||||||
|       }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   created(): void { |  | ||||||
|     this.updateStats(); |  | ||||||
|  |  | ||||||
|     this.updaterHandle = setInterval(() => { |  | ||||||
|       this.updateStats(); |  | ||||||
|     }, 5000); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   beforeDestroy(): void { |  | ||||||
|     clearInterval(this.updaterHandle); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </script> |  | ||||||
| <style scoped lang="scss"> |  | ||||||
| .home { |  | ||||||
|   text-align: left; |  | ||||||
| } |  | ||||||
| .total-title { |  | ||||||
|   text-align: right; |  | ||||||
| } |  | ||||||
| .total-count { |  | ||||||
|   text-align: left; |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,81 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div class="search-result-container"> |  | ||||||
|     <h1>Search</h1> |  | ||||||
|     <b-form @submit="onSubmit"> |  | ||||||
|       <b-form-input v-model="searchString" placeholder="" /> |  | ||||||
|     </b-form> |  | ||||||
|     <b-table |  | ||||||
|       class="search-results-table" |  | ||||||
|       responsive="md" |  | ||||||
|       striped |  | ||||||
|       hover |  | ||||||
|       :items="attempts" |  | ||||||
|       :fields="fields" |  | ||||||
|     /> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script lang="ts"> |  | ||||||
| import { Component, Vue } from 'vue-property-decorator'; |  | ||||||
| import { LoginAttempt } from '@/apiary/apiary'; |  | ||||||
| import { BvTableFieldArray, BvTableFormatterCallback } from 'bootstrap-vue'; |  | ||||||
| import Axios from 'axios'; |  | ||||||
|  |  | ||||||
| @Component |  | ||||||
| export default class SearchResult extends Vue { |  | ||||||
|   attempts: LoginAttempt[]; |  | ||||||
|  |  | ||||||
|   searchString: string; |  | ||||||
|  |  | ||||||
|   constructor() { |  | ||||||
|     super(); |  | ||||||
|     this.attempts = []; |  | ||||||
|     this.searchString = ''; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   fields: BvTableFieldArray = [ |  | ||||||
|     { |  | ||||||
|       key: 'date', |  | ||||||
|       sortable: true, |  | ||||||
|       formatter: (value: string): string => { |  | ||||||
|         const d = new Date(value); |  | ||||||
|         // This seems stupid... |  | ||||||
|         return d.toTimeString().split(' ')[0]; |  | ||||||
|       }, |  | ||||||
|       sortByFormatted: false, |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       key: 'username', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       key: 'password', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       key: 'remoteIP', |  | ||||||
|       sortable: true, |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       key: 'country', |  | ||||||
|     }, |  | ||||||
|   ]; |  | ||||||
|  |  | ||||||
|   onSubmit(event: Event) { |  | ||||||
|     event.preventDefault(); |  | ||||||
|  |  | ||||||
|     console.log(this.searchString); |  | ||||||
|     const resp = Axios.get<LoginAttempt[]>( |  | ||||||
|       `/api/query?query=${this.searchString}`, |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     resp.then((r) => { |  | ||||||
|       this.attempts = r.data; |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <style lang="scss" scoped> |  | ||||||
| .search-results-table { |  | ||||||
|   margin-top: 50px; |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,52 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div class="stats"> |  | ||||||
|     <h1>Login Stats</h1> |  | ||||||
|     <b-card no-body> |  | ||||||
|       <b-tabs card> |  | ||||||
|         <b-tab title="Totals"> |  | ||||||
|           <b-card-body> |  | ||||||
|             <home></home> |  | ||||||
|           </b-card-body> |  | ||||||
|         </b-tab> |  | ||||||
|         <b-tab title="Usernames"> |  | ||||||
|           <b-card-body> |  | ||||||
|             <stats-pie statType="username" /> |  | ||||||
|           </b-card-body> |  | ||||||
|         </b-tab> |  | ||||||
|         <b-tab title="Passwords"> |  | ||||||
|           <b-card-body> |  | ||||||
|             <stats-pie statType="password"></stats-pie> |  | ||||||
|           </b-card-body> |  | ||||||
|         </b-tab> |  | ||||||
|         <b-tab title="Countries"> |  | ||||||
|           <b-card-body> |  | ||||||
|             <stats-pie statType="country"></stats-pie> |  | ||||||
|           </b-card-body> |  | ||||||
|         </b-tab> |  | ||||||
|         <b-tab title="IPs"> |  | ||||||
|           <b-card-body> |  | ||||||
|             <stats-pie statType="ip"></stats-pie> |  | ||||||
|           </b-card-body> |  | ||||||
|         </b-tab> |  | ||||||
|       </b-tabs> |  | ||||||
|     </b-card> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script lang="ts"> |  | ||||||
| import { Component, Prop, Vue } from 'vue-property-decorator'; |  | ||||||
| import StatsUsername from '@/components/StatsUsername.vue'; |  | ||||||
| import StatsPie from '@/components/StatsPie.vue'; |  | ||||||
| import Home from '@/components/Home.vue'; |  | ||||||
|  |  | ||||||
| @Component({ |  | ||||||
|   components: { |  | ||||||
|     StatsUsername, |  | ||||||
|     StatsPie, |  | ||||||
|     Home, |  | ||||||
|   }, |  | ||||||
| }) |  | ||||||
| export default class Stats extends Vue {} |  | ||||||
| </script> |  | ||||||
| <style lang="scss" scoped> |  | ||||||
| </style> |  | ||||||
| @@ -1,187 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div class="stats-container" :class="containerClass"> |  | ||||||
|     <h2> |  | ||||||
|       {{ title() }} |  | ||||||
|       <b-icon :id="chartSettingsID()" icon="gear-fill"></b-icon> |  | ||||||
|     </h2> |  | ||||||
|     <div class="chart-container"> |  | ||||||
|       <canvas :id="chartID()" width="300" height="300"></canvas> |  | ||||||
|     </div> |  | ||||||
|     <b-popover |  | ||||||
|       :target="chartSettingsID()" |  | ||||||
|       triggers="click" |  | ||||||
|       :show.sync="settingsShow" |  | ||||||
|       placement="auto" |  | ||||||
|       container="stats-container" |  | ||||||
|       ref="popover" |  | ||||||
|       @show="onShow" |  | ||||||
|     > |  | ||||||
|       <template #title> |  | ||||||
|         <b-button @click="onClose" class="close" aria-label="Close"> |  | ||||||
|           <span class="d-inline-block" aria-hidden="true">×</span> |  | ||||||
|         </b-button> |  | ||||||
|         Settings |  | ||||||
|       </template> |  | ||||||
|  |  | ||||||
|       <div> |  | ||||||
|         <b-form-group |  | ||||||
|           label="Limit" |  | ||||||
|           label-for="limitinput" |  | ||||||
|           label-cols="3" |  | ||||||
|           class="mb-1" |  | ||||||
|           description="Limit number of items" |  | ||||||
|           invalid-feedback="This field is required" |  | ||||||
|         > |  | ||||||
|           <b-form-input |  | ||||||
|             ref="limitinput" |  | ||||||
|             id="limitinput" |  | ||||||
|             v-model="limitState" |  | ||||||
|             type="number" |  | ||||||
|             size="sm" |  | ||||||
|           ></b-form-input> |  | ||||||
|         </b-form-group> |  | ||||||
|  |  | ||||||
|         <b-button @click="onClose" size="sm" variant="danger">Cancel</b-button> |  | ||||||
|         <b-button @click="onOk" size="sm" variant="primary">Ok</b-button> |  | ||||||
|       </div> |  | ||||||
|     </b-popover> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script lang="ts"> |  | ||||||
| import { Component, Prop, Vue, Watch } from 'vue-property-decorator'; |  | ||||||
| import { StatResult } from '@/apiary/apiary'; |  | ||||||
| import axios from 'axios'; |  | ||||||
| import { Chart, ArcElement, Legend, DoughnutController, Title } from 'chart.js'; |  | ||||||
| import randomColor from 'randomcolor'; |  | ||||||
|  |  | ||||||
| export type StatType = 'username' | 'password' | 'ip' | 'country' | 'total'; |  | ||||||
|  |  | ||||||
| Chart.register(ArcElement, Legend, DoughnutController, Title); |  | ||||||
|  |  | ||||||
| @Component |  | ||||||
| export default class StatsPie extends Vue { |  | ||||||
|   @Prop() private statType!: StatType; |  | ||||||
|  |  | ||||||
|   limit: number; |  | ||||||
|  |  | ||||||
|   limitState: number; |  | ||||||
|  |  | ||||||
|   settingsShow = false; |  | ||||||
|  |  | ||||||
|   stats: StatResult[]; |  | ||||||
|  |  | ||||||
|   chart?: Chart; |  | ||||||
|  |  | ||||||
|   constructor() { |  | ||||||
|     super(); |  | ||||||
|     this.stats = []; |  | ||||||
|     this.limit = 10; |  | ||||||
|     this.limitState = 10; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   title(): string { |  | ||||||
|     switch (this.statType) { |  | ||||||
|       case 'password': |  | ||||||
|         return `Top ${this.limit} Passwords`; |  | ||||||
|       case 'username': |  | ||||||
|         return `Top ${this.limit} Usernames`; |  | ||||||
|       case 'ip': |  | ||||||
|         return `Top ${this.limit} IPs`; |  | ||||||
|       case 'country': |  | ||||||
|         return `Top ${this.limit} Countries`; |  | ||||||
|       case 'total': |  | ||||||
|         return 'Totals'; |  | ||||||
|       // Why doesn't eslint know that this switch is exhaustive? |  | ||||||
|       default: |  | ||||||
|         return 'Top 10 Passwords'; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   containerClass(): string { |  | ||||||
|     return `stats-container-${this.statType}`; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   chartID(): string { |  | ||||||
|     return `chart-${this.statType}`; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   chartSettingsID(): string { |  | ||||||
|     return `chartsettings-${this.statType}`; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   settingsID(): string { |  | ||||||
|     return `settings-${this.statType}`; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   onClose(): void { |  | ||||||
|     this.settingsShow = false; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   onOk(): void { |  | ||||||
|     if (this.limitState) { |  | ||||||
|       this.limit = this.limitState; |  | ||||||
|       this.settingsShow = false; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   onShow(): void { |  | ||||||
|     // This is called just before the popover is shown |  | ||||||
|     // Reset our popover form variables |  | ||||||
|     this.limitState = this.limit; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @Watch('limit') |  | ||||||
|   limitChanged(value: number, oldValue: number): void { |  | ||||||
|     this.fetchData(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   fetchData(): void { |  | ||||||
|     const url = `/api/stats?type=${this.statType}&limit=${this.limit}`; |  | ||||||
|     axios.get<StatResult[]>(url).then((resp) => { |  | ||||||
|       this.stats = resp.data; |  | ||||||
|       this.renderPie(); |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   mounted(): void { |  | ||||||
|     this.fetchData(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   renderPie(): void { |  | ||||||
|     const elem = document.getElementById(this.chartID()) as HTMLCanvasElement; |  | ||||||
|     const ctx = elem.getContext('2d') as CanvasRenderingContext2D; |  | ||||||
|     const sortedStats = this.stats.sort(); |  | ||||||
|     const values = sortedStats.map((s) => s.count); |  | ||||||
|     const headers = sortedStats.map((s) => s.name); |  | ||||||
|     const colors = sortedStats.map(() => randomColor()); |  | ||||||
|  |  | ||||||
|     if (this.chart) { |  | ||||||
|       this.chart.destroy(); |  | ||||||
|     } |  | ||||||
|     this.chart = new Chart(ctx, { |  | ||||||
|       type: 'doughnut', |  | ||||||
|       data: { |  | ||||||
|         labels: headers, |  | ||||||
|         datasets: [ |  | ||||||
|           { |  | ||||||
|             data: values, |  | ||||||
|             backgroundColor: colors, |  | ||||||
|           }, |  | ||||||
|         ], |  | ||||||
|       }, |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <style lang="scss" scoped> |  | ||||||
| canvas { |  | ||||||
|   width: 70%; |  | ||||||
|   height: auto; |  | ||||||
|   max-height: 60vmin; |  | ||||||
| } |  | ||||||
| .stats-container { |  | ||||||
|   align-content: center; |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,67 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div class="stats-container"> |  | ||||||
|     <h2>Top 10 usernames</h2> |  | ||||||
|     <div class="chart-container"> |  | ||||||
|       <canvas id="chart" widht="400" height="400"></canvas> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script lang="ts"> |  | ||||||
| import { Component, Prop, Vue } from 'vue-property-decorator'; |  | ||||||
| import { StatResult } from '@/apiary/apiary'; |  | ||||||
| import axios from 'axios'; |  | ||||||
| import Chart from 'chart.js/auto'; |  | ||||||
| import randomColor from 'randomcolor'; |  | ||||||
|  |  | ||||||
| @Component |  | ||||||
| export default class StatsUsername extends Vue { |  | ||||||
|   @Prop() private msg!: string; |  | ||||||
|  |  | ||||||
|   stats: StatResult[]; |  | ||||||
|  |  | ||||||
|   constructor() { |  | ||||||
|     super(); |  | ||||||
|     this.stats = []; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   mounted(): void { |  | ||||||
|     axios |  | ||||||
|       .get<StatResult[]>('/api/stats?type=username&limit=10') |  | ||||||
|       .then((resp) => { |  | ||||||
|         this.stats = resp.data; |  | ||||||
|         this.renderPie(); |  | ||||||
|       }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   renderPie(): void { |  | ||||||
|     const elem = document.getElementById('chart') as HTMLCanvasElement; |  | ||||||
|     const ctx = elem.getContext('2d') as CanvasRenderingContext2D; |  | ||||||
|     const sortedStats = this.stats.sort(); |  | ||||||
|     const values = sortedStats.map((s) => s.count); |  | ||||||
|     const headers = sortedStats.map((s) => s.name); |  | ||||||
|     const colors = sortedStats.map(() => randomColor()); |  | ||||||
|  |  | ||||||
|     const chart = new Chart(ctx, { |  | ||||||
|       type: 'doughnut', |  | ||||||
|       data: { |  | ||||||
|         labels: headers, |  | ||||||
|         options: {}, |  | ||||||
|         datasets: [ |  | ||||||
|           { |  | ||||||
|             data: values, |  | ||||||
|             backgroundColor: colors, |  | ||||||
|           }, |  | ||||||
|         ], |  | ||||||
|       }, |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <style lang="scss" scoped> |  | ||||||
| .chart-container { |  | ||||||
|   max-width: 500px; |  | ||||||
|   max-height: 500px; |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,58 +0,0 @@ | |||||||
| import Vue from 'vue'; |  | ||||||
| import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'; |  | ||||||
| import VueRouter, { RouteConfig } from 'vue-router'; |  | ||||||
| import AttemptList from '@/components/AttemptList.vue'; |  | ||||||
| import Home from '@/components/Home.vue'; |  | ||||||
| import Stats from '@/components/Stats.vue'; |  | ||||||
| import SearchResult from '@/components/SearchResult.vue'; |  | ||||||
| import 'bootstrap/dist/css/bootstrap.css'; |  | ||||||
| import 'bootstrap-vue/dist/bootstrap-vue.css'; |  | ||||||
| import '@fontsource/rubik'; |  | ||||||
| import '@fontsource/secular-one'; |  | ||||||
| import App from './App.vue'; |  | ||||||
|  |  | ||||||
| Vue.config.productionTip = false; |  | ||||||
| // Make BootstrapVue available throughout your project |  | ||||||
| Vue.use(BootstrapVue); |  | ||||||
| // Optionally install the BootstrapVue icon components plugin |  | ||||||
| Vue.use(IconsPlugin); |  | ||||||
|  |  | ||||||
| Vue.use(VueRouter); |  | ||||||
|  |  | ||||||
| const routes: Array<RouteConfig> = [ |  | ||||||
|   { |  | ||||||
|     path: '/', |  | ||||||
|     name: 'Home', |  | ||||||
|     component: Home, |  | ||||||
|     alias: '/home', |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     path: '/attempts', |  | ||||||
|     name: 'Attempt List', |  | ||||||
|     component: AttemptList, |  | ||||||
|     props: { |  | ||||||
|       // items: testAttempts, |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     path: '/stats', |  | ||||||
|     name: 'Stats', |  | ||||||
|     component: Stats, |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     path: '/search', |  | ||||||
|     name: 'Search', |  | ||||||
|     component: SearchResult, |  | ||||||
|   } |  | ||||||
| ]; |  | ||||||
|  |  | ||||||
| const router = new VueRouter({ |  | ||||||
|   mode: 'history', |  | ||||||
|   base: process.env.BASE_URL, |  | ||||||
|   routes, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| new Vue({ |  | ||||||
|   router, |  | ||||||
|   render: (h) => h(App), |  | ||||||
| }).$mount('#app'); |  | ||||||
							
								
								
									
										13
									
								
								web/frontend/src/shims-tsx.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										13
									
								
								web/frontend/src/shims-tsx.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1,13 +0,0 @@ | |||||||
| import Vue, { VNode } from 'vue'; |  | ||||||
|  |  | ||||||
| declare global { |  | ||||||
|   namespace JSX { |  | ||||||
|     // tslint:disable no-empty-interface |  | ||||||
|     interface Element extends VNode {} |  | ||||||
|     // tslint:disable no-empty-interface |  | ||||||
|     interface ElementClass extends Vue {} |  | ||||||
|     interface IntrinsicElements { |  | ||||||
|       [elem: string]: any |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
							
								
								
									
										5
									
								
								web/frontend/src/shims-vue.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								web/frontend/src/shims-vue.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1,5 +0,0 @@ | |||||||
| declare module '*.vue' { |  | ||||||
|   import Vue from 'vue'; |  | ||||||
|  |  | ||||||
|   export default Vue; |  | ||||||
| } |  | ||||||
| @@ -1,40 +0,0 @@ | |||||||
| { |  | ||||||
|   "compilerOptions": { |  | ||||||
|     "target": "esnext", |  | ||||||
|     "module": "esnext", |  | ||||||
|     "strict": true, |  | ||||||
|     "jsx": "preserve", |  | ||||||
|     "importHelpers": true, |  | ||||||
|     "moduleResolution": "node", |  | ||||||
|     "experimentalDecorators": true, |  | ||||||
|     "skipLibCheck": true, |  | ||||||
|     "esModuleInterop": true, |  | ||||||
|     "allowSyntheticDefaultImports": true, |  | ||||||
|     "sourceMap": true, |  | ||||||
|     "baseUrl": ".", |  | ||||||
|     "types": [ |  | ||||||
|       "webpack-env" |  | ||||||
|     ], |  | ||||||
|     "paths": { |  | ||||||
|       "@/*": [ |  | ||||||
|         "src/*" |  | ||||||
|       ] |  | ||||||
|     }, |  | ||||||
|     "lib": [ |  | ||||||
|       "esnext", |  | ||||||
|       "dom", |  | ||||||
|       "dom.iterable", |  | ||||||
|       "scripthost" |  | ||||||
|     ] |  | ||||||
|   }, |  | ||||||
|   "include": [ |  | ||||||
|     "src/**/*.ts", |  | ||||||
|     "src/**/*.tsx", |  | ||||||
|     "src/**/*.vue", |  | ||||||
|     "tests/**/*.ts", |  | ||||||
|     "tests/**/*.tsx" |  | ||||||
|   ], |  | ||||||
|   "exclude": [ |  | ||||||
|     "node_modules" |  | ||||||
|   ] |  | ||||||
| } |  | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Reference in New Issue
	
	Block a user