Compare commits

2 Commits
v0.0.1 ... main

Author SHA1 Message Date
30dac9f12f change password
All checks were successful
Release / publish (push) Successful in 4m9s
2025-05-13 14:06:11 +02:00
73ff28347a added fallback middleware
All checks were successful
Release / publish (push) Successful in 4m7s
2025-05-12 09:30:38 +02:00
25 changed files with 448 additions and 23 deletions

42
.gitignore vendored
View File

@ -1,2 +1,44 @@
blog.db
backend
**/.DS_Store
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
web/dist
web/tmp
web/out-tsc
web/bazel-out
# Node
web/node_modules
web/npm-debug.log
web/yarn-error.log
# IDEs and editors
web/.idea/
web/.project
web/.classpath
web/.c9/
web/*.launch
web/.settings/
web/*.sublime-workspace
# Visual Studio Code
web/.vscode/*
web/!.vscode/settings.json
web/!.vscode/tasks.json
web/!.vscode/launch.json
web/!.vscode/extensions.json
web/.history/*
# Miscellaneous
web/.angular/cache
web/.sass-cache/
web/connect.lock
web/coverage
web/libpeerconnection.log
testem.log
web/typings
# System files
web/Thumbs.db

BIN
backend

Binary file not shown.

2
go.mod
View File

@ -13,6 +13,8 @@ require (
)
require (
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/go-swiss/compress v0.0.0-20231015173048-c7b565746931 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect

4
go.sum
View File

@ -1,3 +1,7 @@
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/go-swiss/compress v0.0.0-20231015173048-c7b565746931 h1:4GONJghYPtbCcPDZXWhbgKgbK8tfmv/C7su6O72AZWw=
github.com/go-swiss/compress v0.0.0-20231015173048-c7b565746931/go.mod h1:atoBfZRTinNQQlYfu42MCp8E1yoKWhmohXj71lgRtfU=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=

View File

@ -48,6 +48,61 @@ func (s *Service) Signup(w http.ResponseWriter, r *http.Request) {
log.Printf("Error: %v", err)
w.WriteHeader(http.StatusInternalServerError)
}
token, err := s.createJWT(&user)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
res, err := json.Marshal(&LoginResponse{
Token: token,
})
if err != nil {
log.Println("Error: ", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Write(res)
}
// Signup handles user signup by decoding request body, hashing the password, and saving user data to the database.
func (s *Service) ChangePassword(w http.ResponseWriter, r *http.Request) {
var err error
var login Login
var password []byte
claims, ok := ExtractClaims(r.Context())
if !ok {
log.Println("Error while extracting claims")
w.WriteHeader(http.StatusInternalServerError)
return
}
if err = json.NewDecoder(r.Body).Decode(&login); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if len([]byte(login.Password)) > 72 {
fmt.Fprint(w, "Password to long, max 72 bytes")
w.WriteHeader(http.StatusBadRequest)
return
}
if password, err = bcrypt.GenerateFromPassword([]byte(login.Password), 6); err != nil {
log.Println("Error: ", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = s.db.Model(&model.User{}).Where("id = ?", claims.UserID).Update("password", password).Error
if err != nil {
log.Printf("Error: %v", err)
w.WriteHeader(http.StatusInternalServerError)
}
w.WriteHeader(http.StatusNoContent)
}
// Login handles user login by decoding request body, verifying credentials, and returning a JWT token.

View File

@ -19,9 +19,7 @@ func CreateMux(cfg *config.Config) (r *mux.Router) {
r.Use(cors.HandlerForOrigin("*"))
app(r.PathPrefix("/api").Subrouter(), cfg)
frontend := web.Frontend
r.PathPrefix("/").Handler(middlewares.AddPrefix("/dist/frontend/browser", http.FileServerFS(frontend)))
frontend(r.PathPrefix("/"))
r.Methods("OPTIONS").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The CORS middleware should set up the headers for you
@ -31,15 +29,28 @@ func CreateMux(cfg *config.Config) (r *mux.Router) {
return
}
func frontend(r *mux.Route) {
frontend := web.Frontend
r.Handler(
middlewares.AddPrefix("/dist/frontend/browser",
middlewares.FallbackFile(
frontend,
"/dist/frontend/browser/index.html",
http.FileServerFS(frontend),
),
))
}
func app(r *mux.Router, cfg *config.Config) {
db := model.Init()
blg := posts.New(db)
auth := auth.New(&cfg.Auth, db)
// auth
r.HandleFunc("/login", auth.Login).Methods("POST")
r.HandleFunc("/signup", auth.Signup).Methods("POST")
r.Handle("/logout", auth.Authenticated(auth.Logout)).Methods("DELETE")
r.HandleFunc("/auth/login", auth.Login).Methods("POST")
r.HandleFunc("/auth/signup", auth.Signup).Methods("POST")
r.Handle("/auth/password", auth.Authenticated(auth.ChangePassword)).Methods("PUT")
r.Handle("/auth/logout", auth.Authenticated(auth.Logout)).Methods("DELETE")
// Posts
r.Handle("/posts", auth.Authenticated(blg.SavePost, model.RoleUser, model.RoleAdmin)).Methods("POST")

View File

@ -0,0 +1,61 @@
package middlewares
import (
"bytes"
"io/fs"
"log"
"net/http"
)
// FallbackFile serves a fallback file if the next handler returns a 404.
// fs: The file system to serve the fallback file from.
// name: The name of the fallback file.
// next: The next handler to serve.
func FallbackFile(fs fs.FS, name string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bw := &buffedResponseWriter{
w,
0,
bytes.NewBuffer([]byte{}),
}
next.ServeHTTP(bw, r)
if bw.statusCode == http.StatusNotFound {
bw.Clear()
http.ServeFileFS(bw, r, fs, name)
bw.WriteHeader(http.StatusOK)
bw.Header().Add("Content-Type", "text/html; charset=utf-8")
}
bw.Apply()
})
}
type buffedResponseWriter struct {
http.ResponseWriter
statusCode int
buff *bytes.Buffer
}
func (b *buffedResponseWriter) WriteHeader(code int) {
b.statusCode = code
}
func (b *buffedResponseWriter) Write(p []byte) (int, error) {
return b.buff.Write(p)
}
func (b *buffedResponseWriter) Clear() {
b.buff.Reset()
}
func (b *buffedResponseWriter) Apply() {
_, err := b.ResponseWriter.Write(b.buff.Bytes())
if err != nil {
log.Println("Error while applying buffedResponseWriter: ", err)
b.ResponseWriter.WriteHeader(http.StatusInternalServerError)
}
if b.statusCode != 200 {
b.ResponseWriter.WriteHeader(b.statusCode)
}
}

11
web/package-lock.json generated
View File

@ -20,6 +20,7 @@
"marked": "^15.0.8",
"postcss": "^8.5.3",
"rxjs": "~7.8.0",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.3",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
@ -13581,6 +13582,16 @@
"node": ">=0.10"
}
},
"node_modules/tailwind-merge": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.0.tgz",
"integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz",

View File

@ -22,6 +22,7 @@
"marked": "^15.0.8",
"postcss": "^8.5.3",
"rxjs": "~7.8.0",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.3",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"

View File

@ -6,6 +6,7 @@
</a>
<nav class="flex gap-5">
<a routerLink="/dashboard" *ngIf="loggedIn()">posts</a>
<a routerLink="/dashboard/account" *ngIf="loggedIn()">account</a>
<button *ngIf="!loggedIn()" (click)="toggleLogin()">login</button>
<button *ngIf="loggedIn()" (click)="logOut()">logout</button>
</nav>

View File

@ -24,6 +24,8 @@ describe('AppComponent', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
expect(compiled.querySelector('h1')?.textContent).toContain(
'Hello, frontend',
);
});
});

View File

@ -3,9 +3,9 @@ import { HomeComponent } from './routes/home/home.component';
import { PostComponent } from './routes/post/post.component';
import { DashboardComponent } from './routes/dashboard/dashboard.component';
import { LoggedInGuard } from './shared/guards/logged-in.guard';
import { PostEditorComponent } from './components/post-editor/post-editor.component';
import { CreatePostComponent } from './routes/post/create-post/create-post.component';
import { UpdatePostComponent } from './routes/post/update-post/update-post.component';
import { AccountComponent } from './routes/dashboard/account/account.component';
export const routes: Routes = [
{ path: '', component: HomeComponent },
@ -22,8 +22,11 @@ export const routes: Routes = [
},
{
path: 'dashboard',
component: DashboardComponent,
canActivate: [LoggedInGuard],
children: [
{ path: 'account', component: AccountComponent },
{ path: '', component: DashboardComponent },
],
data: { requiresAuth: true },
},
{ path: 'tst', component: PostEditorComponent },
];

View File

@ -0,0 +1,8 @@
<button
(click)="(click)"
[disabled]="disabled"
[class]="getStyle()"
[routerLink]="link ? link : null"
>
<ng-content />
</button>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ButtonComponent } from './button.component';
describe('ButtonComponent', () => {
let component: ButtonComponent;
let fixture: ComponentFixture<ButtonComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ButtonComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ButtonComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,33 @@
import { NgIf } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { RouterLink } from '@angular/router';
import { twMerge } from 'tailwind-merge';
@Component({
selector: 'app-button',
imports: [NgIf, RouterLink],
templateUrl: './button.component.html',
})
export class ButtonComponent {
@Output() click = new EventEmitter<Event>();
@Input() disabled: boolean = false;
@Input() color: 'yellow' | 'orange' = 'yellow';
@Input() link: null | string = null;
getStyle() {
let col = 'a';
switch (this.color) {
case 'yellow':
col = 'bg-amber-400';
break;
case 'orange':
col = 'bg-orange-400';
}
return twMerge(
'p-1 px-5 transition-colors rounded-full hover:bg-amber-500 ',
col,
);
}
}

View File

@ -0,0 +1,21 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="grid grid-cols-3 gap-5">
<label>Password:</label>
<input
type="password"
formControlName="password"
class="p-1 border-2 rounded-md col-span-2"
/>
@if (form.invalid && this.form.touched) {
<p class="col-span-3 text-red-600 italic">
Password must be at least 6 chars.
</p>
}
<button
type="submit"
required
[class.bg-gray-500]="form.invalid"
class="col-start-3 col-span-1 bg-blue-300 p-2 rounded-md drop-shadow-md hover:drop-shadow-xl active:bg-blue-600 disabled:bg-gray-500"
>
Change Password
</button>
</form>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ChangePasswordComponent } from './change-password.component';
describe('ChangePasswordComponent', () => {
let component: ChangePasswordComponent;
let fixture: ComponentFixture<ChangePasswordComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ChangePasswordComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ChangePasswordComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,39 @@
import { Component, EventEmitter, inject, Output, output } from '@angular/core';
import {
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { AuthService } from '../../shared/services/auth.service';
@Component({
selector: 'app-change-password',
imports: [ReactiveFormsModule],
templateUrl: './change-password.component.html',
})
export class ChangePasswordComponent {
@Output() done = new EventEmitter();
private auth = inject(AuthService);
form = new FormGroup({
password: new FormControl('', [
Validators.required,
Validators.minLength(6),
]),
});
submit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.auth
.changePassword(this.form.controls.password.value!)
.then((success) => {
if (success) {
this.done.emit();
}
});
}
}

View File

@ -1,6 +1,6 @@
<div
*ngIf="open === true"
class="fixed flex top-0 justify-center items-center w-screen h-screen z-50 backdrop-blur-md bg-black/25"
class="fixed flex top-0 right-0 left-0 justify-center items-center w-screen h-screen z-50 backdrop-blur-md bg-black/25"
>
<div class="p-10 bg-white drop-shadow-md rounded-md">
<button (click)="toggleOpen()" class="absolute top-0 right-1 p-3">X</button>

View File

@ -0,0 +1,9 @@
<h1 class="text-2xl">Actions</h1>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5 mt-5">
<app-button (click)="auth.logout()">logout</app-button>
<app-button (click)="changePW()">Change Password</app-button>
</div>
<app-modal [(open)]="changePWOpen">
<app-change-password (done)="changePWOpen = false" />
</app-modal>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AccountComponent } from './account.component';
describe('AccountComponent', () => {
let component: AccountComponent;
let fixture: ComponentFixture<AccountComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AccountComponent]
})
.compileComponents();
fixture = TestBed.createComponent(AccountComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,19 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { AuthService } from '../../../shared/services/auth.service';
import { ButtonComponent } from '../../../components/button/button.component';
import { ModalComponent } from '../../../components/modal/modal.component';
import { ChangePasswordComponent } from '../../../components/change-password/change-password.component';
@Component({
selector: 'app-account',
imports: [ButtonComponent, ModalComponent, ChangePasswordComponent],
templateUrl: './account.component.html',
})
export class AccountComponent {
auth = inject(AuthService);
changePWOpen: boolean = false;
changePW() {
this.changePWOpen = true;
}
}

View File

@ -18,17 +18,18 @@
<p class="col-start-1 col-span-4 row-start-2 row-span-2">
<strong>TL;DR; </strong>{{ post.tldr }}
</p>
<a
[routerLink]="`/post/${post.id}/edit`"
class="col-start-5 row-start-1 bg-amber-400 rounded-full p-1 px-5 hover:bg-amber-500 active:bg-amber-600 transition-colors text-center"
<app-button
[link]="`/post/${post.id}/edit`"
class="col-start-5 row-start-1"
>
Edit
</a>
<button
</app-button>
<app-button
(click)="delete(post.id)"
class="col-start-5 row-start-3 bg-orange-400 rounded-full p-1 px-5 hover:bg-amber-500 active:bg-orange-600 transition-colors"
class="col-start-5 row-start-3"
color="orange"
>
Delete
</button>
</app-button>
</article>
</section>

View File

@ -1,12 +1,12 @@
import { Component, effect, inject } from '@angular/core';
import { PostEditorComponent } from '../../components/post-editor/post-editor.component';
import { NgFor } from '@angular/common';
import { PostsService } from '../../shared/services/posts.service';
import { RouterLink, RouterOutlet } from '@angular/router';
import { RouterLink } from '@angular/router';
import { ButtonComponent } from '../../components/button/button.component';
@Component({
selector: 'app-admin',
imports: [NgFor, RouterLink],
imports: [NgFor, RouterLink, ButtonComponent],
standalone: true,
templateUrl: './dashboard.component.html',
})

View File

@ -8,6 +8,7 @@ import {
} from '@angular/core';
import { LoginResponse, User, Claims } from '../interfaces/auth';
import { environment } from '../../../environments/environment';
import { ActivatedRouteSnapshot, Router } from '@angular/router';
const JWT_KEY = 'token';
@ -16,6 +17,7 @@ const JWT_KEY = 'token';
})
export class AuthService {
private http = inject(HttpClient);
private router = inject(Router);
jwt: WritableSignal<string | null> = signal(null);
claims: WritableSignal<Claims | null> = signal(null);
timeout: any | null = null;
@ -73,12 +75,35 @@ export class AuthService {
login(user: User) {
this.http
.post<LoginResponse>(`${environment.apiRoot}/login`, user)
.post<LoginResponse>(`${environment.apiRoot}/auth/login`, user)
.subscribe((res) => this.jwt.set(res.token));
}
signup(user: User) {
this.http
.post<LoginResponse>(`${environment.apiRoot}/auth/signup`, user)
.subscribe((res) => this.jwt.set(res.token));
}
changePassword(password: string): Promise<boolean> {
return new Promise((resolve) => {
this.http
.put(`${environment.apiRoot}/auth/password`, { password: password })
.subscribe({
complete: () => resolve(true),
error: () => resolve(false),
});
});
}
logout() {
this.http.delete(`${environment.apiRoot}/logout`).subscribe(() => {
this.http.delete(`${environment.apiRoot}/auth/logout`).subscribe(() => {
this.jwt.set(null);
// move away if protected
if (isOnProtectedRoute(this.router.routerState.snapshot.root)) {
this.router.navigateByUrl('/');
}
});
}
@ -111,3 +136,11 @@ function extractExpiration(token: string): Date {
const jwt = parseJwt(token);
return new Date(jwt.exp * 1000);
}
function isOnProtectedRoute(route: ActivatedRouteSnapshot): boolean {
// If this segment has the flag, return true
if (route.data && route.data['requiresAuth']) {
return true;
}
// Recursively check children
return route.children.some((child) => isOnProtectedRoute(child));
}