feat: add app code
- journal domain package - httpserver package - html templates - main.go in root dir
This commit is contained in:
parent
3cc4d28aac
commit
943922a6e1
20 changed files with 1032 additions and 0 deletions
BIN
assets/favicon.ico
Normal file
BIN
assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
BIN
assets/journal.png
Normal file
BIN
assets/journal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
12
go.mod
12
go.mod
|
|
@ -1,3 +1,15 @@
|
|||
module gitlab.mareshq.com/lab/journal
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
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
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
)
|
||||
|
|
|
|||
12
go.sum
Normal file
12
go.sum
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
34
httpserver/errors.go
Normal file
34
httpserver/errors.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package httpserver
|
||||
|
||||
import "net/http"
|
||||
|
||||
var (
|
||||
ErrorPageBadRequest = `<!DOCTYPE html><html><head><title>Bad request | Journal</title><style>html { font-family: Consolas, monospace; } #error { margin: 0 auto; margin-top: 8rem; max-width: 40rem; } h1 { font-size: 3rem; } p { font-size: 1.5rem; margin-top: -1rem; }</style></head><body><div id="error"><h1>Bad request</h1><p>error 400</p></div></body></html>` // 400
|
||||
ErrorPageForbidden = `<!DOCTYPE html><html><head><title>Forbidden | Journal</title><style>html { font-family: Consolas, monospace; } #error { margin: 0 auto; margin-top: 8rem; max-width: 40rem; } h1 { font-size: 3rem; } p { font-size: 1.5rem; margin-top: -1rem; }</style></head><body><div id="error"><h1>Forbidden</h1><p>error 403</p></div></body></html>` // 403
|
||||
ErrorPageNotFound = `<!DOCTYPE html><html><head><title>Not found | Journal</title><style>html { font-family: Consolas, monospace; } #error { margin: 0 auto; margin-top: 8rem; max-width: 40rem; } h1 { font-size: 3rem; } p { font-size: 1.5rem; margin-top: -1rem; }</style></head><body><div id="error"><h1>Not found</h1><p>error 404</p></div></body></html>` // 404
|
||||
ErrorPageInternalServerError = `<!DOCTYPE html><html><head><title>Internal server error | Journal</title></head><style>html { font-family: Consolas, monospace; } #error { margin: 0 auto; margin-top: 8rem; max-width: 40rem; } h1 { font-size: 3rem; } p { font-size: 1.5rem; margin-top: -1rem; }</style><body><div id="error"><h1>Internal server error</h1><p>error 500</p></div></body></html>` // 500
|
||||
)
|
||||
|
||||
func BadRequest(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(ErrorPageBadRequest))
|
||||
}
|
||||
|
||||
func Forbidden(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(ErrorPageForbidden))
|
||||
}
|
||||
|
||||
func NotFound(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(ErrorPageNotFound))
|
||||
}
|
||||
|
||||
func InternalServerError(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(ErrorPageInternalServerError))
|
||||
}
|
||||
18
httpserver/favicon.go
Normal file
18
httpserver/favicon.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package httpserver
|
||||
|
||||
import "net/http"
|
||||
|
||||
type Favicon struct {
|
||||
favicon []byte
|
||||
}
|
||||
|
||||
func NewFavicon(favicon []byte) *Favicon {
|
||||
return &Favicon{
|
||||
favicon: favicon,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Favicon) FaviconHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/x-icon")
|
||||
w.Write(f.favicon)
|
||||
}
|
||||
74
httpserver/middleware.go
Normal file
74
httpserver/middleware.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Middleware http.HandlerFunc
|
||||
|
||||
func Authenticated(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Perform authentication here
|
||||
// For example, check for a valid token in the request header
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// If authentication is successful, call the next handler
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type LoggingMiddleware struct {
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func NewLoggingMiddleware(logger *log.Logger) *LoggingMiddleware {
|
||||
return &LoggingMiddleware{logger: logger}
|
||||
}
|
||||
|
||||
func (m *LoggingMiddleware) Logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
m.logger.Println("Request:", r.Method, r.URL.Path, "Duration:", time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
type Timeout struct {
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func (m *Timeout) TimeoutMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), m.Timeout)
|
||||
defer cancel()
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
ch := make(chan struct{})
|
||||
defer close(ch)
|
||||
//
|
||||
go func() {
|
||||
next.ServeHTTP(w, r)
|
||||
ch <- struct{}{}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
// Request completed within timeout
|
||||
case <-ctx.Done():
|
||||
// Request timed out
|
||||
http.Error(w, "Request timed out", http.StatusGatewayTimeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func NewTimeout(timeout time.Duration) *Timeout {
|
||||
return &Timeout{Timeout: timeout}
|
||||
}
|
||||
39
httpserver/renderer.go
Normal file
39
httpserver/renderer.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type TemplateRenderer struct {
|
||||
templatesFS fs.FS
|
||||
}
|
||||
|
||||
func NewTemplateRenderer(templatesFS fs.FS) *TemplateRenderer {
|
||||
return &TemplateRenderer{
|
||||
templatesFS: templatesFS,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *TemplateRenderer) Render(w http.ResponseWriter, templatePath string, data interface{}) {
|
||||
tmpl, err := template.ParseFS(r.templatesFS, fmt.Sprintf("templates/%s", templatePath), "templates/layout.html")
|
||||
if err != nil {
|
||||
InternalServerError(w, nil)
|
||||
log.Printf("failed to parse template %s: %v", templatePath, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = tmpl.Execute(w, data)
|
||||
if err != nil {
|
||||
InternalServerError(w, nil)
|
||||
log.Printf("failed to execute template %s: %v", templatePath, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
}
|
||||
305
journal/handlers.go
Normal file
305
journal/handlers.go
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
package journal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitlab.mareshq.com/lab/journal/httpserver"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const dateFormat = "2006-01-02"
|
||||
|
||||
type httpEntry struct {
|
||||
ID int
|
||||
Title string
|
||||
FormattedDate string
|
||||
Content string
|
||||
HTMLContent template.HTML
|
||||
}
|
||||
|
||||
func httpEntryFromEntry(entry Entry) httpEntry {
|
||||
return httpEntry{
|
||||
ID: entry.ID,
|
||||
Title: entry.Title,
|
||||
FormattedDate: entry.Date.Format(dateFormat),
|
||||
Content: html.UnescapeString(entry.Content),
|
||||
HTMLContent: renderContent(entry.Content),
|
||||
}
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
repo *Repository
|
||||
logger *log.Logger
|
||||
templateRenderer *httpserver.TemplateRenderer
|
||||
}
|
||||
|
||||
func NewServer(repo *Repository, templateRenderer *httpserver.TemplateRenderer, logger *log.Logger) *Server {
|
||||
return &Server{
|
||||
repo: repo,
|
||||
logger: logger,
|
||||
templateRenderer: templateRenderer,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) HandleEntries(w http.ResponseWriter, r *http.Request) {
|
||||
type templateProperties struct {
|
||||
Entries []httpEntry
|
||||
Pagination struct {
|
||||
Next int
|
||||
Prev int
|
||||
}
|
||||
}
|
||||
|
||||
limit := 24
|
||||
|
||||
var offsetID *int = nil
|
||||
strOffset := r.URL.Query().Get("offset")
|
||||
|
||||
if strOffset != "" {
|
||||
parsedOffset, err := strconv.Atoi(strOffset)
|
||||
if err == nil && parsedOffset > 0 {
|
||||
offsetID = &parsedOffset
|
||||
}
|
||||
}
|
||||
|
||||
entriesFromDB, err := s.repo.Find(r.Context(), limit, offsetID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
httpserver.NotFound(w, r)
|
||||
s.logger.Println("No entries found:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
httpserver.InternalServerError(w, r)
|
||||
s.logger.Println("Error finding entries:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
prev := 0
|
||||
next := 0
|
||||
|
||||
if offsetID != nil {
|
||||
if *offsetID > 0 {
|
||||
prev = *offsetID - limit
|
||||
}
|
||||
if len(entriesFromDB) == limit {
|
||||
next = *offsetID + limit
|
||||
}
|
||||
} else {
|
||||
if len(entriesFromDB) == limit {
|
||||
next = limit
|
||||
}
|
||||
if len(entriesFromDB) > 0 {
|
||||
prev = entriesFromDB[0].ID - limit
|
||||
}
|
||||
}
|
||||
|
||||
if len(entriesFromDB) == limit {
|
||||
next = entriesFromDB[len(entriesFromDB)-1].ID
|
||||
}
|
||||
|
||||
if offsetID != nil {
|
||||
prev = *offsetID - limit // no fancy DB pagination, just simple/naive cursor
|
||||
}
|
||||
|
||||
// normalize offset IDs
|
||||
if prev < 0 {
|
||||
prev = 0
|
||||
}
|
||||
if next < 0 {
|
||||
next = 0
|
||||
}
|
||||
|
||||
tplProps := templateProperties{
|
||||
Entries: make([]httpEntry, len(entriesFromDB)),
|
||||
Pagination: struct {
|
||||
Next int
|
||||
Prev int
|
||||
}{
|
||||
Next: next,
|
||||
Prev: prev,
|
||||
},
|
||||
}
|
||||
|
||||
for i, entry := range entriesFromDB {
|
||||
tplProps.Entries[i] = httpEntryFromEntry(*entry)
|
||||
}
|
||||
|
||||
s.templateRenderer.Render(w, "entries.html", tplProps)
|
||||
}
|
||||
|
||||
func (s *Server) HandleEntryByID(w http.ResponseWriter, r *http.Request) {
|
||||
strID := r.PathValue("id")
|
||||
id, err := strconv.Atoi(strID)
|
||||
if err != nil {
|
||||
httpserver.BadRequest(w, r)
|
||||
log.Println("Error converting ID to int:", err)
|
||||
return
|
||||
}
|
||||
|
||||
entryFromDB, err := s.repo.FindByID(r.Context(), id)
|
||||
if err != nil {
|
||||
httpserver.InternalServerError(w, r)
|
||||
log.Println("Error finding entry by ID:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
entry := httpEntryFromEntry(*entryFromDB)
|
||||
|
||||
s.templateRenderer.Render(w, "entry.html", entry)
|
||||
}
|
||||
|
||||
func (s *Server) HandleDeleteEntryByID(w http.ResponseWriter, r *http.Request) {
|
||||
strID := r.PostFormValue("id")
|
||||
|
||||
s.logger.Println("Deleting entry with ID:", strID)
|
||||
|
||||
id, err := strconv.Atoi(strID)
|
||||
if err != nil {
|
||||
httpserver.BadRequest(w, r)
|
||||
s.logger.Printf("Error converting ID to int: %v, got: %v\n", err, strID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = s.repo.Delete(r.Context(), id)
|
||||
if err != nil {
|
||||
httpserver.InternalServerError(w, r)
|
||||
s.logger.Println("Error deleting entry by ID:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/entry/deleted", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) HandleNewEntry(w http.ResponseWriter, r *http.Request) {
|
||||
templateProps := struct {
|
||||
Today string
|
||||
}{
|
||||
Today: time.Now().Format(dateFormat),
|
||||
}
|
||||
|
||||
s.templateRenderer.Render(w, "new-entry.html", templateProps)
|
||||
}
|
||||
|
||||
func (s *Server) HandleSubmitNewEntry(w http.ResponseWriter, r *http.Request) {
|
||||
title := r.PostFormValue("title")
|
||||
date := r.PostFormValue("date")
|
||||
content := r.PostFormValue("content")
|
||||
|
||||
if title == "" || date == "" || content == "" {
|
||||
httpserver.BadRequest(w, r)
|
||||
s.logger.Println("Missing required fields")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
parsedDate, err := time.Parse(dateFormat, date)
|
||||
if err != nil {
|
||||
httpserver.BadRequest(w, r)
|
||||
s.logger.Println("Error parsing date:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
e := &Entry{
|
||||
Title: title,
|
||||
Date: parsedDate,
|
||||
Content: content,
|
||||
}
|
||||
|
||||
err = s.repo.Create(r.Context(), e)
|
||||
if err != nil {
|
||||
httpserver.InternalServerError(w, r)
|
||||
s.logger.Println("Error creating entry:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/entry/%d", e.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) HandleDeletedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
s.templateRenderer.Render(w, "deleted-entry.html", nil)
|
||||
}
|
||||
|
||||
func (s *Server) HandleEditEntryByID(w http.ResponseWriter, r *http.Request) {
|
||||
strID := r.PathValue("id")
|
||||
id, err := strconv.Atoi(strID)
|
||||
if err != nil {
|
||||
httpserver.BadRequest(w, r)
|
||||
s.logger.Println("Error converting ID to int:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
entryFromDB, err := s.repo.FindByID(r.Context(), id)
|
||||
if err != nil {
|
||||
httpserver.InternalServerError(w, r)
|
||||
s.logger.Println("Error finding entry by ID:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
entry := httpEntryFromEntry(*entryFromDB)
|
||||
|
||||
s.templateRenderer.Render(w, "edit-entry.html", entry)
|
||||
}
|
||||
|
||||
func (s *Server) HandleSubmitEditEntryByID(w http.ResponseWriter, r *http.Request) {
|
||||
strID := r.PathValue("id")
|
||||
id, err := strconv.Atoi(strID)
|
||||
if err != nil {
|
||||
httpserver.BadRequest(w, r)
|
||||
s.logger.Println("Error converting ID to int:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
title := r.PostFormValue("title")
|
||||
date := r.PostFormValue("date")
|
||||
content := r.PostFormValue("content")
|
||||
|
||||
if title == "" || date == "" || content == "" {
|
||||
httpserver.BadRequest(w, r)
|
||||
s.logger.Println("Missing required fields")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
parsedDate, err := time.Parse(dateFormat, date)
|
||||
if err != nil {
|
||||
httpserver.BadRequest(w, r)
|
||||
s.logger.Println("Error parsing date:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
e := &Entry{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Date: parsedDate,
|
||||
Content: content,
|
||||
}
|
||||
|
||||
err = s.repo.Update(r.Context(), e)
|
||||
if err != nil {
|
||||
httpserver.InternalServerError(w, r)
|
||||
s.logger.Println("Error updating entry:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/entry/%d", e.ID), http.StatusSeeOther)
|
||||
}
|
||||
20
journal/model.go
Normal file
20
journal/model.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package journal
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
ID int `gorm:"primaryKey"`
|
||||
Date time.Time
|
||||
Title string
|
||||
Content string
|
||||
|
||||
gorm.DeletedAt `gorm:"index,default:null"`
|
||||
}
|
||||
|
||||
func (e *Entry) TableName() string {
|
||||
return "entries"
|
||||
}
|
||||
10
journal/renderer.go
Normal file
10
journal/renderer.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package journal
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func renderContent(content string) template.HTML {
|
||||
return template.HTML(strings.ReplaceAll(content, "\n", "<br />"))
|
||||
}
|
||||
103
journal/repository.go
Normal file
103
journal/repository.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
package journal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) Create(ctx context.Context, entry *Entry) error {
|
||||
if entry == nil {
|
||||
return fmt.Errorf("entry cannot be nil")
|
||||
}
|
||||
|
||||
sanitizedContent := sanitizeContent(entry.Content)
|
||||
entry.Content = sanitizedContent
|
||||
|
||||
err := r.db.WithContext(ctx).Create(entry).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create entry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindByID(ctx context.Context, id int) (*Entry, error) {
|
||||
var entry Entry
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&entry).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find entry: %w", err)
|
||||
}
|
||||
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func (r *Repository) Find(ctx context.Context, limit int, offsetID *int) ([]*Entry, error) {
|
||||
var entries []*Entry
|
||||
|
||||
if offsetID == nil {
|
||||
err := r.db.WithContext(ctx).Limit(limit).Order("date DESC, id DESC").Find(&entries).Error
|
||||
if err != nil {
|
||||
return nil, errors.Join(errors.New("failed to find entries"), err)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
if *offsetID < 0 {
|
||||
return nil, fmt.Errorf("offsetID must be greater than or equal to 0, %d given", *offsetID)
|
||||
}
|
||||
|
||||
if limit <= 0 {
|
||||
return nil, fmt.Errorf("limit must be greater than 0")
|
||||
}
|
||||
|
||||
err := r.db.WithContext(ctx).Where("id > ?", offsetID).Limit(limit).Order("id ASC").Find(&entries).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find entries: %w", err)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (r *Repository) Update(ctx context.Context, entry *Entry) error {
|
||||
if entry == nil {
|
||||
return fmt.Errorf("entry cannot be nil")
|
||||
}
|
||||
|
||||
sanitizedContent := sanitizeContent(entry.Content)
|
||||
entry.Content = sanitizedContent
|
||||
|
||||
err := r.db.WithContext(ctx).Save(entry).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update entry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) Delete(ctx context.Context, id int) error {
|
||||
err := r.db.WithContext(ctx).Delete(&Entry{}, id).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete entry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeContent(content string) string {
|
||||
// Implement your sanitization logic here
|
||||
// For example, you can use a library like "github.com/microcosm-cc/bluemonday" to sanitize HTML content
|
||||
return html.EscapeString(content)
|
||||
}
|
||||
14
journal/routes.go
Normal file
14
journal/routes.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package journal
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /", s.HandleEntries)
|
||||
mux.HandleFunc("GET /entry/new", s.HandleNewEntry)
|
||||
mux.HandleFunc("POST /entry/new", s.HandleSubmitNewEntry)
|
||||
mux.HandleFunc("POST /entry/delete", s.HandleDeleteEntryByID)
|
||||
mux.HandleFunc("GET /entry/{id}", s.HandleEntryByID)
|
||||
mux.HandleFunc("GET /entry/{id}/edit", s.HandleEditEntryByID)
|
||||
mux.HandleFunc("POST /entry/{id}/edit", s.HandleSubmitEditEntryByID)
|
||||
mux.HandleFunc("GET /entry/deleted", s.HandleDeletedEntry)
|
||||
}
|
||||
92
main.go
Normal file
92
main.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitlab.mareshq.com/lab/journal/httpserver"
|
||||
"gitlab.mareshq.com/lab/journal/journal"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
//go:embed assets/favicon.ico
|
||||
var favicon []byte
|
||||
|
||||
//go:embed templates/*
|
||||
var templatesFS embed.FS
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
logger := log.New(os.Stdout, "", log.LstdFlags)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("var/local.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to connect database: %v", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to get database: %v", err)
|
||||
}
|
||||
|
||||
// graceful shutdown of database connections
|
||||
defer func() {
|
||||
err = sqlDB.Close()
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to close database: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err = db.AutoMigrate(&journal.Entry{})
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to migrate database: %v\n", err)
|
||||
}
|
||||
|
||||
repo := journal.NewRepository(db)
|
||||
tr := httpserver.NewTemplateRenderer(templatesFS)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
fav := httpserver.NewFavicon(favicon)
|
||||
mux.HandleFunc("GET /favicon.ico", fav.FaviconHandler)
|
||||
|
||||
// Journal server is HTTP interface
|
||||
journalServer := journal.NewServer(repo, tr, logger)
|
||||
journalServer.RegisterRoutes(mux)
|
||||
|
||||
loggingMiddleware := httpserver.NewLoggingMiddleware(logger)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: loggingMiddleware.Logging(mux),
|
||||
}
|
||||
|
||||
// start the HTTP server in a goroutine
|
||||
go func() {
|
||||
logger.Printf("Starting HTTP server on %s\n", server.Addr)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatalf("listen: %s\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
logger.Println("Received signal, shutting down...")
|
||||
|
||||
ctxShutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctxShutdown); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Fatalf("HTTP Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
logger.Println("HTTP server shut down")
|
||||
}
|
||||
16
templates/deleted-entry.html
Normal file
16
templates/deleted-entry.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{{ template "layout.html" . }}
|
||||
|
||||
{{ define "title" }}Entry deleted{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
<style>
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
<main class="container">
|
||||
<p>Entry has been deleted.</p>
|
||||
</main>
|
||||
{{ end }}
|
||||
61
templates/edit-entry.html
Normal file
61
templates/edit-entry.html
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{{ template "layout.html" . }}
|
||||
|
||||
{{ define "title" }}Edit entry: {{ .Title }}{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* max-width: 600px; */
|
||||
margin: 0 auto;
|
||||
}
|
||||
form input,
|
||||
form textarea {
|
||||
font-size: 1rem;
|
||||
width: calc(100% - 1rem);
|
||||
}
|
||||
form input[type="date"] {
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
form input[type="text"] {
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
form textarea {
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
resize: none;
|
||||
height: 24rem;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
function editEntry(event) {
|
||||
event.preventDefault();
|
||||
document.getElementById('edit-entry').submit();
|
||||
}
|
||||
</script>
|
||||
<main class="container">
|
||||
<h1>Edit entry: {{ .Title }}</h1>
|
||||
<hr />
|
||||
<p>
|
||||
[<a href="/entry/{{ .ID }}">Back to entry</a>]
|
||||
</p>
|
||||
<hr />
|
||||
<form id="edit-entry" action="/entry/{{ .ID }}/edit" method="POST">
|
||||
<input type="text" name="title" placeholder="Title" value="{{ .Title }}" required>
|
||||
<input type="date" name="date" value="{{ .FormattedDate }}" required>
|
||||
<textarea name="content" placeholder="Content" rows="12" required>{{ .Content }}</textarea>
|
||||
<div>
|
||||
[<a href="#" onclick="editEntry(event)">Save</a>]
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
{{ end }}
|
||||
29
templates/entries.html
Normal file
29
templates/entries.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{{ template "layout.html" . }}
|
||||
|
||||
{{ define "title" }}Home{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
<style>
|
||||
.disabled {
|
||||
color: #aaa !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<main class="container">
|
||||
<h1>Journal entries</h1>
|
||||
<hr />
|
||||
<ul>
|
||||
<!-- Entry title [date | YYYY-MM-DD] -->
|
||||
{{ range .Entries }} <!-- begin range -->
|
||||
<li>
|
||||
<a href="/entry/{{ .ID }}">{{ .Title }} [{{ .FormattedDate }}]</a>
|
||||
</li>
|
||||
{{ end }} <!-- end range -->
|
||||
</ul>
|
||||
<hr />
|
||||
<p>
|
||||
[<a href="/?offset={{ .Pagination.Prev }}" class="{{ if eq .Pagination.Prev 0 }}disabled{{ end }}">prev</a>]
|
||||
[<a href="/?offset={{ .Pagination.Next }}" class="{{ if eq .Pagination.Next 0 }}disabled{{ end }}">next</a>]
|
||||
</p>
|
||||
</main>
|
||||
{{ end }}
|
||||
28
templates/entry.html
Normal file
28
templates/entry.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{{ template "layout.html" . }}
|
||||
|
||||
{{ define "title" }}{{ .Title }} [{{ .FormattedDate }}]{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
<script>
|
||||
function deleteEntry(event) {
|
||||
event.preventDefault();
|
||||
if (window.confirm('Do you really want to delete this entry?')) {
|
||||
document.getElementById('delete-entry').submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<main class="container">
|
||||
<h1>{{ .Title }}</h1>
|
||||
<h2>[{{ .FormattedDate }}]</h2>
|
||||
<hr />
|
||||
<p>
|
||||
[<a href="/entry/{{ .ID }}/edit">Edit</a>]
|
||||
[<a href="#" onclick="deleteEntry(event)">Delete</a>]
|
||||
</p>
|
||||
<form id="delete-entry" action="/entry/delete" method="POST">
|
||||
<input type="hidden" name="id" value="{{ .ID }}">
|
||||
</form>
|
||||
<hr />
|
||||
<p>{{ .HTMLContent }}</p>
|
||||
</main>
|
||||
{{ end }}
|
||||
91
templates/layout.html
Normal file
91
templates/layout.html
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ block "title" .}}{{ end }} | Journal</title>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<style>
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: 64rem;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
nav ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
}
|
||||
nav ul li {
|
||||
margin-right: 20px;
|
||||
}
|
||||
nav ul li a {
|
||||
color: #007BFF;
|
||||
}
|
||||
body {
|
||||
font-family: Consolas, monospace;
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
}
|
||||
header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
main {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
ul {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
li {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
a {
|
||||
color: #007BFF;
|
||||
}
|
||||
.container {
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
/* padding: 1rem; */
|
||||
padding-top: .5rem;
|
||||
padding-bottom: 1rem;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
border-radius: 2rem;
|
||||
border-color: #000;
|
||||
border-width: 0.25rem;
|
||||
border-style: solid;
|
||||
background-color: #eeeeee;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><strong>Journal</strong></li>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/entry/new">New entry</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
{{ block "content" . }}{{ end }}
|
||||
</body>
|
||||
</html>
|
||||
74
templates/new-entry.html
Normal file
74
templates/new-entry.html
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
{{ template "layout.html" . }}
|
||||
|
||||
{{ define "title" }}New entry{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* max-width: 600px; */
|
||||
margin: 0 auto;
|
||||
}
|
||||
form input,
|
||||
form textarea {
|
||||
font-size: 1rem;
|
||||
width: calc(100% - 1rem);
|
||||
}
|
||||
form input[type="date"] {
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
form input[type="text"] {
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
form textarea {
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
resize: none;
|
||||
height: 24rem;
|
||||
}
|
||||
/* form button {
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background-color: #000;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
form button:hover {
|
||||
background-color: #333;
|
||||
} */
|
||||
</style>
|
||||
<script>
|
||||
function saveEntry(event) {
|
||||
event.preventDefault();
|
||||
document.getElementById('new-entry').submit();
|
||||
}
|
||||
function clearForm(event) {
|
||||
event.preventDefault();
|
||||
document.getElementById('new-entry').reset();
|
||||
}
|
||||
</script>
|
||||
<main class="container">
|
||||
<h1>New entry</h1>
|
||||
<hr />
|
||||
<form id="new-entry" action="/entry/new" method="POST">
|
||||
<input type="text" name="title" placeholder="Title" required>
|
||||
<input type="date" name="date" value="{{ .Today }}" required>
|
||||
<textarea name="content" placeholder="Content" rows="12" required></textarea>
|
||||
<div>
|
||||
[<a href="#" onclick="saveEntry(event)">Save</a>] [<a href="#" onclick="clearForm(event)">Clear</a>]
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
{{ end }}
|
||||
Loading…
Add table
Add a link
Reference in a new issue