From 7da18e4420c4b71a8ecfda07f39324fbfec081c3 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 9 Aug 2016 21:45:21 +0200 Subject: Client: add user management --- client/src/app/admin/admin.component.ts | 10 ++ client/src/app/admin/admin.routes.ts | 14 +++ client/src/app/admin/index.ts | 3 + client/src/app/admin/users/index.ts | 5 + client/src/app/admin/users/shared/index.ts | 1 + client/src/app/admin/users/shared/user.service.ts | 49 +++++++++ client/src/app/admin/users/user-add/index.ts | 1 + .../admin/users/user-add/user-add.component.html | 29 +++++ .../app/admin/users/user-add/user-add.component.ts | 33 ++++++ client/src/app/admin/users/user-list/index.ts | 1 + .../admin/users/user-list/user-list.component.html | 24 +++++ .../admin/users/user-list/user-list.component.scss | 7 ++ .../admin/users/user-list/user-list.component.ts | 44 ++++++++ client/src/app/admin/users/users.component.ts | 13 +++ client/src/app/admin/users/users.routes.ts | 27 +++++ client/src/app/app.component.html | 7 +- client/src/app/app.component.ts | 4 + client/src/app/app.routes.ts | 3 +- client/src/app/shared/auth/auth-user.model.ts | 120 +++++++++++++++++++++ client/src/app/shared/auth/auth.service.ts | 20 ++-- client/src/app/shared/auth/index.ts | 2 +- client/src/app/shared/auth/user.model.ts | 118 -------------------- client/src/app/shared/index.ts | 1 + client/src/app/shared/users/index.ts | 1 + client/src/app/shared/users/user.model.ts | 15 +++ .../app/videos/video-list/video-list.component.ts | 6 +- client/src/index.html | 1 + 27 files changed, 428 insertions(+), 131 deletions(-) create mode 100644 client/src/app/admin/admin.component.ts create mode 100644 client/src/app/admin/admin.routes.ts create mode 100644 client/src/app/admin/index.ts create mode 100644 client/src/app/admin/users/index.ts create mode 100644 client/src/app/admin/users/shared/index.ts create mode 100644 client/src/app/admin/users/shared/user.service.ts create mode 100644 client/src/app/admin/users/user-add/index.ts create mode 100644 client/src/app/admin/users/user-add/user-add.component.html create mode 100644 client/src/app/admin/users/user-add/user-add.component.ts create mode 100644 client/src/app/admin/users/user-list/index.ts create mode 100644 client/src/app/admin/users/user-list/user-list.component.html create mode 100644 client/src/app/admin/users/user-list/user-list.component.scss create mode 100644 client/src/app/admin/users/user-list/user-list.component.ts create mode 100644 client/src/app/admin/users/users.component.ts create mode 100644 client/src/app/admin/users/users.routes.ts create mode 100644 client/src/app/shared/auth/auth-user.model.ts delete mode 100644 client/src/app/shared/auth/user.model.ts create mode 100644 client/src/app/shared/users/index.ts create mode 100644 client/src/app/shared/users/user.model.ts (limited to 'client/src') diff --git a/client/src/app/admin/admin.component.ts b/client/src/app/admin/admin.component.ts new file mode 100644 index 000000000..82f2529ec --- /dev/null +++ b/client/src/app/admin/admin.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; +import { ROUTER_DIRECTIVES } from '@angular/router'; + +@Component({ + template: '', + directives: [ ROUTER_DIRECTIVES ] +}) + +export class AdminComponent { +} diff --git a/client/src/app/admin/admin.routes.ts b/client/src/app/admin/admin.routes.ts new file mode 100644 index 000000000..d375a86af --- /dev/null +++ b/client/src/app/admin/admin.routes.ts @@ -0,0 +1,14 @@ +import { RouterConfig } from '@angular/router'; + +import { AdminComponent } from './admin.component'; +import { UsersRoutes } from './users'; + +export const AdminRoutes: RouterConfig = [ + { + path: 'admin', + component: AdminComponent, + children: [ + ...UsersRoutes + ] + } +]; diff --git a/client/src/app/admin/index.ts b/client/src/app/admin/index.ts new file mode 100644 index 000000000..3b0540818 --- /dev/null +++ b/client/src/app/admin/index.ts @@ -0,0 +1,3 @@ +export * from './users'; +export * from './admin.component'; +export * from './admin.routes'; diff --git a/client/src/app/admin/users/index.ts b/client/src/app/admin/users/index.ts new file mode 100644 index 000000000..e98a81f62 --- /dev/null +++ b/client/src/app/admin/users/index.ts @@ -0,0 +1,5 @@ +export * from './shared'; +export * from './user-add'; +export * from './user-list'; +export * from './users.component'; +export * from './users.routes'; diff --git a/client/src/app/admin/users/shared/index.ts b/client/src/app/admin/users/shared/index.ts new file mode 100644 index 000000000..e17ee5c7a --- /dev/null +++ b/client/src/app/admin/users/shared/index.ts @@ -0,0 +1 @@ +export * from './user.service'; diff --git a/client/src/app/admin/users/shared/user.service.ts b/client/src/app/admin/users/shared/user.service.ts new file mode 100644 index 000000000..be433f0a1 --- /dev/null +++ b/client/src/app/admin/users/shared/user.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@angular/core'; +import { Response } from '@angular/http'; +import { Observable } from 'rxjs/Observable'; + +import { AuthHttp, User } from '../../../shared'; + +@Injectable() +export class UserService { + // TODO: merge this constant with account + private static BASE_USERS_URL = '/api/v1/users/'; + + constructor(private authHttp: AuthHttp) {} + + addUser(username: string, password: string) { + const body = { + username, + password + }; + + return this.authHttp.post(UserService.BASE_USERS_URL, body); + } + + getUsers() { + return this.authHttp.get(UserService.BASE_USERS_URL) + .map(res => res.json()) + .map(this.extractUsers) + .catch(this.handleError); + } + + removeUser(user: User) { + return this.authHttp.delete(UserService.BASE_USERS_URL + user.id); + } + + private extractUsers(body: any) { + const usersJson = body.data; + const totalUsers = body.total; + const users = []; + for (const userJson of usersJson) { + users.push(new User(userJson)); + } + + return { users, totalUsers }; + } + + private handleError(error: Response) { + console.error(error); + return Observable.throw(error.json().error || 'Server error'); + } +} diff --git a/client/src/app/admin/users/user-add/index.ts b/client/src/app/admin/users/user-add/index.ts new file mode 100644 index 000000000..66d5ca04f --- /dev/null +++ b/client/src/app/admin/users/user-add/index.ts @@ -0,0 +1 @@ +export * from './user-add.component'; diff --git a/client/src/app/admin/users/user-add/user-add.component.html b/client/src/app/admin/users/user-add/user-add.component.html new file mode 100644 index 000000000..aa102358a --- /dev/null +++ b/client/src/app/admin/users/user-add/user-add.component.html @@ -0,0 +1,29 @@ +

Add user

+ +
{{ error }}
+ +
+
+ + +
+ Username is required with a length >= 3 and <= 20 +
+
+ +
+ + +
+ Password is required with a length >= 6 +
+
+ + +
diff --git a/client/src/app/admin/users/user-add/user-add.component.ts b/client/src/app/admin/users/user-add/user-add.component.ts new file mode 100644 index 000000000..30ca947a0 --- /dev/null +++ b/client/src/app/admin/users/user-add/user-add.component.ts @@ -0,0 +1,33 @@ +import { Control, ControlGroup, Validators } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; + +import { UserService } from '../shared'; + +@Component({ + selector: 'my-user-add', + template: require('./user-add.component.html'), +}) +export class UserAddComponent implements OnInit { + userAddForm: ControlGroup; + error: string = null; + + constructor(private router: Router, private userService: UserService) {} + + ngOnInit() { + this.userAddForm = new ControlGroup({ + username: new Control('', Validators.compose([ Validators.required, Validators.minLength(3), Validators.maxLength(20) ])), + password: new Control('', Validators.compose([ Validators.required, Validators.minLength(6) ])), + }); + } + + addUser(username: string, password: string) { + this.error = null; + + this.userService.addUser(username, password).subscribe( + ok => this.router.navigate([ '/admin/users/list' ]), + + err => this.error = err + ); + } +} diff --git a/client/src/app/admin/users/user-list/index.ts b/client/src/app/admin/users/user-list/index.ts new file mode 100644 index 000000000..51fbefa80 --- /dev/null +++ b/client/src/app/admin/users/user-list/index.ts @@ -0,0 +1 @@ +export * from './user-list.component'; diff --git a/client/src/app/admin/users/user-list/user-list.component.html b/client/src/app/admin/users/user-list/user-list.component.html new file mode 100644 index 000000000..2aca05f2b --- /dev/null +++ b/client/src/app/admin/users/user-list/user-list.component.html @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + +
IdUsernameRemove
{{ user.id }}{{ user.username }} + +
+ + + + Add user + diff --git a/client/src/app/admin/users/user-list/user-list.component.scss b/client/src/app/admin/users/user-list/user-list.component.scss new file mode 100644 index 000000000..e9f61e900 --- /dev/null +++ b/client/src/app/admin/users/user-list/user-list.component.scss @@ -0,0 +1,7 @@ +.glyphicon-remove { + cursor: pointer; +} + +.add-user { + margin-top: 10px; +} diff --git a/client/src/app/admin/users/user-list/user-list.component.ts b/client/src/app/admin/users/user-list/user-list.component.ts new file mode 100644 index 000000000..598daa42a --- /dev/null +++ b/client/src/app/admin/users/user-list/user-list.component.ts @@ -0,0 +1,44 @@ +import { Component, OnInit } from '@angular/core'; +import { ROUTER_DIRECTIVES } from '@angular/router'; + +import { User } from '../../../shared'; +import { UserService } from '../shared'; + +@Component({ + selector: 'my-user-list', + template: require('./user-list.component.html'), + styles: [ require('./user-list.component.scss') ], + directives: [ ROUTER_DIRECTIVES ] +}) +export class UserListComponent implements OnInit { + totalUsers: number; + users: User[]; + + constructor(private userService: UserService) {} + + ngOnInit() { + this.getUsers(); + } + + getUsers() { + this.userService.getUsers().subscribe( + ({ users, totalUsers }) => { + this.users = users; + this.totalUsers = totalUsers; + }, + + err => alert(err) + ); + } + + + removeUser(user: User) { + if (confirm('Are you sure?')) { + this.userService.removeUser(user).subscribe( + () => this.getUsers(), + + err => alert(err) + ); + } + } +} diff --git a/client/src/app/admin/users/users.component.ts b/client/src/app/admin/users/users.component.ts new file mode 100644 index 000000000..46aa0862f --- /dev/null +++ b/client/src/app/admin/users/users.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { ROUTER_DIRECTIVES } from '@angular/router'; + +import { UserService } from './shared'; + +@Component({ + template: '', + directives: [ ROUTER_DIRECTIVES ], + providers: [ UserService ] +}) + +export class UsersComponent { +} diff --git a/client/src/app/admin/users/users.routes.ts b/client/src/app/admin/users/users.routes.ts new file mode 100644 index 000000000..0457c3843 --- /dev/null +++ b/client/src/app/admin/users/users.routes.ts @@ -0,0 +1,27 @@ +import { RouterConfig } from '@angular/router'; + +import { UsersComponent } from './users.component'; +import { UserAddComponent } from './user-add'; +import { UserListComponent } from './user-list'; + +export const UsersRoutes: RouterConfig = [ + { + path: 'users', + component: UsersComponent, + children: [ + { + path: '', + redirectTo: 'list', + pathMatch: 'full' + }, + { + path: 'list', + component: UserListComponent + }, + { + path: 'add', + component: UserAddComponent + } + ] + } +]; diff --git a/client/src/app/app.component.html b/client/src/app/app.component.html index ea4b31421..58967abca 100644 --- a/client/src/app/app.component.html +++ b/client/src/app/app.component.html @@ -47,7 +47,12 @@ -
+
+
+ + List users +
+
Make friends diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index 5764f24ca..444b6b3b4 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts @@ -45,6 +45,10 @@ export class AppComponent { ); } + isUserAdmin() { + return this.authService.isAdmin(); + } + logout() { this.authService.logout(); // Redirect to home page diff --git a/client/src/app/app.routes.ts b/client/src/app/app.routes.ts index 1c414038d..d7194cb4f 100644 --- a/client/src/app/app.routes.ts +++ b/client/src/app/app.routes.ts @@ -2,6 +2,7 @@ import { RouterConfig } from '@angular/router'; import { AccountRoutes } from './account'; import { LoginRoutes } from './login'; +import { AdminRoutes } from './admin'; import { VideosRoutes } from './videos'; export const routes: RouterConfig = [ @@ -10,7 +11,7 @@ export const routes: RouterConfig = [ redirectTo: '/videos/list', pathMatch: 'full' }, - + ...AdminRoutes, ...AccountRoutes, ...LoginRoutes, ...VideosRoutes diff --git a/client/src/app/shared/auth/auth-user.model.ts b/client/src/app/shared/auth/auth-user.model.ts new file mode 100644 index 000000000..bdd5ea5a9 --- /dev/null +++ b/client/src/app/shared/auth/auth-user.model.ts @@ -0,0 +1,120 @@ +import { User } from '../users'; + +export class AuthUser extends User { + private static KEYS = { + ID: 'id', + ROLE: 'role', + USERNAME: 'username' + }; + + id: string; + role: string; + username: string; + tokens: Tokens; + + static load() { + const usernameLocalStorage = localStorage.getItem(this.KEYS.USERNAME); + if (usernameLocalStorage) { + return new AuthUser( + { + id: localStorage.getItem(this.KEYS.ID), + username: localStorage.getItem(this.KEYS.USERNAME), + role: localStorage.getItem(this.KEYS.ROLE) + }, + Tokens.load() + ); + } + + return null; + } + + static flush() { + localStorage.removeItem(this.KEYS.USERNAME); + localStorage.removeItem(this.KEYS.ID); + localStorage.removeItem(this.KEYS.ROLE); + Tokens.flush(); + } + + constructor(userHash: { id: string, username: string, role: string }, hashTokens: any) { + super(userHash); + this.tokens = new Tokens(hashTokens); + } + + getAccessToken() { + return this.tokens.access_token; + } + + getRefreshToken() { + return this.tokens.refresh_token; + } + + getTokenType() { + return this.tokens.token_type; + } + + refreshTokens(access_token: string, refresh_token: string) { + this.tokens.access_token = access_token; + this.tokens.refresh_token = refresh_token; + } + + save() { + localStorage.setItem(AuthUser.KEYS.ID, this.id); + localStorage.setItem(AuthUser.KEYS.USERNAME, this.username); + localStorage.setItem(AuthUser.KEYS.ROLE, this.role); + this.tokens.save(); + } +} + +// Private class only used by User +class Tokens { + private static KEYS = { + ACCESS_TOKEN: 'access_token', + REFRESH_TOKEN: 'refresh_token', + TOKEN_TYPE: 'token_type', + }; + + access_token: string; + refresh_token: string; + token_type: string; + + static load() { + const accessTokenLocalStorage = localStorage.getItem(this.KEYS.ACCESS_TOKEN); + const refreshTokenLocalStorage = localStorage.getItem(this.KEYS.REFRESH_TOKEN); + const tokenTypeLocalStorage = localStorage.getItem(this.KEYS.TOKEN_TYPE); + + if (accessTokenLocalStorage && refreshTokenLocalStorage && tokenTypeLocalStorage) { + return new Tokens({ + access_token: accessTokenLocalStorage, + refresh_token: refreshTokenLocalStorage, + token_type: tokenTypeLocalStorage + }); + } + + return null; + } + + static flush() { + localStorage.removeItem(this.KEYS.ACCESS_TOKEN); + localStorage.removeItem(this.KEYS.REFRESH_TOKEN); + localStorage.removeItem(this.KEYS.TOKEN_TYPE); + } + + constructor(hash?: any) { + if (hash) { + this.access_token = hash.access_token; + this.refresh_token = hash.refresh_token; + + if (hash.token_type === 'bearer') { + this.token_type = 'Bearer'; + } else { + this.token_type = hash.token_type; + } + } + } + + save() { + localStorage.setItem('access_token', this.access_token); + localStorage.setItem('refresh_token', this.refresh_token); + localStorage.setItem('token_type', this.token_type); + } +} diff --git a/client/src/app/shared/auth/auth.service.ts b/client/src/app/shared/auth/auth.service.ts index 24d1a4fa2..8eea0c4bf 100644 --- a/client/src/app/shared/auth/auth.service.ts +++ b/client/src/app/shared/auth/auth.service.ts @@ -4,7 +4,7 @@ import { Observable } from 'rxjs/Observable'; import { Subject } from 'rxjs/Subject'; import { AuthStatus } from './auth-status.model'; -import { User } from './user.model'; +import { AuthUser } from './auth-user.model'; @Injectable() export class AuthService { @@ -17,7 +17,7 @@ export class AuthService { private clientId: string; private clientSecret: string; private loginChanged: Subject; - private user: User = null; + private user: AuthUser = null; constructor(private http: Http) { this.loginChanged = new Subject(); @@ -40,7 +40,7 @@ export class AuthService { ); // Return null if there is nothing to load - this.user = User.load(); + this.user = AuthUser.load(); } getRefreshToken() { @@ -65,10 +65,16 @@ export class AuthService { return this.user.getTokenType(); } - getUser(): User { + getUser(): AuthUser { return this.user; } + isAdmin() { + if (this.user === null) return false; + + return this.user.isAdmin(); + } + isLoggedIn() { if (this.getAccessToken()) { return true; @@ -108,7 +114,7 @@ export class AuthService { logout() { // TODO: make an HTTP request to revoke the tokens this.user = null; - User.flush(); + AuthUser.flush(); this.setStatus(AuthStatus.LoggedOut); } @@ -163,13 +169,13 @@ export class AuthService { const id = obj.id; const username = obj.username; const role = obj.role; - const hash_tokens = { + const hashTokens = { access_token: obj.access_token, token_type: obj.token_type, refresh_token: obj.refresh_token }; - this.user = new User(id, username, role, hash_tokens); + this.user = new AuthUser({ id, username, role }, hashTokens); this.user.save(); this.setStatus(AuthStatus.LoggedIn); diff --git a/client/src/app/shared/auth/index.ts b/client/src/app/shared/auth/index.ts index aafaacbf1..ebd9e14cd 100644 --- a/client/src/app/shared/auth/index.ts +++ b/client/src/app/shared/auth/index.ts @@ -1,4 +1,4 @@ export * from './auth-http.service'; export * from './auth-status.model'; export * from './auth.service'; -export * from './user.model'; +export * from './auth-user.model'; diff --git a/client/src/app/shared/auth/user.model.ts b/client/src/app/shared/auth/user.model.ts deleted file mode 100644 index e486873ab..000000000 --- a/client/src/app/shared/auth/user.model.ts +++ /dev/null @@ -1,118 +0,0 @@ -export class User { - private static KEYS = { - ID: 'id', - ROLE: 'role', - USERNAME: 'username' - }; - - id: string; - role: string; - username: string; - tokens: Tokens; - - static load() { - const usernameLocalStorage = localStorage.getItem(this.KEYS.USERNAME); - if (usernameLocalStorage) { - return new User( - localStorage.getItem(this.KEYS.ID), - localStorage.getItem(this.KEYS.USERNAME), - localStorage.getItem(this.KEYS.ROLE), - Tokens.load() - ); - } - - return null; - } - - static flush() { - localStorage.removeItem(this.KEYS.USERNAME); - localStorage.removeItem(this.KEYS.ID); - localStorage.removeItem(this.KEYS.ROLE); - Tokens.flush(); - } - - constructor(id: string, username: string, role: string, hash_tokens: any) { - this.id = id; - this.username = username; - this.role = role; - this.tokens = new Tokens(hash_tokens); - } - - getAccessToken() { - return this.tokens.access_token; - } - - getRefreshToken() { - return this.tokens.refresh_token; - } - - getTokenType() { - return this.tokens.token_type; - } - - refreshTokens(access_token: string, refresh_token: string) { - this.tokens.access_token = access_token; - this.tokens.refresh_token = refresh_token; - } - - save() { - localStorage.setItem(User.KEYS.ID, this.id); - localStorage.setItem(User.KEYS.USERNAME, this.username); - localStorage.setItem(User.KEYS.ROLE, this.role); - this.tokens.save(); - } -} - -// Private class only used by User -class Tokens { - private static KEYS = { - ACCESS_TOKEN: 'access_token', - REFRESH_TOKEN: 'refresh_token', - TOKEN_TYPE: 'token_type', - }; - - access_token: string; - refresh_token: string; - token_type: string; - - static load() { - const accessTokenLocalStorage = localStorage.getItem(this.KEYS.ACCESS_TOKEN); - const refreshTokenLocalStorage = localStorage.getItem(this.KEYS.REFRESH_TOKEN); - const tokenTypeLocalStorage = localStorage.getItem(this.KEYS.TOKEN_TYPE); - - if (accessTokenLocalStorage && refreshTokenLocalStorage && tokenTypeLocalStorage) { - return new Tokens({ - access_token: accessTokenLocalStorage, - refresh_token: refreshTokenLocalStorage, - token_type: tokenTypeLocalStorage - }); - } - - return null; - } - - static flush() { - localStorage.removeItem(this.KEYS.ACCESS_TOKEN); - localStorage.removeItem(this.KEYS.REFRESH_TOKEN); - localStorage.removeItem(this.KEYS.TOKEN_TYPE); - } - - constructor(hash?: any) { - if (hash) { - this.access_token = hash.access_token; - this.refresh_token = hash.refresh_token; - - if (hash.token_type === 'bearer') { - this.token_type = 'Bearer'; - } else { - this.token_type = hash.token_type; - } - } - } - - save() { - localStorage.setItem('access_token', this.access_token); - localStorage.setItem('refresh_token', this.refresh_token); - localStorage.setItem('token_type', this.token_type); - } -} diff --git a/client/src/app/shared/index.ts b/client/src/app/shared/index.ts index dfea4c67c..c05e8d253 100644 --- a/client/src/app/shared/index.ts +++ b/client/src/app/shared/index.ts @@ -1,2 +1,3 @@ export * from './auth'; export * from './search'; +export * from './users'; diff --git a/client/src/app/shared/users/index.ts b/client/src/app/shared/users/index.ts new file mode 100644 index 000000000..5a670ce8f --- /dev/null +++ b/client/src/app/shared/users/index.ts @@ -0,0 +1 @@ +export * from './user.model'; diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts new file mode 100644 index 000000000..0f34d4480 --- /dev/null +++ b/client/src/app/shared/users/user.model.ts @@ -0,0 +1,15 @@ +export class User { + id: string; + username: string; + role: string; + + constructor(hash: { id: string, username: string, role: string }) { + this.id = hash.id; + this.username = hash.username; + this.role = hash.role; + } + + isAdmin() { + return this.role === 'admin'; + } +} diff --git a/client/src/app/videos/video-list/video-list.component.ts b/client/src/app/videos/video-list/video-list.component.ts index 5691d684e..062340ec5 100644 --- a/client/src/app/videos/video-list/video-list.component.ts +++ b/client/src/app/videos/video-list/video-list.component.ts @@ -12,7 +12,7 @@ import { Video, VideoService } from '../shared'; -import { AuthService, Search, SearchField, User } from '../../shared'; +import { AuthService, AuthUser, Search, SearchField } from '../../shared'; import { VideoMiniatureComponent } from './video-miniature.component'; import { VideoSortComponent } from './video-sort.component'; import { SearchService } from '../../shared'; @@ -33,7 +33,7 @@ export class VideoListComponent implements OnInit, OnDestroy { totalItems: null }; sort: SortField; - user: User = null; + user: AuthUser = null; videos: Video[] = []; private search: Search; @@ -51,7 +51,7 @@ export class VideoListComponent implements OnInit, OnDestroy { ngOnInit() { if (this.authService.isLoggedIn()) { - this.user = User.load(); + this.user = AuthUser.load(); } // Subscribe to route changes diff --git a/client/src/index.html b/client/src/index.html index 5cf491221..f39d8d2cf 100644 --- a/client/src/index.html +++ b/client/src/index.html @@ -1,3 +1,4 @@ + -- cgit v1.2.3