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

32
.air.toml Normal file
View file

@ -0,0 +1,32 @@
root = "."
tmp_dir = "tmp"
[build]
bin = "./tmp/main"
cmd = "templ generate && tailwindcss -i views/static/css/input.css -o views/static/css/output.css && go build -v -o ./tmp/main ./server/server.go"
delay = 500
exclude_dir = ["assets", "tmp", "vendor", "data"]
exclude_file = []
exclude_regex = [".*_templ.go", "output.css"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "templ", "html", "css"]
# kill_delay = "0s"
log = "build-errors.log"
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
time = false
[misc]
clean_on_exit = false

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.env
views/static/css/output.css
*_templ.go
tmp/*
routes/tmp/*
views/static/box/*

31
Dockerfile Normal file
View file

@ -0,0 +1,31 @@
FROM golang:1.23
WORKDIR /app
COPY views views
COPY server server
COPY database database
COPY routes routes
COPY tailwind.config.js ./
COPY go.mod go.sum ./
COPY Makefile ./
# não é legal
COPY .env ./
RUN wget https://github.com/tailwindlabs/tailwindcss/releases/download/v4.0.3/tailwindcss-linux-x64 \
--quiet \
-O tailwindcss
RUN chmod a+x tailwindcss
RUN go install github.com/a-h/templ/cmd/templ@latest
RUN templ generate .
# make não consegue encontrar o tailwind sem que ajustemos o PATH
RUN PATH=$PATH:/app make build
EXPOSE 8888
CMD ["/app/base"]

26
Makefile Normal file
View file

@ -0,0 +1,26 @@
.PHONY: dev build build-docker tw templ db_teste goose
include .env
dev:
sudo docker compose up db adminer prometheus -d
air
build: tw templ
go build -v -o base ./server
build-docker:
sudo docker compose up --force-recreate --build -d
tw:
tailwindcss -i views/static/css/input.css -o views/static/css/output.css
templ:
templ generate
db-teste: goose
psql -h localhost -U ${PG_USER} ${PG_DB} < database/testdata/teste.sql
goose:
goose postgres "postgres://${PG_USER}:${PG_PASS}@${PG_ADDR}/${PG_DB}" up -dir database/migrations

6
README.md Normal file
View file

@ -0,0 +1,6 @@
# **This box URL belongs to me!**
teste
<script>alert("if this is executed it means i screwed up")</script>

75
database/box.go Normal file
View file

@ -0,0 +1,75 @@
package database
import (
"database/sql"
"foobar/model"
"time"
"github.com/google/uuid"
)
func SelectBox(boxURL model.BoxURL) (model.Box, error) {
row := instance.QueryRow(
`SELECT id, edit_code, header, private, moderation, created_at, last_updated_at
FROM box WHERE url = $1;`,
boxURL,
)
if row.Err() != nil {
return model.Box{}, row.Err()
}
var (
id uuid.UUID
edit_code string
header *model.Markdown
private bool
moderation bool
createdAt time.Time
lastUpdatedAt time.Time
)
err := row.Scan(&id, &edit_code, &header, &private, &moderation, &createdAt, &lastUpdatedAt)
if err != nil {
return model.Box{}, err
}
if header == nil {
header = new(model.Markdown)
*header = ""
}
return model.NewBox(id, boxURL, edit_code, *header, private, moderation, createdAt, lastUpdatedAt), nil
}
func UpdateHeader(tx *sql.Tx, boxId uuid.UUID, text model.Markdown) error {
result, err := tx.Exec(`UPDATE box SET header = $1 where id = $2`, text, boxId)
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n != 1 {
return errNoRowsAffected
}
return nil
}
func InsertBox(tx *sql.Tx, box model.Box, editCode string, header model.Markdown) error {
result, err := tx.Exec(
`INSERT INTO box (id, url, edit_code, header, private, moderation, created_at, last_updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
box.ID(), box.Url(), editCode, header, box.Private(), box.Moderation(),
box.CreatedAt(), box.LastUpdatedAt(),
)
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n != 1 {
return errNoRowsAffected
}
return nil
}

86
database/database.go Normal file
View file

@ -0,0 +1,86 @@
package database
import (
"context"
"database/sql"
"embed"
"fmt"
"log"
"os"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/joho/godotenv"
"github.com/pressly/goose/v3"
)
var (
instance *sql.DB
errNoRowsAffected = fmt.Errorf("no rows affected")
//go:embed migrations/*.sql
embedMigrations embed.FS
)
func New() *sql.DB {
// Evita criar mais de um banco
if instance != nil {
return instance
}
// Carrega as variáveis de ambiente definadas no arquivo .env na raiz do projeto.
err := godotenv.Load()
if err != nil {
panic(err)
}
// Para rodar os testes desse pacote, É necessário que haja um symlink de um .env nesta
// pasta (database/) apontando para o .env no topo do projeto, já que 'go test' usa o
// diretório raiz do pacote (./database) para executar os testes enquanto 'go run' usa o diretório
// raiz do projeto.
// variáveis ambientais de .env são carregadas para o ambiente do nosso processo, basta
// usar a forma padrão para pegar os valores dessas variáveis.
var (
user = os.Getenv("PG_USER")
pass = os.Getenv("PG_PASS")
dbName = os.Getenv("PG_DB")
hostAddr = os.Getenv("PG_ADDR")
)
connectionString := fmt.Sprintf("postgresql://%s:%s@%s:5432/%s", user, pass, hostAddr, dbName)
db, err := sql.Open("pgx", connectionString)
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
panic(err)
}
// Verifica se a conexão foi um sucesso
if err := db.Ping(); err != nil {
panic(err)
}
goose.SetBaseFS(embedMigrations)
if err := goose.SetDialect("postgres"); err != nil {
panic(err)
}
// Aplica todas as migrações disponíveis
if err := goose.Up(db, "migrations"); err != nil {
panic(err)
}
instance = db
log.Println("Nova instância do banco de dados foi criada.")
return instance
}
func MustBeginTx() *sql.Tx {
if instance == nil {
panic(fmt.Errorf("Banco de dados não foi inicializado. Não foi possível criar nova transação."))
}
tx, err := instance.BeginTx(context.Background(), nil)
if err != nil {
panic(err)
}
return tx
}

82
database/database_test.go Normal file
View file

@ -0,0 +1,82 @@
package database
import (
"crypto/md5"
"foobar/model"
"testing"
"time"
"github.com/google/uuid"
)
func TestNew(t *testing.T) {
defer func() {
if err := recover(); err != nil {
t.Fatal(err)
}
}()
_ = New()
}
func TestGetBox(t *testing.T) {
_ = New()
boxURL, err := model.CheckBoxURL("1")
if err != nil {
t.Fatal(err)
}
b, err := SelectBox(boxURL)
if err != nil {
t.Fatal(err)
}
if b.ID().String() == "" {
t.Fatalf("")
}
}
func TestInsertBox(t *testing.T) {
_ = New()
tx := MustBeginTx()
defer tx.Rollback()
url, err := model.CheckBoxURL("foobar")
if err != nil {
t.Fatal(err)
}
box := model.NewBox(uuid.New(), url, "foobar", "", false, false, time.Now(), time.Now())
err = InsertBox(tx, box, "foobar", "foobar")
if err != nil {
t.Fatal(err)
}
}
func TestRegisterFile(t *testing.T) {
_ = New()
tx := MustBeginTx()
defer tx.Rollback()
url, err := model.CheckBoxURL("foobar")
if err != nil {
t.Fatal(err)
}
box := model.NewBox(uuid.New(), url, "foobar", "", false, false, time.Now(), time.Now())
err = InsertBox(tx, box, "foobar", "foobar")
if err != nil {
t.Fatal(err)
}
var file = model.NewFile(
uuid.New(), "foobar", 6, time.Now(), model.FileMime("plain/text"), md5.Sum([]byte("foobar")),
)
err = InsertFile(tx, box.ID(), file)
if err != nil {
t.Fatal(err)
}
}

65
database/file.go Normal file
View file

@ -0,0 +1,65 @@
package database
import (
"database/sql"
"encoding/hex"
"foobar/model"
"time"
"github.com/google/uuid"
)
func InsertFile(tx *sql.Tx, boxId uuid.UUID, file model.File) error {
row, err := tx.Exec(
`INSERT INTO file (id, id_box, name, created_at, size, mime, md5)
VALUES
($1, $2, $3, $4, $5, $6, $7);`,
file.ID(), boxId, file.Name(), file.CreatedAt(),
file.Size(), file.Mime(), file.Checksum(),
)
if err != nil {
return err
}
affected, err := row.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return errNoRowsAffected
}
return nil
}
func SelectBoxFiles(boxID uuid.UUID) (f []model.File, e error) {
// TODO: teste
row, err := instance.Query(
`SELECT id, name, size, created_at, mime, md5
FROM file WHERE id_box = $1 ORDER BY created_at DESC`,
boxID,
)
if err != nil {
return nil, err
}
for row.Next() {
var (
id uuid.UUID
name model.Filename
size int64
createdAt time.Time
mime model.FileMime
encodedMd5 string
)
err = row.Scan(&id, &name, &size, &createdAt, &mime, &encodedMd5)
if err != nil {
return nil, err
}
decodedMD5, err := hex.DecodeString(encodedMd5)
if err != nil {
return nil, err
}
f = append(f, model.NewFile(id, name, size, createdAt, mime, model.MD5Checksum(decodedMD5)))
}
return f, nil
}

View file

@ -0,0 +1,36 @@
-- +goose Up
-- +goose StatementBegin
-- +goose StatementEnd
-- Diff code generated with pgModeler (PostgreSQL Database Modeler)
-- pgModeler version: 1.2.0-alpha1
-- Diff date: 2025-02-01 23:56:43
-- Source model: dev1
-- Database: dev1
-- PostgreSQL version: 16.0
-- [ Diff summary ]
-- Dropped objects: 0
-- Created objects: 1
-- Changed objects: 0
SET search_path=public,pg_catalog;
-- ddl-end --
-- [ Created objects ] --
-- object: public.usuario | type: TABLE --
-- DROP TABLE IF EXISTS public.usuario CASCADE;
CREATE TABLE public.usuario (
id integer NOT NULL GENERATED ALWAYS AS IDENTITY ,
email text NOT NULL,
senha_hash text NOT NULL,
nome text NOT NULL,
ctime timestamp NOT NULL DEFAULT current_timestamp,
CONSTRAINT usuario_pk PRIMARY KEY (id)
);
-- ddl-end --
ALTER TABLE public.usuario OWNER TO dev;
-- ddl-end --

View file

@ -0,0 +1,36 @@
-- +goose Up
-- Diff code generated with pgModeler (PostgreSQL Database Modeler)
-- pgModeler version: 1.2.0-alpha1
-- Diff date: 2025-04-12 16:39:19
-- Source model: dev1
-- Database: dev1
-- PostgreSQL version: 17.0
-- [ Diff summary ]
-- Dropped objects: 1
-- Created objects: 1
-- Changed objects: 0
SET search_path=public,pg_catalog;
-- ddl-end --
-- [ Dropped objects ] --
DROP TABLE IF EXISTS public.usuario CASCADE;
-- ddl-end --
-- [ Created objects ] --
-- object: public.file | type: TABLE --
-- DROP TABLE IF EXISTS public.file CASCADE;
CREATE TABLE public.file (
id uuid NOT NULL,
name varchar(256) NOT NULL,
size integer NOT NULL,
created_at timestamp NOT NULL,
CONSTRAINT file_pk PRIMARY KEY (id)
);
-- ddl-end --
ALTER TABLE public.file OWNER TO dev;
-- ddl-end --

View file

@ -0,0 +1,32 @@
-- +goose Up
-- Diff code generated with pgModeler (PostgreSQL Database Modeler)
-- pgModeler version: 1.2.0-alpha1
-- Diff date: 2025-04-14 01:18:24
-- Source model: dev1
-- Database: dev1
-- PostgreSQL version: 17.0
-- [ Diff summary ]
-- Dropped objects: 0
-- Created objects: 2
-- Changed objects: 0
SET search_path=public,pg_catalog;
-- ddl-end --
-- [ Created objects ] --
-- object: mime | type: COLUMN --
ALTER TABLE public.file ADD COLUMN mime text;
-- ddl-end --
UPDATE public.file SET mime = '';
ALTER TABLE public.file ALTER COLUMN mime SET NOT NULL;
-- object: message | type: COLUMN --
ALTER TABLE public.file ADD COLUMN message varchar(2048);
-- ddl-end --

View file

@ -0,0 +1,71 @@
-- +goose Up
-- Diff code generated with pgModeler (PostgreSQL Database Modeler)
-- pgModeler version: 1.2.0-alpha1
-- Diff date: 2025-04-15 00:18:26
-- Source model: dev1
-- Database: dev1
-- PostgreSQL version: 17.0
-- [ Diff summary ]
-- Dropped objects: 0
-- Created objects: 6
-- Changed objects: 0
SET search_path=public,pg_catalog;
-- ddl-end --
-- [ Created objects ] --
-- object: width | type: COLUMN --
ALTER TABLE public.file ADD COLUMN width smallint;
-- ddl-end --
-- object: height | type: COLUMN --
ALTER TABLE public.file ADD COLUMN height smallint;
-- ddl-end --
-- object: public.box | type: TABLE --
-- DROP TABLE IF EXISTS public.box CASCADE;
CREATE TABLE public.box (
id uuid NOT NULL,
url varchar(256) NOT NULL,
edit_code varchar(64) NOT NULL,
header varchar(4096),
private boolean NOT NULL DEFAULT false,
moderation boolean NOT NULL DEFAULT false,
created_at timestamp NOT NULL DEFAULT current_timestamp,
last_updated_at timestamp NOT NULL DEFAULT current_timestamp,
CONSTRAINT url_uq UNIQUE (url),
CONSTRAINT box_pk PRIMARY KEY (id)
);
-- ddl-end --
ALTER TABLE public.box OWNER TO dev;
-- ddl-end --
-- object: id_box | type: COLUMN --
ALTER TABLE public.file ADD COLUMN id_box uuid;
-- ddl-end --
INSERT INTO public.box (id, url, edit_code) VALUES ('ce407978-b599-4cd6-9ddf-e9249280d321', '1', 'foobar');
UPDATE public.file SET id_box = 'ce407978-b599-4cd6-9ddf-e9249280d321';
ALTER TABLE public.file ALTER COLUMN id_box SET NOT NULL;
-- object: md5 | type: COLUMN --
ALTER TABLE public.file ADD COLUMN md5 text;
-- ddl-end --
UPDATE public.file SET md5 = '';
ALTER TABLE public.file ALTER COLUMN md5 SET NOT NULL;
-- [ Created foreign keys ] --
-- object: box_fk | type: CONSTRAINT --
-- ALTER TABLE public.file DROP CONSTRAINT IF EXISTS box_fk CASCADE;
ALTER TABLE public.file ADD CONSTRAINT box_fk FOREIGN KEY (id_box)
REFERENCES public.box (id) MATCH FULL
ON DELETE RESTRICT ON UPDATE CASCADE;
-- ddl-end --

View file

@ -0,0 +1,22 @@
-- +goose Up
-- Diff code generated with pgModeler (PostgreSQL Database Modeler)
-- pgModeler version: 1.2.0-alpha1
-- Diff date: 2025-04-19 17:49:54
-- Source model: dev1
-- Database: dev1
-- PostgreSQL version: 17.0
-- [ Diff summary ]
-- Dropped objects: 0
-- Created objects: 0
-- Changed objects: 2
SET search_path=public,pg_catalog;
-- ddl-end --
-- [ Changed objects ] --
ALTER TABLE public.file ALTER COLUMN message TYPE varchar(8192);
-- ddl-end --
ALTER TABLE public.box ALTER COLUMN header TYPE varchar(16384);
-- ddl-end --

View file

@ -0,0 +1,20 @@
-- +goose Up
-- Diff code generated with pgModeler (PostgreSQL Database Modeler)
-- pgModeler version: 1.2.0-alpha1
-- Diff date: 2025-04-20 22:52:53
-- Source model: dev1
-- Database: dev1
-- PostgreSQL version: 17.0
-- [ Diff summary ]
-- Dropped objects: 0
-- Created objects: 0
-- Changed objects: 1
SET search_path=public,pg_catalog;
-- ddl-end --
-- [ Changed objects ] --
ALTER TABLE public.box ALTER COLUMN header TYPE text;
-- ddl-end --

47
database/model.go Normal file
View file

@ -0,0 +1,47 @@
package database
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
type Model interface {
Id() (int, error)
SetId(int)
Saved() bool
}
type identity struct {
id int
saved bool
}
var (
ErrNotSaved = fmt.Errorf("primary key id não foi salva")
)
func (p *identity) Id() (int, error) {
if !p.saved {
return 0, ErrNotSaved
}
return p.id, nil
}
func (p *identity) SetId(i int) {
p.id = i
p.saved = true
}
func (p *identity) Saved() bool {
return p.saved
}
func checkPasswordHash(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
return string(bytes), err
}

View file

@ -0,0 +1,153 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
CAUTION: Do not modify this file unless you know what you are doing.
Unexpected results may occur if the code is changed deliberately.
-->
<dbmodel pgmodeler-ver="1.2.0-alpha1" use-changelog="false" max-obj-count="4"
last-position="0,29" last-zoom="1"
default-schema="public" default-owner="dev"
layers="Default layer"
active-layers="0"
layer-name-colors="#000000"
layer-rect-colors="#b4b4b4"
show-layer-names="false" show-layer-rects="false">
<role name="dev"
superuser="true"
createdb="true"
replication="true"
createrole="true"
inherit="true"
login="true"
bypassrls="true"
password="********"
sql-disabled="true">
</role>
<database name="dev1" encoding="UTF8" lc-collate="en_US.utf8" lc-ctype="en_US.utf8" is-template="false" allow-conns="true">
<role name="dev"/>
<tablespace name="pg_default"/>
</database>
<schema name="public" layers="0" rect-visible="true" fill-color="#e1e1e1" name-color="#000000" sql-disabled="true">
</schema>
<table name="goose_db_version" layers="0" collapse-mode="2" max-obj-count="4" z-value="0">
<schema name="public"/>
<role name="dev"/>
<position x="440" y="300"/>
<column name="id" not-null="true"
identity-type="BY DEFAULT" start="1" increment="1" min-value="1" max-value="2147483647" cache="1">
<type name="integer" length="0"/>
</column>
<column name="version_id" not-null="true">
<type name="bigint" length="0"/>
</column>
<column name="is_applied" not-null="true">
<type name="boolean" length="0"/>
</column>
<column name="tstamp" not-null="true" default-value="now()">
<type name="timestamp" length="0"/>
</column>
<constraint name="goose_db_version_pkey" type="pk-constr" table="public.goose_db_version">
<columns names="id" ref-type="src-columns"/>
</constraint>
</table>
<sequence name="goose_db_version_id_seq" cycle="false" start="1" increment="1" min-value="1" max-value="2147483647" cache="1" sql-disabled="true">
<schema name="public"/>
<role name="dev"/>
</sequence>
<sequence name="usuario_id_seq" cycle="false" start="1" increment="1" min-value="1" max-value="2147483647" cache="1" sql-disabled="true">
<schema name="public"/>
<role name="dev"/>
</sequence>
<table name="file" layers="0" collapse-mode="2" max-obj-count="12" z-value="0">
<schema name="public"/>
<role name="dev"/>
<position x="1160" y="520"/>
<column name="id" not-null="true">
<type name="uuid" length="0"/>
</column>
<column name="name" not-null="true">
<type name="varchar" length="256"/>
</column>
<column name="size" not-null="true">
<type name="integer" length="0"/>
</column>
<column name="created_at" not-null="true">
<type name="timestamp" length="0"/>
</column>
<column name="mime" not-null="true">
<type name="text" length="0"/>
</column>
<column name="message">
<type name="varchar" length="8192"/>
</column>
<column name="width">
<type name="smallint" length="0"/>
</column>
<column name="height">
<type name="smallint" length="0"/>
</column>
<column name="md5" not-null="true">
<type name="text" length="0"/>
</column>
<constraint name="file_pk" type="pk-constr" table="public.file">
<columns names="id" ref-type="src-columns"/>
</constraint>
<customidxs object-type="column">
<object name="id_box" index="8"/>
</customidxs>
<customidxs object-type="constraint">
<object name="box_fk" index="1"/>
</customidxs></table>
<table name="box" layers="0" collapse-mode="2" max-obj-count="9" z-value="0">
<schema name="public"/>
<role name="dev"/>
<position x="560" y="560"/>
<column name="id" not-null="true">
<type name="uuid" length="0"/>
</column>
<column name="url" not-null="true">
<type name="varchar" length="256"/>
</column>
<column name="edit_code" not-null="true">
<type name="varchar" length="64"/>
</column>
<column name="header">
<type name="text" length="16384"/>
</column>
<column name="private" not-null="true" default-value="false">
<type name="boolean" length="0"/>
</column>
<column name="moderation" not-null="true" default-value="false">
<type name="boolean" length="0"/>
</column>
<column name="created_at" not-null="true" default-value="current_timestamp">
<type name="timestamp" length="0"/>
</column>
<column name="last_updated_at" not-null="true" default-value="current_timestamp">
<type name="timestamp" length="0"/>
</column>
<constraint name="url_uq" type="uq-constr" table="public.box">
<columns names="url" ref-type="src-columns"/>
</constraint>
<constraint name="box_pk" type="pk-constr" table="public.box">
<columns names="id" ref-type="src-columns"/>
</constraint>
</table>
<relationship name="box_has_many_file" type="rel1n" layers="0"
src-col-pattern="{sc}_{st}"
pk-pattern="{dt}_pk" uq-pattern="{dt}_uq"
src-fk-pattern="{st}_fk"
custom-color="#ccd394"
src-table="public.box"
dst-table="public.file"
src-required="true" dst-required="false"/>
</dbmodel>

69
database/usuario.go Normal file
View file

@ -0,0 +1,69 @@
package database
import (
"database/sql"
"time"
)
type Usuario struct {
identity
Email string
Nome string
// senhaHash string `json:"-"`
Ctime time.Time
}
func VerifyUser(tx *sql.Tx, email, senha string) (*Usuario, error) {
row := tx.QueryRow(
`SELECT id, nome, ctime, senha_hash FROM usuario WHERE email = $1 LIMIT 1`,
email,
)
if row.Err() != nil {
return nil, row.Err()
}
usuario := &Usuario{
Email: email,
}
var id int
var senhaHash string
err := row.Scan(
&id,
&usuario.Nome,
&usuario.Ctime,
&senhaHash,
)
if err != nil {
return nil, err
}
usuario.SetId(id)
if err = checkPasswordHash(senha, senhaHash); err != nil {
return nil, err
}
return usuario, nil
}
func FindUsuarioByID(tx *sql.Tx, id int) (*Usuario, error) {
row := tx.QueryRow(
`SELECT id, nome, email, ctime FROM usuario WHERE id = $1 LIMIT 1`,
id,
)
if row.Err() != nil {
return nil, row.Err()
}
usuario := &Usuario{}
err := row.Scan(
&id,
&usuario.Nome,
&usuario.Email,
&usuario.Ctime,
)
if err != nil {
return nil, err
}
usuario.SetId(id)
return usuario, nil
}

73
docker-compose.yml Normal file
View file

@ -0,0 +1,73 @@
networks:
metrics:
driver: bridge
transactional:
driver: bridge
services:
db:
image: postgres
healthcheck:
# Serve de condicional para que o docker compose saiba que o banco
# foi criado com sucesso antes de inicializar a aplicação.
test: ["CMD-SHELL", "pg_isready -U ${PG_USER} -d ${PG_DB}"]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
volumes:
- ./data/postgres:/var/lib/postgresql/data
environment:
POSTGRES_USER: ${PG_USER}
POSTGRES_PASSWORD: ${PG_PASS}
POSTGRES_DB: ${PG_DB}
ports:
- 5432:5432
networks:
- transactional
# Interface web para monitoramento e consultas no banco.
adminer:
image: adminer
ports:
- 8090:8080
depends_on:
- db
networks:
- transactional
# Observabilidade e coleta de dados
prometheus:
image: prom/prometheus
expose:
- '9090'
ports:
- "9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
networks:
- metrics
# Necessário para que o serviço consiga encontrar a aplicação quando
# estivermos desenvolvendo localmente, fora de um contêiner.
extra_hosts:
- "host.docker.internal:host-gateway"
# Servidor. rodar apenas quando estivermos em produção.
app:
build: .
environment:
# Aplicação não se conecta com o banco sem isso.
# Apenas funciona pois o docker compose registra o nome de cada serviço como um
# endereço de rede, e esse é o endereço do banco.
PG_ADDR: db
depends_on:
db: # Não incializa até que o banco esteja recebendo requisições
condition: service_healthy
restart: true
ports:
- 8888:8888
networks:
- metrics
- transactional

53
go.mod Normal file
View file

@ -0,0 +1,53 @@
module foobar
go 1.23.0
require (
github.com/jackc/pgx/v5 v5.7.2
github.com/joho/godotenv v1.5.1
)
require (
github.com/PuerkitoBio/goquery v1.10.1 // indirect
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
github.com/a-h/templ v0.3.833 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/natefinch/atomic v1.0.1 // indirect
github.com/pressly/goose/v3 v3.24.1 // indirect
github.com/prometheus/client_golang v1.21.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/mod v0.20.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.24.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
)

166
go.sum Normal file
View file

@ -0,0 +1,166 @@
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY=
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.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-20201119102817-f84b799fce68/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-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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

46
metrics/main.go Normal file
View file

@ -0,0 +1,46 @@
package metrics
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
responseLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "response_latency",
Help: "latency of the response",
},
// favor adicionar itens à essa lista com cautela.
// uma alta cardinalidade em vetores de uma métrica no prometheus
// pode causar impactos na performance do servidor remoto.
[]string{"status", "method", "path", "panic"},
)
TransactionLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "transaction_db_latency",
Help: "latency of the response",
}, []string{})
)
func ResponseLatencyObserve(latency time.Duration, statusCode int, panicked bool, r *http.Request) {
var (
method = r.Method
path = r.URL.Path
)
observer, err := responseLatency.GetMetricWithLabelValues(
strconv.Itoa(statusCode),
method,
path,
fmt.Sprint(panicked),
)
if err != nil {
// não temos como tratar esse erro
panic(err)
}
observer.Observe(latency.Seconds())
}

239
model/main.go Normal file
View file

@ -0,0 +1,239 @@
// Algumas dessas classes sao absurdas mas quero ver até onde isso vai.
package model
import (
"fmt"
"mime/multipart"
"net/textproto"
"regexp"
"strings"
"time"
"crypto/md5"
"github.com/google/uuid"
)
type Filename string
type BoxURL struct {
url string
}
var boxURLCheck = regexp.MustCompile(`^[a-zA-Z0-9-]+$`)
func CheckBoxURL(url string) (BoxURL, error) {
if url == "" || !boxURLCheck.MatchString(url) {
return BoxURL{}, fmt.Errorf(`invalid url: "%s"`, url)
}
return BoxURL{url}, nil
}
func (b BoxURL) String() string {
return b.url
}
type FileMime string
type Markdown string
type MD5Checksum [md5.Size]byte
func (m MD5Checksum) String() string {
return fmt.Sprintf("%x", m[:])
}
type File struct {
id uuid.UUID
name string
size int64
createdAt time.Time
mime FileMime
checksum MD5Checksum
}
func NewFile(
id uuid.UUID,
name Filename,
size int64,
createdat time.Time,
mime FileMime,
checksum MD5Checksum,
) File {
return File{
id: id,
name: string(name),
size: size,
createdAt: createdat,
mime: mime,
checksum: checksum,
}
}
func (f *File) ID() uuid.UUID {
return f.id
}
func (f *File) Name() string {
return f.name
}
func (f *File) Size() int64 {
return f.size
}
func (f *File) CreatedAt() time.Time {
return f.createdAt
}
func (f *File) Mime() FileMime {
return f.mime
}
func (f *File) Checksum() MD5Checksum {
return f.checksum
}
type FileUploadContext struct {
id uuid.UUID
uploadedDate time.Time
size int64
filename Filename
mime FileMime
fileHeader *multipart.FileHeader
}
func NewFileUploadContext(
id uuid.UUID,
uploadedDate time.Time,
size int64,
filename Filename,
mime FileMime,
fileHeader *multipart.FileHeader,
) FileUploadContext {
return FileUploadContext{
id: id,
uploadedDate: uploadedDate,
size: size,
filename: filename,
mime: mime,
fileHeader: fileHeader,
}
}
func (f *FileUploadContext) ID() uuid.UUID {
return f.id
}
func (f *FileUploadContext) UploadedDate() time.Time {
return f.uploadedDate
}
func (f *FileUploadContext) Size() int64 {
return f.size
}
func (f *FileUploadContext) Filename() Filename {
return f.filename
}
func (f *FileUploadContext) Mime() FileMime {
return f.mime
}
func (f *FileUploadContext) FileHeader() *multipart.FileHeader {
return f.fileHeader
}
type Box struct {
id uuid.UUID
url BoxURL
header Markdown
private bool
moderation bool
createdAt time.Time
lastUpdatedAt time.Time
// FIXME: should be encoded in bcrypt
editCode string
}
func NewBox(
id uuid.UUID,
url BoxURL,
editCode string,
header Markdown,
private, moderation bool,
createdAt, lastUpdatedAt time.Time,
) Box {
return Box{
id: id,
url: url,
header: header,
editCode: editCode,
private: private,
moderation: moderation,
createdAt: createdAt,
lastUpdatedAt: lastUpdatedAt,
}
}
func (b Box) ID() uuid.UUID {
return b.id
}
func (b Box) Url() BoxURL {
return b.url
}
func (b Box) Header() Markdown {
return b.header
}
func (b Box) EditCode() string {
return b.editCode
}
func (b Box) Private() bool {
return b.private
}
func (b Box) Moderation() bool {
return b.moderation
}
func (b Box) CreatedAt() time.Time {
return b.createdAt
}
func (b Box) LastUpdatedAt() time.Time {
return b.lastUpdatedAt
}
func (m FileMime) IsImage() bool {
return strings.Contains(string(m), "image")
}
func (m FileMime) IsVideo() bool {
return strings.Contains(string(m), "video")
}
func (m FileMime) PrettyType() string {
switch {
case strings.Contains(string(m), "gif"):
return "gif"
case m.IsImage():
return "image"
case m.IsVideo():
return "video"
case strings.Contains(string(m), "pdf"):
return "pdf"
default:
return "unknown"
}
}
func MimeFromHeader(header textproto.MIMEHeader) FileMime {
return FileMime(header.Get("Content-Type"))
}

21
model/main_test.go Normal file
View file

@ -0,0 +1,21 @@
package model
import (
"fmt"
"testing"
)
func pp(result, expected any) error {
return fmt.Errorf("result: %v, expected: %v", result, expected)
}
func TestCheckBoxURL(t *testing.T) {
boxURL, err := CheckBoxURL("123123")
if err != nil {
t.Fatal(err)
}
if result, expected := fmt.Sprintf("%s", boxURL), "123123"; result != expected {
t.Fatal(pp(result, expected))
}
}

23
prometheus/prometheus.yml Normal file
View file

@ -0,0 +1,23 @@
global:
scrape_interval: 15s # By default, scrape targets every 15 seconds.
# Attach these labels to any time series or alerts when communicating with
# external systems (federation, remote storage, Alertmanager).
external_labels:
monitor: 'codelab-monitor'
# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs:
# The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
- job_name: 'prometheus'
scrape_interval: 5s
static_configs:
- targets: ['localhost:9090']
- job_name: 'node'
scrape_interval: 5s
static_configs:
- targets: ['host.docker.internal:8888']
labels:
group: 'production'

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)
}

79
server/server.go Normal file
View file

@ -0,0 +1,79 @@
package main
import (
_ "embed"
"fmt"
"foobar/database"
"foobar/routes"
"log"
"net"
"net/http"
"os"
"time"
"github.com/joho/godotenv" // Carrega .env
)
//go:generate templ generate
var (
Port = "8888"
Addr = "localhost"
s *http.Server = nil
)
func MustStartServer() {
if s != nil {
return
}
ready := make(chan bool, 1)
go mustStartServer(ready)
<-ready
}
func mustStartServer(ready chan<- bool) {
if s != nil {
ready <- true
return
}
err := godotenv.Load()
if err != nil {
panic(err)
}
if port := os.Getenv("SERVER_PORT"); port != "" {
Port = port
}
if addr := os.Getenv("SERVER_ADDR"); addr != "" {
Addr = addr
}
_ = database.New()
routes := routes.Mux()
s = &http.Server{
Addr: fmt.Sprintf("%s:%s", Addr, Port),
Handler: routes,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
}
// Abre uma conexão de socket mas não bloqueia o fluxo do programa.
// Isso significa que o servidor já está recebendo requests.
// Isso é necessário para rodar os testes.
ln, err := net.Listen("tcp", s.Addr)
if err != nil {
panic(err)
}
ready <- true
fmt.Printf("Estamos ao vivo! teste em http://%s:%s\n", Addr, Port)
log.Fatal(s.Serve(ln))
}
func main() {
mustStartServer(make(chan bool, 1))
}

11
tailwind.config.js Normal file
View file

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
// "./internal/**/*.{go,js,templ,html}",
"./views/html/*.html"
],
theme: {
extend: {},
},
plugins: [],
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,78 @@
@import "tailwindcss";
/* não deve ser o jeito certo de fazer isso. */
.primary_button {
@apply rounded p-2 bg-indigo-500;
}
.my-input {
@apply w-full bg-transparent placeholder:text-slate-400 text-slate-700 text-sm border border-slate-200 rounded-md px-3 py-2 transition duration-300 focus:outline-none focus:border-slate-400 hover:border-slate-300 shadow-sm focus:shadow;
}
.my-label {
@apply block mb-1 text-sm text-slate-600;
}
.however {
@apply flex items-center mt-2 text-xs text-slate-400;
}
.my-my-input {
@apply w-full min-w-[200px] mb-5;
}
.btn {
@apply w-full rounded-md bg-slate-800 py-2 px-4 my-4 border border-transparent text-center text-sm text-white transition-all shadow-md hover:shadow-lg focus:bg-slate-700 focus:shadow-none active:bg-slate-700 hover:bg-slate-700 active:shadow-none disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none;
}
h1 {
@apply text-5xl font-bold mb-5
}
h2 {
@apply text-4xl font-bold mb-5
}
@keyframes bounce {
0% {
transform: translateX(-7px);
timing-function: ease-in;
}
37% {
transform: translateX(7px);
timing-function: ease-out;
}
55% {
transform: translateX(-7px);
timing-function: ease-in;
}
73% {
transform: translateX(5px);
timing-function: ease-out;
}
82% {
transform: translateX(-5px);
timing-function: ease-in;
}
91% {
transform: translateX(2px);
timing-function: ease-out;
}
96% {
transform: translateX(-2px);
timing-function: ease-in;
}
100% {
transform: translateX(0px);
timing-function: ease-in;
}
}
.bounce {
animation-name: bounce;
animation-duration: .5s;
}

View file

@ -0,0 +1,74 @@
# **This box URL belongs to no one.**
**Drop a file** with the `Edit code` field of your choice **to claim this URL as yours**.
So as long as you **keep your edit code**, you will be able to make permissive moditifications to this box,
including visualizing files, moderation and exclusion of files.
**To make modifications to this readme section, drop a file called `README.md`** that will override the
currently set header. This is how you're supposed to make edits to this section.
New boxes have been set to have a **maximum lifetime of two years,** after the lifetime period is over,
the box files are going to be deleted and the URL is going to be reclaimed. You'll no longer own this URL.
Once you've claimed this box, you'll be able to **drop a `config.json5`** with valid [`json5`](https://json5.org/) options
**to replace the default box configuration.** [The default configuration file can be found
here](/box-configurations#) along with all customizeble options.
## FAQ
### Can I set a maximum upload size?
Works is in progress. You won't be able to set it above the maximum size set be this instance administrator
### Is there a views counter?
Works is in progress. There will be one.
### Can I block others from viewing the box contents?
Works is in progress. You will be able to.
### What filetypes are allowed? Can I black/white list filetypes?
Per the default configuration, all filetypes are allowed. but works are in progress. You will be able to
white or blacklist filetypes.
### How can I see the IP addresses of the file uploaders
You cannot and there is no configuration you or the instance administrator can set that will allow you
to see address of the uploaders because the servers don't collect them in the first place.
Let it be known that the instance administrator can still set the server to run behind a reverse proxy
which would allow him or her to collect data from the client accesses, but even still,
this is not something the instance has access to and therefore you won't either.
### Is the instance adminstrator able to view the contents of this box
Yes they are. Whether you trust the instance adminsitrator with these files is up to you.
### What can't be uploaded to these boxes?
This is up to the instance administrator to define. See [Uploading guidelines](/uploading-guidelines#).
### What happens if the instance administrator changes the max lifetime of a box or the maximum upload size?
If the max lifetime is changed your newly created box won't be affected and your lifetime will still be the same from when the
box was created. If the instance administrator changes te maximum upload size, your configurations will be capped to
conform to those changes. Make sure your configurations are up to date by downloading the `config.json5` from this box at
every periodic change.
### Making changes to these files is hard on a mobile UwU, why can't I edit the configurations with a form?
Not our problem. Get a computer.
### Why json5?
YAML sucks. TOML sucks. JSON sucks. JSON5 sucks less. [Here's what it looks like](https://github.com/chromium/chromium/blob/feb3c9f670515edf9a88f185301cbd7794ee3e52/third_party/blink/renderer/platform/runtime_enabled_features.json5).
Lua is an option and it actually rocks but that's a bit overkill for this project.
### What happens if I lose my edit code?
It's totally up to you to not do that.
<script>alert("if this is executed it means we screwed up")</script>

256
views/template.templ Normal file
View file

@ -0,0 +1,256 @@
package views
import (
"foobar/database"
"foobar/model"
"fmt"
"time"
)
templ indexLayout() {
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<title>Mude meu título!</title>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="htmx-config" content='{ "responseHandling":[{"code":"422", "swap": true}, {"code":"200", "swap": true}] }' />
<!-- tailwind -->
<link href="/static/css/output.css" rel="stylesheet"/>
<!-- markdown styling -->
<link href="/static/css/github-markdown.css" rel="stylesheet"/>
<!-- daisyUI -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<!-- daisyUI Themes -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
// <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<!-- Hyperscript -->
<script src="https://unpkg.com/hyperscript.org@0.9.14"></script>
<style>
html, body {
min-height: 100vh;
}
</style>
</head>
<body class="flex-wrap flex content-center justify-between flex-col bg-base-100 text-sm"
hx-boost="true" hx-push-url="true" hx-target="this">
{ children... }
<footer></footer>
</body>
</html>
}
templ mainCenterBody() {
@indexLayout() {
<main class="w-full" >
{ children... }
</main>
}
}
const toggleButtonInMeUntilLoad =
`on every htmx:beforeSend
tell <button/> in me
toggle [@disabled='true'] until htmx:afterOnLoad
end
end`
func safeURL(s string) string {
return fmt.Sprint(templ.SafeURL(s))
}
templ Index() {
@mainCenterBody() {
<div class="hero-content">
<div class="max-w-md text-center">
<h1>hakobox</h1>
<p>nothing here...</p>
</div>
</div>
}
}
templ boxUploadSidebar(boxURL model.BoxURL) {
@mainCenterBody() {
<div class="">
<section class="static md:fixed top-0 left-0 z-10 w-full md:w-3/9 p-10 bg-base-200 h-full ">
<header>
<h1>hakobox</h1>
<p>It's temporary file sharing but everyone gets to contribute! </p>
<p class="leading-tight font-medium text-black">Drop in a file to be added to this box </p>
</header>
<form id="add-file"
hx-trigger="submit"
hx-post={safeURL(fmt.Sprintf("/box/%s", boxURL))}
hx-encoding="multipart/form-data">
<fieldset class="fieldset">
<legend class="fieldset-legend">Pick a file</legend>
<input name="file" type="file" class="file-input" style="width: 100%;" multiple required/>
<label class="fieldset-label">Max size 2MB</label>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">Edit code</legend>
<input type="text" class="input" name="edit_code" placeholder="Type here" style="width: 100%;"/>
<p class="label">Optional</p>
</fieldset>
@errorTarget()
<button type="submit" class="btn btn-primary mb-5">Add to the box!</button>
<div class="text-center">
<a class="link link-primary block mb-0"
href={templ.SafeURL(fmt.Sprintf("/box/%s/inside", boxURL))}>
See what's inside
</a>
<a class="link link-primary block mb-0"
href={templ.SafeURL(fmt.Sprintf("/box/%s", boxURL))}>
Front page
</a>
</div>
</form>
</section>
<section class="md:px-20 p-4 md:my-40 m-0 md:ml-auto md:max-w-6/9">
{ children... }
</section>
</div>
}
}
templ BoxUpload(box model.Box) {
@boxUploadSidebar(box.Url()) {
<div class="markdown-body">
@templ.Raw(box.Header())
</div>
}
}
templ NewBox(boxURL model.BoxURL, templateReadme model.Markdown) {
@boxUploadSidebar(boxURL) {
<div class="markdown-body">
@templ.Raw(templateReadme)
</div>
}
}
templ Box(boxURL model.BoxURL, files []model.File) {
@boxUploadSidebar(boxURL) {
<main class="inline-flex flex-wrap gap-0 md:gap-5 mt-20 w-full h-full justify-start md:justify-center">
if len(files) == 0 {
<h2 class="m-auto">Empty box.</h2>
} else {
for i := range files {
@fileCard(files[i])
}
}
</main>
}
}
func fileDisplay(file model.File) templ.Component {
switch {
case file.Mime().IsImage():
return fileImageFigure(file)
case file.Mime().IsVideo():
return fileVideoFigure(file)
default:
return unknownFileFigure()
// panic(fmt.Errorf("unrecognized mime: %s", file.Mime))
}
}
templ fileCard(file model.File) {
<a class="card w-1/2 md:w-2xs aspect-square mb-10" href={templ.SafeURL(fmt.Sprintf("/static/box/%s", file.ID()))} target="_blank">
<figure class="flex flex-col p-1 m-auto mb-auto md:mb-0 text-center">
@fileDisplay(file)
</figure>
<div class="hidden md:inline font-light text-sm" style="text-wrap: nowrap;">
<p title={file.Name()}>{shorten(file.Name())}</p>
<p title={file.Name()}>{file.CreatedAt().Format(time.DateOnly)}</p>
<p>{formatSize(file.Size())} {file.Mime().PrettyType()}</p>
</div>
</a>
}
templ fileImageFigure(file model.File) {
<img
title={file.Name()}
loading="lazy"
src={fmt.Sprintf("/static/box/%s", file.ID())}
alt={file.Name()} />
}
templ fileVideoFigure(file model.File) {
<video title={file.Name()} loading="lazy" alt={file.Name()} >
<source src={fmt.Sprintf("/static/box/%s", file.ID())}/>
</video>
}
templ unknownFileFigure() {
<div>unknown format!</div>
}
func shorten(text string) string {
if len(text) > 41 {
return fmt.Sprintf("%s...", text[:41-3])
} else {
return text
}
}
func formatSize(size int64) string {
units := []string{"b", "Kb", "Mb", "Gb"}
i := 0
fSize := float64(size)
for fSize >= 1000 && i < len(units)-1 {
fSize /= 1000
i++
}
return fmt.Sprintf("%.2f %s", fSize, units[i])
}
templ FourOfour() {
@indexLayout() {
<div>404</div>
}
}
templ Logged(usuario *database.Usuario) {
@indexLayout() {
<p>Logado como: { usuario.Nome }</p>
<div style="text-align: center;">
<p>
<a href="/">index</a>
</p>
<p>
<a href="/logout">logout</a>
</p>
</div>
}
}
templ errorTarget() {
<div id="error-target">
</div>
}
templ ErrorBox(msg string) {
<div role="alert" class="bounce mt-3 relative flex flex-col w-full p-3 text-sm text-white bg-red-600 rounded-md">
<p class="flex text-base">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="h-5 w-5 mr-2 mt-0.5"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"></path></svg>
Error
</p>
<p class="ml-4 p-3">
{ msg }
</p>
<button class="flex items-center justify-center transition-all w-8 h-8 rounded-md text-white hover:bg-white/10 active:bg-white/10 absolute top-1.5 right-1.5" type="button"
_='on click tell closest <div[role="alert"]/> transition opacity to 0 then remove yourself end'
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="h-5 w-5" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
}