initial commit

This commit is contained in:
silva guimaraes 2025-04-21 17:59:57 -03:00
commit c21f569144
37 changed files with 3956 additions and 0 deletions

69
routes/auth.go Normal file
View file

@ -0,0 +1,69 @@
package routes
import (
"fmt"
"foobar/database"
"net/http"
"os"
"github.com/gorilla/sessions"
"github.com/joho/godotenv" // Carrega .env
)
var store *sessions.CookieStore
const sessionIdCookie = "session_id"
func init() {
err := godotenv.Load()
if err != nil {
panic(err)
}
key := os.Getenv("SESSION_STORE_KEY")
if key == "" {
panic(fmt.Errorf("chave da sessão de cookies não foi definida."))
}
store = sessions.NewCookieStore([]byte(key))
}
func currentUser(r *http.Request) (*database.Usuario, error) {
session, err := store.Get(r, sessionIdCookie)
if err != nil {
logError(err)
return nil, err
}
id, ok := session.Values["userId"].(int)
if !ok {
return nil, fmt.Errorf("not ok")
}
tx := database.MustBeginTx()
defer tx.Rollback()
usuario, err := database.FindUsuarioByID(tx, id)
if err != nil {
return nil, err
}
return usuario, nil
}
func redirectUnauthorized(w http.ResponseWriter, r *http.Request) {
session, err := store.Get(r, sessionIdCookie)
if err != nil {
logInternalError(w, err)
return
}
session.AddFlash("Autenticação necessária.")
err = session.Save(r, w)
if err != nil {
logInternalError(w, err)
return
}
http.Redirect(w, r, "/index.html", http.StatusFound)
}

69
routes/box.go Normal file
View file

@ -0,0 +1,69 @@
package routes
import (
"database/sql"
"errors"
"foobar/database"
"foobar/model"
"foobar/views"
"net/http"
"github.com/a-h/templ"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"github.com/microcosm-cc/bluemonday"
)
func boxUpload(w http.ResponseWriter, r *http.Request) (templ.Component, error) {
boxURL, err := GetBoxURL(r)
if err != nil {
return nil, err
}
box, err := database.SelectBox(boxURL)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return views.NewBox(boxURL, readmeTemplate), nil
} else {
return nil, err
}
}
return views.BoxUpload(box), nil
}
func checkBox(w http.ResponseWriter, r *http.Request) (templ.Component, error) {
boxURL, err := GetBoxURL(r)
if err != nil {
return nil, err
}
box, err := database.SelectBox(boxURL)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return views.Box(boxURL, []model.File{}), nil
}
return nil, err
}
boxFiles, err := database.SelectBoxFiles(box.ID())
if err != nil {
return nil, err
}
return views.Box(boxURL, boxFiles), nil
}
func mdToHTML(md []byte) model.Markdown {
// create markdown parser with extensions
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
p := parser.NewWithExtensions(extensions)
doc := p.Parse(md)
// create HTML renderer with extensions
htmlFlags := html.CommonFlags | html.HrefTargetBlank
opts := html.RendererOptions{Flags: htmlFlags}
renderer := html.NewRenderer(opts)
sanitizer := bluemonday.UGCPolicy()
return model.Markdown(sanitizer.SanitizeBytes(markdown.Render(doc, renderer)))
}

204
routes/file.go Normal file
View file

@ -0,0 +1,204 @@
package routes
import (
"bytes"
"crypto/md5"
"database/sql"
"errors"
"fmt"
"foobar/database"
"foobar/model"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
)
var maxFileSize int64 = 32 * 1000 * 1000
func fileUpload(w http.ResponseWriter, r *http.Request) (redirectURL, error) {
boxURL, err := GetBoxURL(r)
if err != nil {
return noRedirect, err
}
tx := database.MustBeginTx()
defer tx.Rollback()
box, err := database.SelectBox(boxURL)
if err != nil {
if errors.Is(err, sql.ErrNoRows) { // this is a new URL the user wants to claim for themselves
editCode := r.FormValue("edit_code")
if editCode == "" {
return noRedirect, newUserError(
"Missing edit code. Box can't be created without it",
)
}
var (
id = uuid.New()
createdAt = time.Now()
lastUpdatedAt = createdAt
header = readmeTemplate
)
newBox := model.NewBox(
id, boxURL, editCode, header, false, false, createdAt, lastUpdatedAt,
)
err = database.InsertBox(tx, newBox, editCode, header)
if err != nil {
return noRedirect, err
}
box = newBox
} else {
return noRedirect, err
}
}
filesCtx, err := GetFilesFromUploadRequest(r)
if err != nil {
return noRedirect, err
}
var files []model.File
for _, file := range filesCtx {
bytes, err := readFileBytes(file.FileHeader())
if err != nil {
return noRedirect, err
}
mime := model.FileMime(http.DetectContentType(bytes))
if file.Filename() == "README.md" {
editCode := r.FormValue("edit_code")
if editCode == "" {
return noRedirect, newUserError(
"Missing edit code. README can't be updated. " +
"If you're not attempting to update the header, we'd " +
"suggest changing \"README.md\" to a new filename.",
)
}
if editCode != box.EditCode() {
return noRedirect, newUserError(
"Wrong edit code.",
)
}
md := mdToHTML(bytes)
err := database.UpdateHeader(tx, box.ID(), md)
if err != nil {
return noRedirect, err
}
if err := tx.Commit(); err != nil {
return noRedirect, err
}
// FIXME: we're ignoring all other uploaded files
return redirectURL(fmt.Sprintf("/box/%s", box.Url())), nil
} else {
err = copyBytesToDisk(file.ID(), bytes)
if err != nil {
return "", err
}
checksum := calculateMD5(bytes)
newFile := model.NewFile(
file.ID(), file.Filename(), file.Size(),
file.UploadedDate(), mime, checksum,
)
files = append(files, newFile)
}
}
for _, file := range files {
err = database.InsertFile(tx, box.ID(), file)
if err != nil {
return noRedirect, err
}
}
if err = tx.Commit(); err != nil {
return noRedirect, err
}
return redirectURL(fmt.Sprintf("/box/%s/inside", box.Url())), nil
}
func calculateMD5(fileBytes []byte) model.MD5Checksum {
return md5.Sum(fileBytes)
}
func readFileBytes(fileHeader *multipart.FileHeader) ([]byte, error) {
fileReader, err := fileHeader.Open()
if err != nil {
panic(err)
}
defer fileReader.Close()
fileBytes, err := io.ReadAll(fileReader)
if err != nil {
return nil, err
}
return fileBytes, nil
}
func copyBytesToDisk(fileId uuid.UUID, fileBytes []byte) error {
fileWriter, err := os.Create(filepath.Join(
staticDir, "box", fmt.Sprintf(fileId.String())),
)
if err != nil {
return err
}
defer fileWriter.Close()
_, err = io.Copy(fileWriter, bytes.NewReader(fileBytes))
if err != nil {
return err
}
return nil
}
func GetFilesFromUploadRequest(r *http.Request) ([]model.FileUploadContext, error) {
err := r.ParseMultipartForm(int64(maxFileSize))
if err != nil {
return nil, err
}
headers, ok := r.MultipartForm.File["file"]
if !ok {
return nil, newUserError("no file")
}
var (
files []model.FileUploadContext
)
for _, header := range headers {
var (
id = uuid.New()
uploadedDate = time.Now()
size = header.Size
filename = model.Filename(header.Filename)
mime = model.MimeFromHeader(header.Header)
)
fileUpload := model.NewFileUploadContext(id, uploadedDate, size, filename, mime, header)
files = append(files, fileUpload)
}
return files, nil
}

97
routes/logging.go Normal file
View file

@ -0,0 +1,97 @@
package routes
import (
"errors"
"fmt"
"foobar/metrics"
"log"
"net/http"
"runtime/debug"
"time"
)
type wrappedWriter struct {
http.ResponseWriter
statusCode int
}
func (w *wrappedWriter) WriteHeader(statusCode int) {
w.ResponseWriter.WriteHeader(statusCode)
w.statusCode = statusCode
}
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &wrappedWriter{
ResponseWriter: w,
statusCode: http.StatusOK,
}
var panicked bool
err := recoverServeHTTP(next, wrapped, r)
if err != nil {
wrapped.Write([]byte("500 error"))
log.Println(err)
panicked = true
}
var (
latency = time.Since(start)
path = r.URL.Path
statusCode = wrapped.statusCode
)
if path == "/metrics" && statusCode == 200 {
return
}
log.Printf("%v %v %v %v", wrapped.statusCode, r.Method, r.URL.Path, latency)
metrics.ResponseLatencyObserve(latency, statusCode, panicked, r)
})
}
func recoverServeHTTP(next http.Handler, w http.ResponseWriter, r *http.Request) (e error) {
defer func() {
if rec := recover(); rec != nil {
var err error
switch v := rec.(type) {
case error:
err = v
default:
err = fmt.Errorf("%v", v)
}
stack := debug.Stack()
w.WriteHeader(http.StatusInternalServerError)
e = errors.Join(err, fmt.Errorf(string(stack)))
}
}()
next.ServeHTTP(w, r)
return
}
func logError(err error) {
if err == nil {
log.Printf("LOG: Erro Nulo!?\n")
} else {
log.Printf("LOG: %s\n", err.Error())
}
log.Println(string(debug.Stack()))
}
func logInternalError(w http.ResponseWriter, err error) {
logError(err)
http.Error(w, `500`, http.StatusInternalServerError)
}
func httpErrorf(w http.ResponseWriter, status int, formatString string, format ...interface{}) {
http.Error(w, fmt.Sprintf(formatString, format...), status)
}
func formValueMissing(field string, r *http.Request) error {
return fmt.Errorf(
"campo '%s' em formulário não pôde ser encontrado. Content-Type: %s",
field,
r.Header.Get("Content-Type"),
)
}

97
routes/middleware.go Normal file
View file

@ -0,0 +1,97 @@
package routes
import (
"context"
"errors"
"fmt"
"foobar/views"
"net/http"
"github.com/a-h/templ"
)
type userError struct {
error
user string
}
type redirectURL string
type postRouteRedirectFunc func(http.ResponseWriter, *http.Request) (redirectURL, error)
type routeGetFunc func(http.ResponseWriter, *http.Request) (templ.Component, error)
var (
errUser = errors.New("user error")
)
func newUserError(msg string) *userError {
return &userError{
user: msg,
}
}
func (u *userError) Error() string {
return u.user
}
func (u redirectURL) Valid() bool {
return len(u) > 0 && u[0] == '/'
}
func getRouteMiddleware(fun routeGetFunc) http.HandlerFunc {
// TODO: handle boosted HTMX requests gracefully
return func(w http.ResponseWriter, r *http.Request) {
component, err := fun(w, r)
if err != nil {
logInternalError(w, err)
return
}
err = component.Render(context.Background(), w)
if err != nil {
logInternalError(w, err)
return
}
}
}
func redirectHtmxFormMiddleware(fun postRouteRedirectFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
hxRequest := r.Header.Get("HX-Request") == "true"
redirectURL, err := fun(w, r)
if err != nil {
w.Header().Add("HX-Retarget", "#error-target")
w.Header().Add("HX-Reswap", "innerHTML")
w.WriteHeader(http.StatusUnprocessableEntity)
if u, ok := err.(*userError); ok {
if hxRequest {
err = views.ErrorBox(u.user).Render(context.Background(), w)
if err != nil {
logError(fmt.Errorf("%w: %s", err, r.URL.Path))
}
} else {
_, _ = w.Write([]byte(u.user))
}
return
} else {
msg := "Internal error... See the server logs for more information"
_ = views.ErrorBox(msg).Render(context.Background(), w)
logError(err)
return
}
}
if !redirectURL.Valid() {
panic("not implemented")
}
if hxRequest {
w.Header().Add("HX-Redirect", string(redirectURL))
} else {
http.Redirect(w, r, string(redirectURL), http.StatusFound)
}
}
}

178
routes/routes.go Normal file
View file

@ -0,0 +1,178 @@
package routes
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"foobar/database"
"foobar/model"
"foobar/views"
"net/http"
"os"
"path/filepath"
"text/template"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/crypto/bcrypt"
)
const noRedirect redirectURL = ""
var (
mux *http.ServeMux = nil
errBoxNotFound error = fmt.Errorf("box id not found")
staticDir string = filepath.Join("views", "static")
)
var readmeTemplate model.Markdown
func init() {
mux = http.NewServeMux()
if err := os.MkdirAll(staticDir, 777); err != nil {
panic(err)
}
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(os.DirFS(staticDir))))
mux.HandleFunc("/", index)
mux.HandleFunc("POST /box/{box}", redirectHtmxFormMiddleware(fileUpload))
mux.HandleFunc("GET /box/{box}", getRouteMiddleware(boxUpload))
mux.HandleFunc("GET /box/{box}/inside", getRouteMiddleware(checkBox))
mux.HandleFunc("/panic", func(w http.ResponseWriter, r *http.Request) {
panic("não implementado")
})
mux.Handle("/metrics", promhttp.Handler())
var buf bytes.Buffer
err := template.Must(template.ParseFiles(filepath.Join(staticDir, "default", "README.md"))).
Execute(&buf, nil)
if err != nil {
panic(err)
}
readmeTemplate = mdToHTML(buf.Bytes())
}
func Mux() http.Handler {
return logging(mux)
}
func notFound(w http.ResponseWriter) {
w.WriteHeader(404)
if err := views.FourOfour().Render(context.Background(), w); err != nil {
logInternalError(w, err)
return
}
}
func index(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/index.html" && r.URL.Path != "/" || r.Method != "GET" {
notFound(w)
return
}
if err := views.Index().Render(context.Background(), w); err != nil {
logInternalError(w, err)
return
}
}
func GetBoxURL(r *http.Request) (model.BoxURL, error) {
return model.CheckBoxURL(r.PathValue("box"))
}
// func sobre(w http.ResponseWriter, r *http.Request) {
// if err := views.Sobre().Render(context.Background(), w); err != nil {
// logInternalError(w, err)
// return
// }
// }
// func logIn(w http.ResponseWriter, r *http.Request) {
//
// _, err := currentUser(r)
//
// userLogged := err == nil
//
// if userLogged {
// http.Redirect(w, r, "/logged", http.StatusFound)
// return
// }
//
// if err := views.Login().Render(context.Background(), w); err != nil {
// logInternalError(w, err)
// return
// }
// }
func logInPOST(w http.ResponseWriter, r *http.Request) (redirectURL, error) {
email := r.FormValue("email")
if email == "" {
return "", formValueMissing("email", r)
}
senha := r.FormValue("senha")
if senha == "" {
return "", formValueMissing("senha", r)
}
tx := database.MustBeginTx()
defer tx.Rollback()
usuario, err := database.VerifyUser(tx, email, senha)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return "", newUserError("senha ou email não conferem")
} else {
return "", err
}
}
session, err := store.Get(r, sessionIdCookie)
if err != nil {
return "", err
}
userId, err := usuario.Id()
if err != nil {
return "", err
}
session.Values["userId"] = userId
session.Options.SameSite = http.SameSiteDefaultMode
if err := session.Save(r, w); err != nil {
return "", err
}
return "/logged", nil
}
func logged(w http.ResponseWriter, r *http.Request) {
usuario, err := currentUser(r)
if err != nil {
logError(err)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Não Autorizado."))
return
}
if err = views.Logged(usuario).Render(context.Background(), w); err != nil {
logInternalError(w, err)
return
}
}
func logout(w http.ResponseWriter, r *http.Request) {
session, err := store.Get(r, sessionIdCookie)
if err != nil {
logInternalError(w, err)
return
}
session.Options.MaxAge = -1
if err = session.Save(r, w); err != nil {
logInternalError(w, err)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
}