From 893c49ec886abaac9b1ff7859f67471c9e369dcf Mon Sep 17 00:00:00 2001 From: schreifuchs Date: Fri, 17 Oct 2025 23:51:19 +0200 Subject: [PATCH] feat: private posts --- internal/auth/controller.go | 8 +-- internal/auth/middleware.go | 7 ++- internal/initialize/inject.go | 15 +++--- internal/model/blog.go | 4 +- internal/posts/controller.go | 53 ++++++++++++++++++- web/src/app/app.routes.ts | 2 + .../post-editor/post-editor.component.html | 12 +++++ .../post-editor/post-editor.component.ts | 12 +++++ .../routes/dashboard/dashboard.component.html | 11 +++- .../routes/dashboard/dashboard.component.ts | 4 ++ web/src/app/routes/post/post.component.ts | 1 - .../secret-post/secret-post.component.html | 7 +++ .../post/secret-post/secret-post.component.ts | 28 ++++++++++ web/src/app/shared/interfaces/post.ts | 2 + web/src/app/shared/services/posts.service.ts | 3 ++ 15 files changed, 154 insertions(+), 15 deletions(-) create mode 100644 web/src/app/routes/post/secret-post/secret-post.component.html create mode 100644 web/src/app/routes/post/secret-post/secret-post.component.ts diff --git a/internal/auth/controller.go b/internal/auth/controller.go index 4bfd9b4..6c3dba4 100644 --- a/internal/auth/controller.go +++ b/internal/auth/controller.go @@ -116,12 +116,14 @@ func (s *Service) Login(w http.ResponseWriter, r *http.Request) { } if err := s.db.First(&user).Error; err != nil { - fmt.Fprint(w, "user not found") w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "user not found") + return } if err := bcrypt.CompareHashAndPassword(user.Password, []byte(login.Password)); err != nil { - fmt.Fprint(w, "Invalid Password") w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "Invalid Password") + return } token, err := s.createJWT(&user) @@ -134,8 +136,8 @@ func (s *Service) Login(w http.ResponseWriter, r *http.Request) { Token: token, }) if err != nil { - log.Println("Error: ", err) w.WriteHeader(http.StatusInternalServerError) + log.Println("Error: ", err) return } diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 13e3026..9226e6e 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -8,11 +8,16 @@ import ( ) // Authenticated: This function is a middleware that authenticates incoming HTTP requests using JWT tokens and role-based access control. -func (s *Service) Authenticated(next http.HandlerFunc, roles ...model.Role) http.Handler { +func (s *Service) Authenticated(next http.HandlerFunc, mustauth bool, roles ...model.Role) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Our middleware logic goes here... token, err := extractToken(r) if err != nil { + if !mustauth { + r = writeToContext(r, nil) + next(w, r) + return + } w.WriteHeader(http.StatusUnauthorized) return } diff --git a/internal/initialize/inject.go b/internal/initialize/inject.go index ce53282..05b9e30 100644 --- a/internal/initialize/inject.go +++ b/internal/initialize/inject.go @@ -26,7 +26,7 @@ func CreateMux(cfg *config.Config) (r *mux.Router) { w.WriteHeader(http.StatusNoContent) }) - return + return r } func frontend(r *mux.Route) { @@ -51,12 +51,13 @@ func app(r *mux.Router, cfg *config.Config) { // auth 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") + r.Handle("/auth/password", auth.Authenticated(auth.ChangePassword, true)).Methods("PUT") + r.Handle("/auth/logout", auth.Authenticated(auth.Logout, true)).Methods("DELETE") // Posts - r.Handle("/posts", auth.Authenticated(blg.SavePost, model.RoleUser, model.RoleAdmin)).Methods("POST") - r.Handle("/posts", auth.Authenticated(blg.SavePost, model.RoleUser, model.RoleAdmin)).Methods("PUT") - r.Handle("/posts/{postID}", auth.Authenticated(blg.DeletePost, model.RoleUser, model.RoleAdmin)).Methods("DELETE") - r.Handle("/posts", http.HandlerFunc(blg.GetAllPosts)).Methods("GET") + r.Handle("/posts", auth.Authenticated(blg.SavePost, true, model.RoleUser, model.RoleAdmin)).Methods("POST") + r.Handle("/posts", auth.Authenticated(blg.SavePost, true, model.RoleUser, model.RoleAdmin)).Methods("PUT") + r.Handle("/posts/{postID}", auth.Authenticated(blg.DeletePost, true, model.RoleUser, model.RoleAdmin)).Methods("DELETE") + r.Handle("/posts", auth.Authenticated(blg.GetAllPosts, false)).Methods("GET") + r.Handle("/posts/secret/{secret}", http.HandlerFunc(blg.GetPostBySecret)).Methods("GET") } diff --git a/internal/model/blog.go b/internal/model/blog.go index c9d034e..a750a32 100644 --- a/internal/model/blog.go +++ b/internal/model/blog.go @@ -12,7 +12,9 @@ type Post struct { TLDR string `json:"tldr"` Content string `json:"content"` Comments []Comment - UserID uint `gorm:"->;<-:create"` + UserID uint `gorm:"->;<-:create"` + Private bool `json:"private" gorm:"-"` + Secret *string `json:"secret,omitempty" gorm:"->;<-:create;unique"` } // Comment represents a comment on a post, including its ID, post association, content, and creator. diff --git a/internal/posts/controller.go b/internal/posts/controller.go index 1a5b73e..b49d0a8 100644 --- a/internal/posts/controller.go +++ b/internal/posts/controller.go @@ -9,6 +9,7 @@ import ( "git.schreifuchs.ch/schreifuchs/ng-blog/internal/auth" "git.schreifuchs.ch/schreifuchs/ng-blog/internal/model" + "github.com/google/uuid" "github.com/gorilla/mux" ) @@ -28,6 +29,13 @@ func (s Service) SavePost(w http.ResponseWriter, r *http.Request) { return } + if post.Private { + secret := uuid.NewString() + post.Secret = &secret + } else { + post.Secret = nil + } + post.UserID = claims.UserID if err := s.db.Save(&post).Error; err != nil { @@ -50,13 +58,56 @@ func (s Service) SavePost(w http.ResponseWriter, r *http.Request) { func (s Service) GetAllPosts(w http.ResponseWriter, r *http.Request) { var posts []model.Post + claims, ok := auth.ExtractClaims(r.Context()) + if !ok { + claims = nil + } + if err := s.db.Preload("Comments").Order("created_at DESC").Find(&posts).Error; err != nil { fmt.Fprint(w, err.Error()) w.WriteHeader(http.StatusInternalServerError) return } - res, err := json.Marshal(&posts) + newPosts := make([]model.Post, 0, len(posts)) + for _, p := range posts { + if p.Secret == nil { + newPosts = append(newPosts, p) + continue + } + if claims == nil { + continue + } + if claims.UserID == p.UserID { + newPosts = append(newPosts, p) + } + } + + res, err := json.Marshal(&newPosts) + if err != nil { + fmt.Fprint(w, err.Error()) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Write(res) +} + +// GetPostBySecret retrieves a post by its secret from the database, eager-loads comments, and returns it as JSON. +func (s Service) GetPostBySecret(w http.ResponseWriter, r *http.Request) { + secret, ok := mux.Vars(r)["secret"] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + + var post model.Post + if err := s.db.Preload("Comments").Where("secret = ?", secret).First(&post).Error; err != nil { + fmt.Fprint(w, err.Error()) + w.WriteHeader(http.StatusNotFound) + return + } + + res, err := json.Marshal(&post) if err != nil { fmt.Fprint(w, err.Error()) w.WriteHeader(http.StatusInternalServerError) diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index 38c7fc3..af5167d 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -6,6 +6,7 @@ import { LoggedInGuard } from './shared/guards/logged-in.guard'; 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'; +import { SecretPostComponent } from './routes/post/secret-post/secret-post.component'; export const routes: Routes = [ { path: '', component: HomeComponent }, @@ -13,6 +14,7 @@ export const routes: Routes = [ path: 'post', children: [ { path: 'new', component: CreatePostComponent }, + { path: 'secret/:secret', component: SecretPostComponent }, { path: ':id/edit', component: UpdatePostComponent }, { path: ':id', diff --git a/web/src/app/components/post-editor/post-editor.component.html b/web/src/app/components/post-editor/post-editor.component.html index 3fd9509..d28756a 100644 --- a/web/src/app/components/post-editor/post-editor.component.html +++ b/web/src/app/components/post-editor/post-editor.component.html @@ -1,4 +1,16 @@
+
+ + +
+

+ {{ data().private ? "this post is private" : "this post is public" }} +

(); + constructor() { + effect(() => { + if (this.data().private === null) { + this.private = false; + } + }); + } + set title(val: string) { this.data.update((d) => ({ ...d, title: val })); } @@ -36,4 +45,7 @@ export class PostEditorComponent { set content(val: string) { this.data.update((d) => ({ ...d, content: val })); } + set private(val: boolean) { + this.data.update((d) => ({ ...d, private: val })); + } } diff --git a/web/src/app/routes/dashboard/dashboard.component.html b/web/src/app/routes/dashboard/dashboard.component.html index 30dcd41..92f4ec3 100644 --- a/web/src/app/routes/dashboard/dashboard.component.html +++ b/web/src/app/routes/dashboard/dashboard.component.html @@ -12,7 +12,7 @@

{{ post.title }}

@@ -31,5 +31,14 @@ > Delete + @if (post.secret !== undefined) { + + Copy Link + + }

diff --git a/web/src/app/routes/dashboard/dashboard.component.ts b/web/src/app/routes/dashboard/dashboard.component.ts index 3368765..857fa61 100644 --- a/web/src/app/routes/dashboard/dashboard.component.ts +++ b/web/src/app/routes/dashboard/dashboard.component.ts @@ -17,4 +17,8 @@ export class DashboardComponent { delete(id: number) { this.postsService.deletePost(id); } + getPath(secret: string) { + const url = document.URL.replaceAll('/dashboard', ''); + navigator.clipboard.writeText(`${url}/post/secret/${secret}`); + } } diff --git a/web/src/app/routes/post/post.component.ts b/web/src/app/routes/post/post.component.ts index 7a54e8c..575cbe5 100644 --- a/web/src/app/routes/post/post.component.ts +++ b/web/src/app/routes/post/post.component.ts @@ -1,6 +1,5 @@ import { Component, inject, Input } from '@angular/core'; import { PostsService } from '../../shared/services/posts.service'; -import { JsonPipe, NgIf } from '@angular/common'; import { MarkdownComponent } from '../../components/markdown/markdown.component'; @Component({ diff --git a/web/src/app/routes/post/secret-post/secret-post.component.html b/web/src/app/routes/post/secret-post/secret-post.component.html new file mode 100644 index 0000000..5193ad4 --- /dev/null +++ b/web/src/app/routes/post/secret-post/secret-post.component.html @@ -0,0 +1,7 @@ +

{{ post()?.title }}

+

TL;DR; {{ post()?.tldr }}

+ + diff --git a/web/src/app/routes/post/secret-post/secret-post.component.ts b/web/src/app/routes/post/secret-post/secret-post.component.ts new file mode 100644 index 0000000..3c67b8d --- /dev/null +++ b/web/src/app/routes/post/secret-post/secret-post.component.ts @@ -0,0 +1,28 @@ +import { + Component, + inject, + Input, + OnInit, + signal, + WritableSignal, +} from '@angular/core'; +import { PostsService } from '../../../shared/services/posts.service'; +import { Post } from '../../../shared/interfaces/post'; +import { MarkdownComponent } from '../../../components/markdown/markdown.component'; + +@Component({ + selector: 'app-secret-post', + imports: [MarkdownComponent], + templateUrl: './secret-post.component.html', +}) +export class SecretPostComponent implements OnInit { + @Input() secret!: string; + private postsService = inject(PostsService); + post: WritableSignal = signal(undefined); + + ngOnInit(): void { + this.postsService.getPostBySecret(this.secret).subscribe((post) => { + this.post.set(post); + }); + } +} diff --git a/web/src/app/shared/interfaces/post.ts b/web/src/app/shared/interfaces/post.ts index bcb0670..35f42bd 100644 --- a/web/src/app/shared/interfaces/post.ts +++ b/web/src/app/shared/interfaces/post.ts @@ -3,6 +3,8 @@ export interface Post { title: string; tldr: string; content: string; + private?: boolean; + secret?: string; } export interface Comment { diff --git a/web/src/app/shared/services/posts.service.ts b/web/src/app/shared/services/posts.service.ts index 4e56e17..6b91d17 100644 --- a/web/src/app/shared/services/posts.service.ts +++ b/web/src/app/shared/services/posts.service.ts @@ -35,6 +35,9 @@ export class PostsService { getPost(id: number): Signal { return computed(() => this.posts().get(id)); } + getPostBySecret(secret: string) { + return this.http.get(`${environment.apiRoot}/posts/secret/${secret}`); + } deletePost(id: number) { this.http.delete(`${environment.apiRoot}/posts/${id}`).subscribe(() => {