New templates #117 (#236)

Didn't have the chance to migrate //all// templates just yet. We'll get there.

* Implement yet another template system

* Move orphans to the new system and fix a bug in it

* Link orphans in the admin panel

* Move the backlink handlers to the web package

* Move auth routing to web

* Move /user-list to the new system

* Move change password and translate it

* Move stuff

* Move admin-related stuff to the web

* Move a lot of files into internal dir

Outside of it are web and stuff that needs further refactoring

* Fix static not loading and de-qtpl tree

* Move tree to internal

* Keep the globe on the same line #230

* Revert "Keep the globe on the same line #230"

This reverts commit ae78e5e459b1e980ba89bf29e61f75c0625ed2c7.

* Migrate templates from hypview: delete, edit, start empty and existing WIP

The delete media view was removed, I didn't even know it still existed as a GET. A rudiment.

* Make views multi-file and break compilation

* Megarefactoring of hypha views

* Auth-related stuffs

* Fix some of those weird imports

* Migrate cat views

* Fix cat js

* Lower standards

* Internalize trauma
This commit is contained in:
Timur Ismagilov
2024-09-07 21:22:41 +03:00
committed by GitHub
parent a9ee700aad
commit 41733c50bd
129 changed files with 1766 additions and 3765 deletions
+129
View File
@@ -0,0 +1,129 @@
package user
import (
"encoding/json"
"errors"
"github.com/bouncepaw/mycorrhiza/internal/cfg"
"log"
"os"
"github.com/bouncepaw/mycorrhiza/internal/files"
"github.com/bouncepaw/mycorrhiza/util"
)
// InitUserDatabase loads users, if necessary. Call it during initialization.
func InitUserDatabase() {
ReadUsersFromFilesystem()
}
// ReadUsersFromFilesystem reads all user information from filesystem and
// stores it internally.
func ReadUsersFromFilesystem() {
if cfg.UseAuth {
rememberUsers(usersFromFile())
readTokensToUsers()
}
}
func usersFromFile() []*User {
var users []*User
contents, err := os.ReadFile(files.UserCredentialsJSON())
if errors.Is(err, os.ErrNotExist) {
return users
}
if err != nil {
log.Fatal(err)
}
err = json.Unmarshal(contents, &users)
if err != nil {
log.Fatal(err)
}
for _, u := range users {
u.Name = util.CanonicalName(u.Name)
if u.Source == "" {
u.Source = "local"
}
}
log.Println("Found", len(users), "users")
return users
}
func rememberUsers(userList []*User) {
for _, user := range userList {
if !IsValidUsername(user.Name) {
continue
}
users.Store(user.Name, user)
}
}
func readTokensToUsers() {
contents, err := os.ReadFile(files.TokensJSON())
if os.IsNotExist(err) {
return
}
if err != nil {
log.Fatal(err)
}
var tmp map[string]string
err = json.Unmarshal(contents, &tmp)
if err != nil {
log.Fatal(err)
}
for token, username := range tmp {
tokens.Store(token, username)
// commenceSession(username, token)
}
log.Println("Found", len(tmp), "active sessions")
}
// SaveUserDatabase stores current user credentials into JSON file by configured path.
func SaveUserDatabase() error {
return dumpUserCredentials()
}
func dumpUserCredentials() error {
var userList []*User
// TODO: lock the map during saving to prevent corruption
for u := range YieldUsers() {
userList = append(userList, u)
}
blob, err := json.MarshalIndent(userList, "", "\t")
if err != nil {
log.Println(err)
return err
}
err = os.WriteFile(files.UserCredentialsJSON(), blob, 0666)
if err != nil {
log.Println(err)
return err
}
return nil
}
func dumpTokens() {
tmp := make(map[string]string)
tokens.Range(func(k, v interface{}) bool {
token := k.(string)
username := v.(string)
tmp[token] = username
return true
})
blob, err := json.MarshalIndent(tmp, "", "\t")
if err != nil {
log.Println(err)
return
}
err = os.WriteFile(files.TokensJSON(), blob, 0666)
if err != nil {
log.Println("an error occurred in dumpTokens function:", err)
}
}
+162
View File
@@ -0,0 +1,162 @@
package user
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"github.com/bouncepaw/mycorrhiza/internal/cfg"
"log"
"net/http"
"sort"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/bouncepaw/mycorrhiza/util"
)
// CanProceed returns `true` if the user in `rq` has enough rights to access `route`.
func CanProceed(rq *http.Request, route string) bool {
return FromRequest(rq).CanProceed(route)
}
// FromRequest returns user from `rq`. If there is no user, an anon user is returned instead.
func FromRequest(rq *http.Request) *User {
cookie, err := rq.Cookie("mycorrhiza_token")
if err != nil {
return EmptyUser()
}
return ByToken(cookie.Value)
}
// LogoutFromRequest logs the user in `rq` out and rewrites the cookie in `w`.
func LogoutFromRequest(w http.ResponseWriter, rq *http.Request) {
cookieFromUser, err := rq.Cookie("mycorrhiza_token")
if err == nil {
http.SetCookie(w, cookie("token", "", time.Unix(0, 0)))
terminateSession(cookieFromUser.Value)
}
}
// Register registers the given user. If it fails, a non-nil error is returned.
func Register(username, password, group, source string, force bool) error {
if !IsValidUsername(username) {
return fmt.Errorf("illegal username %s", username)
}
username = util.CanonicalName(username)
switch {
case !IsValidUsername(username):
return fmt.Errorf("illegal username %s", username)
case !ValidGroup(group):
return fmt.Errorf("invalid group %s", group)
case !ValidSource(source):
return fmt.Errorf("invalid source %s", source)
case HasUsername(username):
return fmt.Errorf("username %s is already taken", username)
case !force && cfg.RegistrationLimit > 0 && Count() >= cfg.RegistrationLimit:
return fmt.Errorf("reached the limit of registered users (%d)", cfg.RegistrationLimit)
case password == "" && source != "telegram":
return fmt.Errorf("password must not be empty")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
u := User{
Name: username,
Group: group,
Source: source,
Password: string(hash),
RegisteredAt: time.Now(),
}
users.Store(username, &u)
return SaveUserDatabase()
}
var (
ErrUnknownUsername = errors.New("unknown username")
ErrWrongPassword = errors.New("wrong password")
)
// LoginDataHTTP logs such user in and returns string representation of an error if there is any.
//
// The HTTP parameters are used for setting header status (bad request, if it is bad) and saving a cookie.
func LoginDataHTTP(w http.ResponseWriter, username, password string) error {
w.Header().Set("Content-Type", "text/html;charset=utf-8")
if !HasUsername(username) {
w.WriteHeader(http.StatusBadRequest)
log.Println("Unknown username", username, "was entered")
return ErrUnknownUsername
}
if !CredentialsOK(username, password) {
w.WriteHeader(http.StatusBadRequest)
log.Println("A wrong password was entered for username", username)
return ErrWrongPassword
}
token, err := AddSession(username)
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusBadRequest)
return err
}
http.SetCookie(w, cookie("token", token, time.Now().Add(365*24*time.Hour)))
return nil
}
// AddSession saves a session for `username` and returns a token to use.
func AddSession(username string) (string, error) {
token, err := util.RandomString(16)
if err == nil {
commenceSession(username, token)
log.Println("New token for", username, "is", token)
}
return token, err
}
// A handy cookie constructor
func cookie(nameSuffix, val string, t time.Time) *http.Cookie {
return &http.Cookie{
Name: "mycorrhiza_" + nameSuffix,
Value: val,
Expires: t,
Path: "/",
}
}
// TelegramAuthParamsAreValid is true if the given params are ok.
func TelegramAuthParamsAreValid(params map[string][]string) bool {
// According to the Telegram documentation,
// > You can verify the authentication and the integrity of the data received by comparing the received hash parameter with the hexadecimal representation of the HMAC-SHA-256 signature of the data-check-string with the SHA256 hash of the bot's token used as a secret key.
tokenHash := sha256.New()
tokenHash.Write([]byte(cfg.TelegramBotToken))
secretKey := tokenHash.Sum(nil)
hash := hmac.New(sha256.New, secretKey)
hash.Write([]byte(telegramDataCheckString(params)))
hexHash := hex.EncodeToString(hash.Sum(nil))
passedHash := params["hash"][0]
return passedHash == hexHash
}
// According to the Telegram documentation,
// > Data-check-string is a concatenation of all received fields, sorted in alphabetical order, in the format key=<value> with a line feed character ('\n', 0x0A) used as separator e.g., 'auth_date=<auth_date>\nfirst_name=<first_name>\nid=<id>\nusername=<username>'.
//
// Note that hash is not used here.
func telegramDataCheckString(params map[string][]string) string {
var lines []string
for key, value := range params {
if key == "hash" {
continue
}
lines = append(lines, fmt.Sprintf("%s=%s", key, value[0]))
}
sort.Strings(lines)
return strings.Join(lines, "\n")
}
+175
View File
@@ -0,0 +1,175 @@
package user
import (
"fmt"
"github.com/bouncepaw/mycorrhiza/internal/cfg"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// User contains information about a given user required for identification.
type User struct {
// Name is a username. It must follow hypha naming rules.
Name string `json:"name"`
Group string `json:"group"`
Password string `json:"hashed_password"`
RegisteredAt time.Time `json:"registered_on"`
// Source is where the user from. Valid values: local, telegram.
Source string `json:"source"`
sync.RWMutex
// A note about why HashedPassword is string and not []byte. The reason is
// simple: golang's json marshals []byte as slice of numbers, which is not
// acceptable.
}
// Route — Right (more is more right)
var minimalRights = map[string]int{
"text": 0,
"backlinks": 0,
"history": 0,
"media": 1,
"edit": 1,
"upload-binary": 1,
"rename": 1,
"upload-text": 1,
"add-to-category": 1,
"remove-from-category": 1,
"remove-media": 2,
"update-header-links": 3,
"delete": 3,
"reindex": 4,
"admin": 4,
"admin/shutdown": 4,
}
var groups = []string{
"anon",
"reader",
"editor",
"trusted",
"moderator",
"admin",
}
// Group — Right level
var groupRight = map[string]int{
"anon": 0,
"reader": 0,
"editor": 1,
"trusted": 2,
"moderator": 3,
"admin": 4,
}
// ValidGroup checks whether provided user group name exists.
func ValidGroup(group string) bool {
for _, grp := range groups {
if grp == group {
return true
}
}
return false
}
// ValidSource checks whether provided user source name exists.
func ValidSource(source string) bool {
return source == "local" || source == "telegram"
}
// EmptyUser constructs an anonymous user.
func EmptyUser() *User {
return &User{
Name: "anon",
Group: "anon",
Password: "",
Source: "local",
}
}
// WikimindUser constructs the wikimind user, which is to be used for automated wiki edits and has admin privileges.
func WikimindUser() *User {
return &User{
Name: "wikimind",
Group: "admin",
Password: "",
Source: "local",
}
}
// CanProceed checks whether user has rights to visit the provided path (and perform an action).
func (user *User) CanProceed(route string) bool {
if !cfg.UseAuth {
return true
}
user.RLock()
defer user.RUnlock()
right := groupRight[user.Group]
minimalRight, specified := minimalRights[route]
if !specified {
return false
}
return right >= minimalRight
}
func (user *User) isCorrectPassword(password string) bool {
user.RLock()
defer user.RUnlock()
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
return err == nil
}
// ShowLockMaybe redirects to the lock page if the user is anon and the wiki has been configured to use the lock. It returns true if the user was redirected.
func (user *User) ShowLockMaybe(w http.ResponseWriter, rq *http.Request) bool {
if cfg.Locked && user.Group == "anon" {
http.Redirect(w, rq, "/lock", http.StatusSeeOther)
return true
}
return false
}
// Sets a new password for the user.
func (user *User) ChangePassword(password string) error {
if user.Source != "local" {
return fmt.Errorf("Only local users can change their passwords.")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
user.Password = string(hash)
return SaveUserDatabase()
}
// IsValidUsername checks if the given username is valid.
func IsValidUsername(username string) bool {
for _, r := range username {
if strings.ContainsRune("?!:#@><*|\"'&%{}/", r) {
return false
}
}
return username != "anon" &&
username != "wikimind" &&
usernameIsWhiteListed(username)
}
func usernameIsWhiteListed(username string) bool {
if !cfg.UseWhiteList {
return true
}
for _, allowedUsername := range cfg.WhiteList {
if allowedUsername == username {
return true
}
}
return false
}
+125
View File
@@ -0,0 +1,125 @@
package user
import (
"sort"
"sync"
)
var users sync.Map
var tokens sync.Map
// YieldUsers creates a channel which iterates existing users.
func YieldUsers() chan *User {
ch := make(chan *User)
go func(ch chan *User) {
users.Range(func(_, v any) bool {
ch <- v.(*User)
return true
})
close(ch)
}(ch)
return ch
}
// ListUsersWithGroup returns a slice with users of desired group.
func ListUsersWithGroup(group string) []string {
var filtered []string
for u := range YieldUsers() {
if u.Group == group {
filtered = append(filtered, u.Name)
}
}
return filtered
}
// Count returns total users count
func Count() (i uint64) {
users.Range(func(k, v interface{}) bool {
i++
return true
})
return i
}
func HasAnyAdmins() bool {
for u := range YieldUsers() {
if u.Group == "admin" {
return true
}
}
return false
}
// HasUsername checks whether the desired user exists
func HasUsername(username string) bool {
_, has := users.Load(username)
return has
}
// CredentialsOK checks whether a correct user-password pair is provided
func CredentialsOK(username, password string) bool {
return ByName(username).isCorrectPassword(password)
}
// ByToken finds a user by provided session token
func ByToken(token string) *User {
// TODO: Needs more session data -- chekoopa
if usernameUntyped, ok := tokens.Load(token); ok {
username := usernameUntyped.(string)
return ByName(username)
}
return EmptyUser()
}
// ByName finds a user by one's username
func ByName(username string) *User {
if userUntyped, ok := users.Load(username); ok {
user := userUntyped.(*User)
return user
}
return EmptyUser()
}
// DeleteUser removes a user by one's name and saves user database.
func DeleteUser(name string) error {
user, loaded := users.LoadAndDelete(name)
if loaded {
u := user.(*User)
u.Name = "anon"
u.Group = "anon"
u.Password = ""
return SaveUserDatabase()
}
return nil
}
func commenceSession(username, token string) {
tokens.Store(token, username)
dumpTokens()
}
func terminateSession(token string) {
tokens.Delete(token)
dumpTokens()
}
func UsersInGroups() (admins []string, moderators []string, editors []string, readers []string) {
for u := range YieldUsers() {
switch u.Group {
// What if we place the users into sorted slices?
case "admin":
admins = append(admins, u.Name)
case "moderator":
moderators = append(moderators, u.Name)
case "editor", "trusted":
editors = append(editors, u.Name)
case "reader":
readers = append(readers, u.Name)
}
}
sort.Strings(admins)
sort.Strings(moderators)
sort.Strings(editors)
sort.Strings(readers)
return
}