TBD
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

701 lines
21 KiB

package main
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"math"
"net/http"
"net/url"
"os"
"path"
"runtime/debug"
"strconv"
"strings"
"text/template"
"time"
spatial "git.sequentialread.com/forest/modular-spatial-index"
errors "git.sequentialread.com/forest/pkg-errors"
"github.com/boltdb/bolt"
base58 "github.com/shengdoushi/base58"
)
type PostType int
const (
PostTypeText PostType = 1
PostTypeImage PostType = 2
PostTypeVideo PostType = 3
)
type Post struct {
Id string `json:"id,omitempty"`
SpatialKey64 string `json:"id,omitempty"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
Angle int `json:"angle,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Collider *PostCollider `json:"collider,omitempty"`
AuthorId string `json:"authorId,omitempty"`
AuthorDisplayName string `json:"authorDisplayName,omitempty"`
RandomSeed int `json:"randomSeed,omitempty"`
Type PostType `json:"type,omitempty"`
TextContent string `json:"textContent,omitempty"`
TransparentImage bool `json:"transparentImage,omitempty"`
ThumbnailJPEGBase64 string `json:"thumbnailJPEGBase64,omitempty"`
Filename string `json:"filename,omitempty"`
ContentType string `json:"contentType,omitempty"`
MillisecondsSinceUnixEpoch int64 `json:"millisecondsSinceUnixEpoch,omitempty"`
}
type PostCollider struct {
R int `json:"r,omitempty"`
X1 int `json:"x1,omitempty"`
Y1 int `json:"y1,omitempty"`
X2 int `json:"x2,omitempty"`
Y2 int `json:"y2,omitempty"`
}
type IndexTemplateData struct {
Title string
AppStateJSON string
PreloadCSS string
}
type FrontendState struct {
Version string
Invited bool
IsAuthor bool
DisplayName string
TemporaryError string
}
type Author struct {
Id string
DisplayName string
Ancestry []string
}
var db *bolt.DB
var pendingUploadMap = map[string]Post{}
var progressMap = map[string]int{}
var cookieSecureHTTPSOnly = false
var bpgencPath = "/home/forest/Desktop/git/bpg/bpgenc"
var getPostsRadius = 2000
var appVersion = "0.0.0"
var appTitle = "pride 2021 graffiti by cyberia.club"
var listenPort = 8080
var indexTemplate *template.Template
var indexCSSBytes []byte
var spatialIndex *spatial.SpatialIndex2D
// the spatial index has a worst-case (about 3x slower) performance near 0,0.
// so we simply shift the universe over by 25 units so the home page query lands in a more performant area.
var sillyHilbertOptimizationOffset = 50
// we scale the pixel-based world down by this much before storing in the spatial index,
// so that we don't run out of space on our 32 bit spatial index which is only 32766 units wide
var spatialIndexUnitSizeInPixels = float64(64)
var pixelsToSpatialIndexUnits = float64(1) / spatialIndexUnitSizeInPixels
// TODO set these to default to false for prod
// create xyz.bpg.png files (decoded from bpg) so it's easier to view them and compare the quality, etc.
var debugBPG = true
// verbose logging
var debugLog = true
func main() {
// sane defaults for golang runtime 🤮
debug.SetPanicOnFault(true)
var err error
// Force 32 bit index so it will always be compatible no matter what CPU arch.
// normally you would do spatial.NewSpatialIndex2D(bits.UintSize))
spatialIndex, err = spatial.NewSpatialIndex2D(32)
if err != nil {
log.Fatalf("can't start because can't create spatial index: %+v", err)
}
envPath := os.Getenv("BPGENC_PATH")
if envPath != "" {
bpgencPath = envPath
}
debugBPG = getBooleanEnvVar("DEBUG_BPG", debugBPG)
debugLog = getBooleanEnvVar("DEBUG_LOG", debugLog)
if os.Getenv("LOG_LEVEL") == "DEBUG" || os.Getenv("LOG_LEVEL") == "VERBOSE" {
debugLog = true
}
db, err = bolt.Open("graffiti-bolt.db", 0600, nil)
if err != nil {
log.Fatalf("can't start because can't open/create database file graffiti-bolt.db: %s", err)
}
defer db.Close()
err = db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte("invite_codes"))
if err == nil {
_, err = tx.CreateBucketIfNotExists([]byte("authors"))
}
if err == nil {
_, err = tx.CreateBucketIfNotExists([]byte("posts_by_author"))
}
if err == nil {
_, err = tx.CreateBucketIfNotExists([]byte("posts_spatial"))
}
if err == nil {
_, err = tx.CreateBucketIfNotExists([]byte("bpg_encode_queue"))
}
return err
})
if err != nil {
log.Fatalf("can't start because can't initialize the database: %s", err)
}
indexTemplateBytes, err := ioutil.ReadFile("index.gotemplate.html")
if err == nil {
indexTemplate, err = template.New("index.html").Parse(string(indexTemplateBytes))
}
if err != nil {
log.Fatalf("can't start becasue can't read index.gotemplate.html: %s", err)
}
indexCSSBytes, err = ioutil.ReadFile("index.css")
if err != nil {
log.Fatalf("can't start becasue can't read index.css: %s", err)
}
mux := http.NewServeMux()
setupRoutes(mux)
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/"))))
mux.Handle("/user-content/", http.StripPrefix("/user-content/", http.FileServer(http.Dir("./user-content/"))))
go runBPGEncodeQueueConsumer()
log.Printf(" 🎨🖼️🖌️ Graffiti is listening on ':%d'\n", listenPort)
err = http.ListenAndServe(fmt.Sprintf(":%d", listenPort), mux)
// if it got this far it means the server crashed!
panic(errors.Wrap(err, "http server crashed"))
}
func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/", func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path != "" && request.URL.Path != "/" {
http.Error(response, "Not Found", http.StatusNotFound)
return
}
author := getAuthor(db, request)
displayName := ""
if author != nil {
displayName = author.DisplayName
}
temporaryError := getCookie(request, "graffiti_temporary_error")
if temporaryError != "" {
setCookie(response, "graffiti_temporary_error", "", 0)
}
stateJSONBytes, err := json.MarshalIndent(
FrontendState{
Version: appVersion,
Invited: getCookie(request, "graffiti_invite_code") != "",
IsAuthor: author != nil,
DisplayName: displayName,
TemporaryError: temporaryError,
},
" ", " ",
)
var buffer bytes.Buffer
if err == nil {
err = indexTemplate.Execute(
&buffer,
IndexTemplateData{
Title: appTitle,
AppStateJSON: strings.TrimSpace(string(stateJSONBytes)),
PreloadCSS: string(indexCSSBytes),
},
)
}
if err != nil {
log.Printf("internal server error: indexTemplate.Execute: %+v\n", err)
http.Error(response, "internal server error", http.StatusInternalServerError)
return
}
response.Header().Set("Content-Type", "text/html")
response.Header().Set("Content-Length", strconv.Itoa(buffer.Len()))
io.Copy(response, &buffer)
})
mux.HandleFunc("/invite-code/", func(response http.ResponseWriter, request *http.Request) {
code := getLastElementInURLPath(request.URL)
setCookie(response, "graffiti_invite_code", code, 60*60*24)
http.Redirect(response, request, "/", http.StatusFound)
})
mux.HandleFunc("/register", func(response http.ResponseWriter, request *http.Request) {
code := getCookie(request, "graffiti_invite_code")
displayName := request.URL.Query().Get("displayName")
err := db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte("invite_codes"))
authorsBucket := tx.Bucket([]byte("authors"))
isFirstAuthor := authorsBucket.Stats().KeyN == 0
parentAuthorIdBytes := bucket.Get([]byte(code))
parentAuthorId := ""
if parentAuthorIdBytes != nil {
parentAuthorId = string(parentAuthorIdBytes)
}
//log.Printf("parentAuthorId != \"\" || isFirstAuthor: %t || %t", parentAuthorId != "", isFirstAuthor)
if parentAuthorId != "" || isFirstAuthor {
newAuthorId, err := createAuthor(tx, parentAuthorId, displayName)
if err != nil {
return err
}
bucket.Delete([]byte(code))
setCookie(response, "graffiti_author_id", newAuthorId, 60*60*24*365)
} else {
setCookie(response, "graffiti_temporary_error", "invite_code_missing_or_already_used", 60*60)
}
return nil
})
if err != nil {
log.Printf("internal server error: registration transaction failed: %+v\n", err)
http.Error(response, "internal server error", http.StatusInternalServerError)
return
}
http.Redirect(response, request, "/", http.StatusFound)
})
mux.HandleFunc("/posts", func(response http.ResponseWriter, request *http.Request) {
query := request.URL.Query()
xString := query.Get("x")
yString := query.Get("y")
x, err := strconv.Atoi(xString)
var y int
if err == nil {
y, err = strconv.Atoi(yString)
}
if err != nil {
http.Error(response, "Bad Request, x and y are required", http.StatusBadRequest)
}
posts, err := getPosts(x, y, getPostsRadius)
if err != nil {
log.Printf("/posts: getPosts(%d, %d, %d): %+v\n", x, y, getPostsRadius, err)
http.Error(response, "internal server error", http.StatusInternalServerError)
}
contentLength := 2 // two for the surrounding array square brackets []
for _, post := range posts {
contentLength += len(post) // all of the post json objects
}
if len(posts) > 1 {
contentLength += len(posts) - 1 // one for each comma between the objects
}
response.Header().Add("Content-Length", strconv.Itoa(contentLength))
response.Write([]byte("["))
for i, post := range posts {
response.Write(post)
if i < len(posts)-1 {
response.Write([]byte(","))
}
}
response.Write([]byte("]"))
})
mux.HandleFunc("/upload-meta", func(response http.ResponseWriter, request *http.Request) {
if request.Method == "POST" {
author := getAuthor(db, request)
if author == nil {
log.Println("/upload-meta 401 unauthorized: graffiti_author_id cookie is missing or invalid")
http.Error(response, "Unauthorized: graffiti_author_id cookie is missing or invalid", http.StatusUnauthorized)
return
}
var newPost Post
err := json.NewDecoder(request.Body).Decode(&newPost)
if err != nil {
log.Printf("/upload-meta 400 bad request: json.NewDecoder(request.Body).Decode(): %+v\n", err)
http.Error(response, "Bad Request", http.StatusBadRequest)
return
}
if newPost.Type != PostTypeText && newPost.Type != PostTypeImage && newPost.Type != PostTypeVideo {
log.Println("/upload-meta 400 bad request: Unknown post type")
http.Error(response, "Bad Request: Unknown post type", http.StatusBadRequest)
return
}
postIdBuffer := make([]byte, 16)
rand.Read(postIdBuffer)
postId := base58.Encode(postIdBuffer, base58.BitcoinAlphabet)
newPost.Id = postId
newPost.AuthorId = author.Id
//newPost.Filename = regexp.MustCompile(`[^a-zA-Z0-9._-]+`).ReplaceAllString(newPost.Filename, "_")
newPost.Filename = postId
if newPost.Type != PostTypeText {
pendingUploadMap[postId] = newPost
} else {
// TODO add post to DB
}
response.Write([]byte(postId))
} else {
response.Header().Set("Allow", "POST")
http.Error(response, "Try POSTing instead", http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/upload/", func(response http.ResponseWriter, request *http.Request) {
if request.Method == "POST" {
uploadId := getLastElementInURLPath(request.URL)
if uploadId == "" {
log.Println("/upload 400: malformed url")
http.Error(response, "malformed url", http.StatusBadRequest)
return
}
post, hasPost := pendingUploadMap[uploadId]
if !hasPost {
log.Printf("/upload 404: upload '%s' not found\n", uploadId)
http.Error(response, fmt.Sprintf("upload '%s' not found", uploadId), http.StatusNotFound)
return
}
multipartReader, err := request.MultipartReader()
if err != nil {
log.Printf("/upload 400: failed to read multipart upload: %+v\n", err)
http.Error(response, "failed to read multipart upload", http.StatusBadRequest)
return
}
length := request.ContentLength
filepath := path.Join("user-content", post.Filename)
dstFile, err := os.OpenFile(filepath, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
log.Printf("/upload 500: os.OpenFile(%s, os.O_RDWR|os.O_CREATE, 0644): %+v\n", filepath, err)
http.Error(response, "internal server error", http.StatusInternalServerError)
return
}
defer dstFile.Close()
// the image encoding also updates the progress.
// 1 for decoding the image.
// ---
// 1 for each time it resizes the image
// 4 for every bpg encoding
// 1 for every jpeg encoding
// = 1 + 6*4 = 25
// so the upload has to only push the progress up to 75%
const lastStepsCount = 25
for {
part, err := multipartReader.NextPart()
if err == io.EOF {
break
}
var read int64
for {
//time.Sleep(time.Millisecond * 10)
buffer := make([]byte, 1000)
cBytes, err := part.Read(buffer)
if err != nil && err != io.EOF {
http.Error(response, "an error occurred while uploading this file, please try again :(", http.StatusInternalServerError)
fmt.Printf("an error occurred while reading the multipart request body: %s\n", err)
return
}
if cBytes > 0 {
read = read + int64(cBytes)
progressMap[uploadId] = int(float32(read) / float32(length) * (100 - lastStepsCount))
_, err := dstFile.Write(buffer[0:cBytes])
if err != nil {
http.Error(response, "an error occurred while uploading this file, please try again :(", http.StatusInternalServerError)
fmt.Printf("an error occurred while writing to %s: %s\n", filepath, err)
return
}
}
if err == io.EOF {
break
}
}
}
_, err = dstFile.Seek(0, 0)
if err != nil {
log.Printf("/upload 500: dstFile.Seek(0,0): %+v\n", err)
http.Error(response, "internal server error", http.StatusInternalServerError)
return
}
determineContentTypeBuffer := make([]byte, 512)
_, err = dstFile.Read(determineContentTypeBuffer)
if err != nil {
log.Printf("/upload 500: dstFile.Read(determineContentTypeBuffer): %+v\n", err)
http.Error(response, "internal server error", http.StatusInternalServerError)
return
}
contentType := http.DetectContentType(determineContentTypeBuffer)
_, err = dstFile.Seek(0, 0)
if err != nil {
log.Printf("/upload 500: dstFile.Seek(0,0): %+v\n", err)
http.Error(response, "internal server error", http.StatusInternalServerError)
return
}
// handleImageEncodingOrDieTrying will respond with an http error if it fails
// -- so we dont handle errors, we continue *only* if there are no errors.
thumbnailJPEGBase64, err := handleImageEncodingOrDieTrying(response, dstFile, contentType, post.Filename, uploadId)
if err == nil {
post.ThumbnailJPEGBase64 = thumbnailJPEGBase64
err := createPost(&post)
if err != nil {
log.Printf("/upload 500: createPost(): %+v\n", err)
http.Error(response, "internal server error", http.StatusInternalServerError)
return
}
}
} else {
response.Header().Set("Allow", "POST")
http.Error(response, "Try POSTing instead", http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/progress/", func(response http.ResponseWriter, request *http.Request) {
uploadId := getLastElementInURLPath(request.URL)
if uploadId == "" {
http.Error(response, "malformed url", http.StatusNotFound)
return
}
progress, hasProgress := progressMap[uploadId]
if !hasProgress {
http.Error(response, fmt.Sprintf("upload '%s' not found", uploadId), http.StatusNotFound)
return
}
response.Header().Add("Content-Type", "text/plain; charset=utf-8")
response.Write([]byte(strconv.Itoa(progress)))
})
}
func createPost(post *Post) error {
return db.Update(func(tx *bolt.Tx) error {
spatialX := int(math.Round(float64(post.X)*pixelsToSpatialIndexUnits)) + sillyHilbertOptimizationOffset
spatialY := int(math.Round(float64(post.Y)*pixelsToSpatialIndexUnits)) + sillyHilbertOptimizationOffset
spatialKey, err := spatialIndex.GetIndexedPoint(spatialX, spatialY)
if err != nil {
return err
}
byAuthorBucket := tx.Bucket([]byte("posts_by_author"))
byAuthorKey := []byte(fmt.Sprintf("%s-%s", post.AuthorId, post.Id))
postByAuthorBytes, err := json.Marshal(&Post{
Id: post.Id,
SpatialKey64: base64.StdEncoding.EncodeToString(spatialKey),
X: post.X,
Y: post.Y,
})
if err != nil {
return err
}
err = byAuthorBucket.Put(byAuthorKey, postByAuthorBytes)
if err != nil {
return err
}
postsBucket := tx.Bucket([]byte("posts_spatial"))
post.MillisecondsSinceUnixEpoch = MillisecondsSinceUnixEpoch()
postBytes, err := json.Marshal(post)
if err != nil {
return err
}
err = postsBucket.Put(spatialKey, postBytes)
if err != nil {
return err
}
return nil
})
}
func getPosts(x, y, radius int) ([][]byte, error) {
spatialXMin := int(math.Floor(float64(x-radius)*pixelsToSpatialIndexUnits)) + sillyHilbertOptimizationOffset
spatialYMin := int(math.Floor(float64(x-radius)*pixelsToSpatialIndexUnits)) + sillyHilbertOptimizationOffset
spatialWidth := int(math.Ceil(float64(radius*2) * pixelsToSpatialIndexUnits))
spatialHeight := int(math.Ceil(float64(radius*2) * pixelsToSpatialIndexUnits))
spatialXMax := spatialXMin + spatialWidth
spatialYMax := spatialYMin + spatialHeight
if debugLog {
log.Printf("getPosts(): RectangleToIndexedRanges(%d, %d, %d, %d, 1)\n", spatialXMin, spatialYMin, spatialWidth, spatialHeight)
}
ranges, err := spatialIndex.RectangleToIndexedRanges(spatialXMin, spatialYMin, spatialWidth, spatialHeight, 1)
if err != nil {
return nil, err
}
if debugLog {
log.Printf("getPosts(): spatialIndex range count: %d\n---\n", len(ranges))
for _, rng := range ranges {
fmt.Printf("%x\n%x\n---\n", rng.Start[0:8], rng.End[0:8])
}
}
toReturn := [][]byte{}
err = db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte("posts_spatial"))
for _, rng := range ranges {
cursor := bucket.Cursor()
key, value := cursor.Seek(rng.Start)
for key != nil && bytes.Compare(key, rng.End) < 1 {
spatialX, spatialY, err := spatialIndex.GetPositionFromIndexedPoint(key)
if err != nil {
return err
}
if spatialX > spatialXMin && spatialX < spatialXMax && spatialY > spatialYMin && spatialY < spatialYMax {
toReturn = append(toReturn, value)
}
key, value = cursor.Next()
}
}
return nil
})
return toReturn, err
}
func createAuthor(tx *bolt.Tx, parentAuthorId string, displayName string) (string, error) {
bucket := tx.Bucket([]byte("authors"))
newAuthorAncestry := []string{}
parentAuthorBytes := bucket.Get([]byte(parentAuthorId))
if parentAuthorBytes != nil {
var parentAuthor Author
err := json.Unmarshal(parentAuthorBytes, &parentAuthor)
if err != nil {
return "", err
}
newAuthorAncestry = append(parentAuthor.Ancestry, parentAuthorId)
}
newAuthorId := getRandomId(8)
newAuthorBytes, err := json.Marshal(Author{
Id: newAuthorId,
DisplayName: displayName,
Ancestry: newAuthorAncestry,
})
if err != nil {
return "", err
}
err = bucket.Put([]byte(newAuthorId), newAuthorBytes)
if err != nil {
return "", err
}
return newAuthorId, nil
}
func getAuthor(db *bolt.DB, request *http.Request) *Author {
var author *Author = nil
err := db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte("authors"))
authorBytes := bucket.Get([]byte(getCookie(request, "graffiti_author_id")))
if authorBytes != nil {
var temp Author
err := json.Unmarshal(authorBytes, &temp)
if err != nil {
return err
}
author = &temp
}
return nil
})
if err != nil {
log.Printf("error in getAuthor(): %s\n", err)
}
return author
}
func getRandomId(bytesCount int) string {
randomIdBuffer := make([]byte, bytesCount)
rand.Read(randomIdBuffer)
return base58.Encode(randomIdBuffer, base58.BitcoinAlphabet)
}
func setCookie(response http.ResponseWriter, key, value string, maxAge int) {
http.SetCookie(response, &http.Cookie{
Name: key,
HttpOnly: true,
Secure: cookieSecureHTTPSOnly,
SameSite: http.SameSiteStrictMode,
Path: "/",
Value: value,
MaxAge: maxAge,
})
}
func getCookie(request *http.Request, name string) string {
for _, cookie := range request.Cookies() {
if cookie.Name == name {
return cookie.Value
}
}
return ""
}
func getLastElementInURLPath(url *url.URL) string {
rawParts := strings.Split(url.Path, "/")
pathParts := []string{}
for _, p := range rawParts {
if p != "" {
pathParts = append(pathParts, p)
}
}
if len(pathParts) > 0 {
return pathParts[len(pathParts)-1]
}
return ""
}
func getBooleanEnvVar(envVar string, defaultValue bool) bool {
str := strings.ToLower(os.Getenv("DEBUG_BPG"))
if str != "" {
return str == "1" || str == "t" || str == "y" || str == "true" || str == "yes"
}
return defaultValue
}
func TimeFromMillisecondsSinceUnixEpoch(timestamp int64) time.Time {
return time.Unix(timestamp/int64(1000), 0)
}
func MillisecondsSinceUnixEpoch() int64 {
return time.Now().UnixNano() / int64(time.Millisecond)
}