Compare commits
80 Commits
legacy
...
blog-cleanup
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b86dbeaab | |||
| 687fbd7e65 | |||
| 9edd91148a | |||
| 3670f1b7a9 | |||
| b37fb8a7cf | |||
| 7c7f3bd131 | |||
| 123551c109 | |||
| 7e2c1da952 | |||
| 54f7f1b1fa | |||
| 8eb031c64f | |||
| e183eefee8 | |||
| f04c00c015 | |||
| 18aa193fe7 | |||
| 6896266055 | |||
| 94633293f5 | |||
| 98adb3da39 | |||
| 3ee8963a06 | |||
| 39f66782fb | |||
| 0f2b0f4f5d | |||
| 6925ff9e0c | |||
| 52f0d357a7 | |||
| 555e8e177e | |||
| 1061a38c6a | |||
| 6965917e76 | |||
| fcab9458ee | |||
| 05702814c9 | |||
| 4cc07e61d9 | |||
| 42064fd64c | |||
| 1dc937fcaf | |||
| 9cc4fa72f0 | |||
| 55ae9a0f05 | |||
| da2db3ea9a | |||
| 1e9eeb25d1 | |||
| b7c63ca794 | |||
| 2afbd64dc2 | |||
| a9ca12395e | |||
| bb44e26dd4 | |||
| 65a1834e9d | |||
| f99c9bd2fb | |||
| 0d40dd95c1 | |||
| 5386899d30 | |||
| 88524ce43c | |||
| 7fe19eddc3 | |||
| 87ac3bf270 | |||
| fd89e568af | |||
| 40271fa088 | |||
| 892c386027 | |||
| 91c122e212 | |||
| f86cdb0554 | |||
| cb53b7b831 | |||
| f59a4207c4 | |||
| 4eb69bb343 | |||
| 15dcc2928a | |||
| e43409923c | |||
| a77d55924d | |||
| 68edbca349 | |||
| 5af5de7719 | |||
| 5db8692ec8 | |||
| b58fb76b67 | |||
| 3a35a85100 | |||
| 6ed9df45fb | |||
| 11c9ec9242 | |||
| 197860b9d4 | |||
| fa9330a959 | |||
| fdd8d49055 | |||
| 7b15aab7ec | |||
| 7f9de3f025 | |||
| 28654e237e | |||
| e3af23873f | |||
| df3fe83c5f | |||
| 99c881b51e | |||
| 2040631737 | |||
| 228f0ff86d | |||
| 144f45293b | |||
| e4f8b31e29 | |||
| 4244b6985a | |||
| f7bbdaa4b3 | |||
| 75f8356893 | |||
| b6bc9240d9 | |||
| abfc67d10f |
@@ -1 +1,2 @@
|
|||||||
darkstorm-server
|
darkstorm-server
|
||||||
|
test.sh
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2021 Caleb Gardner
|
Copyright (c) 2024 Caleb Gardner
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend"
|
||||||
|
)
|
||||||
|
|
||||||
|
func latestBlogsHandle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
latest, err := blogApp.LatestBlogs(r.Context(), 0)
|
||||||
|
if err != nil {
|
||||||
|
if err == backend.ErrNotFound {
|
||||||
|
w.WriteHeader(404)
|
||||||
|
sendContent(w, r, "Page not found", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
log.Println("error getting latest blogs:", err)
|
||||||
|
sendContent(w, r, "Error getting page", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var out string
|
||||||
|
for _, b := range latest {
|
||||||
|
out += b.HTMX(blogApp, r.Context())
|
||||||
|
}
|
||||||
|
sendContent(w, r, out, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func blogHandle(w http.ResponseWriter, r *http.Request, blog string) {
|
||||||
|
bl, err := blogApp.Blog(r.Context(), blog)
|
||||||
|
if err != nil {
|
||||||
|
if err == backend.ErrNotFound {
|
||||||
|
w.WriteHeader(404)
|
||||||
|
sendContent(w, r, "Page not found", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
log.Printf("error getting blog %v: %v\n", blog, err)
|
||||||
|
sendContent(w, r, "Error getting page", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendContent(w, r, bl.HTMX(blogApp, r.Context()), bl.Title, bl.Favicon)
|
||||||
|
}
|
||||||
|
|
||||||
|
func blogListHandle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pag, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||||
|
list, err := blogApp.BlogList(r.Context(), int64(pag))
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
sendContent(w, r, "Error getting page", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := ""
|
||||||
|
for i := range list {
|
||||||
|
out += "<p>" + list[i].HTMX() + "</p>"
|
||||||
|
}
|
||||||
|
if pag > 0 || len(list) == 50 {
|
||||||
|
out += "<div id='blog-list-page-selector'>"
|
||||||
|
if pag > 0 {
|
||||||
|
pagNum := strconv.Itoa(pag - 1)
|
||||||
|
out += "<a href='https://darkstorm.tech/list?page=" + pagNum + "' hx-get='/list?page='" + pagNum + "' hx-push-url='true' hx-target='#content'><Previous</a>"
|
||||||
|
}
|
||||||
|
if len(list) == 50 {
|
||||||
|
pagNum := strconv.Itoa(pag + 1)
|
||||||
|
out += "<a href='https://darkstorm.tech/list?page=" + pagNum + "' hx-get='/list?page='" + pagNum + "' hx-push-url='true' hx-target='#content'>Next></a>"
|
||||||
|
}
|
||||||
|
out += "</div>"
|
||||||
|
}
|
||||||
|
sendContent(w, r, out, "", "")
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend"
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/blog"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
loginPage = `
|
||||||
|
<form id="loginForm" hx-post="/login" hx-target="#formResult">
|
||||||
|
<label for="username">Username:</label>
|
||||||
|
<input name="username" id="usernameInput" onkeydown="return event.key != 'Enter';" type="text"></input>
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input name="password" type="password" id="passwordInput"></input>
|
||||||
|
<div id="formResult"></div>
|
||||||
|
<button class="formButton" type="submit">Login</button>
|
||||||
|
</form>`
|
||||||
|
editorPage = `
|
||||||
|
<p>
|
||||||
|
<label for="blog" style="margin-right:10px">Blog:</label>
|
||||||
|
<select id="blogSelect"
|
||||||
|
name='blog'
|
||||||
|
hx-get='/editor/edit'
|
||||||
|
hx-target='#editor'>
|
||||||
|
<option value=""{{if eq .Selected ""}} selected{{end}}></option>
|
||||||
|
<option value='new'>New Blog</option>
|
||||||
|
{{ range $blog := .Blogs }}
|
||||||
|
<option value='{{.ID}}'{{if eq $.Selected .ID}} selected{{end}}>{{.Title}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<div id="editor" hx-on::after-settle="blogEditorResize()">{{.Editor}}</div>
|
||||||
|
`
|
||||||
|
editorForm = `
|
||||||
|
<form id="editorForm" hx-post="/editor/post" hx-target="#formResult" hx-confirm="Save changes, overwritting previous values??">
|
||||||
|
<input name="id" type="hidden" value="{{.Blog.ID}}"></input>
|
||||||
|
<p>
|
||||||
|
<label for="staticPage" style="margin-right:10px">Static Page:</label><input type="checkbox" name="staticPage"{{if .Blog.StaticPage}} checked {{end}}/>
|
||||||
|
<span class="vertical-seperator"></span>
|
||||||
|
<label for="draft" style="margin-right:10px">Draft:</label><input type="checkbox" name="draft"{{if or .Blog.Draft (not .Blog.ID)}} checked {{end}}/>
|
||||||
|
</p>
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input id="titleInput" name="title" value="{{.Blog.Title}}" type="text" onkeydown="return event.key != 'Enter';"/>
|
||||||
|
<textarea id="blogEditor" name="blog" oninput="blogEditorResize()">{{.Blog.RawBlog}}</textarea>
|
||||||
|
<div id="formResult">{{.Result}}</div>
|
||||||
|
<p style="margin-right:0px;">
|
||||||
|
<button class="formButton" type="submit">{{if eq .Blog.ID ""}}Create{{else}}Update{{end}}</button>
|
||||||
|
<button class="formButton"
|
||||||
|
hx-get="/editor/edit"
|
||||||
|
hx-include="#blogSelect"
|
||||||
|
hx-target="#editor"
|
||||||
|
hx-confirm="Undo all your changes??">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
</form>`
|
||||||
|
)
|
||||||
|
|
||||||
|
func loginPageRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sendContent(w, r, loginPage, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func trueLoginRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("HX-Request") != "true" {
|
||||||
|
sendContent(w, r, "<p>Bad request</p>", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
sendContent(w, r, "<p>Bad request</p>", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := back.TryLogin(r.Context(), r.FormValue("username"), r.FormValue("password"))
|
||||||
|
if err != nil {
|
||||||
|
if err == backend.ErrLoginTimeout {
|
||||||
|
sendContent(w, r, fmt.Sprint("<p>Timed out for", time.Unix(u.Timeout, 0).Sub(time.Now()), "</p>"), "", "")
|
||||||
|
} else if err == backend.ErrLoginIncorrect {
|
||||||
|
sendContent(w, r, "<p>Username or password invalid</p>", "", "")
|
||||||
|
} else {
|
||||||
|
log.Println("error trying to login:", err)
|
||||||
|
sendContent(w, r, "<p>Server error</p>", "", "")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tok, err := back.GenerateJWT(u.ToReqUser())
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error trying to generate JWT:", err)
|
||||||
|
sendContent(w, r, "<p>Server error</p>", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Set-Cookie", "blogAuthToken="+tok+"; Secure; Max-Age=43170; SameSite=Lax") // Max-Age is 11.5 hours. JWTs are valid for 12 hours.
|
||||||
|
sendContent(w, r, "<p hx-get='/editor' hx-push-url='true' hx-trigger='load' hx-target='#content'>Successful Login</p>", "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
pageTmpl *template.Template
|
||||||
|
formTmpl *template.Template
|
||||||
|
)
|
||||||
|
|
||||||
|
type pageTmplStruct struct {
|
||||||
|
Selected string
|
||||||
|
Blogs []blog.BlogListResult
|
||||||
|
Editor string
|
||||||
|
}
|
||||||
|
|
||||||
|
type formTmplStruct struct {
|
||||||
|
Blog blog.Blog
|
||||||
|
Result string
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupEditorTemplates() error {
|
||||||
|
var err error
|
||||||
|
pageTmpl, err = template.New("page").Parse(editorPage)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
formTmpl, err = template.New("form").Parse(editorForm)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func editorRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if verifyEditorCookie(r) == nil {
|
||||||
|
editorRedirect(w, r, "/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
blogs, err := blogApp.AllBlogsList(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error getting all blogs:", err)
|
||||||
|
sendContent(w, r, "ERROR", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
err = pageTmpl.Execute(buf, pageTmplStruct{Blogs: blogs})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error executing editor page template:", err)
|
||||||
|
sendContent(w, r, "ERROR", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendContent(w, r, buf.String(), "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func editorEdit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if verifyEditorCookie(r) == nil {
|
||||||
|
editorRedirect(w, r, "/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var bl *blog.Blog
|
||||||
|
blogID := r.URL.Query().Get("blog")
|
||||||
|
if blogID == "" {
|
||||||
|
sendContent(w, r, "<p>Select a blog!</p>", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if blogID == "new" {
|
||||||
|
bl = &blog.Blog{}
|
||||||
|
} else {
|
||||||
|
bl, err = blogApp.AnyBlog(r.Context(), blogID)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error getting blog for editor:", err)
|
||||||
|
log.Println(blogID)
|
||||||
|
sendContent(w, r, "ERROR", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
err = formTmpl.Execute(buf, formTmplStruct{Blog: *bl})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error executing editor template:", err)
|
||||||
|
sendContent(w, r, "ERROR", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendContent(w, r, buf.String(), "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func editorPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
usr := verifyEditorCookie(r)
|
||||||
|
if usr == nil {
|
||||||
|
editorRedirect(w, r, "/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if usr.Perm["blog"] != "admin" {
|
||||||
|
sendContent(w, r, "<p>You are not allowed to perform this action. Sorry, not sorry.</p>", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
sendContent(w, r, "<p>Bad request</p>", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newBlog := blog.Blog{
|
||||||
|
ID: r.FormValue("id"),
|
||||||
|
Title: r.FormValue("title"),
|
||||||
|
RawBlog: r.FormValue("blog"),
|
||||||
|
Draft: r.FormValue("draft") == "on",
|
||||||
|
StaticPage: r.FormValue("staticPage") == "on",
|
||||||
|
}
|
||||||
|
if newBlog.Title == "" || newBlog.RawBlog == "" {
|
||||||
|
sendContent(w, r, "<p>Title and Blog content required</p>", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if newBlog.ID == "" {
|
||||||
|
newBlog.ID = strings.TrimSpace(strings.ToLower(strings.ReplaceAll(newBlog.ID, " ", "-")))
|
||||||
|
if blogApp.Contains(r.Context(), newBlog.ID) {
|
||||||
|
sendContent(w, r, "<p>Title is not unique!</p>", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
newBlog.CreateTime = now.Unix()
|
||||||
|
newBlog.Author = usr.Username
|
||||||
|
err = blogApp.InsertBlog(r.Context(), newBlog)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error creating new blog ID:", err)
|
||||||
|
sendContent(w, r, "<p>Error inserting into DB</p>", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var blogs []blog.BlogListResult
|
||||||
|
blogs, err = blogApp.AllBlogsList(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error getting all blogs list:", err)
|
||||||
|
sendContent(w, r, "<p>Successfully save, but page reload failed</p>", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("HX-Retarget", "#content")
|
||||||
|
newForm := new(bytes.Buffer)
|
||||||
|
formTmpl.Execute(newForm, formTmplStruct{Blog: newBlog, Result: "<p>Successfully Created</p>"})
|
||||||
|
pageTmpl.Execute(w, pageTmplStruct{Selected: newBlog.ID, Blogs: blogs, Editor: newForm.String()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = blogApp.UpdateBlog(r.Context(), newBlog.ID,
|
||||||
|
bson.M{
|
||||||
|
"updateTime": time.Now().Unix(),
|
||||||
|
"title": newBlog.Title,
|
||||||
|
"blog": newBlog.RawBlog,
|
||||||
|
"draft": newBlog.Draft,
|
||||||
|
"staticPage": newBlog.StaticPage})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error updating blog:", err)
|
||||||
|
sendContent(w, r, "<p>Server error updating blog</p>", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
old, err := blogApp.AnyBlog(r.Context(), newBlog.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error getting old blog to be updated:", err)
|
||||||
|
sendContent(w, r, "<p>Updated!</p>", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if old.Title == newBlog.Title {
|
||||||
|
sendContent(w, r, "<p>Updated!</p>", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var blogs []blog.BlogListResult
|
||||||
|
blogs, err = blogApp.AllBlogsList(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error getting all blogs list:", err)
|
||||||
|
sendContent(w, r, "<p>Updated!</p>", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("HX-Retarget", "#content")
|
||||||
|
newForm := new(bytes.Buffer)
|
||||||
|
formTmpl.Execute(newForm, formTmplStruct{Blog: newBlog, Result: "<p>Successfully Created</p>"})
|
||||||
|
pageTmpl.Execute(w, pageTmplStruct{Selected: newBlog.ID, Blogs: blogs, Editor: newForm.String()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyEditorCookie(r *http.Request) *backend.User {
|
||||||
|
authCookie, err := r.Cookie("blogAuthToken")
|
||||||
|
if err != nil {
|
||||||
|
if err != http.ErrNoCookie {
|
||||||
|
log.Println("error getting auth cookie:", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
usr, err := back.VerifyUser(r.Context(), authCookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
if err != backend.ErrTokenUnauthorized {
|
||||||
|
log.Println("error authorizing JWT token:", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return usr
|
||||||
|
}
|
||||||
|
|
||||||
|
func editorRedirect(w http.ResponseWriter, r *http.Request, path string) {
|
||||||
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
|
w.Header().Set("HX-Location", `{"path": "`+path+`", "target":"#content"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "https://darkstorm.tech"+path, http.StatusFound)
|
||||||
|
}
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/tls"
|
|
||||||
"flag"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
smtp "github.com/emersion/go-smtp"
|
|
||||||
)
|
|
||||||
|
|
||||||
func startSMTPServer() {
|
|
||||||
keyPath := flag.Arg(1)
|
|
||||||
if keyPath == "" {
|
|
||||||
log.Println("No argument given for key files. smtp signing off...")
|
|
||||||
quitChan <- "smtp arg"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tlsCert, err := tls.LoadX509KeyPair(keyPath+"/fullchain.pem", keyPath+"/key.pem")
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while loading tls certificate:", err)
|
|
||||||
quitChan <- "smtp err"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg := &tls.Config{Certificates: []tls.Certificate{tlsCert}}
|
|
||||||
serv := smtp.NewServer(&SMTPBackend{
|
|
||||||
tlsConfig: cfg,
|
|
||||||
})
|
|
||||||
serv.Network = "tcp"
|
|
||||||
serv.Addr = ":587"
|
|
||||||
serv.TLSConfig = cfg
|
|
||||||
serv.Domain = "darkstorm.tech"
|
|
||||||
serv.WriteTimeout = 10 * time.Second
|
|
||||||
serv.ReadTimeout = 10 * time.Second
|
|
||||||
serv.MaxMessageBytes = 1024 * 1024
|
|
||||||
serv.AllowInsecureAuth = true
|
|
||||||
serv.MaxRecipients = 2
|
|
||||||
err = serv.ListenAndServeTLS()
|
|
||||||
log.Println("Error while serving smtp:", err)
|
|
||||||
quitChan <- "smtp err"
|
|
||||||
}
|
|
||||||
|
|
||||||
type SMTPBackend struct {
|
|
||||||
tlsConfig *tls.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *SMTPBackend) NewSession(c *smtp.Conn) (smtp.Session, error) {
|
|
||||||
return NewSession(b.tlsConfig, c.Server().Addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
type SMTPSession struct {
|
|
||||||
tlsConfig *tls.Config
|
|
||||||
client *smtp.Client
|
|
||||||
addr string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSession(tlsConfig *tls.Config, addr string) (*SMTPSession, error) {
|
|
||||||
client, err := smtp.DialTLS(addr, tlsConfig)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &SMTPSession{
|
|
||||||
tlsConfig: tlsConfig,
|
|
||||||
client: client,
|
|
||||||
addr: addr,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SMTPSession) Reset() {
|
|
||||||
var err error
|
|
||||||
s.client, err = smtp.DialTLS(s.addr, s.tlsConfig)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while resetting smtp session:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SMTPSession) Logout() error {
|
|
||||||
return s.client.Quit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SMTPSession) AuthPlain(username, password string) error {
|
|
||||||
//TODO
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SMTPSession) Mail(from string, opts *smtp.MailOptions) error {
|
|
||||||
return s.client.Mail(from, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SMTPSession) Rcpt(to string, opts *smtp.RcptOptions) error {
|
|
||||||
return s.client.Rcpt(to, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SMTPSession) Data(r io.Reader) error {
|
|
||||||
wrt, err := s.client.Data()
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while writing smtp data:", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = io.Copy(wrt, r)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while writing smtp data:", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
wrt.Close()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fileElement = "<div class='files-link'><a href='https://darkstorm.tech%v'>%v</a><div style='float:right;'>%v</div></div>"
|
||||||
|
|
||||||
|
func filesRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
partPath := filepath.Clean(r.URL.Path)
|
||||||
|
path := filepath.Join(*webRoot, partPath)
|
||||||
|
var pageContent string
|
||||||
|
fil, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
pageContent = "<p>404 Not Found</p>"
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
} else {
|
||||||
|
pageContent = "<p>Server error!</p>"
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
log.Println("error serving files:", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stat, _ := fil.Stat()
|
||||||
|
if stat.IsDir() {
|
||||||
|
var dirs []os.DirEntry
|
||||||
|
dirs, err = fil.ReadDir(-1)
|
||||||
|
slices.SortFunc(dirs, func(a, b os.DirEntry) int {
|
||||||
|
return strings.Compare(a.Name(), b.Name())
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pageContent = "<p>Server error!</p>"
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
log.Println("error serving files:", err)
|
||||||
|
}
|
||||||
|
for _, f := range dirs {
|
||||||
|
if f.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inf, _ := f.Info()
|
||||||
|
pageContent += fmt.Sprintf(fileElement, filepath.Join(partPath, f.Name()), f.Name(), inf.ModTime().Format(time.DateOnly))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
http.ServeFile(w, r, path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendContent(w, r, pageContent, "Files", "")
|
||||||
|
}
|
||||||
@@ -1,32 +1,27 @@
|
|||||||
module github.com/CalebQ42/darkstorm-server
|
module github.com/CalebQ42/darkstorm-server
|
||||||
|
|
||||||
go 1.22.2
|
go 1.23.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/1lann/udp-forward v0.0.0-20191015034046-6b774a53ea39
|
github.com/CalebQ42/bbConvert v1.0.2
|
||||||
github.com/CalebQ42/bbConvert v1.0.0
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/CalebQ42/cdr-backend v0.1.1
|
github.com/google/uuid v1.6.0
|
||||||
github.com/CalebQ42/stupid-backend/v2 v2.0.5
|
github.com/lithammer/shortuuid/v3 v3.0.7
|
||||||
github.com/CalebQ42/swassistant-backend v0.2.1
|
go.mongodb.org/mongo-driver v1.15.1
|
||||||
go.mongodb.org/mongo-driver v1.15.0
|
golang.org/x/crypto v0.24.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/emersion/go-smtp v0.21.1
|
github.com/dlclark/regexp2 v1.11.5-0.20240806004527-5bbbed8ea10b // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/go-cmp v0.6.0 // indirect
|
||||||
github.com/klauspost/compress v1.17.8 // indirect
|
github.com/klauspost/compress v1.13.6 // indirect
|
||||||
github.com/lithammer/shortuuid/v3 v3.0.7 // indirect
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
|
||||||
github.com/pascaldekloe/jwt v1.12.0 // indirect
|
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.1.2 // indirect
|
github.com/xdg-go/scram v1.1.2 // indirect
|
||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240424034433-3c2c7870ae76 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
|
||||||
golang.org/x/crypto v0.22.0 // indirect
|
|
||||||
golang.org/x/sync v0.7.0 // indirect
|
golang.org/x/sync v0.7.0 // indirect
|
||||||
golang.org/x/sys v0.19.0 // indirect
|
golang.org/x/sys v0.21.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.16.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,119 +1,63 @@
|
|||||||
github.com/1lann/udp-forward v0.0.0-20191015034046-6b774a53ea39 h1:wAEqazqaAqb7wwijTl14KruBl7cvYytD3SkhXT9v5zs=
|
github.com/CalebQ42/bbConvert v1.0.2 h1:N0+q7Kw3Ge2m117jbYp1DyrCa7zUuBOpyBfhcuphjz8=
|
||||||
github.com/1lann/udp-forward v0.0.0-20191015034046-6b774a53ea39/go.mod h1:zK6NTEHRcxPf9N4gcm0WXvE8RpsJfg/a8hMSW6dAQ0c=
|
github.com/CalebQ42/bbConvert v1.0.2/go.mod h1:gV0gaDhzuIwWqX9O1F8qu7tFXz50DnGQOnq56qrtO3A=
|
||||||
github.com/CalebQ42/bbConvert v1.0.0 h1:2WSAxVKhCCMReuU30r3ehLtL6m9aH8sY3wugp9yUdzg=
|
|
||||||
github.com/CalebQ42/bbConvert v1.0.0/go.mod h1:QJevnlhzUdL3EJB5Lgqoi7rdgtzt/UEamn/QGNoVgkM=
|
|
||||||
github.com/CalebQ42/cdr-backend v0.1.0 h1:9245aooAyLxAFO5gfXPgpqOHaYr6NAkMI75v2IlPOVA=
|
|
||||||
github.com/CalebQ42/cdr-backend v0.1.0/go.mod h1:N7A+ia+4GDsDMZ3gb5IRZ6CY07gdFfJECtR9csKh5nI=
|
|
||||||
github.com/CalebQ42/cdr-backend v0.1.1 h1:OwSVMODCPYMw3HpYTxqCAq9L1k0flLuPb5ICNv8qAn8=
|
|
||||||
github.com/CalebQ42/cdr-backend v0.1.1/go.mod h1:A0YjZk5xKAFXBNPdJ3HhsUgH37kY8Cxjt6isSU0o+Ok=
|
|
||||||
github.com/CalebQ42/stupid-backend/v2 v2.0.4 h1:ph75UDj5JevTiGyJbKgZsH3xyZZAGmXqfwSR6gLWoYQ=
|
|
||||||
github.com/CalebQ42/stupid-backend/v2 v2.0.4/go.mod h1:skBYIF77NzxYcqZ34V1eSD2/MIZCAGyyINWIkCBrpx8=
|
|
||||||
github.com/CalebQ42/stupid-backend/v2 v2.0.5 h1:l6lCzzAF0SCkGFLUiLMfWfBOP41uB/8Y3L50GuH2Npg=
|
|
||||||
github.com/CalebQ42/stupid-backend/v2 v2.0.5/go.mod h1:skBYIF77NzxYcqZ34V1eSD2/MIZCAGyyINWIkCBrpx8=
|
|
||||||
github.com/CalebQ42/swassistant-backend v0.2.0 h1:pXUG7+uHP5/lHaqqD6Hc64hOjfPzCGO8TkOpvtDB238=
|
|
||||||
github.com/CalebQ42/swassistant-backend v0.2.0/go.mod h1:m67UAzh552+puEc0LngTHzOL3b/Y2B19NzbaVdVeabI=
|
|
||||||
github.com/CalebQ42/swassistant-backend v0.2.1 h1:DqBx1pvPgMOE7LbzxvSoasH83FDgGLVv8JUhvA8CAZ0=
|
|
||||||
github.com/CalebQ42/swassistant-backend v0.2.1/go.mod h1:WY+3UvzBcTUoZMtYCVhNWfFW/Cx3lBActCJfk+EUD0s=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
github.com/dlclark/regexp2 v1.11.5-0.20240806004527-5bbbed8ea10b h1:AJKOdc+1fRSJ0/75Jty1npvxUUD0y7hQDg15LMAHhyU=
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/dlclark/regexp2 v1.11.5-0.20240806004527-5bbbed8ea10b/go.mod h1:YvCrhrh/qlds8EhFKPtJprdXn5fWBllSw1qo99dZyiQ=
|
||||||
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY=
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/emersion/go-smtp v0.20.2 h1:peX42Qnh5Q0q3vrAnRy43R/JwTnnv75AebxbkTL7Ia4=
|
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||||
github.com/emersion/go-smtp v0.20.2/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
|
||||||
github.com/emersion/go-smtp v0.21.1 h1:VQeZSZAKk8ueYii1yR5Zalmy7jI287eWDUqSaJ68vRM=
|
|
||||||
github.com/emersion/go-smtp v0.21.1/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
|
||||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
|
|
||||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
|
||||||
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc=
|
||||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
|
||||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
|
||||||
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
|
||||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
|
||||||
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
|
|
||||||
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
|
||||||
github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
|
github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
|
||||||
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
|
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
|
||||||
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0=
|
||||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
|
||||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
|
||||||
github.com/pascaldekloe/jwt v1.12.0 h1:imQSkPOtAIBAXoKKjL9ZVJuF/rVqJ+ntiLGpLyeqMUQ=
|
|
||||||
github.com/pascaldekloe/jwt v1.12.0/go.mod h1:LiIl7EwaglmH1hWThd/AmydNCnHf/mmfluBlNqHbk8U=
|
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a h1:fZHgsYlfvtyqToslyjUt3VOPF4J7aK/3MPcK7xp3PDk=
|
|
||||||
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a/go.mod h1:ul22v+Nro/R083muKhosV54bj5niojjWZvU8xrevuH4=
|
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240424034433-3c2c7870ae76 h1:tBiBTKHnIjovYoLX/TPkcf+OjqqKGQrPtGT3Foz+Pgo=
|
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240424034433-3c2c7870ae76/go.mod h1:SQliXeA7Dhkt//vS29v3zpbEwoa+zb2Cn5xj5uO4K5U=
|
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk=
|
go.mongodb.org/mongo-driver v1.15.1 h1:l+RvoUOoMXFmADTLfYDm7On9dRm7p4T80/lEQM+r7HU=
|
||||||
go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo=
|
go.mongodb.org/mongo-driver v1.15.1/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
|
||||||
go.mongodb.org/mongo-driver v1.15.0 h1:rJCKC8eEliewXjZGf0ddURtl7tTVy1TK3bfl0gkUSLc=
|
|
||||||
go.mongodb.org/mongo-driver v1.15.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
|
||||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
|
||||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
|
||||||
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
|
||||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
|
|
||||||
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
|
||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
|
||||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
|
||||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
# Darkstorm Backend
|
||||||
|
|
||||||
|
This is a purposefully "simple" application backend made specifically for _my_ apps. It's purpose is to collect minimal (only what's absolutely necessary) amounts of data while still fulfilling all my needs. I've found that other, off the shelf options such as Firebase are a bit heavy on the data collection. Plus I like to make things :P.
|
||||||
|
|
||||||
|
## DB Structure
|
||||||
|
|
||||||
|
### API Key
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
id: "API Key",
|
||||||
|
appID: "appID",
|
||||||
|
death: -1, // unix timestamp (seconds) when the key is no longer valid. -1 means there is not expected expiration (that can change in the future)
|
||||||
|
perm: {
|
||||||
|
user: true, // create and login users
|
||||||
|
count: true, // count users
|
||||||
|
crash: true, // crash reports
|
||||||
|
management: false, // managing
|
||||||
|
// further permissions can be added as needed
|
||||||
|
},
|
||||||
|
allowedOrigins:[
|
||||||
|
"http://foo.bar" // Request with this origin header is considered to be under this key.
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally you can set a special AppID to be a management key. Setting a management key enables management requests.
|
||||||
|
|
||||||
|
### Count log
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
id: "UUID",
|
||||||
|
platform: "android",
|
||||||
|
Date: 20240519 // YYYYMMDD as int
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### User
|
||||||
|
|
||||||
|
Users are stored per backend and not per app.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
id: "uuid",
|
||||||
|
username: "username",
|
||||||
|
password: "hashed password",
|
||||||
|
salt: "password salt",
|
||||||
|
email: "email",
|
||||||
|
fails: 0, // number of failed attemps in a row.
|
||||||
|
timeout: 0, // unix timestamp (seconds) when current timeout ends.
|
||||||
|
passwordChange: 0, // unix timestamp (seconds) of last password change
|
||||||
|
perm: {
|
||||||
|
appID: "user", // Optional. Apps should have a default permission level if thier appID is not in perm.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crash Reports
|
||||||
|
|
||||||
|
#### Individual Report
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
count: 1, // We do not store duplicates. If a duplicate does occur
|
||||||
|
platform: "android",
|
||||||
|
version: "v1.0.0", // Application version
|
||||||
|
error: "error",
|
||||||
|
stack: "stacktrace"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Crashes
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
id: "UUID",
|
||||||
|
error: "error",
|
||||||
|
firstLine: "first line of error",
|
||||||
|
individual: [
|
||||||
|
// Individual Crash Reports
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requests
|
||||||
|
|
||||||
|
### Standard Header
|
||||||
|
|
||||||
|
Any request might or might not need these headers. These values can be authenticated via the `ParseHeader` function.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
X-API-Key: "{API Key}",
|
||||||
|
Authorization: "Bearer {JWT Token}" // No built-in functions require a JWT Token, but may be required by specific implementations.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Response
|
||||||
|
|
||||||
|
If an error status code is returned then the body will be as follows.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
errorCode: "Error value for internal use",
|
||||||
|
errorMsg: "User error message", //This message is meant to be displayed to the user. May be empty.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`errorCode`'s returned from the main library:
|
||||||
|
|
||||||
|
* misconfigured
|
||||||
|
* Backend is configured incorrectly (such as App returning nil crash table, but key has crash permission)
|
||||||
|
* invalidKey
|
||||||
|
* API Key is invalid or does not have the needed permission for the request.
|
||||||
|
* invalidBody
|
||||||
|
* Body of the request is malformed.
|
||||||
|
* unauthorized
|
||||||
|
* User is not authorized for the given task or no user token is given.
|
||||||
|
* badRequest
|
||||||
|
* Some part of your request is invalid
|
||||||
|
* internal
|
||||||
|
* Server-side issue.
|
||||||
|
|
||||||
|
### Count
|
||||||
|
|
||||||
|
API Key must have the `count` permission.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
> POST: /count
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
id: "uuid", // Should be an empty string on first request. If invalid or too old, a new UUID will be returned.
|
||||||
|
platform: "web"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
id: "uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Count
|
||||||
|
|
||||||
|
Get a count of users.
|
||||||
|
|
||||||
|
API Key must have the `management` permission.
|
||||||
|
|
||||||
|
`platform` query is optional (defaults to all).
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
> GET: /count?platform=all
|
||||||
|
|
||||||
|
With management key:
|
||||||
|
|
||||||
|
> GET: /{appID}/count?platform=all
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
count: 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
> TODO: Add the ability to create users and log-in through third-parties (such as Google).
|
||||||
|
|
||||||
|
All requsests pertaining to users requires the `X-API-Key` header and the key must have the `users` permission.
|
||||||
|
|
||||||
|
Enabled by using `Backend.AddUserAuth`.
|
||||||
|
|
||||||
|
#### Create User
|
||||||
|
|
||||||
|
> TODO: Email user to confirm.
|
||||||
|
>
|
||||||
|
> TODO: Screen username for offensive words and phrases.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
> POST: /user/create
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
username: "Username",
|
||||||
|
password: "Password", // Allowed length: 12-128
|
||||||
|
email: "Email",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
username: "Username",
|
||||||
|
token: "JWT Token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If returned status is 401, the errorCode will be one of the following:
|
||||||
|
|
||||||
|
* taken
|
||||||
|
* Username or email is already taken
|
||||||
|
* usernameDisallowed
|
||||||
|
* Username is not allowed (due to offensive words/phrases)
|
||||||
|
* password
|
||||||
|
* Password is to short or too long.
|
||||||
|
|
||||||
|
#### Delete User
|
||||||
|
|
||||||
|
Requires either the `management` permission or a management key.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
> DELETE: /user/{userID}
|
||||||
|
|
||||||
|
#### Login
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
> POST: /user/login
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
username: "Username",
|
||||||
|
password: "Password",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
token: "JWT Token",
|
||||||
|
error: "Error",
|
||||||
|
timeout: 0, // login attempt timeout remaining (in seconds). If non-zero, token will be empty.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`token` and `error` are mutually exclusive.
|
||||||
|
|
||||||
|
Possible `error` values:
|
||||||
|
|
||||||
|
* timeout
|
||||||
|
* Account is currently timed-out. The `timeout` value will be non-zero.
|
||||||
|
* invalid
|
||||||
|
* Either the username or password is incorrect
|
||||||
|
|
||||||
|
#### Change Password
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
> POST: /user/changepassword
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
token: "JWT Token",
|
||||||
|
old: "Old Password",
|
||||||
|
new: "New Password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crash Report
|
||||||
|
|
||||||
|
#### Report
|
||||||
|
|
||||||
|
API Key must have the `crash` permission.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
> POST: /crash
|
||||||
|
|
||||||
|
Request Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
id: "UUID", // This is an ignored value, but it is highly recommended to include it to prevent reporting the same crash multiple times.
|
||||||
|
platform: "android",
|
||||||
|
appVersion: "v1.0.0",
|
||||||
|
error: "error",
|
||||||
|
stack: "stacktrace"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Delete
|
||||||
|
|
||||||
|
API Key must have the `management` permission.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
> DELETE: /crash/{crashID}
|
||||||
|
|
||||||
|
With management key:
|
||||||
|
|
||||||
|
> DELETE: /{appID}/crash/{crashID}
|
||||||
|
|
||||||
|
#### Archive
|
||||||
|
|
||||||
|
Archive an error, preventing error with these values to be ignored in the future. API Key must have the `management` permission.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
> POST: /crash/archive
|
||||||
|
|
||||||
|
With management key:
|
||||||
|
|
||||||
|
> POST: /{appID}/crash/archive
|
||||||
|
|
||||||
|
Request Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
error: "error",
|
||||||
|
stack: "full stacktrace", // Archives will only match against a perfect match.
|
||||||
|
platform: "all", // Limit the archive to a specific platform, or use "all".
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An application interface. Both LogTable and CrashTable are optional, if they return nil then requests will be forbidden.
|
||||||
|
type App interface {
|
||||||
|
AppID() string
|
||||||
|
CountTable() CountTable
|
||||||
|
CrashTable() CrashTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provides an App access to it's parent *Backend. This is called only once, while setting up the Backend.
|
||||||
|
type CallbackApp interface {
|
||||||
|
App
|
||||||
|
AddBackend(*Backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allows for an App to filter crashes before they get added to the DB, such as making sure the crash is from the correct version.
|
||||||
|
type CrashFilterApp interface {
|
||||||
|
App
|
||||||
|
ShouldAddCrash(context.Context, IndividualCrash) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allows an app more flexibility by directly interfacing with the backend's mux
|
||||||
|
type ExtendedApp interface {
|
||||||
|
App
|
||||||
|
Extension(*http.ServeMux)
|
||||||
|
}
|
||||||
|
|
||||||
|
type simpleApp struct {
|
||||||
|
countTab CountTable
|
||||||
|
crashTab CrashTable
|
||||||
|
appID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSimpleApp(appID string, countTable CountTable, crashTable CrashTable) App {
|
||||||
|
return &simpleApp{
|
||||||
|
appID: appID,
|
||||||
|
countTab: countTable,
|
||||||
|
crashTab: crashTable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *simpleApp) AppID() string {
|
||||||
|
return s.appID
|
||||||
|
}
|
||||||
|
func (s *simpleApp) CountTable() CountTable {
|
||||||
|
return s.countTab
|
||||||
|
}
|
||||||
|
func (s *simpleApp) CrashTable() CrashTable {
|
||||||
|
return s.crashTab
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CountLog struct {
|
||||||
|
ID string `json:"id" bson:"_id"`
|
||||||
|
Platform string `json:"platform" bson:"platform"`
|
||||||
|
Date int `json:"date" bson:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CountLog) GetID() string {
|
||||||
|
return c.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
type countLogReq struct {
|
||||||
|
ID string
|
||||||
|
Platform string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) countLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := b.VerifyHeader(w, r, "count", false)
|
||||||
|
if hdr == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Println("request key parsing error:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
var req countLogReq
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if err != nil || req.Platform == "" {
|
||||||
|
if r.URL.Query().Get("platform") != "" {
|
||||||
|
//TODO: remove legacy code
|
||||||
|
req.Platform = r.URL.Query().Get("platform")
|
||||||
|
req.ID = r.URL.Query().Get("id")
|
||||||
|
} else {
|
||||||
|
ReturnError(w, http.StatusBadRequest, "invalidBody", "Bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ap := b.GetApp(hdr.Key)
|
||||||
|
count := ap.CountTable()
|
||||||
|
if count == nil {
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "misconfigured", "Server Misconfigured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
curDate := getDate(time.Now())
|
||||||
|
if req.ID == "" {
|
||||||
|
err = addToCountTable(r.Context(), w, count, req.Platform, curDate)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error adding to count table:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l, err := count.Get(r.Context(), req.ID)
|
||||||
|
if err == ErrNotFound {
|
||||||
|
err = addToCountTable(r.Context(), w, count, req.Platform, curDate)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error adding to count table:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if l.Date >= curDate {
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"id": req.ID})
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = count.PartUpdate(r.Context(), req.ID, map[string]any{"date": curDate})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error updating count log:", err)
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"id": req.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func addToCountTable(ctx context.Context, w http.ResponseWriter, c CountTable, platform string, curDate int) error {
|
||||||
|
id, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error generating new log UUID:", err)
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = c.Insert(ctx, CountLog{
|
||||||
|
ID: id.String(),
|
||||||
|
Platform: platform,
|
||||||
|
Date: curDate,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error inserting new count log:", err)
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"id": id.String()})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) getCount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := b.VerifyHeader(w, r, "management", true)
|
||||||
|
if hdr == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Println("request key parsing error:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ap App
|
||||||
|
if hdr.Key.AppID == b.managementKeyID {
|
||||||
|
ap = b.apps[r.PathValue("appID")]
|
||||||
|
if ap == nil {
|
||||||
|
ReturnError(w, http.StatusBadRequest, "badRequest", "Bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ap = b.GetApp(hdr.Key)
|
||||||
|
}
|
||||||
|
count := ap.CountTable()
|
||||||
|
if count == nil {
|
||||||
|
ReturnError(w, http.StatusBadRequest, "badRequest", "Trying to get user count on app that doesn't have a count table")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := count.Count(r.Context(), r.URL.Query().Get("platform"))
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error getting count:", err)
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]int{"count": out})
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ArchivedCrash struct {
|
||||||
|
Error string `json:"error" bson:"error"`
|
||||||
|
Stack string `json:"stack" bson:"stack"`
|
||||||
|
Platform string `json:"platform" bson:"platform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IndividualCrash struct {
|
||||||
|
Platform string `json:"platform" bson:"platform"`
|
||||||
|
Version string `json:"version" bson:"version"`
|
||||||
|
Error string `json:"error" bson:"error"`
|
||||||
|
Stack string `json:"stack" bson:"stack"`
|
||||||
|
Count int `json:"count" bson:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CrashReport struct {
|
||||||
|
ID string `json:"id" bson:"_id"`
|
||||||
|
Error string `json:"error" bson:"error"`
|
||||||
|
FirstLine string `json:"firstLine" bson:"firstLine"`
|
||||||
|
Individual []IndividualCrash `json:"individual" bson:"individual"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CrashReport) GetID() string {
|
||||||
|
return c.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) reportCrash(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := b.VerifyHeader(w, r, "crash", false)
|
||||||
|
if hdr == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Println("request key parsing error:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ap := b.GetApp(hdr.Key)
|
||||||
|
defer r.Body.Close()
|
||||||
|
var crash IndividualCrash
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&crash)
|
||||||
|
if err != nil || crash.Platform == "" || crash.Version == "" || crash.Error == "" || crash.Stack == "" {
|
||||||
|
ReturnError(w, http.StatusBadRequest, "invalidBody", "Bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if filter, ok := ap.(CrashFilterApp); ok {
|
||||||
|
if !filter.ShouldAddCrash(r.Context(), crash) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tab := ap.CrashTable()
|
||||||
|
if tab == nil {
|
||||||
|
log.Printf("key %v has crash permission, but app does not have a crash table", hdr.Key.AppID)
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "misconfigured", "Server misconfigured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !tab.IsArchived(r.Context(), crash) {
|
||||||
|
err = tab.InsertCrash(r.Context(), crash)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("crash insertion error:", err)
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) deleteCrash(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := b.VerifyHeader(w, r, "management", false)
|
||||||
|
if hdr == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Println("request key parsing error:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
crashID := r.PathValue("crashID")
|
||||||
|
if crashID == "" {
|
||||||
|
ReturnError(w, http.StatusBadRequest, "badRequest", "Bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.actualCrashDelete(r.Context(), w, b.GetApp(hdr.Key), crashID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) managementDeleteCrash(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := b.VerifyHeader(w, r, "management", true)
|
||||||
|
if hdr == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Println("request key parsing error:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
crashID := r.PathValue("crashID")
|
||||||
|
if crashID == "" {
|
||||||
|
ReturnError(w, http.StatusBadRequest, "badRequest", "Bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appID := r.PathValue("appID")
|
||||||
|
ap := b.apps[appID]
|
||||||
|
if ap == nil || appID == "" {
|
||||||
|
ReturnError(w, http.StatusBadRequest, "badRequest", "Bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.actualCrashDelete(r.Context(), w, ap, crashID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) actualCrashDelete(ctx context.Context, w http.ResponseWriter, ap App, crashID string) {
|
||||||
|
crash := ap.CrashTable()
|
||||||
|
if crash == nil {
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "misconfigured", "Server Misconfigured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := crash.Remove(ctx, crashID)
|
||||||
|
if err != nil && err != ErrNotFound {
|
||||||
|
log.Println("error when deleting crash:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) archiveCrash(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := b.VerifyHeader(w, r, "management", false)
|
||||||
|
if hdr == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Println("request key parsing error:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
var toArchive ArchivedCrash
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&toArchive)
|
||||||
|
if err != nil || toArchive.Platform == "" || toArchive.Error == "" || toArchive.Stack == "" {
|
||||||
|
ReturnError(w, http.StatusBadRequest, "invalidBody", "Bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.actualCrashArchive(r.Context(), w, b.GetApp(hdr.Key), toArchive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) managementArchiveCrash(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := b.VerifyHeader(w, r, "management", true)
|
||||||
|
if hdr == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Println("request key parsing error:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appID := r.PathValue("appID")
|
||||||
|
ap := b.apps[appID]
|
||||||
|
if ap == nil || appID == "" {
|
||||||
|
ReturnError(w, http.StatusBadRequest, "badRequest", "Bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
var toArchive ArchivedCrash
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&toArchive)
|
||||||
|
if err != nil || toArchive.Platform == "" || toArchive.Error == "" || toArchive.Stack == "" {
|
||||||
|
ReturnError(w, http.StatusBadRequest, "invalidBody", "Bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.actualCrashArchive(r.Context(), w, ap, toArchive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) actualCrashArchive(ctx context.Context, w http.ResponseWriter, ap App, toArchive ArchivedCrash) {
|
||||||
|
crash := ap.CrashTable()
|
||||||
|
if crash == nil {
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "misconfigured", "Server Misconfigured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := crash.Archive(ctx, toArchive)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error archive crash:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
first, _, _ := strings.Cut(toArchive.Stack, "\n")
|
||||||
|
crashes, err := crash.Find(ctx, map[string]any{"error": toArchive.Error, "firstLine": first})
|
||||||
|
if err == ErrNotFound {
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
log.Println("error finding matching crashes:", err)
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range crashes {
|
||||||
|
ogLen := len(c.Individual)
|
||||||
|
for i := 0; i < len(c.Individual); i++ {
|
||||||
|
ind := c.Individual[i]
|
||||||
|
if ind.Stack == toArchive.Stack {
|
||||||
|
if toArchive.Platform == "all" || toArchive.Platform == ind.Platform {
|
||||||
|
c.Individual = append(c.Individual[:i], c.Individual[i+1:]...)
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(c.Individual) == 0 {
|
||||||
|
err = crash.Remove(ctx, c.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error removing empty crash report:", err)
|
||||||
|
}
|
||||||
|
} else if len(c.Individual) < ogLen {
|
||||||
|
err = crash.PartUpdate(ctx, c.ID, map[string]any{"individual": c.Individual})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error updating individual crash reports:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package backend_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStuff(t *testing.T) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("no matches found in table")
|
||||||
|
)
|
||||||
|
|
||||||
|
type IDStruct interface {
|
||||||
|
GetID() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Table[T IDStruct] interface {
|
||||||
|
Get(ctx context.Context, ID string) (data T, err error)
|
||||||
|
Find(ctx context.Context, values map[string]any) ([]T, error)
|
||||||
|
Insert(ctx context.Context, data T) error
|
||||||
|
Remove(ctx context.Context, ID string) error
|
||||||
|
FullUpdate(ctx context.Context, ID string, data T) error
|
||||||
|
PartUpdate(ctx context.Context, ID string, update map[string]any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type CountTable interface {
|
||||||
|
Table[CountLog]
|
||||||
|
// Remove all Log items that have a CountLog.Date value less then the given value.
|
||||||
|
RemoveOldLogs(ctx context.Context, date int) error
|
||||||
|
// Get count. If platform is an empty string or "all", the full count should be given
|
||||||
|
Count(ctx context.Context, platform string) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CrashTable interface {
|
||||||
|
Table[CrashReport]
|
||||||
|
// Move a crash type to archive. Crashes that match the archived crash will be automatically removed from the CrashTable.
|
||||||
|
Archive(context.Context, ArchivedCrash) error
|
||||||
|
IsArchived(context.Context, IndividualCrash) bool
|
||||||
|
// Add the IndividualCrash report to the crash table. If a CrashReport exists that matches, then it gets added to CrashReport.Individual.
|
||||||
|
// If an IndividualCrash exists that is a perfect match, Count is incremented instead of adding it to the array.
|
||||||
|
InsertCrash(context.Context, IndividualCrash) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MongoTable[T backend.IDStruct] struct {
|
||||||
|
col *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMongoTable[T backend.IDStruct](col *mongo.Collection) *MongoTable[T] {
|
||||||
|
return &MongoTable[T]{
|
||||||
|
col: col,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoTable[T]) Get(ctx context.Context, ID string) (data T, err error) {
|
||||||
|
res := m.col.FindOne(ctx, bson.M{"_id": ID})
|
||||||
|
if res.Err() == mongo.ErrNoDocuments {
|
||||||
|
return data, backend.ErrNotFound
|
||||||
|
} else if res.Err() != nil {
|
||||||
|
return data, res.Err()
|
||||||
|
}
|
||||||
|
var out T
|
||||||
|
err = res.Decode(&out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoTable[T]) Find(ctx context.Context, values map[string]any) ([]T, error) {
|
||||||
|
res, err := m.col.Find(ctx, values)
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, backend.ErrNotFound
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []T
|
||||||
|
err = res.All(ctx, &out)
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, backend.ErrNotFound
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoTable[T]) Insert(ctx context.Context, data T) error {
|
||||||
|
_, err := m.col.InsertOne(ctx, data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoTable[T]) Remove(ctx context.Context, ID string) error {
|
||||||
|
res := m.col.FindOneAndDelete(ctx, bson.M{"_id": ID})
|
||||||
|
return res.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoTable[T]) FullUpdate(ctx context.Context, ID string, data T) error {
|
||||||
|
res := m.col.FindOneAndReplace(ctx, bson.M{"_id": ID}, data)
|
||||||
|
if res.Err() == mongo.ErrNoDocuments {
|
||||||
|
return backend.ErrNotFound
|
||||||
|
}
|
||||||
|
return res.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoTable[T]) PartUpdate(ctx context.Context, ID string, update map[string]any) error {
|
||||||
|
res := m.col.FindOneAndUpdate(ctx, bson.M{"_id": ID}, bson.M{"$set": update})
|
||||||
|
if res.Err() == mongo.ErrNoDocuments {
|
||||||
|
return backend.ErrNotFound
|
||||||
|
}
|
||||||
|
return res.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoTable[CountLog]) RemoveOldLogs(ctx context.Context, date int) error {
|
||||||
|
_, err := m.col.DeleteMany(ctx, bson.M{"date": bson.M{"$lt": date}})
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
func (m *MongoTable[CountLog]) Count(ctx context.Context, platform string) (int, error) {
|
||||||
|
var filter bson.M
|
||||||
|
if platform == "" || platform == "all" {
|
||||||
|
filter = bson.M{}
|
||||||
|
} else {
|
||||||
|
filter = bson.M{"platform": platform}
|
||||||
|
}
|
||||||
|
out, err := m.col.CountDocuments(ctx, filter)
|
||||||
|
return int(out), err
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MongoCrashTable struct {
|
||||||
|
*MongoTable[backend.CrashReport]
|
||||||
|
archiveCol *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMongoCrashTable(crashCol *mongo.Collection, archiveCol *mongo.Collection) *MongoCrashTable {
|
||||||
|
return &MongoCrashTable{
|
||||||
|
MongoTable: NewMongoTable[backend.CrashReport](crashCol),
|
||||||
|
archiveCol: archiveCol,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoCrashTable) Archive(ctx context.Context, toArchive backend.ArchivedCrash) error {
|
||||||
|
if toArchive.Platform == "" {
|
||||||
|
toArchive.Platform = "all"
|
||||||
|
}
|
||||||
|
_, err := m.archiveCol.InsertOne(ctx, toArchive)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoCrashTable) IsArchived(ctx context.Context, ind backend.IndividualCrash) bool {
|
||||||
|
res := m.archiveCol.FindOne(ctx,
|
||||||
|
bson.M{"error": ind.Error, "stack": ind.Stack, "platform": bson.M{"$in": []string{ind.Platform, "all"}}},
|
||||||
|
)
|
||||||
|
return res.Err() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoCrashTable) InsertCrash(ctx context.Context, ind backend.IndividualCrash) error {
|
||||||
|
first, _, _ := strings.Cut(ind.Stack, "\n")
|
||||||
|
res, err := m.col.UpdateOne(ctx,
|
||||||
|
bson.M{"error": ind.Error, "firstLine": first, //filter main report
|
||||||
|
"individual": bson.M{"$elemMatch": bson.M{"stack": ind.Stack, "platform": ind.Platform}}}, //filter individual
|
||||||
|
bson.M{"$inc": bson.M{"individual.$.count": 1}}, //increment count
|
||||||
|
)
|
||||||
|
if err != nil && err != mongo.ErrNoDocuments {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err == mongo.ErrNoDocuments || res.MatchedCount == 0 {
|
||||||
|
ind.Count = 1
|
||||||
|
res, err = m.col.UpdateMany(ctx,
|
||||||
|
bson.M{"error": ind.Error, "firstLine": first}, //filter
|
||||||
|
bson.M{"$push": bson.M{"individual": ind}}, //Add new individual report
|
||||||
|
)
|
||||||
|
if err != nil && err != mongo.ErrNoDocuments {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err == mongo.ErrNoDocuments || res.MatchedCount == 0 {
|
||||||
|
var id uuid.UUID
|
||||||
|
id, err = uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ind.Count = 1
|
||||||
|
_, err = m.col.InsertOne(ctx,
|
||||||
|
backend.CrashReport{
|
||||||
|
ID: id.String(),
|
||||||
|
Error: ind.Error,
|
||||||
|
FirstLine: first,
|
||||||
|
Individual: []backend.IndividualCrash{ind},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
/*
|
||||||
|
TODO
|
||||||
|
I don't like SQL, lol.
|
||||||
|
*/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
/*
|
||||||
|
TODO
|
||||||
|
Currently there isn't a easy and clean way to implement this (as far as I can tell).
|
||||||
|
valkey-go relies on an internal library for it's command builder, which makes it impossible to
|
||||||
|
use properly for generics without manually writing out the Index command. I could probably do this, but
|
||||||
|
it's a pain.
|
||||||
|
valkey-go does have a Generic Object Mapping library (valkey-go/om), but it requires a Version field
|
||||||
|
on every struct which would be confusing if I did add it to all my structs and Go doesn't allow anonymous generics inside structs
|
||||||
|
*/
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrApiKeyUnauthorized = errors.New("api key present but invalid")
|
||||||
|
ErrTokenUnauthorized = errors.New("token present but invalid")
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApiKey struct {
|
||||||
|
Perm map[string]bool `json:"perm" bson:"perm"`
|
||||||
|
ID string `json:"id" bson:"_id" valkey:",key"`
|
||||||
|
AppID string `json:"appID" bson:"appID"`
|
||||||
|
Death int64 `json:"death" bson:"death"`
|
||||||
|
AllowedOrigins []string `json:"allowedOrigins" bson:"allowedOrigins"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k ApiKey) GetID() string {
|
||||||
|
return k.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedHeader struct {
|
||||||
|
User *ReqestUser
|
||||||
|
Key *ApiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses the X-API-Key and Authorization headers. If the API Key provided but invalid (either due to expiring or isn't found), ErrApiKeyUnauthorized is returned.
|
||||||
|
// If the Authorization header is present but invalid, ErrTokenUnauthorized is returned.
|
||||||
|
// NOTE: An invalid apiKey will cause a nil return, but a invalid token will not. Token parsing is only
|
||||||
|
func (b *Backend) ParseHeader(r *http.Request) (*ParsedHeader, error) {
|
||||||
|
out := &ParsedHeader{}
|
||||||
|
key := r.Header.Get("X-API-Key")
|
||||||
|
|
||||||
|
if key != "" {
|
||||||
|
apiKey, err := b.keyTable.Get(r.Context(), key)
|
||||||
|
if err == ErrNotFound {
|
||||||
|
return nil, ErrApiKeyUnauthorized
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if apiKey.Death > 0 && time.Unix(apiKey.Death, 0).Before(time.Now()) {
|
||||||
|
return nil, ErrApiKeyUnauthorized
|
||||||
|
}
|
||||||
|
out.Key = &apiKey
|
||||||
|
} else {
|
||||||
|
fmt.Println("origin:", r.Header.Get("origin"))
|
||||||
|
keys, err := b.keyTable.Find(r.Context(), map[string]any{"allowedOrigins": r.Header.Get("origin")})
|
||||||
|
if err == ErrNotFound {
|
||||||
|
return nil, ErrApiKeyUnauthorized
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if keys[0].Death > 0 && time.Unix(keys[0].Death, 0).Before(time.Now()) {
|
||||||
|
return nil, ErrApiKeyUnauthorized
|
||||||
|
}
|
||||||
|
out.Key = &keys[0]
|
||||||
|
}
|
||||||
|
if b.userTable == nil || r.Header.Get("Authorization") == "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
if token == "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
usr, err := b.VerifyUser(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.User = usr.ToReqUser()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Similiar to ParseHeader, but with key checking and automatic error returns. Guarentess Backend.GetApp is non-nil
|
||||||
|
// Checks that the key is a management key (not management permission and if allowManagement is true) or that it has the necessary permission.
|
||||||
|
// If the check if failed, ReturnError will be called and the returned *ParsedHeader will be nil.
|
||||||
|
// If token is present but invalid, no error will be returned just ParsedHeader.User will be nil.
|
||||||
|
// The error return will only be populated on "internal" errors and should *probably* be logged.
|
||||||
|
//
|
||||||
|
// This function does not check the Key's appID so after calling VerifyHeader it's recommended to check the Key's appID.
|
||||||
|
func (b *Backend) VerifyHeader(w http.ResponseWriter, r *http.Request, keyPerm string, allowManagementKey bool) (*ParsedHeader, error) {
|
||||||
|
hdr, err := b.ParseHeader(r)
|
||||||
|
if hdr == nil || hdr.Key == nil {
|
||||||
|
if err == ErrApiKeyUnauthorized {
|
||||||
|
ReturnError(w, http.StatusUnauthorized, "invalidKey", "Application not authorized")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
ReturnError(w, http.StatusUnauthorized, "noKey", "No API Key provided")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, ErrTokenUnauthorized) {
|
||||||
|
log.Println("error parsing header:", err)
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if hdr.Key.AppID == b.managementKeyID {
|
||||||
|
if allowManagementKey {
|
||||||
|
return hdr, nil
|
||||||
|
} else {
|
||||||
|
ReturnError(w, http.StatusUnauthorized, "invalidKey", "Application not authorized")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := b.apps[hdr.Key.AppID]; !ok {
|
||||||
|
ReturnError(w, http.StatusUnauthorized, "invalidKey", "Application not authorized")
|
||||||
|
return nil, errors.New("server misconfigured, appID present in DB, but App not added to backend")
|
||||||
|
}
|
||||||
|
return hdr, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed robots.txt
|
||||||
|
var robotEmbed embed.FS
|
||||||
|
|
||||||
|
// A simple backend that handles user authentication, user count, and crash reports.
|
||||||
|
type Backend struct {
|
||||||
|
userTable Table[User]
|
||||||
|
keyTable Table[ApiKey]
|
||||||
|
m *http.ServeMux
|
||||||
|
apps map[string]App
|
||||||
|
managementKeyID string
|
||||||
|
corsAddr string
|
||||||
|
jwtPriv ed25519.PrivateKey
|
||||||
|
jwtPub ed25519.PublicKey
|
||||||
|
userCreateMutex sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new Backend with the given apps. keyTable must be specified.
|
||||||
|
func NewBackend(keyTable Table[ApiKey], apps ...App) (*Backend, error) {
|
||||||
|
b := &Backend{
|
||||||
|
keyTable: keyTable,
|
||||||
|
m: &http.ServeMux{},
|
||||||
|
apps: make(map[string]App),
|
||||||
|
userCreateMutex: sync.Mutex{},
|
||||||
|
}
|
||||||
|
b.m.Handle("GET /robots.txt", http.FileServerFS(robotEmbed))
|
||||||
|
var hasLog, hasCrash bool
|
||||||
|
for i := range apps {
|
||||||
|
_, has := b.apps[apps[i].AppID()]
|
||||||
|
if has {
|
||||||
|
return nil, errors.New("duplicate AppIDs found")
|
||||||
|
}
|
||||||
|
b.apps[apps[i].AppID()] = apps[i]
|
||||||
|
if ext, is := apps[i].(ExtendedApp); is {
|
||||||
|
ext.Extension(b.m)
|
||||||
|
}
|
||||||
|
if back, is := apps[i].(CallbackApp); is {
|
||||||
|
back.AddBackend(b)
|
||||||
|
}
|
||||||
|
if !hasLog && apps[i].CountTable() != nil {
|
||||||
|
hasLog = true
|
||||||
|
}
|
||||||
|
if !hasCrash && apps[i].CrashTable() != nil {
|
||||||
|
hasCrash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasLog {
|
||||||
|
b.m.HandleFunc("POST /count", b.countLog)
|
||||||
|
b.m.HandleFunc("GET /count", b.getCount)
|
||||||
|
|
||||||
|
//TODO: Remove legacy paths
|
||||||
|
b.m.HandleFunc("POST /log", b.countLog)
|
||||||
|
}
|
||||||
|
if hasCrash {
|
||||||
|
b.m.HandleFunc("POST /crash", b.reportCrash)
|
||||||
|
b.m.HandleFunc("DELETE /crash/{crashID}", b.deleteCrash)
|
||||||
|
b.m.HandleFunc("POST /crash/archive", b.archiveCrash)
|
||||||
|
}
|
||||||
|
b.m.HandleFunc("OPTIONS /", func(_ http.ResponseWriter, _ *http.Request) {}) //Here to send just CORS data.
|
||||||
|
go b.cleanupLoop()
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) cleanupLoop() {
|
||||||
|
for range time.Tick(24 * time.Hour) {
|
||||||
|
old := getDate(time.Now().Add(-30 * 24 * time.Hour))
|
||||||
|
var err error
|
||||||
|
for _, a := range b.apps {
|
||||||
|
log.Printf("Removing logs for %v", a.AppID())
|
||||||
|
tab := a.CountTable()
|
||||||
|
if tab == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err = tab.RemoveOldLogs(context.Background(), old)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error removing old logs for %v: %v\n", a.AppID(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable CORS for with the given cors address
|
||||||
|
func (b *Backend) AddCorsAddress(corsAddr string) {
|
||||||
|
b.corsAddr = corsAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// http.Handler
|
||||||
|
func (b *Backend) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if b.corsAddr != "" {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", b.corsAddr)
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "*, Authorization")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.m.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDate(t time.Time) int {
|
||||||
|
return (t.Year() * 10000) + (int(t.Month()) * 100) + t.Day()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enables the use of a management API key for crash and count.
|
||||||
|
func (b *Backend) EnableManagementKey(managementID string) {
|
||||||
|
b.managementKeyID = managementID
|
||||||
|
b.m.HandleFunc("DELETE /{appID}/crash/{crashID}", b.managementDeleteCrash)
|
||||||
|
b.m.HandleFunc("POST /{appID}/crash/archive", b.managementArchiveCrash)
|
||||||
|
b.m.HandleFunc("GET /{appID}/count", b.getCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enables user creation and authentication.
|
||||||
|
func (b *Backend) AddUserAuth(userTable Table[User], privKey, pubKey []byte) {
|
||||||
|
b.userTable = userTable
|
||||||
|
b.jwtPriv = privKey
|
||||||
|
b.jwtPub = pubKey
|
||||||
|
b.m.HandleFunc("POST /user/create", b.createUser)
|
||||||
|
b.m.HandleFunc("DELETE /user/{userID}", b.deleteUser)
|
||||||
|
b.m.HandleFunc("POST /user/login", b.login)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add values to the Backend's underlying ServeMux
|
||||||
|
func (b *Backend) HandleFunc(pattern string, h http.HandlerFunc) {
|
||||||
|
b.m.HandleFunc(pattern, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get the App associated with the given ApiKey. Returns nil if not found.
|
||||||
|
func (b *Backend) GetApp(a *ApiKey) App {
|
||||||
|
return b.apps[a.AppID]
|
||||||
|
}
|
||||||
|
|
||||||
|
type retError struct {
|
||||||
|
ErrorCode string `json:"errorCode"`
|
||||||
|
ErrorMsg string `json:"errorMsg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return an error response with the given status code, code, and message.
|
||||||
|
func ReturnError(w http.ResponseWriter, status int, code, msg string) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(retError{
|
||||||
|
ErrorCode: code,
|
||||||
|
ErrorMsg: msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrPasswordLength = errors.New("password length must be 12-128")
|
||||||
|
)
|
||||||
|
|
||||||
|
func generateSalt() (string, error) {
|
||||||
|
out := make([]byte, 16)
|
||||||
|
_, err := rand.Read(out)
|
||||||
|
return base64.RawStdEncoding.EncodeToString(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReqestUser struct {
|
||||||
|
Perm map[string]string
|
||||||
|
ID string
|
||||||
|
Username string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GenerateJWT(r *ReqestUser) (string, error) {
|
||||||
|
if b.jwtPriv == nil || b.jwtPub == nil {
|
||||||
|
return "", errors.New("user management not enabled")
|
||||||
|
}
|
||||||
|
return jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.RegisteredClaims{
|
||||||
|
Issuer: "darkstorm.tech",
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(12 * time.Hour)),
|
||||||
|
Subject: r.ID,
|
||||||
|
}).SignedString(b.jwtPriv)
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Perm map[string]string `json:"perm" bson:"perm"`
|
||||||
|
ID string `json:"id" bson:"_id"`
|
||||||
|
Username string `json:"username" bson:"username"`
|
||||||
|
Password string `json:"password" bson:"password"`
|
||||||
|
Salt string `json:"salt" bson:"salt"`
|
||||||
|
Email string `json:"email" bson:"email"`
|
||||||
|
Fails int `json:"fails" bson:"fails"`
|
||||||
|
Timeout int64 `json:"timeout" bson:"timeout"`
|
||||||
|
PasswordChange int64 `json:"passwordChange" bson:"passwordChange"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrLoginTimeout = errors.New("user is timed out")
|
||||||
|
ErrLoginIncorrect = errors.New("username or password is incorrect")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tries to login with the given username and password.
|
||||||
|
// If the user exists, but is timed out, the user is still returned.
|
||||||
|
func (b *Backend) TryLogin(ctx context.Context, username, password string) (User, error) {
|
||||||
|
users, err := b.userTable.Find(ctx, map[string]any{"username": username})
|
||||||
|
if err == ErrNotFound {
|
||||||
|
return User{}, ErrLoginIncorrect
|
||||||
|
}
|
||||||
|
if len(users) > 1 {
|
||||||
|
log.Println("duplicate username detected, fix immediately:", username)
|
||||||
|
}
|
||||||
|
user := users[0]
|
||||||
|
if time.Unix(user.Timeout, 0).After(time.Now()) {
|
||||||
|
return user, ErrLoginTimeout
|
||||||
|
}
|
||||||
|
if valid, _ := user.ValidatePassword(password); !valid {
|
||||||
|
upd := map[string]any{"fails": user.Fails + 1}
|
||||||
|
if (user.Fails+1)%3 == 0 {
|
||||||
|
minutes := 3 ^ (((user.Fails + 1) / 3) - 1)
|
||||||
|
upd["timeout"] = time.Now().Add(time.Minute * time.Duration(minutes)).Unix()
|
||||||
|
b.userTable.PartUpdate(ctx, user.ID, upd)
|
||||||
|
return user, ErrLoginTimeout
|
||||||
|
}
|
||||||
|
return User{}, ErrLoginIncorrect
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) VerifyUser(ctx context.Context, token string) (*User, error) {
|
||||||
|
t, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
return b.jwtPub, nil
|
||||||
|
}, jwt.WithIssuer("darkstorm.tech"), jwt.WithExpirationRequired(), jwt.WithValidMethods([]string{"EdDSA"}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
exp, _ := t.Claims.GetExpirationTime()
|
||||||
|
if exp.Time.Before(time.Now()) {
|
||||||
|
return nil, ErrTokenUnauthorized
|
||||||
|
}
|
||||||
|
sub, err := t.Claims.GetSubject()
|
||||||
|
if err == jwt.ErrInvalidKey {
|
||||||
|
return nil, ErrTokenUnauthorized
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
usr, err := b.userTable.Get(ctx, sub)
|
||||||
|
if err == ErrNotFound {
|
||||||
|
return nil, ErrTokenUnauthorized
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
iss, err := t.Claims.GetIssuedAt()
|
||||||
|
if err == jwt.ErrInvalidKey {
|
||||||
|
return nil, ErrTokenUnauthorized
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, errors.Join(ErrTokenUnauthorized, err)
|
||||||
|
}
|
||||||
|
if usr.PasswordChange > 0 && iss.Time.Before(time.Unix(usr.PasswordChange, 0)) {
|
||||||
|
return nil, ErrTokenUnauthorized
|
||||||
|
}
|
||||||
|
return &usr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUser(username, password, email string) (User, error) {
|
||||||
|
id, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
salt, err := generateSalt()
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
u := User{
|
||||||
|
Perm: make(map[string]string),
|
||||||
|
ID: id.String(),
|
||||||
|
Username: username,
|
||||||
|
Salt: salt,
|
||||||
|
Email: email,
|
||||||
|
}
|
||||||
|
u.Password, err = u.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) GetID() string {
|
||||||
|
return u.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) ToReqUser() *ReqestUser {
|
||||||
|
return &ReqestUser{
|
||||||
|
Perm: u.Perm,
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) HashPassword(password string) (string, error) {
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(u.Salt)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
res := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
|
||||||
|
return base64.RawStdEncoding.EncodeToString(res), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) ValidatePassword(password string) (bool, error) {
|
||||||
|
hsh, err := u.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return hsh == u.Password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type createUserRequest struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
type createUserReturn struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) createUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := b.VerifyHeader(w, r, "user", false)
|
||||||
|
if hdr == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Println("request key parsing error:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
var req createUserRequest
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if err != nil || req.Username == "" || req.Password == "" || req.Email == "" {
|
||||||
|
ReturnError(w, http.StatusBadRequest, "invalidBody", "Bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Password) < 12 || len(req.Password) > 128 {
|
||||||
|
ReturnError(w, http.StatusUnauthorized, "password", "Invalid password.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// TODO: filter offensive words/phrases
|
||||||
|
b.userCreateMutex.Lock()
|
||||||
|
defer b.userCreateMutex.Unlock()
|
||||||
|
matchUsername, err := b.userTable.Find(r.Context(), map[string]any{"username": req.Username})
|
||||||
|
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||||
|
log.Println("error when checking for username collisions:", err)
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
} else if (err == nil || errors.Is(err, ErrNotFound)) && len(matchUsername) > 0 {
|
||||||
|
ReturnError(w, http.StatusUnauthorized, "taken", "Username or email already used")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matchEmail, err := b.userTable.Find(r.Context(), map[string]any{"email": req.Email})
|
||||||
|
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||||
|
log.Println("error when checking for email collisions:", err)
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
} else if (err == nil || errors.Is(err, ErrNotFound)) && len(matchEmail) > 0 {
|
||||||
|
ReturnError(w, http.StatusUnauthorized, "taken", "Username or email already used")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := NewUser(req.Username, req.Password, req.Email)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error creating new user:", err)
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = b.userTable.Insert(r.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error inserting new user:", err)
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ret createUserReturn
|
||||||
|
ret.Username = u.Username
|
||||||
|
ret.Token, err = b.GenerateJWT(u.ToReqUser())
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error generating token:", err)
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) deleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := b.VerifyHeader(w, r, "management", true)
|
||||||
|
if hdr == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Println("request key parsing error:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := r.PathValue("userID")
|
||||||
|
if userID == "" {
|
||||||
|
ReturnError(w, http.StatusBadRequest, "badRequest", "Bad Request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = b.userTable.Remove(r.Context(), userID)
|
||||||
|
if err != nil && err != ErrNotFound {
|
||||||
|
log.Println("error deleting user:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginReturn struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorMsg string `json:"errorMsg"`
|
||||||
|
Timeout int64 `json:"timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := b.VerifyHeader(w, r, "user", false)
|
||||||
|
if hdr == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Println("request key parsing error:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
var req loginRequest
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if err != nil || req.Username == "" || req.Password == "" {
|
||||||
|
ReturnError(w, http.StatusBadRequest, "invalidBody", "Bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ret loginReturn
|
||||||
|
u, err := b.TryLogin(r.Context(), req.Username, req.Password)
|
||||||
|
if err == nil {
|
||||||
|
ret.Token, err = b.GenerateJWT(u.ToReqUser())
|
||||||
|
if err != nil {
|
||||||
|
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err == ErrLoginTimeout {
|
||||||
|
ret.Error = "timeout"
|
||||||
|
ret.ErrorMsg = fmt.Sprint("Timed out for", time.Unix(u.Timeout, 0).Sub(time.Now()), "seconds")
|
||||||
|
ret.Timeout = u.Timeout
|
||||||
|
} else {
|
||||||
|
ret.Error = "incorrect"
|
||||||
|
ret.ErrorMsg = "Incorrect username or password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(ret)
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Blog module
|
||||||
|
|
||||||
|
A simple blog module for darkstorm-backend.
|
||||||
|
|
||||||
|
## Requests
|
||||||
|
|
||||||
|
### Author info
|
||||||
|
|
||||||
|
#### Get author info
|
||||||
|
|
||||||
|
> GET /author/{authorID}
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
id: "authorID",
|
||||||
|
name: "author name",
|
||||||
|
about: "about",
|
||||||
|
picurl: "picture URL"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Update author info
|
||||||
|
|
||||||
|
> POST /author/{authorID}
|
||||||
|
|
||||||
|
Must have a auth token for a user with the `"blog": "admin"` permission.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
name: "author name",
|
||||||
|
about: "about",
|
||||||
|
picurl: "picture url"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Add Author info
|
||||||
|
|
||||||
|
> POST /author
|
||||||
|
|
||||||
|
Must have a auth token for a user with the `"blog": "admin"` permission.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
name: "author name",
|
||||||
|
about: "about",
|
||||||
|
picurl: "picture URL"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blog
|
||||||
|
|
||||||
|
#### Specific blog
|
||||||
|
|
||||||
|
> GET /blog/{blogID}
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
id: "blogID",
|
||||||
|
staticPage: false, // static pages don't show up alongside other blog pages.
|
||||||
|
createTime: 0, // creation time in Unix format
|
||||||
|
updateTime: 0, // last update time in Unix format
|
||||||
|
author: "authorID",
|
||||||
|
favicon: "favicon url",
|
||||||
|
title: "blog title",
|
||||||
|
blog: "blog", // blog will have been converted to HTML
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Create blog
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
> POST /blog
|
||||||
|
|
||||||
|
Must have a auth token for a user with the `"blog": "admin"` permission.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
favicon: "favicon url",
|
||||||
|
title: "blog title",
|
||||||
|
blog: "blog", // blog will have been converted to HTML
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
id: "blogID"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Update blog
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
> POST /blog/{blogID}
|
||||||
|
|
||||||
|
Must have a auth token for a user with the `"blog": "admin"` permission.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
favicon: "new icon",
|
||||||
|
title: "new title",
|
||||||
|
blog: "new blog content"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Latest blogs
|
||||||
|
|
||||||
|
> GET /blog?page=0
|
||||||
|
|
||||||
|
Will return up to 5 blogs. `page` query is optional (implies 0 if not set).
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
num: 1, // Number of returned results, returns up to 5 results
|
||||||
|
blogs: [
|
||||||
|
{
|
||||||
|
id: "blogID",
|
||||||
|
createTime: 0, // creation time in Unix format
|
||||||
|
updateTime: 0, // last update time in Unix format
|
||||||
|
author: "authorID",
|
||||||
|
favicon: "favicon url",
|
||||||
|
title: "blog title",
|
||||||
|
blog: "blog", // blog will have been converted to HTML
|
||||||
|
}
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Blog List
|
||||||
|
|
||||||
|
> GET /blog/list?page=0
|
||||||
|
|
||||||
|
Will return up to 50 IDs. `page` query is optional (implies 0 if not set).
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
num: 1, // Number of returned results, returns up to 50 results
|
||||||
|
blogList: [
|
||||||
|
{
|
||||||
|
id: "blogID",
|
||||||
|
createTime: 0, // Unix format
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "blogID",
|
||||||
|
createTime: 0, // Unix format
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Portfolio
|
||||||
|
|
||||||
|
#### Get Projects
|
||||||
|
|
||||||
|
> GET: /portfolio?lang=go
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: "Darkstorm Server",
|
||||||
|
order: 0,
|
||||||
|
repository: "https://github.com/CalebQ42/darkstorm-server",
|
||||||
|
description: "The backend that runs runs my website and APIs",
|
||||||
|
technologies: [ // May be empty
|
||||||
|
"MongoDB",
|
||||||
|
"RESTful API"
|
||||||
|
],
|
||||||
|
language: [
|
||||||
|
{
|
||||||
|
language: "go",
|
||||||
|
dates: "September 2021"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package blog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const authorInfo = `
|
||||||
|
<table><tr>
|
||||||
|
<td><img src="%v" alt="%v" class='author-pic'></td>
|
||||||
|
<td><h3 class="author-title">%v</h3>%v</td>
|
||||||
|
</tr></table>`
|
||||||
|
|
||||||
|
type Author struct {
|
||||||
|
ID string `json:"id" bson:"_id"`
|
||||||
|
Name string `json:"name" bson:"name"`
|
||||||
|
About string `json:"about" bson:"about"`
|
||||||
|
PicURL string `json:"picurl" bson:"picurl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Author) HTML() string {
|
||||||
|
return fmt.Sprintf(authorInfo, a.PicURL, a.Name+"'s profile picture", a.Name, a.About)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) AboutMe(ctx context.Context) (*Author, error) {
|
||||||
|
res := b.authCol.FindOne(ctx, bson.M{"_id": "caleb_gardner"})
|
||||||
|
if res.Err() != nil {
|
||||||
|
log.Println("error getting about me:", res.Err())
|
||||||
|
if res.Err() == mongo.ErrNoDocuments {
|
||||||
|
return nil, backend.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, res.Err()
|
||||||
|
}
|
||||||
|
var aboutMe Author
|
||||||
|
err := res.Decode(&aboutMe)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error decoding about me:", res)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &aboutMe, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) reqAuthorInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
res := b.authCol.FindOne(r.Context(), r.PathValue("authorID"))
|
||||||
|
if res.Err() == mongo.ErrNoDocuments {
|
||||||
|
backend.ReturnError(w, http.StatusNotFound, "notFound", "Author with ID "+r.PathValue("authorID")+" not found")
|
||||||
|
return
|
||||||
|
} else if res.Err() != nil {
|
||||||
|
log.Println("error getting author info:", res.Err())
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server Error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var auth Author
|
||||||
|
err := res.Decode(&auth)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error decoding author info:", err)
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server Error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) addAuthorInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := b.back.VerifyHeader(w, r, "blogManagement", false)
|
||||||
|
if hdr == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Println("request key parsing error:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else if hdr.Key.AppID != "blog" {
|
||||||
|
backend.ReturnError(w, http.StatusUnauthorized, "invalidKey", "Application is unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hdr.User == nil || hdr.User.Perm["blog"] != "admin" {
|
||||||
|
backend.ReturnError(w, http.StatusUnauthorized, "unauthorized", "Application is unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var newAuth Author
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&newAuth)
|
||||||
|
r.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
backend.ReturnError(w, http.StatusBadRequest, "badRequest", "Invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := 1; ; i++ {
|
||||||
|
newID := strings.ReplaceAll(newAuth.Name, " ", "-")
|
||||||
|
if i != 1 {
|
||||||
|
newID += strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
collisionCheck := b.authCol.FindOne(r.Context(), bson.M{"name": newAuth.Name})
|
||||||
|
if collisionCheck.Err() == mongo.ErrNoDocuments {
|
||||||
|
newAuth.ID = newID
|
||||||
|
break
|
||||||
|
} else if collisionCheck.Err() != nil {
|
||||||
|
log.Println("error checking for new author ID collisions:", err)
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server Error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = b.authCol.InsertOne(r.Context(), newAuth)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error inserting new author:", err)
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server Error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) updateAuthorInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := b.back.VerifyHeader(w, r, "blogManagement", false)
|
||||||
|
if hdr == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Println("request key parsing error:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else if hdr.Key.AppID != "blog" {
|
||||||
|
backend.ReturnError(w, http.StatusUnauthorized, "invalidKey", "Application is unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hdr.User == nil || hdr.User.Perm["blog"] != "admin" {
|
||||||
|
backend.ReturnError(w, http.StatusUnauthorized, "unauthorized", "Application is unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rawUpd map[string]string
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&rawUpd)
|
||||||
|
r.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
backend.ReturnError(w, http.StatusBadRequest, "badRequest", "Invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actlUpd := make(map[string]string)
|
||||||
|
if rawUpd["name"] != "" {
|
||||||
|
actlUpd["name"] = rawUpd["name"]
|
||||||
|
}
|
||||||
|
if rawUpd["about"] != "" {
|
||||||
|
actlUpd["about"] = rawUpd["about"]
|
||||||
|
}
|
||||||
|
if rawUpd["picurl"] != "" {
|
||||||
|
actlUpd["picurl"] = rawUpd["picurl"]
|
||||||
|
}
|
||||||
|
res, err := b.authCol.UpdateByID(r.Context(), r.PathValue("authorID"), actlUpd)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
backend.ReturnError(w, http.StatusNotFound, "notFound", "Blog with ID "+r.PathValue("blogID")+" not found")
|
||||||
|
} else {
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server Error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.MatchedCount == 0 {
|
||||||
|
backend.ReturnError(w, http.StatusNotFound, "notFound", "Blog with ID "+r.PathValue("blogID")+" not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
package blog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
blogTitle = "<h1 class='blog-title'><a hx-push-url='true' hx-get='/%v' hx-target='#content' href='/%v' style='text-decoration:none'>%v</a></h1>"
|
||||||
|
blogAuthor = "<h4 class='blog-author'><i><b>By %v</b></i></h4>"
|
||||||
|
blogCreate = "<h5 class='blog-time'><i>Written on: %v</i></h5>"
|
||||||
|
blogMain = "<div class='blog'>%v</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Blog struct {
|
||||||
|
ID string `json:"id" bson:"_id"`
|
||||||
|
Author string `json:"author" bson:"author"`
|
||||||
|
Favicon string `json:"favicon" bson:"favicon"`
|
||||||
|
Title string `json:"title" bson:"title"`
|
||||||
|
RawBlog string `json:"blog" bson:"blog"`
|
||||||
|
HTMLBlog string `json:"-" bson:"-"`
|
||||||
|
StaticPage bool `json:"staticPage" bson:"staticPage"`
|
||||||
|
Draft bool `json:"draft" bson:"draft"`
|
||||||
|
CreateTime int64 `json:"createTime" bson:"createTime"`
|
||||||
|
UpdateTime int64 `json:"updateTime" bson:"updateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Blog) HTMX(blogApp *BlogApp, ctx context.Context) string {
|
||||||
|
if b.StaticPage {
|
||||||
|
return b.RawBlog
|
||||||
|
}
|
||||||
|
out := fmt.Sprintf(blogTitle, b.ID, b.ID, b.Title)
|
||||||
|
auth, err := blogApp.GetAuthor(ctx, b)
|
||||||
|
if err == nil {
|
||||||
|
out += fmt.Sprintf(blogAuthor, auth.Name)
|
||||||
|
} else {
|
||||||
|
out += fmt.Sprintf(blogAuthor, "unknown")
|
||||||
|
}
|
||||||
|
cTime := time.Unix(b.CreateTime, 0).Format(time.DateOnly)
|
||||||
|
if b.UpdateTime > b.CreateTime {
|
||||||
|
out += fmt.Sprintf(blogCreate, cTime+"; Last updated on: "+time.Unix(b.UpdateTime, 0).Format(time.DateOnly))
|
||||||
|
} else {
|
||||||
|
out += fmt.Sprintf(blogCreate, cTime)
|
||||||
|
}
|
||||||
|
out += fmt.Sprintf(blogMain, b.HTMLBlog)
|
||||||
|
if err == nil {
|
||||||
|
out += "<h2 class='blog-author-info'>About the author:</h2>" + auth.HTML()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) ConvertBlog(blog *Blog) {
|
||||||
|
if !blog.StaticPage {
|
||||||
|
blog.HTMLBlog = b.conv.HTMLConvert(blog.RawBlog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) GetAuthor(ctx context.Context, blog *Blog) (*Author, error) {
|
||||||
|
res := b.authCol.FindOne(ctx, bson.M{"_id": blog.Author})
|
||||||
|
if res.Err() != nil {
|
||||||
|
if res.Err() == mongo.ErrNoDocuments {
|
||||||
|
return nil, backend.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, res.Err()
|
||||||
|
}
|
||||||
|
var author Author
|
||||||
|
err := res.Decode(&author)
|
||||||
|
return &author, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) Blog(ctx context.Context, ID string) (*Blog, error) {
|
||||||
|
b.cacheMutex.RLock()
|
||||||
|
blog, has := b.blogCache[ID]
|
||||||
|
b.cacheMutex.RUnlock()
|
||||||
|
if has {
|
||||||
|
return &blog, nil
|
||||||
|
}
|
||||||
|
res := b.blogCol.FindOne(ctx, bson.M{"_id": ID, "draft": false})
|
||||||
|
if res.Err() != nil {
|
||||||
|
if res.Err() == mongo.ErrNoDocuments {
|
||||||
|
return nil, backend.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, res.Err()
|
||||||
|
}
|
||||||
|
err := res.Decode(&blog)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b.ConvertBlog(&blog)
|
||||||
|
b.cacheMutex.Lock()
|
||||||
|
b.blogCache[ID] = blog
|
||||||
|
b.cacheMutex.Unlock()
|
||||||
|
go b.CleanCache(ID)
|
||||||
|
return &blog, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) AnyBlog(ctx context.Context, ID string) (*Blog, error) {
|
||||||
|
res := b.blogCol.FindOne(ctx, bson.M{"_id": ID})
|
||||||
|
if res.Err() != nil {
|
||||||
|
if res.Err() == mongo.ErrNoDocuments {
|
||||||
|
return nil, backend.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, res.Err()
|
||||||
|
}
|
||||||
|
var blog Blog
|
||||||
|
err := res.Decode(&blog)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b.ConvertBlog(&blog)
|
||||||
|
return &blog, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) Contains(ctx context.Context, ID string) bool {
|
||||||
|
res := b.blogCol.FindOne(ctx, bson.M{"_id": ID})
|
||||||
|
return res.Err() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) CleanCache(ID string) {
|
||||||
|
time.Sleep(5 * time.Minute)
|
||||||
|
b.cacheMutex.Lock()
|
||||||
|
delete(b.blogCache, ID)
|
||||||
|
b.cacheMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) reqBlog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
blogID := r.PathValue("blogID")
|
||||||
|
if blogID == "" {
|
||||||
|
backend.ReturnError(w, http.StatusBadRequest, "badRequest", "Must provide a blogID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
blog, err := b.Blog(r.Context(), blogID)
|
||||||
|
if err != nil {
|
||||||
|
if err == backend.ErrNotFound {
|
||||||
|
backend.ReturnError(w, http.StatusNotFound, "notFound", "Not blog found with the given ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("error getting blog:", err)
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Header.Get("Hx-Request") == "true" {
|
||||||
|
w.Write([]byte(blog.HTMX(b, r.Context())))
|
||||||
|
} else {
|
||||||
|
json.NewEncoder(w).Encode(blog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) createBlog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := b.back.VerifyHeader(w, r, "blogManagement", false)
|
||||||
|
if hdr == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Println("request key parsing error:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else if hdr.Key.AppID != "blog" {
|
||||||
|
backend.ReturnError(w, http.StatusUnauthorized, "invalidKey", "Application is unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hdr.User == nil || hdr.User.Perm["blog"] != "admin" {
|
||||||
|
backend.ReturnError(w, http.StatusUnauthorized, "unauthorized", "Application is unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var newBlog Blog
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&newBlog)
|
||||||
|
r.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
backend.ReturnError(w, http.StatusBadRequest, "badRequest", "Bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server Error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tim := time.Now().Unix()
|
||||||
|
newBlog.ID = id.String()
|
||||||
|
newBlog.CreateTime = tim
|
||||||
|
newBlog.UpdateTime = tim
|
||||||
|
newBlog.Author = hdr.User.Username
|
||||||
|
_, err = b.blogCol.InsertOne(r.Context(), newBlog)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error when inserting new blog:", err)
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server Error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) updateBlog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := b.back.VerifyHeader(w, r, "blogManagement", false)
|
||||||
|
if hdr == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Println("request key parsing error:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else if hdr.Key.AppID != "blog" {
|
||||||
|
backend.ReturnError(w, http.StatusUnauthorized, "invalidKey", "Application is unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hdr.User == nil || hdr.User.Perm["blog"] != "admin" {
|
||||||
|
backend.ReturnError(w, http.StatusUnauthorized, "unauthorized", "Application is unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.PathValue("blogID") == "" {
|
||||||
|
backend.ReturnError(w, http.StatusBadRequest, "badRequest", "Bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var reqUpdRaw map[string]string
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&reqUpdRaw)
|
||||||
|
r.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
backend.ReturnError(w, http.StatusBadRequest, "badRequest", "Bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reqUpd := bson.M{}
|
||||||
|
if fav, ok := reqUpdRaw["favicon"]; ok && fav != "" {
|
||||||
|
reqUpd["favicon"] = fav
|
||||||
|
}
|
||||||
|
if titl, ok := reqUpdRaw["title"]; ok && titl != "" {
|
||||||
|
reqUpd["title"] = titl
|
||||||
|
}
|
||||||
|
if blog, ok := reqUpdRaw["blog"]; ok && blog != "" {
|
||||||
|
reqUpd["blog"] = blog
|
||||||
|
}
|
||||||
|
reqUpd["updateTime"] = time.Now().Unix()
|
||||||
|
res, err := b.blogCol.UpdateByID(r.Context(), r.PathValue("blogID"), reqUpd)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
backend.ReturnError(w, http.StatusNotFound, "notFound", "Blog with ID "+r.PathValue("blogID")+" not found")
|
||||||
|
} else {
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server Error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.MatchedCount == 0 {
|
||||||
|
backend.ReturnError(w, http.StatusNotFound, "notFound", "Blog with ID "+r.PathValue("blogID")+" not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) InsertBlog(ctx context.Context, blog Blog) error {
|
||||||
|
_, err := b.blogCol.InsertOne(ctx, blog)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) UpdateBlog(ctx context.Context, ID string, updates bson.M) error {
|
||||||
|
_, err := b.blogCol.UpdateByID(ctx, ID, bson.M{"$set": updates})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) LatestBlogs(ctx context.Context, page int64) ([]*Blog, error) {
|
||||||
|
res, err := b.blogCol.Find(ctx, bson.M{"staticPage": false, "draft": false}, options.Find().
|
||||||
|
SetSort(bson.M{"createTime": -1}).
|
||||||
|
SetLimit(5).
|
||||||
|
SetSkip(page*5))
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, backend.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []*Blog
|
||||||
|
err = res.All(ctx, &out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
b.ConvertBlog(out[i])
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) reqLatestBlogs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var page int
|
||||||
|
var err error
|
||||||
|
pagQuery := r.URL.Query().Get("page")
|
||||||
|
if pagQuery != "" {
|
||||||
|
page, err = strconv.Atoi(pagQuery)
|
||||||
|
if err != nil {
|
||||||
|
page = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blogs, err := b.LatestBlogs(r.Context(), int64(page))
|
||||||
|
if err != nil && err != backend.ErrNotFound {
|
||||||
|
log.Println("error getting latest blogs:", err)
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ret struct {
|
||||||
|
Blogs []*Blog `json:"blogs"`
|
||||||
|
Num int `json:"num"`
|
||||||
|
}
|
||||||
|
ret.Num = len(blogs)
|
||||||
|
ret.Blogs = blogs
|
||||||
|
json.NewEncoder(w).Encode(ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlogListResult struct {
|
||||||
|
ID string `json:"id" bson:"_id"`
|
||||||
|
Title string `json:"title" bson:"title"`
|
||||||
|
CreateTime int `json:"createTime" bson:"createTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b BlogListResult) HTMX() string {
|
||||||
|
return "<a class='blog-list-item' href='https://darkstorm.tech/" +
|
||||||
|
b.ID +
|
||||||
|
"' hx-push-url='true' hx-target='#content' hx-get='/" +
|
||||||
|
b.ID +
|
||||||
|
"'>" + b.Title + "</a>"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) BlogList(ctx context.Context, page int64) ([]BlogListResult, error) {
|
||||||
|
res, err := b.blogCol.Find(ctx, bson.M{"staticPage": false, "draft": false}, options.Find().
|
||||||
|
SetProjection(bson.M{"_id": 1, "createTime": 1, "title": 1}).
|
||||||
|
SetSort(bson.M{"createTime": -1}).
|
||||||
|
SetLimit(50).
|
||||||
|
SetSkip(page*50))
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, backend.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []BlogListResult
|
||||||
|
err = res.All(ctx, &out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) AllBlogsList(ctx context.Context) ([]BlogListResult, error) {
|
||||||
|
res, err := b.blogCol.Find(ctx, bson.M{}, options.Find().
|
||||||
|
SetProjection(bson.M{"_id": 1, "createTime": 1, "title": 1}).
|
||||||
|
SetSort(bson.M{"createTime": -1}))
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, backend.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []BlogListResult
|
||||||
|
err = res.All(ctx, &out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) reqBlogList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var page int
|
||||||
|
var err error
|
||||||
|
pagQuery := r.URL.Query().Get("page")
|
||||||
|
if pagQuery != "" {
|
||||||
|
page, err = strconv.Atoi(pagQuery)
|
||||||
|
if err != nil {
|
||||||
|
page = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blogList, err := b.BlogList(r.Context(), int64(page))
|
||||||
|
if err != nil && err != backend.ErrNotFound {
|
||||||
|
log.Println("error getting blog list:", err)
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ret struct {
|
||||||
|
BlogList []BlogListResult `json:"blogList"`
|
||||||
|
Num int `json:"num"`
|
||||||
|
}
|
||||||
|
ret.Num = len(blogList)
|
||||||
|
ret.BlogList = blogList
|
||||||
|
json.NewEncoder(w).Encode(ret)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package blog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/CalebQ42/bbConvert"
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BlogApp struct {
|
||||||
|
back *backend.Backend
|
||||||
|
blogCol *mongo.Collection
|
||||||
|
authCol *mongo.Collection
|
||||||
|
portfolioCol *mongo.Collection
|
||||||
|
conv bbConvert.ComboConverter
|
||||||
|
|
||||||
|
cacheMutex *sync.RWMutex
|
||||||
|
blogCache map[string]Blog
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBlogApp(db *mongo.Database) *BlogApp {
|
||||||
|
out := &BlogApp{
|
||||||
|
blogCol: db.Collection("blog"),
|
||||||
|
authCol: db.Collection("author"),
|
||||||
|
portfolioCol: db.Collection("portfolio"),
|
||||||
|
conv: bbConvert.NewComboConverter(),
|
||||||
|
cacheMutex: &sync.RWMutex{},
|
||||||
|
blogCache: make(map[string]Blog),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) AppID() string {
|
||||||
|
return "blog"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) CountTable() backend.CountTable {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) CrashTable() backend.CrashTable {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) AddBackend(back *backend.Backend) {
|
||||||
|
b.back = back
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) Extension(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /blog", b.reqLatestBlogs)
|
||||||
|
mux.HandleFunc("GET /blog/list", b.reqBlogList)
|
||||||
|
mux.HandleFunc("GET /blog/{blogID}", b.reqBlog)
|
||||||
|
mux.HandleFunc("POST /blog", b.createBlog)
|
||||||
|
mux.HandleFunc("POST /blog/{blogID}", b.updateBlog)
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /blog/author/{authorID}", b.reqAuthorInfo)
|
||||||
|
mux.HandleFunc("POST /blog/author", b.addAuthorInfo)
|
||||||
|
mux.HandleFunc("POST /blog/author/{authorID}", b.updateAuthorInfo)
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /blog/portfolio", b.reqPortfolio)
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package blog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
portfolioTitle = "<h2 class='portfolio-title'>%v</h2>"
|
||||||
|
portfolioLink = "<p class='portfolio-link'><a href='%v'>%v</a>"
|
||||||
|
portfolioLanguage = "<p class='portfolio-language'><b>%v</b>: %v</p>"
|
||||||
|
portfolioTech = "<p class='portfolio-tech'><b>Tech: </b>%v</p>"
|
||||||
|
portfolioDesc = "<p class='portfolio-description'>%v</p>"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PortfolioProject struct {
|
||||||
|
Title string `json:"_id" bson:"_id"`
|
||||||
|
Order int `json:"order" bson:"order"`
|
||||||
|
Repository string `json:"repository" bson:"repository"`
|
||||||
|
Description string `json:"description" bson:"description"`
|
||||||
|
Technologies []string `json:"technologies" bson:"technologies"`
|
||||||
|
Languages []struct {
|
||||||
|
Language string `json:"language" bson:"language"`
|
||||||
|
Dates string `json:"dates" bson:"dates"`
|
||||||
|
} `json:"language" bson:"language"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p PortfolioProject) HTMX() string {
|
||||||
|
out := fmt.Sprintf(portfolioTitle, p.Title)
|
||||||
|
out += fmt.Sprintf(portfolioLink, p.Repository, p.Repository)
|
||||||
|
for _, l := range p.Languages {
|
||||||
|
out += fmt.Sprintf(portfolioLanguage, l.Language, l.Dates)
|
||||||
|
}
|
||||||
|
out += fmt.Sprintf(portfolioTech, strings.Join(p.Technologies, ", "))
|
||||||
|
out += fmt.Sprintf(portfolioDesc, p.Description)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type Portfolio []PortfolioProject
|
||||||
|
|
||||||
|
const (
|
||||||
|
portfolioSelector = `
|
||||||
|
<p>Tech Filter:
|
||||||
|
<select
|
||||||
|
id='techSelect'
|
||||||
|
name='tech'
|
||||||
|
hx-get='https://api.darkstorm.tech/blog/portfolio'
|
||||||
|
hx-target='#projects'>
|
||||||
|
%v
|
||||||
|
</select>
|
||||||
|
</p>`
|
||||||
|
portfolioSelectorOption = "<option value='%v'%v>%v</option>"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p Portfolio) FullHTMX(ctx context.Context, blogApp *BlogApp, selectedTech string) string {
|
||||||
|
aboutMe := "<h1 class='about-me-header'>About Me</h1>"
|
||||||
|
if me, err := blogApp.AboutMe(ctx); err != nil {
|
||||||
|
aboutMe += "Error getting info about me :("
|
||||||
|
} else {
|
||||||
|
aboutMe += me.HTML()
|
||||||
|
}
|
||||||
|
aboutMe += "<h1 class='my-projects-header' style='margin-bottom:15px'>My Projects</h1>"
|
||||||
|
tech := make(map[string]struct{})
|
||||||
|
for i := range p {
|
||||||
|
for _, t := range p[i].Technologies {
|
||||||
|
tech[t] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
techKeys := make([]string, 0, len(tech))
|
||||||
|
for k := range tech {
|
||||||
|
techKeys = append(techKeys, k)
|
||||||
|
}
|
||||||
|
slices.Sort(techKeys)
|
||||||
|
var out string
|
||||||
|
if selectedTech == "" {
|
||||||
|
out = fmt.Sprintf(portfolioSelectorOption, "", " selected=true", "All")
|
||||||
|
} else {
|
||||||
|
out = fmt.Sprintf(portfolioSelectorOption, "", "", "All")
|
||||||
|
}
|
||||||
|
for _, k := range techKeys {
|
||||||
|
if selectedTech == strings.ToLower(k) {
|
||||||
|
out += fmt.Sprintf(portfolioSelectorOption, k, " selected=true", k)
|
||||||
|
} else {
|
||||||
|
out += fmt.Sprintf(portfolioSelectorOption, k, "", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aboutMe + fmt.Sprintf(portfolioSelector, out) + "<div id='projects'>" + p.HTMX() + "</div>"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Portfolio) HTMX() string {
|
||||||
|
out := ""
|
||||||
|
for _, proj := range p {
|
||||||
|
out += proj.HTMX()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) Projects(ctx context.Context, techFilter string) (Portfolio, error) {
|
||||||
|
filter := bson.M{}
|
||||||
|
if techFilter != "" {
|
||||||
|
filter = bson.M{"technologies": techFilter}
|
||||||
|
}
|
||||||
|
res, err := b.portfolioCol.Find(ctx, filter, options.Find().SetSort(bson.M{"order": 1}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []PortfolioProject
|
||||||
|
err = res.All(ctx, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlogApp) reqPortfolio(w http.ResponseWriter, r *http.Request) {
|
||||||
|
folio, err := b.Projects(r.Context(), r.URL.Query().Get("tech"))
|
||||||
|
if err != nil {
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server Error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Header.Get("Hx-Request") == "true" {
|
||||||
|
if r.URL.Query().Has("tech") {
|
||||||
|
w.Write([]byte(folio.HTMX()))
|
||||||
|
} else {
|
||||||
|
w.Write([]byte(folio.FullHTMX(r.Context(), b, r.URL.Query().Get("tech"))))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
json.NewEncoder(w).Encode(folio)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Blogs
|
||||||
|
|
||||||
|
An HTMX powered blog system and editor.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package blog
|
||||||
|
|
||||||
|
type Author struct {
|
||||||
|
ID string `json:"id" bson:"_id"`
|
||||||
|
Name string `json:"name" bson:"name"`
|
||||||
|
About string `json:"about" bson:"about"`
|
||||||
|
PicURL string `json:"picurl" bson:"picurl"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package blog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BlogList struct {
|
||||||
|
ID string `json:"id" bson:"_id"`
|
||||||
|
Title string `json:"title" bson:"title"`
|
||||||
|
Draft bool `json:"draft" bson:"draft"`
|
||||||
|
CreateTime int64 `json:"createTime" bson:"createTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) FullBlogList(ctx context.Context) ([]BlogList, error) {
|
||||||
|
res, err := b.blogCol.Find(ctx, bson.M{}, options.Find().
|
||||||
|
SetProjection(bson.M{"_id": 1, "createTime": 1, "title": 1, "draft": 1}).
|
||||||
|
SetSort(bson.M{"createTime": -1}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var list []BlogList
|
||||||
|
err = res.All(ctx, &list)
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) BlogList(ctx context.Context) ([]BlogList, error) {
|
||||||
|
res, err := b.blogCol.Find(ctx, bson.M{
|
||||||
|
"draft": false,
|
||||||
|
"staticPage": false,
|
||||||
|
}, options.Find().
|
||||||
|
SetProjection(bson.M{"_id": 1, "createTime": 1, "title": 1, "draft": 1}).
|
||||||
|
SetSort(bson.M{"createTime": -1}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var list []BlogList
|
||||||
|
err = res.All(ctx, &list)
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package blog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Blog struct {
|
||||||
|
ID string `json:"id" bson:"_id"`
|
||||||
|
Author string `json:"author" bson:"author"`
|
||||||
|
Favicon string `json:"favicon" bson:"favicon"`
|
||||||
|
Title string `json:"title" bson:"title"`
|
||||||
|
RawBlog string `json:"blog" bson:"blog"`
|
||||||
|
HTMLBlog string `json:"-" bson:"-"`
|
||||||
|
StaticPage bool `json:"staticPage" bson:"staticPage"`
|
||||||
|
Draft bool `json:"draft" bson:"draft"`
|
||||||
|
CreateTime int64 `json:"createTime" bson:"createTime"`
|
||||||
|
UpdateTime int64 `json:"updateTime" bson:"updateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GetBlog(ctx context.Context, ID string, allowDraft bool) (Blog, error) {
|
||||||
|
filter := bson.M{"_id": ID}
|
||||||
|
if !allowDraft {
|
||||||
|
filter["draft"] = false
|
||||||
|
}
|
||||||
|
res := b.blogCol.FindOne(ctx, filter)
|
||||||
|
if res.Err() != nil {
|
||||||
|
return Blog{}, res.Err()
|
||||||
|
}
|
||||||
|
var out Blog
|
||||||
|
err := res.Decode(&out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) postBlogReq(w http.ResponseWriter, r *http.Request) {
|
||||||
|
usr := b.verifyEditorCookie(r)
|
||||||
|
if usr == nil {
|
||||||
|
redirect(w, r, "/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if usr.Perm["blog"] != "admin" && usr.Perm["blog"] != "writer" {
|
||||||
|
w.Write([]byte("<p>Sorry, but you aren't authorized to do this action.</p>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
w.Write([]byte("<p>Error decoding form</p>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newBlog := Blog{
|
||||||
|
ID: r.FormValue("id"),
|
||||||
|
Title: r.FormValue("title"),
|
||||||
|
RawBlog: r.FormValue("blog"),
|
||||||
|
StaticPage: r.FormValue("staticPage") == "on",
|
||||||
|
Draft: r.FormValue("draft") == "on",
|
||||||
|
UpdateTime: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
if newBlog.Title == "" || newBlog.RawBlog == "" {
|
||||||
|
w.Write([]byte("<p>Title and blog contents are required</p>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if newBlog.ID == "" {
|
||||||
|
newBlog.ID = strings.ToLower(strings.ReplaceAll(newBlog.Title, " ", "-"))
|
||||||
|
newBlog.CreateTime = newBlog.UpdateTime
|
||||||
|
newBlog.Author = usr.Username
|
||||||
|
_, err = b.blogCol.InsertOne(r.Context(), newBlog)
|
||||||
|
if mongo.IsDuplicateKeyError(err) {
|
||||||
|
w.Write([]byte("<p>Title already exists</p>"))
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
log.Println("error inserting document")
|
||||||
|
w.Write([]byte("<p>Server error inserting document</p>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.blogSuccessFullPageReplace(r.Context(), w, newBlog)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := b.blogCol.FindOne(r.Context(), bson.M{"_id": newBlog.ID})
|
||||||
|
if res.Err() == mongo.ErrNoDocuments {
|
||||||
|
w.Write([]byte("<p>Error finding old document</p>"))
|
||||||
|
return
|
||||||
|
} else if res.Err() != nil {
|
||||||
|
log.Println("error getting old blog for update:", res.Err())
|
||||||
|
w.Write([]byte("<p>Server error!</p>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var oldBlog Blog
|
||||||
|
err = res.Decode(&oldBlog)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error decoding old blog for update:", res.Err())
|
||||||
|
w.Write([]byte("<p>Server error!</p>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newBlog.CreateTime = oldBlog.CreateTime
|
||||||
|
if oldBlog.Title != newBlog.Title {
|
||||||
|
res = b.blogCol.FindOne(r.Context(), bson.M{"_id": strings.ToLower(strings.ReplaceAll(newBlog.Title, " ", "-"))})
|
||||||
|
if res.Err() == nil {
|
||||||
|
w.Write([]byte("<p>Title already exists</p>"))
|
||||||
|
return
|
||||||
|
} else if res.Err() != mongo.ErrNoDocuments {
|
||||||
|
log.Println("error checking for title existance:", res.Err())
|
||||||
|
w.Write([]byte("<p>Server error!</p>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res = b.blogCol.FindOneAndDelete(r.Context(), bson.M{"_id": oldBlog.ID})
|
||||||
|
if res.Err() != nil {
|
||||||
|
log.Println("error deleting old blog:", res.Err())
|
||||||
|
w.Write([]byte("<p>Server error!</p>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newBlog.ID = strings.ToLower(strings.ReplaceAll(newBlog.Title, " ", "-"))
|
||||||
|
_, err = b.blogCol.InsertOne(r.Context(), newBlog)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error inserting document")
|
||||||
|
w.Write([]byte("<p>Server error inserting document</p>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.blogSuccessFullPageReplace(r.Context(), w, newBlog)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = b.blogCol.UpdateByID(r.Context(), newBlog.ID, bson.M{
|
||||||
|
"updateTime": newBlog.UpdateTime,
|
||||||
|
"blog": newBlog.RawBlog,
|
||||||
|
"staticPage": newBlog.StaticPage,
|
||||||
|
"draft": newBlog.Draft,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error updating blog:", err)
|
||||||
|
w.Write([]byte("<p>Server error inserting document</p>"))
|
||||||
|
} else {
|
||||||
|
w.Write([]byte("<p>Successfully updated</p>"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) blogSuccessFullPageReplace(ctx context.Context, w http.ResponseWriter, blog Blog) {
|
||||||
|
form, err := b.BlogEditForm(blog)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error with blogForm template:", err)
|
||||||
|
w.Write([]byte("<p>Success, but error reloading page</p>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := b.BlogEditPage(ctx, blog.ID, form)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error with getting blog list:", err)
|
||||||
|
w.Write([]byte("<p>Success, but error reloading page</p>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Hx-Retarget", "#editPage")
|
||||||
|
w.Write([]byte(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) BlogEditForm(blog Blog) (string, error) {
|
||||||
|
form := new(bytes.Buffer)
|
||||||
|
err := b.tmpl.ExecuteTemplate(form, "blogForm", blogFormStruct{
|
||||||
|
Blog: blog,
|
||||||
|
Result: "<p>Success!!</p>",
|
||||||
|
})
|
||||||
|
return form.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) blogFormReq(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Hx-Request") != "true" {
|
||||||
|
redirect(w, r, "/editor")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
blogID := r.URL.Query().Get("blog")
|
||||||
|
var blog Blog
|
||||||
|
if blogID != "" {
|
||||||
|
var err error
|
||||||
|
blog, err = b.GetBlog(r.Context(), blogID, true)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error getting blog:", err)
|
||||||
|
w.Write([]byte("<p>Server error!</p>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
form, err := b.BlogEditForm(blog)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error using blogForm template:", err)
|
||||||
|
w.Write([]byte("<p>Server error!</p>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write([]byte(form))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) BlogEditPage(ctx context.Context, selectedID, editor string) (string, error) {
|
||||||
|
blogs, err := b.FullBlogList(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
err = b.tmpl.ExecuteTemplate(out, "blogPage", blogPageStruct{
|
||||||
|
Selected: selectedID,
|
||||||
|
Editor: editor,
|
||||||
|
Blogs: blogs,
|
||||||
|
})
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) blogPageReq(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Hx-Request") != "true" {
|
||||||
|
redirect(w, r, "/editor")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form, err := b.BlogEditForm(Blog{})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error using blogForm template:", err)
|
||||||
|
w.Write([]byte("<p>Server error!</p>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, err := b.BlogEditPage(r.Context(), "", form)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error using blogPage template:", err)
|
||||||
|
w.Write([]byte("<p>Server error!</p>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write([]byte(page))
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package blog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *Backend) editorReq(w http.ResponseWriter, r *http.Request) {
|
||||||
|
blogForm := new(bytes.Buffer)
|
||||||
|
err := b.tmpl.ExecuteTemplate(blogForm, "blogForm", blogFormStruct{
|
||||||
|
Blog: Blog{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error using blogForm:", err)
|
||||||
|
b.wrapper(w, r, "error", "<p>Server error</p>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
blogs, err := b.FullBlogList(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error getting blog list:", err)
|
||||||
|
b.wrapper(w, r, "error", "<p>Server error</p>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
blogPage := new(bytes.Buffer)
|
||||||
|
err = b.tmpl.ExecuteTemplate(blogPage, "blogPage", blogPageStruct{
|
||||||
|
Selected: "",
|
||||||
|
Editor: blogForm.String(),
|
||||||
|
Blogs: blogs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error using blogPage:", err)
|
||||||
|
b.wrapper(w, r, "error", "<p>Server error</p>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
err = b.tmpl.ExecuteTemplate(out, "editor", editorStruct{
|
||||||
|
SelectedPage: "",
|
||||||
|
Page: blogPage.String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error using editor:", err)
|
||||||
|
b.wrapper(w, r, "error", "<p>Server error</p>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.wrapper(w, r, "Editor", out.String())
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package blog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WrapperFunc func(w http.ResponseWriter, r *http.Request, title, content string)
|
||||||
|
|
||||||
|
type Backend struct {
|
||||||
|
blogCol *mongo.Collection
|
||||||
|
authCol *mongo.Collection
|
||||||
|
projCol *mongo.Collection
|
||||||
|
|
||||||
|
tmpl *template.Template
|
||||||
|
wrapper WrapperFunc
|
||||||
|
|
||||||
|
back *backend.Backend
|
||||||
|
|
||||||
|
cacheMutex sync.RWMutex
|
||||||
|
cache map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(c *mongo.Client, back *backend.Backend, wrapper WrapperFunc) (*Backend, error) {
|
||||||
|
var b = &Backend{
|
||||||
|
blogCol: c.Database("blog").Collection("blog"),
|
||||||
|
authCol: c.Database("blog").Collection("blog"),
|
||||||
|
projCol: c.Database("blog").Collection("blog"),
|
||||||
|
wrapper: wrapper,
|
||||||
|
back: back,
|
||||||
|
cache: make(map[string]string),
|
||||||
|
}
|
||||||
|
return b, b.parseTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) RegisterToMux(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /editor", b.editorReq)
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /editor/blog", b.blogPageReq)
|
||||||
|
mux.HandleFunc("GET /editor/blog/edit", b.blogFormReq)
|
||||||
|
mux.HandleFunc("POST /editor/blog/post", b.postBlogReq)
|
||||||
|
|
||||||
|
// mux.HandleFunc("GET /editor/portfolio", b.portfolioPageReq)
|
||||||
|
// mux.HandleFunc("GET /editor/portfolio/edit", b.portfolioFormReq)
|
||||||
|
// mux.HandleFunc("POST /editor/portfolio/post", b.postPortfolioReq)
|
||||||
|
|
||||||
|
// mux.HandleFunc("GET /editor/author", b.authorPageReq)
|
||||||
|
// mux.HandleFunc("GET /editor/author/edit", b.authorFormReq)
|
||||||
|
// mux.HandleFunc("POST /editor/author/post", b.postAuthorReq)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) verifyEditorCookie(r *http.Request) *backend.User {
|
||||||
|
authCookie, err := r.Cookie("blogAuthToken")
|
||||||
|
if err != nil {
|
||||||
|
if err != http.ErrNoCookie {
|
||||||
|
log.Println("error getting auth cookie:", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
usr, err := b.back.VerifyUser(r.Context(), authCookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
if err != backend.ErrTokenUnauthorized {
|
||||||
|
log.Println("error authorizing JWT token:", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return usr
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirect(w http.ResponseWriter, r *http.Request, path string) {
|
||||||
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
|
w.Header().Set("HX-Location", `{"path": "`+path+`", "target":"#content"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "https://darkstorm.tech"+path, http.StatusFound)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package blog
|
||||||
|
|
||||||
|
type PortfolioProject struct {
|
||||||
|
Title string `json:"_id" bson:"_id"`
|
||||||
|
Order int `json:"order" bson:"order"`
|
||||||
|
Repository string `json:"repository" bson:"repository"`
|
||||||
|
Description string `json:"description" bson:"description"`
|
||||||
|
Technologies []string `json:"technologies" bson:"technologies"`
|
||||||
|
Languages []struct {
|
||||||
|
Language string `json:"language" bson:"language"`
|
||||||
|
Dates string `json:"dates" bson:"dates"`
|
||||||
|
} `json:"language" bson:"language"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package blog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
const editor = `
|
||||||
|
<p id="editSelector" hx-target="#editPage" hx-swap="swap:0.5s settle:0.5s" hx-push-url="true">
|
||||||
|
<a href="/editor/blog" hx-get="/editor/blog" class="editSelectorItems{{if (eq .SelectedPage "blogs") | (eq .SelectedPage "")}} editSelectorSelected{{end}}" {{if eq .SelectedPage "blogs"}}{{end}}>Blogs</a>
|
||||||
|
<a href="/editor/portfolio" hx-get="/editor/portfolio" class="editSelectorItems{{if eq .SelectedPage "portfolio"}} editSelectorSelected{{end}}">Portfolio</a>
|
||||||
|
<a href="/editor/author" hx-get="/editor/author" class="editSelectorItems{{if eq .SelectedPage "author"}} editSelectorSelected{{end}}">Author</a>
|
||||||
|
</p>
|
||||||
|
<div id="editPage">{{ .Page }}</div>`
|
||||||
|
|
||||||
|
type editorStruct struct {
|
||||||
|
SelectedPage string
|
||||||
|
Page string
|
||||||
|
}
|
||||||
|
|
||||||
|
const blogPage = `<p>
|
||||||
|
<label for="blog" style="margin-right:10px">Blog:</label>
|
||||||
|
<select id="blogSelect"
|
||||||
|
name='blog'
|
||||||
|
hx-get='/editor/blog/edit'
|
||||||
|
hx-target='#editor'>
|
||||||
|
<option value=''{{if eq .Selected ""}} selected{{end}}>New Blog</option>
|
||||||
|
{{ range $blog := .Blogs }}
|
||||||
|
<option value='{{.ID}}'{{if eq $.Selected .ID}} selected{{end}}>{{.Title}}{{if .Draft}} (Draft){{end}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<div id="editor" hx-on::after-settle="editorResize()">{{.Editor}}</div>`
|
||||||
|
|
||||||
|
type blogPageStruct struct {
|
||||||
|
Selected string
|
||||||
|
Editor string
|
||||||
|
Blogs []BlogList
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add delete
|
||||||
|
const blogForm = `
|
||||||
|
<form id="editorForm" hx-post="/editor/blog/post" hx-target="#formResult" hx-confirm="Save changes, overwritting previous values??">
|
||||||
|
<input name="id" type="hidden" value="{{.Blog.ID}}"></input>
|
||||||
|
<p>
|
||||||
|
<label for="staticPage" style="margin-right:10px">Static Page:</label><input type="checkbox" name="staticPage"{{if .Blog.StaticPage}} checked {{end}}/>
|
||||||
|
<span class="vertical-seperator"></span>
|
||||||
|
<label for="draft" style="margin-right:10px">Draft:</label><input type="checkbox" name="draft"{{if or .Blog.Draft (not .Blog.ID)}} checked {{end}}/>
|
||||||
|
</p>
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input id="titleInput" name="title" value="{{.Blog.Title}}" type="text" onkeydown="return event.key != 'Enter';"/>
|
||||||
|
<textarea id="textEditor" name="blog" oninput="editorResize()">{{.Blog.RawBlog}}</textarea>
|
||||||
|
<div id="formResult">{{.Result}}</div>
|
||||||
|
<p style="margin-right:0px;">
|
||||||
|
<button class="formButton" type="submit">{{if eq .Blog.ID ""}}Create{{else}}Update{{end}}</button>
|
||||||
|
<button class="formButton"
|
||||||
|
hx-get="/editor/blog/edit"
|
||||||
|
hx-include="#blogSelect"
|
||||||
|
hx-target="#editor"
|
||||||
|
hx-confirm="Undo all your changes??">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
</form>`
|
||||||
|
|
||||||
|
type blogFormStruct struct {
|
||||||
|
Blog Blog
|
||||||
|
Result string
|
||||||
|
}
|
||||||
|
|
||||||
|
const portfolioPage = `<p>
|
||||||
|
<label for="project" style="margin-right:10px">Project:</label>
|
||||||
|
<select id="projectSelect"
|
||||||
|
name='project'
|
||||||
|
hx-get='/editor/portfolio/edit'
|
||||||
|
hx-target='#editor'>
|
||||||
|
<option value=''{{if eq .Selected ""}} selected{{end}}>New Project</option>
|
||||||
|
{{ range $project := .Projects }}
|
||||||
|
<option value='{{.Title}}'{{if eq $.Selected .Title}} selected{{end}}>{{.Title}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<div id="editor" hx-on::after-settle="editorResize()">{{.Editor}}</div>`
|
||||||
|
|
||||||
|
type portfolioPageStruct struct {
|
||||||
|
Selected string
|
||||||
|
Editor string
|
||||||
|
Projects []PortfolioProject
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add Languages to editor
|
||||||
|
const portfolioForm = `<form id="editorForm" hx-post="/editor/portfolio/post" hx-target="#formResult" hx-confirm="Save changes, overwritting previous values??">
|
||||||
|
<input name="origTitle" type="hidden" value="{{.Project.Title}}"></input>
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input id="titleInput" name="title" value="{{.Project.Title}}" type="text" onkeydown="return event.key != 'Enter';"/>
|
||||||
|
<label for="technologies">Technologies</label>
|
||||||
|
<input id="techInput" name="technologies" value="{{range $ind, $tech := .Project.Technologies}}{{if not (eq $ind 0)}}, {{end}}$tech{{end}}" type="text" onkeydown="return event.key != 'Enter';"/>
|
||||||
|
<textarea id="textEditor" name="description" oninput="editorResize()">{{.Project.Description}}</textarea>
|
||||||
|
<div id="formResult">{{.Result}}</div>
|
||||||
|
<p style="margin-right:0px;">
|
||||||
|
<button class="formButton" type="submit">{{if eq .Project.Title ""}}Create{{else}}Update{{end}}</button>
|
||||||
|
<button class="formButton"
|
||||||
|
hx-get="/editor/portfolio/edit"
|
||||||
|
hx-include="#projectSelect"
|
||||||
|
hx-target="#editor"
|
||||||
|
hx-confirm="Undo all your changes??">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
</form>`
|
||||||
|
|
||||||
|
type portfolioFormStruct struct {
|
||||||
|
Project PortfolioProject
|
||||||
|
Result string
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorPage = `<p>
|
||||||
|
<label for="author" style="margin-right:10px">Author:</label>
|
||||||
|
<select id="authorSelect"
|
||||||
|
name='author'
|
||||||
|
hx-get='/editor/author/edit'
|
||||||
|
hx-target='#editor'>
|
||||||
|
<option value=''{{if eq .Selected ""}} selected{{end}}>New Author</option>
|
||||||
|
{{ range $author := .Authors }}
|
||||||
|
<option value='{{.ID}}'{{if eq $.Selected .ID}} selected{{end}}>{{.Name}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<div id="editor" hx-on::after-settle="editorResize()">{{.Editor}}</div>`
|
||||||
|
|
||||||
|
type authorPageStruct struct {
|
||||||
|
Selected string
|
||||||
|
Editor string
|
||||||
|
Authors []Author
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorForm = `<form id="editorForm" hx-post="/editor/author/post" hx-target="#formResult" hx-confirm="Save changes, overwritting previous values??">
|
||||||
|
<input name="id" type="hidden" value="{{.Author.ID}}"></input>
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="nameInput" name="name" value="{{.Author.Name}}" type="text" onkeydown="return event.key != 'Enter';"/>
|
||||||
|
<textarea id="textEditor" name="about" oninput="editorResize()">{{.Author.About}}</textarea>
|
||||||
|
<div id="formResult">{{.Result}}</div>
|
||||||
|
<p style="margin-right:0px;">
|
||||||
|
<button class="formButton" type="submit">{{if eq .Author.ID ""}}Create{{else}}Update{{end}}</button>
|
||||||
|
<button class="formButton"
|
||||||
|
hx-get="/editor/author/edit"
|
||||||
|
hx-include="#projectSelect"
|
||||||
|
hx-target="#editor"
|
||||||
|
hx-confirm="Undo all your changes??">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
</form>`
|
||||||
|
|
||||||
|
type authorFormStruct struct {
|
||||||
|
Author Author
|
||||||
|
Result string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) parseTemplates() error {
|
||||||
|
var err error
|
||||||
|
b.tmpl, err = template.New("editor").Parse(editor)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.tmpl, err = template.New("blogPage").Parse(blogPage)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.tmpl, err = template.New("blogForm").Parse(blogForm)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.tmpl, err = template.New("portfolioPage").Parse(portfolioPage)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.tmpl, err = template.New("portfolioForm").Parse(portfolioForm)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.tmpl, err = template.New("authorPage").Parse(authorPage)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.tmpl, err = template.New("authorForm").Parse(authorForm)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 Caleb Gardner
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# cdr-backend
|
||||||
|
|
||||||
|
Stupid backend for [CDR]("https://github.com/CalebQ42/CustomDiceRoller").
|
||||||
|
|
||||||
|
## APIs
|
||||||
|
|
||||||
|
### Dice
|
||||||
|
|
||||||
|
Dice sharing
|
||||||
|
|
||||||
|
> POST: /upload?key={api_key}
|
||||||
|
|
||||||
|
Upload a die.
|
||||||
|
|
||||||
|
Request Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
// Die data
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Only allows up to 1MB of data. If over 1MB returns 413. Further limits might be imposed in the future.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "die ID",
|
||||||
|
"expiration": 0 // Unix time (Seconds) of expiration
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> GET: /die/{die id}?key={api_key}
|
||||||
|
|
||||||
|
Get an uploaded die.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
// die data minus uid
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package cdr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend"
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend/db"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CDRBackend struct {
|
||||||
|
back *backend.Backend
|
||||||
|
db *mongo.Database
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBackend(db *mongo.Database) *CDRBackend {
|
||||||
|
go func() {
|
||||||
|
for range time.Tick(time.Hour) {
|
||||||
|
log.Println("CDR: Deleting expired dice")
|
||||||
|
res, err := db.Collection("profiles").DeleteMany(context.Background(), bson.M{"expiration": bson.M{"$lt": time.Now().Unix()}})
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Println("CDR: Deleted", res.DeletedCount, "dice")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return &CDRBackend{
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b CDRBackend) AppID() string {
|
||||||
|
return "cdr"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b CDRBackend) CountTable() backend.CountTable {
|
||||||
|
return db.NewMongoTable[backend.CountLog](b.db.Collection("logs"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b CDRBackend) CrashTable() backend.CrashTable {
|
||||||
|
return db.NewMongoCrashTable(b.db.Collection("crashes"), b.db.Collection("crashArchive"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *CDRBackend) AddBackend(back *backend.Backend) {
|
||||||
|
b.back = back
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s CDRBackend) ShouldAddCrash(ctx context.Context, cr backend.IndividualCrash) bool {
|
||||||
|
res := s.db.Collection("versions").FindOne(ctx, bson.M{"version": cr.Version})
|
||||||
|
return res.Err() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b CDRBackend) Extension(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("POST /cdr/die", b.UploadDie)
|
||||||
|
mux.HandleFunc("GET /cdr/die/{dieID}", b.GetDie)
|
||||||
|
|
||||||
|
//Legacy (TODO: remove this after a month or two after the applciation gets updated)
|
||||||
|
mux.HandleFunc("POST /upload", b.UploadDie)
|
||||||
|
mux.HandleFunc("GET /die/{dieID}", b.GetDie)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package cdr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UploadedDie struct {
|
||||||
|
Die map[string]any `json:"die" bson:"die"`
|
||||||
|
ID string `json:"id" bson:"_id"`
|
||||||
|
Expiration int64 `json:"expiration" bson:"expiration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b CDRBackend) UploadDie(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := b.back.VerifyHeader(w, r, "dice", false)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hdr.Key.AppID != "cdr" {
|
||||||
|
backend.ReturnError(w, http.StatusUnauthorized, "unauthorized", "Application not authorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Body == nil {
|
||||||
|
backend.ReturnError(w, http.StatusBadRequest, "bad request", "Application sent a bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bod, err := io.ReadAll(r.Body)
|
||||||
|
r.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
backend.ReturnError(w, http.StatusBadRequest, "bad request", "Application sent a bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(bod) > 1048576 { //1MB
|
||||||
|
backend.ReturnError(w, http.StatusRequestEntityTooLarge, "too large", "Die is too large to upload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var toUpload = UploadedDie{
|
||||||
|
Die: make(map[string]any),
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Expiration: time.Now().Add(12 * time.Hour).Round(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(bod, &toUpload.Die)
|
||||||
|
if err != nil {
|
||||||
|
backend.ReturnError(w, http.StatusBadRequest, "bad request", "Application sent a bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if toUpload.Die["uuid"] != nil {
|
||||||
|
delete(toUpload.Die, "uuid")
|
||||||
|
}
|
||||||
|
_, err = b.db.Collection("dice").InsertOne(r.Context(), toUpload)
|
||||||
|
if err != nil {
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
log.Println("error inserting die:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"id": toUpload.ID, "expiration": toUpload.Expiration})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b CDRBackend) GetDie(w http.ResponseWriter, r *http.Request) {
|
||||||
|
res := b.db.Collection("dice").FindOne(r.Context(), bson.M{"_id": r.PathValue("dieID")})
|
||||||
|
if res.Err() == mongo.ErrNoDocuments {
|
||||||
|
backend.ReturnError(w, 404, "not found", "Die with the given id is not found")
|
||||||
|
return
|
||||||
|
} else if res.Err() != nil {
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
log.Println("error getting CDR die:", res.Err())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var dieGet UploadedDie
|
||||||
|
err := res.Decode(&dieGet)
|
||||||
|
if err != nil {
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
log.Println("error decoding die:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(dieGet.Die)
|
||||||
|
}
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
package darkstormtech
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/CalebQ42/bbConvert"
|
|
||||||
"github.com/CalebQ42/stupid-backend/v2"
|
|
||||||
"go.mongodb.org/mongo-driver/bson"
|
|
||||||
"go.mongodb.org/mongo-driver/mongo"
|
|
||||||
"go.mongodb.org/mongo-driver/mongo/options"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DarkstormTech struct {
|
|
||||||
stupid.UnKeyedApp
|
|
||||||
bb *bbConvert.HTMLConverter
|
|
||||||
DB *mongo.Database
|
|
||||||
filesFolder string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDarkstormTech(c *mongo.Client, filesFolder string) *DarkstormTech {
|
|
||||||
bb := &bbConvert.HTMLConverter{}
|
|
||||||
bb.ImplementDefaults()
|
|
||||||
return &DarkstormTech{
|
|
||||||
bb: bb,
|
|
||||||
DB: c.Database("darkstormtech"),
|
|
||||||
filesFolder: filesFolder,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DarkstormTech) AlternateName() string {
|
|
||||||
return "page"
|
|
||||||
}
|
|
||||||
|
|
||||||
type pageOut struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Favicon string `json:"favicon"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func notFoundPage() pageOut {
|
|
||||||
return pageOut{
|
|
||||||
Content: "404 Page Not Found 😥",
|
|
||||||
Title: "Darkstorm.Tech",
|
|
||||||
Favicon: "https://darkstorm.tech/favicon.png",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func pageWith(content string, title string) pageOut {
|
|
||||||
if title == "" {
|
|
||||||
title = "Darkstorm.Tech"
|
|
||||||
}
|
|
||||||
return pageOut{
|
|
||||||
Content: content,
|
|
||||||
Title: title,
|
|
||||||
Favicon: "https://darkstorm.tech/favicon.png",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p pageOut) json() []byte {
|
|
||||||
out, _ := json.Marshal(p)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *pageOut) addDefaults() {
|
|
||||||
if p.Title == "" {
|
|
||||||
p.Title = "Darkstorm.Tech"
|
|
||||||
}
|
|
||||||
if p.Favicon == "" {
|
|
||||||
p.Favicon = "https://darkstorm.tech/favicon.png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DarkstormTech) HandleReqest(req *stupid.Request) bool {
|
|
||||||
if req.Path[0] != "page" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(req.Path) == 1 {
|
|
||||||
req.Resp.WriteHeader(http.StatusBadRequest)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
switch req.Path[1] {
|
|
||||||
case "blog":
|
|
||||||
return d.handleBlog(req)
|
|
||||||
case "files":
|
|
||||||
return d.handleFiles(req)
|
|
||||||
case "portfolio":
|
|
||||||
return d.handlePortfolio(req)
|
|
||||||
case "default":
|
|
||||||
b, err := d.getBlog(req)
|
|
||||||
if err == mongo.ErrNoDocuments {
|
|
||||||
req.Resp.Write(notFoundPage().json())
|
|
||||||
req.Resp.WriteHeader(http.StatusNotFound)
|
|
||||||
return true
|
|
||||||
} else if err != nil {
|
|
||||||
log.Println("Error while getting blog:", err)
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
out := pageOut{
|
|
||||||
Content: d.bb.Convert(b.Content),
|
|
||||||
}
|
|
||||||
(&out).addDefaults()
|
|
||||||
_, err = req.Resp.Write(out.json())
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while writing response:", err)
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
res := d.DB.Collection("pages").FindOne(context.TODO(), bson.M{"_id": strings.Join(req.Path[1:], "/")}, options.FindOne().SetProjection(bson.M{"_id": 0}))
|
|
||||||
if res.Err() == mongo.ErrNoDocuments {
|
|
||||||
req.Resp.Write(notFoundPage().json())
|
|
||||||
req.Resp.WriteHeader(http.StatusNotFound)
|
|
||||||
return true
|
|
||||||
} else if res.Err() != nil {
|
|
||||||
log.Println("Error while getting page:", res.Err())
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
var pag pageOut
|
|
||||||
err := res.Decode(&pag)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while decoding page:", err)
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
pag.Content = d.bb.Convert(pag.Content)
|
|
||||||
(&pag).addDefaults()
|
|
||||||
_, err = req.Resp.Write(pag.json())
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while writing response:", err)
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
type blog struct {
|
|
||||||
ID string `bson:"_id" json:"id"`
|
|
||||||
Title string `bson:"title" json:"title"`
|
|
||||||
Content string `bson:"content" json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DarkstormTech) getBlog(req *stupid.Request) (*blog, error) {
|
|
||||||
var res *mongo.SingleResult
|
|
||||||
if len(req.Path) == 2 {
|
|
||||||
res = d.DB.Collection("blog").FindOne(context.TODO(), bson.M{}, options.FindOne().SetSort(bson.M{"_id": -1}))
|
|
||||||
} else {
|
|
||||||
res = d.DB.Collection("blog").FindOne(context.TODO(), bson.M{"_id": req.Path[2]})
|
|
||||||
}
|
|
||||||
if res.Err() != nil {
|
|
||||||
return nil, res.Err()
|
|
||||||
}
|
|
||||||
var b blog
|
|
||||||
err := res.Decode(&b)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DarkstormTech) handleBlog(req *stupid.Request) bool {
|
|
||||||
if req.Method == http.MethodPost {
|
|
||||||
return d.addBlog(req)
|
|
||||||
} else if req.Method != http.MethodGet {
|
|
||||||
req.Resp.WriteHeader(http.StatusBadRequest)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
b, err := d.getBlog(req)
|
|
||||||
if err == mongo.ErrNoDocuments {
|
|
||||||
req.Resp.Write(notFoundPage().json())
|
|
||||||
req.Resp.WriteHeader(http.StatusNotFound)
|
|
||||||
return true
|
|
||||||
} else if err != nil {
|
|
||||||
log.Println("Error while getting blog:", err)
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
out := pageOut{
|
|
||||||
Content: d.bb.Convert(b.Content),
|
|
||||||
Title: b.Title,
|
|
||||||
}
|
|
||||||
(&out).addDefaults()
|
|
||||||
_, err = req.Resp.Write(out.json())
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while writing response:", err)
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DarkstormTech) addBlog(req *stupid.Request) bool {
|
|
||||||
if req.User == nil || req.User.Role != "admin" {
|
|
||||||
req.Resp.WriteHeader(http.StatusUnauthorized)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if req.Body == nil {
|
|
||||||
req.Resp.WriteHeader(http.StatusBadRequest)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
bod, err := io.ReadAll(req.Body)
|
|
||||||
req.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while reading body:", err)
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if len(bod) == 0 {
|
|
||||||
req.Resp.WriteHeader(http.StatusBadRequest)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
var b blog
|
|
||||||
err = json.Unmarshal(bod, &b)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while unmarshalling body:", err)
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
b.ID = strconv.Itoa(int(time.Now().Unix()))
|
|
||||||
_, err = d.DB.Collection("blog").InsertOne(context.TODO(), b)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while inserting blog:", err)
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DarkstormTech) handleFiles(req *stupid.Request) bool {
|
|
||||||
if req.Method != http.MethodGet {
|
|
||||||
req.Resp.WriteHeader(http.StatusBadRequest)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
foldPath := ""
|
|
||||||
if len(req.Path) > 1 {
|
|
||||||
foldPath = filepath.Join(req.Path[2:]...)
|
|
||||||
}
|
|
||||||
fils, err := os.ReadDir(filepath.Join(d.filesFolder, foldPath))
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while getting files:", err)
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
out := ""
|
|
||||||
var inf fs.FileInfo
|
|
||||||
for _, f := range fils {
|
|
||||||
if f.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
inf, err = f.Info()
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while getting FileInfo for", f.Name(), err)
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
out += "<p><a href='https://darkstorm.tech/files/" + f.Name() + "'>" + f.Name() + "</a> " + inf.ModTime().Round(time.Minute).String() + "</p>\n"
|
|
||||||
}
|
|
||||||
_, err = req.Resp.Write(pageWith(out, "Files").json())
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while writing output:", err)
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
type project struct {
|
|
||||||
ID string `bson:"_id"`
|
|
||||||
Repository string
|
|
||||||
Description string
|
|
||||||
Language []struct {
|
|
||||||
Language string
|
|
||||||
Dates string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectedString(selected bool) string {
|
|
||||||
if !selected {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return " selected"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DarkstormTech) handlePortfolio(req *stupid.Request) bool {
|
|
||||||
if req.Method != http.MethodGet {
|
|
||||||
req.Resp.WriteHeader(http.StatusBadRequest)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
filter := bson.M{}
|
|
||||||
lang := ""
|
|
||||||
if l, ok := req.Query["lang"]; ok && len(l) == 1 && l[0] != "" {
|
|
||||||
lang = l[0]
|
|
||||||
filter = bson.M{"language.language": l[0]}
|
|
||||||
}
|
|
||||||
projects := make([]project, 0)
|
|
||||||
res, err := d.DB.Collection("projects").Find(context.TODO(), filter)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while getting projects:", err)
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
err = res.All(context.TODO(), &projects)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while decoding projects:", err)
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
out := "<p>Language Filter: <select name='langSelect' id='langSelect'>"
|
|
||||||
out += "<option value=''" + selectedString(lang == "") + ">All</option>"
|
|
||||||
out += "<option value='Go'" + selectedString(lang == "Go") + ">Go</option>"
|
|
||||||
out += "<option value='Dart'" + selectedString(lang == "Dart") + ">Dart (Flutter)</option>"
|
|
||||||
out += "<option value='Java'" + selectedString(lang == "Java") + ">Java</option>"
|
|
||||||
out += "</select></p>"
|
|
||||||
for _, p := range projects {
|
|
||||||
out += "<h1 style='margin-bottom:10px'>" + p.ID + "</h1>"
|
|
||||||
out += "<p><a href='" + p.Repository + "'>" + p.Repository + "</a></p>"
|
|
||||||
for _, l := range p.Language {
|
|
||||||
lang := l.Language
|
|
||||||
if lang == "Dart" {
|
|
||||||
lang = "Dart (Flutter)"
|
|
||||||
}
|
|
||||||
out += "<p><b>" + lang + "</b>: " + l.Dates + "</p>"
|
|
||||||
}
|
|
||||||
out += "<p>" + p.Description + "</p>"
|
|
||||||
}
|
|
||||||
_, err = req.Resp.Write(pageWith(out, "Portfolio").json())
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while writing output:", err)
|
|
||||||
req.Resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package flexmls
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/CalebQ42/stupid-backend/v2/defaultapp"
|
|
||||||
"go.mongodb.org/mongo-driver/mongo"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FlexMLS struct {
|
|
||||||
*defaultapp.App
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewBackend(client *mongo.Client) *FlexMLS {
|
|
||||||
return &FlexMLS{defaultapp.NewDefaultApp(client.Database("flexmls"))}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 Caleb Gardner
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# swassistant-backend
|
||||||
|
|
||||||
|
Custom backend for [SWAssistant](https://github.com/CalebQ42/SWAssistant). Extension of [darkstorm-backend](https://github.com/CalebQ42/darkstorm-server/tree/main/internal/backend)
|
||||||
|
|
||||||
|
## APIs
|
||||||
|
|
||||||
|
For `POST` requests, the `X-API-Key` http header must be set.
|
||||||
|
|
||||||
|
### Profiles
|
||||||
|
|
||||||
|
#### Upload profile to share
|
||||||
|
|
||||||
|
Character, vehicles, and minion profiles.
|
||||||
|
|
||||||
|
> POST: /profile?type={character|vehicle|minion}
|
||||||
|
|
||||||
|
Upload a profile. `type` query is required.
|
||||||
|
|
||||||
|
Request Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
// profile data
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Only allows up to 5MB of data. If over 5MB returns 413. Further limits might be imposed in the future.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "profile ID",
|
||||||
|
"expiration": 0 // Unix time (Seconds) of expiration
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get a shared profile
|
||||||
|
|
||||||
|
> GET: /profile/{profileID}
|
||||||
|
|
||||||
|
Get an uploaded profile.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "character|vehicle|minion",
|
||||||
|
// profile data minus uid
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rooms
|
||||||
|
|
||||||
|
All room requests must include both `X-API-Key` and `Authorization` headers.
|
||||||
|
|
||||||
|
#### Room list
|
||||||
|
|
||||||
|
> GET: /rooms
|
||||||
|
|
||||||
|
Get a list of rooms your currently a part of.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "room ID",
|
||||||
|
"name": "room name",
|
||||||
|
"owner": "username"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Create new room
|
||||||
|
|
||||||
|
> POST: /rooms/new?name={roomName}
|
||||||
|
|
||||||
|
Create a new room. `name` query is required.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "room ID",
|
||||||
|
"name": "room name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get room info
|
||||||
|
|
||||||
|
> GET: /rooms/{roomID}
|
||||||
|
|
||||||
|
Get info about a room.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "room ID",
|
||||||
|
"name": "room name",
|
||||||
|
"owner": "username",
|
||||||
|
"users": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"profiles": [
|
||||||
|
"profile uuids"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package swassistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend"
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend/db"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SWBackend struct {
|
||||||
|
back *backend.Backend
|
||||||
|
db *mongo.Database
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSWBackend(db *mongo.Database) *SWBackend {
|
||||||
|
go func() {
|
||||||
|
for range time.Tick(time.Hour) {
|
||||||
|
log.Println("SWAssistant: Deleting expired profiles")
|
||||||
|
res, err := db.Collection("profiles").DeleteMany(context.Background(), bson.M{"expiration": bson.M{"$lt": time.Now().Unix()}})
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Println("SWAssistant: Deleted", res.DeletedCount, "profiles")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return &SWBackend{
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SWBackend) AppID() string {
|
||||||
|
return "swassistant"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SWBackend) CountTable() backend.CountTable {
|
||||||
|
return db.NewMongoTable[backend.CountLog](s.db.Collection("logs"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SWBackend) CrashTable() backend.CrashTable {
|
||||||
|
return db.NewMongoCrashTable(s.db.Collection("crashes"), s.db.Collection("crashArchive"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SWBackend) AddBackend(b *backend.Backend) {
|
||||||
|
s.back = b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SWBackend) ShouldAddCrash(ctx context.Context, cr backend.IndividualCrash) bool {
|
||||||
|
res := s.db.Collection("versions").FindOne(ctx, bson.M{"version": cr.Version})
|
||||||
|
return res.Err() != mongo.ErrNoDocuments
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SWBackend) Extension(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /swa/room", s.ListRooms)
|
||||||
|
mux.HandleFunc("POST /swa/room", s.NewRoom)
|
||||||
|
mux.HandleFunc("GET /swa/room/{roomID}", s.GetRoom)
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /swa/profile", s.UploadProfile)
|
||||||
|
mux.HandleFunc("GET /swa/profile/{profileID}", s.GetProfile)
|
||||||
|
|
||||||
|
//Legacy (TODO: remove this after a month or two after the applciation gets updated)
|
||||||
|
mux.HandleFunc("GET /room/list", s.ListRooms)
|
||||||
|
mux.HandleFunc("POST /room/new", s.NewRoom)
|
||||||
|
mux.HandleFunc("GET /room/{roomID}", s.GetRoom)
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /profile/upload", s.UploadProfile)
|
||||||
|
mux.HandleFunc("GET /profile/{profileID}", s.GetProfile)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package swassistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend"
|
||||||
|
"github.com/lithammer/shortuuid/v3"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UploadedProf struct {
|
||||||
|
Profile map[string]any `json:"profile" bson:"profile"`
|
||||||
|
ID string `json:"id" bson:"_id"`
|
||||||
|
Type string `json:"type" bson:"type"`
|
||||||
|
Expiration int64 `json:"expiration" bson:"expiration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SWBackend) UploadProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := s.back.VerifyHeader(w, r, "profile", false)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hdr.Key.AppID != "swassistant" {
|
||||||
|
backend.ReturnError(w, http.StatusUnauthorized, "unauthorized", "Application not authorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
profType := r.URL.Query().Get("type")
|
||||||
|
if profType == "" || (profType != "character" && profType != "vehicle" && profType != "minion") {
|
||||||
|
backend.ReturnError(w, http.StatusBadRequest, "bad request", "Application sent a bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Body == nil {
|
||||||
|
backend.ReturnError(w, http.StatusBadRequest, "bad request", "Application sent a bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(r.Body)
|
||||||
|
r.Body.Close()
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
backend.ReturnError(w, http.StatusBadRequest, "bad request", "Application sent a bad request")
|
||||||
|
return
|
||||||
|
} else if len(data) > 5242880 { // 5MB
|
||||||
|
backend.ReturnError(w, http.StatusRequestEntityTooLarge, "too large", "Profile is too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prof := make(map[string]any)
|
||||||
|
err = json.Unmarshal(data, &prof)
|
||||||
|
if err != nil {
|
||||||
|
backend.ReturnError(w, http.StatusBadRequest, "bad request", "Application sent a bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(prof, "uid")
|
||||||
|
toUpload := UploadedProf{
|
||||||
|
ID: shortuuid.New(),
|
||||||
|
Expiration: time.Now().Add(time.Hour * 12).Round(time.Hour).Unix(),
|
||||||
|
Type: profType,
|
||||||
|
Profile: prof,
|
||||||
|
}
|
||||||
|
_, err = s.db.Collection("profiles").InsertOne(r.Context(), toUpload)
|
||||||
|
if err != nil {
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
log.Println("error inserting profile:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"id": toUpload.ID, "expiration": toUpload.Expiration})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SWBackend) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
res := s.db.Collection("profiles").FindOne(r.Context(), bson.M{"_id": r.PathValue("profileID")})
|
||||||
|
if res.Err() == mongo.ErrNoDocuments {
|
||||||
|
backend.ReturnError(w, 404, "not found", "Profile not found")
|
||||||
|
return
|
||||||
|
} else if res.Err() != nil {
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
log.Println("error getting profile:", res.Err())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var prof UploadedProf
|
||||||
|
err := res.Decode(&prof)
|
||||||
|
if err != nil {
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
log.Println("error decoding profile:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prof.Profile["type"] = prof.Type
|
||||||
|
json.NewEncoder(w).Encode(prof.Profile)
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package swassistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Room struct {
|
||||||
|
ID string `json:"id" bson:"_id"`
|
||||||
|
Name string `json:"name" bson:"name"`
|
||||||
|
Owner string `json:"owner" bson:"owner"`
|
||||||
|
Users []string `json:"users" bson:"users"`
|
||||||
|
Profiles []string `json:"profiles" bson:"profiles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SWBackend) ListRooms(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := s.back.VerifyHeader(w, r, "rooms", false)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hdr.Key.AppID != "swassistant" || hdr.User == nil {
|
||||||
|
backend.ReturnError(w, http.StatusUnauthorized, "unauthorized", "Application not authorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := s.db.Collection("rooms").Find(r.Context(), bson.M{"users": hdr.User.Username},
|
||||||
|
options.Find().SetProjection(bson.M{"_id": 1, "name": 1, "owner": 1}))
|
||||||
|
if err != nil && err != mongo.ErrNoDocuments {
|
||||||
|
log.Println("error getting room list:", err)
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]struct {
|
||||||
|
ID string `json:"id" bson:"_id"`
|
||||||
|
Name string `json:"name" bson:"name"`
|
||||||
|
Owner string `json:"owner" bson:"owner"`
|
||||||
|
}, 0)
|
||||||
|
if err == nil {
|
||||||
|
err = res.All(r.Context(), &out)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error decoding room list:", err)
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SWBackend) NewRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := s.back.VerifyHeader(w, r, "rooms", false)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hdr.Key.AppID != "swassistant" || hdr.User == nil {
|
||||||
|
backend.ReturnError(w, http.StatusUnauthorized, "unauthorized", "Application not authorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.URL.Query().Get("name")
|
||||||
|
if name == "" {
|
||||||
|
backend.ReturnError(w, http.StatusBadRequest, "bad request", "Application sent bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
//TODO: check room name for unsavory words
|
||||||
|
newRoom := Room{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Name: name,
|
||||||
|
Owner: hdr.User.Username,
|
||||||
|
Users: []string{},
|
||||||
|
Profiles: []string{},
|
||||||
|
}
|
||||||
|
_, err = s.db.Collection("rooms").InsertOne(r.Context(), newRoom)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error creating room:", err)
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"id": newRoom.ID, "name": newRoom.Name})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SWBackend) GetRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hdr, err := s.back.VerifyHeader(w, r, "rooms", false)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hdr.Key.AppID != "swassistant" || hdr.User == nil {
|
||||||
|
backend.ReturnError(w, http.StatusUnauthorized, "unauthorized", "Application not authorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roomID := r.PathValue("roomID")
|
||||||
|
res := s.db.Collection("rooms").FindOne(r.Context(), bson.M{"_id": roomID})
|
||||||
|
if res.Err() == mongo.ErrNoDocuments {
|
||||||
|
backend.ReturnError(w, http.StatusNotFound, "not found", "Room not found")
|
||||||
|
return
|
||||||
|
} else if res.Err() != nil {
|
||||||
|
log.Println("error getting room:", res.Err())
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rm Room
|
||||||
|
err = res.Decode(&rm)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error decoding room:", err)
|
||||||
|
backend.ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(rm)
|
||||||
|
}
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
forward "github.com/1lann/udp-forward"
|
|
||||||
)
|
|
||||||
|
|
||||||
type link struct {
|
|
||||||
addr string
|
|
||||||
linkType string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l link) isTCP() bool {
|
|
||||||
return strings.HasPrefix(l.linkType, "tcp") || strings.HasPrefix(l.linkType, "unix")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l link) isUDP() bool {
|
|
||||||
return strings.HasPrefix(l.linkType, "udp")
|
|
||||||
}
|
|
||||||
|
|
||||||
func linker() {
|
|
||||||
links, err := parseConf()
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while trying to parse config file:", err, "tcp linker signing off")
|
|
||||||
quitChan <- "tcp conf"
|
|
||||||
return
|
|
||||||
} else if links == nil {
|
|
||||||
log.Println("No values in config file or file not present (/etc/darkstorm-server.conf). tcp linker signing off")
|
|
||||||
quitChan <- "tcp conf"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fails := make(map[int]int) //logs how many fails per 5 seconds
|
|
||||||
failChan := make(chan int, 20)
|
|
||||||
open := make(map[int]bool)
|
|
||||||
for port, addr := range links {
|
|
||||||
open[port] = true
|
|
||||||
go createLink(port, addr, failChan)
|
|
||||||
}
|
|
||||||
failWaiting:
|
|
||||||
for portFail := <-failChan; ; portFail = <-failChan {
|
|
||||||
if fails[portFail] == 0 {
|
|
||||||
go func() {
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
fails[portFail] = 0
|
|
||||||
}()
|
|
||||||
} else if fails[portFail] == 4 {
|
|
||||||
log.Println("Port", portFail, "has failed 5 time is as many seconds. Not restarting port...")
|
|
||||||
open[portFail] = false
|
|
||||||
for _, b := range open {
|
|
||||||
if b {
|
|
||||||
continue failWaiting
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Println("All ports dead. Attempting restart...")
|
|
||||||
quitChan <- "tcp err"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fails[portFail]++
|
|
||||||
log.Println("Restarting linking for port", portFail)
|
|
||||||
go createLink(portFail, links[portFail], failChan)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func createLink(port int, l link, failChan chan int) {
|
|
||||||
log.Println("Linking", port, "to", l.addr, "with type", l.linkType)
|
|
||||||
if l.isUDP() {
|
|
||||||
_, err := forward.Forward(":"+strconv.Itoa(port), l.addr, forward.DefaultTimeout)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error with udp forwarder on port", port, ":", err)
|
|
||||||
failChan <- port
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var tcpListen net.Listener
|
|
||||||
var con net.Conn
|
|
||||||
var err error
|
|
||||||
if l.isTCP() {
|
|
||||||
tcpListen, err = net.Listen(l.linkType, ":"+strconv.Itoa(port))
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while trying to listen to port", port, ":", err)
|
|
||||||
failChan <- port
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer tcpListen.Close()
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
con, err = tcpListen.Accept()
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while trying to accept connection to port ", port, ":", err)
|
|
||||||
failChan <- port
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = copyConn(con, l)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while trying copy data from port", port, "to address", l.addr, ":", err)
|
|
||||||
failChan <- port
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseConf() (links map[int]link, err error) {
|
|
||||||
conf, err := os.Open("/etc/darkstorm-server.conf")
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, nil
|
|
||||||
} else if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
lineNum := 0
|
|
||||||
links = make(map[int]link)
|
|
||||||
rdr := bufio.NewReader(conf)
|
|
||||||
multilineComment := false
|
|
||||||
var line string
|
|
||||||
for {
|
|
||||||
if line == "" {
|
|
||||||
lineNum++
|
|
||||||
line, err = rdr.ReadString('\n')
|
|
||||||
if err != nil && line == "" {
|
|
||||||
break
|
|
||||||
} else if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
startCom, endCom := strings.Index(line, "/*"), strings.Index(line, "*/")
|
|
||||||
if multilineComment {
|
|
||||||
if endCom != -1 {
|
|
||||||
line = line[endCom:]
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if startCom != -1 {
|
|
||||||
if endCom != -1 {
|
|
||||||
line = line[:startCom] + line[endCom:]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
line = line[:startCom]
|
|
||||||
multilineComment = true
|
|
||||||
}
|
|
||||||
if strings.Contains(line, "//") {
|
|
||||||
line = line[:strings.Index(line, "//")]
|
|
||||||
}
|
|
||||||
line = strings.ReplaceAll(line, "\t", " ")
|
|
||||||
for strings.Contains(line, " ") {
|
|
||||||
line = strings.Replace(line, " ", " ", -1)
|
|
||||||
}
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
split := strings.Split(line, " ")
|
|
||||||
if len(split) < 2 || len(split) > 3 {
|
|
||||||
return nil, errors.New("invalid line #" + strconv.Itoa(lineNum))
|
|
||||||
}
|
|
||||||
var l link
|
|
||||||
if len(split) == 3 {
|
|
||||||
l.linkType = split[0]
|
|
||||||
split = split[1:]
|
|
||||||
} else {
|
|
||||||
l.linkType = "tcp"
|
|
||||||
}
|
|
||||||
var i int
|
|
||||||
i, err = strconv.Atoi(split[0])
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New("invalid line #" + strconv.Itoa(lineNum))
|
|
||||||
}
|
|
||||||
l.addr = split[1]
|
|
||||||
links[i] = l
|
|
||||||
line = ""
|
|
||||||
}
|
|
||||||
err = nil
|
|
||||||
if len(links) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyConn(src net.Conn, l link) error {
|
|
||||||
dst, err := net.Dial(l.linkType, l.addr)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error while dialing", l.addr)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer src.Close()
|
|
||||||
defer dst.Close()
|
|
||||||
io.Copy(dst, src)
|
|
||||||
done <- struct{}{}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer src.Close()
|
|
||||||
defer dst.Close()
|
|
||||||
io.Copy(src, dst)
|
|
||||||
done <- struct{}{}
|
|
||||||
}()
|
|
||||||
|
|
||||||
<-done
|
|
||||||
<-done
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,48 +1,187 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"flag"
|
"flag"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend"
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/backend/db"
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/blog"
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/cdr"
|
||||||
|
"github.com/CalebQ42/darkstorm-server/internal/swassistant"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
)
|
)
|
||||||
|
|
||||||
var quitChan chan string = make(chan string)
|
var (
|
||||||
|
mongoClient *mongo.Client
|
||||||
|
back *backend.Backend
|
||||||
|
blogApp *blog.BlogApp
|
||||||
|
webRoot *string
|
||||||
|
testing *bool
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
mongoStr := flag.String("mongo", "", "MongoDB connection string for APIs")
|
mongoURL := flag.String("mongo", "", "Enables MongoDB usage for Darkstorm backend.")
|
||||||
|
webRoot = flag.String("web-root", "", "Sets root directory of web server.")
|
||||||
|
addr := flag.String("addr", ":443", "Set listen address. Defaults to \":443\"")
|
||||||
|
testing = flag.Bool("testing", false, "Start in testing mode. If you don't know what this is, don't use it.")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
go linker()
|
if *testing {
|
||||||
go webserver(*mongoStr)
|
*addr = ":4242"
|
||||||
go startSMTPServer()
|
}
|
||||||
for failure := <-quitChan; ; failure = <-quitChan {
|
if !*testing && flag.NArg() != 1 {
|
||||||
switch failure {
|
log.Fatal("You must specify key directory. ex: darkstorm-server /etc/web-keys")
|
||||||
case "tcp conf":
|
}
|
||||||
continue
|
if *mongoURL == "" || *webRoot == "" {
|
||||||
case "tcp err":
|
log.Fatal("SPECIFY MONGO AND WEB-ROOT OR I WILL DIE, OH NO, THEY'RE COMING FOR ME.... **DEATH NOISES**")
|
||||||
go tcpLinkerRestart()
|
}
|
||||||
case "web arg":
|
if !*testing {
|
||||||
continue
|
go func() {
|
||||||
case "web err":
|
log.Println("error redirecting http traffice:",
|
||||||
go websiteRestart(*mongoStr)
|
http.ListenAndServe(":80", http.RedirectHandler("https://darkstorm.tech", http.StatusPermanentRedirect)))
|
||||||
case "smtp arg":
|
}()
|
||||||
continue
|
}
|
||||||
case "smtp err":
|
mux := http.NewServeMux()
|
||||||
//TODO: restart smtp server
|
setupMongo(*mongoURL)
|
||||||
continue
|
setupBackend(mux)
|
||||||
|
setupWebsite(mux)
|
||||||
|
serv := &http.Server{
|
||||||
|
Addr: *addr,
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if *testing {
|
||||||
|
err = serv.ListenAndServe()
|
||||||
|
} else {
|
||||||
|
err = serv.ListenAndServeTLS(filepath.Join(flag.Arg(0), "fullchain.pem"), filepath.Join(flag.Arg(0), "key.pem"))
|
||||||
|
}
|
||||||
|
log.Println("webserver closed:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupMongo(uri string) {
|
||||||
|
if !*testing {
|
||||||
|
mongoCert, err := tls.LoadX509KeyPair(filepath.Join(flag.Arg(0), "mongo.pem"), filepath.Join(flag.Arg(0), "key.pem"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("error loading mongo keys:", err)
|
||||||
|
}
|
||||||
|
mongoClient, err = mongo.Connect(context.Background(), options.Client().ApplyURI(uri).SetTLSConfig(&tls.Config{
|
||||||
|
Certificates: []tls.Certificate{mongoCert},
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("error connecting to mongo:", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
mongoClient, err = mongo.Connect(context.Background(), options.Client().ApplyURI(uri))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("error connecting to mongo:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func tcpLinkerRestart() {
|
func setupBackend(mux *http.ServeMux) {
|
||||||
log.Println("TCP linker failed. Restarting in 5 seconds...")
|
blogApp = blog.NewBlogApp(mongoClient.Database("blog"))
|
||||||
time.Sleep(5 * time.Second)
|
var err error
|
||||||
log.Println("Restarting tcp linker")
|
back, err = backend.NewBackend(db.NewMongoTable[backend.ApiKey](
|
||||||
linker()
|
mongoClient.Database("darkstorm").Collection("keys")),
|
||||||
|
blogApp,
|
||||||
|
swassistant.NewSWBackend(mongoClient.Database("swassistant")),
|
||||||
|
cdr.NewBackend(mongoClient.Database("cdr")),
|
||||||
|
)
|
||||||
|
if !*testing {
|
||||||
|
back.AddCorsAddress("https://darkstorm.tech")
|
||||||
|
var pubFil, privFil *os.File
|
||||||
|
defer pubFil.Close()
|
||||||
|
defer privFil.Close()
|
||||||
|
var pub, priv []byte
|
||||||
|
pubFil, err = os.Open(filepath.Join(flag.Arg(0), "darkstorm-pub.key"))
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error openning darkstorm user public key:", err)
|
||||||
|
goto here
|
||||||
|
}
|
||||||
|
pub, err = io.ReadAll(pubFil)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error reading darkstorm user public key:", err)
|
||||||
|
goto here
|
||||||
|
}
|
||||||
|
privFil, err = os.Open(filepath.Join(flag.Arg(0), "darkstorm-priv.key"))
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error openning darkstorm user private key:", err)
|
||||||
|
goto here
|
||||||
|
}
|
||||||
|
priv, err = io.ReadAll(privFil)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error reading darkstorm user private key:", err)
|
||||||
|
goto here
|
||||||
|
}
|
||||||
|
back.AddUserAuth(db.NewMongoTable[backend.User](mongoClient.Database("darkstorm").Collection("users")), priv, pub)
|
||||||
|
} else {
|
||||||
|
back.AddCorsAddress("*")
|
||||||
|
}
|
||||||
|
here:
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("error setting up backend:", err)
|
||||||
|
}
|
||||||
|
if !*testing {
|
||||||
|
mux.Handle("api.darkstorm.tech/", back)
|
||||||
|
} else {
|
||||||
|
go func() {
|
||||||
|
http.ListenAndServe(":2323", back)
|
||||||
|
}()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func websiteRestart(mongoStr string) {
|
func setupWebsite(mux *http.ServeMux) {
|
||||||
log.Println("Website failed. Restarting in 5 seconds...")
|
if !*testing {
|
||||||
time.Sleep(5 * time.Second)
|
url, _ := url.Parse("https://localhost:30000")
|
||||||
log.Println("Restarting website")
|
mux.Handle("rpg.darkstorm.tech/", httputil.NewSingleHostReverseProxy(url))
|
||||||
webserver(mongoStr)
|
}
|
||||||
|
mux.HandleFunc("/", mainHandle)
|
||||||
|
mux.HandleFunc("GET /files/{w...}", filesRequest)
|
||||||
|
mux.HandleFunc("GET /portfolio", portfolioRequest)
|
||||||
|
mux.HandleFunc("GET /list", blogListHandle)
|
||||||
|
|
||||||
|
err := setupEditorTemplates()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error setting up editor templates:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Editor stuff
|
||||||
|
mux.HandleFunc("GET /login", loginPageRequest)
|
||||||
|
mux.HandleFunc("GET /editor/", editorRequest)
|
||||||
|
mux.HandleFunc("GET /editor/edit", editorEdit)
|
||||||
|
mux.HandleFunc("POST /editor/post", editorPost)
|
||||||
|
mux.HandleFunc("POST /login", trueLoginRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mainHandle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||||
|
if path == "/" || path == "" {
|
||||||
|
latestBlogsHandle(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stat, err := os.Stat(filepath.Join(*webRoot, path))
|
||||||
|
if err == nil && !stat.IsDir() {
|
||||||
|
http.ServeFile(w, r, filepath.Join(*webRoot, path))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
spl := strings.Split(path, "/")
|
||||||
|
ind := filepath.Join(*webRoot, spl[0], "index.html")
|
||||||
|
_, err = os.Stat(ind)
|
||||||
|
if err == nil {
|
||||||
|
http.ServeFile(w, r, ind)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
blogHandle(w, r, path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func portfolioRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
selectedTech := r.URL.Query().Get("tech")
|
||||||
|
proj, err := blogApp.Projects(r.Context(), selectedTech)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error getting portfolio projects:", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
sendContent(w, r, "Error getting portfolio", "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendContent(w, r, proj.FullHTMX(r.Context(), blogApp, selectedTech), "Portfolio", "")
|
||||||
|
}
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"flag"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/CalebQ42/cdr-backend"
|
|
||||||
"github.com/CalebQ42/darkstorm-server/internal/darkstormtech"
|
|
||||||
"github.com/CalebQ42/darkstorm-server/internal/flexmls"
|
|
||||||
"github.com/CalebQ42/stupid-backend/v2"
|
|
||||||
"github.com/CalebQ42/stupid-backend/v2/db"
|
|
||||||
swassistantbackend "github.com/CalebQ42/swassistant-backend"
|
|
||||||
"go.mongodb.org/mongo-driver/mongo"
|
|
||||||
"go.mongodb.org/mongo-driver/mongo/options"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupStupid(keyPath, mongoStr string) error {
|
|
||||||
tlsCert, err := tls.LoadX509KeyPair(keyPath+"/fullchain.pem", keyPath+"/key.pem")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
client, err := mongo.Connect(
|
|
||||||
context.TODO(),
|
|
||||||
options.Client().ApplyURI(mongoStr),
|
|
||||||
options.Client().SetTLSConfig(&tls.Config{
|
|
||||||
Certificates: []tls.Certificate{tlsCert},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Issues connecting to mongo:", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
stupid := stupid.NewStupidBackend(db.NewMongoTable(client.Database("stupid").Collection("keys")), map[string]any{
|
|
||||||
"swassistant": swassistantbackend.NewSWBackend(client),
|
|
||||||
"cdr": cdr.NewBackend(client),
|
|
||||||
"darkstormtech": darkstormtech.NewDarkstormTech(client, filepath.Join(flag.Arg(0), "files")),
|
|
||||||
"flexmls": flexmls.NewBackend(client),
|
|
||||||
}, "https://darkstorm.tech")
|
|
||||||
users := true
|
|
||||||
var pub, priv []byte
|
|
||||||
stupidPubFil, err := os.Open(keyPath + "/stupid-pub.key")
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Disabling API users:", err)
|
|
||||||
users = false
|
|
||||||
} else {
|
|
||||||
pub, err = io.ReadAll(stupidPubFil)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Disabling API users:", err)
|
|
||||||
users = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stupidPrivFil, err := os.Open(keyPath + "/stupid-pub.key")
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Disabling API users:", err)
|
|
||||||
users = false
|
|
||||||
} else {
|
|
||||||
priv, err = io.ReadAll(stupidPrivFil)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Disabling API users:", err)
|
|
||||||
users = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if users {
|
|
||||||
stupid.EnableUserAuth(db.NewMongoTable(client.Database("stupid").Collection("keys")), pub, priv)
|
|
||||||
}
|
|
||||||
http.Handle("api.darkstorm.tech/", stupid)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,88 +1,54 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"bytes"
|
||||||
"flag"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func webserver(mongoStr string) {
|
const (
|
||||||
path := flag.Arg(0)
|
contentReplace = "<!--Content-->"
|
||||||
keyPath := flag.Arg(1)
|
faviconReplace = "<!--Favicon-->"
|
||||||
if path == "" {
|
titleReplace = "<!--Title-->"
|
||||||
log.Println("No argument given for website file path. website signing off...")
|
)
|
||||||
quitChan <- "web arg"
|
|
||||||
return
|
func sendContent(w http.ResponseWriter, r *http.Request, content string, title string, favicon string) {
|
||||||
} else if keyPath == "" {
|
if title == "" {
|
||||||
log.Println("No argument given for key files. website signing off...")
|
title = "Darkstorm.tech"
|
||||||
quitChan <- "web arg"
|
}
|
||||||
|
if r.Header.Get("Hx-Request") == "true" {
|
||||||
|
w.Write([]byte("<title>" + title + "</title>" + content))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var err error
|
if r.URL.Query().Get("contentOnly") == "true" {
|
||||||
if mongoStr != "" {
|
json.NewEncoder(w).Encode(map[string]string{"content": content, "title": title, "favicon": favicon})
|
||||||
err = setupStupid(keyPath, mongoStr)
|
return
|
||||||
if err != nil {
|
|
||||||
quitChan <- "web err"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
url, err := url.Parse("https://localhost:30000")
|
indexFile, err := os.Open(filepath.Join(*webRoot, "index.html"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Can't parse foundry url:", err)
|
log.Println("error when opening main index.html:", err)
|
||||||
quitChan <- "web err"
|
w.WriteHeader(http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// http.Handle("/", http.FileServer(http.Dir(path)))
|
dat, err := io.ReadAll(indexFile)
|
||||||
mainHandle := &fileOrIndexHandler{
|
if err != nil {
|
||||||
baseFolder: path,
|
log.Println("error reading main index.html:", err)
|
||||||
appFolders: []string{
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
"SWAssistant",
|
|
||||||
"CDR",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
http.Handle("/", mainHandle)
|
|
||||||
// http.Handle("/SWAssistant/", swaHandler{})
|
|
||||||
// http.Handle("/CDR/", cdrHandler{})
|
|
||||||
http.Handle("rpg.darkstorm.tech/", httputil.NewSingleHostReverseProxy(url))
|
|
||||||
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
|
||||||
err = http.ListenAndServeTLS(":443", keyPath+"/fullchain.pem", keyPath+"/key.pem", nil)
|
|
||||||
log.Println("Error while serving website:", err)
|
|
||||||
quitChan <- "web err"
|
|
||||||
}
|
|
||||||
|
|
||||||
type fileOrIndexHandler struct {
|
|
||||||
baseFolder string
|
|
||||||
appFolders []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fileOrIndexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
reqPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
|
||||||
if reqPath == "" || reqPath == "index.html" {
|
|
||||||
http.ServeFile(w, r, path.Join(f.baseFolder, "index.html"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reqPath = path.Join(f.baseFolder, reqPath)
|
dat = bytes.ReplaceAll(dat, []byte(contentReplace), []byte(content))
|
||||||
if fil, err := os.Open(reqPath); err == nil {
|
if title == "" {
|
||||||
inf, _ := fil.Stat()
|
title = "Darkstorm.tech"
|
||||||
if !inf.IsDir() {
|
|
||||||
http.ServeFile(w, r, reqPath)
|
|
||||||
return
|
|
||||||
} else if _, err = os.Open(path.Join(reqPath, "index.html")); err == nil {
|
|
||||||
http.ServeFile(w, r, path.Join(reqPath, "index.html"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for _, a := range f.appFolders {
|
dat = bytes.ReplaceAll(dat, []byte(titleReplace), []byte(title))
|
||||||
if strings.HasPrefix(reqPath, path.Join(f.baseFolder, a)) {
|
if favicon == "" {
|
||||||
http.ServeFile(w, r, path.Join(f.baseFolder, a, "index.html"))
|
favicon = "https://darkstorm.tech/favicon.png"
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
http.ServeFile(w, r, path.Join(f.baseFolder, "index.html"))
|
dat = bytes.ReplaceAll(dat, []byte(faviconReplace), []byte(favicon))
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write(dat)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user