comics/manager.go

788 lines
16 KiB
Go

package main
import (
"io"
"os"
"log"
"fmt"
"path"
"time"
"errors"
"strconv"
"strings"
"net/http"
"math/rand"
"database/sql"
"crypto/sha256"
"path/filepath"
"github.com/golang-jwt/jwt"
"golang.org/x/crypto/bcrypt"
)
var seededRand *rand.Rand = nil
var secretKey string = ""
var secretKeyHash string = ""
var tokenSecondsDefault = 60 * 60 * 24 * 14
var managerRoot = ""
const userSquema string = `
CREATE TABLE IF NOT EXISTS user (
id CHAR(20) NOT NULL PRIMARY KEY,
name CHAR(20),
password CHAR(50)
);`
const tokenSquema string = `
CREATE TABLE IF NOT EXISTS token (
id CHAR(20) NOT NULL PRIMARY KEY,
expiry INTEGER NOT NULL,
username CHAR(20) NOT NULL
);`
const isAuthorOfSquema string = `
CREATE TABLE IF NOT EXISTS isauthorof (
id INTEGER PRIMARY KEY AUTOINCREMENT,
comicid INT NOT NULL,
userid CHAR(20) NOT NULL
);`
type User struct {
ID string
Name string
Password string
}
type ManagerContext struct {
Context
User User
ManagerRoot string
}
type ConfirmContext struct {
Action string
Message string
OnYes string
OnNo string
}
type TokenClaims struct {
jwt.StandardClaims
ID string
Expiry time.Time
Username string
}
type IsAuthorOf struct {
ID int
ComicID int
UserID string
}
type ViewHandler func(w http.ResponseWriter, r *http.Request)
func (u *User) byID(id string) error {
rows, err := db.Query("SELECT * FROM user WHERE id = ?;", id)
if err != nil {
return err
}
defer rows.Close()
if !rows.Next() {
return errors.New("no such entry found")
}
return u.readRow(rows)
}
func (a *IsAuthorOf) byComic(id int) error {
rows, err := db.Query("SELECT * FROM isauthorof WHERE comicid = ?;", id)
if err != nil {
return err
}
defer rows.Close()
if !rows.Next() {
return errors.New("no such entry found")
}
return a.readRow(rows)
}
func (c *Comic) getAuthor() (*User, error) {
authorOf := &IsAuthorOf{}
user := &User{}
err := authorOf.byComic(c.ID)
if err != nil {
return nil, err
}
err = user.byID(authorOf.UserID)
if err != nil {
return nil, err
}
return user, nil
}
func (u *User) readRow(db * sql.Rows) error {
return db.Scan(&u.ID, &u.Name, &u.Password)
}
func getComicByID(id int) (*Comic, error) {
comic := &Comic{}
rows, err := db.Query("SELECT * FROM comic WHERE id = ?;", id)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, errors.New("no such comic found")
}
err = comic.readRow(rows)
if err != nil {
return nil, err
}
return comic, nil
}
func (u *User) getComics() ([]Comic, error) {
authors, err := getAllIsAuthorOfs()
if err != nil {
return nil, err
}
comics := []Comic{}
for _, a := range authors {
if a.UserID != u.ID {
continue
}
c, err := getComicByID(a.ComicID)
if err != nil {
continue
}
comics = append(comics, *c)
}
return comics, nil
}
func (t *TokenClaims) readRow(db * sql.Rows) error {
var e int64
err := db.Scan(&t.ID, &e, &t.Username)
t.Expiry = time.Unix(e, 0)
return err
}
func (a *IsAuthorOf) readRow(db *sql.Rows) error {
return db.Scan(&a.ID, &a.ComicID, &a.UserID)
}
func generateToken(username string, seconds time.Duration) (string, *TokenClaims, error) {
claims := TokenClaims{
ID: randomString(20),
Expiry: time.Now().Add(seconds * time.Second).Truncate((time.Second)),
Username: username,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secretKey))
if err != nil {
return "", nil, err
}
err = cleannupTokens()
if err != nil {
return "", nil, err
}
_, err = db.Exec("INSERT INTO token(id, expiry, username) VALUES(?, ?, ?);",
claims.ID, claims.Expiry.Unix(), claims.Username)
if err != nil {
return "", nil, err
}
return tokenString, &claims, nil
}
func cleannupTokens() error {
_, err := db.Exec("DELETE FROM token WHERE expiry < ?;", time.Now().Unix())
return err
}
func verifyToken(token string) (*TokenClaims, error) {
claims := &TokenClaims{}
t, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
return []byte(secretKey), nil
})
if err != nil {
return nil, err
}
if !t.Valid {
return nil, errors.New("invalid token")
}
err = cleannupTokens()
if err != nil {
return nil, err
}
rows, err := db.Query("SELECT * FROM token WHERE id = ?;", claims.ID)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, errors.New(fmt.Sprintf("no such claim with id \"%s\" was found", claims.ID))
}
claimsOnDB := &TokenClaims{}
err = claimsOnDB.readRow(rows)
if err != nil {
return nil, err
}
if !(claimsOnDB.ID == claims.ID && claims.Expiry == claimsOnDB.Expiry &&
claims.Username == claimsOnDB.Username) {
log.Println(*claims)
log.Println(*claimsOnDB)
return nil, errors.New("claims do not match database records")
}
return claims, nil
}
func revokeToken(id string) error {
_, err := db.Exec("DELETE FROM token WHERE id = ?;", id)
return err
}
func saltPassword(password string, id string) string {
s := password + id + secretKeyHash
if len(s) > 50 {
s = s[:50]
}
return s
}
func addUser(name string, password string) (*User, error) {
id := randomString(20)
var user *User = nil
users, err := getAllUsers()
if err != nil {
return nil, err
}
for user == nil {
user = &User{}
for _, u := range(users) {
if name == u.Name {
return nil, errors.New("user with the same name already exists")
}
if id == u.ID {
id = randomString(20)
user = nil
break
}
}
}
p, err := bcrypt.GenerateFromPassword([]byte(saltPassword(password, id)), 8)
_, err = db.Exec("INSERT INTO user(id, name, password) VALUES(?, ?, ?);",
id, name, string(p))
if err != nil {
return nil, err
}
return user, nil
}
func getAllUsers() ([]User, error) {
rows, err := db.Query("SELECT * FROM user ORDER BY id ASC")
if err != nil {
return nil, err
}
defer rows.Close()
users := []User{}
for rows.Next() {
u := User{}
err := u.readRow(rows)
if err != nil {
return nil, err
}
users = append(users, u)
}
return users, nil
}
func getAllIsAuthorOfs() ([]IsAuthorOf, error) {
rows, err := db.Query("SELECT * FROM isauthorof ORDER BY id ASC")
if err != nil {
return nil, err
}
defer rows.Close()
authors := []IsAuthorOf{}
for rows.Next() {
i := IsAuthorOf{}
err := i.readRow(rows)
if err != nil {
return nil, err
}
authors = append(authors, i)
}
return authors, nil
}
func addIsAuthorOf(userID string, comicID int) error {
authors, err := getAllIsAuthorOfs()
if err != nil {
return err
}
for _, a := range authors {
if a.ComicID == comicID && a.UserID == userID {
return nil
}
}
_, err = db.Exec("INSERT INTO isauthorof(comicid, userid) VALUES(?, ?);",
comicID, userID)
return err
}
func removeComicFromIsAuthorOf(id int) error {
_, err := db.Exec("DELETE FROM isauthorof WHERE comicid = ?;", id)
return err
}
func removeUserFromIsAuthorOf(id string) error {
_, err := db.Exec("DELETE FROM isauthorof WHERE userid = ?;", id)
return err
}
func randomString(length int) string {
charset := "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ0123456789"
b := make([]byte, length)
for i := range b {
b[i] = charset[seededRand.Intn(len(charset)-1)]
}
return string(b)
}
func getSessionToken(r *http.Request) (string, error) {
c, err := r.Cookie("token")
if err != nil {
return "", err
}
return c.Value, err
}
func getSessionUser(r * http.Request) (*User, *TokenClaims, error) {
c, err := getSessionToken(r)
if err != nil {
return nil, nil, err
}
return authenticateUserByToken(c)
}
func authenticateUserByToken(token string) (*User, *TokenClaims, error) {
claims, err := verifyToken(token)
if err != nil {
return nil, nil, err
}
users, _ := getAllUsers()
var user *User = nil
for i, u := range(users) {
if u.Name == claims.Username {
user = &users[i]
break
}
}
if user == nil {
err = errors.New("no such user \"" + claims.Username + "\"")
}
return user, claims, nil
}
func athenticateUserByPassword(username string, password string) (*User, error) {
if len(username) >= 20 {
username = username[:20]
}
if len(password) >= 50 {
password = password[:50]
}
users, err := getAllUsers()
if err != nil {
return nil, err
}
var user *User = nil
for i, u := range(users) {
if u.Name == username {
user = &users[i]
break
}
}
if user == nil {
return nil, errors.New("No such user \"" + username + "\" found")
}
password = saltPassword(password, user.ID)
if err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return nil, err
}
return user, err
}
func managerPath(path string) string {
return "/" + options.ManagerRoot + path
}
func managerIndexView(w http.ResponseWriter, r * http.Request) {
var err error
defer func() {
if err != nil {
errlog.Println(err)
return500(w,r)
err = nil
return
}
} ()
user, _, err := getSessionUser(r)
if err != nil {
http.Redirect(w, r, managerPath("/login"), http.StatusSeeOther)
return
}
context := ManagerContext{
Context: Context{
Title: "Black Ram Comics Manager",
},
User: *user,
ManagerRoot: options.ManagerRoot,
}
comics, err := user.getComics()
if err != nil {
return
}
for _, comic := range comics {
context.Comics = append(context.Comics, struct {
No int
Title string
Date string
Image string
}{
Title: comic.Title,
No: comic.ID,
Date: comic.DateTime,
Image: comic.Image,
})
}
context.SiteURL = options.SiteURL
err = executeTemplate(w, "manager", context)
}
func managerRemoveView(w http.ResponseWriter, r * http.Request) {
tmp := strings.TrimPrefix(r.URL.Path, "/" + options.ManagerRoot + "/remove/")
path := strings.TrimRight(tmp, "/confirmed")
isConfirmed := strings.TrimPrefix(strings.TrimPrefix(tmp, path), "/") == "confirmed"
user, _, err := getSessionUser(r)
if err != nil {
log.Println("failed to get session user: " + err.Error())
return401(w, r)
err = nil
return
}
log.Println(path)
i, err := strconv.Atoi(path)
if err != nil {
return404(w, r)
return
}
comic, err := getComicByID(i)
if err != nil {
return404(w, r)
return
}
authorOf := &IsAuthorOf{}
err = authorOf.byComic(i)
if err != nil {
errlog.Println(err)
return500(w, r)
return
}
if authorOf.UserID != user.ID {
return401(w, r)
return
}
if !isConfirmed {
err = executeTemplate(w, "confirm", ConfirmContext{
Action: fmt.Sprintf("Remove %s", comic.Title),
Message: fmt.Sprintf("Are you sure you want to remove \"%s\"", comic.Title),
OnYes: fmt.Sprintf("/" + options.ManagerRoot + "/remove/%d/confirmed", i),
OnNo: "/" + options.ManagerRoot + "/",
})
if err != nil {
return500(w, r)
return
}
return
}
err = deleteComic(comic.ID)
if err != nil {
return500(w, r)
return
}
err = removeComicFromIsAuthorOf(comic.ID)
if err != nil {
return500(w, r)
return
}
http.Redirect(w, r, managerPath("/index"), http.StatusSeeOther)
}
func managerPublishView(w http.ResponseWriter, r * http.Request) {
var err error
if r.Method == "GET" {
http.Redirect(w, r, managerPath("/index"), http.StatusSeeOther)
return
}
if r.Method != "POST" {
return404(w, r)
return
}
defer func() {
if err != nil {
errlog.Println(err)
return500(w, r)
}
} ()
user, _, err := getSessionUser(r)
if err != nil {
log.Println("failed to get session user: " + err.Error())
return401(w, r)
err = nil
return
}
r.ParseMultipartForm(options.UploadSize * (1 << 20))
file, handler, err := r.FormFile("image")
if err != nil {
return
}
defer file.Close()
title := r.FormValue("title")
if len(title) > 80 {
title = title[:80]
}
ext := path.Ext(handler.Filename)
filename := randomString(10)+ext
f, err := os.OpenFile(filepath.Join(options.MediaPath, filename), os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return
}
defer f.Close()
io.Copy(f, file)
comicID, err := newComic(title, filename, "", "")
if err != nil {
return
}
log.Println(user.ID)
err = addIsAuthorOf(user.ID, comicID)
if err != nil {
return
}
http.Redirect(w, r, managerPath("/index"), http.StatusSeeOther)
}
func managerLoginView(w http.ResponseWriter, r * http.Request) {
context := ManagerContext{
Context: Context {
Title: "Blackram Manager",
},
ManagerRoot: options.ManagerRoot,
}
user, _, err := getSessionUser(r)
if err == nil {
http.Redirect(w, r, managerPath("/index"), http.StatusSeeOther)
return
}
if r.Method == "GET" {
executeTemplate(w, "login", context)
return
}
if r.Method != "POST" {
return404(w, r)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
if username == "" || password == "" {
context.Message = "missing name/password"
executeTemplate(w, "login", context)
return
}
user, err = athenticateUserByPassword(username, password)
if err != nil {
context.Message = "wrong username/password"
executeTemplate(w, "login", context)
return
}
token, claims, err := generateToken(user.Name, time.Duration(tokenSecondsDefault))
if err != nil {
log.Println(fmt.Sprintf("failed to generate token for \"%s\": %s", user.Name, err.Error()))
return500(w, r)
return
}
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: token,
Expires: claims.Expiry,
})
http.Redirect(w, r, managerPath("/index"), http.StatusSeeOther)
}
func managerLogoutView(w http.ResponseWriter, r * http.Request) {
if r.Method != "GET" {
return404(w, r)
return
}
_, claims, err := getSessionUser(r)
if err != nil {
log.Println("failed to get session user: " + err.Error())
return401(w, r)
return
}
err = revokeToken(claims.ID)
if err != nil {
log.Println("failed to revoke session token: " + err.Error())
return500(w, r)
return
}
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: "",
})
http.Redirect(w, r, managerPath("/index"), http.StatusSeeOther)
}
func managerEditView(w http.ResponseWriter, r * http.Request) {
}
func startManager(address string, port int, dbPath string, mediaPath string) error {
seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))
_, err := db.Exec(userSquema)
if err != nil {
log.Fatal(err)
}
_, err = db.Exec(tokenSquema)
if err != nil {
log.Fatal(err)
}
_, err = db.Exec(isAuthorOfSquema)
if err != nil {
log.Fatal(err)
}
_, err = os.Stat(options.SecretPath)
if os.IsNotExist(err) {
secretKey = randomString(100)
err = os.WriteFile(options.SecretPath, []byte(secretKey), 0600)
if err != nil {
log.Fatal(fmt.Sprintf("failed to create random key on path \"%s\": %s",
options.SecretPath, err))
}
} else {
s, err := os.ReadFile(options.SecretPath)
secretKey = string(s)
if err != nil {
log.Fatal(fmt.Sprintf("failed to read random key: %s", err))
}
}
sh := sha256.Sum256([]byte(secretKey))
secretKeyHash = fmt.Sprintf("%x", sh)
if options.CreateUser {
_, err := addUser(options.Username, options.Password)
if err != nil {
log.Fatal(fmt.Sprintf("failed to create new user: %s", err.Error()))
}
log.Println(fmt.Sprintf("successfuly created user: \"%s\"", options.Username))
return nil
}
http.HandleFunc(managerPath("/index") , managerIndexView)
http.HandleFunc(managerPath("/edit/"), managerEditView)
http.HandleFunc(managerPath("/remove/"), managerRemoveView)
http.HandleFunc(managerPath("/publish"), managerPublishView)
http.HandleFunc(managerPath("/login"), managerLoginView)
http.HandleFunc(managerPath("/logout"), managerLogoutView)
uri := fmt.Sprintf("%s:%d", address, port)
log.Println("manager listening on http://" + uri + "/" + options.ManagerRoot)
return nil
//return http.ListenAndServe(uri, logRequest(http.DefaultServeMux))
}