Compare commits

..

18 Commits

Author SHA1 Message Date
b88fb1643c Fixed bug 2024-04-07 11:12:07 +02:00
92189a4a51 Pictures are now handeled correctly 2024-04-07 10:58:07 +02:00
8dc8f02504 Changed rss package to tagged version 2024-04-04 17:17:55 +02:00
e3ce1d7b55 Simply provide RSS feed when GET request is received 2024-04-04 17:13:42 +02:00
532bc6490a Added XML encoding 2024-04-04 17:09:29 +02:00
84fa828b38 Provide RSS feed as file when pressing the button or typing the URL 2024-04-03 21:05:12 +02:00
a3c53b1b20 Changed URL patterns to be more specific 2024-04-03 20:24:54 +02:00
ca70fa6d4d Applied changes also to rework-article.html 2024-04-03 19:52:16 +02:00
972b8cac19 Corrected vertical gap size for tags when wrapping onto the next line 2024-04-03 19:51:27 +02:00
d0605660f7 Made tags wrap onto the next line when overflowing parent container 2024-04-03 19:48:42 +02:00
5d2d841aba Changed tag length to 50 characters 2024-04-03 19:47:27 +02:00
d62c5a4078 Changed visual layout for to-be-published articles 2024-04-03 18:12:28 +02:00
803c5bbdbd Slightly changed button color and changed body height to be min-100vh 2024-04-03 04:50:25 +02:00
c74bdeba72 Only show logout button in hub 2024-04-02 21:35:34 +02:00
717f1c813b Add setup script for DB 2024-04-02 19:38:16 +02:00
52797760bb Also, handle first user differently under the hood 2024-04-02 19:37:53 +02:00
8711ba0629 Handle first user differently from the rest 2024-04-01 19:26:18 +02:00
ed51d28c65 Corrected back button class for unpublished articles 2024-04-01 15:58:36 +02:00
27 changed files with 420 additions and 229 deletions

View File

@ -20,13 +20,13 @@ func HandleCliArgs() (*CliArgs, error) {
var err error var err error
cliArgs := new(CliArgs) cliArgs := new(CliArgs)
flag.StringVar(&cliArgs.DBName, "db", "cpolis", "DB name")
keyFile := flag.String("key", "/var/www/cpolis/cpolis.key", "key file") keyFile := flag.String("key", "/var/www/cpolis/cpolis.key", "key file")
logFile := flag.String("log", "/var/log/cpolis.log", "log file") logFile := flag.String("log", "/var/log/cpolis.log", "log file")
picsDir := flag.String("pics", "/var/www/cpolis/pics", "pictures directory") flag.StringVar(&cliArgs.PicsDir, "pics", "pics", "pictures directory")
port := flag.Int("port", 8080, "port") port := flag.Int("port", 8080, "port")
rssFile := flag.String("rss", "/var/www/cpolis/cpolis.rss", "RSS file") rssFile := flag.String("rss", "/var/www/cpolis/cpolis.rss", "RSS file")
webDir := flag.String("web", "/var/www/cpolis/web", "web directory") webDir := flag.String("web", "/var/www/cpolis/web", "web directory")
flag.StringVar(&cliArgs.DBName, "db", "cpolis", "DB name")
flag.Parse() flag.Parse()
cliArgs.KeyFile, err = filepath.Abs(*keyFile) cliArgs.KeyFile, err = filepath.Abs(*keyFile)
@ -39,7 +39,7 @@ func HandleCliArgs() (*CliArgs, error) {
return nil, fmt.Errorf("error finding absolute path for LogFile: %v", err) return nil, fmt.Errorf("error finding absolute path for LogFile: %v", err)
} }
cliArgs.PicsDir, err = filepath.Abs(*picsDir) _, err = filepath.Abs(cliArgs.PicsDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("error finding absolute path for PicsDir: %v", err) return nil, fmt.Errorf("error finding absolute path for PicsDir: %v", err)
} }

View File

@ -107,7 +107,7 @@ func GenerateRSS(db *model.DB, title, link, desc string) (*string, error) {
feed := rss.NewFeed() feed := rss.NewFeed()
feed.Channels = append(feed.Channels, channel) feed.Channels = append(feed.Channels, channel)
rss, err := feed.ToXML() rss, err := feed.ToXML("UTF-8")
if err != nil { if err != nil {
return nil, fmt.Errorf("error converting RSS feed to XML: %v", err) return nil, fmt.Errorf("error converting RSS feed to XML: %v", err)
} }

View File

@ -49,42 +49,33 @@ func main() {
http.FileServer(http.Dir(args.WebDir+"/static/")))) http.FileServer(http.Dir(args.WebDir+"/static/"))))
mux.HandleFunc("/", view.HomePage(args, db, store)) mux.HandleFunc("/", view.HomePage(args, db, store))
mux.HandleFunc("GET /create-tag/", view.CreateTag(args)) mux.HandleFunc("GET /create-tag", view.CreateTag(args))
mux.HandleFunc("GET /create-user/", view.CreateUser(args)) mux.HandleFunc("GET /create-user", view.CreateUser(args))
mux.HandleFunc("GET /edit-user/", view.EditUser(args, db, store)) mux.HandleFunc("GET /edit-user", view.EditUser(args, db, store))
mux.HandleFunc("GET /hub/", view.ShowHub(args, db, store)) mux.HandleFunc("GET /hub", view.ShowHub(args, db, store))
mux.HandleFunc("GET /logout/", view.Logout(args, store)) mux.HandleFunc("GET /logout", view.Logout(args, store))
mux.HandleFunc("GET /publish-issue/", mux.HandleFunc("GET /publish-article/{id}", view.PublishArticle(args, db, store))
view.PublishLatestIssue(args, db, store)) mux.HandleFunc("GET /publish-issue", view.PublishLatestIssue(args, db, store))
mux.HandleFunc("GET /rejected-articles/", mux.HandleFunc("GET /reject-article/{id}", view.RejectArticle(args, db, store))
view.ShowRejectedArticles(args, db, store)) mux.HandleFunc("GET /rejected-articles", view.ShowRejectedArticles(args, db, store))
mux.HandleFunc("GET /review-rejected-article/{id}/", mux.HandleFunc("GET /review-rejected-article/{id}", view.ReviewRejectedArticle(args, db, store))
view.ReviewRejectedArticle(args, db, store)) mux.HandleFunc("GET /review-unpublished-article/{id}", view.ReviewUnpublishedArticle(args, db, store))
mux.HandleFunc("GET /review-unpublished-article/{id}/", mux.HandleFunc("GET /rss", func(w http.ResponseWriter, r *http.Request) {
view.ReviewUnpublishedArticle(args, db, store)) http.ServeFile(w, r, args.RSSFile)
mux.HandleFunc("GET /rss/", view.ShowRSS(args, })
db, mux.HandleFunc("GET /pics/{pic}", view.ServeImage(args, store))
"Freimaurer Distrikt Niedersachsen und Sachsen-Anhalt", mux.HandleFunc("GET /this-issue", view.ShowCurrentArticles(args, db))
"https://distrikt-ni-st.de", mux.HandleFunc("GET /unpublished-articles", view.ShowUnpublishedArticles(args, db))
"Freiheit, Gleichheit, Brüderlichkeit, Toleranz und Humanität", mux.HandleFunc("GET /write-article", view.WriteArticle(args, db))
))
mux.HandleFunc("GET /this-issue/", view.ShowCurrentArticles(args, db))
mux.HandleFunc("GET /unpublished-articles/",
view.ShowUnpublishedArticles(args, db))
mux.HandleFunc("GET /write-article/", view.WriteArticle(args, db))
mux.HandleFunc("POST /add-tag/", view.AddTag(args, db, store)) mux.HandleFunc("POST /add-first-user", view.AddFirstUser(args, db, store))
mux.HandleFunc("POST /add-user/", view.AddUser(args, db, store)) mux.HandleFunc("POST /add-tag", view.AddTag(args, db, store))
mux.HandleFunc("POST /login/", view.Login(args, db, store)) mux.HandleFunc("POST /add-user", view.AddUser(args, db, store))
mux.HandleFunc("POST /publish-article/{id}/", mux.HandleFunc("POST /login", view.Login(args, db, store))
view.PublishArticle(args, db, store)) mux.HandleFunc("POST /resubmit-article/{id}", view.ResubmitArticle(args, db, store))
mux.HandleFunc("POST /reject-article/{id}/", mux.HandleFunc("POST /submit-article", view.SubmitArticle(args, db, store))
view.RejectArticle(args, db, store)) mux.HandleFunc("POST /update-user", view.UpdateUser(args, db, store))
mux.HandleFunc("POST /resubmit-article/{id}/", mux.HandleFunc("POST /upload-image", view.UploadImage(args))
view.ResubmitArticle(args, db, store))
mux.HandleFunc("POST /submit-article/", view.SubmitArticle(args, db, store))
mux.HandleFunc("POST /update-user/", view.UpdateUser(args, db, store))
mux.HandleFunc("POST /upload-image/", view.UploadImage(args))
log.Fatalln(http.ListenAndServe(args.Port, mux)) log.Fatalln(http.ListenAndServe(args.Port, mux))
} }

View File

@ -1,6 +1,8 @@
package model package model
import ( import (
"context"
"database/sql"
"fmt" "fmt"
"log" "log"
@ -198,3 +200,71 @@ func (db *DB) UpdateUserAttributes(id int64, user, first, last, oldPass, newPass
return fmt.Errorf("error: %v unsuccessful retries for DB operation, aborting", TxMaxRetries) return fmt.Errorf("error: %v unsuccessful retries for DB operation, aborting", TxMaxRetries)
} }
func (db *DB) AddFirstUser(u *User, pass string) (int64, error) {
var numUsers int64
txOptions := &sql.TxOptions{Isolation: sql.LevelSerializable}
selectQuery := "SELECT COUNT(*) FROM users"
insertQuery := `
INSERT INTO users (username, password, first_name, last_name, role)
VALUES (?, ?, ?, ?, ?)
`
for i := 0; i < TxMaxRetries; i++ {
id, err := func() (int64, error) {
tx, err := db.BeginTx(context.Background(), txOptions)
if err != nil {
return 0, fmt.Errorf("error starting transaction: %v", err)
}
if err := tx.QueryRow(selectQuery).Scan(&numUsers); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return 0, fmt.Errorf("error getting ID of %v: %v", u.UserName, err)
}
if numUsers != 0 {
if err = tx.Commit(); err != nil {
return 0, fmt.Errorf("error committing transaction: %v", err)
}
return 2, nil
}
hashedPass, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return 0, fmt.Errorf("error creating password hash: %v", err)
}
result, err := tx.Exec(insertQuery, u.UserName, string(hashedPass), u.FirstName, u.LastName, u.Role)
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return 0, fmt.Errorf("error inserting new user %v into DB: %v", u.UserName, err)
}
id, err := result.LastInsertId()
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return 0, fmt.Errorf("error inserting user into DB: %v", err)
}
if err = tx.Commit(); err != nil {
return 0, fmt.Errorf("error committing transaction: %v", err)
}
return id, nil
}()
if err == nil {
return id, nil
}
log.Println(err)
wait(i)
}
return 0, fmt.Errorf("error: %v unsuccessful retries for DB operation, aborting", TxMaxRetries)
}

View File

@ -7,6 +7,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strconv" "strconv"
"time" "time"
@ -197,26 +198,53 @@ func ShowRejectedArticles(c *control.CliArgs, db *model.DB, s *control.CookieSto
func ReviewUnpublishedArticle(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc { func ReviewUnpublishedArticle(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
type htmlData struct { type htmlData struct {
Article *model.Article Title string
Tags []*model.Tag Description string
Content template.HTML
Tags []*model.Tag
ID int64
} }
var err error
data := new(htmlData) data := new(htmlData)
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) data.ID, err = strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
data.Article, err = db.GetArticle(id) article, err := db.GetArticle(data.ID)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
data.Tags, err = db.GetArticleTags(id) data.Title, err = control.ConvertToPlain(article.Title)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data.Description, err = control.ConvertToPlain(article.Description)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
content, err := control.ConvertToHTML(article.Content)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data.Content = template.HTML(content)
data.Tags, err = db.GetArticleTags(data.ID)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@ -385,8 +413,16 @@ func UploadImage(c *control.CliArgs) http.HandlerFunc {
} }
defer file.Close() defer file.Close()
filename := fmt.Sprint(c.PicsDir, time.Now().Format("2006-01-02_15:04:05"), "-", header.Filename) filename := fmt.Sprint(time.Now().Format("2006-01-02_15:04:05"), "-",
img, err := os.Create(filename) header.Filename)
absFilepath, err := filepath.Abs(fmt.Sprint(c.PicsDir, "/", filename))
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
img, err := os.Create(absFilepath)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@ -400,7 +436,8 @@ func UploadImage(c *control.CliArgs) http.HandlerFunc {
return return
} }
imgMD := fmt.Sprint("![", header.Filename, "](/pics/", filename, ")")
tmpl, err := template.ParseFiles(c.WebDir + "/templates/editor.html") tmpl, err := template.ParseFiles(c.WebDir + "/templates/editor.html")
template.Must(tmpl, err).ExecuteTemplate(w, "editor-images", fmt.Sprint("![", header.Filename, "](", filename, ")")) template.Must(tmpl, err).ExecuteTemplate(w, "editor-images", imgMD)
} }
} }

22
cmd/view/images.go Normal file
View File

@ -0,0 +1,22 @@
package view
import (
"log"
"net/http"
"path/filepath"
"streifling.com/jason/cpolis/cmd/control"
)
func ServeImage(c *control.CliArgs, s *control.CookieStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
absFilepath, err := filepath.Abs(c.PicsDir)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.ServeFile(w, r, absFilepath+"/"+r.PathValue("pic"))
}
}

View File

@ -1,95 +0,0 @@
package view
import (
"fmt"
"html/template"
"log"
"net/http"
"time"
"git.streifling.com/jason/rss"
"streifling.com/jason/cpolis/cmd/control"
"streifling.com/jason/cpolis/cmd/model"
)
func ShowRSS(c *control.CliArgs, db *model.DB, title, link, desc string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
channel := &rss.Channel{
Title: title,
Link: link,
Description: desc,
Items: make([]*rss.Item, 0),
}
articles, err := db.GetCertainArticles(true, false)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, article := range articles {
tags, err := db.GetArticleTags(article.ID)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tagNames := make([]string, 0)
for _, tag := range tags {
tagNames = append(tagNames, tag.Name)
}
tagNames = append(tagNames, fmt.Sprint("Orient Express ", article.IssueID))
user, err := db.GetUser(article.AuthorID)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
articleTitle, err := control.ConvertToPlain(article.Title)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
articleDescription, err := control.ConvertToPlain(article.Description)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
articleContent, err := control.ConvertToHTML(article.Content)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
channel.Items = append(channel.Items, &rss.Item{
Title: articleTitle,
Author: user.FirstName + user.LastName,
PubDate: article.Created.Format(time.RFC1123Z),
Description: articleDescription,
Content: &rss.Content{Value: articleContent},
Categories: tagNames,
})
}
feed := rss.NewFeed()
feed.Channels = append(feed.Channels, channel)
rss, err := feed.ToXML()
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
files := []string{c.WebDir + "/templates/index.html", c.WebDir + "/templates/feed.rss"}
tmpl, err := template.ParseFiles(files...)
template.Must(tmpl, err).Execute(w, rss)
}
}

View File

@ -36,7 +36,7 @@ func HomePage(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.Han
files := []string{c.WebDir + "/templates/index.html"} files := []string{c.WebDir + "/templates/index.html"}
if numRows == 0 { if numRows == 0 {
files = append(files, c.WebDir+"/templates/add-user.html") files = append(files, c.WebDir+"/templates/first-user.html")
tmpl, err := template.ParseFiles(files...) tmpl, err := template.ParseFiles(files...)
template.Must(tmpl, err).Execute(w, nil) template.Must(tmpl, err).Execute(w, nil)
} else { } else {

View File

@ -88,37 +88,13 @@ func AddUser(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.Hand
return return
} }
htmlData.ID, err = db.AddUser(htmlData.User, pass) _, err = db.AddUser(htmlData.User, pass)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
if htmlData.ID == 1 {
htmlData.Role = model.Admin
if err = db.UpdateAttributes(
&model.Attribute{Table: "users", ID: id, AttName: "role", Value: htmlData.Role},
); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := saveSession(w, r, s, htmlData.User); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, err := db.AddIssue(); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
tmpl, err := template.ParseFiles(c.WebDir + "/templates/hub.html") tmpl, err := template.ParseFiles(c.WebDir + "/templates/hub.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", 0) template.Must(tmpl, err).ExecuteTemplate(w, "page-content", 0)
} }
@ -214,3 +190,78 @@ func UpdateUser(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.H
tmpl.ExecuteTemplate(w, "page-content", session.Values["role"].(int)) tmpl.ExecuteTemplate(w, "page-content", session.Values["role"].(int))
} }
} }
func AddFirstUser(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var err error
htmlData := UserData{
User: &model.User{
UserName: r.PostFormValue("username"),
FirstName: r.PostFormValue("first-name"),
LastName: r.PostFormValue("last-name"),
Role: model.Admin,
},
}
pass := r.PostFormValue("password")
pass2 := r.PostFormValue("password2")
if len(htmlData.UserName) == 0 || len(htmlData.FirstName) == 0 ||
len(htmlData.LastName) == 0 || len(pass) == 0 || len(pass2) == 0 {
htmlData.Msg = "Alle Felder müssen ausgefüllt werden."
tmpl, err := template.ParseFiles(c.WebDir + "/templates/add-user.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", htmlData)
return
}
userString, stringLen, ok := checkUserStrings(htmlData.User)
if !ok {
htmlData.Msg = fmt.Sprint(userString, " ist zu lang. Maximal ",
stringLen, " Zeichen erlaubt.")
tmpl, err := template.ParseFiles(c.WebDir + "/templates/add-user.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", htmlData)
return
}
id, _ := db.GetID(htmlData.UserName)
if id != 0 {
htmlData.Msg = fmt.Sprint(htmlData.UserName,
" ist bereits vergeben. Bitte anderen Benutzernamen wählen.")
tmpl, err := template.ParseFiles(c.WebDir + "/templates/add-user.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", htmlData)
return
}
if pass != pass2 {
htmlData.Msg = "Die Passwörter stimmen nicht überein."
tmpl, err := template.ParseFiles(c.WebDir + "/templates/add-user.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", htmlData)
return
}
htmlData.ID, err = db.AddFirstUser(htmlData.User, pass)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if htmlData.ID > 1 {
errString := "error: there is already a first user"
log.Println(errString)
http.Error(w, errString, http.StatusInternalServerError)
return
}
if err := saveSession(w, r, s, htmlData.User); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, err := db.AddIssue(); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles(c.WebDir + "/templates/hub.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", 0)
}
}

50
create_db.sql Normal file
View File

@ -0,0 +1,50 @@
DROP TABLE IF EXISTS articles_tags;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS articles;
DROP TABLE IF EXISTS issues;
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id INT AUTO_INCREMENT,
username VARCHAR(15) NOT NULL UNIQUE,
password VARCHAR(60) NOT NULL,
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
role INT NOT NULL,
PRIMARY KEY(id)
);
CREATE TABLE issues (
id INT AUTO_INCREMENT,
published BOOL NOT NULL,
PRIMARY KEY(id)
);
CREATE TABLE articles (
id INT AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
description TEXT NOT NULL,
content TEXT NOT NULL,
published BOOL NOT NULL,
rejected BOOL NOT NULL,
author_id INT NOT NULL,
issue_id INT NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY(author_id) REFERENCES users(id),
FOREIGN KEY(issue_id) REFERENCES issues(id)
);
CREATE TABLE tags (
id INT AUTO_INCREMENT,
name VARCHAR(50) NOT NULL UNIQUE,
PRIMARY KEY(id)
);
CREATE TABLE articles_tags (
article_id INT,
tag_id INT,
PRIMARY KEY(article_id, tag_id),
FOREIGN KEY(article_id) REFERENCES articles(id),
FOREIGN KEY(tag_id) REFERENCES tags(id)
);

2
go.mod
View File

@ -3,7 +3,7 @@ module streifling.com/jason/cpolis
go 1.22.0 go 1.22.0
require ( require (
git.streifling.com/jason/rss v0.0.0-20240305164907-524bf9676188 git.streifling.com/jason/rss v0.1.2
github.com/go-sql-driver/mysql v1.7.1 github.com/go-sql-driver/mysql v1.7.1
github.com/gorilla/sessions v1.2.2 github.com/gorilla/sessions v1.2.2
github.com/microcosm-cc/bluemonday v1.0.26 github.com/microcosm-cc/bluemonday v1.0.26

4
go.sum
View File

@ -1,5 +1,5 @@
git.streifling.com/jason/rss v0.0.0-20240305164907-524bf9676188 h1:C8M/j3f+cl5Y7YfGpU/ynb/SC/4tTYMDsyGFt3rswM8= git.streifling.com/jason/rss v0.1.2 h1:UB3UHJXMt5WDDh9y8n0Z6nS1XortbPXjEr7QZTdovY4=
git.streifling.com/jason/rss v0.0.0-20240305164907-524bf9676188/go.mod h1:gpZF0nZbQSstMpyHD9DTAvlQEG7v4pjO5c7aIMWM4Jg= git.streifling.com/jason/rss v0.1.2/go.mod h1:gpZF0nZbQSstMpyHD9DTAvlQEG7v4pjO5c7aIMWM4Jg=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=

View File

@ -4,5 +4,7 @@ module.exports = {
theme: { theme: {
extend: {} extend: {}
}, },
plugins: [], plugins: [
require('@tailwindcss/typography')
],
} }

View File

@ -33,7 +33,7 @@ textarea {
} }
.btn { .btn {
@apply bg-slate-50 border my-2 px-3 py-2 rounded-md w-full hover:bg-slate-100; @apply bg-slate-200 border my-2 px-3 py-2 rounded-md w-full hover:bg-slate-100;
} }
.action-btn { .action-btn {

View File

@ -4,8 +4,8 @@
<form> <form>
<input required name="tag" placeholder="Tag eingeben" type="text" /> <input required name="tag" placeholder="Tag eingeben" type="text" />
<div class="btn-area"> <div class="btn-area">
<input class="action-btn" type="submit" value="Anlegen" hx-post="/add-tag/" hx-target="#page-content" /> <input class="action-btn" type="submit" value="Anlegen" hx-post="/add-tag" hx-target="#page-content" />
<button class="btn" hx-get="/hub/" hx-target="#page-content">Abbrechen</button> <button class="btn" hx-get="/hub" hx-target="#page-content">Abbrechen</button>
</div> </div>
</form> </form>

View File

@ -44,8 +44,8 @@
</div> </div>
<div class="btn-area"> <div class="btn-area">
<input class="action-btn" type="submit" value="Anlegen" hx-post="/add-user/" hx-target="#page-content" /> <input class="action-btn" type="submit" value="Anlegen" hx-post="/add-user" hx-target="#page-content" />
<button class="btn" hx-get="/hub/" hx-target="#page-content">Abbrechen</button> <button class="btn" hx-get="/hub" hx-target="#page-content">Abbrechen</button>
</div> </div>
</form> </form>

View File

@ -9,7 +9,7 @@
</div> </div>
<div class="btn-area"> <div class="btn-area">
<button class="action-btn" hx-get="/publish-issue/" hx-target="#page-content">Ausgabe publizieren</button> <button class="action-btn" hx-get="/publish-issue" hx-target="#page-content">Ausgabe publizieren</button>
<button class="btn" hx-get="/hub/" hx-target="#page-content">Abbrechen</button> <button class="btn" hx-get="/hub" hx-target="#page-content">Abbrechen</button>
</div> </div>
{{end}} {{end}}

View File

@ -28,9 +28,9 @@
</div> </div>
<div class="btn-area"> <div class="btn-area">
<input class="action-btn" type="submit" value="Aktualisieren" hx-post="/update-user/" <input class="action-btn" type="submit" value="Aktualisieren" hx-post="/update-user"
hx-target="#page-content" /> hx-target="#page-content" />
<button class="btn" hx-get="/hub/" hx-target="#page-content">Abbrechen</button> <button class="btn" hx-get="/hub" hx-target="#page-content">Abbrechen</button>
</div> </div>
</form> </form>
{{end}} {{end}}

View File

@ -14,23 +14,26 @@
<textarea name="article-content"></textarea> <textarea name="article-content"></textarea>
</div> </div>
<div class="flex gap-4"> <div>
{{range .}} <span>Tags</span>
<div> <div class="flex flex-wrap gap-x-4">
<input id="{{.Name}}" name="tags" type="checkbox" value="{{.ID}}" /> {{range .}}
<label for="{{.Name}}">{{.Name}}</label> <div>
<input id="{{.Name}}" name="tags" type="checkbox" value="{{.ID}}" />
<label for="{{.Name}}">{{.Name}}</label>
</div>
{{end}}
</div> </div>
{{end}}
</div> </div>
<div id="editor-images"> <div id="editor-images">
<input class="mb-2" name="article-image" type="file" hx-encoding="multipart/form-data" hx-post="/upload-image/" <input class="mb-2" name="article-image" type="file" hx-encoding="multipart/form-data" hx-post="/upload-image"
hx-swap="beforeend" hx-target="#editor-images" /> hx-swap="beforeend" hx-target="#editor-images" />
</div> </div>
<div class="btn-area"> <div class="btn-area">
<input class="action-btn" type="submit" value="Senden" hx-post="/submit-article/" hx-target="#page-content" /> <input class="action-btn" type="submit" value="Senden" hx-post="/submit-article" hx-target="#page-content" />
<button class="btn" hx-get="/hub/" hx-target="#page-content">Abbrechen</button> <button class="btn" hx-get="/hub" hx-target="#page-content">Abbrechen</button>
</div> </div>
</form> </form>

View File

@ -0,0 +1,39 @@
{{define "page-content"}}
<h2>Erster Benutzer (Administrator)</h2>
<form>
<div class="grid grid-cols-3 gap-4">
<div>
<label for="username">Benutzername</label>
<input class="w-full" required name="username" type="text" value="{{.UserName}}" />
</div>
<div>
<label for="password">Passwort</label>
<input class="w-full" required name="password" placeholder="***" type="password" />
</div>
<div>
<label for="password2">Passwort wiederholen</label>
<input class="w-full" required name="password2" placeholder="***" type="password" />
</div>
<div>
<label for="first-name">Vorname</label>
<input class="w-full" required name="first-name" type="text" value="{{.FirstName}}" />
</div>
<div>
<label for="last-name">Nachname</label>
<input class="w-full" required name="last-name" type="text" value="{{.LastName}}" />
</div>
</div>
<div class="btn-area">
<input class="action-btn" type="submit" value="Anlegen" hx-post="/add-first-user" hx-target="#page-content" />
</div>
</form>
<script>
var msg = "{{.Msg}}";
if (msg != "") {
alert(msg);
}
</script>
{{end}}

View File

@ -1,38 +1,43 @@
{{define "page-content"}} {{define "page-content"}}
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<button class="btn" hx-get="/logout" hx-target="#page-content">Abmelden</button>
<div class="mb-3"> <div class="mb-3">
<h2>Autor</h2> <h2>Autor</h2>
<div class="grid grid-cols-2 gap-x-4 gap-y-2"> <div class="grid grid-cols-2 gap-x-4 gap-y-2">
<button class="btn" hx-get="/write-article/" hx-target="#page-content">Artikel schreiben</button> <button class="btn" hx-get="/write-article" hx-target="#page-content">Artikel schreiben</button>
<button class="btn" hx-get="/rejected-articles/" hx-target="#page-content">Abgelehnte Artikel</button> <button class="btn" hx-get="/rejected-articles" hx-target="#page-content">Abgelehnte Artikel</button>
<button class="btn" hx-get="/rss/" hx-target="#page-content">RSS Feed</button> <a class="btn text-center" href="/rss">RSS Feed</a>
<button class="btn" hx-get="/edit-user/" hx-target="#page-content">Benutzer bearbeiten</button> <button class="btn" hx-get="/edit-user" hx-target="#page-content">Benutzer bearbeiten</button>
</div> </div>
</div> </div>
{{if lt . 3}} {{if lt . 3}}
<div class="mb-3"> <div class="mb-3">
<h2>Redakteur</h2> <h2>Redakteur</h2>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<button class="btn" hx-get="/unpublished-articles/" hx-target="#page-content"> <button class="btn" hx-get="/unpublished-articles" hx-target="#page-content">
Unveröffentlichte Artikel Unveröffentlichte Artikel
</button> </button>
<button class="btn" hx-get="/create-tag/" hx-target="#page-content">Neuer Tag</button> <button class="btn" hx-get="/create-tag" hx-target="#page-content">Neuer Tag</button>
</div> </div>
</div> </div>
{{end}} {{end}}
{{if lt . 2}} {{if lt . 2}}
<div class="mb-3"> <div class="mb-3">
<h2>Herausgeber</h2> <h2>Herausgeber</h2>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<button class="btn" hx-get="/this-issue/" hx-target="#page-content">Diese Ausgabe</button> <button class="btn" hx-get="/this-issue" hx-target="#page-content">Diese Ausgabe</button>
</div> </div>
</div> </div>
{{end}} {{end}}
{{if eq . 0}} {{if eq . 0}}
<div class="mb-3"> <div class="mb-3">
<h2>Administrator</h2> <h2>Administrator</h2>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<button class="btn" hx-get="/create-user/" hx-target="#page-content">Benutzer hinzufügen</button> <button class="btn" hx-get="/create-user" hx-target="#page-content">Benutzer hinzufügen</button>
</div> </div>
</div> </div>
{{end}} {{end}}

View File

@ -8,18 +8,15 @@
<link href="/web/static/css/style.css" rel="stylesheet"> <link href="/web/static/css/style.css" rel="stylesheet">
</head> </head>
<body class="flex flex-col justify-between min-h-[100dvh] bg-slate-50"> <body class="flex flex-col justify-between min-h-screen bg-slate-50">
<header class="my-8"> <header class="my-8">
<h1 class="font-bold text-4xl text-center">Orient Editor</h1> <h1 class="font-bold text-4xl text-center">Orient Editor</h1>
<button class="btn" hx-get="logout" hx-target="#page-content">Abmelden</button>
</header> </header>
<main> <main class="mx-4">
<div id="page-content"> <div id="page-content">
{{template "page-content" .}} {{template "page-content" .}}
</div> </div>
<script src="/web/static/js/htmx.min.js"></script>
</main> </main>
<footer class="my-8"> <footer class="my-8">
@ -27,6 +24,8 @@
&copy; 2024 Jason Streifling. Alle Rechte vorbehalten. &copy; 2024 Jason Streifling. Alle Rechte vorbehalten.
</p> </p>
</footer> </footer>
<script src="/web/static/js/htmx.min.js"></script>
</body> </body>
</html> </html>

View File

@ -6,6 +6,6 @@
<input class="w-full" name="password" placeholder="Passwort" type="password" /> <input class="w-full" name="password" placeholder="Passwort" type="password" />
</div> </div>
<input class="action-btn" type="submit" value="Anmelden" hx-post="/login/" hx-target="#page-content" /> <input class="action-btn" type="submit" value="Anmelden" hx-post="/login" hx-target="#page-content" />
</form> </form>
{{end}} {{end}}

View File

@ -2,13 +2,13 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
{{range .RejectedArticles}} {{range .RejectedArticles}}
{{if index $.MyIDs .ID}} {{if index $.MyIDs .ID}}
<button class="btn" hx-get="/review-rejected-article/{{.ID}}/" hx-target="#page-content"> <button class="btn" hx-get="/review-rejected-article/{{.ID}}" hx-target="#page-content">
<h1 class="font-bold text-2xl">{{.Title}}</h1> <h1 class="font-bold text-2xl">{{.Title}}</h1>
<p>{{.Description}}</p> <p>{{.Description}}</p>
</button> </button>
{{end}} {{end}}
{{end}} {{end}}
<button class="action-btn" hx-get="/hub/" hx-target="#page-content">Zurück</button> <button class="action-btn" hx-get="/hub" hx-target="#page-content">Zurück</button>
</div> </div>
{{end}} {{end}}

View File

@ -14,25 +14,28 @@
<textarea name="article-content" placeholder="Artikel">{{.Article.Content}}</textarea> <textarea name="article-content" placeholder="Artikel">{{.Article.Content}}</textarea>
</div> </div>
<div class="flex gap-4"> <div>
{{range .Tags}} <span>Tags</span>
<div> <div class="flex flex-wrap gap-x-4">
<input id="tag-{{.Name}}" name="tags" type="checkbox" value="{{.ID}}" {{if index $.Selected {{range .Tags}}
.ID}}checked{{end}} /> <div>
<label for="tag-{{.Name}}">{{.Name}}</label> <input id="tag-{{.Name}}" name="tags" type="checkbox" value="{{.ID}}" {{if index $.Selected
.ID}}checked{{end}} />
<label for="tag-{{.Name}}">{{.Name}}</label>
</div>
{{end}}
</div> </div>
{{end}}
</div> </div>
<div id="editor-images"> <div id="editor-images">
<input class="mb-2" name="article-image" type="file" hx-encoding="multipart/form-data" hx-post="/upload-image/" <input class="mb-2" name="article-image" type="file" hx-encoding="multipart/form-data" hx-post="/upload-image"
hx-swap="beforeend" hx-target="#editor-images" /> hx-swap="beforeend" hx-target="#editor-images" />
</div> </div>
<div class="btn-area"> <div class="btn-area">
<input class="action-btn" type="submit" value="Senden" hx-post="/resubmit-article/{{.Article.ID}}/" <input class="action-btn" type="submit" value="Senden" hx-post="/resubmit-article/{{.Article.ID}}"
hx-target="#page-content" /> hx-target="#page-content" />
<button class="btn" hx-get="/hub/" hx-target="#page-content">Zurück</button> <button class="btn" hx-get="/hub" hx-target="#page-content">Zurück</button>
</div> </div>
</form> </form>

View File

@ -1,21 +1,35 @@
{{define "page-content"}} {{define "page-content"}}
<form> <div>
<h2>{{.Article.Title}}</h2> <span>Titel</span>
<p>{{.Article.Description}}</p> <div class="bg-white border mb-3 px-2 py-2 rounded-md w-full">
{{.Article.Content}} {{.Title}}
</div>
<p> <span>Beschreibung</span>
<div class="bg-white border mb-3 px-2 py-2 rounded-md w-full">
{{.Description}}
</div>
<span>Artikel</span>
<div class="bg-white border mb-3 px-2 py-2 rounded-md w-full">
<div class="prose">
{{.Content}}
</div>
</div>
<span>Tags</span>
<div class="bg-white border mb-3 px-2 py-2 rounded-md w-full">
{{range .Tags}} {{range .Tags}}
{{.Name}} {{.Name}}
<br>
{{end}} {{end}}
</p> </div>
<div class="btn-area"> <div class="btn-area">
<input class="action-btn" type="submit" value="Veröffentlichen" hx-post="/publish-article/{{.Article.ID}}/" <input class="action-btn" type="submit" value="Veröffentlichen" hx-get="/publish-article/{{.ID}}"
hx-target="#page-content" /> hx-target="#page-content" />
<input class="btn" type="submit" value="Ablehnen" hx-post="/reject-article/{{.Article.ID}}/" <input class="btn" type="submit" value="Ablehnen" hx-get="/reject-article/{{.ID}}" hx-target="#page-content" />
hx-target="#page-content" /> <button class="btn" hx-get="/hub" hx-target="#page-content">Zurück</button>
<button class="btn" hx-get="/hub/" hx-target="#page-content">Zurück</button>
</div> </div>
</form> </div>
{{end}} {{end}}

View File

@ -1,11 +1,11 @@
{{define "page-content"}} {{define "page-content"}}
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
{{range .}} {{range .}}
<button class="btn" hx-get="/review-unpublished-article/{{.ID}}/" hx-target="#page-content"> <button class="btn" hx-get="/review-unpublished-article/{{.ID}}" hx-target="#page-content">
<h1 class="font-bold text-2xl">{{.Title}}</h1> <h1 class="font-bold text-2xl">{{.Title}}</h1>
<p>{{.Description}}</p> <p>{{.Description}}</p>
</button> </button>
{{end}} {{end}}
<button class="btn" hx-get="/hub/" hx-target="#page-content">Zurück</button> <button class="action-btn" hx-get="/hub" hx-target="#page-content">Zurück</button>
</div> </div>
{{end}} {{end}}