Compare commits

...

49 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
7e7de28b14 Streamlined selection of rejected and unpublished articles 2024-04-01 15:42:51 +02:00
0139f7ab9a Use ID in path rather than an invisible input when publishing, rejecting or resubmitting an article 2024-04-01 15:30:24 +02:00
7fc115bcc3 Refined look of rejected and unpublished articles 2024-04-01 14:38:31 +02:00
ae90f693f6 no more style.css 2024-04-01 14:27:42 +02:00
a730e11b4a Styled with tailwind css 2024-04-01 14:22:59 +02:00
959e1e96b3 Fix typo 2024-03-31 05:00:57 +02:00
68b052625f Fixed bug with specifying port 2024-03-30 10:22:51 +01:00
a0fe0024f2 Allow uploading pictures when editing once rejected articles 2024-03-30 09:56:22 +01:00
6e3c4bf647 Added ability to specify port and RSS file as command line arguments 2024-03-30 09:55:37 +01:00
26988ecf6a Corrected error messages for CliArgs 2024-03-29 09:48:03 +01:00
9408ce99e3 Added DBName into CliArgs 2024-03-29 09:16:41 +01:00
af036b4909 Added ability to upload media and parse cli arguments 2024-03-29 09:07:17 +01:00
e60e6114bd Generate RSS to file 2024-03-28 12:51:33 +01:00
600044c621 Cleaned up templates 2024-03-28 08:41:38 +01:00
77a90cb4f1 Fixed bug not showing correct issue in RSS feed 2024-03-28 07:41:11 +01:00
34e9e9edd5 Fixed bug in publishing issue 2024-03-28 07:34:36 +01:00
4d1faf3d4a Add ability to update tags when resubmitting article 2024-03-28 07:29:49 +01:00
78addbd8e3 Incorporated issues 2024-03-28 07:00:37 +01:00
304d3aa2e0 Corrected copyright 2024-03-28 06:59:39 +01:00
f44291e278 Disabled option to do transaction from view 2024-03-28 06:58:59 +01:00
3be16781e7 Added copyright 2024-03-17 15:29:12 +01:00
4fffc1c696 Set pubDate to published time and date 2024-03-17 09:41:09 +01:00
ceab7281e9 Now everyone only sees their own rejected articles 2024-03-17 09:15:37 +01:00
450dd79e51 Added ability to view tags when rejecting and change tags when reworking articles 2024-03-17 08:46:49 +01:00
c45df4bf1a Implemented retry logic on all transactions 2024-03-15 18:37:24 +01:00
6d3a28a6ce Implement retry logic for UpdateAttributes 2024-03-15 15:18:02 +01:00
3d3fb3c826 Added logout 2024-03-12 20:27:39 +01:00
f52674b179 Fixed dumb routing mistake 2024-03-12 19:56:22 +01:00
697939a17a Added ability to edit user info 2024-03-11 21:08:27 +01:00
f10220f936 Added ability to reject and rework article 2024-03-10 15:03:46 +01:00
a1a6b6c29f Split up db.go into multiple files 2024-03-09 11:06:03 +01:00
41 changed files with 2275 additions and 752 deletions

44
.air.toml Normal file
View File

@ -0,0 +1,44 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main -key tmp/key.gob -log tmp/cpolis.log -pics tmp/pics -rss tmp/orientexpress_alle.rss -web web"
cmd = "go build -o ./tmp/main ./cmd/main.go"
delay = 0
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "css"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true

1
.gitignore vendored
View File

@ -23,3 +23,4 @@ go.work
# Custom stuff # Custom stuff
tmp/ tmp/
style.css

View File

@ -209,7 +209,7 @@ If you develop a new program, and you want it to be of the greatest possible use
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
cpolis cpolis
Copyright (C) 2024 jason Copyright (C) 2024 Jason Streifling
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
@ -221,7 +221,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
cpolis Copyright (C) 2024 jason cpolis Copyright (C) 2024 Jason Streifling
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.

60
cmd/control/cli.go Normal file
View File

@ -0,0 +1,60 @@
package control
import (
"flag"
"fmt"
"path/filepath"
)
type CliArgs struct {
DBName string
KeyFile string
LogFile string
Port string
PicsDir string
RSSFile string
WebDir string
}
func HandleCliArgs() (*CliArgs, error) {
var err error
cliArgs := new(CliArgs)
flag.StringVar(&cliArgs.DBName, "db", "cpolis", "DB name")
keyFile := flag.String("key", "/var/www/cpolis/cpolis.key", "key file")
logFile := flag.String("log", "/var/log/cpolis.log", "log file")
flag.StringVar(&cliArgs.PicsDir, "pics", "pics", "pictures directory")
port := flag.Int("port", 8080, "port")
rssFile := flag.String("rss", "/var/www/cpolis/cpolis.rss", "RSS file")
webDir := flag.String("web", "/var/www/cpolis/web", "web directory")
flag.Parse()
cliArgs.KeyFile, err = filepath.Abs(*keyFile)
if err != nil {
return nil, fmt.Errorf("error finding absolute path for KeyFile: %v", err)
}
cliArgs.LogFile, err = filepath.Abs(*logFile)
if err != nil {
return nil, fmt.Errorf("error finding absolute path for LogFile: %v", err)
}
_, err = filepath.Abs(cliArgs.PicsDir)
if err != nil {
return nil, fmt.Errorf("error finding absolute path for PicsDir: %v", err)
}
cliArgs.Port = fmt.Sprint(":", *port)
cliArgs.RSSFile, err = filepath.Abs(*rssFile)
if err != nil {
return nil, fmt.Errorf("error finding absolute path for RSSFile: %v", err)
}
cliArgs.WebDir, err = filepath.Abs(*webDir)
if err != nil {
return nil, fmt.Errorf("error finding absolute path for WebDir: %v", err)
}
return cliArgs, nil
}

View File

@ -2,21 +2,23 @@ package control
import ( import (
"fmt" "fmt"
"io"
"os"
"time" "time"
"git.streifling.com/jason/rss" "git.streifling.com/jason/rss"
"streifling.com/jason/cpolis/cmd/model" "streifling.com/jason/cpolis/cmd/model"
) )
func GetChannel(db *model.DB, title, link, desc string) (*rss.Channel, error) { func GetChannel(db *model.DB, title, link, description string) (*rss.Channel, error) {
channel := &rss.Channel{ channel := &rss.Channel{
Title: title, Title: title,
Link: link, Link: link,
Description: desc, Description: description,
Items: make([]*rss.Item, 0), Items: make([]*rss.Item, 0),
} }
articles, err := db.GetCertainArticles(true) articles, err := db.GetCertainArticles(true, false)
if err != nil { if err != nil {
return nil, fmt.Errorf("error fetching published articles: %v", err) return nil, fmt.Errorf("error fetching published articles: %v", err)
} }
@ -40,7 +42,7 @@ func GetChannel(db *model.DB, title, link, desc string) (*rss.Channel, error) {
Title: article.Title, Title: article.Title,
Author: user.FirstName + user.LastName, Author: user.FirstName + user.LastName,
PubDate: article.Created.Format(time.RFC1123Z), PubDate: article.Created.Format(time.RFC1123Z),
Description: article.Desc, Description: article.Description,
Content: &rss.Content{Value: article.Content}, Content: &rss.Content{Value: article.Content},
Categories: tagNames, Categories: tagNames,
}) })
@ -48,3 +50,82 @@ func GetChannel(db *model.DB, title, link, desc string) (*rss.Channel, error) {
return channel, nil return channel, nil
} }
func GenerateRSS(db *model.DB, title, link, desc string) (*string, error) {
channel := &rss.Channel{
Title: title,
Link: link,
Description: desc,
Items: make([]*rss.Item, 0),
}
articles, err := db.GetCertainArticles(true, false)
if err != nil {
return nil, fmt.Errorf("error getting published articles for RSS feed: %v", err)
}
for _, article := range articles {
tags, err := db.GetArticleTags(article.ID)
if err != nil {
return nil, fmt.Errorf("error getting tags for articles for RSS feed: %v", err)
}
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 {
return nil, fmt.Errorf("error getting user user info for RSS feed: %v", err)
}
articleTitle, err := ConvertToPlain(article.Title)
if err != nil {
return nil, fmt.Errorf("error converting title to plain text for RSS feed: %v", err)
}
articleDescription, err := ConvertToPlain(article.Description)
if err != nil {
return nil, fmt.Errorf("error converting description to plain text for RSS feed: %v", err)
}
articleContent, err := ConvertToHTML(article.Content)
if err != nil {
return nil, fmt.Errorf("error converting content to HTML for RSS feed: %v", err)
}
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("UTF-8")
if err != nil {
return nil, fmt.Errorf("error converting RSS feed to XML: %v", err)
}
return &rss, nil
}
func SaveRSS(filename string, feed *string) error {
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("error creating file for RSS feed: %v", err)
}
defer file.Close()
file.Chmod(0644)
if _, err = io.WriteString(file, *feed); err != nil {
return fmt.Errorf("error writing to RSS file: %v", err)
}
return nil
}

View File

@ -16,53 +16,66 @@ func init() {
} }
func main() { func main() {
logFile, err := os.OpenFile("tmp/cpolis.log", args, err := control.HandleCliArgs()
os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) if err != nil {
log.Fatalln(err)
}
logFile, err := os.OpenFile(args.LogFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
defer logFile.Close() defer logFile.Close()
// log.SetOutput(logFile) log.SetOutput(logFile)
db, err := model.OpenDB("cpolis") db, err := model.OpenDB(args.DBName)
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
defer db.Close() defer db.Close()
key, err := control.LoadKey("tmp/key.gob") key, err := control.LoadKey(args.KeyFile)
if err != nil { if err != nil {
key, err = control.NewKey() key, err = control.NewKey()
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
control.SaveKey(key, "tmp/key.gob") control.SaveKey(key, args.KeyFile)
} }
store := control.NewCookieStore(key) store := control.NewCookieStore(key)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/web/static/", http.StripPrefix("/web/static/", mux.Handle("/web/static/", http.StripPrefix("/web/static/",
http.FileServer(http.Dir("web/static/")))) http.FileServer(http.Dir(args.WebDir+"/static/"))))
mux.HandleFunc("/", view.HomePage(db, store)) mux.HandleFunc("/", view.HomePage(args, db, store))
mux.HandleFunc("GET /create-tag/", view.CreateTag) mux.HandleFunc("GET /create-tag", view.CreateTag(args))
mux.HandleFunc("GET /create-user/", view.CreateUser) mux.HandleFunc("GET /create-user", view.CreateUser(args))
mux.HandleFunc("GET /hub/", view.ShowHub(store)) mux.HandleFunc("GET /edit-user", view.EditUser(args, db, store))
mux.HandleFunc("GET /rss/", view.ShowRSS( mux.HandleFunc("GET /hub", view.ShowHub(args, db, store))
db, mux.HandleFunc("GET /logout", view.Logout(args, store))
"Freimaurer Distrikt Niedersachsen und Sachsen-Anhalt", mux.HandleFunc("GET /publish-article/{id}", view.PublishArticle(args, db, store))
"https://distrikt-ni-st.de", mux.HandleFunc("GET /publish-issue", view.PublishLatestIssue(args, db, store))
"Freiheit, Gleichheit, Brüderlichkeit, Toleranz und Humanität", mux.HandleFunc("GET /reject-article/{id}", view.RejectArticle(args, db, store))
)) mux.HandleFunc("GET /rejected-articles", view.ShowRejectedArticles(args, db, store))
mux.HandleFunc("GET /unpublished-articles/", view.ShowUnpublishedArticles(db)) mux.HandleFunc("GET /review-rejected-article/{id}", view.ReviewRejectedArticle(args, db, store))
mux.HandleFunc("GET /write-article/", view.WriteArticle(db)) mux.HandleFunc("GET /review-unpublished-article/{id}", view.ReviewUnpublishedArticle(args, db, store))
mux.HandleFunc("GET /rss", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, args.RSSFile)
})
mux.HandleFunc("GET /pics/{pic}", view.ServeImage(args, store))
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(db, store)) mux.HandleFunc("POST /add-first-user", view.AddFirstUser(args, db, store))
mux.HandleFunc("POST /add-user/", view.AddUser(db, store)) mux.HandleFunc("POST /add-tag", view.AddTag(args, db, store))
mux.HandleFunc("POST /finish-article/", view.FinishArticle(db, store)) mux.HandleFunc("POST /add-user", view.AddUser(args, db, store))
mux.HandleFunc("POST /login/", view.Login(db, store)) mux.HandleFunc("POST /login", view.Login(args, db, store))
mux.HandleFunc("POST /review-article/", view.ReviewArticle(db, store)) mux.HandleFunc("POST /resubmit-article/{id}", view.ResubmitArticle(args, db, store))
mux.HandleFunc("POST /publish-article/", view.PublishArticle(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(":8080", mux)) log.Fatalln(http.ListenAndServe(args.Port, mux))
} }

258
cmd/model/articles.go Normal file
View File

@ -0,0 +1,258 @@
package model
import (
"context"
"database/sql"
"fmt"
"log"
"time"
)
type Article struct {
Title string
Created time.Time
Description string
Content string
Published bool
Rejected bool
ID int64
AuthorID int64
IssueID int64
}
func (db *DB) AddArticle(a *Article) (int64, error) {
var id int64
txOptions := &sql.TxOptions{Isolation: sql.LevelSerializable}
selectQuery := "SELECT id FROM issues WHERE published = false"
insertQuery := `
INSERT INTO articles
(title, description, content, published, rejected, author_id, issue_id)
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(&id); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return 0, fmt.Errorf("error getting issue ID when adding article to DB: %v", err)
}
result, err := tx.Exec(insertQuery, a.Title, a.Description,
a.Content, a.Published, a.Rejected, a.AuthorID, id)
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 article into DB: %v", 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 retrieving ID of added article: %v", err)
}
if err = tx.Commit(); err != nil {
return 0, fmt.Errorf("error committing transaction when adding article to DB: %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)
}
func (db *DB) GetArticle(id int64) (*Article, error) {
query := `
SELECT title, created, description, content, published, author_id
FROM articles
WHERE id = ?
`
row := db.QueryRow(query, id)
article := new(Article)
var created []byte
var err error
if err := row.Scan(&article.Title, &created, &article.Description,
&article.Content, &article.Published, &article.AuthorID); err != nil {
return nil, fmt.Errorf("error scanning article row: %v", err)
}
article.ID = id
article.Created, err = time.Parse("2006-01-02 15:04:05", string(created))
if err != nil {
return nil, fmt.Errorf("error parsing created: %v", err)
}
return article, nil
}
func (db *DB) GetCertainArticles(published, rejected bool) ([]*Article, error) {
query := `
SELECT id, title, created, description, content, author_id, issue_id
FROM articles
WHERE published = ?
AND rejected = ?
`
rows, err := db.Query(query, published, rejected)
if err != nil {
return nil, fmt.Errorf("error querying articles: %v", err)
}
articleList := make([]*Article, 0)
for rows.Next() {
article := new(Article)
var created []byte
if err = rows.Scan(&article.ID, &article.Title, &created,
&article.Description, &article.Content, &article.AuthorID,
&article.IssueID); err != nil {
return nil, fmt.Errorf("error scanning article row: %v", err)
}
article.Published = false
article.Created, err = time.Parse("2006-01-02 15:04:05", string(created))
if err != nil {
return nil, fmt.Errorf("error parsing created: %v", err)
}
articleList = append(articleList, article)
}
return articleList, nil
}
func (db *DB) GetCurrentIssueArticles() ([]*Article, error) {
var issueID int64
txOptions := &sql.TxOptions{Isolation: sql.LevelSerializable}
issueQuery := "SELECT id FROM issues WHERE published = false"
articlesQuery := `
SELECT id, title, created, description, content, author_id
FROM articles
WHERE issue_id = ? AND published = true
`
for i := 0; i < TxMaxRetries; i++ {
id, err := func() ([]*Article, error) {
tx, err := db.BeginTx(context.Background(), txOptions)
if err != nil {
return nil, fmt.Errorf("error starting transaction: %v", err)
}
row := tx.QueryRow(issueQuery)
if err := row.Scan(&issueID); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return nil, fmt.Errorf("error querying DB for unpublished issue: %v", err)
}
rows, err := tx.Query(articlesQuery, issueID)
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return nil, fmt.Errorf("error querying DB for articles of issue %v: %v", issueID, err)
}
articleList := make([]*Article, 0)
for rows.Next() {
article := new(Article)
var created []byte
if err = rows.Scan(&article.ID, &article.Title, &created,
&article.Description, &article.Content, &article.AuthorID); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return nil, fmt.Errorf("error scanning article from issue %v: %v", issueID, err)
}
article.Created, err = time.Parse("2006-01-02 15:04:05", string(created))
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return nil, fmt.Errorf("error parsing created: %v", err)
}
articleList = append(articleList, article)
}
if err = tx.Commit(); err != nil {
return nil, fmt.Errorf("error committing transaction when getting articles of issue %v: %v", issueID, err)
}
return articleList, nil
}()
if err == nil {
return id, nil
}
log.Println(err)
wait(i)
}
return nil, fmt.Errorf("error: %v unsuccessful retries for DB operation, aborting", TxMaxRetries)
}
func (db *DB) AddArticleToCurrentIssue(id int64) error {
var issueID int64
txOptions := &sql.TxOptions{Isolation: sql.LevelSerializable}
selectQuery := "SELECT id FROM issues WHERE published = false"
updateQuery := "UPDATE articles SET issue_id = ? WHERE id = ?"
for i := 0; i < TxMaxRetries; i++ {
err := func() error {
tx, err := db.BeginTx(context.Background(), txOptions)
if err != nil {
return fmt.Errorf("error starting transaction: %v", err)
}
if err = tx.QueryRow(selectQuery).Scan(&issueID); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("error scanning row: %v", err)
}
_, err = db.Exec(updateQuery, issueID, id)
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("error updating issueID for article: %v", err)
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("error committing transaction when getting articles of issue %v: %v", issueID, err)
}
return nil
}()
if err == nil {
return nil
}
log.Println(err)
wait(i)
}
return fmt.Errorf("error: %v unsuccessful retries for DB operation, aborting", TxMaxRetries)
}

107
cmd/model/articles_tags.go Normal file
View File

@ -0,0 +1,107 @@
package model
import (
"fmt"
"log"
)
func (db *DB) WriteArticleTags(articleID int64, tagIDs []int64) error {
query := "INSERT INTO articles_tags (article_id, tag_id) VALUES (?, ?)"
for i := 0; i < TxMaxRetries; i++ {
err := func() error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("error starting transaction: %v", err)
}
for _, tagID := range tagIDs {
if _, err := tx.Exec(query, articleID, tagID); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("error inserting into articles_tags: %v", err)
}
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("error committing transaction: %v", err)
}
return nil
}()
if err == nil {
return nil
}
log.Println(err)
wait(i)
}
return fmt.Errorf("error: %v unsuccessful retries for DB operation, aborting", TxMaxRetries)
}
func (db *DB) GetArticleTags(articleID int64) ([]*Tag, error) {
query := `
SELECT t.id, t.name
FROM articles a
INNER JOIN articles_tags at ON a.id = at.article_id
INNER JOIN tags t ON at.tag_id = t.id
WHERE a.id = ?
`
rows, err := db.Query(query, articleID)
if err != nil {
return nil, fmt.Errorf("error querying articles_tags: %v", err)
}
tags := make([]*Tag, 0)
for rows.Next() {
tag := new(Tag)
if err = rows.Scan(&tag.ID, &tag.Name); err != nil {
return nil, fmt.Errorf("error scanning rows: %v", err)
}
tags = append(tags, tag)
}
return tags, nil
}
func (db *DB) UpdateArticleTags(articleID int64, tagIDs []int64) error {
deleteQuery := "DELETE FROM articles_tags WHERE article_id = ?"
insertQuery := "INSERT INTO articles_tags (article_id, tag_id) VALUES (?, ?)"
for i := 0; i < TxMaxRetries; i++ {
err := func() error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("error starting transaction: %v", err)
}
if _, err := tx.Exec(deleteQuery, articleID); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("error deleting entries from articles_tags before inserting new ones: %v", err)
}
for _, tagID := range tagIDs {
if _, err := tx.Exec(insertQuery, articleID, tagID); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("error inserting new entries into articles_tags: %v", err)
}
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("error committing transaction: %v", err)
}
return nil
}()
if err == nil {
return nil
}
log.Println(err)
wait(i)
}
return fmt.Errorf("error: %v unsuccessful retries for DB operation, aborting", TxMaxRetries)
}

View File

@ -1,22 +1,85 @@
package model package model
import ( import (
"bufio"
"database/sql" "database/sql"
"fmt" "fmt"
"log" "log"
"math"
"math/rand/v2"
"os"
"strings"
"syscall"
"time" "time"
"github.com/go-sql-driver/mysql" "github.com/go-sql-driver/mysql"
"golang.org/x/crypto/bcrypt" "golang.org/x/term"
) )
type DB struct { var TxMaxRetries = 5
*sql.DB
type (
DB struct{ *sql.DB }
Tx struct{ *sql.Tx }
Attribute struct {
Value any
Table string
AttName string
ID int64
}
)
func getUsername() (string, error) {
user := os.Getenv("DB_USER")
if user == "" {
var err error
fmt.Printf("DB Benutzer: ")
user, err = bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return "", fmt.Errorf("error reading username: %v", err)
}
}
return strings.TrimSpace(user), nil
}
func getPassword() (string, error) {
pass := os.Getenv("DB_PASS")
if pass == "" {
fmt.Printf("DB Passwort: ")
bytePass, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return "", fmt.Errorf("error reading password: %v", err)
}
fmt.Println()
pass = strings.TrimSpace(string(bytePass))
}
return pass, nil
}
func getCredentials() (string, string, error) {
user, err := getUsername()
if err != nil {
return "", "", fmt.Errorf("error getting username: %v", err)
}
pass, err := getPassword()
if err != nil {
return "", "", fmt.Errorf("error getting password: %v", err)
}
return user, pass, nil
}
func wait(iteration int) {
waitTime := time.Duration(math.Pow(2, float64(iteration))) * 100 * time.Millisecond
jitter := time.Duration(rand.IntN(int(waitTime)/2)) * time.Millisecond
time.Sleep(waitTime + jitter)
} }
func OpenDB(dbName string) (*DB, error) { func OpenDB(dbName string) (*DB, error) {
var err error var err error
db := DB{DB: &sql.DB{}} db := DB{DB: new(sql.DB)}
cfg := mysql.NewConfig() cfg := mysql.NewConfig()
cfg.DBName = dbName cfg.DBName = dbName
@ -36,130 +99,47 @@ func OpenDB(dbName string) (*DB, error) {
return &db, nil return &db, nil
} }
func (db *DB) UpdateAttribute(table string, id int64, attribute string, val interface{}) error { func (db *DB) UpdateAttributes(a ...*Attribute) error {
query := fmt.Sprintf(` for i := 0; i < TxMaxRetries; i++ {
UPDATE %s err := func() error {
SET %s = ? tx, err := db.Begin()
WHERE id = ? if err != nil {
`, table, attribute) return fmt.Errorf("error starting transaction: %v", err)
if _, err := db.Exec(query, val, id); err != nil { }
return fmt.Errorf("error updating article in DB: %v", err)
}
return nil
}
func (db *DB) AddUser(user *User, pass string) error { for _, attribute := range a {
hashedPass, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) query := fmt.Sprintf(`
if err != nil { UPDATE %s
return fmt.Errorf("error creating password hash: %v", err) SET %s = ?
} WHERE id = ?
`, attribute.Table, attribute.AttName)
if _, err := tx.Exec(query, attribute.Value, attribute.ID); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("error updating %v in DB: %v", attribute.AttName, err)
}
}
query := ` if err = tx.Commit(); err != nil {
INSERT INTO users return fmt.Errorf("error committing transaction: %v", err)
(username, password, first_name, last_name, role) }
VALUES (?, ?, ?, ?, ?) return nil
` }()
if _, err = db.Exec(query, user.UserName, string(hashedPass), user.FirstName, user.LastName, user.Role); err != nil { if err == nil {
return fmt.Errorf("error inserting user into DB: %v", err) return nil
}
return nil
}
func (db *DB) GetID(userName string) (int64, error) {
var id int64
query := `
SELECT id
FROM users
WHERE username = ?
`
row := db.QueryRow(query, userName)
if err := row.Scan(&id); err != nil {
return 0, fmt.Errorf("user not in DB: %v", err)
}
return id, nil
}
func (db *DB) CheckPassword(id int64, pass string) error {
var queriedPass string
query := `
SELECT password
FROM users
WHERE id = ?
`
row := db.QueryRow(query, id)
if err := row.Scan(&queriedPass); err != nil {
return fmt.Errorf("error reading password from DB: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(queriedPass), []byte(pass)); err != nil {
return fmt.Errorf("incorrect password: %v", err)
}
return nil
}
func (db *DB) ChangePassword(id int64, oldPass, newPass string) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("error starting transaction: %v", err)
}
var queriedPass string
getQuery := `
SELECT password
FROM users
WHERE id = ?
`
row := tx.QueryRow(getQuery, id)
if err := row.Scan(&queriedPass); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("error: transaction error: %v, rollback error: %v", err, rollbackErr)
} }
return fmt.Errorf("error reading password from DB: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(queriedPass), []byte(oldPass)); err != nil { log.Println(err)
if rollbackErr := tx.Rollback(); rollbackErr != nil { wait(i)
log.Fatalf("error: transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("incorrect password: %v", err)
} }
return fmt.Errorf("error: %v unsuccessful retries for DB operation, aborting", TxMaxRetries)
newHashedPass, err := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("error: transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("error creating password hash: %v", err)
}
setQuery := `
UPDATE users
SET password = ?
WHERE id = ?
`
if _, err = tx.Exec(setQuery, string(newHashedPass), id); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("error: transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("error updating password in DB: %v", err)
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("error committing transaction: %v", err)
}
return nil
} }
func (db *DB) CountEntries() (int64, error) { func (db *DB) CountEntries(table string) (int64, error) {
var count int64 var count int64
query := `SELECT COUNT(*) FROM users` query := fmt.Sprintf("SELECT COUNT(*) FROM %s", table)
row := db.QueryRow(query) row := db.QueryRow(query)
if err := row.Scan(&count); err != nil { if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("error counting rows in user DB: %v", err) return 0, fmt.Errorf("error counting rows in user DB: %v", err)
@ -168,175 +148,20 @@ func (db *DB) CountEntries() (int64, error) {
return count, nil return count, nil
} }
// TODO: No need for ID field in general func (tx *Tx) UpdateAttributes(a ...*Attribute) error {
func (db *DB) GetUser(id int64) (*User, error) { for _, attribute := range a {
user := new(User) query := fmt.Sprintf(`
query := ` UPDATE %s
SELECT id, username, first_name, last_name, role SET %s = ?
FROM users WHERE id = ?
WHERE id = ? `, attribute.Table, attribute.AttName)
` if _, err := tx.Exec(query, attribute.Value, attribute.ID); err != nil {
row := db.QueryRow(query, id)
if err := row.Scan(&user.ID, &user.UserName, &user.FirstName, &user.LastName, &user.Role); err != nil {
return nil, fmt.Errorf("error reading user information: %v", err)
}
return user, nil
}
func (db *DB) AddTag(tagName string) error {
query := "INSERT INTO tags (name) VALUES (?)"
if _, err := db.Exec(query, tagName); err != nil {
return fmt.Errorf("error inserting tag into DB: %v", err)
}
return nil
}
func (db *DB) GetTagList() ([]*Tag, error) {
query := "SELECT id, name FROM tags"
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("error querying tags: %v", err)
}
tagList := make([]*Tag, 0)
for rows.Next() {
tag := new(Tag)
if err = rows.Scan(&tag.ID, &tag.Name); err != nil {
return nil, fmt.Errorf("error scanning tag row: %v", err)
}
tagList = append(tagList, tag)
}
return tagList, nil
}
func (db *DB) AddArticle(a *Article) (int64, error) {
query := `
INSERT INTO articles
(title, description, content, published, author_id)
VALUES
(?, ?, ?, ?, ?)
`
result, err := db.Exec(query, a.Title, a.Desc, a.Content, a.Published, a.AuthorID)
if err != nil {
return 0, fmt.Errorf("error inserting article into DB: %v", err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("error retrieving last ID: %v", err)
}
return id, nil
}
func (db *DB) GetArticle(id int64) (*Article, error) {
query := `
SELECT title, created, description, content, published, author_id
FROM articles
WHERE id = ?
`
row := db.QueryRow(query, id)
article := new(Article)
var created []byte
var err error
if err := row.Scan(&article.Title, &created, &article.Desc,
&article.Content, &article.Published, &article.AuthorID); err != nil {
return nil, fmt.Errorf("error scanning article row: %v", err)
}
article.ID = id
article.Created, err = time.Parse("2006-01-02 15:04:05", string(created))
if err != nil {
return nil, fmt.Errorf("error parsing created: %v", err)
}
return article, nil
}
func (db *DB) GetCertainArticles(published bool) ([]*Article, error) {
query := `
SELECT id, title, created, description, content, author_id
FROM articles
WHERE published = ?
`
rows, err := db.Query(query, published)
if err != nil {
return nil, fmt.Errorf("error querying articles: %v", err)
}
articleList := make([]*Article, 0)
for rows.Next() {
article := new(Article)
var created []byte
if err = rows.Scan(&article.ID, &article.Title, &created, &article.Desc,
&article.Content, &article.AuthorID); err != nil {
return nil, fmt.Errorf("error scanning article row: %v", err)
}
article.Published = false
article.Created, err = time.Parse("2006-01-02 15:04:05", string(created))
if err != nil {
return nil, fmt.Errorf("error parsing created: %v", err)
}
articleList = append(articleList, article)
}
return articleList, nil
}
func (db *DB) WriteArticleTags(articleID int64, tagIDs []int64) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("error starting transaction: %v", err)
}
for _, tagID := range tagIDs {
query := `
INSERT INTO articles_tags (article_id, tag_id)
VALUES (?, ?)
`
if _, err := tx.Exec(query, articleID, tagID); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("error: transaction error: %v, rollback error: %v", err, rollbackErr) log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
} }
return fmt.Errorf("error inserting into articles_tags: %v", err) return fmt.Errorf("error updating %v in DB: %v", attribute.AttName, err)
} }
} }
if err = tx.Commit(); err != nil {
return fmt.Errorf("error committing transaction: %v", err)
}
return nil return nil
} }
func (db *DB) GetArticleTags(articleID int64) ([]*Tag, error) {
query := `
SELECT t.id, t.name
FROM articles a
INNER JOIN articles_tags at ON a.id = at.article_id
INNER JOIN tags t ON at.tag_id = t.id
WHERE a.id = ?
`
rows, err := db.Query(query, articleID)
if err != nil {
return nil, fmt.Errorf("error querying articles_tags: %v", err)
}
tags := make([]*Tag, 0)
for rows.Next() {
tag := new(Tag)
if err = rows.Scan(&tag.ID, &tag.Name); err != nil {
return nil, fmt.Errorf("error scanning rows: %v", err)
}
tags = append(tags, tag)
}
return tags, nil
}

View File

@ -1,52 +0,0 @@
package model
import (
"bufio"
"fmt"
"os"
"strings"
"syscall"
"golang.org/x/term"
)
func getUsername() (string, error) {
user := os.Getenv("DB_USER")
if user == "" {
var err error
fmt.Printf("DB Benutzer: ")
user, err = bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return "", fmt.Errorf("error reading username: %v", err)
}
}
return strings.TrimSpace(user), nil
}
func getPassword() (string, error) {
pass := os.Getenv("DB_PASS")
if pass == "" {
fmt.Printf("DB Passwort: ")
bytePass, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return "", fmt.Errorf("error reading password: %v", err)
}
fmt.Println()
pass = strings.TrimSpace(string(bytePass))
}
return pass, nil
}
func getCredentials() (string, string, error) {
user, err := getUsername()
if err != nil {
return "", "", fmt.Errorf("error getting username: %v", err)
}
pass, err := getPassword()
if err != nil {
return "", "", fmt.Errorf("error getting password: %v", err)
}
return user, pass, nil
}

65
cmd/model/issues.go Normal file
View File

@ -0,0 +1,65 @@
package model
import (
"context"
"database/sql"
"fmt"
"log"
)
func (db *DB) AddIssue() (int64, error) {
query := "INSERT INTO issues (published) VALUES (?)"
result, err := db.Exec(query, false)
if err != nil {
return 0, fmt.Errorf("error inserting issue into DB: %v", err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("error getting ID of added issue: %v", err)
}
return id, nil
}
func (db *DB) PublishLatestIssue() error {
txOptions := &sql.TxOptions{Isolation: sql.LevelSerializable}
updateQuery := "UPDATE issues SET published = true WHERE published = false"
insertQuery := "INSERT INTO issues (published) VALUES (?)"
for i := 0; i < TxMaxRetries; i++ {
err := func() error {
tx, err := db.BeginTx(context.Background(), txOptions)
if err != nil {
return fmt.Errorf("error starting transaction: %v", err)
}
if _, err := tx.Exec(updateQuery); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("error publishing issue: %v", err)
}
if _, err := tx.Exec(insertQuery, false); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("error inserting new issue into DB: %v", err)
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("error committing transaction when publishing issue: %v", err)
}
return nil
}()
if err == nil {
return nil
}
log.Println(err)
wait(i)
}
return fmt.Errorf("error: %v unsuccessful retries for DB operation, aborting", TxMaxRetries)
}

View File

@ -1,35 +0,0 @@
package model
import (
"time"
)
const (
Admin = iota
Editor
Writer
)
type User struct {
UserName string
FirstName string
LastName string
RejectedArticles []*Article
ID int64
Role int
}
type Tag struct {
Name string
ID int64
}
type Article struct {
Title string
Created time.Time
Desc string
Content string
Published bool
ID int64
AuthorID int64
}

35
cmd/model/tags.go Normal file
View File

@ -0,0 +1,35 @@
package model
import "fmt"
type Tag struct {
Name string
ID int64
}
func (db *DB) AddTag(tagName string) error {
query := "INSERT INTO tags (name) VALUES (?)"
if _, err := db.Exec(query, tagName); err != nil {
return fmt.Errorf("error inserting tag into DB: %v", err)
}
return nil
}
func (db *DB) GetTagList() ([]*Tag, error) {
query := "SELECT id, name FROM tags"
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("error querying tags: %v", err)
}
tagList := make([]*Tag, 0)
for rows.Next() {
tag := new(Tag)
if err = rows.Scan(&tag.ID, &tag.Name); err != nil {
return nil, fmt.Errorf("error scanning tag row: %v", err)
}
tagList = append(tagList, tag)
}
return tagList, nil
}

270
cmd/model/users.go Normal file
View File

@ -0,0 +1,270 @@
package model
import (
"context"
"database/sql"
"fmt"
"log"
"golang.org/x/crypto/bcrypt"
)
const (
Admin = iota
Publisher
Editor
Author
)
type User struct {
UserName string
FirstName string
LastName string
ID int64
Role int
}
func (db *DB) AddUser(u *User, pass string) (int64, error) {
hashedPass, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
if err != nil {
return 0, fmt.Errorf("error creating password hash: %v", err)
}
query := `
INSERT INTO users (username, password, first_name, last_name, role)
VALUES (?, ?, ?, ?, ?)
`
result, err := db.Exec(query, u.UserName, string(hashedPass), u.FirstName, u.LastName, u.Role)
if err != nil {
return 0, fmt.Errorf("error inserting new user %v into DB: %v", u.UserName, err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("error inserting user into DB: %v", err)
}
return id, nil
}
func (db *DB) GetID(userName string) (int64, bool) {
var id int64
query := `
SELECT id
FROM users
WHERE username = ?
`
row := db.QueryRow(query, userName)
if err := row.Scan(&id); err != nil {
return 0, false
}
return id, true
}
func (db *DB) CheckPassword(id int64, pass string) error {
var queriedPass string
query := `
SELECT password
FROM users
WHERE id = ?
`
row := db.QueryRow(query, id)
if err := row.Scan(&queriedPass); err != nil {
return fmt.Errorf("error reading password from DB: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(queriedPass), []byte(pass)); err != nil {
return fmt.Errorf("incorrect password: %v", err)
}
return nil
}
func (tx *Tx) ChangePassword(id int64, oldPass, newPass string) error {
var queriedPass string
getQuery := `
SELECT password
FROM users
WHERE id = ?
`
row := tx.QueryRow(getQuery, id)
if err := row.Scan(&queriedPass); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("error reading password from DB: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(queriedPass), []byte(oldPass)); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("incorrect password: %v", err)
}
newHashedPass, err := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("error creating password hash: %v", err)
}
setQuery := `
UPDATE users
SET password = ?
WHERE id = ?
`
if _, err = tx.Exec(setQuery, string(newHashedPass), id); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("error updating password in DB: %v", err)
}
return nil
}
// TODO: No need for ID field in general
func (db *DB) GetUser(id int64) (*User, error) {
user := new(User)
query := `
SELECT id, username, first_name, last_name, role
FROM users
WHERE id = ?
`
row := db.QueryRow(query, id)
if err := row.Scan(&user.ID, &user.UserName, &user.FirstName,
&user.LastName, &user.Role); err != nil {
return nil, fmt.Errorf("error reading user information: %v", err)
}
return user, nil
}
func (db *DB) UpdateUserAttributes(id int64, user, first, last, oldPass, newPass, newPass2 string) error {
passwordEmpty := true
if len(newPass) > 0 || len(newPass2) > 0 {
if newPass != newPass2 {
return fmt.Errorf("error: passwords do not match")
}
passwordEmpty = false
}
tx := new(Tx)
var err error
for i := 0; i < TxMaxRetries; i++ {
err := func() error {
tx.Tx, err = db.Begin()
if err != nil {
return fmt.Errorf("error starting transaction: %v", err)
}
if !passwordEmpty {
if err = tx.ChangePassword(id, oldPass, newPass); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("error changing password: %v", err)
}
}
if err = tx.UpdateAttributes(
&Attribute{Table: "users", ID: id, AttName: "username", Value: user},
&Attribute{Table: "users", ID: id, AttName: "first_name", Value: first},
&Attribute{Table: "users", ID: id, AttName: "last_name", Value: last},
); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr)
}
return fmt.Errorf("error updating attributes in DB: %v", err)
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("error committing transaction: %v", err)
}
return nil
}()
if err == nil {
return nil
}
log.Println(err)
wait(i)
}
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

@ -1,128 +0,0 @@
package view
import (
"fmt"
"html/template"
"log"
"net/http"
"strconv"
"streifling.com/jason/cpolis/cmd/control"
"streifling.com/jason/cpolis/cmd/model"
)
type AddUserData struct {
*model.User
Msg string
}
func inputsEmpty(user *model.User, pass, pass2 string) bool {
return len(user.UserName) == 0 ||
len(user.FirstName) == 0 ||
len(user.LastName) == 0 ||
len(pass) == 0 ||
len(pass2) == 0
}
func checkUserStrings(user *model.User) (string, int, bool) {
userLen := 15
nameLen := 50
if len(user.UserName) > userLen {
return "Benutzername", userLen, false
} else if len(user.FirstName) > nameLen {
return "Vorname", nameLen, false
} else if len(user.LastName) > nameLen {
return "Nachname", nameLen, false
} else {
return "", 0, true
}
}
func CreateUser(w http.ResponseWriter, r *http.Request) {
tmpl, err := template.ParseFiles("web/templates/add-user.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", nil)
}
func AddUser(db *model.DB, s *control.CookieStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
role, err := strconv.Atoi(r.PostFormValue("role"))
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
htmlData := AddUserData{
User: &model.User{
UserName: r.PostFormValue("username"),
FirstName: r.PostFormValue("first-name"),
LastName: r.PostFormValue("last-name"),
Role: role,
},
}
pass := r.PostFormValue("password")
pass2 := r.PostFormValue("password2")
if inputsEmpty(htmlData.User, pass, pass2) {
htmlData.Msg = "Alle Felder müssen ausgefüllt werden."
tmpl, err := template.ParseFiles("web/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("web/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("web/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("web/templates/add-user.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", htmlData)
return
}
num, err := db.CountEntries()
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if num == 0 {
if htmlData.Role != model.Admin {
htmlData.Msg = "Der erste Benutzer muss ein Administrator sein."
htmlData.Role = model.Admin
tmpl, err := template.ParseFiles("web/templates/add-user.html")
tmpl = template.Must(tmpl, err)
tmpl.ExecuteTemplate(w, "page-content", htmlData)
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.AddUser(htmlData.User, pass); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles("web/templates/hub.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", 0)
}
}

View File

@ -1,30 +1,35 @@
package view package view
import ( import (
"fmt"
"html/template" "html/template"
"io"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"time"
"streifling.com/jason/cpolis/cmd/control" "streifling.com/jason/cpolis/cmd/control"
"streifling.com/jason/cpolis/cmd/model" "streifling.com/jason/cpolis/cmd/model"
) )
func ShowHub(s *control.CookieStore) http.HandlerFunc { func ShowHub(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) {
session, err := s.Get(r, "cookie") session, err := s.Get(r, "cookie")
if err != nil { if err != nil {
tmpl, err := template.ParseFiles("web/templates/login.html") tmpl, err := template.ParseFiles(c.WebDir + "/templates/login.html")
msg := "Session nicht mehr gültig. Bitte erneut anmelden." msg := "Session nicht mehr gültig. Bitte erneut anmelden."
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", msg) template.Must(tmpl, err).ExecuteTemplate(w, "page-content", msg)
} }
tmpl, err := template.ParseFiles("web/templates/hub.html") tmpl, err := template.ParseFiles(c.WebDir + "/templates/hub.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", session.Values["role"]) template.Must(tmpl, err).ExecuteTemplate(w, "page-content", session.Values["role"].(int))
} }
} }
func WriteArticle(db *model.DB) http.HandlerFunc { func WriteArticle(c *control.CliArgs, db *model.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
tags, err := db.GetTagList() tags, err := db.GetTagList()
if err != nil { if err != nil {
@ -33,45 +38,29 @@ func WriteArticle(db *model.DB) http.HandlerFunc {
return return
} }
tmpl, err := template.ParseFiles("web/templates/editor.html") tmpl, err := template.ParseFiles(c.WebDir + "/templates/editor.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", tags) template.Must(tmpl, err).ExecuteTemplate(w, "page-content", tags)
} }
} }
func FinishArticle(db *model.DB, s *control.CookieStore) http.HandlerFunc { func SubmitArticle(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) {
article := new(model.Article)
var err error
article.Title, err = control.ConvertToPlain(r.PostFormValue("editor-title"))
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
article.Desc, err = control.ConvertToPlain(r.PostFormValue("editor-desc"))
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
article.Content, err = control.ConvertToHTML(r.PostFormValue("editor-text"))
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
session, err := s.Get(r, "cookie") session, err := s.Get(r, "cookie")
if err != nil { if err != nil {
tmpl, err := template.ParseFiles("web/templates/login.html") tmpl, err := template.ParseFiles(c.WebDir + "/templates/login.html")
msg := "Session nicht mehr gültig. Bitte erneut anmelden." msg := "Session nicht mehr gültig. Bitte erneut anmelden."
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", msg) template.Must(tmpl, err).ExecuteTemplate(w, "page-content", msg)
} }
article.AuthorID = session.Values["id"].(int64) article := &model.Article{
Title: r.PostFormValue("article-title"),
Description: r.PostFormValue("article-description"),
Content: r.PostFormValue("article-content"),
Published: false,
Rejected: false,
AuthorID: session.Values["id"].(int64),
}
article.ID, err = db.AddArticle(article) article.ID, err = db.AddArticle(article)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
@ -96,57 +85,228 @@ func FinishArticle(db *model.DB, s *control.CookieStore) http.HandlerFunc {
return return
} }
tmpl, err := template.ParseFiles("web/templates/hub.html") tmpl, err := template.ParseFiles(c.WebDir + "/templates/hub.html")
tmpl = template.Must(tmpl, err) tmpl = template.Must(tmpl, err)
tmpl.ExecuteTemplate(w, "page-content", session.Values["role"]) tmpl.ExecuteTemplate(w, "page-content", session.Values["role"])
} }
} }
func ShowUnpublishedArticles(db *model.DB) http.HandlerFunc { func ResubmitArticle(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) {
articles, err := db.GetCertainArticles(false) id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles("web/templates/unpublished-articles.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", articles)
}
}
func ReviewArticle(db *model.DB, s *control.CookieStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PostFormValue("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
} }
article, err := db.GetArticle(id) title := r.PostFormValue("article-title")
if err != nil { description := r.PostFormValue("article-description")
tmpl, err := template.ParseFiles("web/templates/to-be-published.html") content := r.PostFormValue("article-content")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", article)
if err = db.UpdateAttributes(
&model.Attribute{Table: "articles", ID: id, AttName: "title", Value: title},
&model.Attribute{Table: "articles", ID: id, AttName: "description", Value: description},
&model.Attribute{Table: "articles", ID: id, AttName: "content", Value: content},
&model.Attribute{Table: "articles", ID: id, AttName: "rejected", Value: false},
); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
// session, err := s.Get(r, "cookie") r.ParseForm()
// if err != nil { tags := make([]int64, 0)
// tmpl, err := template.ParseFiles("web/templates/login.html") for _, tag := range r.Form["tags"] {
// msg := "Session nicht mehr gültig. Bitte erneut anmelden." tagID, err := strconv.ParseInt(tag, 10, 64)
// template.Must(tmpl, err).ExecuteTemplate(w, "page-content", msg) if err != nil {
// } log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tags = append(tags, tagID)
}
if err = db.UpdateArticleTags(id, tags); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles("web/templates/to-be-published.html") session, err := s.Get(r, "cookie")
if err != nil {
tmpl, err := template.ParseFiles(c.WebDir + "/templates/login.html")
msg := "Session nicht mehr gültig. Bitte erneut anmelden."
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", msg)
}
tmpl, err := template.ParseFiles(c.WebDir + "/templates/hub.html")
tmpl = template.Must(tmpl, err) tmpl = template.Must(tmpl, err)
tmpl.ExecuteTemplate(w, "page-content", article) tmpl.ExecuteTemplate(w, "page-content", session.Values["role"])
} }
} }
func PublishArticle(db *model.DB, s *control.CookieStore) http.HandlerFunc { func ShowUnpublishedArticles(c *control.CliArgs, db *model.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PostFormValue("id"), 10, 64) unpublishedArticles, err := db.GetCertainArticles(false, false)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles(c.WebDir + "/templates/unpublished-articles.html")
tmpl = template.Must(tmpl, err)
tmpl.ExecuteTemplate(w, "page-content", unpublishedArticles)
}
}
func ShowRejectedArticles(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
type htmlData struct {
MyIDs map[int64]bool
RejectedArticles []*model.Article
}
data := new(htmlData)
session, err := s.Get(r, "cookie")
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data.RejectedArticles, err = db.GetCertainArticles(false, true)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data.MyIDs = make(map[int64]bool)
for _, article := range data.RejectedArticles {
if article.AuthorID == session.Values["id"].(int64) {
data.MyIDs[article.ID] = true
}
}
tmpl, err := template.ParseFiles(c.WebDir + "/templates/rejected-articles.html")
tmpl = template.Must(tmpl, err)
tmpl.ExecuteTemplate(w, "page-content", data)
}
}
func ReviewUnpublishedArticle(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
type htmlData struct {
Title string
Description string
Content template.HTML
Tags []*model.Tag
ID int64
}
var err error
data := new(htmlData)
data.ID, err = strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
article, err := db.GetArticle(data.ID)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
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 {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles(c.WebDir + "/templates/to-be-published.html")
tmpl = template.Must(tmpl, err)
tmpl.ExecuteTemplate(w, "page-content", data)
}
}
func ReviewRejectedArticle(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
type htmlData struct {
Selected map[int64]bool
Article *model.Article
Tags []*model.Tag
}
data := new(htmlData)
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data.Article, err = db.GetArticle(id)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data.Tags, err = db.GetTagList()
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
selectedTags, err := db.GetArticleTags(id)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data.Selected = make(map[int64]bool)
for _, tag := range selectedTags {
data.Selected[tag.ID] = true
}
tmpl, err := template.ParseFiles(c.WebDir + "/templates/rework-article.html")
tmpl = template.Must(tmpl, err)
tmpl.ExecuteTemplate(w, "page-content", data)
}
}
func PublishArticle(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
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)
@ -155,15 +315,129 @@ func PublishArticle(db *model.DB, s *control.CookieStore) http.HandlerFunc {
session, err := s.Get(r, "cookie") session, err := s.Get(r, "cookie")
if err != nil { if err != nil {
tmpl, err := template.ParseFiles("web/templates/login.html") tmpl, err := template.ParseFiles(c.WebDir + "/templates/login.html")
msg := "Session nicht mehr gültig. Bitte erneut anmelden." msg := "Session nicht mehr gültig. Bitte erneut anmelden."
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", msg) template.Must(tmpl, err).ExecuteTemplate(w, "page-content", msg)
} }
db.UpdateAttribute("articles", id, "published", true) if err = db.AddArticleToCurrentIssue(id); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles("web/templates/hub.html") if err = db.UpdateAttributes(
&model.Attribute{Table: "articles", ID: id, AttName: "published", Value: true},
&model.Attribute{Table: "articles", ID: id, AttName: "rejected", Value: false},
&model.Attribute{Table: "articles", ID: id, AttName: "created", Value: time.Now().Format("2006-01-02 15:04:05")},
); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
feed, err := control.GenerateRSS(
db,
"Freimaurer Distrikt Niedersachsen und Sachsen-Anhalt",
"https://distrikt-ni-st.de",
"Freiheit, Gleichheit, Brüderlichkeit, Toleranz und Humanität",
)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err = control.SaveRSS(c.RSSFile, feed); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles(c.WebDir + "/templates/hub.html")
tmpl = template.Must(tmpl, err) tmpl = template.Must(tmpl, err)
tmpl.ExecuteTemplate(w, "page-content", session.Values["role"]) tmpl.ExecuteTemplate(w, "page-content", session.Values["role"])
} }
} }
func RejectArticle(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
session, err := s.Get(r, "cookie")
if err != nil {
tmpl, err := template.ParseFiles(c.WebDir + "/templates/login.html")
msg := "Session nicht mehr gültig. Bitte erneut anmelden."
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", msg)
}
if err = db.UpdateAttributes(
&model.Attribute{Table: "articles", ID: id, AttName: "rejected", Value: true},
); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles(c.WebDir + "/templates/hub.html")
tmpl = template.Must(tmpl, err)
tmpl.ExecuteTemplate(w, "page-content", session.Values["role"])
}
}
func ShowCurrentArticles(c *control.CliArgs, db *model.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
articles, err := db.GetCurrentIssueArticles()
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles(c.WebDir + "/templates/current-articles.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", articles)
}
}
func UploadImage(c *control.CliArgs) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
file, header, err := r.FormFile("article-image")
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer file.Close()
filename := fmt.Sprint(time.Now().Format("2006-01-02_15:04:05"), "-",
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 {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer img.Close()
if _, err = io.Copy(img, file); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
imgMD := fmt.Sprint("![", header.Filename, "](/pics/", filename, ")")
tmpl, err := template.ParseFiles(c.WebDir + "/templates/editor.html")
template.Must(tmpl, err).ExecuteTemplate(w, "editor-images", imgMD)
}
}

View File

@ -8,23 +8,25 @@ import (
"streifling.com/jason/cpolis/cmd/model" "streifling.com/jason/cpolis/cmd/model"
) )
func CreateTag(w http.ResponseWriter, r *http.Request) { func CreateTag(c *control.CliArgs) http.HandlerFunc {
tmpl, err := template.ParseFiles("web/templates/add-tag.html") return func(w http.ResponseWriter, r *http.Request) {
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", nil) tmpl, err := template.ParseFiles(c.WebDir + "/templates/add-tag.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", nil)
}
} }
func AddTag(db *model.DB, s *control.CookieStore) http.HandlerFunc { func AddTag(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) {
db.AddTag(r.PostFormValue("tag")) db.AddTag(r.PostFormValue("tag"))
session, err := s.Get(r, "cookie") session, err := s.Get(r, "cookie")
if err != nil { if err != nil {
tmpl, err := template.ParseFiles("web/templates/login.html") tmpl, err := template.ParseFiles(c.WebDir + "/templates/login.html")
msg := "Session nicht mehr gültig. Bitte erneut anmelden." msg := "Session nicht mehr gültig. Bitte erneut anmelden."
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", msg) template.Must(tmpl, err).ExecuteTemplate(w, "page-content", msg)
} }
tmpl, err := template.ParseFiles("web/templates/hub.html") tmpl, err := template.ParseFiles(c.WebDir + "/templates/hub.html")
tmpl = template.Must(tmpl, err) tmpl = template.Must(tmpl, err)
tmpl.ExecuteTemplate(w, "page-content", session.Values["role"]) tmpl.ExecuteTemplate(w, "page-content", session.Values["role"])
} }

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"))
}
}

31
cmd/view/issues.go Normal file
View File

@ -0,0 +1,31 @@
package view
import (
"html/template"
"log"
"net/http"
"streifling.com/jason/cpolis/cmd/control"
"streifling.com/jason/cpolis/cmd/model"
)
func PublishLatestIssue(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := db.PublishLatestIssue(); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
session, err := s.Get(r, "cookie")
if err != nil {
tmpl, err := template.ParseFiles(c.WebDir + "/templates/login.html")
msg := "Session nicht mehr gültig. Bitte erneut anmelden."
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", msg)
}
tmpl, err := template.ParseFiles(c.WebDir + "/templates/hub.html")
tmpl = template.Must(tmpl, err)
tmpl.ExecuteTemplate(w, "page-content", session.Values["role"])
}
}

View File

@ -1,71 +0,0 @@
package view
import (
"html/template"
"log"
"net/http"
"time"
"git.streifling.com/jason/rss"
"streifling.com/jason/cpolis/cmd/model"
)
func ShowRSS(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)
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)
}
user, err := db.GetUser(article.AuthorID)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
channel.Items = append(channel.Items, &rss.Item{
Title: article.Title,
Author: user.FirstName + user.LastName,
PubDate: article.Created.Format(time.RFC1123Z),
Description: article.Desc,
Content: &rss.Content{Value: article.Content},
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{"web/templates/index.html", "web/templates/feed.rss"}
tmpl, err := template.ParseFiles(files...)
template.Must(tmpl, err).Execute(w, rss)
}
}

View File

@ -27,26 +27,26 @@ func saveSession(w http.ResponseWriter, r *http.Request, s *control.CookieStore,
return nil return nil
} }
func HomePage(db *model.DB, s *control.CookieStore) http.HandlerFunc { func HomePage(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) {
numRows, err := db.CountEntries() numRows, err := db.CountEntries("users")
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
files := []string{"web/templates/index.html"} files := []string{c.WebDir + "/templates/index.html"}
if numRows == 0 { if numRows == 0 {
files = append(files, "web/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 {
session, _ := s.Get(r, "cookie") session, _ := s.Get(r, "cookie")
if auth, ok := session.Values["authenticated"].(bool); auth && ok { if auth, ok := session.Values["authenticated"].(bool); auth && ok {
files = append(files, "web/templates/hub.html") files = append(files, c.WebDir+"/templates/hub.html")
tmpl, err := template.ParseFiles(files...) tmpl, err := template.ParseFiles(files...)
template.Must(tmpl, err).Execute(w, session.Values["role"]) template.Must(tmpl, err).Execute(w, session.Values["role"])
} else { } else {
files = append(files, "web/templates/login.html") files = append(files, c.WebDir+"/templates/login.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)
} }
@ -54,15 +54,14 @@ func HomePage(db *model.DB, s *control.CookieStore) http.HandlerFunc {
} }
} }
func Login(db *model.DB, s *control.CookieStore) http.HandlerFunc { func Login(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) {
userName := r.PostFormValue("username") userName := r.PostFormValue("username")
password := r.PostFormValue("password") password := r.PostFormValue("password")
id, err := db.GetID(userName) id, ok := db.GetID(userName)
if err != nil { if !ok {
log.Println(err) http.Error(w, fmt.Sprintf("no such user: %v", userName), http.StatusInternalServerError)
http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -85,7 +84,29 @@ func Login(db *model.DB, s *control.CookieStore) http.HandlerFunc {
return return
} }
tmpl, err := template.ParseFiles("web/templates/hub.html") tmpl, err := template.ParseFiles(c.WebDir + "/templates/hub.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", user.Role) template.Must(tmpl, err).ExecuteTemplate(w, "page-content", user.Role)
} }
} }
func Logout(c *control.CliArgs, s *control.CookieStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, err := s.Get(r, "cookie")
if err != nil {
tmpl, err := template.ParseFiles(c.WebDir + "/templates/login.html")
msg := "Session nicht mehr gültig. Bitte erneut anmelden."
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", msg)
}
session.Options.MaxAge = -1
if err = session.Save(r, w); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles(c.WebDir + "/templates/login.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", nil)
}
}

267
cmd/view/users.go Normal file
View File

@ -0,0 +1,267 @@
package view
import (
"fmt"
"html/template"
"log"
"net/http"
"strconv"
"streifling.com/jason/cpolis/cmd/control"
"streifling.com/jason/cpolis/cmd/model"
)
type UserData struct {
*model.User
Msg string
}
func checkUserStrings(user *model.User) (string, int, bool) {
userLen := 15
nameLen := 50
if len(user.UserName) > userLen {
return "Benutzername", userLen, false
} else if len(user.FirstName) > nameLen {
return "Vorname", nameLen, false
} else if len(user.LastName) > nameLen {
return "Nachname", nameLen, false
} else {
return "", 0, true
}
}
func CreateUser(c *control.CliArgs) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tmpl, err := template.ParseFiles(c.WebDir + "/templates/add-user.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", nil)
}
}
func AddUser(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
role, err := strconv.Atoi(r.PostFormValue("role"))
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
htmlData := UserData{
User: &model.User{
UserName: r.PostFormValue("username"),
FirstName: r.PostFormValue("first-name"),
LastName: r.PostFormValue("last-name"),
Role: role,
},
}
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
}
_, err = db.AddUser(htmlData.User, pass)
if 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)
}
}
func EditUser(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, err := s.Get(r, "cookie")
if err != nil {
tmpl, err := template.ParseFiles(c.WebDir + "/templates/login.html")
msg := "Session nicht mehr gültig. Bitte erneut anmelden."
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", msg)
}
user, err := db.GetUser(session.Values["id"].(int64))
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles(c.WebDir + "/templates/edit-user.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", user)
}
}
func UpdateUser(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, err := s.Get(r, "cookie")
if err != nil {
tmpl, err := template.ParseFiles(c.WebDir + "/templates/login.html")
msg := "Session nicht mehr gültig. Bitte erneut anmelden."
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", msg)
}
userData := UserData{
User: &model.User{
ID: session.Values["id"].(int64),
UserName: r.PostFormValue("username"),
FirstName: r.PostFormValue("first-name"),
LastName: r.PostFormValue("last-name"),
},
}
oldPass := r.PostFormValue("old-password")
newPass := r.PostFormValue("password")
newPass2 := r.PostFormValue("password2")
if len(userData.UserName) == 0 || len(userData.FirstName) == 0 ||
len(userData.LastName) == 0 {
userData.Msg = "Alle Felder mit * müssen ausgefüllt sein."
tmpl, err := template.ParseFiles(c.WebDir + "/templates/edit-user.html")
tmpl = template.Must(tmpl, err)
tmpl.ExecuteTemplate(w, "page-content", userData.Msg)
return
}
userString, stringLen, ok := checkUserStrings(userData.User)
if !ok {
userData.Msg = fmt.Sprint(userString, " ist zu lang. Maximal ",
stringLen, " Zeichen erlaubt.")
tmpl, err := template.ParseFiles(c.WebDir + "/templates/edit-user.html")
tmpl = template.Must(tmpl, err)
tmpl.ExecuteTemplate(w, "page-content", userData)
return
}
if id, ok := db.GetID(userData.UserName); ok {
if id != userData.ID {
userData.Msg = "Benutzername bereits vergeben."
userData.UserName = ""
tmpl, err := template.ParseFiles(c.WebDir + "/templates/edit-user.html")
tmpl = template.Must(tmpl, err)
tmpl.ExecuteTemplate(w, "page-content", userData)
return
}
}
if err = db.UpdateUserAttributes(
userData.ID,
userData.UserName,
userData.FirstName,
userData.LastName,
oldPass,
newPass,
newPass2); err != nil {
userData.Msg = "Aktualisierung der Benutzerdaten fehlgeschlagen."
tmpl, err := template.ParseFiles(c.WebDir + "/templates/edit-user.html")
template.Must(tmpl, err).ExecuteTemplate(w, "page-content", userData)
}
tmpl, err := template.ParseFiles(c.WebDir + "/templates/hub.html")
tmpl = template.Must(tmpl, err)
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=

10
tailwind.config.js Normal file
View File

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./web/templates/*.html"],
theme: {
extend: {}
},
plugins: [
require('@tailwindcss/typography')
],
}

41
web/static/css/input.css Normal file
View File

@ -0,0 +1,41 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
width: 800px;
@apply mx-auto text-slate-900;
}
h2 {
@apply font-bold mb-2 text-2xl;
}
form {
@apply flex flex-col gap-y-3;
}
input[type="file"] {
@apply border rounded-md w-full;
}
input[type="password"],
input[type="text"] {
@apply border h-8 rounded-md;
}
textarea {
@apply border h-32 rounded-md;
}
.btn-area {
@apply flex gap-4 mt-4;
}
.btn {
@apply bg-slate-200 border my-2 px-3 py-2 rounded-md w-full hover:bg-slate-100;
}
.action-btn {
@apply bg-slate-800 border my-2 px-3 py-2 rounded-md text-slate-50 w-full hover:bg-slate-700;
}

View File

@ -1,8 +1,12 @@
{{define "page-content"}} {{define "page-content"}}
<h2>Neuer Benutzer</h2> <h2>Neuer Tag</h2>
<form> <form>
<input required name="tag" placeholder="Tag" type="text" /> <input required name="tag" placeholder="Tag eingeben" type="text" />
<input type="submit" value="Anlegen" hx-post="/add-tag/" hx-target="#page-content" /> <div class="btn-area">
<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>
</div>
</form> </form>
<button hx-get="/hub/" hx-target="#page-content">Abbrechen</button>
{{end}} {{end}}

View File

@ -1,23 +1,53 @@
{{define "page-content"}} {{define "page-content"}}
<h2>Neuer Benutzer</h2> <h2>Neuer Benutzer</h2>
<form> <form>
<input required name="username" placeholder="Benutzername" type="text" value="{{.UserName}}" /> <div class="grid grid-cols-3 gap-4">
<input required name="password" placeholder="Passwort" type="password" /> <div>
<input required name="password2" placeholder="Passwort wiederholen" type="password" /> <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>
<input required name="first-name" placeholder="Vorname" type="text" value="{{.FirstName}}" /> <div class="flex gap-4">
<input required name="last-name" placeholder="Nachname" type="text" value="{{.LastName}}" /> <div>
<input required id="author" name="role" type="radio" value="3" {{if eq .Role 3 }}checked{{end}} />
<label for="author">Autor</label>
</div>
<div>
<input required id="editor" name="role" type="radio" value="2" {{if eq .Role 2 }}checked{{end}} />
<label for="editor">Redakteur</label>
</div>
<div>
<input required id="publisher" name="role" type="radio" value="1" {{if eq .Role 1 }}checked{{end}} />
<label for="publisher">Herausgeber</label>
</div>
<div>
<input required id="admin" name="role" type="radio" value="0" {{if eq .Role 0 }}checked{{end}} />
<label for="admin">Admin</label>
</div>
</div>
<input required id="writer" name="role" type="radio" value="2" {{if eq .Role 2 }}checked{{end}} /> <div class="btn-area">
<label for="writer">Schreiber</label> <input class="action-btn" type="submit" value="Anlegen" hx-post="/add-user" hx-target="#page-content" />
<input required id="editor" name="role" type="radio" value="1" {{if eq .Role 1 }}checked{{end}} /> <button class="btn" hx-get="/hub" hx-target="#page-content">Abbrechen</button>
<label for="editor">Redakteur</label> </div>
<input required id="admin" name="role" type="radio" value="0" {{if eq .Role 0 }}checked{{end}} />
<label for="admin">Admin</label>
<input type="submit" value="Anlegen" hx-post="/add-user/" hx-target="#page-content" />
</form> </form>
<button hx-get="/hub/" hx-target="#page-content">Abbrechen</button>
<script> <script>
var msg = "{{.Msg}}"; var msg = "{{.Msg}}";

View File

@ -0,0 +1,15 @@
{{define "page-content"}}
<div class="flex flex-col gap-4">
{{range .}}
<div class="border px-2 py-1 rounded-md">
<h1 class="font-bold text-2xl">{{.Title}}</h1>
<p>{{.Description}}</p>
</div>
{{end}}
</div>
<div class="btn-area">
<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>
</div>
{{end}}

View File

@ -0,0 +1,36 @@
{{define "page-content"}}
<form>
<div class="grid grid-cols-3 gap-4">
<div>
<label for="username">Benutzername</label>
<input class="w-full" name="username" type="text" value="{{.UserName}}" />
</div>
<div>
<label for="first-name">Vorname</label>
<input class="w-full" name="first-name" type="text" value="{{.FirstName}}" />
</div>
<div>
<label for="last-name">Nachname</label>
<input class="w-full" name="last-name" type="text" value="{{.LastName}}" />
</div>
<div>
<label for="old-password">Altes Passwort</label>
<input class="w-full" name="old-password" placeholder="***" type="password" />
</div>
<div>
<label for="password">Passwort</label>
<input class="w-full" name="password" placeholder="***" type="password" />
</div>
<div>
<label for="password2">Passwort wiederholen</label>
<input class="w-full" name="password2" placeholder="***" type="password" />
</div>
</div>
<div class="btn-area">
<input class="action-btn" type="submit" value="Aktualisieren" hx-post="/update-user"
hx-target="#page-content" />
<button class="btn" hx-get="/hub" hx-target="#page-content">Abbrechen</button>
</div>
</form>
{{end}}

View File

@ -1,17 +1,69 @@
{{define "page-content"}} {{define "page-content"}}
<h2>Editor</h2> <h2>Editor</h2>
<form> <form>
<input name="editor-title" placeholder="Titel" type="text" /> <div class="flex flex-col gap-y-1">
<textarea name="editor-desc" placeholder="Beschreibung"></textarea> <label for="article-title">Titel</label>
<textarea name="editor-text" placeholder="Artikel"></textarea> <input name="article-title" type="text" />
{{range .}} </div>
<input id="{{.Name}}" name="tags" type="checkbox" value="{{.ID}}" /> <div class="flex flex-col">
<label for="{{.Name}}">{{.Name}}</label> <label for="article-description">Beschreibung</label>
{{end}} <textarea name="article-description"></textarea>
<input type="submit" value="Senden" hx-post="/finish-article/" hx-target="#page-content" /> </div>
<div class="flex flex-col">
<label for="article-content">Artikel</label>
<textarea name="article-content"></textarea>
</div>
<div>
<span>Tags</span>
<div class="flex flex-wrap gap-x-4">
{{range .}}
<div>
<input id="{{.Name}}" name="tags" type="checkbox" value="{{.ID}}" />
<label for="{{.Name}}">{{.Name}}</label>
</div>
{{end}}
</div>
</div>
<div id="editor-images">
<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" />
</div>
<div class="btn-area">
<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>
</div>
</form> </form>
<script>
function copyToClipboard(text) {
event.preventDefault(); // Get-Request verhindern
var textarea = document.createElement("textarea");
textarea.textContent = text;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
} catch (err) {
console.warn('Fehler beim Kopieren', err);
}
document.body.removeChild(textarea);
}
</script>
{{end}} {{end}}
{{define "html-result"}} {{define "editor-images"}}
{{.}} {{if gt (len .) 0}}
<div class="border px-2 py-1 rounded-md flex gap-4 justify-between">
<div class="self-center">{{.}}</div>
<button class="bg-slate-50 border my-2 px-3 py-2 rounded-md w-32 hover:bg-slate-100"
onclick="copyToClipboard('{{.}}')">Kopieren</button>
</div>
{{end}}
{{end}} {{end}}

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

View File

@ -5,17 +5,27 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Orient Editor</title> <title>Orient Editor</title>
<link href="web/static/css/style.css" rel="stylesheet"> <link href="/web/static/css/style.css" rel="stylesheet">
</head> </head>
<body> <body class="flex flex-col justify-between min-h-screen bg-slate-50">
<h1>Orient Editor</h1> <header class="my-8">
<h1 class="font-bold text-4xl text-center">Orient Editor</h1>
</header>
<div id="page-content"> <main class="mx-4">
{{template "page-content" .}} <div id="page-content">
</div> {{template "page-content" .}}
</div>
</main>
<script src="web/static/js/htmx.min.js"></script> <footer class="my-8">
<p class="text-center text-gray-500 dark:text-gray-400">
&copy; 2024 Jason Streifling. Alle Rechte vorbehalten.
</p>
</footer>
<script src="/web/static/js/htmx.min.js"></script>
</body> </body>
</html> </html>

View File

@ -1,8 +1,11 @@
{{define "page-content"}} {{define "page-content"}}
<h2>Anmeldung</h2> <h2>Anmeldung</h2>
<form> <form>
<input name="username" placeholder="Benutzername" type="text" /> <div class="btn-area">
<input name="password" placeholder="Passwort" type="password" /> <input class="w-full" name="username" placeholder="Benutzername" type="text" />
<input type="submit" value="Anmelden" hx-post="/login/" hx-target="#page-content" /> <input class="w-full" name="password" placeholder="Passwort" type="password" />
</div>
<input class="action-btn" type="submit" value="Anmelden" hx-post="/login" hx-target="#page-content" />
</form> </form>
{{end}} {{end}}

View File

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

View File

@ -0,0 +1,71 @@
{{define "page-content"}}
<h2>Editor</h2>
<form>
<div class="flex flex-col gap-y-1">
<label for="article-title">Titel</label>
<input name="article-title" type="text" value="{{.Article.Title}}" />
</div>
<div class="flex flex-col">
<label for="article-description">Beschreibung</label>
<textarea name="article-description">{{.Article.Description}}</textarea>
</div>
<div class="flex flex-col">
<label for="article-content">Artikel</label>
<textarea name="article-content" placeholder="Artikel">{{.Article.Content}}</textarea>
</div>
<div>
<span>Tags</span>
<div class="flex flex-wrap gap-x-4">
{{range .Tags}}
<div>
<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>
<div id="editor-images">
<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" />
</div>
<div class="btn-area">
<input class="action-btn" type="submit" value="Senden" hx-post="/resubmit-article/{{.Article.ID}}"
hx-target="#page-content" />
<button class="btn" hx-get="/hub" hx-target="#page-content">Zurück</button>
</div>
</form>
<script>
function copyToClipboard(text) {
event.preventDefault(); // Get-Request verhindern
var textarea = document.createElement("textarea");
textarea.textContent = text;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
} catch (err) {
console.warn('Fehler beim Kopieren', err);
}
document.body.removeChild(textarea);
}
</script>
{{end}}
{{define "editor-images"}}
{{if gt (len .) 0}}
<div class="border px-2 py-1 rounded-md flex gap-4 justify-between">
<div class="self-center">{{.}}</div>
<button class="bg-slate-50 border my-2 px-3 py-2 rounded-md w-32 hover:bg-slate-100"
onclick="copyToClipboard('{{.}}')">Kopieren</button>
</div>
{{end}}
{{end}}

View File

@ -1,11 +1,35 @@
{{define "page-content"}} {{define "page-content"}}
<form> <div>
<h2>{{.Title}}</h2> <span>Titel</span>
<p>{{.Desc}}</p> <div class="bg-white border mb-3 px-2 py-2 rounded-md w-full">
<span>{{.Content}}</span> {{.Title}}
<input name="id" type="hidden" value="{{.ID}}" /> </div>
<input type="submit" value="Veröffentlichen" hx-post="/publish-article/" hx-target="#page-content" />
<input type="submit" value="Ablehnen" hx-post="/reject-article/" hx-target="#page-content" /> <span>Beschreibung</span>
</form> <div class="bg-white border mb-3 px-2 py-2 rounded-md w-full">
<button hx-get="/hub/" hx-target="#page-content">Zurück</button> {{.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}}
{{.Name}}
<br>
{{end}}
</div>
<div class="btn-area">
<input class="action-btn" type="submit" value="Veröffentlichen" hx-get="/publish-article/{{.ID}}"
hx-target="#page-content" />
<input class="btn" type="submit" value="Ablehnen" hx-get="/reject-article/{{.ID}}" hx-target="#page-content" />
<button class="btn" hx-get="/hub" hx-target="#page-content">Zurück</button>
</div>
</div>
{{end}} {{end}}

View File

@ -1,10 +1,11 @@
{{define "page-content"}} {{define "page-content"}}
<form> <div class="flex flex-col gap-4">
{{range .}} {{range .}}
<input required id="{{.ID}}" name="id" type="radio" value="{{.ID}}" /> <button class="btn" hx-get="/review-unpublished-article/{{.ID}}" hx-target="#page-content">
<label for="{{.ID}}">{{.Title}}</label> <h1 class="font-bold text-2xl">{{.Title}}</h1>
<p>{{.Description}}</p>
</button>
{{end}} {{end}}
<input type="submit" value="Auswählen" hx-post="/review-article/" hx-target="#page-content" /> <button class="action-btn" hx-get="/hub" hx-target="#page-content">Zurück</button>
</form> </div>
<button hx-get="/hub/" hx-target="#page-content">Zurück</button>
{{end}} {{end}}