├── web ├── src │ ├── assets │ │ ├── .gitkeep │ │ ├── logo.png │ │ ├── angular.png │ │ ├── github.png │ │ ├── golang.png │ │ ├── forkme_right_gray_6d6d6d.png │ │ ├── filter.svg │ │ └── filtera.svg │ ├── app │ │ ├── logout │ │ │ ├── logout.component.html │ │ │ └── logout.component.ts │ │ ├── verify-email │ │ │ ├── verify-email.component.html │ │ │ └── verify-email.component.ts │ │ ├── user │ │ │ ├── user.component.html │ │ │ └── user.component.ts │ │ ├── utils │ │ │ ├── color-percent.component.html │ │ │ ├── date.ts │ │ │ ├── color-percent.component.ts │ │ │ ├── bytes.pipe.ts │ │ │ └── mark-as-touched.directive.ts │ │ ├── collection-tracking │ │ │ ├── tracking.component.html │ │ │ └── tracking.component.ts │ │ ├── admin │ │ │ ├── admin.component.ts │ │ │ └── admin.component.html │ │ ├── forms │ │ │ ├── rvalidators.ts │ │ │ └── invalid.component.ts │ │ ├── home │ │ │ ├── home.component.ts │ │ │ └── home.component.html │ │ ├── settings │ │ │ ├── settings.component.ts │ │ │ ├── settings.component.html │ │ │ ├── profile │ │ │ │ ├── profile.component.ts │ │ │ │ └── profile.component.html │ │ │ ├── delete-account │ │ │ │ ├── delete-account.component.html │ │ │ │ └── delete-account.component.ts │ │ │ └── change-password │ │ │ │ ├── change-password.component.html │ │ │ │ └── change-password.component.ts │ │ ├── admin-users │ │ │ ├── admin-users.component.ts │ │ │ └── admin-users.component.html │ │ ├── toasty │ │ │ ├── toasty.module.ts │ │ │ ├── toasty.utils.ts │ │ │ ├── toast.component.ts │ │ │ └── toasty.component.ts │ │ ├── admin-collections │ │ │ ├── admin-collections.component.ts │ │ │ └── admin-collections.component.html │ │ ├── collection-create │ │ │ ├── create.component.html │ │ │ └── create.component.ts │ │ ├── admin-backups │ │ │ ├── admin-backups.component.html │ │ │ └── admin-backups.component.ts │ │ ├── reset-password │ │ │ ├── reset-password.component.html │ │ │ └── reset-password.component.ts │ │ ├── collection-stat │ │ │ ├── table-sum.component.ts │ │ │ ├── table-sum.component.html │ │ │ ├── stat.component.ts │ │ │ └── stat.component.html │ │ ├── forgot-password │ │ │ ├── forgot-password.component.html │ │ │ └── forgot-password.component.ts │ │ ├── collection-settings │ │ │ ├── teammates.component.html │ │ │ ├── teammates.component.ts │ │ │ ├── settings.component.html │ │ │ └── settings.component.ts │ │ ├── app.component.ts │ │ ├── admin-users-create │ │ │ ├── admin-users-create.component.html │ │ │ └── admin-users-create.component.ts │ │ ├── chart │ │ │ └── chart.component.ts │ │ ├── login │ │ │ ├── login.component.html │ │ │ └── login.component.ts │ │ ├── registration │ │ │ ├── registration.component.ts │ │ │ └── registration.component.html │ │ ├── app.component.html │ │ ├── session │ │ │ ├── session.component.ts │ │ │ └── session.component.html │ │ ├── collection-dashboard │ │ │ └── dashboard.component.html │ │ ├── admin-users-edit │ │ │ ├── admin-users-edit.component.ts │ │ │ └── admin-users-edit.component.html │ │ ├── collection │ │ │ ├── collection.component.html │ │ │ └── collection.component.ts │ │ └── app.module.ts │ ├── favicon.ico │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── typings.d.ts │ ├── tsconfig.app.json │ ├── main.ts │ ├── tsconfig.spec.json │ ├── index.html │ ├── test.ts │ ├── styles.scss │ ├── polyfills.ts │ └── tracker.js ├── proxy.conf.json ├── e2e │ ├── app.po.ts │ ├── tsconfig.e2e.json │ └── app.e2e-spec.ts ├── .editorconfig ├── tsconfig.json ├── .gitignore ├── protractor.conf.js ├── README.md ├── karma.conf.js ├── package.json ├── tslint.json └── angular.json ├── .gitignore ├── .travis.yml ├── internal ├── db │ ├── models.go │ ├── teammates.go │ ├── models.proto │ ├── shardbolt │ │ ├── tx.go │ │ ├── shard.go │ │ ├── db_test.go │ │ └── db.go │ ├── db_test.go │ ├── encode.go │ └── seed.go ├── api │ ├── backup.go │ ├── config.go │ ├── auth.go │ ├── authtoken.go │ ├── collect.go │ ├── admin.go │ ├── user.go │ └── api.go ├── service │ ├── backup.go │ ├── authtoken.go │ ├── error.go │ ├── session.go │ └── admin.go ├── mail │ ├── templates_test.go │ ├── mail.go │ └── templates.go ├── geoip │ └── geoip.go └── config │ └── read.go ├── go.mod ├── rightana.go ├── cmd.go ├── netseed.go └── README.md /web/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/app/logout/logout.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/app/verify-email/verify-email.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/app/user/user.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | rightana 2 | rightana.yml 3 | data 4 | api/statik/statik.go 5 | -------------------------------------------------------------------------------- /web/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soyersoyer/rightana/HEAD/web/src/favicon.ico -------------------------------------------------------------------------------- /web/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soyersoyer/rightana/HEAD/web/src/assets/logo.png -------------------------------------------------------------------------------- /web/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /web/src/assets/angular.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soyersoyer/rightana/HEAD/web/src/assets/angular.png -------------------------------------------------------------------------------- /web/src/assets/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soyersoyer/rightana/HEAD/web/src/assets/github.png -------------------------------------------------------------------------------- /web/src/assets/golang.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soyersoyer/rightana/HEAD/web/src/assets/golang.png -------------------------------------------------------------------------------- /web/proxy.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api": { 3 | "target": "http://localhost:3000", 4 | "secure": false 5 | } 6 | } 7 | 8 | -------------------------------------------------------------------------------- /web/src/assets/forkme_right_gray_6d6d6d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soyersoyer/rightana/HEAD/web/src/assets/forkme_right_gray_6d6d6d.png -------------------------------------------------------------------------------- /web/src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /* SystemJS module definition */ 2 | declare var module: NodeModule; 3 | interface NodeModule { 4 | id: string; 5 | } 6 | -------------------------------------------------------------------------------- /web/src/app/utils/color-percent.component.html: -------------------------------------------------------------------------------- 1 | 2 | {{percent > 0 ? '+' : ''}}{{percent | percent:"1.1-1"}} 3 | 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 1.12 3 | node_js: 10 4 | 5 | env: 6 | - GO111MODULE=on 7 | 8 | before_install: 9 | - npm i -g npm 10 | 11 | install: 12 | - ./build.sh 13 | 14 | script: 15 | - go test -v ./... 16 | -------------------------------------------------------------------------------- /web/src/app/collection-tracking/tracking.component.html: -------------------------------------------------------------------------------- 1 |
2 |

To start tracking, include the following JavaScript on your site:

3 |
{{trackingCode}}
4 |
5 | -------------------------------------------------------------------------------- /web/e2e/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get('/'); 6 | } 7 | 8 | getParagraphText() { 9 | return element(by.css('app-root h1')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /web/src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "baseUrl": "./", 6 | "module": "es2015", 7 | "types": [] 8 | }, 9 | "exclude": [ 10 | "test.ts", 11 | "**/*.spec.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /web/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /web/e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/e2e", 5 | "baseUrl": "./", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": [ 9 | "jasmine", 10 | "jasminewd2", 11 | "node" 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /web/src/app/admin/admin.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'rana-admin', 5 | templateUrl: './admin.component.html', 6 | }) 7 | export class AdminComponent implements OnInit { 8 | 9 | constructor() { } 10 | 11 | ngOnInit() { 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /web/e2e/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | 3 | describe('rana-front App', () => { 4 | let page: AppPage; 5 | 6 | beforeEach(() => { 7 | page = new AppPage(); 8 | }); 9 | 10 | it('should display welcome message', () => { 11 | page.navigateTo(); 12 | expect(page.getParagraphText()).toEqual('Welcome to app!'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /internal/db/models.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import "time" 4 | 5 | // ExtSession extends the Session proto struct with calculated information 6 | type ExtSession struct { 7 | Session 8 | Key string 9 | Begin time.Time 10 | PageviewCount int 11 | } 12 | 13 | // ExtPageview extends the Pageview proto struct with calculated information 14 | type ExtPageview struct { 15 | Pageview 16 | Time time.Time 17 | } 18 | -------------------------------------------------------------------------------- /web/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.log(err)); 13 | -------------------------------------------------------------------------------- /web/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // The file contents for the current environment will overwrite these during build. 2 | // The build system defaults to the dev environment which uses `environment.ts`, but if you do 3 | // `ng build --env=prod` then `environment.prod.ts` will be used instead. 4 | // The list of which env maps to which file can be found in `.angular-cli.json`. 5 | 6 | export const environment = { 7 | production: false 8 | }; 9 | -------------------------------------------------------------------------------- /web/src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "baseUrl": "./", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": [ 9 | "jasmine", 10 | "node" 11 | ] 12 | }, 13 | "files": [ 14 | "test.ts", 15 | "polyfills.ts" 16 | ], 17 | "include": [ 18 | "**/*.spec.ts", 19 | "**/*.d.ts" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "outDir": "./dist/out-tsc", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "target": "es5", 11 | "typeRoots": [ 12 | "node_modules/@types" 13 | ], 14 | "lib": [ 15 | "es2017", 16 | "dom" 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /web/src/app/utils/date.ts: -------------------------------------------------------------------------------- 1 | export function getDateStrFromUnixTime(unix: number, res: string): string { 2 | var d = new Date(unix*1000); 3 | var datestr = d.getFullYear()+"-"+padNumber(d.getMonth()+1)+"-"+padNumber(d.getDate()); 4 | if (res === "hour") { 5 | return datestr+" "+padNumber(d.getHours())+":"+padNumber(d.getMinutes()); 6 | } 7 | return datestr; 8 | } 9 | 10 | function padNumber(n: number): string { 11 | return n<10?"0"+n:""+n 12 | } 13 | -------------------------------------------------------------------------------- /web/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | RightAna - Carefree web analytics on your server 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /web/src/app/forms/rvalidators.ts: -------------------------------------------------------------------------------- 1 | import { Validators } from '@angular/forms'; 2 | 3 | export class RValidators { 4 | static password = Validators.compose([Validators.required, Validators.minLength(8)]); 5 | static collectionName = Validators.compose([Validators.required, Validators.pattern("^[a-z0-9.]+$")]); 6 | static userName = Validators.compose([Validators.required, Validators.pattern("^[a-z0-9.]+$")]); 7 | static email = Validators.compose([Validators.required, Validators.email]); 8 | } 9 | -------------------------------------------------------------------------------- /web/src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { BackendService } from '../backend.service'; 4 | 5 | @Component({ 6 | selector: 'rana-home', 7 | templateUrl: './home.component.html', 8 | }) 9 | export class HomeComponent implements OnInit { 10 | 11 | constructor( 12 | private backend: BackendService, 13 | ) { } 14 | 15 | ngOnInit() { 16 | } 17 | 18 | get serverAnnounce(): string { 19 | return this.backend.config && this.backend.config.server_announce; 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /web/src/app/user/user.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute, Params } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'rana-user', 6 | templateUrl: './user.component.html', 7 | }) 8 | export class UserComponent implements OnInit { 9 | user: string; 10 | 11 | constructor( 12 | private route: ActivatedRoute, 13 | ) { } 14 | 15 | ngOnInit() { 16 | this.route.params.forEach((params: Params) => { 17 | this.user = params['user']; 18 | }); 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /internal/api/backup.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi" 7 | "github.com/soyersoyer/rightana/internal/service" 8 | ) 9 | 10 | func getBackupsE(w http.ResponseWriter, r *http.Request) error { 11 | return respond(w, service.GetBackups()) 12 | } 13 | 14 | var getBackups = handleError(getBackupsE) 15 | 16 | func runBackupE(w http.ResponseWriter, r *http.Request) error { 17 | backupID := chi.URLParam(r, "backupID") 18 | 19 | return service.RunBackup(backupID) 20 | } 21 | 22 | var runBackup = handleError(runBackupE) 23 | -------------------------------------------------------------------------------- /web/src/app/utils/color-percent.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'rana-color-percent', 5 | templateUrl: './color-percent.component.html' 6 | }) 7 | export class ColorPercentComponent { 8 | @Input() percent: number; 9 | @Input() inverse = false; 10 | 11 | getClass() { 12 | const p = this.inverse ? this.percent * -1 : this.percent; 13 | if (p < 0) { 14 | return 'text-danger'; 15 | } 16 | if (p > 0) { 17 | return 'text-success'; 18 | } 19 | return ''; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /web/src/assets/filter.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /web/src/assets/filtera.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /web/src/app/settings/settings.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { AuthService } from '../backend.service'; 4 | import { Router } from '@angular/router'; 5 | 6 | @Component({ 7 | selector: 'rana-settings', 8 | templateUrl: './settings.component.html', 9 | }) 10 | export class SettingsComponent implements OnInit { 11 | 12 | constructor( 13 | private auth: AuthService, 14 | private router: Router, 15 | ) { 16 | if (!this.auth.loggedIn) { 17 | this.router.navigateByUrl('/login'); 18 | } 19 | } 20 | 21 | ngOnInit() { 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /web/src/app/admin-users/admin-users.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { UserInfo, BackendService } from '../backend.service'; 4 | 5 | @Component({ 6 | selector: 'rana-admin-users', 7 | templateUrl: './admin-users.component.html', 8 | }) 9 | export class AdminUsersComponent implements OnInit { 10 | users: UserInfo[]; 11 | 12 | constructor( 13 | private backend: BackendService, 14 | ) { } 15 | 16 | ngOnInit() { 17 | this.getUsers(); 18 | } 19 | 20 | getUsers() { 21 | this.backend.getUsers().subscribe(users => this.users = users) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /internal/api/config.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/soyersoyer/rightana/internal/config" 7 | ) 8 | 9 | type publicConfigT struct { 10 | EnableRegistration bool `json:"enable_registration"` 11 | TrackingID string `json:"tracking_id"` 12 | ServerAnnounce string `json:"server_announce"` 13 | } 14 | 15 | func getPublicConfigE(w http.ResponseWriter, r *http.Request) error { 16 | return respond(w, publicConfigT{ 17 | config.ActualConfig.EnableRegistration, 18 | config.ActualConfig.TrackingID, 19 | config.ActualConfig.ServerAnnounce, 20 | }) 21 | } 22 | 23 | var getPublicConfig = handleError(getPublicConfigE) 24 | -------------------------------------------------------------------------------- /web/src/app/toasty/toasty.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | 4 | import { ToastyService } from './toasty.service'; 5 | import { ToastyComponent } from './toasty.component'; 6 | import { ToastComponent } from './toast.component'; 7 | 8 | export * from './toasty.service'; 9 | 10 | @NgModule({ 11 | imports: [ 12 | CommonModule 13 | ], 14 | declarations: [ 15 | ToastyComponent, 16 | ToastComponent, 17 | ], 18 | providers: [ 19 | ToastyService, 20 | ], 21 | exports: [ 22 | ToastComponent, 23 | ToastyComponent, 24 | ], 25 | }) 26 | export class ToastyModule { } 27 | -------------------------------------------------------------------------------- /web/src/app/admin-collections/admin-collections.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { CollectionInfo, BackendService } from '../backend.service'; 4 | 5 | @Component({ 6 | selector: 'rana-admin-collections', 7 | templateUrl: './admin-collections.component.html', 8 | }) 9 | export class AdminCollectionsComponent implements OnInit { 10 | collections: CollectionInfo[]; 11 | 12 | constructor( 13 | private backend: BackendService, 14 | ) { } 15 | 16 | ngOnInit() { 17 | this.getUsers(); 18 | } 19 | 20 | getUsers() { 21 | this.backend.getCollections().subscribe(collections => this.collections = collections) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /web/src/app/collection-create/create.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Create new collection

3 |
4 |
5 |
6 |
7 | 8 | 9 |
10 | 11 |
12 |
13 | 14 |
15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /web/src/app/admin-backups/admin-backups.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Backups

3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
IDDestination dirTrigger URLRun
{{b.id}}{{b.dir}}{{origin}}/api/backups/{{b.id}}/run
21 |
22 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | testem.log 34 | /typings 35 | 36 | # e2e 37 | /e2e/*.js 38 | /e2e/*.map 39 | 40 | # System Files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /web/src/app/admin/admin.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |

Admin

6 |
7 | Users 8 | Collections 9 | Backups 10 |
11 |
12 |
13 |
14 | 15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /web/src/app/utils/bytes.pipe.ts: -------------------------------------------------------------------------------- 1 | import {PipeTransform, Pipe} from '@angular/core'; 2 | 3 | @Pipe({name: 'bytes'}) 4 | export class BytesPipe implements PipeTransform { 5 | 6 | transform(value: number): string | number { 7 | const dictionary: Array<{max: number, trunc?: number, type: string}> = [ 8 | { max: 1e3, type: 'B' }, 9 | { max: 1e6, trunc: 1e1, type: 'KB' }, 10 | { max: 1e9, trunc: 1e4, type: 'MB' }, 11 | { max: 1e12, trunc: 1e7, type: 'GB' } 12 | ]; 13 | 14 | const format = dictionary.find(d => value < d.max) || dictionary[dictionary.length - 1]; 15 | const num = (format.trunc? value - (value % format.trunc) : value) / (format.max / 1e3); 16 | return `${num} ${format.type}`; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /web/src/app/settings/settings.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |

Settings

6 | 11 |
12 |
13 |
14 | 15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /web/src/app/utils/mark-as-touched.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive } from '@angular/core'; 2 | import { FormGroupDirective } from '@angular/forms'; 3 | 4 | export function markAsTouchedDeep(control: any): void { 5 | control.markAsTouched(); 6 | const controls = control.controls; 7 | if (!controls) { 8 | return; 9 | } 10 | if (controls instanceof Array) { 11 | controls.forEach(c => markAsTouchedDeep(c)); 12 | } 13 | else { 14 | Object.keys(control.controls).forEach(key => markAsTouchedDeep(control.controls[key])); 15 | } 16 | } 17 | 18 | @Directive({ selector: '[mark-as-touched]' }) 19 | export class MarkAsToucedDirective { 20 | constructor(private fgDirective: FormGroupDirective) { 21 | this.fgDirective.ngSubmit.forEach(_ => markAsTouchedDeep(this.fgDirective.form)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /web/src/app/reset-password/reset-password.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Reset password

4 |
5 |
6 |
7 |
8 | 9 | 10 |
11 | 12 |
13 |
14 | 15 |
16 |
17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /web/src/app/admin-collections/admin-collections.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Collections

3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
IDNameOwnerCreatedTeammate count
{{c.id}}{{c.name}}{{c.owner_name}}{{c.created / 1000000 | date:"yyyy.MM.dd HH:mm:ss"}}{{c.teammate_count}}
23 |
24 | -------------------------------------------------------------------------------- /web/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | const { SpecReporter } = require('jasmine-spec-reporter'); 5 | 6 | exports.config = { 7 | allScriptsTimeout: 11000, 8 | specs: [ 9 | './e2e/**/*.e2e-spec.ts' 10 | ], 11 | capabilities: { 12 | 'browserName': 'chrome' 13 | }, 14 | directConnect: true, 15 | baseUrl: 'http://localhost:4200/', 16 | framework: 'jasmine', 17 | jasmineNodeOpts: { 18 | showColors: true, 19 | defaultTimeoutInterval: 30000, 20 | print: function() {} 21 | }, 22 | onPrepare() { 23 | require('ts-node').register({ 24 | project: 'e2e/tsconfig.e2e.json' 25 | }); 26 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/soyersoyer/rightana 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/etcd-io/bbolt v1.3.2 7 | github.com/go-chi/chi v4.0.2+incompatible 8 | github.com/go-chi/cors v1.0.0 9 | github.com/go-mail/mail v2.3.1+incompatible 10 | github.com/gofrs/uuid v3.2.0+incompatible 11 | github.com/golang/protobuf v1.3.1 12 | github.com/mssola/user_agent v0.5.0 13 | github.com/oschwald/geoip2-golang v1.3.0 14 | github.com/oschwald/maxminddb-golang v1.3.1 // indirect 15 | github.com/rakyll/statik v0.1.6 16 | github.com/soyersoyer/cipobolt v0.0.0-20190604113039-57f27697b28a 17 | github.com/spf13/viper v1.4.0 18 | golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 19 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 20 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 21 | gopkg.in/mail.v2 v2.3.1 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /web/src/app/forms/invalid.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'rana-invalid-username', 5 | template: `Usernames can contain letters (a-z), numbers (0-9), and dot (.).`, 6 | }) 7 | export class InvalidUsernameComponent {} 8 | 9 | @Component({ 10 | selector: 'rana-invalid-password', 11 | template: `Your password must be at least 8 characters long.`, 12 | }) 13 | export class InvalidPasswordComponent {} 14 | 15 | @Component({ 16 | selector: 'rana-invalid-email', 17 | template: `Please enter a valid email address!`, 18 | }) 19 | export class InvalidEmailComponent {} 20 | 21 | @Component({ 22 | selector: 'rana-invalid-collection-name', 23 | template: `Collection names can contain letters (a-z), numbers (0-9), and dot (.).`, 24 | }) 25 | export class InvalidCollectionNameComponent {} 26 | -------------------------------------------------------------------------------- /internal/service/backup.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/soyersoyer/rightana/internal/config" 5 | "github.com/soyersoyer/rightana/internal/db" 6 | ) 7 | 8 | // Backup stores the backup's properties 9 | type Backup struct { 10 | ID string `json:"id"` 11 | Dir string `json:"dir"` 12 | } 13 | 14 | // RunBackup runs the backup 15 | func RunBackup(backupID string) error { 16 | dir, ok := config.ActualConfig.Backup[backupID] 17 | if !ok { 18 | return ErrBackupNotExist.T(backupID) 19 | } 20 | if err := db.RunBackup(dir); err != nil { 21 | return ErrDB.Wrap(err).T(dir) 22 | } 23 | return nil 24 | } 25 | 26 | // GetBackups returns the backup configuration 27 | func GetBackups() []Backup { 28 | backups := []Backup{} 29 | for k, v := range config.ActualConfig.Backup { 30 | backups = append(backups, Backup{k, v}) 31 | } 32 | return backups 33 | } 34 | -------------------------------------------------------------------------------- /web/src/app/collection-stat/table-sum.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | import { CollectionStatComponent } from './stat.component'; 4 | 5 | @Component({ 6 | selector: 'rana-table-sum', 7 | templateUrl: './table-sum.component.html' 8 | }) 9 | export class TableSumComponent { 10 | @Input() name: string; 11 | @Input() sums: any; 12 | @Input() key: string; 13 | 14 | showAll = false; 15 | 16 | constructor( 17 | private statComponent: CollectionStatComponent, 18 | ) {} 19 | 20 | showMeAll() { 21 | this.showAll = true; 22 | } 23 | 24 | addFilter(value: string) { 25 | this.statComponent.dashboardSetup.add(this.key, value); 26 | } 27 | 28 | removeFilter() { 29 | this.statComponent.dashboardSetup.del(this.key); 30 | } 31 | 32 | get inFilter(): boolean { 33 | return this.key && this.statComponent.setup.in(this.key); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /web/src/app/forgot-password/forgot-password.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Forgot password

5 |
6 |
7 |
8 |
9 | 10 | 11 |
12 | 13 |
14 |
15 | 16 |
17 |
18 |
19 |
20 |
21 |
22 | -------------------------------------------------------------------------------- /web/src/app/settings/profile/profile.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { BackendService, AuthService, UserInfo } from "../../backend.service" 4 | import { ToastyService } from '../../toasty/toasty.module'; 5 | 6 | @Component({ 7 | selector: 'rana-profile', 8 | templateUrl: './profile.component.html', 9 | }) 10 | export class ProfileComponent implements OnInit { 11 | user: UserInfo; 12 | 13 | constructor( 14 | private backend: BackendService, 15 | private auth: AuthService, 16 | private toasty: ToastyService, 17 | ) { } 18 | 19 | ngOnInit() { 20 | this.backend.getUserInfo(this.auth.user) 21 | .subscribe(user => { 22 | this.user = user; 23 | }); 24 | } 25 | 26 | sendVerifyEmail() { 27 | this.backend.sendVerifyEmail(this.auth.user) 28 | .subscribe(_ => { 29 | this.toasty.success('Verify Email sent!') 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /web/src/app/admin-backups/admin-backups.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { Backup, BackendService } from '../backend.service'; 4 | import { ToastyService } from '../toasty/toasty.module'; 5 | 6 | @Component({ 7 | selector: 'rana-admin-backups', 8 | templateUrl: './admin-backups.component.html', 9 | }) 10 | export class AdminBackupsComponent implements OnInit { 11 | backups: Backup[]; 12 | 13 | constructor( 14 | private backend: BackendService, 15 | private toasty: ToastyService, 16 | ) { } 17 | 18 | ngOnInit() { 19 | this.getUsers(); 20 | } 21 | 22 | getUsers() { 23 | this.backend.getBackups().subscribe(backups => this.backups = backups) 24 | } 25 | 26 | run(b: Backup) { 27 | this.backend.runBackup(b.id) 28 | .subscribe(_ => this.toasty.success(`Backup (${b.id}) success`)); 29 | } 30 | 31 | get origin(): string { 32 | return window.location.origin; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /web/src/app/settings/profile/profile.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Profile

4 |
5 |
6 |
7 |
Username:
8 |
{{user.name}}
9 |
Email:
10 |
11 |
{{user.email}} 12 | (verified) 13 | (not verified) 14 |
15 |
16 | 17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | -------------------------------------------------------------------------------- /web/src/app/collection-settings/teammates.component.html: -------------------------------------------------------------------------------- 1 |

Teammates

2 |
Teammates can read the collection
3 |
4 | 10 |
11 |
12 |
13 | 14 |
15 | 16 |
17 |
18 | 19 |
20 |
21 |
22 | -------------------------------------------------------------------------------- /internal/api/auth.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/soyersoyer/rightana/internal/service" 8 | ) 9 | 10 | func getLoggedInUserCtx(ctx context.Context) *service.User { 11 | return ctx.Value(keyLoggedInUser).(*service.User) 12 | } 13 | 14 | func setLoggedInUserCtx(ctx context.Context, user *service.User) context.Context { 15 | return context.WithValue(ctx, keyLoggedInUser, user) 16 | } 17 | 18 | func loggedOnlyHandler(next http.Handler) http.Handler { 19 | return http.HandlerFunc(handleError( 20 | func(w http.ResponseWriter, r *http.Request) error { 21 | authToken := r.Header.Get("Authorization") 22 | 23 | userID, err := service.CheckAuthToken(authToken) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | user, err := service.GetUserByID(userID) 29 | if err != nil { 30 | return err 31 | } 32 | ctx := setLoggedInUserCtx(r.Context(), user) 33 | next.ServeHTTP(w, r.WithContext(ctx)) 34 | return nil 35 | })) 36 | } 37 | -------------------------------------------------------------------------------- /web/src/app/toasty/toasty.utils.ts: -------------------------------------------------------------------------------- 1 | 2 | // Copyright (C) 2016-2017 Sergey Akopkokhyants 3 | // This project is licensed under the terms of the MIT license. 4 | // https://github.com/akserg/ng2-toasty 5 | 6 | /** 7 | * Check and return true if an object is type of string 8 | * @param obj Analyse has to object the string type 9 | * @return result of analysis 10 | */ 11 | export function isString(obj: any): boolean { 12 | return typeof obj === "string"; 13 | } 14 | 15 | /** 16 | * Check and return true if an object is type of number 17 | * @param obj Analyse has to object the boolean type 18 | * @return result of analysis 19 | */ 20 | export function isNumber(obj: any): boolean { 21 | return typeof obj === "number"; 22 | } 23 | 24 | /** 25 | * Check and return true if an object is type of Function 26 | * @param obj Analyse has to object the function type 27 | * @return result of analysis 28 | */ 29 | export function isFunction(obj: any): boolean { 30 | return typeof obj === "function"; 31 | } 32 | -------------------------------------------------------------------------------- /web/src/app/logout/logout.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { AuthService, BackendService } from '../backend.service'; 3 | import { Router } from '@angular/router'; 4 | 5 | import { ToastyService } from '../toasty/toasty.module'; 6 | 7 | @Component({ 8 | selector: 'rana-logout', 9 | templateUrl: './logout.component.html', 10 | }) 11 | export class LogoutComponent implements OnInit { 12 | 13 | constructor( 14 | private auth: AuthService, 15 | private backend: BackendService, 16 | private toasty: ToastyService, 17 | private router: Router, 18 | ) { } 19 | 20 | ngOnInit() { 21 | this.backend 22 | .deleteAuthToken(this.auth.token) 23 | .subscribe( 24 | () => { 25 | this.toasty.success('Logout success'); 26 | this.auth.unset(); 27 | this.router.navigate(['/']); 28 | }, 29 | () => { 30 | this.auth.unset(); 31 | this.router.navigate(['/']); 32 | }, 33 | ); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /web/src/app/settings/delete-account/delete-account.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Delete your account

4 |
5 |
6 | 9 |
10 |
11 | 12 | 13 |
14 | 15 |
16 |
17 | 18 |
19 |
20 |
21 |
22 |
23 | -------------------------------------------------------------------------------- /web/src/app/forgot-password/forgot-password.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { FormGroup, FormBuilder, Validators } from '@angular/forms'; 3 | 4 | import { RValidators } from '../forms/rvalidators'; 5 | import { BackendService } from '../backend.service'; 6 | import { ToastyService } from '../toasty/toasty.module'; 7 | 8 | @Component({ 9 | selector: 'rana-forgot-password', 10 | templateUrl: './forgot-password.component.html', 11 | }) 12 | export class ForgotPasswordComponent implements OnInit { 13 | form: FormGroup; 14 | 15 | constructor( 16 | private fb: FormBuilder, 17 | private backend: BackendService, 18 | private toasty: ToastyService, 19 | ) { } 20 | 21 | ngOnInit() { 22 | this.form = this.fb.group({ 23 | email: [null, RValidators.email], 24 | }); 25 | } 26 | 27 | send() { 28 | this.backend 29 | .sendResetPassword(this.form.value.email) 30 | .subscribe(_ => { 31 | this.toasty.success('Reset password email sent!') 32 | this.form.reset(); 33 | }); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /web/src/app/verify-email/verify-email.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute, Router, Params } from '@angular/router'; 3 | 4 | import { UserComponent } from '../user/user.component'; 5 | import { BackendService } from "../backend.service" 6 | import { ToastyService } from '../toasty/toasty.module'; 7 | 8 | @Component({ 9 | selector: 'rana-verify-email', 10 | templateUrl: './verify-email.component.html', 11 | }) 12 | export class VerifyEmailComponent implements OnInit { 13 | 14 | constructor( 15 | private user: UserComponent, 16 | private backend: BackendService, 17 | private toasty: ToastyService, 18 | private route: ActivatedRoute, 19 | private router: Router, 20 | ) { } 21 | 22 | ngOnInit() { 23 | this.route.queryParams.forEach((params: Params) => { 24 | this.backend.verifyEmail(this.user.user, params['verification_key']).subscribe(_ => { 25 | this.toasty.success('Email verification complete!') 26 | this.router.navigateByUrl('/settings/profile'); 27 | }); 28 | }); 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Rightana Frontend 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 1.5.0. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). 28 | -------------------------------------------------------------------------------- /web/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client:{ 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, 'coverage'), reports: [ 'html', 'lcovonly' ], 20 | fixWebpackSourcePaths: true 21 | }, 22 | angularCli: { 23 | environment: 'dev' 24 | }, 25 | reporters: ['progress', 'kjhtml'], 26 | port: 9876, 27 | colors: true, 28 | logLevel: config.LOG_INFO, 29 | autoWatch: true, 30 | browsers: ['Chrome'], 31 | singleRun: false 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /web/src/app/collection-create/create.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; 3 | import { Router, ActivatedRoute } from '@angular/router'; 4 | 5 | import { UserComponent } from '../user/user.component'; 6 | import { BackendService } from '../backend.service'; 7 | import { RValidators } from '../forms/rvalidators'; 8 | 9 | @Component({ 10 | selector: 'rana-collection-create', 11 | templateUrl: './create.component.html', 12 | }) 13 | export class CollectionCreateComponent implements OnInit { 14 | form: FormGroup; 15 | 16 | constructor( 17 | private fb: FormBuilder, 18 | private backend: BackendService, 19 | private router: Router, 20 | private route: ActivatedRoute, 21 | private user: UserComponent, 22 | ) { } 23 | 24 | ngOnInit() { 25 | this.form = this.fb.group({ 26 | name: [null, RValidators.collectionName], 27 | }); 28 | } 29 | 30 | create() { 31 | this.backend.createCollection(this.user.user, this.form.value) 32 | .subscribe(collection => this.router.navigate(['..', collection.name, 'settings'], {relativeTo: this.route})); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/mail/templates_test.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestResetPassword(t *testing.T) { 10 | user := "user" 11 | display := "display" 12 | resetKey := "resetKey" 13 | expireMinutes := 12 14 | tmpl, err := getResetPasswordBody(user, display, resetKey, 12) 15 | if err != nil { 16 | t.Error(err) 17 | } 18 | if strings.Index(tmpl, user) == -1 { 19 | t.Error(user) 20 | } 21 | if strings.Index(tmpl, display) == -1 { 22 | t.Error(display) 23 | } 24 | if strings.Index(tmpl, resetKey) == -1 { 25 | t.Error(resetKey) 26 | } 27 | if strings.Index(tmpl, strconv.Itoa(expireMinutes)) == -1 { 28 | t.Error(expireMinutes) 29 | } 30 | } 31 | 32 | func TestVerifyEmail(t *testing.T) { 33 | user := "user" 34 | display := "display" 35 | verificationKey := "verificationKey" 36 | tmpl, err := getVerifyEmailBody(user, display, verificationKey) 37 | if err != nil { 38 | t.Error(err) 39 | } 40 | if strings.Index(tmpl, user) == -1 { 41 | t.Error(user) 42 | } 43 | if strings.Index(tmpl, display) == -1 { 44 | t.Error(display) 45 | } 46 | if strings.Index(tmpl, verificationKey) == -1 { 47 | t.Error(verificationKey) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /web/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/long-stack-trace-zone'; 4 | import 'zone.js/dist/proxy.js'; 5 | import 'zone.js/dist/sync-test'; 6 | import 'zone.js/dist/jasmine-patch'; 7 | import 'zone.js/dist/async-test'; 8 | import 'zone.js/dist/fake-async-test'; 9 | import { getTestBed } from '@angular/core/testing'; 10 | import { 11 | BrowserDynamicTestingModule, 12 | platformBrowserDynamicTesting 13 | } from '@angular/platform-browser-dynamic/testing'; 14 | 15 | // Unfortunately there's no typing for the `__karma__` variable. Just declare it as any. 16 | declare const __karma__: any; 17 | declare const require: any; 18 | 19 | // Prevent Karma from running prematurely. 20 | __karma__.loaded = function () {}; 21 | 22 | // First, initialize the Angular testing environment. 23 | getTestBed().initTestEnvironment( 24 | BrowserDynamicTestingModule, 25 | platformBrowserDynamicTesting() 26 | ); 27 | // Then we find all the tests. 28 | const context = require.context('./', true, /\.spec\.ts$/); 29 | // And load the modules. 30 | context.keys().map(context); 31 | // Finally, start Karma to run the tests. 32 | __karma__.start(); 33 | -------------------------------------------------------------------------------- /web/src/app/collection-stat/table-sum.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 18 | 19 | 20 | 21 | 22 | 25 | 26 | 27 |
#{{name}}Count%
{{i+1}} 14 | {{sum.name}} 15 | 16 | 17 | {{sum.count}}{{sum.percent | percent:"1.1-1"}}
23 | show all 24 |
28 | -------------------------------------------------------------------------------- /web/src/app/settings/change-password/change-password.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Change password

4 |
5 |
6 |
7 |
8 | 9 | 10 |
11 | 12 |
13 |
14 |
15 | 16 | 17 |
18 | 19 |
20 |
21 | 22 |
23 |
24 |
25 |
26 |
27 | -------------------------------------------------------------------------------- /web/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Router, RouterEvent, NavigationEnd } from '@angular/router'; 3 | 4 | import { AuthService, BackendService } from './backend.service'; 5 | 6 | declare var rightana: any; 7 | 8 | @Component({ 9 | selector: 'rana-root', 10 | templateUrl: './app.component.html', 11 | }) 12 | export class AppComponent { 13 | 14 | constructor( 15 | private auth: AuthService, 16 | private backend: BackendService, 17 | private router: Router, 18 | ) { 19 | this.setupRightana(); 20 | } 21 | 22 | setupRightana() { 23 | this.backend.getConfig().subscribe(config => { 24 | if (config.tracking_id) { 25 | rightana('setup', '/api', config.tracking_id); 26 | rightana('trackPageview'); 27 | this.router.events.subscribe((event: RouterEvent) => { 28 | if (event instanceof NavigationEnd) { 29 | rightana('trackPageview'); 30 | } 31 | }); 32 | } 33 | }); 34 | } 35 | 36 | get loggedIn(): boolean { 37 | return this.auth.loggedIn; 38 | } 39 | 40 | get user(): string { 41 | return this.auth.user; 42 | } 43 | 44 | get isAdmin(): boolean { 45 | return this.auth.isAdmin; 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /web/src/app/settings/change-password/change-password.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; 3 | 4 | import { BackendService, AuthService } from "../../backend.service" 5 | import { RValidators } from '../../forms/rvalidators'; 6 | import { ToastyService } from '../../toasty/toasty.module'; 7 | 8 | @Component({ 9 | selector: 'rana-change-password', 10 | templateUrl: './change-password.component.html', 11 | }) 12 | export class ChangePasswordComponent implements OnInit { 13 | form: FormGroup; 14 | 15 | constructor( 16 | private fb: FormBuilder, 17 | private backend: BackendService, 18 | private auth: AuthService, 19 | private toasty: ToastyService, 20 | ) { } 21 | 22 | ngOnInit() { 23 | this.form = this.fb.group({ 24 | currentPassword: [null, RValidators.password], 25 | password: [null, RValidators.password], 26 | }); 27 | } 28 | 29 | changePassword() { 30 | const v = this.form.value 31 | this.backend 32 | .updateUserPassword(this.auth.user, v.currentPassword, v.password) 33 | .subscribe(_ => { 34 | this.form.reset(); 35 | this.toasty.success("Password change success"); 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /web/src/app/admin-users-create/admin-users-create.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Create User

3 |
4 |
5 |
6 |
7 | 8 | 9 |
10 | 11 |
12 |
13 |
14 | 15 | 16 |
17 | 18 |
19 |
20 |
21 | 22 | 23 |
24 | 25 |
26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /web/src/app/settings/delete-account/delete-account.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; 3 | import { Router } from '@angular/router'; 4 | 5 | 6 | import { BackendService, AuthService } from "../../backend.service" 7 | import { RValidators } from '../../forms/rvalidators'; 8 | import { ToastyService } from '../../toasty/toasty.module'; 9 | 10 | @Component({ 11 | selector: 'rana-delete-account', 12 | templateUrl: './delete-account.component.html', 13 | }) 14 | export class DeleteAccountComponent implements OnInit { 15 | form: FormGroup; 16 | 17 | constructor( 18 | private fb: FormBuilder, 19 | private backend: BackendService, 20 | private auth: AuthService, 21 | private toasty: ToastyService, 22 | private router: Router, 23 | ) { } 24 | 25 | ngOnInit() { 26 | this.form = this.fb.group({ 27 | password: [null, RValidators.password], 28 | }); 29 | } 30 | 31 | deleteAccount() { 32 | this.backend 33 | .deleteUser(this.auth.user, this.form.value.password) 34 | .subscribe(_ => { 35 | this.toasty.success('Account delete success'); 36 | this.auth.unset(); 37 | this.router.navigate(['/']); 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /web/src/styles.scss: -------------------------------------------------------------------------------- 1 | @import "../node_modules/bootstrap/scss/bootstrap.scss"; 2 | @import "app/toasty/style-default.css"; 3 | 4 | 5 | .table .text-long { 6 | position: relative; 7 | width: 100%; 8 | } 9 | .table .text-long span { 10 | overflow-x: auto; 11 | white-space: nowrap; 12 | position: absolute; 13 | left: $table-cell-padding; 14 | right: 14px; 15 | } 16 | /*.text-long:before { 17 | content: ''; 18 | display: inline-block; 19 | }*/ 20 | 21 | .table .text-long .badge-right { 22 | position: absolute; 23 | right: 0; 24 | } 25 | 26 | .w-0 { 27 | width: 0; 28 | } 29 | 30 | .active a, a.active{ 31 | background-color: rgba(224, 232, 240, 0.5); 32 | border-color: rgba(134, 142, 150, 0.5); 33 | border-radius: 5px; 34 | } 35 | 36 | a { 37 | outline: 0; 38 | } 39 | 40 | 41 | #toasty.toasty-position-top-right { 42 | top: 68px; 43 | } 44 | 45 | .min-height-100vh-minus-top { 46 | min-height: calc(100vh - 56px); 47 | } 48 | 49 | .form-check-input.ng-touched.ng-invalid ~ .invalid-feedback, 50 | .form-control.ng-touched.ng-invalid ~ .invalid-feedback { 51 | display: block; 52 | } 53 | 54 | .form-control.ng-touched.ng-invalid { 55 | border-color: theme-color(danger); 56 | } 57 | 58 | .form-check-input.ng-touched.ng-invalid ~ .form-check-label { 59 | color: theme-color(danger); 60 | } 61 | -------------------------------------------------------------------------------- /web/src/app/admin-users-create/admin-users-create.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Router, ActivatedRoute } from '@angular/router'; 3 | import { FormBuilder, FormGroup } from '@angular/forms'; 4 | 5 | import { RValidators } from '../forms/rvalidators'; 6 | import { BackendService } from '../backend.service'; 7 | import { ToastyService } from '../toasty/toasty.module'; 8 | 9 | 10 | @Component({ 11 | selector: 'rana-admin-users-create', 12 | templateUrl: './admin-users-create.component.html', 13 | }) 14 | export class AdminUsersCreateComponent implements OnInit { 15 | form: FormGroup; 16 | 17 | constructor( 18 | private fb: FormBuilder, 19 | private backend: BackendService, 20 | private route: ActivatedRoute, 21 | private router: Router, 22 | private toasty: ToastyService, 23 | ) { } 24 | 25 | ngOnInit() { 26 | this.form = this.fb.group({ 27 | name: [null, RValidators.userName], 28 | email: [null, RValidators.email], 29 | password: [null, RValidators.password], 30 | }); 31 | } 32 | 33 | create() { 34 | this.backend.createUserAdmin(this.form.value) 35 | .subscribe(_ => { 36 | this.toasty.success("Create success"); 37 | this.router.navigate([".."], {relativeTo: this.route}); 38 | }); 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /web/src/app/chart/chart.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, ElementRef, OnInit, OnChanges, SimpleChanges } from '@angular/core'; 2 | 3 | import { Chart } from 'chart.js'; 4 | 5 | @Component({ 6 | selector: 'rana-chart', 7 | template: '', 8 | styles: [':host { display: block; }'] 9 | }) 10 | export class ChartComponent implements OnInit, OnChanges { 11 | chart: Chart; 12 | 13 | @Input() type: string; 14 | @Input() data: any; 15 | @Input() options: any; 16 | 17 | constructor(private elementRef: ElementRef) { } 18 | 19 | ngOnInit() { 20 | this.create(); 21 | } 22 | 23 | ngOnChanges(changes: SimpleChanges) { 24 | if (this.chart) { 25 | if (changes['type'] || changes['options']) { 26 | this.create(); 27 | } else if (changes['data']) { 28 | const currentValue = changes['data'].currentValue; 29 | ['datasets', 'labels', 'xLabels', 'yLabels'] 30 | .forEach(property => { 31 | this.chart.data[property] = currentValue[property]; 32 | }); 33 | this.chart.update(); 34 | } 35 | } 36 | } 37 | 38 | private create() { 39 | this.chart = new Chart(this.elementRef.nativeElement.querySelector('canvas'), { 40 | type: this.type, 41 | data: this.data, 42 | options: this.options 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /web/src/app/collection-stat/stat.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | 3 | import { BackendService, CollectionSumData } from '../backend.service'; 4 | 5 | import { CollectionDashboardComponent, Setup } from '../collection-dashboard/dashboard.component'; 6 | 7 | @Component({ 8 | selector: 'rana-collection-stat', 9 | templateUrl: './stat.component.html', 10 | }) 11 | export class CollectionStatComponent implements OnInit, OnDestroy { 12 | sums: CollectionSumData; 13 | 14 | dashboardSetup: Setup; 15 | setup = new Setup(); 16 | 17 | subscription: any; 18 | 19 | constructor( 20 | private backend: BackendService, 21 | private dashboard: CollectionDashboardComponent, 22 | ) { } 23 | 24 | ngOnInit() { 25 | this.dashboardSetup = this.dashboard.setup; 26 | this.getSums(this.dashboard.setup); 27 | this.subscription = this.dashboard.setup.events.subscribe(setup => { 28 | this.getSums(setup); 29 | }); 30 | } 31 | 32 | getSums(setup: Setup) { 33 | this.backend 34 | .getCollectionStatData(this.dashboard.user, setup.collectionName, setup.from, setup.to, setup.filter) 35 | .subscribe(sums => { 36 | this.sums = sums; 37 | this.setup.set(setup); 38 | }); 39 | } 40 | 41 | ngOnDestroy() { 42 | this.subscription.unsubscribe(); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /internal/db/teammates.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // GetTeammate returns the teammate by ID 8 | func GetTeammate(collection *Collection, ID uint64) *Teammate { 9 | idx := findTeammate(collection, ID) 10 | if idx == -1 { 11 | return nil 12 | } 13 | return collection.Teammates[idx] 14 | } 15 | 16 | // AddTeammate adds a Teammate to a user 17 | func AddTeammate(collection *Collection, user *User) error { 18 | idx := findTeammate(collection, user.ID) 19 | if idx != -1 { 20 | return fmt.Errorf("teammate already added") 21 | } 22 | collection.Teammates = append(collection.Teammates, &Teammate{ID: user.ID}) 23 | return UpdateCollection(collection) 24 | } 25 | 26 | // RemoveTeammate removes a teammate by email 27 | func RemoveTeammate(collection *Collection, ID uint64) error { 28 | idx := findTeammate(collection, ID) 29 | if idx == -1 { 30 | return fmt.Errorf("teammate not found") 31 | } 32 | removeTeammateByIdx(collection, idx) 33 | return UpdateCollection(collection) 34 | } 35 | 36 | func findTeammate(collection *Collection, ID uint64) int { 37 | for k, v := range collection.Teammates { 38 | if v.ID == ID { 39 | return k 40 | } 41 | } 42 | return -1 43 | } 44 | 45 | func removeTeammateByIdx(collection *Collection, idx int) { 46 | cs := collection.Teammates 47 | collection.Teammates = append(cs[:idx], cs[idx+1:]...) 48 | } 49 | -------------------------------------------------------------------------------- /web/src/app/collection-tracking/tracking.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input } from '@angular/core'; 2 | import { ActivatedRoute, Params } from '@angular/router'; 3 | 4 | import { Collection, BackendService } from '../backend.service'; 5 | 6 | @Component({ 7 | selector: 'rana-collection-tracking', 8 | templateUrl: './tracking.component.html', 9 | }) 10 | export class CollectionTrackingComponent implements OnInit { 11 | @Input() collection: Collection; 12 | trackingCode: string; 13 | 14 | constructor( 15 | private backend: BackendService, 16 | private route: ActivatedRoute, 17 | ) { } 18 | 19 | ngOnInit() { 20 | this.trackingCode = this.getTrackingCode(); 21 | } 22 | 23 | getOrigin(): string { 24 | return window.location.origin; 25 | } 26 | 27 | getTrackingCode(): string { 28 | return ` 29 | 42 | 43 | `; 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /internal/api/authtoken.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi" 8 | 9 | "github.com/soyersoyer/rightana/internal/service" 10 | ) 11 | 12 | type createTokenT struct { 13 | NameOrEmail string `json:"name_or_email"` 14 | Password string `json:"password"` 15 | } 16 | 17 | type createTokenOutT struct { 18 | ID string `json:"id"` 19 | service.UserInfoT `json:"user_info"` 20 | } 21 | 22 | func createTokenE(w http.ResponseWriter, r *http.Request) error { 23 | var input createTokenT 24 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 25 | return service.ErrInputDecodeFailed.Wrap(err) 26 | } 27 | 28 | tokenID, user, err := service.CreateAuthToken(input.NameOrEmail, input.Password) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | return respond(w, createTokenOutT{tokenID, service.UserInfoT{ 34 | ID: user.ID, 35 | Email: user.Email, 36 | Name: user.Name, 37 | Created: user.Created, 38 | IsAdmin: user.IsAdmin}, 39 | }) 40 | } 41 | 42 | var createToken = handleError(createTokenE) 43 | 44 | func deleteTokenE(w http.ResponseWriter, r *http.Request) error { 45 | tokenID := chi.URLParam(r, "token") 46 | if err := service.DeleteAuthToken(tokenID); err != nil { 47 | return err 48 | } 49 | return respond(w, tokenID) 50 | } 51 | 52 | var deleteToken = handleError(deleteTokenE) 53 | -------------------------------------------------------------------------------- /web/src/app/login/login.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Login

5 |
6 |
7 |
8 |
9 | 10 | 11 |
12 |
13 | 14 | 15 |
16 | 17 |
18 |
19 | 20 |
21 |
22 |
23 |
24 | Don't have an account? Create one 25 |
26 |
27 | Forgot password? 28 |
29 |
30 |
31 |
32 | -------------------------------------------------------------------------------- /web/src/app/login/login.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; 3 | import { Router } from '@angular/router'; 4 | 5 | import { AuthService, BackendService, ServerConfig } from '../backend.service'; 6 | import { RValidators } from '../forms/rvalidators'; 7 | 8 | @Component({ 9 | selector: 'rana-login', 10 | templateUrl: './login.component.html', 11 | }) 12 | export class LoginComponent implements OnInit { 13 | form: FormGroup; 14 | config: ServerConfig; 15 | 16 | constructor( 17 | private fb: FormBuilder, 18 | private backend: BackendService, 19 | private auth: AuthService, 20 | private router: Router, 21 | ) { } 22 | 23 | ngOnInit() { 24 | this.form = this.fb.group({ 25 | name_or_email: [null, Validators.required], 26 | password: [null, RValidators.password] 27 | }); 28 | this.getConfig(); 29 | } 30 | 31 | getConfig() { 32 | this.backend.getConfig() 33 | .subscribe(config => this.config = config); 34 | } 35 | 36 | login() { 37 | this.backend 38 | .createAuthToken(this.form.value.name_or_email, this.form.value.password) 39 | .subscribe(token => { 40 | this.auth.set(token.id, token.user_info.name, token.user_info.is_admin); 41 | this.router.navigateByUrl(token.user_info.name); 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /web/src/app/toasty/toast.component.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016-2017 Sergey Akopkokhyants 2 | // This project is licensed under the terms of the MIT license. 3 | // https://github.com/akserg/ng2-toasty 4 | 5 | import { Component, Input, Output, EventEmitter } from '@angular/core'; 6 | 7 | import { ToastData } from './toasty.service'; 8 | 9 | /** 10 | * A Toast component shows message with title and close button. 11 | */ 12 | @Component({ 13 | selector: 'ng2-toast', 14 | template: ` 15 |
16 |
17 |
18 | {{toast.title}} 19 |
20 | {{toast.msg}} 21 |
22 |
` 23 | }) 24 | export class ToastComponent { 25 | 26 | @Input() toast: ToastData; 27 | @Output('closeToast') closeToastEvent = new EventEmitter(); 28 | 29 | /** 30 | * Event handler invokes when user clicks on close button. 31 | * This method emit new event into ToastyContainer to close it. 32 | */ 33 | close($event: any) { 34 | $event.preventDefault(); 35 | this.closeToastEvent.next(this.toast); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web/src/app/admin-users/admin-users.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Users

4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 29 | 30 | 31 | 32 | 33 | 34 |
IDNameEmailCreatedCollectionsAdminEmail VerifiedEdit
{{user.id}}{{user.name}}{{user.email}}{{user.created / 1000000 | date:"yyyy.MM.dd HH:mm:ss"}} 26 | {{user.collection_count}} 27 | 28 | edit
35 |
36 | -------------------------------------------------------------------------------- /internal/geoip/geoip.go: -------------------------------------------------------------------------------- 1 | package geoip 2 | 3 | import ( 4 | "log" 5 | "net" 6 | 7 | "github.com/oschwald/geoip2-golang" 8 | ) 9 | 10 | var cityDB *geoip2.Reader 11 | var asnDB *geoip2.Reader 12 | 13 | // OpenDB opens the geoip2 databases 14 | func OpenDB(cityDBFile string, asnDBFile string) { 15 | openDB(&cityDB, cityDBFile) 16 | openDB(&asnDB, asnDBFile) 17 | } 18 | 19 | func openDB(db **geoip2.Reader, dbFile string) { 20 | var err error 21 | *db, err = geoip2.Open(dbFile) 22 | if err != nil { 23 | log.Println(err, dbFile) 24 | } 25 | } 26 | 27 | // Location contains the Geoip2 Location data 28 | type Location struct { 29 | CountryCode string 30 | City string 31 | } 32 | 33 | // LocationByIP returns the corresponding Location data 34 | func LocationByIP(ipAddr string) *Location { 35 | if cityDB != nil { 36 | ip := net.ParseIP(ipAddr) 37 | record, err := cityDB.City(ip) 38 | if err == nil { 39 | return &Location{ 40 | record.Country.IsoCode, 41 | record.City.Names["en"], 42 | } 43 | } 44 | } 45 | return &Location{} 46 | } 47 | 48 | // AS contains the Geoip2 AS data 49 | type AS struct { 50 | Number uint 51 | Name string 52 | } 53 | 54 | // ASNByIP returns the corresponding AS data 55 | func ASNByIP(ipAddr string) *AS { 56 | if asnDB != nil { 57 | ip := net.ParseIP(ipAddr) 58 | if record, err := asnDB.ASN(ip); err == nil { 59 | return &AS{ 60 | record.AutonomousSystemNumber, 61 | record.AutonomousSystemOrganization, 62 | } 63 | } 64 | } 65 | return &AS{} 66 | } 67 | -------------------------------------------------------------------------------- /internal/db/models.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package db; 3 | 4 | message User { 5 | uint64 ID = 1; 6 | string Email = 2; 7 | string Password = 3; 8 | int64 Created = 4; // unixnano 9 | string Name = 5; 10 | bool IsAdmin = 10; 11 | bool DisablePwChange = 11; 12 | bool LimitCollections = 12; 13 | uint32 CollectionLimit = 13; 14 | bool DisableUserDeletion = 14; 15 | bool EmailVerified = 20; 16 | string EmailVerificationKey = 21; 17 | int64 EmailVerificationAt = 22; // unixnano 18 | string PasswordResetKey = 23; 19 | int64 PasswordResetAt = 24; // unixnano 20 | } 21 | 22 | message Teammate { 23 | uint64 ID = 1; 24 | } 25 | 26 | message Collection { 27 | string ID = 1; 28 | uint64 OwnerID = 2; 29 | string Name = 3; 30 | repeated Teammate Teammates = 4; 31 | int64 Created = 5; // unixnano 32 | } 33 | 34 | message AuthToken { 35 | string ID = 1; 36 | uint64 OwnerID = 2; 37 | int32 TTL = 3; 38 | int64 Created = 4; // unixnano 39 | } 40 | 41 | message Session { 42 | int32 Duration = 1; 43 | string Hostname = 2; 44 | string DeviceOS = 3; 45 | string BrowserName = 4; 46 | string BrowserVersion = 5; 47 | string BrowserLanguage = 6; 48 | string ScreenResolution = 7; 49 | string WindowResolution = 8; 50 | string DeviceType = 9; 51 | string CountryCode = 10; 52 | string City = 11; 53 | string UserAgent = 12; 54 | string UserIP = 13; 55 | string UserHostname = 14; 56 | string Referrer = 15; 57 | int32 ASNumber = 16; 58 | string ASName = 17; 59 | } 60 | 61 | message Pageview { 62 | string Path = 1; 63 | string QueryString = 2; 64 | } 65 | -------------------------------------------------------------------------------- /web/src/app/collection-settings/teammates.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input } from '@angular/core'; 2 | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; 3 | 4 | import { Collection, BackendService, Teammate } from '../backend.service'; 5 | import { UserComponent } from '../user/user.component'; 6 | import { RValidators } from '../forms/rvalidators'; 7 | 8 | @Component({ 9 | selector: 'rana-collection-teammates', 10 | templateUrl: './teammates.component.html', 11 | }) 12 | export class TeammatesComponent implements OnInit { 13 | form: FormGroup; 14 | @Input() collection: Collection; 15 | teammates: Teammate[]; 16 | 17 | constructor( 18 | private backend: BackendService, 19 | private fb: FormBuilder, 20 | private user: UserComponent, 21 | ) { } 22 | 23 | ngOnInit() { 24 | this.form = this.fb.group({ 25 | email: [null, RValidators.email], 26 | }); 27 | this.getTeammates(); 28 | } 29 | 30 | getTeammates() { 31 | this.backend 32 | .getTeammates(this.user.user, this.collection.name) 33 | .subscribe(teammates => this.teammates = teammates); 34 | } 35 | 36 | add() { 37 | this.backend 38 | .addTeammate(this.user.user, this.collection.name, this.form.value.email) 39 | .subscribe(_ => { 40 | this.getTeammates(); 41 | this.form.reset(); 42 | }); 43 | } 44 | 45 | remove(email: string) { 46 | this.backend 47 | .removeTeammate(this.user.user, this.collection.name, email) 48 | .subscribe(_ => this.getTeammates()); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /web/src/app/registration/registration.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; 3 | import { Router } from '@angular/router'; 4 | 5 | import { AuthService, BackendService } from '../backend.service'; 6 | import { RValidators } from '../forms/rvalidators'; 7 | 8 | @Component({ 9 | selector: 'rana-registration', 10 | templateUrl: './registration.component.html', 11 | }) 12 | export class RegistrationComponent implements OnInit { 13 | form: FormGroup; 14 | 15 | constructor( 16 | private fb: FormBuilder, 17 | private router: Router, 18 | private auth: AuthService, 19 | private backend: BackendService, 20 | ) { } 21 | 22 | ngOnInit() { 23 | this.form = this.fb.group({ 24 | name: [null, RValidators.userName], 25 | email: [null, RValidators.email], 26 | password: [null, RValidators.password] 27 | }); 28 | } 29 | 30 | registrate() { 31 | this.backend 32 | .createUser(this.form.value) 33 | .subscribe(() => this.login()); 34 | } 35 | 36 | login() { 37 | this.backend 38 | .createAuthToken(this.form.value.name, this.form.value.password) 39 | .subscribe(token => { 40 | this.auth.set(token.id, token.user_info.name, token.user_info.is_admin); 41 | this.sendVerifyEmail(); 42 | this.router.navigateByUrl(token.user_info.name); 43 | }); 44 | } 45 | 46 | sendVerifyEmail() { 47 | this.backend 48 | .sendVerifyEmail(this.form.value.name).subscribe(); 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /internal/db/shardbolt/tx.go: -------------------------------------------------------------------------------- 1 | package shardbolt 2 | 3 | import ( 4 | "log" 5 | 6 | bolt "github.com/etcd-io/bbolt" 7 | ) 8 | 9 | type MultiTx struct { 10 | db *DB 11 | writeable bool 12 | txs []*shardTx 13 | } 14 | 15 | type shardTx struct { 16 | id string 17 | tx *bolt.Tx 18 | } 19 | 20 | func (db *DB) Begin(writeable bool) *MultiTx { 21 | return &MultiTx{db, writeable, nil} 22 | } 23 | 24 | func (tx *MultiTx) Rollback() error { 25 | var errAny error 26 | for _, v := range tx.txs { 27 | err := v.tx.Rollback() 28 | if err != nil { 29 | log.Println(err) 30 | errAny = err 31 | } 32 | } 33 | return errAny 34 | } 35 | 36 | func (tx *MultiTx) Commit() error { 37 | var errAny error 38 | for _, v := range tx.txs { 39 | err := v.tx.Commit() 40 | if err != nil { 41 | log.Println(err) 42 | errAny = err 43 | } 44 | } 45 | return errAny 46 | } 47 | 48 | func (tx *MultiTx) Put(bucket []byte, key []byte, value []byte) error { 49 | stx, err := tx.ensureTx(key) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | b, err := stx.tx.CreateBucketIfNotExists(bucket) 55 | if err != nil { 56 | return err 57 | } 58 | b.FillPercent = tx.db.options.FillPercent 59 | return b.Put(key, value) 60 | } 61 | 62 | func (tx *MultiTx) ensureTx(key []byte) (*shardTx, error) { 63 | id := tx.db.mapFn(key) 64 | for _, v := range tx.txs { 65 | if v.id == id { 66 | return v, nil 67 | } 68 | } 69 | 70 | actualShard, err := tx.db.ensureShard(key) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | btx, err := actualShard.db.Begin(tx.writeable) 76 | if err != nil { 77 | return nil, err 78 | } 79 | stx := &shardTx{id, btx} 80 | tx.txs = append(tx.txs, stx) 81 | return stx, nil 82 | } 83 | -------------------------------------------------------------------------------- /web/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 33 | 34 | -------------------------------------------------------------------------------- /internal/mail/mail.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-mail/mail" 7 | ) 8 | 9 | // SMTPConfig holds the configuration 10 | type SMTPConfig struct { 11 | Hostname string 12 | Port int 13 | User string 14 | Password string 15 | Sender string 16 | AppURL string 17 | AppName string 18 | } 19 | 20 | var ( 21 | config SMTPConfig 22 | ) 23 | 24 | // Configure sets the config variables 25 | func Configure(smtpConfig SMTPConfig) { 26 | config = smtpConfig 27 | } 28 | 29 | // SendResetPassword sends an password change email to the user 30 | func SendResetPassword(userName, recipient, displayName, resetKey string, expireMinutes int) error { 31 | body, err := getResetPasswordBody(userName, displayName, resetKey, expireMinutes) 32 | if err != nil { 33 | return err 34 | } 35 | return SendUserEmail(recipient, userName, "Reset your password", body) 36 | } 37 | 38 | // SendVerifyEmail sends an email verification request to the user 39 | func SendVerifyEmail(userName, recipient, displayName, verificationKey string) error { 40 | body, err := getVerifyEmailBody(userName, displayName, verificationKey) 41 | if err != nil { 42 | return err 43 | } 44 | return SendUserEmail(recipient, userName, "Verify your email address", body) 45 | } 46 | 47 | // SendUserEmail sends a html email to an user 48 | func SendUserEmail(recipient, name, subject, htmlBody string) error { 49 | m := mail.NewMessage() 50 | m.SetHeader("From", config.Sender) 51 | m.SetHeader("To", fmt.Sprintf("%s <%s>", name, recipient)) 52 | m.SetHeader("Subject", subject) 53 | m.SetBody("text/html", htmlBody) 54 | d := mail.NewDialer( 55 | config.Hostname, 56 | config.Port, 57 | config.User, 58 | config.Password) 59 | d.StartTLSPolicy = mail.MandatoryStartTLS 60 | 61 | return d.DialAndSend(m) 62 | } 63 | -------------------------------------------------------------------------------- /web/src/app/reset-password/reset-password.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { FormBuilder, FormGroup } from '@angular/forms'; 3 | import { ActivatedRoute, Params, Router } from '@angular/router'; 4 | 5 | import { UserComponent } from '../user/user.component'; 6 | import { BackendService, AuthService } from "../backend.service" 7 | import { RValidators } from '../forms/rvalidators'; 8 | import { ToastyService } from '../toasty/toasty.module'; 9 | 10 | @Component({ 11 | selector: 'rana-reset-password', 12 | templateUrl: './reset-password.component.html', 13 | }) 14 | export class ResetPasswordComponent implements OnInit { 15 | form: FormGroup; 16 | resetKey: string; 17 | 18 | constructor( 19 | private user: UserComponent, 20 | private fb: FormBuilder, 21 | private backend: BackendService, 22 | private auth: AuthService, 23 | private toasty: ToastyService, 24 | private route: ActivatedRoute, 25 | private router: Router, 26 | ) { } 27 | 28 | ngOnInit() { 29 | this.route.queryParams.forEach((params: Params) => { 30 | this.resetKey = params['reset_key']; 31 | }); 32 | this.form = this.fb.group({ 33 | password: [null, RValidators.password], 34 | }); 35 | } 36 | 37 | resetPassword() { 38 | this.backend 39 | .resetPassword(this.user.user, this.resetKey, this.form.value.password) 40 | .subscribe(_ => { 41 | this.login(); 42 | this.toasty.success("Password change success"); 43 | }); 44 | } 45 | 46 | login() { 47 | this.backend 48 | .createAuthToken(this.user.user, this.form.value.password) 49 | .subscribe(token => { 50 | this.auth.set(token.id, token.user_info.name, token.user_info.is_admin); 51 | this.router.navigateByUrl('/'+token.user_info.name); 52 | }); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /web/src/app/registration/registration.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Registration

5 |
6 |
7 |
8 |
9 | 10 | 11 |
12 | 13 |
14 |
15 |
16 | 17 | 18 |
19 | 20 |
21 |
22 |
23 | 24 | 25 |
26 | 27 |
28 |
29 | 30 |
31 |
32 |
33 |
34 | Already have an account? Login 35 |
36 |
37 | Forgot password? 38 |
39 |
40 |
41 |
42 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rightana-front", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "ng": "ng", 7 | "start": "ng serve --aot --proxy-config proxy.conf.json", 8 | "build": "ng build", 9 | "dist": "npm run ng build --prod", 10 | "test": "ng test", 11 | "lint": "ng lint", 12 | "e2e": "ng e2e" 13 | }, 14 | "private": true, 15 | "dependencies": { 16 | "@angular-devkit/schematics": "^0.6.1", 17 | "@angular/animations": "^6.0.0", 18 | "@angular/common": "^6.0.0", 19 | "@angular/compiler": "^6.0.0", 20 | "@angular/core": "^6.0.0", 21 | "@angular/forms": "^6.0.0", 22 | "@angular/http": "^6.0.0", 23 | "@angular/platform-browser": "^6.0.0", 24 | "@angular/platform-browser-dynamic": "^6.0.0", 25 | "@angular/router": "^6.0.0", 26 | "@ng-bootstrap/ng-bootstrap": "^2.0.0", 27 | "bootstrap": "^4.1.1", 28 | "chart.js": "^2.7.2", 29 | "core-js": "^2.5.6", 30 | "npm": "^6.0.1", 31 | "rxjs": "^6.1.0", 32 | "zone.js": "^0.8.26" 33 | }, 34 | "devDependencies": { 35 | "@angular-devkit/build-angular": "~0.6.1", 36 | "@angular/cli": "^6.0.1", 37 | "@angular/compiler-cli": "^6.0.0", 38 | "@angular/language-service": "^6.0.0", 39 | "@types/jasmine": "~2.8.7", 40 | "@types/jasminewd2": "~2.0.2", 41 | "@types/node": "^10.0.7", 42 | "codelyzer": "~4.3.0", 43 | "jasmine-core": "~3.1.0", 44 | "jasmine-spec-reporter": "~4.2.1", 45 | "karma": "~2.0.2", 46 | "karma-chrome-launcher": "~2.2.0", 47 | "karma-cli": "~1.0.1", 48 | "karma-coverage-istanbul-reporter": "^1.4.2", 49 | "karma-jasmine": "^1.1.2", 50 | "karma-jasmine-html-reporter": "^1.1.0", 51 | "protractor": "~5.3.1", 52 | "rxjs-compat": "^6.1.0", 53 | "ts-node": "~6.0.3", 54 | "tslint": "~5.10.0", 55 | "typescript": "^2.7.2" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /web/src/app/home/home.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Fork me on GitHub 5 | 6 |
7 |
8 | Rightana 9 |
10 |
11 |

RightAna

12 |

RightAna

13 |

Carefree web analytics on your server!

14 |
15 | 16 | {{serverAnnounce}} 17 |
18 |
19 |
20 |
21 |
    22 |
  • Easy to install
  • 23 |
  • Easy to use
  • 24 |
  • Easy to upgrade (guaranteed after version 1.0)
  • 25 |
  • Space efficient, fast, embedded database
  • 26 |
  • Written in Go go logo and Angular angular logo
  • 27 |
  • Open-source, hosted on github github logo
  • 28 |
29 |
30 |
31 |
    32 |
  • Visitor friendly (no popups, no cookie consent bar, no nonsense)
  • 33 |
  • You don't have to sell your visitor's data to a company
  • 34 |
  • Tracks sessions, not users
  • 35 |
  • GDPR compliant without any annoying popup
  • 36 |
37 |
38 |
39 |
40 |
41 | -------------------------------------------------------------------------------- /web/src/app/session/session.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | import { ActivatedRoute, Params, Router } from '@angular/router'; 3 | 4 | import { BackendService, Session, Pageview } from '../backend.service'; 5 | 6 | import { CollectionDashboardComponent, Setup } from '../collection-dashboard/dashboard.component'; 7 | 8 | class SessionD extends Session { 9 | pageviews?: Pageview[]; 10 | showDetails?: boolean; 11 | } 12 | 13 | @Component({ 14 | selector: 'rana-session', 15 | templateUrl: './session.component.html', 16 | }) 17 | export class SessionComponent implements OnInit, OnDestroy { 18 | sessions: SessionD[]; 19 | 20 | setup = new Setup(); 21 | 22 | subscription: any; 23 | 24 | actual = 50; 25 | 26 | constructor( 27 | private backend: BackendService, 28 | private dashboard: CollectionDashboardComponent, 29 | ) { } 30 | 31 | ngOnInit() { 32 | this.getSessions(this.dashboard.setup); 33 | this.subscription = this.dashboard.setup.events.subscribe(setup => { 34 | this.getSessions(setup); 35 | }); 36 | } 37 | 38 | ngOnDestroy() { 39 | this.subscription.unsubscribe(); 40 | } 41 | 42 | getSessions(setup: Setup) { 43 | this.backend 44 | .getSessions(this.dashboard.user, setup.collectionName, setup.from, setup.to, setup.filter) 45 | .subscribe(sessions => { 46 | this.sessions = sessions; 47 | this.actual = 50; 48 | this.setup.set(setup); 49 | }); 50 | } 51 | 52 | toggleDetails(session: SessionD) { 53 | if (!session.showDetails) { 54 | session.showDetails = true; 55 | this.getPageviews(session) 56 | } else { 57 | session.showDetails = false; 58 | } 59 | } 60 | 61 | getPageviews(session: SessionD) { 62 | this.backend 63 | .getPageviews(this.dashboard.user, this.setup.collectionName, session.key) 64 | .subscribe(pageviews => session.pageviews = pageviews); 65 | } 66 | 67 | loadMore() { 68 | this.actual += 50; 69 | } 70 | } 71 | 72 | -------------------------------------------------------------------------------- /internal/api/collect.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/soyersoyer/rightana/internal/service" 8 | ) 9 | 10 | type createSessionInputT struct { 11 | CollectionID string `json:"c"` 12 | Hostname string `json:"h"` 13 | BrowserLanguage string `json:"bl"` 14 | ScreenResolution string `json:"sr"` 15 | WindowResolution string `json:"wr"` 16 | DeviceType string `json:"dt"` 17 | Referrer string `json:"r"` 18 | } 19 | 20 | func createSessionE(w http.ResponseWriter, r *http.Request) error { 21 | var input createSessionInputT 22 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 23 | return service.ErrInputDecodeFailed.Wrap(err) 24 | } 25 | 26 | sessionKey, err := service.CreateSession(r.UserAgent(), r.RemoteAddr, service.CreateSessionInputT(input)) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | return respond(w, sessionKey) 32 | } 33 | 34 | var createSession = handleError(createSessionE) 35 | 36 | type updateSessionInputT struct { 37 | CollectionID string `json:"c"` 38 | SessionKey string `json:"s"` 39 | } 40 | 41 | func updateSessionE(w http.ResponseWriter, r *http.Request) error { 42 | var input updateSessionInputT 43 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 44 | return service.ErrInputDecodeFailed.Wrap(err) 45 | } 46 | 47 | return service.UpdateSession(r.UserAgent(), input.CollectionID, input.SessionKey) 48 | } 49 | 50 | var updateSession = handleError(updateSessionE) 51 | 52 | type createPageviewInputT struct { 53 | CollectionID string `json:"c"` 54 | SessionKey string `json:"s"` 55 | Path string `json:"p"` 56 | } 57 | 58 | func createPageviewE(w http.ResponseWriter, r *http.Request) error { 59 | var input createPageviewInputT 60 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 61 | return service.ErrInputDecodeFailed.Wrap(err) 62 | } 63 | 64 | return service.CreatePageview(r.UserAgent(), service.CreatePageviewInputT(input)) 65 | } 66 | 67 | var createPageview = handleError(createPageviewE) 68 | -------------------------------------------------------------------------------- /internal/service/authtoken.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/gofrs/uuid" 8 | "github.com/soyersoyer/rightana/internal/db" 9 | ) 10 | 11 | // AuthToken is the db's authToken struct 12 | type AuthToken = db.AuthToken 13 | 14 | // CreateAuthToken creates an AuthToken 15 | func CreateAuthToken(nameOrEmail string, password string) (string, *User, error) { 16 | var user *User 17 | var err error 18 | if strings.Contains(nameOrEmail, "@") { 19 | user, err = db.GetUserByEmail(nameOrEmail) 20 | } else { 21 | user, err = db.GetUserByName(nameOrEmail) 22 | } 23 | if err != nil || user == nil { 24 | return "", nil, ErrUserNotExist.T(nameOrEmail) 25 | } 26 | if err := compareHashAndPassword(user.Password, password); err != nil { 27 | return "", nil, ErrPasswordNotMatch 28 | } 29 | token := db.AuthToken{ 30 | ID: uuid.Must(uuid.NewV4()).String(), 31 | OwnerID: user.ID, 32 | } 33 | if err := db.InsertAuthToken(&token); err != nil { 34 | return "", nil, ErrDB.Wrap(err, token) 35 | } 36 | return token.ID, user, nil 37 | } 38 | 39 | // DeleteAuthToken deletes an AuthToken 40 | func DeleteAuthToken(tokenID string) error { 41 | if err := db.DeleteAuthToken(tokenID); err != nil { 42 | if err == db.ErrKeyNotExists { 43 | return ErrAuthtokenNotExist.T(tokenID) 44 | } 45 | return ErrDB.Wrap(err, tokenID) 46 | } 47 | return nil 48 | } 49 | 50 | // CheckAuthToken check whether the AuthToken is valid 51 | func CheckAuthToken(tokenID string) (uint64, error) { 52 | token, err := getAuthToken(tokenID) 53 | if err != nil { 54 | return 0, ErrAuthtokenExpired 55 | } 56 | 57 | expiryTime := time.Unix(0, token.Created).Add(time.Duration(token.TTL) * time.Second) 58 | if expiryTime.Before(time.Now()) { 59 | DeleteAuthToken(tokenID) 60 | return 0, ErrAuthtokenExpired 61 | } 62 | return token.OwnerID, nil 63 | } 64 | 65 | func getAuthToken(tokenID string) (*AuthToken, error) { 66 | token, err := db.GetAuthToken(tokenID) 67 | if err != nil { 68 | if err == db.ErrKeyNotExists { 69 | return nil, ErrAuthtokenNotExist.T(tokenID) 70 | } 71 | return nil, ErrDB.Wrap(err, tokenID) 72 | } 73 | return token, nil 74 | } 75 | -------------------------------------------------------------------------------- /rightana.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "gopkg.in/alecthomas/kingpin.v2" 7 | ) 8 | 9 | var ( 10 | app = kingpin.New("rightana", "Rightana - open source web analytics.") 11 | serve = app.Command("serve", "Serve") 12 | seed = app.Command("seed", "Seed") 13 | seedCollectionID = seed.Arg("id", "Collection's ID").Required().String() 14 | seedCount = seed.Arg("count", "Session Count").Required().Int() 15 | netseed = app.Command("netseed", "Network Seed") 16 | netseedServer = netseed.Arg("server", "Server address (eg http://localhost:3000)").Required().String() 17 | netseedCollectionID = netseed.Arg("id", "Collection's ID").Required().String() 18 | netseedCount = netseed.Arg("count", "Session Count").Required().Int() 19 | register = app.Command("register", "Register a new user.") 20 | registerEmail = register.Arg("email", "Email for user.").Required().String() 21 | registerName = register.Arg("name", "Username for user.").Required().String() 22 | passwd = app.Command("passwd", "Change user password") 23 | passwdName = passwd.Arg("name", "username for user.").Required().String() 24 | createCollection = app.Command("create-collection", "Create a collection") 25 | createCollectionID = createCollection.Arg("id", "Collection's ID").Required().String() 26 | createCollectionName = createCollection.Arg("name", "Collection's name").Required().String() 27 | createCollectionUser = createCollection.Arg("user", "Owner's username").Required().String() 28 | ) 29 | 30 | func main() { 31 | app.Version("0.4.1") 32 | app.UsageTemplate(kingpin.CompactUsageTemplate) 33 | 34 | switch kingpin.MustParse(app.Parse(os.Args[1:])) { 35 | case "serve": 36 | Serve() 37 | case "seed": 38 | Seed(*seedCollectionID, *seedCount) 39 | case "netseed": 40 | NetSeed(*netseedServer, *netseedCollectionID, *netseedCount) 41 | case "register": 42 | RegisterUser(*registerEmail, *registerName) 43 | case "passwd": 44 | ChangePassword(*passwdName) 45 | case "create-collection": 46 | CreateCollection(*createCollectionID, *createCollectionName, *createCollectionUser) 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /web/src/app/collection-settings/settings.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Settings

3 |
4 |
5 |

Tracking code

6 | 7 |
8 |
9 |
10 |

Basic settings

11 |
12 |
13 |
14 |
15 | 16 | 17 |
18 | 19 |
20 |
21 | 22 |
23 |
24 |
25 | 26 | 27 |

Storage

28 |
29 |
30 | {{shards.length}} shard {{allSize | bytes}} 31 |
32 |
33 |
    34 |
  • 35 |
    36 | {{shard.id}} 37 | {{shard.size | bytes}} 38 |
    39 | Delete 40 |
  • 41 |
42 |
43 |
44 |
45 |

Delete collection

46 |
47 |
48 | 49 |
50 |
51 |
52 |
53 | -------------------------------------------------------------------------------- /web/src/app/collection-dashboard/dashboard.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

{{user}} / {{collection.name}}

5 |
6 | 17 |
18 |
19 |
20 |
21 | 22 | {{setup.filter[key]}} 23 | x 24 | 25 |
26 |
27 | 28 |
29 |
30 | 31 |
32 |
33 |
34 |
35 | 36 |
37 |
38 |
39 |
40 | 41 | -------------------------------------------------------------------------------- /internal/db/db_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | var ( 11 | dir = "data" 12 | email = "soyer@irl.hu" 13 | collectionID = "AAAA" 14 | from = time.Now() 15 | to = from.Add(time.Duration(10000) * time.Hour) 16 | collection = Collection{ 17 | ID: collectionID, 18 | Name: "test.org", 19 | OwnerID: 1, 20 | } 21 | ) 22 | 23 | func TestMain(m *testing.M) { 24 | err := os.RemoveAll(dir) 25 | if err != nil { 26 | log.Fatalln(err) 27 | } 28 | err = os.Mkdir(dir, 0700) 29 | if err != nil { 30 | log.Fatalln(err) 31 | } 32 | 33 | ret := m.Run() 34 | if ret == 0 { 35 | os.RemoveAll(dir) 36 | } 37 | 38 | os.Exit(ret) 39 | } 40 | 41 | func TestUserCreate(t *testing.T) { 42 | InitDatabase(dir) 43 | 44 | user := User{ 45 | Email: email, 46 | Password: "e!", 47 | } 48 | 49 | if err := InsertUser(&user); err != nil { 50 | t.Error(err) 51 | } 52 | 53 | user2, err := GetUserByEmail(email) 54 | if err != nil { 55 | t.Error(err) 56 | } 57 | if user.Email != user2.Email || user.Password != user2.Password { 58 | t.Error(user, user2) 59 | } 60 | } 61 | 62 | func TestCollectionCreate(t *testing.T) { 63 | if err := InsertCollection(&collection); err != nil { 64 | t.Error(err) 65 | } 66 | } 67 | 68 | func TestCollectionGetByID(t *testing.T) { 69 | if _, err := GetCollection(collection.ID); err != nil { 70 | t.Error(err) 71 | } 72 | } 73 | 74 | func TestCollectionGetByName(t *testing.T) { 75 | if _, err := GetCollectionByName(collection.OwnerID, collection.Name); err != nil { 76 | t.Error(err) 77 | } 78 | } 79 | 80 | func TestSeed(t *testing.T) { 81 | Seed(from, to, collection.ID, 100000) 82 | } 83 | 84 | func TestStat(t *testing.T) { 85 | input := CollectionDataInputT{ 86 | From: from, 87 | To: to, 88 | Bucket: "hour", 89 | } 90 | 91 | start := time.Now() 92 | _, err := GetBucketSums(&collection, &input) 93 | if err != nil { 94 | t.Error(err) 95 | } 96 | elapsed := time.Since(start) 97 | log.Printf("bucketsums time: %s", elapsed) 98 | 99 | start = time.Now() 100 | _, err = GetStatistics(&collection, &input) 101 | if err != nil { 102 | t.Error(err) 103 | } 104 | elapsed = time.Since(start) 105 | log.Printf("stat time: %s", elapsed) 106 | } 107 | -------------------------------------------------------------------------------- /web/src/app/admin-users-edit/admin-users-edit.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Router, ActivatedRoute, Params } from '@angular/router'; 3 | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; 4 | 5 | 6 | import { UserInfo, UserUpdate, BackendService } from '../backend.service'; 7 | import { RValidators } from '../forms/rvalidators'; 8 | import { ToastyService } from '../toasty/toasty.module'; 9 | 10 | @Component({ 11 | selector: 'rana-admin-users-edit', 12 | templateUrl: './admin-users-edit.component.html', 13 | }) 14 | export class AdminUsersEditComponent implements OnInit { 15 | form: FormGroup; 16 | user: UserInfo; 17 | 18 | constructor( 19 | private fb: FormBuilder, 20 | private backend: BackendService, 21 | private route: ActivatedRoute, 22 | private router: Router, 23 | private toasty: ToastyService, 24 | ) { } 25 | 26 | ngOnInit() { 27 | this.form = this.fb.group({ 28 | name: [null, RValidators.userName], 29 | email: [null, RValidators.email], 30 | password: [null, RValidators.password], 31 | is_admin: [null, [Validators.required]], 32 | disable_pw_change: [null, [Validators.required]], 33 | limit_collections: [null, [Validators.required]], 34 | collection_limit: [null, [Validators.required]], 35 | disable_user_deletion: [null, [Validators.required]], 36 | email_verified: [null, [Validators.required]], 37 | }); 38 | this.route.params.forEach((params: Params) => { 39 | this.getUser(params['name']); 40 | }); 41 | } 42 | 43 | getUser(name: string) { 44 | this.backend.getUserInfo(name) 45 | .subscribe(user => { 46 | this.user = user; 47 | this.form.patchValue(user); 48 | }); 49 | } 50 | 51 | update() { 52 | this.backend.updateUser(this.user.name, this.form.value) 53 | .subscribe(_ => { 54 | this.toasty.success("Update success"); 55 | this.router.navigate(["..", this.form.value.name], {relativeTo: this.route}); 56 | }); 57 | } 58 | 59 | delete() { 60 | this.backend.deleteUserAdmin(this.user.name) 61 | .subscribe(_ => { 62 | this.toasty.success("Delete success"); 63 | this.router.navigate([".."], {relativeTo: this.route}); 64 | }); 65 | } 66 | 67 | sendVerifyEmail(user: UserInfo) { 68 | this.backend.sendVerifyEmail(user.name).subscribe(_ => this.toasty.success('Verify email sent!')); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /web/src/app/collection/collection.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

{{user}}

5 | 6 |
7 |
8 |
9 |
10 |
11 | 15 |
16 | 17 | 18 | 19 |
20 |
21 |
{{c.session_count}} sessions
22 | 23 |
24 |
25 |
{{c.pageview_count}} pageviews
26 | 27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | You don't have any collection yet. 38 |
39 |
40 | Create new 41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | 50 |
51 | -------------------------------------------------------------------------------- /internal/api/admin.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi" 8 | "github.com/soyersoyer/rightana/internal/service" 9 | ) 10 | 11 | func adminAccessHandler(next http.Handler) http.Handler { 12 | return http.HandlerFunc(handleError( 13 | func(w http.ResponseWriter, r *http.Request) error { 14 | user := getLoggedInUserCtx(r.Context()) 15 | if !user.IsAdmin { 16 | return service.ErrAccessDenied 17 | } 18 | next.ServeHTTP(w, r) 19 | return nil 20 | })) 21 | } 22 | 23 | func getUsersE(w http.ResponseWriter, r *http.Request) error { 24 | users, err := service.GetUsers() 25 | if err != nil { 26 | return err 27 | } 28 | 29 | return respond(w, users) 30 | } 31 | 32 | var getUsers = handleError(getUsersE) 33 | 34 | func createUserAdminE(w http.ResponseWriter, r *http.Request) error { 35 | var input service.CreateUserT 36 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 37 | return service.ErrInputDecodeFailed.Wrap(err) 38 | } 39 | 40 | user, err := service.CreateUser(&input) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | return respond(w, user.Email) 46 | } 47 | 48 | var createUserAdmin = handleError(createUserAdminE) 49 | 50 | func getUserInfoE(w http.ResponseWriter, r *http.Request) error { 51 | name := chi.URLParam(r, "name") 52 | user, err := service.GetUserInfo(name) 53 | if err != nil { 54 | return err 55 | } 56 | return respond(w, user) 57 | } 58 | 59 | var getUserInfo = handleError(getUserInfoE) 60 | 61 | func updateUserE(w http.ResponseWriter, r *http.Request) error { 62 | var input service.UserUpdateT 63 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 64 | return service.ErrInputDecodeFailed.Wrap(err) 65 | } 66 | 67 | name := chi.URLParam(r, "name") 68 | if err := service.UpdateUser(name, &input); err != nil { 69 | return err 70 | } 71 | 72 | return respond(w, name) 73 | } 74 | 75 | var updateUser = handleError(updateUserE) 76 | 77 | func deleteUserAdminE(w http.ResponseWriter, r *http.Request) error { 78 | name := chi.URLParam(r, "name") 79 | if err := service.DeleteUserByAdmin(name); err != nil { 80 | return err 81 | } 82 | return respond(w, name) 83 | } 84 | 85 | var deleteUserAdmin = handleError(deleteUserAdminE) 86 | 87 | func getCollectionsE(w http.ResponseWriter, r *http.Request) error { 88 | collections, err := service.GetCollections() 89 | if err != nil { 90 | return err 91 | } 92 | 93 | return respond(w, collections) 94 | } 95 | 96 | var getCollections = handleError(getCollectionsE) 97 | -------------------------------------------------------------------------------- /internal/db/shardbolt/shard.go: -------------------------------------------------------------------------------- 1 | package shardbolt 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | 8 | bolt "github.com/etcd-io/bbolt" 9 | ) 10 | 11 | type shard struct { 12 | id string 13 | db *bolt.DB 14 | } 15 | 16 | func (db *DB) openShard(shardID string) (*shard, error) { 17 | actualDB, err := bolt.Open(db.dir+"/"+shardID+".bolt", db.mode, db.options.boltOptions) 18 | if err != nil { 19 | return nil, err 20 | } 21 | return &shard{shardID, actualDB}, nil 22 | } 23 | 24 | func (s *shard) closeDB() error { 25 | return s.db.Close() 26 | } 27 | 28 | func (db *DB) getShardFileName(s *shard) string { 29 | return db.dir + "/" + s.id + ".bolt" 30 | } 31 | 32 | func getShardIDFromFilename(fname string) (string, error) { 33 | idx := strings.Index(fname, ".bolt") 34 | if idx == -1 { 35 | return "", fmt.Errorf("invalid shard filename: %v", fname) 36 | } 37 | shardID := fname[:idx] 38 | return shardID, nil 39 | } 40 | 41 | func (db *DB) getActualShard(key []byte) *shard { 42 | shards := db.getShardArray() 43 | id := db.mapFn(key) 44 | for _, v := range shards { 45 | if v.id == id { 46 | return v 47 | } 48 | } 49 | return nil 50 | } 51 | 52 | func (db *DB) getShards(fromKey []byte, toKey []byte) []*shard { 53 | fromID := db.mapFn(fromKey) 54 | toID := db.mapFn(toKey) 55 | out := []*shard{} 56 | 57 | shards := db.getShardArray() 58 | 59 | for _, v := range shards { 60 | if fromID <= v.id && v.id <= toID { 61 | out = append(out, v) 62 | } 63 | } 64 | return out 65 | } 66 | 67 | func (db *DB) createActualShard(key []byte) (*shard, error) { 68 | shardID := db.mapFn(key) 69 | newShard, err := db.openShard(shardID) 70 | if err != nil { 71 | return nil, err 72 | } 73 | shards := db.getShardArray() 74 | 75 | newShards := make(shardArray, len(shards), len(shards)+1) 76 | copy(newShards, shards) 77 | newShards = append(newShards, newShard) 78 | sortShards(newShards) 79 | db.setShardArray(newShards) 80 | return newShard, nil 81 | } 82 | 83 | func (db *DB) ensureShard(key []byte) (*shard, error) { 84 | actualShard := db.getActualShard(key) 85 | if actualShard == nil { 86 | db.shardMutex.Lock() 87 | defer db.shardMutex.Unlock() 88 | actualShard = db.getActualShard(key) 89 | if actualShard != nil { 90 | return actualShard, nil 91 | } 92 | var err error 93 | actualShard, err = db.createActualShard(key) 94 | if err != nil { 95 | return nil, err 96 | } 97 | } 98 | return actualShard, nil 99 | } 100 | 101 | func sortShards(shards shardArray) { 102 | sort.Slice(shards, func(i, j int) bool { 103 | return shards[i].id < shards[j].id 104 | }) 105 | } 106 | -------------------------------------------------------------------------------- /web/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/ 22 | import 'core-js/es6/symbol'; 23 | import 'core-js/es6/object'; 24 | import 'core-js/es6/function'; 25 | import 'core-js/es6/parse-int'; 26 | import 'core-js/es6/parse-float'; 27 | import 'core-js/es6/number'; 28 | import 'core-js/es6/math'; 29 | import 'core-js/es6/string'; 30 | import 'core-js/es6/date'; 31 | import 'core-js/es6/array'; 32 | import 'core-js/es6/regexp'; 33 | import 'core-js/es6/map'; 34 | import 'core-js/es6/weak-map'; 35 | import 'core-js/es6/set'; 36 | 37 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 38 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 39 | 40 | /** Evergreen browsers require these. **/ 41 | import 'core-js/es6/reflect'; 42 | import 'core-js/es7/reflect'; 43 | 44 | 45 | /** 46 | * Required to support Web Animations `@angular/platform-browser/animations`. 47 | * Needed for: All but Chrome, Firefox and Opera. http://caniuse.com/#feat=web-animation 48 | **/ 49 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 50 | 51 | 52 | 53 | /*************************************************************************************************** 54 | * Zone JS is required by Angular itself. 55 | */ 56 | import 'zone.js/dist/zone'; // Included with Angular CLI. 57 | 58 | 59 | 60 | /*************************************************************************************************** 61 | * APPLICATION IMPORTS 62 | */ 63 | 64 | /** 65 | * Date, currency, decimal and percent pipes. 66 | * Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10 67 | */ 68 | // import 'intl'; // Run `npm install --save intl`. 69 | /** 70 | * Need to import at least one locale-data with intl. 71 | */ 72 | // import 'intl/locale-data/jsonp/en'; 73 | -------------------------------------------------------------------------------- /web/src/app/collection-settings/settings.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Router, ActivatedRoute, Params } from '@angular/router'; 3 | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; 4 | 5 | import { Collection, Shard, BackendService } from '../backend.service'; 6 | import { UserComponent } from '../user/user.component'; 7 | import { RValidators } from '../forms/rvalidators'; 8 | import { ToastyService } from '../toasty/toasty.service'; 9 | 10 | @Component({ 11 | selector: 'rana-collection-settings', 12 | templateUrl: './settings.component.html', 13 | }) 14 | export class CollectionSettingsComponent implements OnInit { 15 | form: FormGroup; 16 | collection: Collection; 17 | 18 | shards: Shard[]; 19 | allSize: number; 20 | 21 | constructor( 22 | private backend: BackendService, 23 | private router: Router, 24 | private route: ActivatedRoute, 25 | private fb: FormBuilder, 26 | private user: UserComponent, 27 | private toasty: ToastyService, 28 | ) { } 29 | 30 | ngOnInit() { 31 | this.form = this.fb.group({ 32 | id: [null], 33 | name: [null, RValidators.collectionName], 34 | }); 35 | this.route.parent.params.forEach((params: Params) => { 36 | const collectionName = params['collectionName']; 37 | this.getCollection(collectionName); 38 | this.getCollectionShards(collectionName); 39 | }); 40 | } 41 | 42 | getCollection(collectionName: string) { 43 | this.backend 44 | .getCollection(this.user.user, collectionName) 45 | .subscribe(collection => { 46 | this.form.setValue(collection); 47 | this.collection = collection; 48 | }); 49 | } 50 | 51 | getCollectionShards(collectionName: string) { 52 | this.backend 53 | .getCollectionShards(this.user.user, collectionName) 54 | .subscribe(shards => { 55 | this.shards = shards; 56 | this.allSize = shards.reduce((a, b) => a + b.size, 0); 57 | }); 58 | } 59 | 60 | save() { 61 | this.backend.saveCollection(this.user.user, this.collection.name, this.form.value) 62 | .subscribe(_ => { 63 | this.toasty.success('Save success'); 64 | this.router.navigate(['../..', this.form.value.name, 'settings'], {relativeTo: this.route}); 65 | }); 66 | } 67 | 68 | delete() { 69 | this.backend.deleteCollection(this.user.user, this.collection.name) 70 | .subscribe(_ => { 71 | this.toasty.success('Delete success'); 72 | this.router.navigate(['../..'], {relativeTo: this.route}); 73 | }); 74 | } 75 | 76 | deleteShard(shard: Shard) { 77 | this.backend 78 | .deleteCollectionShard(this.user.user, this.collection.name, shard.id) 79 | .subscribe(_ => { 80 | this.getCollectionShards(this.collection.name); 81 | }) 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /internal/config/read.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/spf13/viper" 7 | ) 8 | 9 | // Config contains the configuration options 10 | type Config struct { 11 | Listening string 12 | GeoIPCityFile string 13 | GeoIPASNFile string 14 | DataDir string 15 | EnableRegistration bool 16 | UseBundledWebApp bool 17 | TrackingID string 18 | ServerAnnounce string 19 | Backup map[string]string 20 | AppName string 21 | AppURL string 22 | EmailExpiryMinutes int 23 | SMTPHostname string 24 | SMTPPort int 25 | SMTPUser string 26 | SMTPPassword string 27 | SMTPSender string 28 | } 29 | 30 | var ( 31 | // ActualConfig stores the last readed config value 32 | ActualConfig = Config{} 33 | file = "rightana" 34 | ) 35 | 36 | // ReadConfig reads the config file from the default locations 37 | func ReadConfig() Config { 38 | 39 | viper.AddConfigPath("/etc/rightana/") 40 | viper.AddConfigPath("$HOME/.rightana/") 41 | viper.AddConfigPath("$HOME/.config/rightana/") 42 | viper.AddConfigPath("data") 43 | viper.AddConfigPath(".") 44 | viper.SetConfigName(file) 45 | 46 | viper.SetDefault("Listening", ":3000") 47 | viper.SetDefault("GeoIPCityFile", "/var/lib/GeoIP/GeoLite2-City.mmdb") 48 | viper.SetDefault("GeoIPASNFile", "/var/lib/GeoIP/GeoLite2-ASN.mmdb") 49 | viper.SetDefault("DataDir", "data") 50 | viper.SetDefault("EnableRegistration", true) 51 | viper.SetDefault("UseBundledWebApp", true) 52 | 53 | viper.SetDefault("AppName", "RightAna") 54 | 55 | viper.SetDefault("EmailExpiryMinutes", 15) 56 | 57 | viper.SetDefault("SMTPHostname", "localhost") 58 | viper.SetDefault("SMTPPort", 25) 59 | 60 | err := viper.ReadInConfig() 61 | if err != nil { 62 | log.Println(err) 63 | } 64 | ActualConfig.Listening = viper.GetString("Listening") 65 | ActualConfig.GeoIPCityFile = viper.GetString("GeoIPCityFile") 66 | ActualConfig.GeoIPASNFile = viper.GetString("GeoIPASNFile") 67 | ActualConfig.DataDir = viper.GetString("DataDir") 68 | ActualConfig.EnableRegistration = viper.GetBool("EnableRegistration") 69 | ActualConfig.UseBundledWebApp = viper.GetBool("UseBundledWebApp") 70 | ActualConfig.TrackingID = viper.GetString("TrackingID") 71 | ActualConfig.ServerAnnounce = viper.GetString("ServerAnnounce") 72 | ActualConfig.Backup = viper.GetStringMapString("Backup") 73 | 74 | ActualConfig.AppName = viper.GetString("AppName") 75 | ActualConfig.AppURL = viper.GetString("AppURL") 76 | 77 | ActualConfig.EmailExpiryMinutes = viper.GetInt("EmailExpiryMinutes") 78 | 79 | ActualConfig.SMTPHostname = viper.GetString("SMTPHostname") 80 | ActualConfig.SMTPPort = viper.GetInt("SMTPPort") 81 | ActualConfig.SMTPUser = viper.GetString("SMTPUser") 82 | ActualConfig.SMTPSender = viper.GetString("SMTPSender") 83 | 84 | log.Printf("using config: %+v", ActualConfig) 85 | 86 | ActualConfig.SMTPPassword = viper.GetString("SMTPPassword") 87 | 88 | return ActualConfig 89 | } 90 | -------------------------------------------------------------------------------- /internal/service/error.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // The possible errors 8 | var ( 9 | ErrRegistrationDisabled = &Error{"Registration disabled", 403, "", ""} 10 | ErrInvalidEmail = &Error{"Invalid email", 400, "", ""} 11 | ErrInvalidUsername = &Error{"Invalid username", 400, "", ""} 12 | ErrInvalidCollectionName = &Error{"Invalid collection name", 400, "", ""} 13 | ErrPasswordNotMatch = &Error{"Password not match", 403, "", ""} 14 | ErrPasswordTooShort = &Error{"Password too short", 400, "", ""} 15 | ErrPasswordChangeDisabled = &Error{"Password change disabled for this account", 403, "", ""} 16 | ErrUserNotExist = &Error{"User not exist", 404, "", ""} 17 | ErrUserNameExist = &Error{"User Name exist", 403, "", ""} 18 | ErrUserEmailExist = &Error{"User Email exist", 403, "", ""} 19 | ErrUserDeletionDisabled = &Error{"User deletion disabled for this account", 403, "", ""} 20 | ErrUserIsTheLastAdmin = &Error{"User is the last admin", 403, "", ""} 21 | ErrAccessDenied = &Error{"Access denied", 403, "", ""} 22 | ErrInputDecodeFailed = &Error{"Input decode failed", 400, "", ""} 23 | ErrAuthtokenNotExist = &Error{"Authtoken not exist", 403, "", ""} 24 | ErrAuthtokenExpired = &Error{"Authtoken expired", 403, "", ""} 25 | ErrDB = &Error{"DB error", 500, "", ""} 26 | ErrBotsDontMatter = &Error{"Bots don't matter", 403, "", ""} 27 | ErrCollectionNotExist = &Error{"Collection not exist", 404, "", ""} 28 | ErrCollectionLimitExceeded = &Error{"Collection limit exceeded", 403, "", ""} 29 | ErrCollectionNameExist = &Error{"Collection name exists", 403, "", ""} 30 | ErrSessionNotExist = &Error{"Session not exist", 404, "", ""} 31 | ErrTeammateExist = &Error{"Teammate exist", 403, "", ""} 32 | ErrBackupNotExist = &Error{"Backup not exist", 404, "", ""} 33 | ErrEmailSending = &Error{"Can't send email", 500, "", ""} 34 | ErrEmailExpired = &Error{"Email expired", 403, "", ""} 35 | ) 36 | 37 | // Error is the Extended error struct 38 | type Error struct { 39 | Message string 40 | Code int 41 | Thing string 42 | Additional string 43 | } 44 | 45 | func (e *Error) Error() string { 46 | msg := e.Message 47 | if e.Thing != "" { 48 | msg += fmt.Sprintf(" (%v)", e.Thing) 49 | } 50 | if e.Additional != "" { 51 | msg += " " + e.Additional 52 | } 53 | return msg 54 | } 55 | 56 | // HTTPMessage returns the default HTTP error message 57 | func (e *Error) HTTPMessage() string { 58 | if e.Thing != "" { 59 | return fmt.Sprintf("%v (%v)", e.Message, e.Thing) 60 | } 61 | return e.Message 62 | } 63 | 64 | // T sets the thing which causes the error 65 | func (e *Error) T(thing string) *Error { 66 | return &Error{e.Message, e.Code, thing, e.Additional} 67 | } 68 | 69 | // Wrap add more information to the error 70 | func (e *Error) Wrap(v ...interface{}) *Error { 71 | return &Error{e.Message, e.Code, e.Thing, fmt.Sprint(v...)} 72 | } 73 | -------------------------------------------------------------------------------- /internal/mail/templates.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "bytes" 5 | "strconv" 6 | "text/template" 7 | "time" 8 | ) 9 | 10 | var ( 11 | verifyEmail *template.Template 12 | resetPassword *template.Template 13 | ) 14 | 15 | func init() { 16 | header := 17 | ` 18 | 19 | 20 | 21 | 22 | 23 | 24 |

Hi {{.DisplayName}},

` 25 | footer := ` 26 |

Not working? Try copying and pasting it to your browser.

27 |

© {{.YearStr}} {{.AppName}}

28 | 29 | ` 30 | 31 | verifyEmail = template.Must(template.New("verifyEmail").Parse(` 32 | {{template "header" .}} 33 |

Please click the following link to verify your email address:

34 |

{{template "verifyEmailLink" .}}

35 | {{template "footer" .}} 36 | `)) 37 | verifyEmailLink := `{{.AppURL}}/{{.UserName}}/verify-email?verification_key={{.VerificationKey}}` 38 | template.Must(verifyEmail.New("header").Parse(header)) 39 | template.Must(verifyEmail.New("verifyEmailLink").Parse(verifyEmailLink)) 40 | template.Must(verifyEmail.New("footer").Parse(footer)) 41 | 42 | resetPassword = template.Must(template.New("resetPassword").Parse(` 43 | {{template "header" .}} 44 |

Please click the following link to change your password within {{.ExpireMinutes}} minutes:

45 |

{{template "resetPasswordLink" .}}

46 | {{template "footer" .}} 47 | `)) 48 | resetPasswordLink := `{{.AppURL}}/{{.UserName}}/reset-password?reset_key={{.ResetKey}}` 49 | template.Must(resetPassword.New("header").Parse(header)) 50 | template.Must(resetPassword.New("resetPasswordLink").Parse(resetPasswordLink)) 51 | template.Must(resetPassword.New("footer").Parse(footer)) 52 | } 53 | 54 | func getResetPasswordBody(userName, displayName, resetKey string, expireMinutes int) (string, error) { 55 | type params struct { 56 | UserName string 57 | DisplayName string 58 | ResetKey string 59 | ExpireMinutes int 60 | AppURL string 61 | AppName string 62 | YearStr string 63 | } 64 | body := &bytes.Buffer{} 65 | err := resetPassword.Execute(body, ¶ms{ 66 | userName, displayName, resetKey, expireMinutes, 67 | config.AppURL, config.AppName, getYearStr()}) 68 | if err != nil { 69 | return "", err 70 | } 71 | return body.String(), nil 72 | } 73 | 74 | func getVerifyEmailBody(userName, displayName, verificationKey string) (string, error) { 75 | type params struct { 76 | UserName string 77 | DisplayName string 78 | VerificationKey string 79 | AppURL string 80 | AppName string 81 | YearStr string 82 | } 83 | body := &bytes.Buffer{} 84 | err := verifyEmail.Execute(body, ¶ms{ 85 | userName, displayName, verificationKey, 86 | config.AppURL, config.AppName, getYearStr()}) 87 | if err != nil { 88 | return "", err 89 | } 90 | return body.String(), nil 91 | } 92 | 93 | func getYearStr() string { 94 | return strconv.Itoa(time.Now().Year()) 95 | } 96 | -------------------------------------------------------------------------------- /internal/db/shardbolt/db_test.go: -------------------------------------------------------------------------------- 1 | package shardbolt 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "log" 7 | "os" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | var ( 13 | dir = "test" 14 | bucket = []byte("test") 15 | key = []byte("test") 16 | value = []byte("test") 17 | now = time.Now() 18 | mapFn = func(key []byte) string { 19 | t, err := unmarshalTime(key) 20 | if err != nil { 21 | panic(err) 22 | } 23 | return t.Format("2006-01") 24 | } 25 | ) 26 | 27 | func TestMain(m *testing.M) { 28 | err := os.RemoveAll(dir) 29 | if err != nil { 30 | log.Fatalln(err) 31 | } 32 | 33 | ret := m.Run() 34 | 35 | if ret == 0 { 36 | os.RemoveAll(dir) 37 | } 38 | 39 | os.Exit(ret) 40 | } 41 | 42 | func TestOpenNotExists(t *testing.T) { 43 | db, err := Open(dir, mapFn, 0666, nil) 44 | if err != nil { 45 | t.Error(err) 46 | } 47 | defer db.Close() 48 | } 49 | 50 | func TestPut(t *testing.T) { 51 | db, err := Open(dir, mapFn, 0666, nil) 52 | if err != nil { 53 | t.Error(err) 54 | } 55 | defer db.Close() 56 | 57 | err = db.Update(func(tx *MultiTx) error { 58 | return tx.Put(bucket, createKey(now, key), value) 59 | }) 60 | if err != nil { 61 | t.Error(err) 62 | } 63 | err = db.Update(func(tx *MultiTx) error { 64 | return tx.Put(bucket, createKey(now.Add(time.Duration(1)*time.Second), key), value) 65 | }) 66 | if err != nil { 67 | t.Error(err) 68 | } 69 | } 70 | 71 | func TestIterate(t *testing.T) { 72 | db, err := Open(dir, mapFn, 0666, nil) 73 | if err != nil { 74 | t.Error(err) 75 | } 76 | defer db.Close() 77 | 78 | count := 0 79 | db.Iterate(bucket, marshalTime(now), marshalTime(now.AddDate(0, 0, 1)), func(k []byte, v []byte) { 80 | count++ 81 | if !bytes.HasSuffix(k, key) { 82 | t.Error("bad key", k, key) 83 | } 84 | if bytes.Compare(v, value) != 0 { 85 | t.Error("bad value", v, value) 86 | } 87 | }) 88 | if count != 2 { 89 | t.Error(count) 90 | } 91 | 92 | } 93 | 94 | func TestGet(t *testing.T) { 95 | db, err := Open(dir, mapFn, 0666, nil) 96 | if err != nil { 97 | t.Error(err) 98 | } 99 | defer db.Close() 100 | 101 | v, err := db.Get(bucket, createKey(now, key)) 102 | if err != nil { 103 | t.Error(err) 104 | } 105 | if bytes.Compare(v, value) != 0 { 106 | t.Error("bad value", v, value) 107 | } 108 | 109 | } 110 | 111 | func marshalTime(t time.Time) []byte { 112 | nsec := t.UnixNano() 113 | enc := []byte{ 114 | byte(nsec >> 56), 115 | byte(nsec >> 48), 116 | byte(nsec >> 40), 117 | byte(nsec >> 32), 118 | byte(nsec >> 24), 119 | byte(nsec >> 16), 120 | byte(nsec >> 8), 121 | byte(nsec), 122 | } 123 | return enc 124 | } 125 | 126 | func unmarshalTime(data []byte) (time.Time, error) { 127 | if len(data) < 8 { 128 | return time.Time{}, errors.New("unmarshalTime: invalid length") 129 | } 130 | data = data[:8] 131 | nsec := int64(data[0])<<56 | 132 | int64(data[1])<<48 | 133 | int64(data[2])<<40 | 134 | int64(data[3])<<32 | 135 | int64(data[4])<<24 | 136 | int64(data[5])<<16 | 137 | int64(data[6])<<8 | 138 | int64(data[7]) 139 | return time.Unix(0, nsec), nil 140 | } 141 | 142 | func createKey(t time.Time, key []byte) []byte { 143 | return append(marshalTime(t), key...) 144 | } 145 | -------------------------------------------------------------------------------- /web/src/app/session/session.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Sessions {{sessions.length}}

4 | {{setup.from | date:"yyyy.MM.dd HH:mm"}} - {{setup.to | date:"yyyy.MM.dd HH:mm"}} 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 51 | 52 | 53 | 71 | 72 | 73 | 74 | 77 | 78 | 79 |
BeginDurationDeviceOSBrowserLangScreenWindowHostnameLocationAS NamePageviews
{{s.begin / 1000000 | date:"yyyy.MM.dd HH:mm:ss"}}{{s.duration/60 | number:"1.0-0"}} m{{s.device_type}}{{s.device_os}}{{s.browser_name}} {{s.browser_version}}{{s.browser_language}}{{s.screen_resolution}}{{s.window_resolution}}{{s.user_hostname}}{{s.country_code}} {{s.city}}{{s.as_name}}{{s.pageview_count}}
41 |
Begin: {{s.begin / 1000000 | date:"yyyy.MM.dd HH:mm:ss"}}
42 |
End: {{s.begin / 1000000 + s.duration*1000 | date:"yyyy.MM.dd HH:mm:ss"}}
43 |
User IP: {{s.user_ip}}
44 |
User Hostname: {{s.user_hostname}}
45 |
AS Number: {{s.as_number}}
46 |
AS Name: {{s.as_name}}
47 |
User Agent: {{s.user_agent}}
48 |
Hostname: {{s.hostname}}
49 |
Referrer: {{s.referrer}}
50 |
54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 |
TimePathQuery String
{{pv.time / 1000000 |date:"yyyy.MM.dd HH:mm:ss"}}{{pv.path}}{{pv.query_string}}
70 |
75 | load more 76 |
80 |
81 | -------------------------------------------------------------------------------- /cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "syscall" 8 | "time" 9 | 10 | "github.com/go-chi/chi" 11 | "github.com/go-chi/chi/middleware" 12 | "golang.org/x/crypto/ssh/terminal" 13 | 14 | "github.com/soyersoyer/rightana/internal/api" 15 | "github.com/soyersoyer/rightana/internal/config" 16 | "github.com/soyersoyer/rightana/internal/db" 17 | "github.com/soyersoyer/rightana/internal/geoip" 18 | "github.com/soyersoyer/rightana/internal/mail" 19 | "github.com/soyersoyer/rightana/internal/service" 20 | ) 21 | 22 | func inits() { 23 | config.ReadConfig() 24 | geoip.OpenDB(config.ActualConfig.GeoIPCityFile, config.ActualConfig.GeoIPASNFile) 25 | db.InitDatabase(config.ActualConfig.DataDir) 26 | mail.Configure(mail.SMTPConfig{ 27 | Hostname: config.ActualConfig.SMTPHostname, 28 | User: config.ActualConfig.SMTPUser, 29 | Password: config.ActualConfig.SMTPPassword, 30 | Port: config.ActualConfig.SMTPPort, 31 | Sender: config.ActualConfig.SMTPSender, 32 | AppName: config.ActualConfig.AppName, 33 | AppURL: config.ActualConfig.AppURL, 34 | }) 35 | } 36 | 37 | // Serve starts a http server 38 | func Serve() { 39 | inits() 40 | r := chi.NewRouter() 41 | r.Use(middleware.RequestID) 42 | r.Use(middleware.RealIP) 43 | r.Use(middleware.Logger) 44 | r.Use(middleware.Recoverer) 45 | r.Use(middleware.Timeout(60 * time.Second)) 46 | r.Use(middleware.DefaultCompress) 47 | 48 | api.Wire(r) 49 | 50 | log.Println("HTTP server will now start listening on", config.ActualConfig.Listening) 51 | err := http.ListenAndServe(config.ActualConfig.Listening, r) 52 | log.Fatal(err) 53 | } 54 | 55 | // Seed seed a collection with count session 56 | func Seed(trackingID string, count int) { 57 | inits() 58 | now := time.Now() 59 | start := now.AddDate(0, -int(now.Month())+1, -int(now.Day())+1) 60 | end := start.AddDate(1, 0, 0) 61 | if err := service.SeedCollection(start, end, trackingID, count); err != nil { 62 | log.Fatalln(err) 63 | } 64 | } 65 | 66 | // RegisterUser registers a new user 67 | func RegisterUser(email string, name string) { 68 | inits() 69 | config.ActualConfig.EnableRegistration = true 70 | fmt.Print("Password: ") 71 | password, err := terminal.ReadPassword(int(syscall.Stdin)) 72 | if err != nil { 73 | log.Fatalln(err) 74 | } 75 | fmt.Println("") 76 | user, err := service.CreateUser(&service.CreateUserT{ 77 | Email: email, 78 | Name: name, 79 | Password: string(password)}) 80 | if err != nil { 81 | log.Fatalln(err) 82 | } 83 | log.Println("user created:", user.Name) 84 | } 85 | 86 | // ChangePassword changes a user's password 87 | func ChangePassword(name string) { 88 | inits() 89 | user, err := service.GetUserByName(name) 90 | if err != nil { 91 | log.Fatalln(err) 92 | } 93 | fmt.Print("Password: ") 94 | password, err := terminal.ReadPassword(int(syscall.Stdin)) 95 | if err != nil { 96 | log.Fatalln(err) 97 | } 98 | fmt.Println("") 99 | err = service.ChangePasswordForce(user, string(password)) 100 | if err != nil { 101 | log.Fatalln(err) 102 | } 103 | } 104 | 105 | // CreateCollection creates a collection with name and the owner's username 106 | func CreateCollection(collectionID string, name string, user string) { 107 | inits() 108 | collection, err := service.CreateCollectionByID(collectionID, name, user) 109 | if err != nil { 110 | log.Fatalln(err) 111 | } 112 | log.Println("collection created", collection) 113 | } 114 | -------------------------------------------------------------------------------- /web/src/app/collection/collection.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, HostListener } from '@angular/core'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | 4 | import { UserComponent } from '../user/user.component'; 5 | import { BackendService, CollectionSummary, AuthService } from '../backend.service'; 6 | import { getDateStrFromUnixTime } from '../utils/date'; 7 | 8 | @Component({ 9 | selector: 'rana-collection', 10 | templateUrl: './collection.component.html', 11 | }) 12 | export class CollectionComponent implements OnInit { 13 | collections: CollectionSummary[]; 14 | 15 | timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 16 | 17 | data: { 18 | labels: string[], 19 | datasets: { 20 | label: string, 21 | borderColor: string, 22 | fill: boolean, 23 | data: {x: string, y: number}[], 24 | }[], 25 | }[]; 26 | dataTime: Date; 27 | options = { 28 | tooltips: {mode: 'index', intersect: false}, 29 | hover: {mode: 'nearest', intersect: true}, 30 | legend: {display: false}, 31 | layout: {padding: 5}, 32 | elements: {point: {pointStyle: 'star'}}, 33 | scales: { 34 | xAxes: [{ 35 | display: false, 36 | stacked: true 37 | }], 38 | yAxes: [ 39 | { 40 | id: 'session-axis', 41 | display: false, 42 | ticks: { 43 | beginAtZero: true, 44 | }, 45 | }, 46 | { 47 | id: 'page-view-axis', 48 | display: false, 49 | ticks: { 50 | beginAtZero: true, 51 | }, 52 | }, 53 | ] 54 | } 55 | }; 56 | 57 | constructor( 58 | private backend: BackendService, 59 | private route: ActivatedRoute, 60 | private userComp: UserComponent, 61 | private auth: AuthService, 62 | ) { } 63 | 64 | ngOnInit() { 65 | this.route.params.forEach(_ => { 66 | this.getCollections(this.user); 67 | }) 68 | } 69 | 70 | @HostListener('window:focus', ['$event']) 71 | onFocus(): void { 72 | const min10 = 10*60*1000; 73 | if((new Date()).getTime() > this.dataTime.getTime() + min10) { 74 | this.getCollections(this.user); 75 | } 76 | } 77 | 78 | get user(): string { 79 | return this.userComp.user; 80 | } 81 | 82 | get selfPage(): boolean { 83 | return this.user === this.auth.user 84 | } 85 | 86 | getCollections(user: string) { 87 | this.backend.getCollectionSummaries(user, this.timezone) 88 | .subscribe(collections => { 89 | this.collections = collections; 90 | this.setData(collections); 91 | }); 92 | } 93 | 94 | setData(collections: CollectionSummary[]) { 95 | this.data = collections.map(c => ({ 96 | labels: c.session_sums.map(s => getDateStrFromUnixTime(s.bucket, "day")), 97 | datasets: [ 98 | { 99 | label: 'Sessions', 100 | borderColor: 'rgba(0, 255, 0, 0.5)', 101 | fill: false, 102 | yAxisID: 'session-axis', 103 | data: c.session_sums.map(s => ({x: getDateStrFromUnixTime(s.bucket, "day"), y: s.count})), 104 | }, 105 | { 106 | label: 'Page views', 107 | borderColor: 'rgba(0, 0, 255, 0.5)', 108 | fill: false, 109 | yAxisID: 'page-view-axis', 110 | data: c.pageview_sums.map(s => ({x: getDateStrFromUnixTime(s.bucket, "day"), y: s.count})), 111 | }, 112 | ] 113 | })); 114 | this.dataTime = new Date(); 115 | } 116 | 117 | 118 | } 119 | -------------------------------------------------------------------------------- /internal/db/encode.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | proto "github.com/golang/protobuf/proto" 9 | ) 10 | 11 | // These are the bucket's names 12 | var ( 13 | BUser = []byte("User") 14 | BCollection = []byte("Collection") 15 | BSession = []byte("Session") 16 | BPageview = []byte("Pageview") 17 | BAuthToken = []byte("AuthToken") 18 | ) 19 | 20 | func bucketName(value interface{}) []byte { 21 | switch value := value.(type) { 22 | default: 23 | panic(fmt.Errorf("bucketName: invalid type: %T", value)) 24 | case *User: 25 | return BUser 26 | case *Collection: 27 | return BCollection 28 | case *Session: 29 | return BSession 30 | case *Pageview: 31 | return BPageview 32 | case *AuthToken: 33 | return BAuthToken 34 | } 35 | } 36 | 37 | func protoEncode(value interface{}) ([]byte, error) { 38 | switch value := value.(type) { 39 | default: 40 | return nil, fmt.Errorf("protoEncode: invalid type: %T %v", value, value) 41 | case string: 42 | return []byte(value), nil 43 | case uint32: 44 | return marshal(value), nil 45 | case uint64: 46 | return marshaluint64(value), nil 47 | case proto.Message: 48 | return proto.Marshal(value) 49 | } 50 | } 51 | 52 | func protoDecode(data []byte, value interface{}) error { 53 | switch value := value.(type) { 54 | default: 55 | return fmt.Errorf("protoDecode: invalid type: %T", value) 56 | case *string: 57 | *value = string(data) 58 | return nil 59 | case *uint32: 60 | var err error 61 | *value, err = unmarshal(data) 62 | return err 63 | case *uint64: 64 | var err error 65 | *value, err = unmarshaluint64(data) 66 | return err 67 | case proto.Message: 68 | return proto.Unmarshal(data, value) 69 | } 70 | } 71 | 72 | func marshal(id uint32) []byte { 73 | return []byte{ 74 | byte(id >> 24), 75 | byte(id >> 16), 76 | byte(id >> 8), 77 | byte(id), 78 | } 79 | } 80 | 81 | func marshaluint64(id uint64) []byte { 82 | return []byte{ 83 | byte(id >> 56), 84 | byte(id >> 48), 85 | byte(id >> 40), 86 | byte(id >> 32), 87 | byte(id >> 24), 88 | byte(id >> 16), 89 | byte(id >> 8), 90 | byte(id), 91 | } 92 | } 93 | 94 | func unmarshal(b []byte) (uint32, error) { 95 | if len(b) != 4 { 96 | return 0, errors.New("unmarshal uint32 invalid length") 97 | } 98 | return uint32(b[3]) | uint32(b[2])<<8 | uint32(b[1])<<16 | uint32(b[0])<<24, nil 99 | } 100 | 101 | func unmarshaluint64(b []byte) (uint64, error) { 102 | if len(b) != 8 { 103 | return 0, errors.New("unmarshal uint64 invalid length") 104 | } 105 | return uint64(b[7]) | 106 | uint64(b[6])<<8 | 107 | uint64(b[5])<<16 | 108 | uint64(b[4])<<24 | 109 | uint64(b[3])<<32 | 110 | uint64(b[2])<<40 | 111 | uint64(b[1])<<48 | 112 | uint64(b[0])<<56, nil 113 | } 114 | 115 | func marshalTime(t time.Time) []byte { 116 | nsec := t.UnixNano() 117 | enc := []byte{ 118 | byte(nsec >> 56), 119 | byte(nsec >> 48), 120 | byte(nsec >> 40), 121 | byte(nsec >> 32), 122 | byte(nsec >> 24), 123 | byte(nsec >> 16), 124 | byte(nsec >> 8), 125 | byte(nsec), 126 | } 127 | return enc 128 | } 129 | 130 | func unmarshalTime(data []byte) (time.Time, error) { 131 | if len(data) < 8 { 132 | return time.Time{}, errors.New(fmt.Sprint("unmarshalTime: invalid length ", len(data), " < 8")) 133 | } 134 | data = data[:8] 135 | nsec := int64(data[0])<<56 | 136 | int64(data[1])<<48 | 137 | int64(data[2])<<40 | 138 | int64(data[3])<<32 | 139 | int64(data[4])<<24 | 140 | int64(data[5])<<16 | 141 | int64(data[6])<<8 | 142 | int64(data[7]) 143 | return time.Unix(0, nsec), nil 144 | } 145 | -------------------------------------------------------------------------------- /web/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "arrow-return-shorthand": true, 7 | "callable-types": true, 8 | "class-name": true, 9 | "comment-format": [ 10 | true, 11 | "check-space" 12 | ], 13 | "curly": true, 14 | "eofline": true, 15 | "forin": true, 16 | "import-blacklist": [ 17 | true, 18 | "rxjs/Rx" 19 | ], 20 | "import-spacing": true, 21 | "indent": [ 22 | true, 23 | "spaces" 24 | ], 25 | "interface-over-type-literal": true, 26 | "label-position": true, 27 | "max-line-length": [ 28 | true, 29 | 140 30 | ], 31 | "member-access": false, 32 | "member-ordering": [ 33 | true, 34 | { 35 | "order": [ 36 | "static-field", 37 | "instance-field", 38 | "static-method", 39 | "instance-method" 40 | ] 41 | } 42 | ], 43 | "no-arg": true, 44 | "no-bitwise": true, 45 | "no-console": [ 46 | true, 47 | "debug", 48 | "info", 49 | "time", 50 | "timeEnd", 51 | "trace" 52 | ], 53 | "no-construct": true, 54 | "no-debugger": true, 55 | "no-duplicate-super": true, 56 | "no-empty": false, 57 | "no-empty-interface": true, 58 | "no-eval": true, 59 | "no-inferrable-types": [ 60 | true, 61 | "ignore-params" 62 | ], 63 | "no-misused-new": true, 64 | "no-non-null-assertion": true, 65 | "no-shadowed-variable": true, 66 | "no-string-literal": false, 67 | "no-string-throw": true, 68 | "no-switch-case-fall-through": true, 69 | "no-trailing-whitespace": true, 70 | "no-unnecessary-initializer": true, 71 | "no-unused-expression": true, 72 | "no-use-before-declare": true, 73 | "no-var-keyword": true, 74 | "object-literal-sort-keys": false, 75 | "one-line": [ 76 | true, 77 | "check-open-brace", 78 | "check-catch", 79 | "check-else", 80 | "check-whitespace" 81 | ], 82 | "prefer-const": true, 83 | "quotemark": [ 84 | true, 85 | "single" 86 | ], 87 | "radix": true, 88 | "semicolon": [ 89 | true, 90 | "always" 91 | ], 92 | "triple-equals": [ 93 | true, 94 | "allow-null-check" 95 | ], 96 | "typedef-whitespace": [ 97 | true, 98 | { 99 | "call-signature": "nospace", 100 | "index-signature": "nospace", 101 | "parameter": "nospace", 102 | "property-declaration": "nospace", 103 | "variable-declaration": "nospace" 104 | } 105 | ], 106 | "typeof-compare": true, 107 | "unified-signatures": true, 108 | "variable-name": false, 109 | "whitespace": [ 110 | true, 111 | "check-branch", 112 | "check-decl", 113 | "check-operator", 114 | "check-separator", 115 | "check-type" 116 | ], 117 | "directive-selector": [ 118 | true, 119 | "attribute", 120 | "rana", 121 | "camelCase" 122 | ], 123 | "component-selector": [ 124 | true, 125 | "element", 126 | "rana", 127 | "kebab-case" 128 | ], 129 | "use-input-property-decorator": true, 130 | "use-output-property-decorator": true, 131 | "use-host-property-decorator": true, 132 | "no-input-rename": true, 133 | "no-output-rename": true, 134 | "use-life-cycle-interface": true, 135 | "use-pipe-transform-interface": true, 136 | "component-class-suffix": true, 137 | "directive-class-suffix": true, 138 | "invoke-injectable": true 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /web/src/app/admin-users-edit/admin-users-edit.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Edit User

3 |
4 |
5 |
6 |
7 | 8 | 9 |
10 | 11 |
12 |
13 |
14 | 15 | 16 |
17 | 18 |
19 |
20 |
21 | 22 | 23 | Leave it empty to remain unchanged. 24 |
25 |
26 | 27 | 28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 | 40 | 41 |
42 |
43 |
44 | 45 | 46 |
47 |
48 | 49 | 50 |
51 |
52 | 53 |
54 |
55 |
56 |

Delete user

57 |
58 |
59 | 62 | 63 |
64 |
65 |
66 | -------------------------------------------------------------------------------- /web/src/app/collection-stat/stat.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Statistics

4 | {{setup.from | date:"yyyy.MM.dd HH:mm"}} - {{setup.to | date:"yyyy.MM.dd HH:mm"}} 5 |
6 |
7 |
8 |
9 |
Sessions
10 | {{sums.session_total.count}} 11 | 12 |
13 |
14 |
15 |
16 |
Pageviews
17 | {{sums.pageview_total.count}} 18 | 19 |
20 |
21 |
22 |
23 |
Average session length
24 | {{sums.avg_session_length.count/60 | number:"1.0-0"}} m 25 | 26 |
27 |
28 |
29 |
30 |
Bounce rate
31 | {{sums.bounce_rate.percent | percent:"1.1-1"}} 32 | 33 |
34 |
35 |
36 |
37 |
38 |

Pages

39 | 40 | 41 |

Query strings

42 | 43 |
44 |

Referrers

45 | 46 |

Hosts

47 | 48 |
49 |
50 |

Pageviews per session

51 | 52 |

Screen resolutions

53 | 54 |

Window resolutions

55 | 56 |
57 |
58 |

Countries

59 | 60 |

Cities

61 | 62 |

AS Names

63 | 64 |
65 |
66 |
67 |
68 |

Device Types

69 | 70 |

Device OS

71 | 72 |

Browsers

73 | 74 | 75 |

Browser versions

76 | 77 |
78 |

Browser Languages

79 | 80 |
81 |
82 |
83 | -------------------------------------------------------------------------------- /web/src/tracker.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | window.rightana = function() { 4 | var sessionStorageKey = 'rightana-session-key', 5 | trackerUrl = '', 6 | collectionId = '', 7 | debug = false, 8 | 9 | setup = function(trackerUrl_, collectionId_, debug_) { 10 | trackerUrl = trackerUrl_; 11 | collectionId = collectionId_; 12 | debug = debug_ || false; 13 | }, 14 | 15 | trackPageview = function() { 16 | if (navigator.doNotTrack === '1') { 17 | return; 18 | } 19 | getSessionKey(sendPageView); 20 | }, 21 | 22 | getSessionKey = function(cb) { 23 | var sessionKey = sessionStorage[sessionStorageKey]; 24 | if (sessionKey === undefined) { 25 | getSessionKeyFromServer(cb); 26 | } else { 27 | cb(); 28 | } 29 | }, 30 | 31 | getSessionKeyFromServer = function(cb) { 32 | var d = { 33 | c: collectionId, 34 | h: location.hostname, 35 | bl: navigator.language, 36 | sr: screen.width + 'x' + screen.height, 37 | wr: window.innerWidth + 'x' + window.innerHeight, 38 | dt: getDeviceType(), 39 | r: document.referrer, 40 | } 41 | postDataTo(d, '/sessions', true, function(response) { 42 | var key = JSON.parse(response); 43 | sessionStorage[sessionStorageKey] = key; 44 | if (debug) { 45 | console.log('session key:', key) 46 | } 47 | cb(); 48 | }); 49 | }, 50 | 51 | sendPageView = function() { 52 | var sessionKey = sessionStorage[sessionStorageKey]; 53 | if (!sessionKey) { 54 | return; 55 | } 56 | // get the path or canonical 57 | var path = location.pathname + location.search; 58 | var canonical = document.querySelector('link[rel="canonical"]'); 59 | if (canonical && canonical.href) { 60 | path = canonical.href.substring(canonical.href.indexOf('/', 7)) || '/'; 61 | } 62 | 63 | var d = { 64 | c: collectionId, 65 | s: sessionKey, 66 | p: path, 67 | }; 68 | postDataTo(d, '/pageviews', true, function() { 69 | if (debug) { 70 | console.log('post to pageviews success', path); 71 | } 72 | }); 73 | }, 74 | 75 | updateSessionEnd = function() { 76 | var sessionKey = sessionStorage[sessionStorageKey]; 77 | if (!sessionKey) { 78 | return; 79 | } 80 | var d = { 81 | c: collectionId, 82 | s: sessionKey, 83 | }; 84 | postDataTo(d, '/sessions/update', false, function() { 85 | if (debug) { 86 | console.log('session updated'); 87 | } 88 | }); 89 | }, 90 | 91 | postDataTo = function(data, url, async, cb) { 92 | var httpRequest = new XMLHttpRequest(); 93 | 94 | if (!httpRequest) { 95 | console.log('Giving up :( Cannot create an XMLHTTP instance'); 96 | return false; 97 | } 98 | httpRequest.onreadystatechange = function() { 99 | if (httpRequest.readyState === XMLHttpRequest.DONE) { 100 | if (httpRequest.status === 200) { 101 | if (cb) { 102 | cb(httpRequest.responseText); 103 | } 104 | } else { 105 | console.log('failed to post ', data, ' to', url, httpRequest); 106 | } 107 | } 108 | } 109 | httpRequest.open('POST', trackerUrl + url, async); 110 | httpRequest.setRequestHeader('content-type', 'application/json'); 111 | httpRequest.send(JSON.stringify(data)); 112 | }, 113 | 114 | getDeviceType = function() { 115 | var ua = navigator.userAgent, 116 | tablet = /Tablet|iPad/i.test(ua), 117 | mobile = typeof orientation !== 'undefined' || /mobile/i.test(ua); 118 | return tablet ? 'tablet' : mobile ? 'mobile' : 'desktop'; 119 | }, 120 | 121 | commands = { 122 | 'trackPageview': trackPageview, 123 | 'setup': setup, 124 | }, 125 | 126 | processCommands = function() { 127 | var args = [].slice.call(arguments); 128 | var c = args.shift(); 129 | commands[c].apply(this, args); 130 | }; 131 | 132 | window.addEventListener('beforeunload', function(event) { 133 | updateSessionEnd(); 134 | }); 135 | 136 | (window.rightana && window.rightana.q || []).forEach(function(i) { 137 | processCommands.apply(this, i); 138 | }); 139 | 140 | return processCommands; 141 | }(); 142 | 143 | // for compatibility 144 | window.k20a = function() { 145 | (window.k20a && window.k20a.q || []).forEach(function(i) { 146 | window.rightana.apply(this, i); 147 | }); 148 | return window.rightana; 149 | }(); 150 | -------------------------------------------------------------------------------- /web/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "rightana-front": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "architect": { 11 | "build": { 12 | "builder": "@angular-devkit/build-angular:browser", 13 | "options": { 14 | "outputPath": "dist", 15 | "index": "src/index.html", 16 | "main": "src/main.ts", 17 | "tsConfig": "src/tsconfig.app.json", 18 | "polyfills": "src/polyfills.ts", 19 | "assets": [ 20 | "src/assets", 21 | "src/favicon.ico", 22 | "src/tracker.js" 23 | ], 24 | "styles": [ 25 | "src/styles.scss" 26 | ], 27 | "scripts": [ 28 | "node_modules/chart.js/dist/Chart.bundle.min.js" 29 | ] 30 | }, 31 | "configurations": { 32 | "production": { 33 | "optimization": true, 34 | "outputHashing": "all", 35 | "sourceMap": false, 36 | "extractCss": true, 37 | "namedChunks": false, 38 | "aot": true, 39 | "extractLicenses": true, 40 | "vendorChunk": false, 41 | "buildOptimizer": true, 42 | "fileReplacements": [ 43 | { 44 | "replace": "src/environments/environment.ts", 45 | "with": "src/environments/environment.prod.ts" 46 | } 47 | ] 48 | } 49 | } 50 | }, 51 | "serve": { 52 | "builder": "@angular-devkit/build-angular:dev-server", 53 | "options": { 54 | "browserTarget": "rightana-front:build" 55 | }, 56 | "configurations": { 57 | "production": { 58 | "browserTarget": "rightana-front:build:production" 59 | } 60 | } 61 | }, 62 | "extract-i18n": { 63 | "builder": "@angular-devkit/build-angular:extract-i18n", 64 | "options": { 65 | "browserTarget": "rightana-front:build" 66 | } 67 | }, 68 | "test": { 69 | "builder": "@angular-devkit/build-angular:karma", 70 | "options": { 71 | "main": "src/test.ts", 72 | "karmaConfig": "./karma.conf.js", 73 | "polyfills": "src/polyfills.ts", 74 | "tsConfig": "src/tsconfig.spec.json", 75 | "scripts": [ 76 | "node_modules/chart.js/dist/Chart.bundle.min.js" 77 | ], 78 | "styles": [ 79 | "src/styles.scss" 80 | ], 81 | "assets": [ 82 | "src/assets", 83 | "src/favicon.ico", 84 | "src/tracker.js" 85 | ] 86 | } 87 | }, 88 | "lint": { 89 | "builder": "@angular-devkit/build-angular:tslint", 90 | "options": { 91 | "tsConfig": [ 92 | "src/tsconfig.app.json", 93 | "src/tsconfig.spec.json" 94 | ], 95 | "exclude": [ 96 | "**/node_modules/**" 97 | ] 98 | } 99 | } 100 | } 101 | }, 102 | "rana-front-e2e": { 103 | "root": "", 104 | "sourceRoot": "", 105 | "projectType": "application", 106 | "architect": { 107 | "e2e": { 108 | "builder": "@angular-devkit/build-angular:protractor", 109 | "options": { 110 | "protractorConfig": "./protractor.conf.js", 111 | "devServerTarget": "rightana-front:serve" 112 | } 113 | }, 114 | "lint": { 115 | "builder": "@angular-devkit/build-angular:tslint", 116 | "options": { 117 | "tsConfig": [ 118 | "e2e/tsconfig.e2e.json" 119 | ], 120 | "exclude": [ 121 | "**/node_modules/**" 122 | ] 123 | } 124 | } 125 | } 126 | } 127 | }, 128 | "defaultProject": "rightana-front", 129 | "schematics": { 130 | "@schematics/angular:component": { 131 | "prefix": "rana", 132 | "styleext": "css" 133 | }, 134 | "@schematics/angular:directive": { 135 | "prefix": "rana" 136 | } 137 | } 138 | } -------------------------------------------------------------------------------- /netseed.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "math/rand" 9 | "net/http" 10 | "sync" 11 | "time" 12 | ) 13 | 14 | type empty struct{} 15 | 16 | var userAgents = []string{ 17 | "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/56.0", 18 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36", 19 | "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/37.0.2049.0 Safari/537.36", 20 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", 21 | } 22 | 23 | var deviceTypes = []string{ 24 | "mobile", 25 | "desktop", 26 | "tablet", 27 | } 28 | 29 | var browserLanguages = []string{ 30 | "en-US", 31 | "en-US", 32 | "nl-NL", 33 | "fr-FR", 34 | "de-DE", 35 | "es-ES", 36 | "hu-HU", 37 | } 38 | 39 | var resolutions = []string{ 40 | "2560x1440", 41 | "1920x1080", 42 | "1920x1080", 43 | "360x640", 44 | } 45 | 46 | var urls = []string{ 47 | "dl", 48 | "ld?q=22222", 49 | "ndl;matrixnotation=true", 50 | "hdl", 51 | } 52 | 53 | var referrers = []string{ 54 | "https://google.com", 55 | "https://google.com/?q=longlonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglsearch", 56 | "https://yahoo.com", 57 | "https://bing.com", 58 | "https://wikipedia.org", 59 | } 60 | 61 | var userHostnames = []string{ 62 | "localhost", 63 | "catv-176-63-166-75.catv.broadband.hu.", 64 | "telekom.hu", 65 | "digi.hu", 66 | } 67 | 68 | // NetSeed seeds a collection over the network 69 | func NetSeed(serverURL, collectionID string, n int) { 70 | defer trace("seed")() 71 | log.Println("seeding:", serverURL, "id:", collectionID, "with n:", n) 72 | var wg sync.WaitGroup 73 | var tokens = make(chan empty, 300) 74 | for i := 0; i < n; i++ { 75 | wg.Add(1) 76 | go func(i int) { 77 | defer wg.Done() 78 | tokens <- empty{} 79 | sessionID := createSession(serverURL, collectionID) 80 | for j := 0; j < (i%10)+1; j++ { 81 | createPageview(serverURL, collectionID, sessionID) 82 | } 83 | <-tokens 84 | }(i) 85 | } 86 | 87 | wg.Wait() 88 | } 89 | 90 | type createSessionInput struct { 91 | CollectionID string `json:"c"` 92 | Hostname string `json:"h"` 93 | BrowserLanguage string `json:"bl"` 94 | ScreenResolution string `json:"sr"` 95 | WindowResolution string `json:"wr"` 96 | DeviceType string `json:"dt"` 97 | Referrer string `json:"r"` 98 | } 99 | 100 | func createSession(serverURL, collectionID string) string { 101 | createSession := createSessionInput{ 102 | collectionID, 103 | "localhost", 104 | randElem(browserLanguages), 105 | randElem(resolutions), 106 | randElem(resolutions), 107 | randElem(deviceTypes), 108 | randElem(referrers), 109 | } 110 | 111 | b, _ := json.Marshal(createSession) 112 | 113 | req, err := http.NewRequest("POST", serverURL+"/api/sessions", bytes.NewBuffer(b)) 114 | if err != nil { 115 | log.Fatalln(err) 116 | } 117 | req.Header.Add("x-real-ip", fmt.Sprintf("95.85.%d.%d", randInt(1, 254), randInt(1, 254))) 118 | req.Header.Set("user-agent", randElem(userAgents)) 119 | 120 | client := &http.Client{} 121 | resp, err := client.Do(req) 122 | if err != nil { 123 | log.Fatalln(err) 124 | } 125 | defer resp.Body.Close() 126 | 127 | var sessionID string 128 | if err := json.NewDecoder(resp.Body).Decode(&sessionID); err != nil { 129 | log.Fatalln(err) 130 | } 131 | return sessionID 132 | } 133 | 134 | type createPageviewInputT struct { 135 | CollectionID string `json:"c"` 136 | SessionKey string `json:"s"` 137 | Path string `json:"p"` 138 | } 139 | 140 | func createPageview(serverURL, collectionID, sessionID string) { 141 | input := &createPageviewInputT{ 142 | collectionID, 143 | sessionID, 144 | randElem(urls), 145 | } 146 | b, _ := json.Marshal(input) 147 | 148 | req, err := http.NewRequest("POST", serverURL+"/api/pageviews", bytes.NewBuffer(b)) 149 | if err != nil { 150 | log.Fatalln(err) 151 | } 152 | client := &http.Client{} 153 | resp, err := client.Do(req) 154 | if err != nil { 155 | log.Fatalln(err) 156 | } 157 | defer resp.Body.Close() 158 | } 159 | 160 | func randInt(min int, max int) int { 161 | return min + rand.Intn(max-min) 162 | } 163 | 164 | func randElem(list []string) string { 165 | return list[rand.Intn(len(list))] 166 | } 167 | 168 | func trace(msg string) func() { 169 | start := time.Now() 170 | log.Printf("enter %s", msg) 171 | return func() { log.Printf("exit %s (%s)", msg, time.Since(start)) } 172 | } 173 | -------------------------------------------------------------------------------- /internal/db/seed.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math/rand" 7 | "strings" 8 | "time" 9 | 10 | "github.com/mssola/user_agent" 11 | 12 | "github.com/soyersoyer/rightana/internal/geoip" 13 | ) 14 | 15 | var userAgents = []string{ 16 | "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/56.0", 17 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36", 18 | "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/37.0.2049.0 Safari/537.36", 19 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", 20 | } 21 | 22 | var deviceTypes = []string{ 23 | "mobile", 24 | "desktop", 25 | "tablet", 26 | } 27 | 28 | var browserLanguages = []string{ 29 | "en-US", 30 | "en-US", 31 | "nl-NL", 32 | "fr-FR", 33 | "de-DE", 34 | "es-ES", 35 | "hu-HU", 36 | } 37 | 38 | var resolutions = []string{ 39 | "2560x1440", 40 | "1920x1080", 41 | "1920x1080", 42 | "360x640", 43 | } 44 | 45 | var urls = []string{ 46 | "dl", 47 | "ld?q=22222", 48 | "ndl;matrixnotation=true", 49 | "hdl", 50 | } 51 | 52 | var referrers = []string{ 53 | "https://google.com", 54 | "https://google.com/?q=longlonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglsearch", 55 | "https://yahoo.com", 56 | "https://bing.com", 57 | "https://wikipedia.org", 58 | } 59 | 60 | var userHostnames = []string{ 61 | "localhost", 62 | "catv-176-63-166-75.catv.broadband.hu.", 63 | "telekom.hu", 64 | "digi.hu", 65 | } 66 | 67 | // Seed seeds a collection with n session 68 | func Seed(from time.Time, to time.Time, collectionID string, n int) error { 69 | rand.Seed(time.Now().UTC().UnixNano()) 70 | start := time.Now() 71 | collection, err := GetCollection(collectionID) 72 | if err != nil { 73 | return err 74 | } 75 | fmt.Println("seeding from:", from, "to:", to, "n:", n) 76 | sdb, err := getShardDB(collectionID) 77 | if err != nil { 78 | return err 79 | } 80 | tx := sdb.Begin(true) 81 | for i := 0; i < n; i++ { 82 | sessionID := rand.Uint32() 83 | userAgent := randElem(userAgents) 84 | ua := user_agent.New(userAgent) 85 | browserName, browserVersion := ua.Browser() 86 | ip := fmt.Sprintf("95.85.%d.%d", randInt(1, 254), randInt(1, 254)) 87 | location := geoip.LocationByIP(ip) 88 | asn := geoip.ASNByIP(ip) 89 | host := collection.Name 90 | duration := time.Duration(to.Sub(from).Seconds()/float64(n)*float64(i)) * time.Second 91 | tfrom := from.Add(duration) 92 | randomSessionDuration := rand.Intn(7200) 93 | if i%100 == 0 { 94 | fmt.Printf("\r%d", i) 95 | } 96 | 97 | session := &Session{ 98 | Duration: int32(randomSessionDuration), 99 | Hostname: host, 100 | DeviceOS: ua.OS(), 101 | UserIP: ip, 102 | UserHostname: randElem(userHostnames), 103 | BrowserName: browserName, 104 | BrowserVersion: browserVersion, 105 | BrowserLanguage: randElem(browserLanguages), 106 | ScreenResolution: randElem(resolutions), 107 | WindowResolution: randElem(resolutions), 108 | DeviceType: randElem(deviceTypes), 109 | CountryCode: location.CountryCode, 110 | City: location.City, 111 | ASNumber: int32(asn.Number), 112 | ASName: asn.Name, 113 | UserAgent: userAgent, 114 | Referrer: randElem(referrers), 115 | } 116 | sessionKey := GetKey(tfrom, sessionID) 117 | if err := ShardUpsertTx(tx, sessionKey, session); err != nil { 118 | return fmt.Errorf("session %v insert error err: %v session: %v t %v id %v", i, err, session, tfrom, sessionID) 119 | } 120 | for j := 0; j < i%10+1; j++ { 121 | pvfrom := tfrom.Add(time.Duration(j) * time.Minute) 122 | path, queryString := splitURL(randElem(urls)) 123 | pageview := &Pageview{ 124 | Path: path, 125 | QueryString: queryString, 126 | } 127 | if err := ShardUpsertTx(tx, GetPVKey(sessionKey, pvfrom), pageview); err != nil { 128 | return fmt.Errorf("pageview %v %v insert error err: %v pv %v", i, j, err, pageview) 129 | } 130 | } 131 | 132 | } 133 | err = tx.Commit() 134 | if err != nil { 135 | return err 136 | } 137 | fmt.Println("") 138 | elapsed := time.Since(start) 139 | log.Printf("seeding time: %s", elapsed) 140 | return nil 141 | } 142 | 143 | func randInt(min int, max int) int { 144 | return min + rand.Intn(max-min) 145 | } 146 | 147 | func randElem(list []string) string { 148 | return list[rand.Intn(len(list))] 149 | } 150 | 151 | /* TODO - remove from here */ 152 | func splitURL(url string) (path, queryString string) { 153 | idx := strings.IndexAny(url, "?;") 154 | if idx < 0 { 155 | return url, "" 156 | } 157 | return url[:idx], url[idx+1:] 158 | } 159 | -------------------------------------------------------------------------------- /internal/service/session.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/base64" 5 | "math/rand" 6 | "net" 7 | "strings" 8 | "time" 9 | 10 | "github.com/mssola/user_agent" 11 | 12 | "github.com/soyersoyer/rightana/internal/db" 13 | "github.com/soyersoyer/rightana/internal/geoip" 14 | ) 15 | 16 | // CreateSessionInputT is the struct for creating a session 17 | type CreateSessionInputT struct { 18 | CollectionID string 19 | Hostname string 20 | BrowserLanguage string 21 | ScreenResolution string 22 | WindowResolution string 23 | DeviceType string 24 | Referrer string 25 | } 26 | 27 | // CreateSession creates a session 28 | func CreateSession(userAgent string, remoteAddr string, input CreateSessionInputT) (string, error) { 29 | now := time.Now() 30 | ua := user_agent.New(userAgent) 31 | 32 | if ua.Bot() { 33 | return "", ErrBotsDontMatter 34 | } 35 | 36 | ip, err := getIP(remoteAddr) 37 | if err != nil { 38 | return "", err 39 | } 40 | 41 | browserName, browserVersion := ua.Browser() 42 | 43 | location := geoip.LocationByIP(ip) 44 | asn := geoip.ASNByIP(ip) 45 | 46 | userHostname := "" 47 | userHostnames, _ := net.LookupAddr(ip) 48 | if len(userHostnames) > 0 { 49 | userHostname = userHostnames[0] 50 | } 51 | 52 | collection, err := db.GetCollection(input.CollectionID) 53 | if err != nil { 54 | if err == db.ErrKeyNotExists { 55 | return "", ErrCollectionNotExist.T(input.CollectionID).Wrap(err) 56 | } 57 | return "", ErrDB.Wrap(err, input.CollectionID) 58 | } 59 | 60 | session := &db.Session{ 61 | Hostname: input.Hostname, 62 | UserIP: ip, 63 | UserHostname: userHostname, 64 | DeviceOS: ua.OS(), 65 | BrowserName: browserName, 66 | BrowserVersion: browserVersion, 67 | BrowserLanguage: input.BrowserLanguage, 68 | ScreenResolution: input.ScreenResolution, 69 | WindowResolution: input.WindowResolution, 70 | DeviceType: input.DeviceType, 71 | CountryCode: location.CountryCode, 72 | City: location.City, 73 | ASNumber: int32(asn.Number), 74 | ASName: asn.Name, 75 | UserAgent: userAgent, 76 | Duration: 0, 77 | Referrer: input.Referrer, 78 | } 79 | key := db.GetKey(now, rand.Uint32()) 80 | if err := db.ShardUpsertBatch(collection.ID, key, session); err != nil { 81 | return "", ErrDB.Wrap(err, session) 82 | } 83 | sessionKey := db.EncodeSessionKey(key) 84 | return sessionKey, nil 85 | } 86 | 87 | // UpdateSession updates the session.End field 88 | func UpdateSession(userAgent string, CollectionID string, sessionKey string) error { 89 | ua := user_agent.New(userAgent) 90 | 91 | if ua.Bot() { 92 | return ErrBotsDontMatter 93 | } 94 | 95 | key, err := db.DecodeSessionKey(sessionKey) 96 | if err != nil { 97 | return ErrSessionNotExist.T(sessionKey).Wrap(err) 98 | } 99 | session, err := db.GetSession(CollectionID, key) 100 | if err != nil { 101 | return ErrSessionNotExist.T(sessionKey).Wrap(err, CollectionID) 102 | } 103 | sessionBegin := db.GetTimeFromKey(key) 104 | session.Duration = int32(time.Now().Sub(sessionBegin).Seconds()) 105 | 106 | if err := db.ShardUpsertBatch(CollectionID, key, session); err != nil { 107 | return ErrDB.Wrap(err, CollectionID, key, session) 108 | } 109 | return nil 110 | } 111 | 112 | // CreatePageviewInputT is the input for the CreatePageView 113 | type CreatePageviewInputT struct { 114 | CollectionID string 115 | SessionKey string 116 | Path string 117 | } 118 | 119 | // CreatePageview creates a pageview 120 | func CreatePageview(userAgent string, input CreatePageviewInputT) error { 121 | now := time.Now() 122 | ua := user_agent.New(userAgent) 123 | 124 | if ua.Bot() { 125 | return ErrBotsDontMatter 126 | } 127 | 128 | sessKey, err := base64.StdEncoding.DecodeString(input.SessionKey) 129 | if err != nil { 130 | return ErrSessionNotExist.T(input.SessionKey).Wrap(err, input.SessionKey) 131 | } 132 | _, err = db.GetSession(input.CollectionID, sessKey) 133 | if err != nil { 134 | return ErrSessionNotExist.T(input.SessionKey).Wrap(err, input.CollectionID) 135 | } 136 | 137 | pvKey := db.GetPVKey(sessKey, now) 138 | 139 | path, queryString := splitURL(input.Path) 140 | 141 | pageview := &db.Pageview{ 142 | Path: path, 143 | QueryString: queryString, 144 | } 145 | 146 | if err := db.ShardUpsertBatch(input.CollectionID, pvKey, pageview); err != nil { 147 | return ErrDB.Wrap(err, input) 148 | } 149 | return nil 150 | } 151 | 152 | func getIP(remoteAddr string) (string, error) { 153 | if i := strings.IndexRune(remoteAddr, ':'); i < 0 { 154 | return remoteAddr, nil 155 | } 156 | ip, _, err := net.SplitHostPort(remoteAddr) 157 | return ip, err 158 | } 159 | 160 | func splitURL(url string) (path, queryString string) { 161 | idx := strings.IndexAny(url, "?;") 162 | if idx < 0 { 163 | return url, "" 164 | } 165 | return url[:idx], url[idx+1:] 166 | } 167 | -------------------------------------------------------------------------------- /internal/api/user.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | 8 | "github.com/go-chi/chi" 9 | 10 | "github.com/soyersoyer/rightana/internal/service" 11 | ) 12 | 13 | func registerUserE(w http.ResponseWriter, r *http.Request) error { 14 | var input service.CreateUserT 15 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 16 | return service.ErrInputDecodeFailed.Wrap(err) 17 | } 18 | 19 | user, err := service.RegisterUser(&input) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | return respond(w, user.Email) 25 | } 26 | 27 | var registerUser = handleError(registerUserE) 28 | 29 | func setUserCtx(ctx context.Context, user *service.User) context.Context { 30 | return context.WithValue(ctx, keyUser, user) 31 | } 32 | 33 | func getUserCtx(ctx context.Context) *service.User { 34 | return ctx.Value(keyUser).(*service.User) 35 | } 36 | 37 | func userBaseHandler(next http.Handler) http.Handler { 38 | return http.HandlerFunc(handleError( 39 | func(w http.ResponseWriter, r *http.Request) error { 40 | name := chi.URLParam(r, "name") 41 | user, err := service.GetUserByName(name) 42 | if err != nil { 43 | return err 44 | } 45 | ctx := setUserCtx(r.Context(), user) 46 | next.ServeHTTP(w, r.WithContext(ctx)) 47 | return nil 48 | })) 49 | } 50 | 51 | func userAccessHandler(next http.Handler) http.Handler { 52 | return http.HandlerFunc(handleError( 53 | func(w http.ResponseWriter, r *http.Request) error { 54 | loggedInUser := getLoggedInUserCtx(r.Context()) 55 | user := getUserCtx(r.Context()) 56 | if user.ID != loggedInUser.ID && !loggedInUser.IsAdmin { 57 | return service.ErrAccessDenied 58 | } 59 | next.ServeHTTP(w, r) 60 | return nil 61 | })) 62 | } 63 | 64 | type updateUserPasswordT struct { 65 | CurrentPassword string 66 | Password string 67 | } 68 | 69 | func updateUserPasswordE(w http.ResponseWriter, r *http.Request) error { 70 | user := getUserCtx(r.Context()) 71 | var input updateUserPasswordT 72 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 73 | return service.ErrInputDecodeFailed.Wrap(err) 74 | } 75 | 76 | if err := service.ChangePassword(user, input.CurrentPassword, input.Password); err != nil { 77 | return err 78 | } 79 | 80 | return respond(w, "") 81 | } 82 | 83 | var updateUserPassword = handleError(updateUserPasswordE) 84 | 85 | type deleteUserInputT struct { 86 | Password string 87 | } 88 | 89 | func deleteUserE(w http.ResponseWriter, r *http.Request) error { 90 | user := getUserCtx(r.Context()) 91 | var input deleteUserInputT 92 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 93 | return service.ErrInputDecodeFailed.Wrap(err) 94 | } 95 | 96 | if err := service.DeleteUser(user, input.Password); err != nil { 97 | return err 98 | } 99 | return respond(w, user.Email) 100 | } 101 | 102 | var deleteUser = handleError(deleteUserE) 103 | 104 | func sendVerifyEmailE(w http.ResponseWriter, r *http.Request) error { 105 | user := getUserCtx(r.Context()) 106 | if err := service.SendVerifyEmail(user); err != nil { 107 | return err 108 | } 109 | return respond(w, user.Email) 110 | } 111 | 112 | var sendVerifyEmail = handleError(sendVerifyEmailE) 113 | 114 | type verifyEmailInputT struct { 115 | VerificationKey string 116 | } 117 | 118 | func verifyEmailE(w http.ResponseWriter, r *http.Request) error { 119 | user := getUserCtx(r.Context()) 120 | var input verifyEmailInputT 121 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 122 | return service.ErrInputDecodeFailed.Wrap(err) 123 | } 124 | if err := service.VerifyEmail(user, input.VerificationKey); err != nil { 125 | return err 126 | } 127 | return respond(w, user.Email) 128 | } 129 | 130 | var verifyEmail = handleError(verifyEmailE) 131 | 132 | type sendResetPasswordInput struct { 133 | Email string 134 | } 135 | 136 | func sendResetPasswordE(w http.ResponseWriter, r *http.Request) error { 137 | var input sendResetPasswordInput 138 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 139 | return service.ErrInputDecodeFailed.Wrap(err) 140 | } 141 | 142 | user, err := service.GetUserByEmail(input.Email) 143 | if err != nil { 144 | return err 145 | } 146 | 147 | err = service.SendResetPassword(user) 148 | if err != nil { 149 | return err 150 | } 151 | return respond(w, user.Email) 152 | } 153 | 154 | var sendResetPassword = handleError(sendResetPasswordE) 155 | 156 | type resetPasswordInput struct { 157 | ResetKey string 158 | Password string 159 | } 160 | 161 | func resetPasswordE(w http.ResponseWriter, r *http.Request) error { 162 | user := getUserCtx(r.Context()) 163 | var input resetPasswordInput 164 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 165 | return service.ErrInputDecodeFailed.Wrap(err) 166 | } 167 | if err := service.ChangePasswordWithResetKey(user, input.ResetKey, input.Password); err != nil { 168 | return err 169 | } 170 | return respond(w, user.Email) 171 | } 172 | 173 | var resetPassword = handleError(resetPasswordE) 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RightAna 2 | [![Build Status](https://travis-ci.org/soyersoyer/rightana.svg?branch=master)](https://travis-ci.org/soyersoyer/rightana) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/soyersoyer/rightana?)](https://goreportcard.com/report/github.com/soyersoyer/rightana) 4 | 5 | Carefree web analytics on your server 6 | 7 | ## What is RightAna? 8 | 9 | It's a self-hosted web analytics software. 10 | 11 | ## How does it work? 12 | 13 | From the user's perspective it is very similar to GA 14 | - register 15 | - add your site 16 | - get a tracking code 17 | - include it to your webpage 18 | - View the reports 19 | 20 | ## Goals 21 | 22 | - Easy to install 23 | - Easy to use 24 | - Easy to upgrade (guaranteed after version 1.0) 25 | - One binary distribution 26 | - Space efficient, fast, embedded database 27 | - Visitor friendly (no popups, no cookie consent bar, no nonsense) 28 | - You don't have to sell your visitor's data to a company 29 | - Tracks sessions, not users 30 | - GDPR compliant without any annoying popup 31 | 32 | ### Demo server 33 | 34 | [https://rightana.com](https://rightana.com) (user: demo pass: demo1234) 35 | 36 | ### Screenshots 37 | 38 | ### Multiple collections 39 | ![collections](https://user-images.githubusercontent.com/5169997/42170576-3714edec-7e17-11e8-9ae9-f7909f112c43.png) 40 | 41 | ### Simple overview 42 | ![chart](https://user-images.githubusercontent.com/5169997/34117162-1f82043a-e41b-11e7-9ff5-72a0d82f1bfb.png) 43 | 44 | ### Multiple resolution and interval 45 | ![resolution](https://user-images.githubusercontent.com/5169997/34116446-f7ae1018-e418-11e7-9b12-159160aef5f6.png) 46 | 47 | ### Basic informations 48 | ![basic-info](https://user-images.githubusercontent.com/5169997/34116575-5484cf84-e419-11e7-8423-d9c9c769def5.png) 49 | 50 | ### Multiple summaries 51 | ![pages](https://user-images.githubusercontent.com/5169997/34116643-81d16ae2-e419-11e7-9547-1bf1d1c25879.png) 52 | ![sums](https://user-images.githubusercontent.com/5169997/34116646-83392fc8-e419-11e7-84b0-2331a7d84eb9.png) 53 | 54 | ### GeoIP support 55 | ![geoip](https://user-images.githubusercontent.com/5169997/34117762-f5268006-e41c-11e7-8ea3-34722e057fea.png) 56 | 57 | ### You can add filters 58 | ![filter](https://user-images.githubusercontent.com/5169997/34116771-d6d3328c-e419-11e7-8631-98910fda9dcb.png) 59 | 60 | ### You can use the bars for selecting the intervals too 61 | ![bar-navigation](https://user-images.githubusercontent.com/5169997/34116997-8ee17c44-e41a-11e7-874b-b83719136cad.png) 62 | 63 | ### Watch sessions 64 | ![sessions](https://user-images.githubusercontent.com/5169997/34117093-e0f252d8-e41a-11e7-8811-5c90d73560b5.png) 65 | 66 | ### Adding teammates 67 | ![teammates](https://user-images.githubusercontent.com/5169997/34117250-6577d690-e41b-11e7-9931-2c3ccca01b91.png) 68 | 69 | ### Deleting old data is easy 70 | ![storage](https://user-images.githubusercontent.com/5169997/34117249-6558a39c-e41b-11e7-9fb1-5c184e52fbb9.png) 71 | 72 | ## Installation 73 | 74 | ### From binary 75 | 76 | 1. Download the latest version from the [Releases](https://github.com/soyersoyer/rightana/releases) section (currently only x64 linux versions) 77 | 1. Start it and/or add to your service starter 78 | 79 | ### From source 80 | 81 | 1. Get a working Go environment 82 | 1. Get a working Node.js environment (for building the Angular frontend) 83 | 1. `git clone https://github.com/soyersoyer/rightana.git $GOPATH/src/github.com/soyersoyer/rightana` 84 | 1. `cd $GOPATH/src/github.com/soyersoyer/rightana` 85 | 1. `./build.sh` 86 | 1. Start it and/or add to your service starter 87 | 88 | ## Configuration 89 | The configuration filename is rightana.yaml (or an another format what the viper library support) 90 | ### Options 91 | 92 | |Option|Default|Description| 93 | |---|---|---| 94 | |Listening|:3000|Where should the server listen| 95 | |GeoIPCityFile|/var/lib/GeoIP/GeoLite2-City.mmdb|GeoIP2/GeoLite2 City file| 96 | |GeoIPASNFile|/var/lib/GeoIP/GeoLite2-ASN.mmdb|GeoIP2/GeoLite2 ASN file| 97 | |DataDir|data|Where is the base data dir| 98 | |EnableRegistration|true|Whether registration enabled or not| 99 | |UseBundledWebApp|true|Whether the program should use the bundled webapp or use the frontend/dist folder| 100 | |TrackingID||The server's tracking ID, if you want to track it| 101 | |ServerAnnounce||An announce which will show on the home page| 102 | |Backup||The backup configuration in a map[id]dir format| 103 | |AppName|RightAna|The application name in the mails| 104 | |AppURL||The application url in the mails| 105 | |EmailExpiryMinutes|15|When should the keys in the emails expire| 106 | |SMTPHostname|localhost|The SMTP server's hostname| 107 | |SMTPPort|25|The SMTP server's port| 108 | |SMTPUser||The SMTP user| 109 | |SMTPPassword||The SMTP password| 110 | |SMTPSender||The SMTP sender| 111 | 112 | 113 | ## Limitations 114 | This software is under initial development (0.x) and the database format may change in the future. In other words, it is not guaranteed that the next version of the software will be able to read the the data stored by the current version. 115 | 116 | ## Coming features to 0.5 117 | - Compressed logs 118 | -------------------------------------------------------------------------------- /web/src/app/toasty/toasty.component.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016-2017 Sergey Akopkokhyants 2 | // This project is licensed under the terms of the MIT license. 3 | // https://github.com/akserg/ng2-toasty 4 | 5 | import { Component, Input, OnInit } from '@angular/core'; 6 | 7 | import { isFunction } from './toasty.utils'; 8 | import { ToastyService, ToastData, ToastyConfig, ToastyEvent, ToastyEventType } from './toasty.service'; 9 | 10 | /** 11 | * Toasty is container for Toast components 12 | */ 13 | @Component({ 14 | selector: 'ng2-toasty', 15 | template: ` 16 |
17 | 18 |
` 19 | }) 20 | export class ToastyComponent implements OnInit { 21 | /** 22 | * Set of constants defins position of Toasty on the page. 23 | */ 24 | static POSITIONS: Array = ['bottom-right', 'bottom-left', 'top-right', 'top-left', 'top-center', 'bottom-center', 'center-center']; 25 | 26 | private _position: string = ''; 27 | // The window position where the toast pops up. Possible values: 28 | // - bottom-right (default value from ToastConfig) 29 | // - bottom-left 30 | // - top-right 31 | // - top-left 32 | // - top-center 33 | // - bottom-center 34 | // - center-center 35 | @Input() set position(value: string) { 36 | if (value) { 37 | let notFound = true; 38 | for (let i = 0; i < ToastyComponent.POSITIONS.length; i++) { 39 | if (ToastyComponent.POSITIONS[i] === value) { 40 | notFound = false; 41 | break; 42 | } 43 | } 44 | if (notFound) { 45 | // Position was wrong - clear it here to use the one from config. 46 | value = this.config.position; 47 | } 48 | } else { 49 | value = this.config.position; 50 | } 51 | this._position = 'toasty-position-' + value; 52 | } 53 | 54 | get position(): string { 55 | return this._position; 56 | } 57 | 58 | // The storage for toasts. 59 | toasts: Array = []; 60 | 61 | constructor(private toastyService: ToastyService) { 62 | // Initialise position 63 | this.position = ''; 64 | } 65 | 66 | get config(): ToastyConfig { 67 | return this.toastyService.config; 68 | } 69 | /** 70 | * `ngOnInit` is called right after the directive's data-bound properties have been checked for the 71 | * first time, and before any of its children have been checked. It is invoked only once when the 72 | * directive is instantiated. 73 | */ 74 | ngOnInit(): any { 75 | // We listen events from our service 76 | this.toastyService.events.subscribe((event: ToastyEvent) => { 77 | if (event.type === ToastyEventType.ADD) { 78 | // Add the new one 79 | let toast: ToastData = event.value; 80 | this.add(toast); 81 | } else if (event.type === ToastyEventType.CLEAR) { 82 | // Clear the one by number 83 | let id: number = event.value; 84 | this.clear(id); 85 | } else if (event.type === ToastyEventType.CLEAR_ALL) { 86 | // Lets clear all toasts 87 | this.clearAll(); 88 | } 89 | }); 90 | } 91 | 92 | /** 93 | * Event listener of 'closeToast' event comes from ToastyComponent. 94 | * This method removes ToastComponent assosiated with this Toast. 95 | */ 96 | closeToast(toast: ToastData) { 97 | this.clear(toast.id); 98 | } 99 | 100 | /** 101 | * Add new Toast 102 | */ 103 | add(toast: ToastData) { 104 | // If we've gone over our limit, remove the earliest 105 | // one from the array 106 | if (this.toasts.length >= this.config.limit) { 107 | this.toasts.shift(); 108 | } 109 | // Add toasty to array 110 | this.toasts.push(toast); 111 | // 112 | // If there's a timeout individually or globally, 113 | // set the toast to timeout 114 | if (toast.timeout) { 115 | this._setTimeout(toast); 116 | } 117 | } 118 | 119 | /** 120 | * Clear individual toast by id 121 | * @param id is unique identifier of Toast 122 | */ 123 | clear(id: number) { 124 | if (id) { 125 | this.toasts.forEach((value: any, key: number) => { 126 | if (value.id === id) { 127 | if (value.onRemove && isFunction(value.onRemove)) { 128 | value.onRemove.call(this, value); 129 | } 130 | this.toasts.splice(key, 1); 131 | } 132 | }); 133 | } else { 134 | throw new Error('Please provide id of Toast to close'); 135 | } 136 | } 137 | 138 | /** 139 | * Clear all toasts 140 | */ 141 | clearAll() { 142 | this.toasts.forEach((value: any, key: number) => { 143 | if (value.onRemove && isFunction(value.onRemove)) { 144 | value.onRemove.call(this, value); 145 | } 146 | }); 147 | this.toasts = []; 148 | } 149 | 150 | /** 151 | * Custom setTimeout function for specific setTimeouts on individual toasts. 152 | */ 153 | private _setTimeout(toast: ToastData) { 154 | window.setTimeout(() => { 155 | this.clear(toast.id); 156 | }, toast.timeout); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /internal/service/admin.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/soyersoyer/rightana/internal/db" 5 | ) 6 | 7 | // UserInfoT is struct for clients, stores the user information 8 | type UserInfoT struct { 9 | ID uint64 `json:"id"` 10 | Email string `json:"email"` 11 | Name string `json:"name"` 12 | Created int64 `json:"created"` 13 | IsAdmin bool `json:"is_admin"` 14 | DisablePwChange bool `json:"disable_pw_change"` 15 | LimitCollections bool `json:"limit_collections"` 16 | CollectionLimit uint32 `json:"collection_limit"` 17 | DisableUserDeletion bool `json:"disable_user_deletion"` 18 | EmailVerified bool `json:"email_verified"` 19 | CollectionCount int `json:"collection_count"` 20 | } 21 | 22 | // GetUsers returns all user 23 | func GetUsers() ([]UserInfoT, error) { 24 | users, err := db.GetUsers() 25 | if err != nil { 26 | return nil, ErrDB.Wrap(err) 27 | } 28 | userInfos := []UserInfoT{} 29 | for _, u := range users { 30 | collections, err := db.GetCollectionsOwnedByUser(u.ID) 31 | if err != nil { 32 | return nil, ErrDB.Wrap(err) 33 | } 34 | userInfos = append(userInfos, UserInfoT{ 35 | u.ID, 36 | u.Email, 37 | u.Name, 38 | u.Created, 39 | u.IsAdmin, 40 | u.DisablePwChange, 41 | u.LimitCollections, 42 | u.CollectionLimit, 43 | u.DisableUserDeletion, 44 | u.EmailVerified, 45 | len(collections), 46 | }) 47 | } 48 | return userInfos, nil 49 | } 50 | 51 | // GetUserInfo fetch an user by the user's email 52 | func GetUserInfo(name string) (*UserInfoT, error) { 53 | user, err := db.GetUserByName(name) 54 | if err != nil { 55 | return nil, ErrUserNotExist.T(name).Wrap(err) 56 | } 57 | return &UserInfoT{ 58 | user.ID, 59 | user.Email, 60 | user.Name, 61 | user.Created, 62 | user.IsAdmin, 63 | user.DisablePwChange, 64 | user.LimitCollections, 65 | user.CollectionLimit, 66 | user.DisableUserDeletion, 67 | user.EmailVerified, 68 | 0, 69 | }, nil 70 | } 71 | 72 | // UserUpdateT is the struct for updating a user 73 | type UserUpdateT struct { 74 | Name string `json:"name"` 75 | Email string `json:"email"` 76 | Password string `json:"password"` 77 | IsAdmin bool `json:"is_admin"` 78 | DisablePwChange bool `json:"disable_pw_change"` 79 | LimitCollections bool `json:"limit_collections"` 80 | CollectionLimit uint32 `json:"collection_limit"` 81 | DisableUserDeletion bool `json:"disable_user_deletion"` 82 | EmailVerified bool `json:"email_verified"` 83 | } 84 | 85 | // UpdateUser updates a user with UserUpdateT struct 86 | func UpdateUser(name string, input *UserUpdateT) error { 87 | user, err := db.GetUserByName(name) 88 | if err != nil { 89 | return ErrUserNotExist.T(name).Wrap(err) 90 | } 91 | 92 | if user.Name != input.Name { 93 | if !usernameCheck(input.Name) { 94 | return ErrInvalidUsername.T(input.Name) 95 | } 96 | _, err = db.GetUserByName(input.Name) 97 | if err != nil && err != db.ErrKeyNotExists { 98 | return ErrDB.T(input.Name).Wrap(err) 99 | } 100 | if err == nil { 101 | return ErrUserNameExist.T(input.Name) 102 | } 103 | 104 | user.Name = input.Name 105 | } 106 | 107 | if !emailCheck(input.Email) { 108 | return ErrInvalidEmail.T(input.Email) 109 | } 110 | user.Email = input.Email 111 | 112 | if input.Password != "" { 113 | if !passwordCheck(input.Password) { 114 | return ErrPasswordTooShort 115 | } 116 | hashedPass, err := hashPassword(input.Password) 117 | if err != nil { 118 | return err 119 | } 120 | user.Password = hashedPass 121 | } 122 | 123 | user.IsAdmin = input.IsAdmin 124 | if input.IsAdmin == false { 125 | admins, err := db.GetAdminUsers() 126 | if err != nil { 127 | return err 128 | } 129 | if len(admins) == 1 && admins[0].Email == user.Email { 130 | return ErrUserIsTheLastAdmin 131 | } 132 | } 133 | 134 | user.DisablePwChange = input.DisablePwChange 135 | 136 | user.LimitCollections = input.LimitCollections 137 | user.CollectionLimit = input.CollectionLimit 138 | 139 | user.DisableUserDeletion = input.DisableUserDeletion 140 | 141 | user.EmailVerified = input.EmailVerified 142 | 143 | err = db.UpdateUser(user) 144 | if err != nil { 145 | return ErrDB.Wrap(err) 146 | } 147 | return nil 148 | } 149 | 150 | // CollectionInfoT is struct for clients, stores the user information 151 | type CollectionInfoT struct { 152 | ID string `json:"id"` 153 | Name string `json:"name"` 154 | OwnerName string `json:"owner_name"` 155 | Created int64 `json:"created"` 156 | TeammateCount int `json:"teammate_count"` 157 | } 158 | 159 | // GetCollections returns all collection 160 | func GetCollections() ([]CollectionInfoT, error) { 161 | collections, err := db.GetCollections() 162 | if err != nil { 163 | return nil, ErrDB.Wrap(err) 164 | } 165 | collectionInfos := []CollectionInfoT{} 166 | for _, c := range collections { 167 | user, err := db.GetUserByID(c.OwnerID) 168 | if err != nil { 169 | return nil, ErrDB.T(string(c.OwnerID)).Wrap(err) 170 | } 171 | collectionInfos = append(collectionInfos, CollectionInfoT{ 172 | c.ID, 173 | c.Name, 174 | user.Name, 175 | c.Created, 176 | len(c.Teammates)}) 177 | } 178 | return collectionInfos, nil 179 | } 180 | -------------------------------------------------------------------------------- /internal/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | //go:generate statik -src=../../web/dist/ 4 | 5 | import ( 6 | "encoding/json" 7 | "log" 8 | "net/http" 9 | "os" 10 | "path" 11 | "path/filepath" 12 | 13 | "github.com/go-chi/chi" 14 | "github.com/go-chi/cors" 15 | "github.com/rakyll/statik/fs" 16 | 17 | _ "github.com/soyersoyer/rightana/internal/api/statik" //the embedded statik fs data 18 | "github.com/soyersoyer/rightana/internal/config" 19 | "github.com/soyersoyer/rightana/internal/service" 20 | ) 21 | 22 | type ctxKey int 23 | 24 | const ( 25 | keyLoggedInUser ctxKey = iota 26 | keyCollection 27 | keyUser 28 | ) 29 | 30 | func webAppFileServer(dir string) http.HandlerFunc { 31 | fs := http.FileServer(http.Dir(dir)) 32 | 33 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 34 | _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(path.Clean("/"+r.URL.Path)))) 35 | if err != nil && os.IsNotExist(err) { 36 | r.URL.Path = "/" 37 | } 38 | fs.ServeHTTP(w, r) 39 | }) 40 | } 41 | 42 | func webAppFileServerBundled() http.HandlerFunc { 43 | statikFS, err := fs.New() 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | fileServer := http.FileServer(statikFS) 48 | 49 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 50 | _, err := statikFS.Open(filepath.FromSlash(path.Clean("/" + r.URL.Path))) 51 | if err != nil { 52 | r.URL.Path = "/" 53 | } 54 | fileServer.ServeHTTP(w, r) 55 | }) 56 | } 57 | 58 | // Wire function wires the http endpoints 59 | func Wire(r *chi.Mux) { 60 | if config.ActualConfig.UseBundledWebApp { 61 | r.Get("/*", webAppFileServerBundled()) 62 | } else { 63 | r.Get("/*", webAppFileServer("frontend/dist")) 64 | } 65 | r.Route("/api", func(r chi.Router) { 66 | cors := cors.New(cors.Options{ 67 | AllowedOrigins: []string{"*"}, 68 | AllowedMethods: []string{"POST"}, 69 | AllowedHeaders: []string{}, 70 | ExposedHeaders: []string{}, 71 | AllowCredentials: false, 72 | MaxAge: 300, // Maximum value not ignored by any of major browsers 73 | }) 74 | r.Use(cors.Handler) 75 | r.Get("/config", getPublicConfig) 76 | r.With(loggedOnlyHandler).With(adminAccessHandler).Get("/backups", getBackups) 77 | r.Get("/backups/{backupID}/run", runBackup) 78 | r.Post("/sessions", createSession) 79 | r.Post("/sessions/update", updateSession) 80 | r.Post("/pageviews", createPageview) 81 | r.Post("/authtokens", createToken) 82 | r.Delete("/authtokens/{token}", deleteToken) 83 | r.Mount("/users", userRouter()) 84 | r.Mount("/admin", adminRouter()) 85 | }) 86 | } 87 | 88 | func userRouter() http.Handler { 89 | r := chi.NewRouter() 90 | r.Post("/", registerUser) 91 | r.Post("/send-reset-password", sendResetPassword) 92 | r.Route("/{name}", func(r chi.Router) { 93 | r.Use(userBaseHandler) 94 | r.Post("/verify-email", verifyEmail) 95 | r.Post("/reset-password", resetPassword) 96 | r.Mount("/collections", collectionRouter()) 97 | r.With(loggedOnlyHandler).With(userAccessHandler).Get("/", getUserInfo) 98 | r.Route("/settings", func(r chi.Router) { 99 | r.Use(loggedOnlyHandler) 100 | r.Use(userAccessHandler) 101 | r.Patch("/password", updateUserPassword) 102 | r.Post("/delete", deleteUser) 103 | r.Post("/send-verify-email", sendVerifyEmail) 104 | }) 105 | }) 106 | 107 | return r 108 | } 109 | 110 | func collectionRouter() http.Handler { 111 | r := chi.NewRouter() 112 | r.Use(loggedOnlyHandler) 113 | r.Post("/", getCollectionSummaries) 114 | r.With(collectionCreateAccessHandler).Post("/create-new", createCollection) 115 | r.Route("/{collectionName}", func(r chi.Router) { 116 | r.Use(collectionBaseHandler) 117 | r.Use(collectionReadAccessHandler) 118 | r.With(collectionWriteAccessHandler).Get("/", getCollection) 119 | r.With(collectionWriteAccessHandler).Put("/", updateCollection) 120 | r.With(collectionWriteAccessHandler).Delete("/", deleteCollection) 121 | r.With(collectionWriteAccessHandler).Get("/shards", getCollectionShards) 122 | r.With(collectionWriteAccessHandler).Delete("/shards/{shardID}", deleteCollectionShard) 123 | r.With(collectionWriteAccessHandler).Get("/teammates", getTeammates) 124 | r.With(collectionWriteAccessHandler).Post("/teammates", addTeammate) 125 | r.With(collectionWriteAccessHandler).Delete("/teammates/{email}", removeTeammate) 126 | r.Post("/data", getCollectionData) 127 | r.Post("/stat", getCollectionStatData) 128 | r.Post("/sessions", getSessions) 129 | r.Post("/pageviews", getPageviews) 130 | }) 131 | return r 132 | } 133 | 134 | func adminRouter() http.Handler { 135 | r := chi.NewRouter() 136 | r.Use(loggedOnlyHandler) 137 | r.Use(adminAccessHandler) 138 | r.Get("/users", getUsers) 139 | r.Post("/users", createUserAdmin) 140 | r.Patch("/users/{name}", updateUser) 141 | r.Delete("/users/{name}", deleteUserAdmin) 142 | r.Get("/collections", getCollections) 143 | return r 144 | } 145 | 146 | func respond(w http.ResponseWriter, d interface{}) error { 147 | w.Header().Set("content-type", "application/json") 148 | enc := json.NewEncoder(w) 149 | return enc.Encode(d) 150 | } 151 | 152 | type handlerFuncWithError func(w http.ResponseWriter, r *http.Request) error 153 | 154 | func handleError(fn handlerFuncWithError) http.HandlerFunc { 155 | return func(w http.ResponseWriter, r *http.Request) { 156 | if err := fn(w, r); err != nil { 157 | switch e := err.(type) { 158 | default: 159 | log.Println(e) 160 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 161 | case *service.Error: 162 | log.Println(e) 163 | http.Error(w, e.HTTPMessage(), e.Code) 164 | } 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /internal/db/shardbolt/db.go: -------------------------------------------------------------------------------- 1 | package shardbolt 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "sync" 11 | "sync/atomic" 12 | 13 | bolt "github.com/etcd-io/bbolt" 14 | ) 15 | 16 | type shardArray []*shard 17 | 18 | type DB struct { 19 | dir string 20 | mapFn func([]byte) string 21 | mode os.FileMode 22 | options *Options 23 | shards atomic.Value 24 | shardMutex sync.Mutex 25 | } 26 | 27 | type Options struct { 28 | FillPercent float64 29 | boltOptions *bolt.Options 30 | } 31 | 32 | func Open(dir string, mapFn func([]byte) string, mode os.FileMode, options *Options) (*DB, error) { 33 | if options == nil { 34 | options = &Options{ 35 | FillPercent: 0.9, 36 | } 37 | } 38 | 39 | db := &DB{ 40 | dir: dir, 41 | mapFn: mapFn, 42 | mode: mode, 43 | options: options, 44 | } 45 | 46 | os.Mkdir(dir, os.ModePerm) 47 | 48 | shards := shardArray{} 49 | 50 | err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 51 | if err != nil { 52 | return err 53 | } 54 | if info.IsDir() { 55 | return nil 56 | } 57 | shardID, err := getShardIDFromFilename(info.Name()) 58 | if err != nil { 59 | log.Println(err) 60 | return nil 61 | } 62 | shard, err := db.openShard(shardID) 63 | if err != nil { 64 | log.Println("can't open shard:", path, "cause:", err) 65 | return nil 66 | } 67 | shards = append(shards, shard) 68 | return nil 69 | }) 70 | if err != nil { 71 | log.Println("cant open dir:", dir, "cause:", err) 72 | return nil, err 73 | } 74 | sortShards(shards) 75 | db.setShardArray(shards) 76 | return db, nil 77 | } 78 | 79 | func (db *DB) Close() []error { 80 | shards := db.getShardArray() 81 | var errs []error 82 | for _, v := range shards { 83 | err := v.db.Close() 84 | if err != nil { 85 | errs = append(errs, err) 86 | log.Println(err) 87 | } 88 | } 89 | return errs 90 | } 91 | 92 | func (db *DB) DeleteShard(id string) error { 93 | shards := db.getShardArray() 94 | var newShards shardArray 95 | var ashard *shard 96 | for _, v := range shards { 97 | if v.id == id { 98 | ashard = v 99 | } else { 100 | newShards = append(newShards, v) 101 | } 102 | } 103 | if ashard == nil { 104 | return fmt.Errorf("shard not found '%v'", id) 105 | } 106 | db.setShardArray(newShards) 107 | if err := ashard.closeDB(); err != nil { 108 | return err 109 | } 110 | return os.Remove(db.getShardFileName(ashard)) 111 | } 112 | 113 | func (db *DB) Iterate(bucket []byte, fromKey []byte, toKey []byte, fn func(k []byte, v []byte)) { 114 | shards := db.getShards(fromKey, toKey) 115 | for _, v := range shards { 116 | v.db.View(func(tx *bolt.Tx) error { 117 | b := tx.Bucket(bucket) 118 | if b == nil { 119 | log.Println("bucket not found", string(bucket)) 120 | return nil 121 | } 122 | c := b.Cursor() 123 | for k, v := c.Seek(fromKey); k != nil && bytes.Compare(k, toKey) < 0; k, v = c.Next() { 124 | fn(k, v) 125 | } 126 | return nil 127 | }) 128 | } 129 | } 130 | 131 | func (db *DB) IteratePrefix(bucket []byte, prefixKey []byte, fn func(k []byte, v []byte)) { 132 | shards := db.getShards(prefixKey, prefixKey) 133 | for _, v := range shards { 134 | v.db.View(func(tx *bolt.Tx) error { 135 | b := tx.Bucket(bucket) 136 | if b == nil { 137 | log.Println("bucket not found", string(bucket)) 138 | return nil 139 | } 140 | c := b.Cursor() 141 | for k, v := c.Seek(prefixKey); k != nil && bytes.HasPrefix(k, prefixKey); k, v = c.Next() { 142 | fn(k, v) 143 | } 144 | return nil 145 | }) 146 | } 147 | } 148 | 149 | func (db *DB) Get(bucket []byte, key []byte) ([]byte, error) { 150 | actualShard := db.getActualShard(key) 151 | if actualShard == nil { 152 | return nil, errors.New(fmt.Sprint("shard not found with key", key)) 153 | } 154 | 155 | ret := []byte{} 156 | err := actualShard.db.View(func(tx *bolt.Tx) error { 157 | b := tx.Bucket(bucket) 158 | ret = b.Get(key) 159 | if ret == nil { 160 | return errors.New(fmt.Sprint("key not found in shard", actualShard, key)) 161 | } 162 | return nil 163 | }) 164 | return ret, err 165 | } 166 | 167 | func (db *DB) Update(fn func(tx *MultiTx) error) error { 168 | tx := db.Begin(true) 169 | success := false 170 | 171 | defer func() { 172 | if !success { 173 | tx.Rollback() 174 | } 175 | }() 176 | 177 | err := fn(tx) 178 | 179 | if err != nil { 180 | tx.Rollback() 181 | return err 182 | } 183 | success = true 184 | return tx.Commit() 185 | } 186 | 187 | func (db *DB) BatchUpsert(bucket []byte, key []byte, value []byte) error { 188 | actualShard, err := db.ensureShard(key) 189 | if err != nil { 190 | return err 191 | } 192 | return actualShard.db.Batch(func(tx *bolt.Tx) error { 193 | b, err := tx.CreateBucketIfNotExists(bucket) 194 | if err != nil { 195 | return err 196 | } 197 | b.FillPercent = db.options.FillPercent 198 | return b.Put(key, value) 199 | }) 200 | } 201 | 202 | type ShardSize struct { 203 | ID string 204 | Size int 205 | } 206 | 207 | func (db *DB) GetSizes() []ShardSize { 208 | shards := db.getShardArray() 209 | 210 | var sizes []ShardSize 211 | for _, v := range shards { 212 | size := -1 213 | fileinfo, err := os.Stat(v.db.Path()) 214 | if err == nil { 215 | size = int(fileinfo.Size()) 216 | } 217 | sizes = append(sizes, ShardSize{v.id, size}) 218 | } 219 | return sizes 220 | } 221 | 222 | func (db *DB) getShardArray() shardArray { 223 | return db.shards.Load().(shardArray) 224 | } 225 | 226 | func (db *DB) setShardArray(shards shardArray) { 227 | db.shards.Store(shards) 228 | } 229 | 230 | // RunBackup creates a backup for this db 231 | func (db *DB) RunBackup(dir string) []error { 232 | errs := []error{} 233 | if err := os.MkdirAll(dir, os.ModePerm); err != nil { 234 | errs = append(errs, err) 235 | return errs 236 | } 237 | shards := db.getShardArray() 238 | for _, shard := range shards { 239 | err := shard.db.View(func(tx *bolt.Tx) error { 240 | return tx.CopyFile(dir+"/"+shard.id+".bolt", 0600) 241 | }) 242 | if err != nil { 243 | errs = append(errs, err) 244 | continue 245 | } 246 | } 247 | return errs 248 | } 249 | -------------------------------------------------------------------------------- /web/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; 4 | import { ReactiveFormsModule } from '@angular/forms'; 5 | import { RouterModule, Routes } from '@angular/router'; 6 | 7 | import { ToastyModule, ToastyService } from './toasty/toasty.module'; 8 | import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; 9 | 10 | import { AppComponent } from './app.component'; 11 | import { AuthService, BackendService, AuthInterceptor, ErrorInterceptor } from './backend.service'; 12 | import { LoginComponent } from './login/login.component'; 13 | import { InvalidUsernameComponent, InvalidEmailComponent, InvalidPasswordComponent, 14 | InvalidCollectionNameComponent } from './forms/invalid.component'; 15 | import { HomeComponent } from './home/home.component'; 16 | import { RegistrationComponent } from './registration/registration.component'; 17 | import { VerifyEmailComponent } from './verify-email/verify-email.component'; 18 | import { ForgotPasswordComponent } from './forgot-password/forgot-password.component'; 19 | import { ResetPasswordComponent } from './reset-password/reset-password.component'; 20 | 21 | import { CollectionDashboardComponent } from './collection-dashboard/dashboard.component'; 22 | import { CollectionComponent } from './collection/collection.component'; 23 | import { CollectionCreateComponent } from './collection-create/create.component'; 24 | import { LogoutComponent } from './logout/logout.component'; 25 | import { CollectionSettingsComponent } from './collection-settings/settings.component'; 26 | import { TeammatesComponent } from './collection-settings/teammates.component'; 27 | import { CollectionTrackingComponent } from './collection-tracking/tracking.component'; 28 | import { SessionComponent } from './session/session.component'; 29 | import { CollectionStatComponent } from './collection-stat/stat.component'; 30 | import { TableSumComponent } from './collection-stat/table-sum.component'; 31 | 32 | import { SettingsComponent } from './settings/settings.component'; 33 | import { ChangePasswordComponent } from './settings/change-password/change-password.component'; 34 | import { DeleteAccountComponent } from './settings/delete-account/delete-account.component'; 35 | import { ProfileComponent } from './settings/profile/profile.component'; 36 | 37 | import { ChartComponent } from './chart/chart.component'; 38 | 39 | import { ColorPercentComponent } from './utils/color-percent.component'; 40 | import { BytesPipe } from './utils/bytes.pipe'; 41 | import { MarkAsToucedDirective } from './utils/mark-as-touched.directive'; 42 | 43 | import { AdminComponent } from './admin/admin.component'; 44 | import { AdminUsersComponent } from './admin-users/admin-users.component'; 45 | import { AdminCollectionsComponent } from './admin-collections/admin-collections.component'; 46 | import { AdminUsersEditComponent } from './admin-users-edit/admin-users-edit.component'; 47 | import { AdminUsersCreateComponent } from './admin-users-create/admin-users-create.component'; 48 | import { AdminBackupsComponent } from './admin-backups/admin-backups.component'; 49 | 50 | import { UserComponent } from './user/user.component'; 51 | 52 | 53 | 54 | 55 | const routes: Routes = [ 56 | { path: '', component: HomeComponent}, 57 | { path: 'login', component: LoginComponent}, 58 | { path: 'logout', component: LogoutComponent}, 59 | { path: 'registration', component: RegistrationComponent}, 60 | { path: 'forgot-password', component: ForgotPasswordComponent}, 61 | { path: 'settings', component: SettingsComponent, children: [ 62 | { path: '', redirectTo: 'profile', pathMatch: 'full'}, 63 | { path: 'profile', component: ProfileComponent}, 64 | { path: 'change-password', component: ChangePasswordComponent}, 65 | { path: 'delete-account', component: DeleteAccountComponent}, 66 | ]}, 67 | { path: 'admin', component: AdminComponent, children: [ 68 | { path: '', redirectTo: 'users', pathMatch: 'full'}, 69 | { path: 'users', component: AdminUsersComponent}, 70 | { path: 'users/create-new', component: AdminUsersCreateComponent}, 71 | { path: 'users/:name', component: AdminUsersEditComponent}, 72 | { path: 'collections', component: AdminCollectionsComponent}, 73 | { path: 'backups', component: AdminBackupsComponent}, 74 | ]}, 75 | { path: ':user', component: UserComponent, children: [ 76 | { path: 'verify-email', component: VerifyEmailComponent}, 77 | { path: 'reset-password', component: ResetPasswordComponent}, 78 | { path: "", component: CollectionComponent}, 79 | { path: 'create', component: CollectionCreateComponent}, 80 | { path: ':collectionName', component: CollectionDashboardComponent, children: [ 81 | { path: '', redirectTo: 'statistics', pathMatch: 'full'}, 82 | { path: 'statistics', component: CollectionStatComponent}, 83 | { path: 'sessions', component: SessionComponent}, 84 | { path: 'settings', component: CollectionSettingsComponent}, 85 | ]}, 86 | ]}, 87 | 88 | ]; 89 | 90 | @NgModule({ 91 | declarations: [ 92 | AppComponent, 93 | LoginComponent, 94 | HomeComponent, 95 | RegistrationComponent, 96 | CollectionComponent, 97 | CollectionCreateComponent, 98 | CollectionDashboardComponent, 99 | InvalidUsernameComponent, 100 | InvalidEmailComponent, 101 | InvalidPasswordComponent, 102 | InvalidCollectionNameComponent, 103 | LogoutComponent, 104 | CollectionSettingsComponent, 105 | TeammatesComponent, 106 | CollectionTrackingComponent, 107 | SessionComponent, 108 | CollectionStatComponent, 109 | TableSumComponent, 110 | SettingsComponent, 111 | ChangePasswordComponent, 112 | DeleteAccountComponent, 113 | ChartComponent, 114 | ColorPercentComponent, 115 | BytesPipe, 116 | MarkAsToucedDirective, 117 | AdminComponent, 118 | AdminUsersComponent, 119 | AdminCollectionsComponent, 120 | AdminUsersEditComponent, 121 | AdminUsersCreateComponent, 122 | AdminBackupsComponent, 123 | UserComponent, 124 | ProfileComponent, 125 | VerifyEmailComponent, 126 | ForgotPasswordComponent, 127 | ResetPasswordComponent, 128 | ], 129 | imports: [ 130 | BrowserModule, 131 | HttpClientModule, 132 | ReactiveFormsModule, 133 | RouterModule.forRoot(routes), 134 | NgbDropdownModule.forRoot(), 135 | ToastyModule, 136 | ], 137 | providers: [ 138 | BackendService, 139 | AuthService, 140 | { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true, deps: [AuthService]}, 141 | { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true, deps: [ToastyService, AuthService] }, 142 | ], 143 | bootstrap: [AppComponent] 144 | }) 145 | export class AppModule { } 146 | --------------------------------------------------------------------------------