788 lines
16 KiB
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))
|
|
} |