Move darkstorm_backend to just backend

Added MongoDB instances of DB tables
Updated some DB interfaces
Added logging to count cleanup
This commit is contained in:
Caleb Gardner
2024-06-12 03:31:09 -05:00
parent e3af23873f
commit 28654e237e
15 changed files with 246 additions and 22 deletions
+317
View File
@@ -0,0 +1,317 @@
# 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
}
}
```
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",
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.
* 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",
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".
}
```
+16
View File
@@ -0,0 +1,16 @@
package backend
import "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
}
type ExtendedApp interface {
// Extension is called for any calls to /{appID}/
// Alternatively, use Backend.HandleFunc for more customizability
Extension(http.ResponseWriter, *http.Request)
}
+201
View File
@@ -0,0 +1,201 @@
package backend
import (
"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"`
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.Error == "" || crash.Stack == "" {
ReturnError(w, http.StatusBadRequest, "invalidBody", "Bad request")
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(crash) {
err = tab.InsertCrash(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(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(w, ap, crashID)
}
func (b *Backend) actualCrashDelete(w http.ResponseWriter, ap App, crashID string) {
crash := ap.CrashTable()
if crash == nil {
ReturnError(w, http.StatusInternalServerError, "misconfigured", "Server Misconfigured")
return
}
err := crash.Remove(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(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(w, ap, toArchive)
}
func (b *Backend) actualCrashArchive(w http.ResponseWriter, ap App, toArchive ArchivedCrash) {
crash := ap.CrashTable()
if crash == nil {
ReturnError(w, http.StatusInternalServerError, "misconfigured", "Server Misconfigured")
return
}
err := crash.Archive(toArchive)
if err != nil {
log.Println("error archive crash:", err)
return
}
first, _, _ := strings.Cut(toArchive.Stack, "\n")
crashes, err := crash.Find(map[string]any{"error": toArchive.Error, "firstLine": first})
if err == ErrNotFound {
return
} else if err != nil {
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(c.ID)
if err != nil {
log.Println("error removing empty crash report:", err)
}
} else if len(c.Individual) < ogLen {
err = crash.PartUpdate(c.ID, map[string]any{"individual": c.Individual})
if err != nil {
log.Println("error updating individual crash reports:", err)
}
}
}
}
+118
View File
@@ -0,0 +1,118 @@
package backend
import (
"crypto/ed25519"
"encoding/json"
"errors"
"log"
"net/http"
"sync"
"time"
)
type Backend struct {
userTable Table[User]
keyTable Table[ApiKey]
m *http.ServeMux
apps map[string]App
managementKeyID string
jwtPriv ed25519.PrivateKey
jwtPub ed25519.PublicKey
userMutex sync.RWMutex
}
func NewBackend(keyTable Table[ApiKey], apps ...App) (*Backend, error) {
b := &Backend{
keyTable: keyTable,
m: &http.ServeMux{},
apps: make(map[string]App),
userMutex: sync.RWMutex{},
}
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 {
b.m.HandleFunc("/"+apps[i].AppID()+"/", ext.Extension)
}
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)
}
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)
}
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(old)
if err != nil {
log.Printf("error removing old logs for %v: %v\n", a.AppID(), err)
}
}
}
}
func getDate(t time.Time) int {
return (t.Year() * 10000) + (int(t.Month()) * 100) + t.Day()
}
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)
}
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)
}
func (b *Backend) HandleFunc(pattern string, h http.HandlerFunc) {
b.m.HandleFunc(pattern, h)
}
func (b *Backend) GetApp(a *ApiKey) App {
return b.apps[a.AppID]
}
type retError struct {
ErrorCode string `json:"errorCode"`
ErrorMsg string `json:"errorMsg"`
}
func ReturnError(w http.ResponseWriter, status int, code, msg string) {
w.WriteHeader(status)
json.NewEncoder(w).Encode(retError{
ErrorCode: code,
ErrorMsg: msg,
})
}
+23
View File
@@ -0,0 +1,23 @@
package backend
import (
"fmt"
"testing"
"time"
"github.com/google/uuid"
)
func TestStuff(t *testing.T) {
for i := 0; i < 50; i++ {
go func() {
id, err := uuid.NewV7()
if err != nil {
fmt.Println(err)
}
fmt.Println(id.String())
}()
}
time.Sleep(3 * time.Second)
t.Fatal("end")
}
+38
View File
@@ -0,0 +1,38 @@
package backend
import "errors"
var (
ErrNotFound = errors.New("no matches found in table")
)
type IDStruct interface {
GetID() string
}
type Table[T IDStruct] interface {
Get(ID string) (data *T, err error)
Find(values map[string]any) ([]T, error)
Insert(data T) error
Remove(ID string) error
FullUpdate(ID string, data T) error
PartUpdate(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(date int) error
// Get count. If platform is an empty string or "all", the full count should be given
Count(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(ArchivedCrash) error
IsArchived(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(IndividualCrash) error
}
+83
View File
@@ -0,0 +1,83 @@
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(ID string) (data *T, err error) {
res := m.col.FindOne(context.Background(), bson.M{"_id": ID})
if res.Err() == mongo.ErrNoDocuments {
return nil, backend.ErrNotFound
} else if res.Err() != nil {
return nil, res.Err()
}
var out T
err = res.Decode(&out)
return &out, err
}
func (m *MongoTable[T]) Find(values map[string]any) ([]T, error) {
res, err := m.col.Find(context.Background(), values)
if err == mongo.ErrNoDocuments {
return nil, backend.ErrNotFound
} else if err != nil {
return nil, err
}
var out []T
err = res.All(context.Background(), &out)
return out, err
}
func (m *MongoTable[T]) Insert(data T) error {
_, err := m.col.InsertOne(context.Background(), data)
return err
}
func (m *MongoTable[T]) Remove(ID string) error {
res := m.col.FindOneAndDelete(context.Background(), bson.M{"_id": ID})
return res.Err()
}
func (m *MongoTable[T]) FullUpdate(ID string, data T) error {
res := m.col.FindOneAndReplace(context.Background(), bson.M{"_id": ID}, data)
if res.Err() == mongo.ErrNoDocuments {
return backend.ErrNotFound
}
return res.Err()
}
func (m *MongoTable[T]) PartUpdate(ID string, update map[string]any) error {
res := m.col.FindOneAndUpdate(context.Background(), bson.M{"_id": ID}, update)
if res.Err() == mongo.ErrNoDocuments {
return backend.ErrNotFound
}
return res.Err()
}
func (m *MongoTable[CountLog]) RemoveOldLogs(date int) {
m.col.DeleteMany(context.Background(), bson.M{"date": bson.M{"$lt": date}})
}
func (m *MongoTable[CountLog]) Count(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(context.Background(), filter)
return int(out), err
}
+70
View File
@@ -0,0 +1,70 @@
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(toArchive backend.ArchivedCrash) error {
if toArchive.Platform == "" {
toArchive.Platform = "all"
}
_, err := m.archiveCol.InsertOne(context.Background(), toArchive)
return err
}
func (m *MongoCrashTable) IsArchived(ind backend.IndividualCrash) bool {
res := m.archiveCol.FindOne(context.Background(),
bson.M{"error": ind.Error, "stack": ind.Stack, "platform": bson.M{"$in": []string{ind.Platform, "all"}}},
)
return res.Err() == nil
}
func (m *MongoCrashTable) InsertCrash(ind backend.IndividualCrash) error {
first, _, _ := strings.Cut(ind.Stack, "\n")
_, err := m.col.UpdateOne(context.Background(),
bson.M{"error": ind.Error, "firstLine": first, //filter main report
"individual.stack": ind.Stack, "individual.platform": ind.Platform}, //filter individual
bson.M{"$inc": bson.M{"individual.count": 1}}, //increment count
)
if err == mongo.ErrNoDocuments {
ind.Count = 1
_, err = m.col.UpdateOne(context.Background(),
bson.M{"error": ind.Error, "firstLine": first}, //filter
bson.M{"$push": bson.M{"individual": ind}}, //Add new individual report
)
if err == mongo.ErrNoDocuments {
var id uuid.UUID
id, err = uuid.NewV7()
if err != nil {
return err
}
_, err = m.col.InsertOne(context.Background(),
backend.CrashReport{
ID: id.String(),
Error: ind.Error,
FirstLine: first,
Individual: []backend.IndividualCrash{ind},
},
)
}
}
return err
}
+1
View File
@@ -0,0 +1 @@
package db
+110
View File
@@ -0,0 +1,110 @@
package backend
import (
"errors"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
var (
ErrApiKeyUnauthorized = errors.New("api key present but invalid")
ErrTokenUnauthorized = errors.New("token present but invalid")
)
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 part of the returned error (check with errors.Is).
// 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")
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if key != "" {
apiKey, err := b.keyTable.Get(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
}
if token != "" && b.userTable != nil {
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 out, errors.Join(ErrTokenUnauthorized, err)
}
exp, _ := t.Claims.GetExpirationTime()
if exp.Time.Before(time.Now()) {
return out, ErrTokenUnauthorized
}
sub, err := t.Claims.GetSubject()
if err == jwt.ErrInvalidKey {
return out, ErrTokenUnauthorized
} else if err != nil {
return out, errors.Join(ErrTokenUnauthorized, err)
}
usr, err := b.userTable.Get(sub)
if err == jwt.ErrInvalidKey {
return out, ErrTokenUnauthorized
} else if err != nil {
return out, errors.Join(ErrTokenUnauthorized, err)
}
iss, err := t.Claims.GetIssuedAt()
if err == jwt.ErrInvalidKey {
return out, ErrTokenUnauthorized
} else if err != nil {
return out, errors.Join(ErrTokenUnauthorized, err)
}
if usr.PasswordChange > 0 && iss.Time.Before(time.Unix(usr.PasswordChange, 0)) {
return out, ErrTokenUnauthorized
}
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.
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.StatusInternalServerError, "internal", "Server error")
return nil, err
}
if err != nil && !errors.Is(err, ErrTokenUnauthorized) {
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
}
+12
View File
@@ -0,0 +1,12 @@
package backend
type ApiKey struct {
Perm map[string]bool
ID string
AppID string
Death int64
}
func (k ApiKey) GetID() string {
return k.ID
}
+129
View File
@@ -0,0 +1,129 @@
package backend
import (
"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 == "" {
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(w, count, req.Platform, curDate)
if err != nil {
log.Println("error adding to count table:", err)
}
return
}
l, err := count.Get(req.ID)
if err == ErrNotFound {
err = addToCountTable(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(req.ID, map[string]any{"date": curDate})
if err != nil {
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
return
}
json.NewEncoder(w).Encode(map[string]string{"id": req.ID})
w.WriteHeader(http.StatusCreated)
}
func addToCountTable(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(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
}
json.NewEncoder(w).Encode(map[string]string{"id": id.String()})
w.WriteHeader(http.StatusCreated)
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.URL.Query().Get("platform"))
if err != nil {
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
return
}
json.NewEncoder(w).Encode(map[string]int{"count": out})
}
+269
View File
@@ -0,0 +1,269 @@
package backend
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"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{
ID: r.ID,
Issuer: "darkstorm.tech",
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(12 * time.Hour)),
}).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"`
}
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.userMutex.Lock()
defer b.userMutex.Unlock()
matchUsername, err := b.userTable.Find(map[string]any{"username": req.Username})
if err != nil && !errors.Is(err, ErrNotFound) {
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(map[string]any{"email": req.Email})
if err != nil && !errors.Is(err, ErrNotFound) {
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 {
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
return
}
err = b.userTable.Insert(u)
if err != nil {
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 {
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
}
b.userMutex.Lock()
defer b.userMutex.Unlock()
err = b.userTable.Remove(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"`
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
}
b.userMutex.RLock()
defer b.userMutex.RUnlock()
var ret loginReturn
users, err := b.userTable.Find(map[string]any{"username": req.Username})
if errors.Is(err, ErrNotFound) || len(users) != 1 {
ret.Error = "invalid"
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(ret)
return
}
u := users[0]
if time.Unix(u.Timeout, 0).After(time.Now()) {
ret.Error = "timeout"
ret.Timeout = time.Now().Unix() - u.Timeout
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(ret)
return
}
hash, err := u.HashPassword(req.Password)
if err != nil {
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
return
}
if u.Password == hash {
ret.Token, err = b.GenerateJWT(u.toReqUser())
if err != nil {
ReturnError(w, http.StatusInternalServerError, "internal", "Server error")
return
}
json.NewEncoder(w).Encode(ret)
} else {
ret.Error = "invalid"
upd := map[string]any{"fails": u.Fails + 1}
if (u.Fails+1)%3 == 0 {
minutes := 3 ^ ((u.Fails / 3) - 1)
timeout := time.Now().Add(time.Duration(minutes) * time.Minute).Unix()
upd["timeout"] = timeout
ret.Timeout = timeout - time.Now().Unix()
}
b.userTable.PartUpdate(u.ID, upd)
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(ret)
}
}